From 1db8507f4dbf86c7df4082ee68a2e830e289a527 Mon Sep 17 00:00:00 2001 From: Hako Date: Mon, 5 Oct 2026 14:15:10 +0900 Subject: [PATCH 1/6] feat(chat): a tool row reads as verb + object, and live narration is answer prose A work row was caret, per-tool icon, tool id, "/" and summary, indented under the block's header. It is now caret + verb + object ("Read src/metrics.ts", "Ran pnpm test"): the verb in the interface face, the object in mono, on the same left edge as the header and the prose. The caret is always shown. - lib/toolVerbs.ts picks the verb by exact tool id (Read, Edited, Wrote, Ran) for the file and shell tools of Claude, Codex, pi, omp, gjc and omo. A Codex exec that applies a patch is an edit. Any other tool, or a call with no object, keeps its id in mono as before. - The tool's own id is the row's title and the first line of its opened detail, so touch can read it. - While a turn runs, the narration between rows is --text at the prose size on the prose edge; a settled block keeps the dim, smaller style. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 8 +++++ DESIGN.md | 16 ++++++++-- scripts/chat-browser-qa.ts | 8 ++++- src/AGENTS.md | 2 +- src/components/ChatView.css | 48 ++++++++++++++++++++-------- src/components/ChatView.tsx | 39 +++++++++------------- src/lib/i18n.ja.ts | 4 +++ src/lib/i18n.ko.ts | 4 +++ src/lib/i18n.test.ts | 1 + src/lib/i18n.zh.ts | 4 +++ src/lib/toolVerbs.test.ts | 64 +++++++++++++++++++++++++++++++++++++ src/lib/toolVerbs.ts | 44 +++++++++++++++++++++++++ 12 files changed, 199 insertions(+), 43 deletions(-) create mode 100644 src/lib/toolVerbs.test.ts create mode 100644 src/lib/toolVerbs.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index d89cc1d736..5c53e59bd3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,14 @@ between releases do not reach them. Remote-PC runtime bundles are versioned sepa ## [Unreleased] +### Changed +- In chat, a tool row reads as what the agent did: "Read src/metrics.ts", "Edited + src/pages/Reports.tsx", "Ran pnpm test", in place of an icon, the tool's id and a slash. The + rows start on the same left edge as the block's header and the prose. The tool's own id + (`exec`, `Bash`, `apply_patch`) is the row's tooltip and the first line of the row once + opened; a tool without a verb keeps its id in the row. While the agent works, what it says + between tool calls is in the answer's size and colour instead of small and dim. + ### Fixed - In one tab of the app, the alert sound chimes once for alerts that come together from several panes, instead of sounding over itself. A pane that needs input right after one that finished diff --git a/DESIGN.md b/DESIGN.md index 4a053b4d9d..d261a6ee9d 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -459,9 +459,19 @@ One set for both themes: the card is island black wherever it shows. ### Work block (`.work-block`, `.work-row`) - One per assistant turn: a `▸ Worked for 7s · 1 edit · 2 commands` header (duration = next turn's timestamp minus this one's; "Working…" in `--status-working` behind a breathing dot while the agent runs) over - one-line rows `▸ [icon] name / summary` in mono, indented under the header; mid-work narration - sits between rows as dim, one-step-smaller prose. The newest turn opens by default, older ones - fold. A row expands to the typed input (command, diff, file, checklist, raw) and an Output pane. + one-line rows on the header's own left edge, which is the prose edge too (the hover plate + overhangs it by `--space-1`). The newest turn opens by default, older ones fold. +- A row is caret + verb + object: `▸ Read src/metrics.ts`, `▸ Edited src/pages/Reports.tsx`, + `▸ Ran pnpm test`. The caret is always shown; there is no per-tool icon and no separator. The + verb (Read, Edited, Wrote, Ran; `lib/toolVerbs.ts`, by exact tool id) is in the interface face, + `--fs-sm`, `--text-dim`; the object is mono `--fs-xs`. A tool the table does not know, or a call + with no object, keeps its id in mono `--text` in the verb's place. A failed row turns the verb or + id `--status-blocked` and adds the word "failed". +- A row expands to the typed input (command, diff, file, checklist, raw) and an Output pane. Under + a verb the tool's own id (`exec`, `Bash`, `apply_patch`) is the first line of that detail + (`.work-row-tool`, mono, dim) and the row's title. +- Mid-work narration sits between rows. While the turn runs it is the agent's voice: answer prose + (`--text`, `--fs-md`) on the prose edge. Once the turn settles it is dim, one step smaller. ### Prompt card (`.prompt-card`) - Appears in chat while the agent is blocked and the visible pane contains a supported Claude, omp, diff --git a/scripts/chat-browser-qa.ts b/scripts/chat-browser-qa.ts index d0f96524f7..25e1a959a7 100644 --- a/scripts/chat-browser-qa.ts +++ b/scripts/chat-browser-qa.ts @@ -64,7 +64,13 @@ try { const work = log.locator(".work-block-head"); assert.equal(await work.getAttribute("aria-expanded"), "true"); assert.match(await work.innerText(), /Worked for 7s/); - await log.getByRole("button", { name: /exec_command/ }).click(); + // a tool the verb table knows reads as verb + object; its id is the title and the detail's first line + const row = log.getByRole("button", { name: "Ran git status", exact: true }); + assert.equal(await row.getAttribute("title"), "exec_command · git status"); + assert.equal(await row.locator(".work-row-caret").isVisible(), true); + assert.equal(await row.locator(".work-row-icon, .work-row-sep").count(), 0); + await row.click(); + assert.equal(await log.locator(".work-row-detail > :first-child").innerText(), "exec_command"); await log.getByText("git status", { exact: true }).last().waitFor(); assert.equal(await log.locator(".chat-agent-meta").count(), 1); console.log("PASS native user/answer rendering, internal context filtering, tool expansion, duration"); diff --git a/src/AGENTS.md b/src/AGENTS.md index 44012cbdd1..07f737be7d 100644 --- a/src/AGENTS.md +++ b/src/AGENTS.md @@ -32,7 +32,7 @@ React 18 + xterm.js browser client. The render-state machine, xterm lifecycle an | Key bar | components/KeyBar.tsx + lib/keys.ts | taps call `term.input(keySequence(key, term.modes.applicationCursorKeysMode))`, reusing the onData → WS path; one-shot Ctrl is armed in PaneTerminal; hidden while observing | | Chat composer | components/Composer.tsx + lib/compose.ts + lib/mentions.ts | Enter or Mod+Enter sends per `settings.enterSends`, IME Enter never sends; `/` slash commands are cached 60s per pane, `@` file search is debounced 150ms; Stop is Escape into the pty via `onAbort`; Queue when a send would land mid-run; disabled (text held, never queued) while disconnected | | Session panel | components/SessionPanel.tsx + lib/activity.ts | derived client-side from the turns ChatView already has (`onActivity`); task state comes from the tool part's `task`; open state lives in App (`herdr-web-ui:session-panel`); phones (≤640px) show neither the panel nor its strip | -| Chat lens | components/ChatView.tsx + Markdown.tsx (lib/markdown.ts) + lib/workBlocks.ts + PromptCard.tsx | `splitTurn` folds tools/thinking/narration into one `.work-block` and honors explicit Codex commentary/final phases; duration = `end_ts − ts`; conversation polls run serially, a send refresh preserves history/scroll, a pane change resets; PromptCard polls `/api/pane/prompt` every 2s while blocked and answers through `/api/pane/prompt/answer` (409 → re-read) | +| Chat lens | components/ChatView.tsx + Markdown.tsx (lib/markdown.ts) + lib/workBlocks.ts + lib/toolVerbs.ts + PromptCard.tsx | `splitTurn` folds tools/thinking/narration into one `.work-block` and honors explicit Codex commentary/final phases; a row is verb + object by exact tool id (`toolVerb`), the id kept in its title and opened detail; duration = `end_ts − ts`; conversation polls run serially, a send refresh preserves history/scroll, a pane change resets; PromptCard polls `/api/pane/prompt` every 2s while blocked and answers through `/api/pane/prompt/answer` (409 → re-read) | | Sidebar roster | components/Sidebar.tsx + TabStrip.tsx | rendered only inside `MachineSidebar`, one per PC; grouping is a setting (`SidebarGrouping`, by workspace or by folder); a row shows the roll-up of its panes' states (`rollupStatus`, lib/status.ts); workspace reorder is `workspace.move`, optimistic; other panes of a workspace are reached from the TabStrip, the palette and Needs you; no search or filter here on purpose, the command palette is the search; Add PC lives in Settings → Remote PCs and the palette (`actions.openAddPc`) | | New workspace / New tab | components/NewSessionDialog.tsx | `createWorkspace`, or `createTab` (POST /api/tab/create) when given a `tab` prop; agent start may take 60s and the dialog stays open while pending; `agent_started:false` + error offers to open the already-created workspace | | Command palette | components/CommandPalette.tsx + lib/paletteSearch.ts | Mod+Shift+K; panes (recent first, fuzzy ranked) + actions with `.kbd` hints; Escape closes | diff --git a/src/components/ChatView.css b/src/components/ChatView.css index 0a2b8d0ce4..d6e70c20e8 100644 --- a/src/components/ChatView.css +++ b/src/components/ChatView.css @@ -349,6 +349,13 @@ cursor: pointer; } +/* the header, its rows and the prose around them start on one line: the hover plate overhangs it */ +.work-block-head, +.work-row-head { + width: calc(100% + 2 * var(--space-1)); + margin-inline: calc(-1 * var(--space-1)); +} + .work-block-head:hover, .work-row-head:hover { background: var(--bg-hover); @@ -397,11 +404,10 @@ white-space: nowrap; } -/* rows hang under the header, indented past its caret */ +/* rows hang under the header on its own left edge */ .work-block-rows { display: flex; flex-direction: column; - padding-left: var(--space-5); } .work-row { @@ -415,20 +421,17 @@ font-size: var(--fs-xs); } -.work-row-icon { - flex: none; - width: var(--fs-sm); - height: var(--fs-sm); -} - .work-row-name { flex: none; color: var(--text); font-weight: var(--fw-medium); } -.work-row-sep { - flex: none; +/* a tool the verb table knows: "Ran" in the interface face, its object after it in mono */ +.work-row-name.is-verb { + color: var(--text-dim); + font-family: var(--font-ui); + font-size: var(--fs-sm); } .work-row-summary { @@ -448,9 +451,9 @@ color: var(--text-dim); } -/* what the agent said mid-work: prose, but dim and one step smaller than the answer */ +/* what the agent said mid-work: prose, dim and one step smaller than the answer once the turn settles */ .work-narration { - padding: var(--space-1) var(--space-1) var(--space-2); + padding: var(--space-1) 0 var(--space-2); color: var(--text-dim); } @@ -458,6 +461,24 @@ font-size: var(--fs-sm); } +/* while the agent runs it is its voice: answer prose, on the prose edge */ +.work-block.is-live .work-narration { + color: var(--text); +} + +.work-block.is-live .work-narration .markdown { + font-size: var(--fs-md); +} + +/* the tool's own id, first in the opened detail of a row that shows a verb in its place */ +.work-row-tool { + margin: 0; + color: var(--text-dim); + font-family: var(--font-mono); + font-size: var(--fs-xs); + overflow-wrap: anywhere; +} + .work-row-detail { display: flex; flex-direction: column; @@ -727,8 +748,7 @@ } /* a call that failed: its name in the alert colour and a word that says so, even folded */ -.work-row.is-error .work-row-name, -.work-row.is-error .work-row-icon { +.work-row.is-error .work-row-name { color: var(--status-blocked); } diff --git a/src/components/ChatView.tsx b/src/components/ChatView.tsx index 12d92fdd2d..8b42eeae8b 100644 --- a/src/components/ChatView.tsx +++ b/src/components/ChatView.tsx @@ -1,6 +1,6 @@ import { createContext, memo, useCallback, useContext, useEffect, useLayoutEffect, useMemo, useRef, useState, type ComponentType } from "react"; import { - ArrowDown, BookOpen, Bot, Brain, Check, ChevronDown, ChevronRight, Circle, CircleAlert, CircleCheck, CircleDot, CircleSlash, Copy, FilePen, FileSearch, Globe, ListChecks, Target, Terminal, Wrench, + ArrowDown, BookOpen, Check, ChevronDown, ChevronRight, Circle, CircleAlert, CircleCheck, CircleDot, CircleSlash, Copy, Target, type LucideProps, } from "lucide-react"; @@ -26,6 +26,7 @@ import { usePageVisible } from "../lib/visibility.ts"; import { dismissKeyboardOn } from "../lib/keyboard.ts"; import { OpenFileContext } from "../lib/filePaths.ts"; import { patchText } from "../../shared/patch.ts"; +import { toolVerb } from "../lib/toolVerbs.ts"; import { machinePath } from "../../shared/machines.ts"; import { fileUrl } from "../lib/api.ts"; import { useMachineId } from "../lib/machineContext.tsx"; @@ -223,19 +224,6 @@ function ToolInputView({ part }: { part: ToolPartType }) { return
{part.input}
; } -function toolIcon(name: string): ComponentType { - const normalized = name.toLowerCase(); - if (normalized === "skill") return BookOpen; - if (normalized.includes("bash") || normalized.includes("command")) return Terminal; - if (["read", "glob", "grep"].some((item) => normalized.includes(item))) return FileSearch; - if (normalized.includes("edit") || normalized.includes("write")) return FilePen; - if (normalized.includes("task") || normalized.includes("agent")) return Bot; - if (normalized.includes("web")) return Globe; - if (normalized.includes("todo")) return ListChecks; - if (normalized.endsWith("_goal")) return Target; - return Wrench; -} - /** A cut output's rest, fetched when asked for: the page carries the first few thousand characters. */ function useWholeOutput(ref: string | undefined): { text: string | null; state: "idle" | "loading" | "failed"; load: () => void } { const paneId = useContext(ChatPaneContext); @@ -256,24 +244,28 @@ function ToolImages({ paneId, part }: { paneId: string; part: ToolPartType }) { })}; } -/** One row of a work block: `▸ name summary`, expanding to the call's input and output. */ +/** + * One row of a work block: `▸ Ran bun test`, expanding to the call's input and output. A tool the + * verb table knows reads as verb + object, and its id moves to the title and the opened detail; + * any other keeps its id in front, as the agent names it. + */ function WorkRow({ paneId, part }: { paneId: string; part: ToolPartType }) { const t = useT(); const [open, setOpen] = useState(false); const whole = useWholeOutput(part.output_ref); - const Icon = toolIcon(part.name); const summary = todoCallSummary(part) ?? part.summary; + const verb = toolVerb(part, summary); // the list is the answer of a todo call: its raw text would say it twice const output = isTodoTool(part.name) && parseTodoAnswer(part.output) !== null ? "" : whole.text ?? part.output; return
- - {open &&
{output.length > 0 &&

{t(part.error ? "Error" : "Output")}

{output}
+ {open &&
{verb !== null &&

{part.name}

}{output.length > 0 &&

{t(part.error ? "Error" : "Output")}

{output}
{part.output_ref !== undefined && whole.text === null && }
}
} @@ -286,7 +278,6 @@ function ThinkingRow({ text }: { text: string }) { return
{open &&
{text}
} @@ -296,7 +287,7 @@ function ThinkingRow({ text }: { text: string }) { /** * Everything the agent did on the way — tool calls, reasoning and the narration * between them — under one header ("Worked for 7s · 1 edit"). Rows stay one line - * each until opened; the narration reads as dim prose between them. + * each until opened; the narration between them is answer prose while the turn runs, dim after. */ function WorkBlockView({ paneId, parts, duration, live, defaultOpen, showThinking }: { paneId: string; parts: ConversationPart[]; duration: string | null; live: boolean; defaultOpen: boolean; showThinking: boolean }) { const t = useT(); diff --git a/src/lib/i18n.ja.ts b/src/lib/i18n.ja.ts index 2284e2dbbb..fb116560b5 100644 --- a/src/lib/i18n.ja.ts +++ b/src/lib/i18n.ja.ts @@ -457,6 +457,10 @@ export const JA: Record = { "{n} commands": "コマンド {n} 件", "{n} other tool": "その他のツール {n} 件", "{n} other tools": "その他のツール {n} 件", + "Read": "読み取り", + "Edited": "編集", + "Wrote": "書き込み", + "Ran": "実行", "{s}s": "{s}秒", "{m}m": "{m}分", "{m}m {s}s": "{m}分 {s}秒", diff --git a/src/lib/i18n.ko.ts b/src/lib/i18n.ko.ts index d04d94e779..02c96d6646 100644 --- a/src/lib/i18n.ko.ts +++ b/src/lib/i18n.ko.ts @@ -455,6 +455,10 @@ export const KO: Record = { "{n} commands": "명령 {n}개", "{n} other tool": "기타 도구 {n}개", "{n} other tools": "기타 도구 {n}개", + "Read": "읽음", + "Edited": "편집함", + "Wrote": "작성함", + "Ran": "실행함", "{s}s": "{s}초", "{m}m": "{m}분", "{m}m {s}s": "{m}분 {s}초", diff --git a/src/lib/i18n.test.ts b/src/lib/i18n.test.ts index d8caabad31..ab0ceda3ab 100644 --- a/src/lib/i18n.test.ts +++ b/src/lib/i18n.test.ts @@ -71,6 +71,7 @@ function labelMapKeys(): string[] { ...grab("lib/updateProgress.ts", "STEPS", /label: "([^"]+)"/g), ...grab("lib/workBlocks.ts", "CATEGORY_LABEL", /\["([^"]+)", "([^"]+)"\]/g), ...grab("lib/usage.ts", "WINDOW_LABEL", value), + ...grab("lib/toolVerbs.ts", "VERB_LABEL", value), ]; } diff --git a/src/lib/i18n.zh.ts b/src/lib/i18n.zh.ts index 05828ed992..8e5cc017b5 100644 --- a/src/lib/i18n.zh.ts +++ b/src/lib/i18n.zh.ts @@ -459,6 +459,10 @@ export const ZH: Record = { "{n} commands": "{n} 条命令", "{n} other tool": "{n} 个其他工具", "{n} other tools": "{n} 个其他工具", + "Read": "已读取", + "Edited": "已编辑", + "Wrote": "已写入", + "Ran": "已运行", "{s}s": "{s} 秒", "{m}m": "{m} 分钟", "{m}m {s}s": "{m} 分 {s} 秒", diff --git a/src/lib/toolVerbs.test.ts b/src/lib/toolVerbs.test.ts new file mode 100644 index 0000000000..1d28c69c52 --- /dev/null +++ b/src/lib/toolVerbs.test.ts @@ -0,0 +1,64 @@ +import { describe, expect, it } from "bun:test"; + +import { toolVerb, toolVerbKind } from "./toolVerbs.ts"; + +describe("toolVerbKind", () => { + it("reads every agent's file and shell tools as one of four verbs", () => { + const expected: Record = { + // Claude Code + Read: "read", Edit: "edit", MultiEdit: "edit", NotebookEdit: "edit", Write: "write", Bash: "run", + // Codex + exec: "run", exec_command: "run", shell: "run", shell_command: "run", local_shell: "run", apply_patch: "edit", + // pi, omp, gjc and omo name theirs in lowercase + read: "read", edit: "edit", multiedit: "edit", patch: "edit", write: "write", bash: "run", + }; + for (const [name, kind] of Object.entries(expected)) expect([name, toolVerbKind(name)]).toEqual([name, kind as ReturnType]); + }); + + it("leaves every other tool the chat knows under its own name", () => { + const untouched = [ + // counted as a file read or a command in the header, but not a plain read or run + "Grep", "grep", "Glob", "glob", "LS", "ls", "find", "list", "search", "eval", "run_command", + // tools with their own row or panel + "Skill", "Task", "Agent", "task", "WebFetch", "WebSearch", "webfetch", "web_search", + "TodoWrite", "update_plan", "todo", "todo_write", "mcp__omo__todo", + "create_goal", "update_goal", "get_goal", "request_user_input_async", + // a name that only contains a known one + "mcp__files__read_all", "read_mcp_resource", "BashOutput", "write_stdin", "tool", + ]; + for (const name of untouched) expect([name, toolVerbKind(name)]).toEqual([name, null]); + }); + + it("calls a Codex exec that applies a patch an edit", () => { + const script = 'const r = await tools.apply_patch("*** Begin Patch\\n*** Update File: src/a.ts\\n@@\\n-a\\n+b\\n*** End Patch");'; + expect(toolVerbKind("exec", script)).toBe("edit"); + expect(toolVerbKind("exec", '{"cmd":"bun test"}')).toBe("run"); + // only a shell tool is re-read this way + expect(toolVerbKind("Read", "*** Begin Patch\n*** End Patch")).toBe("read"); + expect(toolVerbKind("lookup", "*** Begin Patch\n*** End Patch")).toBeNull(); + }); +}); + +describe("toolVerb", () => { + const part = (name: string, input = "{}") => ({ name, input }); + + it("is the verb a row shows before its object", () => { + expect(toolVerb(part("Read"), "src/metrics.ts")).toBe("Read"); + expect(toolVerb(part("Edit"), "src/pages/Reports.tsx")).toBe("Edited"); + expect(toolVerb(part("apply_patch"), "src/pages/Reports.tsx")).toBe("Edited"); + expect(toolVerb(part("Write"), "notes.md")).toBe("Wrote"); + expect(toolVerb(part("exec"), "pnpm test")).toBe("Ran"); + expect(toolVerb(part("bash"), "pnpm test")).toBe("Ran"); + }); + + it("keeps the tool id when there is nothing to put after the verb", () => { + expect(toolVerb(part("Bash"), "")).toBeNull(); + // a call with no command or path is summed up by its own name + expect(toolVerb(part("Bash"), "Bash")).toBeNull(); + }); + + it("keeps the id of a tool it does not know", () => { + expect(toolVerb(part("Grep"), "TODO")).toBeNull(); + expect(toolVerb(part("mcp__linear__create_issue"), "Fix the chart")).toBeNull(); + }); +}); diff --git a/src/lib/toolVerbs.ts b/src/lib/toolVerbs.ts new file mode 100644 index 0000000000..462a4076b7 --- /dev/null +++ b/src/lib/toolVerbs.ts @@ -0,0 +1,44 @@ +import { patchText } from "../../shared/patch.ts"; + +export type ToolVerbKind = "read" | "edit" | "write" | "run"; + +/** What a work row says the call did, before its object: "Read src/a.ts", "Ran bun test". */ +const VERB_LABEL: Record = { + read: "Read", + edit: "Edited", + write: "Wrote", + run: "Ran", +}; + +/** + * The tool ids a verb stands for, by exact name (case aside): Claude's Read / Edit / MultiEdit / + * NotebookEdit / Write / Bash, Codex's exec / exec_command / shell / local_shell / apply_patch, + * and the lowercase read / edit / write / bash / patch of pi, omp, gjc and omo. Anything else + * (Grep, a web or task tool, a todo or goal call, an MCP tool) keeps its own name: a guess by + * substring would call `mcp__files__read_all` a read. + */ +const VERB_OF: Record = { + read: "read", + edit: "edit", multiedit: "edit", notebookedit: "edit", apply_patch: "edit", patch: "edit", + write: "write", + bash: "run", exec: "run", exec_command: "run", shell: "run", shell_command: "run", local_shell: "run", +}; + +/** The kind of verb a tool id reads as, or null for a tool the table does not know. */ +export function toolVerbKind(name: string, input = ""): ToolVerbKind | null { + const key = name.toLowerCase(); + if (!Object.hasOwn(VERB_OF, key)) return null; + const kind = VERB_OF[key]!; + // Codex applies a patch from inside an exec script: the row names the files, so it is an edit + return kind === "run" && patchText(input) !== null ? "edit" : kind; +} + +/** + * The English verb (an i18n key) a row shows in place of the tool id, or null when the id stays: + * an unknown tool, or a call with no object to follow the verb. + */ +export function toolVerb(part: { name: string; input: string }, object: string): string | null { + if (object.length === 0 || object === part.name) return null; + const kind = toolVerbKind(part.name, part.input); + return kind === null ? null : VERB_LABEL[kind]; +} From a7f9e4465e467de3662d29241d211c532861cb56 Mon Sep 17 00:00:00 2001 From: Hako Date: Mon, 5 Oct 2026 14:27:07 +0900 Subject: [PATCH 2/6] fix(chat): a verb only in front of a call's own command or path, and "failed" after the object - omp sums a call up by its intent: "bash Checking ports" keeps its id instead of reading "Ran Checking ports". The verb needs the summary to be the input's command or path (by verb kind), or the files of its patch. - On a verb row the failed pill follows the object: "Ran pnpm test failed". - The demo's apply_patch and pi bash rows carry a real input, so they keep a verb. Co-Authored-By: Claude Opus 5.5 --- DESIGN.md | 8 +++--- scripts/chat-browser-qa.ts | 6 +++++ site/demo/fixtures.ts | 6 ++--- src/components/ChatView.tsx | 4 ++- src/lib/toolVerbs.test.ts | 50 ++++++++++++++++++++++++++++++------- src/lib/toolVerbs.ts | 41 ++++++++++++++++++++++++++---- 6 files changed, 94 insertions(+), 21 deletions(-) diff --git a/DESIGN.md b/DESIGN.md index d261a6ee9d..98caf3c9ff 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -464,9 +464,11 @@ One set for both themes: the card is island black wherever it shows. - A row is caret + verb + object: `▸ Read src/metrics.ts`, `▸ Edited src/pages/Reports.tsx`, `▸ Ran pnpm test`. The caret is always shown; there is no per-tool icon and no separator. The verb (Read, Edited, Wrote, Ran; `lib/toolVerbs.ts`, by exact tool id) is in the interface face, - `--fs-sm`, `--text-dim`; the object is mono `--fs-xs`. A tool the table does not know, or a call - with no object, keeps its id in mono `--text` in the verb's place. A failed row turns the verb or - id `--status-blocked` and adds the word "failed". + `--fs-sm`, `--text-dim`; the object is mono `--fs-xs`. A tool the table does not know, a call + with no object, or a call summed up by something other than its own command or path (omp's + `intent`: `bash Checking ports`) keeps its id in mono `--text` in the verb's place. A failed row + turns the verb or id `--status-blocked` and adds the word "failed": after the object under a + verb (`▸ Ran pnpm test [failed]`), after the id otherwise. - A row expands to the typed input (command, diff, file, checklist, raw) and an Output pane. Under a verb the tool's own id (`exec`, `Bash`, `apply_patch`) is the first line of that detail (`.work-row-tool`, mono, dim) and the row's title. diff --git a/scripts/chat-browser-qa.ts b/scripts/chat-browser-qa.ts index 25e1a959a7..aa68c9d856 100644 --- a/scripts/chat-browser-qa.ts +++ b/scripts/chat-browser-qa.ts @@ -37,6 +37,8 @@ try { add(message("assistant", "I am checking the transcript.", "commentary"), 1); add({ type: "function_call", name: "exec_command", call_id: "c1", arguments: '{"cmd":"git status"}' }, 2); add({ type: "function_call_output", call_id: "c1", output: "clean" }, 3); + add({ type: "function_call", name: "exec_command", call_id: "c2", arguments: '{"cmd":"bun test"}' }, 4); + add({ type: "function_call_output", call_id: "c2", output: "Process exited with code 1\n1 fail" }, 5); add(message("assistant", answer, "final_answer"), 8); persist(); const created = await workspaceCreate({ cwd: root, label: "herdr-web-ui-test-chat-browser" }); @@ -69,6 +71,10 @@ try { assert.equal(await row.getAttribute("title"), "exec_command · git status"); assert.equal(await row.locator(".work-row-caret").isVisible(), true); assert.equal(await row.locator(".work-row-icon, .work-row-sep").count(), 0); + // a failed call says so after its object, never between the verb and what it ran + const failedRow = log.getByRole("button", { name: "Ran bun test failed", exact: true }); + assert.equal(await failedRow.evaluate((node) => node.lastElementChild?.className), "work-row-failed"); + assert.equal(await failedRow.getAttribute("title"), "exec_command · bun test"); await row.click(); assert.equal(await log.locator(".work-row-detail > :first-child").innerText(), "exec_command"); await log.getByText("git status", { exact: true }).last().waitFor(); diff --git a/site/demo/fixtures.ts b/site/demo/fixtures.ts index 480c8552a8..7ad17c21a7 100644 --- a/site/demo/fixtures.ts +++ b/site/demo/fixtures.ts @@ -25,7 +25,7 @@ export const SPECS: DemoSpec[] = [ ]; export const t = (min: number, sec = 0) => `2026-09-25T09:${String(min).padStart(2, "0")}:${String(sec).padStart(2, "0")}.000Z`; -const tool = (name: string, summary: string, input: unknown, output: string): ConversationTurn["parts"][number] => ({ kind: "tool", name, summary, input: JSON.stringify(input, null, 2), output }); +const tool = (name: string, summary: string, input: unknown, output: string): ConversationTurn["parts"][number] => ({ kind: "tool", name, summary, input: typeof input === "string" ? input : JSON.stringify(input, null, 2), output }); export const CHATS: Record = { api: { @@ -55,7 +55,7 @@ export const CHATS: Record startExport(filters)}"), - tool("apply_patch", "src/pages/Reports.tsx", {}, "Success. Updated the following files:\nM src/pages/Reports.tsx"), + tool("apply_patch", "src/pages/Reports.tsx", "*** Begin Patch\n*** Update File: src/pages/Reports.tsx\n@@\n- onClick={() => startExport(filters)}\n+ disabled={exporting}\n+ onClick={() => { if (!exporting) startExport(filters); }}\n*** End Patch", "Success. Updated the following files:\nM src/pages/Reports.tsx"), tool("exec", "pnpm test --filter reports", { cmd: "pnpm test --filter reports" }, "✓ 9 tests passed"), { kind: "text" as const, text: "The button is disabled while an export runs, and a second click within the same run is ignored. Tests pass; I need your OK to push `feat/export-guard`." }, ] }, @@ -66,7 +66,7 @@ export const CHATS: Record