Files
makelore/src/stores/coding-conversations.ts

1029 lines
37 KiB
TypeScript

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 interface CodingConversationEntry {
reducer: ConversationReducerState;
loadState: CodingConversationLoadState;
error: string | null;
unread: boolean;
}
export interface SubmitCodingPromptInput {
conversationId: string;
mode: PromptMode;
text?: string;
attachments?: CodingDraftAttachment[];
prepareAttachments?(): Promise<CodingDraftAttachment[]>;
}
interface CodingConversationStoreDependencies {
getSnapshot(conversationId: string): Promise<ConversationSnapshot>;
openEvents(conversationId?: string): Promise<EventSource>;
submitPrompt(input: SubmitCodingConversationPromptInput): Promise<PromptAcceptance>;
recover(conversationId: string): Promise<void>;
queueSettledSessionSync(snapshot: ConversationSnapshot): void;
createId(kind: 'request' | 'node'): string;
preparationTimeoutMs: number;
}
export interface CodingConversationStoreState {
selectedConversationId: string | null;
entriesByConversationId: Record<string, CodingConversationEntry>;
summariesByConversationId: Record<string, CodingConversationSummary>;
draftsByConversationId: Record<string, CodingConversationDraft>;
requestsByConversationId: Record<string, Record<string, CodingPromptRequestState>>;
connectionState: 'disconnected' | 'connecting' | 'live' | 'reconnecting' | 'error';
globalError: string | null;
primeConversation(snapshot: ConversationSnapshot): void;
clearConversationSelection(): void;
selectConversation(conversationId: string): Promise<void>;
loadSnapshot(conversationId: string, recovering?: boolean): Promise<ConversationSnapshot>;
recoverConversation(conversationId: string): Promise<void>;
connectEvents(): Promise<void>;
disconnectEvents(): void;
setDraft(conversationId: string, text: string, attachments?: CodingDraftAttachment[]): void;
markUnread(conversationId: string, unread: boolean): void;
submitPrompt(input: SubmitCodingPromptInput): Promise<PromptAcceptance>;
applySnapshotEvent(event: CodingConversationSnapshotEvent): void;
applyPatchBatchEvent(event: CodingConversationPatchBatchEvent): void;
}
const DEFAULT_DRAFT: CodingConversationDraft = {
text: '',
attachments: [],
revision: 0,
};
const EMPTY_REQUESTS: Record<string, CodingPromptRequestState> = {};
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<string, CodingConversationSummary>,
entry: CodingConversationEntry,
): Record<string, CodingConversationSummary> {
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<string> {
return new Set(snapshot.nodes.flatMap((node) => (
node.kind === 'message' && node.clientRequestId ? [node.clientRequestId] : []
)));
}
function withoutReconciledRequests(
requests: Record<string, CodingPromptRequestState> | undefined,
snapshot: ConversationSnapshot,
): Record<string, CodingPromptRequestState> {
const reconciled = requestIdsInSnapshot(snapshot);
if (!requests || reconciled.size === 0) return requests ?? {};
return Object.fromEntries(
Object.entries(requests).filter(([clientRequestId]) => !reconciled.has(clientRequestId)),
);
}
function withReconciledOptimisticNodeIds(
snapshot: ConversationSnapshot,
requests: Record<string, CodingPromptRequestState> | 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<string, CodingPromptRequestState>,
): 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<T>(promise: Promise<T>, timeoutMs: number): Promise<T> {
let timer: ReturnType<typeof setTimeout> | undefined;
return new Promise<T>((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;
}
export function createCodingConversationStore(
dependencies: Partial<CodingConversationStoreDependencies> = {},
): StoreApi<CodingConversationStoreState> {
const deps = { ...defaultDependencies(), ...dependencies };
const snapshotLoads = new Map<string, Promise<ConversationSnapshot>>();
const recoveryBatches = new Map<string, CodingConversationPatchBatchEvent[]>();
const recoveryRefreshKeys = new Map<string, string>();
let eventSource: EventSource | null = null;
let connectFlight: Promise<void> | null = null;
let connectionGeneration = 0;
let store!: StoreApi<CodingConversationStoreState>;
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<number, CodingConversationPatchBatchEvent['items'][number]>();
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, true).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<CodingConversationStoreState>((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, Boolean(entry?.reducer.invalidation))
: Promise.resolve(entry.reducer.snapshot);
await Promise.all([snapshotFlight, get().connectEvents()]);
},
loadSnapshot(conversationId, recovering = false) {
const prior = snapshotLoads.get(conversationId);
if (prior) return prior;
set((state) => {
const entry = state.entriesByConversationId[conversationId] ?? emptyEntry();
return {
entriesByConversationId: {
...state.entriesByConversationId,
[conversationId]: {
...entry,
loadState: recovering ? 'recovering' : 'loading',
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) => {
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, true);
} 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<void>;
flight = deps.openEvents()
.then((source) => {
if (generation !== connectionGeneration) {
source.close();
return;
}
eventSource = source;
const parse = <T,>(event: Event): T => JSON.parse((event as MessageEvent<string>).data) as T;
source.addEventListener('snapshot', (event) => {
try {
get().applySnapshotEvent(parse<CodingConversationSnapshotEvent>(event));
} catch {
set({ connectionState: 'error', globalError: 'Conversation 事件快照无法读取。' });
}
});
source.addEventListener('patch-batch', (event) => {
try {
get().applyPatchBatchEvent(parse<CodingConversationPatchBatchEvent>(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, Boolean(entry?.reducer.invalidation));
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,
},
},
};
});
let attachmentsPrepared = !input.prepareAttachments;
try {
if (input.prepareAttachments) {
submittedDraft.attachments = (await input.prepareAttachments())
.map((attachment) => ({ ...attachment }));
attachmentsPrepared = true;
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';
const preSubmitFailure = !attachmentsPrepared;
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,
error: preSubmitFailure ? currentEntry.error : failure.message,
};
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, true).catch(() => undefined);
}
return;
}
if (recovering
|| !currentSnapshot
|| event.workerGeneration !== currentSnapshot.cursor.workerGeneration
|| event.fromSeq !== currentSnapshot.cursor.seq + 1) {
bufferRecoveryBatch(event);
if (!snapshotLoads.has(event.conversationId)) {
void get().loadSnapshot(event.conversationId, true).catch(() => undefined);
}
return;
}
const shouldSyncSettledSession = completesOrdinaryPrompt(event, currentSnapshot);
let recover = false;
let applied = false;
set((state) => {
const current = state.entriesByConversationId[event.conversationId] ?? emptyEntry();
let reducer = current.reducer;
for (const item of event.items) {
reducer = reduceConversationPatch(reducer, {
conversationId: event.conversationId,
workerGeneration: event.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 incomingMessages = event.items.flatMap((item) => (
item.patch.op === 'message.upsert' ? [item.patch.node] : []
));
const unread = state.selectedConversationId !== event.conversationId
&& incomingMessages.some((message) => message.role === 'assistant')
? true
: current.unread;
const entry: CodingConversationEntry = {
...current,
reducer,
loadState: 'live',
error: null,
unread,
};
const requests = state.requestsByConversationId[event.conversationId] ?? {};
const reconciledRequestIds = new Set(incomingMessages.flatMap((message) => (
message.clientRequestId ? [message.clientRequestId] : []
)));
const nextRequests = reconciledRequestIds.size > 0
? Object.fromEntries(
Object.entries(requests).filter(([id]) => !reconciledRequestIds.has(id)),
)
: requests;
return {
entriesByConversationId: {
...state.entriesByConversationId,
[event.conversationId]: entry,
},
summariesByConversationId: summariesWithEntry(state.summariesByConversationId, entry),
requestsByConversationId: nextRequests === requests
? state.requestsByConversationId
: {
...state.requestsByConversationId,
[event.conversationId]: nextRequests,
},
};
});
if (recover) {
void get().loadSnapshot(event.conversationId, true).catch(() => undefined);
} else if (applied && shouldSyncSettledSession) {
const settledSnapshot = selectCodingConversationSnapshot(event.conversationId)(get());
if (settledSnapshot) deps.queueSettledSessionSync(settledSnapshot);
}
},
}));
return store;
}
export const codingConversationStore = createCodingConversationStore();
export function useCodingConversationStore<T>(
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<string, CodingPromptRequestState> => (
state.requestsByConversationId[conversationId] ?? EMPTY_REQUESTS
);