From ced262a0daf0ea28ad9e5f6544ecca403ab6e387 Mon Sep 17 00:00:00 2001 From: scotttong Date: Fri, 31 Jul 2026 17:50:26 -0700 Subject: [PATCH] =?UTF-8?q?feat(ui):=20task-chat=20tool=20taxonomy=20?= =?UTF-8?q?=E2=80=94=20pill=20verbs/icons,=20Shift+Enter=20to=20send,=20ba?= =?UTF-8?q?seline-aligned=20elapsed?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - new shared tool-taxonomy.ts (family/icon/verb per raw ACP tool name, mcp__ prefix collapse) used by the status pill, chat tool rows, and the classic transcript's previously hardcoded Wrench sites - deriveRunStatusLabel: tail tool_call now yields the taxonomy verb ("Searching", "Running a command") with "Tool · target" detail and the raw toolName so the pill shows the family icon beside the pulse dot - live status line: label/elapsed/detail/tokens share one baseline row - TaskChatComposer: Shift+Enter posts, plain Enter inserts a newline - tests: tool-taxonomy unit tests, deriveRunStatusLabel/toolDisplayName tests, composer Enter-behavior tests updated Co-Authored-By: Claude Fable 5 --- ui/src/components/TaskChatThread.tsx | 3 +- .../task-chat/TaskChatComposer.test.tsx | 28 +- .../components/task-chat/TaskChatComposer.tsx | 6 +- .../task-chat/TaskChatStatusPill.tsx | 35 +- .../components/task-chat/TaskChatToolCard.tsx | 21 +- .../task-chat/task-chat-fixtures.ts | 2 +- .../components/task-chat/task-chat-model.ts | 4 + .../task-chat/tool-taxonomy.test.ts | 79 +-- ui/src/components/task-chat/tool-taxonomy.ts | 84 +-- .../task-chat/transcript-adapter.test.ts | 637 +----------------- .../task-chat/transcript-adapter.ts | 24 +- 11 files changed, 111 insertions(+), 812 deletions(-) diff --git a/ui/src/components/TaskChatThread.tsx b/ui/src/components/TaskChatThread.tsx index f5a46b5e8f7..aea8c94b890 100644 --- a/ui/src/components/TaskChatThread.tsx +++ b/ui/src/components/TaskChatThread.tsx @@ -310,7 +310,7 @@ export function TaskChatThread(props: TaskChatThreadProps) { const startedAt = liveRun.startedAt ? new Date(liveRun.startedAt).getTime() : null; const queued = liveRun.status === "queued"; const status = queued - ? { label: "Queued", detail: "Waiting to start" } + ? { label: "Queued", detail: "Waiting to start", toolName: undefined } : deriveRunStatusLabel(entries); out.push({ id: `${liveRun.id}:status`, @@ -318,6 +318,7 @@ export function TaskChatThread(props: TaskChatThreadProps) { status: "running", label: status.label, detail: status.detail, + toolName: status.toolName, startedAtMs: startedAt ?? undefined, }); } diff --git a/ui/src/components/task-chat/TaskChatComposer.test.tsx b/ui/src/components/task-chat/TaskChatComposer.test.tsx index 193088b92b5..fc031eebb76 100644 --- a/ui/src/components/task-chat/TaskChatComposer.test.tsx +++ b/ui/src/components/task-chat/TaskChatComposer.test.tsx @@ -36,14 +36,18 @@ function setTextareaValue(textarea: HTMLTextAreaElement, value: string) { }); } -function pressEnter(textarea: HTMLTextAreaElement) { +function pressEnter(textarea: HTMLTextAreaElement, shiftKey = false) { flushSync(() => { textarea.dispatchEvent( - new KeyboardEvent("keydown", { key: "Enter", bubbles: true, cancelable: true }), + new KeyboardEvent("keydown", { key: "Enter", shiftKey, bubbles: true, cancelable: true }), ); }); } +function pressShiftEnter(textarea: HTMLTextAreaElement) { + pressEnter(textarea, true); +} + async function flushAsync() { await new Promise((resolve) => setTimeout(resolve, 0)); } @@ -57,7 +61,7 @@ function sendButton() { } describe("TaskChatComposer", () => { - it("submits the trimmed body on Enter and clears the draft", async () => { + it("submits the trimmed body on Shift+Enter and clears the draft", async () => { const onAdd = vi.fn().mockResolvedValue(undefined); render(); @@ -65,13 +69,25 @@ describe("TaskChatComposer", () => { setTextareaValue(input(), " hello there "); expect(sendButton().disabled).toBe(false); - pressEnter(input()); + pressShiftEnter(input()); await flushAsync(); expect(onAdd).toHaveBeenCalledWith("hello there", undefined, undefined); expect(input().value).toBe(""); }); + it("does not submit on plain Enter (newline stays in the draft)", async () => { + const onAdd = vi.fn().mockResolvedValue(undefined); + render(); + + setTextareaValue(input(), "line one"); + pressEnter(input()); + await flushAsync(); + + expect(onAdd).not.toHaveBeenCalled(); + expect(input().value).toBe("line one"); + }); + it("cycles the pending mode with Shift+Tab and applies it on submit", async () => { const onAdd = vi.fn().mockResolvedValue(undefined); const onWorkModeChange = vi.fn().mockResolvedValue(undefined); @@ -92,7 +108,7 @@ describe("TaskChatComposer", () => { expect(chip.textContent).toContain("Plan"); setTextareaValue(input(), "do the plan"); - pressEnter(input()); + pressShiftEnter(input()); await flushAsync(); expect(onWorkModeChange).toHaveBeenCalledWith("planning"); @@ -111,7 +127,7 @@ describe("TaskChatComposer", () => { ); setTextareaValue(input(), "wake up"); - pressEnter(input()); + pressShiftEnter(input()); await flushAsync(); expect(onAdd).toHaveBeenCalledWith("wake up", true, undefined); diff --git a/ui/src/components/task-chat/TaskChatComposer.tsx b/ui/src/components/task-chat/TaskChatComposer.tsx index e61c88700d7..d509ce053b1 100644 --- a/ui/src/components/task-chat/TaskChatComposer.tsx +++ b/ui/src/components/task-chat/TaskChatComposer.tsx @@ -272,7 +272,9 @@ export function TaskChatComposer({ value={body} onChange={(e) => setBody(e.target.value)} onKeyDown={(e) => { - if (e.key === "Enter" && !e.shiftKey) { + // Shift+Enter posts; plain Enter inserts a newline (Enter alone was + // too easy to trip while drafting). + if (e.key === "Enter" && e.shiftKey) { e.preventDefault(); void submit(); return; @@ -417,7 +419,7 @@ export function TaskChatComposer({ type="button" onClick={() => void submit()} disabled={disabled || submitting || body.trim().length === 0} - title="Send" + title="Send (Shift+Enter)" aria-label="Send" className="flex h-8 w-8 shrink-0 items-center justify-center rounded-md bg-primary text-primary-foreground transition-transform hover:scale-105 disabled:scale-100 disabled:bg-muted disabled:text-muted-foreground" data-testid="task-chat-composer-send" diff --git a/ui/src/components/task-chat/TaskChatStatusPill.tsx b/ui/src/components/task-chat/TaskChatStatusPill.tsx index 8cb3d8a0818..2b1275ecc11 100644 --- a/ui/src/components/task-chat/TaskChatStatusPill.tsx +++ b/ui/src/components/task-chat/TaskChatStatusPill.tsx @@ -2,6 +2,7 @@ import { useEffect, useState } from "react"; import { cn } from "@/lib/utils"; import { Loader2, ShieldQuestion, OctagonX, Ban, Scissors } from "lucide-react"; import type { TaskChatStatusItem } from "./task-chat-model"; +import { toolTaxonomy } from "./tool-taxonomy"; function elapsedLabel(ms?: number): string | null { if (ms == null) return null; @@ -59,26 +60,32 @@ export function TaskChatStatusPill({ item, onApprovalDecision }: TaskChatStatusP if (live) { const liveElapsed = liveElapsedLabel(liveElapsedMs ?? item.elapsedMs); + const ToolIcon = item.toolName ? toolTaxonomy(item.toolName).icon : null; return (
- {item.label}… - {liveElapsed ? ( - - {liveElapsed} - - ) : null} - {item.detail ? ( - {item.detail} - ) : null} - {item.tokens ? ( - - {item.tokens.used.toLocaleString()}/{item.tokens.size.toLocaleString()} ctx - - ) : null} + {ToolIcon ? : null} + {/* Text cells share a baseline row so the smaller mono readouts don't + float above the label's baseline. */} + + {item.label}… + {liveElapsed ? ( + + {liveElapsed} + + ) : null} + {item.detail ? ( + {item.detail} + ) : null} + {item.tokens ? ( + + {item.tokens.used.toLocaleString()}/{item.tokens.size.toLocaleString()} ctx + + ) : null} +
); } diff --git a/ui/src/components/task-chat/TaskChatToolCard.tsx b/ui/src/components/task-chat/TaskChatToolCard.tsx index 20081a6df6b..49c89d7bfc2 100644 --- a/ui/src/components/task-chat/TaskChatToolCard.tsx +++ b/ui/src/components/task-chat/TaskChatToolCard.tsx @@ -3,19 +3,13 @@ import { cn } from "@/lib/utils"; import { Check, ChevronRight, - FileSearch, - Globe, Loader2, - Pencil, ShieldCheck, ShieldX, - SquareTerminal, - BookOpen, - Wrench, X, } from "lucide-react"; -import type { LucideIcon } from "lucide-react"; import type { TaskChatToolItem } from "./task-chat-model"; +import { toolTaxonomy } from "./tool-taxonomy"; const STATUS_ICON = { pending: { Icon: Loader2, spin: false, tone: "text-muted-foreground" }, @@ -24,17 +18,6 @@ const STATUS_ICON = { failed: { Icon: X, spin: false, tone: "text-destructive" }, } as const; -/** Tool name → row glyph (v7 grammar: read/search/terminal/edit families). */ -function toolIcon(name: string): LucideIcon { - const n = name.toLowerCase(); - if (n === "read" || n === "notebookread") return BookOpen; - if (n === "grep" || n === "glob" || n === "search" || n.includes("search")) return FileSearch; - if (n === "bash" || n === "shell" || n.includes("terminal") || n === "run") return SquareTerminal; - if (n === "edit" || n === "write" || n === "notebookedit" || n === "multiedit") return Pencil; - if (n.includes("fetch") || n.includes("web") || n.includes("http")) return Globe; - return Wrench; -} - /** * Tool invocation as a flat activity row (v7): [glyph] [name] [mono target] … * [hover chevron] [status]. No card chrome — activity is metadata, not @@ -43,7 +26,7 @@ function toolIcon(name: string): LucideIcon { */ export function TaskChatToolCard({ item }: { item: TaskChatToolItem }) { const { Icon, spin, tone } = STATUS_ICON[item.status]; - const RowIcon = toolIcon(item.name); + const RowIcon = toolTaxonomy(item.rawName ?? item.name).icon; const [showDetail, setShowDetail] = useState(false); const expandable = Boolean(item.detail); diff --git a/ui/src/components/task-chat/task-chat-fixtures.ts b/ui/src/components/task-chat/task-chat-fixtures.ts index 8dafe2c6ef8..9d95d638d43 100644 --- a/ui/src/components/task-chat/task-chat-fixtures.ts +++ b/ui/src/components/task-chat/task-chat-fixtures.ts @@ -99,7 +99,7 @@ export function buildScenario(id: TaskChatStateId): TaskChatScenario { return { surface: "thread", items: [ - { id: "st-working", kind: "status", status: "working", label: "Using edit", detail: "server/src/routes/auth.ts", startedAtMs: Date.now() - 4200 }, + { id: "st-working", kind: "status", status: "working", label: "Editing files", detail: "Edit · server/src/routes/auth.ts", toolName: "Edit", startedAtMs: Date.now() - 4200 }, ], }; case "running": diff --git a/ui/src/components/task-chat/task-chat-model.ts b/ui/src/components/task-chat/task-chat-model.ts index 78c4f7170e6..ad3e4c105e2 100644 --- a/ui/src/components/task-chat/task-chat-model.ts +++ b/ui/src/components/task-chat/task-chat-model.ts @@ -95,6 +95,8 @@ export interface TaskChatToolItem { id: string; kind: "tool"; name: string; + /** Raw ACP tool name pre-collapse ("mcp__server__tool") for taxonomy lookup. */ + rawName?: string; /** ACP ToolKind. */ toolKind?: string; status: TaskChatToolStatus; @@ -126,6 +128,8 @@ export interface TaskChatStatusItem { | "truncated"; label: string; detail?: string; + /** Raw tool name of the in-flight tail tool_call (drives the pill's icon). */ + toolName?: string; elapsedMs?: number; /** Run start epoch ms; live states tick their own elapsed from this. */ startedAtMs?: number; diff --git a/ui/src/components/task-chat/tool-taxonomy.test.ts b/ui/src/components/task-chat/tool-taxonomy.test.ts index 0758e1986f6..9f12f6bd26f 100644 --- a/ui/src/components/task-chat/tool-taxonomy.test.ts +++ b/ui/src/components/task-chat/tool-taxonomy.test.ts @@ -1,38 +1,31 @@ import { describe, expect, it } from "vitest"; import { BookOpen, - Brain, - ChevronsLeftRightEllipsis, - MessageSquareReply, - Network, - Search, - SearchCode, - Terminal, + Bot, + FileSearch, + Globe, + Pencil, + Plug, + SquareTerminal, Wrench, } from "lucide-react"; -import { McpIcon } from "./McpIcon"; -import { isGenericToolName, mcpToolSegment, statusLabelIcon, toolTaxonomy } from "./tool-taxonomy"; +import { mcpToolSegment, toolTaxonomy } from "./tool-taxonomy"; describe("toolTaxonomy", () => { it("maps each family to its icon and verb", () => { expect(toolTaxonomy("Bash")).toEqual({ family: "terminal", - icon: Terminal, + icon: SquareTerminal, verbLabel: "Running a command", }); expect(toolTaxonomy("Shell").family).toBe("terminal"); expect(toolTaxonomy("Grep")).toEqual({ - family: "grep", - icon: SearchCode, - verbLabel: "Grepping", - }); - - expect(toolTaxonomy("Glob")).toEqual({ family: "search", - icon: Search, + icon: FileSearch, verbLabel: "Searching", }); + expect(toolTaxonomy("Glob").family).toBe("search"); expect(toolTaxonomy("WebSearch").family).toBe("search"); expect(toolTaxonomy("Read")).toEqual({ @@ -42,10 +35,9 @@ describe("toolTaxonomy", () => { }); expect(toolTaxonomy("NotebookRead").family).toBe("read"); - // Round-4 board feedback: edits share the terminal glyph. expect(toolTaxonomy("Edit")).toEqual({ family: "edit", - icon: Terminal, + icon: Pencil, verbLabel: "Editing files", }); expect(toolTaxonomy("Write").family).toBe("edit"); @@ -54,22 +46,22 @@ describe("toolTaxonomy", () => { expect(toolTaxonomy("WebFetch")).toEqual({ family: "web", - icon: ChevronsLeftRightEllipsis, + icon: Globe, verbLabel: "Fetching the web", }); expect(toolTaxonomy("Task")).toEqual({ family: "agent", - icon: Network, + icon: Bot, verbLabel: "Delegating", }); expect(toolTaxonomy("Agent").family).toBe("agent"); }); - it("collapses mcp__ names to the MCP logo with the tool segment verb", () => { + it("collapses mcp__ names to a Plug entry with the tool segment verb", () => { const entry = toolTaxonomy("mcp__linear-server__search_issues"); expect(entry.family).toBe("mcp"); - expect(entry.icon).toBe(McpIcon); + expect(entry.icon).toBe(Plug); expect(entry.verbLabel).toBe("Using Search_issues"); }); @@ -85,47 +77,6 @@ describe("toolTaxonomy", () => { }); }); -describe("toolTaxonomy multi-word ACP titles", () => { - it("classifies by the first word", () => { - expect(toolTaxonomy("Read File").icon).toBe(BookOpen); - expect(toolTaxonomy("Edit File").icon).toBe(Terminal); - expect(toolTaxonomy("Write File").icon).toBe(Terminal); - expect(toolTaxonomy("Terminal").icon).toBe(Terminal); - }); -}); - -describe("statusLabelIcon", () => { - it("gives the tool-free informative statuses their glyphs", () => { - expect(statusLabelIcon("Thinking")).toBe(Brain); - expect(statusLabelIcon("Responding")).toBe(MessageSquareReply); - expect(statusLabelIcon("Responding (streaming)")).toBe(MessageSquareReply); - }); - - it("leaves whimsified and generic labels glyph-free", () => { - for (const label of ["Running", "Working", "Clipping", "Brewing", "Queued", "", undefined, null]) { - expect(statusLabelIcon(label)).toBeNull(); - } - }); -}); - -describe("isGenericToolName", () => { - it("flags acpx placeholder names, including status-suffixed variants", () => { - expect(isGenericToolName("tool call")).toBe(true); - expect(isGenericToolName("tool call (completed)")).toBe(true); - expect(isGenericToolName("Tool Call (failed)")).toBe(true); - expect(isGenericToolName("acp_tool")).toBe(true); - expect(isGenericToolName("tool")).toBe(true); - expect(isGenericToolName("")).toBe(true); - expect(isGenericToolName(undefined)).toBe(true); - }); - - it("keeps real names", () => { - expect(isGenericToolName("Terminal")).toBe(false); - expect(isGenericToolName("Read")).toBe(false); - expect(isGenericToolName("mcp__linear__search_issues")).toBe(false); - }); -}); - describe("mcpToolSegment", () => { it("extracts and capitalizes the tool segment", () => { expect(mcpToolSegment("mcp__linear-server__search_issues")).toBe("Search_issues"); diff --git a/ui/src/components/task-chat/tool-taxonomy.ts b/ui/src/components/task-chat/tool-taxonomy.ts index c9ee6579f75..f3746a0231d 100644 --- a/ui/src/components/task-chat/tool-taxonomy.ts +++ b/ui/src/components/task-chat/tool-taxonomy.ts @@ -7,24 +7,18 @@ */ import { BookOpen, - Brain, - ChevronsLeftRightEllipsis, - MessageSquareReply, - Network, - Search, - SearchCode, - Terminal, + Bot, + FileSearch, + Globe, + Pencil, + Plug, + SquareTerminal, Wrench, } from "lucide-react"; -import type { ComponentType, SVGProps } from "react"; -import { McpIcon } from "./McpIcon"; - -/** Lucide icons and hand-rolled SVGs (the MCP logo) share this shape. */ -export type ToolIcon = ComponentType>; +import type { LucideIcon } from "lucide-react"; export type ToolFamily = | "terminal" - | "grep" | "search" | "read" | "edit" @@ -35,27 +29,11 @@ export type ToolFamily = export interface ToolTaxonomyEntry { family: ToolFamily; - icon: ToolIcon; + icon: LucideIcon; /** Progressive verb for the status pill, without the trailing ellipsis. */ verbLabel: string; } -/** - * Placeholder names that carry no tool identity. acpx fills a tool_call_update - * whose ACP payload omits `title` with the literal "tool call", and older - * stored logs carry "tool call (completed)" / "acp_tool" variants — none of - * these should ever displace a real name like "Terminal" or "Read". - */ -const GENERIC_TOOL_NAMES = new Set(["tool", "tool call", "tool_call", "acp_tool"]); - -export function isGenericToolName(name: string | undefined | null): boolean { - const raw = (name ?? "") - .trim() - .toLowerCase() - .replace(/\s*\((?:pending|in_progress|completed|failed|cancelled)\)$/, ""); - return !raw || GENERIC_TOOL_NAMES.has(raw); -} - /** * Collapse an MCP tool name ("mcp__server__tool") to its tool segment, * capitalized — the same rule toolDisplayName() applies. Returns null for @@ -69,34 +47,22 @@ export function mcpToolSegment(name: string): string | null { } const FAMILY_META: Record, Omit> = { - terminal: { icon: Terminal, verbLabel: "Running a command" }, - grep: { icon: SearchCode, verbLabel: "Grepping" }, - search: { icon: Search, verbLabel: "Searching" }, + terminal: { icon: SquareTerminal, verbLabel: "Running a command" }, + search: { icon: FileSearch, verbLabel: "Searching" }, read: { icon: BookOpen, verbLabel: "Reading files" }, - // Board round-4 feedback asked for the terminal glyph on edits as well. - edit: { icon: Terminal, verbLabel: "Editing files" }, - web: { icon: ChevronsLeftRightEllipsis, verbLabel: "Fetching the web" }, - agent: { icon: Network, verbLabel: "Delegating" }, + edit: { icon: Pencil, verbLabel: "Editing files" }, + web: { icon: Globe, verbLabel: "Fetching the web" }, + agent: { icon: Bot, verbLabel: "Delegating" }, other: { icon: Wrench, verbLabel: "Working" }, }; function classify(n: string): Exclude { - // ACP titles are often multi-word ("Read File", "Edit File") — the first - // word carries the family. - const first = n.split(/[\s_:-]+/, 1)[0] ?? ""; - if (first === "read" || n === "notebookread") return "read"; - if (first === "edit" || first === "write" || n === "multiedit" || n === "notebookedit") { - return "edit"; - } - if (first === "bash" || first === "shell" || first === "run" || n.includes("terminal")) { - return "terminal"; - } - // Content grep is its own action (search-code glyph); file-name globbing and - // generic searches stay under the plain Search family. - if (first === "grep") return "grep"; - if (first === "glob" || n.includes("search")) return "search"; + if (n === "read" || n === "notebookread") return "read"; + if (n === "edit" || n === "write" || n === "multiedit" || n === "notebookedit") return "edit"; + if (n === "bash" || n === "shell" || n === "run" || n.includes("terminal")) return "terminal"; + if (n === "grep" || n === "glob" || n.includes("search")) return "search"; if (n.includes("fetch") || n.includes("web") || n.includes("http")) return "web"; - if (first === "task" || first === "agent") return "agent"; + if (n === "task" || n === "agent") return "agent"; return "other"; } @@ -105,19 +71,7 @@ export function toolTaxonomy(name: string | undefined | null): ToolTaxonomyEntry const raw = (name ?? "").trim(); if (!raw) return { family: "other", ...FAMILY_META.other }; const mcpTool = mcpToolSegment(raw); - if (mcpTool) return { family: "mcp", icon: McpIcon, verbLabel: `Using ${mcpTool}` }; + if (mcpTool) return { family: "mcp", icon: Plug, verbLabel: `Using ${mcpTool}` }; const family = classify(raw.toLowerCase()); return { family, ...FAMILY_META[family] }; } - -/** - * Icons for the tool-free informative statuses ("Thinking", "Responding", - * "Responding (streaming)"). Whimsified and generic labels get no glyph — the - * pulse dot alone carries those. - */ -export function statusLabelIcon(label: string | undefined | null): ToolIcon | null { - const raw = (label ?? "").trim().toLowerCase(); - if (raw === "thinking") return Brain; - if (raw === "responding" || raw.startsWith("responding (")) return MessageSquareReply; - return null; -} diff --git a/ui/src/components/task-chat/transcript-adapter.test.ts b/ui/src/components/task-chat/transcript-adapter.test.ts index 674656eb2d0..f26dbe28474 100644 --- a/ui/src/components/task-chat/transcript-adapter.test.ts +++ b/ui/src/components/task-chat/transcript-adapter.test.ts @@ -1,23 +1,6 @@ import { describe, expect, it } from "vitest"; import type { TranscriptEntry } from "@/adapters"; -import { - assembleThreadItems, - attachSettledTurns, - buildMergedTurnSummary, - buildTurnSummary, - coalesceSettledTurns, - deriveRunStatusLabel, - flattenSelfTalk, - isNestableLiveChild, - ISSUE_BRIEF_ITEM_ID, - prependIssueBrief, - settledRunChildren, - toolDisplayName, - transcriptToTaskChatItems, - type SettledTurnMergeMeta, - type ThreadBackboneEntry, -} from "./transcript-adapter"; -import type { TaskChatItem, TaskChatTurnItem } from "./task-chat-model"; +import { deriveRunStatusLabel, toolDisplayName } from "./transcript-adapter"; const TS = "2026-07-31T12:00:00.000Z"; @@ -28,7 +11,7 @@ function toolCall(name: string, input?: unknown): TranscriptEntry { describe("deriveRunStatusLabel", () => { it("labels a tail tool_call with the taxonomy verb and tool · target detail", () => { const status = deriveRunStatusLabel([toolCall("Grep", { pattern: "ui/src/components" })]); - expect(status.label).toBe("Grepping"); + expect(status.label).toBe("Searching"); expect(status.detail).toBe("Grep · ui/src/components"); expect(status.toolName).toBe("Grep"); }); @@ -75,620 +58,4 @@ describe("toolDisplayName", () => { expect(toolDisplayName("")).toBe("Tool"); expect(toolDisplayName("tool")).toBe("Tool"); }); - - it("maps acpx placeholder names to the generic Tool label", () => { - expect(toolDisplayName("tool call")).toBe("Tool"); - expect(toolDisplayName("tool call (completed)")).toBe("Tool"); - expect(toolDisplayName("acp_tool")).toBe("Tool"); - }); -}); - -describe("transcriptToTaskChatItems tool_call updates", () => { - const opts = { runId: "run-1", running: false }; - - function update(toolUseId: string, status: string): TranscriptEntry { - // Mirrors what a persisted acpx tool_call_update line parses to: the - // literal placeholder name plus a synthesized { text, status } input. - return { - kind: "tool_call", - ts: TS, - name: "tool call", - toolUseId, - input: { text: `tool call (${status})`, status }, - } as TranscriptEntry; - } - - it("keeps the initial real name and target when a generic update arrives", () => { - const items = transcriptToTaskChatItems( - [ - toolCall("Terminal", { command: "pnpm test" }), - update("tool-Terminal", "completed"), - { - kind: "tool_result", - ts: TS, - toolUseId: "tool-Terminal", - toolName: "tool call", - content: "ok", - } as TranscriptEntry, - ], - opts, - ); - expect(items).toHaveLength(1); - const tool = items[0]; - expect(tool.kind).toBe("tool"); - if (tool.kind !== "tool") return; - expect(tool.name).toBe("Terminal"); - expect(tool.rawName).toBe("Terminal"); - expect(tool.target).toBe("pnpm test"); - expect(tool.status).toBe("completed"); - }); - - it("keeps identity on retitle and uses the invocation as the target", () => { - // Real stored sequence: "Terminal" (pending) → retitle to the command → - // generic "tool call" completion updates. - const items = transcriptToTaskChatItems( - [ - { kind: "tool_call", ts: TS, name: "Terminal", toolUseId: "tc-2" } as TranscriptEntry, - { kind: "tool_call", ts: TS, name: "ls -la", toolUseId: "tc-2" } as TranscriptEntry, - update("tc-2", "completed"), - ], - opts, - ); - expect(items).toHaveLength(1); - if (items[0].kind !== "tool") return; - expect(items[0].name).toBe("Terminal"); - expect(items[0].rawName).toBe("Terminal"); - expect(items[0].target).toBe("ls -la"); - }); - - it("never renders the synthesized { text, status } input as a target", () => { - const items = transcriptToTaskChatItems([update("tc-orphan", "pending")], opts); - expect(items).toHaveLength(1); - if (items[0].kind !== "tool") return; - expect(items[0].target).toBeUndefined(); - }); - - it("upgrades a generic-named call when a later entry carries the real name", () => { - const items = transcriptToTaskChatItems( - [ - { kind: "tool_call", ts: TS, name: "tool call", toolUseId: "tc-1" } as TranscriptEntry, - { kind: "tool_call", ts: TS, name: "Read", toolUseId: "tc-1" } as TranscriptEntry, - ], - opts, - ); - expect(items).toHaveLength(1); - if (items[0].kind !== "tool") return; - expect(items[0].name).toBe("Read"); - }); -}); - -describe("buildTurnSummary tool counting", () => { - function statusEntry(toolUseId: string | undefined, status: string): TranscriptEntry { - return { - kind: "tool_call", - ts: TS, - name: "Bash", - toolUseId, - input: { text: `tool call (${status})`, status }, - } as TranscriptEntry; - } - - it("counts unique tool calls, not per-status transcript entries", () => { - // 4 real calls × 4 status changes each = 16 entries; the summary must - // match the 4 rows the expanded list renders. - const entries = ["tc-1", "tc-2", "tc-3", "tc-4"].flatMap((id) => - ["pending", "in_progress", "in_progress", "completed"].map((status) => - statusEntry(id, status), - ), - ); - expect(entries).toHaveLength(16); - expect(buildTurnSummary(entries).toolCount).toBe(4); - }); - - it("counts id-less legacy entries once each", () => { - const entries = [ - statusEntry(undefined, "completed"), - statusEntry(undefined, "completed"), - toolCall("Read"), - ]; - expect(buildTurnSummary(entries).toolCount).toBe(3); - }); -}); - -describe("deriveRunStatusLabel with generic tail updates", () => { - it("recovers the real tool name from the call's initial entry", () => { - const status = deriveRunStatusLabel([ - toolCall("Terminal", { command: "ls -la" }), - { - kind: "tool_call", - ts: TS, - name: "tool call", - toolUseId: "tool-Terminal", - } as TranscriptEntry, - ]); - expect(status.label).toBe("Running a command"); - expect(status.toolName).toBe("Terminal"); - }); - - it("treats a tail retitle as the invocation, not the identity", () => { - const status = deriveRunStatusLabel([ - { kind: "tool_call", ts: TS, name: "Terminal", toolUseId: "tc-3" } as TranscriptEntry, - { kind: "tool_call", ts: TS, name: "pnpm test", toolUseId: "tc-3" } as TranscriptEntry, - ]); - expect(status.label).toBe("Running a command"); - expect(status.toolName).toBe("Terminal"); - expect(status.detail).toBe("Terminal · pnpm test"); - }); -}); - -describe("isNestableLiveChild", () => { - const items: TaskChatItem[] = [ - { id: "t", kind: "tool", name: "Read", status: "completed" }, - { id: "th", kind: "thinking", lines: ["hm"] }, - { id: "u", kind: "usage", usage: { used: 1, size: 2 } }, - { id: "m", kind: "message", author: "agent", text: "finished self-talk", interstitial: true }, - { id: "mk", kind: "marker", variant: "session_start", label: "Session started" }, - { id: "s", kind: "status", status: "running", label: "Running" }, - { id: "i", kind: "interaction", interaction: {} as never }, - ]; - - it("nests only tool and usage rows inside the live parent row (PAP-361)", () => { - expect(items.filter(isNestableLiveChild).map((it) => it.kind)).toEqual([ - "tool", - "usage", - ]); - }); - - it("keeps messages, thinking, markers, statuses and interactions out", () => { - // Interstitial updates — finished or streaming — never nest: they are - // ephemeral, living on the live line only while streaming. Thinking never - // nests either (its live signal is the pill's "Thinking…" state), and the - // final reply keeps its bubble. - for (const kind of ["message", "thinking", "marker", "status", "interaction"]) { - const item = items.find((it) => it.kind === kind)!; - expect(isNestableLiveChild(item)).toBe(false); - } - expect( - isNestableLiveChild({ - id: "m2", - kind: "message", - author: "agent", - text: "typing…", - interstitial: true, - streaming: true, - }), - ).toBe(false); - expect( - isNestableLiveChild({ id: "m3", kind: "message", author: "agent", text: "Final reply." }), - ).toBe(false); - }); -}); - -describe("interstitial self-talk classification (PAP-355)", () => { - const stream: TranscriptEntry[] = [ - { kind: "assistant", ts: TS, text: "Let me check the adapter." } as TranscriptEntry, - toolCall("Read", { file_path: "a.ts" }), - { kind: "assistant", ts: "2026-07-31T12:00:05.000Z", text: "Found it — fixing now." } as TranscriptEntry, - ]; - - it("tags agent messages streamed inside a live run turn as interstitial", () => { - const messages = transcriptToTaskChatItems(stream, { runId: "run-1", running: true }).filter( - (it) => it.kind === "message", - ); - expect(messages).toHaveLength(2); - for (const m of messages) { - if (m.kind !== "message") continue; - expect(m.interstitial).toBe(true); - } - }); - - it("marks only the message still open at the tail as streaming", () => { - const items = transcriptToTaskChatItems(stream, { runId: "run-1", running: true }); - const messages = items.filter((it) => it.kind === "message"); - if (messages[0].kind !== "message" || messages[1].kind !== "message") return; - // The first message was closed by the tool call — finished self-talk. - expect(messages[0].streaming).toBe(false); - expect(messages[1].streaming).toBe(true); - }); - - it("marks no message streaming when the tail is a tool call", () => { - const items = transcriptToTaskChatItems( - [...stream, toolCall("Bash", { command: "pnpm test" })], - { runId: "run-1", running: true }, - ); - for (const m of items) { - if (m.kind === "message") expect(m.streaming).toBe(false); - } - }); - - it("tags settled-history messages interstitial too, so live and history agree", () => { - const messages = transcriptToTaskChatItems(stream, { runId: "run-1", running: false }).filter( - (it) => it.kind === "message", - ); - expect(messages).toHaveLength(2); - for (const m of messages) { - if (m.kind !== "message") continue; - expect(m.interstitial).toBe(true); - expect(m.streaming).toBe(false); - } - }); - - it("stamps atMs from the first streamed chunk", () => { - const messages = transcriptToTaskChatItems(stream, { runId: "run-1", running: false }).filter( - (it) => it.kind === "message", - ); - if (messages[0].kind !== "message" || messages[1].kind !== "message") return; - expect(messages[0].atMs).toBe(Date.parse(TS)); - expect(messages[1].atMs).toBe(Date.parse("2026-07-31T12:00:05.000Z")); - }); -}); - -describe("flattenSelfTalk", () => { - it("strips markdown markers to one plain line", () => { - expect( - flattenSelfTalk("## Plan\n\nI'll **extend** the `ipRateLimit` helper:\n- read it\n- patch it"), - ).toBe("Plan I'll extend the ipRateLimit helper: read it patch it"); - }); - - it("keeps link text, drops the url, and is stream-safe on unclosed markers", () => { - expect(flattenSelfTalk("see [the docs](https://x) for **bol")).toBe("see the docs for bol"); - expect(flattenSelfTalk("```ts\nconst a = 1;")).toBe("const a = 1;"); - }); -}); - -describe("deriveRunStatusLabel streaming interstitial text (PAP-361)", () => { - it("returns the trailing message's flattened text with the Responding label", () => { - const status = deriveRunStatusLabel([ - { kind: "assistant", ts: TS, text: "I'll **extend** " } as TranscriptEntry, - { kind: "assistant", ts: TS, text: "the `ipRateLimit` helper." } as TranscriptEntry, - ]); - expect(status.label).toBe("Responding"); - expect(status.selfTalk).toBe("I'll extend the ipRateLimit helper."); - expect(status.detail).toBeUndefined(); - }); - - it("only accumulates the trailing message, not one closed by a tool call", () => { - const status = deriveRunStatusLabel([ - { kind: "assistant", ts: TS, text: "Earlier note." } as TranscriptEntry, - toolCall("Read", { file_path: "a.ts" }), - { kind: "assistant", ts: TS, text: "Fresh line." } as TranscriptEntry, - ]); - expect(status.selfTalk).toBe("Fresh line."); - }); - - it("carries no selfTalk for tool or thinking tails", () => { - expect(deriveRunStatusLabel([toolCall("Read")]).selfTalk).toBeUndefined(); - expect( - deriveRunStatusLabel([{ kind: "thinking", ts: TS, text: "hm" } as TranscriptEntry]).selfTalk, - ).toBeUndefined(); - expect(deriveRunStatusLabel([]).selfTalk).toBeUndefined(); - }); -}); - -describe("settledRunChildren (PAP-361)", () => { - const transcript: TranscriptEntry[] = [ - { kind: "assistant", ts: TS, text: "Checking the adapter first." } as TranscriptEntry, - toolCall("Read", { file_path: "a.ts" }), - { kind: "thinking", ts: TS, text: "wire it in" } as TranscriptEntry, - toolCall("Edit", { file_path: "a.ts" }), - { kind: "assistant", ts: TS, text: "Done — the limiter is wired in." } as TranscriptEntry, - ]; - const parsed = transcriptToTaskChatItems(transcript, { runId: "run-1", running: false }); - - it("keeps exactly the tool rows — messages AND thinking are excluded", () => { - const children = settledRunChildren(parsed); - expect(children.map((c) => c.kind)).toEqual(["tool", "tool"]); - }); - - it("matches the folded summary's tool count exactly (row-count parity)", () => { - const children = settledRunChildren(parsed); - const summary = buildTurnSummary(transcript); - expect(children.filter((c) => c.kind === "tool")).toHaveLength(summary.toolCount); - }); -}); - -describe("buildMergedTurnSummary (PAP-362)", () => { - const runA: TranscriptEntry[] = [ - { kind: "tool_call", ts: "2026-07-31T12:00:00.000Z", name: "Read", toolUseId: "a-1" } as TranscriptEntry, - { kind: "tool_call", ts: "2026-07-31T12:00:05.000Z", name: "Edit", toolUseId: "a-2" } as TranscriptEntry, - { kind: "diff", ts: "2026-07-31T12:00:06.000Z", changeType: "add", text: "+x" } as TranscriptEntry, - { kind: "result", ts: "2026-07-31T12:00:10.000Z", inputTokens: 500, outputTokens: 300 } as TranscriptEntry, - ]; - const runB: TranscriptEntry[] = [ - { kind: "tool_call", ts: "2026-07-31T12:30:00.000Z", name: "Bash", toolUseId: "b-1" } as TranscriptEntry, - { kind: "result", ts: "2026-07-31T12:30:05.000Z", inputTokens: 300, outputTokens: 100 } as TranscriptEntry, - ]; - - it("sums tool count, diffs, tokens, and duration across runs", () => { - const summary = buildMergedTurnSummary([ - { entries: runA, durationMs: 30_000 }, - { entries: runB, durationMs: 15_000 }, - ]); - expect(summary.toolCount).toBe(3); - expect(summary.added).toBe(1); - expect(summary.tokensLabel).toBe("1.2k tokens"); - expect(summary.durationLabel).toBe("45s"); - expect(summary.failed).toBeUndefined(); - }); - - it("sums per-run ts spans — never the idle gap between runs", () => { - // runA spans 10s, runB spans 5s, the gap between them is ~30min. - const summary = buildMergedTurnSummary([{ entries: runA }, { entries: runB }]); - expect(summary.durationLabel).toBe("15s"); - }); - - it("marks the merged turn failed when any run failed", () => { - const summary = buildMergedTurnSummary([ - { entries: runA, durationMs: 1000 }, - { entries: runB, durationMs: 1000, failed: true }, - ]); - expect(summary.failed).toBe(true); - }); -}); - -describe("coalesceSettledTurns (PAP-362)", () => { - const runA: TranscriptEntry[] = [ - { kind: "tool_call", ts: TS, name: "Read", toolUseId: "a-1" } as TranscriptEntry, - { kind: "tool_call", ts: TS, name: "Edit", toolUseId: "a-2" } as TranscriptEntry, - ]; - const runB: TranscriptEntry[] = [ - { kind: "tool_call", ts: TS, name: "Bash", toolUseId: "b-1" } as TranscriptEntry, - ]; - - function agentBubble(id: string): TaskChatItem { - return { id, kind: "message", author: "agent", authorName: "CEO", text: "Done." }; - } - function turn(id: string, entries: TranscriptEntry[], durationMs: number): TaskChatItem { - return { - id, - kind: "turn", - settled: true, - items: entries.map((e, i) => ({ - id: `${id}:tool:${i}`, - kind: "tool" as const, - name: (e as { name: string }).name, - status: "completed" as const, - })), - summary: buildTurnSummary(entries, { durationMs }), - }; - } - const metaById = new Map([ - ["run-a:turn", { agentKey: "agent-1", agentName: "CEO", parts: [{ entries: runA, durationMs: 30_000 }] }], - ["run-b:turn", { agentKey: "agent-1", agentName: "CEO", parts: [{ entries: runB, durationMs: 15_000 }] }], - ]); - - it("merges two back-to-back runs into ONE Worked row summing tools and duration", () => { - // Two terminal runs, same agent, consecutive comments, no user message - // between → the merged turn sits below the LAST bubble. - const items: TaskChatItem[] = [ - agentBubble("msg-a"), - turn("run-a:turn", runA, 30_000), - agentBubble("msg-b"), - turn("run-b:turn", runB, 15_000), - ]; - const out = coalesceSettledTurns(items, metaById); - expect(out.map((i) => i.kind)).toEqual(["message", "message", "turn"]); - const merged = out[2] as Extract; - expect(merged.id).toBe("run-a:turn"); // stable: keeps the first run's id - expect(merged.summary.toolCount).toBe(3); - expect(merged.summary.durationLabel).toBe("45s"); - expect(merged.items.map((c) => c.id)).toEqual([ - "run-a:turn:tool:0", - "run-a:turn:tool:1", - "run-b:turn:tool:0", - ]); - }); - - it("keeps two rows when a user message sits between the runs", () => { - const items: TaskChatItem[] = [ - agentBubble("msg-a"), - turn("run-a:turn", runA, 30_000), - { id: "msg-u", kind: "message", author: "human", text: "also do X" }, - agentBubble("msg-b"), - turn("run-b:turn", runB, 15_000), - ]; - const out = coalesceSettledTurns(items, metaById); - expect(out.filter((i) => i.kind === "turn")).toHaveLength(2); - expect(out.map((i) => i.id)).toEqual(["msg-a", "run-a:turn", "msg-u", "msg-b", "run-b:turn"]); - }); - - it("does not merge across an interaction or into another agent's turn", () => { - const withInteraction: TaskChatItem[] = [ - turn("run-a:turn", runA, 30_000), - { id: "int-1", kind: "interaction", interaction: {} } as TaskChatItem, - turn("run-b:turn", runB, 15_000), - ]; - expect(coalesceSettledTurns(withInteraction, metaById).filter((i) => i.kind === "turn")).toHaveLength(2); - - const otherAgent = new Map(metaById); - otherAgent.set("run-b:turn", { agentKey: "agent-2", agentName: "QA", parts: [{ entries: runB }] }); - const sameShape: TaskChatItem[] = [turn("run-a:turn", runA, 30_000), turn("run-b:turn", runB, 15_000)]; - expect(coalesceSettledTurns(sameShape, otherAgent).filter((i) => i.kind === "turn")).toHaveLength(2); - }); - - it("never merges the live (unsettled) turn and preserves animateFold", () => { - const live: TaskChatItem = { id: "run-b:turn", kind: "turn", settled: false, items: [], summary: buildTurnSummary([]) }; - const out = coalesceSettledTurns([turn("run-a:turn", runA, 30_000), live], metaById); - expect(out).toHaveLength(2); - - const seenLive = { ...turn("run-b:turn", runB, 15_000), animateFold: true } as TaskChatItem; - const merged = coalesceSettledTurns([turn("run-a:turn", runA, 30_000), seenLive], metaById); - expect(merged).toHaveLength(1); - expect((merged[0] as Extract).animateFold).toBe(true); - }); -}); - -describe("attachSettledTurns (round 9)", () => { - const runA: TranscriptEntry[] = [ - { kind: "tool_call", ts: TS, name: "Read", toolUseId: "a-1" } as TranscriptEntry, - ]; - - function agentBubble(id: string, authorName = "CEO"): TaskChatItem { - return { id, kind: "message", author: "agent", authorName, text: "Done." }; - } - function settledTurn(id: string): TaskChatItem { - return { - id, - kind: "turn", - settled: true, - items: [{ id: `${id}:tool:0`, kind: "tool", name: "Read", status: "completed" }], - summary: buildTurnSummary(runA, { durationMs: 30_000 }), - }; - } - const metaById = new Map([ - ["run-a:turn", { agentKey: "agent-1", agentName: "CEO", parts: [{ entries: runA, durationMs: 30_000 }] }], - ]); - - it("folds a settled turn into the agent reply bubble directly above it", () => { - const out = attachSettledTurns([agentBubble("msg-a"), settledTurn("run-a:turn")], metaById); - expect(out).toHaveLength(1); - const bubble = out[0] as Extract; - expect(bubble.id).toBe("msg-a"); - expect(bubble.attachedTurn?.id).toBe("run-a:turn"); - expect(bubble.attachedTurn?.summary.durationLabel).toBe("30s"); - }); - - it("keeps the standalone row when the preceding item is not that agent's bubble", () => { - // Human message directly above. - const afterHuman = attachSettledTurns( - [{ id: "msg-u", kind: "message", author: "human", text: "hi" }, settledTurn("run-a:turn")], - metaById, - ); - expect(afterHuman.map((i) => i.kind)).toEqual(["message", "turn"]); - // Another agent's bubble above. - const otherAgent = attachSettledTurns([agentBubble("msg-q", "QA"), settledTurn("run-a:turn")], metaById); - expect(otherAgent.map((i) => i.kind)).toEqual(["message", "turn"]); - // No meta (unknown identity) → conservative standalone fallback. - const noMeta = attachSettledTurns([agentBubble("msg-a"), settledTurn("run-x:turn")], metaById); - expect(noMeta.map((i) => i.kind)).toEqual(["message", "turn"]); - // Nothing above at all. - const first = attachSettledTurns([settledTurn("run-a:turn")], metaById); - expect(first.map((i) => i.kind)).toEqual(["turn"]); - }); - - it("never attaches the live (unsettled) turn and attaches at most one turn per bubble", () => { - const live: TaskChatItem = { - id: "run-live:turn", - kind: "turn", - settled: false, - items: [], - summary: buildTurnSummary([]), - }; - const out = attachSettledTurns([agentBubble("msg-a"), live], metaById); - expect(out.map((i) => i.kind)).toEqual(["message", "turn"]); - - // A second settled turn behind an already-attached bubble stays standalone - // (coalesceSettledTurns runs first, so same-agent turns arrive pre-merged). - const twice = attachSettledTurns( - [agentBubble("msg-a"), settledTurn("run-a:turn"), settledTurn("run-a:turn")], - metaById, - ); - expect(twice.map((i) => i.kind)).toEqual(["message", "turn"]); - expect((twice[0] as Extract).attachedTurn?.id).toBe("run-a:turn"); - }); -}); - -describe("assembleThreadItems (PAP-367)", () => { - function entry(id: string, ms: number, item?: Partial): ThreadBackboneEntry { - return { - ms, - id, - item: { id, kind: "message", author: "human", text: `msg ${id}`, ...item } as TaskChatItem, - }; - } - function settledTurn(id: string): TaskChatTurnItem { - return { - id, - kind: "turn", - settled: true, - items: [{ id: `${id}:tool:0`, kind: "tool", name: "Read", status: "completed" }], - summary: buildTurnSummary([toolCall("Read")], { durationMs: 34_000 }), - }; - } - const noAnchors = new Map(); - - it("inserts a comment-less settled turn chronologically at its run's start time", () => { - const backbone = [entry("u1", 1_000), entry("a1", 2_000, { author: "agent", authorName: "CEO" }), entry("u2", 3_000)]; - const out = assembleThreadItems(backbone, noAnchors, [{ turn: settledTurn("run-x:turn"), startMs: 2_500 }]); - expect(out.map((i) => i.id)).toEqual(["u1", "a1", "run-x:turn", "u2"]); - }); - - it("does not append a comment-less turn after the newest user message (the P11 bug)", () => { - // The screenshot bug: an OLD stopped run (start 1_500) re-pinning its - // "Worked · 34s" row under every NEW user bubble (3_000). - const backbone = [entry("u1", 1_000), entry("u2", 3_000)]; - const out = assembleThreadItems(backbone, noAnchors, [{ turn: settledTurn("run-x:turn"), startMs: 1_500 }]); - expect(out.map((i) => i.id)).toEqual(["u1", "run-x:turn", "u2"]); - expect(out[out.length - 1].id).toBe("u2"); - }); - - it("keeps anchored turns directly after their run's reply comment", () => { - const backbone = [entry("u1", 1_000), entry("a1", 2_000, { author: "agent", authorName: "CEO" }), entry("u2", 3_000)]; - const anchors = new Map([["a1", [settledTurn("run-a:turn")]]]); - const out = assembleThreadItems(backbone, anchors, []); - expect(out.map((i) => i.id)).toEqual(["u1", "a1", "run-a:turn", "u2"]); - }); - - it("keeps the tail slot for a turn with unknown start, and puts ties after the entry", () => { - const backbone = [entry("u1", 1_000), entry("u2", 3_000)]; - // Unknown start (no linked meta yet — just-settled run): Infinity → tail. - const tail = assembleThreadItems(backbone, noAnchors, [ - { turn: settledTurn("run-x:turn"), startMs: Number.POSITIVE_INFINITY }, - ]); - expect(tail.map((i) => i.id)).toEqual(["u1", "u2", "run-x:turn"]); - // Tie with the trigger comment: comment renders first. - const tie = assembleThreadItems(backbone, noAnchors, [{ turn: settledTurn("run-y:turn"), startMs: 1_000 }]); - expect(tie.map((i) => i.id)).toEqual(["u1", "run-y:turn", "u2"]); - }); - - it("chronological insertion after a user message stays a standalone row through coalesce+attach", () => { - // Interplay guard: the inserted turn sits below a HUMAN bubble, so the - // later passes must neither merge it into anything nor attach it. - const backbone = [entry("u1", 1_000), entry("u2", 3_000)]; - const metaById = new Map([ - ["run-x:turn", { agentKey: "agent-1", agentName: "CEO", parts: [{ entries: [toolCall("Read")], durationMs: 34_000 }] }], - ]); - const assembled = assembleThreadItems(backbone, noAnchors, [{ turn: settledTurn("run-x:turn"), startMs: 1_500 }]); - const out = attachSettledTurns(coalesceSettledTurns(assembled, metaById), metaById); - expect(out.map((i) => i.id)).toEqual(["u1", "run-x:turn", "u2"]); - expect(out.every((i) => i.kind !== "message" || (i as Extract).attachedTurn == null)).toBe(true); - }); -}); - -describe("prependIssueBrief (PAP-375)", () => { - function entry(id: string, ms: number): ThreadBackboneEntry { - return { - ms, - id, - item: { id, kind: "message", author: "human", text: `msg ${id}` } as TaskChatItem, - }; - } - function settledTurn(id: string): TaskChatTurnItem { - return { - id, - kind: "turn", - settled: true, - items: [{ id: `${id}:tool:0`, kind: "tool", name: "Read", status: "completed" }], - summary: buildTurnSummary([toolCall("Read")], { durationMs: 34_000 }), - }; - } - - it("keeps the description bubble above an unanchored turn that predates every backbone entry (F15)", () => { - // A stopped run whose startMs (0) is EARLIER than the oldest comment: it - // sorts to the very top of the assembled thread — the brief must still - // render above it, because the prepend runs after assembly. - const backbone = [entry("u1", 1_000), entry("u2", 3_000)]; - const assembled = assembleThreadItems(backbone, new Map(), [ - { turn: settledTurn("run-x:turn"), startMs: 0 }, - ]); - const out = prependIssueBrief(assembled, true); - expect(out.map((i) => i.id)).toEqual([ISSUE_BRIEF_ITEM_ID, "run-x:turn", "u1", "u2"]); - expect(out[0].kind).toBe("brief"); - }); - - it("is the first item of an otherwise empty thread, and a no-op without a brief", () => { - expect(prependIssueBrief([], true).map((i) => i.kind)).toEqual(["brief"]); - const items = [entry("u1", 1_000).item]; - expect(prependIssueBrief(items, false)).toBe(items); - }); }); diff --git a/ui/src/components/task-chat/transcript-adapter.ts b/ui/src/components/task-chat/transcript-adapter.ts index 9e35e8e6496..1480e15ff1b 100644 --- a/ui/src/components/task-chat/transcript-adapter.ts +++ b/ui/src/components/task-chat/transcript-adapter.ts @@ -12,6 +12,7 @@ import type { TaskChatToolItem, TaskChatTurnItem, } from "./task-chat-model"; +import { mcpToolSegment, toolTaxonomy } from "./tool-taxonomy"; const TERMINAL_STATUSES = new Set([ "failed", @@ -82,9 +83,9 @@ export function summarizeToolInput(input: unknown): string | undefined { export function toolDisplayName(name: string | undefined | null): string { const raw = (name ?? "").trim(); if (!raw || raw === "tool") return "Tool"; - const mcp = raw.match(/^mcp__[^_]+(?:_[^_]+)*__(.+)$/); - const base = mcp?.[1] ?? raw; - return base.charAt(0).toUpperCase() + base.slice(1); + const mcp = mcpToolSegment(raw); + if (mcp) return mcp; + return raw.charAt(0).toUpperCase() + raw.slice(1); } /** "Thought for Ns" once a coalesced thinking group spans ≥1s. */ @@ -190,6 +191,7 @@ export function transcriptToTaskChatItems( id: `${runId}:tool:${toolCallId}`, kind: "tool", name: toolDisplayName(entry.name), + rawName: entry.name ?? undefined, target: summarizeToolInput(entry.input), status: "in_progress", }; @@ -213,6 +215,7 @@ export function transcriptToTaskChatItems( existing.status = entry.isError ? "failed" : "completed"; if (existing.name === "Tool" && entry.toolName) { existing.name = toolDisplayName(entry.toolName); + existing.rawName = entry.toolName; } if (!existing.detail) { const detail = formatToolResultDetail(entry.content); @@ -314,15 +317,26 @@ export function buildTurnSummary( }; } -/** Human-readable label for the live status pill from the tail of a transcript. */ +/** + * Human-readable label for the live status pill from the tail of a transcript. + * A tail tool_call yields the taxonomy verb ("Searching", "Running a command") + * with tool + target as detail; `toolName` lets the pill show the family icon. + */ export function deriveRunStatusLabel(entries: readonly TranscriptEntry[]): { label: string; detail?: string; + toolName?: string; } { for (let i = entries.length - 1; i >= 0; i--) { const entry = entries[i]; if (entry.kind === "tool_call") { - return { label: "Working", detail: toolDisplayName(entry.name) }; + const target = summarizeToolInput(entry.input); + const display = toolDisplayName(entry.name); + return { + label: toolTaxonomy(entry.name).verbLabel, + detail: target ? `${display} · ${target}` : display, + toolName: entry.name ?? undefined, + }; } if (entry.kind === "tool_result") break; if (entry.kind === "assistant") return { label: "Responding" };