Public: anyone can read this; the crew can change it.
The /talk page speaks the home page's language #9704
Check · Review · Land · Live done — Landed and live in production.
mason/forge:mason/9690-the-talk-page-speaks-the-home-page-s-lan → main 7 commits fork 2 threads · 2 unresolved
4 files
changed +29
−9
docs/design/areas/talk.md +5 −3
| @@ -5,10 +5,10 @@ | ||
| 5 | 5 | - `.talk` — the page's column: at most 760px, centred, `min-width: 0` so nothing pushes the page sideways at 375px. |
| 6 | 6 | - `.talk-log` — the ordered list of turns; its `data-last` is the last turn id the stream resumes from. |
| 7 | 7 | - `.talk-turn` — one turn; wraps anywhere, so a long word or URL never scrolls the page. |
| 8 | - `.talk-person` — the person's words: a right-aligned bubble, pre-wrapped. | |
| 8 | - `.talk-person` — the person's words: right-aligned, no box, pre-wrapped. | |
| 9 | 9 | - `.talk-agent` — the agent's reply, markdown, full width, no bubble. |
| 10 | 10 | - `.talk-tool` — what the agent did, one muted small line. |
| 11 | - `.talk-confirm` — a held action's card: the sentence, then Confirm and Cancel at 44px high. | |
| 11 | - `.talk-confirm` — a held action: the sentence, then Confirm and Cancel at 44px high. | |
| 12 | 12 | - `.talk-compose` — the composer, sticky to the bottom of the viewport (padded for the phone's safe area), so it stays reachable; its textarea is 16px so iOS does not zoom. |
| 13 | 13 | - `.talk-status` — one polite line (sending, answering, an error); it always takes its line, so nothing shifts. |
| 14 | 14 | - `.talk-more` — the "Earlier" link above the log. |
| @@ -33,6 +33,8 @@ | ||
| 33 | 33 | - `.talk-attach` — the "Attach" file-picker label in the composer bar, a `.btn` that stays hidden until the script runs (the picker needs it). |
| 34 | 34 | |
| 35 | 35 | Under a reply, `.talk-cost` is one quiet line with the turn's dollars; above the log, `.talk-context` says how full the context is and, from 80 %, suggests a new conversation. Both are `small muted` text, no new colour. |
| 36 | - `.talk-queued` — a message waiting for the running turn: the person bubble, dimmed with a dashed border, its "Queued" line and Cancel under it. | |
| 36 | - `.talk-queued` — a message waiting for the running turn: the person's words, dimmed, its "Queued" line and Cancel under it. | |
| 37 | 37 | |
| 38 | 38 | Search (#8823) adds no component class: the form is a `.bar` with `.input` and `.btn`, and the results and the conversations list are `.list` of `row()`s (an "archived" `badge` as the trail, the match as the row's meta). The turn a result opens at is marked by a muted hairline on its left edge, `.talk-turn:target`, the one new rule, which replaces the two `.talk-chats li a` rules the rows made unnecessary. |
| 39 | ||
| 40 | #9690: the page speaks the home page's language: an `sr-only` h1, the conversations as a `.list` of rows, and no box round a turn, a tool call or a held action; the only hairline is above the composer. No new classes. |
src/web/pages/talk.ts +2 −2
| @@ -117,7 +117,7 @@ const ranOutOf = (ctx: Ctx, userId: string, turns: readonly Turn[]): Set<string> | ||
| 117 | 117 | const meta = (who: string, at: string): Html => |
| 118 | 118 | html`<p class="talk-meta small muted">${who} · ${time(at)}</p>`; |
| 119 | 119 | |
| 120 | /** A tool call or its outcome as a quiet card: closed, the name alone; open, the whole line. */ | |
| 120 | /** A tool call or its outcome as a quiet line: closed, the name alone; open, the whole line. */ | |
| 121 | 121 | const toolCard = (id: string, says: string, name: string, at: string, set = ""): Html => |
| 122 | 122 | html`<li class="talk-turn talk-tool${set}" id="${id}"><details><summary>${name.slice(0, 60)} <span class="talk-when">${time(at)}</span></summary><p>${says}</p></details></li>`; |
| 123 | 123 | |
| @@ -298,7 +298,7 @@ function talkPage(ctx: Ctx, conversation: Conversation, before0: string | undefi | ||
| 298 | 298 | const acts = !paged ? exchangeActs(ctx.app.db, user.id, conversation.id, running, here) : new Map<string, Html>(); |
| 299 | 299 | // Edit shows the composer holding the last message, posting to Edit instead of Send. |
| 300 | 300 | const editing = edit ? lastExchange(ctx.app.db, user.id, conversation.id).person : undefined; |
| 301 | const body = html`<div class="page-head"><h1>Talk</h1></div> | |
| 301 | const body = html`<h1 class="sr-only">Talk</h1> | |
| 302 | 302 | ${chats(ctx, conversation, showArchived, typed)} |
| 303 | 303 | <div class="talk" data-talk data-user="${user.id}" data-c="${conversation.id}" data-here="${here}" data-paged="${paged ? "1" : "0"}" data-running="${running && !paged ? "1" : "0"}"> |
| 304 | 304 | <div> |
src/web/pages/talk_client.ts +3 −4
| @@ -16,10 +16,10 @@ export const talkCss = /* css */ ` | ||
| 16 | 16 | .talk-body.prose table { display: block; max-width: 100%; overflow-x: auto; } |
| 17 | 17 | .talk-body.prose :is(p, ul, ol, pre, table, blockquote) { margin: 0 0 8px; } |
| 18 | 18 | .talk-body.prose > :last-child { margin-bottom: 0; } |
| 19 | .talk-person { justify-self: end; max-width: 85%; background: var(--muted); border: 1px solid var(--border); border-radius: var(--radius-lg); padding: 0.5rem 0.75rem; white-space: pre-wrap; } | |
| 19 | .talk-person { justify-self: end; max-width: 85%; padding: 4px 0; white-space: pre-wrap; } | |
| 20 | 20 | .talk-agent { justify-self: start; max-width: 100%; padding: 4px 0; } |
| 21 | 21 | .talk-tool { color: var(--muted); font-size: var(--fs-min); padding: 0 4px; } |
| 22 | .talk-confirm { border: 1px solid var(--border); border-radius: var(--radius-lg); padding: 0.75rem; background: var(--card); } | |
| 22 | .talk-confirm { padding: 8px 0; } | |
| 23 | 23 | .talk-confirm .bar { margin-top: 8px; gap: 8px; } |
| 24 | 24 | .talk-confirm .btn { min-height: 2.75rem; flex: 1 1 8rem; } |
| 25 | 25 | .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; } |
| @@ -32,7 +32,6 @@ export const talkCss = /* css */ ` | ||
| 32 | 32 | .talk-copy { margin-left: auto; background: none; border: 0; color: inherit; font: inherit; cursor: pointer; padding: 6px 8px; min-height: 2rem; opacity: 0.7; } |
| 33 | 33 | .talk-copy:hover { opacity: 1; text-decoration: underline; } |
| 34 | 34 | .talk-plain { white-space: pre-wrap; } |
| 35 | .talk-tool details { border: 1px solid var(--border); border-radius: var(--radius-lg); padding: 2px 10px; } | |
| 36 | 35 | .talk-tool summary { cursor: pointer; padding: 6px 0; min-height: 1.5rem; } |
| 37 | 36 | .talk-tool details p { margin: 0 0 8px; white-space: pre-wrap; } |
| 38 | 37 | .talk-steps { margin: 4px 0 0; } |
| @@ -52,7 +51,7 @@ export const talkCss = /* css */ ` | ||
| 52 | 51 | .talk-replaced { opacity: 0.55; } |
| 53 | 52 | .talk-failed form { margin-top: 6px; } |
| 54 | 53 | .talk-failed .btn, .talk-act { min-height: 2.75rem; } |
| 55 | .talk-queued { opacity: 0.7; border-style: dashed; } | |
| 54 | .talk-queued { opacity: 0.7; } | |
| 56 | 55 | .talk-acts { display: flex; gap: 6px; justify-content: flex-end; margin-top: 4px; } |
| 57 | 56 | .talk-agent .talk-acts { justify-content: flex-start; } |
| 58 | 57 | `; |
test/talk-page.test.ts +19
| @@ -78,6 +78,25 @@ afterAll(async () => { | ||
| 78 | 78 | }); |
| 79 | 79 | |
| 80 | 80 | describe("the page", () => { |
| 81 | test("the conversations are rows and no turn sits in a box (#9690)", async () => { | |
| 82 | say(alice, "person", "hi"); | |
| 83 | const html = await (await get("/talk", alice)).text(); | |
| 84 | expect(html).toContain('<h1 class="sr-only">Talk</h1>'); | |
| 85 | expect(html).not.toContain('class="page-head"'); | |
| 86 | const nav = html.slice(html.indexOf('<nav class="talk-chats"'), html.indexOf("</nav>")); | |
| 87 | expect(nav).toContain('<ul class="list">'); | |
| 88 | const { talkCss } = await import("../src/web/pages/talk_client.ts"); | |
| 89 | const rule = (sel: string) => talkCss.match(new RegExp(`(?:^|\\n)${sel.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\s*\\{([^}]*)\\}`))?.[1]; | |
| 90 | expect(rule(".talk-person")).toBeDefined(); | |
| 91 | expect(rule(".talk-person")).not.toMatch(/border|background/); | |
| 92 | expect(rule(".talk-confirm")).toBeDefined(); | |
| 93 | expect(rule(".talk-confirm")).not.toMatch(/border|background/); | |
| 94 | expect(rule(".talk-queued")).toBeDefined(); | |
| 95 | expect(rule(".talk-queued")).not.toContain("border-style"); | |
| 96 | expect(talkCss).not.toMatch(/\.talk-tool details\s*\{[^}]*border/); | |
| 97 | expect(talkCss).not.toMatch(/\.talk-chats ul\s*\{/); | |
| 98 | }); | |
| 99 | ||
| 81 | 100 | test("signed out goes to /signin and comes back", async () => { |
| 82 | 101 | const res = await get("/talk"); |
| 83 | 102 | expect(res.status).toBe(303); |
Vacuous: a failed fetch becomes "" through
.catch, and the CSS assertions below then pass. ImporttalkCssand assert on it.