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
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
// @vitest-environment jsdom

import { cleanup, render } from "@testing-library/react";
import { MemoryRouter } from "react-router-dom";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { RouteNavigationProvider } from "@/components/ui/app-route-anchor";
import { ConversationMessageContent } from "./ConversationMessageContent";

// Record every markdown document parse. `react-markdown` is a plain function
// component, so it renders exactly once per `MarkdownPreview` render and never
// when the memoized preview bails out — which is what the streaming split has
// to guarantee for the settled prefix.
const markdownRenders = vi.hoisted(() => [] as string[]);
vi.mock("react-markdown", () => ({
default: ({ children }: { children: string }) => {
markdownRenders.push(children);
return <div data-markdown-document="">{children}</div>;
},
defaultUrlTransform: (url: string) => url,
}));

function renderAssistantMessage(text: string, streaming: boolean) {
const element = (
<MemoryRouter>
<RouteNavigationProvider>
<ConversationMessageContent
role="assistant"
attachments={null}
id="msg_stream"
threadId="thr_stream"
turnId="turn_stream"
sourceSeqStart={1}
sourceSeqEnd={2}
showActions={false}
mobileActionDisplay="overflow"
streaming={streaming}
text={text}
turnRequest={null}
/>
</RouteNavigationProvider>
</MemoryRouter>
);
const view = render(element);
return {
view,
update: (nextText: string, nextStreaming: boolean) =>
view.rerender(
<MemoryRouter>
<RouteNavigationProvider>
<ConversationMessageContent
role="assistant"
attachments={null}
id="msg_stream"
threadId="thr_stream"
turnId="turn_stream"
sourceSeqStart={1}
sourceSeqEnd={2}
showActions={false}
mobileActionDisplay="overflow"
streaming={nextStreaming}
text={nextText}
turnRequest={null}
/>
</RouteNavigationProvider>
</MemoryRouter>,
),
};
}

function documents(container: HTMLElement): string[] {
return Array.from(
container.querySelectorAll<HTMLElement>("[data-markdown-document]"),
).map((node) => node.textContent ?? "");
}

beforeEach(() => {
markdownRenders.length = 0;
});

afterEach(cleanup);

describe("ConversationMessageContent streaming split", () => {
it("re-parses only the live tail when a delta arrives and collapses to one document once complete", () => {
const { view, update } = renderAssistantMessage(
"Para one.\n\nPara two.\n\nPara th",
true,
);
expect(documents(view.container)).toEqual([
"Para one.\n\n",
"Para two.\n\nPara th",
]);
expect(markdownRenders).toEqual(["Para one.\n\n", "Para two.\n\nPara th"]);

markdownRenders.length = 0;
update("Para one.\n\nPara two.\n\nPara three.", true);
// The settled prefix keeps its memoized render; only the tail re-parses.
expect(markdownRenders).toEqual(["Para two.\n\nPara three."]);

// A new blank line moves the boundary forward: the prefix grows once and
// the tail shrinks to the newest paragraph.
markdownRenders.length = 0;
update("Para one.\n\nPara two.\n\nPara three.\n\nPara four", true);
expect(documents(view.container)).toEqual([
"Para one.\n\nPara two.\n\n",
"Para three.\n\nPara four",
]);

// Completion renders the whole message as one document again.
markdownRenders.length = 0;
update("Para one.\n\nPara two.\n\nPara three.\n\nPara four.", false);
expect(documents(view.container)).toEqual([
"Para one.\n\nPara two.\n\nPara three.\n\nPara four.",
]);
expect(markdownRenders).toEqual([
"Para one.\n\nPara two.\n\nPara three.\n\nPara four.",
]);
});

it("keeps an open fenced block inside the live tail", () => {
const { view } = renderAssistantMessage(
"Intro.\n\n```ts\nconst a = 1;\n\nconst b = 2;\n",
true,
);
expect(documents(view.container)).toEqual([
"Intro.\n\n",
"```ts\nconst a = 1;\n\nconst b = 2;\n",
]);
});

it("renders a single document when no boundary is available or when not streaming", () => {
const { view, update } = renderAssistantMessage("Only one paragraph", true);
expect(documents(view.container)).toEqual(["Only one paragraph"]);

update("Para one.\n\nPara two.\n\nPara three", false);
expect(documents(view.container)).toEqual([
"Para one.\n\nPara two.\n\nPara three",
]);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,7 @@ describe("ConversationMessageContent assistant images", () => {
sourceSeqEnd={2}
showActions={false}
mobileActionDisplay="overflow"
streaming={false}
text="![Generated diagram](/workspace/output/diagram.png)"
turnRequest={null}
/>
Expand Down Expand Up @@ -128,6 +129,7 @@ describe("ConversationMessageContent assistant thread mentions", () => {
sourceSeqEnd={2}
showActions={false}
mobileActionDisplay="overflow"
streaming={false}
text="Spawned and parented: @thread:thr_xpxxt2ipz8"
turnRequest={null}
/>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ import {
USER_MESSAGE_CHAR_CAP,
} from "./conversation-message-limits.js";
import { turnRequestLabel } from "./conversation-turn-request-label.js";
import { splitStreamingMarkdown } from "./streaming-markdown-split.js";
import { TurnRequestLabel } from "./TurnRequestLabel.js";
import { MessageActionBar } from "./MessageActionBar.js";
import {
Expand Down Expand Up @@ -133,6 +134,16 @@ const ASSISTANT_THREAD_MENTIONS: MarkdownThreadMentions = {
preserveSoftBreaks: false,
};

// The settled prefix and live tail of a streaming message are two sibling
// markdown documents. Their block margins collapse across the wrapper
// boundary like siblings inside one document, except for the `last:mb-0` on a
// trailing paragraph and the `first:mt-0` on a leading heading, which would
// otherwise remove the gap at the seam and shift the layout when the finished
// message re-renders as one document. Restore those margins at the seam only.
const STREAMING_SETTLED_MARKDOWN_CLASS_NAME = "[&>p:last-child]:mb-2";
const STREAMING_TAIL_MARKDOWN_CLASS_NAME =
"[&>h1:first-child]:mt-4 [&>h2:first-child]:mt-4 [&>h3:first-child]:mt-3 [&>h4:first-child]:mt-3 [&>h5:first-child]:mt-2 [&>h6:first-child]:mt-2";

export interface ConversationMessageContentAssistantProps
extends ConversationMessageContentBaseProps, AssistantMessageRowIdentity {
role: "assistant";
Expand Down Expand Up @@ -170,6 +181,12 @@ export interface ConversationMessageContentAssistantProps
showActions: boolean;
/** Mobile presentation for this message's action footer. */
mobileActionDisplay: "inline" | "overflow";
/**
* The message is still receiving text deltas. The body then renders as a
* settled prefix plus a live tail (two memoized markdown documents) so each
* delta re-parses only the tail. A completed message renders one document.
*/
streaming: boolean;
turnRequest: null;
workspaceRootPath?: string;
}
Expand Down Expand Up @@ -226,6 +243,7 @@ interface AssistantConversationMessageProps extends AssistantMessageRowIdentity
projectId?: string;
showActions: boolean;
mobileActionDisplay: "inline" | "overflow";
streaming: boolean;
text: string;
workspaceRootPath?: string;
}
Expand Down Expand Up @@ -546,11 +564,18 @@ function AssistantConversationMessage({
projectId,
showActions,
mobileActionDisplay,
streaming,
text,
threadId,
turnId,
workspaceRootPath,
}: AssistantConversationMessageProps) {
// While streaming, everything before the last safe blank line is settled and
// keeps its memoized render; only the tail document re-parses per delta.
const streamingSplit = useMemo(
() => (streaming ? splitStreamingMarkdown(text) : null),
[streaming, text],
);
const linkRouting = useMemo<MarkdownLinkRouting>(() => {
const localImage: NonNullable<MarkdownLinkRouting["localImage"]> = {
absolutePaths: {
Expand Down Expand Up @@ -652,11 +677,25 @@ function AssistantConversationMessage({
*/}
<SelectableMessageProse onSelect={onSelectProse}>
<MarkdownPreview
content={text}
className={
streamingSplit === null
? undefined
: STREAMING_SETTLED_MARKDOWN_CLASS_NAME
}
content={streamingSplit === null ? text : streamingSplit.settled}
linkRouting={linkRouting}
messageDirectives={messageDirectives}
threadMentions={ASSISTANT_THREAD_MENTIONS}
/>
{streamingSplit === null ? null : (
<MarkdownPreview
className={STREAMING_TAIL_MARKDOWN_CLASS_NAME}
content={streamingSplit.tail}
linkRouting={linkRouting}
messageDirectives={messageDirectives}
threadMentions={ASSISTANT_THREAD_MENTIONS}
/>
)}
</SelectableMessageProse>
<ConversationAttachments
filePaths={attachmentItems.filePaths}
Expand Down Expand Up @@ -760,6 +799,7 @@ export function ConversationMessageContent(
projectId={projectId}
showActions={props.showActions}
mobileActionDisplay={props.mobileActionDisplay}
streaming={props.streaming}
sourceSeqEnd={props.sourceSeqEnd}
sourceSeqStart={props.sourceSeqStart}
text={text}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -962,9 +962,20 @@ describe("ThreadTimelineRows actions", () => {
});

it("ignores sidebar search scroll state for a different thread", () => {
const requestAnimationFrame = vi.spyOn(window, "requestAnimationFrame");
// Row wrappers schedule frames of their own (containment arming), so run
// every frame synchronously and assert on the reveal itself.
vi.spyOn(window, "requestAnimationFrame").mockImplementation((callback) => {
callback(performance.now());
return 1;
});
vi.spyOn(window, "cancelAnimationFrame").mockImplementation(() => {});
const scrollIntoView = vi.fn();
Object.defineProperty(HTMLElement.prototype, "scrollIntoView", {
configurable: true,
value: scrollIntoView,
});

renderWithRouter(
const { container } = renderWithRouter(
<ThreadTimelineRows
threadId="thr_side_chat"
timelineRows={[
Expand All @@ -988,7 +999,12 @@ describe("ThreadTimelineRows actions", () => {
],
);

expect(requestAnimationFrame).not.toHaveBeenCalled();
expect(scrollIntoView).not.toHaveBeenCalled();
expect(
container
.querySelector('[data-timeline-row-id="side_chat_message"]')
?.classList.contains("bb-search-flash"),
).toBe(false);
});

it("scrolls sidebar search matches to the nested row instead of the containing parent", async () => {
Expand Down
Loading
Loading