fix(chat): sort messages appropriately

This commit is contained in:
Haileyesus
2026-06-11 21:48:46 +03:00
parent 89f05247ed
commit 123ae31020
3 changed files with 193 additions and 12 deletions

View File

@@ -166,6 +166,108 @@ function hasServerEchoForLocalUser(
});
}
function compareMessagesChronologically(a: NormalizedMessage, b: NormalizedMessage): number {
const timeA = readMessageTime(a) ?? 0;
const timeB = readMessageTime(b) ?? 0;
if (timeA !== timeB) {
return timeA - timeB;
}
return 0;
}
/**
* Count how many user turns precede `message` in a chronologically merged view
* of server + realtime rows. Used to match a realtime row to the correct turn
* on disk when several turns share identical assistant text.
*/
function getUserTurnOrdinalBefore(
message: NormalizedMessage,
serverMessages: NormalizedMessage[],
realtimeMessages: NormalizedMessage[],
): number {
const messageTime = readMessageTime(message);
let userCount = 0;
for (const candidate of [...serverMessages, ...realtimeMessages].sort(compareMessagesChronologically)) {
if (candidate.id === message.id) {
break;
}
const candidateTime = readMessageTime(candidate);
if (
messageTime !== null
&& candidateTime !== null
&& candidateTime > messageTime
) {
break;
}
if (candidate.kind === 'text' && candidate.role === 'user') {
userCount++;
}
}
return Math.max(0, userCount - 1);
}
function findServerTurnRangeByOrdinal(
serverMessages: NormalizedMessage[],
turnOrdinal: number,
): { start: number; end: number } | null {
let userCount = -1;
let start = -1;
for (let index = 0; index < serverMessages.length; index++) {
const message = serverMessages[index];
if (message.kind === 'text' && message.role === 'user') {
userCount++;
if (userCount === turnOrdinal) {
start = index;
break;
}
}
}
if (start < 0) {
return null;
}
let end = serverMessages.length;
for (let index = start + 1; index < serverMessages.length; index++) {
if (serverMessages[index].kind === 'text' && serverMessages[index].role === 'user') {
end = index;
break;
}
}
return { start, end };
}
function isAssistantTextEchoedInSameTurnOnServer(
message: NormalizedMessage,
serverMessages: NormalizedMessage[],
realtimeMessages: NormalizedMessage[],
): boolean {
const assistantText = (message.content || '').trim();
if (!assistantText) {
return false;
}
const turnOrdinal = getUserTurnOrdinalBefore(message, serverMessages, realtimeMessages);
const turnRange = findServerTurnRangeByOrdinal(serverMessages, turnOrdinal);
if (!turnRange) {
return false;
}
return serverMessages
.slice(turnRange.start + 1, turnRange.end)
.some((serverMessage) =>
serverMessage.kind === 'text'
&& serverMessage.role === 'assistant'
&& (serverMessage.content || '').trim() === assistantText,
);
}
/**
* After `finalizeStreaming`, the client holds a synthetic assistant `text` row
* while the sessions API soon returns the same reply with a different id.
@@ -203,22 +305,92 @@ function dedupeAdjacentAssistantEchoes(merged: NormalizedMessage[]): NormalizedM
return out;
}
/**
* After a server refresh, drop only the realtime rows the persisted transcript
* already owns. Anything not yet on disk (common right after `complete`, while
* JSONL indexing lags) stays in `realtimeMessages` so the chat pane never
* flashes the empty "Continue your conversation" state.
*/
function pruneRealtimeSupersededByServer(
serverMessages: NormalizedMessage[],
realtimeMessages: NormalizedMessage[],
): NormalizedMessage[] {
if (realtimeMessages.length === 0) {
return realtimeMessages;
}
const serverIds = new Set(serverMessages.map((message) => message.id));
return realtimeMessages.filter((message) => {
if (serverIds.has(message.id)) {
return false;
}
if (message.id.startsWith('local_') && hasServerEchoForLocalUser(message, serverMessages)) {
return false;
}
if (message.kind === 'stream_delta' || message.id === `__streaming_${message.sessionId}`) {
if (isAssistantTextEchoedInSameTurnOnServer(message, serverMessages, realtimeMessages)) {
return false;
}
return true;
}
if (message.kind === 'text' && message.role === 'assistant') {
if (isAssistantTextEchoedInSameTurnOnServer(message, serverMessages, realtimeMessages)) {
return false;
}
return true;
}
if (message.kind === 'text' && message.role === 'user') {
return !hasServerEchoForLocalUser(message, serverMessages);
}
if (message.kind === 'tool_use' && message.toolId) {
if (serverMessages.some((serverMessage) => serverMessage.kind === 'tool_use' && serverMessage.toolId === message.toolId)) {
return false;
}
}
return true;
});
}
function computeMerged(server: NormalizedMessage[], realtime: NormalizedMessage[]): NormalizedMessage[] {
if (realtime.length === 0) return server;
if (server.length === 0) return dedupeAdjacentAssistantEchoes(realtime);
const serverIds = new Set(server.map(m => m.id));
const extra = realtime.filter((m) => {
if (serverIds.has(m.id)) return false;
if (realtime.length === 0) {
return dedupeAdjacentAssistantEchoes(server);
}
if (server.length === 0) {
return dedupeAdjacentAssistantEchoes(realtime);
}
const serverIds = new Set(server.map((message) => message.id));
const extra = realtime.filter((message) => {
if (serverIds.has(message.id)) {
return false;
}
// Optimistic user rows use `local_*` ids; once the same text exists on the
// server-backed copy from the same send window, drop the realtime echo to
// avoid duplicate bubbles without hiding repeated prompts from history.
if (m.id.startsWith('local_')) {
if (hasServerEchoForLocalUser(m, server)) return false;
if (message.id.startsWith('local_')) {
if (hasServerEchoForLocalUser(message, server)) {
return false;
}
}
return true;
});
if (extra.length === 0) return server;
return dedupeAdjacentAssistantEchoes([...server, ...extra]);
if (extra.length === 0) {
return dedupeAdjacentAssistantEchoes(server);
}
// Interleave by timestamp so live rows stay with their turn instead of
// piling up at the bottom after every refresh.
return dedupeAdjacentAssistantEchoes(
[...server, ...extra].sort(compareMessagesChronologically),
);
}
/**
@@ -439,8 +611,13 @@ export function useSessionStore() {
slot.total = data.total ?? slot.serverMessages.length;
slot.hasMore = Boolean(data.hasMore);
slot.fetchedAt = Date.now();
// drop realtime messages that the server has caught up with to prevent unbounded growth.
slot.realtimeMessages = [];
// Only drop realtime rows the server transcript now owns. A blind clear
// here caused the chat pane to flash "Continue your conversation" after
// `complete` while JSONL / provider_session_id indexing was still behind.
slot.realtimeMessages = pruneRealtimeSupersededByServer(
slot.serverMessages,
slot.realtimeMessages,
);
recomputeMergedIfNeeded(slot);
notify(sessionId);
} catch (error) {