feat(ui): task-chat tool taxonomy — pill verbs/icons, Shift+Enter to send, baseline-aligned elapsed

- 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 <noreply@anthropic.com>
This commit is contained in:
scotttongandClaude Fable 5 committed 2026-08-07 16:55:54 -07:00
1 parent 67e0713a2a
commit ced262a0da
11 files changed
+111 -812

No files matched your search

+2 -1
View File
@@ -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,
});
}
@@ -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(<TaskChatComposer onAdd={onAdd} workMode="standard" />);
@@ -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(<TaskChatComposer onAdd={onAdd} workMode="standard" />);
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);
@@ -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"
@@ -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 (
<div className="tc-enter-status flex items-center gap-2 py-0.5 text-xs text-muted-foreground">
<span
aria-hidden
className="h-2 w-2 shrink-0 animate-pulse rounded-full bg-(--status-agent-running)"
/>
<span className="shimmer-text shimmer-text-muted font-medium">{item.label}…</span>
{liveElapsed ? (
<span className="shrink-0 font-mono tabular-nums text-(length:--text-micro)">
{liveElapsed}
</span>
) : null}
{item.detail ? (
<span className="min-w-0 truncate font-mono text-(length:--text-micro)">{item.detail}</span>
) : null}
{item.tokens ? (
<span className="ml-auto shrink-0 font-mono text-(length:--text-micro)">
{item.tokens.used.toLocaleString()}/{item.tokens.size.toLocaleString()} ctx
</span>
) : null}
{ToolIcon ? <ToolIcon className="h-3.5 w-3.5 shrink-0" aria-hidden /> : null}
{/* Text cells share a baseline row so the smaller mono readouts don't
float above the label's baseline. */}
<span className="flex min-w-0 flex-1 items-baseline gap-2">
<span className="shimmer-text shimmer-text-muted shrink-0 font-medium">{item.label}…</span>
{liveElapsed ? (
<span className="shrink-0 font-mono tabular-nums text-(length:--text-micro)">
{liveElapsed}
</span>
) : null}
{item.detail ? (
<span className="min-w-0 truncate font-mono text-(length:--text-micro)">{item.detail}</span>
) : null}
{item.tokens ? (
<span className="ml-auto shrink-0 font-mono text-(length:--text-micro)">
{item.tokens.used.toLocaleString()}/{item.tokens.size.toLocaleString()} ctx
</span>
) : null}
</span>
</div>
);
}
@@ -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);
@@ -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":
@@ -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;
@@ -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");
+19 -65
View File
@@ -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<SVGProps<SVGSVGElement>>;
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<Exclude<ToolFamily, "mcp">, Omit<ToolTaxonomyEntry, "family">> = {
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<ToolFamily, "mcp"> {
// 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;
}
@@ -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<string, SettledTurnMergeMeta>([
["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<TaskChatItem, { kind: "turn" }>;
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<TaskChatItem, { kind: "turn" }>).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<string, SettledTurnMergeMeta>([
["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<TaskChatItem, { kind: "message" }>;
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<TaskChatItem, { kind: "message" }>).attachedTurn?.id).toBe("run-a:turn");
});
});
describe("assembleThreadItems (PAP-367)", () => {
function entry(id: string, ms: number, item?: Partial<TaskChatItem>): 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<string, TaskChatTurnItem[]>();
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<string, SettledTurnMergeMeta>([
["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<TaskChatItem, { kind: "message" }>).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);
});
});
@@ -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" };