587 lines
19 KiB
TypeScript
587 lines
19 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 { DirectoryStore } from "./child-store"
|
||
|
|
import type { StoreApi } from "zustand"
|
||
|
|
import { opencodeClient } from "@/lib/opencode/client"
|
||
|
|
import { useGlobalSessionsStore } from "@/stores/useGlobalSessionsStore"
|
||
|
|
|
||
|
|
// Reference set by SyncProvider — allows actions to access SDK and stores
|
||
|
|
let _sdk: OpencodeClient | null = null
|
||
|
|
let _childStores: { ensureChild: (dir: string) => StoreApi<DirectoryStore> } | null = null
|
||
|
|
let _getDirectory: () => string = () => ""
|
||
|
|
let _optimisticAdd: ((input: { sessionID: string; message: Message; parts: Part[] }) => void) | null = null
|
||
|
|
let _optimisticRemove: ((input: { sessionID: string; messageID: string }) => void) | null = null
|
||
|
|
|
||
|
|
export function setActionRefs(
|
||
|
|
sdk: OpencodeClient,
|
||
|
|
childStores: { ensureChild: (dir: string) => StoreApi<DirectoryStore> },
|
||
|
|
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 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()
|
||
|
|
}
|
||
|
|
|
||
|
|
// ---------------------------------------------------------------------------
|
||
|
|
// 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
|
||
|
|
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
|
||
|
|
const snapshot = optimisticRemoveSession(sessionId, sessionDirectory)
|
||
|
|
const ui = useSessionUIStore.getState()
|
||
|
|
if (ui.currentSessionId === sessionId) {
|
||
|
|
ui.setCurrentSession(null)
|
||
|
|
}
|
||
|
|
try {
|
||
|
|
await sdk().session.delete({ sessionID: sessionId, directory: sessionDirectory })
|
||
|
|
useGlobalSessionsStore.getState().removeSessions([sessionId])
|
||
|
|
return true
|
||
|
|
} catch (error) {
|
||
|
|
console.error("[session-actions] deleteSession failed", error)
|
||
|
|
if (snapshot) getDirectoryStore(sessionDirectory).setState({ session: snapshot })
|
||
|
|
return false
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
/** Delete a session specifying which directory it lives in. Used by agent groups for cross-directory deletes. */
|
||
|
|
export async function deleteSessionInDirectory(sessionId: string, directory: string): Promise<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?")
|
||
|
|
}
|
||
|
|
|
||
|
|
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> {
|
||
|
|
const result = await getSessionReplyClient(sessionId).permission.reply({
|
||
|
|
requestID: requestId,
|
||
|
|
reply: response,
|
||
|
|
})
|
||
|
|
if (!result.data) {
|
||
|
|
throw new Error("Permission reply failed")
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
export async function dismissPermission(
|
||
|
|
sessionId: string,
|
||
|
|
requestId: string,
|
||
|
|
): Promise<void> {
|
||
|
|
const result = await getSessionReplyClient(sessionId).permission.reply({
|
||
|
|
requestID: requestId,
|
||
|
|
reply: "reject",
|
||
|
|
})
|
||
|
|
if (!result.data) {
|
||
|
|
throw new Error("Permission dismissal failed")
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// ---------------------------------------------------------------------------
|
||
|
|
// Questions
|
||
|
|
// ---------------------------------------------------------------------------
|
||
|
|
|
||
|
|
export async function respondToQuestion(
|
||
|
|
sessionId: string,
|
||
|
|
requestId: string,
|
||
|
|
answers: string[] | string[][],
|
||
|
|
): Promise<void> {
|
||
|
|
const result = await getSessionReplyClient(sessionId).question.reply({
|
||
|
|
requestID: requestId,
|
||
|
|
answers: answers as Array<Array<string>>,
|
||
|
|
})
|
||
|
|
if (!result.data) {
|
||
|
|
throw new Error("Question reply failed")
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
export async function rejectQuestion(
|
||
|
|
sessionId: string,
|
||
|
|
requestId: string,
|
||
|
|
): Promise<void> {
|
||
|
|
const result = await getSessionReplyClient(sessionId).question.reject({
|
||
|
|
requestID: requestId,
|
||
|
|
})
|
||
|
|
if (!result.data) {
|
||
|
|
throw new Error("Question rejection failed")
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// ---------------------------------------------------------------------------
|
||
|
|
// Message history
|
||
|
|
// ---------------------------------------------------------------------------
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Revert to a specific user message.
|
||
|
|
*
|
||
|
|
* 1. Abort if session is busy
|
||
|
|
* 2. Extract text from the target message for prompt restoration
|
||
|
|
* 3. Optimistically set revert marker so messages hide immediately
|
||
|
|
* 4. Call SDK session.revert() and merge returned session
|
||
|
|
* 5. Set pendingInputText so the reverted message text appears in the input
|
||
|
|
*/
|
||
|
|
export async function revertToMessage(sessionId: string, messageId: string): Promise<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
|
||
|
|
const messages = state.message[sessionId] ?? []
|
||
|
|
const targetMsg = messages.find((m) => m.id === messageId)
|
||
|
|
let messageText = ""
|
||
|
|
if (targetMsg && targetMsg.role === "user") {
|
||
|
|
const parts = state.part[messageId] ?? []
|
||
|
|
const textParts = parts.filter((p) => p.type === "text")
|
||
|
|
messageText = textParts
|
||
|
|
.map((p: Record<string, unknown>) => (p as { text?: string }).text || (p as { content?: string }).content || "")
|
||
|
|
.join("\n")
|
||
|
|
.trim()
|
||
|
|
}
|
||
|
|
|
||
|
|
// Optimistically remove reverted messages + set marker
|
||
|
|
const prevRevert = (() => {
|
||
|
|
const s = state.session.find((s) => s.id === sessionId)
|
||
|
|
return (s as Session & { revert?: unknown })?.revert
|
||
|
|
})()
|
||
|
|
const sessions = [...state.session]
|
||
|
|
const sessionIdx = sessions.findIndex((s) => s.id === sessionId)
|
||
|
|
|
||
|
|
// Remove messages at and after the revert point from the store
|
||
|
|
const prevMessages = state.message[sessionId] ?? []
|
||
|
|
const prevPart = { ...state.part }
|
||
|
|
const keptMessages = prevMessages.filter((m) => m.id < messageId)
|
||
|
|
const removedMessages = prevMessages.filter((m) => m.id >= messageId)
|
||
|
|
for (const m of removedMessages) {
|
||
|
|
delete prevPart[m.id]
|
||
|
|
}
|
||
|
|
|
||
|
|
const patch: Record<string, unknown> = {
|
||
|
|
message: { ...state.message, [sessionId]: keptMessages },
|
||
|
|
part: prevPart,
|
||
|
|
}
|
||
|
|
|
||
|
|
if (sessionIdx >= 0) {
|
||
|
|
sessions[sessionIdx] = { ...sessions[sessionIdx], revert: { messageID: messageId } } as Session
|
||
|
|
patch.session = sessions
|
||
|
|
}
|
||
|
|
|
||
|
|
store.setState(patch)
|
||
|
|
|
||
|
|
// Restore reverted message text to input
|
||
|
|
if (messageText) {
|
||
|
|
useInputStore.setState({
|
||
|
|
pendingInputText: messageText,
|
||
|
|
pendingInputMode: "replace" as const,
|
||
|
|
})
|
||
|
|
}
|
||
|
|
|
||
|
|
// Call SDK and merge authoritative result into store
|
||
|
|
try {
|
||
|
|
const result = await sdk().session.revert({ sessionID: sessionId, directory: dir(), messageID: messageId })
|
||
|
|
if (result.data) {
|
||
|
|
const current = store.getState()
|
||
|
|
const updated = [...current.session]
|
||
|
|
const idx = updated.findIndex((s) => s.id === sessionId)
|
||
|
|
if (idx >= 0) {
|
||
|
|
updated[idx] = result.data
|
||
|
|
store.setState({ session: updated })
|
||
|
|
}
|
||
|
|
}
|
||
|
|
} catch (err) {
|
||
|
|
// Rollback: restore removed messages + revert marker
|
||
|
|
const current = store.getState()
|
||
|
|
const rollback = [...current.session]
|
||
|
|
const idx = rollback.findIndex((s) => s.id === sessionId)
|
||
|
|
if (idx >= 0) {
|
||
|
|
rollback[idx] = { ...rollback[idx], revert: prevRevert } as Session
|
||
|
|
}
|
||
|
|
store.setState({
|
||
|
|
session: rollback,
|
||
|
|
message: { ...current.message, [sessionId]: prevMessages },
|
||
|
|
part: { ...current.part, ...Object.fromEntries(removedMessages.map((m) => [m.id, state.part[m.id] ?? []])) },
|
||
|
|
})
|
||
|
|
throw err
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Unrevert — restore all previously reverted messages.
|
||
|
|
* Restore all previously reverted messages. Aborts if busy, merges result.
|
||
|
|
*/
|
||
|
|
export async function unrevertSession(sessionId: string): Promise<void> {
|
||
|
|
const store = dirStore()
|
||
|
|
const state = store.getState()
|
||
|
|
|
||
|
|
// Abort if busy
|
||
|
|
const status = state.session_status[sessionId]
|
||
|
|
if (status && status.type !== "idle") {
|
||
|
|
try {
|
||
|
|
await sdk().session.abort({ sessionID: sessionId, directory: dir() })
|
||
|
|
} catch {
|
||
|
|
// ignore
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
const result = await sdk().session.unrevert({ sessionID: sessionId, directory: dir() })
|
||
|
|
if (result.data) {
|
||
|
|
const current = store.getState()
|
||
|
|
const sessions = [...current.session]
|
||
|
|
const idx = sessions.findIndex((s) => s.id === sessionId)
|
||
|
|
if (idx >= 0) {
|
||
|
|
sessions[idx] = result.data
|
||
|
|
store.setState({ session: sessions })
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Fork from a user message.
|
||
|
|
*
|
||
|
|
* 1. Extract text from the message for input restoration
|
||
|
|
* 2. Call SDK session.fork()
|
||
|
|
* 3. Insert the new session into the child store (so sidebar updates immediately)
|
||
|
|
* 4. Switch to new session and set pending input text
|
||
|
|
*/
|
||
|
|
export async function forkFromMessage(sessionId: string, messageId: string): Promise<void> {
|
||
|
|
const store = dirStore()
|
||
|
|
const state = store.getState()
|
||
|
|
|
||
|
|
// Extract message text for input restoration
|
||
|
|
const parts = state.part[messageId] ?? []
|
||
|
|
let messageText = ""
|
||
|
|
const textParts = parts.filter((p) => p.type === "text")
|
||
|
|
messageText = textParts
|
||
|
|
.map((p: Part) => ((p as Record<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,
|
||
|
|
})
|
||
|
|
}
|
||
|
|
}
|