Review comments, quotes, terminal selections and annotations were consumed at send and never put back, so reverting pulled the message into the composer without the context it was sent with. Claude-Session: https://claude.ai/code/session_01TwLFeTfBnWdvbg9XyezZQx
2251 lines
88 KiB
TypeScript
2251 lines
88 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, resolveGlobalSessionDirectory, useGlobalSessionsStore } from "@/stores/useGlobalSessionsStore"
|
|
import { useConfigStore } from "@/stores/useConfigStore"
|
|
import { registerSessionDirectory } from "./sync-refs"
|
|
import { useGlobalSessionStatusStore } from "./global-session-status"
|
|
import { recordSendFailure } from "./send-failure-log"
|
|
import { isSyntheticPart } from "@/lib/messages/synthetic"
|
|
import { draftFromContextPayload, readContextPart, type ContextCarrierPart } from "@/lib/messages/contextParts"
|
|
import { useInlineCommentDraftStore, type InlineCommentDraftTarget } from "@/stores/useInlineCommentDraftStore"
|
|
import { materializeSessionSnapshots } from "./materialization"
|
|
import { stripMessageDiffSnapshots, stripSessionDiffSnapshots } from "./sanitize"
|
|
import { sessionEvents } from "@/lib/sessionEvents"
|
|
import {
|
|
getOriginalSessionID,
|
|
getSessionMetadata,
|
|
isReviewSession,
|
|
withoutReviewSessionLink,
|
|
type SessionMetadataRecord,
|
|
} from "@/lib/sessionReviewMetadata"
|
|
import { withContextObligatoryMessage, type ContextObligatoryMessage } from "@/lib/contextObligatoryMessages"
|
|
import { getBtwOriginalSessionID, getBtwSessionID, isBtwSession, withoutBtwSessionLink } from "@/lib/sessionBtwMetadata"
|
|
import { withLinkedIssue, type LinkedIssue } from "@/lib/linkedIssues"
|
|
import { getImperativeSessionMessageLoader } from "./session-message-loader"
|
|
import { cleanupPersistedSessionState } from "./session-deletion-cleanup"
|
|
import { getRuntimeKey } from "@/lib/runtime-switch"
|
|
import { markAmbiguousTransportFailure } from "@/lib/relay/transport-error"
|
|
import { getErrorStatus, isAmbiguousSendFailure } from "./send-failure-classification"
|
|
import { getStaleRunningToolMessageID } from "./materialization"
|
|
import { normalizePath } from "@/lib/pathNormalization"
|
|
import { mergeMessages } from "./optimistic"
|
|
import { messagesBefore, messagesFrom } from "./message-ordering"
|
|
import { deleteChatDirectory } from "@/lib/chatDirectories"
|
|
|
|
const MESSAGE_REFETCH_LIMIT = 100
|
|
const SEND_CONFIRMATION_REFETCH_LIMIT = 30
|
|
// A relay-tunnel send fails when the tunnel drops, and the confirming refetch
|
|
// then has to travel over that same tunnel to answer "did my message land?".
|
|
// Two attempts 150ms apart always answered "no" on a remote connection, so an
|
|
// accepted prompt looked like a failed one and got re-sent — two AI responses
|
|
// for one user message. Wait for the connection to actually come back (an
|
|
// authoritative signal, not a blind sleep), then retry with backoff. A healthy
|
|
// connection skips the wait and answers on the first attempt.
|
|
const SEND_CONFIRMATION_REFETCH_ATTEMPTS = 3
|
|
const SEND_CONFIRMATION_REFETCH_BASE_RETRY_MS = 250
|
|
const SEND_CONFIRMATION_RECONNECT_TIMEOUT_MS = 3000
|
|
const SEND_CONFIRMATION_RECONNECT_POLL_MS = 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 = () => ""
|
|
// Optional ref into the sync layer's session-tail materialization queue. Used
|
|
// to reconcile a trailing running tool part after a blocking request is
|
|
// confirmed stale server-side (see recoverStaleBlockingRequest).
|
|
let _enqueueSessionMaterialization: ((directory: string, sessionID: string, messageID: string) => void) | null = null
|
|
type OptimisticAddInput = { sessionID: string; directory?: string | null; message: Message; parts: Part[] }
|
|
type OptimisticRemoveInput = { sessionID: string; directory?: string | null; messageID: string }
|
|
type OptimisticConfirmInput = OptimisticRemoveInput
|
|
|
|
let _optimisticAdd: ((input: OptimisticAddInput) => void) | null = null
|
|
let _optimisticRemove: ((input: OptimisticRemoveInput) => void) | null = null
|
|
let _optimisticConfirm: ((input: OptimisticConfirmInput) => void) | null = null
|
|
|
|
function sessionMutationPatch(
|
|
state: ReturnType<DirectoryStoreApi["getState"]>,
|
|
sessionId: string,
|
|
deleted: boolean,
|
|
) {
|
|
const revision = (state.sessionRevision ?? 0) + 1
|
|
const sessionEventRevision = { ...(state.sessionEventRevision ?? {}) }
|
|
const sessionDeletedRevision = { ...(state.sessionDeletedRevision ?? {}) }
|
|
if (deleted) {
|
|
sessionDeletedRevision[sessionId] = revision
|
|
delete sessionEventRevision[sessionId]
|
|
} else {
|
|
sessionEventRevision[sessionId] = revision
|
|
delete sessionDeletedRevision[sessionId]
|
|
}
|
|
return {
|
|
sessionListSource: "live" as const,
|
|
sessionRevision: revision,
|
|
sessionEventRevision,
|
|
sessionDeletedRevision,
|
|
}
|
|
}
|
|
|
|
function invalidateSessionLoads(sessionId: string, directories: Iterable<string | null | undefined>): void {
|
|
const loader = getImperativeSessionMessageLoader()
|
|
if (!loader) return
|
|
for (const directory of new Set(directories)) {
|
|
if (directory) loader.invalidateSession({ directory, sessionID: sessionId })
|
|
}
|
|
}
|
|
|
|
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
|
|
// Wrapping loses the original error's identity: the transport's
|
|
// "dispatched, outcome unknown" tag, a DOMException abort, a TypeError from
|
|
// fetch. Re-tag the wrapper so `isAmbiguousSendFailure` still classifies it
|
|
// as ambiguous instead of reading it as a definite server rejection.
|
|
throw isAmbiguousSendFailure(result.error) ? markAmbiguousTransportFailure(error) : 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,
|
|
enqueueSessionMaterialization?: (directory: string, sessionID: string, messageID: string) => void,
|
|
) {
|
|
_sdk = sdk
|
|
_childStores = childStores
|
|
_getDirectory = getDirectory
|
|
_enqueueSessionMaterialization = enqueueSessionMaterialization ?? null
|
|
}
|
|
|
|
export function setOptimisticRefs(
|
|
add: (input: OptimisticAddInput) => void,
|
|
remove: (input: OptimisticRemoveInput) => void,
|
|
confirm?: (input: OptimisticConfirmInput) => void,
|
|
) {
|
|
_optimisticAdd = add
|
|
_optimisticRemove = remove
|
|
_optimisticConfirm = confirm ?? null
|
|
}
|
|
|
|
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): boolean {
|
|
const stores = _childStores
|
|
if (!stores) return false
|
|
|
|
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 true
|
|
}
|
|
|
|
return false
|
|
}
|
|
|
|
function mirrorSessionIntoLiveStores(session: Session, directory?: string): void {
|
|
if (directory && updateLiveSession(session, directory)) {
|
|
return
|
|
}
|
|
updateLiveSession(session)
|
|
}
|
|
|
|
function moveRecordEntries<T>(
|
|
source: Record<string, T>,
|
|
destination: Record<string, T>,
|
|
keys: Iterable<string>,
|
|
): { source: Record<string, T>; destination: Record<string, T> } {
|
|
let nextSource = source
|
|
let nextDestination = destination
|
|
|
|
for (const key of keys) {
|
|
if (!Object.prototype.hasOwnProperty.call(source, key)) continue
|
|
if (nextSource === source) nextSource = { ...source }
|
|
if (nextDestination === destination) nextDestination = { ...destination }
|
|
nextDestination[key] = source[key]
|
|
delete nextSource[key]
|
|
}
|
|
|
|
return { source: nextSource, destination: nextDestination }
|
|
}
|
|
|
|
function reconcileSessionMove(
|
|
session: Session,
|
|
sourceDirectory: string,
|
|
destinationDirectory: string,
|
|
): Session {
|
|
const stores = _childStores
|
|
const sourceStore = stores?.getChild(sourceDirectory)
|
|
const destinationStore = stores?.ensureChild(destinationDirectory, { bootstrap: false })
|
|
const sourceState = sourceStore?.getState()
|
|
const destinationState = destinationStore?.getState()
|
|
const liveSession = sourceState?.session.find((candidate) => candidate.id === session.id) ?? session
|
|
const movedSession = { ...liveSession, directory: destinationDirectory } as Session
|
|
|
|
if (!destinationStore || !destinationState || sourceStore === destinationStore) {
|
|
return movedSession
|
|
}
|
|
|
|
const destinationSessionIndex = destinationState.session.findIndex((candidate) => candidate.id === session.id)
|
|
const destinationSessions = [...destinationState.session]
|
|
if (destinationSessionIndex === -1) destinationSessions.push(movedSession)
|
|
else destinationSessions[destinationSessionIndex] = movedSession
|
|
|
|
if (!sourceStore || !sourceState) {
|
|
destinationStore.setState({
|
|
session: destinationSessions,
|
|
sessionTotal: destinationSessionIndex === -1
|
|
? destinationState.sessionTotal + 1
|
|
: destinationState.sessionTotal,
|
|
})
|
|
return movedSession
|
|
}
|
|
|
|
const sourceContainsSession = sourceState.session.some((candidate) => candidate.id === session.id)
|
|
const status = moveRecordEntries(sourceState.session_status, destinationState.session_status, [session.id])
|
|
const diffs = moveRecordEntries(sourceState.session_diff, destinationState.session_diff, [session.id])
|
|
const todos = moveRecordEntries(sourceState.todo, destinationState.todo, [session.id])
|
|
const permissions = moveRecordEntries(sourceState.permission, destinationState.permission, [session.id])
|
|
const questions = moveRecordEntries(sourceState.question, destinationState.question, [session.id])
|
|
const messages = moveRecordEntries(sourceState.message, destinationState.message, [session.id])
|
|
const messageIds = sourceState.message[session.id]?.map((message) => message.id) ?? []
|
|
const parts = moveRecordEntries(sourceState.part, destinationState.part, messageIds)
|
|
|
|
sourceStore.setState({
|
|
session: sourceState.session.filter((candidate) => candidate.id !== session.id),
|
|
sessionTotal: sourceContainsSession ? Math.max(0, sourceState.sessionTotal - 1) : sourceState.sessionTotal,
|
|
session_status: status.source,
|
|
session_diff: diffs.source,
|
|
todo: todos.source,
|
|
permission: permissions.source,
|
|
question: questions.source,
|
|
message: messages.source,
|
|
part: parts.source,
|
|
...sessionMutationPatch(sourceState, session.id, true),
|
|
})
|
|
destinationStore.setState({
|
|
session: destinationSessions,
|
|
sessionTotal: destinationSessionIndex === -1
|
|
? destinationState.sessionTotal + 1
|
|
: destinationState.sessionTotal,
|
|
session_status: status.destination,
|
|
session_diff: diffs.destination,
|
|
todo: todos.destination,
|
|
permission: permissions.destination,
|
|
question: questions.destination,
|
|
message: messages.destination,
|
|
part: parts.destination,
|
|
...sessionMutationPatch(destinationState, session.id, false),
|
|
})
|
|
|
|
return movedSession
|
|
}
|
|
|
|
export async function moveSessionToDirectory(
|
|
session: Session,
|
|
sourceDirectory: string,
|
|
destinationDirectory: string,
|
|
moveChanges = true,
|
|
): Promise<void> {
|
|
const result = await opencodeClient.getSdkClient().experimental.controlPlane.moveSession({
|
|
sessionID: session.id,
|
|
destination: { directory: destinationDirectory },
|
|
moveChanges,
|
|
})
|
|
assertSdkSuccess(result, "Move session")
|
|
|
|
invalidateSessionLoads(session.id, [sourceDirectory, destinationDirectory])
|
|
|
|
const moved = reconcileSessionMove(session, sourceDirectory, destinationDirectory)
|
|
|
|
registerSessionDirectory(session.id, destinationDirectory)
|
|
useGlobalSessionsStore.getState().upsertSession(moved)
|
|
useSessionUIStore.getState().setSessionDirectory(session.id, destinationDirectory)
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
type DirectoryStoreApi = ReturnType<ChildStoreManager["ensureChild"]>
|
|
|
|
type DescendantSession = {
|
|
session: Session
|
|
directory: string
|
|
}
|
|
|
|
/** "unknown" means no live source covers this session right now, so no caller
|
|
* may treat it as idle on this answer. "idle" requires positive coverage. */
|
|
export type SessionLiveActivity = "unknown" | "idle" | "active"
|
|
|
|
/**
|
|
* A session's live status can live in a different child store than the one that
|
|
* wins the directory dedup, so any store reporting a non-idle status counts.
|
|
* Read at the moment of use: a descendant can start working after the subtree
|
|
* snapshot was taken.
|
|
*
|
|
* Absence of a non-idle status is not proof of idleness. Child stores are
|
|
* evicted for background directories, and the global status index keeps only
|
|
* non-idle entries, so "no report" and "idle" are different answers: report
|
|
* "idle" only when a child store actually covers the session's directory.
|
|
*/
|
|
export function getSessionLiveActivity(sessionId: string): SessionLiveActivity {
|
|
const stores = _childStores
|
|
|
|
if (stores) {
|
|
for (const [, store] of stores.children) {
|
|
const status = store.getState().session_status?.[sessionId]
|
|
if (status && status.type !== "idle") return "active"
|
|
}
|
|
}
|
|
|
|
// Cross-directory live index: populated by global events and authoritative
|
|
// per-directory status snapshots, and it survives child-store eviction.
|
|
if (useGlobalSessionStatusStore.getState().statusById.has(sessionId)) return "active"
|
|
|
|
if (!stores) return "unknown"
|
|
return isSessionCoveredByChildStore(sessionId, stores) ? "idle" : "unknown"
|
|
}
|
|
|
|
function isSessionCoveredByChildStore(sessionId: string, stores: ChildStoreManager): boolean {
|
|
if (findSessionDirectoryInChildStores(sessionId)) return true
|
|
const directory = useSessionUIStore.getState().getDirectoryForSession(sessionId)
|
|
?? resolveKnownSessionDirectory(sessionId)
|
|
if (!directory) return false
|
|
return stores.children.has(normalizePath(directory) ?? directory)
|
|
}
|
|
|
|
function resolveKnownSessionDirectory(sessionId: string): string | null {
|
|
const globalSession = getGlobalSessionSnapshot(sessionId)
|
|
return globalSession ? resolveGlobalSessionDirectory(globalSession) : null
|
|
}
|
|
|
|
export function isSessionBusyNow(sessionId: string): boolean {
|
|
return getSessionLiveActivity(sessionId) === "active"
|
|
}
|
|
|
|
async function abortDescendantIfBusy(sessionId: string, directory: string): Promise<void> {
|
|
if (!isSessionBusyNow(sessionId)) return
|
|
try {
|
|
await sdk().session.abort({ sessionID: sessionId, directory })
|
|
} catch {
|
|
// ignore abort errors
|
|
}
|
|
}
|
|
|
|
function getDescendantSessions(rootId: string): DescendantSession[] {
|
|
const stores = _childStores
|
|
if (!stores) return []
|
|
|
|
const sessionsById = new Map<string, DescendantSession>()
|
|
for (const [storeDirectory, store] of stores.children) {
|
|
const state = store.getState()
|
|
for (const session of state.session) {
|
|
const directory = session.directory || storeDirectory
|
|
const current = sessionsById.get(session.id)
|
|
if (!current || session.directory) sessionsById.set(session.id, { session, directory })
|
|
}
|
|
}
|
|
|
|
const subtreeIds = computeSubtreeIds(
|
|
[...sessionsById.values()].map(({ session }) => session),
|
|
rootId,
|
|
)
|
|
subtreeIds.delete(rootId)
|
|
return [...subtreeIds]
|
|
.map((id) => sessionsById.get(id))
|
|
.filter((entry): entry is DescendantSession => !!entry)
|
|
}
|
|
|
|
function firstUserMessageAtOrAfter(messages: Message[], cutoff: number): Message | null {
|
|
let target: Message | null = null
|
|
for (const message of messages) {
|
|
if (message.role !== "user" || message.time.created < cutoff) continue
|
|
if (!target || message.time.created < target.time.created) target = message
|
|
}
|
|
return target
|
|
}
|
|
|
|
async function fetchSessionMessages(sessionId: string, directory?: string | null): Promise<Message[]> {
|
|
const records = await opencodeClient.getSessionMessages(sessionId, undefined, directory)
|
|
return records.map(({ info }) => info)
|
|
}
|
|
|
|
async function cascadeRevertToDescendants(rootId: string, cutoff: number): Promise<void> {
|
|
for (const { session, directory } of getDescendantSessions(rootId)) {
|
|
try {
|
|
// A running descendant would keep writing messages past the revert
|
|
// boundary, so stop it first for the same reason the parent is aborted.
|
|
await abortDescendantIfBusy(session.id, directory)
|
|
const messages = await fetchSessionMessages(session.id, directory)
|
|
// Equal timestamps belong to the reverted side of the boundary. Keeping
|
|
// them would rely on unrelated message IDs to decide chronology.
|
|
const target = firstUserMessageAtOrAfter(messages, cutoff)
|
|
if (!target) continue
|
|
const reverted = await opencodeClient.revertSession(session.id, target.id, undefined, directory)
|
|
mirrorSessionIntoLiveStores(reverted, directory)
|
|
} catch (error) {
|
|
console.error(`[session-actions] Failed to cascade revert to descendant ${session.id}:`, error)
|
|
}
|
|
}
|
|
}
|
|
|
|
async function cascadeUnrevertToDescendants(rootId: string): Promise<void> {
|
|
for (const { session, directory } of getDescendantSessions(rootId)) {
|
|
if (!session.revert) continue
|
|
try {
|
|
// Same reason as the revert cascade: a running descendant keeps writing
|
|
// messages that the unrevert would race against.
|
|
await abortDescendantIfBusy(session.id, directory)
|
|
const result = await sdk().session.unrevert({ sessionID: session.id, directory })
|
|
mirrorSessionIntoLiveStores(assertSdkData(result, "session.unrevert"), directory)
|
|
} catch (error) {
|
|
console.error(`[session-actions] Failed to cascade unrevert to descendant ${session.id}:`, error)
|
|
}
|
|
}
|
|
}
|
|
|
|
function getGlobalSessionSnapshot(sessionId: string): Session | null {
|
|
const global = useGlobalSessionsStore.getState()
|
|
return [...global.activeSessions, ...global.archivedSessions].find((session) => session.id === sessionId) ?? null
|
|
}
|
|
|
|
function getSessionDirectory(sessionId: string): string | undefined {
|
|
const globalSession = getGlobalSessionSnapshot(sessionId)
|
|
return findSessionDirectoryInChildStores(sessionId)
|
|
|| useSessionUIStore.getState().getDirectoryForSession(sessionId)
|
|
|| (globalSession ? resolveGlobalSessionDirectory(globalSession) ?? undefined : undefined)
|
|
|| 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 })
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Put a message's attached context (review comments, quotes, terminal
|
|
* selections, annotations) back on the composer chips.
|
|
*
|
|
* Context rides out as synthetic parts carrying structured metadata, so a
|
|
* reverted or forked message can be rebuilt into the drafts it came from.
|
|
* Without this the context is simply gone: the message is pulled back into the
|
|
* composer with its text and files, but the comments attached to it are not.
|
|
*
|
|
* The target's existing drafts are replaced, matching how text and file
|
|
* attachments are restored — the composer ends up as the message was sent.
|
|
*/
|
|
function restoreContextPartsToInput(
|
|
parts: readonly ContextCarrierPart[],
|
|
target: InlineCommentDraftTarget,
|
|
): void {
|
|
const store = useInlineCommentDraftStore.getState()
|
|
store.clearDrafts(target)
|
|
for (const part of parts) {
|
|
const payload = readContextPart(part)
|
|
if (!payload) continue
|
|
const draft = draftFromContextPayload(payload)
|
|
if (draft) store.addDraft(target, draft)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Server-confirmed directory that owns a session, from the session record
|
|
* (`directory`, then `project.worktree`). Mirrors the authoritative source in
|
|
* session-directory-resolution: holding a session in a child store proves
|
|
* containment, not ownership — a project's session list legitimately includes
|
|
* the sessions of its worktrees so the sidebar can group them — so reading
|
|
* ownership from the containing store reports the parent for a session that
|
|
* lives in a worktree, and every fetch is then addressed to a directory that
|
|
* does not own it.
|
|
*/
|
|
function resolveSessionOwnedDirectory(session: Session): string | null {
|
|
const record = session as Session & {
|
|
directory?: string | null
|
|
project?: { worktree?: string | null } | null
|
|
}
|
|
const raw = typeof record.directory === "string" && record.directory.trim().length > 0
|
|
? record.directory
|
|
: typeof record.project?.worktree === "string" && record.project.worktree.trim().length > 0
|
|
? record.project.worktree
|
|
: null
|
|
return raw ? normalizePath(raw) : null
|
|
}
|
|
|
|
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; sessionID?: string }> | undefined>) {
|
|
const request = requests?.find((candidate) => candidate.id === requestId)
|
|
if (!request) continue
|
|
|
|
// Ownership beats containment. The request belongs to one specific
|
|
// session, and the reply must reach the instance that actually tracks
|
|
// it — the directory the session record's server-confirmed `directory`
|
|
// names. The containing store's key only proves containment: a project
|
|
// store holds its worktree sessions too, and a reply addressed to the
|
|
// parent instance makes the server answer QuestionNotFoundError while
|
|
// the question stays pending in the worktree instance, leaving the
|
|
// session stuck on the running question tool. Fall back to the store
|
|
// key only when the session record carries no directory.
|
|
const requestSessionID = typeof request.sessionID === "string" && request.sessionID.length > 0
|
|
? request.sessionID
|
|
: sessionId
|
|
const sessionRecord = requestSessionID
|
|
? state.session.find((s) => s.id === requestSessionID)
|
|
: undefined
|
|
const ownedDirectory = sessionRecord ? resolveSessionOwnedDirectory(sessionRecord) : null
|
|
if (ownedDirectory) return ownedDirectory
|
|
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)
|
|
}
|
|
|
|
/**
|
|
* Reconcile the trailing assistant tool part after a blocking request turned
|
|
* out to be stale server-side (reply/reject answered with not-found). The
|
|
* local request is removed (the server no longer tracks it), but the
|
|
* question/permission tool part can remain `running` with the session busy —
|
|
* the UI would stay on "asking question" with no recovery until the user
|
|
* stops the run. Enqueue the sync layer's settled-running-tool tail
|
|
* materialization so the part converges to the server's actual state.
|
|
*/
|
|
function recoverStaleBlockingRequest(sessionId: string): void {
|
|
const stores = _childStores
|
|
const enqueue = _enqueueSessionMaterialization
|
|
if (!stores || !enqueue || !sessionId) return
|
|
|
|
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.question ?? {}, sessionId)
|
|
) {
|
|
continue
|
|
}
|
|
const messageID = getStaleRunningToolMessageID(state, sessionId)
|
|
if (messageID) {
|
|
enqueue(directory, sessionId, messageID)
|
|
}
|
|
return
|
|
}
|
|
}
|
|
|
|
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 isPermissionRequestNotFoundError(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 /Permission(?:\.)?NotFoundError|Permission request not found/i.test(message)
|
|
}
|
|
|
|
function removePermissionRequestFromChildStores(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().permission ?? {}
|
|
let nextPermission: 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
|
|
|
|
nextPermission ??= { ...current }
|
|
if (nextRequests.length > 0) {
|
|
nextPermission[candidateSessionId] = nextRequests
|
|
} else {
|
|
delete nextPermission[candidateSessionId]
|
|
}
|
|
removed = true
|
|
}
|
|
|
|
if (nextPermission) {
|
|
store.setState({ permission: nextPermission })
|
|
}
|
|
}
|
|
|
|
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>,
|
|
selectionTransition?: "submitted-draft",
|
|
): Promise<Session | null> {
|
|
try {
|
|
// Capture the effective directory used for session creation so we can fall
|
|
// back to it when the server response omits the `directory` field.
|
|
// Without this, setCurrentSession would fall through to a stale
|
|
// opencodeClient.getDirectory() value and group the session under the
|
|
// wrong project (closes #1637, #2270).
|
|
const effectiveDirectory = directoryOverride ?? dir()
|
|
const session = await opencodeClient.createSession({
|
|
title,
|
|
parentID: parentID ?? undefined,
|
|
metadata,
|
|
}, effectiveDirectory)
|
|
|
|
const sessionDirectory = (session as { directory?: string | null }).directory ?? effectiveDirectory ?? 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, selectionTransition)
|
|
useSessionUIStore.getState().markSessionAsOpenChamberCreated(session.id)
|
|
useGlobalSessionsStore.getState().upsertSession(session)
|
|
return session
|
|
} catch (error) {
|
|
console.error("[session-actions] createSession failed", error)
|
|
return null
|
|
}
|
|
}
|
|
|
|
/**
|
|
* True when a caller captured a runtime key before an asynchronous mutation and
|
|
* that runtime is no longer the active one. Callers pass `undefined` when they
|
|
* do not participate in runtime-scoped guarding, which keeps the previous
|
|
* unguarded behavior.
|
|
*/
|
|
function isStaleRuntime(expectedRuntimeKey: string | undefined): boolean {
|
|
return expectedRuntimeKey !== undefined && getRuntimeKey() !== expectedRuntimeKey
|
|
}
|
|
|
|
/**
|
|
* Read a session, apply `updater` to its metadata, and persist the result.
|
|
*
|
|
* `expectedRuntimeKey` is optional here and unguarded when omitted, unlike the
|
|
* archive and delete actions. When supplied, the runtime is rechecked before
|
|
* the read, before the write, and before the global store is updated; a change
|
|
* at any of those points **throws** `"runtime changed"` rather than returning a
|
|
* value, because this function must resolve to a `Session`. Callers that pass a
|
|
* key must therefore be prepared to catch that rejection.
|
|
*/
|
|
export async function patchSessionMetadata(
|
|
sessionId: string,
|
|
directory: string | null | undefined,
|
|
updater: (metadata: SessionMetadataRecord) => SessionMetadataRecord,
|
|
expectedRuntimeKey?: string,
|
|
): Promise<Session> {
|
|
if (isStaleRuntime(expectedRuntimeKey)) throw new Error("runtime changed")
|
|
const targetDirectory = directory ?? getSessionDirectory(sessionId)
|
|
const current = await opencodeClient.getSession(sessionId, targetDirectory)
|
|
if (isStaleRuntime(expectedRuntimeKey)) throw new Error("runtime changed")
|
|
const nextMetadata = updater(getSessionMetadata(current))
|
|
const updated = await opencodeClient.updateSession(sessionId, { metadata: nextMetadata }, targetDirectory)
|
|
if (isStaleRuntime(expectedRuntimeKey)) throw new Error("runtime changed")
|
|
useGlobalSessionsStore.getState().upsertSession(updated)
|
|
const sessionDirectory = (updated as { directory?: string | null }).directory ?? targetDirectory
|
|
if (sessionDirectory) registerSessionDirectory(updated.id, sessionDirectory)
|
|
mirrorSessionIntoLiveStores(updated, sessionDirectory ?? undefined)
|
|
return updated
|
|
}
|
|
|
|
export async function setLinkedIssue(
|
|
sessionId: string,
|
|
directory: string | null | undefined,
|
|
issue: LinkedIssue,
|
|
linked: boolean,
|
|
): Promise<Session> {
|
|
return patchSessionMetadata(sessionId, directory, (metadata) =>
|
|
withLinkedIssue(metadata, issue, linked))
|
|
}
|
|
|
|
export async function setContextObligatoryMessage(
|
|
sessionId: string,
|
|
directory: string | null | undefined,
|
|
message: ContextObligatoryMessage,
|
|
pinned: boolean,
|
|
): Promise<Session> {
|
|
return patchSessionMetadata(sessionId, directory, (metadata) =>
|
|
withContextObligatoryMessage(metadata, message, pinned))
|
|
}
|
|
|
|
async function cleanupReviewMetadataBeforeDelete(
|
|
sessionId: string,
|
|
directory?: string | null,
|
|
expectedRuntimeKey?: string,
|
|
): Promise<void> {
|
|
if (isStaleRuntime(expectedRuntimeKey)) return
|
|
let session: Session
|
|
try {
|
|
session = await opencodeClient.getSession(sessionId, directory ?? getSessionDirectory(sessionId))
|
|
} catch {
|
|
return
|
|
}
|
|
if (isStaleRuntime(expectedRuntimeKey)) return
|
|
|
|
const unlinkParent = async (originalSessionID: string, unlink: (metadata: SessionMetadataRecord) => SessionMetadataRecord) => {
|
|
try {
|
|
await patchSessionMetadata(originalSessionID, directory ?? getSessionDirectory(originalSessionID), unlink, expectedRuntimeKey)
|
|
} catch (error) {
|
|
const message = error instanceof Error ? error.message : String(error)
|
|
if (/not found/i.test(message)) return
|
|
console.warn("[session-actions] linked-session metadata cleanup failed before delete", error)
|
|
}
|
|
}
|
|
|
|
if (isReviewSession(session)) {
|
|
const originalSessionID = getOriginalSessionID(session)
|
|
if (originalSessionID) await unlinkParent(originalSessionID, (metadata) => withoutReviewSessionLink(metadata, sessionId))
|
|
return
|
|
}
|
|
|
|
if (isBtwSession(session)) {
|
|
const originalSessionID = getBtwOriginalSessionID(session)
|
|
if (originalSessionID) await unlinkParent(originalSessionID, (metadata) => withoutBtwSessionLink(metadata, sessionId))
|
|
return
|
|
}
|
|
|
|
// Deleting or archiving a session that has an active btw fork also removes
|
|
// the fork: it is a temporary session that only exists for its parent's
|
|
// panel. Best-effort — a failed fork delete must not block the parent's
|
|
// operation; the orphaned fork stays visible in the sidebar.
|
|
const btwSessionID = getBtwSessionID(session)
|
|
if (btwSessionID) {
|
|
try {
|
|
if (isStaleRuntime(expectedRuntimeKey)) return
|
|
await deleteSession(btwSessionID, { expectedRuntimeKey })
|
|
} catch (error) {
|
|
console.warn("[session-actions] failed to delete btw fork before parent delete", error)
|
|
}
|
|
}
|
|
}
|
|
|
|
/** Remove a server-confirmed session from every live child store that has it. */
|
|
function removeSessionFromLiveStores(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 })
|
|
store.setState({
|
|
session: current.session.filter((session) => session.id !== sessionId),
|
|
...sessionMutationPatch(current, sessionId, true),
|
|
})
|
|
}
|
|
|
|
return snapshots
|
|
}
|
|
|
|
function cleanupSessionWorktreeMetadata(sessionId: string): void {
|
|
useSessionUIStore.getState().setWorktreeMetadata(sessionId, null)
|
|
}
|
|
|
|
/**
|
|
* Commit a server-confirmed deletion.
|
|
*
|
|
* `expectedRuntimeKey` is the runtime the deletion was confirmed on. It is
|
|
* forwarded to `cleanupPersistedSessionState`, which rejects an identity whose
|
|
* runtime is no longer active. Passing the live `getRuntimeKey()` here would
|
|
* make that existing check a tautology, so the captured key is required to keep
|
|
* it meaningful. Callers must still reject a stale runtime themselves, because
|
|
* the in-memory live/global/UI stores mutated below are not runtime-scoped.
|
|
*/
|
|
function finalizeConfirmedSessionDeletion(
|
|
sessionId: string,
|
|
sessionDirectory?: string,
|
|
expectedRuntimeKey = getRuntimeKey(),
|
|
): void {
|
|
const snapshots = removeSessionFromLiveStores(sessionId, sessionDirectory)
|
|
invalidateSessionLoads(sessionId, [...snapshots.map((snapshot) => snapshot.directory), sessionDirectory])
|
|
useGlobalSessionsStore.getState().removeSessions([sessionId])
|
|
const ui = useSessionUIStore.getState()
|
|
if (ui.currentSessionId === sessionId) ui.setCurrentSession(null)
|
|
cleanupSessionWorktreeMetadata(sessionId)
|
|
if (sessionDirectory) {
|
|
cleanupPersistedSessionState({
|
|
runtimeKey: expectedRuntimeKey,
|
|
directory: sessionDirectory,
|
|
sessionId,
|
|
})
|
|
}
|
|
}
|
|
|
|
async function cleanupDeletedChatDirectory(directory: string | undefined, deleteDirectory: boolean): Promise<void> {
|
|
if (!directory || !deleteDirectory) return
|
|
try {
|
|
await deleteChatDirectory(directory)
|
|
} catch (error) {
|
|
console.warn("[session-actions] deleted chat directory cleanup failed", error)
|
|
}
|
|
}
|
|
|
|
export type DeleteSessionOptions = {
|
|
/**
|
|
* Runtime key the deletion is scoped to. Defaults to the active runtime when
|
|
* the action starts; callers may supply a key captured earlier when
|
|
* confirmation spans a runtime switch.
|
|
*/
|
|
expectedRuntimeKey?: string
|
|
}
|
|
|
|
/**
|
|
* Delete one session.
|
|
*
|
|
* The runtime is rechecked before the request and again before any store is
|
|
* reconciled, so a response produced by the previous runtime cannot mutate the
|
|
* current runtime's state. Session IDs are not unique across runtimes, so
|
|
* committing a stale deletion could otherwise evict an unrelated session and
|
|
* erase its persisted queue, todos, drafts, folders, and pins.
|
|
*
|
|
* A `404` is treated as an already-completed deletion, but only when it is
|
|
* still authoritative for the captured runtime. After a runtime change the
|
|
* `404` describes either the previous runtime or a runtime this session never
|
|
* belonged to; neither justifies committing cleanup here, so the action reports
|
|
* failure and leaves reconciliation to the next authoritative load.
|
|
*/
|
|
export async function deleteSession(sessionId: string, options?: DeleteSessionOptions): Promise<boolean> {
|
|
const expectedRuntimeKey = options?.expectedRuntimeKey ?? getRuntimeKey()
|
|
if (isStaleRuntime(expectedRuntimeKey)) return false
|
|
const sessionDirectory = getSessionDirectory(sessionId)
|
|
const sessionSnapshot = getGlobalSessionSnapshot(sessionId)
|
|
const deleteManagedDirectory = Boolean(sessionSnapshot && sessionSnapshot.parentID == null)
|
|
try {
|
|
await cleanupReviewMetadataBeforeDelete(sessionId, sessionDirectory, expectedRuntimeKey)
|
|
if (isStaleRuntime(expectedRuntimeKey)) return false
|
|
const deleted = await opencodeClient.deleteSession(sessionId, sessionDirectory)
|
|
if (isStaleRuntime(expectedRuntimeKey)) return false
|
|
if (deleted !== true) {
|
|
throw new Error("session.delete failed: server did not confirm deletion")
|
|
}
|
|
finalizeConfirmedSessionDeletion(sessionId, sessionDirectory, expectedRuntimeKey)
|
|
await cleanupDeletedChatDirectory(sessionDirectory, deleteManagedDirectory)
|
|
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) {
|
|
if (isStaleRuntime(expectedRuntimeKey)) return false
|
|
finalizeConfirmedSessionDeletion(sessionId, sessionDirectory, expectedRuntimeKey)
|
|
await cleanupDeletedChatDirectory(sessionDirectory, deleteManagedDirectory)
|
|
return true
|
|
}
|
|
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,
|
|
expectedRuntimeKey = getRuntimeKey(),
|
|
): Promise<boolean> {
|
|
if (isStaleRuntime(expectedRuntimeKey)) return false
|
|
const sessionSnapshot = getGlobalSessionSnapshot(sessionId)
|
|
const deleteManagedDirectory = Boolean(sessionSnapshot && sessionSnapshot.parentID == null)
|
|
try {
|
|
await cleanupReviewMetadataBeforeDelete(sessionId, directory, expectedRuntimeKey)
|
|
if (isStaleRuntime(expectedRuntimeKey)) return false
|
|
const deleted = await opencodeClient.deleteSession(sessionId, directory)
|
|
if (isStaleRuntime(expectedRuntimeKey)) return false
|
|
if (deleted !== true) {
|
|
throw new Error("session.delete failed: server did not confirm deletion")
|
|
}
|
|
finalizeConfirmedSessionDeletion(sessionId, directory, expectedRuntimeKey)
|
|
await cleanupDeletedChatDirectory(directory, deleteManagedDirectory)
|
|
return true
|
|
} catch (error) {
|
|
console.error("[session-actions] deleteSessionInDirectory failed", error)
|
|
if ((error as { status?: number })?.status === 404) {
|
|
if (isStaleRuntime(expectedRuntimeKey)) return false
|
|
finalizeConfirmedSessionDeletion(sessionId, directory, expectedRuntimeKey)
|
|
await cleanupDeletedChatDirectory(directory, deleteManagedDirectory)
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
}
|
|
|
|
export type DeleteSessionsOptions = {
|
|
/**
|
|
* Runtime key captured when the batch was confirmed. When supplied, the batch
|
|
* stops as soon as the active runtime differs.
|
|
*/
|
|
expectedRuntimeKey?: string
|
|
}
|
|
|
|
/**
|
|
* Delete several sessions sequentially, preserving partial results.
|
|
*
|
|
* One failed session never blocks or erases the others: it is reported in
|
|
* `failedIds` while the remaining IDs are still attempted. When the runtime
|
|
* changes mid-batch, the sessions already committed on the captured runtime
|
|
* stay in `deletedIds` and every ID that was not committed there is reported in
|
|
* `failedIds`, so existing partial-failure feedback stays truthful.
|
|
*/
|
|
export async function deleteSessions(
|
|
ids: string[],
|
|
options?: DeleteSessionsOptions,
|
|
): Promise<{ deletedIds: string[]; failedIds: string[] }> {
|
|
const deletedIds: string[] = []
|
|
const failedIds: string[] = []
|
|
const expectedRuntimeKey = options?.expectedRuntimeKey ?? getRuntimeKey()
|
|
|
|
for (const [index, id] of ids.entries()) {
|
|
if (isStaleRuntime(expectedRuntimeKey)) {
|
|
failedIds.push(...ids.slice(index))
|
|
break
|
|
}
|
|
if (await deleteSession(id, { expectedRuntimeKey })) deletedIds.push(id)
|
|
else failedIds.push(id)
|
|
}
|
|
|
|
return { deletedIds, failedIds }
|
|
}
|
|
|
|
/**
|
|
* Archive one session.
|
|
*
|
|
* `expectedRuntimeKey` defaults to the active runtime when the action starts.
|
|
* Callers may supply a key captured earlier when confirmation spans a runtime
|
|
* switch. When the runtime changes, the action stops and returns `false`
|
|
* without reconciling any store, so a response
|
|
* produced by the previous runtime cannot mutate the current runtime's live or
|
|
* global session state. A session the server already archived before the switch
|
|
* stays archived on that runtime and is re-read from the server the next time
|
|
* the runtime is loaded.
|
|
*/
|
|
export async function archiveSession(sessionId: string, expectedRuntimeKey = getRuntimeKey()): Promise<boolean> {
|
|
if (isStaleRuntime(expectedRuntimeKey)) return false
|
|
const sessionDirectory = getSessionDirectory(sessionId)
|
|
const archivedAt = Date.now()
|
|
try {
|
|
await cleanupReviewMetadataBeforeDelete(sessionId, sessionDirectory, expectedRuntimeKey)
|
|
if (isStaleRuntime(expectedRuntimeKey)) return false
|
|
const archived = await opencodeClient.updateSession(sessionId, { time: { archived: archivedAt } }, sessionDirectory)
|
|
if (isStaleRuntime(expectedRuntimeKey)) return false
|
|
if (!archived) {
|
|
throw new Error("session.update failed: server did not return the archived session")
|
|
}
|
|
const snapshots = removeSessionFromLiveStores(sessionId, sessionDirectory)
|
|
invalidateSessionLoads(sessionId, [...snapshots.map((snapshot) => snapshot.directory), sessionDirectory])
|
|
useGlobalSessionsStore.getState().upsertSession(archived)
|
|
const ui = useSessionUIStore.getState()
|
|
if (ui.currentSessionId === sessionId) ui.setCurrentSession(null)
|
|
return true
|
|
} catch (error) {
|
|
console.error("[session-actions] archiveSession failed", error)
|
|
return false
|
|
}
|
|
}
|
|
|
|
export type ArchiveSessionsOptions = {
|
|
/**
|
|
* Runtime key captured when the batch was confirmed. When supplied, the batch
|
|
* stops as soon as the active runtime differs.
|
|
*/
|
|
expectedRuntimeKey?: string
|
|
}
|
|
|
|
/**
|
|
* Archive several sessions sequentially, preserving partial results.
|
|
*
|
|
* One failed session never blocks or erases the others: it is reported in
|
|
* `failedIds` while the remaining IDs are still attempted. When
|
|
* `expectedRuntimeKey` is supplied and the runtime changes mid-batch, the
|
|
* already-confirmed sessions stay in `archivedIds` and every ID that was not
|
|
* confirmed on the captured runtime is reported in `failedIds`, so callers keep
|
|
* showing the existing partial-failure feedback instead of silently dropping
|
|
* work.
|
|
*/
|
|
export async function archiveSessions(
|
|
ids: string[],
|
|
options?: ArchiveSessionsOptions,
|
|
): Promise<{ archivedIds: string[]; failedIds: string[] }> {
|
|
const archivedIds: string[] = []
|
|
const failedIds: string[] = []
|
|
const expectedRuntimeKey = options?.expectedRuntimeKey ?? getRuntimeKey()
|
|
|
|
for (const [index, id] of ids.entries()) {
|
|
if (isStaleRuntime(expectedRuntimeKey)) {
|
|
failedIds.push(...ids.slice(index))
|
|
break
|
|
}
|
|
if (await archiveSession(id, expectedRuntimeKey)) archivedIds.push(id)
|
|
else failedIds.push(id)
|
|
}
|
|
|
|
return { archivedIds, failedIds }
|
|
}
|
|
|
|
/**
|
|
* Sentinel written to `time.archived` when restoring a session.
|
|
*
|
|
* The OpenCode server has no HTTP path to clear `time.archived` back to NULL:
|
|
* `session.update` only applies the field when the payload carries a finite
|
|
* number (`archived !== undefined`), so omitting the key is a no-op and `null`
|
|
* is silently ignored. Writing `0` is the only value that makes every reader
|
|
* treat the session as active again: the UI, the event reducer, and the
|
|
* OpenCode app/TUI all classify archive state by truthiness of
|
|
* `time.archived`, and `0` is falsy. The one place that still excludes such a
|
|
* session is the server's own `time_archived IS NULL` list filter, so the
|
|
* global session cache loads with the inclusive `archived` flag and splits
|
|
* client-side instead of relying on that filter (see
|
|
* `useGlobalSessionsStore.loadSessions`).
|
|
*/
|
|
const UNARCHIVED_TIMESTAMP = 0
|
|
|
|
/**
|
|
* Restore one archived session back to the active list.
|
|
*
|
|
* Same contract as `archiveSession`: waits for server confirmation before
|
|
* reconciling stores, and rejects stale runtimes so a response produced by a
|
|
* previous runtime cannot mutate the current runtime's state. The global
|
|
* session cache is updated directly (the sidebar reads active/archived
|
|
* buckets from it); the live directory store is re-populated by the
|
|
* authoritative `session.updated` event the server publishes for the update.
|
|
*/
|
|
export async function unarchiveSession(sessionId: string, expectedRuntimeKey = getRuntimeKey()): Promise<boolean> {
|
|
if (isStaleRuntime(expectedRuntimeKey)) return false
|
|
const sessionDirectory = getSessionDirectory(sessionId)
|
|
try {
|
|
const restored = await opencodeClient.updateSession(sessionId, { time: { archived: UNARCHIVED_TIMESTAMP } }, sessionDirectory)
|
|
if (isStaleRuntime(expectedRuntimeKey)) return false
|
|
if (!restored) {
|
|
throw new Error("session.update failed: server did not return the restored session")
|
|
}
|
|
if (restored.time?.archived) {
|
|
throw new Error("session.update failed: server kept the session archived")
|
|
}
|
|
useGlobalSessionsStore.getState().upsertSession(restored)
|
|
if (sessionDirectory) registerSessionDirectory(sessionId, sessionDirectory)
|
|
return true
|
|
} catch (error) {
|
|
console.error("[session-actions] unarchiveSession failed", error)
|
|
return false
|
|
}
|
|
}
|
|
|
|
export type UnarchiveSessionsOptions = {
|
|
/**
|
|
* Runtime key captured when the batch was confirmed. When supplied, the batch
|
|
* stops as soon as the active runtime differs.
|
|
*/
|
|
expectedRuntimeKey?: string
|
|
}
|
|
|
|
/**
|
|
* Restore several archived sessions sequentially, preserving partial results.
|
|
*
|
|
* One failed session never blocks or erases the others: it is reported in
|
|
* `failedIds` while the remaining IDs are still attempted. When
|
|
* `expectedRuntimeKey` is supplied and the runtime changes mid-batch, the
|
|
* already-confirmed sessions stay in `restoredIds` and every ID that was not
|
|
* confirmed on the captured runtime is reported in `failedIds`, so callers keep
|
|
* showing truthful partial-failure feedback.
|
|
*/
|
|
export async function unarchiveSessions(
|
|
ids: string[],
|
|
options?: UnarchiveSessionsOptions,
|
|
): Promise<{ restoredIds: string[]; failedIds: string[] }> {
|
|
const restoredIds: string[] = []
|
|
const failedIds: string[] = []
|
|
const expectedRuntimeKey = options?.expectedRuntimeKey ?? getRuntimeKey()
|
|
|
|
for (const [index, id] of ids.entries()) {
|
|
if (isStaleRuntime(expectedRuntimeKey)) {
|
|
failedIds.push(...ids.slice(index))
|
|
break
|
|
}
|
|
if (await unarchiveSession(id, expectedRuntimeKey)) restoredIds.push(id)
|
|
else failedIds.push(id)
|
|
}
|
|
|
|
return { restoredIds, failedIds }
|
|
}
|
|
|
|
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)
|
|
mirrorSessionIntoLiveStores(session, sessionDirectory)
|
|
}
|
|
|
|
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 })
|
|
// A successful unshare is authoritative even when the upstream response
|
|
// echoes the pre-mutation session with its old share URL. Normalize that
|
|
// stale field at the action boundary before publishing to either store.
|
|
const session = {
|
|
...stripSessionDiffSnapshots(assertSdkData(result, "session.unshare")),
|
|
share: undefined,
|
|
}
|
|
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 wire format.
|
|
// Uses BigInt(timestamp) * 0x1000 + counter, encoded as 6 hex bytes + random base62.
|
|
// The 6-byte prefix rolls over, so this value is identity only; transcript
|
|
// chronology is always derived from message.time.created.
|
|
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: {
|
|
runtimeKey?: string
|
|
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?")
|
|
}
|
|
const optimisticAdd = _optimisticAdd
|
|
const optimisticRemove = _optimisticRemove
|
|
const optimisticConfirm = _optimisticConfirm
|
|
|
|
const assertRuntimeUnchanged = () => {
|
|
if (input.runtimeKey && input.runtimeKey !== getRuntimeKey()) {
|
|
throw new Error("Message was not sent because the runtime changed.")
|
|
}
|
|
}
|
|
|
|
assertRuntimeUnchanged()
|
|
await waitForConnectionOrThrow()
|
|
input.beforeOptimisticInsert?.()
|
|
assertRuntimeUnchanged()
|
|
|
|
const targetDirectory = input.directory ?? dir()
|
|
const store = targetDirectory ? dirStoreForDirectory(targetDirectory) : dirStore()
|
|
const stateBeforeSend = store.getState()
|
|
const sessionBeforeSend = stateBeforeSend.session.find((session) => session.id === input.sessionId)
|
|
const revertMessageID = sessionBeforeSend?.revert?.messageID
|
|
const messagesBeforeSend = stateBeforeSend.message[input.sessionId] ?? []
|
|
const revertedMessages = messagesFrom(messagesBeforeSend, revertMessageID)
|
|
const revertedParts = new Map(
|
|
revertedMessages.map((message) => [message.id, stateBeforeSend.part[message.id] ?? []] as const),
|
|
)
|
|
|
|
if (revertMessageID) {
|
|
const session = stateBeforeSend.session.map((candidate) => (
|
|
candidate.id === input.sessionId ? { ...candidate, revert: undefined } as Session : candidate
|
|
))
|
|
const message = {
|
|
...stateBeforeSend.message,
|
|
[input.sessionId]: messagesBefore(messagesBeforeSend, revertMessageID),
|
|
}
|
|
const part = { ...stateBeforeSend.part }
|
|
for (const revertedMessage of revertedMessages) delete part[revertedMessage.id]
|
|
store.setState({ session, message, part })
|
|
|
|
// A server-backed user message can still remain in the loader's optimistic
|
|
// shadow until a page fetch confirms it. Forget the reverted branch there
|
|
// too, or the next tail refresh will merge those deleted messages back in.
|
|
for (const revertedMessage of revertedMessages) {
|
|
_optimisticConfirm?.({
|
|
sessionID: input.sessionId,
|
|
directory: targetDirectory,
|
|
messageID: revertedMessage.id,
|
|
})
|
|
}
|
|
}
|
|
|
|
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 {
|
|
assertRuntimeUnchanged()
|
|
await input.send(messageID)
|
|
} catch (error) {
|
|
const status = getErrorStatus(error)
|
|
const ambiguousFailure = isAmbiguousSendFailure(error)
|
|
const acceptedRecords = ambiguousFailure
|
|
? await fetchRecentSendConfirmationRecords(input.sessionId, messageID, targetDirectory)
|
|
: null
|
|
|
|
if (acceptedRecords) {
|
|
materializeConfirmedSendRecords(store, input.sessionId, messageID, acceptedRecords)
|
|
optimisticConfirm?.({
|
|
sessionID: input.sessionId,
|
|
directory: targetDirectory,
|
|
messageID,
|
|
})
|
|
return
|
|
}
|
|
|
|
// The rollback below makes the user's message disappear with no other
|
|
// trace, and the composer intentionally stays silent for transport-level
|
|
// failures. Record the failure so the About dialog's diagnostics report can
|
|
// answer "it disappeared and nothing happened" with an actual cause.
|
|
// `reason` is truncated by the recorder: a rejected send echoes the
|
|
// provider/OpenCode response body, which this log has no reason to keep.
|
|
const failureRecord = {
|
|
sessionId: input.sessionId,
|
|
messageId: messageID,
|
|
directory: targetDirectory ?? null,
|
|
status,
|
|
ambiguous: ambiguousFailure,
|
|
confirmationChecked: ambiguousFailure,
|
|
reason: error instanceof Error ? error.message : String(error),
|
|
}
|
|
recordSendFailure(failureRecord)
|
|
console.warn("[session-actions] prompt send rejected; rolling back optimistic message", failureRecord)
|
|
|
|
// Rollback via optimistic infrastructure
|
|
optimisticRemove({
|
|
sessionID: input.sessionId,
|
|
directory: targetDirectory,
|
|
messageID,
|
|
})
|
|
const rollbackState = store.getState()
|
|
let session = rollbackState.session
|
|
let message = rollbackState.message
|
|
let part = rollbackState.part
|
|
|
|
if (revertMessageID) {
|
|
session = rollbackState.session.map((candidate) => (
|
|
candidate.id === input.sessionId ? { ...candidate, revert: sessionBeforeSend?.revert } as Session : candidate
|
|
))
|
|
message = {
|
|
...rollbackState.message,
|
|
[input.sessionId]: mergeMessages(rollbackState.message[input.sessionId] ?? [], revertedMessages),
|
|
}
|
|
part = { ...rollbackState.part }
|
|
for (const [revertedMessageID, parts] of revertedParts) {
|
|
part[revertedMessageID] = parts
|
|
}
|
|
}
|
|
|
|
store.setState({
|
|
session,
|
|
message,
|
|
part,
|
|
session_status: {
|
|
...rollbackState.session_status,
|
|
[input.sessionId]: { type: "idle" as const },
|
|
},
|
|
})
|
|
throw error
|
|
}
|
|
}
|
|
|
|
async function fetchRecentSendConfirmationRecords(
|
|
sessionId: string,
|
|
messageID: string,
|
|
directory?: string | null,
|
|
): Promise<Array<{ info: Message; parts?: Part[] }> | null> {
|
|
// Bounded: a connection that never returns must still let the send fail
|
|
// rather than hang the composer.
|
|
const reconnectDeadline = Date.now() + SEND_CONFIRMATION_RECONNECT_TIMEOUT_MS
|
|
while (!useConfigStore.getState().isConnected && Date.now() < reconnectDeadline) {
|
|
await wait(SEND_CONFIRMATION_RECONNECT_POLL_MS)
|
|
}
|
|
|
|
for (let attempt = 0; attempt < SEND_CONFIRMATION_REFETCH_ATTEMPTS; attempt += 1) {
|
|
if (attempt > 0) await wait(SEND_CONFIRMATION_REFETCH_BASE_RETRY_MS * 2 ** (attempt - 1))
|
|
try {
|
|
const result = await sdk().session.messages({
|
|
sessionID: sessionId,
|
|
directory: directory ?? undefined,
|
|
limit: SEND_CONFIRMATION_REFETCH_LIMIT,
|
|
})
|
|
const records = (assertSdkSuccess(result, "session.messages") ?? [])
|
|
.filter((record: { info?: { id?: string } }) => !!record?.info?.id) as Array<{ info: Message; parts?: Part[] }>
|
|
if (records.some((record) => record.info.id === messageID)) {
|
|
return records
|
|
}
|
|
} catch {
|
|
// Confirmation is best-effort; if it fails, keep the original send error path.
|
|
}
|
|
}
|
|
return null
|
|
}
|
|
|
|
function materializeConfirmedSendRecords(
|
|
store: DirectoryStoreApi,
|
|
sessionId: string,
|
|
messageID: string,
|
|
records: Array<{ info: Message; parts?: Part[] }>,
|
|
): void {
|
|
store.setState((state) => {
|
|
const currentMessages = state.message[sessionId]
|
|
const message = { ...state.message }
|
|
const part = { ...state.part }
|
|
if (currentMessages) {
|
|
const nextMessages = currentMessages.filter((message) => message.id !== messageID)
|
|
message[sessionId] = nextMessages
|
|
}
|
|
delete part[messageID]
|
|
|
|
const materialized = materializeSessionSnapshots(
|
|
{ ...state, message, part },
|
|
sessionId,
|
|
records.map((record) => ({
|
|
info: stripMessageDiffSnapshots(record.info),
|
|
parts: record.parts ?? [],
|
|
})),
|
|
{ skipPartTypes: MESSAGE_REFETCH_SKIP_PARTS },
|
|
)
|
|
return { message: materialized.message, part: materialized.part }
|
|
})
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 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",
|
|
directoryOverride?: string,
|
|
): Promise<void> {
|
|
await waitForConnectionOrThrow()
|
|
const directory = directoryOverride
|
|
|| resolveDirectoryForBlockingRequest("permission", sessionId, requestId)
|
|
|| getSessionDirectory(sessionId)
|
|
|| dir()
|
|
const client = directoryOverride
|
|
? opencodeClient.getScopedSdkClient(directoryOverride)
|
|
: getRequestReplyClient("permission", sessionId, requestId)
|
|
const result = await client.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()
|
|
try {
|
|
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")
|
|
}
|
|
} catch (error) {
|
|
if (isPermissionRequestNotFoundError(error)) {
|
|
removePermissionRequestFromChildStores(sessionId, requestId)
|
|
}
|
|
throw error
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Dismiss every pending permission 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 permission prompt is open must cancel/supersede the
|
|
* open permission so it cannot linger or block the new turn.
|
|
*
|
|
* The permissions are removed from the local store OPTIMISTICALLY (before any
|
|
* network call) so the prompt disappears instantly instead of waiting on the
|
|
* `permission.reply` round-trip. Each permission is then formally rejected on
|
|
* the backend via `permission.reply` with `reply: "reject"`, which fires
|
|
* `permission.replied` for reconciliation.
|
|
*
|
|
* Returns true when at least one permission was dismissed. Rejection failures are
|
|
* swallowed (a stranded permission must never block the send);
|
|
* PermissionNotFoundError also clears the stale entry from the child store via
|
|
* {@link dismissPermission}.
|
|
*
|
|
* 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
|
|
* queue the message 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 dismissOpenPermissionsForSession(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 permissionsBySession = state.permission ?? {}
|
|
for (const scopedId of scopedIds) {
|
|
const requests = permissionsBySession[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 permissions from the local store so the prompt
|
|
// disappears immediately, before the reject round-trip.
|
|
for (const { sessionId: scopedSessionId, requestId } of toDismiss) {
|
|
removePermissionRequestFromChildStores(scopedSessionId, requestId)
|
|
}
|
|
|
|
await Promise.all(
|
|
toDismiss.map(async ({ sessionId: scopedSessionId, requestId }) => {
|
|
try {
|
|
await dismissPermission(scopedSessionId, requestId)
|
|
} catch (error) {
|
|
if (isPermissionRequestNotFoundError(error)) return
|
|
// Swallow: a failed dismissal must not block the send. The next
|
|
// permission.asked / permission.replied event reconciles the store.
|
|
console.error("[session-actions] Failed to dismiss open permission on send:", error)
|
|
}
|
|
}),
|
|
)
|
|
return true
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 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")
|
|
}
|
|
// A successful reply is authoritative: the backend resolved the question,
|
|
// so clear it from the local store deterministically instead of waiting
|
|
// for the SSE `question.replied` event. A lost event (SSE gap) would leave
|
|
// the question pending forever, which keeps the session in "waiting for
|
|
// answer" — the next task's thinking and final response never render
|
|
// (issues #2911, #2448). The later SSE event is a no-op (the reducer only
|
|
// removes when present).
|
|
removeQuestionRequestFromChildStores(sessionId, requestId)
|
|
} catch (error) {
|
|
if (isQuestionRequestNotFoundError(error)) {
|
|
removeQuestionRequestFromChildStores(sessionId, requestId)
|
|
recoverStaleBlockingRequest(sessionId)
|
|
}
|
|
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")
|
|
}
|
|
// A successful rejection is authoritative: the backend resolved the
|
|
// question, so clear it from the local store deterministically (see
|
|
// respondToQuestion for the lost-SSE-event rationale — issues #2911,
|
|
// #2448). The later SSE `question.rejected` event is a no-op.
|
|
removeQuestionRequestFromChildStores(sessionId, requestId)
|
|
} catch (error) {
|
|
if (isQuestionRequestNotFoundError(error)) {
|
|
removeQuestionRequestFromChildStores(sessionId, requestId)
|
|
recoverStaleBlockingRequest(sessionId)
|
|
}
|
|
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`.
|
|
*
|
|
* A successful reject clears the local store deterministically (see
|
|
* {@link rejectQuestion}) so a lost `question.rejected` SSE event cannot leave
|
|
* the session in the pending "waiting for answer" state (issues #2911, #2448).
|
|
*/
|
|
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()
|
|
|
|
const localTarget = state.message[sessionId]?.find((message) => message.id === messageId)
|
|
const targetMessage = localTarget
|
|
?? (await fetchSessionMessages(sessionId, directory)).find((message) => message.id === messageId)
|
|
if (!targetMessage) throw new Error(`Cannot revert session: message ${messageId} was not found`)
|
|
|
|
// 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>> = []
|
|
let submittedContextParts: readonly ContextCarrierPart[] = []
|
|
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>>
|
|
// Attached context (review comments, quotes, terminal selections) rides in
|
|
// synthetic text parts and belongs back on the composer chips.
|
|
submittedContextParts = parts
|
|
}
|
|
|
|
// 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
|
|
const draftTarget: InlineCommentDraftTarget | null = directory
|
|
? { directory, sessionKey: sessionId }
|
|
: null
|
|
const prevDrafts = draftTarget ? useInlineCommentDraftStore.getState().getDrafts(draftTarget) : []
|
|
|
|
// 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)
|
|
if (draftTarget) restoreContextPartsToInput(submittedContextParts, draftTarget)
|
|
|
|
// Call SDK and merge authoritative result into store
|
|
try {
|
|
// Descendants go first because OpenCode also restores file snapshots during
|
|
// revert. All sessions share a directory, so the parent's snapshot must win.
|
|
await cascadeRevertToDescendants(sessionId, targetMessage.time.created)
|
|
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,
|
|
})
|
|
if (draftTarget) {
|
|
useInlineCommentDraftStore.getState().clearDrafts(draftTarget)
|
|
useInlineCommentDraftStore.getState().restoreDrafts(draftTarget, prevDrafts)
|
|
}
|
|
throw err
|
|
}
|
|
}
|
|
|
|
export async function refetchSessionMessages(sessionId: string): Promise<void> {
|
|
const { store, directory } = dirStoreForSession(sessionId)
|
|
const loader = getImperativeSessionMessageLoader()
|
|
if (loader && directory) {
|
|
await loader.refreshTail({ directory, sessionID: sessionId }, MESSAGE_REFETCH_LIMIT)
|
|
const snapshot = loader.getSnapshot({ directory, sessionID: sessionId })
|
|
if (snapshot.status === "error") throw snapshot.error ?? new Error("Session message refresh failed")
|
|
return
|
|
}
|
|
|
|
// Actions can run in isolated tests before SyncProvider binds the shared
|
|
// loader. The application runtime always takes the shared path above.
|
|
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
|
|
}
|
|
}
|
|
|
|
// Descendants go first because unrevert can also restore shared file state.
|
|
// Applying the parent last leaves the working tree at the parent's snapshot.
|
|
await cascadeUnrevertToDescendants(sessionId)
|
|
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)
|
|
// The forked session is a fresh draft target, so the attached context of the
|
|
// forked message follows the text into its composer.
|
|
if (directory) {
|
|
restoreContextPartsToInput(parts, { directory, sessionKey: forkedSession.id })
|
|
}
|
|
}
|
|
|
|
export async function fetchMessagesForSession(sessionID: string, directory?: string | null): Promise<void> {
|
|
const resolvedDir = directory ?? dir()
|
|
if (!resolvedDir) return
|
|
await getImperativeSessionMessageLoader()?.ensure(
|
|
{ directory: resolvedDir, sessionID },
|
|
{ reason: "navigation" },
|
|
)
|
|
}
|