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
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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])
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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 } : {}),
|
||||
|
||||
Reference in New Issue
Block a user