From d1ae6e0f5d9153c20dcf35ad7e87eca81fe25ef7 Mon Sep 17 00:00:00 2001 From: Stener1 Date: Tue, 27 Oct 2026 16:44:05 +0000 Subject: [PATCH 1/3] feat(app): long links in an agent's questions wrap and open whole MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A question card cut a long address (a sign-in link) off at its edge and gave no way to open it. Question text, option labels and descriptions, the review, the in-terminal card and the screen-read "Needs your answer" card now wrap inside the card, and every http(s) address in them is a link that opens the whole address. A link inside an option opens without picking it. An address that runs into the box's 1,000-byte cut ("…") is shown but not linked. At a phone's width the form's Submit goes under Back instead of past the card's edge. Co-Authored-By: Claude Opus 5.5 --- .../skills/verify-berthd/features/README.md | 2 +- .../verify-berthd/features/chat-questions.md | 5 + .../scripts/scenarios/chat-question-links.mjs | 125 ++++++++++++++++++ app/e2e/ask-card.spec.ts | 21 +++ app/e2e/question-links.spec.ts | 96 ++++++++++++++ .../conversation/conversation-view.tsx | 7 +- .../components/conversation/conversation.css | 5 + .../components/conversation/linked-text.tsx | 31 +++++ .../components/conversation/question-form.tsx | 37 ++++-- app/src/lib/link-text.test.ts | 49 +++++++ app/src/lib/link-text.ts | 36 +++++ app/src/lib/mock-conversation.ts | 17 ++- docs/changelog.mdx | 6 + 13 files changed, 421 insertions(+), 16 deletions(-) create mode 100644 .claude/skills/verify-berthd/scripts/scenarios/chat-question-links.mjs create mode 100644 app/e2e/question-links.spec.ts create mode 100644 app/src/components/conversation/linked-text.tsx create mode 100644 app/src/lib/link-text.test.ts create mode 100644 app/src/lib/link-text.ts diff --git a/.claude/skills/verify-berthd/features/README.md b/.claude/skills/verify-berthd/features/README.md index 11a07f637..102489893 100644 --- a/.claude/skills/verify-berthd/features/README.md +++ b/.claude/skills/verify-berthd/features/README.md @@ -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 | diff --git a/.claude/skills/verify-berthd/features/chat-questions.md b/.claude/skills/verify-berthd/features/chat-questions.md index 4d7fba5fc..cf725572a 100644 --- a/.claude/skills/verify-berthd/features/chat-questions.md +++ b/.claude/skills/verify-berthd/features/chat-questions.md @@ -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. @@ -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 @@ -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. diff --git a/.claude/skills/verify-berthd/scripts/scenarios/chat-question-links.mjs b/.claude/skills/verify-berthd/scripts/scenarios/chat-question-links.mjs new file mode 100644 index 000000000..503d36ef7 --- /dev/null +++ b/.claude/skills/verify-berthd/scripts/scenarios/chat-question-links.mjs @@ -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://example-shop.myshopify.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"); +} diff --git a/app/e2e/ask-card.spec.ts b/app/e2e/ask-card.spec.ts index 929571fcc..da436c18e 100644 --- a/app/e2e/ask-card.spec.ts +++ b/app/e2e/ask-card.spec.ts @@ -98,6 +98,27 @@ 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); + await link.click(); + await expect.poll(() => app.page.evaluate(() => (window as unknown as { __opened: string[] }).__opened)).toEqual([URL]); + expect(agent.sends).toEqual([]); + expect(await card.evaluate((el) => el.scrollWidth <= el.clientWidth)).toBe(true); +}); + // 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 = [ diff --git a/app/e2e/question-links.spec.ts b/app/e2e/question-links.spec.ts new file mode 100644 index 000000000..96c735e9a --- /dev/null +++ b/app/e2e/question-links.spec.ts @@ -0,0 +1,96 @@ +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://example-shop.myshopify.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 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); +}); diff --git a/app/src/components/conversation/conversation-view.tsx b/app/src/components/conversation/conversation-view.tsx index eff616c5c..6a97bf313 100644 --- a/app/src/components/conversation/conversation-view.tsx +++ b/app/src/components/conversation/conversation-view.tsx @@ -24,6 +24,7 @@ import { ChatList } from "@/components/conversation/chat-list"; import { ChatSearch, plainMarkdown, type SearchEntry } from "@/components/conversation/chat-search"; import { EditChange, EditPanel } from "@/components/conversation/edit-diff"; import { ArtifactCard, ArtifactJumper } from "@/components/conversation/artifacts"; +import { LinkedText } from "@/components/conversation/linked-text"; import { QuestionCard } from "@/components/conversation/question-form"; import { SelectionActions } from "@/components/conversation/selection-actions"; import { PromptActions, PromptActionsContext, type PromptContext } from "@/components/conversation/prompt-actions"; @@ -689,7 +690,11 @@ function Ask({ it, onAnswer }: { it: Extract; o {question ? "Needs your answer" : "Wants to run a command"} - {it.detail && (question ?

{it.detail}

: {it.detail})} + {it.detail && (question ? ( +

+ +

+ ) : {it.detail})}
{choices.map((c, i) => ( diff --git a/app/src/components/conversation/conversation.css b/app/src/components/conversation/conversation.css index ababa78ed..3e1453766 100644 --- a/app/src/components/conversation/conversation.css +++ b/app/src/components/conversation/conversation.css @@ -46,6 +46,11 @@ .cv-md strong { font-weight: 600; } .cv-md a { color: var(--link, var(--foreground)); text-decoration: underline; text-decoration-color: color-mix(in srgb, currentColor 35%, transparent); text-underline-offset: 2px; } .cv-md a:hover { text-decoration-color: currentColor; } +/* An address in a question or its options (linked-text.tsx), drawn as a + reply's links are. */ +a.cv-link { color: var(--link, var(--foreground)); text-decoration: underline; text-decoration-color: color-mix(in srgb, currentColor 35%, transparent); text-underline-offset: 2px; outline: none; border-radius: 3px; } +a.cv-link:hover, a.cv-link:focus-visible { text-decoration-color: currentColor; } +a.cv-link:focus-visible { box-shadow: 0 0 0 2px var(--ring); } /* An issue, pull request or commit the reply names (repo-refs.tsx): a link the app made rather than one the agent wrote, so a quieter dotted line; hovered or focused, the line in the text's own colour. A commit's hash diff --git a/app/src/components/conversation/linked-text.tsx b/app/src/components/conversation/linked-text.tsx new file mode 100644 index 000000000..0ba7bf30c --- /dev/null +++ b/app/src/components/conversation/linked-text.tsx @@ -0,0 +1,31 @@ +import { Fragment } from "react"; + +import { linkParts } from "@/lib/link-text"; +import { openUrl } from "@/lib/open-url"; + +// An agent's plain text (a question, an option) with its web addresses as +// links that open the whole address in the browser (lib/link-text). An +// option's text sits in its