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:
Bohdan Triapitsyn
2026-06-02 00:43:05 +03:00
committed by GitHub
parent a4314c189b
commit 2031e3b4a8
282 changed files with 16524 additions and 4259 deletions
@@ -15,6 +15,22 @@ function part(id: string, messageID: string, type = "text", text = id): Part {
}
describe("materializeSessionSnapshots", () => {
test("marks an empty successful page as materialized", () => {
const result = materializeSessionSnapshots(
{ message: {}, part: {} },
"ses_1",
[],
)
expect(result.message.ses_1).toEqual([])
expect(result.messagesChanged).toBe(true)
expect(getSessionMaterializationStatus(result, "ses_1")).toEqual({
hasMessages: true,
renderable: true,
missingPartMessageIDs: [],
})
})
test("materializes messages and parts together", () => {
const result = materializeSessionSnapshots(
{ message: {}, part: {} },
@@ -3,6 +3,15 @@ import { describe, expect, test } from "bun:test"
import { shouldSkipSessionPrefetch } from "../session-prefetch-cache"
describe("shouldSkipSessionPrefetch", () => {
test("does not skip when only metadata exists without cached messages", () => {
expect(shouldSkipSessionPrefetch({
hasMessages: false,
info: { limit: 200, complete: true, at: 1_000 },
pageSize: 200,
now: 1_001,
})).toBe(false)
})
test("does not skip a larger fetch when only a smaller partial prefetch is cached", () => {
expect(shouldSkipSessionPrefetch({
hasMessages: true,
+2 -1
View File
@@ -1,6 +1,7 @@
import type { OpencodeClient, PermissionRequest, Project, QuestionRequest } from "@opencode-ai/sdk/v2/client"
import { retry } from "./retry"
import type { GlobalState, State } from "./types"
import { runtimeFetch } from "../lib/runtime-fetch"
const cmp = (a: string, b: string) => (a < b ? -1 : a > b ? 1 : 0)
@@ -94,7 +95,7 @@ export async function bootstrapGlobal(
if (errors.length === results.length) {
let message = errors[0] instanceof Error ? errors[0].message : String(errors[0])
try {
const healthRes = await fetch("/health", { signal: AbortSignal.timeout(4000) })
const healthRes = await runtimeFetch('/health', { signal: AbortSignal.timeout(4000) })
if (healthRes.ok) {
const health = await healthRes.json()
if (health.lastOpenCodeError) {
+5 -31
View File
@@ -14,6 +14,7 @@
import type { Event, OpencodeClient, SessionStatus } from "@opencode-ai/sdk/v2/client"
import { opencodeClient } from "@/lib/opencode/client"
import { getRuntimeUrlResolver } from "@/lib/runtime-url"
import { syncDebug } from "./debug"
export type QueuedEvent = {
@@ -41,8 +42,6 @@ const RETRY_BACKOFF_BASE_MS = 250
const RETRY_BACKOFF_CAP_VISIBLE_MS = 5_000
const RETRY_BACKOFF_CAP_HIDDEN_OR_OFFLINE_MS = 60_000
const RETRY_BACKOFF_MAX_EXPONENT = 8
const ABSOLUTE_URL_PATTERN = /^[a-zA-Z][a-zA-Z\d+\-.]*:\/\//
export type EventPipelineInput = {
sdk: OpencodeClient
onEvent: (directory: string, payload: Event) => void
@@ -189,30 +188,6 @@ function resolveEventPayload(payload: unknown): Event | null {
return null
}
function resolveAbsoluteUrl(candidate: string): string {
const normalized = typeof candidate === "string" && candidate.trim().length > 0 ? candidate.trim() : "/api"
if (ABSOLUTE_URL_PATTERN.test(normalized)) {
return normalized
}
if (typeof window === "undefined") {
return normalized
}
const baseReference = window.location?.href || window.location?.origin
if (!baseReference) {
return normalized
}
return new URL(normalized, baseReference).toString()
}
function toWebSocketUrl(candidate: string): string {
const url = new URL(resolveAbsoluteUrl(candidate))
url.protocol = url.protocol === "https:" ? "wss:" : "ws:"
return url.toString()
}
function buildGlobalEventWsUrl(lastEventId?: string): string {
let baseUrl = "/api"
try {
@@ -224,11 +199,10 @@ function buildGlobalEventWsUrl(lastEventId?: string): string {
baseUrl = "/api"
}
const normalizedBase = baseUrl.endsWith("/") ? baseUrl : `${baseUrl}/`
const httpUrl = new URL("global/event/ws", resolveAbsoluteUrl(normalizedBase))
if (lastEventId && lastEventId.length > 0) {
httpUrl.searchParams.set("lastEventId", lastEventId)
}
return toWebSocketUrl(httpUrl.toString())
return getRuntimeUrlResolver().websocket(
`${normalizedBase}global/event/ws`,
lastEventId && lastEventId.length > 0 ? { lastEventId } : undefined,
)
}
type DirectoryQueue = {
+3 -2
View File
@@ -139,9 +139,10 @@ export function materializeSessionSnapshots(
.filter((record) => !!record?.info?.id)
.sort((left, right) => cmp(left.info.id, right.info.id))
const nextMessages = snapshots.map((record) => record.info)
const currentMessages = state.message[sessionID] ?? []
const existingMessages = state.message[sessionID]
const currentMessages = existingMessages ?? []
const messages = mergeMessages(currentMessages, nextMessages)
const messagesChanged = messages !== currentMessages
const messagesChanged = messages !== currentMessages || (existingMessages === undefined && snapshots.length === 0)
let partsChanged = false
const nextPartState = { ...state.part }
@@ -0,0 +1,50 @@
import type { SessionStatus } from "@opencode-ai/sdk/v2/client"
export const LIVE_STATUS_TTL_MS = 15_000
type RuntimeLiveStatus = {
runtimeKey: string
directory: string
sessionId: string
status: SessionStatus
expiresAt: number
}
const liveStatusByRuntime = new Map<string, RuntimeLiveStatus>()
const keyFor = (runtimeKey: string, directory: string) => `${runtimeKey}\n${directory}`
export function rememberRuntimeLiveStatus(params: {
runtimeKey: string
directory: string | null | undefined
sessionId: string | null | undefined
status: SessionStatus | null | undefined
}) {
if (!params.runtimeKey || !params.directory || !params.sessionId || !params.status) return
if (params.status.type === "idle") return
// Evict expired entries on write so keys that are never read again don't
// accumulate (reads are lazy and only prune their own key).
const now = Date.now()
for (const [key, entry] of liveStatusByRuntime) {
if (entry.expiresAt <= now) liveStatusByRuntime.delete(key)
}
liveStatusByRuntime.set(keyFor(params.runtimeKey, params.directory), {
runtimeKey: params.runtimeKey,
directory: params.directory,
sessionId: params.sessionId,
status: params.status,
expiresAt: Date.now() + LIVE_STATUS_TTL_MS,
})
}
export function getRuntimeLiveStatusSeed(runtimeKey: string, directory: string): RuntimeLiveStatus | null {
const entry = liveStatusByRuntime.get(keyFor(runtimeKey, directory))
if (!entry) return null
if (entry.expiresAt <= Date.now()) {
liveStatusByRuntime.delete(keyFor(runtimeKey, directory))
return null
}
return entry
}
+125 -4
View File
@@ -3,6 +3,7 @@ import type { PermissionRequest } from "@/types/permission"
// Mock SDK client that records permission.reply / question.reply calls
const replyCalls: Array<{ method: string; params: Record<string, unknown> }> = []
let sessionRevertResult: { data?: unknown; error?: unknown; response?: { status?: number } } = {}
const mockScopedClient = {
permission: {
@@ -24,6 +25,20 @@ const mockScopedClient = {
}
const mockSdk = {
session: {
messages: mock((params: Record<string, unknown>) => {
replyCalls.push({ method: "session.messages", params })
return Promise.resolve({ data: [] })
}),
revert: mock((params: Record<string, unknown>) => {
replyCalls.push({ method: "session.revert", params })
return Promise.resolve(sessionRevertResult)
}),
abort: mock((params: Record<string, unknown>) => {
replyCalls.push({ method: "session.abort", params })
return Promise.resolve({ data: true })
}),
},
permission: {
reply: mock((params: Record<string, unknown>) => {
replyCalls.push({ method: "permission.reply", params })
@@ -48,6 +63,25 @@ mock.module("@/lib/opencode/client", () => ({
// eslint-disable-next-line @typescript-eslint/no-unused-vars
getScopedSdkClient: (_: string) => mockScopedClient,
getDirectory: () => "/test/project",
replyToPermission: mock((requestId: string, reply: string, options?: { directory?: string | null }) => {
replyCalls.push({ method: "permission.reply", params: { requestID: requestId, reply, directory: options?.directory } })
return Promise.resolve(true)
}),
replyToQuestion: mock((requestId: string, answers: string[] | string[][], directory?: string | null) => {
replyCalls.push({ method: "question.reply", params: { requestID: requestId, answers, directory } })
return Promise.resolve(true)
}),
revertSession: mock((sessionId: string, messageId: string, partId?: string, directory?: string | null) => {
replyCalls.push({
method: "session.revert",
params: { sessionID: sessionId, messageID: messageId, partID: partId, directory },
})
if (sessionRevertResult.error) {
const status = sessionRevertResult.response?.status
throw new Error(`session.revert failed${status ? ` (${status})` : ""}: rejected`)
}
return Promise.resolve(sessionRevertResult.data)
}),
},
}))
@@ -74,9 +108,24 @@ mock.module("./session-ui-store", () => ({
},
}))
// Mock useInputStore (imported but not used in permission functions)
// Mock useInputStore
const inputState = {
pendingInputText: "",
pendingInputMode: "normal" as const,
attachedFiles: [],
clearAttachedFiles: () => {
inputState.attachedFiles = []
},
addRestoredAttachment: (attachment: never) => {
inputState.attachedFiles = [...inputState.attachedFiles, attachment]
},
}
mock.module("./input-store", () => ({
useInputStore: {},
useInputStore: {
getState: () => inputState,
setState: (patch: Partial<typeof inputState>) => Object.assign(inputState, patch),
},
}))
// Mock useGlobalSessionsStore (imported but not used in permission functions)
@@ -92,11 +141,15 @@ mock.module("./sync-refs", () => ({
import { create, type StoreApi } from "zustand"
import { INITIAL_STATE } from "./types"
import type { DirectoryStore } from "./child-store"
import type { OpencodeClient } from "@opencode-ai/sdk/v2/client"
import type { Message, OpencodeClient, Part, Session } from "@opencode-ai/sdk/v2/client"
function createStore(permissions: Record<string, PermissionRequest[]>): StoreApi<DirectoryStore> {
function createStore(
permissions: Record<string, PermissionRequest[]>,
state?: Partial<DirectoryStore>,
): StoreApi<DirectoryStore> {
return create<DirectoryStore>()((set) => ({
...INITIAL_STATE,
...state,
permission: permissions,
patch: (partial) => set(partial),
replace: (next) => set(next),
@@ -117,6 +170,7 @@ function createChildStores(entries: Array<[string, StoreApi<DirectoryStore>]>) {
describe("respondToPermission passes directory", () => {
beforeEach(() => {
replyCalls.length = 0
sessionRevertResult = {}
})
test("passes directory from child store when permission is found", async () => {
@@ -172,6 +226,73 @@ describe("respondToPermission passes directory", () => {
})
})
describe("revertToMessage passes session directory", () => {
beforeEach(() => {
replyCalls.length = 0
sessionRevertResult = {}
Object.assign(inputState, {
pendingInputText: "previous draft",
pendingInputMode: "normal" as const,
attachedFiles: [],
})
})
test("routes revert through the session directory instead of the current directory", async () => {
const session = { id: "session-a", time: { created: 1 } } as Session
const targetMessage = { id: "msg_2", sessionID: "session-a", role: "user", time: { created: 2 } } as Message
const targetPart = { id: "prt_2", messageID: "msg_2", type: "text", text: "edit this" } as Part
const sessionStore = createStore({}, {
session: [session],
message: { "session-a": [targetMessage] },
part: { "msg_2": [targetPart] },
})
const currentStore = createStore({})
const childStores = createChildStores([
["/test/project", sessionStore],
["/current/project", currentStore],
])
sessionRevertResult = { data: { id: "session-a", time: { created: 1, updated: 2 }, revert: { messageID: "msg_2" } } }
const { setActionRefs, revertToMessage } = await import("./session-actions")
setActionRefs(mockSdk as unknown as OpencodeClient, childStores, () => "/current/project")
await revertToMessage("session-a", "msg_2")
expect(replyCalls.find((call) => call.method === "session.revert")?.params.directory).toBe("/test/project")
expect((sessionStore.getState().session[0] as Session & { revert?: { messageID?: string } }).revert?.messageID).toBe("msg_2")
expect(currentStore.getState().session).toHaveLength(0)
expect(inputState.pendingInputText).toBe("edit this")
})
test("rolls back optimistic revert when the SDK returns an error", async () => {
const session = { id: "session-a", time: { created: 1 } } as Session
const targetMessage = { id: "msg_2", sessionID: "session-a", role: "user", time: { created: 2 } } as Message
const targetPart = { id: "prt_2", messageID: "msg_2", type: "text", text: "edit this" } as Part
const sessionStore = createStore({}, {
session: [session],
message: { "session-a": [targetMessage] },
part: { "msg_2": [targetPart] },
})
const childStores = createChildStores([["/test/project", sessionStore]])
sessionRevertResult = { error: { message: "rejected" }, response: { status: 500 } }
const { setActionRefs, revertToMessage } = await import("./session-actions")
setActionRefs(mockSdk as unknown as OpencodeClient, childStores, () => "/test/project")
let thrown: unknown
try {
await revertToMessage("session-a", "msg_2")
} catch (error) {
thrown = error
}
expect(thrown).toBeInstanceOf(Error)
expect((thrown as Error).message).toContain("session.revert failed (500)")
expect((sessionStore.getState().session[0] as Session & { revert?: { messageID?: string } }).revert).toBe(undefined)
expect(inputState.pendingInputText).toBe("previous draft")
})
})
describe("dismissPermission passes directory", () => {
beforeEach(() => {
replyCalls.length = 0
+202 -133
View File
@@ -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()
@@ -0,0 +1,11 @@
type SessionOpener = (sessionID: string, directory: string) => void
let sessionOpener: SessionOpener | null = null
export const setSessionOpener = (opener: SessionOpener | null) => {
sessionOpener = opener
}
export const openSessionFromToast = (sessionID: string, directory: string) => {
sessionOpener?.(sessionID, directory)
}
@@ -38,15 +38,16 @@ export function shouldSkipSessionPrefetch(input: {
pageSize: number
now?: number
}): boolean {
if (input.hasMessages) {
if (!input.info) return true
if (input.info.complete) return true
if (input.info.limit > input.pageSize) return true
if (input.info.limit < input.pageSize) return false
} else {
if (!input.info) return false
if (!input.hasMessages) {
return false
}
return (input.now ?? Date.now()) - input.info.at < SESSION_PREFETCH_TTL
const info = input.info
if (!info) return true
if (info.complete) return true
if (info.limit > input.pageSize) return true
if (info.limit < input.pageSize) return false
return (input.now ?? Date.now()) - info.at < SESSION_PREFETCH_TTL
}
export function getSessionPrefetch(directory: string, sessionID: string): Meta | undefined {
+46 -1
View File
@@ -1,6 +1,7 @@
import { beforeEach, describe, expect, test } from 'bun:test';
import { opencodeClient } from '@/lib/opencode/client';
import { useSessionWorktreeStore } from './session-worktree-store';
import { useSessionUIStore } from './session-ui-store';
import { routeMessage, useSessionUIStore } from './session-ui-store';
/**
* Unit tests for session worktree routing through the authoritative store.
@@ -189,3 +190,47 @@ describe('session-worktree-store worktree routing', () => {
expect(attachment.worktreeStatus).toBe('not-a-repo');
});
});
describe('routeMessage directory scoping', () => {
test('runs sends in the provided session directory', async () => {
const calls = [];
let activeDirectory = '/current/project';
const originalWithDirectory = opencodeClient.withDirectory;
const originalGetDirectory = opencodeClient.getDirectory;
const originalShellSession = opencodeClient.shellSession;
opencodeClient.withDirectory = async (directory, fn) => {
calls.push({ method: 'withDirectory', directory });
const previousDirectory = activeDirectory;
activeDirectory = directory ?? undefined;
try {
return await fn();
} finally {
activeDirectory = previousDirectory;
}
};
opencodeClient.getDirectory = () => activeDirectory;
opencodeClient.shellSession = async (params) => {
calls.push({ method: 'session.shell', params });
return { info: {}, parts: [] };
};
try {
await routeMessage({
sessionId: 'session-a',
directory: '/session/project',
content: 'pwd',
providerID: 'provider-a',
modelID: 'model-a',
inputMode: 'shell',
});
} finally {
opencodeClient.withDirectory = originalWithDirectory;
opencodeClient.getDirectory = originalGetDirectory;
opencodeClient.shellSession = originalShellSession;
}
expect(calls[0]).toEqual({ method: 'withDirectory', directory: '/session/project' });
expect(calls[1].params.directory).toBe('/session/project');
});
});
+179 -94
View File
@@ -17,6 +17,7 @@ import type { Session, Part, Message, TextPart } from "@opencode-ai/sdk/v2/clien
import type { AttachedFile, SessionContextUsage, SessionWorktreeAttachment } from "@/stores/types/sessionTypes"
import type { WorktreeMetadata } from "@/types/worktree"
import { opencodeClient } from "@/lib/opencode/client"
import { runtimeFetch } from "@/lib/runtime-fetch"
import { useConfigStore } from "@/stores/useConfigStore"
import { useProjectsStore } from "@/stores/useProjectsStore"
import { useGlobalSessionsStore, resolveGlobalSessionDirectory } from "@/stores/useGlobalSessionsStore"
@@ -48,12 +49,18 @@ import {
unshareSession as unshareSessionAction,
optimisticSend,
refetchSessionMessages,
revertToMessage as revertToMessageAction,
unrevertSession as unrevertSessionAction,
forkFromMessage as forkFromMessageAction,
} from "./session-actions"
import { useInputStore, type SyntheticContextPart } from "./input-store"
import { useSelectionStore } from "./selection-store"
import { useViewportStore } from "./viewport-store"
import { getViewportSessionMemory, useViewportStore, viewportSessionKey } from "./viewport-store"
import { useSessionWorktreeStore } from "./session-worktree-store"
import { getAttachedSessionDirectory } from "./session-worktree-contract"
import { setSessionOpener } from "./session-navigation"
import { getRuntimeKey } from "@/lib/runtime-switch"
import { rememberRuntimeLiveStatus } from "./runtime-live-memory"
export type { AttachedFile }
@@ -74,82 +81,75 @@ export function routeMessage(params: {
files?: Array<{ type: "file"; mime: string; url: string; filename: string }>
additionalParts?: Array<{ text: string; synthetic?: boolean; files?: Array<{ type: "file"; mime: string; url: string; filename: string }> }>
}): Promise<void> {
const run = (): Promise<void> => {
if (params.inputMode === "shell") {
const sdk = opencodeClient.getSdkClient()
const dir = opencodeClient.getDirectory() || undefined
return sdk.session.shell({
sessionID: params.sessionId,
directory: dir,
agent: params.agent,
model: { providerID: params.providerID, modelID: params.modelID },
command: params.content,
}).then(() => {})
}
// Slash commands — fire and forget, SSE delivers messages and status
if (params.content.startsWith("/")) {
const [head, ...tail] = params.content.split(" ")
const cmdName = head.slice(1)
const dirState = getDirectoryState(params.directory ?? undefined)
const syncCommands = dirState?.command ?? []
const storeCommands = useCommandsStore.getState().commands
const isCommand = syncCommands.find((c) => c.name === cmdName)
|| storeCommands.find((c) => c.name === cmdName)
if (isCommand) {
return optimisticSend({
sessionId: params.sessionId,
content: params.content,
providerID: params.providerID,
modelID: params.modelID,
agent: params.agent,
files: params.files,
send: (messageID) => opencodeClient.sendCommand({
id: params.sessionId,
providerID: params.providerID,
modelID: params.modelID,
command: cmdName,
arguments: tail.join(" "),
agent: params.agent,
variant: params.variant,
files: params.files,
messageId: messageID,
}).then(() => {}),
})
}
}
// Normal prompt — optimistic insert so message appears instantly
return optimisticSend({
const requestDirectory = params.directory ?? undefined
if (params.inputMode === "shell") {
return opencodeClient.shellSession({
sessionId: params.sessionId,
content: params.content,
providerID: params.providerID,
modelID: params.modelID,
agent: params.agent,
files: params.files,
send: (messageID) => opencodeClient.sendMessage({
id: params.sessionId,
directory: requestDirectory,
agent: params.agent ?? "",
model: { providerID: params.providerID, modelID: params.modelID },
command: params.content,
}).then(() => undefined)
}
// Slash commands — fire and forget, SSE delivers messages and status
if (params.content.startsWith("/")) {
const [head, ...tail] = params.content.split(" ")
const cmdName = head.slice(1)
const dirState = getDirectoryState(requestDirectory)
const syncCommands = dirState?.command ?? []
const storeCommands = useCommandsStore.getState().commands
const isCommand = syncCommands.find((c) => c.name === cmdName)
|| storeCommands.find((c) => c.name === cmdName)
if (isCommand) {
return optimisticSend({
sessionId: params.sessionId,
content: params.content,
providerID: params.providerID,
modelID: params.modelID,
text: params.content,
agent: params.agent,
agentMentions: params.agentMentionName ? [{ name: params.agentMentionName }] : undefined,
variant: params.variant,
files: params.files,
additionalParts: params.additionalParts,
messageId: messageID,
}).then(() => {}),
})
send: (messageID) => opencodeClient.sendCommand({
id: params.sessionId,
providerID: params.providerID,
modelID: params.modelID,
command: cmdName,
arguments: tail.join(" "),
agent: params.agent,
variant: params.variant,
files: params.files,
messageId: messageID,
directory: requestDirectory,
}).then(() => {}),
})
}
}
if (params.directory !== undefined) {
return opencodeClient.withDirectory(params.directory, run)
}
return run()
// Normal prompt — optimistic insert so message appears instantly
return optimisticSend({
sessionId: params.sessionId,
content: params.content,
providerID: params.providerID,
modelID: params.modelID,
agent: params.agent,
files: params.files,
send: (messageID) => opencodeClient.sendMessage({
id: params.sessionId,
providerID: params.providerID,
modelID: params.modelID,
text: params.content,
agent: params.agent,
agentMentions: params.agentMentionName ? [{ name: params.agentMentionName }] : undefined,
variant: params.variant,
files: params.files,
additionalParts: params.additionalParts,
messageId: messageID,
directory: requestDirectory,
}).then(() => {}),
})
}
type SendMessageOptions = {
@@ -157,7 +157,7 @@ type SendMessageOptions = {
}
function notifyMessageSent(sessionId: string): void {
fetch(`/api/sessions/${sessionId}/message-sent`, { method: "POST" })
runtimeFetch(`/api/sessions/${sessionId}/message-sent`, { method: "POST" })
.catch(() => { /* ignore */ })
}
@@ -222,6 +222,8 @@ export type SessionUIState = {
// Actions — UI state management
setCurrentSession: (id: string | null, directoryHint?: string | null) => void
prepareForRuntimeSwitch: (apiBaseUrl?: string | null) => void
restoreForRuntimeSwitch: (apiBaseUrl?: string | null) => void
openNewSessionDraft: (options?: Partial<NewSessionDraftState>) => void
closeNewSessionDraft: () => void
setNewSessionDraftTarget: (target: { projectId?: string | null; selectedProjectId?: string | null; directoryOverride?: string | null }, options?: { force?: boolean }) => void
@@ -358,6 +360,31 @@ const DEFAULT_DRAFT: NewSessionDraftState = {
parentID: null,
}
const activeSessionByRuntime = new Map<string, string | null>()
type RuntimeSessionMemory = {
sessionId: string | null
directory: string | null
draft: NewSessionDraftState
}
const runtimeSessionMemory = new Map<string, RuntimeSessionMemory>()
const runtimeMemoryKey = (value?: string | null): string => {
const key = (value ?? getRuntimeKey()).trim()
return key || "default"
}
const cloneDraft = (draft: NewSessionDraftState): NewSessionDraftState => ({ ...draft })
const writeRuntimeSessionMemory = (key: string, patch: Partial<RuntimeSessionMemory>): void => {
const current = runtimeSessionMemory.get(key)
runtimeSessionMemory.set(key, {
sessionId: current?.sessionId ?? null,
directory: current?.directory ?? null,
draft: current?.draft ? cloneDraft(current.draft) : { ...DEFAULT_DRAFT },
...patch,
})
}
// ---------------------------------------------------------------------------
// Store
// ---------------------------------------------------------------------------
@@ -387,6 +414,9 @@ export const useSessionUIStore = create<SessionUIState>()((set, get) => ({
get().closeNewSessionDraft()
}
const key = runtimeMemoryKey()
activeSessionByRuntime.set(key, id)
const previousSessionId = get().currentSessionId
// Set currentSessionId immediately so the skeleton renders without delay.
@@ -400,6 +430,7 @@ export const useSessionUIStore = create<SessionUIState>()((set, get) => ({
)
const fallbackDir = opencodeClient.getDirectory() ?? directoryState.currentDirectory ?? null
const resolvedDir = (directoryHint ? normalizePath(directoryHint) : null) ?? sessionDir ?? fallbackDir
writeRuntimeSessionMemory(key, { sessionId: id, directory: resolvedDir ?? null })
try {
if (resolvedDir && directoryState.currentDirectory !== resolvedDir) {
@@ -415,7 +446,7 @@ export const useSessionUIStore = create<SessionUIState>()((set, get) => ({
if (previousSessionId && previousSessionId !== id) {
const prevId = previousSessionId
setTimeout(() => {
const memState = useViewportStore.getState().sessionMemoryState.get(prevId)
const memState = getViewportSessionMemory(prevId)
if (!memState?.isStreaming) {
const prevMessages = getSyncMessages(prevId)
if (prevMessages.length > 0) {
@@ -432,6 +463,50 @@ export const useSessionUIStore = create<SessionUIState>()((set, get) => ({
}
},
prepareForRuntimeSwitch: (apiBaseUrl?: string | null) => {
const key = runtimeMemoryKey(apiBaseUrl)
const directory = useDirectoryStore.getState().currentDirectory || null
const currentSessionId = get().currentSessionId
const directorySnapshot = directory ? getDirectoryState(directory) : null
rememberRuntimeLiveStatus({
runtimeKey: key,
directory,
sessionId: currentSessionId,
status: currentSessionId ? directorySnapshot?.session_status?.[currentSessionId] : null,
})
activeSessionByRuntime.set(key, get().currentSessionId)
writeRuntimeSessionMemory(key, {
sessionId: currentSessionId,
directory,
draft: cloneDraft(get().newSessionDraft),
})
},
restoreForRuntimeSwitch: (apiBaseUrl?: string | null) => {
const key = runtimeMemoryKey(apiBaseUrl)
const memory = runtimeSessionMemory.get(key)
const restoredSessionId = memory?.sessionId ?? activeSessionByRuntime.get(key) ?? null
const restoredDraft = memory?.draft ? cloneDraft(memory.draft) : { ...DEFAULT_DRAFT }
const restoredDirectory = memory?.directory ?? null
if (restoredDirectory) {
useDirectoryStore.getState().setDirectory(restoredDirectory, { showOverlay: false })
}
set({
currentSessionId: restoredSessionId,
newSessionDraft: restoredSessionId ? { ...DEFAULT_DRAFT } : restoredDraft,
abortPromptSessionId: null,
abortPromptExpiresAt: null,
error: null,
sessionAbortFlags: new Map(),
pendingChangesBarDismissed: new Map(),
})
if (restoredSessionId) {
setActiveSession(opencodeClient.getDirectory() ?? "", restoredSessionId)
} else {
setActiveSession("", "")
}
},
// ---------------------------------------------------------------------------
// openNewSessionDraft
// ---------------------------------------------------------------------------
@@ -481,24 +556,29 @@ export const useSessionUIStore = create<SessionUIState>()((set, get) => ({
persistDraftTarget({ projectId: selectedProject?.id ?? null, directory })
const nextDraft: NewSessionDraftState = {
open: true,
selectedProjectId: selectedProject?.id ?? null,
directoryOverride: directory,
pendingWorktreeRequestId: options?.pendingWorktreeRequestId ?? null,
bootstrapPendingDirectory: normalizePath(options?.bootstrapPendingDirectory ?? null),
preserveDirectoryOverride: options?.preserveDirectoryOverride === true,
parentID: options?.parentID ?? null,
title: options?.title,
initialPrompt: options?.initialPrompt,
syntheticParts: options?.syntheticParts,
targetFolderId: options?.targetFolderId,
}
set({
newSessionDraft: {
open: true,
selectedProjectId: selectedProject?.id ?? null,
directoryOverride: directory,
pendingWorktreeRequestId: options?.pendingWorktreeRequestId ?? null,
bootstrapPendingDirectory: normalizePath(options?.bootstrapPendingDirectory ?? null),
preserveDirectoryOverride: options?.preserveDirectoryOverride === true,
parentID: options?.parentID ?? null,
title: options?.title,
initialPrompt: options?.initialPrompt,
syntheticParts: options?.syntheticParts,
targetFolderId: options?.targetFolderId,
...nextDraft,
},
currentSessionId: null,
error: null,
})
writeRuntimeSessionMemory(runtimeMemoryKey(), { sessionId: null, directory, draft: nextDraft })
// Clear composer attachments when opening a new session draft.
// Attachments from the previous session (e.g. restored by revert) must
// not bleed into the new session's input.
@@ -515,8 +595,7 @@ export const useSessionUIStore = create<SessionUIState>()((set, get) => ({
// closeNewSessionDraft
// ---------------------------------------------------------------------------
closeNewSessionDraft: () => {
set({
newSessionDraft: {
const nextDraft: NewSessionDraftState = {
open: false,
selectedProjectId: null,
directoryOverride: null,
@@ -528,8 +607,11 @@ export const useSessionUIStore = create<SessionUIState>()((set, get) => ({
initialPrompt: undefined,
syntheticParts: undefined,
targetFolderId: undefined,
},
}
set({
newSessionDraft: nextDraft,
})
writeRuntimeSessionMemory(runtimeMemoryKey(), { draft: nextDraft })
},
setNewSessionDraftTarget: (target) => {
@@ -841,10 +923,10 @@ export const useSessionUIStore = create<SessionUIState>()((set, get) => ({
if (targetSessionId) {
const viewportState = useViewportStore.getState()
const memState = viewportState.sessionMemoryState.get(targetSessionId)
const memState = getViewportSessionMemory(targetSessionId)
if (!memState || !memState.lastUserMessageAt) {
const newMemState = new Map(viewportState.sessionMemoryState)
newMemState.set(targetSessionId, {
newMemState.set(viewportSessionKey(targetSessionId), {
viewportAnchor: 0,
isStreaming: false,
lastAccessedAt: Date.now(),
@@ -980,8 +1062,7 @@ export const useSessionUIStore = create<SessionUIState>()((set, get) => ({
// Ensure the complete message range is present before applying the revert
// marker. Reverted UI is derived from session.revert + stored messages.
await refetchSessionMessages(sessionId)
const { revertToMessage: revert } = await import("./session-actions")
await revert(sessionId, messageId)
await revertToMessageAction(sessionId, messageId)
},
// ---------------------------------------------------------------------------
@@ -1056,8 +1137,7 @@ export const useSessionUIStore = create<SessionUIState>()((set, get) => ({
return
}
const { unrevertSession } = await import("./session-actions")
await unrevertSession(sessionId)
await unrevertSessionAction(sessionId)
const { toast } = await import("sonner")
const { useI18nStore, formatMessage } = await import("@/lib/i18n/store")
const { dictionary } = useI18nStore.getState()
@@ -1073,8 +1153,7 @@ export const useSessionUIStore = create<SessionUIState>()((set, get) => ({
if (!existingSession) return
try {
const { forkFromMessage: fork } = await import("./session-actions")
await fork(sessionId, messageId)
await forkFromMessageAction(sessionId, messageId)
const { toast } = await import("sonner")
toast.success(`Forked from ${existingSession.title}`)
@@ -1127,6 +1206,7 @@ export const useSessionUIStore = create<SessionUIState>()((set, get) => ({
if (!pID || !mID) return
const sessionDirectory = normalizePath(directory ?? session.directory ?? null)
await opencodeClient.sendMessage({
id: session.id,
providerID: pID,
@@ -1134,6 +1214,7 @@ export const useSessionUIStore = create<SessionUIState>()((set, get) => ({
text: assistantPlanText,
prefaceText: EXECUTION_FORK_META_TEXT,
agent: currentAgentName ?? undefined,
directory: sessionDirectory,
})
},
@@ -1238,3 +1319,7 @@ export const useSessionUIStore = create<SessionUIState>()((set, get) => ({
return get().sessionPlanAvailable.get(sessionId) ?? false
},
}))
setSessionOpener((sessionID, directory) => {
useSessionUIStore.getState().setCurrentSession(sessionID, directory)
})
+7
View File
@@ -31,6 +31,13 @@ export const useStreamingStore = create<StreamingStore>()(() => ({
messageStreamStates: new Map(),
}))
export function resetStreamingState() {
useStreamingStore.setState({
streamingMessageIds: new Map(),
messageStreamStates: new Map(),
})
}
/**
* Called from the SyncBridge/flush handler when child store state changes.
* Derives streaming state from session_status + messages.
+23 -33
View File
@@ -1,7 +1,7 @@
import type { Message, Part } from "@opencode-ai/sdk/v2/client"
import { useCallback } from "react"
import { useSyncSDK } from "./sync-context"
import { useDirectoryStore } from "./sync-context"
import { opencodeClient } from "@/lib/opencode/client"
import { useDirectoryStore, useSyncDirectory } from "./sync-context"
import { useSync } from "./use-sync"
// ---------------------------------------------------------------------------
@@ -33,8 +33,8 @@ export type SubmitInput = {
}
export function usePromptSubmit() {
const sdk = useSyncSDK()
const store = useDirectoryStore()
const directory = useSyncDirectory()
const sync = useSync()
const submit = useCallback(
@@ -82,41 +82,31 @@ export function usePromptSubmit() {
try {
if (input.command) {
// Slash command
await sdk.session.command({
sessionID: input.sessionID,
command: input.command.name,
arguments: input.command.arguments,
await opencodeClient.sendCommand({
id: input.sessionID,
command: input.command?.name ?? "",
arguments: input.command?.arguments ?? "",
agent: input.agent,
model: `${input.model.providerID}/${input.model.modelID}`,
providerID: input.model.providerID,
modelID: input.model.modelID,
variant: input.variant,
parts: input.images,
})
files: input.images,
messageId: messageID,
directory,
}).then(() => undefined)
} else {
// Regular prompt
const requestParts: Array<{ id: string; type: "text"; text: string }
| { id: string; type: "file"; mime: string; url: string; filename?: string }> = [
{ id: textPart.id, type: "text" as const, text: input.text },
]
if (input.images) {
for (const img of input.images) {
requestParts.push({
id: img.id ?? ascending("part"),
type: "file" as const,
mime: img.mime,
url: img.url,
filename: img.filename,
})
}
}
await sdk.session.promptAsync({
sessionID: input.sessionID,
await opencodeClient.sendMessage({
id: input.sessionID,
agent: input.agent,
model: input.model,
messageID,
parts: requestParts,
providerID: input.model.providerID,
modelID: input.model.modelID,
messageId: messageID,
text: input.text,
files: input.images,
variant: input.variant,
})
directory,
}).then(() => undefined)
}
return true
} catch (error) {
@@ -136,7 +126,7 @@ export function usePromptSubmit() {
throw error
}
},
[sdk, store, sync],
[directory, store, sync],
)
return submit
+170 -28
View File
@@ -39,6 +39,10 @@ import type { PermissionRequest } from "@/types/permission"
import type { QuestionRequest } from "@/types/question"
import * as sessionActions from "./session-actions"
import { getSessionMaterializationStatus, materializeSessionSnapshots } from "./materialization"
import { openSessionFromToast } from "./session-navigation"
import { getRuntimeLiveStatusSeed, LIVE_STATUS_TTL_MS } from "./runtime-live-memory"
import { getRuntimeKey } from "@/lib/runtime-switch"
import { getRegisteredRuntimeAPIs } from "@/contexts/runtimeAPIRegistry"
import { setSessionPrefetch } from "./session-prefetch-cache"
// ---------------------------------------------------------------------------
@@ -60,6 +64,34 @@ const syncGlobal = globalThis as SyncGlobal
const SyncContext = syncGlobal[SYNC_CONTEXT_GLOBAL_KEY] ?? createContext<SyncSystem | null>(null)
syncGlobal[SYNC_CONTEXT_GLOBAL_KEY] = SyncContext
type SdkResult<T> = {
data?: T
error?: unknown
response?: {
status?: number
headers?: { get?: (name: string) => string | null }
}
}
function formatSdkError(error: unknown): string {
if (error instanceof Error) return error.message
if (typeof error === "string") return error
if (error && typeof error === "object" && "message" in error && typeof (error as { message?: unknown }).message === "string") {
return (error as { message: string }).message
}
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 useSyncSystem() {
const ctx = useContext(SyncContext)
if (!ctx) throw new Error("useSyncSystem must be used within <SyncProvider>")
@@ -153,6 +185,7 @@ export function useAllLiveSessions(): Session[] {
// Boot debounce — suppresses redundant refresh/re-bootstrap events during startup.
let bootingRoot = false
let bootedAt = 0
let globalBootstrapGeneration = 0
const BOOT_DEBOUNCE_MS = 1500
const RECONNECT_MESSAGE_LIMIT = 30
const SESSION_MATERIALIZATION_MESSAGE_LIMIT = 30
@@ -225,9 +258,11 @@ async function materializeSessionFromServer(
store: StoreApi<DirectoryStore>,
) {
const scopedClient = opencodeClient.getScopedSdkClient(directory)
const result = await retry(() =>
scopedClient.session.messages({ sessionID, limit: SESSION_MATERIALIZATION_MESSAGE_LIMIT }),
)
const result = await retry(async () => {
const response = await scopedClient.session.messages({ sessionID, limit: SESSION_MATERIALIZATION_MESSAGE_LIMIT })
assertSdkSuccess(response, "session.messages")
return response
})
const records = (result.data ?? []).filter((record: { info?: { id?: string } }) => !!record?.info?.id)
if (records.length === 0) return
const cursor = result.response?.headers?.get?.("x-next-cursor") ?? undefined
@@ -282,12 +317,56 @@ const getPermissionToastKey = (sessionID?: string, requestID?: string) => {
return `${sessionID}:${requestID}`
}
const openSessionFromToast = (sessionID: string, directory: string) => {
void import("./session-ui-store")
.then(({ useSessionUIStore }) => {
useSessionUIStore.getState().setCurrentSession(sessionID, directory)
})
.catch(() => undefined)
type UiNotificationPayload = {
title?: unknown
body?: unknown
tag?: unknown
kind?: unknown
sessionId?: unknown
directory?: unknown
requireHidden?: unknown
desktopStdoutActive?: unknown
}
const asOptionalString = (value: unknown): string | undefined => {
if (typeof value !== "string") return undefined
const trimmed = value.trim()
return trimmed.length > 0 ? trimmed : undefined
}
const handleUiNotificationEvent = (payload: Event, fallbackDirectory: string): boolean => {
if ((payload as { type?: unknown }).type !== "openchamber:notification") {
return false
}
const properties = (payload as { properties?: unknown }).properties
if (!properties || typeof properties !== "object") {
return true
}
const notification = properties as UiNotificationPayload
if (notification.desktopStdoutActive === true && getRuntimeKey() === "local") {
return true
}
const notifications = getRegisteredRuntimeAPIs()?.notifications
if (!notifications?.notifyAgentCompletion) {
return true
}
void notifications.notifyAgentCompletion({
title: asOptionalString(notification.title),
body: asOptionalString(notification.body),
tag: asOptionalString(notification.tag),
kind: asOptionalString(notification.kind),
sessionId: asOptionalString(notification.sessionId),
directory: asOptionalString(notification.directory) ?? (fallbackDirectory && fallbackDirectory !== "global" ? fallbackDirectory : undefined),
requireHidden: notification.requireHidden === true,
}).catch((error) => {
console.warn("[notifications] failed to dispatch UI notification", error)
})
return true
}
export function setActiveSession(directory: string, sessionId: string) {
@@ -955,16 +1034,26 @@ export async function resyncBlockingRequestsForDirectory(
const autoAcceptingSessionIds = Object.keys(grouped).filter((sessionId) => permissionStore.isSessionAutoAccepting(sessionId))
if (autoAcceptingSessionIds.length > 0) {
await Promise.all(
autoAcceptingSessionIds.flatMap((sessionId) =>
(grouped[sessionId] ?? []).map((permission) =>
sessionActions.respondToPermission(permission.sessionID, permission.id, "once").catch(() => undefined),
),
),
)
const acceptedIdsBySession = new Map<string, Set<string>>()
await Promise.all(autoAcceptingSessionIds.flatMap((sessionId) =>
(grouped[sessionId] ?? []).map(async (permission) => {
try {
await sessionActions.respondToPermission(permission.sessionID, permission.id, "once")
const accepted = acceptedIdsBySession.get(sessionId) ?? new Set<string>()
accepted.add(permission.id)
acceptedIdsBySession.set(sessionId, accepted)
} catch {
// Keep failed auto-accept permissions in UI state so the user can act.
}
}),
))
for (const sessionId of autoAcceptingSessionIds) {
delete grouped[sessionId]
const acceptedIds = acceptedIdsBySession.get(sessionId)
if (!acceptedIds) continue
const remaining = (grouped[sessionId] ?? []).filter((permission) => !acceptedIds.has(permission.id))
if (remaining.length > 0) grouped[sessionId] = remaining
else delete grouped[sessionId]
}
}
@@ -1024,8 +1113,16 @@ async function resyncDirectoryAfterReconnect(
const scopedClient = opencodeClient.getScopedSdkClient(directory)
await Promise.all(candidateSessionIds.map(async (sessionId) => {
const [sessionResponse, messageResponse] = await Promise.all([
scopedClient.session.get({ sessionID: sessionId }).catch(() => null),
scopedClient.session.messages({ sessionID: sessionId, limit: RECONNECT_MESSAGE_LIMIT }).catch(() => null),
retry(async () => {
const response = await scopedClient.session.get({ sessionID: sessionId })
assertSdkSuccess(response, "session.get")
return response
}).catch(() => null),
retry(async () => {
const response = await scopedClient.session.messages({ sessionID: sessionId, limit: RECONNECT_MESSAGE_LIMIT })
assertSdkSuccess(response, "session.messages")
return response
}).catch(() => null),
])
const session = sessionResponse?.data
const records = messageResponse?.data
@@ -1104,6 +1201,10 @@ function handleEvent(
) {
const directory = resolveDirectoryFromRoutingIndex(routingIndex, rawDirectory, payload, childStores)
if (handleUiNotificationEvent(payload, directory)) {
return
}
// Global events
if (directory === "global" || !directory) {
const recent = isRecentBoot()
@@ -1177,7 +1278,6 @@ function handleEvent(
if (permissionStore.isSessionAutoAccepting(permission.sessionID)) {
updateRoutingIndexFromEvent(routingIndex, resolvedDirectory, payload)
void sessionActions.respondToPermission(permission.sessionID, permission.id, "once").catch(() => undefined)
return
}
const toastKey = getPermissionToastKey(permission.sessionID, permission.id)
@@ -1528,15 +1628,29 @@ export function SyncProvider(props: {
// Bootstrap global state — set bootingRoot/bootedAt to suppress
// redundant refresh events during startup
useEffect(() => {
const generation = ++globalBootstrapGeneration
bootingRoot = true
const globalActions = useGlobalSyncStore.getState().actions
bootstrapGlobal(props.sdk, globalActions.set)
bootstrapGlobal(props.sdk, (patch) => {
if (globalBootstrapGeneration === generation) {
globalActions.set(patch)
}
})
.then(() => {
bootedAt = Date.now()
if (globalBootstrapGeneration === generation) {
bootedAt = Date.now()
}
})
.finally(() => {
bootingRoot = false
if (globalBootstrapGeneration === generation) {
bootingRoot = false
}
})
return () => {
if (globalBootstrapGeneration === generation) {
bootingRoot = false
}
}
}, [props.sdk])
// Event pipeline — created once per mount. No class, no start/stop.
@@ -1690,10 +1804,36 @@ export function SyncProvider(props: {
// Ensure current directory's child store exists
useEffect(() => {
let seedExpiryTimer: ReturnType<typeof setTimeout> | undefined
if (props.directory) {
const store = childStores.ensureChild(props.directory)
const statusSeed = getRuntimeLiveStatusSeed(getRuntimeKey(), props.directory)
if (statusSeed) {
store.setState((state: DirectoryStore) => ({
session_status: {
...state.session_status,
[statusSeed.sessionId]: state.session_status[statusSeed.sessionId] ?? statusSeed.status,
},
}))
seedExpiryTimer = setTimeout(() => {
store.setState((state: DirectoryStore) => {
if (state.session_status[statusSeed.sessionId] !== statusSeed.status) {
return state
}
return {
session_status: {
...state.session_status,
[statusSeed.sessionId]: { type: "idle" as const },
},
}
})
}, LIVE_STATUS_TTL_MS)
}
ingestDirectoryStateIntoRoutingIndex(routingIndex, props.directory, store.getState())
}
return () => {
if (seedExpiryTimer) clearTimeout(seedExpiryTimer)
}
}, [props.directory, childStores, routingIndex])
// Set refs so non-React code (session-actions, session-ui-store) can access sync state
@@ -1713,6 +1853,7 @@ export function SyncProvider(props: {
if (!props.directory) return
const store = childStores.getChild(props.directory)
if (!store) return
updateStreamingState(store.getState())
const unsubscribe = store.subscribe((state) => {
updateStreamingState(state)
})
@@ -2341,7 +2482,9 @@ export function useSessionMessageRecords(
const _ensureMessagesLoading = new Set<string>()
export function useEnsureSessionMessages(sessionID: string, directory?: string) {
const store = useDirectoryStore(directory)
const syncDirectory = useSyncDirectory()
const resolvedDirectory = directory ?? syncDirectory
const store = useDirectoryStore(resolvedDirectory)
React.useEffect(() => {
if (!sessionID) return
@@ -2352,8 +2495,7 @@ export function useEnsureSessionMessages(sessionID: string, directory?: string)
// Session doesn't exist — nothing to load
if (!state.session.some((s) => s.id === sessionID)) return
const dir = directory ?? opencodeClient.getDirectory()
const loadingKey = `${dir ?? ""}:${sessionID}`
const loadingKey = `${resolvedDirectory}:${sessionID}`
// Already loading this session for this directory
if (_ensureMessagesLoading.has(loadingKey)) return
@@ -2361,14 +2503,14 @@ export function useEnsureSessionMessages(sessionID: string, directory?: string)
void (async () => {
try {
await materializeSessionFromServer(dir ?? "", sessionID, store)
await materializeSessionFromServer(resolvedDirectory, sessionID, store)
} catch {
// Transient failure — next navigation or reconnect will retry
} finally {
_ensureMessagesLoading.delete(loadingKey)
}
})()
}, [sessionID, store, directory])
}, [sessionID, store, resolvedDirectory])
}
/**
+39 -4
View File
@@ -47,6 +47,35 @@ type SyncMeta = {
loading: boolean
}
type SdkResult<T> = {
data?: T
error?: unknown
response?: {
status?: number
headers?: { get?: (name: string) => string | null }
}
}
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
}
try {
return JSON.stringify(error)
} catch {
return String(error)
}
}
function assertSdkSuccess<T>(result: SdkResult<T>, operation: string): void {
if (!result.error) return
const status = result.response?.status
throw new Error(`${operation} failed${status ? ` (${status})` : ""}: ${formatSdkError(result.error)}`)
}
const isConstrainedSessionRuntime = () => isVSCodeRuntime() || isMobileSurfaceRuntime()
const getConstrainedInitialPageExpansionMax = () => VSCODE_INITIAL_PAGE_EXPANSION_LIMITS[VSCODE_INITIAL_PAGE_EXPANSION_LIMITS.length - 1]
const getEffectiveSessionCacheLimit = () => {
@@ -267,9 +296,11 @@ export function useSync() {
// Fetch messages from API
const fetchMessages = useCallback(
async (sessionID: string, limit: number, before?: string) => {
const result = await retry(() =>
sdk.session.messages({ sessionID, directory, limit, before }),
)
const result = await retry(async () => {
const response = await sdk.session.messages({ sessionID, directory, limit, before })
assertSdkSuccess(response, "session.messages")
return response
})
const items = (result.data ?? []).filter((x: { info?: { id?: string } }) => !!x?.info?.id)
const session = items
.map((x: { info: Message }) => stripMessageDiffSnapshots(x.info))
@@ -397,7 +428,11 @@ export function useSync() {
shouldFetchSession
? (async () => {
try {
const result = await retry(() => sdk.session.get({ sessionID, directory }))
const result = await retry(async () => {
const response = await sdk.session.get({ sessionID, directory })
assertSdkSuccess(response, "session.get")
return response
})
if (result.data) {
const s = store.getState()
const sessions = [...s.session]
+11 -2
View File
@@ -4,6 +4,7 @@
*/
import { create } from "zustand"
import { getRuntimeKey } from "@/lib/runtime-switch"
export type SessionMemoryState = {
viewportAnchor: number
@@ -36,6 +37,13 @@ export type ViewportState = {
updateViewportAnchor: (sessionId: string, anchor: number, scrollPosition?: SessionMemoryState['scrollPosition']) => void
}
export const viewportSessionKey = (sessionId: string, runtimeKey = getRuntimeKey()): string => `${runtimeKey}\n${sessionId}`
export const getViewportSessionMemory = (sessionId: string): SessionMemoryState | undefined => {
const state = useViewportStore.getState()
return state.sessionMemoryState.get(viewportSessionKey(sessionId)) ?? state.sessionMemoryState.get(sessionId)
}
export const useViewportStore = create<ViewportState>()((set) => ({
sessionMemoryState: new Map(),
isSyncing: false,
@@ -43,13 +51,14 @@ export const useViewportStore = create<ViewportState>()((set) => ({
updateViewportAnchor: (sessionId, anchor, scrollPosition) =>
set((s) => {
const map = new Map(s.sessionMemoryState)
const existing = map.get(sessionId) ?? {
const key = viewportSessionKey(sessionId)
const existing = map.get(key) ?? map.get(sessionId) ?? {
viewportAnchor: 0,
isStreaming: false,
lastAccessedAt: Date.now(),
backgroundMessageCount: 0,
}
map.set(sessionId, {
map.set(key, {
...existing,
viewportAnchor: anchor,
...(scrollPosition ? { scrollPosition } : {}),