Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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 @@ -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 = (
<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
19 changes: 19 additions & 0 deletions apps/web/src/components/sidebar/routeReveal.browser.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,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 +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(
<RevealList reveal={reveal} rowKeys={keys} activeKey="thread-0" />,
// 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(<RevealList reveal={reveal} rowKeys={keys} activeKey="thread-2" />);

expect(list.scrollTop).toBe(60);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
});

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 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;
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
row.scrollIntoView({ block: "nearest" });
};
}
Expand Down
Loading