Decouple bundled UI from runtime API and add remote instance tooling (#1228)
Add a packaged-client runtime boundary so the shared UI can talk to local, desktop, remote, and VS Code runtimes through the right transport instead of assuming one same-origin web server. Centralize OpenChamber-owned API access behind RuntimeAPIs, runtimeFetch, and runtime URL helpers, while keeping official OpenCode traffic on the SDK path. Support runtime switching, remote host selection, desktop client credentials, and headless connection links for pairing packaged clients with remote OpenChamber servers. Harden the new auth model by moving long-lived client tokens out of browser URLs, introducing short-lived scoped URL tokens for browser-owned transports, restricting URL-token access to explicit readable/realtime routes, and making client-token management session-scoped or self-scoped as appropriate. Update browser-owned assets and preview proxy flows to work with the split runtime model, including authenticated project icons, preview token propagation, CSP-safe preview bridge injection, and preview proxy auth that survives short-lived URL-token expiry. Tighten Electron security boundaries for packaged clients by gating privileged preload state to trusted origins and requiring explicit confirmation before connect deep-links import or switch remote runtimes. Also refresh agent guidance and project skills so future runtime/API, auth, preview, UI, CLI, settings, locale, and drag-to-reorder work follows the new architecture.
This commit is contained in:
committed by
GitHub
parent
a4314c189b
commit
2031e3b4a8
@@ -31,6 +31,46 @@ let _optimisticRemove: ((input: { sessionID: string; messageID: string }) => voi
|
||||
|
||||
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
|
||||
throw new Error(`${operation} failed${status ? ` (${status})` : ""}: ${formatSdkError(result.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,
|
||||
@@ -61,6 +101,20 @@ function dirStore() {
|
||||
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() }
|
||||
}
|
||||
|
||||
function dir() {
|
||||
return _getDirectory() || undefined
|
||||
}
|
||||
@@ -96,15 +150,47 @@ export async function waitForConnectionOrThrow(): Promise<void> {
|
||||
throw connectionLostError()
|
||||
}
|
||||
|
||||
function getSessionDirectory(sessionId: string): string | undefined {
|
||||
return useSessionUIStore.getState().getDirectoryForSession(sessionId) || dir()
|
||||
type SessionListSnapshot = {
|
||||
directory: string
|
||||
sessions: Session[]
|
||||
}
|
||||
|
||||
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)
|
||||
type DirectoryStoreApi = ReturnType<ChildStoreManager["ensureChild"]>
|
||||
|
||||
function getGlobalSessionSnapshot(sessionId: string): Session | null {
|
||||
const global = useGlobalSessionsStore.getState()
|
||||
return [...global.activeSessions, ...global.archivedSessions].find((session) => session.id === sessionId) ?? null
|
||||
}
|
||||
|
||||
function restoreGlobalSessionSnapshot(session: Session | null): void {
|
||||
if (!session) return
|
||||
useGlobalSessionsStore.getState().upsertSession(session)
|
||||
}
|
||||
|
||||
function getSessionDirectory(sessionId: string): string | undefined {
|
||||
return findSessionDirectoryInChildStores(sessionId)
|
||||
|| useSessionUIStore.getState().getDirectoryForSession(sessionId)
|
||||
|| dir()
|
||||
}
|
||||
|
||||
function findSessionDirectoryInChildStores(sessionId: string): string | null {
|
||||
const stores = _childStores
|
||||
if (!stores || !sessionId) return null
|
||||
|
||||
for (const [directory, store] of stores.children) {
|
||||
const state = store.getState()
|
||||
if (
|
||||
state.session.some((session) => session.id === sessionId)
|
||||
|| Object.prototype.hasOwnProperty.call(state.message, sessionId)
|
||||
|| Object.prototype.hasOwnProperty.call(state.session_status ?? {}, sessionId)
|
||||
|| Object.prototype.hasOwnProperty.call(state.permission ?? {}, sessionId)
|
||||
|| Object.prototype.hasOwnProperty.call(state.question ?? {}, sessionId)
|
||||
) {
|
||||
return directory
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
function getSessionReplyClient(sessionId?: string): OpencodeClient {
|
||||
@@ -192,13 +278,10 @@ export async function createSession(
|
||||
parentID?: string | null,
|
||||
): Promise<Session | null> {
|
||||
try {
|
||||
const result = await sdk().session.create({
|
||||
directory: directoryOverride ?? dir(),
|
||||
const session = await opencodeClient.createSession({
|
||||
title,
|
||||
parentID: parentID ?? undefined,
|
||||
})
|
||||
const session = result.data
|
||||
if (!session) return null
|
||||
}, directoryOverride ?? dir())
|
||||
|
||||
const sessionDirectory = (session as { directory?: string }).directory ?? directoryOverride ?? null
|
||||
// Pre-populate routing index so SSE events arriving before session.created
|
||||
@@ -216,62 +299,70 @@ export async function createSession(
|
||||
}
|
||||
}
|
||||
|
||||
/** 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
|
||||
/** Optimistically remove a session from every live child store that has it. */
|
||||
function optimisticRemoveSession(sessionId: string, preferredDirectory?: string): SessionListSnapshot[] {
|
||||
if (!_childStores) return []
|
||||
|
||||
const snapshots: SessionListSnapshot[] = []
|
||||
const visited = new Set<string>()
|
||||
const candidates: Array<[string, DirectoryStoreApi]> = []
|
||||
|
||||
if (preferredDirectory) {
|
||||
const preferredStore = _childStores.children.get(preferredDirectory)
|
||||
if (preferredStore) {
|
||||
candidates.push([preferredDirectory, preferredStore])
|
||||
visited.add(preferredDirectory)
|
||||
}
|
||||
}
|
||||
|
||||
for (const entry of _childStores.children.entries()) {
|
||||
if (visited.has(entry[0])) continue
|
||||
candidates.push(entry)
|
||||
}
|
||||
|
||||
for (const [directory, store] of candidates) {
|
||||
const current = store.getState()
|
||||
if (!current.session.some((session) => session.id === sessionId)) {
|
||||
continue
|
||||
}
|
||||
snapshots.push({ directory, sessions: current.session })
|
||||
store.setState({ session: current.session.filter((session) => session.id !== sessionId) })
|
||||
}
|
||||
|
||||
return snapshots
|
||||
}
|
||||
|
||||
function restoreSessionListSnapshots(snapshots: SessionListSnapshot[]): void {
|
||||
if (!_childStores) return
|
||||
for (const snapshot of snapshots) {
|
||||
const store = _childStores.children.get(snapshot.directory)
|
||||
if (!store) continue
|
||||
store.setState({ session: snapshot.sessions })
|
||||
}
|
||||
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 snapshots = optimisticRemoveSession(sessionId, sessionDirectory)
|
||||
const globalSnapshot = getGlobalSessionSnapshot(sessionId)
|
||||
useGlobalSessionsStore.getState().removeSessions([sessionId])
|
||||
|
||||
const ui = useSessionUIStore.getState()
|
||||
if (ui.currentSessionId === sessionId) {
|
||||
ui.setCurrentSession(null)
|
||||
}
|
||||
try {
|
||||
await sdk().session.delete({ sessionID: sessionId, directory: sessionDirectory })
|
||||
const deleted = await opencodeClient.deleteSession(sessionId, sessionDirectory)
|
||||
if (deleted !== true) {
|
||||
throw new Error("session.delete failed: server did not confirm deletion")
|
||||
}
|
||||
useGlobalSessionsStore.getState().removeSessions([sessionId])
|
||||
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
|
||||
}
|
||||
}
|
||||
restoreSessionListSnapshots(snapshots)
|
||||
restoreGlobalSessionSnapshot(globalSnapshot)
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -279,72 +370,71 @@ export async function deleteSession(sessionId: string, _options?: Record<string,
|
||||
/** 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 snapshots = optimisticRemoveSession(sessionId, directory)
|
||||
const globalSnapshot = getGlobalSessionSnapshot(sessionId)
|
||||
useGlobalSessionsStore.getState().removeSessions([sessionId])
|
||||
const ui = useSessionUIStore.getState()
|
||||
if (ui.currentSessionId === sessionId) ui.setCurrentSession(null)
|
||||
try {
|
||||
await sdk().session.delete({ sessionID: sessionId, directory })
|
||||
const deleted = await opencodeClient.deleteSession(sessionId, directory)
|
||||
if (deleted !== true) {
|
||||
throw new Error("session.delete failed: server did not confirm deletion")
|
||||
}
|
||||
useGlobalSessionsStore.getState().removeSessions([sessionId])
|
||||
return true
|
||||
} catch (error) {
|
||||
console.error("[session-actions] deleteSessionInDirectory failed", error)
|
||||
if (snapshot) store.setState({ session: snapshot })
|
||||
restoreSessionListSnapshots(snapshots)
|
||||
restoreGlobalSessionSnapshot(globalSnapshot)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
export async function archiveSession(sessionId: string): Promise<boolean> {
|
||||
const sessionDirectory = getSessionDirectory(sessionId)
|
||||
const snapshot = optimisticRemoveSession(sessionId, sessionDirectory)
|
||||
const snapshots = optimisticRemoveSession(sessionId, sessionDirectory)
|
||||
const globalSnapshot = getGlobalSessionSnapshot(sessionId)
|
||||
const archivedAt = Date.now()
|
||||
useGlobalSessionsStore.getState().archiveSessions([sessionId], archivedAt)
|
||||
const ui = useSessionUIStore.getState()
|
||||
if (ui.currentSessionId === sessionId) {
|
||||
ui.setCurrentSession(null)
|
||||
}
|
||||
try {
|
||||
const archivedAt = Date.now()
|
||||
await sdk().session.update({ sessionID: sessionId, directory: sessionDirectory, time: { archived: archivedAt } })
|
||||
useGlobalSessionsStore.getState().archiveSessions([sessionId], archivedAt)
|
||||
const archived = await opencodeClient.updateSession(sessionId, { time: { archived: archivedAt } }, sessionDirectory)
|
||||
if (!archived) {
|
||||
throw new Error("session.update failed: server did not return the archived session")
|
||||
}
|
||||
useGlobalSessionsStore.getState().upsertSession(archived)
|
||||
return true
|
||||
} catch (error) {
|
||||
console.error("[session-actions] archiveSession failed", error)
|
||||
if (snapshot) getDirectoryStore(sessionDirectory).setState({ session: snapshot })
|
||||
restoreSessionListSnapshots(snapshots)
|
||||
restoreGlobalSessionSnapshot(globalSnapshot)
|
||||
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)
|
||||
}
|
||||
const session = await opencodeClient.updateSession(sessionId, { title }, sessionDirectory)
|
||||
useGlobalSessionsStore.getState().upsertSession(session)
|
||||
}
|
||||
|
||||
export async function shareSession(sessionId: string): Promise<Session | null> {
|
||||
const sessionDirectory = getSessionDirectory(sessionId)
|
||||
const result = await sdk().session.share({ sessionID: sessionId, directory: sessionDirectory })
|
||||
if (result.data) {
|
||||
useGlobalSessionsStore.getState().upsertSession(result.data)
|
||||
}
|
||||
return result.data ?? null
|
||||
const session = assertSdkData(result, "session.share")
|
||||
useGlobalSessionsStore.getState().upsertSession(session)
|
||||
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 })
|
||||
if (result.data) {
|
||||
useGlobalSessionsStore.getState().upsertSession(result.data)
|
||||
}
|
||||
return result.data ?? null
|
||||
const session = assertSdkData(result, "session.unshare")
|
||||
useGlobalSessionsStore.getState().upsertSession(session)
|
||||
return session
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -494,12 +584,7 @@ export async function respondToPermission(
|
||||
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) {
|
||||
if (await opencodeClient.replyToPermission(requestId, response, { directory }) !== true) {
|
||||
throw new Error("Permission reply failed")
|
||||
}
|
||||
}
|
||||
@@ -512,12 +597,7 @@ export async function dismissPermission(
|
||||
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) {
|
||||
if (await opencodeClient.replyToPermission(requestId, "reject", { directory }) !== true) {
|
||||
throw new Error("Permission dismissal failed")
|
||||
}
|
||||
}
|
||||
@@ -535,12 +615,7 @@ export async function respondToQuestion(
|
||||
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) {
|
||||
if (await opencodeClient.replyToQuestion(requestId, answers, directory) !== true) {
|
||||
throw new Error("Question reply failed")
|
||||
}
|
||||
}
|
||||
@@ -557,7 +632,7 @@ export async function rejectQuestion(
|
||||
requestID: requestId,
|
||||
...(directory ? { directory } : {}),
|
||||
})
|
||||
if (!result.data) {
|
||||
if (assertSdkData(result, "question.reject") !== true) {
|
||||
throw new Error("Question rejection failed")
|
||||
}
|
||||
}
|
||||
@@ -572,18 +647,18 @@ export async function rejectQuestion(
|
||||
* 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
|
||||
* 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 = dirStore()
|
||||
const { store, directory } = dirStoreForSession(sessionId)
|
||||
const state = store.getState()
|
||||
|
||||
// Abort if busy before mutating session state
|
||||
const status = state.session_status[sessionId]
|
||||
if (status && status.type !== "idle") {
|
||||
try {
|
||||
await sdk().session.abort({ sessionID: sessionId, directory: dir() })
|
||||
await sdk().session.abort({ sessionID: sessionId, directory })
|
||||
} catch {
|
||||
// ignore abort errors
|
||||
}
|
||||
@@ -649,16 +724,13 @@ export async function revertToMessage(sessionId: string, messageId: string): Pro
|
||||
|
||||
// Call SDK and merge authoritative result into store
|
||||
try {
|
||||
const directory = dir()
|
||||
const result = await sdk().session.revert({ sessionID: sessionId, directory, 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 })
|
||||
}
|
||||
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 })
|
||||
@@ -685,9 +757,10 @@ export async function revertToMessage(sessionId: string, messageId: string): Pro
|
||||
}
|
||||
|
||||
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)
|
||||
const { store, directory } = dirStoreForSession(sessionId)
|
||||
const result = await sdk().session.messages({ sessionID: sessionId, directory, limit: MESSAGE_REFETCH_LIMIT })
|
||||
const records = (assertSdkSuccess(result, "session.messages") ?? [])
|
||||
.filter((record: { info?: { id?: string } }) => !!record?.info?.id)
|
||||
if (records.length === 0) return
|
||||
|
||||
store.setState((state) => {
|
||||
@@ -709,7 +782,7 @@ export async function refetchSessionMessages(sessionId: string): Promise<void> {
|
||||
* Restore all previously reverted messages. Aborts if busy, merges result.
|
||||
*/
|
||||
export async function unrevertSession(sessionId: string): Promise<void> {
|
||||
const store = dirStore()
|
||||
const { store, directory } = dirStoreForSession(sessionId)
|
||||
const state = store.getState()
|
||||
const previousMessageCount = state.message[sessionId]?.length ?? 0
|
||||
|
||||
@@ -717,21 +790,20 @@ export async function unrevertSession(sessionId: string): Promise<void> {
|
||||
const status = state.session_status[sessionId]
|
||||
if (status && status.type !== "idle") {
|
||||
try {
|
||||
await sdk().session.abort({ sessionID: sessionId, directory: dir() })
|
||||
await sdk().session.abort({ sessionID: sessionId, directory })
|
||||
} 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 })
|
||||
}
|
||||
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)
|
||||
@@ -745,12 +817,12 @@ export async function unrevertSession(sessionId: string): Promise<void> {
|
||||
* Fork from a user message.
|
||||
*
|
||||
* 1. Extract text from the message for input restoration
|
||||
* 2. Call SDK session.fork()
|
||||
* 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 = dirStore()
|
||||
const { store, directory } = dirStoreForSession(sessionId)
|
||||
const state = store.getState()
|
||||
|
||||
// Extract message text and file attachments for input restoration.
|
||||
@@ -766,10 +838,7 @@ export async function forkFromMessage(sessionId: string, messageId: string): Pro
|
||||
.trim()
|
||||
const fileParts = parts.filter((p) => p.type === "file" && !isSyntheticPart(p)) as Array<Record<string, unknown>>
|
||||
|
||||
const result = await sdk().session.fork({ sessionID: sessionId, directory: dir(), messageID: messageId })
|
||||
if (!result.data) return
|
||||
|
||||
const forkedSession = result.data
|
||||
const forkedSession = await opencodeClient.forkSession(sessionId, messageId, directory)
|
||||
|
||||
// Insert new session into child store so sidebar updates immediately
|
||||
const current = store.getState()
|
||||
|
||||
Reference in New Issue
Block a user