/** * Session actions — SDK-calling operations for session management. * Replaces the action methods from the old useSessionStore. */ import type { OpencodeClient, Session, Message, Part } from "@opencode-ai/sdk/v2/client" import { Binary } from "./binary" import { useSessionUIStore } from "./session-ui-store" import { useInputStore } from "./input-store" import type { ChildStoreManager } from "./child-store" import { opencodeClient } from "@/lib/opencode/client" import { useGlobalSessionsStore } from "@/stores/useGlobalSessionsStore" import { useConfigStore } from "@/stores/useConfigStore" import { registerSessionDirectory } from "./sync-refs" // Reference set by SyncProvider — allows actions to access SDK and stores let _sdk: OpencodeClient | null = null let _childStores: ChildStoreManager | null = null let _getDirectory: () => string = () => "" let _optimisticAdd: ((input: { sessionID: string; message: Message; parts: Part[] }) => void) | null = null let _optimisticRemove: ((input: { sessionID: string; messageID: string }) => void) | null = null export function setActionRefs( sdk: OpencodeClient, childStores: ChildStoreManager, getDirectory: () => string, ) { _sdk = sdk _childStores = childStores _getDirectory = getDirectory } export function setOptimisticRefs( add: (input: { sessionID: string; message: Message; parts: Part[] }) => void, remove: (input: { sessionID: string; messageID: string }) => void, ) { _optimisticAdd = add _optimisticRemove = remove } function sdk() { if (!_sdk) throw new Error("SDK not initialized — is SyncProvider mounted?") return _sdk } function dirStore() { if (!_childStores) throw new Error("Child stores not initialized") const d = _getDirectory() if (!d) throw new Error("No current directory") return _childStores.ensureChild(d) } function dir() { return _getDirectory() || undefined } function connectionLostError(): Error { const reason = useConfigStore.getState().lastDisconnectReason const suffix = reason ? ` (${reason})` : " (never connected)" return new Error(`Connection lost${suffix}. Please wait for reconnection.`) } function getSessionDirectory(sessionId: string): string | undefined { return useSessionUIStore.getState().getDirectoryForSession(sessionId) || dir() } function getDirectoryStore(directory?: string) { if (!_childStores) throw new Error("Child stores not initialized") const resolvedDirectory = directory || _getDirectory() if (!resolvedDirectory) throw new Error("No current directory") return _childStores.ensureChild(resolvedDirectory) } function getSessionReplyClient(sessionId?: string): OpencodeClient { const directory = sessionId ? useSessionUIStore.getState().getDirectoryForSession(sessionId) : null if (directory) { return opencodeClient.getScopedSdkClient(directory) } return sdk() } function resolveDirectoryForBlockingRequest( type: "permission" | "question", sessionId: string, requestId: string, ): string | null { const stores = _childStores if (!stores || !requestId) { return null } for (const [directory, store] of stores.children) { const state = store.getState() const requestMap = type === "permission" ? state.permission : state.question for (const requests of Object.values(requestMap) as Array | undefined>) { if (requests?.some((request) => request.id === requestId)) { return directory } } } const sessionDirectory = useSessionUIStore.getState().getDirectoryForSession(sessionId) if (sessionDirectory) { return sessionDirectory } for (const [directory, store] of stores.children) { const state = store.getState() if ( state.session.some((session) => session.id === sessionId) || Object.prototype.hasOwnProperty.call(state.message, sessionId) || Object.prototype.hasOwnProperty.call(state.session_status ?? {}, sessionId) || Object.prototype.hasOwnProperty.call(state.permission ?? {}, sessionId) || Object.prototype.hasOwnProperty.call(state.question ?? {}, sessionId) ) { return directory } } return null } function getRequestReplyClient( type: "permission" | "question", sessionId: string, requestId: string, ): OpencodeClient { const requestDirectory = resolveDirectoryForBlockingRequest(type, sessionId, requestId) if (requestDirectory) { return opencodeClient.getScopedSdkClient(requestDirectory) } return getSessionReplyClient(sessionId) } // --------------------------------------------------------------------------- // Session CRUD // --------------------------------------------------------------------------- export async function createSession( title?: string, directoryOverride?: string | null, parentID?: string | null, ): Promise { try { const result = await sdk().session.create({ directory: directoryOverride ?? dir(), title, parentID: parentID ?? undefined, }) const session = result.data if (!session) return null const sessionDirectory = (session as { directory?: string }).directory ?? directoryOverride ?? null // Pre-populate routing index so SSE events arriving before session.created // can be routed to the correct child store if (sessionDirectory) { registerSessionDirectory(session.id, sessionDirectory) } useSessionUIStore.getState().setCurrentSession(session.id, sessionDirectory) useSessionUIStore.getState().markSessionAsOpenChamberCreated(session.id) useGlobalSessionsStore.getState().upsertSession(session) return session } catch (error) { console.error("[session-actions] createSession failed", error) return null } } /** Optimistically remove a session from the child store list. Returns previous list for rollback. */ function optimisticRemoveSession(sessionId: string, directory?: string): Session[] | null { const store = getDirectoryStore(directory) const current = store.getState() const sessions = [...current.session] const result = Binary.search(sessions, sessionId, (s) => s.id) if (result.found) { const snapshot = current.session sessions.splice(result.index, 1) store.setState({ session: sessions }) return snapshot } return null } // eslint-disable-next-line @typescript-eslint/no-unused-vars export async function deleteSession(sessionId: string, _options?: Record): Promise { const sessionDirectory = getSessionDirectory(sessionId) // Remove from UI immediately, rollback on error const snapshot = optimisticRemoveSession(sessionId, sessionDirectory) const ui = useSessionUIStore.getState() if (ui.currentSessionId === sessionId) { ui.setCurrentSession(null) } try { await sdk().session.delete({ sessionID: sessionId, directory: sessionDirectory }) useGlobalSessionsStore.getState().removeSessions([sessionId]) return true } catch (error) { console.error("[session-actions] deleteSession failed", error) if (snapshot) getDirectoryStore(sessionDirectory).setState({ session: snapshot }) return false } } /** Delete a session specifying which directory it lives in. Used by agent groups for cross-directory deletes. */ export async function deleteSessionInDirectory(sessionId: string, directory: string): Promise { if (!_childStores) return false const store = _childStores.ensureChild(directory) const current = store.getState() const sessions = [...current.session] const result = Binary.search(sessions, sessionId, (s) => s.id) let snapshot: Session[] | null = null if (result.found) { snapshot = current.session sessions.splice(result.index, 1) store.setState({ session: sessions }) } const ui = useSessionUIStore.getState() if (ui.currentSessionId === sessionId) ui.setCurrentSession(null) try { await sdk().session.delete({ sessionID: sessionId, directory }) useGlobalSessionsStore.getState().removeSessions([sessionId]) return true } catch (error) { console.error("[session-actions] deleteSessionInDirectory failed", error) if (snapshot) store.setState({ session: snapshot }) return false } } export async function archiveSession(sessionId: string): Promise { const sessionDirectory = getSessionDirectory(sessionId) const snapshot = optimisticRemoveSession(sessionId, sessionDirectory) const ui = useSessionUIStore.getState() if (ui.currentSessionId === sessionId) { ui.setCurrentSession(null) } try { const archivedAt = Date.now() await sdk().session.update({ sessionID: sessionId, directory: sessionDirectory, time: { archived: archivedAt } }) useGlobalSessionsStore.getState().archiveSessions([sessionId], archivedAt) return true } catch (error) { console.error("[session-actions] archiveSession failed", error) if (snapshot) getDirectoryStore(sessionDirectory).setState({ session: snapshot }) return false } } export async function updateSessionTitle(sessionId: string, title: string): Promise { const sessionDirectory = getSessionDirectory(sessionId) const result = await sdk().session.update({ sessionID: sessionId, directory: sessionDirectory, title }) if (result.data) { useGlobalSessionsStore.getState().upsertSession(result.data) } } export async function shareSession(sessionId: string): Promise { const sessionDirectory = getSessionDirectory(sessionId) const result = await sdk().session.share({ sessionID: sessionId, directory: sessionDirectory }) if (result.data) { useGlobalSessionsStore.getState().upsertSession(result.data) } return result.data ?? null } export async function unshareSession(sessionId: string): Promise { const sessionDirectory = getSessionDirectory(sessionId) const result = await sdk().session.unshare({ sessionID: sessionId, directory: sessionDirectory }) if (result.data) { useGlobalSessionsStore.getState().upsertSession(result.data) } return result.data ?? null } // --------------------------------------------------------------------------- // Optimistic message send — insert user message before API call, rollback on error // --------------------------------------------------------------------------- // ID generator matching OpenCode's Identifier.ascending format. // Uses BigInt(timestamp) * 0x1000 + counter, encoded as 6 hex bytes + random base62. // This ensures client-generated IDs sort correctly with server-generated ones. let lastIdTimestamp = 0 let idCounter = 0 function ascendingId(prefix: string): string { const now = Date.now() if (now !== lastIdTimestamp) { lastIdTimestamp = now idCounter = 0 } idCounter += 1 const value = BigInt(now) * BigInt(0x1000) + BigInt(idCounter) const bytes = new Uint8Array(6) for (let i = 0; i < 6; i++) { bytes[i] = Number((value >> BigInt(40 - 8 * i)) & BigInt(0xff)) } let hex = "" for (let i = 0; i < bytes.length; i++) { hex += bytes[i].toString(16).padStart(2, "0") } const chars = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz" let rand = "" for (let i = 0; i < 14; i++) { rand += chars[Math.floor(Math.random() * 62)] } return `${prefix}_${hex}${rand}` } /** * Wraps an async send operation with optimistic user-message insertion. * Uses useSync()'s optimistic infrastructure — message + parts are inserted * into the store AND registered in the shadow Map. mergeOptimisticPage * handles deduplication when the server echoes back the real message. */ export async function optimisticSend(input: { sessionId: string content: string providerID: string modelID: string agent?: string files?: Array<{ type: "file"; mime: string; url: string; filename: string }> /** The actual API call — receives the optimistic messageID so the server can use the same ID */ send: (messageID: string) => Promise }): Promise { if (!_optimisticAdd || !_optimisticRemove) { throw new Error("Optimistic refs not set — is useSync() mounted?") } if (!useConfigStore.getState().isConnected) { throw connectionLostError() } const store = dirStore() const messageID = ascendingId("msg") const textPartId = ascendingId("prt") const optimisticParts: Part[] = [ { id: textPartId, type: "text", text: input.content } as Part, ] if (input.files) { for (const f of input.files) { optimisticParts.push({ id: ascendingId("prt"), type: "file", mime: f.mime, url: f.url, filename: f.filename } as Part) } } const optimisticMessage = { id: messageID, role: "user" as const, sessionID: input.sessionId, parentID: "", modelID: input.modelID, providerID: input.providerID, system: "", agent: input.agent ?? "", model: `${input.providerID}/${input.modelID}`, metadata: {} as Record, time: { created: Date.now(), completed: 0 }, } as unknown as Message // Insert into store + register in shadow Map (for mergeOptimisticPage cleanup) _optimisticAdd({ sessionID: input.sessionId, message: optimisticMessage, parts: optimisticParts, }) // Set busy status const current = store.getState() store.setState({ session_status: { ...current.session_status, [input.sessionId]: { type: "busy" as const }, }, }) try { await input.send(messageID) } catch (error) { // Rollback via optimistic infrastructure _optimisticRemove({ sessionID: input.sessionId, messageID, }) const s = store.getState() store.setState({ session_status: { ...s.session_status, [input.sessionId]: { type: "idle" as const }, }, }) throw error } } // --------------------------------------------------------------------------- // Abort // --------------------------------------------------------------------------- export async function abortCurrentOperation(sessionId: string): Promise { try { await sdk().session.abort({ sessionID: sessionId, directory: dir() }) } catch (error) { console.error("[session-actions] abort failed", error) } } // --------------------------------------------------------------------------- // Permissions // --------------------------------------------------------------------------- export async function respondToPermission( sessionId: string, requestId: string, response: "once" | "always" | "reject", ): Promise { if (!useConfigStore.getState().isConnected) { throw connectionLostError() } const result = await getRequestReplyClient("permission", sessionId, requestId).permission.reply({ requestID: requestId, reply: response, }) if (!result.data) { throw new Error("Permission reply failed") } } export async function dismissPermission( sessionId: string, requestId: string, ): Promise { if (!useConfigStore.getState().isConnected) { throw connectionLostError() } const result = await getRequestReplyClient("permission", sessionId, requestId).permission.reply({ requestID: requestId, reply: "reject", }) if (!result.data) { throw new Error("Permission dismissal failed") } } // --------------------------------------------------------------------------- // Questions // --------------------------------------------------------------------------- export async function respondToQuestion( sessionId: string, requestId: string, answers: string[] | string[][], ): Promise { if (!useConfigStore.getState().isConnected) { throw connectionLostError() } const result = await getRequestReplyClient("question", sessionId, requestId).question.reply({ requestID: requestId, answers: answers as Array>, }) if (!result.data) { throw new Error("Question reply failed") } } export async function rejectQuestion( sessionId: string, requestId: string, ): Promise { if (!useConfigStore.getState().isConnected) { throw connectionLostError() } const result = await getRequestReplyClient("question", sessionId, requestId).question.reject({ requestID: requestId, }) if (!result.data) { throw new Error("Question rejection failed") } } // --------------------------------------------------------------------------- // Message history // --------------------------------------------------------------------------- /** * Revert to a specific user message. * * 1. Abort if session is busy * 2. Extract text from the target message for prompt restoration * 3. Optimistically set revert marker so messages hide immediately * 4. Call SDK session.revert() and merge returned session * 5. Set pendingInputText so the reverted message text appears in the input */ export async function revertToMessage(sessionId: string, messageId: string): Promise { const store = dirStore() const state = store.getState() // Abort if busy before mutating session state const status = state.session_status[sessionId] if (status && status.type !== "idle") { try { await sdk().session.abort({ sessionID: sessionId, directory: dir() }) } catch { // ignore abort errors } } // Extract message text for prompt restoration const messages = state.message[sessionId] ?? [] const targetMsg = messages.find((m) => m.id === messageId) let messageText = "" if (targetMsg && targetMsg.role === "user") { const parts = state.part[messageId] ?? [] const textParts = parts.filter((p) => p.type === "text") messageText = textParts .map((p: Record) => (p as { text?: string }).text || (p as { content?: string }).content || "") .join("\n") .trim() } // Optimistically remove reverted messages + set marker const prevRevert = (() => { const s = state.session.find((s) => s.id === sessionId) return (s as Session & { revert?: unknown })?.revert })() const sessions = [...state.session] const sessionIdx = sessions.findIndex((s) => s.id === sessionId) // Remove messages at and after the revert point from the store const prevMessages = state.message[sessionId] ?? [] const prevPart = { ...state.part } const keptMessages = prevMessages.filter((m) => m.id < messageId) const removedMessages = prevMessages.filter((m) => m.id >= messageId) for (const m of removedMessages) { delete prevPart[m.id] } const patch: Record = { message: { ...state.message, [sessionId]: keptMessages }, part: prevPart, } if (sessionIdx >= 0) { sessions[sessionIdx] = { ...sessions[sessionIdx], revert: { messageID: messageId } } as Session patch.session = sessions } store.setState(patch) // Restore reverted message text to input if (messageText) { useInputStore.setState({ pendingInputText: messageText, pendingInputMode: "replace" as const, }) } // Call SDK and merge authoritative result into store try { const result = await sdk().session.revert({ sessionID: sessionId, directory: dir(), messageID: messageId }) if (result.data) { const current = store.getState() const updated = [...current.session] const idx = updated.findIndex((s) => s.id === sessionId) if (idx >= 0) { updated[idx] = result.data store.setState({ session: updated }) } } } catch (err) { // Rollback: restore removed messages + revert marker const current = store.getState() const rollback = [...current.session] const idx = rollback.findIndex((s) => s.id === sessionId) if (idx >= 0) { rollback[idx] = { ...rollback[idx], revert: prevRevert } as Session } store.setState({ session: rollback, message: { ...current.message, [sessionId]: prevMessages }, part: { ...current.part, ...Object.fromEntries(removedMessages.map((m) => [m.id, state.part[m.id] ?? []])) }, }) throw err } } /** * Unrevert — restore all previously reverted messages. * Restore all previously reverted messages. Aborts if busy, merges result. */ export async function unrevertSession(sessionId: string): Promise { const store = dirStore() const state = store.getState() // Abort if busy const status = state.session_status[sessionId] if (status && status.type !== "idle") { try { await sdk().session.abort({ sessionID: sessionId, directory: dir() }) } catch { // ignore } } const result = await sdk().session.unrevert({ sessionID: sessionId, directory: dir() }) if (result.data) { const current = store.getState() const sessions = [...current.session] const idx = sessions.findIndex((s) => s.id === sessionId) if (idx >= 0) { sessions[idx] = result.data store.setState({ session: sessions }) } } } /** * Fork from a user message. * * 1. Extract text from the message for input restoration * 2. Call SDK session.fork() * 3. Insert the new session into the child store (so sidebar updates immediately) * 4. Switch to new session and set pending input text */ export async function forkFromMessage(sessionId: string, messageId: string): Promise { const store = dirStore() const state = store.getState() // Extract message text for input restoration const parts = state.part[messageId] ?? [] let messageText = "" const textParts = parts.filter((p) => p.type === "text") messageText = textParts .map((p: Part) => ((p as Record).text as string) || ((p as Record).content as string) || "") .join("\n") .trim() const result = await sdk().session.fork({ sessionID: sessionId, directory: dir(), messageID: messageId }) if (!result.data) return const forkedSession = result.data // Insert new session into child store so sidebar updates immediately const current = store.getState() const sessions = [...current.session] const searchResult = Binary.search(sessions, forkedSession.id, (s) => s.id) if (!searchResult.found) { sessions.splice(searchResult.index, 0, forkedSession) store.setState({ session: sessions }) } // Switch to new session useSessionUIStore.getState().setCurrentSession(forkedSession.id) // Restore forked message text to input if (messageText) { useInputStore.setState({ pendingInputText: messageText, pendingInputMode: "replace" as const, }) } }