diff --git a/apps/web/src/components/ChatMarkdown.browser.test.tsx b/apps/web/src/components/ChatMarkdown.browser.test.tsx index 745e69087cb1e..d5ea6a943c0c0 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) => @@ -99,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: [] })); @@ -139,6 +152,19 @@ 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(); + 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 bd13a8bdc96d5..acf24ba40b7a5 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} /> ))} 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 0000000000000..f2f14684b208c --- /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 c09c371aa997a..b9462ffe46aab 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(); }; diff --git a/apps/web/src/components/sidebar/routeReveal.browser.test.tsx b/apps/web/src/components/sidebar/routeReveal.browser.test.tsx index f15a2a1ebcab5..a87333e40ff08 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"; @@ -17,6 +18,7 @@ function RevealList(props: {
  • revealActiveRow(row, key) : undefined} + tabIndex={-1} style={{ height: ROW_HEIGHT_PX }} > {key} @@ -46,6 +48,41 @@ describe("useActiveRowReveal", () => { await expect.element(target).toBeInViewport(); }); + 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( + , + // No router: the route arrives as the activeKey prop. + { router: false }, + ); + 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(); + + 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 () => { 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 df9d45dc9d33e..f88433577581b 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 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" }); }; }