From 8dab241aa2b5178e6437000d3a0c9f751c647497 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Val=20Alexander=20=F0=9F=91=91?= Date: Fri, 30 Oct 2026 22:20:47 -0500 Subject: [PATCH 1/2] Defer Markdown far from the view so long threads open fast Hosted CI's approved 500 ms cold-open gate failed on runner variance: a cold open spent ~110 ms parsing Markdown for every visible answer. Turns before the newest eight now show their text as written until it nears the transcript's view, then parse once and stay parsed; scroll anchoring keeps the reader's place. The budget probe waits for parsed Markdown. Local five-launch median ~190 -> 88 ms; 3x throttled 806-870 -> 312-340 ms. The gate is unchanged. Co-Authored-By: Claude Opus 5.5 --- apps/desktop/e2e/markdown-defer.spec.ts | 78 +++++++++++++++++++ apps/desktop/e2e/transcript-measure.spec.ts | 3 +- .../src/renderer/components/Markdown.tsx | 25 +++++- .../src/renderer/components/ThreadView.tsx | 7 +- apps/desktop/src/renderer/styles.css | 2 + apps/desktop/test/markdown-render.test.ts | 16 +++- docs/reviews/2026-10-30-deferred-markdown.md | 50 ++++++++++++ docs/status.md | 2 + 8 files changed, 179 insertions(+), 4 deletions(-) create mode 100644 apps/desktop/e2e/markdown-defer.spec.ts create mode 100644 docs/reviews/2026-10-30-deferred-markdown.md diff --git a/apps/desktop/e2e/markdown-defer.spec.ts b/apps/desktop/e2e/markdown-defer.spec.ts new file mode 100644 index 00000000..b4b884b5 --- /dev/null +++ b/apps/desktop/e2e/markdown-defer.spec.ts @@ -0,0 +1,78 @@ +import { test, expect, type ElectronApplication, type Page } from "@playwright/test"; +import fs from "node:fs"; +import path from "node:path"; +import { items, launch, seedHome, tid } from "./support"; + +/** + * Long threads parse only the Markdown near the view (`DeferMarkdown`, Markdown.tsx): the newest + * turns at once, older answers as written until they scroll near, then parsed for good. Parsing + * above the view must not move what the reader is looking at. + */ +let app: ElectronApplication | undefined; +let home = "", repo = ""; + +test.afterEach(async () => { + await app?.close().catch(() => {}); + app = undefined; + for (const dir of [home, repo]) if (dir) fs.rmSync(dir, { recursive: true, force: true }); +}); + +const TURNS = 40; + +async function openLongThread(): Promise { + ({ home, repo } = seedHome()); + const threadId = "long"; + const at = new Date(Date.now() - TURNS * 60_000).toISOString(); + const saved: Record[] = []; + for (let turn = 0; turn < TURNS; turn++) { + const origin = { thread: threadId, turn: `u-${turn}`, attempt: `a-${turn}`, backend: "codex", native: { runtime: "R", thread: "nt", turn: `nu-${turn}`, item: `f-${turn}` } }; + saved.push({ id: `u-${turn}`, kind: "user", text: `Request ${turn}`, at }); + saved.push({ id: `f-${turn}`, kind: "assistant", text: `Answer **${turn}**.\n\n${"A paragraph of explanation that wraps. ".repeat(12)}\n\n\`\`\`ts\nexport const value${turn} = ${turn};\n\`\`\``, + phase: "final", nativePhase: "final_answer", outcome: "succeeded", origin, at }); + } + fs.writeFileSync(path.join(home, "app", "threads", `${threadId}.json`), JSON.stringify(saved)); + const statePath = path.join(home, "app", "state.json"); + const state = JSON.parse(fs.readFileSync(statePath, "utf8")); + const now = new Date().toISOString(); + state.threads = [{ id: threadId, projectId: "p1", title: "Long", titleSource: "manual", createdAt: now, updatedAt: now, cwd: repo, backend: "codex", mode: "chat", plan: false, model: "", status: "idle" }]; + fs.writeFileSync(statePath, JSON.stringify(state)); + let page: Page; + ({ app, page } = await launch(home)); + await page.locator(`[data-testid="thread-row"][data-thread-id="${threadId}"] button.thread-main`).click(); + await expect(items(page, "assistant")).toHaveCount(TURNS); + return page; +} + +test("a long thread parses its newest answers at once and older ones as they near the view", async () => { + const page = await openLongThread(); + const answers = items(page, "assistant"); + // The newest answer is parsed Markdown, not text as written. + await expect(answers.last().locator("strong")).toHaveText(String(TURNS - 1)); + await expect(answers.last().locator("[data-markdown-deferred]")).toHaveCount(0); + // The oldest is far above the view: its text is all there, as written, until it nears. + const first = answers.first(); + await expect(first.locator("[data-markdown-deferred]")).toHaveCount(1); + await expect(first).toContainText("Answer **0**."); + // Scrolling to the top parses it, and it stays parsed after scrolling back down. + await tid(page, "transcript").evaluate((el) => { el.scrollTop = 0; }); + await expect(first.locator("strong")).toHaveText("0"); + await expect(first.locator("pre code")).toContainText("export const value0 = 0;"); + await tid(page, "transcript").evaluate((el) => { el.scrollTop = el.scrollHeight; }); + await expect(first.locator("[data-markdown-deferred]")).toHaveCount(0); +}); + +test("answers parsing above the view do not move the answer being read", async () => { + const page = await openLongThread(); + const answers = items(page, "assistant"); + // Bring a still-deferred middle answer into view; the answers around it parse as it arrives. + const middle = answers.nth(Math.floor(TURNS / 3)); + await expect(middle.locator("[data-markdown-deferred]")).toHaveCount(1); + // Its position is read in the same task as the scroll, before any of those parses run. + const before = await middle.evaluate((el) => { el.scrollIntoView({ block: "start" }); return el.getBoundingClientRect().top; }); + await expect(middle.locator("[data-markdown-deferred]")).toHaveCount(0); + // The answers above it parse too, and grow; the reader's answer stays where it was. + await expect(answers.nth(Math.floor(TURNS / 3) - 3).locator("[data-markdown-deferred]")).toHaveCount(0); + await page.waitForTimeout(300); + const after = await middle.evaluate((el) => el.getBoundingClientRect().top); + expect(Math.abs(after - before), `moved from ${before} to ${after}`).toBeLessThanOrEqual(2); +}); diff --git a/apps/desktop/e2e/transcript-measure.spec.ts b/apps/desktop/e2e/transcript-measure.spec.ts index a470541d..81d5bf46 100644 --- a/apps/desktop/e2e/transcript-measure.spec.ts +++ b/apps/desktop/e2e/transcript-measure.spec.ts @@ -183,7 +183,8 @@ async function measureTranscript(testInfo: TestInfo, launchNumber: number): Prom // Item markup both this build and the pre-#273 baseline share, so the same probe measures either. const answers = transcript ? [...transcript.querySelectorAll('[data-testid="item"][data-item-kind="assistant"]')] : []; const last = answers.at(-1); - if (last?.textContent?.includes(marker)) { + // Its Markdown must be parsed, not shown as written while deferred (DeferMarkdown). + if (last?.textContent?.includes(marker) && !last.querySelector("[data-markdown-deferred]")) { const box = last.getBoundingClientRect(), view = transcript!.getBoundingClientRect(); if (box.top < view.bottom && box.bottom > view.top) return performance.now() - start; } diff --git a/apps/desktop/src/renderer/components/Markdown.tsx b/apps/desktop/src/renderer/components/Markdown.tsx index 643b35b6..163251a2 100644 --- a/apps/desktop/src/renderer/components/Markdown.tsx +++ b/apps/desktop/src/renderer/components/Markdown.tsx @@ -1,4 +1,4 @@ -import { cloneElement, isValidElement, memo, useEffect, useState, type ReactNode } from "react"; +import { cloneElement, createContext, isValidElement, memo, useContext, useEffect, useRef, useState, type ReactNode, type RefObject } from "react"; import type { Definition, ListItem, Nodes, Parents, PhrasingContent, RootContent, TableRow } from "mdast"; import { fromMarkdown } from "mdast-util-from-markdown"; import { gfmFromMarkdown } from "mdast-util-gfm"; @@ -22,6 +22,20 @@ export const Markdown = memo(function Markdown({ text }: { text: string }) { const timer = setTimeout(() => setSettled(text), STREAM_SETTLE_MS); return () => clearTimeout(timer); }, [text, settled]); + // Far from the newest turns, text shows as written until it nears the transcript's view, then + // parses once and stays parsed. Text parsed earlier in this session renders at once. + const root = useContext(DeferMarkdown); + const [near, setNear] = useState(() => !root || settledRenders.has(text)); + const deferred = useRef(null); + useEffect(() => { + const el = deferred.current; + if (near || !root || !el) return; + const observer = new IntersectionObserver((entries) => { if (entries.some((entry) => entry.isIntersecting)) setNear(true); }, + { root: root.current, rootMargin: DEFER_MARGIN }); + observer.observe(el); + return () => observer.disconnect(); + }, [near, root]); + if (!near) return
{text}
; if (text === settled) return
{settledBlocks(text)}
; // Keep the same top-level React structure during streaming and settling. Wrapping chunks // in component boundaries remounted every code block on the first delta and at settlement, @@ -34,6 +48,14 @@ export const Markdown = memo(function Markdown({ text }: { text: string }) { /** How long text must stop changing before it is parsed whole again. */ const STREAM_SETTLE_MS = 300; +/** + * Provided around transcript turns far from the newest, with the transcript's scroller: Markdown + * inside parses only once it comes within `DEFER_MARGIN` of that view. Opening a long thread then + * parses what is on screen, not every answer above it. + */ +export const DeferMarkdown = createContext | null>(null); +const DEFER_MARGIN = "1500px 0px"; + function render(text: string): ReactNode[] { const tree = fromMarkdown(text, { extensions: [gfm()], mdastExtensions: [gfmFromMarkdown()] }); const definitions = new Map(); @@ -46,6 +68,7 @@ export class RenderCache { private readonly kept = new Map(); constructor(private readonly entries: number, private readonly maxChars: number) {} get size(): number { return this.kept.size; } + has(text: string): boolean { return this.kept.has(text); } get(text: string, make: () => T): T { if (text.length > this.maxChars) return make(); if (this.kept.has(text)) { diff --git a/apps/desktop/src/renderer/components/ThreadView.tsx b/apps/desktop/src/renderer/components/ThreadView.tsx index 0071a3d4..883c0cbd 100644 --- a/apps/desktop/src/renderer/components/ThreadView.tsx +++ b/apps/desktop/src/renderer/components/ThreadView.tsx @@ -11,7 +11,7 @@ import type { Attachment } from "../../shared/attachments"; import { bridge } from "../bridge"; import { AttachmentChips } from "./Attachments"; import { Composer } from "./Composer"; -import { Markdown } from "./Markdown"; +import { DeferMarkdown, Markdown } from "./Markdown"; import { Icon } from "./ui/Icon"; import { ErrorNotice } from "./ui/ErrorNotice"; import { TurnContent, groupTurns } from "./transcript/TurnContent"; @@ -26,6 +26,9 @@ import { PatchCompare, TranscriptThread } from "./transcript/PatchCompare"; export { formatElapsed } from "./transcript/TurnContent"; +/** The newest turns parse their Markdown at once; older ones when they near the view (`DeferMarkdown`). */ +const EAGER_TURNS = 8; + interface Props { thread: Thread; project: Project; @@ -219,9 +222,11 @@ export function ThreadView({ thread, project, items, text, onText, attachments, )} {turns.map((turn, i) => ( + <>} render={(item, live) => onUpdate({ approvals })} onFix={onFix} onRetry={!thread.retired && !busy && item.id === retryId ? onRetry : undefined} onReply={!thread.retired && !busy && item.id === askId ? (text) => void onSend(text) : undefined} />} /> + ))} diff --git a/apps/desktop/src/renderer/styles.css b/apps/desktop/src/renderer/styles.css index b2af5561..028be0bf 100644 --- a/apps/desktop/src/renderer/styles.css +++ b/apps/desktop/src/renderer/styles.css @@ -533,6 +533,8 @@ a.thread-pr:hover { text-decoration: underline; } .msg.user { display: flex; flex-direction: column; align-items: flex-end; } .msg.user .bubble { max-width: 70%; padding: 8px 16px; border: 1px solid var(--bubble-border); border-radius: 12px; background: var(--bg-user-bubble); white-space: pre-wrap; overflow-wrap: anywhere; } .md p { margin: 0 0 12px; } +/* Text as written until it nears the view and parses (Markdown.tsx, DeferMarkdown). */ +.md-deferred { white-space: pre-wrap; overflow-wrap: anywhere; } .md h2, .md h3, .md h4, .md h5, .md h6 { margin: 18px 0 8px; font-weight: var(--weight-semibold); } .md h5, .md h6 { font-size: var(--text-md); } .md ul, .md ol { margin: 0 0 12px; padding-left: 28px; } diff --git a/apps/desktop/test/markdown-render.test.ts b/apps/desktop/test/markdown-render.test.ts index fde988de..1c30489a 100644 --- a/apps/desktop/test/markdown-render.test.ts +++ b/apps/desktop/test/markdown-render.test.ts @@ -143,7 +143,7 @@ test("rendering a reply piece by piece shows the same text as rendering it whole test("the settled cache keeps recent renders, drops the oldest past its limit, and never holds very long text", async () => { await render(""); - const { RenderCache } = await server!.ssrLoadModule("/src/renderer/components/Markdown.tsx") as { RenderCache: new (entries: number, maxChars: number) => { get(text: string, make: () => T): T; size: number } }; + const { RenderCache } = await server!.ssrLoadModule("/src/renderer/components/Markdown.tsx") as { RenderCache: new (entries: number, maxChars: number) => { get(text: string, make: () => T): T; has(text: string): boolean; size: number } }; const cache = new RenderCache(2, 10); let made = 0; const make = () => ++made; @@ -153,6 +153,8 @@ test("the settled cache keeps recent renders, drops the oldest past its limit, a cache.get("a", make); // "a" is now the most recent cache.get("c", make); // past the limit: "b", the least recent, goes assert.equal(cache.size, 2); + assert.equal(cache.has("b"), false, "a dropped text is not reported as kept"); + assert.equal(cache.has("c"), true); assert.equal(cache.get("a", make), 1); assert.equal(cache.get("b", make), 4, "b was rendered again"); const long = "x".repeat(11); @@ -161,6 +163,18 @@ test("the settled cache keeps recent renders, drops the oldest past its limit, a assert.equal(cache.size, 2); }); +test("deferred Markdown shows its text as written until it nears the view, and text parsed before renders at once", async () => { + await render(""); + const { DeferMarkdown } = await server!.ssrLoadModule("/src/renderer/components/Markdown.tsx") as { DeferMarkdown: import("react").Context }; + const deferred = (text: string) => renderToStaticMarkup(createElement(DeferMarkdown.Provider, { value: { current: null } }, createElement(Markdown, { text }))); + const fresh = "Not parsed **yet** raw"; + assert.equal(deferred(fresh), '
Not parsed **yet** <b>raw</b>
'); + // Rendered (and kept) once outside a deferred turn, the same text parses at once inside one. + const seen = "Already **parsed**"; + await render(seen); + assert.match(deferred(seen), /^

Already parsed<\/strong><\/p><\/div>$/); +}); + test("a code block names its language and offers Copy", async () => { const html = await render("```ts\nconst a = 1;\n```\n\n```\nplain\n```"); assert.match(html, /ts<\/span>