diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index 13024a7516ff..8a39e05ab4f5 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -125,6 +125,11 @@ interface ChatMarkdownProps { parseRawHtml?: boolean; } +interface StreamingChatMarkdownProps { + text: string; + className?: string | undefined; +} + const EMPTY_MARKDOWN_SKILLS: ReadonlyArray> = []; const CODE_FENCE_LANGUAGE_REGEX = /(?:^|\s)language-([^\s]+)/; @@ -1442,7 +1447,44 @@ function areMarkdownFileLinkPropsEqual( ); } -function ChatMarkdown({ +const StreamingChatMarkdown = memo(function StreamingChatMarkdown({ + text, + className, +}: StreamingChatMarkdownProps) { + const [displayText, setDisplayText] = useState(text); + + useEffect(() => { + if (typeof window === "undefined") { + setDisplayText(text); + return; + } + + const frame = window.requestAnimationFrame(() => setDisplayText(text)); + return () => window.cancelAnimationFrame(frame); + }, [text]); + + return ( +
+
{displayText}
+
+ ); +}); + +function ChatMarkdown(props: ChatMarkdownProps) { + if (props.isStreaming) { + return ; + } + + return ; +} + +function SettledChatMarkdown({ text, cwd, threadRef, diff --git a/apps/web/src/components/ChatMarkdown.workspace-images.test.tsx b/apps/web/src/components/ChatMarkdown.workspace-images.test.tsx index 0b7838afa86e..26251437f046 100644 --- a/apps/web/src/components/ChatMarkdown.workspace-images.test.tsx +++ b/apps/web/src/components/ChatMarkdown.workspace-images.test.tsx @@ -46,6 +46,10 @@ function renderWithoutThread(markdown: string): string { return renderToStaticMarkup(); } +function renderStreaming(markdown: string): string { + return renderToStaticMarkup(); +} + describe("ChatMarkdown workspace images", () => { beforeEach(() => { testState.resources = []; @@ -132,4 +136,13 @@ describe("ChatMarkdown workspace images", () => { expect(html).toContain("max-h-[30rem]"); expect(html).not.toContain("Image unavailable"); }); + + it("uses lightweight text while a response is streaming", () => { + const html = renderStreaming("**not yet rich**\n```ts\nconst value = 1;\n```"); + + expect(html).toContain('data-streaming-markdown="true"'); + expect(html).toContain("**not yet rich**"); + expect(html).not.toContain(""); + expect(html).not.toContain("chat-markdown-codeblock"); + }); }); diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 3c3770127e66..7f359240c820 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -59,6 +59,7 @@ import { memo, Suspense, useCallback, + useDeferredValue, useEffect, useLayoutEffect, useMemo, @@ -1563,6 +1564,16 @@ function ChatViewContent(props: ChatViewProps) { // depend on which route is mounted. const isServerThread = activeServerThread !== null; const activeThread = activeServerThread ?? localDraftThread; + // Thread detail snapshots arrive on the stream for every message/activity + // update. Keep the composer and command surfaces current, but let the + // expensive timeline projection consume those snapshots at background + // priority so input, scrolling, and shell controls remain responsive. + const deferredTimelineThread = useDeferredValue(activeThread); + const timelineThread = + deferredTimelineThread?.id === activeThread?.id && + deferredTimelineThread?.environmentId === activeThread?.environmentId + ? deferredTimelineThread + : activeThread; const threadError = isServerThread ? (localServerError ?? activeServerThread?.session?.lastError ?? null) : localDraftError; @@ -2262,8 +2273,12 @@ function ChatViewContent(props: ChatViewProps) { const selectedProvider: ProviderDriverKind = lockedProvider ?? unlockedSelectedProvider; const phase = derivePhase(activeThread?.session ?? null); const threadActivities = activeThread?.activities ?? EMPTY_ACTIVITIES; - const workLogEntries = useMemo(() => deriveWorkLogEntries(threadActivities), [threadActivities]); - const turnPlans = useMemo(() => deriveTurnPlans(threadActivities), [threadActivities]); + const timelineActivities = timelineThread?.activities ?? EMPTY_ACTIVITIES; + const workLogEntries = useMemo( + () => deriveWorkLogEntries(timelineActivities), + [timelineActivities], + ); + const turnPlans = useMemo(() => deriveTurnPlans(timelineActivities), [timelineActivities]); // Native subagent fold: memoized by activity-list identity, shared by the // Agents surface, live strip, and workflow cards. v2Projection is null // until orchestration-v2 lands (source precedence lives in the derive). @@ -2276,6 +2291,9 @@ function ChatViewContent(props: ChatViewProps) { }), [agentSessionLive, threadActivities], ); + const deferredAgentPanelModel = useDeferredValue(agentPanelModel); + const timelineAgentPanelModel = + timelineThread === activeThread ? agentPanelModel : deferredAgentPanelModel; const pendingApprovals = useMemo( () => derivePendingApprovals(threadActivities), [threadActivities], @@ -2478,6 +2496,9 @@ function ChatViewContent(props: ChatViewProps) { }; }); }, [serverAttachmentUrlById, serverMessages]); + const deferredDisplayServerMessages = useDeferredValue(displayServerMessages); + const timelineDisplayServerMessages = + timelineThread === activeThread ? displayServerMessages : deferredDisplayServerMessages; useEffect(() => { if (typeof Image === "undefined" || displayServerMessages.length === 0) { return; @@ -2563,7 +2584,7 @@ function ChatViewContent(props: ChatViewProps) { }; }, [attachmentPreviewHandoffByMessageId, clearAttachmentPreviewHandoff, displayServerMessages]); const timelineMessages = useMemo(() => { - const messages = displayServerMessages; + const messages = timelineDisplayServerMessages; const serverMessagesWithPreviewHandoff = Object.keys(attachmentPreviewHandoffByMessageId).length === 0 ? messages @@ -2613,16 +2634,16 @@ function ChatViewContent(props: ChatViewProps) { return serverMessagesWithPreviewHandoff; } return [...serverMessagesWithPreviewHandoff, ...pendingMessages]; - }, [attachmentPreviewHandoffByMessageId, displayServerMessages, optimisticUserMessages]); + }, [attachmentPreviewHandoffByMessageId, optimisticUserMessages, timelineDisplayServerMessages]); const timelineEntries = useMemo( () => deriveTimelineEntries( timelineMessages, - activeThread?.proposedPlans ?? [], + timelineThread?.proposedPlans ?? [], workLogEntries, turnPlans, ), - [activeThread?.proposedPlans, timelineMessages, turnPlans, workLogEntries], + [timelineMessages, timelineThread?.proposedPlans, turnPlans, workLogEntries], ); const [dockedDraftHeroThreadKey, setDockedDraftHeroThreadKey] = useState(null); const draftHeroDockRequested = @@ -6549,7 +6570,7 @@ function ChatViewContent(props: ChatViewProps) {
{/* Messages — LegendList handles virtualization and scrolling internally */}