feat(app): long links in an agent's questions wrap and open whole

ST
3d ago
+473-31

Summary

When an agent asks a question with a long address in it (a sign-in link, say), the question card cut the address off at its right edge and gave no way to open it. Now the address wraps inside the card, and every http(s) address in the agent's text is a link that opens the whole address.

 <QuestionCard>
   <Form>                       # Card overflow-hidden
-    <fieldset>                 # min-inline-size: min-content → as wide as the URL, clipped
+    <fieldset min-w-0>
-      {q.question}
+      <LinkedText text={q.question} />          # wrap-anywhere
       <label> <Radio/> <OptionText>
-        {label} {description}
+        <LinkedText …/>                         # click/Enter opens, never picks
     <Review>
-      truncate                                   # cut the question off
+      wrap-anywhere                              # whole; not linked (inside a <button>)
-    footer: Back · status · Submit             # Submit ran past the edge at phone width
+    footer flex-wrap, Submit ml-auto
   <InTerminal>, and the screen-read "Needs your answer" <Ask>: same LinkedText + wrap-anywhere
linkParts(text)                       # app/src/lib/link-text.ts, pure
  for m in findUrls(text, prose=true) # reuses term-links: trims sentence punctuation, unmatched brackets
    skip unless http(s)               # mailto:, file:, javascript: stay text
    if m runs into the trailing "…" at the end of the text   # the box's 1,000-byte cut
      skip                            # shown, never linked: no half addresses
    else strip a trailing "…" (the sentence's)
LinkedText → <a href onClick={preventDefault; stopPropagation; openUrl(url)} onKeyDown={stopPropagation}>

findUrls gets a prose flag that keeps an apostrophe inside an address (?q=O'Reilly). Without it, a cut address with an apostrophe linked its prefix. The terminal's matching is unchanged. A new mock scene, ?scene=question-links, has made-up sign-in addresses and one cut short.

Evidence

Before (main + the mock scene, 1440×900): the address runs past the card's edge, and nothing is a link.

before

After: verify-berthd's new chat-question-links scenario, drive.mjs chat-question-links, OK with 0 console errors. Both hrefs are the literal whole address (231 and 260 chars). A click on the link in "Approved"'s description, then Enter on it, each record window.open(<whole address>) (what openUrl calls outside the Mac app), and "Approved" stays unpicked. A click on the row's label then picks it. At every step the card's scrollWidth ≤ clientWidth.

1440 dark1440 light
390 dark390 lightcut short: no linkreview

Tests:

  • src/lib/link-text.test.ts covers a long OAuth URL with a query, trailing punctuation, quotes, several URLs, a URL cut into "…" (with and without an apostrophe), a mid-sentence "…", and non-http schemes. pnpm test: 614/614.
  • e2e/question-links.spec.ts (new, 4 tests) and e2e/ask-card.spec.ts (a new test: an address in the screen-read card links and opens, sending nothing to the agent) pass, and so does e2e/chat.spec.ts: 27/27 with --workers=1. The a11y chat-question scene passes in berth-dark and berth-light.
  • tsc (app and e2e), check:type, check:titles, check:grid, check:csp and pnpm build pass, and so does docs-site build (changelog). No Go or Rust changes.
  • Codex review: no findings. Codex adversarial review found one bug (an apostrophe address cut short became a partial link), fixed in the second commit with regression tests.

Not driven: the Mac app's window (Tauri shell.open) and a real box. The mock's browser build goes through window.open, the same openUrl call the app makes. The InTerminal card (a box without the answer capability) has the same LinkedText but no scenario of its own.

Merge Danger

Door: two-way

Front-end rendering and CSS only. Nothing is stored, there's no migration and no flag. Reverting the PR undoes it.

Blast Radius: question cards

The question form, the in-terminal question card and the screen-read "Needs your answer" card. The terminal's link matching keeps its default. At narrow widths the form's footer can now wrap Submit under Back.

🤖 Generated with Claude Code