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.
1224 lines
44 KiB
TypeScript
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)
|
|
}
|
|
}
|