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}
+
+
+
+ );
+}
+
+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" });
};
}