Skip to content
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,14 @@ between releases do not reach them. Remote-PC runtime bundles are versioned sepa
before the model's name is cut. In dark themes Queue loses its outline.
([#456](https://github.com/devswha/herdr-web-ui/pull/456))

- 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.
([#457](https://github.com/devswha/herdr-web-ui/pull/457))

### Fixed
- An update asked for in the first moments after the app starts, or right after another update,
waits until the start is over. It used to stop the app in the middle of its start-up check,
Expand Down
18 changes: 15 additions & 3 deletions DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -459,9 +459,21 @@ 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, 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.
- 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,
Expand Down
14 changes: 13 additions & 1 deletion scripts/chat-browser-qa.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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" });
Expand Down Expand Up @@ -75,7 +77,17 @@ 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);
// 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();
assert.equal(await log.locator(".chat-agent-meta").count(), 1);
console.log("PASS native user/answer rendering, internal context filtering, tool expansion, duration");
Expand Down
11 changes: 11 additions & 0 deletions server/conversation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { join, win32 } from "node:path";
import { forgetHistoryChains } from "./codex.ts";
import { ConversationUnavailable, gjcTranscriptPath, HistoryChanged, isOmoProcess, ompSessionPath, parseClaudeTranscript, unwrapPastes, transcriptImage, transcriptPage, transcriptToolOutput } from "./conversation.ts";
import { MAX_TURNS, parseOmpTranscript } from "./transcript-records.ts";
import { toolVerb } from "../src/lib/toolVerbs.ts";

/** Minimal but shape-true slices of a Claude Code session jsonl. */
const lines = [
Expand Down Expand Up @@ -80,6 +81,16 @@ describe("parseClaudeTranscript", () => {
expect(tool && tool.kind === "tool" ? tool.output.length : 0).toBeLessThanOrEqual(4100);
});

it("sums a NotebookEdit up by its notebook, so the row reads as an edit", () => {
const turns = parseClaudeTranscript(JSON.stringify({ type: "assistant", message: { role: "assistant", content: [
{ type: "tool_use", id: "n", name: "NotebookEdit", input: { notebook_path: "/repo/analysis.ipynb", cell_id: "c1", new_source: "print(1)", edit_mode: "replace" } },
] } }));
const tool = turns[0]?.parts[0];
if (tool?.kind !== "tool") throw new Error("expected a tool part");
expect(tool.summary).toBe("/repo/analysis.ipynb");
expect(toolVerb(tool, tool.summary)).toBe("Edited");
});

it("caps the turn list", () => {
const many = Array.from({ length: MAX_TURNS + 50 }, (_, i) =>
JSON.stringify({ type: "user", message: { role: "user", content: `m${i}` } }),
Expand Down
4 changes: 2 additions & 2 deletions server/transcript-records.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,8 +92,8 @@ export function piNotice(value: unknown): Extract<ConversationPart, { kind: "not

/** The one-line summary a collapsed tool chip shows. */
export function toolSummary(name: string, input: Record<string, unknown>): string {
// pi names a file `path` where Claude names it `file_path`; both are worth showing.
const first = input["command"] ?? input["file_path"] ?? input["path"] ?? input["pattern"] ?? input["description"] ?? input["url"];
// pi names a file `path` where Claude names it `file_path`, and a notebook `notebook_path`.
const first = input["command"] ?? input["file_path"] ?? input["notebook_path"] ?? input["path"] ?? input["pattern"] ?? input["description"] ?? input["url"];
return typeof first === "string" ? first.slice(0, 120) : name;
}

Expand Down
6 changes: 3 additions & 3 deletions site/demo/fixtures.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, { turns: ConversationTurn[]; metadata: { model: string; reasoning_effort: string } }> = {
api: {
Expand Down Expand Up @@ -55,7 +55,7 @@ export const CHATS: Record<string, { turns: ConversationTurn[]; metadata: { mode
{ role: "user" as const, ts: t(1), parts: [{ kind: "text" as const, text: "Rate-limit the export button so a double click can't start two exports, then push the branch." }] },
{ role: "assistant" as const, ts: t(1, 4), end_ts: t(3, 20), parts: [
tool("exec", "rg -n \"startExport\" src", { cmd: "rg -n \"startExport\" src" }, "src/pages/Reports.tsx:88: onClick={() => 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`." },
] },
Expand All @@ -66,7 +66,7 @@ export const CHATS: Record<string, { turns: ConversationTurn[]; metadata: { mode
turns: [
{ role: "user" as const, ts: t(0), parts: [{ kind: "text" as const, text: "Why did last night's backup job fail?" }] },
{ role: "assistant" as const, ts: t(0, 6), end_ts: t(1, 2), parts: [
tool("bash", "journalctl -u backup --since yesterday", {}, "backup.sh: disk quota exceeded on /mnt/archive"),
tool("bash", "journalctl -u backup --since yesterday", { command: "journalctl -u backup --since yesterday" }, "backup.sh: disk quota exceeded on /mnt/archive"),
{ kind: "text" as const, text: "The archive volume hit its quota at 02:14. Old snapshots were never pruned because the retention flag was dropped in the last config change. I restored `--keep-daily 14`; tonight's run will prune and succeed." },
] },
],
Expand Down
2 changes: 1 addition & 1 deletion src/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
48 changes: 34 additions & 14 deletions src/components/ChatView.css
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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 {
Expand All @@ -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 {
Expand All @@ -448,16 +451,34 @@
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);
}

.work-narration .markdown {
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;
Expand Down Expand Up @@ -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);
}

Expand Down
Loading
Loading