From f13ee21e7abe0d03a7163ca1db33d048b22c3ccc Mon Sep 17 00:00:00 2001 From: Julius Marminge <51714798+juliusmarminge@users.noreply.github.com> Date: Fri, 30 Oct 2026 13:16:02 -0700 Subject: [PATCH 1/4] perf(web): switching back to a streaming thread reuses its finished blocks A streaming reply turned the parse cache off for the whole message, so every remount, such as switching threads and back, parsed every finished block again. Only the last block still grows; the blocks before it now use the cache. Co-Authored-By: Claude Opus 5.5 --- .../components/ChatMarkdown.browser.test.tsx | 26 +++++++++++++++++++ apps/web/src/components/ChatMarkdown.tsx | 3 ++- 2 files changed, 28 insertions(+), 1 deletion(-) diff --git a/apps/web/src/components/ChatMarkdown.browser.test.tsx b/apps/web/src/components/ChatMarkdown.browser.test.tsx index 745e69087cb1..78aff8e741ba 100644 --- a/apps/web/src/components/ChatMarkdown.browser.test.tsx +++ b/apps/web/src/components/ChatMarkdown.browser.test.tsx @@ -16,6 +16,18 @@ const { workspaceSearch, filesystemMetadata } = vi.hoisted(() => ({ workspaceSearch: vi.fn(), filesystemMetadata: vi.fn(), })); +const parsedSources = vi.hoisted(() => [] as string[]); +vi.mock(import("mdast-util-from-markdown"), async (original) => { + const actual = await original(); + return { + ...actual, + fromMarkdown: ((source: string, ...rest: never[]) => { + parsedSources.push(String(source)); + return (actual.fromMarkdown as (...args: unknown[]) => unknown)(source, ...rest); + }) as typeof actual.fromMarkdown, + }; +}); + vi.mock(import("../state/use-atom-query-runner"), async (original) => ({ ...(await original()), useAtomQueryRunner: ((family: unknown) => @@ -139,6 +151,20 @@ it("loads a host image through the page's own route when there is no environment ); }); +it("reuses the parse of a streaming reply's finished blocks when it remounts", async () => { + setup(); + parsedSources.length = 0; + const message = ( + + ); + { + await using _first = await render(message); + } + await using screen = await render(message); + await expect.element(screen.getByText("Still growing")).toBeVisible(); + expect(parsedSources.filter((source) => source.includes("Finished block"))).toHaveLength(1); +}); + describe("ChatMarkdown favicon privacy", () => { it("suppresses private link images while preserving public links across updates", async () => { setup(); diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index bd13a8bdc96d..acf24ba40b7a 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -1767,7 +1767,8 @@ function ChatMarkdown({ : CHAT_MARKDOWN_LITERAL_HTML_REHYPE_PLUGINS } shared={!uncachedPlugins} - cache={!props.isStreaming} + // Every block before the last is finished, so its parse can be reused. + cache={!props.isStreaming || index < blocks.length - 1} /> ))} From 8272bf926e4abebb6b2812ab05703fe294164b65 Mon Sep 17 00:00:00 2001 From: Julius Marminge <51714798+juliusmarminge@users.noreply.github.com> Date: Fri, 30 Oct 2026 13:19:42 -0700 Subject: [PATCH 2/4] perf(web): opening a thread from its sidebar row no longer scrolls the sidebar Every thread switch scrolled the active sidebar row into view from its ref, in the middle of the commit, which forced a layout of the whole new thread. A row that holds focus was clicked or reached by keyboard and is already in view, so the reveal now skips it; route changes from elsewhere still reveal. Co-Authored-By: Claude Opus 5.5 --- .../sidebar/routeReveal.browser.test.tsx | 19 +++++++++++++++++++ .../web/src/components/sidebar/routeReveal.ts | 3 +++ 2 files changed, 22 insertions(+) diff --git a/apps/web/src/components/sidebar/routeReveal.browser.test.tsx b/apps/web/src/components/sidebar/routeReveal.browser.test.tsx index f15a2a1ebcab..9bacc4968a19 100644 --- a/apps/web/src/components/sidebar/routeReveal.browser.test.tsx +++ b/apps/web/src/components/sidebar/routeReveal.browser.test.tsx @@ -17,6 +17,7 @@ function RevealList(props: {
  • revealActiveRow(row, key) : undefined} + tabIndex={-1} style={{ height: ROW_HEIGHT_PX }} > {key} @@ -46,6 +47,24 @@ describe("useActiveRowReveal", () => { await expect.element(target).toBeInViewport(); }); + it("leaves the list where it is when the user opened the thread from its focused row", async () => { + const reveal = newReveal(); + const keys = rowKeys(50); + await using screen = await render( + , + // No router: the route arrives as the activeKey prop. + { router: false }, + ); + const list = screen.getByRole("list", { name: "Threads" }).element(); + // thread-2 is half above the top edge, where a reveal would scroll it fully in. + list.scrollTop = 60; + screen.getByText("thread-2", { exact: true }).element().focus({ preventScroll: true }); + + await screen.rerender(); + + expect(list.scrollTop).toBe(60); + }); + it("reveals the active row when it mounts after the shells arrive", async () => { const reveal = newReveal(); // The route already names the thread, but its environment's shells have not diff --git a/apps/web/src/components/sidebar/routeReveal.ts b/apps/web/src/components/sidebar/routeReveal.ts index df9d45dc9d33..8786ebf8e700 100644 --- a/apps/web/src/components/sidebar/routeReveal.ts +++ b/apps/web/src/components/sidebar/routeReveal.ts @@ -38,6 +38,9 @@ export function useActiveRowReveal(reveal: SidebarRouteReveal, routeKey: string if (reveal.pendingKey !== rowKey && !mountRevealOwed.current) return; completeSidebarRouteReveal(reveal); mountRevealOwed.current = false; + // A row that holds focus was clicked or reached by keyboard, so it is already in view; + // scrolling it would force a layout in the middle of the thread switch's commit. + if (row.contains(row.ownerDocument.activeElement)) return; row.scrollIntoView({ block: "nearest" }); }; } From a678bd86b7bdff5d857dedb717269c72b1e016e3 Mon Sep 17 00:00:00 2001 From: Julius Marminge <51714798+juliusmarminge@users.noreply.github.com> Date: Fri, 30 Oct 2026 13:23:22 -0700 Subject: [PATCH 3/4] perf(web): the composer measures its prompt once per frame The composer checked whether its prompt wraps from a MutationObserver on the whole composer body, so every DOM write there, from keystrokes to a thread switch rebuilding the editor, forced a layout right away. It now measures at most once per animation frame, after the frame's writes. Co-Authored-By: Claude Opus 5.5 --- ...seComposerMultilinePrompt.browser.test.tsx | 23 +++++++++++++++++++ .../chat/useComposerMultilinePrompt.ts | 12 ++++++++-- 2 files changed, 33 insertions(+), 2 deletions(-) create mode 100644 apps/web/src/components/chat/useComposerMultilinePrompt.browser.test.tsx diff --git a/apps/web/src/components/chat/useComposerMultilinePrompt.browser.test.tsx b/apps/web/src/components/chat/useComposerMultilinePrompt.browser.test.tsx new file mode 100644 index 000000000000..f2f14684b208 --- /dev/null +++ b/apps/web/src/components/chat/useComposerMultilinePrompt.browser.test.tsx @@ -0,0 +1,23 @@ +import { expect, it } from "vite-plus/test"; + +import { render } from "../../../test/render"; +import { useComposerMultilinePrompt } from "./useComposerMultilinePrompt"; + +function Composer({ text }: { readonly text: string }) { + const [multiline, observeBody] = useComposerMultilinePrompt(); + return ( +
    +
    + {text} +
    + {multiline ? "multiline" : "single line"} +
    + ); +} + +it("notices when typing makes the prompt wrap", async () => { + await using screen = await render(, { router: false }); + await expect.element(screen.getByText("single line")).toBeVisible(); + await screen.rerender(); + await expect.element(screen.getByText("multiline")).toBeVisible(); +}); diff --git a/apps/web/src/components/chat/useComposerMultilinePrompt.ts b/apps/web/src/components/chat/useComposerMultilinePrompt.ts index c09c371aa997..b9462ffe46aa 100644 --- a/apps/web/src/components/chat/useComposerMultilinePrompt.ts +++ b/apps/web/src/components/chat/useComposerMultilinePrompt.ts @@ -27,9 +27,16 @@ export function useComposerMultilinePrompt(): readonly [ const [isMultiline, setIsMultiline] = useState(false); const observeBody = (body: HTMLElement | null) => { if (!body) return; + // Measuring reads layout, so one measurement per frame runs after all of the frame's DOM + // writes instead of forcing a layout after each mutation. + let frame: number | null = null; const measure = () => { - const next = measureComposerMultilinePrompt(body); - if (next !== null) setIsMultiline(next); + if (frame !== null) return; + frame = requestAnimationFrame(() => { + frame = null; + const next = measureComposerMultilinePrompt(body); + if (next !== null) setIsMultiline(next); + }); }; measure(); const editor = body.querySelector('[data-testid="composer-editor"]'); @@ -37,6 +44,7 @@ export function useComposerMultilinePrompt(): readonly [ const mutationObserver = new MutationObserver(measure); mutationObserver.observe(body, { childList: true, characterData: true, subtree: true }); return () => { + if (frame !== null) cancelAnimationFrame(frame); stopObserving(); mutationObserver.disconnect(); }; From edda04833a6813fcc30ec8cab59a6513676d82d7 Mon Sep 17 00:00:00 2001 From: Julius Marminge <51714798+juliusmarminge@users.noreply.github.com> Date: Fri, 30 Oct 2026 14:50:14 -0700 Subject: [PATCH 4/4] fix(web): reveal a focused sidebar row the keyboard opened from out of view Skip the reveal only for a row under the pointer, which a click puts in view; a focused row can be scrolled away and activated with Enter. Tests reset the parse spy in setup and assert scroll state through vi.waitFor. Co-Authored-By: Claude Opus 5.5 --- .../components/ChatMarkdown.browser.test.tsx | 2 +- .../sidebar/routeReveal.browser.test.tsx | 32 +++++++++++++++---- .../web/src/components/sidebar/routeReveal.ts | 6 ++-- 3 files changed, 29 insertions(+), 11 deletions(-) diff --git a/apps/web/src/components/ChatMarkdown.browser.test.tsx b/apps/web/src/components/ChatMarkdown.browser.test.tsx index 78aff8e741ba..d5ea6a943c0c 100644 --- a/apps/web/src/components/ChatMarkdown.browser.test.tsx +++ b/apps/web/src/components/ChatMarkdown.browser.test.tsx @@ -111,6 +111,7 @@ it("keeps compact audio playback loaded across signed URL renewals", async () => }); function setup() { + parsedSources.length = 0; settingsOverrides.mathRenderingEnabled = false; renderDiagram.mockReset(); workspaceSearch.mockReset().mockResolvedValue(AsyncResult.success({ entries: [] })); @@ -153,7 +154,6 @@ it("loads a host image through the page's own route when there is no environment it("reuses the parse of a streaming reply's finished blocks when it remounts", async () => { setup(); - parsedSources.length = 0; const message = ( ); diff --git a/apps/web/src/components/sidebar/routeReveal.browser.test.tsx b/apps/web/src/components/sidebar/routeReveal.browser.test.tsx index 9bacc4968a19..a87333e40ff0 100644 --- a/apps/web/src/components/sidebar/routeReveal.browser.test.tsx +++ b/apps/web/src/components/sidebar/routeReveal.browser.test.tsx @@ -1,4 +1,5 @@ -import { describe, expect, it } from "vite-plus/test"; +import { describe, expect, it, vi } from "vite-plus/test"; +import { userEvent } from "vite-plus/test/browser"; import { render } from "../../../test/render"; import { type SidebarRouteReveal, useActiveRowReveal } from "./routeReveal"; @@ -47,7 +48,7 @@ describe("useActiveRowReveal", () => { await expect.element(target).toBeInViewport(); }); - it("leaves the list where it is when the user opened the thread from its focused row", async () => { + it("leaves the list where it is when the user clicked the row that opened the thread", async () => { const reveal = newReveal(); const keys = rowKeys(50); await using screen = await render( @@ -55,14 +56,31 @@ describe("useActiveRowReveal", () => { // No router: the route arrives as the activeKey prop. { router: false }, ); - const list = screen.getByRole("list", { name: "Threads" }).element(); - // thread-2 is half above the top edge, where a reveal would scroll it fully in. - list.scrollTop = 60; - screen.getByText("thread-2", { exact: true }).element().focus({ preventScroll: true }); + await userEvent.hover(screen.getByText("thread-2", { exact: true })); + // Even a no-op scroll forces a layout of the thread being opened. + using scrollIntoView = vi.spyOn(Element.prototype, "scrollIntoView"); await screen.rerender(); - expect(list.scrollTop).toBe(60); + await vi.waitFor(() => expect(reveal.pendingKey).toBeNull()); + expect(scrollIntoView).not.toHaveBeenCalled(); + }); + + it("reveals a focused row scrolled out of view when the keyboard opens its thread", async () => { + const reveal = newReveal(); + const keys = rowKeys(50); + await using screen = await render( + , + // No router: the route arrives as the activeKey prop. + { router: false }, + ); + const target = screen.getByText("thread-40", { exact: true }); + target.element().focus({ preventScroll: true }); + await expect.element(target).not.toBeInViewport(); + + await screen.rerender(); + + await expect.element(target).toBeInViewport(); }); it("reveals the active row when it mounts after the shells arrive", async () => { diff --git a/apps/web/src/components/sidebar/routeReveal.ts b/apps/web/src/components/sidebar/routeReveal.ts index 8786ebf8e700..f88433577581 100644 --- a/apps/web/src/components/sidebar/routeReveal.ts +++ b/apps/web/src/components/sidebar/routeReveal.ts @@ -38,9 +38,9 @@ export function useActiveRowReveal(reveal: SidebarRouteReveal, routeKey: string if (reveal.pendingKey !== rowKey && !mountRevealOwed.current) return; completeSidebarRouteReveal(reveal); mountRevealOwed.current = false; - // A row that holds focus was clicked or reached by keyboard, so it is already in view; - // scrolling it would force a layout in the middle of the thread switch's commit. - if (row.contains(row.ownerDocument.activeElement)) return; + // A row under the pointer was just clicked, so it is in view; scrolling it would force a + // layout mid-commit. Keyboard activation can come from a focused row scrolled out of view. + if (row.matches(":hover")) return; row.scrollIntoView({ block: "nearest" }); }; }