diff --git a/src/components/chat/view/ChatInterface.tsx b/src/components/chat/view/ChatInterface.tsx index 694c4d60..7630c052 100644 --- a/src/components/chat/view/ChatInterface.tsx +++ b/src/components/chat/view/ChatInterface.tsx @@ -325,6 +325,7 @@ function ChatInterface({ onWheel={handleScroll} onTouchMove={handleScroll} isLoadingSessionMessages={isLoadingSessionMessages} + isProcessing={isProcessing} chatMessages={chatMessages} selectedSession={selectedSession} currentSessionId={currentSessionId} diff --git a/src/components/chat/view/subcomponents/ChatMessagesPane.tsx b/src/components/chat/view/subcomponents/ChatMessagesPane.tsx index f4ba1161..5573b31f 100644 --- a/src/components/chat/view/subcomponents/ChatMessagesPane.tsx +++ b/src/components/chat/view/subcomponents/ChatMessagesPane.tsx @@ -19,6 +19,8 @@ interface ChatMessagesPaneProps { onWheel: () => void; onTouchMove: () => void; isLoadingSessionMessages: boolean; + /** True while the viewed session has an active provider run in flight. */ + isProcessing?: boolean; chatMessages: ChatMessage[]; selectedSession: ProjectSession | null; currentSessionId: string | null; @@ -68,6 +70,7 @@ export default function ChatMessagesPane({ onWheel, onTouchMove, isLoadingSessionMessages, + isProcessing = false, chatMessages, selectedSession, currentSessionId, @@ -147,7 +150,7 @@ export default function ChatMessagesPane({ onTouchMove={onTouchMove} className="relative flex-1 space-y-3 overflow-y-auto overflow-x-hidden px-0 py-3 sm:space-y-4 sm:p-4" > - {isLoadingSessionMessages && chatMessages.length === 0 ? ( + {(isLoadingSessionMessages || isProcessing) && chatMessages.length === 0 ? (
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) {