fix: stop stale chat renders after session switches

Roll back aggressive chat reuse paths that froze message parts, tool state, and completed assistant bodies when sessions were inactive or backgrounded.
This commit is contained in:
Bohdan Triapitsyn
2026-04-07 20:33:55 +03:00
parent c16d767dff
commit 605e4bf42c
10 changed files with 90 additions and 523 deletions
@@ -32,99 +32,6 @@ const getMessageCompletedAt = (message: ChatMessageEntry): number | undefined =>
return typeof completed === 'number' ? completed : undefined;
};
const getMessageFinish = (message: ChatMessageEntry): string | undefined => {
const finish = (message.info as { finish?: unknown }).finish;
return typeof finish === 'string' ? finish : undefined;
};
const getMessageStatus = (message: ChatMessageEntry): string | undefined => {
const status = (message.info as { status?: unknown }).status;
return typeof status === 'string' ? status : undefined;
};
const getPartText = (part: ChatMessageEntry['parts'][number]): string | undefined => {
const text = (part as { text?: unknown }).text;
if (typeof text === 'string') {
return text;
}
const content = (part as { content?: unknown }).content;
return typeof content === 'string' ? content : undefined;
};
const arePartsEquivalentForReuse = (
previousPart: ChatMessageEntry['parts'][number],
nextPart: ChatMessageEntry['parts'][number],
): boolean => {
if (previousPart === nextPart) {
return true;
}
if (previousPart.type !== nextPart.type) {
return false;
}
if (previousPart.id && nextPart.id && previousPart.id !== nextPart.id) {
return false;
}
if (previousPart.type === 'text' || previousPart.type === 'reasoning') {
return getPartText(previousPart) === getPartText(nextPart);
}
if (previousPart.type === 'tool') {
const previousTool = previousPart as {
tool?: unknown;
callID?: unknown;
state?: { status?: unknown };
};
const nextTool = nextPart as {
tool?: unknown;
callID?: unknown;
state?: { status?: unknown };
};
return previousTool.tool === nextTool.tool
&& previousTool.callID === nextTool.callID
&& previousTool.state?.status === nextTool.state?.status;
}
return true;
};
const areMessagesEquivalentForReuse = (previousMessage: ChatMessageEntry, nextMessage: ChatMessageEntry): boolean => {
if (previousMessage === nextMessage) {
return true;
}
if (previousMessage.info.id !== nextMessage.info.id) {
return false;
}
if (getMessageCompletedAt(previousMessage) !== getMessageCompletedAt(nextMessage)) {
return false;
}
if (getMessageFinish(previousMessage) !== getMessageFinish(nextMessage)) {
return false;
}
if (getMessageStatus(previousMessage) !== getMessageStatus(nextMessage)) {
return false;
}
if (previousMessage.parts.length !== nextMessage.parts.length) {
return false;
}
for (let index = 0; index < previousMessage.parts.length; index += 1) {
if (!arePartsEquivalentForReuse(previousMessage.parts[index], nextMessage.parts[index])) {
return false;
}
}
return true;
};
const getUserSummaryBody = (message: ChatMessageEntry): string | undefined => {
const summaryBody = (message.info as { summary?: { body?: unknown } | null | undefined })?.summary?.body;
if (typeof summaryBody !== 'string') {
@@ -194,7 +101,6 @@ export const projectTurnRecords = (
const turns: TurnRecord[] = [];
const turnByUserId = new Map<string, TurnRecord>();
const previousTurnsById = new Map((effectiveOptions.previousProjection?.turns ?? []).map((turn) => [turn.turnId, turn]));
const groupedMessageIds = new Set<string>();
let currentTurn: TurnRecord | undefined;
@@ -254,43 +160,6 @@ export const projectTurnRecords = (
});
turns.forEach((turn) => {
const previousTurn = previousTurnsById.get(turn.turnId);
const canReuseComputed = (() => {
if (!previousTurn) {
return false;
}
if (previousTurn.stream.isStreaming) {
return false;
}
if (!areMessagesEquivalentForReuse(previousTurn.userMessage, turn.userMessage)) {
return false;
}
if (previousTurn.assistantMessages.length !== turn.assistantMessages.length) {
return false;
}
for (let index = 0; index < turn.assistantMessages.length; index += 1) {
if (!areMessagesEquivalentForReuse(previousTurn.assistantMessages[index], turn.assistantMessages[index])) {
return false;
}
}
return true;
})();
if (canReuseComputed && previousTurn) {
turn.summary = previousTurn.summary;
turn.summaryText = previousTurn.summaryText;
turn.diffStats = previousTurn.diffStats;
turn.activityParts = previousTurn.activityParts;
turn.activitySegments = previousTurn.activitySegments;
turn.hasTools = previousTurn.hasTools;
turn.hasReasoning = previousTurn.hasReasoning;
turn.stream = previousTurn.stream;
turn.startedAt = previousTurn.startedAt;
turn.completedAt = previousTurn.completedAt;
turn.durationMs = previousTurn.durationMs;
return;
}
turn.summary = projectTurnSummary(turn.assistantMessages);
turn.summaryText = turn.summary.text ?? getUserSummaryBody(turn.userMessage);
turn.diffStats = projectTurnDiffStats(turn.userMessage);
@@ -1,6 +1,24 @@
import { projectTurnIndexes } from './projectTurnIndexes';
import type { TurnProjectionResult, TurnRecord } from './types';
const areTurnMessagesReferenceStable = (previousTurn: TurnRecord, nextTurn: TurnRecord): boolean => {
if (previousTurn.userMessage !== nextTurn.userMessage) {
return false;
}
if (previousTurn.assistantMessages.length !== nextTurn.assistantMessages.length) {
return false;
}
for (let index = 0; index < previousTurn.assistantMessages.length; index += 1) {
if (previousTurn.assistantMessages[index] !== nextTurn.assistantMessages[index]) {
return false;
}
}
return true;
};
const buildTurnSignature = (turn: TurnRecord): string => {
const assistantIds = turn.assistantMessageIds.join(',');
return [
@@ -40,6 +58,10 @@ export const stabilizeTurnProjection = (
return turn;
}
if (!areTurnMessagesReferenceStable(previousTurn, turn)) {
return turn;
}
reused = true;
return previousTurn;
});