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:
bashrusakh
2026-06-18 00:43:16 +03:00
committed by GitHub
parent 077a766f94
commit 59ecd86b4b
47 changed files with 3168 additions and 1829 deletions
@@ -0,0 +1,79 @@
import type { Part } from '@opencode-ai/sdk/v2';
import { filterSyntheticParts } from '@/lib/messages/synthetic';
import { normalizeParts } from '../message/partUtils';
import type { ChatMessageEntry } from './turns/types';
export const hasCompactionPart = (message: ChatMessageEntry): boolean => {
return message.parts.some((part) => {
const type = (part as { type?: unknown } | null | undefined)?.type;
return type === 'compaction';
});
};
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 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>();
export 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;
};
@@ -106,6 +106,27 @@ describe('projectTurnRecords', () => {
expect(next.turns[1]).not.toBe(initial.turns[1]);
});
test('hydrates updated turns when a previous projection exists but no turn is reusable', () => {
const user = createMessageEntry({ id: 'u1', role: 'user', createdAt: 1 });
const assistant = createMessageEntry({ id: 'a1', role: 'assistant', parentID: 'u1', createdAt: 2 });
const initial = projectTurnRecords([user, assistant]);
const updatedAssistant = {
...assistant,
parts: [{ id: 'tool_1', type: 'tool', tool: 'bash', state: { status: 'completed' } } as Part],
};
const next = projectTurnRecords([user, updatedAssistant], {
previousProjection: initial,
});
expect(next.turns).toHaveLength(1);
expect(next.turns[0]).not.toBe(initial.turns[0]);
expect(next.turns[0]?.hasTools).toBe(true);
expect(next.turns[0]?.activityParts).toHaveLength(1);
expect(next.turns[0]?.stream.isStreaming).toBe(true);
expect(next.turns[0]?.stream.isRetrying).toBe(false);
});
test('reuses the whole turns array when every turn is unchanged', () => {
const user = createMessageEntry({ id: 'u1', role: 'user', createdAt: 1 });
const assistant = createMessageEntry({ id: 'a1', role: 'assistant', parentID: 'u1', createdAt: 2 });
@@ -115,12 +115,43 @@ const canReusePreviousTurn = (previous: TurnRecord, next: TurnRecord): boolean =
&& areSameMessageRefs(previous.assistantMessages, next.assistantMessages);
};
const stabilizeTurnRecords = (
const hydrateTurnRecord = (
turn: TurnRecord,
effectiveOptions: ProjectTurnRecordsOptions,
): TurnRecord => {
turn.summary = projectTurnSummary(turn.assistantMessages);
turn.summaryText = turn.summary.text ?? getUserSummaryBody(turn.userMessage);
turn.diffStats = projectTurnDiffStats(turn.userMessage);
turn.changedFiles = effectiveOptions.showTurnChangedFiles
? projectTurnChangedFiles(turn.userMessage)
: undefined;
const activity = projectTurnActivity({
turnId: turn.turnId,
assistantMessages: turn.assistantMessages,
summarySourceMessageId: turn.summary.sourceMessageId,
summarySourcePartId: turn.summary.sourcePartId,
showTextJustificationActivity: effectiveOptions.showTextJustificationActivity,
});
turn.activityParts = activity.activityParts;
turn.activitySegments = activity.activitySegments;
turn.hasTools = activity.hasTools;
turn.hasReasoning = activity.hasReasoning;
turn.stream = buildTurnStreamState(turn.userMessage, turn.assistantMessages);
turn.startedAt = turn.stream.startedAt;
turn.completedAt = turn.stream.completedAt;
turn.durationMs = turn.stream.durationMs;
return turn;
};
const hydrateStableTurnRecords = (
turns: TurnRecord[],
previousProjection?: TurnProjectionResult | null,
effectiveOptions: ProjectTurnRecordsOptions,
): TurnRecord[] => {
const previousProjection = effectiveOptions.previousProjection;
if (!previousProjection || previousProjection.turns.length === 0 || turns.length === 0) {
return turns;
return turns.map((turn) => hydrateTurnRecord(turn, effectiveOptions));
}
let canReuseTurnArray = previousProjection.turns.length === turns.length;
@@ -137,14 +168,14 @@ const stabilizeTurnRecords = (
}
canReuseTurnArray = false;
return turn;
return hydrateTurnRecord(turn, effectiveOptions);
});
if (canReuseTurnArray && reusedAnyTurn) {
return previousProjection.turns;
}
return reusedAnyTurn ? nextTurns : turns;
return nextTurns;
};
export const projectTurnRecords = (
@@ -214,33 +245,7 @@ export const projectTurnRecords = (
groupedMessageIds.add(message.info.id);
});
turns.forEach((turn) => {
turn.summary = projectTurnSummary(turn.assistantMessages);
turn.summaryText = turn.summary.text ?? getUserSummaryBody(turn.userMessage);
turn.diffStats = projectTurnDiffStats(turn.userMessage);
turn.changedFiles = effectiveOptions.showTurnChangedFiles
? projectTurnChangedFiles(turn.userMessage)
: undefined;
const activity = projectTurnActivity({
turnId: turn.turnId,
assistantMessages: turn.assistantMessages,
summarySourceMessageId: turn.summary.sourceMessageId,
summarySourcePartId: turn.summary.sourcePartId,
showTextJustificationActivity: effectiveOptions.showTextJustificationActivity,
});
turn.activityParts = activity.activityParts;
turn.activitySegments = activity.activitySegments;
turn.hasTools = activity.hasTools;
turn.hasReasoning = activity.hasReasoning;
turn.stream = buildTurnStreamState(turn.userMessage, turn.assistantMessages);
turn.startedAt = turn.stream.startedAt;
turn.completedAt = turn.stream.completedAt;
turn.durationMs = turn.stream.durationMs;
});
const stableTurns = stabilizeTurnRecords(turns, effectiveOptions.previousProjection);
const stableTurns = hydrateStableTurnRecords(turns, effectiveOptions);
const projection = projectTurnIndexes(stableTurns);
const ungroupedMessageIds = new Set<string>();
messages.forEach((message) => {
@@ -0,0 +1,134 @@
import { describe, expect, test } from 'bun:test';
import type { Message, Part } from '@opencode-ai/sdk/v2';
import { buildLiveStreamingEntry, type StreamingTailEntry } from './streamingTailEntry';
import type { ChatMessageEntry, TurnRecord } from './types';
const message = (id: string, role: 'user' | 'assistant', parentID?: string, parts: Part[] = []): ChatMessageEntry => ({
info: {
id,
role,
sessionID: 'ses_1',
...(parentID ? { parentID } : {}),
time: { created: 1 },
} as Message,
parts,
});
const textPart = (id: string, text: string): Part => ({
id,
type: 'text',
text,
} as Part);
const syntheticTextPart = (id: string, text: string): Part => ({
id,
type: 'text',
text,
synthetic: true,
} as Part);
const reasoningPart = (id: string, text: string): Part => ({
id,
type: 'reasoning',
text,
} as Part);
const turnEntry = (assistant: ChatMessageEntry): StreamingTailEntry => {
const user = message('user_1', 'user');
return {
kind: 'turn',
key: 'turn:user_1',
isLastTurn: true,
turn: {
turnId: 'user_1',
userMessageId: 'user_1',
userMessage: user,
headerMessageId: assistant.info.id,
messages: [],
assistantMessageIds: [assistant.info.id],
assistantMessages: [assistant],
activityParts: [],
activitySegments: [],
summary: {},
hasTools: false,
hasReasoning: false,
stream: { isStreaming: true, isRetrying: false },
} satisfies TurnRecord,
};
};
describe('buildLiveStreamingEntry', () => {
test('returns the same entry when the active message is not in the tail', () => {
const assistant = message('assistant_1', 'assistant', 'user_1', [textPart('part_1', 'old')]);
const entry = turnEntry(assistant);
const next = buildLiveStreamingEntry(entry, {
activeStreamingMessageId: 'assistant_other',
liveParts: [textPart('part_live', 'live')],
showTextJustificationActivity: true,
showTurnChangedFiles: false,
});
expect(next).toBe(entry);
});
test('rebuilds only the streaming turn with live parts', () => {
const assistant = message('assistant_1', 'assistant', 'user_1', [textPart('part_1', 'hel')]);
const entry = turnEntry(assistant);
const liveParts = [reasoningPart('part_1_live', 'thinking')];
const next = buildLiveStreamingEntry(entry, {
activeStreamingMessageId: 'assistant_1',
liveParts,
showTextJustificationActivity: true,
showTurnChangedFiles: false,
});
expect(next).not.toBe(entry);
expect(next.kind).toBe('turn');
if (next.kind !== 'turn') return;
expect(next.turn.assistantMessages[0]?.parts).toBe(liveParts);
expect(next.turn.activityParts.length).toBeGreaterThan(0);
});
test('updates an ungrouped streaming message with live parts', () => {
const stale = message('assistant_1', 'assistant', undefined, [textPart('part_1', 'old')]);
const entry: StreamingTailEntry = {
kind: 'ungrouped',
key: 'msg:assistant_1',
message: stale,
};
const liveParts = [textPart('part_1_live', 'live')];
const next = buildLiveStreamingEntry(entry, {
activeStreamingMessageId: 'assistant_1',
liveParts,
showTextJustificationActivity: false,
showTurnChangedFiles: false,
});
expect(next).not.toBe(entry);
expect(next.kind).toBe('ungrouped');
if (next.kind !== 'ungrouped') return;
expect(next.message.parts).toBe(liveParts);
});
test('normalizes live tail parts with the display filtering path', () => {
const stale = message('assistant_1', 'assistant', 'user_1', [textPart('part_1', 'old')]);
const entry = turnEntry(stale);
const visible = textPart('part_visible', 'visible');
const synthetic = syntheticTextPart('part_synthetic', 'hidden while streaming');
const next = buildLiveStreamingEntry(entry, {
activeStreamingMessageId: 'assistant_1',
liveParts: [synthetic, visible],
showTextJustificationActivity: true,
showTurnChangedFiles: false,
});
expect(next.kind).toBe('turn');
if (next.kind !== 'turn') return;
expect(next.turn.assistantMessages[0]?.parts).toEqual([visible]);
});
});
@@ -0,0 +1,86 @@
import type { Part } from '@opencode-ai/sdk/v2';
import { getNormalizedMessageForDisplay } from '../messageDisplayNormalization';
import { projectTurnRecords } from './projectTurnRecords';
import type { ChatMessageEntry, TurnRecord } from './types';
export type StreamingTailEntry =
| {
kind: 'ungrouped';
key: string;
message: ChatMessageEntry;
previousMessage?: ChatMessageEntry;
nextMessage?: ChatMessageEntry;
}
| { kind: 'turn'; key: string; turn: TurnRecord; isLastTurn: boolean };
type BuildLiveStreamingEntryOptions = {
activeStreamingMessageId: string | null | undefined;
liveParts: Part[];
showTextJustificationActivity: boolean;
showTurnChangedFiles: boolean;
};
const withLiveParts = (
message: ChatMessageEntry,
activeStreamingMessageId: string,
liveParts: Part[],
): ChatMessageEntry => {
if (message.info.id !== activeStreamingMessageId || message.parts === liveParts) {
return message;
}
return getNormalizedMessageForDisplay({
...message,
parts: liveParts,
});
};
export const buildLiveStreamingEntry = <TEntry extends StreamingTailEntry>(
entry: TEntry,
options: BuildLiveStreamingEntryOptions,
): TEntry => {
const activeStreamingMessageId = options.activeStreamingMessageId;
if (!activeStreamingMessageId) {
return entry;
}
if (entry.kind === 'ungrouped') {
const message = withLiveParts(entry.message, activeStreamingMessageId, options.liveParts);
if (message === entry.message) {
return entry;
}
return {
...entry,
message,
};
}
let changed = false;
const assistantMessages = entry.turn.assistantMessages.map((message) => {
const next = withLiveParts(message, activeStreamingMessageId, options.liveParts);
if (next !== message) {
changed = true;
}
return next;
});
if (!changed) {
return entry;
}
const projection = projectTurnRecords([entry.turn.userMessage, ...assistantMessages], {
showTextJustificationActivity: options.showTextJustificationActivity,
showTurnChangedFiles: options.showTurnChangedFiles,
});
const turn = projection.turns[0] ?? {
...entry.turn,
assistantMessages,
assistantMessageIds: assistantMessages.map((message) => message.info.id),
};
return {
...entry,
turn,
};
};
@@ -0,0 +1,35 @@
import { describe, expect, test } from 'bun:test';
import type { Message, Part } from '@opencode-ai/sdk/v2';
import { buildProjectionCacheKey } from './turnProjectionCache';
import type { ChatMessageEntry } from './types';
const createEntry = (text: string): ChatMessageEntry => ({
info: { id: 'msg_1', role: 'assistant' } as Message,
parts: [{ id: 'prt_1', type: 'text', text } as Part],
});
describe('turnProjectionCache', () => {
test('keeps the cache key stable for unchanged message and part references', () => {
const messages = [createEntry('hello')];
const first = buildProjectionCacheKey('session_1', messages, false, false);
const second = buildProjectionCacheKey('session_1', messages, false, false);
expect(second).toBe(first);
});
test('changes the cache key when streaming replaces a part with the same id and count', () => {
const before = [createEntry('hel')];
const after = [
{
info: before[0].info,
parts: [{ id: 'prt_1', type: 'text', text: 'hello' } as Part],
},
];
const beforeKey = buildProjectionCacheKey('session_1', before, false, false);
const afterKey = buildProjectionCacheKey('session_1', after, false, false);
expect(afterKey).not.toBe(beforeKey);
});
});
@@ -0,0 +1,86 @@
import type { ChatMessageEntry, TurnProjectionResult } from './types';
import { isVSCodeRuntime } from '@/lib/desktop';
import { isMobileSurfaceRuntime } from '@/lib/runtimeSurface';
const TURN_PROJECTION_CACHE_MAX = 30;
const VSCODE_TURN_PROJECTION_CACHE_MAX = 4;
const MOBILE_TURN_PROJECTION_CACHE_MAX = 4;
const projectionCache = new Map<string, TurnProjectionResult>();
const objectVersionByRef = new WeakMap<object, number>();
let nextObjectVersion = 1;
const getProjectionCacheMax = () => {
if (isVSCodeRuntime()) return VSCODE_TURN_PROJECTION_CACHE_MAX;
if (isMobileSurfaceRuntime()) return MOBILE_TURN_PROJECTION_CACHE_MAX;
return TURN_PROJECTION_CACHE_MAX;
};
const getObjectVersion = (value: object): number => {
const cached = objectVersionByRef.get(value);
if (cached !== undefined) return cached;
const next = nextObjectVersion;
nextObjectVersion += 1;
objectVersionByRef.set(value, next);
return next;
};
const buildMessagesVersionSignature = (messages: ChatMessageEntry[]): string => {
return messages.map((message) => {
const infoVersion = getObjectVersion(message.info as object);
const partsVersion = getObjectVersion(message.parts);
const partVersions = message.parts.map((part) => getObjectVersion(part as object)).join(',');
return `${infoVersion}:${partsVersion}:${partVersions}`;
}).join(';');
};
export const buildProjectionCacheKey = (
sessionKey: string,
messages: ChatMessageEntry[],
showTextJustificationActivity: boolean,
showTurnChangedFiles: boolean,
): string => {
const lastMessage = messages.length > 0 ? messages[messages.length - 1] : undefined;
const lastMessageId = lastMessage?.info?.id ?? '';
const lastMessagePartCount = lastMessage?.parts?.length ?? 0;
return [
sessionKey,
messages.length,
lastMessageId,
lastMessagePartCount,
buildMessagesVersionSignature(messages),
showTextJustificationActivity ? '1' : '0',
showTurnChangedFiles ? '1' : '0',
].join('|');
};
export const getCachedProjection = (
sessionKey: string,
messages: ChatMessageEntry[],
showTextJustificationActivity: boolean,
showTurnChangedFiles: boolean,
): TurnProjectionResult | undefined => {
const key = buildProjectionCacheKey(sessionKey, messages, showTextJustificationActivity, showTurnChangedFiles);
const cached = projectionCache.get(key);
if (cached) {
// LRU re-order: move hit to the end (most recent) so it survives
// eviction longer than entries that haven't been read recently.
projectionCache.delete(key);
projectionCache.set(key, cached);
}
return cached;
};
export const setCachedProjection = (
key: string,
projection: TurnProjectionResult,
): void => {
projectionCache.delete(key);
const max = getProjectionCacheMax();
while (projectionCache.size >= max) {
const oldest = projectionCache.keys().next().value;
if (typeof oldest !== 'string') break;
projectionCache.delete(oldest);
}
projectionCache.set(key, projection);
};