Files
openchamber/packages/ui/src/sync/session-actions.ts
T
Bohdan Triapitsyn 28f0736d69 feat: small-model utility calls on existing OpenCode providers (#2049)
Adds a server-side "small model" capability: direct, cheap LLM calls that
reuse the user's existing OpenCode provider logins — the mechanism OpenCode
uses internally for titles and summaries but does not expose through the
SDK or plugins. Zero new dependencies; plain fetch with per-provider wire
formats, credentials never leave the server.

Core (packages/web/server/lib/small-model):
- Resolution mirrors OpenCode's session scoping: explicit settings override
  → small_model from the OpenCode config → family scan within the session's
  provider → the session's own model. The global provider scan only serves
  callers without a session context, and background callers forbid it
  entirely (restrictToPreferredProvider), so conversation content never
  reaches a provider the user didn't pick — explicit choices excepted.
- Per-provider auth replicating OpenCode's plugin loaders: GitHub Copilot
  (device token as bearer, no exchange), ChatGPT plan via the codex
  Responses API (single-flight OAuth refresh written back to auth.json),
  Anthropic messages, Google generateContent, generic OpenAI-compatible.
- OpenCode's free models (opencode/big-pickle, *-free) are never called
  directly; unauthenticated providers are skipped by design.
- Prompt clamping to the model's catalog context limit; thinking disabled
  where a wire switch exists (Z.AI/GLM, MiniMax-M3, Gemini Flash); robust
  content parsing with a clear error when a thinking model spends its whol
  budget on reasoning.
- Settings → Sessions gains a Small Model group: use-default checkbox plus
  an override picker limited to authenticated providers, persisted with
  web/desktop/VS Code sanitization parity.

Consumers:
- Session assist: a server-side watcher on the global SSE hub generates a
  short recap and one suggested follow-up after a session idles quietly fo
  a minute, stored on session metadata (openchamber.assist). Freshness is
  keyed to the last assistant message id, so new activity invalidates the
  payload everywhere with no extra writes. The chat shows the recap under
  the last message after five quiet minutes and the suggestion as a
  dismissible chip above the composer (tap fills the input, never sends).
  Gated by a new Chat setting (default on) that is a hard generation
  switch. Language is anchored to the conversation itself, with a
  script-mismatch guard against model/backend language hallucination.
- TTS: a third input mode, summarized — long replies are condensed to
  spoken prose before playback on any TTS engine.
- Git: commit-message and PR generation moved off the active chat session
  onto the small model fed with real diffs and the commit list (bodies
  included), with a session-transport fallback for free-model-only setups.
- Notes: Add to notes distills long selections into 1-3 dense sentences
  preserving exact identifiers, with verbatim fallback on failure.

Fixes along the way:
- The global event watcher now starts unconditionally; it was gated behind
  the desktop-notify env, leaving the server-side event hub dead in
  packaged apps.
- OpenCode re-emits message.updated for old user messages after idle; the
  watcher no longer mistakes those for new activity.
- Session metadata merges from a fresh read right before the PATCH, so
  writes made during the generation window (suggestion dismissals, review
  links) are preserved; the assist runtime stops during graceful shutdown.
2026-07-05 23:19:10 +03:00

1224 lines
44 KiB
TypeScript

/**
* 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 { computeSubtreeIds } from "./scoped-blocking-requests"
import { opencodeClient } from "@/lib/opencode/client"
import { mergeSessionDirectoryMetadata, useGlobalSessionsStore } from "@/stores/useGlobalSessionsStore"
import { useConfigStore } from "@/stores/useConfigStore"
import { registerSessionDirectory } from "./sync-refs"
import { isSyntheticPart } from "@/lib/messages/synthetic"
import { getSessionMaterializationStatus, materializeSessionSnapshots } from "./materialization"
import { retry } from "./retry"
import { isVSCodeRuntime } from "@/lib/desktop"
import { isMobileSurfaceRuntime } from "@/lib/runtimeSurface"
import { stripMessageDiffSnapshots, stripSessionDiffSnapshots } from "./sanitize"
import { sessionEvents } from "@/lib/sessionEvents"
import {
getOriginalSessionID,
getSessionMetadata,
isReviewSession,
withoutReviewSessionLink,
type SessionMetadataRecord,
} from "@/lib/sessionReviewMetadata"
const MESSAGE_REFETCH_LIMIT = 100
const MESSAGE_REFETCH_SKIP_PARTS = new Set(["patch", "step-start", "step-finish"])
const UNREVERT_REFETCH_ATTEMPTS = 3
const UNREVERT_REFETCH_RETRY_MS = 150
// Reference set by SyncProvider — allows actions to access SDK and stores
let _sdk: OpencodeClient | null = null
let _childStores: ChildStoreManager | null = null
let _getDirectory: () => string = () => ""
type OptimisticAddInput = { sessionID: string; directory?: string | null; message: Message; parts: Part[] }
type OptimisticRemoveInput = { sessionID: string; directory?: string | null; messageID: string }
let _optimisticAdd: ((input: OptimisticAddInput) => void) | null = null
let _optimisticRemove: ((input: OptimisticRemoveInput) => void) | null = null
const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms))
type SdkResult<T> = {
data?: T
error?: unknown
response?: { status?: number }
}
function formatSdkError(error: unknown): string {
if (error instanceof Error) return error.message
if (typeof error === "string") return error
if (error && typeof error === "object") {
const message = (error as { message?: unknown }).message
if (typeof message === "string" && message.length > 0) return message
const data = (error as { data?: unknown }).data
if (data && typeof data === "object") {
const dataMessage = (data as { message?: unknown }).message
if (typeof dataMessage === "string" && dataMessage.length > 0) return dataMessage
}
}
try {
return JSON.stringify(error)
} catch {
return String(error)
}
}
function assertSdkSuccess<T>(result: SdkResult<T>, operation: string): T | undefined {
if (!result.error) return result.data
const status = result.response?.status
const error = new Error(`${operation} failed${status ? ` (${status})` : ""}: ${formatSdkError(result.error)}`) as Error & { status?: number }
if (status !== undefined) error.status = status
throw error
}
function assertSdkData<T>(result: SdkResult<T>, operation: string): T {
const data = assertSdkSuccess(result, operation)
if (data === undefined || data === null) {
throw new Error(`${operation} failed: empty response`)
}
return data
}
export function setActionRefs(
sdk: OpencodeClient,
childStores: ChildStoreManager,
getDirectory: () => string,
) {
_sdk = sdk
_childStores = childStores
_getDirectory = getDirectory
}
export function setOptimisticRefs(
add: (input: OptimisticAddInput) => void,
remove: (input: OptimisticRemoveInput) => 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 dirStoreForDirectory(directory: string) {
if (!_childStores) throw new Error("Child stores not initialized")
if (!directory) throw new Error("No directory")
return _childStores.ensureChild(directory)
}
function dirStoreForSession(sessionId: string): { store: DirectoryStoreApi; directory?: string } {
const directory = getSessionDirectory(sessionId)
if (directory) {
return { store: dirStoreForDirectory(directory), directory }
}
return { store: dirStore(), directory: dir() }
}
/**
* Provider/model of the session's last assistant message — the authoritative
* "session provider" for utility calls (notes distillation etc.), independent
* of what the composer picker currently points at.
*/
export function getSessionLastAssistantModel(sessionId: string): { providerID: string; modelID: string } | null {
try {
const { store } = dirStoreForSession(sessionId)
const messages = store.getState().message[sessionId]
if (!messages) return null
for (let i = messages.length - 1; i >= 0; i -= 1) {
const info = messages[i] as { role?: string; providerID?: string; modelID?: string }
if (info?.role === "assistant" && typeof info.providerID === "string" && info.providerID
&& typeof info.modelID === "string" && info.modelID) {
return { providerID: info.providerID, modelID: info.modelID }
}
}
return null
} catch {
return null
}
}
function updateLiveSession(session: Session, directory?: string): void {
const stores = _childStores
if (!stores) return
const candidates = directory
? [[directory, stores.getChild(directory)] as const]
: stores.children
for (const [, store] of candidates) {
if (!store) continue
const current = store.getState().session
const index = current.findIndex((item) => item.id === session.id)
if (index === -1) continue
const next = [...current]
next[index] = mergeSessionDirectoryMetadata(session, current[index])
store.setState({ session: next })
return
}
}
function dir() {
return _getDirectory() || undefined
}
function connectionLostError(): Error {
const { hasEverConnected, lastDisconnectReason } = useConfigStore.getState()
const suffix = lastDisconnectReason
? ` (${lastDisconnectReason})`
: hasEverConnected
? ""
: " (never connected)"
return new Error(`Connection lost${suffix}. Please wait for reconnection.`)
}
// Wait briefly for the pipeline to re-establish connection before failing a
// send. Transient reconnects (heartbeat race, WS→SSE fallback, brief network
// blip) otherwise surface as a hard "Connection lost" toast even though the
// pipeline recovers within a second. While waiting, run bounded health probes
// inside the same grace window so stale disconnected state can recover quickly.
const CONNECTION_GRACE_MS = 2000
export async function waitForConnectionOrThrow(): Promise<void> {
const deadline = Date.now() + CONNECTION_GRACE_MS
while (Date.now() < deadline) {
if (useConfigStore.getState().isConnected) return
const remainingMs = deadline - Date.now()
if (remainingMs <= 0) break
if (await useConfigStore.getState().probeConnection({ timeoutMs: Math.min(500, remainingMs) })) return
const sleepMs = Math.min(100, deadline - Date.now())
if (sleepMs > 0) {
await new Promise((resolve) => setTimeout(resolve, sleepMs))
}
}
throw connectionLostError()
}
type SessionListSnapshot = {
directory: string
sessions: Session[]
}
type DirectoryStoreApi = ReturnType<ChildStoreManager["ensureChild"]>
function getGlobalSessionSnapshot(sessionId: string): Session | null {
const global = useGlobalSessionsStore.getState()
return [...global.activeSessions, ...global.archivedSessions].find((session) => session.id === sessionId) ?? null
}
function restoreGlobalSessionSnapshot(session: Session | null): void {
if (!session) return
useGlobalSessionsStore.getState().upsertSession(session)
}
function getSessionDirectory(sessionId: string): string | undefined {
return findSessionDirectoryInChildStores(sessionId)
|| useSessionUIStore.getState().getDirectoryForSession(sessionId)
|| dir()
}
function findSessionDirectoryInChildStores(sessionId: string): string | null {
const stores = _childStores
if (!stores || !sessionId) return null
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 getSessionReplyClient(sessionId?: string): OpencodeClient {
const directory = sessionId
? useSessionUIStore.getState().getDirectoryForSession(sessionId)
: null
if (directory) {
return opencodeClient.getScopedSdkClient(directory)
}
return sdk()
}
function restoreFilePartsToInput(fileParts: Array<Record<string, unknown>>): void {
useInputStore.getState().clearAttachedFiles()
for (const filePart of fileParts) {
const url = typeof filePart.url === "string" ? filePart.url : ""
const mime = typeof filePart.mime === "string" ? filePart.mime : "application/octet-stream"
const filename = typeof filePart.filename === "string" ? filePart.filename : "attachment"
if (url) {
useInputStore.getState().addRestoredAttachment({ url, mimeType: mime, filename })
}
}
}
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<Array<{ id: string }> | 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
}
export function isQuestionRequestNotFoundError(error: unknown): boolean {
if (error && typeof error === "object") {
const status = (error as { status?: unknown }).status
if (status === 404) return true
}
let message = ""
if (error instanceof Error) {
message = error.message
} else if (typeof error === "string") {
message = error
}
return /Question(?:\.)?NotFoundError|Question request not found/i.test(message)
}
function removeQuestionRequestFromChildStores(sessionId: string, requestId: string): boolean {
const stores = _childStores
if (!stores || !requestId) return false
let removed = false
for (const [, store] of stores.children) {
const current = store.getState().question ?? {}
let nextQuestion: typeof current | null = null
const sessionIds = new Set([sessionId, ...Object.keys(current)].filter(Boolean))
for (const candidateSessionId of sessionIds) {
const requests = current[candidateSessionId]
if (!requests?.length) continue
const nextRequests = requests.filter((request) => request.id !== requestId)
if (nextRequests.length === requests.length) continue
nextQuestion ??= { ...current }
if (nextRequests.length > 0) {
nextQuestion[candidateSessionId] = nextRequests
} else {
delete nextQuestion[candidateSessionId]
}
removed = true
}
if (nextQuestion) {
store.setState({ question: nextQuestion })
}
}
return removed
}
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,
metadata?: Record<string, unknown>,
): Promise<Session | null> {
try {
const session = await opencodeClient.createSession({
title,
parentID: parentID ?? undefined,
metadata,
}, directoryOverride ?? dir())
const sessionDirectory = (session as { directory?: string | null }).directory ?? 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
}
}
export async function patchSessionMetadata(
sessionId: string,
directory: string | null | undefined,
updater: (metadata: SessionMetadataRecord) => SessionMetadataRecord,
): Promise<Session> {
const targetDirectory = directory ?? getSessionDirectory(sessionId)
const current = await opencodeClient.getSession(sessionId, targetDirectory)
const nextMetadata = updater(getSessionMetadata(current))
const updated = await opencodeClient.updateSession(sessionId, { metadata: nextMetadata }, targetDirectory)
useGlobalSessionsStore.getState().upsertSession(updated)
const sessionDirectory = (updated as { directory?: string | null }).directory ?? targetDirectory
if (sessionDirectory) registerSessionDirectory(updated.id, sessionDirectory)
return updated
}
async function cleanupReviewMetadataBeforeDelete(sessionId: string, directory?: string | null): Promise<void> {
let session: Session
try {
session = await opencodeClient.getSession(sessionId, directory ?? getSessionDirectory(sessionId))
} catch {
return
}
if (!isReviewSession(session)) return
const originalSessionID = getOriginalSessionID(session)
if (!originalSessionID) return
try {
await patchSessionMetadata(originalSessionID, directory ?? getSessionDirectory(originalSessionID), (metadata) =>
withoutReviewSessionLink(metadata, sessionId),
)
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
if (/not found/i.test(message)) return
console.warn("[session-actions] review metadata cleanup failed before delete", error)
}
}
/** Optimistically remove a session from every live child store that has it. */
function optimisticRemoveSession(sessionId: string, preferredDirectory?: string): SessionListSnapshot[] {
if (!_childStores) return []
const snapshots: SessionListSnapshot[] = []
const visited = new Set<string>()
const candidates: Array<[string, DirectoryStoreApi]> = []
if (preferredDirectory) {
const preferredStore = _childStores.children.get(preferredDirectory)
if (preferredStore) {
candidates.push([preferredDirectory, preferredStore])
visited.add(preferredDirectory)
}
}
for (const entry of _childStores.children.entries()) {
if (visited.has(entry[0])) continue
candidates.push(entry)
}
for (const [directory, store] of candidates) {
const current = store.getState()
if (!current.session.some((session) => session.id === sessionId)) {
continue
}
snapshots.push({ directory, sessions: current.session })
store.setState({ session: current.session.filter((session) => session.id !== sessionId) })
}
return snapshots
}
function restoreSessionListSnapshots(snapshots: SessionListSnapshot[]): void {
if (!_childStores) return
for (const snapshot of snapshots) {
const store = _childStores.children.get(snapshot.directory)
if (!store) continue
store.setState({ session: snapshot.sessions })
}
}
function cleanupSessionWorktreeMetadata(sessionId: string): void {
useSessionUIStore.getState().setWorktreeMetadata(sessionId, null)
}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
export async function deleteSession(sessionId: string, _options?: Record<string, unknown>): Promise<boolean> {
const sessionDirectory = getSessionDirectory(sessionId)
const snapshots = optimisticRemoveSession(sessionId, sessionDirectory)
const globalSnapshot = getGlobalSessionSnapshot(sessionId)
useGlobalSessionsStore.getState().removeSessions([sessionId])
const ui = useSessionUIStore.getState()
if (ui.currentSessionId === sessionId) {
ui.setCurrentSession(null)
}
try {
await cleanupReviewMetadataBeforeDelete(sessionId, sessionDirectory)
const deleted = await opencodeClient.deleteSession(sessionId, sessionDirectory)
if (deleted !== true) {
throw new Error("session.delete failed: server did not confirm deletion")
}
useGlobalSessionsStore.getState().removeSessions([sessionId])
cleanupSessionWorktreeMetadata(sessionId)
return true
} catch (error) {
console.error("[session-actions] deleteSession failed", error)
// The server cascade-deletes child sessions when the parent is removed.
// Subsequent delete attempts for those children return 404; treat as
// success since the session was already deleted by the cascade.
if ((error as { status?: number })?.status === 404) {
cleanupSessionWorktreeMetadata(sessionId)
return true
}
restoreSessionListSnapshots(snapshots)
restoreGlobalSessionSnapshot(globalSnapshot)
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<boolean> {
if (!_childStores) return false
const snapshots = optimisticRemoveSession(sessionId, directory)
const globalSnapshot = getGlobalSessionSnapshot(sessionId)
useGlobalSessionsStore.getState().removeSessions([sessionId])
const ui = useSessionUIStore.getState()
if (ui.currentSessionId === sessionId) ui.setCurrentSession(null)
try {
await cleanupReviewMetadataBeforeDelete(sessionId, directory)
const deleted = await opencodeClient.deleteSession(sessionId, directory)
if (deleted !== true) {
throw new Error("session.delete failed: server did not confirm deletion")
}
useGlobalSessionsStore.getState().removeSessions([sessionId])
cleanupSessionWorktreeMetadata(sessionId)
return true
} catch (error) {
console.error("[session-actions] deleteSessionInDirectory failed", error)
if ((error as { status?: number })?.status === 404) {
cleanupSessionWorktreeMetadata(sessionId)
return true
}
restoreSessionListSnapshots(snapshots)
restoreGlobalSessionSnapshot(globalSnapshot)
return false
}
}
export async function archiveSession(sessionId: string): Promise<boolean> {
const sessionDirectory = getSessionDirectory(sessionId)
const snapshots = optimisticRemoveSession(sessionId, sessionDirectory)
const globalSnapshot = getGlobalSessionSnapshot(sessionId)
const archivedAt = Date.now()
useGlobalSessionsStore.getState().archiveSessions([sessionId], archivedAt)
const ui = useSessionUIStore.getState()
if (ui.currentSessionId === sessionId) {
ui.setCurrentSession(null)
}
try {
await cleanupReviewMetadataBeforeDelete(sessionId, sessionDirectory)
const archived = await opencodeClient.updateSession(sessionId, { time: { archived: archivedAt } }, sessionDirectory)
if (!archived) {
throw new Error("session.update failed: server did not return the archived session")
}
useGlobalSessionsStore.getState().upsertSession(archived)
return true
} catch (error) {
console.error("[session-actions] archiveSession failed", error)
restoreSessionListSnapshots(snapshots)
restoreGlobalSessionSnapshot(globalSnapshot)
return false
}
}
export async function updateSessionTitle(sessionId: string, title: string): Promise<void> {
const sessionDirectory = getSessionDirectory(sessionId)
const session = await opencodeClient.updateSession(sessionId, { title }, sessionDirectory)
useGlobalSessionsStore.getState().upsertSession(session)
}
export async function shareSession(sessionId: string): Promise<Session | null> {
const sessionDirectory = getSessionDirectory(sessionId)
const result = await sdk().session.share({ sessionID: sessionId, directory: sessionDirectory })
const session = stripSessionDiffSnapshots(assertSdkData(result, "session.share"))
useGlobalSessionsStore.getState().upsertSession(session)
updateLiveSession(session, sessionDirectory)
return session
}
export async function unshareSession(sessionId: string): Promise<Session | null> {
const sessionDirectory = getSessionDirectory(sessionId)
const result = await sdk().session.unshare({ sessionID: sessionId, directory: sessionDirectory })
const session = stripSessionDiffSnapshots(assertSdkData(result, "session.unshare"))
useGlobalSessionsStore.getState().upsertSession(session)
updateLiveSession(session, sessionDirectory)
return session
}
// ---------------------------------------------------------------------------
// 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
directory?: string | null
files?: Array<{ type: "file"; mime: string; url: string; filename: string }>
onOptimisticInsert?: () => void
onMessageID?: (messageID: string) => void
beforeOptimisticInsert?: () => void
/** The actual API call — receives the optimistic messageID so the server can use the same ID */
send: (messageID: string) => Promise<void>
}): Promise<void> {
if (!_optimisticAdd || !_optimisticRemove) {
throw new Error("Optimistic refs not set — is useSync() mounted?")
}
await waitForConnectionOrThrow()
input.beforeOptimisticInsert?.()
const targetDirectory = input.directory ?? dir()
const store = targetDirectory ? dirStoreForDirectory(targetDirectory) : dirStore()
const messageID = ascendingId("msg")
input.onMessageID?.(messageID)
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<string, unknown>,
time: { created: Date.now(), completed: 0 },
} as unknown as Message
// Insert into store + register in shadow Map (for mergeOptimisticPage cleanup)
_optimisticAdd({
sessionID: input.sessionId,
directory: targetDirectory,
message: optimisticMessage,
parts: optimisticParts,
})
input.onOptimisticInsert?.()
// 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,
directory: targetDirectory,
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<void> {
// The abort must carry the SESSION'S directory, not the active UI directory:
// OpenCode routes the request to the per-directory instance, and an abort
// sent to the wrong instance cancels nothing while still returning 200 true
// (the "stop button does nothing" report — sessions in another project/
// worktree than the UI's current directory could never be aborted).
const { directory } = dirStoreForSession(sessionId)
try {
await sdk().session.abort({ sessionID: sessionId, directory })
} catch (error) {
console.error("[session-actions] abort failed", error)
}
}
// ---------------------------------------------------------------------------
// Permissions
// ---------------------------------------------------------------------------
export async function respondToPermission(
sessionId: string,
requestId: string,
response: "once" | "always" | "reject",
): Promise<void> {
await waitForConnectionOrThrow()
const directory = resolveDirectoryForBlockingRequest("permission", sessionId, requestId)
|| getSessionDirectory(sessionId)
|| dir()
const result = await getRequestReplyClient("permission", sessionId, requestId).permission.reply({
requestID: requestId,
reply: response,
...(directory ? { directory } : {}),
})
if (assertSdkData(result, "permission.reply") !== true) {
throw new Error("Permission reply failed")
}
}
export async function dismissPermission(
sessionId: string,
requestId: string,
): Promise<void> {
await waitForConnectionOrThrow()
const directory = resolveDirectoryForBlockingRequest("permission", sessionId, requestId)
|| getSessionDirectory(sessionId)
|| dir()
const result = await getRequestReplyClient("permission", sessionId, requestId).permission.reply({
requestID: requestId,
reply: "reject",
...(directory ? { directory } : {}),
})
if (assertSdkData(result, "permission.reply") !== true) {
throw new Error("Permission dismissal failed")
}
}
// ---------------------------------------------------------------------------
// Questions
// ---------------------------------------------------------------------------
export async function respondToQuestion(
sessionId: string,
requestId: string,
answers: string[] | string[][],
): Promise<void> {
await waitForConnectionOrThrow()
const directory = resolveDirectoryForBlockingRequest("question", sessionId, requestId)
|| getSessionDirectory(sessionId)
|| dir()
try {
const normalizedAnswers = answers.length === 0
? []
: Array.isArray(answers[0])
? answers as string[][]
: [answers as string[]]
const result = await getRequestReplyClient("question", sessionId, requestId).question.reply({
requestID: requestId,
answers: normalizedAnswers,
...(directory ? { directory } : {}),
})
if (assertSdkData(result, "question.reply") !== true) {
throw new Error("Question reply failed")
}
} catch (error) {
if (isQuestionRequestNotFoundError(error)) {
removeQuestionRequestFromChildStores(sessionId, requestId)
}
throw error
}
}
export async function rejectQuestion(
sessionId: string,
requestId: string,
): Promise<void> {
await waitForConnectionOrThrow()
const directory = resolveDirectoryForBlockingRequest("question", sessionId, requestId)
|| getSessionDirectory(sessionId)
|| dir()
try {
const result = await getRequestReplyClient("question", sessionId, requestId).question.reject({
requestID: requestId,
...(directory ? { directory } : {}),
})
if (assertSdkData(result, "question.reject") !== true) {
throw new Error("Question rejection failed")
}
} catch (error) {
if (isQuestionRequestNotFoundError(error)) {
removeQuestionRequestFromChildStores(sessionId, requestId)
}
throw error
}
}
/**
* Dismiss every pending question for the session subtree rooted at `sessionId`
* (the session itself plus any subagent children). Used by the chat send path:
* sending a message while a question prompt is open must cancel/supersede the
* open question so it cannot linger or strand the session in a half-answered
* state.
*
* The questions are removed from the local store OPTIMISTICALLY (before any
* network call) so the prompt disappears instantly instead of waiting on the
* `question.reject` round-trip. Each question is then formally rejected on the
* backend, which fires `question.rejected` for reconciliation.
*
* Returns true when at least one question was dismissed. Rejection failures are
* swallowed (a stranded question must never block the send);
* QuestionNotFoundError also clears the stale entry from the child store via
* {@link rejectQuestion}.
*
* NOTE: rejecting unblocks the agent's tool but does NOT end its turn. Callers
* that need to send the next message right away (the chat send path) must also
* abort the session so the OpenCode runner reaches `idle` — otherwise the new
* prompt arrives while the run is still active and is discarded by the runner's
* `ensureRunning`.
*/
export async function dismissOpenQuestionsForSession(sessionId: string): Promise<boolean> {
if (!sessionId) return false
const stores = _childStores
if (!stores) return false
const toDismiss: Array<{ sessionId: string; requestId: string }> = []
for (const [, store] of stores.children) {
const state = store.getState()
const scopedIds = computeSubtreeIds(state.session, sessionId)
if (scopedIds.size === 0) continue
const questionsBySession = state.question ?? {}
for (const scopedId of scopedIds) {
const requests = questionsBySession[scopedId]
if (!requests) continue
for (const request of requests) {
toDismiss.push({ sessionId: scopedId, requestId: request.id })
}
}
}
if (toDismiss.length === 0) return false
// Optimistically clear the questions from the local store so the prompt
// disappears immediately, before the reject round-trip.
for (const { sessionId: scopedSessionId, requestId } of toDismiss) {
removeQuestionRequestFromChildStores(scopedSessionId, requestId)
}
await Promise.all(
toDismiss.map(async ({ sessionId: scopedSessionId, requestId }) => {
try {
await rejectQuestion(scopedSessionId, requestId)
} catch (error) {
if (isQuestionRequestNotFoundError(error)) return
// Swallow: a failed dismissal must not block the send. The next
// question.asked / question.rejected event reconciles the store.
console.error("[session-actions] Failed to dismiss open question on send:", error)
}
}),
)
return true
}
// ---------------------------------------------------------------------------
// 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 the runtime revert endpoint 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<void> {
const { store, directory } = dirStoreForSession(sessionId)
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 })
} catch {
// ignore abort errors
}
}
// Extract message text for prompt restoration (only non-synthetic text parts —
// the server adds file content as synthetic text parts that should not be restored)
const messages = state.message[sessionId] ?? []
const targetMsg = messages.find((m) => m.id === messageId)
let messageText = ""
let submittedFileParts: Array<Record<string, unknown>> = []
if (targetMsg && targetMsg.role === "user") {
const parts = state.part[messageId] ?? []
const textParts = parts.filter((p) => p.type === "text" && !isSyntheticPart(p))
messageText = textParts
.map((p: Record<string, unknown>) => (p as { text?: string }).text || (p as { content?: string }).content || "")
.join("\n")
.trim()
// Snapshot file parts for later restoration to the input.
// Exclude synthetic file parts (server-generated file content that should
// not be restored to the composer).
submittedFileParts = parts.filter((p) => p.type === "file" && !isSyntheticPart(p)) as Array<Record<string, unknown>>
}
// Optimistically set only the revert marker. Keep messages and parts in the
// local store; visible-message selectors derive the displayed timeline from
// session.revert. This matches the server model and preserves reverted
// messages for the restore dock without maintaining a separate shadow copy.
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)
const patch: Record<string, unknown> = {}
if (sessionIdx >= 0) {
sessions[sessionIdx] = { ...sessions[sessionIdx], revert: { messageID: messageId } } as Session
patch.session = sessions
}
store.setState(patch)
// Save input store state before mutations — if the API fails we need to
// roll back both text and attachments to their previous values.
const prevInputAttachments = [...useInputStore.getState().attachedFiles]
const prevInputText = useInputStore.getState().pendingInputText
const prevInputMode = useInputStore.getState().pendingInputMode
// Restore reverted message text and file attachments to input
if (messageText) {
useInputStore.setState({
pendingInputText: messageText,
pendingInputMode: "replace" as const,
})
}
// Restore file/image attachments from the target message.
// Clear existing attachments first — previous revert's attachments
// must not carry over, even when the current message has no files.
restoreFilePartsToInput(submittedFileParts)
// Call SDK and merge authoritative result into store
try {
const revertedSession = await opencodeClient.revertSession(sessionId, messageId, undefined, directory)
const current = store.getState()
const updated = [...current.session]
const idx = updated.findIndex((s) => s.id === sessionId)
if (idx >= 0) {
updated[idx] = revertedSession
store.setState({ session: updated })
}
if (directory) {
sessionEvents.requestGitRefresh({ directory })
}
} 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,
})
// Rollback input store: restore previous text and attachments
useInputStore.setState({
pendingInputText: prevInputText,
pendingInputMode: prevInputMode,
attachedFiles: prevInputAttachments,
})
throw err
}
}
export async function refetchSessionMessages(sessionId: string): Promise<void> {
const { store, directory } = dirStoreForSession(sessionId)
const result = await sdk().session.messages({ sessionID: sessionId, directory, limit: MESSAGE_REFETCH_LIMIT })
const records = (assertSdkSuccess(result, "session.messages") ?? [])
.filter((record: { info?: { id?: string } }) => !!record?.info?.id)
if (records.length === 0) return
store.setState((state) => {
const materialized = materializeSessionSnapshots(
state,
sessionId,
records.map((record: { info: Message; parts?: Part[] }) => ({
info: stripMessageDiffSnapshots(record.info),
parts: record.parts ?? [],
})),
{ skipPartTypes: MESSAGE_REFETCH_SKIP_PARTS },
)
return { message: materialized.message, part: materialized.part }
})
}
/**
* Unrevert — restore all previously reverted messages.
* Restore all previously reverted messages. Aborts if busy, merges result.
*/
export async function unrevertSession(sessionId: string): Promise<void> {
const { store, directory } = dirStoreForSession(sessionId)
const state = store.getState()
const previousMessageCount = state.message[sessionId]?.length ?? 0
// Abort if busy
const status = state.session_status[sessionId]
if (status && status.type !== "idle") {
try {
await sdk().session.abort({ sessionID: sessionId, directory })
} catch {
// ignore
}
}
const result = await sdk().session.unrevert({ sessionID: sessionId, directory })
const unrevertedSession = assertSdkData(result, "session.unrevert")
const current = store.getState()
const sessions = [...current.session]
const idx = sessions.findIndex((s) => s.id === sessionId)
if (idx >= 0) {
sessions[idx] = unrevertedSession
store.setState({ session: sessions })
}
for (let attempt = 0; attempt < UNREVERT_REFETCH_ATTEMPTS; attempt += 1) {
if (attempt > 0) await wait(UNREVERT_REFETCH_RETRY_MS)
await refetchSessionMessages(sessionId)
const nextMessageCount = store.getState().message[sessionId]?.length ?? 0
if (nextMessageCount > previousMessageCount) return
}
}
/**
* Fork from a user message.
*
* 1. Extract text from the message for input restoration
* 2. Call the runtime fork endpoint
* 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<void> {
const { store, directory } = dirStoreForSession(sessionId)
const state = store.getState()
// Extract message text and file attachments for input restoration.
// Only non-synthetic text parts — the server adds file content as synthetic
// text parts that should not be restored. File parts (images, pasted
// screenshots) are user-originated and must be restored.
const parts = state.part[messageId] ?? []
let messageText = ""
const textParts = parts.filter((p) => p.type === "text" && !isSyntheticPart(p))
messageText = textParts
.map((p: Part) => ((p as Record<string, unknown>).text as string) || ((p as Record<string, unknown>).content as string) || "")
.join("\n")
.trim()
const fileParts = parts.filter((p) => p.type === "file" && !isSyntheticPart(p)) as Array<Record<string, unknown>>
const forkedSession = await opencodeClient.forkSession(sessionId, messageId, directory)
// 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 and file attachments to input
if (messageText) {
useInputStore.setState({
pendingInputText: messageText,
pendingInputMode: "replace" as const,
})
}
// Clear existing attachments and restore file parts from the forked message.
restoreFilePartsToInput(fileParts)
}
// ---------------------------------------------------------------------------
// Imperative fetch path — starts message loading on the same tick as
// setCurrentSession, before the React commit cycle fires useEffect.
// ---------------------------------------------------------------------------
const FETCH_MESSAGES_LOADING = new Set<string>()
const DESKTOP_INITIAL_PAGE_SIZE = 50
const CONSTRAINED_INITIAL_PAGE_SIZE = 30
const getFetchPageSize = () => {
if (isVSCodeRuntime() || isMobileSurfaceRuntime()) return CONSTRAINED_INITIAL_PAGE_SIZE
return DESKTOP_INITIAL_PAGE_SIZE
}
export async function fetchMessagesForSession(sessionID: string, directory?: string | null): Promise<void> {
const resolvedDir = directory ?? dir()
if (!resolvedDir) return
const loadingKey = `${resolvedDir}:${sessionID}`
if (FETCH_MESSAGES_LOADING.has(loadingKey)) return
FETCH_MESSAGES_LOADING.add(loadingKey)
try {
const s = sdk()
const store = directory
? dirStoreForDirectory(directory)
: dirStore()
if (getSessionMaterializationStatus(store.getState(), sessionID).renderable) return
const result = await retry(async () => {
const response = await s.session.messages({
sessionID,
directory: resolvedDir,
limit: getFetchPageSize(),
})
return response
})
const records = (assertSdkSuccess(result, "session.messages") ?? [])
.filter((record: { info?: { id?: string } }) => !!record?.info?.id)
if (records.length === 0) return
// Staleness guard: a rapid session switch may have moved the user off this
// session while the fetch was in flight. Skip the write so a slow fetch
// can't repopulate (and un-evict) a session already navigated away from.
if (useSessionUIStore.getState().currentSessionId !== sessionID) return
const latestState = store.getState()
const latestStatus = getSessionMaterializationStatus(latestState, sessionID)
if (latestStatus.renderable && (latestState.message[sessionID]?.length ?? 0) >= records.length) return
store.setState((state) => {
const materialized = materializeSessionSnapshots(
state,
sessionID,
records.map((record: { info: Message; parts?: Part[] }) => ({
info: stripMessageDiffSnapshots(record.info),
parts: record.parts ?? [],
})),
{ skipPartTypes: MESSAGE_REFETCH_SKIP_PARTS },
)
if (!materialized.messagesChanged && !materialized.partsChanged) return state
return { message: materialized.message, part: materialized.part }
})
} catch {
// Transient failure — the reactive path in ChatContainer will retry
} finally {
FETCH_MESSAGES_LOADING.delete(loadingKey)
}
}