Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .claude/skills/verify-berthd/features/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ The maintained list of what a person can do in the app, and how to prove each on
| Home's widget grid | [home-widgets.md](home-widgets.md) | none yet; `app/e2e/home-widgets.spec.ts` asserts it |
| Questions in chat: an agent's form of questions as a card, answered on a real box; kept with Retry and Copy when it can't be | [chat-questions.md](chat-questions.md) | `chat-questions-live` (a real box, `--agent`); `app/e2e/chat.spec.ts` on the mock |
| Chat and board: no question card or answers but the options the agent's screen shows; the mode menu while turn check waits | [chat-ask-card.md](chat-ask-card.md) | `chat-ask-card` (a real box, `--agent`); `app/e2e/ask-card.spec.ts` on a stand-in box |
| The board's cards for agents in chat: what they ask with its answers, their reply's last lines, the prompt they started with; never the host's frames or "stdio" | [chat-board-cards.md](chat-board-cards.md) | `chat-board-cards`; `app/e2e/dashboard-chat-cards.spec.ts` asserts it |
| Turn check's "working": an hourglass with why in the sidebar, the tab and the rail's card, not a live turn's spinner; a handoff reads as done | [turn-check-indicator.md](turn-check-indicator.md) | `turn-check-indicator`; `app/e2e/sidebar-turn-check.spec.ts` asserts it, `TestTurnCheckLive` asks the real provider |

## Proof and skip reporting
Expand Down
25 changes: 25 additions & 0 deletions .claude/skills/verify-berthd/features/chat-board-cards.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
# The board's cards for agents in chat

An agent that runs in chat (structured: `Session.driver` is `claude`, `codex` or `acp`) has no screen of its own: its pane holds the host that runs it (`berthd agent-host`), which logs one line per frame (`out stream_event`, `out control_request`), and its command line is the host's, ending in `'--permission-prompt-tool' 'stdio'`. So its card on the Agent Dashboard reads the agent instead (`views/dashboard/agent-card.tsx`, `use-chat-tail.ts`, `lib/chat-card.ts`):

- **Waiting on you**: what it asks, from `Session.ask`. A permission shows its reason and the command (`$ pnpm …`) or the tool and file (`Edit src/x.ts`), with the answers it offers (`ask.choices`) as chips. A question (AskUserQuestion, ExitPlanMode) shows the question or its message, with chips only where it offers choices.
- **Otherwise**: the last lines of its newest reply, without Markdown's marks, or the step it took since (`Read search.ts`, `Edit src/x.ts +9 −2`), or a notice (a usage limit). It reads the conversation as a chat does (`GET sessions/{name}/transcript?since=N&gen=…`): the recent window once, then only what was added, which the box reads on from where it was (about 8 ms on a 30 MB record), keeping the newest 12 items. It reads when a terminal card would read its screen (on a state change, an event about it, and every 20s while it works), one read at a time, and not while the card shows an ask.
- **Hidden** when there's nothing yet (no ask, no reply, or a prompt it hasn't answered), and in Ready as before. Never a host frame.
- **The quote** under the title is its conversation's first prompt, or the command typed as one (`/ship ACME-12`), hidden when it repeats the title. Never "stdio". It is read once per session from the page at the record's start (`transcript?before=262144&limit=50`, paging back to the start when that holds more): the first prompt comes after the agent's opening frame, tens of KB in. A session's conversation list entry (`GET conversations`) has no title when its prompt was sent after it started, which is how orchestrators start agents, so it isn't used.

A terminal agent's card is unchanged: the last 3 meaningful lines of its screen, its menu's options as chips (see [chat-ask-card.md](chat-ask-card.md) `board-answers`).

## How to get to it (user POV)

- ⌘J (or Home with Labs off) opens the Agent Dashboard. Cards sit in Needs you, Working, Idle and Done.

## Driving it with drive.mjs

