Skip to content
Open
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
2 changes: 1 addition & 1 deletion .claude/skills/verify-berthd/features/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ The maintained list of what a person can do in the app, and how to prove each on
| Stopping agents from the Agent Dashboard: one, the picked ones, a column, Clean up; archiving their worktrees where it's safe | [dashboard-stop.md](dashboard-stop.md) | `dashboard-stop-archive`; `app/e2e/dashboard-stop-archive.spec.ts` asserts it |
| Plugins: built-in and your own, what they add (screens, sidebar items, widgets, panels) | [plugins.md](plugins.md) | `plugin-nav-badges` (sidebar items); `plugin-attach-live` (`api.attach`, a real box, `--agent`) |
| 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 |
| 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`); `chat-question-links` (long addresses, mock); `app/e2e/chat.spec.ts` on the mock |
| Chat's foot and reply box: a live turn's spinner, timer, Stop and Queue; a turn that ended while its work runs on shows an hourglass and Send | [chat-turn-state.md](chat-turn-state.md) | `chat-turn-state`; `chat-turn-state-live` (a real box, `--agent`); `app/e2e/chat.spec.ts` asserts it |
| Chat and board: no question card or answers but the options the agent's screen shows; the mode menu while turn check waits; a structured agent's form while its helpers report back, and a reply box that answers a card with nothing to pick | [chat-ask-card.md](chat-ask-card.md) | `chat-ask-card`, `chat-structured-ask-live` (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 |
Expand Down
5 changes: 5 additions & 0 deletions .claude/skills/verify-berthd/features/chat-questions.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ When an agent asks with a form of its own (Claude Code's AskUserQuestion: up to
## Sub-features

- `questions-card`: the card's steps, the person's own words, the review, Submit; once answered it folds to what was answered (`app/e2e/chat.spec.ts`, mock).
- `questions-links`: a web address in a question, an option's label or description, the review, the card for an agent that can't be answered from the chat, or the screen-read "Needs your answer" card wraps inside the card. Every http(s) address in that text is a link to the whole address (`linked-text.tsx`, `lib/link-text.ts`), opened with `openUrl` (the system browser in the Mac app, `window.open` in a browser). A link in an option opens without picking the option, by click or Enter. An address that runs into the box's cut (the trailing "…" at 1,000 bytes, `internal/transcript` `clip`) is shown but not linked. The review's rows are buttons, so their text wraps but isn't linked (`scenarios/chat-question-links.mjs`, `app/e2e/question-links.spec.ts`, and `app/e2e/ask-card.spec.ts` for the screen-read card).
- `questions-deliver`: on a real box, long own words (wrapped by Claude Code), questions with lists in them, on an agent nobody has open (its pane at tmux's 80x24): every answer arrives word for word and the agent goes on. The box makes the pane tall enough while it answers and puts it back.
- `questions-kept`: when the box can't fill the form in, the card keeps every answer and says why, with **Retry** and **Copy answers** (each answer on a line under its question's name), and the agent's own screen opens under it to finish there. A retry that goes closes both.
- `questions-log`: every answer that doesn't go is in the box's log with why, and the screen it stopped at.
Expand All @@ -16,6 +17,7 @@ When an agent asks with a form of its own (Claude Code's AskUserQuestion: up to
## Driving it with drive.mjs

- Mock: `app/e2e/chat.spec.ts` covers the card and, with `?scene=answer-stuck`, a first submit that fails then a Retry.
- Mock, long addresses (`?scene=question-links`, made-up sign-in addresses): `node .claude/skills/verify-berthd/scripts/drive.mjs chat-question-links --port $PORT`. At 1440x900 and 390x844 (the sidebar hidden), in Shipyard Dark and Shipyard Light (picked from the command palette), it checks that the card doesn't run past its edge. It also checks that each link's href is the literal whole address and that clicking a link in an option, or pressing Enter on it, records a `window.open` of that address and leaves the option unpicked. Step 2's cut-short address has no link, and the review shows the question whole.
- Real box: `scenarios/chat-questions-live.mjs`, on a dev box of this checkout (`../scripts/dev-box.sh`):

```sh
Expand All @@ -35,6 +37,9 @@ The preview must be opened as `http://localhost:<1420-1439>`: the agent's API on

## Gotchas

- At 390px wide the sidebar stays open and leaves the chat ~120px: hide it ("Hide the sidebar") before judging a narrow layout.
- The app's theme is Shipyard Dark unless picked, not the system's, so `emulateMedia({ colorScheme })` changes nothing. Pick a theme from the command palette.

