Public: anyone can read this; the crew can change it.
/talk passes the cold read: phone layout, keyboard and screen reader checked, with looks on the pull #12152
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/8824-talk-passes-the-cold-read-phone-layout-k → main 10 commits fork 3 threads · 3 unresolved
4 files
changed +57
−8
docs/features/talk.md +3 −1
| @@ -12,7 +12,9 @@ It needs a granted Claude account (`no_account`), and says `resting` when none c | ||
| 12 | 12 | |
| 13 | 13 | ## On the web: `/talk` |
| 14 | 14 | |
| 15 | Reached from the Talk icon in every page's top bar, "Talk to the forge" in the palette (`Ctrl/Cmd+K`), `g t`, and the Talk button beside the tell box on home. Signed out, it sends you to /signin and back. It shows your own conversation (the last 50 turns, "Earlier" pages back) and a composer; Ctrl+Enter sends. | |
| 15 | Reached from the Talk icon in every page's top bar, "Talk to the forge" in the palette (`Ctrl/Cmd+K`), `g t`, and the Talk button beside the tell box on home. Signed out, it sends you to /signin and back. It shows your own conversation (the last 50 turns, "Earlier" pages back) and a composer; Ctrl+Enter sends. `/` jumps to the composer from anywhere on the page except inside a field (and with no Ctrl, Cmd or Alt held); the composer carries `aria-keyshortcuts="/"`. It is a web shortcut only: the terminal door (`ssh <forge> talk`, `forge talk`) takes its words as typed and has no shortcut keys, so `/` there is just a character. | |
| 16 | ||
| 17 | On a phone the composer is sticky and lifts above the on-screen keyboard (the script sets `--kb` from `visualViewport`); the log is a `role=log` (polite, additions only) and the status line `role=status`, each already a polite live region, so a reply is announced once. | |
| 16 | 18 | |
| 17 | 19 | A reply streams in over `GET /talk/stream?after=<turn id>` (server-sent events, one `turn` frame per turn, each carrying the server-rendered markup). The stream ends with the turn (`done`), or after 40 seconds (`more`), well inside the edge's timeout; the page then reconnects from the last turn id it saw (`Last-Event-ID` works too). Without the script the composer is a plain form and a reload shows the answer. |
| 18 | 20 |
src/web/pages/talk.ts +2 −2
| @@ -347,10 +347,10 @@ async function talkPage(ctx: Ctx, conversation: Conversation, before0: string | | ||
| 347 | 347 | ${aboutLine(ctx)} |
| 348 | 348 | <form class="talk-compose" id="compose" method="post" action="${editing === undefined ? here : `${here}/again/edit`}" ${editing === undefined ? null : html`data-edit`}> |
| 349 | 349 | ${conversation.archivedAt === null ? null : html`<p class="small muted" data-archived-note>Archived: sending here keeps it archived; Restore puts it back in the list.</p>`} |
| 350 | <p class="talk-status small muted" role="status" aria-live="polite">${running ? "The forge is answering…" : ""}</p> | |
| 350 | <p class="talk-status small muted" role="status">${running ? "The forge is answering…" : ""}</p> | |
| 351 | 351 | <div class="talk-chips" data-chips role="group" aria-label="Attached files"></div> |
| 352 | 352 | ${editing === undefined && aboutText(ctx.app.db, ctx.url.searchParams.get("about"), ctx.auth.user) !== null ? html`<input type="hidden" name="about" value="${ctx.url.searchParams.get("about")}" />` : null} |
| 353 | <textarea class="input" name="text" rows="1" maxlength="${WORDS_MAX}" required placeholder="Talk to the forge" aria-label="Talk to the forge">${editing === undefined ? prefill : editing.text}</textarea> | |
| 353 | <textarea class="input" name="text" rows="1" maxlength="${WORDS_MAX}" required placeholder="Talk to the forge" aria-label="Talk to the forge" aria-keyshortcuts="/">${editing === undefined ? prefill : editing.text}</textarea> | |
| 354 | 354 | <div class="bar"> |
| 355 | 355 | <span class="muted small" data-compose-hint></span> |
| 356 | 356 | <label class="btn talk-attach" data-attach hidden>Attach<input type="file" multiple hidden data-pick /></label> |
src/web/pages/talk_client.ts +13 −3
| @@ -19,7 +19,7 @@ | ||
| 19 | 19 | |
| 20 | 20 | export const talkCss = /* css */ ` |
| 21 | 21 | .talk { display: grid; grid-template-rows: auto 1fr auto; gap: 0.75rem; max-width: min(48rem, 100%); margin: 0 auto; min-width: 0; } |
| 22 | .talk-shell, .talk-pane { display: grid; gap: 0.75rem; min-width: 0; align-content: start; } | |
| 22 | .talk-shell, .talk-pane { display: grid; grid-template-columns: minmax(0, 1fr); gap: 0.75rem; min-width: 0; align-content: start; } | |
| 23 | 23 | .talk-frame { width: 100%; height: calc(100dvh - 12rem); border: 0; } |
| 24 | 24 | @media (min-width: 64rem) { |
| 25 | 25 | .talk-shell { grid-template-columns: 14rem minmax(0, 1fr) minmax(0, 1.2fr); height: calc(100dvh - 7rem); } |
| @@ -46,9 +46,9 @@ export const talkCss = /* css */ ` | ||
| 46 | 46 | .talk-confirm { padding: 8px 0; } |
| 47 | 47 | .talk-confirm .bar { margin-top: 8px; gap: 8px; } |
| 48 | 48 | .talk-confirm .btn { min-height: 2.75rem; flex: 1 1 8rem; } |
| 49 | .talk-compose { position: sticky; bottom: 0; background: var(--bg); padding: 0.5rem 0 max(0.5rem, env(safe-area-inset-bottom)); border-top: 1px solid var(--border); z-index: 8; } | |
| 49 | .talk-compose { position: sticky; bottom: var(--kb, 0px); background: var(--bg); padding: 0.5rem 0 max(0.5rem, env(safe-area-inset-bottom)); border-top: 1px solid var(--border); z-index: 8; } | |
| 50 | 50 | .talk-compose textarea { width: 100%; min-height: 44px; max-height: 40dvh; font-size: 16px; resize: none; overflow-y: auto; box-sizing: border-box; } |
| 51 | .talk-compose .bar { margin-top: 6px; gap: 8px; align-items: center; } | |
| 51 | .talk-compose .bar { margin-top: 6px; gap: 8px; align-items: center; flex-wrap: wrap; } | |
| 52 | 52 | .talk-compose .btn { min-height: 2.75rem; } |
| 53 | 53 | .talk-status { min-height: 1.5em; margin: 0; } |
| 54 | 54 | .talk-context, .talk-cost { margin: 0; } |
| @@ -99,6 +99,16 @@ if (root) { | ||
| 99 | 99 | const jump = Object.assign(document.createElement("button"), { type: "button", className: "btn talk-jump", textContent: "Latest ↓", hidden: true }); |
| 100 | 100 | jump.setAttribute("aria-label", "Jump to the latest message"); |
| 101 | 101 | form.appendChild(jump); |
| 102 | // The keyboard covers the layout viewport on phones: lift the composer by what it hides. | |
| 103 | const vv = window.visualViewport; | |
| 104 | if (vv) { | |
| 105 | const lift = () => { document.documentElement.style.setProperty("--kb", Math.max(0, window.innerHeight - vv.height - vv.offsetTop) + "px"); }; | |
| 106 | vv.addEventListener("resize", lift); | |
| 107 | vv.addEventListener("scroll", lift); | |
| 108 | } | |
| 109 | document.addEventListener("keydown", (e) => { | |
| 110 | if (e.key === "/" && !e.metaKey && !e.ctrlKey && !e.altKey && !/^(INPUT|TEXTAREA|SELECT)$/.test(document.activeElement.tagName) && !document.activeElement.isContentEditable) { e.preventDefault(); box.focus(); } | |
| 111 | }); | |
outdated
Untested behaviour: no test fires |
||
| 102 | 112 | const nearEnd = () => window.innerHeight + window.scrollY >= document.documentElement.scrollHeight - 120; |
| 103 | 113 | let stuck = true; |
| 104 | 114 | const toEnd = (force) => { |
test/talk-page.test.ts +39 −2
| @@ -216,10 +216,26 @@ describe("the page", () => { | ||
| 216 | 216 | expect(talkCss).toContain(".talk-body.prose > * { max-width: none; }"); |
| 217 | 217 | }); |
| 218 | 218 | |
| 219 | test("phone and screen reader: new replies are announced, the composer lifts above the keyboard, nothing overflows sideways, / reaches the composer", async () => { | |
| 220 | const { talkCss, talkJs } = await import("../src/web/pages/talk_client.ts"); | |
| 221 | const page = await (await get("/talk", alice)).text(); | |
| 222 | // role=log and role=status already imply polite live regions: | |
| 223 | // a second aria-live would announce twice. | |
| 224 | expect(page).toMatch(/<ol class="talk-log" role="log" data-last/); | |
| 225 | expect(page).not.toContain("aria-live"); | |
| 226 | expect(page).toContain('aria-keyshortcuts="/"'); | |
| 227 | expect(talkCss).toContain("bottom: var(--kb, 0px)"); | |
| 228 | expect(talkCss).not.toContain("overflow-x: clip"); | |
| 229 | expect(talkCss).toMatch(/\.talk-shell, \.talk-pane \{[^}]*grid-template-columns: minmax\(0, 1fr\)/); | |
| 230 | expect(talkCss).toMatch(/\.talk-compose \.bar \{[^}]*flex-wrap: wrap/); | |
| 231 | expect(talkJs).toContain("visualViewport"); | |
| 232 | expect(talkJs).toContain('e.key === "/"'); | |
| 233 | }); | |
| 234 | ||
| 219 | 235 | test("the page's own bytes are held: stylesheet and script stay small", async () => { |
| 220 | 236 | const { talkCss, talkJs } = await import("../src/web/pages/talk_client.ts"); |
| 221 | expect(Buffer.byteLength(talkCss)).toBeLessThan(4_050); | |
| 222 | expect(Buffer.byteLength(talkJs)).toBeLessThan(14_000); | |
| 237 | expect(Buffer.byteLength(talkCss)).toBeLessThan(4_300); | |
| 238 | expect(Buffer.byteLength(talkJs)).toBeLessThan(14_800); | |
| 223 | 239 | say(alice, "agent", "```ts\nlet x = 1;\n```"); |
| 224 | 240 | const page = await get("/talk", alice); |
| 225 | 241 | const size = (await page.arrayBuffer()).byteLength; |
| @@ -325,6 +341,27 @@ describe("the page", () => { | ||
| 325 | 341 | }; |
| 326 | 342 | const tick = () => new Promise((r) => setTimeout(r, 5)); |
| 327 | 343 | |
| 344 | test("/ focuses the composer, but not while typing in a field and not with a modifier", async () => { | |
| 345 | const t = await boot(); | |
| 346 | const press = (init: object = {}) => { | |
| 347 | const e = new t.window.KeyboardEvent("keydown", { key: "/", bubbles: true, cancelable: true, ...init }); | |
| 348 | t.doc.dispatchEvent(e); | |
| 349 | return e.defaultPrevented; | |
| 350 | }; | |
| 351 | expect(press()).toBe(true); | |
| 352 | expect(t.doc.activeElement).toBe(t.box); | |
| 353 | t.box.blur(); | |
| 354 | const field = t.doc.createElement("input"); | |
| 355 | t.doc.body.appendChild(field); | |
| 356 | field.focus(); | |
| 357 | expect(press()).toBe(false); | |
| 358 | expect(t.doc.activeElement).toBe(field); | |
| 359 | field.blur(); | |
| 360 | expect(press({ ctrlKey: true })).toBe(false); | |
| 361 | expect(press({ metaKey: true })).toBe(false); | |
| 362 | expect(t.doc.activeElement).not.toBe(t.box); | |
| 363 | }); | |
| 364 | ||
| 328 | 365 | test("when a turn ends the preview frame is loaded again (a push has deployed)", async () => { |
| 329 | 366 | const t = await boot(); |
| 330 | 367 | const loads: string[] = []; |
new above
offline
Clipping masks the cause of any sideways scroll. Fix the overflowing element and prove it with a 360 px look.