diff --git a/src/stores/useSessionStore.ts b/src/stores/useSessionStore.ts
index 46882464..3e67026d 100644
--- a/src/stores/useSessionStore.ts
+++ b/src/stores/useSessionStore.ts
@@ -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) {