## Backend
- Add workspace_card SSE event protocol: {id, name, props, merge}
- Add _extract_llm_text / _maybe_emit_workspace_card helpers in chat.py
- Refactor all tools to dual-output format: {llm_text, ui: {name, props}}
- kb_search → KnowledgeResultCard
- ticket_list/detail → TicketSummaryCard / TicketDetailCard
- web_search → SearchResultCard
- generate_document → DocumentResultCard
- sandbox_run → SandboxResultCard
- Update SYSTEM_PROMPT: instruct LLM not to repeat tool data (UI shows it)
## Frontend
- Three-column layout: sidebar + chat + Agent Workspace (360px right panel)
- WorkspaceSession state model with ActivityNode + WorkspaceCard
- New components/workspace/: AgentWorkspace, ActivityTimeline, WorkspaceCardRenderer
- 6 card components: Knowledge/Ticket/Search/Document/Sandbox/ErrorCard
- GeminiChat: workspace state management, SSE routing for workspace_card events
- GeminiMessage: replace TracePanel with lightweight activity summary line
- lib/api.ts: add WorkspaceSession/ActivityNode/WorkspaceCard types
Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
66 lines
2.3 KiB
TypeScript
66 lines
2.3 KiB
TypeScript
"use client";
|
|
import { Zap, Wrench, Flag, AlertCircle, Loader2, CheckCircle, XCircle } from "lucide-react";
|
|
import { cn } from "@/lib/utils";
|
|
import type { ActivityNode } from "@/lib/api";
|
|
|
|
interface ActivityTimelineProps {
|
|
nodes: ActivityNode[];
|
|
}
|
|
|
|
const TYPE_ICON: Record<string, React.ComponentType<{ size?: number; className?: string }>> = {
|
|
status: Zap,
|
|
tool: Wrench,
|
|
done: Flag,
|
|
error: AlertCircle,
|
|
};
|
|
|
|
function NodeIcon({ node }: { node: ActivityNode }) {
|
|
if (node.nodeStatus === "running") {
|
|
return <Loader2 size={14} className="text-[var(--gem-blue,#4285f4)] animate-spin" />;
|
|
}
|
|
if (node.nodeStatus === "success") {
|
|
return <CheckCircle size={14} className="text-green-400" />;
|
|
}
|
|
if (node.nodeStatus === "error") {
|
|
return <XCircle size={14} className="text-red-400" />;
|
|
}
|
|
const Icon = TYPE_ICON[node.type] ?? Zap;
|
|
return <Icon size={14} className="text-[var(--gem-text-muted)]" />;
|
|
}
|
|
|
|
export function ActivityTimeline({ nodes }: ActivityTimelineProps) {
|
|
if (nodes.length === 0) return null;
|
|
return (
|
|
<div className="space-y-0">
|
|
{nodes.map((node, i) => (
|
|
<div key={node.id} className="flex gap-3 relative">
|
|
{i < nodes.length - 1 && (
|
|
<div className="absolute left-[13px] top-7 bottom-0 w-px bg-[var(--gem-border)]" />
|
|
)}
|
|
<div className={cn(
|
|
"w-7 h-7 rounded-full flex items-center justify-center flex-shrink-0 mt-0.5 z-10",
|
|
node.nodeStatus === "running"
|
|
? "bg-[var(--gem-blue,#4285f4)]/10 border border-[var(--gem-blue,#4285f4)]/30"
|
|
: node.nodeStatus === "error"
|
|
? "bg-red-500/10 border border-red-500/30"
|
|
: "bg-[var(--gem-surface-2)] border border-[var(--gem-border)]"
|
|
)}>
|
|
<NodeIcon node={node} />
|
|
</div>
|
|
<div className="flex-1 pb-4 min-w-0">
|
|
<p className={cn(
|
|
"text-xs font-medium leading-snug pt-1",
|
|
node.nodeStatus === "running" ? "text-[var(--gem-text)]" : "text-[var(--gem-text-secondary)]"
|
|
)}>
|
|
{node.label}
|
|
</p>
|
|
{node.detail && (
|
|
<p className="text-[10px] text-[var(--gem-text-muted)] mt-0.5">{node.detail}</p>
|
|
)}
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
);
|
|
}
|