- The scenario sends its prompt from the chat, so the session must be at its prompt: one left at a form from a failed run needs Esc first.
- A box started from the app starts agents structured (Claude through its SDK, which answers through a hook, not keys); `dev-box.sh` sets the box's defaults so they start in their terminals, the path this covers. A structured agent's questions are covered by `chat-structured-ask-live` (`VERIFY_DRIVER=claude .claude/skills/verify-berthd/scripts/dev-box.sh up`, [chat-ask-card.md](chat-ask-card.md)).
- `dev-box.sh up` refuses if the dev box rewrote the agent hooks in your home (it does that only when they are older than its own); put them back from your backup before going on.
125 changes: 125 additions & 0 deletions .claude/skills/verify-berthd/scripts/scenarios/chat-question-links.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
// Long web addresses in an agent's questions (features/chat-questions.md,
// questions-links), on the mock's ?scene=question-links (made-up data):
//
// node drive.mjs chat-question-links --port $PORT
//
// At 1440x900 and at a phone's width, dark and light: the sign-in address
// in the question and the longer one in "Approved"'s description wrap
// inside the card, and each links the whole address. Clicking the option's
// link, or Enter on it, opens it (window.open, what lib/open-url calls
// outside the Mac app) and picks nothing; the row around it still picks.
// Step 2's address, cut short by the box (its "…"), shows with no link.
// The review shows the first question whole.

export const params = { sidebar: "list", view: "conversation", scene: "question-links" };

const LINK_URL = "https://shop.example.com/admin/oauth/authorize?client_id=0000example0000example&scope=read_products,write_products,read_orders&redirect_uri=https%3A%2F%2Fapp.example.com%2Fauth%2Fcallback&state=mock-state-1234567890abcdef";
const OPTION_URL = `${LINK_URL}&grant_options%5B%5D=per-user`;

