Public: anyone can read this; the crew can change it.
/talk renders code with highlighting and copy, and tables, as it streams, without jumping #8834
Check · Review · Land · Live done — Landed and live in production.
torque/forge:torque/8816-talk-renders-code-with-highlighting-and → main 2 commits fork 2 threads · 2 unresolved
4 files
changed +95
−2
docs/features/talk.md +9 −1
| @@ -35,8 +35,16 @@ Prior art read: Vercel AI Elements (Apache-2.0 React on shadcn; anatomy and beha | ||
| 35 | 35 | | Many conversations: new, list, search, rename, archive, URL each | next pull | needs a migration: today one row per person | |
| 36 | 36 | | Edit and resend, regenerate, Stop, queue | next pulls | need turn deletion and a cancel on `crew/conversation.ts` | |
| 37 | 37 | | Attachments, cost and context left, tables, thinking, plan line | later | need storage / token data / markdown tables | |
| 38 | | Syntax highlighting while streaming | not adding yet | re-tokenising a half-open fence makes text jump | | |
| 38 | | Code highlighting and tables in replies | this pull (#8816) | a reply is stored whole and drawn whole, so no half-open fence is ever tokenised | | |
| 39 | 39 | |
| 40 | 40 | **Without the script** the page is a plain form: Enter adds a line, starters are links that prefill the box, and Copy and Latest are not drawn (the script draws them, along with the "Enter sends" hint). The log is `role="log"`; with the script, a new message while you are scrolled up is announced in the status line. |
| 41 | 41 | |
| 42 | 42 | **The terminal** (`ssh <forge> talk <words>`, `forge talk`) sends one line and prints the answer; it has no log, so who/when, copy, tool cards, starters and the jump button do not apply to it. The conversation's whole history is read through `GET /api/v1/conversation/turns`, where every turn carries its role and time. |
| 43 | ||
| 44 | ## Code, tables and Copy in a reply (#8816) | |
| 45 | ||
| 46 | An agent reply is Markdown, drawn on the server by the same renderer as a README: fenced code is highlighted, tables and links render, and everything else is escaped (a `<script>` in a reply shows as text). A table wider than the column scrolls inside it; the page does not. The reply sits in the column like a person's turn, with none of the README's padding or line measure. | |
| 47 | ||
| 48 | A reply arrives as one finished turn, never as growing text, and the page only appends a turn whose id it has not drawn (a repeat is ignored). Turns already on the page are not touched, so nothing above a new reply moves, and the page follows the end only if you were already there (otherwise "Latest" appears). An unfinished fence in a stored reply is closed for drawing. The page's own bytes are held by a test: the stylesheet under 3.5 KB, the script under 6.5 KB, and a page with a code reply under 40 KB. | |
| 49 | ||
| 50 | **Copy:** each code block and each message has a Copy button, which copies the text, not the markup. The buttons are drawn by the script: with JavaScript off there are none, and the code is still there to select. |
src/web/pages/talk.ts +1 −1
| @@ -86,7 +86,7 @@ export function turnHtml(turn: Turn, waiting: ReadonlySet<string>): Html { | ||
| 86 | 86 | </li>`; |
| 87 | 87 | if (turn.role === "agent") |
| 88 | 88 | return html`<li class="talk-turn talk-agent" id="${id}"> |
| 89 | <div class="talk-body">${renderMarkdown(turn.text)}</div> | |
| 89 | <div class="talk-body prose">${renderMarkdown(turn.text)}</div> | |
| 90 | 90 | ${meta("Forge", turn.createdAt)} |
| 91 | 91 | </li>`; |
| 92 | 92 | const data = parse(turn.text); |
src/web/pages/talk_client.ts +5
| @@ -11,6 +11,11 @@ export const talkCss = /* css */ ` | ||
| 11 | 11 | .talk-turn p { margin: 0 0 8px; } |
| 12 | 12 | .talk-turn p:last-child { margin-bottom: 0; } |
| 13 | 13 | .talk-turn pre { max-width: 100%; overflow-x: auto; } |
| 14 | .talk-body.prose { padding: 0; font-size: inherit; } | |
| 15 | .talk-body.prose > * { max-width: none; } | |
| 16 | .talk-body.prose table { display: block; max-width: 100%; overflow-x: auto; } | |
outdated
Add |
||
| 17 | .talk-body.prose :is(p, ul, ol, pre, table, blockquote) { margin: 0 0 8px; } | |
| 18 | .talk-body.prose > :last-child { margin-bottom: 0; } | |
| 14 | 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; } |
| 15 | 20 | .talk-agent { justify-self: start; max-width: 100%; padding: 4px 0; } |
| 16 | 21 | .talk-tool { color: var(--muted); font-size: var(--fs-min); padding: 0 4px; } |
test/talk-page.test.ts +80
| @@ -145,6 +145,60 @@ describe("the page", () => { | ||
| 145 | 145 | expect(other).not.toMatch(/<textarea[^>]*>[^<]/); |
| 146 | 146 | }); |
| 147 | 147 | |
| 148 | test("markdown in a reply: highlighted code, a table and a link render; every <script> is escaped", async () => { | |
| 149 | const eve = await createUser(app.db, { origin: "seed", trusted: true, username: "eve", email: "[email protected]", password: "a-good-passphrase" }); | |
| 150 | if (!eve.ok) throw new Error(eve.error); | |
| 151 | promote(app.db, eve.value); | |
| 152 | say(eve.value, "agent", [ | |
| 153 | "Plain <script>alert(1)</script> and [docs](/docs) and [bad](javascript:alert(1))", | |
| 154 | "", | |
| 155 | "```ts", | |
| 156 | "const s = \"<script>alert(2)</script>\";", | |
| 157 | "```", | |
| 158 | "", | |
| 159 | "| name | note |", | |
| 160 | "| --- | ---: |", | |
| 161 | "| a | <script>alert(3)</script> |", | |
| 162 | ].join("\n")); | |
| 163 | const page = await (await get("/talk", eve.value)).text(); | |
| 164 | expect(page).not.toContain("<script>alert"); | |
| 165 | expect(page).toContain("<script>alert(1)</script>"); | |
| 166 | expect(page).toContain("<script>alert(2)</script>"); | |
| 167 | expect(page).toContain("<td class=\"ta-r\"><script>alert(3)</script></td>"); | |
| 168 | expect(page).toMatch(/<pre><code class="language-ts">.*tok-keyword/s); | |
| 169 | expect(page).toContain('<a href="/docs" rel="nofollow noopener">docs</a>'); | |
| 170 | expect(page).not.toContain('href="javascript:'); | |
| 171 | expect(page).toContain("<table>"); | |
| 172 | }); | |
| 173 | ||
| 174 | test("an unfinished code block closes itself and leaves what is above it as it was", async () => { | |
| 175 | const { turnHtml } = await import("../src/web/pages/talk.ts"); | |
| 176 | const turn = (text: string) => ({ id: "9", role: "agent", text, createdAt: "2026-01-01T00:00:00Z" }) as any; | |
| 177 | const above = "Intro **bold**\n\n| a | b |\n| - | - |\n| 1 | 2 |\n\n"; | |
| 178 | const part = turnHtml(turn(`${above}\`\`\`ts\nconst a = 1;\nconst b`), new Set()).text; | |
| 179 | const whole = turnHtml(turn(`${above}\`\`\`ts\nconst a = 1;\nconst b = 2;\n\`\`\`\n\nDone.`), new Set()).text; | |
| 180 | const head = (h: string) => h.slice(0, h.indexOf("<pre>")); | |
| 181 | expect(head(part)).toBe(head(whole)); | |
| 182 | expect(part.match(/<pre>/g)?.length).toBe(1); | |
| 183 | expect(part).toContain("</code></pre>"); | |
| 184 | }); | |
| 185 | ||
| 186 | test("an agent turn carries no prose padding or measure into the column", async () => { | |
| 187 | const { talkCss } = await import("../src/web/pages/talk_client.ts"); | |
| 188 | expect(talkCss).toContain(".talk-body.prose { padding: 0;"); | |
| 189 | expect(talkCss).toContain(".talk-body.prose > * { max-width: none; }"); | |
| 190 | }); | |
| 191 | ||
| 192 | test("the page's own bytes are held: stylesheet and script stay small", async () => { | |
| 193 | const { talkCss, talkJs } = await import("../src/web/pages/talk_client.ts"); | |
| 194 | expect(Buffer.byteLength(talkCss)).toBeLessThan(3_500); | |
| 195 | expect(Buffer.byteLength(talkJs)).toBeLessThan(6_500); | |
| 196 | say(alice, "agent", "```ts\nlet x = 1;\n```"); | |
| 197 | const page = await get("/talk", alice); | |
| 198 | const size = (await page.arrayBuffer()).byteLength; | |
| 199 | expect(size).toBeLessThan(40_000); | |
| 200 | }); | |
| 201 | ||
| 148 | 202 | describe("the script, driven against a DOM", () => { |
| 149 | 203 | const boot = async (user = "u1", store: Record<string, string> = {}) => { |
| 150 | 204 | const sources: Array<{ on: Record<string, (e: { data: string }) => void> }> = []; |
| @@ -215,6 +269,32 @@ describe("the page", () => { | ||
| 215 | 269 | expect(t.doc.querySelectorAll("#t-2 .talk-meta [data-copy]").length).toBe(1); |
| 216 | 270 | }); |
| 217 | 271 | |
| 272 | test("a turn that arrives again is not drawn twice or replaced, so what is above it keeps its place", async () => { | |
| 273 | const t = await boot(); | |
| 274 | const first = t.doc.getElementById("t-1"); | |
| 275 | const copy = first.querySelector("pre [data-copy]"); | |
| 276 | t.emit("2", '<li class="talk-turn" id="t-2"><div class="talk-body"><pre><code>x</code></pre></div><p class="talk-meta">Forge</p></li>'); | |
| 277 | const second = t.doc.getElementById("t-2"); | |
| 278 | t.emit("2", '<li class="talk-turn" id="t-2"><div class="talk-body"><p>changed</p></div></li>'); | |
| 279 | expect(t.doc.querySelectorAll("#t-2").length).toBe(1); | |
| 280 | expect(t.doc.getElementById("t-2")).toBe(second); | |
| 281 | expect(t.doc.getElementById("t-1")).toBe(first); | |
| 282 | expect(first.querySelector("pre [data-copy]")).toBe(copy); | |
| 283 | expect(t.doc.querySelectorAll("#t-2 pre [data-copy]").length).toBe(1); | |
| 284 | }); | |
| 285 | ||
| 286 | test("a turn arriving while scrolled up does not scroll the page", async () => { | |
| 287 | const t = await boot(); | |
| 288 | let scrolled = 0; | |
| 289 | t.doc.getElementById("end").scrollIntoView = () => { scrolled++; }; | |
| 290 | Object.defineProperty(t.window, "innerHeight", { value: 800, configurable: true }); | |
| 291 | Object.defineProperty(t.window, "scrollY", { value: 0, configurable: true }); | |
| 292 | Object.defineProperty(t.doc.documentElement, "scrollHeight", { value: 5000, configurable: true }); | |
| 293 | t.window.dispatchEvent(new t.window.Event("scroll")); | |
| 294 | t.emit("2", '<li class="talk-turn" id="t-2"><div class="talk-body"><pre><code>x</code></pre></div></li>'); | |
| 295 | expect(scrolled).toBe(0); | |
| 296 | }); | |
| 297 | ||
| 218 | 298 | test("the jump button shows only when scrolled up, and a new message is announced", async () => { |
| 219 | 299 | const t = await boot(); |
| 220 | 300 | const jump = t.doc.querySelector(".talk-jump"); |
new above
offline
prosebrings 16px 20px padding and a --measure max-width into the chat column. Reset them for.talk-body, or do not use the class.