import type { Event, Message, Part, PermissionRequest, Project, QuestionRequest, Session, SessionStatus, Todo, } from "@opencode-ai/sdk/v2/client" import { Binary } from "./binary" import type { FileDiff, GlobalState, State } from "./types" import { dropSessionCaches } from "./session-cache" import { stripSessionDiffSnapshots } from "./sanitize" import { syncDebug } from "./debug" const SKIP_PARTS = new Set(["patch", "step-start", "step-finish"]) const DELTA_OVERLAP_FIELDS = ["text", "output"] as const const FINAL_TOOL_STATUSES = new Set(["completed", "error", "aborted", "failed", "timeout", "cancelled"]) type DedupeMetadata = { __dedupeNextDeltaFields?: string[] } function appendNonOverlappingDelta(existingValue: string | undefined, delta: string) { if (!existingValue || delta.length === 0) return (existingValue ?? "") + delta if (existingValue.endsWith(delta)) return existingValue const maxOverlap = Math.min(existingValue.length, delta.length) for (let overlap = maxOverlap; overlap > 0; overlap--) { if (existingValue.endsWith(delta.slice(0, overlap))) { return existingValue + delta.slice(overlap) } } return existingValue + delta } function getUpdatedDeltaFields(previous: Part, next: Part) { const dedupeFields: string[] = [] for (const field of DELTA_OVERLAP_FIELDS) { const previousValue = (previous as Record)[field] const nextValue = (next as Record)[field] if (typeof previousValue !== "string" || typeof nextValue !== "string") continue if (previousValue.length === 0 || nextValue.length === 0) continue if (nextValue === previousValue || nextValue.startsWith(previousValue) || previousValue.startsWith(nextValue)) { dedupeFields.push(field) } } return dedupeFields } function getPartEndTime(part: Part): number | undefined { const stateEnd = (part as { state?: { time?: { end?: unknown } } }).state?.time?.end if (typeof stateEnd === "number") { return stateEnd } const timeEnd = (part as { time?: { end?: unknown } }).time?.end return typeof timeEnd === "number" ? timeEnd : undefined } function getToolStatus(part: Part): string | undefined { if (part.type !== "tool") { return undefined } const status = (part as { state?: { status?: unknown } }).state?.status return typeof status === "string" ? status : undefined } function shouldPreserveExistingPart(previous: Part, next: Part): boolean { if (previous.type !== "tool" || next.type !== "tool") { return false } const previousStatus = getToolStatus(previous) const nextStatus = getToolStatus(next) if (previousStatus && FINAL_TOOL_STATUSES.has(previousStatus) && (!nextStatus || !FINAL_TOOL_STATUSES.has(nextStatus))) { return true } const previousEnd = getPartEndTime(previous) const nextEnd = getPartEndTime(next) if (typeof previousEnd === "number" && typeof nextEnd !== "number") { return true } return false } // --------------------------------------------------------------------------- // Global events // --------------------------------------------------------------------------- export type GlobalEventResult = { type: "refresh" } | { type: "project" project: Project } | null export function reduceGlobalEvent(event: Event): GlobalEventResult { if (event.type === "global.disposed" || event.type === "server.connected") { return { type: "refresh" } } if (event.type === "project.updated") { return { type: "project", project: event.properties as Project } } return null } export function applyGlobalProject(state: GlobalState, project: Project): GlobalState { const projects = [...state.projects] const result = Binary.search(projects, project.id, (s) => s.id) if (result.found) { projects[result.index] = { ...projects[result.index], ...project } } else { projects.splice(result.index, 0, project) } return { ...state, projects } } // --------------------------------------------------------------------------- // Directory events — mutates draft in place for batching efficiency. // Caller MUST pass a mutable copy of State (e.g. structuredClone or spread). // --------------------------------------------------------------------------- export function applyDirectoryEvent( draft: State, event: Event, callbacks?: { onRefresh?: (directory: string) => void onLoadLsp?: () => void onSetSessionTodo?: (sessionID: string, todos: Todo[] | undefined) => void }, ): boolean { switch (event.type) { case "server.instance.disposed": { callbacks?.onRefresh?.("") return false } case "session.created": { const info = stripSessionDiffSnapshots((event.properties as { info: Session }).info) const sessions = draft.session const result = Binary.search(sessions, info.id, (s) => s.id) if (result.found) { sessions[result.index] = info } else { sessions.splice(result.index, 0, info) trimSessions(draft) if (!info.parentID) draft.sessionTotal += 1 } return true } case "session.updated": { const info = stripSessionDiffSnapshots((event.properties as { info: Session }).info) const sessions = draft.session const result = Binary.search(sessions, info.id, (s) => s.id) if (info.time.archived) { if (result.found) sessions.splice(result.index, 1) cleanupSessionCaches(draft, info.id, callbacks?.onSetSessionTodo) if (!info.parentID) draft.sessionTotal = Math.max(0, draft.sessionTotal - 1) return true } if (result.found) { sessions[result.index] = info } else { sessions.splice(result.index, 0, info) trimSessions(draft) } return true } case "session.deleted": { const info = (event.properties as { info: Session }).info const sessions = draft.session const result = Binary.search(sessions, info.id, (s) => s.id) if (result.found) sessions.splice(result.index, 1) cleanupSessionCaches(draft, info.id, callbacks?.onSetSessionTodo) if (!info.parentID) draft.sessionTotal = Math.max(0, draft.sessionTotal - 1) return true } case "session.diff": { const props = event.properties as { sessionID: string; diff: FileDiff[] } draft.session_diff[props.sessionID] = props.diff return true } case "todo.updated": { const props = event.properties as { sessionID: string; todos: Todo[] } draft.todo[props.sessionID] = props.todos callbacks?.onSetSessionTodo?.(props.sessionID, props.todos) return true } case "session.status": { const props = event.properties as { sessionID: string; status: SessionStatus } draft.session_status[props.sessionID] = props.status return true } case "session.idle": { const props = event.properties as { sessionID: string } draft.session_status[props.sessionID] = { type: "idle" } return true } case "session.error": { const props = event.properties as { sessionID: string } draft.session_status[props.sessionID] = { type: "idle" } return true } case "message.updated": { const info = (event.properties as { info: Message }).info const messages = draft.message[info.sessionID] if (!messages) { draft.message[info.sessionID] = [info] return true } const result = Binary.search(messages, info.id, (m) => m.id) if (result.found) { // Skip message replacement if unchanged — preserves reference, avoids re-render const existing = messages[result.index] const unchanged = existing.role === info.role && (existing as { finish?: unknown }).finish === (info as { finish?: unknown }).finish && (existing.time as { completed?: number })?.completed === (info.time as { completed?: number })?.completed if (unchanged) { syncDebug.reducer.messageUpdatedUnchanged(info.sessionID, info.id, info.role, (info as { finish?: unknown }).finish, (info.time as { completed?: number })?.completed) return false } const next = [...messages] next[result.index] = info draft.message[info.sessionID] = next } else { const next = [...messages] next.splice(result.index, 0, info) draft.message[info.sessionID] = next } return true } case "message.removed": { const props = event.properties as { sessionID: string; messageID: string } const messages = draft.message[props.sessionID] if (messages) { const next = [...messages] const result = Binary.search(next, props.messageID, (m) => m.id) if (result.found) { next.splice(result.index, 1) draft.message[props.sessionID] = next } } delete draft.part[props.messageID] return true } case "message.part.updated": { const part = (event.properties as { part: Part }).part if (SKIP_PARTS.has(part.type)) { syncDebug.reducer.partSkipped((part as { messageID: string }).messageID, part.id, part.type) return false } const messageID = (part as { messageID: string }).messageID const parts = draft.part[messageID] if (!parts) { syncDebug.reducer.partUpdatedNoExistingParts(messageID, part.id, part.type) draft.part[messageID] = [part] return true } const next = [...parts] const result = Binary.search(next, part.id, (p) => p.id) if (result.found) { const previous = next[result.index] if (shouldPreserveExistingPart(previous, part)) { return false } const dedupeFields = getUpdatedDeltaFields(previous, part) next[result.index] = dedupeFields.length > 0 ? { ...part, __dedupeNextDeltaFields: dedupeFields } as unknown as Part : part } else { // Replace optimistic part (no sessionID) with server part of same type. // Gate: only scan if the first part lacks sessionID (optimistic parts are // always inserted first). Assistant messages never have optimistic parts, // so this check is effectively free during streaming. const hasOptimistic = next.length > 0 && !(next[0] as { sessionID?: string }).sessionID const optimisticIdx = hasOptimistic && (part.type === "text" || part.type === "file") ? next.findIndex((p) => p.type === part.type && !(p as { sessionID?: string }).sessionID) : -1 if (optimisticIdx >= 0) { next.splice(optimisticIdx, 1) } const insertResult = Binary.search(next, part.id, (p) => p.id) next.splice(insertResult.index, 0, part) } draft.part[messageID] = next return true } case "message.part.removed": { const props = event.properties as { messageID: string; partID: string } const parts = draft.part[props.messageID] if (!parts) return false const result = Binary.search(parts, props.partID, (p) => p.id) if (result.found) { const next = [...parts] next.splice(result.index, 1) if (next.length === 0) { delete draft.part[props.messageID] } else { draft.part[props.messageID] = next } return true } return false } case "message.part.delta": { const props = event.properties as { messageID: string partID: string field: string delta: string } const parts = draft.part[props.messageID] if (!parts) { syncDebug.reducer.partDeltaNoParts(props.messageID, props.partID) return false } const result = Binary.search(parts, props.partID, (p) => p.id) if (!result.found) { syncDebug.reducer.partDeltaNotFound(props.messageID, props.partID) return false } const existing = parts[result.index] as Record const existingValue = existing[props.field] as string | undefined const dedupeFields = (existing as DedupeMetadata).__dedupeNextDeltaFields ?? [] const shouldDedupe = dedupeFields.includes(props.field) // Create new Part object + new array so React detects the change const next = [...parts] next[result.index] = { ...existing, [props.field]: shouldDedupe ? appendNonOverlappingDelta(existingValue, props.delta) : (existingValue ?? "") + props.delta, __dedupeNextDeltaFields: dedupeFields.filter((field) => field !== props.field), } as unknown as Part draft.part[props.messageID] = next return true } case "vcs.branch.updated": { const props = event.properties as { branch: string } if (draft.vcs?.branch === props.branch) return false draft.vcs = { branch: props.branch } return true } case "permission.asked": { const permission = event.properties as PermissionRequest const permissions = draft.permission[permission.sessionID] ?? [] draft.permission[permission.sessionID] = permissions const result = Binary.search(permissions, permission.id, (p) => p.id) if (result.found) { permissions[result.index] = permission } else { permissions.splice(result.index, 0, permission) } return true } case "permission.replied": { const props = event.properties as { sessionID: string; requestID: string } const permissions = draft.permission[props.sessionID] if (!permissions) return false const result = Binary.search(permissions, props.requestID, (p) => p.id) if (result.found) { permissions.splice(result.index, 1) return true } return false } case "question.asked": { const question = event.properties as QuestionRequest const questions = draft.question[question.sessionID] ?? [] draft.question[question.sessionID] = questions const result = Binary.search(questions, question.id, (q) => q.id) if (result.found) { questions[result.index] = question } else { questions.splice(result.index, 0, question) } return true } case "question.replied": case "question.rejected": { const props = event.properties as { sessionID: string; requestID: string } const questions = draft.question[props.sessionID] if (!questions) return false const result = Binary.search(questions, props.requestID, (q) => q.id) if (result.found) { questions.splice(result.index, 1) return true } return false } case "lsp.updated": { callbacks?.onLoadLsp?.() return false } default: return false } } // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- function trimSessions(draft: State) { if (draft.session.length <= draft.limit) return // Keep sessions that have pending permissions (they need to stay visible) const hasPermission = new Set( Object.entries(draft.permission ?? {}) .filter(([, perms]) => perms && perms.length > 0) .map(([sessionID]) => sessionID), ) while (draft.session.length > draft.limit) { // Remove from the beginning (oldest by sorted ID) const candidate = draft.session[0] if (hasPermission.has(candidate.id)) break draft.session.shift() } } function cleanupSessionCaches( draft: State, sessionID: string, setSessionTodo?: (sessionID: string, todos: Todo[] | undefined) => void, ) { if (!sessionID) return setSessionTodo?.(sessionID, undefined) dropSessionCaches(draft, [sessionID]) }