Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions apps/web/src/components/ChatMarkdown.browser.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,18 @@ const { workspaceSearch, filesystemMetadata } = vi.hoisted(() => ({
workspaceSearch: vi.fn(),
filesystemMetadata: vi.fn(),
}));
const parsedSources = vi.hoisted(() => [] as string[]);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
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) =>
Expand Down Expand Up @@ -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: [] }));
Expand Down Expand Up @@ -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 = (
<ChatMarkdown cwd="/tmp/project" text={"Finished block\n\nStill growing"} isStreaming />
);
{
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();
Expand Down
3 changes: 2 additions & 1 deletion apps/web/src/components/ChatMarkdown.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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}
/>
))}
</ChatMarkdownRendererContext>
Expand Down
Original file line number Diff line number Diff line change
@@ -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 (
<div ref={observeBody} style={{ width: 200, lineHeight: "20px" }}>
<div data-testid="composer-editor" contentEditable suppressContentEditableWarning>
{text}
</div>
<output>{multiline ? "multiline" : "single line"}</output>
</div>
);
}

it("notices when typing makes the prompt wrap", async () => {
await using screen = await render(<Composer text="short" />, { router: false });
await expect.element(screen.getByText("single line")).toBeVisible();
await screen.rerender(<Composer text={"a long prompt that wraps ".repeat(6)} />);
await expect.element(screen.getByText("multiline")).toBeVisible();
});
12 changes: 10 additions & 2 deletions apps/web/src/components/chat/useComposerMultilinePrompt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,16 +27,24 @@ 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<HTMLElement>('[data-testid="composer-editor"]');
const stopObserving = observeResize(editor ? [body, editor] : body, measure);
const mutationObserver = new MutationObserver(measure);
mutationObserver.observe(body, { childList: true, characterData: true, subtree: true });
return () => {
if (frame !== null) cancelAnimationFrame(frame);
stopObserving();
mutationObserver.disconnect();
};
Expand Down
39 changes: 38 additions & 1 deletion apps/web/src/components/sidebar/routeReveal.browser.test.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -17,6 +18,7 @@ function RevealList(props: {
<li
key={key}
ref={key === props.activeKey ? (row) => revealActiveRow(row, key) : undefined}
tabIndex={-1}
style={{ height: ROW_HEIGHT_PX }}
>
{key}
Expand Down Expand Up @@ -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(
<RevealList reveal={reveal} rowKeys={keys} activeKey="thread-0" />,
// 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(<RevealList reveal={reveal} rowKeys={keys} activeKey="thread-2" />);

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(
<RevealList reveal={reveal} rowKeys={keys} activeKey="thread-0" />,
// 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(<RevealList reveal={reveal} rowKeys={keys} activeKey="thread-40" />);

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
Expand Down
3 changes: 3 additions & 0 deletions apps/web/src/components/sidebar/routeReveal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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" });
};
}
Expand Down
Loading