refactor: isolate chat hot paths and lazy server watchers

Split static history from live tail, stabilize chat data identities, and reduce scroll-shell churn so old viewport content stops waking on stream deltas. Also defer watcher startup and health polling so idle runtime work better matches actual usage.
This commit is contained in:
Bohdan Triapitsyn
2026-04-05 15:36:11 +03:00
parent fc44465c00
commit 4663abd6f3
17 changed files with 1053 additions and 306 deletions
@@ -7,6 +7,7 @@ import {
buildTurnWindowModel,
clampTurnStart,
getInitialTurnStart,
updateTurnWindowModelIncremental,
windowMessagesByTurn,
type TurnWindowModel,
} from '../lib/turns/windowTurns';
@@ -70,7 +71,19 @@ export const useChatTimelineController = ({
isPinned,
isOverflowing,
}: UseChatTimelineControllerOptions): UseChatTimelineControllerResult => {
const turnWindowModel = React.useMemo(() => buildTurnWindowModel(messages), [messages]);
const previousTurnWindowModelRef = React.useRef<TurnWindowModel | null>(null);
const previousMessagesRef = React.useRef<ChatMessageEntry[] | null>(null);
const turnWindowModel = React.useMemo(() => {
const incrementalModel = updateTurnWindowModelIncremental(
previousTurnWindowModelRef.current,
previousMessagesRef.current,
messages,
);
const nextModel = incrementalModel ?? buildTurnWindowModel(messages);
previousTurnWindowModelRef.current = nextModel;
previousMessagesRef.current = messages;
return nextModel;
}, [messages]);
const [turnStart, setTurnStart] = React.useState(() => getInitialTurnStart(turnWindowModel.turnCount));
const [isLoadingOlder, setIsLoadingOlder] = React.useState(false);
@@ -1,5 +1,6 @@
import React from 'react';
import { projectTurnRecords } from '../lib/turns/projectTurnRecords';
import { projectTurnIndexes } from '../lib/turns/projectTurnIndexes';
import { stabilizeTurnProjection } from '../lib/turns/stabilizeTurnProjection';
import type { ChatMessageEntry, TurnProjectionResult, TurnRecord } from '../lib/turns/types';
import { streamPerfMeasure } from '@/stores/utils/streamDebug';
@@ -15,28 +16,124 @@ export interface TurnRecordsResult {
streamingTurn: TurnProjectionResult['turns'][number] | undefined;
}
const buildTailOnlyProjection = (
previousProjection: TurnProjectionResult | null,
previousMessages: ChatMessageEntry[] | null,
nextMessages: ChatMessageEntry[],
showTextJustificationActivity: boolean,
): TurnProjectionResult | null => {
if (!previousProjection || !previousMessages || previousProjection.turns.length === 0) {
return null;
}
if (previousMessages.length !== nextMessages.length || nextMessages.length === 0) {
return null;
}
let changedCount = 0;
let changedIndex = -1;
for (let index = 0; index < nextMessages.length; index += 1) {
if (previousMessages[index]?.info.id !== nextMessages[index]?.info.id) {
return null;
}
if (previousMessages[index] !== nextMessages[index]) {
changedCount += 1;
changedIndex = index;
if (changedCount > 1) {
return null;
}
}
}
if (changedCount !== 1 || changedIndex !== nextMessages.length - 1) {
return null;
}
const previousLastTurn = previousProjection.turns[previousProjection.turns.length - 1];
if (!previousLastTurn) {
return null;
}
const lastTurnStartIndex = previousMessages.findIndex((message) => message.info.id === previousLastTurn.userMessageId);
if (lastTurnStartIndex < 0) {
return null;
}
const previousTailMessages = previousMessages.slice(lastTurnStartIndex);
const nextTailMessages = nextMessages.slice(lastTurnStartIndex);
if (nextTailMessages[0]?.info.id !== previousLastTurn.userMessageId) {
return null;
}
const previousStaticTurns = previousProjection.turns.slice(0, -1);
const previousStaticUngrouped = new Set(previousProjection.ungroupedMessageIds);
previousTailMessages.forEach((message) => {
previousStaticUngrouped.delete(message.info.id);
});
const previousTailUngrouped = new Set<string>();
previousTailMessages.forEach((message) => {
if (previousProjection.ungroupedMessageIds.has(message.info.id)) {
previousTailUngrouped.add(message.info.id);
}
});
const previousTailProjection: TurnProjectionResult = {
...projectTurnIndexes([previousLastTurn]),
ungroupedMessageIds: previousTailUngrouped,
};
const rawTailProjection = projectTurnRecords(nextTailMessages, {
previousProjection: previousTailProjection,
showTextJustificationActivity,
});
const stabilizedTailProjection = stabilizeTurnProjection(rawTailProjection, previousTailProjection);
const turns = previousStaticTurns.length > 0
? [...previousStaticTurns, ...stabilizedTailProjection.turns]
: stabilizedTailProjection.turns;
const projection = projectTurnIndexes(turns);
const ungroupedMessageIds = new Set(previousStaticUngrouped);
stabilizedTailProjection.ungroupedMessageIds.forEach((messageId) => {
ungroupedMessageIds.add(messageId);
});
return {
...projection,
ungroupedMessageIds,
};
};
export const useTurnRecords = (
messages: ChatMessageEntry[],
options: UseTurnRecordsOptions,
): TurnRecordsResult => {
const previousProjectionRef = React.useRef<TurnProjectionResult | null>(null);
const previousMessagesRef = React.useRef<ChatMessageEntry[] | null>(null);
const staticTurnsRef = React.useRef<TurnRecord[]>([]);
const streamingTurnRef = React.useRef<TurnRecord | undefined>(undefined);
React.useEffect(() => {
previousProjectionRef.current = null;
previousMessagesRef.current = null;
staticTurnsRef.current = [];
streamingTurnRef.current = undefined;
}, [options.sessionKey, options.showTextJustificationActivity]);
const projection = React.useMemo(() => {
return streamPerfMeasure('ui.turns.projection_ms', () => {
const rawProjection = projectTurnRecords(messages, {
const tailOnlyProjection = buildTailOnlyProjection(
previousProjectionRef.current,
previousMessagesRef.current,
messages,
options.showTextJustificationActivity,
);
const rawProjection = tailOnlyProjection ?? projectTurnRecords(messages, {
previousProjection: previousProjectionRef.current,
showTextJustificationActivity: options.showTextJustificationActivity,
});
const stabilizedProjection = stabilizeTurnProjection(rawProjection, previousProjectionRef.current);
previousProjectionRef.current = stabilizedProjection;
previousMessagesRef.current = messages;
return stabilizedProjection;
});
}, [messages, options.showTextJustificationActivity]);