import { useStore } from 'zustand'; import { createStore, type StoreApi } from 'zustand/vanilla'; import { createConversationReducerState, reduceConversationPatch, replaceConversationSnapshot, type ConversationReducerState, } from '../../shared/coding-conversation-reducer'; import { AppError } from '@/lib/error-model'; import { queueCodingConversationSessionSync } from '@/lib/agent-session-sync'; import { getCodingConversationSnapshot, openCodingConversationEvents, recoverCodingConversation, submitCodingConversationPrompt, type SubmitCodingConversationPromptInput, } from '@/lib/coding-conversations'; import type { CodingConversationDraft, CodingConversationPatchBatchEvent, CodingConversationSnapshotEvent, CodingConversationSummary, CodingDraftAttachment, CodingPromptRequestState, ConversationMessageNode, ConversationPatchEnvelope, ConversationSnapshot, PromptAcceptance, PromptMode, } from '@/types/coding-conversation'; export type CodingConversationLoadState = | 'empty' | 'loading' | 'live' | 'recovering' | 'error'; export type CodingConversationSnapshotLoadMode = 'loading' | 'recovering' | 'silent'; export interface CodingConversationEntry { reducer: ConversationReducerState; loadState: CodingConversationLoadState; error: string | null; unread: boolean; } export interface SubmitCodingPromptInput { conversationId: string; mode: PromptMode; text?: string; attachments?: CodingDraftAttachment[]; prepareAttachments?(): Promise; } interface CodingConversationStoreDependencies { getSnapshot(conversationId: string): Promise; openEvents(conversationId?: string): Promise; submitPrompt(input: SubmitCodingConversationPromptInput): Promise; recover(conversationId: string): Promise; queueSettledSessionSync(snapshot: ConversationSnapshot): void; createId(kind: 'request' | 'node'): string; preparationTimeoutMs: number; } export interface CodingConversationStoreState { selectedConversationId: string | null; entriesByConversationId: Record; summariesByConversationId: Record; draftsByConversationId: Record; requestsByConversationId: Record>; connectionState: 'disconnected' | 'connecting' | 'live' | 'reconnecting' | 'error'; globalError: string | null; primeConversation(snapshot: ConversationSnapshot): void; clearConversationSelection(): void; selectConversation(conversationId: string): Promise; loadSnapshot( conversationId: string, mode?: CodingConversationSnapshotLoadMode, ): Promise; recoverConversation(conversationId: string): Promise; connectEvents(): Promise; disconnectEvents(): void; setDraft(conversationId: string, text: string, attachments?: CodingDraftAttachment[]): void; markUnread(conversationId: string, unread: boolean): void; submitPrompt(input: SubmitCodingPromptInput): Promise; applySnapshotEvent(event: CodingConversationSnapshotEvent): void; applyPatchBatchEvent(event: CodingConversationPatchBatchEvent): void; } const DEFAULT_DRAFT: CodingConversationDraft = { text: '', attachments: [], revision: 0, }; const EMPTY_REQUESTS: Record = {}; function cloneDraft(draft: CodingConversationDraft): CodingConversationDraft { return { text: draft.text, attachments: draft.attachments.map((attachment) => ({ ...attachment })), revision: draft.revision, }; } function emptyEntry(): CodingConversationEntry { return { reducer: createConversationReducerState(), loadState: 'empty', error: null, unread: false, }; } function errorDetails(error: unknown): { code?: string; message: string } { if (error instanceof AppError) { return { ...(typeof error.details?.backendCode === 'string' ? { code: error.details.backendCode } : {}), message: error.message, }; } return { message: error instanceof Error ? error.message : String(error) }; } function summaryOf(entry: CodingConversationEntry): CodingConversationSummary | null { const snapshot = entry.reducer.snapshot; if (!snapshot) return null; return { conversationId: snapshot.conversation.id, title: snapshot.conversation.title, runStatus: snapshot.run.status, runError: snapshot.run.error ?? null, workerStatus: snapshot.worker.status, model: snapshot.conversation.model, unread: entry.unread, }; } function summariesWithEntry( summaries: Record, entry: CodingConversationEntry, ): Record { const next = summaryOf(entry); if (!next) return summaries; const current = summaries[next.conversationId]; if (current && current.title === next.title && current.runStatus === next.runStatus && current.runError === next.runError && current.workerStatus === next.workerStatus && current.model === next.model && current.unread === next.unread) { return summaries; } return { ...summaries, [next.conversationId]: next }; } function requestIdsInSnapshot(snapshot: ConversationSnapshot): Set { return new Set(snapshot.nodes.flatMap((node) => ( node.kind === 'message' && node.clientRequestId ? [node.clientRequestId] : [] ))); } function withoutReconciledRequests( requests: Record | undefined, snapshot: ConversationSnapshot, ): Record { const reconciled = requestIdsInSnapshot(snapshot); const requestUncertaintySettled = snapshot.run.status === 'idle' || snapshot.run.status === 'error'; if (!requests || (reconciled.size === 0 && !requestUncertaintySettled)) return requests ?? {}; return Object.fromEntries( Object.entries(requests).filter(([clientRequestId, request]) => ( !reconciled.has(clientRequestId) && !(request.status === 'uncertain' && requestUncertaintySettled) )), ); } function withReconciledOptimisticNodeIds( snapshot: ConversationSnapshot, requests: Record | undefined, ): ConversationSnapshot { if (!requests) return snapshot; let changed = false; const nodes = snapshot.nodes.map((node) => { if (node.kind !== 'message' || !node.clientRequestId) return node; const nodeId = requests[node.clientRequestId]?.nodeId; if (!nodeId || node.id === nodeId) return node; changed = true; return { ...node, id: nodeId }; }); return changed ? { ...snapshot, nodes } : snapshot; } function optimisticNode( nodeId: string, clientRequestId: string, draft: CodingConversationDraft, ): ConversationMessageNode { return { kind: 'message', id: nodeId, clientRequestId, role: 'user', status: 'optimistic', blocks: [ ...(draft.text ? [{ kind: 'text' as const, id: `${nodeId}:text`, text: draft.text, status: 'complete' as const, }] : []), ...draft.attachments.map((attachment, index) => ({ kind: 'image' as const, id: `${nodeId}:attachment:${index}`, attachmentId: attachment.attachmentId, mime: attachment.mime, })), ], }; } function withOptimisticNode( reducer: ConversationReducerState, node: ConversationMessageNode, ): ConversationReducerState { const snapshot = reducer.snapshot; if (!snapshot) return reducer; return replaceConversationSnapshot(reducer, { ...snapshot, nodes: [...snapshot.nodes, node], }); } function withRejectedNode( reducer: ConversationReducerState, nodeId: string | undefined, ): ConversationReducerState { const snapshot = reducer.snapshot; if (!snapshot || !nodeId) return reducer; return replaceConversationSnapshot(reducer, { ...snapshot, nodes: snapshot.nodes.map((node) => ( node.kind === 'message' && node.id === nodeId ? { ...node, status: 'error' as const } : node )), }); } function withUnreconciledOptimisticNodes( reducer: ConversationReducerState, requests: Record, ): ConversationReducerState { const snapshot = reducer.snapshot; if (!snapshot) return reducer; const existingRequestIds = requestIdsInSnapshot(snapshot); const nodes = Object.values(requests).flatMap((request) => ( request.nodeId && request.status !== 'rejected' && !existingRequestIds.has(request.clientRequestId) ? [optimisticNode(request.nodeId, request.clientRequestId, request.submittedDraft)] : [] )); if (nodes.length === 0) return reducer; return replaceConversationSnapshot(reducer, { ...snapshot, nodes: [...snapshot.nodes, ...nodes], }); } function snapshotIsOlder( current: ConversationSnapshot | null, incoming: ConversationSnapshot, ): boolean { if (!current) return false; if (incoming.cursor.workerGeneration !== current.cursor.workerGeneration) { return incoming.cursor.workerGeneration < current.cursor.workerGeneration; } return incoming.cursor.seq < current.cursor.seq; } function defaultDependencies(): CodingConversationStoreDependencies { return { getSnapshot: getCodingConversationSnapshot, openEvents: openCodingConversationEvents, submitPrompt: submitCodingConversationPrompt, recover: recoverCodingConversation, queueSettledSessionSync: queueCodingConversationSessionSync, createId: () => crypto.randomUUID(), preparationTimeoutMs: 10_000, }; } function withPreparedOptimisticNode( reducer: ConversationReducerState, nodeId: string | undefined, clientRequestId: string, draft: CodingConversationDraft, ): ConversationReducerState { const snapshot = reducer.snapshot; if (!snapshot || !nodeId) return reducer; return replaceConversationSnapshot(reducer, { ...snapshot, nodes: snapshot.nodes.map((node) => ( node.kind === 'message' && node.id === nodeId ? optimisticNode(nodeId, clientRequestId, draft) : node )), }); } function withPreparationDeadline(promise: Promise, timeoutMs: number): Promise { let timer: ReturnType | undefined; return new Promise((resolve, reject) => { timer = setTimeout(() => { reject(new Error('本地 Agent 准备超时,请重试。')); }, timeoutMs); promise.then(resolve, reject); }).finally(() => { if (timer !== undefined) clearTimeout(timer); }); } function validPatchBatchRange(event: CodingConversationPatchBatchEvent): boolean { if (event.type !== 'patch-batch' || !event.conversationId || !Number.isSafeInteger(event.workerGeneration) || event.workerGeneration < 0 || !Array.isArray(event.items) || event.items.length === 0 || event.fromSeq !== event.items[0]?.seq || event.toSeq !== event.items.at(-1)?.seq) { return false; } return event.items.every((item, index) => ( item !== null && typeof item === 'object' && Number.isSafeInteger(item.seq) && item.seq >= 0 && (index === 0 || item.seq === event.items[index - 1].seq + 1) )); } function completesOrdinaryPrompt( event: CodingConversationPatchBatchEvent, current: ConversationSnapshot, ): boolean { let mode = current.run.mode; let observedActiveRun = current.run.status !== 'idle'; for (const item of event.items) { if (item.patch.op !== 'run.state') continue; if (item.patch.run.mode) mode = item.patch.run.mode; if (item.patch.run.status !== 'idle') observedActiveRun = true; if (observedActiveRun && mode === 'prompt' && item.patch.run.status === 'idle' && item.patch.run.terminalReason === 'completed') { return true; } } return false; } function taskHasSettled(snapshot: ConversationSnapshot): boolean { return snapshot.run.status === 'error' || (snapshot.run.status === 'idle' && snapshot.run.terminalReason !== undefined); } function newlyNeedsUserAttention( current: ConversationSnapshot | null, next: ConversationSnapshot | null, ): boolean { if (!next) return false; const currentPendingInteractions = new Set( current?.pendingInteractions .filter((interaction) => interaction.status === 'pending') .map((interaction) => interaction.id) ?? [], ); if (next.pendingInteractions.some((interaction) => ( interaction.status === 'pending' && !currentPendingInteractions.has(interaction.id) ))) { return true; } if (!taskHasSettled(next)) return false; return !current || !taskHasSettled(current) || current.run.runId !== next.run.runId || current.run.terminalReason !== next.run.terminalReason; } export function createCodingConversationStore( dependencies: Partial = {}, ): StoreApi { const deps = { ...defaultDependencies(), ...dependencies }; const snapshotLoads = new Map>(); const recoveryBatches = new Map(); const recoveryRefreshKeys = new Map(); let eventSource: EventSource | null = null; let connectFlight: Promise | null = null; let connectionGeneration = 0; let store!: StoreApi; const bufferRecoveryBatch = (event: CodingConversationPatchBatchEvent) => { const buffered = recoveryBatches.get(event.conversationId) ?? []; if (buffered.some((candidate) => ( candidate.workerGeneration === event.workerGeneration && candidate.fromSeq === event.fromSeq && candidate.toSeq === event.toSeq ))) return; recoveryBatches.set( event.conversationId, [...buffered, event].sort((left, right) => ( left.workerGeneration - right.workerGeneration || left.fromSeq - right.fromSeq )), ); }; const replayRecoveryBatches = (conversationId: string) => { const buffered = recoveryBatches.get(conversationId); if (!buffered?.length) return; const snapshot = selectCodingConversationSnapshot(conversationId)(store.getState()); if (!snapshot) return; const itemsBySeq = new Map(); for (const batch of buffered) { if (batch.workerGeneration !== snapshot.cursor.workerGeneration) continue; for (const item of batch.items) { if (item.seq > snapshot.cursor.seq && !itemsBySeq.has(item.seq)) { itemsBySeq.set(item.seq, item); } } } const items = [...itemsBySeq.values()].sort((left, right) => left.seq - right.seq); if (items.length === 0) { recoveryBatches.delete(conversationId); recoveryRefreshKeys.delete(conversationId); return; } const continuous = items[0].seq === snapshot.cursor.seq + 1 && items.every((item, index) => index === 0 || item.seq === items[index - 1].seq + 1); if (!continuous) { const lastSeq = items.at(-1)?.seq ?? items[0].seq; const refreshKey = [ snapshot.cursor.workerGeneration, snapshot.cursor.seq, items[0].seq, lastSeq, ].join(':'); const willRefresh = recoveryRefreshKeys.get(conversationId) !== refreshKey; store.setState((state) => { const current = state.entriesByConversationId[conversationId] ?? emptyEntry(); return { entriesByConversationId: { ...state.entriesByConversationId, [conversationId]: { ...current, loadState: willRefresh ? 'recovering' : 'error', error: 'Conversation patch batch is not continuous', }, }, }; }); if (willRefresh) { recoveryRefreshKeys.set(conversationId, refreshKey); queueMicrotask(() => { if (!snapshotLoads.has(conversationId)) { void store.getState().loadSnapshot(conversationId, 'recovering').catch(() => undefined); } }); } return; } recoveryBatches.delete(conversationId); recoveryRefreshKeys.delete(conversationId); store.getState().applyPatchBatchEvent({ type: 'patch-batch', conversationId, workerGeneration: snapshot.cursor.workerGeneration, fromSeq: items[0].seq, toSeq: items.at(-1)?.seq ?? items[0].seq, items, }); }; store = createStore((set, get) => ({ selectedConversationId: null, entriesByConversationId: {}, summariesByConversationId: {}, draftsByConversationId: {}, requestsByConversationId: {}, connectionState: 'disconnected', globalError: null, primeConversation(snapshot) { const conversationId = snapshot.conversation.id; set((state) => { const current = state.entriesByConversationId[conversationId]; if (current?.loadState === 'live' || current?.reducer.invalidation) return state; const entry: CodingConversationEntry = { ...(current ?? emptyEntry()), reducer: replaceConversationSnapshot( current?.reducer ?? createConversationReducerState(), snapshot, ), loadState: current?.loadState ?? 'empty', error: null, }; return { entriesByConversationId: { ...state.entriesByConversationId, [conversationId]: entry, }, summariesByConversationId: summariesWithEntry( state.summariesByConversationId, entry, ), }; }); }, clearConversationSelection() { set({ selectedConversationId: null }); }, async selectConversation(conversationId) { set((state) => { const current = state.entriesByConversationId[conversationId] ?? emptyEntry(); const entry = current.unread ? { ...current, unread: false } : current; return { selectedConversationId: conversationId, entriesByConversationId: { ...state.entriesByConversationId, [conversationId]: entry, }, summariesByConversationId: summariesWithEntry(state.summariesByConversationId, entry), }; }); const entry = get().entriesByConversationId[conversationId]; const snapshotFlight = !entry?.reducer.snapshot || entry.reducer.invalidation || entry.loadState !== 'live' ? get().loadSnapshot( conversationId, entry?.reducer.invalidation ? 'recovering' : 'loading', ) : Promise.resolve(entry.reducer.snapshot); await Promise.all([snapshotFlight, get().connectEvents()]); }, loadSnapshot(conversationId, mode = 'loading') { const prior = snapshotLoads.get(conversationId); if (prior) return prior; if (mode !== 'silent') { set((state) => { const entry = state.entriesByConversationId[conversationId] ?? emptyEntry(); return { entriesByConversationId: { ...state.entriesByConversationId, [conversationId]: { ...entry, loadState: mode, error: null, }, }, }; }); } const flight = withPreparationDeadline( deps.getSnapshot(conversationId), deps.preparationTimeoutMs, ) .then((snapshot) => { get().applySnapshotEvent({ type: 'snapshot', conversationId, workerGeneration: snapshot.cursor.workerGeneration, seq: snapshot.cursor.seq, snapshot, }); return snapshot; }) .catch((error) => { if (mode !== 'silent') { const failure = errorDetails(error); set((state) => { const entry = state.entriesByConversationId[conversationId] ?? emptyEntry(); return { entriesByConversationId: { ...state.entriesByConversationId, [conversationId]: { ...entry, loadState: 'error', error: failure.message }, }, }; }); } throw error; }) .finally(() => { if (snapshotLoads.get(conversationId) === flight) snapshotLoads.delete(conversationId); const entry = get().entriesByConversationId[conversationId]; if (!entry?.reducer.invalidation) replayRecoveryBatches(conversationId); }); snapshotLoads.set(conversationId, flight); return flight; }, async recoverConversation(conversationId) { recoveryRefreshKeys.delete(conversationId); set((state) => { const entry = state.entriesByConversationId[conversationId] ?? emptyEntry(); return { entriesByConversationId: { ...state.entriesByConversationId, [conversationId]: { ...entry, loadState: 'recovering', error: null }, }, }; }); try { await deps.recover(conversationId); await get().loadSnapshot(conversationId, 'recovering'); } catch (error) { const failure = errorDetails(error); set((state) => { const entry = state.entriesByConversationId[conversationId] ?? emptyEntry(); return { entriesByConversationId: { ...state.entriesByConversationId, [conversationId]: { ...entry, loadState: 'error', error: failure.message }, }, }; }); throw error; } }, async connectEvents() { if (eventSource) return; if (connectFlight) return await connectFlight; const generation = ++connectionGeneration; set({ connectionState: 'connecting', globalError: null }); let flight: Promise; flight = deps.openEvents() .then((source) => { if (generation !== connectionGeneration) { source.close(); return; } eventSource = source; const parse = (event: Event): T => JSON.parse((event as MessageEvent).data) as T; source.addEventListener('snapshot', (event) => { try { get().applySnapshotEvent(parse(event)); } catch { set({ connectionState: 'error', globalError: 'Conversation 事件快照无法读取。' }); } }); source.addEventListener('patch-batch', (event) => { try { get().applyPatchBatchEvent(parse(event)); } catch { set({ connectionState: 'error', globalError: 'Conversation 事件更新无法读取。' }); } }); source.onopen = () => { if (eventSource === source) set({ connectionState: 'live', globalError: null }); }; source.onerror = () => { if (eventSource === source) { set({ connectionState: 'reconnecting', globalError: 'Conversation 事件连接中断,正在重连。', }); } }; }) .catch((error) => { if (generation !== connectionGeneration) return; set({ connectionState: 'error', globalError: errorDetails(error).message }); throw error; }) .finally(() => { if (connectFlight === flight) connectFlight = null; }); connectFlight = flight; await flight; }, disconnectEvents() { connectionGeneration += 1; eventSource?.close(); eventSource = null; connectFlight = null; set({ connectionState: 'disconnected', globalError: null }); }, setDraft(conversationId, text, attachments = []) { set((state) => { const current = state.draftsByConversationId[conversationId] ?? DEFAULT_DRAFT; return { draftsByConversationId: { ...state.draftsByConversationId, [conversationId]: { text, attachments: attachments.map((attachment) => ({ ...attachment })), revision: current.revision + 1, }, }, }; }); }, markUnread(conversationId, unread) { set((state) => { const current = state.entriesByConversationId[conversationId] ?? emptyEntry(); if (current.unread === unread) return state; const entry = { ...current, unread }; return { entriesByConversationId: { ...state.entriesByConversationId, [conversationId]: entry, }, summariesByConversationId: summariesWithEntry(state.summariesByConversationId, entry), }; }); }, async submitPrompt(input) { let entry = get().entriesByConversationId[input.conversationId]; if (!entry?.reducer.snapshot || entry.reducer.invalidation) { await get().loadSnapshot( input.conversationId, entry?.reducer.invalidation ? 'recovering' : 'loading', ); entry = get().entriesByConversationId[input.conversationId]; } if (!entry?.reducer.snapshot) throw new Error('Conversation snapshot is unavailable'); const existingDraft = get().draftsByConversationId[input.conversationId] ?? DEFAULT_DRAFT; const submittedDraft: CodingConversationDraft = { text: input.text ?? existingDraft.text, attachments: (input.attachments ?? existingDraft.attachments).map((attachment) => ({ ...attachment })), revision: existingDraft.revision, }; const clientRequestId = deps.createId('request'); const nodeId = input.mode === 'prompt' ? deps.createId('node') : undefined; const clearedRevision = existingDraft.revision + 1; const request: CodingPromptRequestState = { clientRequestId, mode: input.mode, ...(nodeId ? { nodeId } : {}), status: 'pending', submittedDraft: cloneDraft(submittedDraft), }; set((state) => { const currentEntry = state.entriesByConversationId[input.conversationId] ?? emptyEntry(); const reducer = nodeId ? withOptimisticNode( currentEntry.reducer, optimisticNode(nodeId, clientRequestId, submittedDraft), ) : currentEntry.reducer; const nextEntry = { ...currentEntry, reducer, error: null }; return { entriesByConversationId: { ...state.entriesByConversationId, [input.conversationId]: nextEntry, }, summariesByConversationId: summariesWithEntry( state.summariesByConversationId, nextEntry, ), draftsByConversationId: { ...state.draftsByConversationId, [input.conversationId]: { text: '', attachments: [], revision: clearedRevision }, }, requestsByConversationId: { ...state.requestsByConversationId, [input.conversationId]: { ...(state.requestsByConversationId[input.conversationId] ?? {}), [clientRequestId]: request, }, }, }; }); try { if (input.prepareAttachments) { submittedDraft.attachments = (await input.prepareAttachments()) .map((attachment) => ({ ...attachment })); set((state) => { const currentEntry = state.entriesByConversationId[input.conversationId] ?? emptyEntry(); const requests = state.requestsByConversationId[input.conversationId] ?? {}; const currentRequest = requests[clientRequestId]; if (!currentRequest) return state; const reducer = withPreparedOptimisticNode( currentEntry.reducer, nodeId, clientRequestId, submittedDraft, ); const nextEntry = { ...currentEntry, reducer }; return { entriesByConversationId: { ...state.entriesByConversationId, [input.conversationId]: nextEntry, }, summariesByConversationId: summariesWithEntry( state.summariesByConversationId, nextEntry, ), requestsByConversationId: { ...state.requestsByConversationId, [input.conversationId]: { ...requests, [clientRequestId]: { ...currentRequest, submittedDraft: cloneDraft(submittedDraft), }, }, }, }; }); } const acceptance = await deps.submitPrompt({ conversationId: input.conversationId, clientRequestId, mode: input.mode, text: submittedDraft.text, attachments: submittedDraft.attachments.map(({ attachmentId }) => ({ attachmentId })), }); set((state) => { const requests = state.requestsByConversationId[input.conversationId] ?? {}; const current = requests[clientRequestId]; if (!current) return state; return { requestsByConversationId: { ...state.requestsByConversationId, [input.conversationId]: { ...requests, [clientRequestId]: { ...current, status: 'accepted' }, }, }, }; }); return acceptance; } catch (error) { const failure = errorDetails(error); const uncertain = failure.code === 'CODING_REQUEST_UNCERTAIN'; set((state) => { const currentEntry = state.entriesByConversationId[input.conversationId] ?? emptyEntry(); const requests = state.requestsByConversationId[input.conversationId] ?? {}; const currentRequest = requests[clientRequestId] ?? request; const currentDraft = state.draftsByConversationId[input.conversationId] ?? DEFAULT_DRAFT; const canRestore = currentDraft.revision === clearedRevision && !currentDraft.text && currentDraft.attachments.length === 0; const reducer = uncertain ? currentEntry.reducer : withRejectedNode(currentEntry.reducer, nodeId); const nextEntry = { ...currentEntry, reducer, }; return { entriesByConversationId: { ...state.entriesByConversationId, [input.conversationId]: nextEntry, }, summariesByConversationId: summariesWithEntry( state.summariesByConversationId, nextEntry, ), draftsByConversationId: canRestore ? { ...state.draftsByConversationId, [input.conversationId]: { ...cloneDraft(submittedDraft), revision: clearedRevision + 1, }, } : state.draftsByConversationId, requestsByConversationId: { ...state.requestsByConversationId, [input.conversationId]: { ...requests, [clientRequestId]: { ...currentRequest, status: uncertain ? 'uncertain' : 'rejected', ...(failure.code ? { errorCode: failure.code } : {}), errorMessage: failure.message, }, }, }, }; }); throw error; } }, applySnapshotEvent(event) { const snapshot = event.snapshot; if (event.type !== 'snapshot' || event.conversationId !== snapshot.conversation.id || event.workerGeneration !== snapshot.cursor.workerGeneration || event.seq !== snapshot.cursor.seq) { return; } set((state) => { const current = state.entriesByConversationId[event.conversationId] ?? emptyEntry(); if (!current.reducer.invalidation && snapshotIsOlder(current.reducer.snapshot, snapshot)) { return state; } const reconciledSnapshot = withReconciledOptimisticNodeIds( snapshot, state.requestsByConversationId[event.conversationId], ); const requests = withoutReconciledRequests( state.requestsByConversationId[event.conversationId], reconciledSnapshot, ); const reducer = withUnreconciledOptimisticNodes( replaceConversationSnapshot(current.reducer, reconciledSnapshot), requests, ); const entry: CodingConversationEntry = { ...current, reducer, loadState: reducer.invalidation ? 'error' : 'live', error: reducer.invalidation?.reason ?? null, unread: state.selectedConversationId === event.conversationId ? false : current.unread, }; return { entriesByConversationId: { ...state.entriesByConversationId, [event.conversationId]: entry, }, summariesByConversationId: summariesWithEntry(state.summariesByConversationId, entry), requestsByConversationId: { ...state.requestsByConversationId, [event.conversationId]: requests, }, }; }); if (!snapshotLoads.has(event.conversationId)) { replayRecoveryBatches(event.conversationId); } }, applyPatchBatchEvent(event) { const currentBeforeBatch = get().entriesByConversationId[event.conversationId]; const currentSnapshot = currentBeforeBatch?.reducer.snapshot; if (currentSnapshot && event.workerGeneration < currentSnapshot.cursor.workerGeneration) return; const validRange = validPatchBatchRange(event); const recovering = currentBeforeBatch?.loadState === 'recovering' || Boolean(currentBeforeBatch?.reducer.invalidation); if (!validRange) { if (!snapshotLoads.has(event.conversationId)) { void get().loadSnapshot(event.conversationId, 'recovering').catch(() => undefined); } return; } let applicableEvent = event; if (currentSnapshot && event.workerGeneration === currentSnapshot.cursor.workerGeneration && event.fromSeq <= currentSnapshot.cursor.seq) { const unseenItems = event.items.filter((item) => item.seq > currentSnapshot.cursor.seq); if (unseenItems.length === 0) return; applicableEvent = { ...event, fromSeq: unseenItems[0]!.seq, toSeq: unseenItems.at(-1)!.seq, items: unseenItems, }; } if (recovering || !currentSnapshot || applicableEvent.workerGeneration !== currentSnapshot.cursor.workerGeneration || applicableEvent.fromSeq !== currentSnapshot.cursor.seq + 1) { bufferRecoveryBatch(applicableEvent); if (!snapshotLoads.has(applicableEvent.conversationId)) { void get().loadSnapshot(applicableEvent.conversationId, 'recovering').catch(() => undefined); } return; } const shouldSyncSettledSession = completesOrdinaryPrompt(applicableEvent, currentSnapshot); let recover = false; let applied = false; set((state) => { const current = state.entriesByConversationId[applicableEvent.conversationId] ?? emptyEntry(); let reducer = current.reducer; for (const item of applicableEvent.items) { reducer = reduceConversationPatch(reducer, { conversationId: applicableEvent.conversationId, workerGeneration: applicableEvent.workerGeneration, ...(item.runId ? { runId: item.runId } : {}), seq: item.seq, at: item.at, patch: item.patch, } satisfies ConversationPatchEnvelope); if (reducer.invalidation) { recover = true; return state; } } if (reducer === current.reducer) return state; applied = true; const unread = state.selectedConversationId !== applicableEvent.conversationId && newlyNeedsUserAttention(current.reducer.snapshot, reducer.snapshot) ? true : current.unread; const entry: CodingConversationEntry = { ...current, reducer, loadState: 'live', error: null, unread, }; const requests = state.requestsByConversationId[applicableEvent.conversationId] ?? {}; const nextRequests = reducer.snapshot ? withoutReconciledRequests(requests, reducer.snapshot) : requests; const requestsChanged = Object.keys(nextRequests).length !== Object.keys(requests).length; return { entriesByConversationId: { ...state.entriesByConversationId, [applicableEvent.conversationId]: entry, }, summariesByConversationId: summariesWithEntry(state.summariesByConversationId, entry), requestsByConversationId: !requestsChanged ? state.requestsByConversationId : { ...state.requestsByConversationId, [applicableEvent.conversationId]: nextRequests, }, }; }); if (recover) { void get().loadSnapshot(applicableEvent.conversationId, 'recovering').catch(() => undefined); } else if (applied && shouldSyncSettledSession) { const settledSnapshot = selectCodingConversationSnapshot(applicableEvent.conversationId)(get()); if (settledSnapshot) deps.queueSettledSessionSync(settledSnapshot); } }, })); return store; } export const codingConversationStore = createCodingConversationStore(); export function useCodingConversationStore( selector: (state: CodingConversationStoreState) => T, ): T { return useStore(codingConversationStore, selector); } export const selectCodingConversationSnapshot = (conversationId: string) => ( state: CodingConversationStoreState, ): ConversationSnapshot | null => ( state.entriesByConversationId[conversationId]?.reducer.snapshot ?? null ); export const selectCodingConversationSummary = (conversationId: string) => ( state: CodingConversationStoreState, ): CodingConversationSummary | null => state.summariesByConversationId[conversationId] ?? null; export const selectCodingConversationDraft = (conversationId: string) => ( state: CodingConversationStoreState, ): CodingConversationDraft => state.draftsByConversationId[conversationId] ?? DEFAULT_DRAFT; export const selectCodingConversationRequests = (conversationId: string) => ( state: CodingConversationStoreState, ): Record => ( state.requestsByConversationId[conversationId] ?? EMPTY_REQUESTS );