refactor(app): route backend through adapters

This commit is contained in:
Brendan Allan 2026-07-16 06:24:08 +00:00
commit 08ea08e830
130 changed files with 5302 additions and 1953 deletions

View file

@ -840,7 +840,6 @@
"dependencies": { "dependencies": {
"@kobalte/core": "catalog:", "@kobalte/core": "catalog:",
"@opencode-ai/core": "workspace:*", "@opencode-ai/core": "workspace:*",
"@opencode-ai/sdk": "workspace:*",
"@opencode-ai/ui": "workspace:*", "@opencode-ai/ui": "workspace:*",
"@pierre/diffs": "catalog:", "@pierre/diffs": "catalog:",
"@shikijs/stream": "catalog:", "@shikijs/stream": "catalog:",

View file

@ -169,7 +169,7 @@ export function createCommandPaletteModel(props: { filesOnly?: () => boolean; on
const name = store.vcs?.branch ?? getFilename(directory) const name = store.vcs?.branch ?? getFilename(directory)
return `${kind} : ${name || path}` return `${kind} : ${name || path}`
}, },
load: (directory) => serverSDK.client.session.list({ directory, roots: true }), load: async (directory) => (await serverSDK.backend).common.sessions.list({ location: { directory }, roots: true }),
untitled: () => language.t("command.session.new"), untitled: () => language.t("command.session.new"),
category: () => language.t("command.category.session"), category: () => language.t("command.category.session"),
}) })
@ -233,7 +233,7 @@ function createCommandEntry(option: CommandOption, category: string): CommandPal
function createSessionEntries(props: { function createSessionEntries(props: {
workspaces: () => string[] workspaces: () => string[]
label: (directory: string) => string label: (directory: string) => string
load: (directory: string) => ReturnType<ServerSDK["client"]["session"]["list"]> load: (directory: string) => ReturnType<Awaited<ServerSDK["backend"]>["common"]["sessions"]["list"]>
untitled: () => string untitled: () => string
category: () => string category: () => string
}) { }) {
@ -263,7 +263,7 @@ function createSessionEntries(props: {
return props return props
.load(directory) .load(directory)
.then((result) => .then((result) =>
(result.data ?? []) result.items
.filter((session) => !!session?.id) .filter((session) => !!session?.id)
.map((session) => ({ .map((session) => ({
id: session.id, id: session.id,

View file

@ -1,4 +1,4 @@
import type { ProviderAuthAuthorization, ProviderAuthMethod } from "@opencode-ai/sdk/v2/client" import type { ProviderAuthorization, ProviderAuthMethod } from "@/context/backend"
import { Button } from "@opencode-ai/ui/button" import { Button } from "@opencode-ai/ui/button"
import { useDialog } from "@opencode-ai/ui/context/dialog" import { useDialog } from "@opencode-ai/ui/context/dialog"
import { Dialog } from "@opencode-ai/ui/dialog" import { Dialog } from "@opencode-ai/ui/dialog"
@ -31,6 +31,7 @@ import { popularProviders, useProviders } from "@/hooks/use-providers"
import { CustomProviderForm } from "./dialog-custom-provider" import { CustomProviderForm } from "./dialog-custom-provider"
const CUSTOM_ID = "_custom" const CUSTOM_ID = "_custom"
type AuthMethod = ProviderAuthMethod & { readonly id?: string }
export function useProviderConnectController(options: { onBack?: () => void } = {}) { export function useProviderConnectController(options: { onBack?: () => void } = {}) {
const [store, setStore] = createStore({ selected: undefined as string | undefined }) const [store, setStore] = createStore({ selected: undefined as string | undefined })
@ -176,6 +177,7 @@ function ProviderConnection(props: {
const providers = useProviders(props.directory) const providers = useProviders(props.directory)
const alive = { value: true } const alive = { value: true }
const connected = { value: false }
const timer = { current: undefined as ReturnType<typeof setTimeout> | undefined } const timer = { current: undefined as ReturnType<typeof setTimeout> | undefined }
onCleanup(() => { onCleanup(() => {
@ -188,7 +190,16 @@ function ProviderConnection(props: {
const provider = createMemo( const provider = createMemo(
() => providers.all().get(props.provider) ?? serverSync().data.provider.all.get(props.provider)!, () => providers.all().get(props.provider) ?? serverSync().data.provider.all.get(props.provider)!,
) )
const fallback = createMemo<ProviderAuthMethod[]>(() => [ const integrationID = () => {
const value = provider()
if (!("integrationID" in value) || typeof value.integrationID !== "string") return props.provider
return value.integrationID
}
const location = () => {
const directory = props.directory?.()
return directory ? { location: { directory } } : {}
}
const fallback = createMemo<AuthMethod[]>(() => [
{ {
type: "api" as const, type: "api" as const,
label: language.t("provider.connect.method.apiKey"), label: language.t("provider.connect.method.apiKey"),
@ -197,19 +208,52 @@ function ProviderConnection(props: {
const [auth] = createResource( const [auth] = createResource(
() => props.provider, () => props.provider,
async () => { async () => {
const backend = await serverSDK().backend
if (backend.version === "v2") {
const capability = backend.capabilities.integrationsV2
if (!capability) throw new Error("Server does not support provider integrations")
const integration = await capability.get({ ...location(), integrationID: integrationID() })
if (!alive.value) return fallback()
return (
integration?.methods.flatMap((method): AuthMethod[] => {
if (method.type === "environment") return []
if (method.type === "key") return [{ type: "api", label: method.label }]
return [{ type: "oauth", id: method.id, label: method.label, prompts: method.prompts }]
}) ?? fallback()
)
}
const cached = serverSync().data.provider_auth[props.provider] const cached = serverSync().data.provider_auth[props.provider]
if (cached) return cached if (cached) return cached
const res = await serverSDK().client.provider.auth() const capability = backend.capabilities.providerAuthV1
if (!capability) throw new Error("Server does not support provider authentication")
const result = await capability.methods(location())
if (!alive.value) return fallback() if (!alive.value) return fallback()
serverSync().set("provider_auth", res.data ?? {}) const normalized = Object.fromEntries(
return res.data?.[props.provider] ?? fallback() Object.entries(result).map(([id, methods]) => [
id,
methods.map((method) => ({
...method,
prompts: method.prompts?.map((prompt) =>
prompt.type === "select"
? { ...prompt, options: prompt.options.map((option) => ({ ...option })) }
: { ...prompt },
),
})),
]),
)
serverSync().set("provider_auth", normalized)
return normalized[props.provider] ?? fallback()
}, },
) )
const loading = createMemo(() => auth.loading && !serverSync().data.provider_auth[props.provider]) const loading = createMemo(() => auth.loading && !serverSync().data.provider_auth[props.provider])
const methods = createMemo(() => auth.latest ?? serverSync().data.provider_auth[props.provider] ?? fallback()) const methods = createMemo<AuthMethod[]>(() => [
...(auth.latest ?? serverSync().data.provider_auth[props.provider] ?? fallback()),
])
const [store, setStore] = createStore({ const [store, setStore] = createStore({
methodIndex: undefined as undefined | number, methodIndex: undefined as undefined | number,
authorization: undefined as undefined | ProviderAuthAuthorization, authorization: undefined as undefined | ProviderAuthorization,
attemptID: undefined as string | undefined,
promptInputs: undefined as undefined | Record<string, string>, promptInputs: undefined as undefined | Record<string, string>,
state: "pending" as undefined | "pending" | "complete" | "error" | "prompt", state: "pending" as undefined | "pending" | "complete" | "error" | "prompt",
error: undefined as string | undefined, error: undefined as string | undefined,
@ -221,7 +265,7 @@ function ProviderConnection(props: {
| { type: "auth.prompt" } | { type: "auth.prompt" }
| { type: "auth.inputs"; inputs: Record<string, string> } | { type: "auth.inputs"; inputs: Record<string, string> }
| { type: "auth.pending" } | { type: "auth.pending" }
| { type: "auth.complete"; authorization: ProviderAuthAuthorization } | { type: "auth.complete"; authorization: ProviderAuthorization; attemptID?: string }
| { type: "auth.error"; error: string } | { type: "auth.error"; error: string }
function dispatch(action: Action) { function dispatch(action: Action) {
@ -230,6 +274,7 @@ function ProviderConnection(props: {
if (action.type === "method.select") { if (action.type === "method.select") {
draft.methodIndex = action.index draft.methodIndex = action.index
draft.authorization = undefined draft.authorization = undefined
draft.attemptID = undefined
draft.promptInputs = undefined draft.promptInputs = undefined
draft.state = undefined draft.state = undefined
draft.error = undefined draft.error = undefined
@ -238,6 +283,7 @@ function ProviderConnection(props: {
if (action.type === "method.reset") { if (action.type === "method.reset") {
draft.methodIndex = undefined draft.methodIndex = undefined
draft.authorization = undefined draft.authorization = undefined
draft.attemptID = undefined
draft.promptInputs = undefined draft.promptInputs = undefined
draft.state = undefined draft.state = undefined
draft.error = undefined draft.error = undefined
@ -262,6 +308,7 @@ function ProviderConnection(props: {
if (action.type === "auth.complete") { if (action.type === "auth.complete") {
draft.state = "complete" draft.state = "complete"
draft.authorization = action.authorization draft.authorization = action.authorization
draft.attemptID = action.attemptID
draft.error = undefined draft.error = undefined
return return
} }
@ -273,6 +320,16 @@ function ProviderConnection(props: {
const method = createMemo(() => (store.methodIndex !== undefined ? methods().at(store.methodIndex!) : undefined)) const method = createMemo(() => (store.methodIndex !== undefined ? methods().at(store.methodIndex!) : undefined))
onCleanup(() => {
if (!store.attemptID || connected.value) return
void (async () => {
const backend = await serverSDK().backend
await backend.capabilities.integrationsV2
?.cancelAttempt({ ...location(), attemptID: store.attemptID! })
.catch(() => undefined)
})()
})
const methodLabel = (value?: { type?: string; label?: string }) => { const methodLabel = (value?: { type?: string; label?: string }) => {
if (!value) return "" if (!value) return ""
if (value.type === "api") return language.t("provider.connect.method.apiKey") if (value.type === "api") return language.t("provider.connect.method.apiKey")
@ -322,15 +379,33 @@ function ProviderConnection(props: {
} }
dispatch({ type: "auth.pending" }) dispatch({ type: "auth.pending" })
const start = Date.now() const start = Date.now()
await serverSDK() const backend = await serverSDK().backend
.client.provider.oauth.authorize( const request =
{ backend.version === "v1"
providerID: props.provider, ? (() => {
method: index, const capability = backend.capabilities.providerAuthV1
inputs, if (!capability) throw new Error("Server does not support provider authentication")
}, return capability.authorize({ ...location(), providerID: props.provider, method: index, values: inputs })
{ throwOnError: true }, })()
) : (() => {
const capability = backend.capabilities.integrationsV2
if (!capability) throw new Error("Server does not support provider integrations")
if (!method.id) throw new Error("Provider OAuth method is missing an ID")
return capability
.connectOauth({
...location(),
integrationID: integrationID(),
methodID: method.id,
values: inputs ?? {},
})
.then((attempt) => ({
url: attempt.url,
method: attempt.mode,
instructions: attempt.instructions,
attemptID: attempt.attemptID,
}))
})()
await request
.then((x) => { .then((x) => {
if (!alive.value) return if (!alive.value) return
const elapsed = Date.now() - start const elapsed = Date.now() - start
@ -341,11 +416,19 @@ function ProviderConnection(props: {
timer.current = setTimeout(() => { timer.current = setTimeout(() => {
timer.current = undefined timer.current = undefined
if (!alive.value) return if (!alive.value) return
dispatch({ type: "auth.complete", authorization: x.data! }) dispatch({
type: "auth.complete",
authorization: x,
attemptID: "attemptID" in x && typeof x.attemptID === "string" ? x.attemptID : undefined,
})
}, delay) }, delay)
return return
} }
dispatch({ type: "auth.complete", authorization: x.data! }) dispatch({
type: "auth.complete",
authorization: x,
attemptID: "attemptID" in x && typeof x.attemptID === "string" ? x.attemptID : undefined,
})
}) })
.catch((e) => { .catch((e) => {
if (!alive.value) return if (!alive.value) return
@ -445,7 +528,7 @@ function ProviderConnection(props: {
<div> <div>
<List <List
class="px-3" class="px-3"
items={select()?.options ?? []} items={[...(select()?.options ?? [])]}
key={(x) => x.value} key={(x) => x.value}
current={select()?.options.find((x) => x.value === formStore.value[select()!.key])} current={select()?.options.find((x) => x.value === formStore.value[select()!.key])}
onSelect={(value) => { onSelect={(value) => {
@ -498,7 +581,10 @@ function ProviderConnection(props: {
}) })
async function complete() { async function complete() {
await serverSDK().client.global.dispose() const backend = await serverSDK().backend
if (backend.version === "v1") await backend.capabilities.runtimeV1?.disposeAll()
if (backend.version === "v2") await serverSync().refreshProviders()
connected.value = true
dialog.close() dialog.close()
showToast({ showToast({
variant: "success", variant: "success",
@ -570,14 +656,20 @@ function ProviderConnection(props: {
} }
setFormStore("error", undefined) setFormStore("error", undefined)
await serverSDK().client.auth.set({ const backend = await serverSDK().backend
providerID: props.provider, if (backend.version === "v1") {
auth: { const capability = backend.capabilities.providerAuthV1
type: "api", if (!capability) throw new Error("Server does not support provider authentication")
await capability.setApiKey({ providerID: props.provider, key: apiKey, metadata: store.promptInputs })
} else {
const capability = backend.capabilities.integrationsV2
if (!capability) throw new Error("Server does not support provider integrations")
await capability.connectKey({
...location(),
integrationID: integrationID(),
key: apiKey, key: apiKey,
...(store.promptInputs ? { metadata: store.promptInputs } : {}), })
}, }
})
await complete() await complete()
} }
@ -642,13 +734,20 @@ function ProviderConnection(props: {
} }
setFormStore("error", undefined) setFormStore("error", undefined)
const result = await serverSDK() const result = await (async () => {
.client.provider.oauth.callback({ const backend = await serverSDK().backend
providerID: props.provider, if (backend.version === "v1") {
method: store.methodIndex, const capability = backend.capabilities.providerAuthV1
code, if (!capability) throw new Error("Server does not support provider authentication")
}) await capability.callback({ providerID: props.provider, method: store.methodIndex!, code })
.then((value) => (value.error ? { ok: false as const, error: value.error } : { ok: true as const })) return
}
const capability = backend.capabilities.integrationsV2
if (!capability) throw new Error("Server does not support provider integrations")
if (!store.attemptID) throw new Error("Provider OAuth attempt is missing")
await capability.completeAttempt({ ...location(), attemptID: store.attemptID, code })
})()
.then(() => ({ ok: true as const }))
.catch((error) => ({ ok: false as const, error })) .catch((error) => ({ ok: false as const, error }))
if (result.ok) { if (result.ok) {
await complete() await complete()
@ -695,12 +794,26 @@ function ProviderConnection(props: {
onMount(() => { onMount(() => {
void (async () => { void (async () => {
const result = await serverSDK() const result = await (async () => {
.client.provider.oauth.callback({ const backend = await serverSDK().backend
providerID: props.provider, if (backend.version === "v1") {
method: store.methodIndex, const capability = backend.capabilities.providerAuthV1
}) if (!capability) throw new Error("Server does not support provider authentication")
.then((value) => (value.error ? { ok: false as const, error: value.error } : { ok: true as const })) await capability.callback({ providerID: props.provider, method: store.methodIndex! })
return
}
const capability = backend.capabilities.integrationsV2
if (!capability) throw new Error("Server does not support provider integrations")
if (!store.attemptID) throw new Error("Provider OAuth attempt is missing")
while (alive.value) {
const status = await capability.attemptStatus({ ...location(), attemptID: store.attemptID })
if (status.status === "complete") return
if (status.status === "failed") throw new Error(status.error ?? "Authorization failed")
if (status.status === "expired") throw new Error("Authorization expired")
await new Promise((resolve) => setTimeout(resolve, 500))
}
})()
.then(() => ({ ok: true as const }))
.catch((error) => ({ ok: false as const, error })) .catch((error) => ({ ok: false as const, error }))
if (!alive.value) return if (!alive.value) return

View file

@ -118,7 +118,7 @@ export function CustomProviderForm() {
const output = validateCustomProvider({ const output = validateCustomProvider({
form, form,
t: language.t, t: language.t,
disabledProviders: serverSync().data.config.disabled_providers ?? [], disabledProviders: [...(serverSync().data.config.disabledProviders ?? [])],
existingProviderIDs: new Set(serverSync().data.provider.all.keys()), existingProviderIDs: new Set(serverSync().data.provider.all.keys()),
}) })
batch(() => { batch(() => {
@ -131,23 +131,26 @@ export function CustomProviderForm() {
const saveMutation = useMutation(() => ({ const saveMutation = useMutation(() => ({
mutationFn: async (result: NonNullable<ReturnType<typeof validate>>) => { mutationFn: async (result: NonNullable<ReturnType<typeof validate>>) => {
const disabledProviders = serverSync().data.config.disabled_providers ?? [] const disabledProviders = serverSync().data.config.disabledProviders ?? []
const nextDisabled = disabledProviders.filter((id) => id !== result.providerID) const nextDisabled = disabledProviders.filter((id) => id !== result.providerID)
const backend = await serverSDK().backend
if (result.key) { if (result.key && backend.version === "v1") {
await serverSDK().client.auth.set({ const capability = backend.capabilities.providerAuthV1
providerID: result.providerID, if (!capability) throw new Error("Server does not support provider authentication")
auth: { await capability.setApiKey({ providerID: result.providerID, key: result.key })
type: "api",
key: result.key,
},
})
} }
await serverSync().updateConfig({ await serverSync().updateConfig({
provider: { [result.providerID]: result.config }, provider: { [result.providerID]: result.config },
disabled_providers: nextDisabled, disabledProviders: nextDisabled,
}) })
if (result.key && backend.version === "v2") {
const capability = backend.capabilities.integrationsV2
if (!capability) throw new Error("Server does not support provider integrations")
await capability.connectKey({ integrationID: result.providerID, key: result.key })
await serverSync().refreshProviders()
}
return result return result
}, },
onSuccess: (result) => { onSuccess: (result) => {

View file

@ -80,9 +80,11 @@ export function DialogEditProject(props: { project: LocalProject; server: Server
const start = store.startup.trim() const start = store.startup.trim()
if (props.project.id && props.project.id !== "global") { if (props.project.id && props.project.id !== "global") {
await serverSDK().client.project.update({ const editing = (await serverSDK().backend).capabilities.projectEditing
if (!editing) throw new Error("Project editing is not supported by this server")
await editing.update({
projectID: props.project.id, projectID: props.project.id,
directory: props.project.worktree, location: { directory: props.project.worktree },
name, name,
icon: { color: store.color || "", override: store.iconOverride || "" }, icon: { color: store.color || "", override: store.iconOverride || "" },
commands: { start }, commands: { start },

View file

@ -8,7 +8,7 @@ import { Dialog } from "@opencode-ai/ui/dialog"
import { List } from "@opencode-ai/ui/list" import { List } from "@opencode-ai/ui/list"
import { showToast } from "@/utils/toast" import { showToast } from "@/utils/toast"
import { extractPromptFromParts } from "@/utils/prompt" import { extractPromptFromParts } from "@/utils/prompt"
import type { TextPart as SDKTextPart } from "@opencode-ai/sdk/v2/client" import type { AppPart } from "@/context/backend"
import { base64Encode } from "@opencode-ai/core/util/encode" import { base64Encode } from "@opencode-ai/core/util/encode"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
@ -42,7 +42,9 @@ export const DialogFork: Component = () => {
if (message.role !== "user") continue if (message.role !== "user") continue
const parts = sync().data.part[message.id] ?? [] const parts = sync().data.part[message.id] ?? []
const textPart = parts.find((x): x is SDKTextPart => x.type === "text" && !x.synthetic && !x.ignored) const textPart = parts.find(
(x): x is Extract<AppPart, { type: "text" }> => x.type === "text" && !x.synthetic && !x.ignored,
)
if (!textPart) continue if (!textPart) continue
result.push({ result.push({
@ -69,15 +71,15 @@ export const DialogFork: Component = () => {
const dir = base64Encode(sdk().directory) const dir = base64Encode(sdk().directory)
sdk() sdk()
.client.session.fork({ sessionID, messageID: item.id }) .backend.then((client) => {
const capability = client.capabilities.sessionActionsV1
if (!capability) throw new Error("Session forking is not supported by this server")
return capability.fork({ location: { directory: sdk().directory }, sessionID, messageID: item.id })
})
.then((forked) => { .then((forked) => {
if (!forked.data) {
showToast({ title: language.t("common.requestFailed") })
return
}
dialog.close() dialog.close()
prompt.set(restored, undefined, { dir, id: forked.data.id }) prompt.set(restored, undefined, { dir, id: forked.id })
navigate(`/${dir}/session/${forked.data.id}`) navigate(`/${dir}/session/${forked.id}`)
}) })
.catch((err: unknown) => { .catch((err: unknown) => {
const message = err instanceof Error ? err.message : String(err) const message = err instanceof Error ? err.message : String(err)

View file

@ -68,9 +68,8 @@ export function DialogSelectDirectoryV2(props: DialogSelectDirectoryV2Props) {
const [fallbackPath] = createResource( const [fallbackPath] = createResource(
() => (missingBase() ? true : undefined), () => (missingBase() ? true : undefined),
() => () =>
sdk.client.path sdk.backend
.get() .then((client) => client.capabilities.pathInfo?.get())
.then((result) => result.data)
.catch(() => undefined), .catch(() => undefined),
{ initialValue: undefined }, { initialValue: undefined },
) )
@ -83,20 +82,26 @@ export function DialogSelectDirectoryV2(props: DialogSelectDirectoryV2Props) {
fallbackPath()?.home || fallbackPath()?.home ||
fallbackPath()?.directory, fallbackPath()?.directory,
) )
const search = createDirectorySearch({ sdk, home, base: () => root() || start() }) const search = createDirectorySearch({ backend: sdk.backend, home, base: () => root() || start() })
const [suggestions] = createResource(input, async (value) => { const [suggestions] = createResource(input, async (value) => {
const typed = cleanPickerInput(value).replace(/\/+$/, "") const typed = cleanPickerInput(value).replace(/\/+$/, "")
const current = displayPickerPath(root(), value, home()).replace(/\/+$/, "") const current = displayPickerPath(root(), value, home()).replace(/\/+$/, "")
if (!typed || typed === current) return { query: value, items: [] } if (!typed || typed === current) return { query: value, items: [] }
const directories = (await search(value)).map((absolute) => ({ absolute, type: "directory" as const })) const directories = (await search(value)).map((absolute) => ({ absolute, type: "directory" as const }))
if (!policy.includeFiles) return { query: value, items: directories.slice(0, 5) } if (!policy.includeFiles) return { query: value, items: directories.slice(0, 5) }
const files = await sdk.client.find const files = await sdk.backend
.files({ directory: root(), query: pickerFileSearchQuery(root(), value, home()), type: "file", limit: 20 }) .then((client) =>
.then((result) => result.data ?? []) client.common.files.find({
location: { directory: root() },
query: pickerFileSearchQuery(root(), value, home()),
type: "file",
limit: 20,
}),
)
.catch(() => []) .catch(() => [])
const results = [ const results = [
...directories, ...directories,
...files.map((path) => ({ absolute: absoluteTreePath(root(), path), type: "file" as const })), ...files.map((file) => ({ absolute: file.absolute ?? absoluteTreePath(root(), file.path), type: "file" as const })),
] ]
return { return {
query: value, query: value,
@ -115,9 +120,9 @@ export function DialogSelectDirectoryV2(props: DialogSelectDirectoryV2Props) {
existing ?? existing ??
loads.schedule(`${generation}:${key}`, eager ? "background" : "user", () => { loads.schedule(`${generation}:${key}`, eager ? "background" : "user", () => {
if (!activeTreeNavigation(generation, navigation)) return Promise.resolve(undefined) if (!activeTreeNavigation(generation, navigation)) return Promise.resolve(undefined)
return sdk.client.file return sdk.backend
.list({ directory: absolute, path: "" }) .then((client) => client.common.files.list({ location: { directory: absolute }, path: "" }))
.then((result) => result.data ?? []) .then((nodes) => nodes.map((node) => ({ name: node.name ?? node.path, type: node.type })))
.catch(() => undefined) .catch(() => undefined)
}) })
listings.set(key, request) listings.set(key, request)

View file

@ -60,9 +60,8 @@ export function DialogSelectDirectory(props: DialogSelectDirectoryProps) {
const [fallbackPath] = createResource( const [fallbackPath] = createResource(
() => (missingBase() ? true : undefined), () => (missingBase() ? true : undefined),
async () => { async () => {
return sdk.client.path return sdk.backend
.get() .then((client) => client.capabilities.pathInfo?.get())
.then((x) => x.data)
.catch(() => undefined) .catch(() => undefined)
}, },
{ initialValue: undefined }, { initialValue: undefined },
@ -74,7 +73,7 @@ export function DialogSelectDirectory(props: DialogSelectDirectoryProps) {
) )
const directories = createDirectorySearch({ const directories = createDirectorySearch({
sdk, backend: sdk.backend,
home, home,
base: start, base: start,
}) })

View file

@ -21,6 +21,7 @@ import {
pickerRoot, pickerRoot,
pickerAbsoluteInput, pickerAbsoluteInput,
} from "./directory-picker-domain" } from "./directory-picker-domain"
import { createAppClient } from "@/context/backend.test-fixture"
test("maps server directory entries into Pierre paths", () => { test("maps server directory entries into Pierre paths", () => {
expect( expect(
@ -132,18 +133,20 @@ test("scopes file autocomplete to the current browser root", () => {
test("resolves directory autocomplete from the current browser root", async () => { test("resolves directory autocomplete from the current browser root", async () => {
const directories: string[] = [] const directories: string[] = []
const sdk = { const backend = Promise.resolve(
client: { createAppClient({
find: { common: {
files: (input: { directory: string }) => { files: {
directories.push(input.directory) find: (input) => {
return Promise.resolve({ data: [] }) directories.push(input.location?.directory ?? "")
return Promise.resolve([])
},
}, },
}, },
}, }),
} as unknown as Parameters<typeof createDirectorySearch>[0]["sdk"] )
let base = "/repo" let base = "/repo"
const search = createDirectorySearch({ sdk, home: () => "/home/luke", base: () => base }) const search = createDirectorySearch({ backend, home: () => "/home/luke", base: () => base })
await search("components") await search("components")
base = "/repo/src" base = "/repo/src"

View file

@ -247,7 +247,7 @@ export function nativePickerPath(path: string) {
} }
import { getFilename } from "@opencode-ai/core/util/path" import { getFilename } from "@opencode-ai/core/util/path"
import fuzzysort from "fuzzysort" import fuzzysort from "fuzzysort"
import { ServerSDK } from "@/context/server-sdk" import type { AppClient } from "@/context/backend"
export function cleanPickerInput(value: string) { export function cleanPickerInput(value: string) {
const first = (value ?? "").split(/\r?\n/)[0] ?? "" const first = (value ?? "").split(/\r?\n/)[0] ?? ""
@ -321,7 +321,11 @@ export function displayPickerPath(path: string, input: string, home: string) {
return pickerTilde(value, home) || value return pickerTilde(value, home) || value
} }
export function createDirectorySearch(args: { sdk: ServerSDK; base: () => string | undefined; home: () => string }) { export function createDirectorySearch(args: {
backend: Promise<AppClient>
base: () => string | undefined
home: () => string
}) {
const cache = new Map<string, Promise<Array<{ name: string; absolute: string }>>>() const cache = new Map<string, Promise<Array<{ name: string; absolute: string }>>>()
let current = 0 let current = 0
@ -342,14 +346,16 @@ export function createDirectorySearch(args: { sdk: ServerSDK; base: () => string
const key = trimPickerPath(directory) const key = trimPickerPath(directory)
const existing = cache.get(key) const existing = cache.get(key)
if (existing) return existing if (existing) return existing
const request = args.sdk.client.file const request = args.backend
.list({ directory: key, path: "" }) .then((client) => client.common.files.list({ location: { directory: key }, path: "" }))
.then((result) => result.data ?? [])
.catch(() => []) .catch(() => [])
.then((nodes) => .then((nodes) =>
nodes nodes
.filter((node) => node.type === "directory") .filter((node) => node.type === "directory")
.map((node) => ({ name: node.name, absolute: trimPickerPath(normalizePickerDrive(node.absolute)) })), .map((node) => ({
name: node.name ?? getFilename(node.path),
absolute: trimPickerPath(normalizePickerDrive(node.absolute ?? joinPickerPath(key, node.path))),
})),
) )
cache.set(key, request) cache.set(key, request)
return request return request
@ -371,12 +377,18 @@ export function createDirectorySearch(args: { sdk: ServerSDK; base: () => string
const pathInput = raw.startsWith("~") || !!pickerRoot(raw) || raw.includes("/") const pathInput = raw.startsWith("~") || !!pickerRoot(raw) || raw.includes("/")
const query = normalizePickerDrive(input.path) const query = normalizePickerDrive(input.path)
if (!pathInput) { if (!pathInput) {
const results = await args.sdk.client.find const results = await args.backend
.files({ directory: input.directory, query, type: "directory", limit: 50 }) .then((client) =>
.then((result) => result.data ?? []) client.common.files.find({
location: { directory: input.directory },
query,
type: "directory",
limit: 50,
}),
)
.catch(() => []) .catch(() => [])
if (!active()) return [] if (!active()) return []
return results.map((path) => joinPickerPath(input.directory, path)).slice(0, 50) return results.map((item) => item.absolute ?? joinPickerPath(input.directory, item.path)).slice(0, 50)
} }
const segments = query.replace(/^\/+/, "").split("/") const segments = query.replace(/^\/+/, "").split("/")
const head = segments.slice(0, -1).filter((part) => part && part !== ".") const head = segments.slice(0, -1).filter((part) => part && part !== ".")

View file

@ -1,4 +1,4 @@
import type { FileNode } from "@opencode-ai/sdk/v2" import type { AppFileNode as FileNode } from "@/context/backend"
export type FileTreeV2Model = { export type FileTreeV2Model = {
children: ReadonlyMap<string, readonly FileTreeV2Node[]> children: ReadonlyMap<string, readonly FileTreeV2Node[]>

View file

@ -12,7 +12,7 @@ import {
type ParentProps, type ParentProps,
} from "solid-js" } from "solid-js"
import { Dynamic } from "solid-js/web" import { Dynamic } from "solid-js/web"
import type { FileNode } from "@opencode-ai/sdk/v2" import type { AppFileNode as FileNode } from "@/context/backend"
import { Icon } from "@opencode-ai/ui/v2/icon" import { Icon } from "@opencode-ai/ui/v2/icon"
import { pathToFileUrl, withFileDragImage, type Kind } from "@/components/file-tree" import { pathToFileUrl, withFileDragImage, type Kind } from "@/components/file-tree"
import { createVirtualizer, defaultRangeExtractor } from "@tanstack/solid-virtual" import { createVirtualizer, defaultRangeExtractor } from "@tanstack/solid-virtual"

View file

@ -17,7 +17,7 @@ import {
type ParentProps, type ParentProps,
} from "solid-js" } from "solid-js"
import { Dynamic } from "solid-js/web" import { Dynamic } from "solid-js/web"
import type { FileNode } from "@opencode-ai/sdk/v2" import type { AppFileNode as FileNode } from "@/context/backend"
const MAX_DEPTH = 128 const MAX_DEPTH = 128

View file

@ -74,7 +74,7 @@ import { promptPlaceholder } from "./prompt-input/placeholder"
import { createPromptInputTransientState } from "./prompt-input/transient-state" import { createPromptInputTransientState } from "./prompt-input/transient-state"
import { showToast } from "@/utils/toast" import { showToast } from "@/utils/toast"
import { ImagePreview } from "@opencode-ai/ui/image-preview" import { ImagePreview } from "@opencode-ai/ui/image-preview"
import type { ReferenceInfo } from "@opencode-ai/sdk/v2/client" import type { AppReference as ReferenceInfo } from "@/context/backend"
export type PromptInputState = ReturnType<typeof usePrompt> export type PromptInputState = ReturnType<typeof usePrompt>
@ -678,7 +678,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
type: "resource", type: "resource",
name: resource.name, name: resource.name,
uri: resource.uri, uri: resource.uri,
client: resource.client, client: resource.server,
display: resource.name, display: resource.name,
description: resource.description, description: resource.description,
mime: resource.mimeType, mime: resource.mimeType,

View file

@ -46,6 +46,8 @@ describe("buildRequestParts", () => {
).toBe(true) ).toBe(true)
expect(result.optimisticParts).toHaveLength(result.requestParts.length) expect(result.optimisticParts).toHaveLength(result.requestParts.length)
expect(result.optimisticParts.map((part) => part.id)).toEqual(result.requestParts.map((part) => part.id))
expect(result.optimisticParts.map((part) => part.type)).toEqual(result.requestParts.map((part) => part.type))
expect(result.optimisticParts.every((part) => part.sessionID === "ses_1" && part.messageID === "msg_1")).toBe(true) expect(result.optimisticParts.every((part) => part.sessionID === "ses_1" && part.messageID === "msg_1")).toBe(true)
}) })

View file

@ -1,12 +1,12 @@
import { getFilename } from "@opencode-ai/core/util/path" import { getFilename } from "@opencode-ai/core/util/path"
import { type AgentPartInput, type FilePartInput, type Part, type TextPartInput } from "@opencode-ai/sdk/v2/client" import type { AppPart as Part, PromptPart } from "@/context/backend"
import type { FileSelection } from "@/context/file" import type { FileSelection } from "@/context/file"
import { encodeFilePath } from "@/context/file/path" import { encodeFilePath } from "@/context/file/path"
import type { AgentPart, FileAttachmentPart, ImageAttachmentPart, Prompt } from "@/context/prompt" import type { AgentPart, FileAttachmentPart, ImageAttachmentPart, Prompt } from "@/context/prompt"
import { Identifier } from "@/utils/id" import { Identifier } from "@/utils/id"
import { createCommentMetadata, formatCommentNote } from "@/utils/comment-note" import { createCommentMetadata, formatCommentNote } from "@/utils/comment-note"
type PromptRequestPart = (TextPartInput | FilePartInput | AgentPartInput) & { id: string } type PromptRequestPart = PromptPart
type ContextFile = { type ContextFile = {
key: string key: string

View file

@ -81,6 +81,34 @@ const clientFor = (directory: string) => {
beforeAll(async () => { beforeAll(async () => {
const rootClient = clientFor("/repo/main") const rootClient = clientFor("/repo/main")
const backend = Promise.resolve({
common: {
sessions: {
create: async (input: { location?: { directory?: string } }) => {
const directory = input.location?.directory ?? "/repo/main"
createdSessions.push(directory)
return {
id: `session-${createdSessions.length}`,
projectID: "project",
location: { directory },
title: `New session ${createdSessions.length}`,
cost: 0,
time: { created: Date.now() },
}
},
prompt: async () => undefined,
command: async () => undefined,
interrupt: async () => undefined,
},
},
capabilities: {
sessionExtrasV1: {
shell: async (input: { location?: { directory?: string } }) => {
sentShell.push(input.location?.directory ?? "/repo/main")
},
},
},
})
mock.module("@solidjs/router", () => ({ mock.module("@solidjs/router", () => ({
useNavigate: () => () => undefined, useNavigate: () => () => undefined,
@ -89,13 +117,6 @@ beforeAll(async () => {
useSearchParams: () => [search, () => undefined], useSearchParams: () => [search, () => undefined],
})) }))
mock.module("@opencode-ai/sdk/v2/client", () => ({
createOpencodeClient: (input: { directory: string }) => {
createdClients.push(input.directory)
return clientFor(input.directory)
},
}))
mock.module("@opencode-ai/ui/toast", () => ({ mock.module("@opencode-ai/ui/toast", () => ({
Toast: { Region: () => null }, Toast: { Region: () => null },
showToast: () => 0, showToast: () => 0,
@ -161,6 +182,7 @@ beforeAll(async () => {
scope: "local", scope: "local",
directory: "/repo/main", directory: "/repo/main",
client: rootClient, client: rootClient,
backend,
url: "http://localhost:4096", url: "http://localhost:4096",
createClient(opts: any) { createClient(opts: any) {
return clientFor(opts.directory) return clientFor(opts.directory)
@ -282,7 +304,7 @@ describe("prompt submit worktree selection", () => {
selected = "/repo/worktree-b" selected = "/repo/worktree-b"
await submit.handleSubmit(event) await submit.handleSubmit(event)
expect(createdClients).toEqual(["/repo/worktree-a", "/repo/worktree-b"]) expect(createdClients).toEqual([])
expect(createdSessions).toEqual(["/repo/worktree-a", "/repo/worktree-b"]) expect(createdSessions).toEqual(["/repo/worktree-a", "/repo/worktree-b"])
expect(sentShell).toEqual(["/repo/worktree-a", "/repo/worktree-b"]) expect(sentShell).toEqual(["/repo/worktree-a", "/repo/worktree-b"])
expect(syncedDirectories).toEqual(["/repo/worktree-a", "/repo/worktree-a", "/repo/worktree-b", "/repo/worktree-b"]) expect(syncedDirectories).toEqual(["/repo/worktree-a", "/repo/worktree-a", "/repo/worktree-b", "/repo/worktree-b"])
@ -441,7 +463,13 @@ describe("prompt submit worktree selection", () => {
await submit.handleSubmit(event) await submit.handleSubmit(event)
expect(storedSessions["/repo/worktree-a"]).toEqual([{ id: "session-1", title: "New session 1" }]) expect(storedSessions["/repo/worktree-a"]).toEqual([
expect.objectContaining({
id: "session-1",
title: "New session 1",
location: { directory: "/repo/worktree-a" },
}),
])
expect(optimisticSeeded).toEqual([true]) expect(optimisticSeeded).toEqual([true])
}) })
}) })

View file

@ -1,4 +1,3 @@
import type { Message, Session } from "@opencode-ai/sdk/v2/client"
import { showToast } from "@/utils/toast" import { showToast } from "@/utils/toast"
import { base64Encode } from "@opencode-ai/core/util/encode" import { base64Encode } from "@opencode-ai/core/util/encode"
import { Binary } from "@opencode-ai/core/util/binary" import { Binary } from "@opencode-ai/core/util/binary"
@ -11,7 +10,7 @@ import { useLayout } from "@/context/layout"
import { useLocal, type ModelSelection } from "@/context/local" import { useLocal, type ModelSelection } from "@/context/local"
import { usePermission } from "@/context/permission" import { usePermission } from "@/context/permission"
import { type ContextItem, type ImageAttachmentPart, type Prompt, type usePrompt } from "@/context/prompt" import { type ContextItem, type ImageAttachmentPart, type Prompt, type usePrompt } from "@/context/prompt"
import { useSDK, type DirectorySDK } from "@/context/sdk" import { useSDK } from "@/context/sdk"
import { useSync, type DirectorySync } from "@/context/sync" import { useSync, type DirectorySync } from "@/context/sync"
import { Identifier } from "@/utils/id" import { Identifier } from "@/utils/id"
import { Worktree as WorktreeState } from "@/utils/worktree" import { Worktree as WorktreeState } from "@/utils/worktree"
@ -20,6 +19,7 @@ import { setCursorPosition } from "./editor-dom"
import { formatServerError } from "@/utils/server-errors" import { formatServerError } from "@/utils/server-errors"
import { ScopedKey } from "@/utils/server-scope" import { ScopedKey } from "@/utils/server-scope"
import { createPromptSubmissionState } from "./submission-state" import { createPromptSubmissionState } from "./submission-state"
import type { AppClient, AppMessage, AppSession } from "@/context/backend"
type PendingPrompt = { type PendingPrompt = {
abort: AbortController abort: AbortController
@ -39,13 +39,14 @@ export type FollowupDraft = {
} }
type FollowupSendInput = { type FollowupSendInput = {
client: DirectorySDK["client"] backend: Promise<AppClient>
serverSync: ServerSync serverSync: ServerSync
sync: DirectorySync sync: DirectorySync
draft: FollowupDraft draft: FollowupDraft
messageID?: string messageID?: string
optimisticBusy?: boolean optimisticBusy?: boolean
before?: () => Promise<boolean> | boolean before?: () => Promise<boolean> | boolean
commitRevert?: boolean
} }
const draftText = (prompt: Prompt) => prompt.map((part) => ("content" in part ? part.content : "")).join("") const draftText = (prompt: Prompt) => prompt.map((part) => ("content" in part ? part.content : "")).join("")
@ -53,6 +54,8 @@ const draftText = (prompt: Prompt) => prompt.map((part) => ("content" in part ?
const draftImages = (prompt: Prompt) => prompt.filter((part): part is ImageAttachmentPart => part.type === "image") const draftImages = (prompt: Prompt) => prompt.filter((part): part is ImageAttachmentPart => part.type === "image")
export async function sendFollowupDraft(input: FollowupSendInput) { export async function sendFollowupDraft(input: FollowupSendInput) {
const backend = await input.backend
const location = { directory: input.draft.sessionDirectory }
const text = draftText(input.draft.prompt) const text = draftText(input.draft.prompt)
const images = draftImages(input.draft.prompt) const images = draftImages(input.draft.prompt)
const setBusy = () => { const setBusy = () => {
@ -81,19 +84,26 @@ export async function sendFollowupDraft(input: FollowupSendInput) {
return false return false
} }
await input.client.session.command({ if (input.commitRevert)
await backend.capabilities.sessionExtrasV2?.commitRevert({ location, sessionID: input.draft.sessionID })
const capability = backend.capabilities.sessionActionsV1
if (!capability) throw new Error("Commands are not supported by this server")
await capability.command({
location,
sessionID: input.draft.sessionID, sessionID: input.draft.sessionID,
id: input.messageID,
command: cmd, command: cmd,
arguments: tail.join(" "), arguments: tail.join(" "),
agent: input.draft.agent, agent: input.draft.agent,
model: `${input.draft.model.providerID}/${input.draft.model.modelID}`, model: {
variant: input.draft.variant, providerID: input.draft.model.providerID,
parts: images.map((attachment) => ({ id: input.draft.model.modelID,
id: Identifier.ascending("part"), variant: input.draft.variant,
type: "file" as const, },
files: images.map((attachment) => ({
uri: attachment.dataUrl,
mime: attachment.mime, mime: attachment.mime,
url: attachment.dataUrl, name: attachment.filename,
filename: attachment.filename,
})), })),
}) })
return true return true
@ -114,7 +124,7 @@ export async function sendFollowupDraft(input: FollowupSendInput) {
sessionDirectory: input.draft.sessionDirectory, sessionDirectory: input.draft.sessionDirectory,
}) })
const message: Message = { const message: AppMessage = {
id: messageID, id: messageID,
sessionID: input.draft.sessionID, sessionID: input.draft.sessionID,
role: "user", role: "user",
@ -152,13 +162,22 @@ export async function sendFollowupDraft(input: FollowupSendInput) {
return false return false
} }
await input.client.session.promptAsync({ if (input.commitRevert)
await backend.capabilities.sessionExtrasV2?.commitRevert({ location, sessionID: input.draft.sessionID })
await backend.common.sessions.prompt({
location,
sessionID: input.draft.sessionID, sessionID: input.draft.sessionID,
agent: input.draft.agent, id: messageID,
model: input.draft.model, text,
messageID,
parts: requestParts, parts: requestParts,
variant: input.draft.variant, selection: {
agent: input.draft.agent,
model: {
providerID: input.draft.model.providerID,
id: input.draft.model.modelID,
variant: input.draft.variant,
},
},
}) })
return true return true
} catch (err) { } catch (err) {
@ -172,7 +191,7 @@ export async function sendFollowupDraft(input: FollowupSendInput) {
type PromptSubmitInput = { type PromptSubmitInput = {
prompt: ReturnType<typeof usePrompt> prompt: ReturnType<typeof usePrompt>
info: Accessor<{ id: string } | undefined> info: Accessor<{ id: string; revert?: { messageID: string } } | undefined>
imageAttachments: Accessor<ImageAttachmentPart[]> imageAttachments: Accessor<ImageAttachmentPart[]>
commentCount: Accessor<number> commentCount: Accessor<number>
autoAccept: Accessor<boolean> autoAccept: Accessor<boolean>
@ -233,9 +252,9 @@ export function createPromptSubmit(input: PromptSubmitInput) {
return Promise.resolve() return Promise.resolve()
} }
return sdk() return sdk()
.client.session.abort({ .backend.then((client) =>
sessionID, client.common.sessions.interrupt({ location: { directory: sdk().directory }, sessionID }),
}) )
.catch(() => {}) .catch(() => {})
} }
@ -262,10 +281,10 @@ export function createPromptSubmit(input: PromptSubmitInput) {
} }
} }
const seed = (dir: string, info: Session) => { const seed = (dir: string, info: AppSession) => {
serverSync().session.remember(info) serverSync().session.remember(info)
const [, setStore] = serverSync().child(dir) const [, setStore] = serverSync().child(dir)
setStore("session", (list: Session[]) => { setStore("session", (list: AppSession[]) => {
const result = Binary.search(list, info.id, (item) => item.id) const result = Binary.search(list, info.id, (item) => item.id)
const next = [...list] const next = [...list]
if (result.found) { if (result.found) {
@ -313,19 +332,18 @@ export function createPromptSubmit(input: PromptSubmitInput) {
input.resetHistoryNavigation() input.resetHistoryNavigation()
const projectDirectory = sdk().directory const projectDirectory = sdk().directory
const backend = await sdk().backend
const isNewSession = !params.id const isNewSession = !params.id
const shouldAutoAccept = isNewSession && input.autoAccept() const shouldAutoAccept = isNewSession && input.autoAccept()
const worktreeSelection = input.newSessionWorktree?.() || "main" const worktreeSelection = input.newSessionWorktree?.() || "main"
let sessionDirectory = projectDirectory let sessionDirectory = projectDirectory
let client = sdk().client
if (isNewSession) { if (isNewSession) {
if (worktreeSelection === "create") { if (worktreeSelection === "create") {
const createdWorktree = await client.worktree const createdWorktree = await backend.capabilities.worktreesV1
.create({ directory: projectDirectory }) ?.create({ location: { directory: projectDirectory } })
.then((x) => x.data) .catch((err: unknown) => {
.catch((err) => {
showToast({ showToast({
title: language.t("prompt.toast.worktreeCreateFailed.title"), title: language.t("prompt.toast.worktreeCreateFailed.title"),
description: errorMessage(err), description: errorMessage(err),
@ -349,10 +367,6 @@ export function createPromptSubmit(input: PromptSubmitInput) {
} }
if (sessionDirectory !== projectDirectory) { if (sessionDirectory !== projectDirectory) {
client = sdk().createClient({
directory: sessionDirectory,
throwOnError: true,
})
serverSync().child(sessionDirectory) serverSync().child(sessionDirectory)
} }
@ -361,9 +375,10 @@ export function createPromptSubmit(input: PromptSubmitInput) {
let session = input.info() let session = input.info()
if (!session && isNewSession) { if (!session && isNewSession) {
const created = await client.session const created = await sdk()
.create() .backend.then((backend) =>
.then((x) => x.data ?? undefined) backend.common.sessions.create({ location: { directory: sessionDirectory } }),
)
.catch((err) => { .catch((err) => {
showToast({ showToast({
title: language.t("prompt.toast.sessionCreateFailed.title"), title: language.t("prompt.toast.sessionCreateFailed.title"),
@ -447,12 +462,22 @@ export function createPromptSubmit(input: PromptSubmitInput) {
if (mode === "shell") { if (mode === "shell") {
clearInput() clearInput()
client.session sdk()
.shell({ .backend.then(async (backend) => {
sessionID: session.id, const location = { directory: sessionDirectory }
agent, if (session.revert)
model, await backend.capabilities.sessionExtrasV2?.commitRevert({ location, sessionID: session.id })
command: text, if (backend.capabilities.sessionExtrasV1)
return backend.capabilities.sessionExtrasV1.shell({
location,
sessionID: session.id,
agent,
model: { id: model.modelID, providerID: model.providerID },
command: text,
})
if (backend.capabilities.sessionExtrasV2?.shell)
return backend.capabilities.sessionExtrasV2.shell({ location, sessionID: session.id, command: text })
throw new Error("Shell prompts are not supported by this server")
}) })
.catch((err) => { .catch((err) => {
showToast({ showToast({
@ -470,21 +495,27 @@ export function createPromptSubmit(input: PromptSubmitInput) {
const customCommand = sync().data.command.find((c) => c.name === commandName) const customCommand = sync().data.command.find((c) => c.name === commandName)
if (customCommand) { if (customCommand) {
clearInput() clearInput()
client.session sdk()
.command({ .backend.then(async (backend) => {
sessionID: session.id, const location = { directory: sessionDirectory }
command: commandName, if (session.revert)
arguments: args.join(" "), await backend.capabilities.sessionExtrasV2?.commitRevert({ location, sessionID: session.id })
agent, const capability = backend.capabilities.sessionActionsV1
model: `${model.providerID}/${model.modelID}`, if (!capability) throw new Error("Commands are not supported by this server")
variant, return capability.command({
parts: images.map((attachment) => ({ location,
id: Identifier.ascending("part"), sessionID: session.id,
type: "file" as const, id: Identifier.ascending("message"),
mime: attachment.mime, command: commandName,
url: attachment.dataUrl, arguments: args.join(" "),
filename: attachment.filename, agent,
})), model: { id: model.modelID, providerID: model.providerID, variant },
files: images.map((attachment) => ({
uri: attachment.dataUrl,
mime: attachment.mime,
name: attachment.filename,
})),
})
}) })
.catch((err) => { .catch((err) => {
showToast({ showToast({
@ -570,12 +601,13 @@ export function createPromptSubmit(input: PromptSubmitInput) {
} }
void sendFollowupDraft({ void sendFollowupDraft({
client, backend: sdk().backend,
sync: sync(), sync: sync(),
serverSync: serverSync(), serverSync: serverSync(),
draft, draft,
messageID, messageID,
optimisticBusy: sessionDirectory === projectDirectory, optimisticBusy: sessionDirectory === projectDirectory,
commitRevert: !!session.revert,
before: waitForWorktree, before: waitForWorktree,
}).catch((err) => { }).catch((err) => {
pending.delete(pendingKey(session.id)) pending.delete(pendingKey(session.id))

View file

@ -1,5 +1,5 @@
import { describe, expect, test } from "bun:test" import { describe, expect, test } from "bun:test"
import type { Message, Part } from "@opencode-ai/sdk/v2/client" import type { AppMessage as Message, AppPart as Part } from "@/context/backend"
import { estimateSessionContextBreakdown } from "./session-context-breakdown" import { estimateSessionContextBreakdown } from "./session-context-breakdown"
const user = (id: string) => { const user = (id: string) => {

View file

@ -1,4 +1,4 @@
import type { Message, Part } from "@opencode-ai/sdk/v2/client" import type { AppMessage, AppPart } from "@/context/backend"
export type SessionContextBreakdownKey = "system" | "user" | "assistant" | "tool" | "other" export type SessionContextBreakdownKey = "system" | "user" | "assistant" | "tool" | "other"
@ -13,14 +13,14 @@ const estimateTokens = (chars: number) => Math.ceil(chars / 4)
const toPercent = (tokens: number, input: number) => (tokens / input) * 100 const toPercent = (tokens: number, input: number) => (tokens / input) * 100
const toPercentLabel = (tokens: number, input: number) => Math.round(toPercent(tokens, input) * 10) / 10 const toPercentLabel = (tokens: number, input: number) => Math.round(toPercent(tokens, input) * 10) / 10
const charsFromUserPart = (part: Part) => { const charsFromUserPart = (part: AppPart) => {
if (part.type === "text") return part.text.length if (part.type === "text") return part.text.length
if (part.type === "file") return part.source?.text.value.length ?? 0 if (part.type === "file") return part.source?.text.value.length ?? 0
if (part.type === "agent") return part.source?.value.length ?? 0 if (part.type === "agent") return part.source?.value.length ?? 0
return 0 return 0
} }
const charsFromAssistantPart = (part: Part) => { const charsFromAssistantPart = (part: AppPart) => {
if (part.type === "text") return { assistant: part.text.length, tool: 0 } if (part.type === "text") return { assistant: part.text.length, tool: 0 }
if (part.type === "reasoning") return { assistant: part.text.length, tool: 0 } if (part.type === "reasoning") return { assistant: part.text.length, tool: 0 }
if (part.type !== "tool") return { assistant: 0, tool: 0 } if (part.type !== "tool") return { assistant: 0, tool: 0 }
@ -68,8 +68,8 @@ const build = (
} }
export function estimateSessionContextBreakdown(args: { export function estimateSessionContextBreakdown(args: {
messages: Message[] messages: AppMessage[]
parts: Record<string, Part[] | undefined> parts: Record<string, AppPart[] | undefined>
input: number input: number
systemPrompt?: string systemPrompt?: string
}) { }) {

View file

@ -1,5 +1,5 @@
import { describe, expect, test } from "bun:test" import { describe, expect, test } from "bun:test"
import type { Message } from "@opencode-ai/sdk/v2/client" import type { AppMessage as Message } from "@/context/backend"
import { getSessionContext } from "./session-context-metrics" import { getSessionContext } from "./session-context-metrics"
const assistant = ( const assistant = (

View file

@ -1,4 +1,4 @@
import type { AssistantMessage, Message } from "@opencode-ai/sdk/v2/client" import type { AppAssistantMessage, AppMessage } from "@/context/backend"
type Provider = { type Provider = {
id: string id: string
@ -14,7 +14,7 @@ type Model = {
} }
type Context = { type Context = {
message: AssistantMessage message: AppAssistantMessage
provider?: Provider provider?: Provider
model?: Model model?: Model
providerLabel: string providerLabel: string
@ -25,11 +25,11 @@ type Context = {
usage: number | null usage: number | null
} }
const tokenTotal = (msg: AssistantMessage) => { const tokenTotal = (msg: AppAssistantMessage) => {
return msg.tokens.input + msg.tokens.output + msg.tokens.reasoning + msg.tokens.cache.read + msg.tokens.cache.write return msg.tokens.input + msg.tokens.output + msg.tokens.reasoning + msg.tokens.cache.read + msg.tokens.cache.write
} }
const lastAssistantWithTokens = (messages: Message[]) => { const lastAssistantWithTokens = (messages: AppMessage[]) => {
for (let i = messages.length - 1; i >= 0; i--) { for (let i = messages.length - 1; i >= 0; i--) {
const msg = messages[i] const msg = messages[i]
if (msg.role !== "assistant") continue if (msg.role !== "assistant") continue
@ -38,7 +38,7 @@ const lastAssistantWithTokens = (messages: Message[]) => {
} }
} }
const build = (messages: Message[] = [], providers: Provider[] = []): Context | undefined => { const build = (messages: AppMessage[] = [], providers: Provider[] = []): Context | undefined => {
const message = lastAssistantWithTokens(messages) const message = lastAssistantWithTokens(messages)
if (!message) return undefined if (!message) return undefined
@ -60,6 +60,6 @@ const build = (messages: Message[] = [], providers: Provider[] = []): Context |
} }
} }
export function getSessionContext(messages: Message[] = [], providers: Provider[] = []) { export function getSessionContext(messages: AppMessage[] = [], providers: Provider[] = []) {
return build(messages, providers) return build(messages, providers)
} }

View file

@ -10,7 +10,7 @@ import { StickyAccordionHeader } from "@opencode-ai/ui/sticky-accordion-header"
import { File } from "@opencode-ai/session-ui/file" import { File } from "@opencode-ai/session-ui/file"
import { Markdown } from "@opencode-ai/session-ui/markdown" import { Markdown } from "@opencode-ai/session-ui/markdown"
import { ScrollView } from "@opencode-ai/ui/scroll-view" import { ScrollView } from "@opencode-ai/ui/scroll-view"
import type { Message, Part, UserMessage } from "@opencode-ai/sdk/v2/client" import type { AppMessage as Message, AppPart as Part, AppUserMessage as UserMessage } from "@/context/backend"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
import { useProviders } from "@/hooks/use-providers" import { useProviders } from "@/hooks/use-providers"
import { useSDK } from "@/context/sdk" import { useSDK } from "@/context/sdk"

View file

@ -126,11 +126,11 @@ export const SettingsGeneral: Component = () => {
const serverSdk = useServerSDK() const serverSdk = useServerSDK()
const [shells] = createResource( const [shells] = createResource(
() => async () => {
serverSdk() const capability = (await serverSdk().backend).capabilities.shellDiscovery
.client.pty.shells() if (!capability) return []
.then((res) => res.data ?? []) return capability.list().catch(() => [] as ShellOption[])
.catch(() => [] as ShellOption[]), },
{ initialValue: [] as ShellOption[] }, { initialValue: [] as ShellOption[] },
) )

View file

@ -12,6 +12,7 @@ import { DialogConnectProvider, useProviderConnectController } from "./dialog-co
import { DialogCustomProvider } from "./dialog-custom-provider" import { DialogCustomProvider } from "./dialog-custom-provider"
import { SettingsList } from "./settings-list" import { SettingsList } from "./settings-list"
import { SettingsServerPicker, SettingsServerScope } from "./settings-server-picker" import { SettingsServerPicker, SettingsServerScope } from "./settings-server-picker"
import { credentialConnectionIDs } from "@/context/backend"
type ProviderSource = "env" | "api" | "config" | "custom" type ProviderSource = "env" | "api" | "config" | "custom"
type ProviderItem = ReturnType<ReturnType<typeof useProviders>["connected"]>[number] type ProviderItem = ReturnType<ReturnType<typeof useProviders>["connected"]>[number]
@ -96,12 +97,12 @@ const SettingsProvidersContent: Component<{ onBack?: () => void }> = (props) =>
} }
const disableProvider = async (providerID: string, name: string) => { const disableProvider = async (providerID: string, name: string) => {
const before = serverSync().data.config.disabled_providers ?? [] const before = serverSync().data.config.disabledProviders ?? []
const next = before.includes(providerID) ? before : [...before, providerID] const next = before.includes(providerID) ? before : [...before, providerID]
serverSync().set("config", "disabled_providers", next) serverSync().set("config", "disabledProviders", next)
await serverSync() await serverSync()
.updateConfig({ disabled_providers: next }) .updateConfig({ disabledProviders: next })
.then(() => { .then(() => {
showToast({ showToast({
variant: "success", variant: "success",
@ -111,29 +112,47 @@ const SettingsProvidersContent: Component<{ onBack?: () => void }> = (props) =>
}) })
}) })
.catch((err: unknown) => { .catch((err: unknown) => {
serverSync().set("config", "disabled_providers", before) serverSync().set("config", "disabledProviders", before)
const message = err instanceof Error ? err.message : String(err) const message = err instanceof Error ? err.message : String(err)
showToast({ title: language.t("common.requestFailed"), description: message }) showToast({ title: language.t("common.requestFailed"), description: message })
}) })
} }
const disconnect = async (providerID: string, name: string) => { const disconnect = async (item: ProviderItem) => {
if (isConfigCustom(providerID)) { const backend = await serverSDK().backend
await serverSDK() const remove = async () => {
.client.auth.remove({ providerID }) if (backend.version === "v1") {
.catch(() => undefined) const capability = backend.capabilities.providerAuthV1
await disableProvider(providerID, name) if (!capability) throw new Error("Server does not support provider authentication")
await capability.remove({ providerID: item.id })
return
}
const capability = backend.capabilities.integrationsV2
if (!capability) throw new Error("Server does not support provider integrations")
const integrationID =
"integrationID" in item && typeof item.integrationID === "string" ? item.integrationID : item.id
const integration = await capability.get({ integrationID })
await Promise.all(
credentialConnectionIDs(integration?.connections ?? []).map((credentialID) =>
capability.removeCredential({ credentialID }),
),
)
}
if (isConfigCustom(item.id)) {
await remove().catch(() => undefined)
await disableProvider(item.id, item.name)
return return
} }
await serverSDK() await remove()
.client.auth.remove({ providerID })
.then(async () => { .then(async () => {
await serverSDK().client.global.dispose() if (backend.version === "v1") await backend.capabilities.runtimeV1?.disposeAll()
if (backend.version === "v2") await serverSync().refreshProviders()
showToast({ showToast({
variant: "success", variant: "success",
icon: "circle-check", icon: "circle-check",
title: language.t("provider.disconnect.toast.disconnected.title", { provider: name }), title: language.t("provider.disconnect.toast.disconnected.title", { provider: item.name }),
description: language.t("provider.disconnect.toast.disconnected.description", { provider: name }), description: language.t("provider.disconnect.toast.disconnected.description", { provider: item.name }),
}) })
}) })
.catch((err: unknown) => { .catch((err: unknown) => {
@ -179,7 +198,7 @@ const SettingsProvidersContent: Component<{ onBack?: () => void }> = (props) =>
</span> </span>
} }
> >
<Button size="large" variant="ghost" onClick={() => void disconnect(item.id, item.name)}> <Button size="large" variant="ghost" onClick={() => void disconnect(item)}>
{language.t("common.disconnect")} {language.t("common.disconnect")}
</Button> </Button>
</Show> </Show>

View file

@ -121,11 +121,11 @@ export const SettingsGeneralV2: Component<{
const themeOptions = createMemo<ThemeOption[]>(() => theme.ids().map((id) => ({ id, name: theme.name(id) }))) const themeOptions = createMemo<ThemeOption[]>(() => theme.ids().map((id) => ({ id, name: theme.name(id) })))
const [shells] = createResource( const [shells] = createResource(
() => async () => {
serverSdk() const capability = (await serverSdk().backend).capabilities.shellDiscovery
.client.pty.shells() if (!capability) return []
.then((res) => res.data ?? []) return capability.list().catch(() => [] as ShellOption[])
.catch(() => [] as ShellOption[]), },
{ initialValue: [] as ShellOption[] }, { initialValue: [] as ShellOption[] },
) )

View file

@ -12,6 +12,7 @@ import { DialogConnectProvider, useProviderConnectController } from "../dialog-c
import { DialogCustomProvider } from "../dialog-custom-provider" import { DialogCustomProvider } from "../dialog-custom-provider"
import { SettingsListV2 } from "./parts/list" import { SettingsListV2 } from "./parts/list"
import "./settings-v2.css" import "./settings-v2.css"
import { credentialConnectionIDs } from "@/context/backend"
type ProviderSource = "env" | "api" | "config" | "custom" type ProviderSource = "env" | "api" | "config" | "custom"
type ProviderItem = ReturnType<ReturnType<typeof useProviders>["connected"]>[number] type ProviderItem = ReturnType<ReturnType<typeof useProviders>["connected"]>[number]
@ -90,12 +91,12 @@ export const SettingsProvidersV2: Component<{ onBack?: () => void }> = (props) =
} }
const disableProvider = async (providerID: string, name: string) => { const disableProvider = async (providerID: string, name: string) => {
const before = serverSync().data.config.disabled_providers ?? [] const before = serverSync().data.config.disabledProviders ?? []
const next = before.includes(providerID) ? before : [...before, providerID] const next = before.includes(providerID) ? before : [...before, providerID]
serverSync().set("config", "disabled_providers", next) serverSync().set("config", "disabledProviders", next)
await serverSync() await serverSync()
.updateConfig({ disabled_providers: next }) .updateConfig({ disabledProviders: next })
.then(() => { .then(() => {
showToast({ showToast({
variant: "success", variant: "success",
@ -105,29 +106,47 @@ export const SettingsProvidersV2: Component<{ onBack?: () => void }> = (props) =
}) })
}) })
.catch((err: unknown) => { .catch((err: unknown) => {
serverSync().set("config", "disabled_providers", before) serverSync().set("config", "disabledProviders", before)
const message = err instanceof Error ? err.message : String(err) const message = err instanceof Error ? err.message : String(err)
showToast({ title: language.t("common.requestFailed"), description: message }) showToast({ title: language.t("common.requestFailed"), description: message })
}) })
} }
const disconnect = async (providerID: string, name: string) => { const disconnect = async (item: ProviderItem) => {
if (isConfigCustom(providerID)) { const backend = await serverSdk().backend
await serverSdk() const remove = async () => {
.client.auth.remove({ providerID }) if (backend.version === "v1") {
.catch(() => undefined) const capability = backend.capabilities.providerAuthV1
await disableProvider(providerID, name) if (!capability) throw new Error("Server does not support provider authentication")
await capability.remove({ providerID: item.id })
return
}
const capability = backend.capabilities.integrationsV2
if (!capability) throw new Error("Server does not support provider integrations")
const integrationID =
"integrationID" in item && typeof item.integrationID === "string" ? item.integrationID : item.id
const integration = await capability.get({ integrationID })
await Promise.all(
credentialConnectionIDs(integration?.connections ?? []).map((credentialID) =>
capability.removeCredential({ credentialID }),
),
)
}
if (isConfigCustom(item.id)) {
await remove().catch(() => undefined)
await disableProvider(item.id, item.name)
return return
} }
await serverSdk() await remove()
.client.auth.remove({ providerID })
.then(async () => { .then(async () => {
await serverSdk().client.global.dispose() if (backend.version === "v1") await backend.capabilities.runtimeV1?.disposeAll()
if (backend.version === "v2") await serverSync().refreshProviders()
showToast({ showToast({
variant: "success", variant: "success",
icon: "circle-check", icon: "circle-check",
title: language.t("provider.disconnect.toast.disconnected.title", { provider: name }), title: language.t("provider.disconnect.toast.disconnected.title", { provider: item.name }),
description: language.t("provider.disconnect.toast.disconnected.description", { provider: name }), description: language.t("provider.disconnect.toast.disconnected.description", { provider: item.name }),
}) })
}) })
.catch((err: unknown) => { .catch((err: unknown) => {
@ -175,7 +194,7 @@ export const SettingsProvidersV2: Component<{ onBack?: () => void }> = (props) =
</span> </span>
} }
> >
<ButtonV2 size="normal" variant="ghost-muted" onClick={() => void disconnect(item.id, item.name)}> <ButtonV2 size="normal" variant="ghost-muted" onClick={() => void disconnect(item)}>
{language.t("common.disconnect")} {language.t("common.disconnect")}
</ButtonV2> </ButtonV2>
</Show> </Show>

View file

@ -110,8 +110,8 @@ export const SettingsServersV2: Component = () => {
<div class="settings-v2-servers-copy"> <div class="settings-v2-servers-copy">
<span class="settings-v2-servers-name">{serverName(item)}</span> <span class="settings-v2-servers-name">{serverName(item)}</span>
<span class="settings-v2-servers-meta"> <span class="settings-v2-servers-meta">
<Show when={health()?.version}>v{health()?.version}</Show> <Show when={health()?.installationVersion}>v{health()?.installationVersion}</Show>
<Show when={health()?.version && item.type === "http"}> </Show> <Show when={health()?.installationVersion && item.type === "http"}> </Show>
<Show <Show
when={item.type === "http" && item.http.username} when={item.type === "http" && item.http.username}
fallback={<Show when={item.type === "http"}>{language.t("server.row.noUsername")}</Show>} fallback={<Show when={item.type === "http"}>{language.t("server.row.noUsername")}</Show>}

View file

@ -11,12 +11,10 @@ import { matchKeybind, parseKeybind } from "@/context/command"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
import { usePlatform } from "@/context/platform" import { usePlatform } from "@/context/platform"
import { useSDK } from "@/context/sdk" import { useSDK } from "@/context/sdk"
import { useServerSDK } from "@/context/server-sdk"
import { terminalFontFamily, useSettings } from "@/context/settings" import { terminalFontFamily, useSettings } from "@/context/settings"
import type { LocalPTY } from "@/context/terminal" import type { LocalPTY } from "@/context/terminal"
import { disposeIfDisposable, getHoveredLinkText, setOptionIfSupported } from "@/utils/runtime-adapters" import { disposeIfDisposable, getHoveredLinkText, setOptionIfSupported } from "@/utils/runtime-adapters"
import { terminalWriter } from "@/utils/terminal-writer" import { terminalWriter } from "@/utils/terminal-writer"
import { terminalWebSocketURL } from "@/utils/terminal-websocket-url"
const TOGGLE_TERMINAL_ID = "terminal.toggle" const TOGGLE_TERMINAL_ID = "terminal.toggle"
const DEFAULT_TOGGLE_TERMINAL_KEYBIND = "ctrl+`" const DEFAULT_TOGGLE_TERMINAL_KEYBIND = "ctrl+`"
@ -174,16 +172,8 @@ export const Terminal = (props: TerminalProps) => {
const settings = useSettings() const settings = useSettings()
const theme = useTheme() const theme = useTheme()
const language = useLanguage() const language = useLanguage()
// Terminal captures its connection for the PTY lifetime, so callers must key it per server/session.
const connection = useServerSDK()().server
const directory = sdk().directory const directory = sdk().directory
const client = sdk().client const backend = sdk().backend
const url = sdk().url
const auth = connection.http
const username = auth?.username ?? "opencode"
const password = auth?.password ?? ""
const authToken = connection.type === "http" ? connection.authToken : false
const sameOrigin = new URL(url, location.href).origin === location.origin
let container!: HTMLDivElement let container!: HTMLDivElement
const [local, others] = splitProps(props, ["pty", "class", "classList", "autoFocus", "onConnect", "onConnectError"]) const [local, others] = splitProps(props, ["pty", "class", "classList", "autoFocus", "onConnect", "onConnectError"])
const id = local.pty.id const id = local.pty.id
@ -233,11 +223,14 @@ export const Terminal = (props: TerminalProps) => {
} }
const pushSize = (cols: number, rows: number) => { const pushSize = (cols: number, rows: number) => {
return client.pty return backend
.update({ .then((client) =>
ptyID: id, client.common.pty.update({
size: { cols, rows }, ptyID: id,
}) size: { cols, rows },
location: { directory },
}),
)
.catch((err) => { .catch((err) => {
debugTerminal("failed to sync terminal size", err) debugTerminal("failed to sync terminal size", err)
}) })
@ -490,33 +483,32 @@ export const Terminal = (props: TerminalProps) => {
} }
const gone = () => const gone = () =>
client.pty backend
.get({ ptyID: id }, { throwOnError: false }) .then((client) => {
.then((result) => result.response.status === 404) const transport = client.capabilities.ptyTransport
if (transport) return transport.exists({ ptyID: id, location: { directory } }).then((exists) => !exists)
return client.common.pty.get({ ptyID: id, location: { directory } }).then(() => false)
})
.catch((err) => { .catch((err) => {
debugTerminal("failed to inspect terminal session", err) debugTerminal("failed to inspect terminal session", err)
return false return false
}) })
const connectToken = async () => { const connectToken = async () => {
const result = await client.pty const transport = (await backend).capabilities.ptyTransport
.connectToken( if (!transport) return
{ ptyID: id, directory }, const result = await transport
{ .connectToken({ ptyID: id, location: { directory } })
throwOnError: false,
headers: { "x-opencode-ticket": "1" },
},
)
.catch((err: unknown) => { .catch((err: unknown) => {
if (err instanceof Error && err.message.includes("Request is not supported")) return if (err instanceof Error && err.message.includes("Request is not supported")) return
throw err throw err
}) })
if (!result) return if (!result) return
if (result.response.status === 200 && result.data?.ticket) return result.data.ticket if (result.status === 200 && result.ticket) return result.ticket
if (result.response.status === 404 || result.response.status === 405) return if (result.status === 404 || result.status === 405) return
if (result.response.status === 403) if (result.status === 403)
throw new Error("PTY connect ticket rejected by origin or CSRF checks. Check the server CORS config.") throw new Error("PTY connect ticket rejected by origin or CSRF checks. Check the server CORS config.")
throw new Error(`PTY connect ticket failed with ${result.response.status}`) throw new Error(`PTY connect ticket failed with ${result.status}`)
} }
const retry = (err: unknown) => { const retry = (err: unknown) => {
@ -549,18 +541,13 @@ export const Terminal = (props: TerminalProps) => {
if (once.value) return if (once.value) return
if (disposed) return if (disposed) return
const transport = (await backend).capabilities.ptyTransport
if (!transport) {
fail(new Error("PTY transport is not supported by this server"))
return
}
const socket = new WebSocket( const socket = new WebSocket(
terminalWebSocketURL({ transport.connectURL({ ptyID: id, location: { directory }, cursor: seek, ticket }),
url,
id,
directory,
cursor: seek,
ticket,
sameOrigin,
username,
password,
authToken,
}),
) )
socket.binaryType = "arraybuffer" socket.binaryType = "arraybuffer"
ws = socket ws = socket

View file

@ -9,7 +9,7 @@ import { ServerConnection, serverName } from "@/context/server"
import { displayName, projectForSession } from "@/pages/layout/helpers" import { displayName, projectForSession } from "@/pages/layout/helpers"
import { SessionTabAvatar } from "@/pages/layout/session-tab-avatar" import { SessionTabAvatar } from "@/pages/layout/session-tab-avatar"
import { showToast } from "@/utils/toast" import { showToast } from "@/utils/toast"
import type { Session } from "@opencode-ai/sdk/v2" import type { AppSession } from "@/context/backend"
import { canOpenTabRename, forwardTabRef } from "./titlebar-tab-gesture" import { canOpenTabRename, forwardTabRef } from "./titlebar-tab-gesture"
import { TabPreviewPopover } from "./titlebar-tab-popover" import { TabPreviewPopover } from "./titlebar-tab-popover"
import "./titlebar-tab-nav.css" import "./titlebar-tab-nav.css"
@ -21,7 +21,7 @@ export function TabNavItem(props: {
ref?: Ref<HTMLDivElement> ref?: Ref<HTMLDivElement>
href: string href: string
server: ServerConnection.Key server: ServerConnection.Key
session: () => Session | undefined session: () => AppSession | undefined
fallbackTitle?: string fallbackTitle?: string
onTitleChange?: (title: string) => void onTitleChange?: (title: string) => void
onTitleChangeFailed?: (title: string) => void onTitleChangeFailed?: (title: string) => void
@ -120,8 +120,10 @@ export function TabNavItem(props: {
const ctx = serverCtx() const ctx = serverCtx()
const session = props.session() const session = props.session()
if (!ctx || !session) return if (!ctx || !session) return
const client = ctx.sdk.createClient({ directory: session.directory, throwOnError: true }) const client = await ctx.sdk.backend
await client.session.update({ sessionID: session.id, title }) const capability = client.capabilities.sessionActionsV1
if (!capability) throw new Error("Session renaming is not supported by this server")
await capability.rename({ location: { directory: session.directory }, sessionID: session.id, title })
} }
const closeRename = async (save: boolean) => { const closeRename = async (save: boolean) => {

View file

@ -267,9 +267,8 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
return conn ? { route, sdk: global.ensureServerCtx(conn).sdk } : undefined return conn ? { route, sdk: global.ensureServerCtx(conn).sdk } : undefined
}, },
({ route, sdk }) => ({ route, sdk }) =>
sdk.client.session sdk.backend
.get({ sessionID: route.sessionId }) .then((client) => client.common.sessions.get({ sessionID: route.sessionId }))
.then((x) => x.data)
.catch(() => {}), .catch(() => {}),
) )

View file

@ -0,0 +1,70 @@
import { describe, expect, test } from "bun:test"
import type { ServerHealth } from "@/utils/server-health"
import { backendIdentity, createBackendForServer } from "./backend-client"
const server = {
type: "http" as const,
http: {
url: "http://localhost:4096",
username: "user",
password: "secret",
},
authToken: false,
}
function setup(health: ServerHealth) {
const requests: Request[] = []
const fetch = (async (input: RequestInfo | URL, init?: RequestInit) => {
const request = input instanceof Request ? input : new Request(input, init)
requests.push(request)
if (new URL(request.url).pathname === "/project")
return new Response("[]", { headers: { "content-type": "application/json" } })
return new Response(JSON.stringify({ healthy: true, version: "1.2.3" }), {
headers: { "content-type": "application/json" },
})
}) as typeof globalThis.fetch
return {
requests,
fetch,
backend: createBackendForServer({ server, browserUrl: "https://app.example.test", fetch, health: Promise.resolve(health) }),
}
}
describe("createBackendForServer", () => {
test("changes backend identity when credentials for the same URL change", () => {
expect(backendIdentity(server)).not.toBe(
backendIdentity({ ...server, http: { ...server.http, password: "replacement" } }),
)
})
test("selects v2 and configures fetch and authentication", async () => {
const result = setup({ healthy: true, version: "v2" })
const backend = await result.backend
expect(backend.version).toBe("v2")
expect(result.requests).toHaveLength(0)
await backend.common.health.get()
expect(new URL(result.requests[0].url).pathname).toBe("/api/health")
expect(result.requests[0].headers.get("authorization")).toBe(`Basic ${btoa("user:secret")}`)
expect(backend.capabilities.projectList).toBeUndefined()
expect(backend.capabilities.vcs).toBeUndefined()
expect(backend.capabilities.mcp).toBeUndefined()
expect(result.requests).toHaveLength(1)
expect(backend.version).toBe("v2")
})
test("selects v1 after fallback detection", async () => {
const result = setup({ healthy: true, version: "v1", installationVersion: "1.2.3" })
const backend = await result.backend
expect(backend.version).toBe("v1")
expect(result.requests).toHaveLength(0)
await backend.common.health.get()
expect(new URL(result.requests[0].url).pathname).toBe("/global/health")
expect(result.requests[0].headers.get("authorization")).toBe(`Basic ${btoa("user:secret")}`)
expect(backend.capabilities.projectList).toBeDefined()
expect(backend.capabilities.vcs).toBeDefined()
expect(backend.capabilities.mcp).toBeDefined()
})
})

View file

@ -0,0 +1,64 @@
import { OpenCode } from "@opencode-ai/client"
import { createOpencodeClient } from "@opencode-ai/sdk-v1/v2/client"
import type { ServerHealth } from "@/utils/server-health"
import { authTokenFromCredentials } from "@/utils/server"
import type { ServerConnection } from "./server"
import type { LocationRef } from "./backend"
import { createV1Backend } from "./backend-v1"
import { createV2Backend } from "./backend-v2"
function options(server: ServerConnection.HttpBase, fetch: typeof globalThis.fetch) {
return {
baseUrl: server.url,
fetch,
headers: server.password
? {
Authorization: `Basic ${authTokenFromCredentials({ username: server.username, password: server.password })}`,
}
: undefined,
}
}
export function createV1RawClient(server: ServerConnection.HttpBase, fetch: typeof globalThis.fetch) {
return createOpencodeClient(options(server, fetch))
}
export function createV2RawClient(server: ServerConnection.HttpBase, fetch: typeof globalThis.fetch) {
return OpenCode.make(options(server, fetch))
}
export function backendIdentity(server: ServerConnection.Any) {
return `${server.type}\n${server.http.url}\n${server.http.username ?? ""}\n${server.http.password ?? ""}\n${
server.type === "http" && server.authToken === true ? "token" : ""
}`
}
export async function createBackendForServer(input: {
server: ServerConnection.Any
browserUrl: string
fetch: typeof globalThis.fetch
eventFetch?: typeof globalThis.fetch
health: Promise<ServerHealth>
defaultLocation?: LocationRef
}) {
const health = await input.health
const eventFetch = input.eventFetch ?? input.fetch
const transport = {
baseUrl: input.server.http.url,
fetch: input.fetch,
username: input.server.http.username,
password: input.server.http.password,
sameOrigin: new URL(input.server.http.url, input.browserUrl).origin === new URL(input.browserUrl).origin,
authToken: input.server.type === "http" && input.server.authToken === true,
}
if (health.version === "v2")
return createV2Backend(
createV2RawClient(input.server.http, input.fetch),
transport,
input.defaultLocation,
createV2RawClient(input.server.http, eventFetch),
)
const legacy = createV1RawClient(input.server.http, input.fetch)
const eventLegacy = eventFetch === input.fetch ? legacy : createV1RawClient(input.server.http, eventFetch)
return createV1Backend(legacy, input.defaultLocation, eventLegacy, transport)
}

View file

@ -1,8 +1,13 @@
import { describe, expect, test } from "bun:test" import { describe, expect, test } from "bun:test"
import { createOpencodeClient } from "@opencode-ai/sdk-v1/v2/client" import { createOpencodeClient } from "@opencode-ai/sdk-v1/v2/client"
import type { PtyTransportConfig } from "./backend"
import { createV1Backend } from "./backend-v1" import { createV1Backend } from "./backend-v1"
function setup(respond: (request: Request) => Response | Promise<Response>) { function setup(
respond: (request: Request) => Response | Promise<Response>,
withDefault = false,
transport?: Partial<Pick<PtyTransportConfig, "sameOrigin" | "authToken">>,
) {
const requests: Request[] = [] const requests: Request[] = []
const fetch = Object.assign( const fetch = Object.assign(
async (input: RequestInfo | URL, init?: RequestInit) => { async (input: RequestInfo | URL, init?: RequestInit) => {
@ -12,9 +17,22 @@ function setup(respond: (request: Request) => Response | Promise<Response>) {
}, },
{ preconnect: globalThis.fetch.preconnect }, { preconnect: globalThis.fetch.preconnect },
) satisfies typeof globalThis.fetch ) satisfies typeof globalThis.fetch
const client = createOpencodeClient({ baseUrl: "http://localhost", fetch })
return { return {
requests, requests,
backend: createV1Backend(createOpencodeClient({ baseUrl: "http://localhost", fetch })), backend: createV1Backend(
client,
withDefault ? { directory: "/default", workspaceID: "default-workspace" } : undefined,
client,
{
baseUrl: "http://localhost",
fetch,
username: "user",
password: "secret",
sameOrigin: transport?.sameOrigin ?? false,
authToken: transport?.authToken ?? false,
},
),
} }
} }
@ -35,6 +53,21 @@ const session = {
} }
describe("createV1Backend", () => { describe("createV1Backend", () => {
test("preserves location in OAuth callbacks", async () => {
const setupResult = setup(() => json({}))
await setupResult.backend.capabilities.providerAuthV1?.callback({
providerID: "provider",
method: 1,
code: "code",
location: { directory: "/repo", workspaceID: "workspace" },
})
const url = new URL(setupResult.requests[0].url)
expect(url.searchParams.get("directory")).toBe("/repo")
expect(url.searchParams.get("workspace")).toBe("workspace")
})
test("normalizes session pagination and location", async () => { test("normalizes session pagination and location", async () => {
const setupResult = setup(() => json([session], { "x-next-cursor": "456" })) const setupResult = setup(() => json([session], { "x-next-cursor": "456" }))
@ -49,9 +82,13 @@ describe("createV1Backend", () => {
items: [ items: [
{ {
id: "ses_1", id: "ses_1",
slug: "one",
version: "1",
parentID: undefined, parentID: undefined,
projectID: "project", projectID: "project",
location: { directory: "/repo", workspaceID: undefined }, location: { directory: "/repo", workspaceID: undefined },
directory: "/repo",
workspaceID: undefined,
title: "Session", title: "Session",
cost: 0, cost: 0,
tokens: undefined, tokens: undefined,
@ -60,7 +97,7 @@ describe("createV1Backend", () => {
revert: undefined, revert: undefined,
}, },
], ],
next: "456", older: "456",
}) })
const url = new URL(setupResult.requests[0].url) const url = new URL(setupResult.requests[0].url)
expect(url.pathname).toBe("/experimental/session") expect(url.pathname).toBe("/experimental/session")
@ -75,6 +112,7 @@ describe("createV1Backend", () => {
await setupResult.backend.common.sessions.prompt({ await setupResult.backend.common.sessions.prompt({
sessionID: "ses_1", sessionID: "ses_1",
location: { directory: "/explicit", workspaceID: "explicit-workspace" },
id: "msg_1", id: "msg_1",
text: "hello", text: "hello",
selection: { selection: {
@ -87,6 +125,8 @@ describe("createV1Backend", () => {
const request = setupResult.requests[0] const request = setupResult.requests[0]
expect(new URL(request.url).pathname).toBe("/session/ses_1/prompt_async") expect(new URL(request.url).pathname).toBe("/session/ses_1/prompt_async")
expect(new URL(request.url).searchParams.get("directory")).toBe("/explicit")
expect(new URL(request.url).searchParams.get("workspace")).toBe("explicit-workspace")
expect(await request.json()).toEqual({ expect(await request.json()).toEqual({
messageID: "msg_1", messageID: "msg_1",
model: { providerID: "provider", modelID: "model" }, model: { providerID: "provider", modelID: "model" },
@ -100,6 +140,29 @@ describe("createV1Backend", () => {
}) })
}) })
test("preserves ordered prompt part IDs and metadata", async () => {
const setupResult = setup(() => new Response(null, { status: 204 }))
await setupResult.backend.common.sessions.prompt({
sessionID: "ses_1",
id: "msg_1",
text: "visible",
parts: [
{ id: "part_text", type: "text", text: "visible" },
{ id: "part_note", type: "text", text: "note", synthetic: true, metadata: { source: "review" } },
{ id: "part_file", type: "file", mime: "text/plain", url: "file:///repo/a.ts", filename: "a.ts" },
{ id: "part_agent", type: "agent", name: "build", source: { value: "@build", start: 7, end: 13 } },
],
})
expect((await setupResult.requests[0].json()).parts).toEqual([
{ id: "part_text", type: "text", text: "visible" },
{ id: "part_note", type: "text", text: "note", synthetic: true, metadata: { source: "review" } },
{ id: "part_file", type: "file", mime: "text/plain", url: "file:///repo/a.ts", filename: "a.ts" },
{ id: "part_agent", type: "agent", name: "build", source: { value: "@build", start: 7, end: 13 } },
])
})
test("combines mixed file search and decodes binary content", async () => { test("combines mixed file search and decodes binary content", async () => {
const setupResult = setup((request) => { const setupResult = setup((request) => {
const url = new URL(request.url) const url = new URL(request.url)
@ -122,6 +185,139 @@ describe("createV1Backend", () => {
expect(content.mimeType).toBe("application/octet-stream") expect(content.mimeType).toBe("application/octet-stream")
}) })
test("preserves project, provider, and file metadata", async () => {
const setupResult = setup((request) => {
const path = new URL(request.url).pathname
if (path === "/project")
return json([
{ id: "project", worktree: "/repo", vcs: "git", time: { created: 1, initialized: 2 }, sandboxes: [] },
])
if (path === "/provider")
return json({
all: [{ id: "provider", name: "Provider", source: "config", env: [], options: {}, models: {} }],
connected: [],
default: {},
})
return json([{ name: "a.txt", path: "a.txt", absolute: "/repo/a.txt", type: "file", ignored: false }])
})
const projectList = setupResult.backend.capabilities.projectList
if (!projectList) throw new Error("Missing project list capability")
const projects = await projectList.list()
const providers = await setupResult.backend.common.catalog.providers()
const files = await setupResult.backend.common.files.list({})
expect(projects[0]).toMatchObject({ vcs: "git", time: { created: 1, initialized: 2 } })
expect(providers.providers.get("provider")?.source).toBe("config")
expect(files).toEqual([{ name: "a.txt", path: "a.txt", absolute: "/repo/a.txt", type: "file", ignored: false }])
})
test("uses explicit session locations and preserves mutation confirmations", async () => {
const setupResult = setup((request) => {
const path = new URL(request.url).pathname
if (request.method === "DELETE" || path.startsWith("/experimental/worktree")) return json(true)
if (request.method === "GET" && path === "/session/ses_1/message") return json([], { "x-next-cursor": "older" })
return json(session)
}, true)
const history = await setupResult.backend.common.sessions.history({
sessionID: "ses_1",
location: { directory: "/explicit", workspaceID: "explicit-workspace" },
})
const removed = await setupResult.backend.capabilities.sessionActionsV1?.remove({ sessionID: "ses_1" })
const reverted = await setupResult.backend.capabilities.sessionExtrasV1?.revert({
sessionID: "ses_1",
messageID: "msg_1",
})
const cleared = await setupResult.backend.capabilities.sessionExtrasV1?.clearRevert({ sessionID: "ses_1" })
const worktreeRemoved = await setupResult.backend.capabilities.worktreesV1?.remove({ directory: "/copy" })
const worktreeReset = await setupResult.backend.capabilities.worktreesV1?.reset({ directory: "/copy" })
expect(history.older).toBe("older")
expect(removed).toBe(true)
expect(reverted?.id).toBe("ses_1")
expect(cleared?.id).toBe("ses_1")
expect(worktreeRemoved).toBe(true)
expect(worktreeReset).toBe(true)
const urls = setupResult.requests.map((request) => new URL(request.url))
expect(urls[0].searchParams.get("directory")).toBe("/explicit")
expect(urls[1].searchParams.get("directory")).toBe("/default")
})
test("normalizes idle and compatibility events", async () => {
const events = [
{ type: "session.status", properties: { sessionID: "ses_1", status: { type: "idle" } } },
{
type: "todo.updated",
properties: { sessionID: "ses_1", todos: [{ content: "Ship", status: "pending", priority: "high" }] },
},
{
type: "message.part.delta",
properties: { sessionID: "ses_1", messageID: "msg_1", partID: "part_1", field: "text", delta: "hi" },
},
{ type: "message.part.removed", properties: { sessionID: "ses_1", messageID: "msg_1", partID: "part_1" } },
{ type: "worktree.ready", properties: { name: "copy", branch: "copy" } },
{ type: "lsp.updated", properties: {} },
{ type: "reference.updated", properties: {} },
{ type: "mcp.tools.changed", properties: { server: "docs" } },
{ type: "server.instance.disposed", properties: { directory: "/repo" } },
]
const setupResult = setup(
() =>
new Response(events.map((payload) => `data: ${JSON.stringify({ directory: "/repo", payload })}\n\n`).join(""), {
headers: { "content-type": "text/event-stream" },
}),
)
const iterator = setupResult.backend.common.events.subscribe()[Symbol.asyncIterator]()
const result = await Promise.all(events.map(() => iterator.next()))
expect(result.map((item) => item.value?.event.type)).toEqual([
"session.activity",
"todo.updated",
"timeline.delta",
"timeline.part.removed",
"worktree.ready",
"lsp.updated",
"reference.updated",
"mcp.updated",
"instance.disposed",
])
expect(result[0].value?.event).toEqual({ type: "session.activity", sessionID: "ses_1", activity: { type: "idle" } })
expect(result[1].value?.event).toMatchObject({ todos: [{ priority: "high" }] })
})
test("updates the V1 projection cache for deltas and removals", async () => {
const info = {
id: "msg_1",
sessionID: "ses_1",
role: "user",
time: { created: 1 },
agent: "build",
model: { providerID: "p", modelID: "m" },
}
const part = { id: "part_1", sessionID: "ses_1", messageID: "msg_1", type: "text", text: "a" }
const events = [
{ type: "message.updated", properties: { info } },
{ type: "message.part.updated", properties: { sessionID: "ses_1", part } },
{ type: "message.part.delta", properties: { sessionID: "ses_1", messageID: "msg_1", partID: "part_1", field: "text", delta: "b" } },
{ type: "message.updated", properties: { info } },
{ type: "message.part.removed", properties: { sessionID: "ses_1", messageID: "msg_1", partID: "part_1" } },
{ type: "message.updated", properties: { info } },
]
const setupResult = setup(() => new Response(events.map((payload) => `data: ${JSON.stringify({ directory: "/repo", payload })}\n\n`).join(""), { headers: { "content-type": "text/event-stream" } }))
const iterator = setupResult.backend.common.events.subscribe()[Symbol.asyncIterator]()
await iterator.next()
await iterator.next()
await iterator.next()
const afterDelta = await iterator.next()
await iterator.next()
const afterRemoval = await iterator.next()
expect(afterDelta.value?.event).toMatchObject({ item: { content: [{ id: "part_1", text: "ab" }] } })
expect(afterRemoval.value?.event).toMatchObject({ item: { content: [] } })
expect(setupResult.requests).toHaveLength(1)
})
test("merges global config updates with untouched fields", async () => { test("merges global config updates with untouched fields", async () => {
const bodies: unknown[] = [] const bodies: unknown[] = []
const setupResult = setup(async (request) => { const setupResult = setup(async (request) => {
@ -137,4 +333,75 @@ describe("createV1Backend", () => {
expect(bodies).toEqual([{ autoupdate: true, model: "new", disabled_providers: ["two"] }]) expect(bodies).toEqual([{ autoupdate: true, model: "new", disabled_providers: ["two"] }])
}) })
test("uses legacy PTY endpoints, location queries, status, tickets, and auth fallback", async () => {
const setupResult = setup((request) => {
const path = new URL(request.url).pathname
if (path.endsWith("/connect-token")) return new Response(null, { status: 405 })
if (request.method === "GET") return new Response(null, { status: 404 })
return new Response(null, { status: 204 })
})
await setupResult.backend.common.permissions.reply({
sessionID: "ses_1",
requestID: "per_1",
reply: "once",
location: { directory: "/explicit", workspaceID: "workspace" },
})
await setupResult.backend.common.questions.reject({
sessionID: "ses_1",
requestID: "que_1",
location: { directory: "/explicit", workspaceID: "workspace" },
})
const transport = setupResult.backend.capabilities.ptyTransport
const ticket = await transport?.connectToken({
ptyID: "pty_1",
location: { directory: "/explicit", workspaceID: "workspace" },
})
const exists = await transport?.exists({
ptyID: "pty_1",
location: { directory: "/explicit", workspaceID: "workspace" },
})
expect(ticket).toEqual({ status: 405, ticket: undefined })
expect(exists).toBe(false)
expect(setupResult.requests.map((request) => new URL(request.url).searchParams.get("directory"))).toEqual([
"/explicit",
"/explicit",
"/explicit",
"/explicit",
])
expect(setupResult.requests[2].headers.get("x-opencode-ticket")).toBe("1")
const fallback = transport?.connectURL({
ptyID: "pty/1",
location: { directory: "/explicit", workspaceID: "workspace" },
cursor: 12,
})
expect(fallback?.pathname).toBe("/pty/pty%2F1/connect")
expect(fallback?.protocol).toBe("ws:")
expect(fallback?.searchParams.get("directory")).toBe("/explicit")
expect(fallback?.searchParams.get("workspace")).toBe("workspace")
expect(fallback?.searchParams.get("cursor")).toBe("12")
expect(fallback?.searchParams.get("auth_token")).toBe(btoa("user:secret"))
const ticketURL = transport?.connectURL({
ptyID: "pty_1",
location: { directory: "/explicit" },
cursor: -1,
ticket: "ticket value",
})
expect(ticketURL?.searchParams.get("ticket")).toBe("ticket value")
expect(ticketURL?.searchParams.has("auth_token")).toBe(false)
})
test("preserves same-origin auth-token policy for PTY URLs", () => {
const saved = setup(() => new Response(), false, { sameOrigin: true }).backend.capabilities.ptyTransport
const token = setup(() => new Response(), false, { sameOrigin: true, authToken: true }).backend.capabilities
.ptyTransport
const input = { ptyID: "pty_1", location: { directory: "/repo" }, cursor: 0 }
expect(saved?.connectURL(input).searchParams.has("auth_token")).toBe(false)
expect(token?.connectURL(input).searchParams.get("auth_token")).toBe(btoa("user:secret"))
})
}) })

View file

@ -34,6 +34,7 @@ import type {
Page, Page,
PromptFile, PromptFile,
PromptInput, PromptInput,
PtyTransportConfig,
ProviderCatalog, ProviderCatalog,
RequestOptions, RequestOptions,
SessionActivity, SessionActivity,
@ -47,7 +48,12 @@ type CachedMessage = {
parts: Part[] parts: Part[]
} }
export function createV1Backend(client: OpencodeClient, defaultLocation?: LocationRef): AppClient { export function createV1Backend(
client: OpencodeClient,
defaultLocation?: LocationRef,
eventClient: OpencodeClient = client,
transportConfig?: PtyTransportConfig,
): AppClient {
const messages = new Map<string, CachedMessage>() const messages = new Map<string, CachedMessage>()
const options = (input?: RequestOptions) => ({ signal: input?.signal, throwOnError: true as const }) const options = (input?: RequestOptions) => ({ signal: input?.signal, throwOnError: true as const })
@ -57,13 +63,58 @@ export function createV1Backend(client: OpencodeClient, defaultLocation?: Locati
return toTimelineItem(input.info, input.parts) return toTimelineItem(input.info, input.parts)
} }
const loadMessage = async (input: { sessionID: string; messageID: string }, request?: RequestOptions) => { const loadMessage = async (
input: LocationInput & { sessionID: string; messageID: string },
request?: RequestOptions,
force?: boolean,
) => {
const cached = messages.get(input.messageID) const cached = messages.get(input.messageID)
if (cached) return cached if (cached && !force) return cached
const result = await client.session.message({ ...input, ...legacyLocation(defaultLocation) }, options(request)) const result = await client.session.message(
{ sessionID: input.sessionID, messageID: input.messageID, ...location(input) },
options(request),
)
messages.set(input.messageID, result.data) messages.set(input.messageID, result.data)
return result.data return result.data
} }
const sessionActions = {
remove: async (input: LocationInput & { sessionID: string }, request?: RequestOptions) => {
const result = await client.session.delete(
{ sessionID: input.sessionID, ...location(input) },
options(request),
)
return result.data
},
fork: async (input: LocationInput & { sessionID: string; messageID?: string }, request?: RequestOptions) => {
const result = await client.session.fork(
{ sessionID: input.sessionID, messageID: input.messageID, ...location(input) },
options(request),
)
return toSession(result.data)
},
rename: async (input: LocationInput & { sessionID: string; title: string }, request?: RequestOptions) => {
await client.session.update(
{ sessionID: input.sessionID, title: input.title, ...location(input) },
options(request),
)
},
command: async (input: CommandInput, request?: RequestOptions) => {
await client.session.command(
{
sessionID: input.sessionID,
messageID: input.id,
command: input.command,
arguments: input.arguments ?? "",
agent: input.agent,
model: input.model && `${input.model.providerID}/${input.model.id}`,
variant: input.model?.variant,
parts: input.files?.map(toFilePart),
...location(input),
},
options(request),
)
},
}
return { return {
version: "v1", version: "v1",
@ -75,10 +126,6 @@ export function createV1Backend(client: OpencodeClient, defaultLocation?: Locati
}, },
}, },
projects: { projects: {
list: async (request) => {
const result = await client.project.list(undefined, options(request))
return result.data.map(toProject)
},
current: async (input, request) => { current: async (input, request) => {
const params = location(input) const params = location(input)
const [project, path] = await Promise.all([ const [project, path] = await Promise.all([
@ -130,7 +177,7 @@ export function createV1Backend(client: OpencodeClient, defaultLocation?: Locati
) )
return { return {
items: result.data.map(toSession), items: result.data.map(toSession),
next: result.response.headers.get("x-next-cursor") ?? undefined, older: result.response.headers.get("x-next-cursor") ?? undefined,
} }
}, },
create: async (input, request) => { create: async (input, request) => {
@ -148,22 +195,6 @@ export function createV1Backend(client: OpencodeClient, defaultLocation?: Locati
const result = await client.session.get({ sessionID: input.sessionID, ...location(input) }, options(request)) const result = await client.session.get({ sessionID: input.sessionID, ...location(input) }, options(request))
return toSession(result.data) return toSession(result.data)
}, },
remove: async (input, request) => {
await client.session.delete({ sessionID: input.sessionID, ...location(input) }, options(request))
},
fork: async (input, request) => {
const result = await client.session.fork(
{ sessionID: input.sessionID, messageID: input.messageID, ...location(input) },
options(request),
)
return toSession(result.data)
},
rename: async (input, request) => {
await client.session.update(
{ sessionID: input.sessionID, title: input.title, ...location(input) },
options(request),
)
},
interrupt: async (input, request) => { interrupt: async (input, request) => {
await client.session.abort({ sessionID: input.sessionID, ...location(input) }, options(request)) await client.session.abort({ sessionID: input.sessionID, ...location(input) }, options(request))
}, },
@ -182,16 +213,16 @@ export function createV1Backend(client: OpencodeClient, defaultLocation?: Locati
sessionID: input.sessionID, sessionID: input.sessionID,
limit: input.limit, limit: input.limit,
before: input.cursor, before: input.cursor,
...legacyLocation(defaultLocation), ...location(input),
}, },
options(request), options(request),
) )
return { return {
items: result.data.map(cache), items: result.data.map(cache),
next: result.response.headers.get("x-next-cursor") ?? undefined, older: result.response.headers.get("x-next-cursor") ?? undefined,
} }
}, },
message: async (input, request) => cache(await loadMessage(input, request)), message: async (input, request) => cache(await loadMessage(input, request, true)),
prompt: async (input, request) => { prompt: async (input, request) => {
await client.session.promptAsync( await client.session.promptAsync(
{ {
@ -204,23 +235,7 @@ export function createV1Backend(client: OpencodeClient, defaultLocation?: Locati
}, },
variant: input.selection?.model?.variant, variant: input.selection?.model?.variant,
parts: toPromptParts(input), parts: toPromptParts(input),
...legacyLocation(defaultLocation), ...location(input),
},
options(request),
)
},
command: async (input, request) => {
await client.session.command(
{
sessionID: input.sessionID,
messageID: input.id,
command: input.command,
arguments: input.arguments ?? "",
agent: input.agent,
model: input.model && `${input.model.providerID}/${input.model.id}`,
variant: input.model?.variant,
parts: input.files?.map(toFilePart),
...legacyLocation(defaultLocation),
}, },
options(request), options(request),
) )
@ -229,7 +244,13 @@ export function createV1Backend(client: OpencodeClient, defaultLocation?: Locati
files: { files: {
list: async (input, request) => { list: async (input, request) => {
const result = await client.file.list({ path: input.path ?? "", ...location(input) }, options(request)) const result = await client.file.list({ path: input.path ?? "", ...location(input) }, options(request))
return result.data.map((item) => ({ path: item.path, type: item.type })) return result.data.map((item) => ({
path: item.path,
name: item.name,
absolute: item.absolute,
type: item.type,
ignored: false,
}))
}, },
find: async (input, request) => { find: async (input, request) => {
const find = async (type: FileEntry["type"]) => { const find = async (type: FileEntry["type"]) => {
@ -259,7 +280,7 @@ export function createV1Backend(client: OpencodeClient, defaultLocation?: Locati
requestID: input.requestID, requestID: input.requestID,
reply: input.reply, reply: input.reply,
message: input.message, message: input.message,
...legacyLocation(defaultLocation), ...location(input),
}, },
options(request), options(request),
) )
@ -275,52 +296,13 @@ export function createV1Backend(client: OpencodeClient, defaultLocation?: Locati
{ {
requestID: input.requestID, requestID: input.requestID,
answers: input.answers.map((answer) => [...answer]), answers: input.answers.map((answer) => [...answer]),
...legacyLocation(defaultLocation), ...location(input),
}, },
options(request), options(request),
) )
}, },
reject: async (input, request) => { reject: async (input, request) => {
await client.question.reject( await client.question.reject({ requestID: input.requestID, ...location(input) }, options(request))
{ requestID: input.requestID, ...legacyLocation(defaultLocation) },
options(request),
)
},
},
vcs: {
status: async (input, request) => {
const result = await client.vcs.status(location(input), options(request))
return result.data
},
diff: async (input, request) => {
const result = await client.vcs.diff(
{
mode: input.mode === "working" ? "git" : "branch",
context: input.context,
...location(input),
},
options(request),
)
return result.data
},
},
mcp: {
list: async (input, request) => {
const result = await client.mcp.status(location(input), options(request))
return Object.entries(result.data).map(([name, status]) => ({ name, status }))
},
resources: async (input, request) => {
const result = await client.experimental.resource.list(location(input), options(request))
return {
resources: Object.values(result.data).map((item) => ({
server: item.client,
name: item.name,
uri: item.uri,
description: item.description,
mimeType: item.mimeType,
})),
templates: [],
}
}, },
}, },
pty: { pty: {
@ -365,7 +347,7 @@ export function createV1Backend(client: OpencodeClient, defaultLocation?: Locati
events: { events: {
subscribe: (request) => ({ subscribe: (request) => ({
async *[Symbol.asyncIterator]() { async *[Symbol.asyncIterator]() {
const result = await client.global.event(options(request)) const result = await eventClient.global.event(options(request))
for await (const input of result.stream) { for await (const input of result.stream) {
const event = await toEvent(input, messages, loadMessage) const event = await toEvent(input, messages, loadMessage)
yield { yield {
@ -379,11 +361,51 @@ export function createV1Backend(client: OpencodeClient, defaultLocation?: Locati
}, },
}), }),
}, },
disposeLocation: async (input, request) => {
await client.instance.dispose(location(input), options(request))
},
}, },
capabilities: { capabilities: {
projectList: {
list: async (request) => {
const result = await client.project.list(undefined, options(request))
return result.data.map(toProject)
},
},
vcs: {
status: async (input, request) => {
const result = await client.vcs.status(location(input), options(request))
return result.data
},
diff: async (input, request) => {
const result = await client.vcs.diff(
{
mode: input.mode === "working" ? "git" : "branch",
context: input.context,
...location(input),
},
options(request),
)
return result.data
},
},
mcp: {
list: async (input, request) => {
const result = await client.mcp.status(location(input), options(request))
return Object.entries(result.data).map(([name, status]) => ({ name, status }))
},
resources: async (input, request) => {
const result = await client.experimental.resource.list(location(input), options(request))
return {
resources: Object.values(result.data).map((item) => ({
server: item.client,
name: item.name,
uri: item.uri,
description: item.description,
mimeType: item.mimeType,
})),
templates: [],
}
},
},
sessionActionsV1: sessionActions,
configuration: { configuration: {
getGlobal: async (request) => { getGlobal: async (request) => {
const result = await client.global.config.get(options(request)) const result = await client.global.config.get(options(request))
@ -468,57 +490,74 @@ export function createV1Backend(client: OpencodeClient, defaultLocation?: Locati
return { directory: result.data.directory, branch: result.data.branch } return { directory: result.data.directory, branch: result.data.branch }
}, },
remove: async (input, request) => { remove: async (input, request) => {
await client.worktree.remove( const result = await client.worktree.remove(
{ ...location(input), worktreeRemoveInput: { directory: input.directory } }, { ...location(input), worktreeRemoveInput: { directory: input.directory } },
options(request), options(request),
) )
return result.data
}, },
reset: async (input, request) => { reset: async (input, request) => {
await client.worktree.reset( const result = await client.worktree.reset(
{ ...location(input), worktreeResetInput: { directory: input.directory } }, { ...location(input), worktreeResetInput: { directory: input.directory } },
options(request), options(request),
) )
return result.data
}, },
}, },
sessionExtrasV1: { sessionExtrasV1: {
archive: async (sessionID, archivedAt, request) => { archive: async (input, request) => {
await client.session.update( await client.session.update(
{ sessionID, time: { archived: archivedAt }, ...legacyLocation(defaultLocation) }, { sessionID: input.sessionID, time: { archived: input.archivedAt }, ...location(input) },
options(request), options(request),
) )
}, },
share: async (sessionID, request) => { share: async (input, request) => {
const result = await client.session.share({ sessionID, ...legacyLocation(defaultLocation) }, options(request)) const result = await client.session.share(
if (!result.data.share) throw new Error(`Session ${sessionID} was shared without a URL`) { sessionID: input.sessionID, ...location(input) },
options(request),
)
if (!result.data.share) throw new Error(`Session ${input.sessionID} was shared without a URL`)
return result.data.share.url return result.data.share.url
}, },
unshare: async (sessionID, request) => { unshare: async (input, request) => {
await client.session.unshare({ sessionID, ...legacyLocation(defaultLocation) }, options(request)) await client.session.unshare({ sessionID: input.sessionID, ...location(input) }, options(request))
}, },
diff: async (sessionID, request) => { diff: async (input, request) => {
const result = await client.session.diff({ sessionID, ...legacyLocation(defaultLocation) }, options(request)) const result = await client.session.diff({ sessionID: input.sessionID, ...location(input) }, options(request))
return result.data.flatMap((item) => (item.file ? [{ ...item, file: item.file }] : [])) return result.data.flatMap((item) => (item.file ? [{ ...item, file: item.file }] : []))
}, },
todos: async (sessionID, request) => { todos: async (input, request) => {
const result = await client.session.todo({ sessionID, ...legacyLocation(defaultLocation) }, options(request)) const result = await client.session.todo({ sessionID: input.sessionID, ...location(input) }, options(request))
return result.data.map((item) => ({ content: item.content, status: item.status })) return result.data.map((item) => ({
content: item.content,
status: item.status,
priority: item.priority,
}))
}, },
summarize: async (sessionID, model, request) => { summarize: async (input, request) => {
await client.session.summarize( await client.session.summarize(
{ {
sessionID, sessionID: input.sessionID,
providerID: model.providerID, providerID: input.model.providerID,
modelID: model.id, modelID: input.model.id,
...legacyLocation(defaultLocation), ...location(input),
}, },
options(request), options(request),
) )
}, },
revert: async (sessionID, messageID, request) => { revert: async (input, request) => {
await client.session.revert({ sessionID, messageID, ...legacyLocation(defaultLocation) }, options(request)) const result = await client.session.revert(
{ sessionID: input.sessionID, messageID: input.messageID, ...location(input) },
options(request),
)
return toSession(result.data)
}, },
clearRevert: async (sessionID, request) => { clearRevert: async (input, request) => {
await client.session.unrevert({ sessionID, ...legacyLocation(defaultLocation) }, options(request)) const result = await client.session.unrevert(
{ sessionID: input.sessionID, ...location(input) },
options(request),
)
return toSession(result.data)
}, },
shell: async (input, request) => { shell: async (input, request) => {
await client.session.shell( await client.session.shell(
@ -569,11 +608,26 @@ export function createV1Backend(client: OpencodeClient, defaultLocation?: Locati
return toDecoratedFile(result.data) return toDecoratedFile(result.data)
}, },
}, },
ptyTransport: { ptyTransport: transportConfig && {
connectToken: async (input, request) => { connectToken: async (input, request) => {
const result = await client.pty.connectToken({ ptyID: input.ptyID, ...location(input) }, options(request)) const result = await client.pty.connectToken(
return { ticket: result.data.ticket } { ptyID: input.ptyID, ...location(input) },
{
signal: request?.signal,
throwOnError: false,
headers: { "x-opencode-ticket": "1" },
},
)
return { status: result.response.status, ticket: result.data?.ticket }
}, },
exists: async (input, request) => {
const result = await client.pty.get(
{ ptyID: input.ptyID, ...location(input) },
{ signal: request?.signal, throwOnError: false },
)
return result.response.status !== 404
},
connectURL: (input) => ptyConnectURL(transportConfig, "/pty", input, legacyLocation(input.location)),
}, },
shellDiscovery: { shellDiscovery: {
list: async (input, request) => { list: async (input, request) => {
@ -582,6 +636,9 @@ export function createV1Backend(client: OpencodeClient, defaultLocation?: Locati
}, },
}, },
runtimeV1: { runtimeV1: {
disposeLocation: async (input, request) => {
await client.instance.dispose(location(input), options(request))
},
disposeAll: async (request) => { disposeAll: async (request) => {
await client.global.dispose(options(request)) await client.global.dispose(options(request))
}, },
@ -590,10 +647,31 @@ export function createV1Backend(client: OpencodeClient, defaultLocation?: Locati
} }
} }
function ptyConnectURL(
config: PtyTransportConfig,
root: string,
input: { ptyID: string; cursor: number; ticket?: string },
location: { directory?: string; workspace?: string },
) {
const url = new URL(`${config.baseUrl.replace(/\/+$/, "")}${root}/${encodeURIComponent(input.ptyID)}/connect`)
if (location.directory) url.searchParams.set("directory", location.directory)
if (location.workspace) url.searchParams.set("workspace", location.workspace)
url.searchParams.set("cursor", String(input.cursor))
url.protocol = url.protocol === "https:" ? "wss:" : "ws:"
if (input.ticket) {
url.searchParams.set("ticket", input.ticket)
return url
}
if (config.password && (!config.sameOrigin || config.authToken))
url.searchParams.set("auth_token", btoa(`${config.username ?? "opencode"}:${config.password}`))
return url
}
function legacyLocation(input?: LocationRef) { function legacyLocation(input?: LocationRef) {
if (!input) return {}
return { return {
directory: input?.directory, directory: input.directory,
workspace: input?.workspaceID, workspace: input.workspaceID,
} }
} }
@ -609,6 +687,8 @@ function toProject(input: Project): AppProject {
return { return {
id: input.id, id: input.id,
worktree: input.worktree, worktree: input.worktree,
vcs: input.vcs,
time: { ...input.time, updated: input.time.updated ?? input.time.created },
name: input.name, name: input.name,
icon: input.icon, icon: input.icon,
commands: input.commands, commands: input.commands,
@ -619,9 +699,13 @@ function toProject(input: Project): AppProject {
function toSession(input: Session): AppSession { function toSession(input: Session): AppSession {
return { return {
id: input.id, id: input.id,
slug: input.slug,
version: input.version,
parentID: input.parentID, parentID: input.parentID,
projectID: input.projectID, projectID: input.projectID,
location: { directory: input.directory, workspaceID: input.workspaceID }, location: { directory: input.directory, workspaceID: input.workspaceID },
directory: input.directory,
workspaceID: input.workspaceID,
title: input.title, title: input.title,
cost: input.cost ?? 0, cost: input.cost ?? 0,
tokens: input.tokens, tokens: input.tokens,
@ -657,6 +741,7 @@ function toProvider(input: Provider): AppProvider {
return { return {
id: input.id, id: input.id,
name: input.name, name: input.name,
source: input.source,
models: Object.fromEntries( models: Object.fromEntries(
Object.entries(input.models).flatMap(([id, model]) => Object.entries(input.models).flatMap(([id, model]) =>
model.status === "deprecated" ? [] : [[id, toModel(model)]], model.status === "deprecated" ? [] : [[id, toModel(model)]],
@ -714,8 +799,8 @@ function toReference(input: import("@opencode-ai/sdk-v1/v2/client").ReferenceInf
return input return input
} }
function toActivity(input: import("@opencode-ai/sdk-v1/v2/client").SessionStatus): SessionActivity | undefined { function toActivity(input: import("@opencode-ai/sdk-v1/v2/client").SessionStatus): SessionActivity {
if (input.type === "idle") return if (input.type === "idle") return { type: "idle" }
if (input.type === "busy") return { type: "running" } if (input.type === "busy") return { type: "running" }
return input return input
} }
@ -734,7 +819,10 @@ function toTimelineItem(info: Message, parts: readonly Part[]): TimelineItem {
providerID: info.model.providerID, providerID: info.model.providerID,
variant: info.model.variant, variant: info.model.variant,
}, },
raw: { info, parts }, format: info.format,
summary: info.summary,
system: info.system,
tools: info.tools,
} }
} }
return { return {
@ -749,7 +837,12 @@ function toTimelineItem(info: Message, parts: readonly Part[]): TimelineItem {
model: { id: info.modelID, providerID: info.providerID, variant: info.variant }, model: { id: info.modelID, providerID: info.providerID, variant: info.variant },
tokens: info.tokens, tokens: info.tokens,
error: info.error, error: info.error,
raw: { info, parts }, mode: info.mode,
path: info.path,
cost: info.cost,
structured: info.structured,
finish: info.finish,
summary: info.summary,
} }
} }
@ -763,9 +856,11 @@ function toTimelineContent(input: Part): TimelineContent[] {
synthetic: input.synthetic, synthetic: input.synthetic,
ignored: input.ignored, ignored: input.ignored,
metadata: input.metadata, metadata: input.metadata,
time: input.time,
}, },
] ]
if (input.type === "reasoning") return [{ type: input.type, id: input.id, text: input.text }] if (input.type === "reasoning")
return [{ type: input.type, id: input.id, text: input.text, metadata: input.metadata, time: input.time }]
if (input.type === "file") if (input.type === "file")
return [ return [
{ {
@ -794,14 +889,52 @@ function toTimelineContent(input: Part): TimelineContent[] {
}, },
] ]
if (input.type === "tool") if (input.type === "tool")
return [{ type: input.type, id: input.id, callID: input.callID, tool: input.tool, state: toToolState(input.state) }] return [
{
type: input.type,
id: input.id,
callID: input.callID,
tool: input.tool,
state: toToolState(input.state),
metadata: input.metadata,
},
]
if (input.type === "subtask")
return [
{
type: input.type,
id: input.id,
prompt: input.prompt,
description: input.description,
agent: input.agent,
model: input.model && { id: input.model.modelID, providerID: input.model.providerID },
command: input.command,
},
]
if (input.type === "step-start") return [{ type: input.type, id: input.id, snapshot: input.snapshot }]
if (input.type === "step-finish")
return [
{
type: input.type,
id: input.id,
reason: input.reason,
snapshot: input.snapshot,
cost: input.cost,
tokens: input.tokens,
},
]
if (input.type === "snapshot") return [{ type: input.type, id: input.id, snapshot: input.snapshot }]
if (input.type === "patch") return [{ type: input.type, id: input.id, hash: input.hash, files: input.files }]
if (input.type === "retry")
return [{ type: input.type, id: input.id, attempt: input.attempt, error: input.error, time: input.time }]
if (input.type === "compaction") return [{ type: input.type, id: input.id, auto: input.auto }]
return [] return []
} }
function toToolState(input: import("@opencode-ai/sdk-v1/v2/client").ToolState): ToolState { function toToolState(input: import("@opencode-ai/sdk-v1/v2/client").ToolState): ToolState {
if (input.status === "pending") return { status: input.status, input: input.input, raw: input.raw } if (input.status === "pending") return { status: input.status, input: input.input, raw: input.raw }
if (input.status === "running") if (input.status === "running")
return { status: input.status, input: input.input, title: input.title, metadata: input.metadata } return { status: input.status, input: input.input, title: input.title, metadata: input.metadata, time: input.time }
if (input.status === "completed") if (input.status === "completed")
return { return {
status: input.status, status: input.status,
@ -809,8 +942,10 @@ function toToolState(input: import("@opencode-ai/sdk-v1/v2/client").ToolState):
output: input.output, output: input.output,
title: input.title, title: input.title,
metadata: input.metadata, metadata: input.metadata,
time: input.time,
attachments: input.attachments,
} }
return { status: input.status, input: input.input, error: input.error, metadata: input.metadata } return { status: input.status, input: input.input, error: input.error, metadata: input.metadata, time: input.time }
} }
function toFilePart(input: PromptFile) { function toFilePart(input: PromptFile) {
@ -830,6 +965,7 @@ function toFilePart(input: PromptFile) {
} }
function toPromptParts(input: PromptInput) { function toPromptParts(input: PromptInput) {
if (input.parts) return input.parts.map(toPromptPart)
return [ return [
{ type: "text" as const, text: input.text }, { type: "text" as const, text: input.text },
...(input.files?.map(toFilePart) ?? []), ...(input.files?.map(toFilePart) ?? []),
@ -844,6 +980,11 @@ function toPromptParts(input: PromptInput) {
] ]
} }
function toPromptPart(input: NonNullable<PromptInput["parts"]>[number]) {
if (input.type === "file" && input.source?.type === "resource") return { ...input, source: undefined }
return { ...input }
}
function toFileContent(input: import("@opencode-ai/sdk-v1/v2/client").FileContent): FileContent { function toFileContent(input: import("@opencode-ai/sdk-v1/v2/client").FileContent): FileContent {
if (input.encoding !== "base64") { if (input.encoding !== "base64") {
return { bytes: new TextEncoder().encode(input.content), kind: input.type, mimeType: input.mimeType } return { bytes: new TextEncoder().encode(input.content), kind: input.type, mimeType: input.mimeType }
@ -862,7 +1003,7 @@ function toDecoratedFile(input: import("@opencode-ai/sdk-v1/v2/client").FileCont
diff: input.diff, diff: input.diff,
encoding: input.encoding, encoding: input.encoding,
mimeType: input.mimeType, mimeType: input.mimeType,
patch: input.patch && { hunks: input.patch.hunks.map((hunk) => ({ lines: hunk.lines })) }, patch: input.patch,
} }
} }
@ -872,7 +1013,10 @@ function toPermission(input: import("@opencode-ai/sdk-v1/v2/client").PermissionR
sessionID: input.sessionID, sessionID: input.sessionID,
action: input.permission, action: input.permission,
resources: input.patterns, resources: input.patterns,
metadata: input.metadata, permission: input.permission,
patterns: input.patterns,
always: input.always,
metadata: input.metadata ?? {},
} }
} }
@ -880,7 +1024,11 @@ function toQuestion(input: import("@opencode-ai/sdk-v1/v2/client").QuestionReque
return { return {
id: input.id, id: input.id,
sessionID: input.sessionID, sessionID: input.sessionID,
questions: input.questions, questions: input.questions.map((question) => ({
...question,
header: question.header ?? "",
options: question.options.map((option) => ({ ...option, description: option.description ?? "" })),
})),
} }
} }
@ -891,22 +1039,54 @@ function toPty(input: import("@opencode-ai/sdk-v1/v2/client").Pty) {
async function toEvent( async function toEvent(
envelope: GlobalEvent, envelope: GlobalEvent,
messages: Map<string, CachedMessage>, messages: Map<string, CachedMessage>,
loadMessage: (input: { sessionID: string; messageID: string }) => Promise<CachedMessage>, loadMessage: (input: LocationInput & { sessionID: string; messageID: string }) => Promise<CachedMessage>,
): Promise<AppEvent> { ): Promise<AppEvent> {
const input = envelope.payload as Event const input = envelope.payload as Event
if (input.type === "server.connected") return { type: input.type } const eventLocation =
envelope.directory === "global"
? undefined
: { location: { directory: envelope.directory, workspaceID: envelope.workspace } }
if (input.type === "server.connected") {
messages.clear()
return { type: input.type }
}
if (input.type === "global.disposed") return { type: "server.disposed" } if (input.type === "global.disposed") return { type: "server.disposed" }
if (input.type === "server.instance.disposed") if (input.type === "server.instance.disposed")
return { type: "server.disposed", location: { directory: input.properties.directory } } return { type: "instance.disposed", location: { directory: input.properties.directory } }
if (input.type === "project.updated") return { type: input.type, project: toProject(input.properties) } if (input.type === "project.updated") return { type: input.type, project: toProject(input.properties) }
if (input.type === "session.created" || input.type === "session.updated") if (input.type === "session.created" || input.type === "session.updated")
return { type: input.type, session: toSession(input.properties.info) } return { type: input.type, session: toSession(input.properties.info) }
if (input.type === "session.deleted") return { type: input.type, sessionID: input.properties.sessionID } if (input.type === "session.deleted") {
for (const [messageID, message] of messages) {
if (message.info.sessionID === input.properties.sessionID) messages.delete(messageID)
}
return { type: input.type, sessionID: input.properties.sessionID }
}
if (input.type === "session.status") { if (input.type === "session.status") {
const activity = toActivity(input.properties.status) const activity = toActivity(input.properties.status)
if (activity) return { type: "session.activity", sessionID: input.properties.sessionID, activity } if (activity.type === "idle") {
return { type: "unknown", raw: input } for (const [messageID, message] of messages) {
if (message.info.sessionID === input.properties.sessionID) messages.delete(messageID)
}
}
return { type: "session.activity", sessionID: input.properties.sessionID, activity }
} }
if (input.type === "session.diff")
return {
type: input.type,
sessionID: input.properties.sessionID,
diff: input.properties.diff.flatMap((item) => (item.file ? [{ ...item, file: item.file }] : [])),
}
if (input.type === "todo.updated")
return {
type: input.type,
sessionID: input.properties.sessionID,
todos: input.properties.todos.map((todo) => ({
content: todo.content,
status: todo.status,
priority: todo.priority,
})),
}
if (input.type === "session.error") if (input.type === "session.error")
return { type: input.type, sessionID: input.properties.sessionID, error: input.properties.error } return { type: input.type, sessionID: input.properties.sessionID, error: input.properties.error }
if (input.type === "message.updated") { if (input.type === "message.updated") {
@ -917,7 +1097,7 @@ async function toEvent(
} }
if (input.type === "message.part.updated") { if (input.type === "message.part.updated") {
const messageID = input.properties.part.messageID const messageID = input.properties.part.messageID
const value = await loadMessage({ sessionID: input.properties.sessionID, messageID }) const value = await loadMessage({ sessionID: input.properties.sessionID, messageID, ...eventLocation })
const parts = [...value.parts.filter((part) => part.id !== input.properties.part.id), input.properties.part] const parts = [...value.parts.filter((part) => part.id !== input.properties.part.id), input.properties.part]
const next = { info: value.info, parts } const next = { info: value.info, parts }
messages.set(messageID, next) messages.set(messageID, next)
@ -928,10 +1108,40 @@ async function toEvent(
return { type: "timeline.removed", sessionID: input.properties.sessionID, itemID: input.properties.messageID } return { type: "timeline.removed", sessionID: input.properties.sessionID, itemID: input.properties.messageID }
} }
if (input.type === "message.part.removed") { if (input.type === "message.part.removed") {
const value = await loadMessage({ sessionID: input.properties.sessionID, messageID: input.properties.messageID }) const cached = messages.get(input.properties.messageID)
const next = { info: value.info, parts: value.parts.filter((part) => part.id !== input.properties.partID) } if (cached)
messages.set(input.properties.messageID, next) messages.set(input.properties.messageID, {
return { type: "timeline.updated", item: toTimelineItem(next.info, next.parts) } ...cached,
parts: cached.parts.filter((part) => part.id !== input.properties.partID),
})
return {
type: "timeline.part.removed",
sessionID: input.properties.sessionID,
itemID: input.properties.messageID,
contentID: input.properties.partID,
}
}
if (input.type === "message.part.delta") {
const cached = messages.get(input.properties.messageID)
const part = cached?.parts.find((part) => part.id === input.properties.partID)
if (cached && part) {
const current = part[input.properties.field as keyof Part]
if (typeof current === "string")
messages.set(input.properties.messageID, {
...cached,
parts: cached.parts.map((item) =>
item.id === part.id ? { ...item, [input.properties.field]: current + input.properties.delta } : item,
),
})
}
return {
type: "timeline.delta",
sessionID: input.properties.sessionID,
itemID: input.properties.messageID,
contentID: input.properties.partID,
field: input.properties.field,
delta: input.properties.delta,
}
} }
if (input.type === "permission.asked") if (input.type === "permission.asked")
return { type: "permission.requested", request: toPermission(input.properties) } return { type: "permission.requested", request: toPermission(input.properties) }
@ -942,8 +1152,11 @@ async function toEvent(
id: input.properties.id, id: input.properties.id,
sessionID: input.properties.sessionID, sessionID: input.properties.sessionID,
action: input.properties.action, action: input.properties.action,
resources: input.properties.resources, resources: [...input.properties.resources],
metadata: input.properties.metadata, permission: input.properties.action,
patterns: [...input.properties.resources],
always: [],
metadata: input.properties.metadata ?? {},
}, },
} }
if (input.type === "permission.replied" || input.type === "permission.v2.replied") if (input.type === "permission.replied" || input.type === "permission.v2.replied")
@ -961,6 +1174,12 @@ async function toEvent(
if (input.type === "file.watcher.updated") if (input.type === "file.watcher.updated")
return { type: "file.changed", path: input.properties.file, change: input.properties.event } return { type: "file.changed", path: input.properties.file, change: input.properties.event }
if (input.type === "vcs.branch.updated") return { type: input.type, branch: input.properties.branch } if (input.type === "vcs.branch.updated") return { type: input.type, branch: input.properties.branch }
if (input.type === "worktree.ready")
return { type: input.type, name: input.properties.name, branch: input.properties.branch }
if (input.type === "worktree.failed") return { type: input.type, message: input.properties.message }
if (input.type === "lsp.updated") return { type: input.type }
if (input.type === "reference.updated") return { type: input.type }
if (input.type === "mcp.tools.changed") return { type: "mcp.updated", server: input.properties.server }
if (input.type === "pty.exited") return { type: input.type, ptyID: input.properties.id } if (input.type === "pty.exited") return { type: input.type, ptyID: input.properties.id }
return { type: "unknown", raw: input } return { type: "unknown", raw: input }
} }

View file

@ -1,8 +1,12 @@
import { describe, expect, test } from "bun:test" import { describe, expect, test } from "bun:test"
import { OpenCode } from "@opencode-ai/client" import { OpenCode } from "@opencode-ai/client"
import { credentialConnectionIDs, type PtyTransportConfig } from "./backend"
import { createV2Backend } from "./backend-v2" import { createV2Backend } from "./backend-v2"
function setup(respond: (request: Request) => Response | Promise<Response>) { function setup(
respond: (request: Request) => Response | Promise<Response>,
transport?: Partial<Pick<PtyTransportConfig, "sameOrigin" | "authToken" | "password">>,
) {
const requests: Request[] = [] const requests: Request[] = []
const fetch = Object.assign( const fetch = Object.assign(
async (input: RequestInfo | URL, init?: RequestInit) => { async (input: RequestInfo | URL, init?: RequestInit) => {
@ -12,12 +16,22 @@ function setup(respond: (request: Request) => Response | Promise<Response>) {
}, },
{ preconnect: globalThis.fetch.preconnect }, { preconnect: globalThis.fetch.preconnect },
) satisfies typeof globalThis.fetch ) satisfies typeof globalThis.fetch
const client = OpenCode.make({ baseUrl: "http://localhost", fetch })
return { return {
requests, requests,
backend: createV2Backend(OpenCode.make({ baseUrl: "http://localhost", fetch }), { backend: createV2Backend(
directory: "/default", client,
workspaceID: "default-workspace", {
}), baseUrl: "http://localhost",
fetch,
username: "user",
password: transport && "password" in transport ? transport.password : "secret",
sameOrigin: transport?.sameOrigin ?? false,
authToken: transport?.authToken ?? false,
},
{ directory: "/default", workspaceID: "default-workspace" },
client,
),
} }
} }
@ -36,12 +50,45 @@ const session = {
} }
describe("createV2Backend", () => { describe("createV2Backend", () => {
test("uses no legacy endpoints for bootstrap and common reads", async () => {
const setupResult = setup((request) => {
const path = new URL(request.url).pathname
if (path === "/api/health") return json({ healthy: true, version: "v2" })
if (path === "/api/location")
return json({
directory: "/default",
workspaceID: "default-workspace",
project: { id: "project", directory: "/default" },
})
return json({ location: { directory: "/default" }, data: [] })
})
await Promise.all([
setupResult.backend.common.health.get(),
setupResult.backend.common.projects.current(),
setupResult.backend.common.catalog.providers(),
setupResult.backend.common.catalog.agents(),
setupResult.backend.common.commands.list(),
setupResult.backend.common.references.list(),
setupResult.backend.common.files.list({}),
setupResult.backend.common.files.find({ query: "src" }),
setupResult.backend.common.permissions.pending(),
setupResult.backend.common.questions.pending(),
setupResult.backend.common.pty.list(),
])
const paths = setupResult.requests.map((request) => new URL(request.url).pathname)
expect(paths.every((path) => path.startsWith("/api/"))).toBe(true)
expect(paths.some((path) => path === "/project" || path.startsWith("/vcs") || path.startsWith("/mcp"))).toBe(
false,
)
})
test("normalizes session pages and preserves location precedence", async () => { test("normalizes session pages and preserves location precedence", async () => {
const setupResult = setup(() => json({ data: [session], cursor: { previous: "before", next: "after" } })) const setupResult = setup(() => json({ data: [session], cursor: { previous: "before", next: "after" } }))
const result = await setupResult.backend.common.sessions.list({ const result = await setupResult.backend.common.sessions.list({
location: { directory: "/explicit", workspaceID: "explicit-workspace" }, location: { directory: "/explicit", workspaceID: "explicit-workspace" },
roots: true,
limit: 10, limit: 10,
cursor: "cursor", cursor: "cursor",
}) })
@ -50,9 +97,13 @@ describe("createV2Backend", () => {
items: [ items: [
{ {
id: "ses_1", id: "ses_1",
slug: "ses_1",
version: "",
parentID: undefined, parentID: undefined,
projectID: "project", projectID: "project",
location: { directory: "/repo", workspaceID: "workspace" }, location: { directory: "/repo", workspaceID: "workspace" },
directory: "/repo",
workspaceID: "workspace",
title: "Session", title: "Session",
cost: 1.5, cost: 1.5,
tokens: { input: 1, output: 2, reasoning: 3, cache: { read: 4, write: 5 } }, tokens: { input: 1, output: 2, reasoning: 3, cache: { read: 4, write: 5 } },
@ -60,27 +111,187 @@ describe("createV2Backend", () => {
revert: undefined, revert: undefined,
}, },
], ],
previous: "before", newer: "before",
next: "after", older: "after",
}) })
const url = new URL(setupResult.requests[0].url) const url = new URL(setupResult.requests[0].url)
expect(url.pathname).toBe("/api/session") expect(url.pathname).toBe("/api/session")
expect(url.searchParams.get("directory")).toBe("/explicit") expect(url.searchParams.get("directory")).toBe("/explicit")
expect(url.searchParams.get("workspace")).toBe("explicit-workspace") expect(url.searchParams.get("workspace")).toBe("explicit-workspace")
expect(url.searchParams.get("parentID")).toBe("null") expect(url.searchParams.has("parentID")).toBe(false)
expect(url.searchParams.get("cursor")).toBe("cursor") expect(url.searchParams.get("cursor")).toBe("cursor")
}) })
test("uses the default location and maps binary file responses", async () => { test("paginates native sessions until roots:true contains only the requested roots", async () => {
const setupResult = setup(() => new Response(Uint8Array.from([0, 1, 2]))) const child = { ...session, id: "child", parentID: "root_1" }
const root1 = { ...session, id: "root_1" }
const root2 = { ...session, id: "root_2" }
const setupResult = setup((request) => {
const cursor = new URL(request.url).searchParams.get("cursor")
if (!cursor) return json({ data: [child], cursor: { next: "one" } })
if (cursor === "one") return json({ data: [root1], cursor: { next: "two" } })
return json({ data: [root2], cursor: { next: "three" } })
})
const result = await setupResult.backend.common.files.read({ path: "dir/a.bin" }) const result = await setupResult.backend.common.sessions.list({ roots: true, limit: 2 })
expect(result.items.map((item) => item.id)).toEqual(["root_1", "root_2"])
expect(result.older).toBe("three")
expect(setupResult.requests.map((request) => new URL(request.url).searchParams.get("limit"))).toEqual(["1", "1", "1"])
})
test("uses only native endpoints for bootstrap operations and binary file reads", async () => {
const setupResult = setup((request) => {
if (new URL(request.url).pathname === "/api/fs/read/dir%2Fa.txt")
return new Response(Uint8Array.from([0, 1, 255]), { headers: { "content-type": "application/octet-stream" } })
return json({ location: { directory: "/default" }, data: [] })
})
await setupResult.backend.common.files.list({ path: "dir" })
const content = await setupResult.backend.common.files.read({ path: "dir/a.txt" })
expect([...result.bytes]).toEqual([0, 1, 2])
const url = new URL(setupResult.requests[0].url) const url = new URL(setupResult.requests[0].url)
expect(url.pathname).toBe("/api/fs/read/dir/a.bin") expect(url.pathname).toBe("/api/fs/list")
expect(url.searchParams.get("location[directory]")).toBe("/default") expect(url.searchParams.get("location[directory]")).toBe("/default")
expect(url.searchParams.get("location[workspace]")).toBe("default-workspace") expect(url.searchParams.get("location[workspace]")).toBe("default-workspace")
expect(content).toEqual({
bytes: Uint8Array.from([0, 1, 255]),
kind: "binary",
mimeType: "application/octet-stream",
})
const readURL = new URL(setupResult.requests[1].url)
expect(readURL.pathname).toBe("/api/fs/read/dir%2Fa.txt")
expect(readURL.searchParams.get("location[directory]")).toBe("/default")
expect(readURL.searchParams.get("location[workspace]")).toBe("default-workspace")
expect(setupResult.requests[1].headers.get("authorization")).toBe(`Basic ${btoa("user:secret")}`)
expect(setupResult.requests.every((request) => new URL(request.url).pathname.startsWith("/api/"))).toBe(true)
expect(setupResult.backend.version).toBe("v2")
expect(Object.keys(setupResult.backend.capabilities).sort()).toEqual([
"integrationsV2",
"projectCopiesV2",
"ptyTransport",
"savedPermissionsV2",
"sessionExtrasV2",
])
expect(setupResult.backend.capabilities.providerAuthV1).toBeUndefined()
expect(setupResult.backend.capabilities.worktreesV1).toBeUndefined()
expect(setupResult.backend.capabilities.sessionExtrasV1).toBeUndefined()
expect(setupResult.backend.capabilities.runtimeV1).toBeUndefined()
expect(setupResult.backend.capabilities.projectList).toBeUndefined()
expect(setupResult.backend.capabilities.vcs).toBeUndefined()
expect(setupResult.backend.capabilities.mcp).toBeUndefined()
expect(setupResult.backend.capabilities.sessionExtrasV2?.move).toBeUndefined()
expect(setupResult.backend.capabilities.projectCopiesV2?.directories).toBeUndefined()
})
test("uses native PTY endpoints and preserves status through the v2 adapter", async () => {
const setupResult = setup((request) =>
request.method === "GET" ? new Response(null, { status: 404 }) : new Response(null, { status: 403 }),
)
const transport = setupResult.backend.capabilities.ptyTransport
const ticket = await transport?.connectToken({
ptyID: "pty_1",
location: { directory: "/explicit", workspaceID: "workspace" },
})
const exists = await transport?.exists({
ptyID: "pty_1",
location: { directory: "/explicit", workspaceID: "workspace" },
})
expect(ticket).toEqual({ status: 403, ticket: undefined })
expect(exists).toBe(false)
expect(setupResult.requests[0].headers.get("x-opencode-ticket")).toBe("1")
const tokenURL = new URL(setupResult.requests[0].url)
const existsURL = new URL(setupResult.requests[1].url)
expect(tokenURL.pathname).toBe("/api/pty/pty_1/connect-token")
expect(existsURL.pathname).toBe("/api/pty/pty_1")
expect(tokenURL.searchParams.get("location[directory]")).toBe("/explicit")
expect(tokenURL.searchParams.get("location[workspace]")).toBe("workspace")
expect(setupResult.requests[0].headers.get("authorization")).toBe(`Basic ${btoa("user:secret")}`)
expect(() =>
transport?.connectURL({
ptyID: "pty/1",
location: { directory: "/explicit", workspaceID: "workspace" },
cursor: 8,
}),
).toThrow("require a ticket")
const ticketURL = transport?.connectURL({
ptyID: "pty_1",
location: { directory: "/explicit" },
cursor: 0,
ticket: "ticket value",
})
expect(ticketURL?.searchParams.get("ticket")).toBe("ticket value")
expect(ticketURL?.searchParams.has("auth_token")).toBe(false)
})
test("allows ticketless same-origin native PTY URLs without credential queries", () => {
const transport = setup(() => new Response(), { sameOrigin: true, password: undefined }).backend.capabilities
.ptyTransport
const url = transport?.connectURL({ ptyID: "pty_1", location: { directory: "/repo" }, cursor: 0 })
expect(url?.searchParams.has("auth_token")).toBe(false)
expect(url?.pathname).toBe("/api/pty/pty_1/connect")
})
test("preserves native provider integration IDs", async () => {
const setupResult = setup((request) => {
const path = new URL(request.url).pathname
if (path === "/api/provider")
return json({
location: { directory: "/default" },
data: [
{
id: "provider",
integrationID: "integration",
name: "Provider",
api: { type: "native", settings: {} },
request: { headers: {}, body: {} },
},
],
})
return json({ location: { directory: "/default" }, data: [] })
})
const result = await setupResult.backend.common.catalog.providers()
expect(result.providers.get("provider")?.integrationID).toBe("integration")
})
test("preserves integration connection kinds", async () => {
const setupResult = setup(() =>
json({
location: { directory: "/default" },
data: [
{
id: "integration",
name: "Integration",
methods: [],
connections: [
{ type: "credential", id: "credential", label: "Saved" },
{ type: "environment", name: "TOKEN" },
],
},
],
}),
)
const integrations = await setupResult.backend.capabilities.integrationsV2?.list()
expect(integrations).toEqual([
{
id: "integration",
name: "Integration",
methods: [],
connections: [
{ id: "credential", label: "Saved", kind: "credential" },
{ id: "TOKEN", label: "TOKEN", kind: "environment" },
],
},
])
expect(credentialConnectionIDs(integrations?.[0]?.connections ?? [])).toEqual(["credential"])
}) })
test("switches prompt selection before admission", async () => { test("switches prompt selection before admission", async () => {
@ -115,17 +326,82 @@ describe("createV2Backend", () => {
]) ])
expect(await setupResult.requests[2].json()).toEqual({ expect(await setupResult.requests[2].json()).toEqual({
id: "msg_1", id: "msg_1",
text: "hello", prompt: {
files: [ text: "hello",
{ files: [
uri: "data:text/plain;base64,aGk=", {
name: "hi.txt", uri: "data:text/plain;base64,aGk=",
mention: { start: 0, end: 2, text: "hi" }, mime: "application/octet-stream",
}, name: "hi.txt",
], source: { start: 0, end: 2, text: "hi" },
},
],
},
}) })
}) })
test("serializes selection and prompt admission per session", async () => {
const firstPrompt = Promise.withResolvers<void>()
const firstPromptStarted = Promise.withResolvers<void>()
const setupResult = setup(async (request) => {
const path = new URL(request.url).pathname
if (path.endsWith("/prompt") && setupResult.requests.filter((item) => item.url.endsWith("/prompt")).length === 1) {
firstPromptStarted.resolve()
await firstPrompt.promise
}
return path.endsWith("/prompt")
? json({ data: { id: "msg", sessionID: "ses_1", timeCreated: 1, type: "user", data: { text: "" } } })
: new Response(null, { status: 204 })
})
const first = setupResult.backend.common.sessions.prompt({
sessionID: "ses_1",
id: "msg_1",
text: "first",
selection: { agent: "build" },
})
await firstPromptStarted.promise
const second = setupResult.backend.common.sessions.prompt({
sessionID: "ses_1",
id: "msg_2",
text: "second",
selection: { agent: "plan" },
})
expect(setupResult.requests.map((item) => new URL(item.url).pathname)).toEqual([
"/api/session/ses_1/agent",
"/api/session/ses_1/prompt",
])
firstPrompt.resolve()
await Promise.all([first, second])
expect(setupResult.requests.map((item) => new URL(item.url).pathname)).toEqual([
"/api/session/ses_1/agent",
"/api/session/ses_1/prompt",
"/api/session/ses_1/agent",
"/api/session/ses_1/prompt",
])
})
test("does not switch a selection already present in session state", async () => {
const setupResult = setup((request) =>
request.url.endsWith("/prompt")
? json({ data: { id: "msg", sessionID: "ses_1", timeCreated: 1, type: "user", data: { text: "" } } })
: json({ data: { ...session, agent: "build", model: { id: "model", providerID: "provider" } } }),
)
await setupResult.backend.common.sessions.get({ sessionID: "ses_1" })
await setupResult.backend.common.sessions.prompt({
sessionID: "ses_1",
id: "msg_1",
text: "hello",
selection: { agent: "build", model: { id: "model", providerID: "provider" } },
})
expect(setupResult.requests.map((item) => new URL(item.url).pathname)).toEqual([
"/api/session/ses_1",
"/api/session/ses_1/prompt",
])
})
test("requests file staging when selected revert files are present", async () => { test("requests file staging when selected revert files are present", async () => {
const setupResult = setup(() => json({ data: { messageID: "msg_1", files: [] } })) const setupResult = setup(() => json({ data: { messageID: "msg_1", files: [] } }))
@ -138,75 +414,266 @@ describe("createV2Backend", () => {
expect(await setupResult.requests[0].json()).toEqual({ messageID: "msg_1", files: true }) expect(await setupResult.requests[0].json()).toEqual({ messageID: "msg_1", files: true })
}) })
test("normalizes retry events before message projection refresh", async () => { test("maps ordered app prompt parts to the native prompt shape", async () => {
const setupResult = setup(() => const setupResult = setup(() =>
new Response( json({ data: { admittedSeq: 1, id: "msg_1", sessionID: "ses_1", timeCreated: 1, type: "user", data: {} } }),
`data: ${JSON.stringify({ )
id: "evt_retry",
created: 1, await setupResult.backend.common.sessions.prompt({
type: "session.retry.scheduled", sessionID: "ses_1",
durable: { aggregateID: "ses_1", seq: 3, version: 1 }, id: "msg_1",
location: { directory: "/repo" }, text: "visible",
data: { parts: [
sessionID: "ses_1", { id: "part_text", type: "text", text: "visible" },
assistantMessageID: "msg_1", { id: "part_note", type: "text", text: "note", synthetic: true, metadata: { source: "review" } },
attempt: 2, { id: "part_file", type: "file", mime: "text/plain", url: "file:///repo/a.ts", filename: "a.ts" },
at: 1234, { id: "part_agent", type: "agent", name: "build", source: { value: "@build", start: 7, end: 13 } },
error: { type: "rate_limit", message: "try again" }, ],
}, })
})}\n\n`,
{ headers: { "content-type": "text/event-stream" } }, expect(await setupResult.requests[0].json()).toEqual({
), id: "msg_1",
prompt: {
text: "visiblenote",
files: [{ uri: "file:///repo/a.ts", mime: "text/plain", name: "a.ts" }],
agents: [{ name: "build", source: { text: "@build", start: 7, end: 13 } }],
},
})
})
test("commits a staged revert through the V2 capability", async () => {
const setupResult = setup(() => new Response(null, { status: 204 }))
await setupResult.backend.capabilities.sessionExtrasV2?.commitRevert({ sessionID: "ses_1" })
expect(new URL(setupResult.requests[0].url).pathname).toBe("/api/session/ses_1/revert/commit")
})
test("normalizes current session activity events without projection refresh", async () => {
const setupResult = setup(
() =>
new Response(
`data: ${JSON.stringify({
id: "evt_started",
type: "session.next.step.started",
durable: { aggregateID: "ses_1", seq: 3, version: 1 },
location: { directory: "/repo" },
data: {
timestamp: 1,
sessionID: "ses_1",
assistantMessageID: "msg_1",
agent: "build",
model: { id: "model", providerID: "provider" },
},
})}\n\n`,
{ headers: { "content-type": "text/event-stream" } },
),
) )
const result = await setupResult.backend.common.events.subscribe()[Symbol.asyncIterator]().next() const result = await setupResult.backend.common.events.subscribe()[Symbol.asyncIterator]().next()
expect(result.value).toEqual({ expect(result.value).toMatchObject({
location: { directory: "/repo" }, location: { directory: "/repo" },
event: { event: {
type: "session.activity", type: "session.activity",
sessionID: "ses_1", sessionID: "ses_1",
activity: { type: "retry", attempt: 2, message: "try again", next: 1234 }, activity: { type: "running" },
}, },
}) })
expect(setupResult.requests).toHaveLength(1) expect(setupResult.requests).toHaveLength(1)
}) })
test("maps completed native steps to idle activity", async () => {
const setupResult = setup(
() =>
new Response(
`data: ${JSON.stringify({
id: "evt_ended",
type: "session.next.step.ended",
data: {
timestamp: 2,
sessionID: "ses_1",
assistantMessageID: "msg_1",
finish: "stop",
cost: 1,
tokens: { input: 1, output: 1, reasoning: 0, cache: { read: 0, write: 0 } },
},
})}\n\n`,
{ headers: { "content-type": "text/event-stream" } },
),
)
const result = await setupResult.backend.common.events.subscribe()[Symbol.asyncIterator]().next()
expect(result.value?.event).toEqual({
type: "session.activity",
sessionID: "ses_1",
activity: { type: "idle" },
})
})
test("projects failed steps as completed assistant errors and clears running", async () => {
const events = [
{
id: "start",
type: "session.next.step.started",
data: { timestamp: 1, sessionID: "ses_1", assistantMessageID: "msg_1", agent: "build", model: { id: "m", providerID: "p" } },
},
{ id: "text", type: "session.next.text.started", data: { timestamp: 2, sessionID: "ses_1", assistantMessageID: "msg_1", textID: "text_1" } },
{ id: "delta", type: "session.next.text.delta", data: { timestamp: 3, sessionID: "ses_1", assistantMessageID: "msg_1", textID: "text_1", delta: "partial" } },
{ id: "failed", type: "session.next.step.failed", data: { timestamp: 4, sessionID: "ses_1", assistantMessageID: "msg_1", error: { type: "unknown", message: "boom" } } },
]
const setupResult = setup(() => new Response(events.map((event) => `data: ${JSON.stringify(event)}\n\n`).join(""), { headers: { "content-type": "text/event-stream" } }))
const iterator = setupResult.backend.common.events.subscribe()[Symbol.asyncIterator]()
await iterator.next()
await iterator.next()
await iterator.next()
expect((await iterator.next()).value?.event).toMatchObject({
type: "session.activity",
activity: { type: "idle" },
item: { completed: 4, error: { data: { message: "boom" } }, content: [{ id: "text_1", text: "partial" }] },
})
})
test("does not infer assistant parents across history pages or direct fetches", async () => {
const assistant = { id: "assistant", type: "assistant", time: { created: 2 }, agent: "build", model: { id: "m", providerID: "p" }, content: [] }
const user = { id: "user", type: "user", time: { created: 1 }, text: "hello" }
const setupResult = setup((request) => {
const url = new URL(request.url)
if (url.pathname.endsWith("/message/assistant")) return json({ data: assistant })
if (url.searchParams.get("cursor")) return json({ data: [user], cursor: {} })
return json({ data: [assistant], cursor: { next: "older" } })
})
const first = await setupResult.backend.common.sessions.history({ sessionID: "ses_1" })
await setupResult.backend.common.sessions.history({ sessionID: "ses_1", cursor: first.older })
const direct = await setupResult.backend.common.sessions.message({ sessionID: "ses_1", messageID: "assistant" })
expect(first.items[0]).toMatchObject({ type: "assistant", parentID: undefined })
expect(direct).toMatchObject({ type: "assistant", parentID: undefined })
})
test("normalizes lifecycle and provider refresh events without HTTP fallbacks", async () => {
const events = [
{ id: "moved", type: "session.next.moved", data: { timestamp: 1, sessionID: "ses_1", location: { directory: "/next" } } },
{ id: "revert", type: "session.next.revert.staged", data: { timestamp: 2, sessionID: "ses_1", revert: { messageID: "msg_1" } } },
{ id: "integration", type: "integration.updated", data: {} },
{ id: "unknown", type: "session.next.context.updated", data: { timestamp: 3, sessionID: "ses_1", messageID: "msg_1", text: "x" } },
]
const setupResult = setup(() => new Response(events.map((event) => `data: ${JSON.stringify(event)}\n\n`).join(""), { headers: { "content-type": "text/event-stream" } }))
const iterator = setupResult.backend.common.events.subscribe()[Symbol.asyncIterator]()
expect((await iterator.next()).value?.event).toEqual({ type: "session.moved", sessionID: "ses_1", location: { directory: "/next" } })
expect((await iterator.next()).value?.event).toEqual({ type: "session.revert", sessionID: "ses_1", revert: { messageID: "msg_1" } })
expect((await iterator.next()).value?.event).toEqual({ type: "provider.updated" })
expect((await iterator.next()).value?.event.type).toBe("unknown")
expect(setupResult.requests).toHaveLength(1)
})
test("normalizes native session create, update, and delete lifecycle events", async () => {
const info = {
id: "ses_1",
slug: "one",
version: "2",
projectID: "project",
directory: "/repo",
title: "Session",
time: { created: 1, updated: 2 },
}
const events = [
{ id: "created", type: "session.created", data: { sessionID: "ses_1", info } },
{ id: "updated", type: "session.updated", data: { sessionID: "ses_1", info: { ...info, title: "Renamed" } } },
{ id: "deleted", type: "session.deleted", data: { sessionID: "ses_1", info } },
]
const setupResult = setup(() => new Response(events.map((event) => `data: ${JSON.stringify(event)}\n\n`).join(""), { headers: { "content-type": "text/event-stream" } }))
const iterator = setupResult.backend.common.events.subscribe()[Symbol.asyncIterator]()
expect((await iterator.next()).value?.event).toMatchObject({ type: "session.created", session: { id: "ses_1", title: "Session" } })
expect((await iterator.next()).value?.event).toMatchObject({ type: "session.updated", session: { id: "ses_1", title: "Renamed" } })
expect((await iterator.next()).value?.event).toEqual({ type: "session.deleted", sessionID: "ses_1" })
})
test("normalizes durable session log events through the event mapper", async () => { test("normalizes durable session log events through the event mapper", async () => {
const setupResult = setup(() => const setupResult = setup(
new Response( () =>
`data: ${JSON.stringify({ new Response(
id: "evt_started", `data: ${JSON.stringify({
created: 1, id: "evt_started",
type: "session.execution.started", type: "session.next.step.started",
durable: { aggregateID: "ses_1", seq: 7, version: 1 }, durable: { aggregateID: "ses_1", seq: 7, version: 1 },
data: { sessionID: "ses_1" }, data: {
})}\n\n`, timestamp: 1,
{ headers: { "content-type": "text/event-stream" } }, sessionID: "ses_1",
), assistantMessageID: "msg_1",
agent: "build",
model: { id: "model", providerID: "provider" },
},
})}\n\n`,
{ headers: { "content-type": "text/event-stream" } },
),
) )
const capability = setupResult.backend.capabilities.sessionExtrasV2 const capability = setupResult.backend.capabilities.sessionExtrasV2
if (!capability) throw new Error("Missing V2 session capability") if (!capability) throw new Error("Missing V2 session capability")
const result = await capability.log({ sessionID: "ses_1" })[Symbol.asyncIterator]().next() const result = await capability.log({ sessionID: "ses_1" })[Symbol.asyncIterator]().next()
expect(result.value).toEqual({ expect(result.value).toMatchObject({
sequence: 7, sequence: 7,
event: { type: "session.activity", sessionID: "ses_1", activity: { type: "running" } }, event: { type: "session.activity", sessionID: "ses_1", activity: { type: "running" } },
}) })
expect(new URL(setupResult.requests[0].url).pathname).toBe("/api/experimental/session/ses_1/log") expect(new URL(setupResult.requests[0].url).pathname).toBe("/api/session/ses_1/event")
}) })
test("refreshes message projections for streamed V2 fragments", async () => { test("normalizes native todo and part removal events", async () => {
const events = [
{
id: "evt_todo",
type: "todo.updated",
data: { sessionID: "ses_1", todos: [{ content: "Ship", status: "pending", priority: "high" }] },
},
{
id: "evt_removed",
type: "message.part.removed",
data: { sessionID: "ses_1", messageID: "msg_1", partID: "part_1" },
},
]
const setupResult = setup(
() =>
new Response(events.map((event) => `data: ${JSON.stringify(event)}\n\n`).join(""), {
headers: { "content-type": "text/event-stream" },
}),
)
const iterator = setupResult.backend.common.events.subscribe()[Symbol.asyncIterator]()
expect((await iterator.next()).value?.event).toEqual({
type: "todo.updated",
sessionID: "ses_1",
todos: [{ content: "Ship", status: "pending", priority: "high" }],
})
expect((await iterator.next()).value?.event).toEqual({
type: "timeline.part.removed",
sessionID: "ses_1",
itemID: "msg_1",
contentID: "part_1",
})
})
test("preserves streamed V2 timeline deltas without projection refresh", async () => {
const setupResult = setup((request) => { const setupResult = setup((request) => {
if (new URL(request.url).pathname === "/api/event") { if (new URL(request.url).pathname === "/api/event") {
return new Response( return new Response(
`data: ${JSON.stringify({ `data: ${JSON.stringify({
id: "evt_1", id: "evt_1",
created: 1, type: "session.next.text.delta",
type: "session.text.delta",
location: { directory: "/repo" }, location: { directory: "/repo" },
data: { sessionID: "ses_1", assistantMessageID: "msg_1", ordinal: 0, delta: "hi" }, data: {
timestamp: 1,
sessionID: "ses_1",
assistantMessageID: "msg_1",
textID: "text_1",
delta: "hi",
},
})}\n\n`, })}\n\n`,
{ headers: { "content-type": "text/event-stream" } }, { headers: { "content-type": "text/event-stream" } },
) )
@ -218,7 +685,7 @@ describe("createV2Backend", () => {
time: { created: 1 }, time: { created: 1 },
agent: "build", agent: "build",
model: { id: "model", providerID: "provider" }, model: { id: "model", providerID: "provider" },
content: [{ type: "text", text: "hello" }], content: [{ type: "text", id: "text_1", text: "hello" }],
}, },
}) })
}) })
@ -228,29 +695,51 @@ describe("createV2Backend", () => {
expect(result.value).toEqual({ expect(result.value).toEqual({
location: { directory: "/repo" }, location: { directory: "/repo" },
event: { event: {
type: "timeline.updated", type: "timeline.delta",
item: { sessionID: "ses_1",
type: "assistant", itemID: "msg_1",
id: "msg_1", contentID: "text_1",
sessionID: "ses_1", field: "text",
created: 1, delta: "hi",
completed: undefined,
content: [{ type: "text", id: "msg_1:text:0", text: "hello" }],
agent: "build",
model: { id: "model", providerID: "provider" },
tokens: undefined,
error: undefined,
raw: {
id: "msg_1",
type: "assistant",
time: { created: 1 },
agent: "build",
model: { id: "model", providerID: "provider" },
content: [{ type: "text", text: "hello" }],
},
},
}, },
}) })
expect(new URL(setupResult.requests[1].url).pathname).toBe("/api/session/ses_1/message/msg_1") expect(setupResult.requests).toHaveLength(1)
})
test("projects native fragment starts without blocking the event stream on HTTP", async () => {
const events = [
{
id: "evt_step",
type: "session.next.step.started",
data: {
timestamp: 1,
sessionID: "ses_1",
assistantMessageID: "msg_1",
agent: "build",
model: { id: "model", providerID: "provider" },
},
},
{
id: "evt_text",
type: "session.next.text.started",
data: { timestamp: 2, sessionID: "ses_1", assistantMessageID: "msg_1", textID: "text_1" },
},
]
const setupResult = setup(
() =>
new Response(events.map((event) => `data: ${JSON.stringify(event)}\n\n`).join(""), {
headers: { "content-type": "text/event-stream" },
}),
)
const iterator = setupResult.backend.common.events.subscribe()[Symbol.asyncIterator]()
await iterator.next()
expect((await iterator.next()).value?.event).toEqual({
type: "timeline.content.updated",
sessionID: "ses_1",
itemID: "msg_1",
content: { type: "text", id: "text_1", text: "" },
})
expect(setupResult.requests).toHaveLength(1)
}) })
}) })

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,56 @@
import type { AppClient, Capabilities, CommonClient } from "./backend"
type PartialApi<T> = {
[K in keyof T]?: T[K] extends (...args: never[]) => unknown ? T[K] : PartialApi<T[K]>
}
export function createAppClient(input: {
version?: AppClient["version"]
common?: PartialApi<CommonClient>
capabilities?: Capabilities
} = {}): AppClient {
const unsupported = async () => {
throw new Error("Backend fixture method is not configured")
}
const defaults: CommonClient = {
health: { get: unsupported },
projects: { current: unsupported },
catalog: { providers: unsupported, agents: unsupported },
commands: { list: unsupported },
references: { list: unsupported },
sessions: {
list: unsupported,
create: unsupported,
get: unsupported,
interrupt: unsupported,
activity: unsupported,
history: unsupported,
message: unsupported,
prompt: unsupported,
},
files: { list: unsupported, find: unsupported, read: unsupported },
permissions: { pending: unsupported, reply: unsupported },
questions: { pending: unsupported, reply: unsupported, reject: unsupported },
pty: { list: unsupported, create: unsupported, get: unsupported, update: unsupported, remove: unsupported },
events: {
async *subscribe() {},
},
}
return {
version: input.version ?? "v1",
capabilities: input.capabilities ?? {},
common: {
health: { ...defaults.health, ...input.common?.health },
projects: { ...defaults.projects, ...input.common?.projects },
catalog: { ...defaults.catalog, ...input.common?.catalog },
commands: { ...defaults.commands, ...input.common?.commands },
references: { ...defaults.references, ...input.common?.references },
sessions: { ...defaults.sessions, ...input.common?.sessions },
files: { ...defaults.files, ...input.common?.files },
permissions: { ...defaults.permissions, ...input.common?.permissions },
questions: { ...defaults.questions, ...input.common?.questions },
pty: { ...defaults.pty, ...input.common?.pty },
events: { ...defaults.events, ...input.common?.events },
},
}
}

View file

@ -21,8 +21,10 @@ export type ModelRef = {
export type Page<T> = { export type Page<T> = {
readonly items: readonly T[] readonly items: readonly T[]
readonly previous?: string /** Cursor for items older than this page. */
readonly next?: string readonly older?: string
/** Cursor for items newer than this page. */
readonly newer?: string
} }
export type Health = { export type Health = {
@ -34,6 +36,12 @@ export type Health = {
export type AppProject = { export type AppProject = {
readonly id: string readonly id: string
readonly worktree: string readonly worktree: string
readonly vcs?: "git"
readonly time: {
readonly created: number
readonly updated: number
readonly initialized?: number
}
readonly name?: string readonly name?: string
readonly icon?: { readonly icon?: {
readonly url?: string readonly url?: string
@ -43,7 +51,7 @@ export type AppProject = {
readonly commands?: { readonly commands?: {
readonly start?: string readonly start?: string
} }
readonly sandboxes: readonly string[] sandboxes: string[]
} }
export type CurrentProject = { export type CurrentProject = {
@ -83,6 +91,8 @@ export type AppModel = {
export type AppProvider = { export type AppProvider = {
readonly id: string readonly id: string
readonly name: string readonly name: string
readonly source?: "env" | "config" | "custom" | "api"
readonly integrationID?: string
readonly models: Readonly<Record<string, AppModel>> readonly models: Readonly<Record<string, AppModel>>
} }
@ -137,15 +147,19 @@ export type TokenUsage = {
export type AppSession = { export type AppSession = {
readonly id: string readonly id: string
readonly slug: string
readonly version: string
readonly parentID?: string readonly parentID?: string
readonly projectID: string readonly projectID: string
readonly location: LocationRef readonly location?: LocationRef
readonly title: string readonly directory: string
readonly cost: number readonly workspaceID?: string
title: string
readonly cost?: number
readonly tokens?: TokenUsage readonly tokens?: TokenUsage
readonly time: { readonly time: {
readonly created: number readonly created: number
readonly updated?: number readonly updated: number
readonly archived?: number readonly archived?: number
} }
readonly share?: { readonly share?: {
@ -157,6 +171,8 @@ export type AppSession = {
} }
export type SessionActivity = export type SessionActivity =
| { readonly type: "idle" }
| { readonly type: "busy" }
| { readonly type: "running" } | { readonly type: "running" }
| { | {
readonly type: "retry" readonly type: "retry"
@ -205,28 +221,139 @@ export type ToolState =
| { | {
readonly status: "pending" readonly status: "pending"
readonly input: Readonly<Record<string, unknown>> readonly input: Readonly<Record<string, unknown>>
readonly raw?: string readonly raw: string
} }
| { | {
readonly status: "running" readonly status: "running"
readonly input: Readonly<Record<string, unknown>> readonly input: Readonly<Record<string, unknown>>
readonly title?: string readonly title?: string
readonly metadata?: Readonly<Record<string, unknown>> readonly metadata?: Readonly<Record<string, unknown>>
readonly time: { readonly start: number }
readonly content?: readonly ToolOutputContent[]
readonly provider?: ToolProviderInfo
} }
| { | {
readonly status: "completed" readonly status: "completed"
readonly input: Readonly<Record<string, unknown>> readonly input: Readonly<Record<string, unknown>>
readonly output: string readonly output: string
readonly title?: string readonly title: string
readonly metadata?: Readonly<Record<string, unknown>> readonly metadata: Readonly<Record<string, unknown>>
readonly time: { readonly start: number; readonly end: number; readonly compacted?: number }
readonly attachments?: AppFilePart[]
readonly content?: readonly ToolOutputContent[]
readonly outputPaths?: readonly string[]
readonly result?: unknown
readonly provider?: ToolProviderInfo
} }
| { | {
readonly status: "error" readonly status: "error"
readonly input: Readonly<Record<string, unknown>> readonly input: Readonly<Record<string, unknown>>
readonly error: string readonly error: string
readonly metadata?: Readonly<Record<string, unknown>> readonly metadata?: Readonly<Record<string, unknown>>
readonly time: { readonly start: number; readonly end: number }
readonly content?: readonly ToolOutputContent[]
readonly result?: unknown
readonly provider?: ToolProviderInfo
} }
export type ToolOutputContent =
| { readonly type: "text"; readonly text: string }
| { readonly type: "file"; readonly uri: string; readonly mime: string; readonly name?: string }
export type ToolProviderInfo = {
readonly executed: boolean
readonly metadata?: Readonly<Record<string, Readonly<Record<string, unknown>>>>
readonly resultMetadata?: Readonly<Record<string, Readonly<Record<string, unknown>>>>
}
export type AppMessage = AppUserMessage | AppAssistantMessage
export type AppUserMessage = {
readonly id: string
readonly sessionID: string
readonly role: "user"
readonly time: { readonly created: number }
readonly format?:
| { readonly type: "text" }
| { readonly type: "json_schema"; readonly schema: Readonly<Record<string, unknown>>; readonly retryCount?: number }
readonly summary?: {
readonly title?: string
readonly body?: string
readonly diffs: (Omit<AppFileDiff, "file"> & { readonly file?: string })[]
}
readonly agent: string
readonly model: { readonly providerID: string; readonly modelID: string; readonly variant?: string }
readonly system?: string
readonly tools?: Readonly<Record<string, boolean>>
}
export type AppAssistantMessage = {
readonly id: string
readonly sessionID: string
readonly role: "assistant"
readonly time: { readonly created: number; readonly completed?: number }
readonly parentID: string
readonly modelID: string
readonly providerID: string
readonly mode: string
readonly agent: string
readonly path: { readonly cwd: string; readonly root: string }
readonly summary?: boolean
readonly cost: number
readonly tokens: TokenUsage & { readonly total?: number }
readonly structured?: unknown
readonly variant?: string
readonly finish?: string
readonly error?: AppMessageError
}
export type AppMessageError =
| { readonly name: "ProviderAuthError"; readonly data: { readonly providerID: string; readonly message: string } }
| { readonly name: "UnknownError"; readonly data: { readonly message: string; readonly ref?: string } }
| { readonly name: "MessageOutputLengthError"; readonly data: Readonly<Record<string, unknown>> }
| { readonly name: "MessageAbortedError"; readonly data: { readonly message: string } }
| { readonly name: "StructuredOutputError"; readonly data: { readonly message: string; readonly retries: number } }
| { readonly name: "ContextOverflowError"; readonly data: { readonly message: string; readonly responseBody?: string } }
| { readonly name: "ContentFilterError"; readonly data: { readonly message: string } }
| { readonly name: "APIError"; readonly data: { readonly message: string; readonly statusCode?: number; readonly isRetryable: boolean; readonly responseHeaders?: Readonly<Record<string, string>>; readonly responseBody?: string; readonly metadata?: Readonly<Record<string, string>> } }
export type AppRetryError = Extract<AppMessageError, { readonly name: "APIError" }>
export type AppSessionError = AppMessageError | { readonly type: "unknown"; readonly message: string }
type AppPartBase = {
readonly id: string
readonly sessionID: string
readonly messageID: string
}
export type AppFilePart = AppPartBase & {
readonly type: "file"
readonly mime: string
readonly filename?: string
readonly url: string
readonly source?: AppFilePartSource
}
export type AppFilePartSource =
| { readonly type: "file"; readonly path: string; readonly text: { readonly value: string; readonly start: number; readonly end: number } }
| { readonly type: "symbol"; readonly path: string; readonly name: string; readonly kind: number; readonly range: { readonly start: { readonly line: number; readonly character: number }; readonly end: { readonly line: number; readonly character: number } }; readonly text: { readonly value: string; readonly start: number; readonly end: number } }
| { readonly type: "resource"; readonly clientName: string; readonly uri: string; readonly text: { readonly value: string; readonly start: number; readonly end: number } }
export type AppPart =
| (AppPartBase & { readonly type: "text"; readonly text: string; readonly synthetic?: boolean; readonly ignored?: boolean; readonly time?: { readonly start: number; readonly end?: number }; readonly metadata?: Readonly<Record<string, unknown>> })
| (AppPartBase & { readonly type: "reasoning"; readonly text: string; readonly metadata?: Readonly<Record<string, unknown>>; readonly time: { readonly start: number; readonly end?: number } })
| AppFilePart
| (AppPartBase & { readonly type: "agent"; readonly name: string; readonly source?: { readonly value: string; readonly start: number; readonly end: number } })
| (AppPartBase & { readonly type: "tool"; readonly callID: string; readonly tool: string; readonly state: ToolState; readonly metadata?: Readonly<Record<string, unknown>> })
| (AppPartBase & { readonly type: "subtask"; readonly prompt: string; readonly description: string; readonly agent: string; readonly model?: { readonly providerID: string; readonly modelID: string }; readonly command?: string })
| (AppPartBase & { readonly type: "step-start"; readonly snapshot?: string })
| (AppPartBase & { readonly type: "step-finish"; readonly reason: string; readonly snapshot?: string; readonly cost: number; readonly tokens: TokenUsage & { readonly total?: number } })
| (AppPartBase & { readonly type: "snapshot"; readonly snapshot: string })
| (AppPartBase & { readonly type: "patch"; readonly hash: string; readonly files: string[] })
| (AppPartBase & { readonly type: "retry"; readonly attempt: number; readonly error: AppRetryError; readonly time: { readonly created: number } })
| (AppPartBase & { readonly type: "compaction"; readonly auto: boolean })
export type TimelineContent = export type TimelineContent =
| { | {
readonly type: "text" readonly type: "text"
@ -235,11 +362,14 @@ export type TimelineContent =
readonly synthetic?: boolean readonly synthetic?: boolean
readonly ignored?: boolean readonly ignored?: boolean
readonly metadata?: Readonly<Record<string, unknown>> readonly metadata?: Readonly<Record<string, unknown>>
readonly time?: { readonly start: number; readonly end?: number }
} }
| { | {
readonly type: "reasoning" readonly type: "reasoning"
readonly id: string readonly id: string
readonly text: string readonly text: string
readonly metadata?: Readonly<Record<string, unknown>>
readonly time?: { readonly start: number; readonly end?: number }
} }
| { | {
readonly type: "file" readonly type: "file"
@ -261,7 +391,15 @@ export type TimelineContent =
readonly callID?: string readonly callID?: string
readonly tool: string readonly tool: string
readonly state: ToolState readonly state: ToolState
readonly metadata?: Readonly<Record<string, unknown>>
} }
| { readonly type: "subtask"; readonly id: string; readonly prompt: string; readonly description: string; readonly agent: string; readonly model?: ModelRef; readonly command?: string }
| { readonly type: "step-start"; readonly id: string; readonly snapshot?: string }
| { readonly type: "step-finish"; readonly id: string; readonly reason: string; readonly snapshot?: string; readonly cost: number; readonly tokens: TokenUsage & { readonly total?: number } }
| { readonly type: "snapshot"; readonly id: string; readonly snapshot: string }
| { readonly type: "patch"; readonly id: string; readonly hash: string; readonly files: string[] }
| { readonly type: "retry"; readonly id: string; readonly attempt: number; readonly error: AppRetryError; readonly time: { readonly created: number } }
| { readonly type: "compaction"; readonly id: string; readonly auto: boolean }
export type TimelineItem = export type TimelineItem =
| { | {
@ -272,7 +410,10 @@ export type TimelineItem =
readonly content: readonly TimelineContent[] readonly content: readonly TimelineContent[]
readonly agent?: string readonly agent?: string
readonly model?: ModelRef readonly model?: ModelRef
readonly raw?: unknown readonly format?: AppUserMessage["format"]
readonly summary?: AppUserMessage["summary"]
readonly system?: string
readonly tools?: Readonly<Record<string, boolean>>
} }
| { | {
readonly type: "assistant" readonly type: "assistant"
@ -285,17 +426,158 @@ export type TimelineItem =
readonly agent?: string readonly agent?: string
readonly model?: ModelRef readonly model?: ModelRef
readonly tokens?: TokenUsage readonly tokens?: TokenUsage
readonly error?: unknown readonly error?: AppMessageError
readonly raw?: unknown readonly mode?: string
readonly path?: AppAssistantMessage["path"]
readonly cost?: number
readonly structured?: unknown
readonly finish?: string
readonly summary?: boolean
readonly snapshot?: { readonly start?: string; readonly end?: string; readonly files?: readonly string[] }
} }
| { | {
readonly type: "agent-switch" | "model-switch" | "synthetic" | "system" | "skill" | "shell" | "compaction" readonly type: "agent-switch" | "model-switch" | "synthetic" | "system" | "skill" | "shell" | "compaction"
readonly id: string readonly id: string
readonly sessionID: string readonly sessionID: string
readonly created: number readonly created: number
readonly raw?: unknown readonly metadata?: Readonly<Record<string, unknown>>
readonly text?: string
readonly reason?: "auto" | "manual"
readonly summary?: string
readonly recent?: string
readonly callID?: string
readonly command?: string
readonly output?: string
} }
export function timelineMessage(item: TimelineItem): AppMessage | undefined {
if (item.type === "user")
return {
id: item.id,
sessionID: item.sessionID,
role: "user",
time: { created: item.created },
format: item.format,
summary: item.summary,
agent: item.agent ?? "",
model: {
providerID: item.model?.providerID ?? "",
modelID: item.model?.id ?? "",
variant: item.model?.variant,
},
system: item.system,
tools: item.tools,
}
if (item.type !== "assistant") return
return {
id: item.id,
sessionID: item.sessionID,
role: "assistant",
time: { created: item.created, completed: item.completed },
parentID: item.parentID ?? "",
modelID: item.model?.id ?? "",
providerID: item.model?.providerID ?? "",
variant: item.model?.variant,
mode: item.mode ?? item.agent ?? "",
agent: item.agent ?? "",
path: item.path ?? { cwd: "", root: "" },
summary: item.summary,
cost: item.cost ?? 0,
tokens: item.tokens ?? { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
structured: item.structured,
finish: item.finish,
error: item.error,
}
}
export function timelineParts(item: TimelineItem): AppPart[] {
if (item.type !== "user" && item.type !== "assistant") return []
return item.content.map((content): AppPart => {
const base = { id: content.id, sessionID: item.sessionID, messageID: item.id }
if (content.type === "file")
return {
...base,
type: content.type,
mime: content.mime ?? "application/octet-stream",
filename: content.name,
url: content.uri,
source:
content.source?.type === "resource"
? {
type: content.source.type,
clientName: content.source.clientName,
uri: content.source.uri,
text: {
value: content.source.text.text,
start: content.source.text.start,
end: content.source.text.end,
},
}
: content.source?.type === "symbol"
? {
type: content.source.type,
path: content.source.path,
name: content.source.name ?? "",
kind: content.source.kind ?? 0,
range: {
start: { line: 0, character: content.source.text.start },
end: { line: 0, character: content.source.text.end },
},
text: {
value: content.source.text.text,
start: content.source.text.start,
end: content.source.text.end,
},
}
: content.source && {
type: "file",
path: content.source.path,
text: {
value: content.source.text.text,
start: content.source.text.start,
end: content.source.text.end,
},
},
}
if (content.type === "agent")
return {
...base,
type: content.type,
name: content.name,
source: content.source && {
value: content.source.text,
start: content.source.start,
end: content.source.end,
},
}
if (content.type === "tool")
return {
...base,
type: content.type,
callID: content.callID ?? content.id,
tool: content.tool,
state: content.state,
metadata: content.metadata,
}
if (content.type === "subtask")
return {
...base,
...content,
model: content.model && { providerID: content.model.providerID, modelID: content.model.id },
}
if (content.type === "reasoning")
return {
...base,
...content,
time: content.time ?? {
start: item.created,
end: item.type === "assistant" ? item.completed : undefined,
},
}
return { ...base, ...content }
})
}
export type PromptFile = { export type PromptFile = {
readonly uri: string readonly uri: string
readonly name?: string readonly name?: string
@ -312,12 +594,60 @@ export type PromptAgentMention = {
readonly text?: string readonly text?: string
} }
export type PromptInput = { export type PromptPart =
| {
readonly id: string
readonly type: "text"
readonly text: string
readonly synthetic?: boolean
readonly ignored?: boolean
readonly time?: { readonly start: number; readonly end?: number }
readonly metadata?: Readonly<Record<string, unknown>>
}
| {
readonly id: string
readonly type: "file"
readonly mime: string
readonly url: string
readonly filename?: string
readonly source?:
| {
readonly type: "file"
readonly path: string
readonly text: { readonly value: string; readonly start: number; readonly end: number }
}
| {
readonly type: "symbol"
readonly path: string
readonly name: string
readonly kind: number
readonly range: {
readonly start: { readonly line: number; readonly character: number }
readonly end: { readonly line: number; readonly character: number }
}
readonly text: { readonly value: string; readonly start: number; readonly end: number }
}
| {
readonly type: "resource"
readonly clientName: string
readonly uri: string
readonly text: { readonly value: string; readonly start: number; readonly end: number }
}
}
| {
readonly id: string
readonly type: "agent"
readonly name: string
readonly source?: { readonly value: string; readonly start: number; readonly end: number }
}
export type PromptInput = LocationInput & {
readonly sessionID: string readonly sessionID: string
readonly id: string readonly id: string
readonly text: string readonly text: string
readonly files?: readonly PromptFile[] readonly files?: readonly PromptFile[]
readonly agents?: readonly PromptAgentMention[] readonly agents?: readonly PromptAgentMention[]
readonly parts?: readonly PromptPart[]
readonly selection?: { readonly selection?: {
readonly agent?: string readonly agent?: string
readonly model?: ModelRef readonly model?: ModelRef
@ -325,7 +655,7 @@ export type PromptInput = {
readonly delivery?: "steer" | "queue" readonly delivery?: "steer" | "queue"
} }
export type CommandInput = { export type CommandInput = LocationInput & {
readonly sessionID: string readonly sessionID: string
readonly id?: string readonly id?: string
readonly command: string readonly command: string
@ -338,9 +668,19 @@ export type CommandInput = {
export type FileEntry = { export type FileEntry = {
readonly path: string readonly path: string
readonly name?: string
readonly absolute?: string
readonly type: "file" | "directory" readonly type: "file" | "directory"
} }
export type AppFileNode = {
readonly name: string
readonly path: string
readonly absolute: string
readonly type: "file" | "directory"
readonly ignored: boolean
}
export type FileContent = { export type FileContent = {
readonly bytes: Uint8Array readonly bytes: Uint8Array
readonly kind?: "text" | "binary" readonly kind?: "text" | "binary"
@ -355,20 +695,26 @@ export type AppFileDiff = {
readonly status?: "added" | "deleted" | "modified" readonly status?: "added" | "deleted" | "modified"
} }
export type AppSnapshotFileDiff = Omit<AppFileDiff, "file"> & { readonly file?: string }
export type AppVcsFileDiff = AppFileDiff
export type AppPermissionRequest = { export type AppPermissionRequest = {
readonly id: string readonly id: string
readonly sessionID: string readonly sessionID: string
readonly action: string readonly action: string
readonly resources: readonly string[] readonly resources: readonly string[]
readonly metadata?: Readonly<Record<string, unknown>> readonly permission: string
readonly patterns: string[]
readonly always: string[]
readonly metadata: Record<string, unknown>
} }
export type AppQuestion = { export type AppQuestion = {
readonly question: string readonly question: string
readonly header?: string readonly header: string
readonly options: readonly { readonly options: {
readonly label: string readonly label: string
readonly description?: string readonly description: string
}[] }[]
readonly multiple?: boolean readonly multiple?: boolean
readonly custom?: boolean readonly custom?: boolean
@ -377,7 +723,15 @@ export type AppQuestion = {
export type AppQuestionRequest = { export type AppQuestionRequest = {
readonly id: string readonly id: string
readonly sessionID: string readonly sessionID: string
readonly questions: readonly AppQuestion[] readonly questions: AppQuestion[]
}
export type AppQuestionAnswer = string[]
export type AppSessionNotFoundError = {
readonly _tag: "SessionNotFoundError"
readonly sessionID: string
readonly message: string
} }
export type AppMcpStatus = export type AppMcpStatus =
@ -423,20 +777,56 @@ export type AppEventEnvelope = {
export type AppEvent = export type AppEvent =
| { readonly type: "server.connected" } | { readonly type: "server.connected" }
| { readonly type: "server.disposed"; readonly location?: LocationRef } | { readonly type: "server.disposed"; readonly location?: LocationRef }
| { readonly type: "instance.disposed"; readonly location: LocationRef }
| { readonly type: "project.updated"; readonly project: AppProject } | { readonly type: "project.updated"; readonly project: AppProject }
| { readonly type: "session.created"; readonly session: AppSession } | { readonly type: "session.created"; readonly session: AppSession }
| { readonly type: "session.updated"; readonly session: AppSession } | { readonly type: "session.updated"; readonly session: AppSession }
| { readonly type: "session.deleted"; readonly sessionID: string } | { readonly type: "session.deleted"; readonly sessionID: string }
| { readonly type: "session.activity"; readonly sessionID: string; readonly activity: SessionActivity } | { readonly type: "session.moved"; readonly sessionID: string; readonly location: LocationRef }
| { readonly type: "session.error"; readonly sessionID?: string; readonly error?: unknown } | { readonly type: "session.revert"; readonly sessionID: string; readonly revert?: { readonly messageID: string } }
| {
readonly type: "session.activity"
readonly sessionID: string
readonly activity: SessionActivity
readonly item?: TimelineItem
}
| { readonly type: "session.diff"; readonly sessionID: string; readonly diff: readonly AppFileDiff[] }
| { readonly type: "todo.updated"; readonly sessionID: string; readonly todos: readonly AppTodo[] }
| { readonly type: "session.error"; readonly sessionID?: string; readonly error?: AppSessionError }
| { readonly type: "timeline.updated"; readonly item: TimelineItem } | { readonly type: "timeline.updated"; readonly item: TimelineItem }
| {
readonly type: "timeline.content.updated"
readonly sessionID: string
readonly itemID: string
readonly content: TimelineContent
}
| { readonly type: "timeline.removed"; readonly sessionID: string; readonly itemID: string } | { readonly type: "timeline.removed"; readonly sessionID: string; readonly itemID: string }
| {
readonly type: "timeline.delta"
readonly sessionID: string
readonly itemID: string
readonly contentID: string
readonly field: string
readonly delta: string
}
| {
readonly type: "timeline.part.removed"
readonly sessionID: string
readonly itemID: string
readonly contentID: string
}
| { readonly type: "permission.requested"; readonly request: AppPermissionRequest } | { readonly type: "permission.requested"; readonly request: AppPermissionRequest }
| { readonly type: "permission.replied"; readonly sessionID: string; readonly requestID: string } | { readonly type: "permission.replied"; readonly sessionID: string; readonly requestID: string }
| { readonly type: "question.requested"; readonly request: AppQuestionRequest } | { readonly type: "question.requested"; readonly request: AppQuestionRequest }
| { readonly type: "question.replied" | "question.rejected"; readonly sessionID: string; readonly requestID: string } | { readonly type: "question.replied" | "question.rejected"; readonly sessionID: string; readonly requestID: string }
| { readonly type: "file.changed"; readonly path: string; readonly change: "add" | "change" | "unlink" } | { readonly type: "file.changed"; readonly path: string; readonly change: "add" | "change" | "unlink" }
| { readonly type: "vcs.branch.updated"; readonly branch?: string } | { readonly type: "vcs.branch.updated"; readonly branch?: string }
| { readonly type: "worktree.ready"; readonly name: string; readonly branch?: string }
| { readonly type: "worktree.failed"; readonly message: string }
| { readonly type: "lsp.updated" }
| { readonly type: "reference.updated" }
| { readonly type: "mcp.updated"; readonly server?: string }
| { readonly type: "provider.updated" }
| { readonly type: "pty.exited"; readonly ptyID: string } | { readonly type: "pty.exited"; readonly ptyID: string }
| { readonly type: "unknown"; readonly raw: unknown } | { readonly type: "unknown"; readonly raw: unknown }
@ -445,10 +835,13 @@ export interface HealthApi {
} }
export interface ProjectApi { export interface ProjectApi {
list(options?: RequestOptions): Promise<readonly AppProject[]>
current(input?: LocationInput, options?: RequestOptions): Promise<CurrentProject> current(input?: LocationInput, options?: RequestOptions): Promise<CurrentProject>
} }
export interface ProjectListCapability {
list(options?: RequestOptions): Promise<readonly AppProject[]>
}
export interface CatalogApi { export interface CatalogApi {
providers(input?: LocationInput, options?: RequestOptions): Promise<ProviderCatalog> providers(input?: LocationInput, options?: RequestOptions): Promise<ProviderCatalog>
agents(input?: LocationInput, options?: RequestOptions): Promise<readonly AppAgent[]> agents(input?: LocationInput, options?: RequestOptions): Promise<readonly AppAgent[]>
@ -469,7 +862,21 @@ export interface SessionApi {
options?: RequestOptions, options?: RequestOptions,
): Promise<AppSession> ): Promise<AppSession>
get(input: LocationInput & { readonly sessionID: string }, options?: RequestOptions): Promise<AppSession> get(input: LocationInput & { readonly sessionID: string }, options?: RequestOptions): Promise<AppSession>
remove(input: LocationInput & { readonly sessionID: string }, options?: RequestOptions): Promise<void> interrupt(input: LocationInput & { readonly sessionID: string }, options?: RequestOptions): Promise<void>
activity(input?: LocationInput, options?: RequestOptions): Promise<Readonly<Record<string, SessionActivity>>>
history(
input: LocationInput & { readonly sessionID: string; readonly limit?: number; readonly cursor?: string },
options?: RequestOptions,
): Promise<Page<TimelineItem>>
message(
input: LocationInput & { readonly sessionID: string; readonly messageID: string },
options?: RequestOptions,
): Promise<TimelineItem>
prompt(input: PromptInput, options?: RequestOptions): Promise<void>
}
export interface SessionActionsV1Capability {
remove(input: LocationInput & { readonly sessionID: string }, options?: RequestOptions): Promise<boolean>
fork( fork(
input: LocationInput & { readonly sessionID: string; readonly messageID?: string }, input: LocationInput & { readonly sessionID: string; readonly messageID?: string },
options?: RequestOptions, options?: RequestOptions,
@ -478,22 +885,11 @@ export interface SessionApi {
input: LocationInput & { readonly sessionID: string; readonly title: string }, input: LocationInput & { readonly sessionID: string; readonly title: string },
options?: RequestOptions, options?: RequestOptions,
): Promise<void> ): Promise<void>
interrupt(input: LocationInput & { readonly sessionID: string }, options?: RequestOptions): Promise<void>
activity(input?: LocationInput, options?: RequestOptions): Promise<Readonly<Record<string, SessionActivity>>>
history(
input: { readonly sessionID: string; readonly limit?: number; readonly cursor?: string },
options?: RequestOptions,
): Promise<Page<TimelineItem>>
message(
input: { readonly sessionID: string; readonly messageID: string },
options?: RequestOptions,
): Promise<TimelineItem>
prompt(input: PromptInput, options?: RequestOptions): Promise<void>
command(input: CommandInput, options?: RequestOptions): Promise<void> command(input: CommandInput, options?: RequestOptions): Promise<void>
} }
export interface FileApi { export interface FileApi {
list(input: LocationInput & { readonly path?: string }, options?: RequestOptions): Promise<readonly FileEntry[]> list(input: LocationInput & { readonly path?: string }, options?: RequestOptions): Promise<readonly AppFileNode[]>
find( find(
input: LocationInput & { input: LocationInput & {
readonly query: string readonly query: string
@ -508,7 +904,7 @@ export interface FileApi {
export interface PermissionApi { export interface PermissionApi {
pending(input?: LocationInput, options?: RequestOptions): Promise<readonly AppPermissionRequest[]> pending(input?: LocationInput, options?: RequestOptions): Promise<readonly AppPermissionRequest[]>
reply( reply(
input: { input: LocationInput & {
readonly sessionID: string readonly sessionID: string
readonly requestID: string readonly requestID: string
readonly reply: "once" | "always" | "reject" readonly reply: "once" | "always" | "reject"
@ -521,14 +917,17 @@ export interface PermissionApi {
export interface QuestionApi { export interface QuestionApi {
pending(input?: LocationInput, options?: RequestOptions): Promise<readonly AppQuestionRequest[]> pending(input?: LocationInput, options?: RequestOptions): Promise<readonly AppQuestionRequest[]>
reply( reply(
input: { input: LocationInput & {
readonly sessionID: string readonly sessionID: string
readonly requestID: string readonly requestID: string
readonly answers: readonly (readonly string[])[] readonly answers: readonly (readonly string[])[]
}, },
options?: RequestOptions, options?: RequestOptions,
): Promise<void> ): Promise<void>
reject(input: { readonly sessionID: string; readonly requestID: string }, options?: RequestOptions): Promise<void> reject(
input: LocationInput & { readonly sessionID: string; readonly requestID: string },
options?: RequestOptions,
): Promise<void>
} }
export interface VcsApi { export interface VcsApi {
@ -598,11 +997,8 @@ export interface CommonClient {
readonly files: FileApi readonly files: FileApi
readonly permissions: PermissionApi readonly permissions: PermissionApi
readonly questions: QuestionApi readonly questions: QuestionApi
readonly vcs: VcsApi
readonly mcp: McpApi
readonly pty: PtyApi readonly pty: PtyApi
readonly events: EventApi readonly events: EventApi
disposeLocation(input: LocationInput, options?: RequestOptions): Promise<void>
} }
export type AppProviderConfig = { export type AppProviderConfig = {
@ -655,6 +1051,8 @@ export type ProviderAuthMethod = {
readonly prompts?: readonly ProviderAuthPrompt[] readonly prompts?: readonly ProviderAuthPrompt[]
} }
export type AppProviderAuthResponse = Readonly<Record<string, readonly ProviderAuthMethod[]>>
export type ProviderAuthorization = { export type ProviderAuthorization = {
readonly url: string readonly url: string
readonly method: "auto" | "code" readonly method: "auto" | "code"
@ -710,14 +1108,15 @@ export type AppWorktree = {
export interface WorktreesV1Capability { export interface WorktreesV1Capability {
list(input: LocationInput, options?: RequestOptions): Promise<readonly string[]> list(input: LocationInput, options?: RequestOptions): Promise<readonly string[]>
create(input: LocationInput, options?: RequestOptions): Promise<AppWorktree> create(input: LocationInput, options?: RequestOptions): Promise<AppWorktree>
remove(input: LocationInput & { readonly directory: string }, options?: RequestOptions): Promise<void> remove(input: LocationInput & { readonly directory: string }, options?: RequestOptions): Promise<boolean>
reset(input: LocationInput & { readonly directory: string }, options?: RequestOptions): Promise<void> reset(input: LocationInput & { readonly directory: string }, options?: RequestOptions): Promise<boolean>
} }
export type AppTodo = { export type AppTodo = {
readonly id?: string readonly id?: string
readonly content: string readonly content: string
readonly status: "pending" | "in_progress" | "completed" | "cancelled" | (string & {}) readonly status: "pending" | "in_progress" | "completed" | "cancelled" | (string & {})
readonly priority: "high" | "medium" | "low" | (string & {})
} }
export type LegacySessionShellInput = LocationInput & { export type LegacySessionShellInput = LocationInput & {
@ -729,14 +1128,23 @@ export type LegacySessionShellInput = LocationInput & {
} }
export interface SessionExtrasV1Capability { export interface SessionExtrasV1Capability {
archive(sessionID: string, archivedAt: number, options?: RequestOptions): Promise<void> archive(
share(sessionID: string, options?: RequestOptions): Promise<string> input: LocationInput & { readonly sessionID: string; readonly archivedAt: number },
unshare(sessionID: string, options?: RequestOptions): Promise<void> options?: RequestOptions,
diff(sessionID: string, options?: RequestOptions): Promise<readonly AppFileDiff[]> ): Promise<void>
todos(sessionID: string, options?: RequestOptions): Promise<readonly AppTodo[]> share(input: LocationInput & { readonly sessionID: string }, options?: RequestOptions): Promise<string>
summarize(sessionID: string, model: ModelRef, options?: RequestOptions): Promise<void> unshare(input: LocationInput & { readonly sessionID: string }, options?: RequestOptions): Promise<void>
revert(sessionID: string, messageID: string, options?: RequestOptions): Promise<void> diff(input: LocationInput & { readonly sessionID: string }, options?: RequestOptions): Promise<readonly AppFileDiff[]>
clearRevert(sessionID: string, options?: RequestOptions): Promise<void> todos(input: LocationInput & { readonly sessionID: string }, options?: RequestOptions): Promise<readonly AppTodo[]>
summarize(
input: LocationInput & { readonly sessionID: string; readonly model: ModelRef },
options?: RequestOptions,
): Promise<void>
revert(
input: LocationInput & { readonly sessionID: string; readonly messageID: string },
options?: RequestOptions,
): Promise<AppSession>
clearRevert(input: LocationInput & { readonly sessionID: string }, options?: RequestOptions): Promise<AppSession>
shell(input: LegacySessionShellInput, options?: RequestOptions): Promise<void> shell(input: LegacySessionShellInput, options?: RequestOptions): Promise<void>
} }
@ -784,7 +1192,18 @@ export type DecoratedFileContent = {
readonly encoding?: "base64" readonly encoding?: "base64"
readonly mimeType?: string readonly mimeType?: string
readonly patch?: { readonly patch?: {
readonly hunks: readonly { readonly lines: readonly string[] }[] readonly oldFileName: string
readonly newFileName: string
readonly oldHeader?: string
readonly newHeader?: string
readonly hunks: readonly {
readonly oldStart: number
readonly oldLines: number
readonly newStart: number
readonly newLines: number
readonly lines: readonly string[]
}[]
readonly index?: string
} }
} }
@ -793,11 +1212,28 @@ export interface DecoratedFileCapability {
} }
export type PtyTicket = { export type PtyTicket = {
readonly ticket: string readonly status: number
readonly ticket?: string
}
export type PtyTransportConfig = {
readonly baseUrl: string
readonly fetch: typeof globalThis.fetch
readonly username?: string
readonly password?: string
readonly sameOrigin: boolean
readonly authToken: boolean
} }
export interface PtyTransportCapability { export interface PtyTransportCapability {
connectToken(input: LocationInput & { readonly ptyID: string }, options?: RequestOptions): Promise<PtyTicket> connectToken(input: LocationInput & { readonly ptyID: string }, options?: RequestOptions): Promise<PtyTicket>
exists(input: LocationInput & { readonly ptyID: string }, options?: RequestOptions): Promise<boolean>
connectURL(input: {
readonly ptyID: string
readonly location: LocationRef
readonly cursor: number
readonly ticket?: string
}): URL
} }
export type ShellOption = { export type ShellOption = {
@ -811,6 +1247,7 @@ export interface ShellDiscoveryCapability {
} }
export interface RuntimeV1Capability { export interface RuntimeV1Capability {
disposeLocation(input: LocationInput, options?: RequestOptions): Promise<void>
disposeAll(options?: RequestOptions): Promise<void> disposeAll(options?: RequestOptions): Promise<void>
} }
@ -833,6 +1270,11 @@ export type IntegrationMethod =
export type IntegrationConnection = { export type IntegrationConnection = {
readonly id: string readonly id: string
readonly label: string readonly label: string
readonly kind: "credential" | "environment"
}
export function credentialConnectionIDs(connections: readonly IntegrationConnection[]) {
return connections.filter((connection) => connection.kind === "credential").map((connection) => connection.id)
} }
export type IntegrationInfo = { export type IntegrationInfo = {
@ -914,18 +1356,37 @@ export type InstructionEntry = {
} }
export interface SessionExtrasV2Capability { export interface SessionExtrasV2Capability {
switchAgent(input: { readonly sessionID: string; readonly agent: string }, options?: RequestOptions): Promise<void> diff?(
switchModel(input: { readonly sessionID: string; readonly model: ModelRef }, options?: RequestOptions): Promise<void> input: LocationInput & { readonly sessionID: string },
move( options?: RequestOptions,
input: { readonly sessionID: string; readonly directory: string; readonly moveChanges?: boolean }, ): Promise<readonly AppFileDiff[]>
todos?(
input: LocationInput & { readonly sessionID: string },
options?: RequestOptions,
): Promise<readonly AppTodo[]>
switchAgent(
input: LocationInput & { readonly sessionID: string; readonly agent: string },
options?: RequestOptions, options?: RequestOptions,
): Promise<void> ): Promise<void>
skill( switchModel(
input: { readonly sessionID: string; readonly id?: string; readonly skill: string; readonly resume?: boolean }, input: LocationInput & { readonly sessionID: string; readonly model: ModelRef },
options?: RequestOptions, options?: RequestOptions,
): Promise<void> ): Promise<void>
synthetic( move?(
input: { input: LocationInput & { readonly sessionID: string; readonly directory: string; readonly moveChanges?: boolean },
options?: RequestOptions,
): Promise<void>
skill?(
input: LocationInput & {
readonly sessionID: string
readonly id?: string
readonly skill: string
readonly resume?: boolean
},
options?: RequestOptions,
): Promise<void>
synthetic?(
input: LocationInput & {
readonly sessionID: string readonly sessionID: string
readonly id?: string readonly id?: string
readonly text: string readonly text: string
@ -936,40 +1397,50 @@ export interface SessionExtrasV2Capability {
}, },
options?: RequestOptions, options?: RequestOptions,
): Promise<PendingSessionInput> ): Promise<PendingSessionInput>
shell( shell?(
input: { readonly sessionID: string; readonly id?: string; readonly command: string }, input: LocationInput & { readonly sessionID: string; readonly id?: string; readonly command: string },
options?: RequestOptions, options?: RequestOptions,
): Promise<void> ): Promise<void>
compact( compact?(
input: { readonly sessionID: string; readonly id?: string }, input: LocationInput & { readonly sessionID: string; readonly id?: string },
options?: RequestOptions, options?: RequestOptions,
): Promise<PendingSessionInput> ): Promise<PendingSessionInput>
wait(input: { readonly sessionID: string }, options?: RequestOptions): Promise<void> wait(input: LocationInput & { readonly sessionID: string }, options?: RequestOptions): Promise<void>
context(input: { readonly sessionID: string }, options?: RequestOptions): Promise<readonly TimelineItem[]> context(
pending(input: { readonly sessionID: string }, options?: RequestOptions): Promise<readonly PendingSessionInput[]> input: LocationInput & { readonly sessionID: string },
instructionEntries( options?: RequestOptions,
input: { readonly sessionID: string }, ): Promise<readonly TimelineItem[]>
pending?(
input: LocationInput & { readonly sessionID: string },
options?: RequestOptions,
): Promise<readonly PendingSessionInput[]>
instructionEntries?(
input: LocationInput & { readonly sessionID: string },
options?: RequestOptions, options?: RequestOptions,
): Promise<readonly InstructionEntry[]> ): Promise<readonly InstructionEntry[]>
putInstructionEntry( putInstructionEntry?(
input: { readonly sessionID: string; readonly key: string; readonly value: JsonValue }, input: LocationInput & { readonly sessionID: string; readonly key: string; readonly value: JsonValue },
options?: RequestOptions, options?: RequestOptions,
): Promise<void> ): Promise<void>
removeInstructionEntry( removeInstructionEntry?(
input: { readonly sessionID: string; readonly key: string }, input: LocationInput & { readonly sessionID: string; readonly key: string },
options?: RequestOptions, options?: RequestOptions,
): Promise<void> ): Promise<void>
log( log(
input: { readonly sessionID: string; readonly after?: number; readonly follow?: boolean }, input: LocationInput & { readonly sessionID: string; readonly after?: number; readonly follow?: boolean },
options?: RequestOptions, options?: RequestOptions,
): AsyncIterable<SessionLogItem> ): AsyncIterable<SessionLogItem>
background(input: { readonly sessionID: string }, options?: RequestOptions): Promise<void> background?(input: LocationInput & { readonly sessionID: string }, options?: RequestOptions): Promise<void>
stageRevert( stageRevert(
input: { readonly sessionID: string; readonly messageID: string; readonly files?: readonly string[] }, input: LocationInput & {
readonly sessionID: string
readonly messageID: string
readonly files?: readonly string[]
},
options?: RequestOptions, options?: RequestOptions,
): Promise<{ readonly messageID: string }> ): Promise<{ readonly messageID: string }>
clearRevert(input: { readonly sessionID: string }, options?: RequestOptions): Promise<void> clearRevert(input: LocationInput & { readonly sessionID: string }, options?: RequestOptions): Promise<void>
commitRevert(input: { readonly sessionID: string }, options?: RequestOptions): Promise<void> commitRevert(input: LocationInput & { readonly sessionID: string }, options?: RequestOptions): Promise<void>
} }
export type ProjectDirectory = { export type ProjectDirectory = {
@ -978,7 +1449,7 @@ export type ProjectDirectory = {
} }
export interface ProjectCopiesV2Capability { export interface ProjectCopiesV2Capability {
directories( directories?(
input: LocationInput & { readonly projectID: string }, input: LocationInput & { readonly projectID: string },
options?: RequestOptions, options?: RequestOptions,
): Promise<readonly ProjectDirectory[]> ): Promise<readonly ProjectDirectory[]>
@ -1161,6 +1632,9 @@ export interface DiscoveryV2Capability {
} }
export interface Capabilities { export interface Capabilities {
readonly projectList?: ProjectListCapability
readonly vcs?: VcsApi
readonly mcp?: McpApi
readonly configuration?: ConfigurationCapability readonly configuration?: ConfigurationCapability
readonly providerAuthV1?: ProviderAuthV1Capability readonly providerAuthV1?: ProviderAuthV1Capability
readonly integrationsV2?: IntegrationsV2Capability readonly integrationsV2?: IntegrationsV2Capability
@ -1168,6 +1642,7 @@ export interface Capabilities {
readonly worktreesV1?: WorktreesV1Capability readonly worktreesV1?: WorktreesV1Capability
readonly projectCopiesV2?: ProjectCopiesV2Capability readonly projectCopiesV2?: ProjectCopiesV2Capability
readonly sessionExtrasV1?: SessionExtrasV1Capability readonly sessionExtrasV1?: SessionExtrasV1Capability
readonly sessionActionsV1?: SessionActionsV1Capability
readonly sessionExtrasV2?: SessionExtrasV2Capability readonly sessionExtrasV2?: SessionExtrasV2Capability
readonly lsp?: LspCapability readonly lsp?: LspCapability
readonly mcpControl?: McpControlCapability readonly mcpControl?: McpControlCapability

View file

@ -1,7 +1,7 @@
import { Binary } from "@opencode-ai/core/util/binary" import { Binary } from "@opencode-ai/core/util/binary"
import type { Message, Part, Session } from "@opencode-ai/sdk/v2/client" import type { AppMessage, AppPart, AppSession } from "./backend"
import { createMemo } from "solid-js" import { createMemo } from "solid-js"
import { produce, reconcile, type SetStoreFunction } from "solid-js/store" import { createStore, produce, reconcile, type SetStoreFunction } from "solid-js/store"
import type { createServerSdkContext } from "./server-sdk" import type { createServerSdkContext } from "./server-sdk"
import type { createServerSyncContextInner } from "./server-sync" import type { createServerSyncContextInner } from "./server-sync"
import type { State } from "./global-sync/types" import type { State } from "./global-sync/types"
@ -23,8 +23,8 @@ export const createDirSyncContext = (
serverSync: ReturnType<typeof createServerSyncContextInner>, serverSync: ReturnType<typeof createServerSyncContextInner>,
serverSDK: ReturnType<typeof createServerSdkContext>, serverSDK: ReturnType<typeof createServerSdkContext>,
) => { ) => {
const client = serverSDK.createClient({ directory, throwOnError: true })
const current = createMemo(() => serverSync.child(directory, { mcp: true })) const current = createMemo(() => serverSync.child(directory, { mcp: true }))
const [sessionPage, setSessionPage] = createStore({ cursor: undefined as string | undefined, complete: false })
const absolute = (path: string) => (current()[0].path.directory + "/" + path).replace("//", "/") const absolute = (path: string) => (current()[0].path.directory + "/" + path).replace("//", "/")
const data = new Proxy({} as State, { const data = new Proxy({} as State, {
get(_, property: keyof State) { get(_, property: keyof State) {
@ -72,7 +72,7 @@ export const createDirSyncContext = (
if (match.found) return serverSync.data.project[match.index] if (match.found) return serverSync.data.project[match.index]
}, },
session: { session: {
remember(session: Session) { remember(session: AppSession) {
serverSync.session.remember(session) serverSync.session.remember(session)
index(session.id) index(session.id)
}, },
@ -81,7 +81,7 @@ export const createDirSyncContext = (
if (session?.directory === directory) return session if (session?.directory === directory) return session
}, },
optimistic: { optimistic: {
add(input: { directory?: string; sessionID: string; message: Message; parts: Part[] }) { add(input: { directory?: string; sessionID: string; message: AppMessage; parts: AppPart[] }) {
serverSync.session.optimistic.add(input) serverSync.session.optimistic.add(input)
}, },
remove(input: { directory?: string; sessionID: string; messageID: string }) { remove(input: { directory?: string; sessionID: string; messageID: string }) {
@ -91,7 +91,7 @@ export const createDirSyncContext = (
addOptimisticMessage(input: { addOptimisticMessage(input: {
sessionID: string sessionID: string
messageID: string messageID: string
parts: Part[] parts: AppPart[]
agent: string agent: string
model: { providerID: string; modelID: string } model: { providerID: string; modelID: string }
variant?: string variant?: string
@ -110,7 +110,7 @@ export const createDirSyncContext = (
}) })
}, },
async sync(sessionID: string, options?: { force?: boolean }) { async sync(sessionID: string, options?: { force?: boolean }) {
await serverSync.session.sync(sessionID, options) await serverSync.session.sync(sessionID, { ...options, location: { directory } })
index(sessionID) index(sessionID)
}, },
diff: serverSync.session.diff, diff: serverSync.session.diff,
@ -121,17 +121,25 @@ export const createDirSyncContext = (
fetch: async (count = 10) => { fetch: async (count = 10) => {
const [store, setStore] = current() const [store, setStore] = current()
setStore("limit", (value) => value + count) setStore("limit", (value) => value + count)
const response = await client.session.list() const backend = await serverSDK.backend
const sessions = (response.data ?? []) const response = await backend.common.sessions.list({
.filter((session) => !!session?.id) location: { directory },
roots: true,
limit: count,
cursor: sessionPage.cursor,
})
const sessions = [...new Map([...store.session, ...response.items].map((session) => [session.id, session])).values()]
.sort((a, b) => cmp(a.id, b.id)) .sort((a, b) => cmp(a.id, b.id))
.slice(0, store.limit)
sessions.forEach(serverSync.session.remember) sessions.forEach(serverSync.session.remember)
setStore("session", reconcile(sessions, { key: "id" })) setStore("session", reconcile(sessions, { key: "id" }))
setSessionPage({ cursor: response.older, complete: !response.older })
}, },
more: createMemo(() => current()[0].session.length >= current()[0].limit), more: createMemo(() => !sessionPage.complete),
archive: async (sessionID: string) => { archive: async (sessionID: string) => {
await serverSDK.client.session.update({ sessionID, time: { archived: Date.now() } }) const backend = await serverSDK.backend
const capability = backend.capabilities.sessionExtrasV1
if (!capability) throw new Error("Server does not support session archiving")
await capability.archive({ sessionID, archivedAt: Date.now(), location: { directory } })
current()[1]( current()[1](
"session", "session",
produce((draft) => { produce((draft) => {

View file

@ -79,10 +79,16 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({
const tree = createFileTreeStore({ const tree = createFileTreeStore({
scope, scope,
normalizeDir: path.normalizeDir, normalizeDir: path.normalizeDir,
list: (dir) => list: async (dir) =>
sdk() [...(await (await sdk().backend).common.files.list({ location: { directory: scope() }, path: dir }))].map(
.client.file.list({ path: dir }) (node) => ({
.then((x) => x.data ?? []), name: node.name ?? getFilename(node.path),
path: node.path,
absolute: node.absolute ?? node.path,
type: node.type,
ignored: node.ignored,
}),
),
onError: (message) => { onError: (message) => {
showToast({ showToast({
variant: "error", variant: "error",
@ -181,10 +187,18 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({
setLoading(file) setLoading(file)
const promise = sdk() const promise = sdk()
.client.file.read({ path: file }) .backend.then(async (client) => {
.then((x) => {
if (scope() !== directory) return if (scope() !== directory) return
const content = x.data const content: FileState["content"] = client.capabilities.decoratedFiles
? await client.capabilities.decoratedFiles.read({ location: { directory }, path: file })
: await client.common.files.read({ location: { directory }, path: file }).then((value) => {
if (value.kind !== "text") return
return {
type: "text" as const,
content: new TextDecoder().decode(value.bytes),
mimeType: value.mimeType,
}
})
setLoaded(file, content) setLoaded(file, content)
if (!content) return if (!content) return
@ -205,9 +219,19 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({
const search = (query: string, dirs: "true" | "false", options?: { limit?: number; signal?: AbortSignal }) => const search = (query: string, dirs: "true" | "false", options?: { limit?: number; signal?: AbortSignal }) =>
sdk() sdk()
.client.find.files({ query, dirs, limit: options?.limit }, { signal: options?.signal }) .backend.then((client) =>
client.common.files.find(
{
location: { directory: scope() },
query,
type: dirs === "true" ? undefined : "file",
limit: options?.limit,
},
{ signal: options?.signal },
),
)
.then( .then(
(x) => (x.data ?? []).map(path.normalize), (items) => items.map((item) => path.normalize(item.path)),
(error) => { (error) => {
if (options?.signal?.aborted) throw error if (options?.signal?.aborted) throw error
return [] return []

View file

@ -1,4 +1,4 @@
import type { FileContent } from "@opencode-ai/sdk/v2" import type { DecoratedFileContent as FileContent } from "../backend"
const MAX_FILE_CONTENT_ENTRIES = 40 const MAX_FILE_CONTENT_ENTRIES = 40
const MAX_FILE_CONTENT_BYTES = 20 * 1024 * 1024 const MAX_FILE_CONTENT_BYTES = 20 * 1024 * 1024

View file

@ -1,5 +1,5 @@
import { createStore, produce, reconcile } from "solid-js/store" import { createStore, produce, reconcile } from "solid-js/store"
import type { FileNode } from "@opencode-ai/sdk/v2" import type { AppFileNode as FileNode } from "../backend"
type DirectoryState = { type DirectoryState = {
expanded: boolean expanded: boolean

View file

@ -1,4 +1,4 @@
import type { FileContent } from "@opencode-ai/sdk/v2" import type { DecoratedFileContent as FileContent } from "../backend"
export type FileSelection = { export type FileSelection = {
startLine: number startLine: number

View file

@ -1,8 +1,10 @@
import type { FileNode } from "@opencode-ai/sdk/v2" import type { AppFileNode as FileNode } from "../backend"
type WatcherEvent = { type WatcherEvent = {
type: string type: string
properties: unknown path?: string
change?: string
properties?: unknown
} }
type WatcherOps = { type WatcherOps = {
@ -16,11 +18,11 @@ type WatcherOps = {
} }
export function invalidateFromWatcher(event: WatcherEvent, ops: WatcherOps) { export function invalidateFromWatcher(event: WatcherEvent, ops: WatcherOps) {
if (event.type !== "filesystem.changed") return if (event.type !== "filesystem.changed" && event.type !== "file.changed" && event.type !== "file.watcher.updated") return
const props = const props =
typeof event.properties === "object" && event.properties ? (event.properties as Record<string, unknown>) : undefined typeof event.properties === "object" && event.properties ? (event.properties as Record<string, unknown>) : undefined
const rawPath = typeof props?.file === "string" ? props.file : undefined const rawPath = event.path ?? (typeof props?.file === "string" ? props.file : undefined)
const kind = typeof props?.event === "string" ? props.event : undefined const kind = event.change ?? (typeof props?.event === "string" ? props.event : undefined)
if (!rawPath) return if (!rawPath) return
if (!kind) return if (!kind) return

View file

@ -1,14 +1,15 @@
import { describe, expect, test } from "bun:test" import { describe, expect, test } from "bun:test"
import { createStore } from "solid-js/store" import { createStore } from "solid-js/store"
import { QueryClient } from "@tanstack/solid-query" import { QueryClient } from "@tanstack/solid-query"
import type { Config, OpencodeClient, Project, Session } from "@opencode-ai/sdk/v2/client" import type { AppClient, AppProject as Project, AppSession as Session } from "../backend"
import type { NormalizedProviderListResponse } from "@opencode-ai/session-ui/context" import { createAppClient } from "../backend.test-fixture"
import type { ProviderStore } from "./types"
import { bootstrapDirectory, loadPathQuery, loadProvidersQuery } from "./bootstrap" import { bootstrapDirectory, loadPathQuery, loadProvidersQuery } from "./bootstrap"
import type { State, VcsCache } from "./types" import type { State, VcsCache } from "./types"
import { createServerSession } from "../server-session" import { createServerSession } from "../server-session"
import { ServerScope } from "@/utils/server-scope" import { ServerScope } from "@/utils/server-scope"
const provider = { all: new Map(), connected: [], default: {} } satisfies NormalizedProviderListResponse const provider = { all: new Map(), connected: [], default: {} } satisfies ProviderStore
function directoryState() { function directoryState() {
return createStore<State>({ return createStore<State>({
@ -30,6 +31,7 @@ function directoryState() {
return this.session_status[id]?.type !== "idle" return this.session_status[id]?.type !== "idle"
}, },
session_diff: {}, session_diff: {},
todo: {},
permission: {}, permission: {},
question: {}, question: {},
mcp_ready: true, mcp_ready: true,
@ -55,33 +57,35 @@ describe("bootstrapDirectory", () => {
scope: ServerScope.local, scope: ServerScope.local,
mcp: false, mcp: false,
global: { global: {
config: {} satisfies Config, config: {},
path: { state: "", config: "", worktree: "/project", directory: "/project", home: "/home" }, path: { state: "", config: "", worktree: "/project", directory: "/project", home: "/home" },
project: [{ id: "project", worktree: "/project" } as Project], project: [{ id: "project", worktree: "/project" } as Project],
provider, provider,
}, },
sdk: { backend: {
app: { agents: async () => ({ data: [{ name: "build", mode: "primary" }] }) }, version: "v1",
config: { get: async () => ({ data: {} }) }, capabilities: {
session: { status: async () => ({ data: {} }) }, configuration: { get: async () => ({}), getGlobal: async () => ({}), updateGlobal: async () => {} },
vcs: { get: async () => ({ data: undefined }) }, vcsInfo: { get: async () => ({}) },
command: {
list: async () => {
mcpReads.push("command")
return { data: [] }
},
}, },
permission: { list: async () => ({ data: [] }) }, common: {
question: { list: async () => ({ data: [] }) }, catalog: {
v2: { reference: { list: async () => ({ data: { data: [] } }) } }, agents: async () => [{ id: "build", name: "build", mode: "primary", hidden: false }],
mcp: { providers: async () => ({ providers: new Map(), connected: [], defaults: {} }),
status: async () => {
mcpReads.push("status")
return { data: {} }
}, },
sessions: { activity: async () => ({}) },
projects: { current: async () => ({ id: "project", directory: "/project" }) },
commands: {
list: async () => {
mcpReads.push("command")
return []
},
},
permissions: { pending: async () => [] },
questions: { pending: async () => [] },
references: { list: async () => [] },
}, },
provider: { list: async () => ({ data: { all: [], connected: [], default: {} } }) }, } as unknown as AppClient,
} as unknown as OpencodeClient,
store, store,
setStore, setStore,
vcsCache: { setStore() {} } as unknown as VcsCache, vcsCache: { setStore() {} } as unknown as VcsCache,
@ -101,22 +105,26 @@ describe("bootstrapDirectory", () => {
test("seeds session status even while warming session info stalls", async () => { test("seeds session status even while warming session info stalls", async () => {
const [store, setStore] = directoryState() const [store, setStore] = directoryState()
const stalled = Promise.withResolvers<never>() const stalled = Promise.withResolvers<never>()
const client = { const backend = createAppClient({
app: { agents: async () => ({ data: [{ name: "build", mode: "primary" }] }) }, version: "v1",
config: { get: async () => ({ data: {} }) }, capabilities: {
session: { configuration: { get: async () => ({}), getGlobal: async () => ({}), updateGlobal: async () => {} },
status: async () => ({ data: { ses_busy: { type: "busy" } } }), vcsInfo: { get: async () => ({}) },
get: () => stalled.promise,
}, },
vcs: { get: async () => ({ data: undefined }) }, common: {
command: { list: async () => ({ data: [] }) }, catalog: {
permission: { list: async () => ({ data: [] }) }, agents: async () => [{ id: "build", name: "build", mode: "primary", hidden: false }],
question: { list: async () => ({ data: [] }) }, providers: async () => ({ providers: new Map(), connected: [], defaults: {} }),
v2: { reference: { list: async () => ({ data: { data: [] } }) } }, },
mcp: { status: async () => ({ data: {} }) }, sessions: { activity: async () => ({ ses_busy: { type: "busy" } }), get: () => stalled.promise },
provider: { list: async () => ({ data: { all: [], connected: [], default: {} } }) }, projects: { current: async () => ({ id: "project", directory: "/project" }) },
} as unknown as OpencodeClient commands: { list: async () => [] },
const session = createServerSession(client) permissions: { pending: async () => [] },
questions: { pending: async () => [] },
references: { list: async () => [] },
},
})
const session = createServerSession(backend)
const stale: Session = { const stale: Session = {
id: "ses_stale", id: "ses_stale",
slug: "ses_stale", slug: "ses_stale",
@ -134,12 +142,12 @@ describe("bootstrapDirectory", () => {
scope: ServerScope.local, scope: ServerScope.local,
mcp: false, mcp: false,
global: { global: {
config: {} satisfies Config, config: {},
path: { state: "", config: "", worktree: "/project", directory: "/project", home: "/home" }, path: { state: "", config: "", worktree: "/project", directory: "/project", home: "/home" },
project: [{ id: "project", worktree: "/project" } as Project], project: [{ id: "project", worktree: "/project" } as Project],
provider, provider,
}, },
sdk: client, backend,
store, store,
setStore, setStore,
vcsCache: { setStore() {} } as unknown as VcsCache, vcsCache: { setStore() {} } as unknown as VcsCache,
@ -161,12 +169,12 @@ describe("bootstrapDirectory", () => {
describe("query keys", () => { describe("query keys", () => {
test("partitions identical directories by server scope", () => { test("partitions identical directories by server scope", () => {
const client = {} as OpencodeClient const backend = Promise.resolve({} as AppClient)
const remote = "https://debian.example" as typeof ServerScope.local const remote = "https://debian.example" as typeof ServerScope.local
expect([...loadPathQuery(ServerScope.local, "/repo", client).queryKey]).toEqual(["local", "/repo", "path"]) expect([...loadPathQuery(ServerScope.local, "/repo", backend).queryKey]).toEqual(["local", "/repo", "path"])
expect([...loadPathQuery(remote, "/repo", client).queryKey]).toEqual(["https://debian.example", "/repo", "path"]) expect([...loadPathQuery(remote, "/repo", backend).queryKey]).toEqual(["https://debian.example", "/repo", "path"])
expect([...loadProvidersQuery(remote, null, client).queryKey]).toEqual([ expect([...loadProvidersQuery(remote, null, backend).queryKey]).toEqual([
"https://debian.example", "https://debian.example",
null, null,
"providers", "providers",

View file

@ -1,35 +1,35 @@
import type { import type {
Config, AppClient,
OpencodeClient, AppConfig,
Path, AppPathInfo,
PermissionRequest, AppPermissionRequest,
Project, AppProject,
ProviderAuthResponse, AppProviderAuthResponse,
QuestionRequest, AppQuestionRequest,
ReferenceInfo, AppReference,
Session, AppSession,
} from "@opencode-ai/sdk/v2/client" ProviderCatalog,
} from "../backend"
import { showToast } from "@/utils/toast" import { showToast } from "@/utils/toast"
import { getFilename } from "@opencode-ai/core/util/path" import { getFilename } from "@opencode-ai/core/util/path"
import { retry } from "@opencode-ai/core/util/retry" import { retry } from "@opencode-ai/core/util/retry"
import { batch } from "solid-js" import { batch } from "solid-js"
import { produce, reconcile, type SetStoreFunction, type Store } from "solid-js/store" import { produce, reconcile, type SetStoreFunction, type Store } from "solid-js/store"
import type { State, VcsCache } from "./types" import type { ProviderStore, State, StoreConfig, VcsCache } from "./types"
import type { ServerSession } from "../server-session" import type { ServerSession } from "../server-session"
import { cmp, normalizeAgentList, normalizeProviderList } from "./utils" import { cmp } from "./utils"
import { formatServerError } from "@/utils/server-errors" import { formatServerError } from "@/utils/server-errors"
import { QueryClient, queryOptions } from "@tanstack/solid-query" import { QueryClient, queryOptions } from "@tanstack/solid-query"
import { loadMcpQuery, loadMcpResourcesQuery } from "../server-sync" import { loadMcpQuery, loadMcpResourcesQuery } from "../server-sync"
import { NormalizedProviderListResponse } from "@opencode-ai/session-ui/context"
import { ScopedKey, type ServerScope } from "@/utils/server-scope" import { ScopedKey, type ServerScope } from "@/utils/server-scope"
type GlobalStore = { type GlobalStore = {
ready: boolean ready: boolean
path: Path path: AppPathInfo
project: Project[] project: AppProject[]
provider: NormalizedProviderListResponse provider: ProviderStore
provider_auth: ProviderAuthResponse provider_auth: AppProviderAuthResponse
config: Config config: StoreConfig
reload: undefined | "pending" | "complete" reload: undefined | "pending" | "complete"
} }
@ -82,29 +82,31 @@ function showErrors(input: {
}) })
} }
export const loadGlobalConfigQuery = (scope: ServerScope, sdk: OpencodeClient) => export const loadGlobalConfigQuery = (scope: ServerScope, backend: Promise<AppClient>) =>
queryOptions({ queryOptions({
queryKey: [scope, "config"], queryKey: [scope, "config"],
queryFn: () => retry(() => sdk.global.config.get().then((x) => x.data!)), queryFn: () => retry(async () => (await backend).capabilities.configuration?.getGlobal() ?? {}),
}) })
export const loadProjectsQuery = (scope: ServerScope, sdk: OpencodeClient) => export const loadProjectsQuery = (scope: ServerScope, backend: Promise<AppClient>) =>
queryOptions({ queryOptions({
queryKey: [scope, "project"], queryKey: [scope, "project"],
queryFn: () => queryFn: () =>
retry(() => retry(() =>
sdk.project.list().then((x) => { backend
return (x.data ?? []) .then((client) => client.capabilities.projectList?.list() ?? [])
.filter((p) => !!p?.id) .then((projects) => {
.filter((p) => !!p.worktree && !p.worktree.includes("opencode-test")) return projects
.slice() .filter((p) => !!p?.id)
.sort((a, b) => cmp(a.id, b.id)) .filter((p) => !!p.worktree && !p.worktree.includes("opencode-test"))
}), .slice()
.sort((a, b) => cmp(a.id, b.id))
}),
), ),
}) })
export async function bootstrapGlobal(input: { export async function bootstrapGlobal(input: {
serverSDK: OpencodeClient backend: Promise<AppClient>
scope: ServerScope scope: ServerScope
requestFailedTitle: string requestFailedTitle: string
translate: (key: string, vars?: Record<string, string | number>) => string translate: (key: string, vars?: Record<string, string | number>) => string
@ -113,12 +115,12 @@ export async function bootstrapGlobal(input: {
queryClient: QueryClient queryClient: QueryClient
}) { }) {
const slow = [ const slow = [
() => input.queryClient.fetchQuery(loadGlobalConfigQuery(input.scope, input.serverSDK)), () => input.queryClient.fetchQuery(loadGlobalConfigQuery(input.scope, input.backend)),
() => input.queryClient.fetchQuery(loadProvidersQuery(input.scope, null, input.serverSDK)), () => input.queryClient.fetchQuery(loadProvidersQuery(input.scope, null, input.backend)),
() => input.queryClient.fetchQuery(loadPathQuery(input.scope, null, input.serverSDK)), () => input.queryClient.fetchQuery(loadPathQuery(input.scope, null, input.backend)),
() => () =>
input.queryClient input.queryClient
.fetchQuery(loadProjectsQuery(input.scope, input.serverSDK)) .fetchQuery(loadProjectsQuery(input.scope, input.backend))
.then((data) => input.setGlobalStore("project", data)), .then((data) => input.setGlobalStore("project", data)),
] ]
await runAll(slow) await runAll(slow)
@ -140,11 +142,11 @@ function groupBySession<T extends { id: string; sessionID: string }>(input: T[])
}, {}) }, {})
} }
function projectID(directory: string, projects: Project[]) { function projectID(directory: string, projects: readonly AppProject[]) {
return projects.find((project) => project.worktree === directory || project.sandboxes?.includes(directory))?.id return projects.find((project) => project.worktree === directory || project.sandboxes?.includes(directory))?.id
} }
function mergeSession(setStore: SetStoreFunction<State>, session: Session) { function mergeSession(setStore: SetStoreFunction<State>, session: AppSession) {
setStore("session", (list) => { setStore("session", (list) => {
const next = list.slice() const next = list.slice()
const idx = next.findIndex((item) => item.id >= session.id) const idx = next.findIndex((item) => item.id >= session.id)
@ -162,44 +164,54 @@ function warmSessions(input: {
ids: string[] ids: string[]
store: Store<State> store: Store<State>
setStore: SetStoreFunction<State> setStore: SetStoreFunction<State>
sdk: OpencodeClient backend: AppClient
location: { directory: string }
}) { }) {
const known = new Set(input.store.session.map((item) => item.id)) const known = new Set(input.store.session.map((item) => item.id))
const ids = [...new Set(input.ids)].filter((id) => !!id && !known.has(id)) const ids = [...new Set(input.ids)].filter((id) => !!id && !known.has(id))
if (ids.length === 0) return Promise.resolve() if (ids.length === 0) return Promise.resolve()
return Promise.all( return Promise.all(
ids.map((sessionID) => ids.map((sessionID) =>
retry(() => input.sdk.session.get({ sessionID })).then((x) => { retry(() => input.backend.common.sessions.get({ sessionID, location: input.location })).then((x) => {
const session = x.data if (!x?.id) return
if (!session?.id) return mergeSession(input.setStore, x)
mergeSession(input.setStore, session)
}), }),
), ),
).then(() => undefined) ).then(() => undefined)
} }
export const loadProvidersQuery = (scope: ServerScope, directory: string | null, sdk: OpencodeClient) => export const loadProvidersQuery = (scope: ServerScope, directory: string | null, backend: Promise<AppClient>) =>
queryOptions({ queryOptions({
queryKey: [scope, directory, "providers"], queryKey: [scope, directory, "providers"],
queryFn: () => retry(() => sdk.provider.list().then((x) => normalizeProviderList(x.data!))), queryFn: () =>
retry(() => backend.then((client) => client.common.catalog.providers(location(directory)).then(toProviderStore))),
}) })
export const loadAgentsQuery = (scope: ServerScope, directory: string | null, sdk: OpencodeClient) => export const loadAgentsQuery = (scope: ServerScope, directory: string, backend: Promise<AppClient>) =>
queryOptions({ queryOptions({
queryKey: [scope, directory, "agents"], queryKey: [scope, directory, "agents"],
queryFn: () => retry(() => sdk.app.agents().then((x) => normalizeAgentList(x.data))), queryFn: () =>
retry(() =>
backend.then((client) => client.common.catalog.agents(location(directory)).then((agents) => [...agents])),
),
}) })
export const loadPathQuery = (scope: ServerScope, directory: string | null, sdk: OpencodeClient) => export const loadPathQuery = (scope: ServerScope, directory: string | null, backend: Promise<AppClient>) =>
queryOptions<Path>({ queryOptions<AppPathInfo>({
queryKey: [scope, directory, "path"], queryKey: [scope, directory, "path"],
queryFn: () => retry(() => sdk.path.get().then((x) => x.data!)), queryFn: async () => {
const client = await backend
return retry(
() => client.capabilities.pathInfo?.get(location(directory)) ?? Promise.resolve(emptyPath(directory)),
)
},
}) })
export const loadReferencesQuery = (scope: ServerScope, directory: string, sdk: OpencodeClient) => export const loadReferencesQuery = (scope: ServerScope, directory: string, backend: Promise<AppClient>) =>
queryOptions<ReferenceInfo[]>({ queryOptions<readonly AppReference[]>({
queryKey: [scope, directory, "references"] as const, queryKey: [scope, directory, "references"] as const,
queryFn: () => retry(() => sdk.v2.reference.list().then((x) => x.data?.data ?? [])).catch(() => []), queryFn: () =>
retry(() => backend.then((client) => client.common.references.list(location(directory)))).catch(() => []),
placeholderData: [], placeholderData: [],
}) })
@ -207,17 +219,17 @@ export async function bootstrapDirectory(input: {
directory: string directory: string
scope: ServerScope scope: ServerScope
mcp: boolean mcp: boolean
sdk: OpencodeClient backend: AppClient
store: Store<State> store: Store<State>
setStore: SetStoreFunction<State> setStore: SetStoreFunction<State>
vcsCache: VcsCache vcsCache: VcsCache
loadSessions: (directory: string) => Promise<void> | void loadSessions: (directory: string) => Promise<void> | void
translate: (key: string, vars?: Record<string, string | number>) => string translate: (key: string, vars?: Record<string, string | number>) => string
global: { global: {
config: Config config: StoreConfig
path: Path path: AppPathInfo
project: Project[] project: readonly AppProject[]
provider: NormalizedProviderListResponse provider: ProviderStore
} }
queryClient: QueryClient queryClient: QueryClient
session?: ServerSession session?: ServerSession
@ -240,66 +252,90 @@ export async function bootstrapDirectory(input: {
() => Promise.resolve(input.loadSessions(input.directory)), () => Promise.resolve(input.loadSessions(input.directory)),
() => () =>
input.queryClient input.queryClient
.ensureQueryData(loadAgentsQuery(input.scope, input.directory, input.sdk)) .ensureQueryData(loadAgentsQuery(input.scope, input.directory, Promise.resolve(input.backend)))
.then((data) => input.setStore("agent", data)), .then((data) => input.setStore("agent", data)),
() => () =>
retry(() => input.sdk.config.get().then((x) => input.setStore("config", reconcile(x.data!, { merge: false })))), retry(
() =>
input.backend.capabilities.configuration
?.get(location(input.directory))
.then((config) => input.setStore("config", reconcile(config, { merge: false }))) ?? Promise.resolve(),
),
() => () =>
retry(() => retry(() =>
input.sdk.session.status().then(async (x) => { input.backend.common.sessions.activity(location(input.directory)).then(async (statuses) => {
if (!input.session) { if (!input.session) {
input.setStore("session_status", x.data!) input.setStore("session_status", mapActivity(statuses))
return return
} }
const statuses = x.data ?? {} const mapped = mapActivity(statuses)
input.session.set( input.session.set(
"session_status", "session_status",
produce((draft) => { produce((draft) => {
for (const sessionID of Object.keys(draft)) { for (const sessionID of Object.keys(draft)) {
if (statuses[sessionID]) continue if (mapped[sessionID]) continue
if (input.session?.get(sessionID)?.directory === input.directory) delete draft[sessionID] if (input.session?.get(sessionID)?.directory === input.directory) delete draft[sessionID]
} }
}), }),
) )
for (const [sessionID, status] of Object.entries(statuses)) { for (const [sessionID, status] of Object.entries(mapped)) {
input.session.set("session_status", sessionID, reconcile(status)) input.session.set("session_status", sessionID, reconcile(status))
} }
// Warm session info only after seeding statuses so a stalled session // Warm session info only after seeding statuses so a stalled session
// fetch cannot park busy indicators behind it, mirroring how live // fetch cannot park busy indicators behind it, mirroring how live
// session.status events apply first and resolve info in the background. // session.status events apply first and resolve info in the background.
await Promise.all( await Promise.all(
Object.keys(statuses).map((sessionID) => input.session!.resolve(sessionID).catch(() => undefined)), Object.keys(mapped).map((sessionID) => input.session!.resolve(sessionID).catch(() => undefined)),
) )
}), }),
), ),
!seededProject && !seededProject &&
(() => retry(() => input.sdk.project.current()).then((x) => input.setStore("project", x.data!.id))), (() =>
retry(() => input.backend.common.projects.current(location(input.directory))).then((project) =>
input.setStore("project", project.id),
)),
!seededPath && !seededPath &&
(() => (() =>
input.queryClient.ensureQueryData(loadPathQuery(input.scope, input.directory, input.sdk)).then((data) => { input.queryClient
const next = projectID(data.directory ?? input.directory, input.global.project) .ensureQueryData(loadPathQuery(input.scope, input.directory, Promise.resolve(input.backend)))
if (next) input.setStore("project", next) .then((data) => {
})), const next = projectID(data.directory ?? input.directory, input.global.project)
if (next) input.setStore("project", next)
})),
() => () =>
retry(() => retry(() =>
input.sdk.vcs.get().then((x) => { (input.backend.capabilities.vcsInfo?.get(location(input.directory)) ?? Promise.resolve(undefined)).then(
const next = x.data ?? input.store.vcs (data) => {
input.setStore("vcs", next) const next = data ?? input.store.vcs
if (next) input.vcsCache.setStore("value", next) input.setStore("vcs", next)
}), if (next) input.vcsCache.setStore("value", next)
},
),
), ),
input.mcp && (() => retry(() => input.sdk.command.list().then((x) => input.setStore("command", x.data ?? [])))), input.mcp &&
() => input.queryClient.fetchQuery(loadReferencesQuery(input.scope, input.directory, input.sdk)), (() =>
retry(() => input.backend.common.commands.list(location(input.directory))).then((commands) =>
input.setStore("command", reconcile(commands)),
)),
() =>
input.queryClient.fetchQuery(loadReferencesQuery(input.scope, input.directory, Promise.resolve(input.backend))),
() => () =>
retry(() => retry(() =>
input.sdk.permission.list().then((x) => { input.backend.common.permissions.pending(location(input.directory)).then((data) => {
const ids = (x.data ?? []).map((perm) => perm?.sessionID).filter((id): id is string => !!id) const permissions = data.map(
const grouped = groupBySession( (perm): AppPermissionRequest => perm,
(x.data ?? []).filter((perm): perm is PermissionRequest => !!perm?.id && !!perm.sessionID),
) )
const ids = permissions.map((perm) => perm.sessionID)
const grouped = groupBySession(permissions)
const warm = input.session const warm = input.session
? Promise.all(ids.map((sessionID) => input.session!.resolve(sessionID))).then(() => undefined) ? Promise.all(ids.map((sessionID) => input.session!.resolve(sessionID))).then(() => undefined)
: warmSessions({ ids, store: input.store, setStore: input.setStore, sdk: input.sdk }) : warmSessions({
ids,
store: input.store,
setStore: input.setStore,
backend: input.backend,
location: { directory: input.directory },
})
return warm.then(() => return warm.then(() =>
batch(() => { batch(() => {
const current = input.session?.data.permission ?? input.store.permission const current = input.session?.data.permission ?? input.store.permission
@ -323,12 +359,21 @@ export async function bootstrapDirectory(input: {
), ),
() => () =>
retry(() => retry(() =>
input.sdk.question.list().then((x) => { input.backend.common.questions.pending(location(input.directory)).then((data) => {
const ids = (x.data ?? []).map((question) => question?.sessionID).filter((id): id is string => !!id) const questions = data.map(
const grouped = groupBySession((x.data ?? []).filter((q): q is QuestionRequest => !!q?.id && !!q.sessionID)) (question): AppQuestionRequest => question,
)
const ids = questions.map((question) => question.sessionID)
const grouped = groupBySession(questions)
const warm = input.session const warm = input.session
? Promise.all(ids.map((sessionID) => input.session!.resolve(sessionID))).then(() => undefined) ? Promise.all(ids.map((sessionID) => input.session!.resolve(sessionID))).then(() => undefined)
: warmSessions({ ids, store: input.store, setStore: input.setStore, sdk: input.sdk }) : warmSessions({
ids,
store: input.store,
setStore: input.setStore,
backend: input.backend,
location: { directory: input.directory },
})
return warm.then(() => return warm.then(() =>
batch(() => { batch(() => {
const current = input.session?.data.question ?? input.store.question const current = input.session?.data.question ?? input.store.question
@ -351,17 +396,25 @@ export async function bootstrapDirectory(input: {
}), }),
), ),
() => Promise.resolve(input.loadSessions(input.directory)), () => Promise.resolve(input.loadSessions(input.directory)),
input.mcp && (() => input.queryClient.fetchQuery(loadMcpQuery(input.scope, input.directory, input.sdk))), input.mcp &&
input.mcp && (() => input.queryClient.fetchQuery(loadMcpResourcesQuery(input.scope, input.directory, input.sdk))), (() =>
input.queryClient.fetchQuery(loadMcpQuery(input.scope, input.directory, Promise.resolve(input.backend)))),
input.mcp &&
(() =>
input.queryClient.fetchQuery(
loadMcpResourcesQuery(input.scope, input.directory, Promise.resolve(input.backend)),
)),
() => () =>
input.queryClient.fetchQuery(loadProvidersQuery(input.scope, input.directory, input.sdk)).catch((err) => { input.queryClient
const project = getFilename(input.directory) .fetchQuery(loadProvidersQuery(input.scope, input.directory, Promise.resolve(input.backend)))
showToast({ .catch((err) => {
variant: "error", const project = getFilename(input.directory)
title: input.translate("toast.project.reloadFailed.title", { project }), showToast({
description: formatServerError(err, input.translate), variant: "error",
}) title: input.translate("toast.project.reloadFailed.title", { project }),
}), description: formatServerError(err, input.translate),
})
}),
].filter(Boolean) as (() => Promise<any>)[] ].filter(Boolean) as (() => Promise<any>)[]
await waitForPaint() await waitForPaint()
@ -379,3 +432,23 @@ export async function bootstrapDirectory(input: {
if (loading && slowErrs.length === 0) input.setStore("status", "complete") if (loading && slowErrs.length === 0) input.setStore("status", "complete")
})() })()
} }
function location(directory: string | null) {
return directory === null ? undefined : { location: { directory } }
}
function emptyPath(directory: string | null): AppPathInfo {
return { home: "", directory: directory ?? "", state: "", config: "", worktree: "" }
}
function toProviderStore(input: ProviderCatalog): ProviderStore {
return {
all: input.providers,
connected: [...input.connected],
default: { ...input.defaults },
}
}
function mapActivity(input: Awaited<ReturnType<AppClient["common"]["sessions"]["activity"]>>) {
return input
}

View file

@ -1,8 +1,7 @@
import { beforeAll, describe, expect, mock, test } from "bun:test" import { beforeAll, describe, expect, mock, test } from "bun:test"
import { createRoot, getOwner, type Owner } from "solid-js" import { createRoot, getOwner, type Owner } from "solid-js"
import { createStore } from "solid-js/store" import { createStore } from "solid-js/store"
import type { NormalizedProviderListResponse } from "@opencode-ai/session-ui/context" import type { ProviderStore, State } from "./types"
import type { State } from "./types"
import type { QueryOptionsApi } from "../server-sync" import type { QueryOptionsApi } from "../server-sync"
import { ServerScope } from "@/utils/server-scope" import { ServerScope } from "@/utils/server-scope"
@ -16,7 +15,7 @@ const persist: typeof import("@/utils/persist").persisted = (_target, store) =>
] ]
const child = () => createStore({} as State) const child = () => createStore({} as State)
const provider = { all: new Map(), connected: [], default: {} } satisfies NormalizedProviderListResponse const provider = { all: new Map(), connected: [], default: {} } satisfies ProviderStore
const queryOptionsApi = { const queryOptionsApi = {
globalConfig: () => ({ queryKey: ["globalConfig"], queryFn: async () => ({}) }), globalConfig: () => ({ queryKey: ["globalConfig"], queryFn: async () => ({}) }),

View file

@ -1,7 +1,7 @@
import { createRoot, createSignal, getOwner, onCleanup, runWithOwner, type Owner } from "solid-js" import { createRoot, createSignal, getOwner, onCleanup, runWithOwner, type Owner } from "solid-js"
import { createStore, type SetStoreFunction, type Store } from "solid-js/store" import { createStore, type SetStoreFunction, type Store } from "solid-js/store"
import { Persist, persisted } from "@/utils/persist" import { Persist, persisted } from "@/utils/persist"
import type { VcsInfo } from "@opencode-ai/sdk/v2/client" import type { AppVcsInfo } from "../backend"
import { import {
DIR_IDLE_TTL_MS, DIR_IDLE_TTL_MS,
MAX_DIR_STORES, MAX_DIR_STORES,
@ -10,6 +10,7 @@ import {
type IconCache, type IconCache,
type MetaCache, type MetaCache,
type ProjectMeta, type ProjectMeta,
type ProviderStore,
type State, type State,
type VcsCache, type VcsCache,
} from "./types" } from "./types"
@ -17,7 +18,6 @@ import { canDisposeDirectory, pickDirectoriesToEvict } from "./eviction"
import { useQuery } from "@tanstack/solid-query" import { useQuery } from "@tanstack/solid-query"
import { QueryOptionsApi } from "../server-sync" import { QueryOptionsApi } from "../server-sync"
import { directoryKey, type DirectoryKey } from "./utils" import { directoryKey, type DirectoryKey } from "./utils"
import { NormalizedProviderListResponse } from "@opencode-ai/session-ui/context"
import type { ServerScope } from "@/utils/server-scope" import type { ServerScope } from "@/utils/server-scope"
export function createChildStoreManager(input: { export function createChildStoreManager(input: {
@ -32,7 +32,7 @@ export function createChildStoreManager(input: {
translate: (key: string, vars?: Record<string, string | number>) => string translate: (key: string, vars?: Record<string, string | number>) => string
queryOptions: QueryOptionsApi queryOptions: QueryOptionsApi
global: { global: {
provider: NormalizedProviderListResponse provider: ProviderStore
} }
}) { }) {
const children: Record<string, [Store<State>, SetStoreFunction<State>]> = {} const children: Record<string, [Store<State>, SetStoreFunction<State>]> = {}
@ -152,7 +152,7 @@ export function createChildStoreManager(input: {
const vcs = runWithOwner(input.owner, () => const vcs = runWithOwner(input.owner, () =>
input.persist( input.persist(
Persist.serverWorkspace(input.scope, directory, "vcs", ["vcs.v1"]), Persist.serverWorkspace(input.scope, directory, "vcs", ["vcs.v1"]),
createStore({ value: undefined as VcsInfo | undefined }), createStore({ value: undefined as AppVcsInfo | undefined }),
), ),
) )
if (!vcs) throw new Error(input.translate("error.childStore.persistedCacheCreateFailed")) if (!vcs) throw new Error(input.translate("error.childStore.persistedCacheCreateFailed"))
@ -223,6 +223,7 @@ export function createChildStoreManager(input: {
return (type ?? "idle") !== "idle" return (type ?? "idle") !== "idle"
}, },
session_diff: {}, session_diff: {},
todo: {},
permission: {}, permission: {},
question: {}, question: {},
get mcp_ready() { get mcp_ready() {

View file

@ -1,5 +1,12 @@
import { describe, expect, test } from "bun:test" import { describe, expect, test } from "bun:test"
import type { Message, Part, PermissionRequest, Project, QuestionRequest, Session } from "@opencode-ai/sdk/v2/client" import type {
AppMessage as Message,
AppPart as Part,
AppPermissionRequest as PermissionRequest,
AppProject as Project,
AppQuestionRequest as QuestionRequest,
AppSession as Session,
} from "../backend"
import { createStore } from "solid-js/store" import { createStore } from "solid-js/store"
import type { State } from "./types" import type { State } from "./types"
import { applyDirectoryEvent, applyGlobalEvent, cleanupDroppedSessionCaches } from "./event-reducer" import { applyDirectoryEvent, applyGlobalEvent, cleanupDroppedSessionCaches } from "./event-reducer"
@ -39,7 +46,9 @@ const permissionRequest = (id: string, sessionID: string, title = id) =>
id, id,
sessionID, sessionID,
permission: title, permission: title,
action: title,
patterns: ["*"], patterns: ["*"],
resources: ["*"],
metadata: {}, metadata: {},
always: [], always: [],
}) as PermissionRequest }) as PermissionRequest
@ -523,9 +532,9 @@ describe("applyDirectoryEvent", () => {
}) })
test("updates vcs branch in store and cache", () => { test("updates vcs branch in store and cache", () => {
const [store, setStore] = createStore(baseState({ vcs: { branch: "main", default_branch: "main" } })) const [store, setStore] = createStore(baseState({ vcs: { branch: "main", defaultBranch: "main" } }))
const [cacheStore, setCacheStore] = createStore({ const [cacheStore, setCacheStore] = createStore({
value: { branch: "main", default_branch: "main" } as State["vcs"], value: { branch: "main", defaultBranch: "main" } as State["vcs"],
}) })
applyDirectoryEvent({ applyDirectoryEvent({
@ -542,8 +551,8 @@ describe("applyDirectoryEvent", () => {
}, },
}) })
expect(store.vcs).toEqual({ branch: "feature/test", default_branch: "main" }) expect(store.vcs).toEqual({ branch: "feature/test", defaultBranch: "main" })
expect(cacheStore.value).toEqual({ branch: "feature/test", default_branch: "main" }) expect(cacheStore.value).toEqual({ branch: "feature/test", defaultBranch: "main" })
}) })
test("routes disposal and lsp events to side-effect handlers", () => { test("routes disposal and lsp events to side-effect handlers", () => {

View file

@ -1,15 +1,18 @@
import { Binary } from "@opencode-ai/core/util/binary" import { Binary } from "@opencode-ai/core/util/binary"
import { produce, reconcile, type SetStoreFunction, type Store } from "solid-js/store" import { produce, reconcile, type SetStoreFunction, type Store } from "solid-js/store"
import type { import type {
Message, AppEvent,
Part, AppFileDiff,
PermissionRequest, AppMessage,
Project, AppPart,
QuestionRequest, AppPermissionRequest,
Session, AppProject,
SessionStatus, AppQuestionRequest,
FileDiffInfo, AppSession,
} from "@opencode-ai/sdk/v2/client" AppTodo,
SessionActivity,
} from "../backend"
import { timelineMessage } from "../backend"
import type { State, VcsCache } from "./types" import type { State, VcsCache } from "./types"
import { trimSessions } from "./session-trim" import { trimSessions } from "./session-trim"
import { dropSessionCaches } from "./session-cache" import { dropSessionCaches } from "./session-cache"
@ -31,19 +34,76 @@ const SESSION_CONTENT_EVENTS = new Set([
"question.rejected", "question.rejected",
]) ])
type LegacyEvent = { type: string; properties?: unknown }
type DirectoryLegacyEvent =
| { type: string; properties: unknown }
| { type: "server.instance.disposed"; properties?: undefined }
function legacyEvent(event: AppEvent): LegacyEvent | undefined {
if (event.type === "instance.disposed") return { type: "server.instance.disposed", properties: event }
if (event.type === "session.created" || event.type === "session.updated")
return { type: event.type, properties: { info: event.session } }
if (event.type === "session.deleted") return { type: event.type, properties: { sessionID: event.sessionID } }
if (event.type === "session.activity")
return { type: "session.status", properties: { sessionID: event.sessionID, status: event.activity } }
if (event.type === "session.diff" || event.type === "todo.updated") return { type: event.type, properties: event }
if (event.type === "timeline.updated") {
const message = timelineMessage(event.item)
return message ? { type: "message.updated", properties: { info: message } } : undefined
}
if (event.type === "timeline.content.updated") return undefined
if (event.type === "timeline.removed")
return {
type: "message.removed",
properties: { sessionID: event.sessionID, messageID: event.itemID },
}
if (event.type === "timeline.part.removed")
return {
type: "message.part.removed",
properties: { sessionID: event.sessionID, messageID: event.itemID, partID: event.contentID },
}
if (event.type === "timeline.delta")
return {
type: "message.part.delta",
properties: {
sessionID: event.sessionID,
messageID: event.itemID,
partID: event.contentID,
field: event.field,
delta: event.delta,
},
}
if (event.type === "permission.requested") return { type: "permission.asked", properties: event.request }
if (event.type === "permission.replied" || event.type === "question.replied" || event.type === "question.rejected")
return { type: event.type, properties: event }
if (event.type === "question.requested") return { type: "question.asked", properties: event.request }
return { type: event.type, properties: event }
}
function normalizeDirectoryEvent(event: AppEvent | DirectoryLegacyEvent) {
if ("properties" in event) return event
if (event.type === "server.instance.disposed") return event
return legacyEvent(event)
}
export function applyGlobalEvent(input: { export function applyGlobalEvent(input: {
event: { type: string; properties?: unknown } event: AppEvent | LegacyEvent
project: Project[] project: AppProject[]
setGlobalProject: (next: Project[] | ((draft: Project[]) => Project[])) => void setGlobalProject: (next: AppProject[] | ((draft: AppProject[]) => AppProject[])) => void
refresh: () => void refresh: () => void
}) { }) {
if (input.event.type === "global.disposed" || input.event.type === "server.connected") { if (
input.event.type === "server.disposed" ||
input.event.type === "global.disposed" ||
input.event.type === "server.connected" ||
input.event.type === "provider.updated"
) {
input.refresh() input.refresh()
return return
} }
if (input.event.type !== "project.updated") return if (input.event.type !== "project.updated") return
const properties = input.event.properties as Project const properties = "project" in input.event ? input.event.project : (input.event.properties as AppProject)
const result = Binary.search(input.project, properties.id, (s) => s.id) const result = Binary.search(input.project, properties.id, (s) => s.id)
if (result.found) { if (result.found) {
input.setGlobalProject( input.setGlobalProject(
@ -60,7 +120,11 @@ export function applyGlobalEvent(input: {
) )
} }
function cleanupSessionCaches(setStore: SetStoreFunction<State>, sessionID: string) { function cleanupSessionCaches(
setStore: SetStoreFunction<State>,
sessionID: string,
setSessionTodo?: (sessionID: string, todos: AppTodo[] | undefined) => void,
) {
if (!sessionID) return if (!sessionID) return
setStore( setStore(
produce((draft) => { produce((draft) => {
@ -69,7 +133,12 @@ function cleanupSessionCaches(setStore: SetStoreFunction<State>, sessionID: stri
) )
} }
export function cleanupDroppedSessionCaches(store: Store<State>, setStore: SetStoreFunction<State>, next: Session[]) { export function cleanupDroppedSessionCaches(
store: Store<State>,
setStore: SetStoreFunction<State>,
next: AppSession[],
setSessionTodo?: (sessionID: string, todos: AppTodo[] | undefined) => void,
) {
const keep = new Set(next.map((item) => item.id)) const keep = new Set(next.map((item) => item.id))
const stale = [ const stale = [
...Object.keys(store.message), ...Object.keys(store.message),
@ -90,7 +159,7 @@ export function cleanupDroppedSessionCaches(store: Store<State>, setStore: SetSt
} }
export function applyDirectoryEvent(input: { export function applyDirectoryEvent(input: {
event: { type: string; properties?: unknown } event: AppEvent | DirectoryLegacyEvent
store: Store<State> store: Store<State>
setStore: SetStoreFunction<State> setStore: SetStoreFunction<State>
push: (directory: string) => void push: (directory: string) => void
@ -98,11 +167,17 @@ export function applyDirectoryEvent(input: {
loadLsp: () => void loadLsp: () => void
loadReferences?: () => void loadReferences?: () => void
vcsCache?: VcsCache vcsCache?: VcsCache
setSessionTodo?: (sessionID: string, todos: AppTodo[] | undefined) => void
retainedLimit?: number retainedLimit?: number
sessionContent?: boolean sessionContent?: boolean
permission?: State["permission"] permission?: State["permission"]
}) { }) {
const event = input.event if (input.event.type === "server.instance.disposed") {
input.push(input.directory)
return
}
const event = normalizeDirectoryEvent(input.event)
if (!event) return
if (input.sessionContent === false && SESSION_CONTENT_EVENTS.has(event.type)) return if (input.sessionContent === false && SESSION_CONTENT_EVENTS.has(event.type)) return
const limit = Math.max(input.store.limit, input.retainedLimit ?? 0) const limit = Math.max(input.store.limit, input.retainedLimit ?? 0)
switch (event.type) { switch (event.type) {
@ -111,7 +186,7 @@ export function applyDirectoryEvent(input: {
return return
} }
case "session.created": { case "session.created": {
const info = (event.properties as { info: Session }).info const info = (event.properties as { info: AppSession }).info
const result = Binary.search(input.store.session, info.id, (s) => s.id) const result = Binary.search(input.store.session, info.id, (s) => s.id)
if (result.found) { if (result.found) {
input.setStore("session", result.index, reconcile(info)) input.setStore("session", result.index, reconcile(info))
@ -126,7 +201,7 @@ export function applyDirectoryEvent(input: {
break break
} }
case "session.updated": { case "session.updated": {
const info = (event.properties as { info: Session }).info const info = (event.properties as { info: AppSession }).info
const result = Binary.search(input.store.session, info.id, (s) => s.id) const result = Binary.search(input.store.session, info.id, (s) => s.id)
if (info.time.archived) { if (info.time.archived) {
if (input.store.session[result.index]!.time.archived === info.time.archived) break if (input.store.session[result.index]!.time.archived === info.time.archived) break
@ -155,8 +230,10 @@ export function applyDirectoryEvent(input: {
break break
} }
case "session.deleted": { case "session.deleted": {
const info = (event.properties as { info: Session }).info const properties = event.properties as { sessionID?: string; info?: { id: string } }
const result = Binary.search(input.store.session, info.id, (s) => s.id) const sessionID = properties.sessionID ?? properties.info?.id ?? ""
const result = Binary.search(input.store.session, sessionID, (s) => s.id)
const info = result.found ? input.store.session[result.index] : undefined
if (result.found) { if (result.found) {
input.setStore( input.setStore(
"session", "session",
@ -165,23 +242,29 @@ export function applyDirectoryEvent(input: {
}), }),
) )
} }
cleanupSessionCaches(input.setStore, info.id) cleanupSessionCaches(input.setStore, sessionID, input.setSessionTodo)
if (info.parentID) break if (info?.parentID) break
input.setStore("sessionTotal", (value) => Math.max(0, value - 1)) input.setStore("sessionTotal", (value) => Math.max(0, value - 1))
break break
} }
case "session.diff": { case "session.diff": {
const props = event.properties as { sessionID: string; diff: FileDiffInfo[] } const props = event.properties as { sessionID: string; diff: AppFileDiff[] }
input.setStore("session_diff", props.sessionID, reconcile(list(props.diff), { key: "file" })) input.setStore("session_diff", props.sessionID, reconcile(list(props.diff), { key: "file" }))
break break
} }
case "todo.updated": {
const props = event.properties as { sessionID: string; todos: AppTodo[] }
input.setStore("todo", props.sessionID, reconcile(props.todos, { key: "id" }))
input.setSessionTodo?.(props.sessionID, props.todos)
break
}
case "session.status": { case "session.status": {
const props = event.properties as { sessionID: string; status: SessionStatus } const props = event.properties as { sessionID: string; status: SessionActivity }
input.setStore("session_status", props.sessionID, reconcile(props.status)) input.setStore("session_status", props.sessionID, reconcile(props.status))
break break
} }
case "message.updated": { case "message.updated": {
const info = clean((event.properties as { info: Message }).info) const info = clean((event.properties as { info: AppMessage }).info)
const messages = input.store.message[info.sessionID] const messages = input.store.message[info.sessionID]
if (!messages) { if (!messages) {
input.setStore("message", info.sessionID, [info]) input.setStore("message", info.sessionID, [info])
@ -222,7 +305,7 @@ export function applyDirectoryEvent(input: {
break break
} }
case "message.part.updated": { case "message.part.updated": {
const part = (event.properties as { part: Part }).part const part = (event.properties as { part: AppPart }).part
if (SKIP_PARTS.has(part.type)) break if (SKIP_PARTS.has(part.type)) break
input.setStore( input.setStore(
produce((draft) => { produce((draft) => {
@ -306,7 +389,7 @@ export function applyDirectoryEvent(input: {
break break
} }
case "permission.asked": { case "permission.asked": {
const permission = event.properties as PermissionRequest const permission = event.properties as AppPermissionRequest
const permissions = input.store.permission[permission.sessionID] const permissions = input.store.permission[permission.sessionID]
if (!permissions) { if (!permissions) {
input.setStore("permission", permission.sessionID, [permission]) input.setStore("permission", permission.sessionID, [permission])
@ -342,7 +425,7 @@ export function applyDirectoryEvent(input: {
break break
} }
case "question.asked": { case "question.asked": {
const question = event.properties as QuestionRequest const question = event.properties as AppQuestionRequest
const questions = input.store.question[question.sessionID] const questions = input.store.question[question.sessionID]
if (!questions) { if (!questions) {
input.setStore("question", question.sessionID, [question]) input.setStore("question", question.sessionID, [question])

View file

@ -1,7 +1,7 @@
import type { McpStatus } from "@opencode-ai/sdk/v2/client" import type { AppMcpStatus } from "../backend"
export async function toggleMcp(input: { export async function toggleMcp(input: {
status: McpStatus["status"] status: AppMcpStatus["status"]
connect: () => Promise<void> connect: () => Promise<void>
disconnect: () => Promise<void> disconnect: () => Promise<void>
authenticate: () => Promise<void> authenticate: () => Promise<void>
@ -9,6 +9,7 @@ export async function toggleMcp(input: {
}) { }) {
await { await {
connected: input.disconnect, connected: input.disconnect,
pending: async () => {},
needs_auth: input.authenticate, needs_auth: input.authenticate,
disabled: input.connect, disabled: input.connect,
failed: input.connect, failed: input.connect,

View file

@ -1,12 +1,12 @@
import { describe, expect, test } from "bun:test" import { describe, expect, test } from "bun:test"
import type { import type {
Message, AppMessage as Message,
Part, AppPart as Part,
PermissionRequest, AppPermissionRequest as PermissionRequest,
QuestionRequest, AppQuestionRequest as QuestionRequest,
SessionStatus, AppFileDiff as SnapshotFileDiff,
FileDiffInfo, SessionActivity as SessionStatus,
} from "@opencode-ai/sdk/v2/client" } from "../backend"
import { dropSessionCaches, pickSessionCacheEvictions } from "./session-cache" import { dropSessionCaches, pickSessionCacheEvictions } from "./session-cache"
const msg = (id: string, sessionID: string) => const msg = (id: string, sessionID: string) =>
@ -32,7 +32,8 @@ describe("app session cache", () => {
test("dropSessionCaches clears orphaned parts without message rows", () => { test("dropSessionCaches clears orphaned parts without message rows", () => {
const store: { const store: {
session_status: Record<string, SessionStatus | undefined> session_status: Record<string, SessionStatus | undefined>
session_diff: Record<string, FileDiffInfo[] | undefined> session_diff: Record<string, SnapshotFileDiff[] | undefined>
todo: Record<string, unknown>
message: Record<string, Message[] | undefined> message: Record<string, Message[] | undefined>
part: Record<string, Part[] | undefined> part: Record<string, Part[] | undefined>
permission: Record<string, PermissionRequest[] | undefined> permission: Record<string, PermissionRequest[] | undefined>
@ -41,6 +42,7 @@ describe("app session cache", () => {
} = { } = {
session_status: { ses_1: { type: "busy" } as SessionStatus }, session_status: { ses_1: { type: "busy" } as SessionStatus },
session_diff: { ses_1: [] }, session_diff: { ses_1: [] },
todo: {},
message: {}, message: {},
part: { msg_1: [part("prt_1", "ses_1", "msg_1")] }, part: { msg_1: [part("prt_1", "ses_1", "msg_1")] },
permission: { ses_1: [] as PermissionRequest[] }, permission: { ses_1: [] as PermissionRequest[] },
@ -63,7 +65,8 @@ describe("app session cache", () => {
const m = msg("msg_1", "ses_1") const m = msg("msg_1", "ses_1")
const store: { const store: {
session_status: Record<string, SessionStatus | undefined> session_status: Record<string, SessionStatus | undefined>
session_diff: Record<string, FileDiffInfo[] | undefined> session_diff: Record<string, SnapshotFileDiff[] | undefined>
todo: Record<string, unknown>
message: Record<string, Message[] | undefined> message: Record<string, Message[] | undefined>
part: Record<string, Part[] | undefined> part: Record<string, Part[] | undefined>
permission: Record<string, PermissionRequest[] | undefined> permission: Record<string, PermissionRequest[] | undefined>
@ -72,6 +75,7 @@ describe("app session cache", () => {
} = { } = {
session_status: {}, session_status: {},
session_diff: {}, session_diff: {},
todo: {},
message: { ses_1: [m] }, message: { ses_1: [m] },
part: { [m.id]: [part("prt_1", "ses_1", m.id)] }, part: { [m.id]: [part("prt_1", "ses_1", m.id)] },
permission: {}, permission: {},
@ -85,6 +89,21 @@ describe("app session cache", () => {
expect(store.part[m.id]).toBeUndefined() expect(store.part[m.id]).toBeUndefined()
}) })
test("dropSessionCaches accepts V2 stores without todo caches", () => {
const store = {
session_status: { ses_1: { type: "running" } },
session_diff: { ses_1: [] },
message: { ses_1: [] },
part: {},
permission: { ses_1: [] },
question: { ses_1: [] },
part_text_accum_delta: {},
}
expect(() => dropSessionCaches(store, ["ses_1"])).not.toThrow()
expect(store.session_status.ses_1).toBeUndefined()
})
test("pickSessionCacheEvictions preserves requested sessions", () => { test("pickSessionCacheEvictions preserves requested sessions", () => {
const seen = new Set(["ses_1", "ses_2", "ses_3"]) const seen = new Set(["ses_1", "ses_2", "ses_3"])

View file

@ -1,21 +1,13 @@
import type {
Message,
Part,
PermissionRequest,
QuestionRequest,
SessionStatus,
FileDiffInfo,
} from "@opencode-ai/sdk/v2/client"
export const SESSION_CACHE_LIMIT = 40 export const SESSION_CACHE_LIMIT = 40
type SessionCache = { type SessionCache = {
session_status: Record<string, SessionStatus | undefined> session_status: Record<string, unknown>
session_diff: Record<string, FileDiffInfo[] | undefined> session_diff: Record<string, unknown>
message: Record<string, Message[] | undefined> todo?: Record<string, unknown>
part: Record<string, Part[] | undefined> message: Record<string, readonly { id: string }[] | undefined>
permission: Record<string, PermissionRequest[] | undefined> part: Record<string, readonly { id: string; sessionID: string }[] | undefined>
question: Record<string, QuestionRequest[] | undefined> permission: Record<string, unknown>
question: Record<string, unknown>
part_text_accum_delta: Record<string, string | undefined> part_text_accum_delta: Record<string, string | undefined>
} }
@ -35,6 +27,7 @@ export function dropSessionCaches(store: SessionCache, sessionIDs: Iterable<stri
for (const sessionID of stale) { for (const sessionID of stale) {
delete store.message[sessionID] delete store.message[sessionID]
delete store.session_diff[sessionID] delete store.session_diff[sessionID]
delete store.todo?.[sessionID]
delete store.session_status[sessionID] delete store.session_status[sessionID]
delete store.permission[sessionID] delete store.permission[sessionID]
delete store.question[sessionID] delete store.question[sessionID]

View file

@ -1,5 +1,5 @@
import { describe, expect, test } from "bun:test" import { describe, expect, test } from "bun:test"
import type { PermissionRequest, Session } from "@opencode-ai/sdk/v2/client" import type { AppSession as Session } from "../backend"
import { trimSessions } from "./session-trim" import { trimSessions } from "./session-trim"
const session = (input: { id: string; parentID?: string; created: number; updated?: number; archived?: number }) => const session = (input: { id: string; parentID?: string; created: number; updated?: number; archived?: number }) =>
@ -43,7 +43,7 @@ describe("trimSessions", () => {
const result = trimSessions(list, { const result = trimSessions(list, {
limit: 2, limit: 2,
permission: { permission: {
"child-kept-by-permission": [{ id: "perm-1" } as PermissionRequest], "child-kept-by-permission": [{ id: "perm-1" }],
}, },
now, now,
}) })

View file

@ -1,21 +1,21 @@
import type { PermissionRequest, Session } from "@opencode-ai/sdk/v2/client" import type { AppSession } from "../backend"
import { cmp } from "./utils" import { cmp } from "./utils"
import { SESSION_RECENT_LIMIT, SESSION_RECENT_WINDOW } from "./types" import { SESSION_RECENT_LIMIT, SESSION_RECENT_WINDOW } from "./types"
export function sessionUpdatedAt(session: Session) { export function sessionUpdatedAt(session: AppSession) {
return session.time.updated ?? session.time.created return session.time.updated ?? session.time.created
} }
export function compareSessionRecent(a: Session, b: Session) { export function compareSessionRecent(a: AppSession, b: AppSession) {
const aUpdated = sessionUpdatedAt(a) const aUpdated = sessionUpdatedAt(a)
const bUpdated = sessionUpdatedAt(b) const bUpdated = sessionUpdatedAt(b)
if (aUpdated !== bUpdated) return bUpdated - aUpdated if (aUpdated !== bUpdated) return bUpdated - aUpdated
return cmp(a.id, b.id) return cmp(a.id, b.id)
} }
export function takeRecentSessions(sessions: Session[], limit: number, cutoff: number) { export function takeRecentSessions(sessions: AppSession[], limit: number, cutoff: number) {
if (limit <= 0) return [] as Session[] if (limit <= 0) return [] as AppSession[]
const selected: Session[] = [] const selected: AppSession[] = []
const seen = new Set<string>() const seen = new Set<string>()
for (const session of sessions) { for (const session of sessions) {
if (!session?.id) continue if (!session?.id) continue
@ -31,8 +31,8 @@ export function takeRecentSessions(sessions: Session[], limit: number, cutoff: n
} }
export function trimSessions( export function trimSessions(
input: Session[], input: AppSession[],
options: { limit: number; permission: Record<string, PermissionRequest[]>; now?: number }, options: { limit: number; permission: Record<string, readonly unknown[]>; now?: number },
) { ) {
const limit = Math.max(0, options.limit) const limit = Math.max(0, options.limit)
const cutoff = (options.now ?? Date.now()) - SESSION_RECENT_WINDOW const cutoff = (options.now ?? Date.now()) - SESSION_RECENT_WINDOW

View file

@ -1,22 +1,23 @@
import type { import type {
Agent, AppMessage,
Command, AppPart,
Config, AppPermissionRequest,
LspStatus, AppQuestionRequest,
McpResource, AppSession,
McpStatus, AppFileDiff,
Message, AppTodo,
Part, AppCommand,
Path, AppConfig,
PermissionRequest, AppAgent,
QuestionRequest, AppLspStatus,
ReferenceInfo, AppMcpResource,
Session, AppMcpStatus,
SessionStatus, AppPathInfo,
FileDiffInfo, AppProvider,
VcsInfo, AppReference,
} from "@opencode-ai/sdk/v2/client" AppVcsInfo,
import { NormalizedProviderListResponse } from "@opencode-ai/session-ui/context" SessionActivity,
} from "../backend"
import type { Accessor } from "solid-js" import type { Accessor } from "solid-js"
import type { SetStoreFunction, Store } from "solid-js/store" import type { SetStoreFunction, Store } from "solid-js/store"
@ -31,49 +32,62 @@ export type ProjectMeta = {
} }
} }
export type StoreConfig = {
-readonly [Key in keyof AppConfig]: AppConfig[Key]
}
export type ProviderStore = {
all: ReadonlyMap<string, AppProvider>
connected: readonly string[]
default: Readonly<Record<string, string>>
}
export type State = { export type State = {
status: "loading" | "partial" | "complete" status: "loading" | "partial" | "complete"
agent: Agent[] agent: AppAgent[]
command: Command[] command: readonly AppCommand[]
reference: ReferenceInfo[] reference: readonly AppReference[]
project: string project: string
projectMeta: ProjectMeta | undefined projectMeta: ProjectMeta | undefined
icon: string | undefined icon: string | undefined
provider_ready: boolean provider_ready: boolean
provider: NormalizedProviderListResponse provider: ProviderStore
config: Config config: StoreConfig
path: Path path: AppPathInfo
session: Session[] session: AppSession[]
sessionTotal: number sessionTotal: number
session_status: { session_status: {
[sessionID: string]: SessionStatus [sessionID: string]: SessionActivity
} }
session_working(id: string): boolean session_working(id: string): boolean
session_diff: { session_diff: {
[sessionID: string]: FileDiffInfo[] [sessionID: string]: AppFileDiff[]
}
todo: {
[sessionID: string]: AppTodo[]
} }
permission: { permission: {
[sessionID: string]: PermissionRequest[] [sessionID: string]: AppPermissionRequest[]
} }
question: { question: {
[sessionID: string]: QuestionRequest[] [sessionID: string]: AppQuestionRequest[]
} }
mcp_ready: boolean mcp_ready: boolean
mcp: { mcp: {
[name: string]: McpStatus [name: string]: AppMcpStatus
} }
mcp_resource: { mcp_resource: {
[key: string]: McpResource [key: string]: AppMcpResource
} }
lsp_ready: boolean lsp_ready: boolean
lsp: LspStatus[] lsp: readonly AppLspStatus[]
vcs: VcsInfo | undefined vcs: AppVcsInfo | undefined
limit: number limit: number
message: { message: {
[sessionID: string]: Message[] [sessionID: string]: AppMessage[]
} }
part: { part: {
[messageID: string]: Part[] [messageID: string]: AppPart[]
} }
part_text_accum_delta: { part_text_accum_delta: {
[partID: string]: string [partID: string]: string
@ -81,8 +95,8 @@ export type State = {
} }
export type VcsCache = { export type VcsCache = {
store: Store<{ value: VcsInfo | undefined }> store: Store<{ value: AppVcsInfo | undefined }>
setStore: SetStoreFunction<{ value: VcsInfo | undefined }> setStore: SetStoreFunction<{ value: AppVcsInfo | undefined }>
ready: Accessor<boolean> ready: Accessor<boolean>
} }
@ -127,11 +141,11 @@ export type DisposeCheck = {
export type RootLoadArgs = { export type RootLoadArgs = {
directory: string directory: string
limit: number limit: number
list: (query: { directory: string; roots: true; limit?: number }) => Promise<{ data?: Session[] }> list: (query: { directory: string; roots: true; limit?: number }) => Promise<{ data?: AppSession[] }>
} }
export type RootLoadResult = { export type RootLoadResult = {
data?: Session[] data?: AppSession[]
limit: number limit: number
limited: boolean limited: boolean
} }

View file

@ -1,38 +1,5 @@
import { describe, expect, test } from "bun:test" import { describe, expect, test } from "bun:test"
import type { Agent } from "@opencode-ai/sdk/v2/client" import { directoryKey } from "./utils"
import { directoryKey, normalizeAgentList } from "./utils"
const agent = (name = "build") =>
({
name,
mode: "primary",
permission: {},
options: {},
}) as Agent
describe("normalizeAgentList", () => {
test("keeps array payloads", () => {
expect(normalizeAgentList([agent("build"), agent("docs")])).toEqual([agent("build"), agent("docs")])
})
test("wraps a single agent payload", () => {
expect(normalizeAgentList(agent("docs"))).toEqual([agent("docs")])
})
test("extracts agents from keyed objects", () => {
expect(
normalizeAgentList({
build: agent("build"),
docs: agent("docs"),
}),
).toEqual([agent("build"), agent("docs")])
})
test("drops invalid payloads", () => {
expect(normalizeAgentList({ name: "AbortError" })).toEqual([])
expect(normalizeAgentList([{ name: "build" }, agent("docs")])).toEqual([agent("docs")])
})
})
describe("directoryKey", () => { describe("directoryKey", () => {
test("normalizes slashes", () => { test("normalizes slashes", () => {

View file

@ -1,43 +1,8 @@
import type { Agent, Project, ProviderListResponse } from "@opencode-ai/sdk/v2/client" import type { AppProject as Project } from "../backend"
import { NormalizedProviderListResponse } from "@opencode-ai/session-ui/context"
export { pathKey as directoryKey, type PathKey as DirectoryKey } from "@/utils/path-key" export { pathKey as directoryKey, type PathKey as DirectoryKey } from "@/utils/path-key"
export const cmp = (a: string, b: string) => (a < b ? -1 : a > b ? 1 : 0) export const cmp = (a: string, b: string) => (a < b ? -1 : a > b ? 1 : 0)
function isAgent(input: unknown): input is Agent {
if (!input || typeof input !== "object") return false
const item = input as { name?: unknown; mode?: unknown }
if (typeof item.name !== "string") return false
return item.mode === "subagent" || item.mode === "primary" || item.mode === "all"
}
export function normalizeAgentList(input: unknown): Agent[] {
if (Array.isArray(input)) return input.filter(isAgent)
if (isAgent(input)) return [input]
if (!input || typeof input !== "object") return []
return Object.values(input).filter(isAgent)
}
export function normalizeProviderList(input: ProviderListResponse): NormalizedProviderListResponse {
return {
...input,
all: new Map(
input.all.map(
(provider) =>
[
provider.id,
{
...provider,
models: Object.fromEntries(
Object.entries(provider.models).filter(([, info]) => info.status !== "deprecated"),
),
},
] as const,
),
),
}
}
export function sanitizeProject(project: Project) { export function sanitizeProject(project: Project) {
if (!project.icon?.url && !project.icon?.override) return project if (!project.icon?.url && !project.icon?.override) return project
return { return {

View file

@ -3,20 +3,25 @@ import { createEffect, createMemo, createRoot } from "solid-js"
import { createStore } from "solid-js/store" import { createStore } from "solid-js/store"
import { createServerProjects, RECENTLY_CLOSED_DISPLAY_LIMIT, ServerConnection, useServer } from "./server" import { createServerProjects, RECENTLY_CLOSED_DISPLAY_LIMIT, ServerConnection, useServer } from "./server"
import { pathKey } from "@/utils/path-key" import { pathKey } from "@/utils/path-key"
import { useServerHealth } from "@/utils/server-health" import { useCheckServerHealth, useServerHealth } from "@/utils/server-health"
import { createServerSdkContext } from "./server-sdk" import { createServerSdkContext } from "./server-sdk"
import { createServerSyncContext } from "./server-sync" import { createServerSyncContext } from "./server-sync"
import { getOwner } from "solid-js/web" import { getOwner } from "solid-js/web"
import { QueryClient } from "@tanstack/solid-query" import { QueryClient } from "@tanstack/solid-query"
import type { ServerScope } from "@/utils/server-scope" import type { ServerScope } from "@/utils/server-scope"
import { usePlatform } from "./platform"
import { backendIdentity, createBackendForServer } from "./backend-client"
export const { use: useGlobal, provider: GlobalProvider } = createSimpleContext({ export const { use: useGlobal, provider: GlobalProvider } = createSimpleContext({
name: "Global", name: "Global",
init: () => { init: () => {
const server = useServer() const server = useServer()
const platform = usePlatform()
const checkServerHealth = useCheckServerHealth()
const serverHealth = useServerHealth( const serverHealth = useServerHealth(
() => server.list, () => server.list,
() => true, () => true,
checkServerHealth,
) )
const [store, setStore] = createStore({ const [store, setStore] = createStore({
settings: { settings: {
@ -37,7 +42,7 @@ export const { use: useGlobal, provider: GlobalProvider } = createSimpleContext(
const serverCtxs = new Map< const serverCtxs = new Map<
ServerConnection.Key, ServerConnection.Key,
{ dispose: () => void; serverCtx: ReturnType<typeof createServerCtx> } { dispose: () => void; identity: string; serverCtx: ReturnType<typeof createServerCtx> }
>() >()
const owner = getOwner() const owner = getOwner()
@ -45,10 +50,26 @@ export const { use: useGlobal, provider: GlobalProvider } = createSimpleContext(
const ensureServerCtx = (conn: ServerConnection.Any) => { const ensureServerCtx = (conn: ServerConnection.Any) => {
const key = ServerConnection.key(conn) const key = ServerConnection.key(conn)
const existing = serverCtxs.get(key) const existing = serverCtxs.get(key)
if (existing) return existing.serverCtx const identity = backendIdentity(conn)
if (existing?.identity === identity) return existing.serverCtx
if (existing) {
existing.dispose()
serverCtxs.delete(key)
}
const root = createRoot((dispose) => { const root = createRoot((dispose) => {
const serverCtx = createServerCtx(conn, server.scope(key), server.projects.forServer(key)) const serverCtx = createServerCtx(
return { dispose, serverCtx } conn,
server.scope(key),
server.projects.forServer(key),
createBackendForServer({
server: conn,
browserUrl: location.href,
fetch: platform.fetch ?? globalThis.fetch,
eventFetch: eventStreamFetch(conn.http.url, platform.fetch),
health: checkServerHealth(conn.http),
}),
)
return { dispose, identity, serverCtx }
}, owner as any) }, owner as any)
serverCtxs.set(key, root) serverCtxs.set(key, root)
return root.serverCtx return root.serverCtx
@ -97,6 +118,7 @@ function createServerCtx(
conn: ServerConnection.Any, conn: ServerConnection.Any,
scope: ServerScope, scope: ServerScope,
projects: ReturnType<typeof createServerProjects>, projects: ReturnType<typeof createServerProjects>,
backend: ReturnType<typeof createBackendForServer>,
) { ) {
const queryClient = new QueryClient({ const queryClient = new QueryClient({
defaultOptions: { defaultOptions: {
@ -107,7 +129,7 @@ function createServerCtx(
}, },
}, },
}) })
const sdk = createServerSdkContext(conn, scope) const sdk = createServerSdkContext(conn, scope, backend)
const sync = createServerSyncContext(sdk) const sync = createServerSyncContext(sdk)
function enrich(project: { worktree: string; expanded: boolean }) { function enrich(project: { worktree: string; expanded: boolean }) {
@ -141,6 +163,7 @@ function createServerCtx(
(conn?.type === "sidecar" && conn.variant === "base") || (conn?.type === "http" && isLocalHost(conn.http.url)) (conn?.type === "sidecar" && conn.variant === "base") || (conn?.type === "http" && isLocalHost(conn.http.url))
return { return {
backend,
queryClient, queryClient,
sdk, sdk,
sync, sync,
@ -153,6 +176,18 @@ function createServerCtx(
} }
} }
function eventStreamFetch(url: string, fetch?: typeof globalThis.fetch) {
if (!fetch) return globalThis.fetch
try {
const parsed = new URL(url)
const loopback = parsed.hostname === "localhost" || parsed.hostname === "127.0.0.1" || parsed.hostname === "::1"
if (parsed.protocol === "http:" && !loopback) return fetch
} catch {
return globalThis.fetch
}
return globalThis.fetch
}
export type ServerCtx = ReturnType<typeof createServerCtx> export type ServerCtx = ReturnType<typeof createServerCtx>
function isLocalHost(url: string) { function isLocalHost(url: string) {

View file

@ -7,7 +7,7 @@ import { useServerSync } from "./server-sync"
import { useServerSDK } from "./server-sdk" import { useServerSDK } from "./server-sdk"
import { RECENTLY_CLOSED_DISPLAY_LIMIT, ServerConnection, useServer } from "./server" import { RECENTLY_CLOSED_DISPLAY_LIMIT, ServerConnection, useServer } from "./server"
import { usePlatform } from "./platform" import { usePlatform } from "./platform"
import { Project } from "@opencode-ai/sdk/v2" import type { AppProject } from "./backend"
import { Persist, persisted, removePersisted } from "@/utils/persist" import { Persist, persisted, removePersisted } from "@/utils/persist"
import { pathKey } from "@/utils/path-key" import { pathKey } from "@/utils/path-key"
import { decode64 } from "@/utils/base64" import { decode64 } from "@/utils/base64"
@ -72,7 +72,7 @@ type TabHandoff = {
at: number at: number
} }
export type LocalProject = Partial<Project> & { worktree: string; expanded: boolean } export type LocalProject = Partial<AppProject> & { worktree: string; expanded: boolean }
export type HomeProjectSelection = { server: ServerConnection.Key; directory?: string } export type HomeProjectSelection = { server: ServerConnection.Key; directory?: string }
export type ReviewDiffStyle = "unified" | "split" export type ReviewDiffStyle = "unified" | "split"
@ -550,6 +550,7 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
setColors(worktree, color) setColors(worktree, color)
} }
if (!project.id) continue if (!project.id) continue
const projectID = project.id
const requested = colorRequested.get(worktree) const requested = colorRequested.get(worktree)
if (requested === color) continue if (requested === color) continue
@ -561,7 +562,11 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
} }
void serverSdk() void serverSdk()
.client.project.update({ projectID: project.id, directory: worktree, icon: { color } }) .backend.then((client) => {
const editing = client.capabilities.projectEditing
if (!editing) throw new Error("Project editing is not supported by this server")
return editing.update({ projectID, location: { directory: worktree }, icon: { color } })
})
.catch(() => { .catch(() => {
if (colorRequested.get(worktree) === color) colorRequested.delete(worktree) if (colorRequested.get(worktree) === color) colorRequested.delete(worktree)
}) })

View file

@ -64,7 +64,15 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
const models = useModels() const models = useModels()
const id = createMemo(() => params.id || undefined) const id = createMemo(() => params.id || undefined)
const list = createMemo(() => sync().data.agent.filter((item) => item.mode !== "subagent" && !item.hidden)) const list = createMemo(() =>
sync()
.data.agent.filter((item) => item.mode !== "subagent" && !item.hidden)
.map((item) => ({
...item,
model: item.model && { providerID: item.model.providerID, modelID: item.model.id },
variant: item.model?.variant,
})),
)
const connected = createMemo(() => new Set(providers.connected().map((item) => item.id))) const connected = createMemo(() => new Set(providers.connected().map((item) => item.id)))
const [saved, setSaved, , savedReady] = persisted( const [saved, setSaved, , savedReady] = persisted(

View file

@ -50,7 +50,7 @@ export const { use: useModels, provider: ModelsProvider } = createSimpleContext(
() => () =>
new Map( new Map(
available().map((model) => { available().map((model) => {
const parsed = DateTime.fromISO(model.release_date) const parsed = DateTime.fromISO(model.releaseDate ?? "")
return [modelKey({ providerID: model.provider.id, modelID: model.id }), parsed] as const return [modelKey({ providerID: model.provider.id, modelID: model.id }), parsed] as const
}), }),
), ),
@ -75,7 +75,7 @@ export const { use: useModels, provider: ModelsProvider } = createSimpleContext(
values(), values(),
(groups) => (groups) =>
groups.flatMap((g) => { groups.flatMap((g) => {
const first = firstBy(g, [(x) => x.release_date, "desc"]) const first = firstBy(g, [(x) => x.releaseDate ?? "", "desc"])
return first ? [{ modelID: first.id, providerID: first.provider.id }] : [] return first ? [{ modelID: first.id, providerID: first.provider.id }] : []
}), }),
), ),

View file

@ -9,7 +9,6 @@ import { useLanguage } from "@/context/language"
import { useSettings } from "@/context/settings" import { useSettings } from "@/context/settings"
import { base64Encode } from "@opencode-ai/core/util/encode" import { base64Encode } from "@opencode-ai/core/util/encode"
import { decode64 } from "@/utils/base64" import { decode64 } from "@/utils/base64"
import { EventSessionError } from "@opencode-ai/sdk/v2"
import { Persist, persisted } from "@/utils/persist" import { Persist, persisted } from "@/utils/persist"
import { playSoundById } from "@/utils/sound" import { playSoundById } from "@/utils/sound"
import { useGlobal } from "./global" import { useGlobal } from "./global"
@ -32,7 +31,7 @@ type TurnCompleteNotification = NotificationBase & {
type ErrorNotification = NotificationBase & { type ErrorNotification = NotificationBase & {
type: "error" type: "error"
error: EventSessionError["properties"]["error"] error: unknown
} }
export type Notification = TurnCompleteNotification | ErrorNotification export type Notification = TurnCompleteNotification | ErrorNotification
@ -325,8 +324,7 @@ function createServerNotificationState(input: {
return sessionID === activeSession return sessionID === activeSession
} }
const handleSessionIdle = (directory: string, event: { properties: { sessionID?: string } }, time: number) => { const handleSessionIdle = (directory: string, sessionID: string, time: number) => {
const sessionID = event.properties.sessionID
void lookup(directory, sessionID).then((session) => { void lookup(directory, sessionID).then((session) => {
if (meta.disposed) return if (meta.disposed) return
if (!session) return if (!session) return
@ -353,10 +351,10 @@ function createServerNotificationState(input: {
const handleSessionError = ( const handleSessionError = (
directory: string, directory: string,
event: { properties: { sessionID?: string; error?: EventSessionError["properties"]["error"] } }, event: { sessionID?: string; error?: unknown },
time: number, time: number,
) => { ) => {
const sessionID = event.properties.sessionID const sessionID = event.sessionID
void lookup(directory, sessionID).then((session) => { void lookup(directory, sessionID).then((session) => {
if (meta.disposed) return if (meta.disposed) return
if (session?.parentID) return if (session?.parentID) return
@ -365,7 +363,7 @@ function createServerNotificationState(input: {
void playSoundById(settings.sounds.errors()) void playSoundById(settings.sounds.errors())
} }
const error = "error" in event.properties ? event.properties.error : undefined const error = event.error
append({ append({
directory, directory,
time, time,
@ -386,12 +384,13 @@ function createServerNotificationState(input: {
const unsub = serverSDK().event.listen((e) => { const unsub = serverSDK().event.listen((e) => {
const event = e.details const event = e.details
if (event.type !== "session.idle" && event.type !== "session.error") return if (event.type !== "session.activity" && event.type !== "session.error") return
const directory = e.name const directory = e.name
const time = Date.now() const time = Date.now()
if (event.type === "session.idle") { if (event.type === "session.activity") {
handleSessionIdle(directory, event, time) if (event.activity.type !== "idle") return
handleSessionIdle(directory, event.sessionID, time)
return return
} }
handleSessionError(directory, event, time) handleSessionError(directory, event, time)

View file

@ -1,5 +1,5 @@
import { describe, expect, test } from "bun:test" import { describe, expect, test } from "bun:test"
import type { PermissionRequest, Session } from "@opencode-ai/sdk/v2/client" import type { AppPermissionRequest as PermissionRequest, AppSession as Session } from "./backend"
import { base64Encode } from "@opencode-ai/core/util/encode" import { base64Encode } from "@opencode-ai/core/util/encode"
import { autoRespondsPermission, isDirectoryAutoAccepting } from "./permission-auto-respond" import { autoRespondsPermission, isDirectoryAutoAccepting } from "./permission-auto-respond"

View file

@ -1,7 +1,6 @@
import { type Accessor, createEffect, createMemo, onCleanup } from "solid-js" import { type Accessor, createEffect, createMemo, onCleanup } from "solid-js"
import { createStore, produce } from "solid-js/store" import { createStore, produce } from "solid-js/store"
import { createSimpleContext } from "@opencode-ai/ui/context" import { createSimpleContext } from "@opencode-ai/ui/context"
import type { PermissionRequest } from "@opencode-ai/sdk/v2/client"
import { Persist, persisted } from "@/utils/persist" import { Persist, persisted } from "@/utils/persist"
import { useServerSDK } from "@/context/server-sdk" import { useServerSDK } from "@/context/server-sdk"
import { useServerSync } from "./server-sync" import { useServerSync } from "./server-sync"
@ -21,6 +20,8 @@ type PermissionRespondFn = (input: {
directory?: string directory?: string
}) => void }) => void
type PermissionRequest = { id: string; sessionID: string }
function isNonAllowRule(rule: unknown) { function isNonAllowRule(rule: unknown) {
if (!rule) return false if (!rule) return false
if (typeof rule === "string") return rule !== "allow" if (typeof rule === "string") return rule !== "allow"
@ -119,8 +120,17 @@ export const { use: usePermission, provider: PermissionProvider } = createSimple
} }
const respond: PermissionRespondFn = (input) => { const respond: PermissionRespondFn = (input) => {
const directory = input.directory ?? props.directory?.() ?? decode64(params.dir)
if (!directory) return
serverSDK() serverSDK()
.client.permission.respond(input) .backend.then((client) =>
client.common.permissions.reply({
sessionID: input.sessionID,
requestID: input.permissionID,
reply: input.response,
location: { directory },
}),
)
.catch(() => { .catch(() => {
responded.delete(input.permissionID) responded.delete(input.permissionID)
}) })
@ -164,9 +174,9 @@ export const { use: usePermission, provider: PermissionProvider } = createSimple
const unsubscribe = serverSDK().event.listen((e) => { const unsubscribe = serverSDK().event.listen((e) => {
const event = e.details const event = e.details
if (event?.type !== "permission.asked") return if (event?.type !== "permission.requested") return
const perm = event.properties const perm = event.request
if (!shouldAutoRespond(perm, e.name)) return if (!shouldAutoRespond(perm, e.name)) return
respondOnce(perm, e.name) respondOnce(perm, e.name)
@ -182,10 +192,10 @@ export const { use: usePermission, provider: PermissionProvider } = createSimple
) )
serverSDK() serverSDK()
.client.permission.list({ directory }) .backend.then((client) => client.common.permissions.pending({ location: { directory } }))
.then((x) => { .then((items) => {
if (!isAutoAcceptingDirectory(directory)) return if (!isAutoAcceptingDirectory(directory)) return
for (const perm of x.data ?? []) { for (const perm of items) {
if (!perm?.id) continue if (!perm?.id) continue
if (!shouldAutoRespond(perm, directory)) continue if (!shouldAutoRespond(perm, directory)) continue
respondOnce(perm, directory) respondOnce(perm, directory)
@ -214,11 +224,11 @@ export const { use: usePermission, provider: PermissionProvider } = createSimple
) )
serverSDK() serverSDK()
.client.permission.list({ directory }) .backend.then((client) => client.common.permissions.pending({ location: { directory } }))
.then((x) => { .then((items) => {
if (enableVersion.get(key) !== version) return if (enableVersion.get(key) !== version) return
if (!isAutoAccepting(sessionID, directory)) return if (!isAutoAccepting(sessionID, directory)) return
for (const perm of x.data ?? []) { for (const perm of items) {
if (!perm?.id) continue if (!perm?.id) continue
if (!shouldAutoRespond(perm, directory)) continue if (!shouldAutoRespond(perm, directory)) continue
respondOnce(perm, directory) respondOnce(perm, directory)

View file

@ -1,5 +1,5 @@
import { checksum } from "@opencode-ai/core/util/encode" import { checksum } from "@opencode-ai/core/util/encode"
import type { FilePartSource } from "@opencode-ai/sdk/v2/client" import type { AppFilePartSource as FilePartSource } from "./backend"
import { batch, createMemo, type Accessor } from "solid-js" import { batch, createMemo, type Accessor } from "solid-js"
import { createStore, type SetStoreFunction } from "solid-js/store" import { createStore, type SetStoreFunction } from "solid-js/store"
import type { FileSelection } from "@/context/file" import type { FileSelection } from "@/context/file"

View file

@ -1,6 +1,6 @@
import { describe, expect, test } from "bun:test" import { describe, expect, test } from "bun:test"
import { coalesceServerEvents, enqueueServerEvent, resumeStreamAfterPageShow } from "./server-sdk" import { coalesceServerEvents, enqueueServerEvent, resumeStreamAfterPageShow } from "./server-sdk"
import type { Event } from "@opencode-ai/sdk/v2/client" import type { AppEvent } from "./backend"
describe("resumeStreamAfterPageShow", () => { describe("resumeStreamAfterPageShow", () => {
test("restarts a stream only after a back-forward cache restore", () => { test("restarts a stream only after a back-forward cache restore", () => {
@ -18,34 +18,36 @@ describe("coalesceServerEvents", () => {
const delta = (value: string, field = "text", partID = "part") => ({ const delta = (value: string, field = "text", partID = "part") => ({
directory: "/repo", directory: "/repo",
payload: { payload: {
type: "message.part.delta", type: "timeline.delta",
properties: { messageID: "msg", partID, field, delta: value }, sessionID: "ses",
} as Event, itemID: "msg",
contentID: partID,
field,
delta: value,
} as AppEvent,
}) })
test("merges adjacent deltas for the same field", () => { test("merges adjacent deltas for the same field", () => {
const first = delta("hello ") const first = delta("hello ")
const second = delta("world") const second = delta("world")
first.payload.id = "first"
second.payload.id = "second"
const result = coalesceServerEvents([first, second]) const result = coalesceServerEvents([first, second])
expect(result).toHaveLength(1) expect(result).toHaveLength(1)
expect(result[0]?.payload).toMatchObject({ id: "second", properties: { delta: "hello world" } }) expect(result[0]?.payload).toMatchObject({ delta: "hello world" })
}) })
test("preserves event boundaries and distinct fields", () => { test("preserves event boundaries and distinct fields", () => {
const status = { const status = {
directory: "/repo", directory: "/repo",
payload: { type: "session.status", properties: { sessionID: "ses", status: { type: "idle" } } } as Event, payload: { type: "session.activity", sessionID: "ses", activity: { type: "idle" } } as AppEvent,
} }
const result = coalesceServerEvents([delta("a"), delta("b", "metadata"), status, delta("c")]) const result = coalesceServerEvents([delta("a"), delta("b", "metadata"), status, delta("c")])
expect(result.map((event) => event.payload.type)).toEqual([ expect(result.map((event) => event.payload.type)).toEqual([
"message.part.delta", "timeline.delta",
"message.part.delta", "timeline.delta",
"session.status", "session.activity",
"message.part.delta", "timeline.delta",
]) ])
}) })
@ -53,104 +55,99 @@ describe("coalesceServerEvents", () => {
const first = delta("a") const first = delta("a")
const other = delta("b", "text", "other") const other = delta("b", "text", "other")
const last = delta("c") const last = delta("c")
first.payload.id = "1"
other.payload.id = "2"
last.payload.id = "3"
const result = coalesceServerEvents([first, other, last]) const result = coalesceServerEvents([first, other, last])
expect(result.map((event) => event.payload.id)).toEqual(["1", "2", "3"]) expect(result.map((event) => event.payload.type)).toEqual(["timeline.delta", "timeline.delta", "timeline.delta"])
}) })
}) })
describe("enqueueServerEvent", () => { describe("enqueueServerEvent", () => {
const partUpdated = (text: string) => const partUpdated = (text: string) =>
({ ({
type: "message.part.updated", type: "timeline.updated",
properties: { item: {
type: "user",
id: "message",
sessionID: "session", sessionID: "session",
part: { id: "part", sessionID: "session", messageID: "message", type: "text", text }, created: 1,
content: [{ id: "part", type: "text", text }],
}, },
}) as Event }) as AppEvent
test("preserves part updates across message remove and re-add barriers", () => { test("preserves part updates across message remove and re-add barriers", () => {
const events: Array<{ directory: string; payload: Event }> = [] const events: Array<{ directory: string; payload: AppEvent }> = []
const enqueue = (payload: Event) => enqueueServerEvent(events, { directory: "/repo", payload }) const enqueue = (payload: AppEvent) => enqueueServerEvent(events, { directory: "/repo", payload })
enqueue(partUpdated("old")) enqueue(partUpdated("old"))
enqueue({ type: "message.removed", properties: { sessionID: "session", messageID: "message" } } as Event) enqueue({ type: "timeline.removed", sessionID: "session", itemID: "message" })
enqueue({ enqueue({
type: "message.updated", type: "timeline.updated",
properties: { item: {
type: "user",
created: 1,
content: [],
sessionID: "session", sessionID: "session",
info: { id: "message",
id: "message",
sessionID: "session",
role: "user",
time: { created: 1 },
agent: "build",
model: { providerID: "provider", modelID: "model" },
},
}, },
} as Event) })
enqueue(partUpdated("new")) enqueue(partUpdated("new"))
expect(events.map((event) => event.payload.type)).toEqual([ expect(events.map((event) => event.payload.type)).toEqual([
"message.part.updated", "timeline.updated",
"message.removed", "timeline.removed",
"message.updated", "timeline.updated",
"message.part.updated",
]) ])
}) })
test("preserves deltas after a replacement snapshot", () => { test("preserves deltas after a replacement snapshot", () => {
const events: Array<{ directory: string; payload: Event }> = [] const events: Array<{ directory: string; payload: AppEvent }> = []
const enqueue = (payload: Event) => enqueueServerEvent(events, { directory: "/repo", payload }) const enqueue = (payload: AppEvent) => enqueueServerEvent(events, { directory: "/repo", payload })
enqueue(partUpdated("a")) enqueue(partUpdated("a"))
enqueue(partUpdated("ab")) enqueue(partUpdated("ab"))
enqueue({ enqueue({
type: "message.part.delta", type: "timeline.delta",
properties: { sessionID: "session", messageID: "message", partID: "part", field: "text", delta: "c" }, sessionID: "session",
} as Event) itemID: "message",
contentID: "part",
field: "text",
delta: "c",
})
const result = coalesceServerEvents(events) const result = coalesceServerEvents(events)
expect(result.map((event) => event.payload.type)).toEqual(["message.part.updated", "message.part.delta"]) expect(result.map((event) => event.payload.type)).toEqual(["timeline.updated", "timeline.delta"])
expect(result[0]?.payload).toMatchObject({ properties: { part: { text: "ab" } } }) expect(result[0]?.payload).toMatchObject({ item: { content: [{ text: "ab" }] } })
expect(result[1]?.payload).toMatchObject({ properties: { delta: "c" } }) expect(result[1]?.payload).toMatchObject({ delta: "c" })
}) })
test("preserves updates after session deletion", () => { test("preserves updates after session deletion", () => {
const events: Array<{ directory: string; payload: Event }> = [] const events: Array<{ directory: string; payload: AppEvent }> = []
const enqueue = (payload: Event) => enqueueServerEvent(events, { directory: "/repo", payload }) const enqueue = (payload: AppEvent) => enqueueServerEvent(events, { directory: "/repo", payload })
enqueue(partUpdated("old")) enqueue(partUpdated("old"))
enqueue({ enqueue({
id: "event-delete",
type: "session.deleted", type: "session.deleted",
properties: { sessionID: "session" }, sessionID: "session",
} as Event) })
enqueue(partUpdated("new")) enqueue(partUpdated("new"))
expect(events.map((event) => event.payload.type)).toEqual([ expect(events.map((event) => event.payload.type)).toEqual([
"message.part.updated", "timeline.updated",
"session.deleted", "session.deleted",
"message.part.updated", "timeline.updated",
]) ])
}) })
test("does not coalesce edge-triggered session statuses", () => { test("does not coalesce edge-triggered session statuses", () => {
const events: Array<{ directory: string; payload: Event }> = [] const events: Array<{ directory: string; payload: AppEvent }> = []
const enqueue = (status: "retry" | "busy") => const enqueue = (status: "retry" | "busy") =>
enqueueServerEvent(events, { enqueueServerEvent(events, {
directory: "/repo", directory: "/repo",
payload: { payload: {
type: "session.status", type: "session.activity",
properties: { sessionID: "session",
sessionID: "session", activity: status === "retry" ? { type: "retry", attempt: 1, message: "retry", next: 1 } : { type: "running" },
status: status === "retry" ? { type: "retry", attempt: 1, message: "retry", next: 1 } : { type: "busy" }, },
},
} as Event,
}) })
enqueue("retry") enqueue("retry")

View file

@ -1,9 +1,8 @@
import type { Event } from "@opencode-ai/sdk/v2/client" import type { AppClient, AppEvent } from "./backend"
import { createSimpleContext } from "@opencode-ai/ui/context" import { createSimpleContext } from "@opencode-ai/ui/context"
import { createGlobalEmitter } from "@solid-primitives/event-bus" import { createGlobalEmitter } from "@solid-primitives/event-bus"
import { makeEventListener } from "@solid-primitives/event-listener" import { makeEventListener } from "@solid-primitives/event-listener"
import { type Accessor, batch, createMemo, onCleanup, onMount } from "solid-js" import { type Accessor, batch, createMemo, onCleanup, onMount } from "solid-js"
import { createSdkForServer } from "@/utils/server"
import { useLanguage } from "./language" import { useLanguage } from "./language"
import { usePlatform } from "./platform" import { usePlatform } from "./platform"
import { ServerConnection, useServer } from "./server" import { ServerConnection, useServer } from "./server"
@ -15,14 +14,15 @@ const isAbortError = (error: unknown) =>
error !== null && typeof error === "object" && "name" in error && error.name === "AbortError" error !== null && typeof error === "object" && "name" in error && error.name === "AbortError"
const isStreamClosed = (error: unknown, signal?: AbortSignal) => isAbortError(error) || signal?.aborted === true const isStreamClosed = (error: unknown, signal?: AbortSignal) => isAbortError(error) || signal?.aborted === true
type QueuedServerEvent = { directory: string; payload: Event } type QueuedServerEvent = { directory: string; payload: AppEvent }
const coalescedKey = (event: QueuedServerEvent) => { const coalescedKey = (event: QueuedServerEvent) => {
if (event.payload.type === "lsp.updated") return `lsp.updated:${event.directory}` if (event.payload.type === "lsp.updated") return `lsp.updated:${event.directory}`
if (event.payload.type === "message.part.updated") { if (event.payload.type === "timeline.updated") {
const part = event.payload.properties.part return `timeline.updated:${event.directory}:${event.payload.item.id}`
return `message.part.updated:${event.directory}:${part.messageID}:${part.id}`
} }
if (event.payload.type === "timeline.content.updated")
return `timeline.content.updated:${event.directory}:${event.payload.itemID}:${event.payload.content.id}`
return undefined return undefined
} }
@ -40,23 +40,22 @@ export function enqueueServerEvent(queue: QueuedServerEvent[], event: QueuedServ
export function coalesceServerEvents(events: QueuedServerEvent[]) { export function coalesceServerEvents(events: QueuedServerEvent[]) {
const output: QueuedServerEvent[] = [] const output: QueuedServerEvent[] = []
events.forEach((event) => { events.forEach((event) => {
if (event.payload.type !== "message.part.delta") { if (event.payload.type !== "timeline.delta") {
output.push(event) output.push(event)
return return
} }
const props = event.payload.properties
const previous = output[output.length - 1] const previous = output[output.length - 1]
if ( if (
!previous || !previous ||
previous.payload.type !== "message.part.delta" || previous.payload.type !== "timeline.delta" ||
previous.directory !== event.directory || previous.directory !== event.directory ||
previous.payload.properties.messageID !== props.messageID || previous.payload.itemID !== event.payload.itemID ||
previous.payload.properties.partID !== props.partID || previous.payload.contentID !== event.payload.contentID ||
previous.payload.properties.field !== props.field previous.payload.field !== event.payload.field
) { ) {
output.push({ output.push({
directory: event.directory, directory: event.directory,
payload: { ...event.payload, properties: { ...props } }, payload: { ...event.payload },
}) })
return return
} }
@ -64,7 +63,7 @@ export function coalesceServerEvents(events: QueuedServerEvent[]) {
directory: event.directory, directory: event.directory,
payload: { payload: {
...event.payload, ...event.payload,
properties: { ...props, delta: previous.payload.properties.delta + props.delta }, delta: previous.payload.delta + event.payload.delta,
}, },
} }
}) })
@ -76,7 +75,7 @@ export function resumeStreamAfterPageShow(event: PageTransitionEvent, start: ()
start() start()
} }
function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerScope) { function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerScope, backend: Promise<AppClient>) {
const platform = usePlatform() const platform = usePlatform()
const abort = new AbortController() const abort = new AbortController()
@ -91,13 +90,8 @@ function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerS
} }
})() })()
const eventSdk = createSdkForServer({
signal: abort.signal,
fetch: eventFetch,
server: server.http,
})
const emitter = createGlobalEmitter<{ const emitter = createGlobalEmitter<{
[key: string]: Event [key: string]: AppEvent
}>() }>()
type Queued = QueuedServerEvent type Queued = QueuedServerEvent
@ -174,29 +168,13 @@ function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerS
} }
abort.signal.addEventListener("abort", onAbort) abort.signal.addEventListener("abort", onAbort)
try { try {
const events = await eventSdk.global.event({
signal: attempt.signal,
onSseError: (error) => {
if (isStreamClosed(error, attempt?.signal)) return
if (streamErrorLogged) return
streamErrorLogged = true
console.error("[global-sdk] event stream error", {
url: server.http.url,
fetch: eventFetch ? "platform" : "webview",
error,
})
},
})
let yielded = Date.now() let yielded = Date.now()
resetHeartbeat() resetHeartbeat()
for await (const event of events.stream) { for await (const envelope of (await backend).common.events.subscribe({ signal: attempt.signal })) {
resetHeartbeat() resetHeartbeat()
streamErrorLogged = false streamErrorLogged = false
if (event.payload.type !== "sync") { const directory = envelope.location?.directory ?? "global"
const directory = event.directory ?? "global" if (enqueueServerEvent(queue, { directory, payload: envelope.event })) schedule()
const payload = event.payload as Event
if (enqueueServerEvent(queue, { directory, payload })) schedule()
}
if (Date.now() - yielded < STREAM_YIELD_MS) continue if (Date.now() - yielded < STREAM_YIELD_MS) continue
yielded = Date.now() yielded = Date.now()
@ -253,39 +231,30 @@ function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerS
flush() flush()
}) })
const sdk = createSdkForServer({
server: server.http,
fetch: platform.fetch,
throwOnError: true,
})
return { return {
server, server,
scope, scope,
url: server.http.url, url: server.http.url,
client: sdk,
event: { event: {
on: emitter.on.bind(emitter), on: emitter.on.bind(emitter),
listen: emitter.listen.bind(emitter), listen: emitter.listen.bind(emitter),
start, start,
}, },
createClient(opts: Omit<Parameters<typeof createSdkForServer>[0], "server" | "fetch">) {
return createSdkForServer({
server: server.http,
fetch: platform.fetch,
...opts,
})
},
} }
} }
type ServerSDKBase = ReturnType<typeof createServerSdkContextBase> type ServerSDKBase = ReturnType<typeof createServerSdkContextBase>
export type ServerSDK = ServerSDKBase & { type ServerSDKWithBackend = ServerSDKBase & { backend: Promise<AppClient> }
export type ServerSDK = ServerSDKWithBackend & {
ensureDirSdkContext: (directory: string) => ReturnType<typeof createDirSdkContext> ensureDirSdkContext: (directory: string) => ReturnType<typeof createDirSdkContext>
} }
export function createServerSdkContext(server: ServerConnection.Any, scope: ServerScope): ServerSDK { export function createServerSdkContext(
const sdk = createServerSdkContextBase(server, scope) server: ServerConnection.Any,
scope: ServerScope,
backend: Promise<AppClient>,
): ServerSDK {
const sdk = Object.assign(createServerSdkContextBase(server, scope, backend), { backend })
return Object.assign(sdk, { return Object.assign(sdk, {
ensureDirSdkContext: createRefCountMap((dir) => createDirSdkContext(dir, sdk)), ensureDirSdkContext: createRefCountMap((dir) => createDirSdkContext(dir, sdk)),
}) })
@ -309,15 +278,10 @@ export const { use: useServerSDK, provider: ServerSDKProvider } = createSimpleCo
}) })
type SDKEventMap = { type SDKEventMap = {
[key in Event["type"]]: Extract<Event, { type: key }> [key in AppEvent["type"]]: AppEvent
} }
function createDirSdkContext(directory: string, serverSDK: ServerSDKBase) { function createDirSdkContext(directory: string, serverSDK: ServerSDKWithBackend) {
const client = serverSDK.createClient({
directory,
throwOnError: true,
})
const emitter = createGlobalEmitter<SDKEventMap>() const emitter = createGlobalEmitter<SDKEventMap>()
const unsub = serverSDK.event.on(directory, (event) => { const unsub = serverSDK.event.on(directory, (event) => {
@ -328,13 +292,10 @@ function createDirSdkContext(directory: string, serverSDK: ServerSDKBase) {
return { return {
scope: serverSDK.scope, scope: serverSDK.scope,
directory, directory,
client, backend: serverSDK.backend,
event: emitter, event: emitter,
get url() { get url() {
return serverSDK.url return serverSDK.url
}, },
createClient(opts: Parameters<typeof serverSDK.createClient>[0]) {
return serverSDK.createClient(opts)
},
} }
} }

View file

@ -1,7 +1,51 @@
import { describe, expect, test } from "bun:test" import { describe, expect, test } from "bun:test"
import type { retry } from "@opencode-ai/core/util/retry" import type { retry } from "@opencode-ai/core/util/retry"
import type { Message, OpencodeClient, Part, Session } from "@opencode-ai/sdk/v2/client" import type {
import { createServerSession } from "./server-session" AppFileDiff,
AppMessage as Message,
AppPart as Part,
AppSession as Session,
AppTodo,
TimelineContent,
TimelineItem,
} from "./backend"
import { createAppClient } from "./backend.test-fixture"
import { createServerSession as createAppServerSession } from "./server-session"
type FixtureClient = {
session: {
get(input: unknown): Promise<{ data: Session }>
messages(input: unknown): MessageResponse | Promise<MessageResponse>
message?(input: unknown): SingleMessageResponse | Promise<SingleMessageResponse>
diff?(input: unknown): Promise<{ data: AppFileDiff[] }>
todo?(input: unknown): Promise<{ data: AppTodo[] }>
}
}
const createServerSession = (client: FixtureClient, options?: { retry?: typeof retry }) =>
createAppServerSession(
createAppClient({
version: "v1",
common: {
sessions: {
get: async (input) => (await client.session.get(input)).data,
history: async (input) => {
const result = await client.session.messages(input)
return {
items: result.data.map((item) => timelineItem(item.info, item.parts)),
older: result.response.headers.get("x-next-cursor") ?? undefined,
}
},
message: async (input) => {
if (!client.session.message) throw new Error("Message fixture is not configured")
const result = await client.session.message(input)
return timelineItem(result.data.info, result.data.parts)
},
},
},
}),
options,
)
const session = (id: string, parentID?: string): Session => ({ const session = (id: string, parentID?: string): Session => ({
id, id,
@ -67,6 +111,81 @@ const singleResponse = (info: Message, parts: Part[] = []): SingleMessageRespons
const deferredResponse = () => Promise.withResolvers<MessageResponse>() const deferredResponse = () => Promise.withResolvers<MessageResponse>()
function timelineItem(info: Message, parts: Part[]): TimelineItem {
const content = parts.map(toTimelineContent)
if (info.role === "user")
return {
type: "user",
id: info.id,
sessionID: info.sessionID,
created: info.time.created,
content,
agent: info.agent,
model: { id: info.model.modelID, providerID: info.model.providerID, variant: info.model.variant },
format: info.format,
summary: info.summary,
system: info.system,
tools: info.tools,
}
return {
type: "assistant",
id: info.id,
sessionID: info.sessionID,
parentID: info.parentID,
created: info.time.created,
completed: info.time.completed,
content,
agent: info.agent,
model: { id: info.modelID, providerID: info.providerID, variant: info.variant },
tokens: info.tokens,
error: info.error,
mode: info.mode,
path: info.path,
cost: info.cost,
structured: info.structured,
finish: info.finish,
summary: info.summary,
}
}
function toTimelineContent(part: Part): TimelineContent {
if (part.type === "agent")
return {
type: part.type,
id: part.id,
name: part.name,
source: part.source && { text: part.source.value, start: part.source.start, end: part.source.end },
}
if (part.type === "subtask")
return {
...part,
model: part.model && { id: part.model.modelID, providerID: part.model.providerID },
}
if (part.type !== "file") return { ...part }
return {
type: "file",
id: part.id,
uri: part.url,
name: part.filename,
mime: part.mime,
source:
part.source?.type === "resource"
? {
type: part.source.type,
clientName: part.source.clientName,
uri: part.source.uri,
text: { text: part.source.text.value, start: part.source.text.start, end: part.source.text.end },
}
: part.source && {
type: part.source.type,
path: part.source.path,
name: part.source.type === "symbol" ? part.source.name : undefined,
kind: part.source.type === "symbol" ? part.source.kind : undefined,
text: { text: part.source.text.value, start: part.source.text.start, end: part.source.text.end },
},
}
}
function messageClient(...responses: Array<MessageResponse | Promise<MessageResponse>>) { function messageClient(...responses: Array<MessageResponse | Promise<MessageResponse>>) {
let index = 0 let index = 0
const requests: unknown[] = [] const requests: unknown[] = []
@ -81,7 +200,7 @@ function messageClient(...responses: Array<MessageResponse | Promise<MessageResp
return responses[index++] return responses[index++]
}, },
}, },
} as unknown as OpencodeClient } as FixtureClient
return Object.assign(client, { return Object.assign(client, {
requests, requests,
requested(count: number) { requested(count: number) {
@ -114,7 +233,7 @@ function rootMessageClient(
return roots[rootIndex++] return roots[rootIndex++]
}, },
}, },
} as unknown as OpencodeClient } as FixtureClient
return Object.assign(client, { return Object.assign(client, {
requests, requests,
rootRequests, rootRequests,
@ -152,7 +271,7 @@ function setup(sessions: Record<string, Session>) {
}, },
diff: async () => ({ data: [] }), diff: async () => ({ data: [] }),
}, },
} as unknown as OpencodeClient } as FixtureClient
return { get, messages, store: createServerSession(client) } return { get, messages, store: createServerSession(client) }
} }
@ -163,7 +282,10 @@ describe("server session", () => {
const result = await ctx.store.lineage.resolve("child") const result = await ctx.store.lineage.resolve("child")
expect(result.root.id).toBe("root") expect(result.root.id).toBe("root")
expect(ctx.get).toEqual([{ sessionID: "child" }, { sessionID: "root" }]) expect(ctx.get).toEqual([
{ sessionID: "child", location: undefined },
{ sessionID: "root", location: undefined },
])
expect(ctx.store.lineage.peek("child")).toEqual(result) expect(ctx.store.lineage.peek("child")).toEqual(result)
}) })
@ -194,7 +316,9 @@ describe("server session", () => {
await store.sync("child") await store.sync("child")
expect(client.requests).toEqual([{ sessionID: "child", limit: 2, before: undefined }]) expect(client.requests).toEqual([{ sessionID: "child", limit: 2, before: undefined }])
expect(client.rootRequests).toEqual([{ sessionID: "child", messageID: user.id }]) expect(client.rootRequests).toEqual([
{ sessionID: "child", messageID: user.id, location: { directory: "/repo" } },
])
expect(store.data.message.child).toEqual([user, ...assistants]) expect(store.data.message.child).toEqual([user, ...assistants])
expect(store.history.more("child")).toBe(true) expect(store.history.more("child")).toBe(true)
}) })
@ -278,7 +402,9 @@ describe("server session", () => {
await store.sync("child", { force: true }) await store.sync("child", { force: true })
expect(client.rootRequests).toEqual([{ sessionID: "child", messageID: stale.id }]) expect(client.rootRequests).toEqual([
{ sessionID: "child", messageID: stale.id, location: { directory: "/repo" } },
])
expect(store.data.message.child).toEqual([fresh, assistant]) expect(store.data.message.child).toEqual([fresh, assistant])
expect(store.data.part[stale.id]).toEqual([freshPart]) expect(store.data.part[stale.id]).toEqual([freshPart])
}) })
@ -300,7 +426,9 @@ describe("server session", () => {
await store.sync("child", { force: true }) await store.sync("child", { force: true })
expect(client.rootRequests).toEqual([{ sessionID: "child", messageID: stale.id }]) expect(client.rootRequests).toEqual([
{ sessionID: "child", messageID: stale.id, location: { directory: "/repo" } },
])
expect(store.data.message.child).toEqual([fresh, assistant]) expect(store.data.message.child).toEqual([fresh, assistant])
expect(store.data.part[stale.id]).toEqual([refreshed, pending]) expect(store.data.part[stale.id]).toEqual([refreshed, pending])
}) })
@ -636,6 +764,21 @@ describe("server session", () => {
expect(store.data.part[message.id]).toEqual([fetched]) expect(store.data.part[message.id]).toEqual([fetched])
}) })
test("reconciles semantically identical native parts to optimistic IDs without duplicates", async () => {
const pending = deferredResponse()
const message = userMessage("message")
const optimistic = textPart(message.id, { id: "optimistic", text: "hello" })
const fetched = textPart(message.id, { id: "message:text", text: "hello" })
const store = createServerSession(messageClient(pending.promise))
const loading = store.sync("child")
store.optimistic.add({ sessionID: "child", message, parts: [optimistic] })
pending.resolve(response([{ info: message, parts: [fetched] }]))
await loading
expect(store.data.part[message.id]).toEqual([optimistic])
})
test("rolls back only unconfirmed optimistic parts", async () => { test("rolls back only unconfirmed optimistic parts", async () => {
const pending = deferredResponse() const pending = deferredResponse()
const message = userMessage("message") const message = userMessage("message")
@ -1207,6 +1350,7 @@ describe("server session", () => {
await store.history.loadMore("child") await store.history.loadMore("child")
guard.active = false
expect(store.data.message.child).toEqual([older, latest]) expect(store.data.message.child).toEqual([older, latest])
}) })
@ -1429,4 +1573,28 @@ describe("server session", () => {
expect(ctx.store.data.message.active?.map((message) => message.id)).toEqual(["message"]) expect(ctx.store.data.message.active?.map((message) => message.id)).toEqual(["message"])
expect(ctx.store.data.session_status["session-0"]).toBeUndefined() expect(ctx.store.data.session_status["session-0"]).toBeUndefined()
}) })
test("force-resyncs pinned sessions after a stream reconnect", async () => {
const first = userMessage("first")
const second = userMessage("second", { time: { created: 2 } })
let messages = response([{ info: first, parts: [] }])
let requests = 0
const store = createServerSession({
session: {
get: async () => ({ data: session("child") }),
messages: async () => {
requests++
return messages
},
},
})
store.pin("child")
await store.sync("child")
messages = response([{ info: first, parts: [] }, { info: second, parts: [] }])
await store.resync()
expect(requests).toBe(2)
expect(store.data.message.child.map((message) => message.id)).toEqual(["first", "second"])
})
}) })

View file

@ -1,15 +1,19 @@
import { Binary } from "@opencode-ai/core/util/binary" import { Binary } from "@opencode-ai/core/util/binary"
import { retry } from "@opencode-ai/core/util/retry" import { retry } from "@opencode-ai/core/util/retry"
import type { import type {
Message, AppClient,
OpencodeClient, AppEvent,
Part, AppFileDiff,
PermissionRequest, AppMessage,
QuestionRequest, AppPart,
Session, AppPermissionRequest,
SessionStatus, AppQuestionRequest,
FileDiffInfo, AppSession,
} from "@opencode-ai/sdk/v2/client" AppTodo,
LocationRef,
SessionActivity,
} from "./backend"
import { timelineMessage, timelineParts } from "./backend"
import { batch } from "solid-js" import { batch } from "solid-js"
import { createStore, produce, reconcile } from "solid-js/store" import { createStore, produce, reconcile } from "solid-js/store"
import { diffs as cleanDiffs, message as cleanMessage } from "@/utils/diffs" import { diffs as cleanDiffs, message as cleanMessage } from "@/utils/diffs"
@ -18,7 +22,7 @@ import { rootSession } from "@/utils/session-route"
import { dropSessionCaches, pickSessionCacheEvictions, SESSION_CACHE_LIMIT } from "./global-sync/session-cache" import { dropSessionCaches, pickSessionCacheEvictions, SESSION_CACHE_LIMIT } from "./global-sync/session-cache"
const cmp = (a: string, b: string) => (a < b ? -1 : a > b ? 1 : 0) const cmp = (a: string, b: string) => (a < b ? -1 : a > b ? 1 : 0)
const cmpMessage = (a: Message, b: Message) => a.time.created - b.time.created || cmp(a.id, b.id) const cmpMessage = (a: AppMessage, b: AppMessage) => a.time.created - b.time.created || cmp(a.id, b.id)
const SKIP_PARTS = new Set(["patch", "step-start", "step-finish"]) const SKIP_PARTS = new Set(["patch", "step-start", "step-finish"])
const initialMessagePageSize = 2 const initialMessagePageSize = 2
const historyMessagePageSize = 200 const historyMessagePageSize = 200
@ -26,15 +30,15 @@ const sessionInfoLimit = 2_048
const emptyIDs: ReadonlySet<string> = new Set() const emptyIDs: ReadonlySet<string> = new Set()
type OptimisticItem = { type OptimisticItem = {
message: Message message: AppMessage
parts: Part[] parts: AppPart[]
confirmedParts?: Part[] confirmedParts?: AppPart[]
confirmedMessage?: boolean confirmedMessage?: boolean
} }
type MessagePage = { type MessagePage = {
session: Message[] session: AppMessage[]
part: { id: string; part: Part[] }[] part: { id: string; part: AppPart[] }[]
cursor?: string cursor?: string
complete: boolean complete: boolean
} }
@ -59,22 +63,31 @@ type MessageLoadBaseline = Pick<
> >
function mergeOptimisticPage(page: MessagePage, items: OptimisticItem[]) { function mergeOptimisticPage(page: MessagePage, items: OptimisticItem[]) {
if (items.length === 0) return { ...page, observed: [] as { messageID: string; parts: Part[] }[] } if (items.length === 0) return { ...page, observed: [] as { messageID: string; parts: AppPart[] }[] }
const session = [...page.session] const session = [...page.session]
const part = new Map(page.part.map((item) => [item.id, item.part])) const part = new Map(page.part.map((item) => [item.id, item.part]))
const observed: { messageID: string; parts: Part[] }[] = [] const observed: { messageID: string; parts: AppPart[] }[] = []
for (const item of items) { for (const item of items) {
const result = Binary.search(session, item.message.id, (message) => message.id) const result = Binary.search(session, item.message.id, (message) => message.id)
if (!result.found) session.splice(result.index, 0, item.message) if (!result.found) session.splice(result.index, 0, item.message)
const current = part.get(item.message.id) const current = part.get(item.message.id) ?? []
const confirmed = result.found const matched = result.found
? item.parts.filter((part) => Binary.search(current ?? [], part.id, (value) => value.id).found) ? item.parts.flatMap((optimistic) => {
const fetched = current.find((value) => value.id === optimistic.id || samePromptPart(value, optimistic))
return fetched ? [{ optimistic, fetched }] : []
})
: [] : []
const confirmed = matched.map((value) => value.optimistic)
if (result.found) observed.push({ messageID: item.message.id, parts: confirmed }) if (result.found) observed.push({ messageID: item.message.id, parts: confirmed })
part.set( part.set(
item.message.id, item.message.id,
merge( merge(
result.found ? (current ?? []) : merge(item.confirmedParts ?? [], current ?? []), result.found
? current.map((value) => {
const match = matched.find((item) => item.fetched.id === value.id)
return match ? { ...value, id: match.optimistic.id } : value
})
: merge(item.confirmedParts ?? [], current),
item.parts.filter((part) => !confirmed.includes(part)), item.parts.filter((part) => !confirmed.includes(part)),
), ),
) )
@ -87,6 +100,14 @@ function mergeOptimisticPage(page: MessagePage, items: OptimisticItem[]) {
} }
} }
function samePromptPart(a: AppPart, b: AppPart) {
if (a.type !== b.type) return false
if (a.type === "text" && b.type === "text") return a.text === b.text
if (a.type === "file" && b.type === "file") return a.url === b.url && a.filename === b.filename && a.mime === b.mime
if (a.type === "agent" && b.type === "agent") return a.name === b.name
return false
}
function runInflight(map: Map<string, Promise<void>>, key: string, task: () => Promise<void>) { function runInflight(map: Map<string, Promise<void>>, key: string, task: () => Promise<void>) {
const pending = map.get(key) const pending = map.get(key)
if (pending) return pending if (pending) return pending
@ -134,21 +155,22 @@ function reconcileFetched<T extends { id: string }>(
return [...result.values()].sort((a, b) => cmp(a.id, b.id)) return [...result.values()].sort((a, b) => cmp(a.id, b.id))
} }
export function createServerSession(client: OpencodeClient, options?: { retry?: typeof retry }) { export function createServerSession(backend: Promise<AppClient> | AppClient, options?: { retry?: typeof retry }) {
const [data, setData] = createStore({ const [data, setData] = createStore({
info: {} as Record<string, Session | undefined>, info: {} as Record<string, AppSession | undefined>,
session_status: {} as Record<string, SessionStatus>, session_status: {} as Record<string, SessionActivity>,
session_diff: {} as Record<string, FileDiffInfo[]>, session_diff: {} as Record<string, AppFileDiff[]>,
permission: {} as Record<string, PermissionRequest[]>, todo: {} as Record<string, AppTodo[]>,
question: {} as Record<string, QuestionRequest[]>, permission: {} as Record<string, AppPermissionRequest[]>,
message: {} as Record<string, Message[]>, question: {} as Record<string, AppQuestionRequest[]>,
part: {} as Record<string, Part[]>, message: {} as Record<string, AppMessage[]>,
part: {} as Record<string, AppPart[]>,
part_text_accum_delta: {} as Record<string, string>, part_text_accum_delta: {} as Record<string, string>,
session_working(id: string) { session_working(id: string) {
return (this.session_status[id]?.type ?? "idle") !== "idle" return (this.session_status[id]?.type ?? "idle") !== "idle"
}, },
}) })
const requests = new Map<string, Promise<Session>>() const requests = new Map<string, Promise<AppSession>>()
const inflight = new Map<string, Promise<void>>() const inflight = new Map<string, Promise<void>>()
const inflightDiff = new Map<string, Promise<void>>() const inflightDiff = new Map<string, Promise<void>>()
const optimistic = new Map<string, Map<string, OptimisticItem>>() const optimistic = new Map<string, Map<string, OptimisticItem>>()
@ -158,7 +180,7 @@ export function createServerSession(client: OpencodeClient, options?: { retry?:
const removedMessages = new Map<string, Set<string>>() const removedMessages = new Map<string, Set<string>>()
const deltaBases = new Map<string, { base: string; sessionID: string }>() const deltaBases = new Map<string, { base: string; sessionID: string }>()
const deleteMessageParts = ( const deleteMessageParts = (
cache: { part: Record<string, Part[] | undefined>; part_text_accum_delta: Record<string, string | undefined> }, cache: { part: Record<string, AppPart[] | undefined>; part_text_accum_delta: Record<string, string | undefined> },
messageID: string, messageID: string,
) => { ) => {
for (const part of cache.part[messageID] ?? []) { for (const part of cache.part[messageID] ?? []) {
@ -186,7 +208,15 @@ export function createServerSession(client: OpencodeClient, options?: { retry?:
at: {} as Record<string, number | undefined>, at: {} as Record<string, number | undefined>,
}) })
const remember = (session: Session) => { const locations = new Map<string, LocationRef>()
const location = (sessionID: string) => {
const session = data.info[sessionID]
return session?.location ?? locations.get(sessionID) ?? (session?.directory ? { directory: session.directory } : undefined)
}
const remember = (session: AppSession) => {
if (session.location) locations.set(session.id, session.location)
if (session.parentID && session.location) locations.set(session.parentID, session.location)
setData("info", session.id, reconcile(session)) setData("info", session.id, reconcile(session))
infoSeen.delete(session.id) infoSeen.delete(session.id)
infoSeen.add(session.id) infoSeen.add(session.id)
@ -236,11 +266,13 @@ export function createServerSession(client: OpencodeClient, options?: { retry?:
const pending = requests.get(sessionID) const pending = requests.get(sessionID)
if (pending) return pending if (pending) return pending
const active = generation(sessionID) const active = generation(sessionID)
const request = client.session.get({ sessionID }).then((result) => { const request = Promise.resolve(backend)
if (!result.data) throw sessionNotFoundError(sessionID) .then((client) => client.common.sessions.get({ sessionID, location: location(sessionID) }))
if (generations.get(sessionID) !== active) return result.data .then((result) => {
return remember(result.data) if (!result) throw sessionNotFoundError(sessionID)
}) if (generations.get(sessionID) !== active) return result
return remember(result)
})
requests.set(sessionID, request) requests.set(sessionID, request)
const cleanup = () => { const cleanup = () => {
if (requests.get(sessionID) === request) requests.delete(sessionID) if (requests.get(sessionID) === request) requests.delete(sessionID)
@ -297,7 +329,7 @@ export function createServerSession(client: OpencodeClient, options?: { retry?:
items.set(messageID, { ...item, parts, confirmedParts, confirmedMessage: true }) items.set(messageID, { ...item, parts, confirmedParts, confirmedMessage: true })
} }
const confirmOptimisticPart = (sessionID: string, messageID: string, part: Part) => { const confirmOptimisticPart = (sessionID: string, messageID: string, part: AppPart) => {
const items = optimistic.get(sessionID) const items = optimistic.get(sessionID)
const item = items?.get(messageID) const item = items?.get(messageID)
if (!items || !item) return if (!items || !item) return
@ -314,7 +346,7 @@ export function createServerSession(client: OpencodeClient, options?: { retry?:
}) })
} }
const confirmOptimistic = (sessionID: string, messageID: string, confirmedParts: Part[]) => { const confirmOptimistic = (sessionID: string, messageID: string, confirmedParts: AppPart[]) => {
const items = optimistic.get(sessionID) const items = optimistic.get(sessionID)
const item = items?.get(messageID) const item = items?.get(messageID)
if (!items || !item) return if (!items || !item) return
@ -461,35 +493,38 @@ export function createServerSession(client: OpencodeClient, options?: { retry?:
) )
const fetchMessages = async (sessionID: string, limit: number, before?: string, onAttempt?: () => void) => { const fetchMessages = async (sessionID: string, limit: number, before?: string, onAttempt?: () => void) => {
const response = await (options?.retry ?? retry)(() => { const response = await (options?.retry ?? retry)(async () => {
onAttempt?.() onAttempt?.()
return client.session.messages({ sessionID, limit, before }) return (await backend).common.sessions.history({ sessionID, limit, cursor: before, location: location(sessionID) })
}) })
const items = (response.data ?? []).filter((item) => !!item?.info?.id) const items = response.items
.map((item) => ({ message: timelineMessage(item), parts: timelineParts(item) }))
.filter((item): item is { message: AppMessage; parts: AppPart[] } => !!item.message)
return { return {
session: items.map((item) => cleanMessage(item.info)).sort((a, b) => cmp(a.id, b.id)), session: items.map((item) => cleanMessage(item.message)).sort((a, b) => cmp(a.id, b.id)),
part: items.map((item) => ({ part: items.map((item) => ({
id: item.info.id, id: item.message.id,
part: item.parts.filter((part) => !!part?.id).sort((a, b) => cmp(a.id, b.id)), part: item.parts.sort((a, b) => cmp(a.id, b.id)),
})), })),
cursor: response.response.headers.get("x-next-cursor") ?? undefined, cursor: response.older,
complete: !response.response.headers.get("x-next-cursor"), complete: !response.older,
} }
} }
const fetchMessage = async (sessionID: string, messageID: string, onAttempt?: () => void) => { const fetchMessage = async (sessionID: string, messageID: string, onAttempt?: () => void) => {
const response = await (options?.retry ?? retry)(() => { const response = await (options?.retry ?? retry)(async () => {
onAttempt?.() onAttempt?.()
return client.session.message({ sessionID, messageID }) return (await backend).common.sessions.message({ sessionID, messageID, location: location(sessionID) })
}) })
if (!response.data?.info?.id) throw new Error(`Message not found: ${messageID}`) const message = timelineMessage(response)
if (!message) throw new Error(`Message not found: ${messageID}`)
return { return {
message: cleanMessage(response.data.info), message: cleanMessage(message),
parts: response.data.parts.filter((part) => !!part?.id).sort((a, b) => cmp(a.id, b.id)), parts: timelineParts(response).sort((a, b) => cmp(a.id, b.id)),
} }
} }
const replaceMessages = (sessionID: string, messages: Message[]) => { const replaceMessages = (sessionID: string, messages: AppMessage[]) => {
const messageIDs = new Set(messages.map((message) => message.id)) const messageIDs = new Set(messages.map((message) => message.id))
const dropped = (data.message[sessionID] ?? []).filter((message) => !messageIDs.has(message.id)) const dropped = (data.message[sessionID] ?? []).filter((message) => !messageIDs.has(message.id))
setData("message", sessionID, reconcile(messages, { key: "id" })) setData("message", sessionID, reconcile(messages, { key: "id" }))
@ -559,7 +594,7 @@ export function createServerSession(client: OpencodeClient, options?: { retry?:
sessionID: string, sessionID: string,
page: MessagePage, page: MessagePage,
load: MessageLoadState | undefined, load: MessageLoadState | undefined,
preserveUnfetched: boolean | ((message: Message) => boolean), preserveUnfetched: boolean | ((message: AppMessage) => boolean),
cleanupOrphans: boolean, cleanupOrphans: boolean,
) => { ) => {
const merged = mergeOptimisticPage(page, [...(optimistic.get(sessionID)?.values() ?? [])]) const merged = mergeOptimisticPage(page, [...(optimistic.get(sessionID)?.values() ?? [])])
@ -609,7 +644,7 @@ export function createServerSession(client: OpencodeClient, options?: { retry?:
let applied = false let applied = false
try { try {
const page = await fetchMessages(sessionID, limit, before, () => resetMessageLoad(sessionID, load)) const page = await fetchMessages(sessionID, limit, before, () => resetMessageLoad(sessionID, load))
const first = page.session.reduce<Message | undefined>( const first = page.session.reduce<AppMessage | undefined>(
(oldest, message) => (!oldest || cmpMessage(message, oldest) < 0 ? message : oldest), (oldest, message) => (!oldest || cmpMessage(message, oldest) < 0 ? message : oldest),
undefined, undefined,
) )
@ -630,7 +665,7 @@ export function createServerSession(client: OpencodeClient, options?: { retry?:
const parentIDs = [ const parentIDs = [
...new Set( ...new Set(
page.session.flatMap((message) => page.session.flatMap((message) =>
message.role === "assistant" && !users.has(message.parentID) ? [message.parentID] : [], message.role === "assistant" && message.parentID && !users.has(message.parentID) ? [message.parentID] : [],
), ),
), ),
] ]
@ -659,7 +694,7 @@ export function createServerSession(client: OpencodeClient, options?: { retry?:
), ),
} }
const preserveUnfetched = const preserveUnfetched =
mode === "prepend" || (!result.complete && (!first || ((message: Message) => cmpMessage(message, first) < 0))) mode === "prepend" || (!result.complete && (!first || ((message: AppMessage) => cmpMessage(message, first) < 0)))
applyMessagePage( applyMessagePage(
sessionID, sessionID,
result, result,
@ -682,7 +717,11 @@ export function createServerSession(client: OpencodeClient, options?: { retry?:
} }
} }
const sync = (sessionID: string, options?: { force?: boolean; messageLimit?: number }) => { const sync = (
sessionID: string,
options?: { force?: boolean; messageLimit?: number; location?: LocationRef },
) => {
if (options?.location) locations.set(sessionID, options.location)
touch(sessionID) touch(sessionID)
return runInflight(inflight, sessionID, async () => { return runInflight(inflight, sessionID, async () => {
const cached = data.message[sessionID] !== undefined && meta.limit[sessionID] !== undefined const cached = data.message[sessionID] !== undefined && meta.limit[sessionID] !== undefined
@ -729,7 +768,7 @@ export function createServerSession(client: OpencodeClient, options?: { retry?:
return properties.part.sessionID return properties.part.sessionID
} }
const apply = (event: { type: string; properties?: unknown }) => { const applyLegacy = (event: { type: string; properties?: unknown }) => {
const eventID = eventSessionID(event) const eventID = eventSessionID(event)
if (eventID) { if (eventID) {
touch(eventID) touch(eventID)
@ -743,16 +782,16 @@ export function createServerSession(client: OpencodeClient, options?: { retry?:
} }
switch (event.type) { switch (event.type) {
case "session.created": case "session.created":
remember((event.properties as { info: Session }).info) remember((event.properties as { info: AppSession }).info)
return return
case "session.updated": { case "session.updated": {
const info = (event.properties as { info: Session }).info const info = (event.properties as { info: AppSession }).info
remember(info) remember(info)
if (info.time.archived) evict([info.id]) if (info.time.archived) evict([info.id])
return return
} }
case "session.deleted": { case "session.deleted": {
const sessionID = (event.properties as { info: Session }).info.id const sessionID = (event.properties as { sessionID: string }).sessionID
infoSeen.delete(sessionID) infoSeen.delete(sessionID)
setData( setData(
"info", "info",
@ -762,17 +801,22 @@ export function createServerSession(client: OpencodeClient, options?: { retry?:
return return
} }
case "session.diff": { case "session.diff": {
const props = event.properties as { sessionID: string; diff: FileDiffInfo[] } const props = event.properties as { sessionID: string; diff: AppFileDiff[] }
setData("session_diff", props.sessionID, reconcile(cleanDiffs(props.diff), { key: "file" })) setData("session_diff", props.sessionID, reconcile(cleanDiffs(props.diff), { key: "file" }))
return return
} }
case "todo.updated": {
const props = event.properties as { sessionID: string; todos: AppTodo[] }
setData("todo", props.sessionID, reconcile(props.todos, { key: "id" }))
return
}
case "session.status": { case "session.status": {
const props = event.properties as { sessionID: string; status: SessionStatus } const props = event.properties as { sessionID: string; status: SessionActivity }
setData("session_status", props.sessionID, reconcile(props.status)) setData("session_status", props.sessionID, reconcile(props.status))
return return
} }
case "message.updated": { case "message.updated": {
const info = cleanMessage((event.properties as { info: Message }).info) const info = cleanMessage((event.properties as { info: AppMessage }).info)
const load = messageLoads.get(info.sessionID) const load = messageLoads.get(info.sessionID)
load?.touchedMessages.add(info.id) load?.touchedMessages.add(info.id)
load?.removedMessages.delete(info.id) load?.removedMessages.delete(info.id)
@ -832,7 +876,7 @@ export function createServerSession(client: OpencodeClient, options?: { retry?:
return return
} }
case "message.part.updated": { case "message.part.updated": {
const part = (event.properties as { part: Part }).part const part = (event.properties as { part: AppPart }).part
if (SKIP_PARTS.has(part.type)) return if (SKIP_PARTS.has(part.type)) return
const messages = data.message[part.sessionID] const messages = data.message[part.sessionID]
const load = messageLoads.get(part.sessionID) const load = messageLoads.get(part.sessionID)
@ -971,7 +1015,7 @@ export function createServerSession(client: OpencodeClient, options?: { retry?:
return return
} }
case "permission.asked": { case "permission.asked": {
const permission = event.properties as PermissionRequest const permission = event.properties as AppPermissionRequest
const permissions = data.permission[permission.sessionID] const permissions = data.permission[permission.sessionID]
if (!permissions) { if (!permissions) {
setData("permission", permission.sessionID, [permission]) setData("permission", permission.sessionID, [permission])
@ -1001,7 +1045,7 @@ export function createServerSession(client: OpencodeClient, options?: { retry?:
return return
} }
case "question.asked": { case "question.asked": {
const question = event.properties as QuestionRequest const question = event.properties as AppQuestionRequest
const questions = data.question[question.sessionID] const questions = data.question[question.sessionID]
if (!questions) { if (!questions) {
setData("question", question.sessionID, [question]) setData("question", question.sessionID, [question])
@ -1033,6 +1077,118 @@ export function createServerSession(client: OpencodeClient, options?: { retry?:
} }
} }
const apply = (event: AppEvent | { type: string; properties: unknown }, eventLocation?: LocationRef) => {
if ("properties" in event) {
applyLegacy(event)
return
}
const sessionID =
event.type === "session.created" || event.type === "session.updated"
? event.session.id
: "sessionID" in event && typeof event.sessionID === "string"
? event.sessionID
: event.type === "timeline.updated"
? event.item.sessionID
: undefined
if (sessionID && eventLocation) locations.set(sessionID, eventLocation)
if (event.type === "session.created" || event.type === "session.updated") {
applyLegacy({ type: event.type, properties: { info: event.session } })
return
}
if (event.type === "session.deleted") {
applyLegacy({ type: event.type, properties: { sessionID: event.sessionID } })
return
}
if (event.type === "session.moved") {
const current = data.info[event.sessionID]
if (!current) return
remember({
...current,
location: event.location,
directory: event.location.directory,
workspaceID: event.location.workspaceID,
})
return
}
if (event.type === "session.revert") {
const current = data.info[event.sessionID]
if (current) remember({ ...current, revert: event.revert })
return
}
if (event.type === "session.activity") {
applyLegacy({ type: "session.status", properties: { sessionID: event.sessionID, status: event.activity } })
if (event.item) apply({ type: "timeline.updated", item: event.item }, eventLocation)
return
}
if (event.type === "session.diff" || event.type === "todo.updated") {
applyLegacy({ type: event.type, properties: event })
return
}
if (event.type === "timeline.updated") {
const message = timelineMessage(event.item)
if (!message) return
applyLegacy({ type: "message.updated", properties: { info: message } })
timelineParts(event.item).forEach((part) =>
applyLegacy({ type: "message.part.updated", properties: { part } }),
)
return
}
if (event.type === "timeline.content.updated") {
const message = data.message[event.sessionID]?.find((item) => item.id === event.itemID)
if (!message) return
const part = timelineParts({
type: message.role,
id: message.id,
sessionID: message.sessionID,
created: message.time.created,
completed: message.role === "assistant" ? message.time.completed : undefined,
content: [event.content],
})[0]
if (part) applyLegacy({ type: "message.part.updated", properties: { part } })
return
}
if (event.type === "timeline.removed") {
applyLegacy({
type: "message.removed",
properties: { sessionID: event.sessionID, messageID: event.itemID },
})
return
}
if (event.type === "timeline.part.removed") {
applyLegacy({
type: "message.part.removed",
properties: {
sessionID: event.sessionID,
messageID: event.itemID,
partID: event.contentID,
},
})
return
}
if (event.type === "timeline.delta") {
applyLegacy({
type: "message.part.delta",
properties: {
sessionID: event.sessionID,
messageID: event.itemID,
partID: event.contentID,
field: event.field,
delta: event.delta,
},
})
return
}
if (event.type === "permission.requested") {
applyLegacy({ type: "permission.asked", properties: event.request })
return
}
if (event.type === "permission.replied" || event.type === "question.replied" || event.type === "question.rejected") {
applyLegacy({ type: event.type, properties: event })
return
}
if (event.type === "question.requested") applyLegacy({ type: "question.asked", properties: event.request })
}
return { return {
data, data,
set: setData, set: setData,
@ -1059,7 +1215,7 @@ export function createServerSession(client: OpencodeClient, options?: { retry?:
return Date.now() - (meta.at[sessionID] ?? 0) <= ttl return Date.now() - (meta.at[sessionID] ?? 0) <= ttl
}, },
optimistic: { optimistic: {
add(input: { sessionID: string; message: Message; parts: Part[] }) { add(input: { sessionID: string; message: AppMessage; parts: AppPart[] }) {
const parts = input.parts const parts = input.parts
.filter((part) => !!part?.id && !SKIP_PARTS.has(part.type)) .filter((part) => !!part?.id && !SKIP_PARTS.has(part.type))
.sort((a, b) => cmp(a.id, b.id)) .sort((a, b) => cmp(a.id, b.id))
@ -1122,9 +1278,14 @@ export function createServerSession(client: OpencodeClient, options?: { retry?:
if (data.session_diff[sessionID] !== undefined && !options?.force) return Promise.resolve() if (data.session_diff[sessionID] !== undefined && !options?.force) return Promise.resolve()
return runInflight(inflightDiff, sessionID, () => { return runInflight(inflightDiff, sessionID, () => {
const active = generation(sessionID) const active = generation(sessionID)
return retry(() => client.session.diff({ sessionID })).then((result) => { return retry(async () => {
const client = await backend
const capability = client.capabilities.sessionExtrasV1 ?? client.capabilities.sessionExtrasV2
if (!capability?.diff) return []
return capability.diff({ sessionID, location: location(sessionID) })
}).then((result) => {
if (generations.get(sessionID) !== active) return if (generations.get(sessionID) !== active) return
setData("session_diff", sessionID, reconcile(cleanDiffs(result.data), { key: "file" })) setData("session_diff", sessionID, reconcile(cleanDiffs(result), { key: "file" }))
}) })
}) })
}, },
@ -1155,6 +1316,14 @@ export function createServerSession(client: OpencodeClient, options?: { retry?:
if (!count || count === 1) pinned.delete(sessionID) if (!count || count === 1) pinned.delete(sessionID)
if (count && count > 1) pinned.set(sessionID, count - 1) if (count && count > 1) pinned.set(sessionID, count - 1)
}, },
async resync() {
const sessionIDs = new Set([
...pinned.keys(),
...optimistic.keys(),
...Object.entries(data.session_status).flatMap(([sessionID, status]) => status.type === "idle" ? [] : [sessionID]),
])
await Promise.all([...sessionIDs].map((sessionID) => sync(sessionID, { force: true })))
},
apply, apply,
} }
} }

View file

@ -1,11 +1,11 @@
import type { import type {
Config, AppClient,
McpResource, AppConfig,
OpencodeClient, AppMcpResource,
Path, AppPathInfo,
Project, AppProject,
ProviderAuthResponse, AppProviderAuthResponse,
} from "@opencode-ai/sdk/v2/client" } from "./backend"
import { showToast } from "@/utils/toast" import { showToast } from "@/utils/toast"
import { getFilename } from "@opencode-ai/core/util/path" import { getFilename } from "@opencode-ai/core/util/path"
import { type Accessor, batch, createMemo, getOwner, onCleanup, onMount, untrack } from "solid-js" import { type Accessor, batch, createMemo, getOwner, onCleanup, onMount, untrack } from "solid-js"
@ -28,7 +28,7 @@ import { createChildStoreManager } from "./global-sync/child-store"
import { applyDirectoryEvent, applyGlobalEvent } from "./global-sync/event-reducer" import { applyDirectoryEvent, applyGlobalEvent } from "./global-sync/event-reducer"
import { estimateRootSessionTotal, loadRootSessionsWithFallback } from "./global-sync/session-load" import { estimateRootSessionTotal, loadRootSessionsWithFallback } from "./global-sync/session-load"
import { trimSessions } from "./global-sync/session-trim" import { trimSessions } from "./global-sync/session-trim"
import type { ProjectMeta } from "./global-sync/types" import type { ProjectMeta, ProviderStore, StoreConfig } from "./global-sync/types"
import { SESSION_RECENT_LIMIT } from "./global-sync/types" import { SESSION_RECENT_LIMIT } from "./global-sync/types"
import { formatServerError } from "@/utils/server-errors" import { formatServerError } from "@/utils/server-errors"
import { queryOptions, useMutation, useQueries, useQuery, useQueryClient } from "@tanstack/solid-query" import { queryOptions, useMutation, useQueries, useQuery, useQueryClient } from "@tanstack/solid-query"
@ -37,7 +37,6 @@ import { directoryKey } from "./global-sync/utils"
import { PathKey } from "@/utils/path-key" import { PathKey } from "@/utils/path-key"
import { createDirSyncContext } from "./directory-sync" import { createDirSyncContext } from "./directory-sync"
import { createSimpleContext } from "@opencode-ai/ui/context" import { createSimpleContext } from "@opencode-ai/ui/context"
import { NormalizedProviderListResponse } from "@opencode-ai/session-ui/context"
import { createRefCountMap } from "@/utils/refcount" import { createRefCountMap } from "@/utils/refcount"
import { useGlobal } from "./global" import { useGlobal } from "./global"
import { ServerConnection, useServer } from "./server" import { ServerConnection, useServer } from "./server"
@ -47,53 +46,63 @@ import { persisted } from "@/utils/persist"
import { toggleMcp } from "./global-sync/mcp" import { toggleMcp } from "./global-sync/mcp"
import { createServerSession } from "./server-session" import { createServerSession } from "./server-session"
type GlobalConfigUpdate = Pick<AppConfig, "shell" | "provider" | "disabledProviders">
type GlobalStore = { type GlobalStore = {
ready: boolean ready: boolean
error?: InitError error?: InitError
path: Path path: AppPathInfo
project: Project[] project: AppProject[]
provider: NormalizedProviderListResponse provider: ProviderStore
provider_auth: ProviderAuthResponse provider_auth: AppProviderAuthResponse
config: Config config: StoreConfig
reload: undefined | "pending" | "complete" reload: undefined | "pending" | "complete"
} }
export const loadMcpQuery = (scope: ServerScope, directory: string, sdk: OpencodeClient) => export const loadMcpQuery = (scope: ServerScope, directory: string, backend: Promise<AppClient>) =>
queryOptions({ queryOptions({
queryKey: [scope, directory, "mcp"] as const, queryKey: [scope, directory, "mcp"] as const,
queryFn: () => sdk.mcp.status().then((r) => r.data ?? {}), queryFn: () =>
backend
.then((client) => client.capabilities.mcp?.list({ location: { directory } }) ?? [])
.then((servers) => Object.fromEntries(servers.map((server) => [server.name, server.status]))),
}) })
export const loadMcpResourcesQuery = (scope: ServerScope, directory: string, sdk: OpencodeClient) => export const loadMcpResourcesQuery = (scope: ServerScope, directory: string, backend: Promise<AppClient>) =>
queryOptions<Record<string, McpResource>>({ queryOptions<Record<string, AppMcpResource>>({
queryKey: [scope, directory, "mcpResources"] as const, queryKey: [scope, directory, "mcpResources"] as const,
queryFn: () => sdk.experimental.resource.list().then((r) => r.data ?? {}), queryFn: () =>
backend
.then(
(client) =>
client.capabilities.mcp?.resources({ location: { directory } }) ?? { resources: [], templates: [] },
)
.then((result) =>
Object.fromEntries(result.resources.map((resource) => [`${resource.server}:${resource.uri}`, resource])),
),
placeholderData: {}, placeholderData: {},
}) })
export const loadLspQuery = (scope: ServerScope, directory: string, sdk: OpencodeClient) => export const loadLspQuery = (scope: ServerScope, directory: string, backend: Promise<AppClient>) =>
queryOptions({ queryOptions({
queryKey: [scope, directory, "lsp"] as const, queryKey: [scope, directory, "lsp"] as const,
queryFn: () => sdk.lsp.status().then((r) => r.data ?? []), queryFn: async () => {
const client = await backend
return client.capabilities.lsp?.status({ location: { directory } }) ?? []
},
}) })
function makeQueryOptionsApi( function makeQueryOptionsApi(scope: ServerScope, backend: Promise<AppClient>) {
scope: ServerScope,
serverSDK: () => OpencodeClient,
sdkFor: (dir: PathKey) => OpencodeClient,
) {
return { return {
globalConfig: () => loadGlobalConfigQuery(scope, serverSDK()), globalConfig: () => loadGlobalConfigQuery(scope, backend),
projects: () => loadProjectsQuery(scope, serverSDK()), projects: () => loadProjectsQuery(scope, backend),
providers: (directory: PathKey | null) => providers: (directory: PathKey | null) => loadProvidersQuery(scope, directory, backend),
loadProvidersQuery(scope, directory, directory === null ? serverSDK() : sdkFor(directory)), path: (directory: PathKey | null) => loadPathQuery(scope, directory, backend),
path: (directory: PathKey | null) => agents: (directory: PathKey) => loadAgentsQuery(scope, directory, backend),
loadPathQuery(scope, directory, directory === null ? serverSDK() : sdkFor(directory)), references: (directory: PathKey) => loadReferencesQuery(scope, directory, backend),
agents: (directory: PathKey) => loadAgentsQuery(scope, directory, sdkFor(directory)), mcp: (directory: PathKey) => loadMcpQuery(scope, directory, backend),
references: (directory: PathKey) => loadReferencesQuery(scope, directory, sdkFor(directory)), mcpResources: (directory: PathKey) => loadMcpResourcesQuery(scope, directory, backend),
mcp: (directory: PathKey) => loadMcpQuery(scope, directory, sdkFor(directory)), lsp: (directory: PathKey) => loadLspQuery(scope, directory, backend),
mcpResources: (directory: PathKey) => loadMcpResourcesQuery(scope, directory, sdkFor(directory)),
lsp: (directory: PathKey) => loadLspQuery(scope, directory, sdkFor(directory)),
sessions: (directory: PathKey) => ({ queryKey: [scope, directory, "loadSessions"] as const }), sessions: (directory: PathKey) => ({ queryKey: [scope, directory, "loadSessions"] as const }),
} }
} }
@ -104,24 +113,11 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
const owner = getOwner() const owner = getOwner()
if (!owner) throw new Error("ServerSync must be created within owner") if (!owner) throw new Error("ServerSync must be created within owner")
const sdkCache = new Map<string, OpencodeClient>()
const booting = new Map<string, Promise<void>>() const booting = new Map<string, Promise<void>>()
const sessionLoads = new Map<string, Promise<void>>() const sessionLoads = new Map<string, Promise<void>>()
const sessionMeta = new Map<string, { limit: number }>() const sessionMeta = new Map<string, { limit: number }>()
const sdkFor = (directory: string) => { const queryOptionsApi = makeQueryOptionsApi(serverSDK.scope, serverSDK.backend)
const key = directoryKey(directory)
const cached = sdkCache.get(key)
if (cached) return cached
const sdk = serverSDK.createClient({
directory,
throwOnError: true,
})
sdkCache.set(key, sdk)
return sdk
}
const queryOptionsApi = makeQueryOptionsApi(serverSDK.scope, () => serverSDK.client, sdkFor)
const [configQuery, providerQuery, pathQuery] = useQueries(() => ({ const [configQuery, providerQuery, pathQuery] = useQueries(() => ({
queries: [queryOptionsApi.globalConfig(), queryOptionsApi.providers(null), queryOptionsApi.path(null)], queries: [queryOptionsApi.globalConfig(), queryOptionsApi.providers(null), queryOptionsApi.path(null)],
@ -164,13 +160,13 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
if (eventTimer !== undefined) clearTimeout(eventTimer) if (eventTimer !== undefined) clearTimeout(eventTimer)
}) })
const setProjects = (next: Project[] | ((draft: Project[]) => Project[])) => { const setProjects = (next: AppProject[] | ((draft: AppProject[]) => AppProject[])) => {
setGlobalStore("project", next) setGlobalStore("project", next)
} }
const setBootStore = ((...input: unknown[]) => { const setBootStore = ((...input: unknown[]) => {
if (input[0] === "project" && Array.isArray(input[1])) { if (input[0] === "project" && Array.isArray(input[1])) {
setProjects(input[1] as Project[]) setProjects(input[1] as AppProject[])
return input[1] return input[1]
} }
return (setGlobalStore as (...args: unknown[]) => unknown)(...input) return (setGlobalStore as (...args: unknown[]) => unknown)(...input)
@ -180,7 +176,7 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
queryKey: [serverSDK.scope, "bootstrap"], queryKey: [serverSDK.scope, "bootstrap"],
queryFn: async () => { queryFn: async () => {
await bootstrapGlobal({ await bootstrapGlobal({
serverSDK: serverSDK.client, backend: serverSDK.backend,
scope: serverSDK.scope, scope: serverSDK.scope,
requestFailedTitle: language.t("common.requestFailed"), requestFailedTitle: language.t("common.requestFailed"),
translate: language.t, translate: language.t,
@ -195,7 +191,7 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
const set = ((...input: unknown[]) => { const set = ((...input: unknown[]) => {
if (input[0] === "project" && (Array.isArray(input[1]) || typeof input[1] === "function")) { if (input[0] === "project" && (Array.isArray(input[1]) || typeof input[1] === "function")) {
setProjects(input[1] as Project[] | ((draft: Project[]) => Project[])) setProjects(input[1] as AppProject[] | ((draft: AppProject[]) => AppProject[]))
return input[1] return input[1]
} }
return (setGlobalStore as (...args: unknown[]) => unknown)(...input) return (setGlobalStore as (...args: unknown[]) => unknown)(...input)
@ -210,7 +206,7 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
bootstrapInstance, bootstrapInstance,
}) })
const session = createServerSession(serverSDK.client) const session = createServerSession(serverSDK.backend)
const children = createChildStoreManager({ const children = createChildStoreManager({
owner, owner,
@ -223,9 +219,9 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
}, },
onMcp: (directory, setStore) => { onMcp: (directory, setStore) => {
void retry(() => void retry(() =>
sdkFor(directory) serverSDK.backend
.command.list() .then((client) => client.common.commands.list({ location: { directory } }))
.then((x) => setStore("command", x.data ?? [])), .then((commands) => setStore("command", reconcile(commands))),
).catch((err) => { ).catch((err) => {
showToast({ showToast({
variant: "error", variant: "error",
@ -238,7 +234,6 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
const key = directoryKey(directory) const key = directoryKey(directory)
queue.clear(key) queue.clear(key)
sessionMeta.delete(key) sessionMeta.delete(key)
sdkCache.delete(key)
clearProviderRev(serverSDK.scope, key) clearProviderRev(serverSDK.scope, key)
}, },
translate: language.t, translate: language.t,
@ -280,7 +275,12 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
loadRootSessionsWithFallback({ loadRootSessionsWithFallback({
directory, directory,
limit, limit,
list: (query) => serverSDK.client.session.list(query), list: (query) =>
serverSDK.backend.then((client) =>
client.common.sessions
.list({ location: { directory: query.directory }, roots: query.roots, limit: query.limit })
.then((page) => ({ data: [...page.items] })),
),
}) })
.then((x) => { .then((x) => {
const nonArchived = (x.data ?? []) const nonArchived = (x.data ?? [])
@ -339,7 +339,7 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
const child = children.ensureChild(directory) const child = children.ensureChild(directory)
const cache = children.vcsCache.get(key) const cache = children.vcsCache.get(key)
if (!cache) return if (!cache) return
const sdk = sdkFor(directory) const backend = await serverSDK.backend
await bootstrapDirectory({ await bootstrapDirectory({
directory, directory,
scope: serverSDK.scope, scope: serverSDK.scope,
@ -350,7 +350,7 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
project: globalStore.project, project: globalStore.project,
provider: globalStore.provider, provider: globalStore.provider,
}, },
sdk, backend,
store: child[0], store: child[0],
setStore: child[1], setStore: child[1],
vcsCache: cache, vcsCache: cache,
@ -375,7 +375,14 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
const event = e.details const event = e.details
const recent = bootingRoot || Date.now() - bootedAt < 1500 const recent = bootingRoot || Date.now() - bootedAt < 1500
session.apply(event) session.apply(event, directory === "global" ? undefined : { directory })
if (event.type === "server.connected") void session.resync()
if (event.type === "provider.updated") {
if (!recent) bootstrap.refetch()
if (directory !== "global") queue.push(directory)
return
}
if (directory === "global") { if (directory === "global") {
applyGlobalEvent({ applyGlobalEvent({
@ -387,7 +394,7 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
}, },
setGlobalProject: setProjects, setGlobalProject: setProjects,
}) })
if (event.type === "server.connected" || event.type === "global.disposed") { if (event.type === "server.connected" || event.type === "server.disposed") {
if (recent) return if (recent) return
for (const directory of Object.keys(children.children)) { for (const directory of Object.keys(children.children)) {
queue.push(directory) queue.push(directory)
@ -457,7 +464,16 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
} }
const updateConfigMutation = useMutation(() => ({ const updateConfigMutation = useMutation(() => ({
mutationFn: (config: Config) => serverSDK.client.global.config.update({ config }), mutationFn: async (config: GlobalConfigUpdate) => {
const backend = await serverSDK.backend
const capability = backend.capabilities.configuration
if (!capability) throw new Error("Server does not support configuration updates")
await capability.updateGlobal({
shell: config.shell,
provider: config.provider,
disabledProviders: config.disabledProviders,
})
},
onSuccess: () => { onSuccess: () => {
bootstrap.refetch() bootstrap.refetch()
// Invalidate all provider queries so newly configured custom providers // Invalidate all provider queries so newly configured custom providers
@ -484,23 +500,32 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
queryOptions: queryOptionsApi, queryOptions: queryOptionsApi,
// bootstrap, // bootstrap,
updateConfig: updateConfigMutation.mutateAsync, updateConfig: updateConfigMutation.mutateAsync,
refreshProviders: async () => {
await queryClient.invalidateQueries({
predicate: (query) => query.queryKey[0] === serverSDK.scope && query.queryKey[2] === "providers",
})
await bootstrap.refetch()
},
project: projectApi, project: projectApi,
session, session,
mcp: { mcp: {
toggle: async (directory: string, name: string) => { toggle: async (directory: string, name: string) => {
const key = directoryKey(directory) const key = directoryKey(directory)
const sdk = sdkFor(key) const backend = await serverSDK.backend
const capability = backend.capabilities.mcpControl
if (!capability) throw new Error("Server does not support MCP controls")
const location = { location: { directory: key } }
const status = children.child(key, { bootstrap: false })[0].mcp[name].status const status = children.child(key, { bootstrap: false })[0].mcp[name].status
await toggleMcp({ await toggleMcp({
status, status,
connect: async () => { connect: async () => {
await sdk.mcp.connect({ name }) await capability.connect({ ...location, name })
}, },
disconnect: async () => { disconnect: async () => {
await sdk.mcp.disconnect({ name }) await capability.disconnect({ ...location, name })
}, },
authenticate: async () => { authenticate: async () => {
await sdk.mcp.auth.authenticate({ name }) await capability.authenticate({ ...location, name })
}, },
refresh: async () => { refresh: async () => {
await queryClient.refetchQueries(queryOptionsApi.mcp(key)) await queryClient.refetchQueries(queryOptionsApi.mcp(key))

View file

@ -1,5 +1,5 @@
import { describe, expect, test } from "bun:test" import { describe, expect, test } from "bun:test"
import type { Message, Part } from "@opencode-ai/sdk/v2/client" import type { AppMessage as Message, AppPart as Part } from "./backend"
import { applyOptimisticAdd, applyOptimisticRemove, mergeOptimisticPage } from "./sync" import { applyOptimisticAdd, applyOptimisticRemove, mergeOptimisticPage } from "./sync"
type Text = Extract<Part, { type: "text" }> type Text = Extract<Part, { type: "text" }>

View file

@ -2,25 +2,25 @@ import { Binary } from "@opencode-ai/core/util/binary"
import { createMemo } from "solid-js" import { createMemo } from "solid-js"
import { useServerSync } from "./server-sync" import { useServerSync } from "./server-sync"
import { useSDK } from "./sdk" import { useSDK } from "./sdk"
import type { Message, Part } from "@opencode-ai/sdk/v2/client" import type { AppMessage, AppPart } from "./backend"
const SKIP_PARTS = new Set(["patch", "step-start", "step-finish"]) const SKIP_PARTS = new Set(["patch", "step-start", "step-finish"])
function sortParts(parts: Part[]) { function sortParts(parts: AppPart[]) {
return parts.filter((part) => !!part?.id).sort((a, b) => cmp(a.id, b.id)) return parts.filter((part) => !!part?.id).sort((a, b) => cmp(a.id, b.id))
} }
const cmp = (a: string, b: string) => (a < b ? -1 : a > b ? 1 : 0) const cmp = (a: string, b: string) => (a < b ? -1 : a > b ? 1 : 0)
type OptimisticStore = { type OptimisticStore = {
message: Record<string, Message[] | undefined> message: Record<string, AppMessage[] | undefined>
part: Record<string, Part[] | undefined> part: Record<string, AppPart[] | undefined>
} }
type OptimisticAddInput = { type OptimisticAddInput = {
sessionID: string sessionID: string
message: Message message: AppMessage
parts: Part[] parts: AppPart[]
} }
type OptimisticRemoveInput = { type OptimisticRemoveInput = {
@ -29,23 +29,23 @@ type OptimisticRemoveInput = {
} }
type OptimisticItem = { type OptimisticItem = {
message: Message message: AppMessage
parts: Part[] parts: AppPart[]
} }
type MessagePage = { type MessagePage = {
session: Message[] session: AppMessage[]
part: { id: string; part: Part[] }[] part: { id: string; part: AppPart[] }[]
cursor?: string cursor?: string
complete: boolean complete: boolean
} }
const hasParts = (parts: Part[] | undefined, want: Part[]) => { const hasParts = (parts: AppPart[] | undefined, want: AppPart[]) => {
if (!parts) return want.length === 0 if (!parts) return want.length === 0
return want.every((part) => Binary.search(parts, part.id, (item) => item.id).found) return want.every((part) => Binary.search(parts, part.id, (item) => item.id).found)
} }
const mergeParts = (parts: Part[] | undefined, want: Part[]) => { const mergeParts = (parts: AppPart[] | undefined, want: AppPart[]) => {
if (!parts) return sortParts(want) if (!parts) return sortParts(want)
const next = [...parts] const next = [...parts]
let changed = false let changed = false

View file

@ -1,4 +1,4 @@
import type { Session } from "@opencode-ai/sdk/v2/client" import type { AppSession } from "./backend"
import { createSimpleContext } from "@opencode-ai/ui/context" import { createSimpleContext } from "@opencode-ai/ui/context"
import { createStore, produce } from "solid-js/store" import { createStore, produce } from "solid-js/store"
import { Persist, persisted, removePersisted, draftPersistedKeys } from "@/utils/persist" import { Persist, persisted, removePersisted, draftPersistedKeys } from "@/utils/persist"
@ -45,7 +45,7 @@ export const tabHref = (tab: Tab) =>
export const tabKey = (tab: Tab) => (tab.type === "draft" ? `draft:${tab.draftID}` : `${tab.server}\n${tabHref(tab)}`) export const tabKey = (tab: Tab) => (tab.type === "draft" ? `draft:${tab.draftID}` : `${tab.server}\n${tabHref(tab)}`)
export function sessionHasOpenTab(tabs: Tab[], server: ServerConnection.Key, session: Session) { export function sessionHasOpenTab(tabs: Tab[], server: ServerConnection.Key, session: AppSession) {
return tabs.some((tab) => tab.type === "session" && tab.server === server && tab.sessionId === session.id) return tabs.some((tab) => tab.type === "session" && tab.server === server && tab.sessionId === session.id)
} }
@ -348,7 +348,7 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
for (const key of removed) memory.remove(key) for (const key of removed) memory.remove(key)
for (const key of removed) removeInfo(key) for (const key of removed) removeInfo(key)
}, },
rememberSessionInfo(tab: SessionTab, session: Session) { rememberSessionInfo(tab: SessionTab, session: AppSession) {
const key = tabKey(tab) const key = tabKey(tab)
const next = { title: session.title, directory: session.directory } const next = { title: session.title, directory: session.directory }
const current = info[key] const current = info[key]

View file

@ -149,6 +149,7 @@ function createWorkspaceTerminalSession(
scope: ServerScopeValue, scope: ServerScopeValue,
legacySessionID?: string, legacySessionID?: string,
) { ) {
const location = { directory: sdk.directory }
const legacy = scope === ServerScope.local ? getLegacyTerminalStorageKeys(dir, legacySessionID) : [] const legacy = scope === ServerScope.local ? getLegacyTerminalStorageKeys(dir, legacySessionID) : []
const [store, setStore, _, ready] = persisted( const [store, setStore, _, ready] = persisted(
@ -198,23 +199,27 @@ function createWorkspaceTerminalSession(
}) })
} }
const unsub = sdk.event.on("pty.exited", (event: { properties: { id: string } }) => { const unsub = sdk.event.on("pty.exited", (event) => {
removeExited(event.properties.id) if (event.type !== "pty.exited") return
removeExited(event.ptyID)
}) })
onCleanup(unsub) onCleanup(unsub)
const update = (client: DirectorySDK["client"], pty: Partial<LocalPTY> & { id: string }) => { const update = (pty: Partial<LocalPTY> & { id: string }) => {
const index = store.all.findIndex((x) => x.id === pty.id) const index = store.all.findIndex((x) => x.id === pty.id)
const previous = index >= 0 ? store.all[index] : undefined const previous = index >= 0 ? store.all[index] : undefined
if (index >= 0) { if (index >= 0) {
setStore("all", index, (item) => ({ ...item, ...pty })) setStore("all", index, (item) => ({ ...item, ...pty }))
} }
client.pty sdk.backend
.update({ .then((client) =>
ptyID: pty.id, client.common.pty.update({
title: pty.title, ptyID: pty.id,
size: pty.cols && pty.rows ? { rows: pty.rows, cols: pty.cols } : undefined, title: pty.title,
}) size: pty.cols && pty.rows ? { rows: pty.rows, cols: pty.cols } : undefined,
location,
}),
)
.catch((error: unknown) => { .catch((error: unknown) => {
if (previous) { if (previous) {
const currentIndex = store.all.findIndex((item) => item.id === pty.id) const currentIndex = store.all.findIndex((item) => item.id === pty.id)
@ -224,26 +229,24 @@ function createWorkspaceTerminalSession(
}) })
} }
const clone = async (client: DirectorySDK["client"], id: string) => { const clone = async (id: string) => {
const index = store.all.findIndex((x) => x.id === id) const index = store.all.findIndex((x) => x.id === id)
const pty = store.all[index] const pty = store.all[index]
if (!pty) return if (!pty) return
const next = await client.pty const next = await sdk.backend
.create({ .then((client) => client.common.pty.create({ title: pty.title, location }))
title: pty.title,
})
.catch((error: unknown) => { .catch((error: unknown) => {
console.error("Failed to clone terminal", error) console.error("Failed to clone terminal", error)
return undefined return undefined
}) })
if (!next?.data) return if (!next) return
const active = store.active === pty.id const active = store.active === pty.id
batch(() => { batch(() => {
setStore("all", index, { setStore("all", index, {
id: next.data.id, id: next.id,
title: next.data.title ?? pty.title, title: next.title ?? pty.title,
titleNumber: pty.titleNumber, titleNumber: pty.titleNumber,
buffer: undefined, buffer: undefined,
cursor: undefined, cursor: undefined,
@ -252,7 +255,7 @@ function createWorkspaceTerminalSession(
cols: undefined, cols: undefined,
}) })
if (active) { if (active) {
setStore("active", next.data.id) setStore("active", next.id)
} }
}) })
} }
@ -270,14 +273,14 @@ function createWorkspaceTerminalSession(
new() { new() {
const nextNumber = pickNextTerminalNumber() const nextNumber = pickNextTerminalNumber()
sdk.client.pty sdk.backend
.create({ title: defaultTitle(nextNumber) }) .then((client) => client.common.pty.create({ title: defaultTitle(nextNumber), location }))
.then((pty: { data?: { id?: string; title?: string } }) => { .then((pty) => {
const id = pty.data?.id const id = pty.id
if (!id) return if (!id) return
const newTerminal = { const newTerminal = {
id, id,
title: pty.data?.title ?? defaultTitle(nextNumber), title: pty.title ?? defaultTitle(nextNumber),
titleNumber: nextNumber, titleNumber: nextNumber,
} }
setStore("all", store.all.length, newTerminal) setStore("all", store.all.length, newTerminal)
@ -288,7 +291,7 @@ function createWorkspaceTerminalSession(
}) })
}, },
update(pty: Partial<LocalPTY> & { id: string }) { update(pty: Partial<LocalPTY> & { id: string }) {
update(sdk.client, pty) update(pty)
}, },
trim(id: string) { trim(id: string) {
const index = store.all.findIndex((x) => x.id === id) const index = store.all.findIndex((x) => x.id === id)
@ -303,10 +306,9 @@ function createWorkspaceTerminalSession(
}) })
}, },
async clone(id: string) { async clone(id: string) {
await clone(sdk.client, id) await clone(id)
}, },
bind() { bind() {
const client = sdk.client
return { return {
trim(id: string) { trim(id: string) {
const index = store.all.findIndex((x) => x.id === id) const index = store.all.findIndex((x) => x.id === id)
@ -314,10 +316,10 @@ function createWorkspaceTerminalSession(
setStore("all", index, (pty) => trimTerminal(pty)) setStore("all", index, (pty) => trimTerminal(pty))
}, },
update(pty: Partial<LocalPTY> & { id: string }) { update(pty: Partial<LocalPTY> & { id: string }) {
update(client, pty) update(pty)
}, },
async clone(id: string) { async clone(id: string) {
await clone(client, id) await clone(id)
}, },
} }
}, },
@ -353,9 +355,11 @@ function createWorkspaceTerminalSession(
}) })
} }
await sdk.client.pty.remove({ ptyID: id }).catch((error: unknown) => { await sdk.backend
console.error("Failed to close terminal", error) .then((client) => client.common.pty.remove({ ptyID: id, location }))
}) .catch((error: unknown) => {
console.error("Failed to close terminal", error)
})
}, },
move(id: string, to: number) { move(id: string, to: number) {
const index = store.all.findIndex((f) => f.id === id) const index = store.all.findIndex((f) => f.id === id)

View file

@ -1,9 +1,9 @@
import { expect, test } from "bun:test" import { expect, test } from "bun:test"
import type { NormalizedProviderListResponse } from "@opencode-ai/session-ui/context" import type { ProviderStore } from "@/context/global-sync/types"
import { selectProviderCatalog } from "./provider-catalog" import { selectProviderCatalog } from "./provider-catalog"
const catalog = (id: string): NormalizedProviderListResponse => ({ const catalog = (id: string): ProviderStore => ({
all: new Map([[id, { id, name: id, source: "api", env: [], options: {}, models: {} }]]), all: new Map([[id, { id, name: id, source: "api", models: {} }]]),
connected: [id], connected: [id],
default: { [id]: `${id}-model` }, default: { [id]: `${id}-model` },
}) })

View file

@ -1,10 +1,10 @@
import type { NormalizedProviderListResponse } from "@opencode-ai/session-ui/context" import type { ProviderStore } from "@/context/global-sync/types"
const emptyProviderCatalog: NormalizedProviderListResponse = { all: new Map(), connected: [], default: {} } const emptyProviderCatalog: ProviderStore = { all: new Map(), connected: [], default: {} }
type DirectoryCatalog = { type DirectoryCatalog = {
ready: boolean ready: boolean
providers: NormalizedProviderListResponse providers: ProviderStore
} }
type ProviderCatalogInput = type ProviderCatalogInput =
@ -17,7 +17,7 @@ type ProviderCatalogInput =
explicit: false explicit: false
directory?: string directory?: string
catalog?: DirectoryCatalog catalog?: DirectoryCatalog
global: NormalizedProviderListResponse global: ProviderStore
} }
export function selectProviderCatalog(input: ProviderCatalogInput) { export function selectProviderCatalog(input: ProviderCatalogInput) {

View file

@ -1,4 +1,4 @@
import type { Session } from "@opencode-ai/sdk/v2/client" import type { AppSession as Session } from "@/context/backend"
import { import {
type ComponentProps, type ComponentProps,
createEffect, createEffect,
@ -508,7 +508,15 @@ export function NewHome() {
await archiveHomeSession({ await archiveHomeSession({
server: ServerConnection.key(conn), server: ServerConnection.key(conn),
session, session,
update: (value) => ctx.sdk.client.session.update(value), update: async (value) => {
const capability = (await ctx.sdk.backend).capabilities.sessionExtrasV1
if (!capability) throw new Error("Session archiving is not supported by this server")
await capability.archive({
location: { directory: value.directory },
sessionID: value.sessionID,
archivedAt: value.time.archived,
})
},
remove: () => remove: () =>
setStore( setStore(
produce((draft) => { produce((draft) => {

View file

@ -25,8 +25,8 @@ import { IconButton } from "@opencode-ai/ui/icon-button"
import { Tooltip } from "@opencode-ai/ui/tooltip" import { Tooltip } from "@opencode-ai/ui/tooltip"
import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu" import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu"
import { Dialog } from "@opencode-ai/ui/dialog" import { Dialog } from "@opencode-ai/ui/dialog"
import { getFilename } from "@opencode-ai/core/util/path" import { getDirectory, getFilename } from "@opencode-ai/core/util/path"
import { Session } from "@opencode-ai/sdk/v2/client" import type { AppSession as Session } from "@/context/backend"
import { usePlatform } from "@/context/platform" import { usePlatform } from "@/context/platform"
import { useSettings } from "@/context/settings" import { useSettings } from "@/context/settings"
import { createStore, produce, reconcile } from "solid-js/store" import { createStore, produce, reconcile } from "solid-js/store"
@ -66,6 +66,7 @@ import {
errorMessage, errorMessage,
latestRootSession, latestRootSession,
sortedRootSessions, sortedRootSessions,
workspaceCopyCreateInput,
} from "./layout/helpers" } from "./layout/helpers"
import { import {
collectNewSessionDeepLinks, collectNewSessionDeepLinks,
@ -85,6 +86,11 @@ import { SidebarContent } from "./layout/sidebar-shell"
export default function LegacyLayout(props: ParentProps) { export default function LegacyLayout(props: ParentProps) {
const serverSDK = useServerSDK() const serverSDK = useServerSDK()
const [backend] = createResource(
() => serverSDK().backend,
(value) => value,
)
const canArchive = () => !!backend()?.capabilities.sessionExtrasV1
const [store, setStore, , ready] = persisted( const [store, setStore, , ready] = persisted(
Persist.serverGlobal(serverSDK().scope, "layout.page", ["layout.page.v1"]), Persist.serverGlobal(serverSDK().scope, "layout.page", ["layout.page.v1"]),
createStore({ createStore({
@ -398,7 +404,7 @@ export default function LegacyLayout(props: ParentProps) {
WorktreeState.failed( WorktreeState.failed(
serverSDK().scope, serverSDK().scope,
e.name, e.name,
e.details.properties?.message ?? language.t("common.requestFailed"), e.details.message ?? language.t("common.requestFailed"),
) )
return return
} }
@ -408,21 +414,21 @@ export default function LegacyLayout(props: ParentProps) {
e.details?.type === "question.rejected" || e.details?.type === "question.rejected" ||
e.details?.type === "permission.replied" e.details?.type === "permission.replied"
) { ) {
const props = e.details.properties as { sessionID: string } const props = e.details
const sessionKey = `${e.name}:${props.sessionID}` const sessionKey = `${e.name}:${props.sessionID}`
dismissSessionAlert(sessionKey) dismissSessionAlert(sessionKey)
return return
} }
if (e.details?.type !== "permission.asked" && e.details?.type !== "question.asked") return if (e.details?.type !== "permission.requested" && e.details?.type !== "question.requested") return
const title = const title =
e.details.type === "permission.asked" e.details.type === "permission.requested"
? language.t("notification.permission.title") ? language.t("notification.permission.title")
: language.t("notification.question.title") : language.t("notification.question.title")
const icon = e.details.type === "permission.asked" ? ("checklist" as const) : ("bubble-5" as const) const icon = e.details.type === "permission.requested" ? ("checklist" as const) : ("bubble-5" as const)
const directory = e.name const directory = e.name
const props = e.details.properties const props = e.details.request
if (e.details.type === "permission.asked" && permission.autoResponds(e.details.properties, directory)) return if (e.details.type === "permission.requested" && permission.autoResponds(e.details.request, directory)) return
const [store] = serverSync().child(directory, { bootstrap: false }) const [store] = serverSync().child(directory, { bootstrap: false })
const session = store.session.find((s) => s.id === props.sessionID) const session = store.session.find((s) => s.id === props.sessionID)
@ -431,7 +437,7 @@ export default function LegacyLayout(props: ParentProps) {
const sessionTitle = session?.title ?? language.t("command.session.new") const sessionTitle = session?.title ?? language.t("command.session.new")
const projectName = getFilename(directory) const projectName = getFilename(directory)
const description = const description =
e.details.type === "permission.asked" e.details.type === "permission.requested"
? language.t("notification.permission.description", { sessionTitle, projectName }) ? language.t("notification.permission.description", { sessionTitle, projectName })
: language.t("notification.question.description", { sessionTitle, projectName }) : language.t("notification.question.description", { sessionTitle, projectName })
const href = `/${base64Encode(directory)}/session/${props.sessionID}` const href = `/${base64Encode(directory)}/session/${props.sessionID}`
@ -441,7 +447,7 @@ export default function LegacyLayout(props: ParentProps) {
if (now - lastAlerted < cooldownMs) return if (now - lastAlerted < cooldownMs) return
alertedAtBySession.set(sessionKey, now) alertedAtBySession.set(sessionKey, now)
if (e.details.type === "permission.asked") { if (e.details.type === "permission.requested") {
if (settings.sounds.permissionsEnabled()) { if (settings.sounds.permissionsEnabled()) {
void playSoundById(settings.sounds.permissions()) void playSoundById(settings.sounds.permissions())
} }
@ -450,7 +456,7 @@ export default function LegacyLayout(props: ParentProps) {
} }
} }
if (e.details.type === "question.asked") { if (e.details.type === "question.requested") {
if (settings.notifications.agent()) { if (settings.notifications.agent()) {
void platform.notify(title, description, href) void platform.notify(title, description, href)
} }
@ -874,10 +880,12 @@ export default function LegacyLayout(props: ParentProps) {
const index = sessions.findIndex((s) => s.id === session.id) const index = sessions.findIndex((s) => s.id === session.id)
const nextSession = sessions[index + 1] ?? sessions[index - 1] const nextSession = sessions[index + 1] ?? sessions[index - 1]
await serverSDK().client.session.update({ const capability = (await serverSDK().backend).capabilities.sessionExtrasV1
directory: session.directory, if (!capability) return
await capability.archive({
location: { directory: session.directory },
sessionID: session.id, sessionID: session.id,
time: { archived: Date.now() }, archivedAt: Date.now(),
}) })
setStore( setStore(
produce((draft) => { produce((draft) => {
@ -976,7 +984,7 @@ export default function LegacyLayout(props: ParentProps) {
title: language.t("command.session.archive"), title: language.t("command.session.archive"),
category: language.t("command.category.session"), category: language.t("command.category.session"),
keybind: "mod+shift+backspace", keybind: "mod+shift+backspace",
disabled: !params.dir || !params.id, disabled: !params.dir || !params.id || !canArchive(),
onSelect: () => { onSelect: () => {
const session = currentSessions().find((s) => s.id === params.id) const session = currentSessions().find((s) => s.id === params.id)
if (session) void archiveSession(session) if (session) void archiveSession(session)
@ -1185,8 +1193,15 @@ export default function LegacyLayout(props: ParentProps) {
const refreshDirs = async (target?: string) => { const refreshDirs = async (target?: string) => {
if (!target || target === root || canOpen(target)) return canOpen(target) if (!target || target === root || canOpen(target)) return canOpen(target)
const listed = await serverSDK() const listed = await serverSDK()
.client.worktree.list({ directory: root }) .backend.then((client) => {
.then((x) => x.data ?? []) const location = { directory: root }
if (client.capabilities.worktreesV1) return client.capabilities.worktreesV1.list({ location })
if (project?.id && client.capabilities.projectCopiesV2?.directories)
return client.capabilities.projectCopiesV2
.directories({ projectID: project.id, location })
.then((items) => items.map((item) => item.directory))
return []
})
.catch(() => [] as string[]) .catch(() => [] as string[])
dirs = effectiveWorkspaceOrder(root, [root, ...listed], store.workspaceOrder[root]) dirs = effectiveWorkspaceOrder(root, [root, ...listed], store.workspaceOrder[root])
return canOpen(target) return canOpen(target)
@ -1231,8 +1246,11 @@ export default function LegacyLayout(props: ParentProps) {
dirs.map(async (item) => ({ dirs.map(async (item) => ({
path: { directory: item }, path: { directory: item },
session: await serverSDK() session: await serverSDK()
.client.session.list({ directory: item }) .backend.then((client) =>
.then((x) => x.data ?? []) client.common.sessions
.list({ location: { directory: item } })
.then((page) => [...page.items]),
)
.catch(() => []), .catch(() => []),
})), })),
), ),
@ -1293,7 +1311,9 @@ export default function LegacyLayout(props: ParentProps) {
const name = next === getFilename(project.worktree) ? "" : next const name = next === getFilename(project.worktree) ? "" : next
if (project.id && project.id !== "global") { if (project.id && project.id !== "global") {
await serverSDK().client.project.update({ projectID: project.id, directory: project.worktree, name }) const editing = (await serverSDK().backend).capabilities.projectEditing
if (!editing) throw new Error("Project editing is not supported by this server")
await editing.update({ projectID: project.id, location: { directory: project.worktree }, name })
return return
} }
@ -1387,8 +1407,15 @@ export default function LegacyLayout(props: ParentProps) {
setBusy(directory, true) setBusy(directory, true)
const result = await serverSDK() const result = await serverSDK()
.client.worktree.remove({ directory: root, worktreeRemoveInput: { directory } }) .backend.then(async (client) => {
.then((x) => x.data) const location = { directory: root }
if (client.capabilities.worktreesV1) return client.capabilities.worktreesV1.remove({ location, directory })
const copies = client.capabilities.projectCopiesV2
const project = layout.projects.list().find((item) => item.worktree === root)
if (!copies || !project?.id) throw new Error("Project copy removal is not supported by this server")
await copies.remove({ projectID: project.id, location, directory, force: true })
return true
})
.catch((err) => { .catch((err) => {
showToast({ showToast({
title: language.t("workspace.delete.failed.title"), title: language.t("workspace.delete.failed.title"),
@ -1444,9 +1471,12 @@ export default function LegacyLayout(props: ParentProps) {
}) })
const dismiss = () => toaster.dismiss(progress) const dismiss = () => toaster.dismiss(progress)
const sessions: Session[] = await serverSDK() const sessions = await serverSDK()
.client.session.list({ directory }) .backend.then((client) =>
.then((x) => x.data ?? []) client.common.sessions
.list({ location: { directory } })
.then((page) => [...page.items]),
)
.catch(() => []) .catch(() => [])
clearWorkspaceTerminals( clearWorkspaceTerminals(
@ -1456,12 +1486,26 @@ export default function LegacyLayout(props: ParentProps) {
serverSDK().scope, serverSDK().scope,
) )
await serverSDK() await serverSDK()
.client.instance.dispose({ directory }) .backend.then((client) => client.capabilities.runtimeV1?.disposeLocation({ location: { directory } }))
.catch(() => undefined) .catch(() => undefined)
const result = await serverSDK() const result = await serverSDK()
.client.worktree.reset({ directory: root, worktreeResetInput: { directory } }) .backend.then(async (client) => {
.then((x) => x.data) const location = { directory: root }
if (client.capabilities.worktreesV1) return client.capabilities.worktreesV1.reset({ location, directory })
const copies = client.capabilities.projectCopiesV2
const project = layout.projects.list().find((item) => item.worktree === root)
if (!copies || !project?.id) throw new Error("Project copy reset is not supported by this server")
await copies.remove({ projectID: project.id, location, directory, force: true })
await copies.create({
projectID: project.id,
location,
strategy: "git_worktree",
directory: getDirectory(directory),
name: getFilename(directory),
})
return true
})
.catch((err) => { .catch((err) => {
showToast({ showToast({
title: language.t("workspace.reset.failed.title"), title: language.t("workspace.reset.failed.title"),
@ -1482,10 +1526,14 @@ export default function LegacyLayout(props: ParentProps) {
.filter((session) => session.time.archived === undefined) .filter((session) => session.time.archived === undefined)
.map((session) => .map((session) =>
serverSDK() serverSDK()
.client.session.update({ .backend.then(async (client) => {
sessionID: session.id, const capability = client.capabilities.sessionExtrasV1
directory: session.directory, if (!capability) return
time: { archived: archivedAt }, await capability.archive({
location: { directory: session.directory },
sessionID: session.id,
archivedAt,
})
}) })
.catch(() => undefined), .catch(() => undefined),
), ),
@ -1523,9 +1571,8 @@ export default function LegacyLayout(props: ParentProps) {
onMount(() => { onMount(() => {
serverSDK() serverSDK()
.client.vcs.status({ directory: props.directory }) .backend.then((client) => client.capabilities.vcs?.status({ location: { directory: props.directory } }) ?? [])
.then((x) => { .then((files) => {
const files = x.data ?? []
const dirty = files.length > 0 const dirty = files.length > 0
setData({ status: "ready", dirty }) setData({ status: "ready", dirty })
}) })
@ -1582,8 +1629,11 @@ export default function LegacyLayout(props: ParentProps) {
const refresh = async () => { const refresh = async () => {
const sessions = await serverSDK() const sessions = await serverSDK()
.client.session.list({ directory: props.directory }) .backend.then((client) =>
.then((x) => x.data ?? []) client.common.sessions
.list({ location: { directory: props.directory } })
.then((page) => [...page.items]),
)
.catch(() => []) .catch(() => [])
const active = sessions.filter((session) => session.time.archived === undefined) const active = sessions.filter((session) => session.time.archived === undefined)
setState({ sessions: active }) setState({ sessions: active })
@ -1591,9 +1641,8 @@ export default function LegacyLayout(props: ParentProps) {
onMount(() => { onMount(() => {
serverSDK() serverSDK()
.client.vcs.status({ directory: props.directory }) .backend.then((client) => client.capabilities.vcs?.status({ location: { directory: props.directory } }) ?? [])
.then((x) => { .then((files) => {
const files = x.data ?? []
const dirty = files.length > 0 const dirty = files.length > 0
setState({ status: "ready", dirty }) setState({ status: "ready", dirty })
void refresh() void refresh()
@ -1822,8 +1871,17 @@ export default function LegacyLayout(props: ParentProps) {
const createWorkspace = async (project: LocalProject) => { const createWorkspace = async (project: LocalProject) => {
clearSidebarHoverState() clearSidebarHoverState()
const created = await serverSDK() const created = await serverSDK()
.client.worktree.create({ directory: project.worktree }) .backend.then((client) => {
.then((x) => x.data) const location = { directory: project.worktree }
if (client.capabilities.worktreesV1) return client.capabilities.worktreesV1.create({ location })
const copies = client.capabilities.projectCopiesV2
const input = workspaceCopyCreateInput(project)
if (!copies || !input) throw new Error("Project copy creation is not supported by this server")
return copies.create({
...input,
location,
})
})
.catch((err) => { .catch((err) => {
showToast({ showToast({
title: language.t("workspace.create.failed.title"), title: language.t("workspace.create.failed.title"),
@ -1834,7 +1892,8 @@ export default function LegacyLayout(props: ParentProps) {
if (!created?.directory) return if (!created?.directory) return
setWorkspaceName(created.directory, created.branch ?? getFilename(created.directory), project.id, created.branch) const branch = "branch" in created && typeof created.branch === "string" ? created.branch : undefined
setWorkspaceName(created.directory, branch ?? getFilename(created.directory), project.id, branch)
const local = project.worktree const local = project.worktree
const key = pathKey(created.directory) const key = pathKey(created.directory)
@ -1867,6 +1926,7 @@ export default function LegacyLayout(props: ParentProps) {
clearHoverProjectSoon, clearHoverProjectSoon,
prefetchSession, prefetchSession,
archiveSession, archiveSession,
canArchive,
workspaceName, workspaceName,
renameWorkspace, renameWorkspace,
editorOpen, editorOpen,
@ -1913,6 +1973,7 @@ export default function LegacyLayout(props: ParentProps) {
clearHoverProjectSoon, clearHoverProjectSoon,
prefetchSession, prefetchSession,
archiveSession, archiveSession,
canArchive,
}, },
} }

View file

@ -6,7 +6,7 @@ import {
parseDeepLink, parseDeepLink,
parseNewSessionDeepLink, parseNewSessionDeepLink,
} from "./deep-links" } from "./deep-links"
import { type Session } from "@opencode-ai/sdk/v2/client" import type { AppSession as Session } from "@/context/backend"
import { import {
childSessionOnPath, childSessionOnPath,
closeHomeProject, closeHomeProject,
@ -19,12 +19,22 @@ import {
homeSessionServerStatus, homeSessionServerStatus,
latestRootSession, latestRootSession,
toggleHomeProjectSelection, toggleHomeProjectSelection,
workspaceCopyCreateInput,
} from "./helpers" } from "./helpers"
import { pathKey } from "@/utils/path-key" import { pathKey } from "@/utils/path-key"
import { ServerConnection } from "@/context/server" import { ServerConnection } from "@/context/server"
const serverKey = ServerConnection.Key.make const serverKey = ServerConnection.Key.make
test("builds a v2 git worktree copy request from the project root", () => {
expect(workspaceCopyCreateInput({ id: "project", worktree: "/repos/opencode" })).toEqual({
projectID: "project",
strategy: "git_worktree",
directory: "/repos/",
})
expect(workspaceCopyCreateInput({ worktree: "/repos/opencode" })).toBeUndefined()
})
const session = (input: Partial<Session> & Pick<Session, "id" | "directory">) => const session = (input: Partial<Session> & Pick<Session, "id" | "directory">) =>
({ ({
title: "", title: "",

View file

@ -1,5 +1,5 @@
import { getFilename } from "@opencode-ai/core/util/path" import { getDirectory, getFilename } from "@opencode-ai/core/util/path"
import { type Session } from "@opencode-ai/sdk/v2/client" import type { AppSession as Session } from "@/context/backend"
import { pathKey } from "@/utils/path-key" import { pathKey } from "@/utils/path-key"
import type { ServerConnection } from "@/context/server" import type { ServerConnection } from "@/context/server"
import type { HomeProjectSelection } from "@/context/layout" import type { HomeProjectSelection } from "@/context/layout"
@ -9,6 +9,15 @@ type SessionStore = {
path: { directory: string } path: { directory: string }
} }
export function workspaceCopyCreateInput(project: { id?: string; worktree: string }) {
if (!project.id) return
return {
projectID: project.id,
strategy: "git_worktree",
directory: getDirectory(project.worktree),
}
}
function sortSessions(now: number) { function sortSessions(now: number) {
const oneMinuteAgo = now - 60 * 1000 const oneMinuteAgo = now - 60 * 1000
return (a: Session, b: Session) => { return (a: Session, b: Session) => {

View file

@ -1,4 +1,4 @@
import type { Session } from "@opencode-ai/sdk/v2/client" import type { AppSession as Session } from "@/context/backend"
import { Avatar } from "@opencode-ai/ui/avatar" import { Avatar } from "@opencode-ai/ui/avatar"
import { Icon } from "@opencode-ai/ui/icon" import { Icon } from "@opencode-ai/ui/icon"
import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon" import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
@ -87,6 +87,7 @@ export type SessionItemProps = {
clearHoverProjectSoon: () => void clearHoverProjectSoon: () => void
prefetchSession: (session: Session, priority?: "high" | "low") => void prefetchSession: (session: Session, priority?: "high" | "low") => void
archiveSession: (session: Session) => Promise<void> archiveSession: (session: Session) => Promise<void>
canArchive: Accessor<boolean>
} }
const SessionRow = (props: { const SessionRow = (props: {
@ -241,7 +242,7 @@ export const SessionItem = (props: SessionItemProps): JSX.Element => {
</Show> </Show>
</div> </div>
<Show when={!props.level}> <Show when={!props.level && props.canArchive()}>
<div <div
class="shrink-0 overflow-hidden transition-[width,opacity]" class="shrink-0 overflow-hidden transition-[width,opacity]"
classList={{ classList={{

View file

@ -14,7 +14,7 @@ import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon" import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
import { Spinner } from "@opencode-ai/ui/spinner" import { Spinner } from "@opencode-ai/ui/spinner"
import { Tooltip } from "@opencode-ai/ui/tooltip" import { Tooltip } from "@opencode-ai/ui/tooltip"
import { type Session } from "@opencode-ai/sdk/v2/client" import type { AppSession as Session } from "@/context/backend"
import { type LocalProject } from "@/context/layout" import { type LocalProject } from "@/context/layout"
import { useServerSync, useQueryOptions } from "@/context/server-sync" import { useServerSync, useQueryOptions } from "@/context/server-sync"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
@ -42,6 +42,7 @@ export type WorkspaceSidebarContext = {
clearHoverProjectSoon: () => void clearHoverProjectSoon: () => void
prefetchSession: (session: Session, priority?: "high" | "low") => void prefetchSession: (session: Session, priority?: "high" | "low") => void
archiveSession: (session: Session) => Promise<void> archiveSession: (session: Session) => Promise<void>
canArchive: Accessor<boolean>
workspaceName: (directory: string, projectId?: string, branch?: string) => string | undefined workspaceName: (directory: string, projectId?: string, branch?: string) => string | undefined
renameWorkspace: (directory: string, next: string, projectId?: string, branch?: string) => void renameWorkspace: (directory: string, next: string, projectId?: string, branch?: string) => void
editorOpen: (id: string) => boolean editorOpen: (id: string) => boolean
@ -272,6 +273,7 @@ const WorkspaceSessionList = (props: {
clearHoverProjectSoon={props.ctx.clearHoverProjectSoon} clearHoverProjectSoon={props.ctx.clearHoverProjectSoon}
prefetchSession={props.ctx.prefetchSession} prefetchSession={props.ctx.prefetchSession}
archiveSession={props.ctx.archiveSession} archiveSession={props.ctx.archiveSession}
canArchive={props.ctx.canArchive}
/> />
)} )}
</For> </For>

View file

@ -1,4 +1,10 @@
import type { Project, UserMessage, VcsFileDiff } from "@opencode-ai/sdk/v2" import type {
AppFilePart as FilePart,
AppProject as Project,
AppUserMessage as UserMessage,
AppVcsFileDiff as VcsFileDiff,
} from "@/context/backend"
import { getFilename } from "@opencode-ai/core/util/path"
import { useDialog } from "@opencode-ai/ui/context/dialog" import { useDialog } from "@opencode-ai/ui/context/dialog"
import { createQuery, skipToken, useMutation, useQueryClient } from "@tanstack/solid-query" import { createQuery, skipToken, useMutation, useQueryClient } from "@tanstack/solid-query"
import { import {
@ -640,7 +646,7 @@ export default function Page() {
const project = sync().project const project = sync().project
const vcs = sync().data.vcs const vcs = sync().data.vcs
if (project?.vcs === "git") list.push("git") if (project?.vcs === "git") list.push("git")
if (project?.vcs === "git" && vcs?.branch && vcs?.default_branch && vcs.branch !== vcs.default_branch) { if (project?.vcs === "git" && vcs?.branch && vcs?.defaultBranch && vcs.branch !== vcs.defaultBranch) {
list.push("branch") list.push("branch")
} }
list.push("turn") list.push("turn")
@ -658,7 +664,7 @@ export default function Page() {
}) })
const vcsKey = createMemo( const vcsKey = createMemo(
() => () =>
["session-vcs", sdk().directory, sync().data.vcs?.branch ?? "", sync().data.vcs?.default_branch ?? ""] as const, ["session-vcs", sdk().directory, sync().data.vcs?.branch ?? "", sync().data.vcs?.defaultBranch ?? ""] as const,
) )
const vcsQuery = createQuery(() => { const vcsQuery = createQuery(() => {
const mode = vcsMode() const mode = vcsMode()
@ -670,8 +676,13 @@ export default function Page() {
queryFn: mode queryFn: mode
? () => ? () =>
sdk() sdk()
.client.vcs.diff({ mode }) .backend.then((client) =>
.then((result) => list(result.data)) client.capabilities.vcs?.diff({
location: { directory: sdk().directory },
mode: mode === "git" ? "working" : mode,
}) ?? [],
)
.then(list)
.catch((error) => { .catch((error) => {
console.debug("[session-review] failed to load vcs diff", { mode, error }) console.debug("[session-review] failed to load vcs diff", { mode, error })
return [] return []
@ -717,9 +728,13 @@ export default function Page() {
staleTime: Number.POSITIVE_INFINITY, staleTime: Number.POSITIVE_INFINITY,
retry: 2, retry: 2,
queryFn: () => queryFn: () =>
sdk() sdk().backend.then((client) =>
.client.vcs.diff({ mode, directory: scope, context }) client.capabilities.vcs?.diff({
.then((result) => result.data ?? []), location: { directory: scope },
mode: mode === "git" ? "working" : mode,
context,
}) ?? [],
),
}) })
.then((diffs) => diffs.find((diff) => diff.file === file)) .then((diffs) => diffs.find((diff) => diff.file === file))
@ -823,10 +838,13 @@ export default function Page() {
} }
const gitMutation = useMutation(() => ({ const gitMutation = useMutation(() => ({
mutationFn: () => sdk().client.project.initGit(), mutationFn: async () => {
const editing = (await sdk().backend).capabilities.projectEditing
if (!editing) throw new Error("Git initialization is not supported by this server")
return editing.initGit({ location: { directory: sdk().directory } })
},
onSuccess: (x) => { onSuccess: (x) => {
if (!x.data) return upsert(x as Project)
upsert(x.data)
}, },
onError: (err) => { onError: (err) => {
showToast({ showToast({
@ -891,13 +909,7 @@ export default function Page() {
) )
const stopVcs = sdk().event.listen((evt) => { const stopVcs = sdk().event.listen((evt) => {
if (evt.details.type !== "filesystem.changed") return if (evt.details.type !== "file.changed" || evt.details.path.startsWith(".git/")) return
const props =
typeof evt.details.properties === "object" && evt.details.properties
? (evt.details.properties as Record<string, unknown>)
: undefined
const file = typeof props?.file === "string" ? props.file : undefined
if (!file || file.startsWith(".git/")) return
refreshVcs() refreshVcs()
}) })
onCleanup(stopVcs) onCleanup(stopVcs)
@ -1659,8 +1671,6 @@ export default function Page() {
}) })
} }
const merge = (next: NonNullable<ReturnType<typeof info>>, target = sync()) => target.session.remember(next)
const roll = (sessionID: string, next: NonNullable<ReturnType<typeof info>>["revert"], target = sync()) => { const roll = (sessionID: string, next: NonNullable<ReturnType<typeof info>>["revert"], target = sync()) => {
const session = target.session.get(sessionID) const session = target.session.get(sessionID)
if (!session) return if (!session) return
@ -1691,11 +1701,12 @@ export default function Page() {
setFollowup("failed", input.sessionID, undefined) setFollowup("failed", input.sessionID, undefined)
const ok = await sendFollowupDraft({ const ok = await sendFollowupDraft({
client: sdk().client, backend: sdk().backend,
sync: sync(), sync: sync(),
serverSync: serverSync(), serverSync: serverSync(),
draft: item, draft: item,
optimisticBusy: item.sessionDirectory === sdk().directory, optimisticBusy: item.sessionDirectory === sdk().directory,
commitRevert: !!sync().session.get(input.sessionID)?.revert,
}).catch((err) => { }).catch((err) => {
setFollowup("failed", input.sessionID, input.id) setFollowup("failed", input.sessionID, input.id)
fail(err) fail(err)
@ -1787,13 +1798,14 @@ export default function Page() {
const halt = (sessionID: string) => const halt = (sessionID: string) =>
busy(sessionID) busy(sessionID)
? sdk() ? sdk()
.client.session.abort({ sessionID }) .backend.then((client) =>
client.common.sessions.interrupt({ location: { directory: sdk().directory }, sessionID }),
)
.catch(() => {}) .catch(() => {})
: Promise.resolve() : Promise.resolve()
const revertMutation = useMutation(() => ({ const revertMutation = useMutation(() => ({
mutationFn: async (input: { sessionID: string; messageID: string }) => { mutationFn: async (input: { sessionID: string; messageID: string }) => {
const client = sdk().client
const target = sync() const target = sync()
const last = target.session.get(input.sessionID)?.revert const last = target.session.get(input.sessionID)?.revert
const value = draft(input.messageID) const value = draft(input.messageID)
@ -1803,10 +1815,18 @@ export default function Page() {
roll(input.sessionID, { messageID: input.messageID }, target) roll(input.sessionID, { messageID: input.messageID }, target)
prompt.set(value) prompt.set(value)
}, },
request: () => halt(input.sessionID).then(() => client.session.revert(input)), request: () =>
complete: (result) => { halt(input.sessionID).then(() =>
if (result.data) merge(result.data, target) sdk().backend.then((client) => {
}, const value = { ...input, location: { directory: sdk().directory } }
if (client.capabilities.sessionExtrasV1)
return client.capabilities.sessionExtrasV1.revert(value).then(() => undefined)
if (client.capabilities.sessionExtrasV2)
return client.capabilities.sessionExtrasV2.stageRevert(value).then(() => undefined)
throw new Error("Session revert is not supported by this server")
}),
),
complete: () => undefined,
rollback: () => roll(input.sessionID, last, target), rollback: () => roll(input.sessionID, last, target),
fail, fail,
}) })
@ -1818,7 +1838,6 @@ export default function Page() {
const sessionID = params.id const sessionID = params.id
if (!sessionID) return if (!sessionID) return
const client = sdk().client
const target = sync() const target = sync()
const next = userMessages().find((item) => item.id > id) const next = userMessages().find((item) => item.id > id)
const last = target.session.get(sessionID)?.revert const last = target.session.get(sessionID)?.revert
@ -1835,11 +1854,26 @@ export default function Page() {
}, },
request: () => request: () =>
!next !next
? halt(sessionID).then(() => client.session.unrevert({ sessionID })) ? halt(sessionID).then(() =>
: halt(sessionID).then(() => client.session.revert({ sessionID, messageID: next.id })), sdk().backend.then((client) => {
complete: (result) => { const value = { location: { directory: sdk().directory }, sessionID }
if (result.data) merge(result.data, target) if (client.capabilities.sessionExtrasV1)
}, return client.capabilities.sessionExtrasV1.clearRevert(value).then(() => undefined)
if (client.capabilities.sessionExtrasV2) return client.capabilities.sessionExtrasV2.clearRevert(value)
throw new Error("Session revert is not supported by this server")
}),
)
: halt(sessionID).then(() =>
sdk().backend.then((client) => {
const value = { location: { directory: sdk().directory }, sessionID, messageID: next.id }
if (client.capabilities.sessionExtrasV1)
return client.capabilities.sessionExtrasV1.revert(value).then(() => undefined)
if (client.capabilities.sessionExtrasV2)
return client.capabilities.sessionExtrasV2.stageRevert(value).then(() => undefined)
throw new Error("Session revert is not supported by this server")
}),
),
complete: () => undefined,
rollback: () => roll(sessionID, last, target), rollback: () => roll(sessionID, last, target),
fail, fail,
}) })

View file

@ -1,5 +1,9 @@
import { describe, expect, test } from "bun:test" import { describe, expect, test } from "bun:test"
import type { PermissionRequest, QuestionRequest, Session } from "@opencode-ai/sdk/v2/client" import type {
AppPermissionRequest as PermissionRequest,
AppQuestionRequest as QuestionRequest,
AppSession as Session,
} from "@/context/backend"
import { sessionPermissionRequest, sessionQuestionRequest } from "./session-request-tree" import { sessionPermissionRequest, sessionQuestionRequest } from "./session-request-tree"
const session = (input: { id: string; parentID?: string }) => const session = (input: { id: string; parentID?: string }) =>
@ -12,6 +16,12 @@ const permission = (id: string, sessionID: string) =>
({ ({
id, id,
sessionID, sessionID,
action: "read",
resources: ["*"],
permission: "read",
patterns: ["*"],
always: [],
metadata: {},
}) as PermissionRequest }) as PermissionRequest
const question = (id: string, sessionID: string) => const question = (id: string, sessionID: string) =>

View file

@ -1,6 +1,9 @@
import { createMemo } from "solid-js" import { createMemo } from "solid-js"
import { createStore } from "solid-js/store" import { createStore } from "solid-js/store"
import type { PermissionRequest, QuestionRequest } from "@opencode-ai/sdk/v2" import type {
AppPermissionRequest as PermissionRequest,
AppQuestionRequest as QuestionRequest,
} from "@/context/backend"
import { useParams } from "@solidjs/router" import { useParams } from "@solidjs/router"
import { showToast } from "@/utils/toast" import { showToast } from "@/utils/toast"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
@ -51,7 +54,14 @@ export function createSessionComposerController() {
setStore("responding", perm.id) setStore("responding", perm.id)
sdk() sdk()
.client.permission.respond({ sessionID: perm.sessionID, permissionID: perm.id, response }) .backend.then((client) =>
client.common.permissions.reply({
sessionID: perm.sessionID,
requestID: perm.id,
reply: response,
location: { directory: sdk().directory },
}),
)
.catch((err: unknown) => { .catch((err: unknown) => {
const description = err instanceof Error ? err.message : String(err) const description = err instanceof Error ? err.message : String(err)
showToast({ title: language.t("common.requestFailed"), description }) showToast({ title: language.t("common.requestFailed"), description })

View file

@ -1,5 +1,5 @@
import { For, Show } from "solid-js" import { For, Show } from "solid-js"
import type { PermissionRequest } from "@opencode-ai/sdk/v2" import type { AppPermissionRequest as PermissionRequest } from "@/context/backend"
import { Button } from "@opencode-ai/ui/button" import { Button } from "@opencode-ai/ui/button"
import { DockPrompt } from "@opencode-ai/session-ui/dock-prompt" import { DockPrompt } from "@opencode-ai/session-ui/dock-prompt"
import { Icon } from "@opencode-ai/ui/icon" import { Icon } from "@opencode-ai/ui/icon"

View file

@ -6,7 +6,7 @@ import { DockPrompt } from "@opencode-ai/session-ui/dock-prompt"
import { Icon } from "@opencode-ai/ui/icon" import { Icon } from "@opencode-ai/ui/icon"
import { useSpring } from "@opencode-ai/ui/motion-spring" import { useSpring } from "@opencode-ai/ui/motion-spring"
import { showToast } from "@/utils/toast" import { showToast } from "@/utils/toast"
import type { QuestionAnswer, QuestionRequest } from "@opencode-ai/sdk/v2" import type { AppQuestionAnswer as QuestionAnswer, AppQuestionRequest as QuestionRequest } from "@/context/backend"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
import { useSDK } from "@/context/sdk" import { useSDK } from "@/context/sdk"
import { makeEventListener } from "@solid-primitives/event-listener" import { makeEventListener } from "@solid-primitives/event-listener"
@ -223,7 +223,15 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
} }
const replyMutation = useMutation(() => ({ const replyMutation = useMutation(() => ({
mutationFn: (answers: QuestionAnswer[]) => sdk().client.question.reply({ requestID: props.request.id, answers }), mutationFn: (answers: QuestionAnswer[]) =>
sdk().backend.then((client) =>
client.common.questions.reply({
sessionID: props.request.sessionID,
requestID: props.request.id,
answers,
location: { directory: sdk().directory },
}),
),
onMutate: () => { onMutate: () => {
props.onSubmit() props.onSubmit()
}, },
@ -235,7 +243,14 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
})) }))
const rejectMutation = useMutation(() => ({ const rejectMutation = useMutation(() => ({
mutationFn: () => sdk().client.question.reject({ requestID: props.request.id }), mutationFn: () =>
sdk().backend.then((client) =>
client.common.questions.reject({
sessionID: props.request.sessionID,
requestID: props.request.id,
location: { directory: sdk().directory },
}),
),
onMutate: () => { onMutate: () => {
props.onSubmit() props.onSubmit()
}, },

View file

@ -1,4 +1,8 @@
import type { PermissionRequest, QuestionRequest, Session } from "@opencode-ai/sdk/v2/client" import type {
AppPermissionRequest as PermissionRequest,
AppQuestionRequest as QuestionRequest,
AppSession as Session,
} from "@/context/backend"
function sessionTreeRequest<T>( function sessionTreeRequest<T>(
session: Session[], session: Session[],

View file

@ -1,6 +1,9 @@
import { createEffect, onCleanup, type JSX } from "solid-js" import { createEffect, onCleanup, type JSX } from "solid-js"
import { makeEventListener } from "@solid-primitives/event-listener" import { makeEventListener } from "@solid-primitives/event-listener"
import type { FileDiffInfo, VcsFileDiff } from "@opencode-ai/sdk/v2" import type {
AppFileDiff as FileDiffInfo,
AppVcsFileDiff as VcsFileDiff,
} from "@/context/backend"
import { SessionReview } from "@opencode-ai/session-ui/session-review" import { SessionReview } from "@opencode-ai/session-ui/session-review"
import type { import type {
SessionReviewCommentActions, SessionReviewCommentActions,
@ -54,8 +57,13 @@ export function SessionReviewTab(props: SessionReviewTabProps) {
const readFile = async (path: string) => { const readFile = async (path: string) => {
return sdk() return sdk()
.client.file.read({ path }) .backend.then(async (client) => {
.then((x) => x.data) const input = { location: { directory: sdk().directory }, path }
if (client.capabilities.decoratedFiles) return client.capabilities.decoratedFiles.read(input)
const content = await client.common.files.read(input)
if (content.kind !== "text") return
return { type: "text" as const, content: new TextDecoder().decode(content.bytes), mimeType: content.mimeType }
})
.catch((error) => { .catch((error) => {
console.debug("[session-review] failed to read file", { path, error }) console.debug("[session-review] failed to read file", { path, error })
return undefined return undefined

View file

@ -1,5 +1,5 @@
import { describe, expect, test } from "bun:test" import { describe, expect, test } from "bun:test"
import type { UserMessage } from "@opencode-ai/sdk/v2" import type { AppUserMessage as UserMessage } from "@/context/backend"
import { resetSessionModel, restorePromptModel, syncPromptModel, syncSessionModel } from "./session-model-helpers" import { resetSessionModel, restorePromptModel, syncPromptModel, syncSessionModel } from "./session-model-helpers"
const message = (input?: { agent?: string; model?: UserMessage["model"] }) => const message = (input?: { agent?: string; model?: UserMessage["model"] }) =>

View file

@ -1,4 +1,4 @@
import type { UserMessage } from "@opencode-ai/sdk/v2" import type { AppUserMessage as UserMessage } from "@/context/backend"
type Local = { type Local = {
session: { session: {

View file

@ -9,7 +9,7 @@ import { ResizeHandle } from "@opencode-ai/ui/resize-handle"
import { Mark } from "@opencode-ai/ui/logo" import { Mark } from "@opencode-ai/ui/logo"
import { DragDropProvider, DragDropSensors, DragOverlay, SortableProvider, closestCenter } from "@thisbeyond/solid-dnd" import { DragDropProvider, DragDropSensors, DragOverlay, SortableProvider, closestCenter } from "@thisbeyond/solid-dnd"
import type { DragEvent } from "@thisbeyond/solid-dnd" import type { DragEvent } from "@thisbeyond/solid-dnd"
import type { FileDiffInfo, VcsFileDiff } from "@opencode-ai/sdk/v2" import type { AppFileDiff as FileDiffInfo, AppVcsFileDiff as VcsFileDiff } from "@/context/backend"
import { ConstrainDragYAxis, getDraggableId } from "@/utils/solid-dnd" import { ConstrainDragYAxis, getDraggableId } from "@/utils/solid-dnd"
import { useDialog } from "@opencode-ai/ui/context/dialog" import { useDialog } from "@opencode-ai/ui/context/dialog"

View file

@ -1,6 +1,7 @@
import { import {
createEffect, createEffect,
createMemo, createMemo,
createResource,
createSignal, createSignal,
For, For,
Index, Index,
@ -46,12 +47,12 @@ import { TextField } from "@opencode-ai/ui/text-field"
import { TextReveal } from "@opencode-ai/ui/text-reveal" import { TextReveal } from "@opencode-ai/ui/text-reveal"
import { TextShimmer } from "@opencode-ai/ui/text-shimmer" import { TextShimmer } from "@opencode-ai/ui/text-shimmer"
import type { import type {
AssistantMessage, AppAssistantMessage as AssistantMessage,
Message as MessageType, AppMessage as MessageType,
Part as PartType, AppPart as PartType,
ToolPart, AppUserMessage as UserMessage,
UserMessage, } from "@/context/backend"
} from "@opencode-ai/sdk/v2" type ToolPart = Extract<PartType, { type: "tool" }>
import { showToast } from "@/utils/toast" import { showToast } from "@/utils/toast"
import { getDirectory, getFilename } from "@opencode-ai/core/util/path" import { getDirectory, getFilename } from "@opencode-ai/core/util/path"
import { Popover as KobaltePopover } from "@kobalte/core/popover" import { Popover as KobaltePopover } from "@kobalte/core/popover"
@ -290,7 +291,14 @@ export function MessageTimeline(props: {
const titleValue = createMemo(() => info()?.title) const titleValue = createMemo(() => info()?.title)
const titleLabel = createMemo(() => sessionTitle(titleValue())) const titleLabel = createMemo(() => sessionTitle(titleValue()))
const shareUrl = createMemo(() => info()?.share?.url) const shareUrl = createMemo(() => info()?.share?.url)
const shareEnabled = createMemo(() => sync().data.config.share !== "disabled") const [backend] = createResource(
() => sdk().backend,
(value) => value,
)
const shareEnabled = createMemo(
() => sync().data.config.share !== "disabled" && !!backend()?.capabilities.sessionExtrasV1,
)
const canArchive = createMemo(() => !!backend()?.capabilities.sessionExtrasV1)
const parentID = createMemo(() => info()?.parentID) const parentID = createMemo(() => info()?.parentID)
const parent = createMemo(() => { const parent = createMemo(() => {
const id = parentID() const id = parentID()
@ -647,14 +655,22 @@ export function MessageTimeline(props: {
} }
const shareMutation = useMutation(() => ({ const shareMutation = useMutation(() => ({
mutationFn: (id: string) => serverSDK().client.session.share({ sessionID: id }), mutationFn: async (id: string) => {
const capability = (await serverSDK().backend).capabilities.sessionExtrasV1
if (!capability) throw new Error("Session sharing is not supported by this server")
return capability.share({ location: { directory: sdk().directory }, sessionID: id })
},
onError: (err) => { onError: (err) => {
console.error("Failed to share session", err) console.error("Failed to share session", err)
}, },
})) }))
const unshareMutation = useMutation(() => ({ const unshareMutation = useMutation(() => ({
mutationFn: (id: string) => serverSDK().client.session.unshare({ sessionID: id }), mutationFn: async (id: string) => {
const capability = (await serverSDK().backend).capabilities.sessionExtrasV1
if (!capability) throw new Error("Session sharing is not supported by this server")
return capability.unshare({ location: { directory: sdk().directory }, sessionID: id })
},
onError: (err) => { onError: (err) => {
console.error("Failed to unshare session", err) console.error("Failed to unshare session", err)
}, },
@ -662,7 +678,15 @@ export function MessageTimeline(props: {
const titleMutation = useMutation(() => ({ const titleMutation = useMutation(() => ({
mutationFn: (input: { id: string; title: string }) => mutationFn: (input: { id: string; title: string }) =>
sdk().client.session.update({ sessionID: input.id, title: input.title }), sdk().backend.then((client) => {
const capability = client.capabilities.sessionActionsV1
if (!capability) throw new Error("Session renaming is not supported by this server")
return capability.rename({
location: { directory: sdk().directory },
sessionID: input.id,
title: input.title,
})
}),
onSuccess: (_, input) => { onSuccess: (_, input) => {
sync().set( sync().set(
produce((draft) => { produce((draft) => {
@ -806,7 +830,11 @@ export function MessageTimeline(props: {
const nextSession = index === -1 ? undefined : (sessions[index + 1] ?? sessions[index - 1]) const nextSession = index === -1 ? undefined : (sessions[index + 1] ?? sessions[index - 1])
await sdk() await sdk()
.client.session.update({ sessionID, time: { archived: Date.now() } }) .backend.then(async (client) => {
const capability = client.capabilities.sessionExtrasV1
if (!capability) throw new Error("Session archiving is not supported by this server")
await capability.archive({ location: { directory: sdk().directory }, sessionID, archivedAt: Date.now() })
})
.then(() => { .then(() => {
sync().set( sync().set(
produce((draft) => { produce((draft) => {
@ -835,8 +863,11 @@ export function MessageTimeline(props: {
const nextSession = index === -1 ? undefined : (sessions[index + 1] ?? sessions[index - 1]) const nextSession = index === -1 ? undefined : (sessions[index + 1] ?? sessions[index - 1])
const result = await sdk() const result = await sdk()
.client.session.delete({ sessionID }) .backend.then((client) => {
.then((x) => x.data) const capability = client.capabilities.sessionActionsV1
if (!capability) throw new Error("Session deletion is not supported by this server")
return capability.remove({ location: { directory: sdk().directory }, sessionID })
})
.catch((err) => { .catch((err) => {
showToast({ showToast({
title: language.t("session.delete.failed.title"), title: language.t("session.delete.failed.title"),
@ -1203,7 +1234,10 @@ export function MessageTimeline(props: {
return ( return (
<TimelineRowFrame row={retryRow}> <TimelineRowFrame row={retryRow}>
<div data-slot="session-turn-message-container" class="w-full px-4 md:px-5"> <div data-slot="session-turn-message-container" class="w-full px-4 md:px-5">
<SessionRetry status={sessionStatus()} show={activeMessageID() === retryRow().userMessageID} /> <SessionRetry
status={((status) => (status.type === "running" ? { type: "busy" as const } : status))(sessionStatus())}
show={activeMessageID() === retryRow().userMessageID}
/>
</div> </div>
</TimelineRowFrame> </TimelineRowFrame>
) )
@ -1549,9 +1583,11 @@ export function MessageTimeline(props: {
</DropdownMenu.ItemLabel> </DropdownMenu.ItemLabel>
</DropdownMenu.Item> </DropdownMenu.Item>
</Show> </Show>
<DropdownMenu.Item onSelect={() => void archiveSession(id)}> <Show when={canArchive()}>
<DropdownMenu.ItemLabel>{language.t("common.archive")}</DropdownMenu.ItemLabel> <DropdownMenu.Item onSelect={() => void archiveSession(id)}>
</DropdownMenu.Item> <DropdownMenu.ItemLabel>{language.t("common.archive")}</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
</Show>
<DropdownMenu.Separator /> <DropdownMenu.Separator />
<DropdownMenu.Item <DropdownMenu.Item
onSelect={() => dialog.show(() => <DialogDeleteSession sessionID={id} />)} onSelect={() => dialog.show(() => <DialogDeleteSession sessionID={id} />)}
@ -1620,9 +1656,11 @@ export function MessageTimeline(props: {
{language.t("session.share.action.share")}... {language.t("session.share.action.share")}...
</MenuV2.Item> </MenuV2.Item>
</Show> </Show>
<MenuV2.Item onSelect={() => void archiveSession(id)}> <Show when={canArchive()}>
{language.t("common.archive")} <MenuV2.Item onSelect={() => void archiveSession(id)}>
</MenuV2.Item> {language.t("common.archive")}
</MenuV2.Item>
</Show>
<MenuV2.Separator /> <MenuV2.Separator />
<MenuV2.Item onSelect={() => dialog.show(() => <DialogDeleteSession sessionID={id} />)}> <MenuV2.Item onSelect={() => dialog.show(() => <DialogDeleteSession sessionID={id} />)}>
{language.t("common.delete")}... {language.t("common.delete")}...

View file

@ -1,5 +1,9 @@
import { describe, expect, test } from "bun:test" import { describe, expect, test } from "bun:test"
import type { AssistantMessage, Message, UserMessage } from "@opencode-ai/sdk/v2" import type {
AppAssistantMessage as AssistantMessage,
AppMessage as Message,
AppUserMessage as UserMessage,
} from "@/context/backend"
import { isTimelineReady, loadOlderTimeline, selectUserMessages, selectVisibleUserMessages } from "./model" import { isTimelineReady, loadOlderTimeline, selectUserMessages, selectVisibleUserMessages } from "./model"
const user = (id: string) => ({ id, role: "user" }) as UserMessage const user = (id: string) => ({ id, role: "user" }) as UserMessage

View file

@ -1,4 +1,4 @@
import type { Message, UserMessage } from "@opencode-ai/sdk/v2" import type { AppMessage as Message, AppUserMessage as UserMessage } from "@/context/backend"
import { createMemo, createResource, onCleanup, untrack, type Accessor } from "solid-js" import { createMemo, createResource, onCleanup, untrack, type Accessor } from "solid-js"
import { useServerSync } from "@/context/server-sync" import { useServerSync } from "@/context/server-sync"
import { useSync } from "@/context/sync" import { useSync } from "@/context/sync"

View file

@ -1,5 +1,11 @@
import { Binary } from "@opencode-ai/core/util/binary" import { Binary } from "@opencode-ai/core/util/binary"
import type { AssistantMessage, Message, Part, SessionStatus, UserMessage } from "@opencode-ai/sdk/v2" import type {
AppAssistantMessage as AssistantMessage,
AppMessage as Message,
AppPart as Part,
AppUserMessage as UserMessage,
SessionActivity as SessionStatus,
} from "@/context/backend"
import { createMemo, mapArray, type Accessor } from "solid-js" import { createMemo, mapArray, type Accessor } from "solid-js"
import { reuseTimelineRows } from "./row-reconciliation" import { reuseTimelineRows } from "./row-reconciliation"
import { Timeline, TimelineRow } from "./rows" import { Timeline, TimelineRow } from "./rows"

View file

@ -1,5 +1,10 @@
import { parseCommentNote, readCommentMetadata } from "@/utils/comment-note" import { parseCommentNote, readCommentMetadata } from "@/utils/comment-note"
import { AssistantMessage, Part, SessionStatus, SnapshotFileDiff, UserMessage } from "@opencode-ai/sdk/v2" import type {
AppAssistantMessage as AssistantMessage,
AppPart as Part,
AppUserMessage as UserMessage,
SessionActivity as SessionStatus,
} from "@/context/backend"
import { groupParts, renderable, type PartGroup } from "@opencode-ai/session-ui/message-part" import { groupParts, renderable, type PartGroup } from "@opencode-ai/session-ui/message-part"
import { TimelineRow, type SummaryDiff } from "./timeline-row" import { TimelineRow, type SummaryDiff } from "./timeline-row"
@ -119,7 +124,7 @@ export namespace Timeline {
assistantGroupIndex += 1 assistantGroupIndex += 1
}) })
if (isActive && status === "busy" && !error && (showReasoning ? assistantPartRefs.length === 0 : true)) { if (isActive && (status === "busy" || status === "running") && !error && (showReasoning ? assistantPartRefs.length === 0 : true)) {
const heading = assistantMessages const heading = assistantMessages
.flatMap((message) => getMessageParts(message.id)) .flatMap((message) => getMessageParts(message.id))
.map((part) => (part.type === "reasoning" && part.text ? reasoningHeading(part.text) : undefined)) .map((part) => (part.type === "reasoning" && part.text ? reasoningHeading(part.text) : undefined))
@ -167,7 +172,9 @@ export namespace Timeline {
return rows return rows
} }
function isSummaryDiff(value: SnapshotFileDiff): value is SummaryDiff { function isSummaryDiff(
value: NonNullable<UserMessage["summary"]>["diffs"][number],
): value is SummaryDiff {
return typeof value.file === "string" return typeof value.file === "string"
} }

View file

@ -1,4 +1,4 @@
import type { SnapshotFileDiff } from "@opencode-ai/sdk/v2" import type { AppFileDiff as SnapshotFileDiff } from "@/context/backend"
import type { PartGroup } from "@opencode-ai/session-ui/message-part" import type { PartGroup } from "@opencode-ai/session-ui/message-part"
import { Data, Equal } from "effect" import { Data, Equal } from "effect"

Some files were not shown because too many files have changed in this diff Show more