fix(chat): overlay live parts on every streaming tail message

The streaming tail only overlaid the actively streaming message with live
parts from the sync store. When a turn moved to its next step message, the
previous message fell back to its lagging base record, briefly dropping its
completed tool parts — remounting them and replaying their reveal
animation once the record caught up.

Overlay live parts for every message of the streaming tail via a new
useSessionPartsForMessages hook, guarded so an empty live array never
erases parts the record does have. Also key generate-effect glyphs by
index so appended text does not replay earlier characters, and keep tool
row reveal wrappers mounted unconditionally.
This commit is contained in:
Bohdan Triapitsyn
2026-08-25 14:07:33 +03:00
parent 6a944db746
commit 3703ba730e
6 changed files with 136 additions and 56 deletions
@@ -19,7 +19,7 @@ import { hasPendingUserSendAnimation, consumePendingUserSendAnimation } from '@/
import { streamPerfCount, streamPerfMark, streamPerfMeasure } from '@/stores/utils/streamDebug'; import { streamPerfCount, streamPerfMark, streamPerfMeasure } from '@/stores/utils/streamDebug';
import type { StreamPhase } from './message/types'; import type { StreamPhase } from './message/types';
import { useGlobalSessionsStore } from '@/stores/useGlobalSessionsStore'; import { useGlobalSessionsStore } from '@/stores/useGlobalSessionsStore';
import { useSessionParts } from '@/sync/sync-context'; import { useSessionPartsForMessages } from '@/sync/sync-context';
import type { ReviewTransferDirection } from '@/lib/reviewFlow'; import type { ReviewTransferDirection } from '@/lib/reviewFlow';
import { resolveChatListAnchoredEndSpace, resolveTimelineIsAtEnd } from './lib/scroll/timelineScrollAnchoring'; import { resolveChatListAnchoredEndSpace, resolveTimelineIsAtEnd } from './lib/scroll/timelineScrollAnchoring';
import { import {
@@ -479,6 +479,7 @@ const TurnBlock = React.memo(({
activeStreamingPhase, activeStreamingPhase,
reviewTransferDirection, reviewTransferDirection,
}: TurnBlockProps) => { }: TurnBlockProps) => {
const planModeEnabled = useFeatureFlagsStore((state) => state.planModeEnabled); const planModeEnabled = useFeatureFlagsStore((state) => state.planModeEnabled);
const userMessageHidden = React.useMemo( const userMessageHidden = React.useMemo(
() => isHiddenUserMessage(turn.userMessage, { planModeEnabled }), () => isHiddenUserMessage(turn.userMessage, { planModeEnabled }),
@@ -1121,15 +1122,22 @@ const StreamingTailContent: React.FC<{
activeStreamingPhase, activeStreamingPhase,
reviewTransferDirection, reviewTransferDirection,
}) => { }) => {
const liveParts = useSessionParts(activeStreamingMessageId ?? '', directory); // Overlay live parts on every message of the tail, not only the one
// currently streaming: a finished step message's base record can lag the
// part store, and rendering it from that stale snapshot briefly unmounts
// its completed tool parts when the stream hands off to the next message.
const tailMessageIds = React.useMemo(() => {
if (entry.kind === 'turn') return entry.turn.assistantMessageIds;
return [entry.message.info.id];
}, [entry]);
const livePartsByMessageId = useSessionPartsForMessages(tailMessageIds, directory);
const planModeEnabled = useFeatureFlagsStore((state) => state.planModeEnabled); const planModeEnabled = useFeatureFlagsStore((state) => state.planModeEnabled);
const liveEntry = React.useMemo(() => buildLiveStreamingEntry(entry, { const liveEntry = React.useMemo(() => buildLiveStreamingEntry(entry, {
activeStreamingMessageId, livePartsByMessageId,
liveParts,
showTextJustificationActivity: chatRenderMode === 'sorted', showTextJustificationActivity: chatRenderMode === 'sorted',
showTurnChangedFiles, showTurnChangedFiles,
mergeHiddenUserTurns: { planModeEnabled }, mergeHiddenUserTurns: { planModeEnabled },
}), [activeStreamingMessageId, chatRenderMode, entry, liveParts, showTurnChangedFiles, planModeEnabled]); }), [chatRenderMode, entry, livePartsByMessageId, showTurnChangedFiles, planModeEnabled]);
return ( return (
<MessageListEntry <MessageListEntry
@@ -64,8 +64,7 @@ describe('buildLiveStreamingEntry', () => {
const entry = turnEntry(assistant); const entry = turnEntry(assistant);
const next = buildLiveStreamingEntry(entry, { const next = buildLiveStreamingEntry(entry, {
activeStreamingMessageId: 'assistant_other', livePartsByMessageId: { assistant_other: [textPart('part_live', 'live')] },
liveParts: [textPart('part_live', 'live')],
showTextJustificationActivity: true, showTextJustificationActivity: true,
showTurnChangedFiles: false, showTurnChangedFiles: false,
}); });
@@ -79,8 +78,7 @@ describe('buildLiveStreamingEntry', () => {
const liveParts = [reasoningPart('part_1_live', 'thinking')]; const liveParts = [reasoningPart('part_1_live', 'thinking')];
const next = buildLiveStreamingEntry(entry, { const next = buildLiveStreamingEntry(entry, {
activeStreamingMessageId: 'assistant_1', livePartsByMessageId: { assistant_1: liveParts },
liveParts,
showTextJustificationActivity: true, showTextJustificationActivity: true,
showTurnChangedFiles: false, showTurnChangedFiles: false,
}); });
@@ -102,8 +100,7 @@ describe('buildLiveStreamingEntry', () => {
const liveParts = [textPart('part_1_live', 'live')]; const liveParts = [textPart('part_1_live', 'live')];
const next = buildLiveStreamingEntry(entry, { const next = buildLiveStreamingEntry(entry, {
activeStreamingMessageId: 'assistant_1', livePartsByMessageId: { assistant_1: liveParts },
liveParts,
showTextJustificationActivity: false, showTextJustificationActivity: false,
showTurnChangedFiles: false, showTurnChangedFiles: false,
}); });
@@ -121,8 +118,7 @@ describe('buildLiveStreamingEntry', () => {
const synthetic = syntheticTextPart('part_synthetic', 'hidden while streaming'); const synthetic = syntheticTextPart('part_synthetic', 'hidden while streaming');
const next = buildLiveStreamingEntry(entry, { const next = buildLiveStreamingEntry(entry, {
activeStreamingMessageId: 'assistant_1', livePartsByMessageId: { assistant_1: [synthetic, visible] },
liveParts: [synthetic, visible],
showTextJustificationActivity: true, showTextJustificationActivity: true,
showTurnChangedFiles: false, showTurnChangedFiles: false,
}); });
@@ -131,4 +127,39 @@ describe('buildLiveStreamingEntry', () => {
if (next.kind !== 'turn') return; if (next.kind !== 'turn') return;
expect(next.turn.assistantMessages[0]?.parts).toEqual([visible]); expect(next.turn.assistantMessages[0]?.parts).toEqual([visible]);
}); });
test('keeps a finished step message on its live parts after the stream moves on', () => {
const finished = message('assistant_1', 'assistant', 'user_1', []);
const streaming = message('assistant_2', 'assistant', 'user_1', []);
const entry = turnEntry(finished);
if (entry.kind !== 'turn') return;
entry.turn.assistantMessageIds = ['assistant_1', 'assistant_2'];
entry.turn.assistantMessages = [finished, streaming];
const finishedLive = [textPart('part_tool_done', 'tool output')];
const streamingLive = [textPart('part_streaming', 'streaming')];
const next = buildLiveStreamingEntry(entry, {
livePartsByMessageId: { assistant_1: finishedLive, assistant_2: streamingLive },
showTextJustificationActivity: true,
showTurnChangedFiles: false,
});
expect(next.kind).toBe('turn');
if (next.kind !== 'turn') return;
expect(next.turn.assistantMessages[0]?.parts).toEqual(finishedLive);
expect(next.turn.assistantMessages[1]?.parts).toEqual(streamingLive);
});
test('never erases record parts with an empty live array', () => {
const assistant = message('assistant_1', 'assistant', 'user_1', [textPart('part_1', 'kept')]);
const entry = turnEntry(assistant);
const next = buildLiveStreamingEntry(entry, {
livePartsByMessageId: { assistant_1: [] },
showTextJustificationActivity: true,
showTurnChangedFiles: false,
});
expect(next).toBe(entry);
});
}); });
@@ -15,8 +15,13 @@ export type StreamingTailEntry =
| { kind: 'turn'; key: string; turn: TurnRecord; isLastTurn: boolean }; | { kind: 'turn'; key: string; turn: TurnRecord; isLastTurn: boolean };
type BuildLiveStreamingEntryOptions = { type BuildLiveStreamingEntryOptions = {
activeStreamingMessageId: string | null | undefined; // Live parts for EVERY message of the streaming tail, not only the one
liveParts: Part[]; // currently streaming: when the stream moves to the next step message, the
// previous message's base record can still lag behind the part store, and
// rendering it from that stale snapshot briefly drops its completed tool
// parts — remounting them (and replaying their reveal animation) once the
// record catches up.
livePartsByMessageId: Readonly<Record<string, Part[]>>;
showTextJustificationActivity: boolean; showTextJustificationActivity: boolean;
showTurnChangedFiles: boolean; showTurnChangedFiles: boolean;
mergeHiddenUserTurns?: { planModeEnabled: boolean }; mergeHiddenUserTurns?: { planModeEnabled: boolean };
@@ -24,10 +29,12 @@ type BuildLiveStreamingEntryOptions = {
const withLiveParts = ( const withLiveParts = (
message: ChatMessageEntry, message: ChatMessageEntry,
activeStreamingMessageId: string, livePartsByMessageId: Readonly<Record<string, Part[]>>,
liveParts: Part[],
): ChatMessageEntry => { ): ChatMessageEntry => {
if (message.info.id !== activeStreamingMessageId || message.parts === liveParts) { const liveParts = livePartsByMessageId[message.info.id];
// An empty live array is ambiguous — the store may simply not have loaded
// this message's parts — and must never erase parts the record does have.
if (!liveParts || liveParts.length === 0 || message.parts === liveParts) {
return message; return message;
} }
@@ -41,13 +48,10 @@ export const buildLiveStreamingEntry = <TEntry extends StreamingTailEntry>(
entry: TEntry, entry: TEntry,
options: BuildLiveStreamingEntryOptions, options: BuildLiveStreamingEntryOptions,
): TEntry => { ): TEntry => {
const activeStreamingMessageId = options.activeStreamingMessageId; const livePartsByMessageId = options.livePartsByMessageId;
if (!activeStreamingMessageId) {
return entry;
}
if (entry.kind === 'ungrouped') { if (entry.kind === 'ungrouped') {
const message = withLiveParts(entry.message, activeStreamingMessageId, options.liveParts); const message = withLiveParts(entry.message, livePartsByMessageId);
if (message === entry.message) { if (message === entry.message) {
return entry; return entry;
} }
@@ -59,7 +63,7 @@ export const buildLiveStreamingEntry = <TEntry extends StreamingTailEntry>(
let changed = false; let changed = false;
const assistantMessages = entry.turn.assistantMessages.map((message) => { const assistantMessages = entry.turn.assistantMessages.map((message) => {
const next = withLiveParts(message, activeStreamingMessageId, options.liveParts); const next = withLiveParts(message, livePartsByMessageId);
if (next !== message) { if (next !== message) {
changed = true; changed = true;
} }
@@ -378,7 +378,6 @@ interface ExpandableToolRowProps {
onShowPopup: (content: ToolPopupContent) => void; onShowPopup: (content: ToolPopupContent) => void;
onContentChange?: (reason?: ContentChangeReason) => void; onContentChange?: (reason?: ContentChangeReason) => void;
animateTailText: boolean; animateTailText: boolean;
animateRows: boolean;
} }
const ExpandableToolRow: React.FC<ExpandableToolRowProps> = ({ const ExpandableToolRow: React.FC<ExpandableToolRowProps> = ({
@@ -389,7 +388,6 @@ const ExpandableToolRow: React.FC<ExpandableToolRowProps> = ({
onShowPopup, onShowPopup,
onContentChange, onContentChange,
animateTailText, animateTailText,
animateRows,
}) => { }) => {
const handleToggle = React.useCallback(() => { const handleToggle = React.useCallback(() => {
onToggleTool(activity.id); onToggleTool(activity.id);
@@ -407,17 +405,17 @@ const ExpandableToolRow: React.FC<ExpandableToolRowProps> = ({
/> />
); );
const maybeWrapped = animateTailText ? ( // Wrappers are unconditional: a conditional wrapper changes the element
<ToolRevealOnMount animate={true} wipe> // type at this position when animateTailText/animateRows flip (message
{content} // completion), remounting the tool subtree and replaying the reveal wipe.
</ToolRevealOnMount> // Both wrappers are inert with animation off.
) : content; return (
<FadeInOnReveal>
if (!animateRows) { <ToolRevealOnMount animate={animateTailText} wipe>
return maybeWrapped; {content}
} </ToolRevealOnMount>
</FadeInOnReveal>
return <FadeInOnReveal>{maybeWrapped}</FadeInOnReveal>; );
}; };
const MemoExpandableToolRow = React.memo(ExpandableToolRow, (prev, next) => { const MemoExpandableToolRow = React.memo(ExpandableToolRow, (prev, next) => {
@@ -427,7 +425,6 @@ const MemoExpandableToolRow = React.memo(ExpandableToolRow, (prev, next) => {
&& prev.onShowPopup === next.onShowPopup && prev.onShowPopup === next.onShowPopup
&& prev.onContentChange === next.onContentChange && prev.onContentChange === next.onContentChange
&& prev.animateTailText === next.animateTailText && prev.animateTailText === next.animateTailText
&& prev.animateRows === next.animateRows
&& prev.activity.id === next.activity.id && prev.activity.id === next.activity.id
&& prev.activity.kind === next.activity.kind && prev.activity.kind === next.activity.kind
&& prev.activity.endedAt === next.activity.endedAt && prev.activity.endedAt === next.activity.endedAt
@@ -438,14 +435,12 @@ interface StaticGroupedToolRowProps {
toolName: string; toolName: string;
activities: TurnActivityPart[]; activities: TurnActivityPart[];
animateTailText: boolean; animateTailText: boolean;
animateRows: boolean;
} }
const StaticGroupedToolRow: React.FC<StaticGroupedToolRowProps> = ({ const StaticGroupedToolRow: React.FC<StaticGroupedToolRowProps> = ({
toolName, toolName,
activities, activities,
animateTailText, animateTailText,
animateRows,
}) => { }) => {
const content = ( const content = (
<StaticToolRow <StaticToolRow
@@ -455,23 +450,22 @@ const StaticGroupedToolRow: React.FC<StaticGroupedToolRowProps> = ({
/> />
); );
const maybeWrapped = animateTailText ? ( // Wrappers are unconditional: a conditional wrapper changes the element
<ToolRevealOnMount animate={true} wipe> // type at this position when animateTailText/animateRows flip (message
{content} // completion), remounting the tool subtree and replaying the reveal wipe.
</ToolRevealOnMount> // Both wrappers are inert with animation off.
) : content; return (
<FadeInOnReveal>
if (!animateRows) { <ToolRevealOnMount animate={animateTailText} wipe>
return maybeWrapped; {content}
} </ToolRevealOnMount>
</FadeInOnReveal>
return <FadeInOnReveal>{maybeWrapped}</FadeInOnReveal>; );
}; };
const MemoStaticGroupedToolRow = React.memo(StaticGroupedToolRow, (prev, next) => { const MemoStaticGroupedToolRow = React.memo(StaticGroupedToolRow, (prev, next) => {
return prev.toolName === next.toolName return prev.toolName === next.toolName
&& prev.animateTailText === next.animateTailText && prev.animateTailText === next.animateTailText
&& prev.animateRows === next.animateRows
&& areActivityListsEqual(prev.activities, next.activities); && areActivityListsEqual(prev.activities, next.activities);
}); });
@@ -926,7 +920,6 @@ const ProgressiveGroup: React.FC<ProgressiveGroupProps> = ({
onShowPopup={onShowPopup} onShowPopup={onShowPopup}
onContentChange={onContentChange} onContentChange={onContentChange}
animateTailText={Boolean(animatedToolIds?.has(row.activity.id))} animateTailText={Boolean(animatedToolIds?.has(row.activity.id))}
animateRows={animateRows}
/> />
); );
@@ -937,7 +930,6 @@ const ProgressiveGroup: React.FC<ProgressiveGroupProps> = ({
toolName={row.toolName} toolName={row.toolName}
activities={row.activities} activities={row.activities}
animateTailText={row.activities.some((activity) => animatedToolIds?.has(activity.id))} animateTailText={row.activities.some((activity) => animatedToolIds?.has(activity.id))}
animateRows={animateRows}
/> />
); );
@@ -952,7 +944,6 @@ const ProgressiveGroup: React.FC<ProgressiveGroupProps> = ({
onShowPopup={onShowPopup} onShowPopup={onShowPopup}
onContentChange={onContentChange} onContentChange={onContentChange}
animateTailText={Boolean(animatedToolIds?.has(row.activity.id))} animateTailText={Boolean(animatedToolIds?.has(row.activity.id))}
animateRows={animateRows}
/> />
); );
+5 -1
View File
@@ -43,7 +43,11 @@ const variants = [
{textContent.split("").map((char, index) => ( {textContent.split("").map((char, index) => (
<motion.span <motion.span
{...props} {...props}
key={char + String(index)} // Index-only: keying by character remounted every span when
// the text mutated (a tool title resolving on completion) and
// replayed the whole fade. Same-index spans update in place;
// appended characters still mount with the reveal.
key={index}
className={cn( className={cn(
"inline-block whitespace-pre align-baseline" "inline-block whitespace-pre align-baseline"
)} )}
+42
View File
@@ -2647,6 +2647,48 @@ export function useSessionParts(messageID: string, directory?: string) {
) )
} }
const EMPTY_PARTS_BY_MESSAGE: Record<string, Part[]> = {}
/**
* Get parts for several messages at once, keyed by message id. The snapshot
* keeps its identity until one of the requested part arrays changes, so a
* streaming turn can overlay every one of its step messages — not only the
* currently streaming one — without tearing between them when the stream
* moves to the next message.
*/
export function useSessionPartsForMessages(messageIDs: readonly string[], directory?: string): Record<string, Part[]> {
const store = useDirectoryStore(directory)
const cacheRef = React.useRef<{ ids: readonly string[]; parts: Record<string, Part[]> } | null>(null)
const getSnapshot = useCallback(() => {
if (messageIDs.length === 0) return EMPTY_PARTS_BY_MESSAGE
const state = store.getState()
const cached = cacheRef.current
if (
cached
&& cached.ids === messageIDs
&& messageIDs.every((id) => (state.part[id] ?? EMPTY_PARTS) === (cached.parts[id] ?? EMPTY_PARTS))
) {
return cached.parts
}
const parts: Record<string, Part[]> = {}
for (const id of messageIDs) parts[id] = state.part[id] ?? EMPTY_PARTS
cacheRef.current = { ids: messageIDs, parts }
return parts
}, [messageIDs, store])
const subscribe = useCallback((notify: () => void) => {
if (messageIDs.length === 0) return () => undefined
return store.subscribe((state, previous) => {
for (const id of messageIDs) {
if (state.part[id] !== previous.part[id]) {
notify()
return
}
}
})
}, [messageIDs, store])
return React.useSyncExternalStore(subscribe, getSnapshot, getSnapshot)
}
/** Get status for a specific session */ /** Get status for a specific session */
export function useSessionStatus(sessionID: string, directory?: string) { export function useSessionStatus(sessionID: string, directory?: string) {
const store = useDirectoryStore(directory) const store = useDirectoryStore(directory)