export async function run({ page, shot, step, expect }) {
// Record what would open in the browser, instead of opening it.
await page.addInitScript(() => {
window.__opened = [];
window.open = (url) => {
window.__opened.push(String(url));
return null;
};
});
await page.reload();
await expect(page.getByTestId("nav-home")).toBeVisible({ timeout: 20_000 });
const opened = () => page.evaluate(() => window.__opened);
const chat = page.locator("[data-testid=pane]:visible [data-testid=chat]");
const form = chat.getByTestId("question-form");
// The theme, as a person picks it: the command palette's Themes.
const theme = async (name) => {
const input = page.getByRole("combobox", { name: "Search sessions, worktrees and commands" });
const item = page.getByRole("option", { name: new RegExp(`^${name}`) });
await expect(async () => {
if (!(await input.isVisible())) await page.keyboard.press("Meta+k");
await input.fill(name);
await expect(item).toBeVisible({ timeout: 500 });
}).toPass();
await item.click();
await expect(input).toBeHidden();
step(`theme: ${name}`);
};
const fits = async (label) => {
const over = await form.evaluate((el) => el.scrollWidth - el.clientWidth);
if (over > 0) throw new Error(`${label}: the card runs ${over}px past its edge`);
const card = await form.boundingBox();
for (const link of await form.locator("a.cv-link").all()) {
const b = await link.boundingBox();
if (b.x < card.x - 1 || b.x + b.width > card.x + card.width + 1) throw new Error(`${label}: a link sits outside the card`);
}
};

step("open gpu/shop's chat: Claude asks 2 questions, with long addresses");
await page.locator('[data-testid=worktree-row][data-worktree="gpu/shop"]').click({ position: { x: 32, y: 12 } });
await expect(form).toContainText("Claude asks you 2 questions");
const links = form.locator("a.cv-link");
await expect(links).toHaveCount(2);
await expect(links.nth(0)).toHaveAttribute("href", LINK_URL);
await expect(links.nth(0)).toHaveText(LINK_URL);
await expect(links.nth(1)).toHaveAttribute("href", OPTION_URL);
await expect(links.nth(1)).toHaveText(OPTION_URL);
step(`question link href is the whole address (${LINK_URL.length} chars), the option's too (${OPTION_URL.length} chars)`);
await fits("1440 dark");
await form.scrollIntoViewIfNeeded();
await shot("desktop-dark");
await theme("Shipyard Light");
await fits("1440 light");
await shot("desktop-light");
await theme("Shipyard Dark");

step("click the link in Approved's description");
const approved = form.getByRole("radio", { name: /^Approved/ });
const optionLink = form.getByRole("link", { name: OPTION_URL });
await optionLink.click();
await expect.poll(opened).toEqual([OPTION_URL]);
await expect(approved).not.toBeChecked();
step(`opened ${OPTION_URL.slice(0, 60)}… in full; Approved is not picked`);
step("Enter on the focused link");
await optionLink.focus();
await page.keyboard.press("Enter");
await expect.poll(opened).toEqual([OPTION_URL, OPTION_URL]);
await expect(approved).not.toBeChecked();
step("opened again; still nothing picked");
step("click the question's own link");
await form.getByRole("link", { name: LINK_URL, exact: true }).click();
await expect.poll(opened).toEqual([OPTION_URL, OPTION_URL, LINK_URL]);
step("click Approved's label: it is picked");
await form.getByText("Approved", { exact: true }).click();
await expect(approved).toBeChecked();
await shot("picked-after-links");

step("at a phone's width (390x844), the sidebar hidden");
await page.getByRole("button", { name: "Hide the sidebar" }).first().click();
await page.setViewportSize({ width: 390, height: 844 });
await expect(links.nth(0)).toBeVisible();
await fits("390 dark");
await form.scrollIntoViewIfNeeded();
await shot("phone-dark");
await theme("Shipyard Light");
await fits("390 light");
await shot("phone-light");
await theme("Shipyard Dark");

step("step 2: the address the box cut short shows, with no link");
await form.getByRole("button", { name: /^Next$/ }).last().click();
await expect(form).toContainText("2 of 2");
const no = form.getByRole("radio", { name: /^No/ }).locator("xpath=ancestor::label[1]");
await expect(no).toContainText("project=acme-shop&…");
await expect(no.locator("a")).toHaveCount(0);
await fits("390 step 2");
await form.scrollIntoViewIfNeeded();
await shot("phone-cut-short");

step("the review shows the first question whole");
await form.getByRole("radio", { name: /^Yes/ }).click();
await form.getByRole("button", { name: /^Review$/ }).last().click();
await expect(form).toContainText("Review your answers");
await expect(form.getByRole("button", { name: /Approve the app/ })).toContainText(LINK_URL);
await fits("390 review");
await form.scrollIntoViewIfNeeded();
await shot("phone-review");
}
24 changes: 24 additions & 0 deletions app/e2e/ask-card.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,30 @@ test("a question its screen shows options for still gets its card", async ({ app
await expect.poll(() => agent.sends.map((s) => s.text)).toContain("2");
});

test("an address in a question its screen shows is a link that opens it whole", async ({ app }) => {
const URL = "https://docs.example.com/lockfiles?mode=overwrite&keep=none";
await app.page.addInitScript(() => {
const w = window as unknown as { __opened: string[] };
w.__opened = [];
window.open = (url?: string | URL) => {
w.__opened.push(String(url));
return null;
};
});
await openWaiting(app, {}, ["─".repeat(80), ` Overwrite it? See ${URL}`, "", " ❯ 1. Overwrite it", " 2. Keep mine", "", " Esc to cancel"].join("\n"));
const card = app.chat.locator("[data-testid=chat-item][data-kind=ask]");
await expect(card).toContainText("Needs your answer");
const link = card.getByRole("link", { name: URL });
await expect(link).toHaveAttribute("href", URL);
expect(await card.evaluate((el) => el.scrollWidth <= el.clientWidth)).toBe(true);
await link.click();
await expect.poll(() => app.page.evaluate(() => (window as unknown as { __opened: string[] }).__opened)).toEqual([URL]);
// A pick after it is the only thing sent: the link's click sent nothing,
// even late.
await card.getByRole("button", { name: /Keep mine/ }).click();
await expect.poll(() => agent.sends.map((s) => s.text)).toEqual(["2"]);
});

// The board's card offers the options of a menu on the agent's screen as
// chips: a plan in its last message, above its prompt, is no menu.
const PLAN = [
Expand Down
99 changes: 99 additions & 0 deletions app/e2e/question-links.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
import { expect, mockOnly, test } from "./fixtures";

// Long web addresses in an agent's questions (?scene=question-links, made
// up): they wrap inside the card, link to the whole address, and a link in
// an option opens without picking it. One the box cut short isn't linked.

const LINK_URL = "https://shop.example.com/admin/oauth/authorize?client_id=0000example0000example&scope=read_products,write_products,read_orders&redirect_uri=https%3A%2F%2Fapp.example.com%2Fauth%2Fcallback&state=mock-state-1234567890abcdef";
const OPTION_URL = `${LINK_URL}&grant_options%5B%5D=per-user`;

const opened = (app: { page: import("@playwright/test").Page }) => app.page.evaluate(() => (window as unknown as { __opened: string[] }).__opened);

test.beforeEach(async ({ app }) => {
mockOnly("answers a question on a box");
await app.page.addInitScript(() => {
const w = window as unknown as { __opened: string[] };
w.__opened = [];
window.open = (url?: string | URL) => {
w.__opened.push(String(url));
return null;
};
});
});

for (const width of [1440, 390]) {
test(`at ${width}px a long address wraps inside the card, linked whole`, async ({ app }) => {
await app.open({ params: { view: "conversation", scene: "question-links" } });
await app.openWorktree("gpu/shop");
// A phone's width has room for the chat alone.
if (width < 800) await app.page.getByRole("button", { name: "Hide the sidebar" }).first().click();
await app.page.setViewportSize({ width, height: 900 });
const form = app.chat.getByTestId("question-form");
await expect(form).toContainText("Claude asks you 2 questions");
const links = form.locator("a.cv-link");
await expect(links).toHaveCount(2);
await expect(links.nth(0)).toHaveAttribute("href", LINK_URL);
await expect(links.nth(0)).toHaveText(LINK_URL);
await expect(links.nth(1)).toHaveAttribute("href", OPTION_URL);
// Nothing runs past the card: it wraps, over several lines.
expect(await form.evaluate((el) => el.scrollWidth <= el.clientWidth)).toBe(true);
const card = (await form.boundingBox())!;
for (const link of [links.nth(0), links.nth(1)]) {
const b = (await link.boundingBox())!;
expect(b.x).toBeGreaterThanOrEqual(card.x);
expect(b.x + b.width).toBeLessThanOrEqual(card.x + card.width + 1);
expect(await link.evaluate((el) => el.getClientRects().length)).toBeGreaterThan(1);
}
if (process.env.BERTH_SHOTS) await form.screenshot({ path: `${process.env.BERTH_SHOTS}/question-links-${width}.png` });
});
}

test("a link in an option opens it and leaves the pick alone, by mouse and by keyboard", async ({ app }) => {
await app.open({ params: { view: "conversation", scene: "question-links" } });
await app.openWorktree("gpu/shop");
const form = app.chat.getByTestId("question-form");
const approved = form.getByRole("radio", { name: /^Approved/ });
const link = form.getByRole("link", { name: OPTION_URL });
await expect(approved).not.toBeChecked();

await link.click();
await expect.poll(() => opened(app)).toEqual([OPTION_URL]);
await expect(approved).not.toBeChecked();

await link.focus();
await app.page.keyboard.press("Enter");
await expect.poll(() => opened(app)).toEqual([OPTION_URL, OPTION_URL]);
await expect(approved).not.toBeChecked();
await expect(link).toBeFocused();
// The radio group's arrow keys don't reach in and pick either.
await app.page.keyboard.press("ArrowDown");
await expect(form.getByRole("radio", { checked: true })).toHaveCount(0);

// The question's own link opens too; the rest of the row still picks.
await form.getByRole("link", { name: LINK_URL, exact: true }).click();
await expect.poll(() => opened(app)).toEqual([OPTION_URL, OPTION_URL, LINK_URL]);
await form.getByText("Approved", { exact: true }).click();
await expect(approved).toBeChecked();
});

test("an address the box cut short shows, wrapped, with no link", async ({ app }) => {
await app.open({ params: { view: "conversation", scene: "question-links" } });
await app.openWorktree("gpu/shop");
const form = app.chat.getByTestId("question-form");
const next = form.getByRole("button", { name: /^(Next|Review|Submit answers)$/ }).last();
await form.getByRole("radio", { name: /^It failed/ }).click();
await next.click();
await expect(form).toContainText("2 of 2");
const no = form.getByRole("radio", { name: /^No/ }).locator("xpath=ancestor::label[1]");
await expect(no).toContainText("https://logs.example.com/explorer?query=");
await expect(no).toContainText("project=acme-shop&…");
await expect(no.locator("a")).toHaveCount(0);
expect(await form.evaluate((el) => el.scrollWidth <= el.clientWidth)).toBe(true);

// The review shows the whole question, address and all, wrapped.
await form.getByRole("radio", { name: /^Yes/ }).click();
await next.click();
await expect(form).toContainText("Review your answers");
await expect(form.getByRole("button", { name: /Approve the app/ })).toContainText(LINK_URL);
expect(await form.evaluate((el) => el.scrollWidth <= el.clientWidth)).toBe(true);
});
Loading