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..69ecda14d --- /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://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"); +} diff --git a/app/e2e/ask-card.spec.ts b/app/e2e/ask-card.spec.ts index 929571fcc..3642756a7 100644 --- a/app/e2e/ask-card.spec.ts +++ b/app/e2e/ask-card.spec.ts @@ -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 = [ diff --git a/app/e2e/question-links.spec.ts b/app/e2e/question-links.spec.ts new file mode 100644 index 000000000..a5b1f7830 --- /dev/null +++ b/app/e2e/question-links.spec.ts @@ -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); +}); 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..2948bf11d --- /dev/null +++ b/app/src/components/conversation/linked-text.tsx @@ -0,0 +1,33 @@ +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
)} -
+ {/* At a phone's width Back, the status (min-w-32) and Submit answers don't fit on one line: the button goes under, on the right. */} +
{steps > 1 && ( )} - {busy ? `Answering in ${who}'s form…` : failed ? "Not sent yet" : review ? "Check your answers, then submit" : `${who} waits for your answer`} - diff --git a/app/src/lib/link-text.test.ts b/app/src/lib/link-text.test.ts new file mode 100644 index 000000000..fe149f77b --- /dev/null +++ b/app/src/lib/link-text.test.ts @@ -0,0 +1,62 @@ +// node --experimental-strip-types --test src/lib/link-text.test.ts (pnpm test) +import assert from "node:assert/strict"; +import { test } from "node:test"; + +import { linkParts } from "./link-text.ts"; + +const OAUTH = "https://shop.example.com/admin/oauth/authorize?client_id=0000example&scope=read_products,write_products&redirect_uri=https%3A%2F%2Fapp.example.com%2Fauth%2Fcallback&state=mock-state-1234567890abcdef"; + +test("a long sign-in address with a query string is one link, whole", () => { + assert.deepEqual(linkParts(`Open ${OAUTH} to approve the app`), [{ text: "Open " }, { text: OAUTH, url: OAUTH }, { text: " to approve the app" }]); +}); + +test("punctuation that ends the sentence stays out of the link", () => { + assert.deepEqual(linkParts("See https://example.com/a."), [{ text: "See " }, { text: "https://example.com/a", url: "https://example.com/a" }, { text: "." }]); + assert.deepEqual(linkParts("(see https://example.com/b)"), [{ text: "(see " }, { text: "https://example.com/b", url: "https://example.com/b" }, { text: ")" }]); +}); + +test("every address in the text is a link", () => { + assert.deepEqual(linkParts("https://a.example.com and http://b.example.com/x"), [ + { text: "https://a.example.com", url: "https://a.example.com" }, + { text: " and " }, + { text: "http://b.example.com/x", url: "http://b.example.com/x" }, + ]); +}); + +test("an address the box cut short, into its trailing …, is shown but not linked", () => { + const clipped = `Open ${OAUTH.slice(0, 120)}…`; + assert.deepEqual(linkParts(clipped), [{ text: clipped }]); + // Cut right after a punctuation mark the address doesn't end in, too. + assert.deepEqual(linkParts("Open https://example.com/a?x=1&…"), [{ text: "Open https://example.com/a?x=1&…" }]); +}); + +test("an apostrophe in an address is part of it, and a quote around one isn't", () => { + const url = "https://example.com/search?q=O'Reilly&page=2"; + assert.deepEqual(linkParts(`See ${url} now`), [{ text: "See " }, { text: url, url }, { text: " now" }]); + assert.deepEqual(linkParts("Open 'https://example.com/a' first"), [{ text: "Open '" }, { text: "https://example.com/a", url: "https://example.com/a" }, { text: "' first" }]); +}); + +test("an address with an apostrophe that the box cut short is not linked either", () => { + assert.deepEqual(linkParts("Open https://example.com/search?q=O'Reill…"), [{ text: "Open https://example.com/search?q=O'Reill…" }]); +}); + +test("an ellipsis inside the text is not the box's cut", () => { + assert.deepEqual(linkParts("Try https://example.com/a… or not"), [{ text: "Try " }, { text: "https://example.com/a", url: "https://example.com/a" }, { text: "… or not" }]); + // A sentence's punctuation before the ellipsis stays out of the link too. + assert.deepEqual(linkParts("Try https://example.com/a.… or not"), [{ text: "Try " }, { text: "https://example.com/a", url: "https://example.com/a" }, { text: ".… or not" }]); + assert.deepEqual(linkParts("(try https://example.com/a)… or not"), [{ text: "(try " }, { text: "https://example.com/a", url: "https://example.com/a" }, { text: ")… or not" }]); +}); + +test("an address that ended before the cut is still a link", () => { + assert.deepEqual(linkParts("Open https://example.com/a and then some words that run on…"), [{ text: "Open " }, { text: "https://example.com/a", url: "https://example.com/a" }, { text: " and then some words that run on…" }]); +}); + +test("other schemes are not linked", () => { + assert.deepEqual(linkParts("Mail mailto:ops@example.com or open file:///etc/hosts"), [{ text: "Mail mailto:ops@example.com or open file:///etc/hosts" }]); + assert.deepEqual(linkParts("javascript:alert(1)"), [{ text: "javascript:alert(1)" }]); +}); + +test("text without an address is one part, and no text is none", () => { + assert.deepEqual(linkParts("Which release?"), [{ text: "Which release?" }]); + assert.deepEqual(linkParts(""), []); +}); diff --git a/app/src/lib/link-text.ts b/app/src/lib/link-text.ts new file mode 100644 index 000000000..40b8f46f5 --- /dev/null +++ b/app/src/lib/link-text.ts @@ -0,0 +1,37 @@ +// Web addresses in an agent's plain text (a question, its options), as +// parts to draw: text, and links that open the whole address. + +import { findUrls, trimUrl } from "./term-links.ts"; + +export interface LinkPart { + text: string; + // Set when the part is a link: the address it opens. + url?: string; +} + +// The box cuts a long question or description and ends it in "…" +// (internal/transcript clip). +const CUT = "…"; + +// linkParts splits text at its http(s) addresses. An address that runs into +// the box's cut at the end isn't linked: it would open a page that isn't the +// one meant. An ellipsis after an address anywhere else is the sentence's, +// and so is any punctuation before it ("…/a.… or not"). +export function linkParts(text: string): LinkPart[] { + const out: LinkPart[] = []; + let at = 0; + for (const m of findUrls(text, true)) { + if (!/^https?:\/\//i.test(m.url)) continue; + let url = m.url; + if (url.endsWith(CUT)) { + if (m.end === text.length) continue; + url = trimUrl(url.replace(/…+$/, "")); + } + if (!/^https?:\/\/./i.test(url)) continue; + if (m.start > at) out.push({ text: text.slice(at, m.start) }); + out.push({ text: url, url }); + at = m.start + url.length; + } + if (at < text.length) out.push({ text: text.slice(at) }); + return out; +} diff --git a/app/src/lib/mock-conversation.ts b/app/src/lib/mock-conversation.ts index b496102a2..141ba6edd 100644 --- a/app/src/lib/mock-conversation.ts +++ b/app/src/lib/mock-conversation.ts @@ -155,6 +155,20 @@ const RELEASE_QUESTIONS: Question[] = [ { header: "Reviewer", question: "Who should review the change?", options: [{ label: "bailey" }, { label: "me" }] }, ]; +// ?scene=question-links: questions with long addresses in them, as a +// sign-in page's (made up), and one the box cut short (its "…"), which +// is shown but not 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 CUT_URL = "https://logs.example.com/explorer?query=resource.type%3D%22cloud_run_revision%22%20severity%3E%3DERROR&cursorTimestamp=2026-10-27T15:00:00Z&duration=PT1H&project=acme-shop&"; +const LINK_QUESTIONS: Question[] = [ + { + header: "Connect", + question: `Approve the app for the shop at ${LINK_URL} and pick how it went.`, + options: [{ label: "Approved", description: `If it asks again, open ${LINK_URL}&grant_options%5B%5D=per-user instead.` }, { label: "It failed" }], + }, + { header: "Logs", question: "Did the errors stop?", options: [{ label: "Yes" }, { label: "No", description: `The log the box cut short: ${CUT_URL}…` }] }, +]; + const RELEASE_PLAN = `## The fix, in short Webhook retries now reuse the order the first delivery made: @@ -204,13 +218,14 @@ function releaseChat(): TranscriptItem[] { const picture = new URLSearchParams(location.search).get("scene") === "chat-image" ? "\n\nThe checkout screenshot shows the confirmed order:\n\n![Checkout result](screenshots/checkout-result.png)" : ""; const tables = new URLSearchParams(location.search).get("scene") === "chat-tables"; const testReport = new URLSearchParams(location.search).get("scene") === "chat-tests"; + const links = new URLSearchParams(location.search).get("scene") === "question-links"; return [ { kind: "user", id: "rq-u1", text: "Plan the release of the checkout fix." }, { kind: "tools", id: "rq-t1", verb: "Read", done: true, items: [{ verb: "Read", target: "webhook.ts", file: true, id: "mock-read-1" }, { verb: "Read", target: "createOrder.ts", file: true, id: "mock-read-2" }] }, { kind: "text", id: "rq-x1", text: RELEASE_PLAN + picture }, ...(tables ? [{ kind: "text" as const, id: "rq-x2", text: TABLE_REPORT }] : []), ...(testReport ? [{ kind: "text" as const, id: "rq-x2", text: TEST_REPORT }] : []), - { kind: "question", id: "rq-q1", tool: "mock-ask-release", questions: RELEASE_QUESTIONS }, + { kind: "question", id: "rq-q1", tool: "mock-ask-release", questions: links ? LINK_QUESTIONS : RELEASE_QUESTIONS }, ]; } diff --git a/app/src/lib/term-links.ts b/app/src/lib/term-links.ts index 49ab0c2dd..b9e30ca40 100644 --- a/app/src/lib/term-links.ts +++ b/app/src/lib/term-links.ts @@ -13,30 +13,40 @@ export interface UrlMatch { // Web and mail addresses only: what the app can hand to the system browser // (lib/open-url, and the shell plugin's scope in the app). const URL_RE = /\b(?:https?:\/\/|mailto:)[^\s<>"'`]+/gi; +// In prose an apostrophe can be part of an address (?q=O'Reilly): it is +// kept, and one that closes a quote around it is trimmed as punctuation. +const PROSE_URL_RE = /\b(?:https?:\/\/|mailto:)[^\s<>"`]+/gi; const TRAILING = /[.,;:!?'"*]+$/; const CLOSERS: Record = { ")": "(", "]": "[", "}": "{", ">": "<" }; // findUrls finds the addresses in a line of a terminal's text. Punctuation // that ends a sentence is left out, and so is a closing bracket the address // doesn't open: "(see https://example.com/a)" links https://example.com/a, -// https://en.wikipedia.org/wiki/Tmux_(software) keeps its own. -export function findUrls(line: string): UrlMatch[] { +// https://en.wikipedia.org/wiki/Tmux_(software) keeps its own. prose is +// for an agent's own text rather than a terminal's (lib/link-text). +export function findUrls(line: string, prose = false): UrlMatch[] { const out: UrlMatch[] = []; - URL_RE.lastIndex = 0; - for (let m = URL_RE.exec(line); m; m = URL_RE.exec(line)) { - let url = m[0]; - for (;;) { - const before = url; - url = url.replace(TRAILING, ""); - const last = url.at(-1); - if (last && CLOSERS[last] && count(url, CLOSERS[last]) < count(url, last)) url = url.slice(0, -1); - if (url === before) break; - } + const re = prose ? PROSE_URL_RE : URL_RE; + re.lastIndex = 0; + for (let m = re.exec(line); m; m = re.exec(line)) { + const url = trimUrl(m[0]); if (/^(?:https?:\/\/|mailto:)./i.test(url)) out.push({ start: m.index, end: m.index + url.length, url }); } return out; } +// trimUrl drops what findUrls leaves out of an address's end: a sentence's +// punctuation and a closing bracket it doesn't open. +export function trimUrl(url: string): string { + for (;;) { + const before = url; + url = url.replace(TRAILING, ""); + const last = url.at(-1); + if (last && CLOSERS[last] && count(url, CLOSERS[last]) < count(url, last)) url = url.slice(0, -1); + if (url === before) return url; + } +} + // A terminal's rows as lib/terminal reads them, for links that run on from // one row to the next: text has one character per cell (so a place in it // is a column), and wrapped says the terminal soft-wrapped the row onto diff --git a/docs/changelog.mdx b/docs/changelog.mdx index bd1f405d4..b8464e5f0 100644 --- a/docs/changelog.mdx +++ b/docs/changelog.mdx @@ -10,6 +10,12 @@ upgrades with `berth upgrade BOX`, or by running the again. ## Unreleased +- **Links in an agent's questions are links.** A long address in a question + card, such as a sign-in link, now wraps inside the card instead of being + cut off at its edge. Clicking it opens the whole address in your browser, + and a link in an option opens without choosing the option. An address the + box had to cut short is shown but not linked, so you never open half of + one. - **Worktrees fits a narrow window.** With the sidebar open on a small window, the table and its filters ran off the right edge. Now the Port column steps aside first and "vs base" after it (both are in a