perf: isolate chat streaming renders and reduce sidebar render cost (#1672)
Reworks the chat and session-sidebar render paths to cut render cascades, memory
churn, and UI jank on large sessions and big session trees. Behavior is preserved;
the changes are about *when* and *how much* the UI re-renders.
## Chat streaming
- Freeze the streaming message's parts in the bulk turn projection during streaming,
and re-inject live parts only in an isolated tail leaf, so a ~60/sec delta stream
no longer re-runs the whole-session projection or re-renders unrelated rows.
session with referential reuse of unchanged turns.
- Memoize message rows with field-aware comparators instead of reference equality.
- Replace the manual child-session polling in the task tool with the live SSE
stream + a one-shot load, removing a fetch/settle state machine.
## History loading & scroll
- Load an initial page fast, then prepend one older page in the background so the
scroll container has headroom and "load older on scroll-up" fires before the user
hits the absolute top.
- Compensate scroll synchronously (in a layout effect, before paint) for prepends —
including background prepends that don't originate from a user scroll — so the
viewport stays stable instead of judder-correcting on the next frame.
## Markdown rendering
- Render markdown synchronously *styled* on first paint (paragraphs, lists, code
cards, tables, inline code) instead of raw escaped text; the async pass then only
upgrades syntax-highlight colors. Eliminates the flash of full-width raw text.
- Load KaTeX CSS eagerly with the main bundle instead of inside the lazy markdown
chunk, avoiding a late stylesheet injection on first render.
## Sidebar
- Hoist per-row recursive tree walks out of row comparators into per-group
precomputed sets/keys; batch live-session lookups into a single map; add a
group-level memo boundary.
- Isolate rename drafts so per-keystroke typing doesn't repaint the row tree.
## Sync layer
- Add a staleness guard so a slow message fetch can't repopulate a session the user
navigated away from.
- Throw on fetch failure for authoritative loaders so a transient blip can't read as
an empty server response.
## Cleanup
- Remove dead code (unused hooks, params, duplicated inline types) surfaced while
reworking the above.
## Known issue
- A rare, purely cosmetic first-paint width flash can still appear on large sessions;
it has no behavioral or data impact and is tracked for a follow-up runtime trace.
This commit is contained in:
@@ -6,19 +6,19 @@ import ChatMessage from './ChatMessage';
|
||||
import { areOptionalRenderRelevantMessagesEqual, areRelevantTurnGroupingContextsEqual, areRenderRelevantMessagesEqual } from './message/renderCompare';
|
||||
import TurnItem from './components/TurnItem';
|
||||
import type { AnimationHandlers, ContentChangeReason } from '@/hooks/useChatAutoFollow';
|
||||
import { filterSyntheticParts } from '@/lib/messages/synthetic';
|
||||
import type { ChatMessageEntry, TurnRecord, TurnGroupingContext } from './lib/turns/types';
|
||||
import { useTurnRecords } from './hooks/useTurnRecords';
|
||||
import { applyRetryOverlay } from './lib/turns/applyRetryOverlay';
|
||||
import { buildLiveStreamingEntry } from './lib/turns/streamingTailEntry';
|
||||
import { getNormalizedMessageForDisplay, hasCompactionPart } from './lib/messageDisplayNormalization';
|
||||
import { useUIStore } from '@/stores/useUIStore';
|
||||
import { FadeInDisabledProvider } from './message/FadeInOnReveal';
|
||||
import { hasPendingUserSendAnimation, consumePendingUserSendAnimation } from '@/lib/userSendAnimation';
|
||||
import { streamPerfCount, streamPerfMeasure } from '@/stores/utils/streamDebug';
|
||||
import type { StreamPhase } from './message/types';
|
||||
import { normalizeParts } from './message/partUtils';
|
||||
import { useGlobalSessionsStore } from '@/stores/useGlobalSessionsStore';
|
||||
import { getReviewTransferDirection, type ReviewTransferDirection } from '@/lib/reviewFlow';
|
||||
import { getOriginalSessionID, getReviewSessionID } from '@/lib/sessionReviewMetadata';
|
||||
import { useSessionParts } from '@/sync/sync-context';
|
||||
import type { ReviewTransferDirection } from '@/lib/reviewFlow';
|
||||
|
||||
const MESSAGE_LIST_VIRTUALIZE_THRESHOLD = 5;
|
||||
const EMPTY_STATIC_ENTRY_MESSAGES: ChatMessageEntry[] = [];
|
||||
@@ -88,13 +88,6 @@ const resolveMessageRole = (message: ChatMessageEntry): string | null => {
|
||||
?? null;
|
||||
};
|
||||
|
||||
const hasCompactionPart = (message: ChatMessageEntry): boolean => {
|
||||
return message.parts.some((part) => {
|
||||
const type = (part as { type?: unknown } | null | undefined)?.type;
|
||||
return type === 'compaction';
|
||||
});
|
||||
};
|
||||
|
||||
const getPartText = (part: Part): string => {
|
||||
const text = (part as { text?: unknown }).text;
|
||||
if (typeof text === 'string') {
|
||||
@@ -107,40 +100,6 @@ const getPartText = (part: Part): string => {
|
||||
return '';
|
||||
};
|
||||
|
||||
const normalizeCompactionCommandMessage = (message: ChatMessageEntry): ChatMessageEntry => {
|
||||
if (!hasCompactionPart(message)) {
|
||||
return message;
|
||||
}
|
||||
|
||||
let changedParts = false;
|
||||
const nextParts = message.parts.map((part) => {
|
||||
const type = (part as { type?: unknown } | null | undefined)?.type;
|
||||
if (type !== 'compaction') {
|
||||
return part;
|
||||
}
|
||||
changedParts = true;
|
||||
return { type: 'text', text: '/compact' } as Part;
|
||||
});
|
||||
|
||||
const info = message.info as unknown as { clientRole?: string | null | undefined };
|
||||
const needsClientRole = info.clientRole !== 'user';
|
||||
|
||||
if (!changedParts && !needsClientRole) {
|
||||
return message;
|
||||
}
|
||||
|
||||
return {
|
||||
...message,
|
||||
info: needsClientRole
|
||||
? ({
|
||||
...(message.info as unknown as Record<string, unknown>),
|
||||
clientRole: 'user',
|
||||
} as unknown as typeof message.info)
|
||||
: message.info,
|
||||
parts: changedParts ? nextParts : message.parts,
|
||||
};
|
||||
};
|
||||
|
||||
const normalizeCompactionSummaryMessage = (
|
||||
message: ChatMessageEntry,
|
||||
compactionCommandIds: Set<string>,
|
||||
@@ -391,39 +350,6 @@ const withShellBridgeDetails = (message: ChatMessageEntry, details: ShellBridgeD
|
||||
};
|
||||
};
|
||||
|
||||
const normalizeMessageParts = (message: ChatMessageEntry): ChatMessageEntry => {
|
||||
const parts = normalizeParts(message.parts);
|
||||
if (parts.length === message.parts.length) {
|
||||
return message;
|
||||
}
|
||||
return {
|
||||
...message,
|
||||
parts,
|
||||
};
|
||||
};
|
||||
|
||||
const normalizedMessageBySource = new WeakMap<ChatMessageEntry, ChatMessageEntry>();
|
||||
|
||||
const getNormalizedMessageForDisplay = (message: ChatMessageEntry): ChatMessageEntry => {
|
||||
const cached = normalizedMessageBySource.get(message);
|
||||
if (cached) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
const normalizedPartMessage = normalizeMessageParts(message);
|
||||
const normalizedCompactionMessage = normalizeCompactionCommandMessage(normalizedPartMessage);
|
||||
const filteredParts = filterSyntheticParts(normalizedCompactionMessage.parts);
|
||||
const normalized = filteredParts === normalizedCompactionMessage.parts
|
||||
? normalizedCompactionMessage
|
||||
: {
|
||||
...normalizedCompactionMessage,
|
||||
parts: filteredParts,
|
||||
};
|
||||
|
||||
normalizedMessageBySource.set(message, normalized);
|
||||
return normalized;
|
||||
};
|
||||
|
||||
interface MessageListProps {
|
||||
sessionKey: string;
|
||||
disableStaging?: boolean;
|
||||
@@ -442,6 +368,7 @@ interface MessageListProps {
|
||||
isLoadingOlder: boolean;
|
||||
scrollToBottom?: () => void;
|
||||
scrollRef?: React.RefObject<HTMLDivElement | null>;
|
||||
directory?: string;
|
||||
}
|
||||
|
||||
export interface MessageListHandle {
|
||||
@@ -1007,11 +934,10 @@ type StaticHistoryListProps = {
|
||||
chatRenderMode: 'sorted' | 'live';
|
||||
shouldAnimateUserMessage: (message: ChatMessageEntry) => boolean;
|
||||
onUserAnimationConsumed: (messageId: string) => void;
|
||||
activeStreamingPhase?: StreamPhase | null;
|
||||
reviewTransferDirection?: ReviewTransferDirection | null;
|
||||
};
|
||||
|
||||
const StaticHistoryList = React.memo(({ entries, shouldVirtualize, contentRef, scrollRef, virtualizerRef, virtualizerKey, virtualCache, shift, onMessageContentChange, getAnimationHandlers, scrollToBottom, stickyUserHeader, defaultActivityExpanded, turnUiStates, onToggleTurnGroup, chatRenderMode, shouldAnimateUserMessage, onUserAnimationConsumed, activeStreamingPhase, reviewTransferDirection }: StaticHistoryListProps) => {
|
||||
const StaticHistoryList = React.memo(({ entries, shouldVirtualize, contentRef, scrollRef, virtualizerRef, virtualizerKey, virtualCache, shift, onMessageContentChange, getAnimationHandlers, scrollToBottom, stickyUserHeader, defaultActivityExpanded, turnUiStates, onToggleTurnGroup, chatRenderMode, shouldAnimateUserMessage, onUserAnimationConsumed, reviewTransferDirection }: StaticHistoryListProps) => {
|
||||
const renderEntry = React.useCallback((entry: RenderEntry) => {
|
||||
return (
|
||||
<MessageListEntry
|
||||
@@ -1029,11 +955,11 @@ const StaticHistoryList = React.memo(({ entries, shouldVirtualize, contentRef, s
|
||||
shouldAnimateUserMessage={shouldAnimateUserMessage}
|
||||
onUserAnimationConsumed={onUserAnimationConsumed}
|
||||
activeStreamingMessageId={null}
|
||||
activeStreamingPhase={activeStreamingPhase}
|
||||
activeStreamingPhase={null}
|
||||
reviewTransferDirection={reviewTransferDirection}
|
||||
/>
|
||||
);
|
||||
}, [activeStreamingPhase, chatRenderMode, defaultActivityExpanded, getAnimationHandlers, onMessageContentChange, onToggleTurnGroup, onUserAnimationConsumed, reviewTransferDirection, scrollToBottom, shouldAnimateUserMessage, stickyUserHeader, turnUiStates]);
|
||||
}, [chatRenderMode, defaultActivityExpanded, getAnimationHandlers, onMessageContentChange, onToggleTurnGroup, onUserAnimationConsumed, reviewTransferDirection, scrollToBottom, shouldAnimateUserMessage, stickyUserHeader, turnUiStates]);
|
||||
|
||||
if (!shouldVirtualize) {
|
||||
return (
|
||||
@@ -1074,6 +1000,7 @@ StaticHistoryList.displayName = 'StaticHistoryList';
|
||||
|
||||
const StreamingTailContent: React.FC<{
|
||||
entry: RenderEntry;
|
||||
directory?: string;
|
||||
onMessageContentChange: (reason?: ContentChangeReason) => void;
|
||||
getAnimationHandlers: (messageId: string) => AnimationHandlers;
|
||||
scrollToBottom?: () => void;
|
||||
@@ -1083,6 +1010,7 @@ const StreamingTailContent: React.FC<{
|
||||
turnUiStates: Map<string, TurnUiState>;
|
||||
onToggleTurnGroup: (turnId: string) => void;
|
||||
chatRenderMode: 'sorted' | 'live';
|
||||
showTurnChangedFiles: boolean;
|
||||
shouldAnimateUserMessage: (message: ChatMessageEntry) => boolean;
|
||||
onUserAnimationConsumed: (messageId: string) => void;
|
||||
activeStreamingMessageId?: string | null;
|
||||
@@ -1090,6 +1018,7 @@ const StreamingTailContent: React.FC<{
|
||||
reviewTransferDirection?: ReviewTransferDirection | null;
|
||||
}> = ({
|
||||
entry,
|
||||
directory,
|
||||
onMessageContentChange,
|
||||
getAnimationHandlers,
|
||||
scrollToBottom,
|
||||
@@ -1099,15 +1028,24 @@ const StreamingTailContent: React.FC<{
|
||||
turnUiStates,
|
||||
onToggleTurnGroup,
|
||||
chatRenderMode,
|
||||
showTurnChangedFiles,
|
||||
shouldAnimateUserMessage,
|
||||
onUserAnimationConsumed,
|
||||
activeStreamingMessageId,
|
||||
activeStreamingPhase,
|
||||
reviewTransferDirection,
|
||||
}) => {
|
||||
const liveParts = useSessionParts(activeStreamingMessageId ?? '', directory);
|
||||
const liveEntry = React.useMemo(() => buildLiveStreamingEntry(entry, {
|
||||
activeStreamingMessageId,
|
||||
liveParts,
|
||||
showTextJustificationActivity: chatRenderMode === 'sorted',
|
||||
showTurnChangedFiles,
|
||||
}), [activeStreamingMessageId, chatRenderMode, entry, liveParts, showTurnChangedFiles]);
|
||||
|
||||
return (
|
||||
<MessageListEntry
|
||||
entry={entry}
|
||||
entry={liveEntry}
|
||||
onMessageContentChange={onMessageContentChange}
|
||||
getAnimationHandlers={getAnimationHandlers}
|
||||
scrollToBottom={scrollToBottom}
|
||||
@@ -1141,6 +1079,7 @@ const MessageList = React.forwardRef<MessageListHandle, MessageListProps>(({
|
||||
isLoadingOlder,
|
||||
scrollToBottom,
|
||||
scrollRef,
|
||||
directory,
|
||||
}, ref) => {
|
||||
streamPerfCount('ui.message_list.render');
|
||||
const stickyUserHeader = useUIStore(state => state.stickyUserHeader);
|
||||
@@ -1149,18 +1088,7 @@ const MessageList = React.forwardRef<MessageListHandle, MessageListProps>(({
|
||||
const showTurnChangedFiles = useUIStore((state) => state.showTurnChangedFiles);
|
||||
const defaultActivityExpanded = activityRenderMode === 'summary';
|
||||
const reviewTransferDirection = useGlobalSessionsStore((state) => {
|
||||
const currentSession = state.activeSessions.find((session) => session.id === sessionKey);
|
||||
const direction = getReviewTransferDirection(currentSession);
|
||||
if (!currentSession || !direction) return null;
|
||||
|
||||
const targetSessionId = direction === 'review-to-original'
|
||||
? getOriginalSessionID(currentSession)
|
||||
: getReviewSessionID(currentSession);
|
||||
if (!targetSessionId) return null;
|
||||
|
||||
return state.activeSessions.some((session) => session.id === targetSessionId)
|
||||
? direction
|
||||
: null;
|
||||
return state.reviewTransferBySessionId.get(sessionKey) ?? null;
|
||||
});
|
||||
const [turnUiStates, setTurnUiStates] = React.useState<Map<string, TurnUiState>>(() => new Map());
|
||||
const userAnimationRef = React.useRef<{
|
||||
@@ -1665,12 +1593,12 @@ const MessageList = React.forwardRef<MessageListHandle, MessageListProps>(({
|
||||
chatRenderMode={chatRenderMode}
|
||||
shouldAnimateUserMessage={shouldAnimateUserMessage}
|
||||
onUserAnimationConsumed={onUserAnimationConsumed}
|
||||
activeStreamingPhase={activeStreamingPhase}
|
||||
reviewTransferDirection={reviewTransferDirection}
|
||||
/>
|
||||
{trailingStreamingEntry ? (
|
||||
<StreamingTailContent
|
||||
entry={trailingStreamingEntry}
|
||||
directory={directory}
|
||||
onMessageContentChange={stableTailContentChange}
|
||||
getAnimationHandlers={stableGetAnimationHandlers}
|
||||
scrollToBottom={stableScrollToBottom}
|
||||
@@ -1680,6 +1608,7 @@ const MessageList = React.forwardRef<MessageListHandle, MessageListProps>(({
|
||||
turnUiStates={turnUiStates}
|
||||
onToggleTurnGroup={toggleTurnGroup}
|
||||
chatRenderMode={chatRenderMode}
|
||||
showTurnChangedFiles={showTurnChangedFiles}
|
||||
shouldAnimateUserMessage={shouldAnimateUserMessage}
|
||||
onUserAnimationConsumed={onUserAnimationConsumed}
|
||||
activeStreamingMessageId={activeStreamingMessageId}
|
||||
|
||||
Reference in New Issue
Block a user