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

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

scout/forge:scout/8824-talk-passes-the-cold-read-phone-layout-k → main 10 commits fork 3 threads · 3 unresolved

  • Merged. scout/8824-talk-passes-the-cold-read-phone-layout-k was merged into main as 9b150b4.

Review

Sign in to review this change.
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
1212
1313 ## On the web: `/talk`
1414
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.
1618
1719 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.
1820
src/web/pages/talk.ts +2 −2
@@ -347,10 +347,10 @@ async function talkPage(ctx: Ctx, conversation: Conversation, before0: string |
347347 ${aboutLine(ctx)}
348348 <form class="talk-compose" id="compose" method="post" action="${editing === undefined ? here : `${here}/again/edit`}" ${editing === undefined ? null : html`data-edit`}>
349349 ${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>
351351 <div class="talk-chips" data-chips role="group" aria-label="Attached files"></div>
352352 ${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>
354354 <div class="bar">
355355 <span class="muted small" data-compose-hint></span>
356356 <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 @@
1919
2020 export const talkCss = /* css */ `
2121 .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; }
2323 .talk-frame { width: 100%; height: calc(100dvh - 12rem); border: 0; }
2424 @media (min-width: 64rem) {
2525 .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 */ `
4646 .talk-confirm { padding: 8px 0; }
4747 .talk-confirm .bar { margin-top: 8px; gap: 8px; }
4848 .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; }
5050 .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; }
5252 .talk-compose .btn { min-height: 2.75rem; }
outdated

Clipping masks the cause of any sideways scroll. Fix the overflowing element and prove it with a 360 px look.

5353 .talk-status { min-height: 1.5em; margin: 0; }
5454 .talk-context, .talk-cost { margin: 0; }
@@ -99,6 +99,16 @@ if (root) {
9999 const jump = Object.assign(document.createElement("button"), { type: "button", className: "btn talk-jump", textContent: "Latest ↓", hidden: true });
100100 jump.setAttribute("aria-label", "Jump to the latest message");
101101 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 / and checks focus or the skip inside fields. Add a behavioural test.

102112 const nearEnd = () => window.innerHeight + window.scrollY >= document.documentElement.scrollHeight - 120;
103113 let stuck = true;
104114 const toEnd = (force) => {
test/talk-page.test.ts +39 −2
@@ -216,10 +216,26 @@ describe("the page", () => {
216216 expect(talkCss).toContain(".talk-body.prose > * { max-width: none; }");
217217 });
218218
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
219235 test("the page's own bytes are held: stylesheet and script stay small", async () => {
220236 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);
223239 say(alice, "agent", "```ts\nlet x = 1;\n```");
224240 const page = await get("/talk", alice);
225241 const size = (await page.arrayBuffer()).byteLength;
@@ -325,6 +341,27 @@ describe("the page", () => {
325341 };
326342 const tick = () => new Promise((r) => setTimeout(r, 5));
327343
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
328365 test("when a turn ends the preview frame is loaded again (a push has deployed)", async () => {
329366 const t = await boot();
330367 const loads: string[] = [];

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs