diff --git a/apps/app/src/components/thread/timeline/ConversationMessageContent.streaming.test.tsx b/apps/app/src/components/thread/timeline/ConversationMessageContent.streaming.test.tsx new file mode 100644 index 0000000000..4e443bb2c4 --- /dev/null +++ b/apps/app/src/components/thread/timeline/ConversationMessageContent.streaming.test.tsx @@ -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
{children}
; + }, + defaultUrlTransform: (url: string) => url, +})); + +function renderAssistantMessage(text: string, streaming: boolean) { + const element = ( + + + + + + ); + const view = render(element); + return { + view, + update: (nextText: string, nextStreaming: boolean) => + view.rerender( + + + + + , + ), + }; +} + +function documents(container: HTMLElement): string[] { + return Array.from( + container.querySelectorAll("[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", + ]); + }); +}); diff --git a/apps/app/src/components/thread/timeline/ConversationMessageContent.test.tsx b/apps/app/src/components/thread/timeline/ConversationMessageContent.test.tsx index c4a5219f26..3912ea9343 100644 --- a/apps/app/src/components/thread/timeline/ConversationMessageContent.test.tsx +++ b/apps/app/src/components/thread/timeline/ConversationMessageContent.test.tsx @@ -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} /> @@ -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} /> diff --git a/apps/app/src/components/thread/timeline/ConversationMessageContent.tsx b/apps/app/src/components/thread/timeline/ConversationMessageContent.tsx index b6567670fb..72e78c8d17 100644 --- a/apps/app/src/components/thread/timeline/ConversationMessageContent.tsx +++ b/apps/app/src/components/thread/timeline/ConversationMessageContent.tsx @@ -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 { @@ -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"; @@ -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; } @@ -226,6 +243,7 @@ interface AssistantConversationMessageProps extends AssistantMessageRowIdentity projectId?: string; showActions: boolean; mobileActionDisplay: "inline" | "overflow"; + streaming: boolean; text: string; workspaceRootPath?: string; } @@ -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(() => { const localImage: NonNullable = { absolutePaths: { @@ -652,11 +677,25 @@ function AssistantConversationMessage({ */} + {streamingSplit === null ? null : ( + + )} { }); 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( { ], ); - 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 () => { diff --git a/apps/app/src/components/thread/timeline/ThreadTimelineRows.containment.test.tsx b/apps/app/src/components/thread/timeline/ThreadTimelineRows.containment.test.tsx new file mode 100644 index 0000000000..3555eea988 --- /dev/null +++ b/apps/app/src/components/thread/timeline/ThreadTimelineRows.containment.test.tsx @@ -0,0 +1,211 @@ +// @vitest-environment jsdom + +import { act, cleanup, render } from "@testing-library/react"; +import { MemoryRouter } from "react-router-dom"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { + commandRow, + conversationRow, + turnRow, +} from "@/test/fixtures/thread-timeline-rows"; +import { ThreadTimelineRows } from "./ThreadTimelineRows"; +import { + estimateTimelineRowIntrinsicBlockSizePx, + TOP_LEVEL_TIMELINE_ROW_CLASS_NAME, + TOP_LEVEL_TIMELINE_ROW_INTRINSIC_SIZE_CLASS_NAME, +} from "./timeline-row-containment"; + +// jsdom has no `CSS.supports`; the arming hook reads scroll-anchoring support +// from it, so each test declares which engine it models. +function stubScrollAnchoring(supported: boolean): void { + vi.stubGlobal("CSS", { + supports: (property: string, value: string) => + supported && property === "overflow-anchor" && value === "none", + }); +} + +beforeEach(() => { + stubScrollAnchoring(true); +}); + +afterEach(() => { + cleanup(); + vi.unstubAllGlobals(); +}); + +function rowWrapper(container: HTMLElement, rowId: string): HTMLElement { + const element = container.querySelector( + `[data-timeline-row-id="${rowId}"]`, + ); + if (element === null) { + throw new Error(`row ${rowId} did not render`); + } + return element; +} + +function nextAnimationFrame(): Promise { + return new Promise((resolve) => { + window.requestAnimationFrame(() => resolve()); + }); +} + +describe("ThreadTimelineRows row containment", () => { + it("applies content-visibility containment to top-level row wrappers only, after their first layout", async () => { + const rows = [ + conversationRow({ + id: "user_1", + role: "user", + text: "Please look into the flaky test.", + seq: 1, + }), + turnRow({ + id: "turn_1", + status: "completed", + children: [ + commandRow({ id: "cmd_nested", command: "pnpm test", seq: 2 }), + conversationRow({ + id: "assistant_nested", + role: "assistant", + text: "Nested answer.", + seq: 3, + }), + ], + }), + conversationRow({ + id: "assistant_1", + role: "assistant", + text: "x".repeat(600), + seq: 4, + }), + ]; + const view = render( + + + + + , + ); + + // Freshly mounted rows carry only the intrinsic-size declaration so the + // first layout runs unskipped and records the row's real height. + for (const rowId of ["user_1", "turn_1", "assistant_1"]) { + expect(rowWrapper(view.container, rowId).className).toBe( + TOP_LEVEL_TIMELINE_ROW_INTRINSIC_SIZE_CLASS_NAME, + ); + } + expect(TOP_LEVEL_TIMELINE_ROW_INTRINSIC_SIZE_CLASS_NAME).not.toContain( + "content-visibility", + ); + + // One frame later the row is still unarmed (that frame's layout is the + // one that lays it out); the frame after that opts in. + await act(nextAnimationFrame); + expect(rowWrapper(view.container, "assistant_1").className).toBe( + TOP_LEVEL_TIMELINE_ROW_INTRINSIC_SIZE_CLASS_NAME, + ); + await act(nextAnimationFrame); + const armedClassNames = TOP_LEVEL_TIMELINE_ROW_CLASS_NAME.split(" "); + expect(armedClassNames).toContain("max-md:[content-visibility:auto]"); + expect(armedClassNames).toContain( + TOP_LEVEL_TIMELINE_ROW_INTRINSIC_SIZE_CLASS_NAME, + ); + for (const rowId of ["user_1", "turn_1", "assistant_1"]) { + expect( + Array.from(rowWrapper(view.container, rowId).classList).sort(), + ).toEqual([...armedClassNames].sort()); + } + + // Arming goes through classList, not a React re-render, so classes other + // code adds imperatively (the search-match flash) survive later renders. + rowWrapper(view.container, "assistant_1").classList.add("bb-search-flash"); + view.rerender( + + + + + , + ); + expect( + rowWrapper(view.container, "assistant_1").classList.contains( + "bb-search-flash", + ), + ).toBe(true); + expect( + rowWrapper(view.container, "assistant_1").classList.contains( + "max-md:[content-visibility:auto]", + ), + ).toBe(true); + + // Nested lists (turn / bundle bodies) keep plain wrappers: their parent + // body animates its own height. + expect(rowWrapper(view.container, "cmd_nested").className).toBe(""); + expect(rowWrapper(view.container, "assistant_nested").className).toBe(""); + expect(rowWrapper(view.container, "assistant_nested").style.length).toBe(0); + + // Conversation rows carry a per-row intrinsic size estimate; work rows use + // the class default (one text line). + expect(rowWrapper(view.container, "turn_1").style.length).toBe(0); + expect( + rowWrapper(view.container, "assistant_1").style.containIntrinsicBlockSize, + ).toBe(`auto ${estimateTimelineRowIntrinsicBlockSizePx(rows[2]!)}px`); + expect(estimateTimelineRowIntrinsicBlockSizePx(rows[2]!)).toBeGreaterThan( + estimateTimelineRowIntrinsicBlockSizePx(rows[0]!) ?? Number.NaN, + ); + }); + + it("never arms content-visibility where CSS scroll anchoring is missing (WebKit)", async () => { + stubScrollAnchoring(false); + const rows = [ + conversationRow({ + id: "user_1", + role: "user", + text: "Please look into the flaky test.", + seq: 1, + }), + conversationRow({ + id: "assistant_1", + role: "assistant", + text: "x".repeat(600), + seq: 2, + }), + ]; + const view = render( + + + + + , + ); + await act(nextAnimationFrame); + await act(nextAnimationFrame); + await act(nextAnimationFrame); + for (const rowId of ["user_1", "assistant_1"]) { + const classes = Array.from(rowWrapper(view.container, rowId).classList); + // The intrinsic-size estimate is inert without content-visibility; only + // the arming class must stay away on engines that cannot anchor scroll. + expect(classes).toContain( + TOP_LEVEL_TIMELINE_ROW_INTRINSIC_SIZE_CLASS_NAME, + ); + expect(classes).not.toContain("max-md:[content-visibility:auto]"); + } + }); +}); diff --git a/apps/app/src/components/thread/timeline/ThreadTimelineRows.streaming.test.ts b/apps/app/src/components/thread/timeline/ThreadTimelineRows.streaming.test.ts new file mode 100644 index 0000000000..54812bca3b --- /dev/null +++ b/apps/app/src/components/thread/timeline/ThreadTimelineRows.streaming.test.ts @@ -0,0 +1,105 @@ +import { buildTimelineViewRows } from "@bb/thread-view"; +import { describe, expect, it } from "vitest"; +import { + commandRow, + conversationRow, + delegationRow, + turnRow, +} from "@/test/fixtures/thread-timeline-rows"; +import { findStreamingAssistantMessageId } from "./ThreadTimelineRows"; + +// Only the message that can still receive text deltas renders through the +// settled/tail markdown split. Picking any other row would re-mount a finished +// body as two documents (and pick up the seam margin classes) for nothing; +// missing the live one re-parses the whole message per delta again. +describe("findStreamingAssistantMessageId", () => { + it("returns the trailing top-level assistant message", () => { + const rows = buildTimelineViewRows([ + conversationRow({ id: "user_1", role: "user", text: "Hi", seq: 1 }), + conversationRow({ + id: "assistant_1", + role: "assistant", + text: "Working on it", + seq: 2, + }), + ]); + expect(findStreamingAssistantMessageId(rows)).toBe("assistant_1"); + }); + + it("returns null when later work follows the assistant message or the last row is not assistant text", () => { + const rows = buildTimelineViewRows([ + conversationRow({ + id: "assistant_1", + role: "assistant", + text: "Let me check", + seq: 1, + }), + commandRow({ id: "cmd_1", command: "ls", seq: 2, status: "pending" }), + ]); + expect(findStreamingAssistantMessageId(rows)).toBeNull(); + expect( + findStreamingAssistantMessageId( + buildTimelineViewRows([ + conversationRow({ id: "user_1", role: "user", text: "Hi", seq: 1 }), + ]), + ), + ).toBeNull(); + expect(findStreamingAssistantMessageId([])).toBeNull(); + }); + + it("descends into the pending turn and pending delegation that own the frontier", () => { + const pendingTurn = buildTimelineViewRows([ + turnRow({ + id: "turn_pending", + status: "pending", + children: [ + commandRow({ id: "cmd_1", command: "ls", seq: 2 }), + conversationRow({ + id: "assistant_nested", + role: "assistant", + text: "Streaming inside the turn", + seq: 3, + }), + ], + }), + ]); + expect(findStreamingAssistantMessageId(pendingTurn)).toBe( + "assistant_nested", + ); + + const completedTurn = buildTimelineViewRows([ + turnRow({ + id: "turn_done", + status: "completed", + children: [ + conversationRow({ + id: "assistant_done", + role: "assistant", + text: "Finished", + seq: 3, + }), + ], + }), + ]); + expect(findStreamingAssistantMessageId(completedTurn)).toBeNull(); + + const pendingDelegation = buildTimelineViewRows([ + delegationRow({ + id: "delegation_live", + status: "pending", + seq: 5, + childRows: [ + conversationRow({ + id: "assistant_child", + role: "assistant", + text: "Child agent text", + seq: 6, + }), + ], + }), + ]); + expect(findStreamingAssistantMessageId(pendingDelegation)).toBe( + "assistant_child", + ); + }); +}); diff --git a/apps/app/src/components/thread/timeline/ThreadTimelineRows.tsx b/apps/app/src/components/thread/timeline/ThreadTimelineRows.tsx index 3548ad65dc..a75b30a1b3 100644 --- a/apps/app/src/components/thread/timeline/ThreadTimelineRows.tsx +++ b/apps/app/src/components/thread/timeline/ThreadTimelineRows.tsx @@ -96,6 +96,11 @@ import { timelineRowRenderSignature, timelineRowsSignature, } from "./timelineRowSignatures.js"; +import { + TOP_LEVEL_TIMELINE_ROW_INTRINSIC_SIZE_CLASS_NAME, + timelineRowContainmentStyle, + useArmTopLevelTimelineRowContainment, +} from "./timeline-row-containment.js"; import { NESTED_TIMELINE_GROUP_LINE_CLASS_NAME } from "./timeline-nested-group-line.js"; import { getThreadRoutePath } from "@/lib/route-paths"; import { useThreadTimelineTurnSummaryDetails } from "@/hooks/queries/thread-queries"; @@ -412,6 +417,11 @@ interface ConversationRowContentProps extends ConversationRowProps { * message-id contexts so this body only re-renders when its own value flips. */ mobileActionDisplay: "inline" | "overflow"; + /** + * Resolved by the outer {@link ConversationRow} from the streaming + * assistant message-id context; only the live row re-renders per delta. + */ + streaming: boolean; } const TimelineRendererStaticContext = @@ -430,6 +440,10 @@ const LatestActionableAssistantMessageIdContext = createContext( null, ); const LatestActionableUserMessageIdContext = createContext(null); +// The assistant message still receiving text deltas (the timeline's trailing +// row while the runtime runs), or null. Read by ConversationRow so only that +// body renders through the settled/tail streaming split. +const StreamingAssistantMessageIdContext = createContext(null); const EMPTY_ROW_ID_SET: ReadonlySet = new Set(); const TimelineSearchExpansionContext = createContext>(EMPTY_ROW_ID_SET); @@ -810,6 +824,45 @@ export function findLastActionableAssistantMessageId( return lastMessageId; } +/** + * The assistant message that is currently receiving text deltas: the trailing + * leaf row of the timeline (descending through the pending turn / delegation + * that owns the live frontier) when it is an assistant conversation row. Text + * deltas only ever append to that row; an assistant message followed by later + * work is complete even while the runtime keeps running. + */ +export function findStreamingAssistantMessageId( + rows: readonly ThreadTimelineViewRow[], +): string | null { + let candidateRows: readonly ThreadTimelineViewRow[] = rows; + for (;;) { + const lastRow = candidateRows[candidateRows.length - 1]; + if (lastRow === undefined) { + return null; + } + if (lastRow.kind === "conversation") { + return lastRow.role === "assistant" ? lastRow.id : null; + } + if ( + lastRow.kind === "turn" && + lastRow.status === "pending" && + lastRow.children !== null + ) { + candidateRows = lastRow.children; + continue; + } + if ( + lastRow.kind === "work" && + lastRow.workKind === "delegation" && + lastRow.status === "pending" + ) { + candidateRows = lastRow.childRows; + continue; + } + return null; + } +} + /** Finds the final regular user-authored message with a mobile action footer. */ export function findLastActionableUserMessageId( rows: readonly ThreadTimelineViewRow[], @@ -938,6 +991,9 @@ function ConversationRow({ const latestActionableUserMessageId = useContext( LatestActionableUserMessageIdContext, ); + const streamingAssistantMessageId = useContext( + StreamingAssistantMessageIdContext, + ); const latestActionableMessageId = row.role === "user" ? latestActionableUserMessageId @@ -949,6 +1005,9 @@ function ConversationRow({ mobileActionDisplay={ row.id === latestActionableMessageId ? "inline" : "overflow" } + streaming={ + row.role === "assistant" && row.id === streamingAssistantMessageId + } /> ); } @@ -978,6 +1037,7 @@ const ConversationRowContent = memo(function ConversationRowContent({ row, showAssistantMessageActions, mobileActionDisplay, + streaming, }: ConversationRowContentProps) { const { canSpawnChild, @@ -1148,6 +1208,7 @@ const ConversationRowContent = memo(function ConversationRowContent({ role="assistant" showActions={showAssistantMessageActions} mobileActionDisplay={mobileActionDisplay} + streaming={streaming} sourceSeqEnd={row.sourceSeqEnd} sourceSeqStart={row.sourceSeqStart} text={row.text} @@ -1326,6 +1387,7 @@ function TimelineExpandableBody({ role="assistant" showActions={false} mobileActionDisplay="overflow" + streaming={delegationActive} sourceSeqEnd={row.sourceSeqEnd} sourceSeqStart={row.sourceSeqStart} text={row.output} @@ -1908,6 +1970,33 @@ function buildTimelineRowsListItems({ return items; } +/** + * Wrapper for a top-level row: carries the compact-viewport containment + * (armed after the row's first layout, see + * `useArmTopLevelTimelineRowContainment`) and the per-row intrinsic size + * estimate. + */ +function TopLevelTimelineRowWrapper({ + children, + row, +}: { + children: ReactNode; + row: ThreadTimelineViewRow; +}) { + const wrapperRef = useRef(null); + useArmTopLevelTimelineRowContainment(wrapperRef); + return ( +
+ {children} +
+ ); +} + function TimelineRowsList({ compactActivityIntents, hasOlderTimelineRows, @@ -1957,16 +2046,26 @@ function TimelineRowsList({ ); } + const rowView = ( + + ); + if (spacing === "top-level") { + return ( + + {rowView} + + ); + } return (
- + {rowView}
); })} @@ -2006,6 +2105,10 @@ function ThreadTimelineRowsForTimelineView(props: ThreadTimelineRowsProps) { const scopeActive = isRunningThreadRuntimeDisplayStatus( props.threadRuntimeDisplayStatus, ); + const streamingAssistantMessageId = useMemo( + () => (scopeActive ? findStreamingAssistantMessageId(rows) : null), + [rows, scopeActive], + ); const themeType = props.themeType ?? "light"; const computedAutoExpansionRowIds = useMemo( () => collectTimelineAutoExpansionRowIds({ rows, scopeActive }), @@ -2223,36 +2326,42 @@ function ThreadTimelineRowsForTimelineView(props: ThreadTimelineRowsProps) { - - - - - {hasSelectionActions ? ( - - ) : null} - + + + + + + {hasSelectionActions ? ( + + ) : null} + + diff --git a/apps/app/src/components/thread/timeline/rows/AssistantMessage.stories.tsx b/apps/app/src/components/thread/timeline/rows/AssistantMessage.stories.tsx index 634df5a245..eeba252094 100644 --- a/apps/app/src/components/thread/timeline/rows/AssistantMessage.stories.tsx +++ b/apps/app/src/components/thread/timeline/rows/AssistantMessage.stories.tsx @@ -214,6 +214,7 @@ export function Overview() { turnRequest={null} showActions={true} mobileActionDisplay="inline" + streaming={false} /> @@ -234,6 +235,7 @@ export function Overview() { turnRequest={null} showActions={true} mobileActionDisplay="inline" + streaming={false} /> diff --git a/apps/app/src/components/thread/timeline/streaming-markdown-split.test.ts b/apps/app/src/components/thread/timeline/streaming-markdown-split.test.ts new file mode 100644 index 0000000000..12ec696051 --- /dev/null +++ b/apps/app/src/components/thread/timeline/streaming-markdown-split.test.ts @@ -0,0 +1,116 @@ +import { describe, expect, it } from "vitest"; +import { splitStreamingMarkdown } from "./streaming-markdown-split"; + +function expectSplit(text: string, settled: string) { + const split = splitStreamingMarkdown(text); + expect(split).not.toBeNull(); + expect(split?.settled).toBe(settled); + expect(split?.tail).toBe(text.slice(settled.length)); + expect(`${split?.settled}${split?.tail}`).toBe(text); +} + +describe("splitStreamingMarkdown", () => { + it("returns null when there is no blank line followed by a complete line", () => { + expect(splitStreamingMarkdown("")).toBeNull(); + expect(splitStreamingMarkdown("Only one paragraph so far")).toBeNull(); + expect(splitStreamingMarkdown("Para one.\n\nPara two still")).toBeNull(); + expect(splitStreamingMarkdown("\n\nleading blanks\n")).toBeNull(); + }); + + it("splits at the last blank line whose next line is complete", () => { + expectSplit("Para one.\n\nPara two.\n\nPara three", "Para one.\n\n"); + expectSplit( + "Para one.\n\nPara two.\n\nPara three.\nmore", + "Para one.\n\nPara two.\n\n", + ); + }); + + it("only moves the boundary forward as text streams in", () => { + const chunks = [ + "# Title\n", + "\n", + "Intro paragraph.\n", + "\n", + "```ts\n", + "const a = 1;\n", + "\n", + "const b = 2;\n", + "```\n", + "\n", + "1. first\n", + "\n", + "2. second\n", + "\n", + "Closing", + " words.\n", + "\n", + "Done.\n", + ]; + let text = ""; + let previousSettledLength = 0; + for (const chunk of chunks) { + text += chunk; + const split = splitStreamingMarkdown(text); + const settledLength = split?.settled.length ?? 0; + expect(settledLength).toBeGreaterThanOrEqual(previousSettledLength); + if (split !== null) { + expect(text.startsWith(split.settled)).toBe(true); + } + previousSettledLength = settledLength; + } + }); + + it("does not split inside an open fenced code block", () => { + const text = "Intro.\n\n```js\nline one\n\nline two\n\nline three\n"; + expectSplit(text, "Intro.\n\n"); + const closed = `${text}\`\`\`\n\nAfter the fence.\n`; + expectSplit( + closed, + "Intro.\n\n```js\nline one\n\nline two\n\nline three\n```\n\n", + ); + }); + + it("requires the closing fence to match the opening marker", () => { + // A shorter backtick run does not close a four-backtick fence. + const text = "Intro.\n\n````md\n```\ninner\n```\n\ntext\n"; + expectSplit(text, "Intro.\n\n"); + // A tilde fence is closed only by tildes. + const tilde = "Intro.\n\n~~~\n```\n\nx\n"; + expectSplit(tilde, "Intro.\n\n"); + }); + + it("does not split inside an open $$ math block", () => { + const text = "Formula:\n\n$$\na = b\n\nc = d\n"; + expectSplit(text, "Formula:\n\n"); + const closed = `${text}$$\n\nDone.\n`; + expectSplit(closed, "Formula:\n\n$$\na = b\n\nc = d\n$$\n\n"); + }); + + it("treats inline $$x$$ spans as closed math", () => { + expectSplit( + "Price is $$x$$ here.\n\nNext para.\n\nTail\n", + "Price is $$x$$ here.\n\nNext para.\n\n", + ); + }); + + it("does not split between items of a loose list or before indented continuation", () => { + expectSplit( + "Intro.\n\n- one\n\n- two\n\n- three\n\nAfter list.\n", + "Intro.\n\n- one\n\n- two\n\n- three\n\n", + ); + expectSplit( + "Intro.\n\n1. one\n\n continued\n\n2. two\n\nAfter.\n", + "Intro.\n\n1. one\n\n continued\n\n2. two\n\n", + ); + }); + + it("splits before a heading that follows a blank line", () => { + const split = splitStreamingMarkdown("Intro text.\n\n## Section\n\nBody"); + expect(split?.settled).toBe("Intro text.\n\n"); + expect(split?.tail).toBe("## Section\n\nBody"); + expectSplit( + "Intro text.\n\n## Section\n\nBody paragraph.\n\nMore", + "Intro text.\n\n## Section\n\n", + ); + }); +}); diff --git a/apps/app/src/components/thread/timeline/streaming-markdown-split.ts b/apps/app/src/components/thread/timeline/streaming-markdown-split.ts new file mode 100644 index 0000000000..9d2db109e4 --- /dev/null +++ b/apps/app/src/components/thread/timeline/streaming-markdown-split.ts @@ -0,0 +1,146 @@ +/** + * Splits an in-progress assistant message into a settled prefix and a live + * tail so the timeline can render them as two memoized `MarkdownPreview` + * instances: only the tail is re-parsed when the next delta arrives. + * + * The boundary is the last blank line that + * - is not inside an open fenced code block or `$$` math block, + * - is not inside a list (the next line is neither indented continuation + * nor a further list item), and + * - is followed by a complete line (one already terminated by `\n`), so the + * partially streamed last line can never make an earlier boundary + * eligible and later ineligible. Boundaries therefore only ever move + * forward while text is appended. + * + * Returns `null` when no such boundary exists (short messages, an open fence + * spanning the whole text, ...) so the caller renders one document. + */ +export interface StreamingMarkdownSplit { + settled: string; + tail: string; +} + +interface OpenFence { + char: string; + length: number; +} + +const FENCE_PATTERN = /^\s*(`{3,}|~{3,})/u; +const LIST_MARKER_PATTERN = /^\s{0,3}(?:[-*+]|\d{1,9}[.)])(?:\s|$)/u; +const INDENTED_CONTINUATION_PATTERN = /^(?: {2,}|\t)/u; +const MATH_DELIMITER = "$$"; + +function parseFenceOpen(line: string): OpenFence | null { + const match = FENCE_PATTERN.exec(line); + if (match === null) { + return null; + } + const marker = match[1] ?? ""; + return { char: marker[0] ?? "`", length: marker.length }; +} + +function closesFence(line: string, fence: OpenFence): boolean { + const match = FENCE_PATTERN.exec(line); + if (match === null) { + return false; + } + const marker = match[1] ?? ""; + if (marker[0] !== fence.char || marker.length < fence.length) { + return false; + } + return line.slice(match[0].length).trim().length === 0; +} + +function countOccurrences(line: string, needle: string): number { + let count = 0; + let index = line.indexOf(needle); + while (index !== -1) { + count += 1; + index = line.indexOf(needle, index + needle.length); + } + return count; +} + +function isBlankLine(line: string): boolean { + return line.trim().length === 0; +} + +function isListLike(line: string): boolean { + return ( + LIST_MARKER_PATTERN.test(line) || INDENTED_CONTINUATION_PATTERN.test(line) + ); +} + +export function splitStreamingMarkdown( + text: string, +): StreamingMarkdownSplit | null { + const lines = text.split("\n"); + // `lines[lines.length - 1]` is the unterminated last line (possibly empty). + const lastCompleteLineIndex = lines.length - 2; + let openFence: OpenFence | null = null; + let mathOpen = false; + let lastNonBlankLine: string | null = null; + let boundaryLineIndex = -1; + + for (let index = 0; index <= lastCompleteLineIndex; index += 1) { + const line = lines[index] ?? ""; + if (openFence !== null) { + if (closesFence(line, openFence)) { + openFence = null; + } + lastNonBlankLine = line; + continue; + } + if (mathOpen) { + if (countOccurrences(line, MATH_DELIMITER) % 2 === 1) { + mathOpen = false; + } + lastNonBlankLine = line; + continue; + } + if (isBlankLine(line)) { + // The line after the boundary must already be complete. + if (index + 1 > lastCompleteLineIndex) { + continue; + } + const nextLine = lines[index + 1] ?? ""; + if (INDENTED_CONTINUATION_PATTERN.test(nextLine)) { + continue; + } + if ( + lastNonBlankLine !== null && + isListLike(lastNonBlankLine) && + LIST_MARKER_PATTERN.test(nextLine) + ) { + continue; + } + if (lastNonBlankLine === null) { + // Leading blank lines: nothing settled yet. + continue; + } + boundaryLineIndex = index; + continue; + } + lastNonBlankLine = line; + const fence = parseFenceOpen(line); + if (fence !== null) { + openFence = fence; + continue; + } + if (countOccurrences(line, MATH_DELIMITER) % 2 === 1) { + mathOpen = true; + } + } + + if (boundaryLineIndex === -1) { + return null; + } + let settledLength = 0; + for (let index = 0; index <= boundaryLineIndex; index += 1) { + settledLength += (lines[index] ?? "").length + 1; + } + return { + settled: text.slice(0, settledLength), + tail: text.slice(settledLength), + }; +} diff --git a/apps/app/src/components/thread/timeline/timeline-row-containment.ts b/apps/app/src/components/thread/timeline/timeline-row-containment.ts new file mode 100644 index 0000000000..69c2902646 --- /dev/null +++ b/apps/app/src/components/thread/timeline/timeline-row-containment.ts @@ -0,0 +1,118 @@ +import { useEffect, type CSSProperties, type RefObject } from "react"; +import type { ThreadTimelineViewRow } from "@bb/thread-view"; +import { supportsScrollAnchoring } from "@/lib/scroll-anchoring-support"; + +/** + * Top-level timeline rows skip layout and paint while off screen on compact + * viewports (`content-visibility: auto`). Every mounted page stays in the DOM, + * so on a phone each style/layout pass (keyboard, orientation, streaming + * growth) otherwise walks every loaded row. + * + * Only the top-level list opts in: nested lists live inside an expandable + * body whose own height animates, and containment on those would fight the + * height transition. Compact-only because paint containment clips the + * assistant markdown table breakout, which on wide layouts extends past the + * row column (on compact the breakout equals the row width). + * + * A row is laid out once at its real size before it opts in + * ({@link useArmTopLevelTimelineRowContainment}); `contain-intrinsic-block-size: + * auto ` then replays that last remembered height whenever the row + * is skipped, so realizing the row later does not change the scroll range. + * Applying `content-visibility: auto` from the first frame would leave every + * row above the initial viewport (the timeline mounts scrolled to the bottom) + * and every prepended older page at the estimate. The estimate below only + * backs a row whose remembered size is missing. + * + * WebKit never arms: it has no CSS scroll anchoring, so any difference between + * a skipped row's replayed size and its real size (a stale remembered size, a + * row that changed while skipped) moves the visible content instead of being + * absorbed, which reads as a flash-and-scroll while a thread settles on iOS. + * Chromium and Firefox anchor the viewport through those corrections, so the + * layout/paint savings stay there. + */ +export const TOP_LEVEL_TIMELINE_ROW_INTRINSIC_SIZE_CLASS_NAME = + "max-md:[contain-intrinsic-block-size:auto_1.25rem]"; +const CONTENT_VISIBILITY_CLASS_NAME = "max-md:[content-visibility:auto]"; +export const TOP_LEVEL_TIMELINE_ROW_CLASS_NAME = `${CONTENT_VISIBILITY_CLASS_NAME} ${TOP_LEVEL_TIMELINE_ROW_INTRINSIC_SIZE_CLASS_NAME}`; + +/** + * Arms `content-visibility: auto` on a top-level row wrapper once the row has + * been laid out once. Two animation frames: the first callback runs before + * that frame's style/layout pass (which lays the row out unskipped and records + * its last remembered size), the second runs after it. The class is added + * through `classList` rather than a React re-render: the wrapper's `className` + * prop stays constant, so React never rewrites the attribute and never drops + * classes other code adds imperatively (the search-match flash). + * + * Renders with {@link TOP_LEVEL_TIMELINE_ROW_INTRINSIC_SIZE_CLASS_NAME}; the + * armed wrapper carries {@link TOP_LEVEL_TIMELINE_ROW_CLASS_NAME}. + */ +export function useArmTopLevelTimelineRowContainment( + wrapperRef: RefObject, +): void { + useEffect(() => { + const wrapper = wrapperRef.current; + if (wrapper === null || !supportsScrollAnchoring()) { + return; + } + let cancelled = false; + let secondFrame: number | null = null; + const firstFrame = requestAnimationFrame(() => { + if (cancelled) { + return; + } + secondFrame = requestAnimationFrame(() => { + if (!cancelled) { + wrapper.classList.add(CONTENT_VISIBILITY_CLASS_NAME); + } + }); + }); + return () => { + cancelled = true; + cancelAnimationFrame(firstFrame); + if (secondFrame !== null) { + cancelAnimationFrame(secondFrame); + } + }; + }, [wrapperRef]); +} + +/** + * Compact conversation rows: `text-sm leading-relaxed` lines (~23px) at the + * ~44 characters that fit a phone-width column, plus bubble padding / the + * in-flow action bar. Bucketed so the streaming row's estimate is not + * rewritten on every delta. + */ +const CONVERSATION_ROW_BASE_PX = 48; +const CONVERSATION_ROW_LINE_PX = 23; +const COMPACT_CHARS_PER_LINE = 44; +const CONVERSATION_ROW_BUCKET_PX = 24; +// User messages clamp at 15 lines until expanded. +const USER_MESSAGE_MAX_LINES = 15; + +export function estimateTimelineRowIntrinsicBlockSizePx( + row: ThreadTimelineViewRow, +): number | null { + if (row.kind !== "conversation") { + return null; + } + let lines = Math.max(1, Math.ceil(row.text.length / COMPACT_CHARS_PER_LINE)); + if (row.role === "user") { + lines = Math.min(lines, USER_MESSAGE_MAX_LINES); + } + const estimate = CONVERSATION_ROW_BASE_PX + lines * CONVERSATION_ROW_LINE_PX; + return ( + Math.ceil(estimate / CONVERSATION_ROW_BUCKET_PX) * + CONVERSATION_ROW_BUCKET_PX + ); +} + +export function timelineRowContainmentStyle( + row: ThreadTimelineViewRow, +): CSSProperties | undefined { + const estimate = estimateTimelineRowIntrinsicBlockSizePx(row); + if (estimate === null) { + return undefined; + } + return { containIntrinsicBlockSize: `auto ${estimate}px` }; +} diff --git a/apps/app/src/components/ui/markdown-mermaid-diagram.render.test.tsx b/apps/app/src/components/ui/markdown-mermaid-diagram.render.test.tsx new file mode 100644 index 0000000000..972720fb58 --- /dev/null +++ b/apps/app/src/components/ui/markdown-mermaid-diagram.render.test.tsx @@ -0,0 +1,226 @@ +// @vitest-environment jsdom + +import { act, cleanup, render } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { MarkdownMermaidDiagram } from "./markdown-mermaid-diagram"; +import { + buildMermaidRenderCacheKey, + clearMermaidRenderCache, + getMermaidRenderCacheSize, + MERMAID_RENDER_CACHE_LIMIT, + MERMAID_SOURCE_RENDER_DEBOUNCE_MS, + readMermaidRenderCache, + storeMermaidRenderCache, +} from "./markdown-mermaid-render-cache"; + +const mermaidRender = vi.hoisted(() => + vi.fn(async (_id: string, source: string) => ({ + svg: ``, + bindFunctions: undefined, + })), +); +vi.mock("./markdown-mermaid-loader.js", () => ({ + loadMermaid: async () => ({ + initialize: () => undefined, + render: mermaidRender, + }), +})); + +// One controllable IntersectionObserver for the whole file: tests decide when +// an observed element "enters" the viewport. +type ObserverCallback = ( + entries: { isIntersecting: boolean; target: Element }[], +) => void; +const observers: { + callback: ObserverCallback; + targets: Set; + disconnected: boolean; +}[] = []; +class FakeIntersectionObserver { + private readonly record: (typeof observers)[number]; + constructor(callback: ObserverCallback) { + this.record = { callback, targets: new Set(), disconnected: false }; + observers.push(this.record); + } + observe(target: Element) { + this.record.targets.add(target); + } + unobserve(target: Element) { + this.record.targets.delete(target); + } + disconnect() { + this.record.disconnected = true; + this.record.targets.clear(); + } + takeRecords() { + return []; + } +} + +function enterViewport(target: Element) { + for (const observer of observers) { + if (observer.targets.has(target)) { + observer.callback([{ isIntersecting: true, target }]); + } + } +} + +function diagramContainer(container: HTMLElement): Element { + const element = container.firstElementChild; + if (element === null) { + throw new Error("diagram container did not render"); + } + return element; +} + +async function flushRenders() { + await act(async () => { + await Promise.resolve(); + await Promise.resolve(); + }); +} + +beforeEach(() => { + vi.useFakeTimers(); + vi.stubGlobal("IntersectionObserver", FakeIntersectionObserver); + observers.length = 0; + mermaidRender.mockClear(); + clearMermaidRenderCache(); +}); + +afterEach(() => { + cleanup(); + vi.unstubAllGlobals(); + vi.useRealTimers(); +}); + +describe("MarkdownMermaidDiagram render gating", () => { + it("does not render until the diagram nears the viewport and shares one observer across diagrams", async () => { + const first = render( + , + ); + const second = render( + , + ); + await flushRenders(); + expect(mermaidRender).not.toHaveBeenCalled(); + expect(observers.filter((observer) => !observer.disconnected)).toHaveLength( + 1, + ); + + act(() => { + enterViewport(diagramContainer(first.container)); + }); + await flushRenders(); + expect(mermaidRender).toHaveBeenCalledTimes(1); + expect(mermaidRender.mock.calls[0]?.[1]).toBe("graph TD; A-->B"); + expect( + first.container.querySelector('svg[data-source="graph TD; A-->B"]'), + ).not.toBeNull(); + expect(second.container.querySelector("svg[data-source]")).toBeNull(); + }); + + it("debounces streaming source updates and keeps the previous diagram on screen meanwhile", async () => { + const view = render( + , + ); + act(() => { + enterViewport(diagramContainer(view.container)); + }); + await flushRenders(); + expect(mermaidRender).toHaveBeenCalledTimes(1); + + view.rerender( + , + ); + await flushRenders(); + await act(async () => { + vi.advanceTimersByTime(MERMAID_SOURCE_RENDER_DEBOUNCE_MS - 50); + }); + view.rerender( + , + ); + await flushRenders(); + await act(async () => { + vi.advanceTimersByTime(MERMAID_SOURCE_RENDER_DEBOUNCE_MS - 50); + }); + // Neither intermediate source rendered; the first diagram is still shown. + expect(mermaidRender).toHaveBeenCalledTimes(1); + expect( + view.container.querySelector('svg[data-source="graph TD; A"]'), + ).not.toBeNull(); + + await act(async () => { + vi.advanceTimersByTime(50); + }); + await flushRenders(); + expect(mermaidRender).toHaveBeenCalledTimes(2); + expect(mermaidRender.mock.calls[1]?.[1]).toBe("graph TD; A-->B"); + expect( + view.container.querySelector('svg[data-source="graph TD; A-->B"]'), + ).not.toBeNull(); + }); + + it("serves a remounted diagram from the render cache without calling mermaid again", async () => { + const view = render( + , + ); + act(() => { + enterViewport(diagramContainer(view.container)); + }); + await flushRenders(); + expect(mermaidRender).toHaveBeenCalledTimes(1); + view.unmount(); + + const remounted = render( + , + ); + // Cached diagrams paint on the first frame, without waiting for the + // viewport gate or Mermaid. + expect( + remounted.container.querySelector('svg[data-source="graph TD; X-->Y"]'), + ).not.toBeNull(); + await flushRenders(); + expect(mermaidRender).toHaveBeenCalledTimes(1); + + // A different theme is a different cache entry. + remounted.rerender( + , + ); + await flushRenders(); + expect(mermaidRender).toHaveBeenCalledTimes(2); + }); +}); + +describe("mermaid render cache", () => { + it("evicts the least recently used entry past the limit", () => { + const diagram = { svg: "", bindFunctions: undefined }; + const keyFor = (index: number) => + buildMermaidRenderCacheKey({ + appThemeEpoch: 0, + preferredTheme: "light", + source: `graph ${index}`, + }); + for (let index = 0; index < MERMAID_RENDER_CACHE_LIMIT; index += 1) { + storeMermaidRenderCache(keyFor(index), diagram); + } + // Touch the oldest entry so it becomes most recent. + expect(readMermaidRenderCache(keyFor(0))).toBe(diagram); + storeMermaidRenderCache(keyFor(MERMAID_RENDER_CACHE_LIMIT), diagram); + expect(getMermaidRenderCacheSize()).toBe(MERMAID_RENDER_CACHE_LIMIT); + expect(readMermaidRenderCache(keyFor(0))).toBe(diagram); + expect(readMermaidRenderCache(keyFor(1))).toBeNull(); + }); +}); diff --git a/apps/app/src/components/ui/markdown-mermaid-diagram.tsx b/apps/app/src/components/ui/markdown-mermaid-diagram.tsx index 2a123c4cba..d934f71d8e 100644 --- a/apps/app/src/components/ui/markdown-mermaid-diagram.tsx +++ b/apps/app/src/components/ui/markdown-mermaid-diagram.tsx @@ -8,8 +8,9 @@ import { type CSSProperties, type ComponentPropsWithoutRef, type PointerEventHandler, + type Ref, } from "react"; -import type { MermaidConfig, RenderResult } from "mermaid"; +import type { MermaidConfig } from "mermaid"; import { Dialog, DialogContent, @@ -20,6 +21,15 @@ import { Button } from "@bb/shared-ui/button"; import { CopyButton } from "./copy-button.js"; import { Icon } from "@bb/shared-ui/icon"; import { loadMermaid } from "./markdown-mermaid-loader.js"; +import { + buildMermaidRenderCacheKey, + MERMAID_SOURCE_RENDER_DEBOUNCE_MS, + observeMermaidViewportEntry, + peekMermaidRenderCache, + readMermaidRenderCache, + storeMermaidRenderCache, + type RenderedMermaidDiagram, +} from "./markdown-mermaid-render-cache.js"; import { useAppThemeEpoch } from "@/hooks/useAppTheme"; import type { Theme } from "@/hooks/useTheme"; import { cn } from "@bb/shared-ui/lib/utils"; @@ -29,11 +39,6 @@ export interface MarkdownMermaidDiagramProps { source: string; } -interface RenderedMermaidDiagram { - bindFunctions: RenderResult["bindFunctions"]; - svg: string; -} - interface MermaidThemePalette { actorBorder: string; actorBkg: string; @@ -205,7 +210,9 @@ type MermaidRenderState = | { kind: "source" }; type MermaidTheme = NonNullable; -type MermaidDiagramContainerProps = ComponentPropsWithoutRef<"div">; +type MermaidDiagramContainerProps = ComponentPropsWithoutRef<"div"> & { + ref?: Ref; +}; type MermaidDiagramPointerHandler = PointerEventHandler; const MERMAID_THEME: MermaidTheme = "base"; @@ -557,11 +564,13 @@ function createMermaidDialogDiagramStyle({ function MermaidDiagramContainer({ children, className, + ref, ...containerProps }: MermaidDiagramContainerProps) { return (
(null); const diagramElementRef = useRef(null); const renderId = useMemo(() => buildMermaidRenderId(reactId), [reactId]); // Re-render the SVG (which has baked-in colors) when the app palette changes, // not just on light/dark mode toggles. const appThemeEpoch = useAppThemeEpoch(); - const [renderState, setRenderState] = useState({ - kind: "loading", - }); + // A diagram that was rendered before (a remount during the streaming + // settled/tail hand-off, a re-expanded turn, navigating back) paints its + // cached SVG on the first frame instead of flashing the placeholder. + const [initialCachedDiagram] = useState(() => + peekMermaidRenderCache( + buildMermaidRenderCacheKey({ appThemeEpoch, preferredTheme, source }), + ), + ); + const [renderState, setRenderState] = useState(() => + initialCachedDiagram === null + ? { kind: "loading" } + : { kind: "rendered", diagram: initialCachedDiagram }, + ); const [isDialogOpen, setIsDialogOpen] = useState(false); const [displayMode, setDisplayMode] = useState("preview"); + // Diagrams render only once they come near the viewport (shared observer); + // a long thread with many diagrams does not run Mermaid for all of them on + // mount. + const [hasEnteredViewport, setHasEnteredViewport] = useState( + initialCachedDiagram !== null, + ); + // The source last handed to the renderer for this instance. Source changes + // after the first render are streaming deltas and are debounced; the first + // render and theme changes run immediately. + const renderedSourceRef = useRef( + initialCachedDiagram === null ? null : source, + ); useEffect(() => { - let isCurrentRender = true; + const containerElement = containerElementRef.current; + if (hasEnteredViewport || containerElement === null) { + return; + } + return observeMermaidViewportEntry(containerElement, () => { + setHasEnteredViewport(true); + }); + }, [hasEnteredViewport]); + + useEffect(() => { + if (!hasEnteredViewport) { + return; + } + const cacheKey = buildMermaidRenderCacheKey({ + appThemeEpoch, + preferredTheme, + source, + }); + const cachedDiagram = readMermaidRenderCache(cacheKey); + const isSourceUpdate = + renderedSourceRef.current !== null && + renderedSourceRef.current !== source; + renderedSourceRef.current = source; + if (cachedDiagram !== null) { + setRenderState((currentState) => + currentState.kind === "rendered" && + currentState.diagram === cachedDiagram + ? currentState + : { kind: "rendered", diagram: cachedDiagram }, + ); + setDisplayMode("preview"); + return; + } - setRenderState({ kind: "loading" }); - setDisplayMode("preview"); - loadMermaid() - .then((mermaid) => { - mermaid.initialize(buildMermaidConfig(preferredTheme)); - return mermaid.render(renderId, source); - }) - .then((renderResult) => { - if (!isCurrentRender) { - return; - } - - setRenderState({ - kind: "rendered", - diagram: { + let isCurrentRender = true; + const runRender = () => { + loadMermaid() + .then((mermaid) => { + if (!isCurrentRender) { + return null; + } + mermaid.initialize(buildMermaidConfig(preferredTheme)); + return mermaid.render(renderId, source); + }) + .then((renderResult) => { + if (!isCurrentRender || renderResult === null) { + return; + } + const diagram: RenderedMermaidDiagram = { bindFunctions: renderResult.bindFunctions, svg: renderResult.svg, - }, + }; + storeMermaidRenderCache(cacheKey, diagram); + setRenderState({ kind: "rendered", diagram }); + }) + .catch(() => { + if (!isCurrentRender) { + return; + } + setRenderState({ kind: "source" }); }); - }) - .catch(() => { - if (!isCurrentRender) { - return; - } - - setRenderState({ kind: "source" }); - }); + }; + if (!isSourceUpdate) { + setRenderState({ kind: "loading" }); + setDisplayMode("preview"); + runRender(); + return () => { + isCurrentRender = false; + }; + } + // Streaming delta: keep the previous render on screen and re-render once + // the source has been stable for the debounce window. + const timeoutId = window.setTimeout( + runRender, + MERMAID_SOURCE_RENDER_DEBOUNCE_MS, + ); return () => { isCurrentRender = false; + window.clearTimeout(timeoutId); }; - }, [preferredTheme, renderId, source, appThemeEpoch]); + }, [appThemeEpoch, hasEnteredViewport, preferredTheme, renderId, source]); useEffect(() => { if (renderState.kind !== "rendered" || displayMode !== "preview") { @@ -1045,7 +1125,7 @@ export function MarkdownMermaidDiagram({ }; return ( - +
mermaid diff --git a/apps/app/src/components/ui/markdown-mermaid-render-cache.ts b/apps/app/src/components/ui/markdown-mermaid-render-cache.ts new file mode 100644 index 0000000000..9c8d537acf --- /dev/null +++ b/apps/app/src/components/ui/markdown-mermaid-render-cache.ts @@ -0,0 +1,148 @@ +import type { RenderResult } from "mermaid"; +import type { Theme } from "@/hooks/useTheme"; + +/** + * Module-level helpers that keep Mermaid rendering off the hot path: + * + * - `observeMermaidViewportEntry` shares one `IntersectionObserver` across every + * diagram so a message with many diagrams (or many mounted messages) does not + * allocate an observer each, and reports the first time a diagram comes near + * the viewport. Diagrams stay unrendered until then. + * - The render cache remembers the last rendered SVGs keyed by + * (source, theme, palette epoch) so a diagram that remounts (streaming + * settled/tail hand-off, collapsed turn re-expansion, navigating back to a + * thread) or flips back to a previous theme paints synchronously instead of + * running Mermaid again. + */ + +export interface RenderedMermaidDiagram { + bindFunctions: RenderResult["bindFunctions"]; + svg: string; +} + +export interface MermaidRenderCacheKeyArgs { + appThemeEpoch: number; + preferredTheme: Theme; + source: string; +} + +/** + * Trailing delay applied when a mounted diagram's `source` changes (streaming + * deltas). The first render of a diagram and theme changes are immediate. + */ +export const MERMAID_SOURCE_RENDER_DEBOUNCE_MS = 300; + +/** Diagrams enter the render gate this far before they scroll into view. */ +export const MERMAID_VIEWPORT_ROOT_MARGIN = "256px 0px"; + +export const MERMAID_RENDER_CACHE_LIMIT = 32; + +const renderCache = new Map(); + +export function buildMermaidRenderCacheKey({ + appThemeEpoch, + preferredTheme, + source, +}: MermaidRenderCacheKeyArgs): string { + return `${preferredTheme}\u0000${appThemeEpoch}\u0000${source}`; +} + +/** Reads without touching LRU order (safe to call during render). */ +export function peekMermaidRenderCache( + key: string, +): RenderedMermaidDiagram | null { + return renderCache.get(key) ?? null; +} + +export function readMermaidRenderCache( + key: string, +): RenderedMermaidDiagram | null { + const cached = renderCache.get(key); + if (cached === undefined) { + return null; + } + // Re-insert so the map's iteration order doubles as LRU order. + renderCache.delete(key); + renderCache.set(key, cached); + return cached; +} + +export function storeMermaidRenderCache( + key: string, + diagram: RenderedMermaidDiagram, +): void { + renderCache.delete(key); + renderCache.set(key, diagram); + while (renderCache.size > MERMAID_RENDER_CACHE_LIMIT) { + const oldestKey = renderCache.keys().next().value; + if (oldestKey === undefined) { + break; + } + renderCache.delete(oldestKey); + } +} + +export function clearMermaidRenderCache(): void { + renderCache.clear(); +} + +export function getMermaidRenderCacheSize(): number { + return renderCache.size; +} + +type ViewportEntryCallback = () => void; + +let sharedViewportObserver: IntersectionObserver | null = null; +const viewportEntryCallbacks = new Map(); + +function getSharedViewportObserver(): IntersectionObserver { + sharedViewportObserver ??= new IntersectionObserver( + (entries) => { + for (const entry of entries) { + if (!entry.isIntersecting) { + continue; + } + const callback = viewportEntryCallbacks.get(entry.target); + if (callback === undefined) { + continue; + } + viewportEntryCallbacks.delete(entry.target); + sharedViewportObserver?.unobserve(entry.target); + callback(); + } + releaseSharedViewportObserverIfIdle(); + }, + { rootMargin: MERMAID_VIEWPORT_ROOT_MARGIN }, + ); + return sharedViewportObserver; +} + +function releaseSharedViewportObserverIfIdle(): void { + if (viewportEntryCallbacks.size === 0 && sharedViewportObserver !== null) { + sharedViewportObserver.disconnect(); + sharedViewportObserver = null; + } +} + +/** + * Calls `onEnter` once, the first time `element` intersects the viewport + * (expanded by {@link MERMAID_VIEWPORT_ROOT_MARGIN}). Environments without + * `IntersectionObserver` enter immediately. Returns an unsubscribe function. + */ +export function observeMermaidViewportEntry( + element: Element, + onEnter: ViewportEntryCallback, +): () => void { + if (typeof IntersectionObserver === "undefined") { + onEnter(); + return () => {}; + } + viewportEntryCallbacks.set(element, onEnter); + getSharedViewportObserver().observe(element); + return () => { + if (viewportEntryCallbacks.delete(element)) { + sharedViewportObserver?.unobserve(element); + } + releaseSharedViewportObserverIfIdle(); + }; +}