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
44 changes: 43 additions & 1 deletion apps/web/src/components/ChatMarkdown.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,11 @@ interface ChatMarkdownProps {
parseRawHtml?: boolean;
}

interface StreamingChatMarkdownProps {
text: string;
className?: string | undefined;
}

const EMPTY_MARKDOWN_SKILLS: ReadonlyArray<Pick<ServerProviderSkill, "name" | "displayName">> = [];

const CODE_FENCE_LANGUAGE_REGEX = /(?:^|\s)language-([^\s]+)/;
Expand Down Expand Up @@ -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 (
<div
className={cn(
"chat-markdown w-full min-w-0 text-sm leading-relaxed text-foreground/80 [overflow-wrap:anywhere] [word-break:break-word]",
className,
)}
data-streaming-markdown="true"
>
<div className="whitespace-pre-wrap">{displayText}</div>
</div>
);
});

function ChatMarkdown(props: ChatMarkdownProps) {
if (props.isStreaming) {
return <StreamingChatMarkdown text={props.text} className={props.className} />;
}

return <SettledChatMarkdown {...props} isStreaming={false} />;
}

function SettledChatMarkdown({
text,
cwd,
threadRef,
Expand Down
13 changes: 13 additions & 0 deletions apps/web/src/components/ChatMarkdown.workspace-images.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,10 @@ function renderWithoutThread(markdown: string): string {
return renderToStaticMarkup(<ChatMarkdown cwd={"C:\\Users\\shawn\\project"} text={markdown} />);
}

function renderStreaming(markdown: string): string {
return renderToStaticMarkup(<ChatMarkdown cwd={undefined} text={markdown} isStreaming />);
}

describe("ChatMarkdown workspace images", () => {
beforeEach(() => {
testState.resources = [];
Expand Down Expand Up @@ -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("<strong>");
expect(html).not.toContain("chat-markdown-codeblock");
});
});
37 changes: 29 additions & 8 deletions apps/web/src/components/ChatView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ import {
memo,
Suspense,
useCallback,
useDeferredValue,
useEffect,
useLayoutEffect,
useMemo,
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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).
Expand All @@ -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],
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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<string | null>(null);
const draftHeroDockRequested =
Expand Down Expand Up @@ -6549,15 +6570,15 @@ function ChatViewContent(props: ChatViewProps) {
<div className="relative flex min-h-0 flex-1 flex-col">
{/* Messages — LegendList handles virtualization and scrolling internally */}
<MessagesTimeline
agentPanelModel={agentPanelModel}
agentPanelModel={timelineAgentPanelModel}
onOpenAgents={addAgentsSurface}
key={activeThread.id}
isWorking={isWorking}
workingStepLabel={workingStepLabel}
activeTurnStartedAt={activeWorkStartedAt}
listRef={legendListRef}
timelineEntries={timelineEntries}
latestTurn={activeLatestTurn}
latestTurn={timelineThread?.latestTurn ?? null}
runningTurnId={
activeThread.session?.status === "running"
? activeThread.session.activeTurnId
Expand Down