Files
openchamber/packages/ui/src/sync/session-actions.ts
T

783 lines
27 KiB
TypeScript
Raw Normal View History

/**
* Session actions — SDK-calling operations for session management.
* Replaces the action methods from the old useSessionStore.
*/
import type { OpencodeClient, Session, Message, Part } from "@opencode-ai/sdk/v2/client"
import { Binary } from "./binary"
import { useSessionUIStore } from "./session-ui-store"
import { useInputStore } from "./input-store"
import type { ChildStoreManager } from "./child-store"
import { opencodeClient } from "@/lib/opencode/client"
import { useGlobalSessionsStore } from "@/stores/useGlobalSessionsStore"
import { useConfigStore } from "@/stores/useConfigStore"
import { registerSessionDirectory } from "./sync-refs"
import { isSyntheticPart } from "@/lib/messages/synthetic"
2026-05-08 14:53:48 +03:00
import { materializeSessionSnapshots } from "./materialization"
import { stripMessageDiffSnapshots } from "./sanitize"
const MESSAGE_REFETCH_LIMIT = 200
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 = () => ""
let _optimisticAdd: ((input: { sessionID: string; message: Message; parts: Part[] }) => void) | null = null
let _optimisticRemove: ((input: { sessionID: string; messageID: string }) => void) | null = null
const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms))
export function setActionRefs(
sdk: OpencodeClient,
childStores: ChildStoreManager,
getDirectory: () => string,
) {
_sdk = sdk
_childStores = childStores
_getDirectory = getDirectory
}
export function setOptimisticRefs(
add: (input: { sessionID: string; message: Message; parts: Part[] }) => void,
remove: (input: { sessionID: string; messageID: string }) => void,
) {
_optimisticAdd = add
_optimisticRemove = remove
}
function sdk() {
if (!_sdk) throw new Error("SDK not initialized — is SyncProvider mounted?")
return _sdk
}
function dirStore() {
if (!_childStores) throw new Error("Child stores not initialized")
const d = _getDirectory()
if (!d) throw new Error("No current directory")
return _childStores.ensureChild(d)
}
function dir() {
return _getDirectory() || undefined
}
function connectionLostError(): Error {
const { 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
2026-04-25 16:56:37 +03:00
// 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
2026-04-25 16:56:37 +03:00
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()
}
function getSessionDirectory(sessionId: string): string | undefined {
return useSessionUIStore.getState().getDirectoryForSession(sessionId) || dir()
}
function getDirectoryStore(directory?: string) {
if (!_childStores) throw new Error("Child stores not initialized")
const resolvedDirectory = directory || _getDirectory()
if (!resolvedDirectory) throw new Error("No current directory")
return _childStores.ensureChild(resolvedDirectory)
}
function getSessionReplyClient(sessionId?: string): OpencodeClient {
const directory = sessionId
? useSessionUIStore.getState().getDirectoryForSession(sessionId)
: null
if (directory) {
return opencodeClient.getScopedSdkClient(directory)
}
return sdk()
}
function resolveDirectoryForBlockingRequest(
type: "permission" | "question",
sessionId: string,
requestId: string,
): string | null {
const stores = _childStores
if (!stores || !requestId) {
return null
}
for (const [directory, store] of stores.children) {
const state = store.getState()
const requestMap = type === "permission" ? state.permission : state.question
for (const requests of Object.values(requestMap) as Array<Array<{ id: string }> | undefined>) {
if (requests?.some((request) => request.id === requestId)) {
return directory
}
}
}
const sessionDirectory = useSessionUIStore.getState().getDirectoryForSession(sessionId)
if (sessionDirectory) {
return sessionDirectory
}
for (const [directory, store] of stores.children) {
const state = store.getState()
if (
state.session.some((session) => session.id === sessionId)
|| Object.prototype.hasOwnProperty.call(state.message, sessionId)
|| Object.prototype.hasOwnProperty.call(state.session_status ?? {}, sessionId)
|| Object.prototype.hasOwnProperty.call(state.permission ?? {}, sessionId)
|| Object.prototype.hasOwnProperty.call(state.question ?? {}, sessionId)
) {
return directory
}
}
return null
}
function getRequestReplyClient(
type: "permission" | "question",
sessionId: string,
requestId: string,
): OpencodeClient {
const requestDirectory = resolveDirectoryForBlockingRequest(type, sessionId, requestId)
if (requestDirectory) {
return opencodeClient.getScopedSdkClient(requestDirectory)
}
return getSessionReplyClient(sessionId)
}
// ---------------------------------------------------------------------------
// Session CRUD
// ---------------------------------------------------------------------------
export async function createSession(
title?: string,
directoryOverride?: string | null,
parentID?: string | null,
): Promise<Session | null> {
try {
const result = await sdk().session.create({
directory: directoryOverride ?? dir(),
title,
parentID: parentID ?? undefined,
})
const session = result.data
if (!session) return null
const sessionDirectory = (session as { directory?: string }).directory ?? directoryOverride ?? null
// Pre-populate routing index so SSE events arriving before session.created
// can be routed to the correct child store
if (sessionDirectory) {
registerSessionDirectory(session.id, sessionDirectory)
}
useSessionUIStore.getState().setCurrentSession(session.id, sessionDirectory)
useSessionUIStore.getState().markSessionAsOpenChamberCreated(session.id)
useGlobalSessionsStore.getState().upsertSession(session)
return session
} catch (error) {
console.error("[session-actions] createSession failed", error)
return null
}
}
/** Optimistically remove a session from the child store list. Returns previous list for rollback. */
function optimisticRemoveSession(sessionId: string, directory?: string): Session[] | null {
const store = getDirectoryStore(directory)
const current = store.getState()
const sessions = [...current.session]
const result = Binary.search(sessions, sessionId, (s) => s.id)
if (result.found) {
const snapshot = current.session
sessions.splice(result.index, 1)
store.setState({ session: sessions })
return snapshot
}
return null
}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
export async function deleteSession(sessionId: string, _options?: Record<string, unknown>): Promise<boolean> {
const sessionDirectory = getSessionDirectory(sessionId)
// Remove from UI immediately, rollback on error
let snapshot = optimisticRemoveSession(sessionId, sessionDirectory)
let removedFromDir: string | null = snapshot ? (sessionDirectory ?? null) : null
// If the session wasn't in the resolved directory (e.g. archived session
// whose original child store was disposed), search all child stores.
if (!snapshot && _childStores) {
for (const [dir, store] of _childStores.children.entries()) {
const current = store.getState()
const sessions = [...current.session]
const result = Binary.search(sessions, sessionId, (s) => s.id)
if (result.found) {
snapshot = current.session
sessions.splice(result.index, 1)
store.setState({ session: sessions })
removedFromDir = dir
break
}
}
}
const ui = useSessionUIStore.getState()
if (ui.currentSessionId === sessionId) {
ui.setCurrentSession(null)
}
try {
await sdk().session.delete({ sessionID: sessionId, directory: sessionDirectory })
useGlobalSessionsStore.getState().removeSessions([sessionId])
return true
} catch (error) {
console.error("[session-actions] deleteSession failed", error)
if (snapshot && removedFromDir) {
try {
getDirectoryStore(removedFromDir).setState({ session: snapshot })
} catch {
// child store may have been disposed since — ignore rollback
}
}
return false
}
}
/** Delete a session specifying which directory it lives in. Used by agent groups for cross-directory deletes. */
export async function deleteSessionInDirectory(sessionId: string, directory: string): Promise<boolean> {
if (!_childStores) return false
const store = _childStores.ensureChild(directory)
const current = store.getState()
const sessions = [...current.session]
const result = Binary.search(sessions, sessionId, (s) => s.id)
let snapshot: Session[] | null = null
if (result.found) {
snapshot = current.session
sessions.splice(result.index, 1)
store.setState({ session: sessions })
}
const ui = useSessionUIStore.getState()
if (ui.currentSessionId === sessionId) ui.setCurrentSession(null)
try {
await sdk().session.delete({ sessionID: sessionId, directory })
useGlobalSessionsStore.getState().removeSessions([sessionId])
return true
} catch (error) {
console.error("[session-actions] deleteSessionInDirectory failed", error)
if (snapshot) store.setState({ session: snapshot })
return false
}
}
export async function archiveSession(sessionId: string): Promise<boolean> {
const sessionDirectory = getSessionDirectory(sessionId)
const snapshot = optimisticRemoveSession(sessionId, sessionDirectory)
const ui = useSessionUIStore.getState()
if (ui.currentSessionId === sessionId) {
ui.setCurrentSession(null)
}
try {
const archivedAt = Date.now()
await sdk().session.update({ sessionID: sessionId, directory: sessionDirectory, time: { archived: archivedAt } })
useGlobalSessionsStore.getState().archiveSessions([sessionId], archivedAt)
return true
} catch (error) {
console.error("[session-actions] archiveSession failed", error)
if (snapshot) getDirectoryStore(sessionDirectory).setState({ session: snapshot })
return false
}
}
export async function updateSessionTitle(sessionId: string, title: string): Promise<void> {
const sessionDirectory = getSessionDirectory(sessionId)
const result = await sdk().session.update({ sessionID: sessionId, directory: sessionDirectory, title })
if (result.data) {
useGlobalSessionsStore.getState().upsertSession(result.data)
}
}
export async function shareSession(sessionId: string): Promise<Session | null> {
const sessionDirectory = getSessionDirectory(sessionId)
const result = await sdk().session.share({ sessionID: sessionId, directory: sessionDirectory })
if (result.data) {
useGlobalSessionsStore.getState().upsertSession(result.data)
}
return result.data ?? null
}
export async function unshareSession(sessionId: string): Promise<Session | null> {
const sessionDirectory = getSessionDirectory(sessionId)
const result = await sdk().session.unshare({ sessionID: sessionId, directory: sessionDirectory })
if (result.data) {
useGlobalSessionsStore.getState().upsertSession(result.data)
}
return result.data ?? null
}
// ---------------------------------------------------------------------------
// Optimistic message send — insert user message before API call, rollback on error
// ---------------------------------------------------------------------------
// ID generator matching OpenCode's Identifier.ascending format.
// Uses BigInt(timestamp) * 0x1000 + counter, encoded as 6 hex bytes + random base62.
// This ensures client-generated IDs sort correctly with server-generated ones.
let lastIdTimestamp = 0
let idCounter = 0
function ascendingId(prefix: string): string {
const now = Date.now()
if (now !== lastIdTimestamp) {
lastIdTimestamp = now
idCounter = 0
}
idCounter += 1
const value = BigInt(now) * BigInt(0x1000) + BigInt(idCounter)
const bytes = new Uint8Array(6)
for (let i = 0; i < 6; i++) {
bytes[i] = Number((value >> BigInt(40 - 8 * i)) & BigInt(0xff))
}
let hex = ""
for (let i = 0; i < bytes.length; i++) {
hex += bytes[i].toString(16).padStart(2, "0")
}
const chars = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"
let rand = ""
for (let i = 0; i < 14; i++) {
rand += chars[Math.floor(Math.random() * 62)]
}
return `${prefix}_${hex}${rand}`
}
/**
* Wraps an async send operation with optimistic user-message insertion.
* Uses useSync()'s optimistic infrastructure — message + parts are inserted
* into the store AND registered in the shadow Map. mergeOptimisticPage
* handles deduplication when the server echoes back the real message.
*/
export async function optimisticSend(input: {
sessionId: string
content: string
providerID: string
modelID: string
agent?: string
files?: Array<{ type: "file"; mime: string; url: string; filename: string }>
/** The actual API call — receives the optimistic messageID so the server can use the same ID */
send: (messageID: string) => Promise<void>
}): Promise<void> {
if (!_optimisticAdd || !_optimisticRemove) {
throw new Error("Optimistic refs not set — is useSync() mounted?")
}
await waitForConnectionOrThrow()
const store = dirStore()
const messageID = ascendingId("msg")
const textPartId = ascendingId("prt")
const optimisticParts: Part[] = [
{ id: textPartId, type: "text", text: input.content } as Part,
]
if (input.files) {
for (const f of input.files) {
optimisticParts.push({ id: ascendingId("prt"), type: "file", mime: f.mime, url: f.url, filename: f.filename } as Part)
}
}
const optimisticMessage = {
id: messageID,
role: "user" as const,
sessionID: input.sessionId,
parentID: "",
modelID: input.modelID,
providerID: input.providerID,
system: "",
agent: input.agent ?? "",
model: `${input.providerID}/${input.modelID}`,
metadata: {} as Record<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,
message: optimisticMessage,
parts: optimisticParts,
})
// Set busy status
const current = store.getState()
store.setState({
session_status: {
...current.session_status,
[input.sessionId]: { type: "busy" as const },
},
})
try {
await input.send(messageID)
} catch (error) {
// Rollback via optimistic infrastructure
_optimisticRemove({
sessionID: input.sessionId,
messageID,
})
const s = store.getState()
store.setState({
session_status: {
...s.session_status,
[input.sessionId]: { type: "idle" as const },
},
})
throw error
}
}
// ---------------------------------------------------------------------------
// Abort
// ---------------------------------------------------------------------------
export async function abortCurrentOperation(sessionId: string): Promise<void> {
try {
await sdk().session.abort({ sessionID: sessionId, directory: dir() })
} catch (error) {
console.error("[session-actions] abort failed", error)
}
}
// ---------------------------------------------------------------------------
// Permissions
// ---------------------------------------------------------------------------
export async function respondToPermission(
sessionId: string,
requestId: string,
response: "once" | "always" | "reject",
): Promise<void> {
await waitForConnectionOrThrow()
const directory = resolveDirectoryForBlockingRequest("permission", sessionId, requestId)
|| getSessionDirectory(sessionId)
|| dir()
const result = await getRequestReplyClient("permission", sessionId, requestId).permission.reply({
requestID: requestId,
reply: response,
...(directory ? { directory } : {}),
})
if (!result.data) {
throw new Error("Permission reply failed")
}
}
export async function dismissPermission(
sessionId: string,
requestId: string,
): Promise<void> {
await waitForConnectionOrThrow()
const directory = resolveDirectoryForBlockingRequest("permission", sessionId, requestId)
|| getSessionDirectory(sessionId)
|| dir()
const result = await getRequestReplyClient("permission", sessionId, requestId).permission.reply({
requestID: requestId,
reply: "reject",
...(directory ? { directory } : {}),
})
if (!result.data) {
throw new Error("Permission dismissal failed")
}
}
// ---------------------------------------------------------------------------
// Questions
// ---------------------------------------------------------------------------
export async function respondToQuestion(
sessionId: string,
requestId: string,
answers: string[] | string[][],
): Promise<void> {
await waitForConnectionOrThrow()
const directory = resolveDirectoryForBlockingRequest("question", sessionId, requestId)
|| getSessionDirectory(sessionId)
|| dir()
const result = await getRequestReplyClient("question", sessionId, requestId).question.reply({
requestID: requestId,
answers: answers as Array<Array<string>>,
...(directory ? { directory } : {}),
})
if (!result.data) {
throw new Error("Question reply failed")
}
}
export async function rejectQuestion(
sessionId: string,
requestId: string,
): Promise<void> {
await waitForConnectionOrThrow()
const directory = resolveDirectoryForBlockingRequest("question", sessionId, requestId)
|| getSessionDirectory(sessionId)
|| dir()
const result = await getRequestReplyClient("question", sessionId, requestId).question.reject({
requestID: requestId,
...(directory ? { directory } : {}),
})
if (!result.data) {
throw new Error("Question rejection failed")
}
}
// ---------------------------------------------------------------------------
// Message history
// ---------------------------------------------------------------------------
/**
* Revert to a specific user message.
*
* 1. Abort if session is busy
* 2. Extract text from the target message for prompt restoration
* 3. Optimistically set revert marker so messages hide immediately
* 4. Call SDK session.revert() and merge returned session
* 5. Set pendingInputText so the reverted message text appears in the input
*/
export async function revertToMessage(sessionId: string, messageId: string): Promise<void> {
const store = dirStore()
const state = store.getState()
// Abort if busy before mutating session state
const status = state.session_status[sessionId]
if (status && status.type !== "idle") {
try {
await sdk().session.abort({ sessionID: sessionId, directory: dir() })
} catch {
// ignore abort errors
}
}
// Extract message text for prompt restoration (only non-synthetic text parts —
// the server adds file content as synthetic text parts that should not be restored)
const messages = state.message[sessionId] ?? []
const targetMsg = messages.find((m) => m.id === messageId)
let messageText = ""
let submittedFileParts: Array<Record<string, unknown>> = []
if (targetMsg && targetMsg.role === "user") {
const parts = state.part[messageId] ?? []
const textParts = parts.filter((p) => p.type === "text" && !isSyntheticPart(p))
messageText = textParts
.map((p: Record<string, unknown>) => (p as { text?: string }).text || (p as { content?: string }).content || "")
.join("\n")
.trim()
// Snapshot file parts for later restoration to the input (line ~626).
// File parts (type="file") contain url/mime/filename — the file already
// exists on the server so we record it as a "server" source attachment.
submittedFileParts = parts.filter((p) => p.type === "file") as Array<Record<string, unknown>>
}
// Optimistically set only the revert marker. Keep messages and parts in the
// local store; visible-message selectors derive the displayed timeline from
// session.revert. This matches the server model and preserves reverted
// messages for the restore dock without maintaining a separate shadow copy.
const prevRevert = (() => {
const s = state.session.find((s) => s.id === sessionId)
return (s as Session & { revert?: unknown })?.revert
})()
const sessions = [...state.session]
const sessionIdx = sessions.findIndex((s) => s.id === sessionId)
const patch: Record<string, unknown> = {}
if (sessionIdx >= 0) {
sessions[sessionIdx] = { ...sessions[sessionIdx], revert: { messageID: messageId } } as Session
patch.session = sessions
}
store.setState(patch)
// Save input store state before mutations — if the API fails we need to
// roll back both text and attachments to their previous values.
const prevInputAttachments = [...useInputStore.getState().attachedFiles]
const prevInputText = useInputStore.getState().pendingInputText
const prevInputMode = useInputStore.getState().pendingInputMode
// Restore reverted message text and file attachments to input
if (messageText) {
useInputStore.setState({
pendingInputText: messageText,
pendingInputMode: "replace" as const,
})
}
// Restore file/image attachments from the target message.
// Clear existing attachments first — previous revert's attachments
// must not carry over, even when the current message has no files.
useInputStore.getState().clearAttachedFiles()
for (const fp of submittedFileParts) {
const f = fp as Record<string, unknown>
const url = typeof f.url === "string" ? f.url : ""
const mime = typeof f.mime === "string" ? f.mime : "application/octet-stream"
const filename = typeof f.filename === "string" ? f.filename : "attachment"
if (url) {
useInputStore.getState().addRestoredAttachment({ url, mimeType: mime, filename })
}
}
// Call SDK and merge authoritative result into store
try {
const result = await sdk().session.revert({ sessionID: sessionId, directory: dir(), messageID: messageId })
if (result.data) {
const current = store.getState()
const updated = [...current.session]
const idx = updated.findIndex((s) => s.id === sessionId)
if (idx >= 0) {
updated[idx] = result.data
store.setState({ session: updated })
}
}
} catch (err) {
// Rollback: restore removed messages + revert marker
const current = store.getState()
const rollback = [...current.session]
const idx = rollback.findIndex((s) => s.id === sessionId)
if (idx >= 0) {
rollback[idx] = { ...rollback[idx], revert: prevRevert } as Session
}
store.setState({
session: rollback,
})
// Rollback input store: restore previous text and attachments
useInputStore.setState({
pendingInputText: prevInputText,
pendingInputMode: prevInputMode,
attachedFiles: prevInputAttachments,
})
throw err
}
}
2026-05-08 14:53:48 +03:00
export async function refetchSessionMessages(sessionId: string): Promise<void> {
const store = dirStore()
const result = await sdk().session.messages({ sessionID: sessionId, directory: dir(), limit: MESSAGE_REFETCH_LIMIT })
const records = (result.data ?? []).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 = dirStore()
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: dir() })
} catch {
// ignore
}
}
const result = await sdk().session.unrevert({ sessionID: sessionId, directory: dir() })
if (result.data) {
const current = store.getState()
const sessions = [...current.session]
const idx = sessions.findIndex((s) => s.id === sessionId)
if (idx >= 0) {
sessions[idx] = result.data
store.setState({ session: sessions })
}
}
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 SDK session.fork()
* 3. Insert the new session into the child store (so sidebar updates immediately)
* 4. Switch to new session and set pending input text
*/
export async function forkFromMessage(sessionId: string, messageId: string): Promise<void> {
const store = dirStore()
const state = store.getState()
// Extract message text for input restoration (only non-synthetic text parts —
// the server adds file content as synthetic text parts that should not 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 result = await sdk().session.fork({ sessionID: sessionId, directory: dir(), messageID: messageId })
if (!result.data) return
const forkedSession = result.data
// Insert new session into child store so sidebar updates immediately
const current = store.getState()
const sessions = [...current.session]
const searchResult = Binary.search(sessions, forkedSession.id, (s) => s.id)
if (!searchResult.found) {
sessions.splice(searchResult.index, 0, forkedSession)
store.setState({ session: sessions })
}
// Switch to new session
useSessionUIStore.getState().setCurrentSession(forkedSession.id)
// Restore forked message text to input
if (messageText) {
useInputStore.setState({
pendingInputText: messageText,
pendingInputMode: "replace" as const,
})
}
}