import { Binary } from "@opencode-ai/core/util/binary" import { produce, reconcile, type SetStoreFunction, type Store } from "solid-js/store" import type { Message, Part, PermissionRequest, Project, QuestionRequest, Session, SessionStatus, Todo, } from "@opencode-ai/sdk/v2/client" import type { FileDiffInfo } from "@opencode-ai/client/promise" import type { State, VcsCache } from "./types" import { trimSessions } from "./session-trim" import { dropSessionCaches } from "./session-cache" import { diffs as list, message as clean } from "@/utils/diffs" import { messageKey } from "@/utils/session-message" const SKIP_PARTS = new Set(["patch", "step-start", "step-finish"]) const SESSION_CONTENT_EVENTS = new Set([ "session.diff", "todo.updated", "session.status", "message.updated", "message.removed", "message.part.updated", "message.part.removed", "message.part.delta", "permission.asked", "permission.replied", "question.asked", "question.replied", "question.rejected", ]) export function applyGlobalEvent(input: { event: { type: string; properties?: unknown } project: Project[] setGlobalProject: (next: Project[] | ((draft: Project[]) => Project[])) => void refresh: () => void }) { if (input.event.type === "global.disposed" || input.event.type === "server.connected") { input.refresh() return } if (input.event.type !== "project.updated") return const properties = input.event.properties as Project const result = Binary.search(input.project, properties.id, (s) => s.id) if (result.found) { input.setGlobalProject( produce((draft) => { draft[result.index] = { ...draft[result.index], ...properties } }), ) return } input.setGlobalProject( produce((draft) => { draft.splice(result.index, 0, properties) }), ) } function cleanupSessionCaches( setStore: SetStoreFunction, sessionID: string, setSessionTodo?: (sessionID: string, todos: Todo[] | undefined) => void, ) { if (!sessionID) return setSessionTodo?.(sessionID, undefined) setStore( produce((draft) => { dropSessionCaches(draft, [sessionID]) }), ) } export function cleanupDroppedSessionCaches( store: Store, setStore: SetStoreFunction, next: Session[], setSessionTodo?: (sessionID: string, todos: Todo[] | undefined) => void, ) { const keep = new Set(next.map((item) => item.id)) const stale = [ ...Object.keys(store.message), ...Object.keys(store.session_diff), ...Object.keys(store.todo), ...Object.keys(store.permission), ...Object.keys(store.question), ...Object.keys(store.session_status), ...Object.values(store.part) .map((parts) => parts?.find((part) => !!part?.sessionID)?.sessionID) .filter((sessionID): sessionID is string => !!sessionID), ].filter((sessionID, index, list) => !keep.has(sessionID) && list.indexOf(sessionID) === index) if (stale.length === 0) return for (const sessionID of stale) { setSessionTodo?.(sessionID, undefined) } setStore( produce((draft) => { dropSessionCaches(draft, stale) }), ) } export function applyDirectoryEvent(input: { event: { type: string; properties?: unknown } store: Store setStore: SetStoreFunction push: (directory: string) => void directory: string loadLsp: () => void loadReferences?: () => void vcsCache?: VcsCache setSessionTodo?: (sessionID: string, todos: Todo[] | undefined) => void retainedLimit?: number sessionContent?: boolean permission?: State["permission"] }) { const event = input.event if (input.sessionContent === false && SESSION_CONTENT_EVENTS.has(event.type)) return const limit = Math.max(input.store.limit, input.retainedLimit ?? 0) switch (event.type) { case "server.instance.disposed": { input.push(input.directory) return } case "session.created": { const info = (event.properties as { info: Session }).info const result = Binary.search(input.store.session, info.id, (s) => s.id) if (result.found) { input.setStore("session", result.index, reconcile(info)) break } const next = input.store.session.slice() next.splice(result.index, 0, info) const trimmed = trimSessions(next, { limit, permission: input.permission ?? input.store.permission }) input.setStore("session", reconcile(trimmed, { key: "id" })) cleanupDroppedSessionCaches(input.store, input.setStore, trimmed, input.setSessionTodo) if (!info.parentID) input.setStore("sessionTotal", (value) => value + 1) break } case "session.updated": { const info = (event.properties as { info: Session }).info const result = Binary.search(input.store.session, info.id, (s) => s.id) if (info.time.archived) { if (!result.found) break if (input.store.session[result.index]!.time.archived === info.time.archived) break input.setStore( "session", produce((draft) => { draft.splice(result.index, 1) }), ) cleanupSessionCaches(input.setStore, info.id, input.setSessionTodo) if (info.parentID) break input.setStore("sessionTotal", (value) => Math.max(0, value - 1)) break } if (result.found) { input.setStore("session", result.index, reconcile(info)) break } const next = input.store.session.slice() next.splice(result.index, 0, info) const trimmed = trimSessions(next, { limit, permission: input.permission ?? input.store.permission }) input.setStore("session", reconcile(trimmed, { key: "id" })) cleanupDroppedSessionCaches(input.store, input.setStore, trimmed, input.setSessionTodo) break } case "session.deleted": { const properties = event.properties as { sessionID?: string; info?: Session } const sessionID = properties.info?.id ?? properties.sessionID if (!sessionID) break const result = Binary.search(input.store.session, sessionID, (s) => s.id) const info = properties.info ?? (result.found ? input.store.session[result.index] : undefined) if (result.found) { input.setStore( "session", produce((draft) => { draft.splice(result.index, 1) }), ) } cleanupSessionCaches(input.setStore, sessionID, input.setSessionTodo) if (info?.parentID) break input.setStore("sessionTotal", (value) => Math.max(0, value - 1)) break } case "session.renamed": { const properties = event.properties as { sessionID: string; title: string } const result = Binary.search(input.store.session, properties.sessionID, (session) => session.id) if (!result.found) break input.setStore("session", result.index, (session) => ({ ...session, title: properties.title, time: { ...session.time, updated: Date.now() }, })) break } case "session.usage.updated": { const properties = event.properties as Pick & { sessionID: string } const result = Binary.search(input.store.session, properties.sessionID, (session) => session.id) if (!result.found) break input.setStore("session", result.index, (session) => ({ ...session, cost: properties.cost, tokens: properties.tokens, })) break } // case "session.archived": { // const properties = event.properties as { sessionID: string } // const result = Binary.search(input.store.session, properties.sessionID, (session) => session.id) // if (!result.found) break // const info = input.store.session[result.index] // input.setStore( // "session", // produce((draft) => void draft.splice(result.index, 1)), // ) // cleanupSessionCaches(input.setStore, properties.sessionID) // if (!info?.parentID) input.setStore("sessionTotal", (value) => Math.max(0, value - 1)) // break // } case "session.moved": { const properties = event.properties as { sessionID: string location: { directory: string; workspaceID?: string } projectID?: string subpath?: string } const result = Binary.search(input.store.session, properties.sessionID, (session) => session.id) if (!result.found) break if (properties.location.directory === input.directory) { input.setStore("session", result.index, (session) => ({ ...session, projectID: properties.projectID ?? session.projectID, workspaceID: properties.location.workspaceID, directory: properties.location.directory, path: properties.subpath, time: { ...session.time, updated: Date.now() }, })) break } const info = input.store.session[result.index] input.setStore( "session", produce((draft) => void draft.splice(result.index, 1)), ) if (!info?.parentID) input.setStore("sessionTotal", (value) => Math.max(0, value - 1)) break } case "session.diff": { const props = event.properties as { sessionID: string; diff: FileDiffInfo[] } input.setStore("session_diff", props.sessionID, reconcile(list(props.diff) as FileDiffInfo[], { key: "file" })) break } case "todo.updated": { const props = event.properties as { sessionID: string; todos: Todo[] } input.setStore("todo", props.sessionID, reconcile(props.todos, { key: "id" })) input.setSessionTodo?.(props.sessionID, props.todos) break } case "session.status": { const props = event.properties as { sessionID: string; status: SessionStatus } input.setStore("session_status", props.sessionID, reconcile(props.status)) break } case "message.updated": { const info = clean((event.properties as { info: Message }).info) const messages = input.store.message[info.sessionID] if (!messages) { input.setStore("message", info.sessionID, [info]) break } const result = Binary.search(messages, messageKey(info), messageKey) if (result.found) { input.setStore("message", info.sessionID, result.index, reconcile(info)) break } input.setStore( "message", info.sessionID, produce((draft) => { draft.splice(result.index, 0, info) }), ) break } case "message.removed": { const props = event.properties as { sessionID: string; messageID: string } input.setStore( produce((draft) => { const messages = draft.message[props.sessionID] if (messages) { const index = messages.findIndex((message) => message.id === props.messageID) if (index >= 0) messages.splice(index, 1) } const parts = draft.part[props.messageID] if (parts) { for (const part of parts) { delete draft.part_text_accum_delta[part.id] } } delete draft.part[props.messageID] }), ) break } case "message.part.updated": { const part = (event.properties as { part: Part }).part if (SKIP_PARTS.has(part.type)) break input.setStore( produce((draft) => { delete draft.part_text_accum_delta[part.id] }), ) const parts = input.store.part[part.messageID] if (!parts) { input.setStore("part", part.messageID, [part]) break } const result = Binary.search(parts, part.id, (item) => item.id) if (result.found) { input.setStore("part", part.messageID, result.index, reconcile(part)) break } input.setStore( "part", part.messageID, produce((draft) => { draft.splice(result.index, 0, part) }), ) break } case "message.part.removed": { const props = event.properties as { messageID: string; partID: string } input.setStore( produce((draft) => { delete draft.part_text_accum_delta[props.partID] }), ) const parts = input.store.part[props.messageID] if (!parts) break const result = Binary.search(parts, props.partID, (part) => part.id) if (result.found) { input.setStore( produce((draft) => { const list = draft.part[props.messageID] if (!list) return const next = Binary.search(list, props.partID, (part) => part.id) if (!next.found) return list.splice(next.index, 1) if (list.length === 0) delete draft.part[props.messageID] }), ) } break } case "message.part.delta": { const props = event.properties as { messageID: string; partID: string; field: string; delta: string } const parts = input.store.part[props.messageID] if (!parts) break const result = Binary.search(parts, props.partID, (part) => part.id) if (!result.found) break const field = props.field as keyof (typeof parts)[number] const current = parts[result.index]?.[field] input.setStore( "part_text_accum_delta", props.partID, (existing) => (existing ?? (typeof current === "string" ? current : "")) + props.delta, ) input.setStore( "part", props.messageID, produce((draft) => { const part = draft[result.index] const field = props.field as keyof typeof part const existing = part[field] as string | undefined ;(part[field] as string) = (existing ?? "") + props.delta }), ) break } case "vcs.branch.updated": { const props = event.properties as { branch?: string } if (input.store.vcs?.branch === props.branch) break const next = { ...input.store.vcs, branch: props.branch } input.setStore("vcs", next) if (input.vcsCache) input.vcsCache.setStore("value", next) break } case "permission.asked": { const permission = event.properties as PermissionRequest const permissions = input.store.permission[permission.sessionID] if (!permissions) { input.setStore("permission", permission.sessionID, [permission]) break } const result = Binary.search(permissions, permission.id, (p) => p.id) if (result.found) { input.setStore("permission", permission.sessionID, result.index, reconcile(permission)) break } input.setStore( "permission", permission.sessionID, produce((draft) => { draft.splice(result.index, 0, permission) }), ) break } case "permission.replied": { const props = event.properties as { sessionID: string; requestID: string } const permissions = input.store.permission[props.sessionID] if (!permissions) break const result = Binary.search(permissions, props.requestID, (p) => p.id) if (!result.found) break input.setStore( "permission", props.sessionID, produce((draft) => { draft.splice(result.index, 1) }), ) break } case "question.asked": { const question = event.properties as QuestionRequest const questions = input.store.question[question.sessionID] if (!questions) { input.setStore("question", question.sessionID, [question]) break } const result = Binary.search(questions, question.id, (q) => q.id) if (result.found) { input.setStore("question", question.sessionID, result.index, reconcile(question)) break } input.setStore( "question", question.sessionID, produce((draft) => { draft.splice(result.index, 0, question) }), ) break } case "question.replied": case "question.rejected": { const props = event.properties as { sessionID: string; requestID: string } const questions = input.store.question[props.sessionID] if (!questions) break const result = Binary.search(questions, props.requestID, (q) => q.id) if (!result.found) break input.setStore( "question", props.sessionID, produce((draft) => { draft.splice(result.index, 1) }), ) break } case "lsp.updated": { input.loadLsp() break } case "reference.updated": { input.loadReferences?.() break } } }