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" };