diff --git a/studio/src/app/workspace/chat/_components/chat-view.tsx b/studio/src/app/workspace/chat/_components/chat-view.tsx index fdef2e2e7c..edf1566e85 100644 --- a/studio/src/app/workspace/chat/_components/chat-view.tsx +++ b/studio/src/app/workspace/chat/_components/chat-view.tsx @@ -6,6 +6,7 @@ import { CirclePlus, Ellipsis, FileText, + FoldVertical, Loader2, MessageCircle, PanelLeftClose, @@ -17,7 +18,7 @@ import { Trash2, Wrench, } from "lucide-react"; -import { useCallback, useEffect, useRef, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { Button } from "@/components/ui/button"; import { DropdownMenu, @@ -37,20 +38,28 @@ import type { ApprovalRequest, Attachment, ClarificationRequest, + ToolCallInfo, } from "@/features/agent"; import { formatTokens } from "@/lib/formatters"; -import type { SessionListSide } from "@/lib/profile-preferences"; +import { + type SessionListSide, + useShowToolCalls, +} from "@/lib/profile-preferences"; import { cn } from "@/lib/utils"; import { ChatInput } from "../../_components/chat-input"; import { ApprovalPanel } from "./approval-panel"; import { ClarificationPanel } from "./clarification-panel"; +import { ContextMeter } from "./context-meter"; import { FilePreview } from "./file-preview"; import { MessageBubble } from "./message-bubble"; import { MockProviderNotice } from "./mock-provider-notice"; import { SidePanel } from "./side-panel"; +import { ToolCallPanel } from "./tool-call-panel"; /** The single right-hand panel: exactly one kind is open at a time, or none. */ -type ActivePanel = { kind: "attachment"; attachment: Attachment }; +type ActivePanel = + | { kind: "attachment"; attachment: Attachment } + | { kind: "toolcall"; call: ToolCallInfo }; /** * Bottom-of-transcript activity line while a turn is running: three @@ -287,6 +296,8 @@ export function ChatView({ onSidePanelOpenChange, initialDraft, onInitialDraftConsumed, + onCompact, + contextInfo, }: { session: AgentSession; messages: AgentMessage[]; @@ -315,6 +326,12 @@ export function ChatView({ chip that seeds the message without sending it). */ initialDraft?: string | null; onInitialDraftConsumed?: () => void; + /** Manually compacts the conversation (B1.2); present only when the + daemon's manual_compaction capability is on. Disabled while streaming. */ + onCompact?: () => void; + /** The session's effective model + context window (B1.1): feeds the slim + approximate context meter near the composer. */ + contextInfo?: { modelLabel: string; contextWindow: number } | null; }) { const messagesEndRef = useRef(null); // Whether the transcript is scrolled to (near) the bottom; when it isn't, @@ -324,7 +341,8 @@ export function ChatView({ const [atBottom, setAtBottom] = useState(true); const atBottomRef = useRef(true); const messagesContainerRef = useRef(null); - const [showActivity, setShowActivity] = useState(false); + // The global Show Tools preference (persisted; shared with thread panels). + const { showToolCalls: showActivity, setShowToolCalls } = useShowToolCalls(); // The single right-hand panel — a discriminated union makes "one panel at a // time" structural rather than something to coordinate by hand. const [panel, setPanel] = useState(null); @@ -363,6 +381,25 @@ export function ChatView({ [releasePreviewUrl], ); + // Drill-down from a row in the inline activity list to the call's full + // untruncated input/output in the side panel. + const handleOpenToolCall = useCallback( + (call: ToolCallInfo) => setPanel({ kind: "toolcall", call }), + [], + ); + + // The tool-call panel tracks the LIVE call: the stream replaces call + // objects as results land, so re-resolve by callId each render — the + // clicked snapshot would otherwise read "running" forever. + const activePanel = useMemo(() => { + if (panel?.kind !== "toolcall") return panel; + for (const msg of messages) { + const live = msg.toolCalls?.find((c) => c.callId === panel.call.callId); + if (live) return { kind: "toolcall", call: live }; + } + return panel; + }, [panel, messages]); + // Jump to the latest message whenever the active chat changes so users // always land at the bottom (most-recent) of the conversation. // biome-ignore lint/correctness/useExhaustiveDependencies: scrolling is intentionally driven by session.id changes @@ -394,7 +431,7 @@ export function ChatView({ // With the list docked right, an open side panel occupies its slot — the // toggle then means "give me the list back": close the panel, and the // workspace restores the sidebar to its pre-panel state. - const panelHoldsSidebarSlot = sidebarSide === "right" && panel !== null; + const panelHoldsSidebarSlot = sidebarSide === "right" && activePanel !== null; const sidebarToggle = ( @@ -478,10 +515,16 @@ export function ChatView({ {/* Token usage as the daemon reported it (was a header pill; it lives in the menu now). Hidden until any lands. */} - setShowActivity((v) => !v)}> + setShowToolCalls(!showActivity)}> {showActivity ? "Hide Tools" : "Show Tools"} + {onCompact && ( + + + Compact conversation + + )} {onRename && ( @@ -522,6 +565,7 @@ export function ChatView({ onOpenAttachment={(attachment) => setPanel({ kind: "attachment", attachment }) } + onOpenToolCall={handleOpenToolCall} botName={botName} showActivity={showActivity} /> @@ -562,6 +606,16 @@ export function ChatView({ )}
+ {/* B1.1: effective model + approximate context utilisation, + visible only when the window is known and tokens counted. */} + {contextInfo && contextInfo.contextWindow > 0 && ( + + )} {error && (
@@ -604,9 +658,9 @@ export function ChatView({
- {panel !== null && ( + {activePanel !== null && ( ; + case "toolcall": + return ; } } diff --git a/studio/src/app/workspace/chat/_components/chat-workspace.tsx b/studio/src/app/workspace/chat/_components/chat-workspace.tsx index 05f983485e..abfdd677d8 100644 --- a/studio/src/app/workspace/chat/_components/chat-workspace.tsx +++ b/studio/src/app/workspace/chat/_components/chat-workspace.tsx @@ -3,6 +3,7 @@ import { Loader2, PanelLeft, PanelRight, SquarePen } from "lucide-react"; import { useRouter } from "next/navigation"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { toast } from "sonner"; import { Button } from "@/components/ui/button"; import { Tooltip, @@ -16,11 +17,17 @@ import { useAgentRoster, useAgentSessions, } from "@/features/agent"; +import { useRuntimeStatus } from "@/features/agent/runtime-status"; import { useConfirm } from "@/hooks/use-confirm"; import { useIsCompact } from "@/hooks/use-mobile"; import { useNavReopenSidebar } from "@/hooks/use-nav-reopen-sidebar"; import { usePanelWidth } from "@/hooks/use-panel-width"; import { usePrompt } from "@/hooks/use-prompt"; +import { + compactHarnessSession, + fetchHarnessSessionDetail, + type HarnessResolvedModel, +} from "@/lib/harness/client"; import { type SessionListSide, useAgentDisplayName, @@ -277,6 +284,12 @@ export function ChatWorkspace({ sessionId }: { sessionId?: string }) { // Selection is URL-driven; the state mirror keeps it in sync while also // allowing an optimistic update before the client navigation settles. const [selectedId, setSelectedIdState] = useState(sessionId ?? ""); + // Mirror for effects that must read the current selection without + // re-firing on every selection change. + const selectedIdRef = useRef(selectedId); + useEffect(() => { + selectedIdRef.current = selectedId; + }, [selectedId]); // The id minted for a draft on first send. While the URL settles on that id // the chat hook keeps its draft binding (it already owns the live stream); // re-keying it would wipe the in-flight messages with a transcript refetch. @@ -365,6 +378,7 @@ export function ChatWorkspace({ sessionId }: { sessionId?: string }) { harnessLive, sendMessage, retryLast, + refreshTranscript, pendingApproval, respondToApproval, pendingClarification, @@ -374,6 +388,47 @@ export function ChatWorkspace({ sessionId }: { sessionId?: string }) { onSessionCreated: handleSessionCreated, }); + // The daemon's operator-enabled capabilities (A3 caches /v1/compatibility). + const { connected, serverCapabilities } = useRuntimeStatus(); + + // The session's effective model + context window (B1.1): GET-session's + // resolved_model echo. Per selected chat; a fetch failure just hides the + // meter (it is an approximation, never load-bearing). + const [resolvedModel, setResolvedModel] = + useState(null); + useEffect(() => { + setResolvedModel(null); + if (!selectedId || !connected) return; + const controller = new AbortController(); + void fetchHarnessSessionDetail(selectedId, controller.signal) + .then((detail) => { + if (!controller.signal.aborted) setResolvedModel(detail.resolvedModel); + }) + .catch(() => undefined); + return () => controller.abort(); + }, [selectedId, connected]); + + // Manual compaction (B1.2-B1.4): gated on the daemon's manual_compaction + // capability (the compatibility document is the live source; the GET-session + // echo is its per-session sibling once daemons stamp it). + const compactSupported = serverCapabilities.manual_compaction === true; + const handleCompact = useCallback(async () => { + const id = selectedIdRef.current; + if (!id) return; + try { + const compacted = await compactHarnessSession(id); + toast.success( + compacted ? "Conversation compacted" : "Nothing to compact", + ); + if (compacted) { + // The model history was rewritten; the transcript must refetch. + await refreshTranscript(); + } + } catch (caught) { + toast.error(caught instanceof Error ? caught.message : String(caught)); + } + }, [refreshTranscript]); + useNavReopenSidebar(setSidebarOpen); // Seed for the draft composer, set when a starter prompt is picked. @@ -531,6 +586,15 @@ export function ChatWorkspace({ sessionId }: { sessionId?: string }) { error={turnError} onRetry={retryLast} onSend={sendMessage} + onCompact={compactSupported ? handleCompact : undefined} + contextInfo={ + resolvedModel && resolvedModel.contextWindow > 0 + ? { + modelLabel: resolvedModel.modelId, + contextWindow: resolvedModel.contextWindow, + } + : null + } botName={agentName} sidebarOpen={open} sidebarSide={sidebarSide} diff --git a/studio/src/app/workspace/chat/_components/context-meter.test.ts b/studio/src/app/workspace/chat/_components/context-meter.test.ts new file mode 100644 index 0000000000..332a8568ee --- /dev/null +++ b/studio/src/app/workspace/chat/_components/context-meter.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from "vitest"; +import { contextUtilisation } from "./context-meter"; + +/** + * Pins the context-meter math (B1.1): counted input+output tokens over the + * resolved model's context window, clamped, and honestly null when the window + * is unknown — the meter must never render against a made-up denominator. + */ +describe("contextUtilisation", () => { + it("computes the fraction of the window the counted tokens occupy", () => { + expect(contextUtilisation(30_000, 10_000, 400_000)).toBeCloseTo(0.1); + expect(contextUtilisation(0, 0, 400_000)).toBe(0); + }); + + it("clamps overshoot to 1 (the daemon compacts before the client's approximation catches up)", () => { + expect(contextUtilisation(500_000, 100_000, 400_000)).toBe(1); + }); + + it("returns null when the window is unknown", () => { + expect(contextUtilisation(1_000, 1_000, 0)).toBeNull(); + expect(contextUtilisation(1_000, 1_000, -1)).toBeNull(); + expect(contextUtilisation(1_000, 1_000, Number.NaN)).toBeNull(); + }); + + it("ignores negative token figures rather than going below zero", () => { + expect(contextUtilisation(-5, 100, 1_000)).toBeCloseTo(0.1); + }); +}); diff --git a/studio/src/app/workspace/chat/_components/context-meter.tsx b/studio/src/app/workspace/chat/_components/context-meter.tsx new file mode 100644 index 0000000000..850dec4111 --- /dev/null +++ b/studio/src/app/workspace/chat/_components/context-meter.tsx @@ -0,0 +1,70 @@ +"use client"; + +/** + * The slim context strip near the composer (B1.1): the session's effective + * model and an APPROXIMATE context-utilisation meter — cumulative input+output + * tokens this visit counted (summed from the runs' terminal usage frames) vs + * the model's `resolved_model.context_window`. Deliberately labeled + * approximate: the daemon's true compaction trigger also counts the system + * prompt and tool schemas, which the client never sees, and a page reload + * loses the visit's running total. + */ + +import { cn } from "@/lib/utils"; + +/** + * Fraction of the context window the counted tokens occupy, clamped to + * [0, 1]. Null when the window is unknown (<= 0) — the meter must not render + * against a made-up denominator. + */ +export function contextUtilisation( + inputTokens: number, + outputTokens: number, + contextWindow: number, +): number | null { + if (!Number.isFinite(contextWindow) || contextWindow <= 0) return null; + const used = Math.max(0, inputTokens) + Math.max(0, outputTokens); + return Math.min(1, used / contextWindow); +} + +export function ContextMeter({ + modelLabel, + contextWindow, + inputTokens, + outputTokens, +}: { + /** The effective model (resolved_model.model_id). */ + modelLabel: string; + contextWindow: number; + inputTokens: number; + outputTokens: number; +}) { + const fraction = contextUtilisation(inputTokens, outputTokens, contextWindow); + // No window, or nothing counted yet this visit: showing "0%" on a chat + // whose history the daemon still carries would be a lie, so stay quiet. + if (fraction === null || inputTokens + outputTokens <= 0) return null; + const percent = Math.round(fraction * 100); + return ( +
+ {modelLabel && {modelLabel}} +
+ ); +} diff --git a/studio/src/app/workspace/chat/_components/message-bubble.tsx b/studio/src/app/workspace/chat/_components/message-bubble.tsx index 57d3fb93dc..275bd06fa8 100644 --- a/studio/src/app/workspace/chat/_components/message-bubble.tsx +++ b/studio/src/app/workspace/chat/_components/message-bubble.tsx @@ -25,10 +25,11 @@ import type { AgentMessage, Artifact, Attachment, + DelegationInfo, ToolCallInfo, } from "@/features/agent"; import { fileKindMeta } from "@/lib/file-meta"; -import { formatMessageTime } from "@/lib/formatters"; +import { formatMessageTime, formatTokens } from "@/lib/formatters"; import { useAgentAvatar, useUserAvatar, @@ -234,6 +235,90 @@ function AttachmentChip({ ); } +/** Wall-clock child duration, humanized ("850ms", "12s", "3m 20s"). */ +function formatChildDuration(ms: number): string { + if (!Number.isFinite(ms) || ms <= 0) return ""; + if (ms < 1000) return `${Math.round(ms)}ms`; + const seconds = Math.round(ms / 1000); + if (seconds < 60) return `${seconds}s`; + return `${Math.floor(seconds / 60)}m ${seconds % 60}s`; +} + +/** + * One delegated child on a turn (D1): the start-time badge upgraded into a + * live card. While the child runs it ticks cumulative tool/token counters + * (fed by the redacted `subagent.tool` projection); on end it shows the stop + * reason and duration — and a failed child renders its failure cause instead + * of silently vanishing. `routingReason` (D2.1) rides the tooltip. + */ +function DelegationCard({ delegation }: { delegation: DelegationInfo }) { + const running = delegation.childId !== undefined && !delegation.stop; + const failed = delegation.stop === "error"; + const counters: string[] = []; + if (delegation.toolCount !== undefined && delegation.toolCount > 0) { + counters.push( + `${delegation.toolCount} ${delegation.toolCount === 1 ? "tool" : "tools"}`, + ); + } + const totalTokens = + (delegation.inputTokens ?? 0) + (delegation.outputTokens ?? 0); + if (totalTokens > 0) counters.push(`${formatTokens(totalTokens)} tok`); + if (running && delegation.lastTool) counters.push(delegation.lastTool); + if (delegation.stop && !failed) { + const duration = formatChildDuration(delegation.durationMs ?? 0); + counters.push( + duration ? `done in ${duration}` : `done (${delegation.stop})`, + ); + } + const tooltip = [ + delegation.routingReason && `routing: ${delegation.routingReason}`, + delegation.detail, + failed && delegation.cause, + ] + .filter(Boolean) + .join(" · "); + + return ( +
+ + {running ? ( + + ) : failed ? ( + + ) : ( + + )} + + {delegation.kind}: {delegation.label} + {delegation.background ? " · background" : ""} + {delegation.detail ? ` · ${delegation.detail}` : ""} + {counters.length > 0 ? ` · ${counters.join(" · ")}` : ""} + {failed ? " · failed" : ""} + + + {failed && delegation.cause && ( +

+ {delegation.cause} +

+ )} +
+ ); +} + function ArtifactCard({ artifact, onClick, @@ -274,12 +359,15 @@ export function MessageBubble({ message, onOpenArtifact, onOpenAttachment, + onOpenToolCall, botName = "Mecatl", showActivity = true, }: { message: AgentMessage; onOpenArtifact?: (artifact: Artifact) => void; onOpenAttachment?: (attachment: Attachment) => void; + /** Opens one tool call's full input/output in the side panel. */ + onOpenToolCall?: (call: ToolCallInfo) => void; botName?: string; showActivity?: boolean; }) { @@ -307,7 +395,7 @@ export function MessageBubble({ })); const delegations = (message.delegations ?? []).map((d, index) => ({ ...d, - id: `${index}:${d.kind}:${d.label}`, + id: d.childId ?? `${index}:${d.kind}:${d.label}`, })); // A failed turn must always render (never look like an empty success), as // must one that only carries notices or delegation badges. @@ -367,20 +455,15 @@ export function MessageBubble({ )} {hasToolCalls && showActivity && message.toolCalls && ( - + )} {delegations.length > 0 && (
{delegations.map((d) => ( - - - {d.kind}: {d.label} - {d.detail ? ` · ${d.detail}` : ""} - + ))}
)} @@ -412,14 +495,30 @@ export function MessageBubble({ )} {notices.length > 0 && (
- {notices.map((notice) => ( -

- {notice.text} -

- ))} + {notices.map((notice) => + notice.text.startsWith("[conversation compacted]") ? ( + // Compaction is a milestone, not chatter: a labeled divider, + // with the daemon's full explanation on the tooltip. +
+ + + Earlier messages summarized + + +
+ ) : ( +

+ {notice.text} +

+ ), + )}
)} {message.failed && ( diff --git a/studio/src/app/workspace/chat/_components/tool-call-list.test.ts b/studio/src/app/workspace/chat/_components/tool-call-list.test.ts index d782765c45..28f8569b04 100644 --- a/studio/src/app/workspace/chat/_components/tool-call-list.test.ts +++ b/studio/src/app/workspace/chat/_components/tool-call-list.test.ts @@ -4,17 +4,37 @@ import { activitySummary } from "./tool-call-list"; const call = (status: "running" | "completed" | "failed") => ({ status }); /** - * The collapsed "Activity" line's text: the count fragment, pluralized only - * past one call. + * The collapsed "Activity" line's text: the count fragment always, the + * failed fragment ONLY when something actually failed — "0 failed" noise + * would train users to ignore it. */ describe("activitySummary", () => { - it("counts every call regardless of status", () => { - expect(activitySummary([call("completed"), call("running")])).toEqual({ + it("composes 'N tools · M failed' when any call failed", () => { + const summary = activitySummary([ + call("completed"), + call("failed"), + call("completed"), + ]); + expect(`${summary.tools} · ${summary.failed}`).toBe("3 tools · 1 failed"); + }); + + it("omits the failed fragment when every call succeeded", () => { + expect(activitySummary([call("completed"), call("completed")])).toEqual({ tools: "2 tools", + failed: null, + }); + }); + + it("does not count a still-running call as failed", () => { + expect(activitySummary([call("running")])).toEqual({ + tools: "1 tool", + failed: null, }); }); it("pluralizes only the tool count", () => { - expect(activitySummary([call("failed")]).tools).toBe("1 tool"); + const summary = activitySummary([call("failed")]); + expect(summary.tools).toBe("1 tool"); + expect(summary.failed).toBe("1 failed"); }); }); diff --git a/studio/src/app/workspace/chat/_components/tool-call-list.tsx b/studio/src/app/workspace/chat/_components/tool-call-list.tsx index eabe4c3532..051b0c5c93 100644 --- a/studio/src/app/workspace/chat/_components/tool-call-list.tsx +++ b/studio/src/app/workspace/chat/_components/tool-call-list.tsx @@ -12,19 +12,56 @@ function formatPreview(output: string | undefined): string { } /** - * The collapsed summary line's text: "3 tools", pluralized only past one - * call. Split so the component can render the count without re-deriving it. + * The collapsed summary line's text: "3 tools" plus a "1 failed" fragment + * only when any call failed (never "0 failed"). Split so the component can + * tint the failed fragment destructive without re-deriving the counts. */ export function activitySummary(toolCalls: Pick[]): { tools: string; + failed: string | null; } { const count = toolCalls.length; + const failedCount = toolCalls.filter((t) => t.status === "failed").length; return { tools: `${count} tool${count === 1 ? "" : "s"}`, + failed: failedCount > 0 ? `${failedCount} failed` : null, }; } -export function ToolCallList({ toolCalls }: { toolCalls: ToolCallInfo[] }) { +/** Per-call status dot color: the schedule-badges quiet-dot idiom. */ +export function statusDotClass(status: ToolCallInfo["status"]): string { + switch (status) { + case "failed": + return "bg-destructive"; + case "running": + return "bg-brand animate-pulse"; + default: + return "bg-muted-foreground/50"; + } +} + +/** Quiet status dot: color carries the state, label kept for hover/SRs. */ +function StatusDot({ status }: { status: ToolCallInfo["status"] }) { + return ( + + + ); +} + +export function ToolCallList({ + toolCalls, + onSelect, +}: { + toolCalls: ToolCallInfo[]; + /** Opens one call's full input/output in the side panel; omitted = rows + are plain text (the thread panel keeps its inline previews only). */ + onSelect?: (call: ToolCallInfo) => void; +}) { const [expanded, setExpanded] = useState(false); const toolNames = toolCalls.map((t) => t.name).join(" · "); const summary = activitySummary(toolCalls); @@ -43,24 +80,49 @@ export function ToolCallList({ toolCalls }: { toolCalls: ToolCallInfo[] }) { )} /> - Activity: {summary.tools}{" "} + Activity: {summary.tools} + {summary.failed && ( + + {" "} + · {summary.failed} + + )}{" "} {toolNames} {expanded && (
- {toolCalls.map((tc) => ( -
- - {tc.name} - {tc.output && ( - {formatPreview(tc.output)} - )} -
- ))} + {toolCalls.map((tc) => { + const row = ( + <> + + + + {tc.name} + + {tc.output && ( + {formatPreview(tc.output)} + )} + + ); + return onSelect ? ( + + ) : ( +
+ {row} +
+ ); + })}
)} diff --git a/studio/src/app/workspace/chat/_components/tool-call-panel.test.ts b/studio/src/app/workspace/chat/_components/tool-call-panel.test.ts new file mode 100644 index 0000000000..b412d8087c --- /dev/null +++ b/studio/src/app/workspace/chat/_components/tool-call-panel.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from "vitest"; +import { formatToolInput } from "./tool-call-panel"; + +/** + * The drill-down panel shows the call's FULL input, pretty-printed. Inputs + * arrive as decoded objects (the usual case) or as raw strings; either way + * the formatter must never throw — the panel renders whatever it gets. + */ +describe("formatToolInput", () => { + it("pretty-prints a decoded args object", () => { + expect(formatToolInput({ path: "/a", limit: 2 })).toBe( + '{\n "path": "/a",\n "limit": 2\n}', + ); + }); + + it("re-indents a string that is itself JSON", () => { + expect(formatToolInput('{"a":1}')).toBe('{\n "a": 1\n}'); + }); + + it("passes a non-JSON string through verbatim", () => { + expect(formatToolInput("ls -la")).toBe("ls -la"); + }); + + it("renders nothing for an absent input", () => { + expect(formatToolInput(undefined)).toBe(""); + expect(formatToolInput(null)).toBe(""); + }); + + it("degrades an unserializable input instead of throwing", () => { + const cyclic: Record = {}; + cyclic.self = cyclic; + expect(formatToolInput(cyclic)).toBe("[object Object]"); + }); +}); diff --git a/studio/src/app/workspace/chat/_components/tool-call-panel.tsx b/studio/src/app/workspace/chat/_components/tool-call-panel.tsx new file mode 100644 index 0000000000..ad7a8c0c37 --- /dev/null +++ b/studio/src/app/workspace/chat/_components/tool-call-panel.tsx @@ -0,0 +1,95 @@ +"use client"; + +import { Plug } from "lucide-react"; +import type { ToolCallInfo } from "@/features/agent"; +import { cn } from "@/lib/utils"; +import { SidePanel } from "./side-panel"; +import { statusDotClass } from "./tool-call-list"; + +/** + * Pretty-prints a call's input for the panel: objects as indented JSON, a + * string that happens to BE JSON re-indented, anything else verbatim. Never + * throws — a cyclic or unserializable input degrades to String(). + */ +export function formatToolInput(input: unknown): string { + if (input === undefined || input === null) return ""; + if (typeof input === "string") { + try { + return JSON.stringify(JSON.parse(input), null, 2); + } catch { + return input; + } + } + try { + return JSON.stringify(input, null, 2) ?? String(input); + } catch { + return String(input); + } +} + +function SectionLabel({ children }: { children: React.ReactNode }) { + return ( +

+ {children} +

+ ); +} + +/** + * The full, untruncated detail of one tool call in the right-hand side + * panel: name in the header, status, the pretty-printed input JSON, and the + * raw output. The inline activity list keeps its truncated previews — this + * is the drill-down. + */ +export function ToolCallPanel({ + call, + onClose, + maximized, + onToggleMaximize, + windowControls, +}: { + call: ToolCallInfo; + onClose: () => void; + maximized: boolean; + onToggleMaximize: () => void; + windowControls?: boolean; +}) { + const input = formatToolInput(call.input); + return ( + +
+
+
+ Input +
+          {input || "(no input)"}
+        
+ Output +
+          {call.output ||
+            (call.status === "running" ? "(still running)" : "(no output)")}
+        
+
+
+ ); +} diff --git a/studio/src/features/agent/hooks/use-agent-chat.test.ts b/studio/src/features/agent/hooks/use-agent-chat.test.ts index 79e6ce86d3..229740d1e2 100644 --- a/studio/src/features/agent/hooks/use-agent-chat.test.ts +++ b/studio/src/features/agent/hooks/use-agent-chat.test.ts @@ -1,41 +1,79 @@ -import { act, renderHook } from "@testing-library/react"; -import { describe, expect, it, vi } from "vitest"; -import { useAgentChat } from "./use-agent-chat"; +import { describe, expect, it } from "vitest"; +import type { AgentMessage } from "../types"; +import { applyDelegationUpdate } from "./use-agent-chat"; -// The hook's network surface is exercised against the fixture daemon (e2e); -// here we pin the client-side resting shape every later stage builds on. -vi.mock("../runtime-status", () => ({ - useRuntimeStatus: () => ({ - connected: false, - features: new Set(), - serverCapabilities: {}, - }), -})); +// ── delegation cards (D1) ──────────────────────────────────────────────────── -describe("useAgentChat", () => { - it("opens a draft chat idle: empty transcript, no approval, zero usage", () => { - const { result } = renderHook(() => useAgentChat(null)); - expect(result.current.messages).toEqual([]); - expect(result.current.status).toBe("idle"); - expect(result.current.isStreaming).toBe(false); - expect(result.current.harnessLive).toBe(false); - expect(result.current.pendingApproval).toBeNull(); - expect(result.current.usage).toEqual({ - inputTokens: 0, - outputTokens: 0, - cacheReadTokens: 0, - cacheWriteTokens: 0, - reasoningTokens: 0, - estimatedCost: null, +describe("applyDelegationUpdate", () => { + const base = (): AgentMessage[] => [ + { id: "u1", role: "user", content: "go", timestamp: 0 }, + { + id: "a1", + role: "assistant", + content: "", + timestamp: 0, + delegations: [ + { + kind: "subagent", + label: "explore the repo", + detail: "", + childId: "subagent-abc", + }, + ], + }, + ]; + + it("ticks the running counters on delegation_progress, keyed by childId", () => { + let messages = applyDelegationUpdate(base(), { + type: "delegation_progress", + childId: "subagent-abc", + toolCount: 3, + inputTokens: 1200, + outputTokens: 80, + toolName: "Read", + }); + messages = applyDelegationUpdate(messages, { + type: "delegation_progress", + childId: "subagent-abc", + toolCount: 4, + toolName: "Grep", + }); + expect(messages[1].delegations?.[0]).toMatchObject({ + childId: "subagent-abc", + toolCount: 4, + inputTokens: 1200, + outputTokens: 80, + lastTool: "Grep", }); + // The card is still running: no stop yet. + expect(messages[1].delegations?.[0].stop).toBeUndefined(); }); - it("refuses to send while the daemon is unreachable — no optimistic bubble", async () => { - const { result } = renderHook(() => useAgentChat(null)); - await act(async () => { - await result.current.sendMessage("hello"); + it("stamps stop, duration, and the failure cause on delegation_end — a failed child never vanishes", () => { + const messages = applyDelegationUpdate(base(), { + type: "delegation_end", + childId: "subagent-abc", + stop: "error", + toolCount: 7, + durationMs: 4200, + cause: "provider rejected the request", }); - expect(result.current.messages).toEqual([]); - expect(result.current.status).toBe("idle"); + expect(messages[1].delegations?.[0]).toMatchObject({ + stop: "error", + toolCount: 7, + durationMs: 4200, + cause: "provider rejected the request", + }); + }); + + it("returns the SAME array when no card carries the child (progress without a start)", () => { + const before = base(); + expect( + applyDelegationUpdate(before, { + type: "delegation_progress", + childId: "subagent-unknown", + toolCount: 1, + }), + ).toBe(before); }); }); diff --git a/studio/src/features/agent/hooks/use-agent-chat.ts b/studio/src/features/agent/hooks/use-agent-chat.ts index 14a45b0e2e..25b3e0cd9d 100644 --- a/studio/src/features/agent/hooks/use-agent-chat.ts +++ b/studio/src/features/agent/hooks/use-agent-chat.ts @@ -10,8 +10,10 @@ import { cancelHarnessRun, createHarnessSession, fetchSessionTranscriptMessages, + HarnessApiError, type PromptPart, respondToHarnessApproval, + retryHarnessRun, streamHarnessPrompt, } from "@/lib/harness/client"; import { @@ -26,6 +28,8 @@ import type { ApprovalRequest, Attachment, ClarificationRequest, + DelegationInfo, + RetryDisposition, StreamEvent, ToolCallInfo, } from "../types"; @@ -175,6 +179,58 @@ function messagesFromTranscript(transcript: SessionTranscript): AgentMessage[] { return messages; } +/** + * Applies one live child-activity event (`delegation_progress` / + * `delegation_end`, D1) onto the delegation card it belongs to, searching the + * transcript backwards for the entry keyed by `childId`. Pure — a new array + * on a hit, the SAME array when the child has no card (a progress frame whose + * start this visit never saw updates nothing). + */ +export function applyDelegationUpdate( + messages: AgentMessage[], + event: Extract< + StreamEvent, + { type: "delegation_progress" | "delegation_end" } + >, +): AgentMessage[] { + const apply = (delegation: DelegationInfo): DelegationInfo => + event.type === "delegation_progress" + ? { + ...delegation, + toolCount: event.toolCount ?? delegation.toolCount, + inputTokens: event.inputTokens ?? delegation.inputTokens, + outputTokens: event.outputTokens ?? delegation.outputTokens, + lastTool: event.toolName ?? delegation.lastTool, + } + : { + ...delegation, + toolCount: event.toolCount ?? delegation.toolCount, + inputTokens: event.inputTokens ?? delegation.inputTokens, + outputTokens: event.outputTokens ?? delegation.outputTokens, + stop: event.stop || "end_turn", + durationMs: event.durationMs, + cause: event.cause, + }; + for (let index = messages.length - 1; index >= 0; index -= 1) { + const message = messages[index]; + if ( + !message.delegations?.some( + (delegation) => delegation.childId === event.childId, + ) + ) { + continue; + } + const updated: AgentMessage = { + ...message, + delegations: message.delegations.map((delegation) => + delegation.childId === event.childId ? apply(delegation) : delegation, + ), + }; + return [...messages.slice(0, index), updated, ...messages.slice(index + 1)]; + } + return messages; +} + /** * Chat state for one daemon session. Daemon-only: the sidebar id IS the * daemon session id — there is no client-side session mapping and no demo @@ -217,6 +273,10 @@ export function useAgentChat( const daemonIdRef = useRef(sessionId); const abortRef = useRef(null); const lastPromptRef = useRef(null); + // The last failed terminal's typed disposition (ADR 0239), captured from + // run_result frames: "retryable" routes retryLast through the retry + // endpoint instead of re-sending the prompt. Cleared when a run starts. + const lastDispositionRef = useRef(undefined); // Attachments sent this visit, keyed by session: the daemon's transcript // carries no attachment bytes, so every rehydrate would strip the chips — // this ref re-attaches them by matching user turns in send order. @@ -268,6 +328,7 @@ export function useAgentChat( // Opening a chat (or switching chats) rehydrates from the daemon. useEffect(() => { daemonIdRef.current = sessionId; + lastDispositionRef.current = undefined; setMessages([]); setPendingApproval(null); setError(null); @@ -292,7 +353,11 @@ export function useAgentChat( return () => controller.abort(); }, [sessionId, connected, rehydrate]); - /** Builds the per-run stream-event handler the prompt stream drives. */ + /** + * Builds the per-run stream-event handler shared by the prompt stream and + * the failed-step retry relay (ADR 0239) — both drive the SAME translated + * event switch. + */ const makeStreamHandler = useCallback( (daemonId: string, ids: { assistant: string }) => { // Every update is a functional setState: tokens and tool results arrive @@ -389,6 +454,12 @@ export function useAgentChat( ], })); break; + case "delegation_progress": + case "delegation_end": + // Live child cards (D1): counters tick while the child works; + // the terminal stamps stop/duration/cause onto the card. + setMessages((prev) => applyDelegationUpdate(prev, event)); + break; case "usage": // The daemon reports per-run figures; the chat total is // their sum. (Lost on reload: the HTTP read surface does @@ -407,6 +478,8 @@ export function useAgentChat( break; case "run_result": if (event.stop === "error") { + // The typed disposition routes the Retry button (ADR 0239). + lastDispositionRef.current = event.retryDisposition; const detail = event.errorText || "The run failed without a specific error."; patch((message) => ({ @@ -549,6 +622,7 @@ export function useAgentChat( } onSessionCreatedRef.current?.(daemonId); } + lastDispositionRef.current = undefined; await streamHarnessPrompt( daemonId, content, @@ -590,9 +664,9 @@ export function useAgentChat( [status, connected, makeStreamHandler], ); - /** Re-sends the last prompt after a failure — the error banner's Retry. */ - const retryLast = useCallback(async () => { - if (status === "streaming") return; + /** Re-sends the last prompt after a failure — the legacy Retry path, kept + * for permanent/unknown dispositions and older daemons. */ + const resendLast = useCallback(async () => { const prompt = lastPromptRef.current; if (!prompt) return; // Drop the failed exchange so the retry replaces it instead of stacking. @@ -614,7 +688,90 @@ export function useAgentChat( setError(null); setStatus("idle"); await sendMessage(prompt); - }, [status, sendMessage]); + }, [sendMessage]); + + /** + * The error banner's Retry. When the failed terminal was typed RETRYABLE + * (ADR 0239), this drives `POST .../retry`: the daemon re-drives the + * recorded failed step itself and relays the run as SSE — no user message + * is re-sent, which is exactly the duplicate-effects path the endpoint + * exists to prevent. A 409 `failed_step_retry_ineligible` (the intent + * raced away) falls back to the resend path; a PERMANENT or untyped + * failure keeps the resend path (and the composer's edit-and-resend). + */ + const retryLast = useCallback(async () => { + if (status === "streaming") return; + const daemonId = daemonIdRef.current; + if (lastDispositionRef.current !== "retryable" || !daemonId || !connected) { + await resendLast(); + return; + } + // Drop the failed assistant bubble — the retried step streams into a + // fresh one; the user message stays (nothing is re-sent). + setMessages((prev) => { + const trimmed = [...prev]; + while (trimmed.length) { + const last = trimmed[trimmed.length - 1]; + if (last.role === "assistant" && (last.failed || !last.content)) { + trimmed.pop(); + continue; + } + break; + } + return trimmed; + }); + setError(null); + setStatus("streaming"); + + const ids = { assistant: `assistant-${Date.now()}` }; + setMessages((prev) => [ + ...prev, + { + id: ids.assistant, + role: "assistant", + content: "", + timestamp: Date.now(), + }, + ]); + const controller = new AbortController(); + abortRef.current = controller; + lastDispositionRef.current = undefined; + let ineligible = false; + try { + await retryHarnessRun( + daemonId, + makeStreamHandler(daemonId, ids), + controller.signal, + ); + setStatus((current) => + current === "waiting_approval" || current === "error" + ? current + : "idle", + ); + } catch (caught) { + if (controller.signal.aborted) { + setStatus("idle"); + return; + } + if ( + caught instanceof HarnessApiError && + (caught.code === "failed_step_retry_ineligible" || + caught.status === 409) + ) { + // The retry intent is gone daemon-side (another client acted, or + // the state moved on): quietly fall back to re-sending the prompt. + ineligible = true; + } else { + const message = + caught instanceof Error ? caught.message : String(caught); + setError(message); + setStatus("error"); + } + } finally { + abortRef.current = null; + } + if (ineligible) await resendLast(); + }, [status, connected, resendLast, makeStreamHandler]); const cancelChat = useCallback(async () => { abortRef.current?.abort(); @@ -662,6 +819,14 @@ export function useAgentChat( setStatus("idle"); }, []); + /** Re-fetches the authoritative transcript (e.g. after a manual compaction + * rewrote the model history, B1.3). No-op on a draft with no session. */ + const refreshTranscript = useCallback(async () => { + const daemonId = daemonIdRef.current; + if (!daemonId) return; + await rehydrate(daemonId); + }, [rehydrate]); + return { messages, // A parked approval is still an in-flight run daemon-side; the composer @@ -672,6 +837,7 @@ export function useAgentChat( harnessLive: connected, sendMessage, retryLast, + refreshTranscript, cancelChat, pendingApproval, pendingClarification, diff --git a/studio/src/lib/profile-preferences.test.ts b/studio/src/lib/profile-preferences.test.ts new file mode 100644 index 0000000000..6fa5dcf0d1 --- /dev/null +++ b/studio/src/lib/profile-preferences.test.ts @@ -0,0 +1,70 @@ +import { act, renderHook } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { useShowToolCalls } from "./profile-preferences"; + +const KEY = "mecatl-studio.show-tool-calls"; + +/** This vitest environment ships a method-less localStorage shim (Node's + * --localstorage-file stub shadows jsdom's), so storage tests stub a real + * in-memory Storage; the global afterEach unstubs it. */ +function memoryStorage(): Storage { + let store = new Map(); + return { + get length() { + return store.size; + }, + clear: () => { + store = new Map(); + }, + getItem: (key: string) => store.get(key) ?? null, + key: (index: number) => [...store.keys()][index] ?? null, + removeItem: (key: string) => { + store.delete(key); + }, + setItem: (key: string, value: string) => { + store.set(key, value); + }, + }; +} + +/** + * The Show Tools preference is GLOBAL and persisted: it must round-trip + * through localStorage (survive a "reload" = a fresh hook mount), keep two + * simultaneously mounted instances in sync (the chat view and the thread + * panel both show the toggle), and store nothing while off. + */ +describe("useShowToolCalls", () => { + beforeEach(() => { + vi.stubGlobal("localStorage", memoryStorage()); + }); + + it("defaults off and stores nothing until enabled", () => { + const { result } = renderHook(() => useShowToolCalls()); + expect(result.current.showToolCalls).toBe(false); + expect(window.localStorage.getItem(KEY)).toBeNull(); + }); + + it("round-trips through storage across mounts", () => { + const first = renderHook(() => useShowToolCalls()); + act(() => first.result.current.setShowToolCalls(true)); + expect(window.localStorage.getItem(KEY)).toBe("1"); + first.unmount(); + + // A fresh mount (a reload, a different session's chat) reads it back. + const second = renderHook(() => useShowToolCalls()); + expect(second.result.current.showToolCalls).toBe(true); + + // Turning it off removes the key rather than storing "0" forever. + act(() => second.result.current.setShowToolCalls(false)); + expect(window.localStorage.getItem(KEY)).toBeNull(); + expect(second.result.current.showToolCalls).toBe(false); + }); + + it("keeps two mounted instances in sync (chat menu + thread panel)", () => { + const chat = renderHook(() => useShowToolCalls()); + const thread = renderHook(() => useShowToolCalls()); + act(() => thread.result.current.setShowToolCalls(true)); + expect(chat.result.current.showToolCalls).toBe(true); + expect(thread.result.current.showToolCalls).toBe(true); + }); +}); diff --git a/studio/src/lib/profile-preferences.ts b/studio/src/lib/profile-preferences.ts index 2176f73dd2..db12c54f29 100644 --- a/studio/src/lib/profile-preferences.ts +++ b/studio/src/lib/profile-preferences.ts @@ -1,6 +1,6 @@ "use client"; -import { useCallback, useEffect, useState } from "react"; +import { useCallback, useEffect, useState, useSyncExternalStore } from "react"; /** * Cosmetic, browser-local identity preferences: the agent's display name and @@ -150,6 +150,42 @@ export function useSessionListSide() { return { side, setSide }; } +const SHOW_TOOL_CALLS_KEY = "mecatl-studio.show-tool-calls"; + +const showToolCallsListeners = new Set<() => void>(); + +function subscribeShowToolCalls(callback: () => void): () => void { + showToolCallsListeners.add(callback); + return () => showToolCallsListeners.delete(callback); +} + +function readShowToolCalls(): boolean { + return readLocalStorage(SHOW_TOOL_CALLS_KEY) === "1"; +} + +/** + * Whether chat transcripts render each turn's tool activity ("Show Tools"). + * A GLOBAL browser-local preference, not per session: the chat menu and the + * thread panel's menu read and write the same stored value, and both mount + * at once, so instances sync through a shared store (the use-panel-width + * pattern) instead of hydrating independently. Default OFF; the key stores + * "1" only while enabled. SSR renders "off" and patches up after hydration. + */ +export function useShowToolCalls() { + const showToolCalls = useSyncExternalStore( + subscribeShowToolCalls, + readShowToolCalls, + () => false, + ); + + const setShowToolCalls = useCallback((next: boolean) => { + writeLocalStorage(SHOW_TOOL_CALLS_KEY, next ? "1" : null); + for (const fn of showToolCallsListeners) fn(); + }, []); + + return { showToolCalls, setShowToolCalls }; +} + /** * The user's display name — browser-local, cosmetic. It labels your chat * messages in Studio; the AGENT learns your name in conversation (its memory