- On the mock: `scenarios/chat-board-cards.mjs` opens `?mock=1&structured=1`. There, devl's search-perf Claude (Done) and gpu's judge-v2 (Working), shop (Needs you: AskUserQuestion) and ci-flake (Needs you: a Bash permission with Yes / Yes, and don't ask again / No) run in chat, with the host's command line and pane log as a real box has them. checkout-fix's Claude stays in its terminal.
- It asserts each chat card's box and quote, the chips on the permission and none on the question, the terminal card's screen and chips, and that nowhere on the board shows `out stream_event`, `out control_request`, `out system`, `in user` or `stdio`. Screenshots: `1-waiting-permission` … `5-terminal`, `6-board`.
- `app/e2e/dashboard-chat-cards.spec.ts` asserts the same on the mock.

## Gotchas

- The mock's Review fixtures (`lib/mock-review.ts`) answer `screen` for search-perf and checkout-fix before the mock's own screens do, so search-perf's pane reads as a Claude screen there, not a host log. Its card doesn't read the screen, so this doesn't show on the board.
- Review's summary (`views/review/summary.ts`) still reads the pane for agents in chat.
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
// The Agent Dashboard's cards for agents that run in chat
// (features/chat-board-cards.md): their pane holds only the host that runs
// them, so a card shows what the agent asks, or the last lines of its
// reply, and the prompt it started with. Never the host's frames
// ("out stream_event"), and never "stdio". A terminal agent's card still
// shows the last lines of its screen.
//
// On the mock (?structured: search-perf's and three of gpu's Claudes run in
// chat; the rest in their terminals):
//
// node drive.mjs chat-board-cards --port $PORT

export const params = { structured: "1" };

const HOST = /out stream_event|out control_request|out system|in user|stdio/;

export async function run({ page, shot, step, expect }) {
const board = page.getByTestId("dashboard-board");
const card = (title) => board.locator("article", { hasText: title });
const box = (c) => c.locator(".font-mono.text-tag");

step("open the Agent Dashboard");
await page.keyboard.press("Meta+j");
await expect(page.getByRole("heading", { name: "Agent Dashboard" })).toBeVisible();
await expect(board).toBeVisible();

step("in chat, waiting on a command: the command and why, its answers as chips, the prompt it started with");
const permission = card("Move acme billing to the new ledger");
await expect(permission).toBeVisible();
await expect(box(permission)).toContainText("Backfill the new ledger from this year's invoices");
await expect(box(permission)).toContainText("$ pnpm ledger:backfill --since 2025-01-01");
for (const label of ["Yes", "No"]) await expect(permission.getByRole("button", { name: new RegExp(`^\\d\\s*${label}$`) })).toBeVisible();
// Its conversation's first prompt, not the host's last word.
await expect(permission.getByText(/^“Move acme billing onto the new ledger\. Send helpers to map every ledger write/)).toBeVisible();
await expect(permission).not.toContainText(HOST);
await permission.scrollIntoViewIfNeeded();
await shot("1-waiting-permission");

step("in chat, asking a question: the question, no made-up answers");
const question = card("Plan the checkout release");
await expect(box(question)).toContainText("Three questions about the release window");
await expect(question.getByRole("button", { name: /^\d\s/ })).toHaveCount(0);
await expect(question).not.toContainText(HOST);
await question.scrollIntoViewIfNeeded();
await shot("2-waiting-question");

step("in chat, working: the last lines of what it said");
const working = card("Tune the judge prompt");
await expect(box(working)).toContainText("0.82 agreement with the labels, was 0.74");
await expect(box(working)).toContainText("Next: rerun the hard set with a length penalty.");
await expect(working).not.toContainText(HOST);
await working.scrollIntoViewIfNeeded();
await shot("3-working");

step("in chat, done: its last reply");
const done = card("Speed up product search");
await expect(box(done)).toContainText("Within budget: /search p95 is 410 ms against 500 ms.");
await expect(done).not.toContainText(HOST);
await done.scrollIntoViewIfNeeded();
await shot("4-done");

step("in its terminal, waiting: unchanged, the last lines of its screen and its menu's chips");
const terminal = card("Fix checkout webhook retries");
await expect(box(terminal)).toContainText("the order lock for the whole backoff.");
await expect(terminal.getByRole("button", { name: /^1\s*Yes, cap it at 30s$/ })).toBeVisible();
await terminal.scrollIntoViewIfNeeded();
await shot("5-terminal");

step("nowhere on the board: a host frame or stdio");
await expect(board).not.toContainText(HOST);
await page.evaluate(() => document.querySelector("[data-testid=dashboard-board]")?.scrollTo(0, 0));
await shot("6-board");
}
52 changes: 52 additions & 0 deletions app/e2e/dashboard-chat-cards.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import { expect, mockOnly, test } from "./fixtures";

// An agent that runs in chat (structured) has only the host that runs it in
// its pane, one line per frame ("out stream_event"), and the host's command
// line ends in '--permission-prompt-tool' 'stdio'. Its board card shows what
// it asks, or the last lines of its reply, and the prompt it started with:
// never those frames, and never "stdio" (lib/chat-card.ts). A terminal
// agent's card still shows its screen.

const HOST = /out stream_event|out control_request|out assistant|stdio/;

async function openBoard(app: import("./fixtures").App) {
mockOnly("the agents in chat are mock fixtures (?structured)");
await app.open({ prefs: { labs: false, labsChosen: true, version: 3 }, params: { structured: "1" } });
await app.page.getByTestId("nav-home").click();
return {
needsYou: app.page.getByRole("region", { name: "Needs you" }),
working: app.page.getByRole("region", { name: "Working" }),
};
}

test("a chat card waiting on you shows what it asks, its answers, and no host frames", async ({ app }) => {
const { needsYou } = await openBoard(app);
const permission = needsYou.locator("article", { hasText: "Move acme billing to the new ledger" });
await expect(permission).toContainText("Backfill the new ledger from this year's invoices");
await expect(permission).toContainText("$ pnpm ledger:backfill --since 2025-01-01");
await expect(permission.getByRole("button", { name: /^1\s*Yes$/ })).toBeVisible();
await expect(permission.getByRole("button", { name: /^3\s*No$/ })).toBeVisible();
// Its conversation's first prompt, not the host's last word.
await expect(permission.getByText(/^“Move acme billing onto the new ledger\. Send helpers to map every ledger write/)).toBeVisible();
// A form of questions is answered in its chat: the question, no chips.
const question = needsYou.locator("article", { hasText: "Plan the checkout release" });
await expect(question).toContainText("Three questions about the release window");
await expect(question.getByRole("button", { name: /^\d\s/ })).toHaveCount(0);
for (const card of [permission, question]) await expect(card).not.toContainText(HOST);
});

test("a chat card at work shows the last lines of its reply", async ({ app }) => {
const { working } = await openBoard(app);
const card = working.locator("article", { hasText: "Tune the judge prompt" });
await expect(card).toContainText("0.82 agreement with the labels, was 0.74");
await expect(card).toContainText("Next: rerun the hard set with a length penalty.");
await expect(card).not.toContainText(HOST);
});

test("a terminal card beside them still shows its screen and its menu", async ({ app }) => {
const { needsYou } = await openBoard(app);
const card = needsYou.locator("article", { hasText: "Fix checkout webhook retries" });
await expect(card).toContainText("the order lock for the whole backoff.");
await expect(card.getByRole("button", { name: /^1\s*Yes, cap it at 30s$/ })).toBeVisible();
await expect(card.getByRole("button", { name: /^2\s*No, leave it$/ })).toBeVisible();
});
112 changes: 112 additions & 0 deletions app/src/lib/chat-card.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
// node --experimental-strip-types --test src/lib/chat-card.test.ts (pnpm test)
import assert from "node:assert/strict";
import { test } from "node:test";

import { chatCard, mergeTail, openingPrompt, shownPrompt } from "./chat-card.ts";
import type { TranscriptItem } from "./transcript.ts";

const YES_NO = [
{ key: "1", label: "Yes" },
{ key: "2", label: "Yes, and don't ask again this session" },
{ key: "3", label: "No" },
];

const reply: TranscriptItem[] = [
{ kind: "user", id: "u1", text: "Speed up product search" },
{ kind: "tools", id: "t1", verb: "Read", done: true, items: [{ verb: "Read", target: "search.ts", file: true }] },
{ kind: "text", id: "x1", text: "## Result\n\nSearch is faster now:\n\n- p95 is **410 ms**, was 660 ms\n- all 214 tests pass\n\n```ts\nconst cache = new Map();\n```\n\nThe branch is ready for review." },
];

test("a permission it waits on shows the tool, its input and why, with its answers", () => {
const card = chatCard({ tool: "Bash", input: "pnpm db:migrate --name add-key", why: "Create the migration", choices: YES_NO }, true, reply);
assert.deepEqual(card, { tail: ["Create the migration", "$ pnpm db:migrate --name add-key"], choices: YES_NO });
assert.deepEqual(chatCard({ tool: "Edit", input: "src/search.ts", choices: YES_NO }, true, reply)?.tail, ["Edit src/search.ts"]);
});

test("a question it waits on shows the question, or the message it showed", () => {
assert.deepEqual(chatCard({ tool: "AskUserQuestion", input: "Which release window?" }, true, reply), { tail: ["Which release window?"], choices: [] });
const plan = chatCard({ tool: "ExitPlanMode", message: "Claude has a plan and is ready to start", choices: YES_NO }, true, reply);
assert.deepEqual(plan, { tail: ["Claude has a plan and is ready to start"], choices: YES_NO });
assert.deepEqual(chatCard({ tool: "AskUserQuestion" }, true, [])?.tail, ["Asks you a question"]);
});

test("not waiting, it shows the last lines of its newest reply, without markdown's marks", () => {
assert.deepEqual(chatCard(undefined, false, reply), { tail: ["- all 214 tests pass", "const cache = new Map();", "The branch is ready for review."], choices: [] });
assert.deepEqual(chatCard(undefined, false, reply, 2)?.tail, ["const cache = new Map();", "The branch is ready for review."]);
// An ask left over from a wait that ended is not shown.
assert.deepEqual(chatCard({ tool: "Bash", input: "ls" }, false, reply)?.tail.at(-1), "The branch is ready for review.");
});

test("waiting with no ask (a question in its last message) shows that message", () => {
const asked: TranscriptItem[] = [...reply.slice(0, 2), { kind: "text", id: "x2", text: "Two ways to do this.\n\nShould I start with the cache?" }];
assert.deepEqual(chatCard(undefined, true, asked)?.tail, ["Two ways to do this.", "Should I start with the cache?"]);
});

test("a step newer than its last words is what it shows", () => {
const working: TranscriptItem[] = [
...reply,
{ kind: "tools", id: "t2", verb: "Run", items: [{ verb: "Read", target: "search.test.ts", file: true }, { verb: "Run", target: "pnpm test search" }] },
{ kind: "thinking", id: "th", since: 0 },
];
assert.deepEqual(chatCard(undefined, false, working)?.tail, ["Read search.test.ts", "Run pnpm test search"]);
const edited: TranscriptItem[] = [...reply, { kind: "edit", id: "e1", file: "src/search.ts", added: 9, removed: 2 }];
assert.deepEqual(chatCard(undefined, false, edited)?.tail, ["Edit src/search.ts +9 −2"]);
assert.deepEqual(chatCard(undefined, false, [{ kind: "tools", id: "t3", verb: "Thinking" }])?.tail, ["Thinking"]);
});

test("nothing to show hides the box: no reply yet, or a prompt it hasn't answered", () => {
assert.equal(chatCard(undefined, false, undefined), undefined);
assert.equal(chatCard(undefined, false, []), undefined);
assert.equal(chatCard(undefined, false, [{ kind: "user", id: "u1", text: "Fix the cart badge" }]), undefined);
// A new prompt after a reply: the reply is the last turn's, not this one's.
assert.equal(chatCard(undefined, false, [...reply, { kind: "user", id: "u2", text: "Now the tests" }, { kind: "thinking", id: "th", since: 0 }]), undefined);
assert.equal(chatCard(undefined, false, [{ kind: "text", id: "x", text: "\n\n```\n```\n" }]), undefined);
});

test("a notice newer than its reply (a usage limit, an API error) is what it shows", () => {
const limited: TranscriptItem[] = [...reply, { kind: "notice", id: "n", notice: "limit", level: "warning", text: "Usage limit reached. It resets at 3pm." }];
assert.deepEqual(chatCard(undefined, false, limited)?.tail, ["Usage limit reached. It resets at 3pm."]);
});

test("the prompt shows unless it repeats the title, and only when there is one", () => {
assert.equal(shownPrompt("/ship ACME-12", "Fix checkout webhook retries"), "/ship ACME-12");
assert.equal(shownPrompt("/ship ACME-12", undefined), "/ship ACME-12");
assert.equal(shownPrompt("Fix checkout webhook retries", "Fix checkout webhook retries"), undefined);
// Titles are cut short with an ellipsis; the prompt it began is no news.
assert.equal(shownPrompt("Pick up search-perf: read the issue and get it to green.", "Pick up search-perf: read the issue and get it…"), undefined);
assert.equal(shownPrompt(undefined, "Fix checkout"), undefined);
assert.equal(shownPrompt(" ", "Fix checkout"), undefined);
});

test("the prompt it started with is its first prompt, or the command typed as one", () => {
assert.equal(openingPrompt(reply), "Speed up product search");
assert.equal(openingPrompt([{ kind: "command", id: "c", command: "/ship", args: "ACME-12" }, ...reply]), "/ship ACME-12");
assert.equal(openingPrompt([{ kind: "command", id: "c", command: "/clear" }]), "/clear");
// Read from the middle of a long conversation, its start isn't there.
assert.equal(openingPrompt(reply, true), undefined);
assert.equal(openingPrompt([]), undefined);
assert.equal(openingPrompt(undefined), undefined);
});

test("a question over several lines shows its first lines, one each", () => {
const ask = { tool: "AskUserQuestion", input: "Yes, both, as two issues:\n1. **Shipyard**: shortcuts for your layout.\n2. Lookout: shorter descriptions.\n3. A third." };
assert.deepEqual(chatCard(ask, true, [])?.tail, ["Yes, both, as two issues:", "1. Shipyard: shortcuts for your layout.", "2. Lookout: shorter descriptions."]);
assert.deepEqual(chatCard({ tool: "Bash", input: "make test", why: "Run the tests\nall of them" }, true, [])?.tail, ["Run the tests", "$ make test"]);
});

test("a long or pasted opening prompt is its first line, cut short", () => {
const pasted = `[scrawl 0:57 · Shipyard] ${"so right now here ".repeat(30)}\n#2 the second part`;
const p = openingPrompt([{ kind: "user", id: "u", text: pasted }]);
assert.ok(p && p.length <= 200 && p.endsWith("…") && !p.includes("#2"), String(p));
assert.equal(openingPrompt([{ kind: "user", id: "u", text: "\n Fix the cart badge\nand its test" }]), "Fix the cart badge");
});

test("a card's newest items: read again replace their old selves, new go after, the oldest go", () => {
const open: TranscriptItem = { kind: "tools", id: "t", verb: "Run", items: [{ verb: "Run", target: "pnpm test" }] };
const done: TranscriptItem = { ...open, done: true, items: [{ verb: "Run", target: "pnpm test" }, { verb: "Read", target: "a.ts" }] };
const said: TranscriptItem = { kind: "text", id: "x", text: "Done." };
assert.deepEqual(mergeTail([reply[0], open], [done, said], 3), [reply[0], done, said]);
assert.deepEqual(mergeTail([reply[0], open], [done, said], 2), [done, said]);
assert.deepEqual(mergeTail([], reply, 12), reply);
assert.deepEqual(mergeTail(reply, [], 12), reply);
});
Loading