Compare commits

...

6 Commits

Author SHA1 Message Date
Brendan Allan f346024f0e fix(server): share router with fallback routes 2026-07-17 07:33:04 +00:00
Brendan Allan 56ea8a4a49 fix(server): add cors to modular routes 2026-07-17 07:26:09 +00:00
Brendan Allan efaeda00a1 fix(server): include cors headers on 404s 2026-07-17 06:57:13 +00:00
Brendan Allan 08ea08e830 refactor(app): route backend through adapters 2026-07-16 06:24:08 +00:00
Brendan Allan 4c0feeebb4 feat(app): add v2 backend adapter 2026-07-16 06:04:52 +00:00
Brendan Allan 4d314f6e04 feat(app): add versioned backend interface 2026-07-16 06:04:52 +00:00
135 changed files with 8337 additions and 1323 deletions
+4 -1
View File
@@ -40,9 +40,11 @@
"@dnd-kit/helpers": "0.5.0",
"@dnd-kit/solid": "0.5.0",
"@kobalte/core": "catalog:",
"@opencode-ai/client": "workspace:*",
"@opencode-ai/core": "workspace:*",
"@opencode-ai/schema": "workspace:*",
"@opencode-ai/sdk": "workspace:*",
"@opencode-ai/sdk-v1": "https://registry.npmjs.org/@opencode-ai/sdk/-/sdk-1.17.18.tgz",
"@opencode-ai/session-ui": "workspace:*",
"@opencode-ai/ui": "workspace:*",
"@pierre/trees": "1.0.0-beta.4",
@@ -838,7 +840,6 @@
"dependencies": {
"@kobalte/core": "catalog:",
"@opencode-ai/core": "workspace:*",
"@opencode-ai/sdk": "workspace:*",
"@opencode-ai/ui": "workspace:*",
"@pierre/diffs": "catalog:",
"@shikijs/stream": "catalog:",
@@ -2218,6 +2219,8 @@
"@opencode-ai/sdk-next": ["@opencode-ai/sdk-next@workspace:packages/sdk-next"],
"@opencode-ai/sdk-v1": ["@opencode-ai/sdk@https://registry.npmjs.org/@opencode-ai/sdk/-/sdk-1.17.18.tgz", { "dependencies": { "cross-spawn": "7.0.6" } }, "sha512-c/C9PhY8PrbcxDY+JIYtOZsrmMD0KzoVvxq+RGUrZ6LQp57SuVBbT4lfwA2G8Se5RNC1N5JtYjiuaXeECnF2SQ=="],
"@opencode-ai/server": ["@opencode-ai/server@workspace:packages/server"],
"@opencode-ai/session-ui": ["@opencode-ai/session-ui@workspace:packages/session-ui"],
+2
View File
@@ -53,9 +53,11 @@
"@dnd-kit/helpers": "0.5.0",
"@dnd-kit/solid": "0.5.0",
"@kobalte/core": "catalog:",
"@opencode-ai/client": "workspace:*",
"@opencode-ai/core": "workspace:*",
"@opencode-ai/schema": "workspace:*",
"@opencode-ai/sdk": "workspace:*",
"@opencode-ai/sdk-v1": "https://registry.npmjs.org/@opencode-ai/sdk/-/sdk-1.17.18.tgz",
"@opencode-ai/session-ui": "workspace:*",
"@opencode-ai/ui": "workspace:*",
"@pierre/trees": "1.0.0-beta.4",
@@ -169,7 +169,7 @@ export function createCommandPaletteModel(props: { filesOnly?: () => boolean; on
const name = store.vcs?.branch ?? getFilename(directory)
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"),
category: () => language.t("command.category.session"),
})
@@ -233,7 +233,7 @@ function createCommandEntry(option: CommandOption, category: string): CommandPal
function createSessionEntries(props: {
workspaces: () => 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
category: () => string
}) {
@@ -263,7 +263,7 @@ function createSessionEntries(props: {
return props
.load(directory)
.then((result) =>
(result.data ?? [])
result.items
.filter((session) => !!session?.id)
.map((session) => ({
id: session.id,
@@ -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 { useDialog } from "@opencode-ai/ui/context/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"
const CUSTOM_ID = "_custom"
type AuthMethod = ProviderAuthMethod & { readonly id?: string }
export function useProviderConnectController(options: { onBack?: () => void } = {}) {
const [store, setStore] = createStore({ selected: undefined as string | undefined })
@@ -176,6 +177,7 @@ function ProviderConnection(props: {
const providers = useProviders(props.directory)
const alive = { value: true }
const connected = { value: false }
const timer = { current: undefined as ReturnType<typeof setTimeout> | undefined }
onCleanup(() => {
@@ -188,7 +190,16 @@ function ProviderConnection(props: {
const provider = createMemo(
() => 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,
label: language.t("provider.connect.method.apiKey"),
@@ -197,19 +208,52 @@ function ProviderConnection(props: {
const [auth] = createResource(
() => props.provider,
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]
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()
serverSync().set("provider_auth", res.data ?? {})
return res.data?.[props.provider] ?? fallback()
const normalized = Object.fromEntries(
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 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({
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>,
state: "pending" as undefined | "pending" | "complete" | "error" | "prompt",
error: undefined as string | undefined,
@@ -221,7 +265,7 @@ function ProviderConnection(props: {
| { type: "auth.prompt" }
| { type: "auth.inputs"; inputs: Record<string, string> }
| { type: "auth.pending" }
| { type: "auth.complete"; authorization: ProviderAuthAuthorization }
| { type: "auth.complete"; authorization: ProviderAuthorization; attemptID?: string }
| { type: "auth.error"; error: string }
function dispatch(action: Action) {
@@ -230,6 +274,7 @@ function ProviderConnection(props: {
if (action.type === "method.select") {
draft.methodIndex = action.index
draft.authorization = undefined
draft.attemptID = undefined
draft.promptInputs = undefined
draft.state = undefined
draft.error = undefined
@@ -238,6 +283,7 @@ function ProviderConnection(props: {
if (action.type === "method.reset") {
draft.methodIndex = undefined
draft.authorization = undefined
draft.attemptID = undefined
draft.promptInputs = undefined
draft.state = undefined
draft.error = undefined
@@ -262,6 +308,7 @@ function ProviderConnection(props: {
if (action.type === "auth.complete") {
draft.state = "complete"
draft.authorization = action.authorization
draft.attemptID = action.attemptID
draft.error = undefined
return
}
@@ -273,6 +320,16 @@ function ProviderConnection(props: {
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 }) => {
if (!value) return ""
if (value.type === "api") return language.t("provider.connect.method.apiKey")
@@ -322,15 +379,33 @@ function ProviderConnection(props: {
}
dispatch({ type: "auth.pending" })
const start = Date.now()
await serverSDK()
.client.provider.oauth.authorize(
{
providerID: props.provider,
method: index,
inputs,
},
{ throwOnError: true },
)
const backend = await serverSDK().backend
const request =
backend.version === "v1"
? (() => {
const capability = backend.capabilities.providerAuthV1
if (!capability) throw new Error("Server does not support provider authentication")
return capability.authorize({ ...location(), providerID: props.provider, method: index, values: inputs })
})()
: (() => {
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) => {
if (!alive.value) return
const elapsed = Date.now() - start
@@ -341,11 +416,19 @@ function ProviderConnection(props: {
timer.current = setTimeout(() => {
timer.current = undefined
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)
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) => {
if (!alive.value) return
@@ -445,7 +528,7 @@ function ProviderConnection(props: {
<div>
<List
class="px-3"
items={select()?.options ?? []}
items={[...(select()?.options ?? [])]}
key={(x) => x.value}
current={select()?.options.find((x) => x.value === formStore.value[select()!.key])}
onSelect={(value) => {
@@ -498,7 +581,10 @@ function ProviderConnection(props: {
})
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()
showToast({
variant: "success",
@@ -570,14 +656,20 @@ function ProviderConnection(props: {
}
setFormStore("error", undefined)
await serverSDK().client.auth.set({
providerID: props.provider,
auth: {
type: "api",
const backend = await serverSDK().backend
if (backend.version === "v1") {
const capability = backend.capabilities.providerAuthV1
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,
...(store.promptInputs ? { metadata: store.promptInputs } : {}),
},
})
})
}
await complete()
}
@@ -642,13 +734,20 @@ function ProviderConnection(props: {
}
setFormStore("error", undefined)
const result = await serverSDK()
.client.provider.oauth.callback({
providerID: props.provider,
method: store.methodIndex,
code,
})
.then((value) => (value.error ? { ok: false as const, error: value.error } : { ok: true as const }))
const result = await (async () => {
const backend = await serverSDK().backend
if (backend.version === "v1") {
const capability = backend.capabilities.providerAuthV1
if (!capability) throw new Error("Server does not support provider authentication")
await capability.callback({ providerID: props.provider, method: store.methodIndex!, code })
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 }))
if (result.ok) {
await complete()
@@ -695,12 +794,26 @@ function ProviderConnection(props: {
onMount(() => {
void (async () => {
const result = await serverSDK()
.client.provider.oauth.callback({
providerID: props.provider,
method: store.methodIndex,
})
.then((value) => (value.error ? { ok: false as const, error: value.error } : { ok: true as const }))
const result = await (async () => {
const backend = await serverSDK().backend
if (backend.version === "v1") {
const capability = backend.capabilities.providerAuthV1
if (!capability) throw new Error("Server does not support provider authentication")
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 }))
if (!alive.value) return
@@ -118,7 +118,7 @@ export function CustomProviderForm() {
const output = validateCustomProvider({
form,
t: language.t,
disabledProviders: serverSync().data.config.disabled_providers ?? [],
disabledProviders: [...(serverSync().data.config.disabledProviders ?? [])],
existingProviderIDs: new Set(serverSync().data.provider.all.keys()),
})
batch(() => {
@@ -131,23 +131,26 @@ export function CustomProviderForm() {
const saveMutation = useMutation(() => ({
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 backend = await serverSDK().backend
if (result.key) {
await serverSDK().client.auth.set({
providerID: result.providerID,
auth: {
type: "api",
key: result.key,
},
})
if (result.key && backend.version === "v1") {
const capability = backend.capabilities.providerAuthV1
if (!capability) throw new Error("Server does not support provider authentication")
await capability.setApiKey({ providerID: result.providerID, key: result.key })
}
await serverSync().updateConfig({
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
},
onSuccess: (result) => {
@@ -80,9 +80,11 @@ export function DialogEditProject(props: { project: LocalProject; server: Server
const start = store.startup.trim()
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,
directory: props.project.worktree,
location: { directory: props.project.worktree },
name,
icon: { color: store.color || "", override: store.iconOverride || "" },
commands: { start },
+11 -9
View File
@@ -8,7 +8,7 @@ import { Dialog } from "@opencode-ai/ui/dialog"
import { List } from "@opencode-ai/ui/list"
import { showToast } from "@/utils/toast"
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 { useLanguage } from "@/context/language"
@@ -42,7 +42,9 @@ export const DialogFork: Component = () => {
if (message.role !== "user") continue
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
result.push({
@@ -69,15 +71,15 @@ export const DialogFork: Component = () => {
const dir = base64Encode(sdk().directory)
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) => {
if (!forked.data) {
showToast({ title: language.t("common.requestFailed") })
return
}
dialog.close()
prompt.set(restored, undefined, { dir, id: forked.data.id })
navigate(`/${dir}/session/${forked.data.id}`)
prompt.set(restored, undefined, { dir, id: forked.id })
navigate(`/${dir}/session/${forked.id}`)
})
.catch((err: unknown) => {
const message = err instanceof Error ? err.message : String(err)
@@ -68,9 +68,8 @@ export function DialogSelectDirectoryV2(props: DialogSelectDirectoryV2Props) {
const [fallbackPath] = createResource(
() => (missingBase() ? true : undefined),
() =>
sdk.client.path
.get()
.then((result) => result.data)
sdk.backend
.then((client) => client.capabilities.pathInfo?.get())
.catch(() => undefined),
{ initialValue: undefined },
)
@@ -83,20 +82,26 @@ export function DialogSelectDirectoryV2(props: DialogSelectDirectoryV2Props) {
fallbackPath()?.home ||
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 typed = cleanPickerInput(value).replace(/\/+$/, "")
const current = displayPickerPath(root(), value, home()).replace(/\/+$/, "")
if (!typed || typed === current) return { query: value, items: [] }
const directories = (await search(value)).map((absolute) => ({ absolute, type: "directory" as const }))
if (!policy.includeFiles) return { query: value, items: directories.slice(0, 5) }
const files = await sdk.client.find
.files({ directory: root(), query: pickerFileSearchQuery(root(), value, home()), type: "file", limit: 20 })
.then((result) => result.data ?? [])
const files = await sdk.backend
.then((client) =>
client.common.files.find({
location: { directory: root() },
query: pickerFileSearchQuery(root(), value, home()),
type: "file",
limit: 20,
}),
)
.catch(() => [])
const results = [
...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 {
query: value,
@@ -115,9 +120,9 @@ export function DialogSelectDirectoryV2(props: DialogSelectDirectoryV2Props) {
existing ??
loads.schedule(`${generation}:${key}`, eager ? "background" : "user", () => {
if (!activeTreeNavigation(generation, navigation)) return Promise.resolve(undefined)
return sdk.client.file
.list({ directory: absolute, path: "" })
.then((result) => result.data ?? [])
return sdk.backend
.then((client) => client.common.files.list({ location: { directory: absolute }, path: "" }))
.then((nodes) => nodes.map((node) => ({ name: node.name ?? node.path, type: node.type })))
.catch(() => undefined)
})
listings.set(key, request)
@@ -60,9 +60,8 @@ export function DialogSelectDirectory(props: DialogSelectDirectoryProps) {
const [fallbackPath] = createResource(
() => (missingBase() ? true : undefined),
async () => {
return sdk.client.path
.get()
.then((x) => x.data)
return sdk.backend
.then((client) => client.capabilities.pathInfo?.get())
.catch(() => undefined)
},
{ initialValue: undefined },
@@ -74,7 +73,7 @@ export function DialogSelectDirectory(props: DialogSelectDirectoryProps) {
)
const directories = createDirectorySearch({
sdk,
backend: sdk.backend,
home,
base: start,
})
@@ -21,6 +21,7 @@ import {
pickerRoot,
pickerAbsoluteInput,
} from "./directory-picker-domain"
import { createAppClient } from "@/context/backend.test-fixture"
test("maps server directory entries into Pierre paths", () => {
expect(
@@ -132,18 +133,20 @@ test("scopes file autocomplete to the current browser root", () => {
test("resolves directory autocomplete from the current browser root", async () => {
const directories: string[] = []
const sdk = {
client: {
find: {
files: (input: { directory: string }) => {
directories.push(input.directory)
return Promise.resolve({ data: [] })
const backend = Promise.resolve(
createAppClient({
common: {
files: {
find: (input) => {
directories.push(input.location?.directory ?? "")
return Promise.resolve([])
},
},
},
},
} as unknown as Parameters<typeof createDirectorySearch>[0]["sdk"]
}),
)
let base = "/repo"
const search = createDirectorySearch({ sdk, home: () => "/home/luke", base: () => base })
const search = createDirectorySearch({ backend, home: () => "/home/luke", base: () => base })
await search("components")
base = "/repo/src"
@@ -247,7 +247,7 @@ export function nativePickerPath(path: string) {
}
import { getFilename } from "@opencode-ai/core/util/path"
import fuzzysort from "fuzzysort"
import { ServerSDK } from "@/context/server-sdk"
import type { AppClient } from "@/context/backend"
export function cleanPickerInput(value: string) {
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
}
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 }>>>()
let current = 0
@@ -342,14 +346,16 @@ export function createDirectorySearch(args: { sdk: ServerSDK; base: () => string
const key = trimPickerPath(directory)
const existing = cache.get(key)
if (existing) return existing
const request = args.sdk.client.file
.list({ directory: key, path: "" })
.then((result) => result.data ?? [])
const request = args.backend
.then((client) => client.common.files.list({ location: { directory: key }, path: "" }))
.catch(() => [])
.then((nodes) =>
nodes
.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)
return request
@@ -371,12 +377,18 @@ export function createDirectorySearch(args: { sdk: ServerSDK; base: () => string
const pathInput = raw.startsWith("~") || !!pickerRoot(raw) || raw.includes("/")
const query = normalizePickerDrive(input.path)
if (!pathInput) {
const results = await args.sdk.client.find
.files({ directory: input.directory, query, type: "directory", limit: 50 })
.then((result) => result.data ?? [])
const results = await args.backend
.then((client) =>
client.common.files.find({
location: { directory: input.directory },
query,
type: "directory",
limit: 50,
}),
)
.catch(() => [])
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 head = segments.slice(0, -1).filter((part) => part && part !== ".")
@@ -1,4 +1,4 @@
import type { FileNode } from "@opencode-ai/sdk/v2"
import type { AppFileNode as FileNode } from "@/context/backend"
export type FileTreeV2Model = {
children: ReadonlyMap<string, readonly FileTreeV2Node[]>
+1 -1
View File
@@ -12,7 +12,7 @@ import {
type ParentProps,
} from "solid-js"
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 { pathToFileUrl, withFileDragImage, type Kind } from "@/components/file-tree"
import { createVirtualizer, defaultRangeExtractor } from "@tanstack/solid-virtual"
+1 -1
View File
@@ -17,7 +17,7 @@ import {
type ParentProps,
} from "solid-js"
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
+2 -2
View File
@@ -74,7 +74,7 @@ import { promptPlaceholder } from "./prompt-input/placeholder"
import { createPromptInputTransientState } from "./prompt-input/transient-state"
import { showToast } from "@/utils/toast"
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>
@@ -678,7 +678,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
type: "resource",
name: resource.name,
uri: resource.uri,
client: resource.client,
client: resource.server,
display: resource.name,
description: resource.description,
mime: resource.mimeType,
@@ -46,6 +46,8 @@ describe("buildRequestParts", () => {
).toBe(true)
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)
})
@@ -1,12 +1,12 @@
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 { encodeFilePath } from "@/context/file/path"
import type { AgentPart, FileAttachmentPart, ImageAttachmentPart, Prompt } from "@/context/prompt"
import { Identifier } from "@/utils/id"
import { createCommentMetadata, formatCommentNote } from "@/utils/comment-note"
type PromptRequestPart = (TextPartInput | FilePartInput | AgentPartInput) & { id: string }
type PromptRequestPart = PromptPart
type ContextFile = {
key: string
@@ -81,6 +81,34 @@ const clientFor = (directory: string) => {
beforeAll(async () => {
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", () => ({
useNavigate: () => () => undefined,
@@ -89,13 +117,6 @@ beforeAll(async () => {
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", () => ({
Toast: { Region: () => null },
showToast: () => 0,
@@ -161,6 +182,7 @@ beforeAll(async () => {
scope: "local",
directory: "/repo/main",
client: rootClient,
backend,
url: "http://localhost:4096",
createClient(opts: any) {
return clientFor(opts.directory)
@@ -282,7 +304,7 @@ describe("prompt submit worktree selection", () => {
selected = "/repo/worktree-b"
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(sentShell).toEqual(["/repo/worktree-a", "/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)
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])
})
})
@@ -1,4 +1,3 @@
import type { Message, Session } from "@opencode-ai/sdk/v2/client"
import { showToast } from "@/utils/toast"
import { base64Encode } from "@opencode-ai/core/util/encode"
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 { usePermission } from "@/context/permission"
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 { Identifier } from "@/utils/id"
import { Worktree as WorktreeState } from "@/utils/worktree"
@@ -20,6 +19,7 @@ import { setCursorPosition } from "./editor-dom"
import { formatServerError } from "@/utils/server-errors"
import { ScopedKey } from "@/utils/server-scope"
import { createPromptSubmissionState } from "./submission-state"
import type { AppClient, AppMessage, AppSession } from "@/context/backend"
type PendingPrompt = {
abort: AbortController
@@ -39,13 +39,14 @@ export type FollowupDraft = {
}
type FollowupSendInput = {
client: DirectorySDK["client"]
backend: Promise<AppClient>
serverSync: ServerSync
sync: DirectorySync
draft: FollowupDraft
messageID?: string
optimisticBusy?: boolean
before?: () => Promise<boolean> | boolean
commitRevert?: boolean
}
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")
export async function sendFollowupDraft(input: FollowupSendInput) {
const backend = await input.backend
const location = { directory: input.draft.sessionDirectory }
const text = draftText(input.draft.prompt)
const images = draftImages(input.draft.prompt)
const setBusy = () => {
@@ -81,19 +84,26 @@ export async function sendFollowupDraft(input: FollowupSendInput) {
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,
id: input.messageID,
command: cmd,
arguments: tail.join(" "),
agent: input.draft.agent,
model: `${input.draft.model.providerID}/${input.draft.model.modelID}`,
variant: input.draft.variant,
parts: images.map((attachment) => ({
id: Identifier.ascending("part"),
type: "file" as const,
model: {
providerID: input.draft.model.providerID,
id: input.draft.model.modelID,
variant: input.draft.variant,
},
files: images.map((attachment) => ({
uri: attachment.dataUrl,
mime: attachment.mime,
url: attachment.dataUrl,
filename: attachment.filename,
name: attachment.filename,
})),
})
return true
@@ -114,7 +124,7 @@ export async function sendFollowupDraft(input: FollowupSendInput) {
sessionDirectory: input.draft.sessionDirectory,
})
const message: Message = {
const message: AppMessage = {
id: messageID,
sessionID: input.draft.sessionID,
role: "user",
@@ -152,13 +162,22 @@ export async function sendFollowupDraft(input: FollowupSendInput) {
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,
agent: input.draft.agent,
model: input.draft.model,
messageID,
id: messageID,
text,
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
} catch (err) {
@@ -172,7 +191,7 @@ export async function sendFollowupDraft(input: FollowupSendInput) {
type PromptSubmitInput = {
prompt: ReturnType<typeof usePrompt>
info: Accessor<{ id: string } | undefined>
info: Accessor<{ id: string; revert?: { messageID: string } } | undefined>
imageAttachments: Accessor<ImageAttachmentPart[]>
commentCount: Accessor<number>
autoAccept: Accessor<boolean>
@@ -233,9 +252,9 @@ export function createPromptSubmit(input: PromptSubmitInput) {
return Promise.resolve()
}
return sdk()
.client.session.abort({
sessionID,
})
.backend.then((client) =>
client.common.sessions.interrupt({ location: { directory: sdk().directory }, sessionID }),
)
.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)
const [, setStore] = serverSync().child(dir)
setStore("session", (list: Session[]) => {
setStore("session", (list: AppSession[]) => {
const result = Binary.search(list, info.id, (item) => item.id)
const next = [...list]
if (result.found) {
@@ -313,19 +332,18 @@ export function createPromptSubmit(input: PromptSubmitInput) {
input.resetHistoryNavigation()
const projectDirectory = sdk().directory
const backend = await sdk().backend
const isNewSession = !params.id
const shouldAutoAccept = isNewSession && input.autoAccept()
const worktreeSelection = input.newSessionWorktree?.() || "main"
let sessionDirectory = projectDirectory
let client = sdk().client
if (isNewSession) {
if (worktreeSelection === "create") {
const createdWorktree = await client.worktree
.create({ directory: projectDirectory })
.then((x) => x.data)
.catch((err) => {
const createdWorktree = await backend.capabilities.worktreesV1
?.create({ location: { directory: projectDirectory } })
.catch((err: unknown) => {
showToast({
title: language.t("prompt.toast.worktreeCreateFailed.title"),
description: errorMessage(err),
@@ -349,10 +367,6 @@ export function createPromptSubmit(input: PromptSubmitInput) {
}
if (sessionDirectory !== projectDirectory) {
client = sdk().createClient({
directory: sessionDirectory,
throwOnError: true,
})
serverSync().child(sessionDirectory)
}
@@ -361,9 +375,10 @@ export function createPromptSubmit(input: PromptSubmitInput) {
let session = input.info()
if (!session && isNewSession) {
const created = await client.session
.create()
.then((x) => x.data ?? undefined)
const created = await sdk()
.backend.then((backend) =>
backend.common.sessions.create({ location: { directory: sessionDirectory } }),
)
.catch((err) => {
showToast({
title: language.t("prompt.toast.sessionCreateFailed.title"),
@@ -447,12 +462,22 @@ export function createPromptSubmit(input: PromptSubmitInput) {
if (mode === "shell") {
clearInput()
client.session
.shell({
sessionID: session.id,
agent,
model,
command: text,
sdk()
.backend.then(async (backend) => {
const location = { directory: sessionDirectory }
if (session.revert)
await backend.capabilities.sessionExtrasV2?.commitRevert({ location, sessionID: session.id })
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) => {
showToast({
@@ -470,21 +495,27 @@ export function createPromptSubmit(input: PromptSubmitInput) {
const customCommand = sync().data.command.find((c) => c.name === commandName)
if (customCommand) {
clearInput()
client.session
.command({
sessionID: session.id,
command: commandName,
arguments: args.join(" "),
agent,
model: `${model.providerID}/${model.modelID}`,
variant,
parts: images.map((attachment) => ({
id: Identifier.ascending("part"),
type: "file" as const,
mime: attachment.mime,
url: attachment.dataUrl,
filename: attachment.filename,
})),
sdk()
.backend.then(async (backend) => {
const location = { directory: sessionDirectory }
if (session.revert)
await backend.capabilities.sessionExtrasV2?.commitRevert({ location, sessionID: session.id })
const capability = backend.capabilities.sessionActionsV1
if (!capability) throw new Error("Commands are not supported by this server")
return capability.command({
location,
sessionID: session.id,
id: Identifier.ascending("message"),
command: commandName,
arguments: args.join(" "),
agent,
model: { id: model.modelID, providerID: model.providerID, variant },
files: images.map((attachment) => ({
uri: attachment.dataUrl,
mime: attachment.mime,
name: attachment.filename,
})),
})
})
.catch((err) => {
showToast({
@@ -570,12 +601,13 @@ export function createPromptSubmit(input: PromptSubmitInput) {
}
void sendFollowupDraft({
client,
backend: sdk().backend,
sync: sync(),
serverSync: serverSync(),
draft,
messageID,
optimisticBusy: sessionDirectory === projectDirectory,
commitRevert: !!session.revert,
before: waitForWorktree,
}).catch((err) => {
pending.delete(pendingKey(session.id))
@@ -1,5 +1,5 @@
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"
const user = (id: string) => {
@@ -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"
@@ -13,14 +13,14 @@ const estimateTokens = (chars: number) => Math.ceil(chars / 4)
const toPercent = (tokens: number, input: number) => (tokens / input) * 100
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 === "file") return part.source?.text.value.length ?? 0
if (part.type === "agent") return part.source?.value.length ?? 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 === "reasoning") return { assistant: part.text.length, tool: 0 }
if (part.type !== "tool") return { assistant: 0, tool: 0 }
@@ -68,8 +68,8 @@ const build = (
}
export function estimateSessionContextBreakdown(args: {
messages: Message[]
parts: Record<string, Part[] | undefined>
messages: AppMessage[]
parts: Record<string, AppPart[] | undefined>
input: number
systemPrompt?: string
}) {
@@ -1,5 +1,5 @@
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"
const assistant = (
@@ -1,4 +1,4 @@
import type { AssistantMessage, Message } from "@opencode-ai/sdk/v2/client"
import type { AppAssistantMessage, AppMessage } from "@/context/backend"
type Provider = {
id: string
@@ -14,7 +14,7 @@ type Model = {
}
type Context = {
message: AssistantMessage
message: AppAssistantMessage
provider?: Provider
model?: Model
providerLabel: string
@@ -25,11 +25,11 @@ type Context = {
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
}
const lastAssistantWithTokens = (messages: Message[]) => {
const lastAssistantWithTokens = (messages: AppMessage[]) => {
for (let i = messages.length - 1; i >= 0; i--) {
const msg = messages[i]
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)
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)
}
@@ -10,7 +10,7 @@ import { StickyAccordionHeader } from "@opencode-ai/ui/sticky-accordion-header"
import { File } from "@opencode-ai/session-ui/file"
import { Markdown } from "@opencode-ai/session-ui/markdown"
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 { useProviders } from "@/hooks/use-providers"
import { useSDK } from "@/context/sdk"
@@ -126,11 +126,11 @@ export const SettingsGeneral: Component = () => {
const serverSdk = useServerSDK()
const [shells] = createResource(
() =>
serverSdk()
.client.pty.shells()
.then((res) => res.data ?? [])
.catch(() => [] as ShellOption[]),
async () => {
const capability = (await serverSdk().backend).capabilities.shellDiscovery
if (!capability) return []
return capability.list().catch(() => [] as ShellOption[])
},
{ initialValue: [] as ShellOption[] },
)
@@ -12,6 +12,7 @@ import { DialogConnectProvider, useProviderConnectController } from "./dialog-co
import { DialogCustomProvider } from "./dialog-custom-provider"
import { SettingsList } from "./settings-list"
import { SettingsServerPicker, SettingsServerScope } from "./settings-server-picker"
import { credentialConnectionIDs } from "@/context/backend"
type ProviderSource = "env" | "api" | "config" | "custom"
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 before = serverSync().data.config.disabled_providers ?? []
const before = serverSync().data.config.disabledProviders ?? []
const next = before.includes(providerID) ? before : [...before, providerID]
serverSync().set("config", "disabled_providers", next)
serverSync().set("config", "disabledProviders", next)
await serverSync()
.updateConfig({ disabled_providers: next })
.updateConfig({ disabledProviders: next })
.then(() => {
showToast({
variant: "success",
@@ -111,29 +112,47 @@ const SettingsProvidersContent: Component<{ onBack?: () => void }> = (props) =>
})
})
.catch((err: unknown) => {
serverSync().set("config", "disabled_providers", before)
serverSync().set("config", "disabledProviders", before)
const message = err instanceof Error ? err.message : String(err)
showToast({ title: language.t("common.requestFailed"), description: message })
})
}
const disconnect = async (providerID: string, name: string) => {
if (isConfigCustom(providerID)) {
await serverSDK()
.client.auth.remove({ providerID })
.catch(() => undefined)
await disableProvider(providerID, name)
const disconnect = async (item: ProviderItem) => {
const backend = await serverSDK().backend
const remove = async () => {
if (backend.version === "v1") {
const capability = backend.capabilities.providerAuthV1
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
}
await serverSDK()
.client.auth.remove({ providerID })
await remove()
.then(async () => {
await serverSDK().client.global.dispose()
if (backend.version === "v1") await backend.capabilities.runtimeV1?.disposeAll()
if (backend.version === "v2") await serverSync().refreshProviders()
showToast({
variant: "success",
icon: "circle-check",
title: language.t("provider.disconnect.toast.disconnected.title", { provider: name }),
description: language.t("provider.disconnect.toast.disconnected.description", { provider: name }),
title: language.t("provider.disconnect.toast.disconnected.title", { provider: item.name }),
description: language.t("provider.disconnect.toast.disconnected.description", { provider: item.name }),
})
})
.catch((err: unknown) => {
@@ -179,7 +198,7 @@ const SettingsProvidersContent: Component<{ onBack?: () => void }> = (props) =>
</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")}
</Button>
</Show>
@@ -121,11 +121,11 @@ export const SettingsGeneralV2: Component<{
const themeOptions = createMemo<ThemeOption[]>(() => theme.ids().map((id) => ({ id, name: theme.name(id) })))
const [shells] = createResource(
() =>
serverSdk()
.client.pty.shells()
.then((res) => res.data ?? [])
.catch(() => [] as ShellOption[]),
async () => {
const capability = (await serverSdk().backend).capabilities.shellDiscovery
if (!capability) return []
return capability.list().catch(() => [] as ShellOption[])
},
{ initialValue: [] as ShellOption[] },
)
@@ -12,6 +12,7 @@ import { DialogConnectProvider, useProviderConnectController } from "../dialog-c
import { DialogCustomProvider } from "../dialog-custom-provider"
import { SettingsListV2 } from "./parts/list"
import "./settings-v2.css"
import { credentialConnectionIDs } from "@/context/backend"
type ProviderSource = "env" | "api" | "config" | "custom"
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 before = serverSync().data.config.disabled_providers ?? []
const before = serverSync().data.config.disabledProviders ?? []
const next = before.includes(providerID) ? before : [...before, providerID]
serverSync().set("config", "disabled_providers", next)
serverSync().set("config", "disabledProviders", next)
await serverSync()
.updateConfig({ disabled_providers: next })
.updateConfig({ disabledProviders: next })
.then(() => {
showToast({
variant: "success",
@@ -105,29 +106,47 @@ export const SettingsProvidersV2: Component<{ onBack?: () => void }> = (props) =
})
})
.catch((err: unknown) => {
serverSync().set("config", "disabled_providers", before)
serverSync().set("config", "disabledProviders", before)
const message = err instanceof Error ? err.message : String(err)
showToast({ title: language.t("common.requestFailed"), description: message })
})
}
const disconnect = async (providerID: string, name: string) => {
if (isConfigCustom(providerID)) {
await serverSdk()
.client.auth.remove({ providerID })
.catch(() => undefined)
await disableProvider(providerID, name)
const disconnect = async (item: ProviderItem) => {
const backend = await serverSdk().backend
const remove = async () => {
if (backend.version === "v1") {
const capability = backend.capabilities.providerAuthV1
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
}
await serverSdk()
.client.auth.remove({ providerID })
await remove()
.then(async () => {
await serverSdk().client.global.dispose()
if (backend.version === "v1") await backend.capabilities.runtimeV1?.disposeAll()
if (backend.version === "v2") await serverSync().refreshProviders()
showToast({
variant: "success",
icon: "circle-check",
title: language.t("provider.disconnect.toast.disconnected.title", { provider: name }),
description: language.t("provider.disconnect.toast.disconnected.description", { provider: name }),
title: language.t("provider.disconnect.toast.disconnected.title", { provider: item.name }),
description: language.t("provider.disconnect.toast.disconnected.description", { provider: item.name }),
})
})
.catch((err: unknown) => {
@@ -175,7 +194,7 @@ export const SettingsProvidersV2: Component<{ onBack?: () => void }> = (props) =
</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")}
</ButtonV2>
</Show>
@@ -110,8 +110,8 @@ export const SettingsServersV2: Component = () => {
<div class="settings-v2-servers-copy">
<span class="settings-v2-servers-name">{serverName(item)}</span>
<span class="settings-v2-servers-meta">
<Show when={health()?.version}>v{health()?.version}</Show>
<Show when={health()?.version && item.type === "http"}> </Show>
<Show when={health()?.installationVersion}>v{health()?.installationVersion}</Show>
<Show when={health()?.installationVersion && item.type === "http"}> </Show>
<Show
when={item.type === "http" && item.http.username}
fallback={<Show when={item.type === "http"}>{language.t("server.row.noUsername")}</Show>}
+29 -42
View File
@@ -11,12 +11,10 @@ import { matchKeybind, parseKeybind } from "@/context/command"
import { useLanguage } from "@/context/language"
import { usePlatform } from "@/context/platform"
import { useSDK } from "@/context/sdk"
import { useServerSDK } from "@/context/server-sdk"
import { terminalFontFamily, useSettings } from "@/context/settings"
import type { LocalPTY } from "@/context/terminal"
import { disposeIfDisposable, getHoveredLinkText, setOptionIfSupported } from "@/utils/runtime-adapters"
import { terminalWriter } from "@/utils/terminal-writer"
import { terminalWebSocketURL } from "@/utils/terminal-websocket-url"
const TOGGLE_TERMINAL_ID = "terminal.toggle"
const DEFAULT_TOGGLE_TERMINAL_KEYBIND = "ctrl+`"
@@ -174,16 +172,8 @@ export const Terminal = (props: TerminalProps) => {
const settings = useSettings()
const theme = useTheme()
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 client = sdk().client
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
const backend = sdk().backend
let container!: HTMLDivElement
const [local, others] = splitProps(props, ["pty", "class", "classList", "autoFocus", "onConnect", "onConnectError"])
const id = local.pty.id
@@ -233,11 +223,14 @@ export const Terminal = (props: TerminalProps) => {
}
const pushSize = (cols: number, rows: number) => {
return client.pty
.update({
ptyID: id,
size: { cols, rows },
})
return backend
.then((client) =>
client.common.pty.update({
ptyID: id,
size: { cols, rows },
location: { directory },
}),
)
.catch((err) => {
debugTerminal("failed to sync terminal size", err)
})
@@ -490,33 +483,32 @@ export const Terminal = (props: TerminalProps) => {
}
const gone = () =>
client.pty
.get({ ptyID: id }, { throwOnError: false })
.then((result) => result.response.status === 404)
backend
.then((client) => {
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) => {
debugTerminal("failed to inspect terminal session", err)
return false
})
const connectToken = async () => {
const result = await client.pty
.connectToken(
{ ptyID: id, directory },
{
throwOnError: false,
headers: { "x-opencode-ticket": "1" },
},
)
const transport = (await backend).capabilities.ptyTransport
if (!transport) return
const result = await transport
.connectToken({ ptyID: id, location: { directory } })
.catch((err: unknown) => {
if (err instanceof Error && err.message.includes("Request is not supported")) return
throw err
})
if (!result) return
if (result.response.status === 200 && result.data?.ticket) return result.data.ticket
if (result.response.status === 404 || result.response.status === 405) return
if (result.response.status === 403)
if (result.status === 200 && result.ticket) return result.ticket
if (result.status === 404 || result.status === 405) return
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 failed with ${result.response.status}`)
throw new Error(`PTY connect ticket failed with ${result.status}`)
}
const retry = (err: unknown) => {
@@ -549,18 +541,13 @@ export const Terminal = (props: TerminalProps) => {
if (once.value) 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(
terminalWebSocketURL({
url,
id,
directory,
cursor: seek,
ticket,
sameOrigin,
username,
password,
authToken,
}),
transport.connectURL({ ptyID: id, location: { directory }, cursor: seek, ticket }),
)
socket.binaryType = "arraybuffer"
ws = socket
@@ -9,7 +9,7 @@ import { ServerConnection, serverName } from "@/context/server"
import { displayName, projectForSession } from "@/pages/layout/helpers"
import { SessionTabAvatar } from "@/pages/layout/session-tab-avatar"
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 { TabPreviewPopover } from "./titlebar-tab-popover"
import "./titlebar-tab-nav.css"
@@ -21,7 +21,7 @@ export function TabNavItem(props: {
ref?: Ref<HTMLDivElement>
href: string
server: ServerConnection.Key
session: () => Session | undefined
session: () => AppSession | undefined
fallbackTitle?: string
onTitleChange?: (title: string) => void
onTitleChangeFailed?: (title: string) => void
@@ -120,8 +120,10 @@ export function TabNavItem(props: {
const ctx = serverCtx()
const session = props.session()
if (!ctx || !session) return
const client = ctx.sdk.createClient({ directory: session.directory, throwOnError: true })
await client.session.update({ sessionID: session.id, title })
const client = await ctx.sdk.backend
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) => {
+2 -3
View File
@@ -267,9 +267,8 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
return conn ? { route, sdk: global.ensureServerCtx(conn).sdk } : undefined
},
({ route, sdk }) =>
sdk.client.session
.get({ sessionID: route.sessionId })
.then((x) => x.data)
sdk.backend
.then((client) => client.common.sessions.get({ sessionID: route.sessionId }))
.catch(() => {}),
)
@@ -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()
})
})
@@ -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)
}
+407
View File
@@ -0,0 +1,407 @@
import { describe, expect, test } from "bun:test"
import { createOpencodeClient } from "@opencode-ai/sdk-v1/v2/client"
import type { PtyTransportConfig } from "./backend"
import { createV1Backend } from "./backend-v1"
function setup(
respond: (request: Request) => Response | Promise<Response>,
withDefault = false,
transport?: Partial<Pick<PtyTransportConfig, "sameOrigin" | "authToken">>,
) {
const requests: Request[] = []
const fetch = Object.assign(
async (input: RequestInfo | URL, init?: RequestInit) => {
const request = input instanceof Request ? input : new Request(input, init)
requests.push(request)
return respond(request)
},
{ preconnect: globalThis.fetch.preconnect },
) satisfies typeof globalThis.fetch
const client = createOpencodeClient({ baseUrl: "http://localhost", fetch })
return {
requests,
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,
},
),
}
}
function json(data: unknown, headers?: HeadersInit) {
return new Response(JSON.stringify(data), {
headers: { "content-type": "application/json", ...Object.fromEntries(new Headers(headers)) },
})
}
const session = {
id: "ses_1",
slug: "one",
projectID: "project",
directory: "/repo",
title: "Session",
version: "1",
time: { created: 1, updated: 2 },
}
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 () => {
const setupResult = setup(() => json([session], { "x-next-cursor": "456" }))
const result = await setupResult.backend.common.sessions.list({
location: { directory: "/repo", workspaceID: "workspace" },
roots: true,
limit: 10,
cursor: "123",
})
expect(result).toEqual({
items: [
{
id: "ses_1",
slug: "one",
version: "1",
parentID: undefined,
projectID: "project",
location: { directory: "/repo", workspaceID: undefined },
directory: "/repo",
workspaceID: undefined,
title: "Session",
cost: 0,
tokens: undefined,
time: { created: 1, updated: 2 },
share: undefined,
revert: undefined,
},
],
older: "456",
})
const url = new URL(setupResult.requests[0].url)
expect(url.pathname).toBe("/experimental/session")
expect(url.searchParams.get("directory")).toBe("/repo")
expect(url.searchParams.get("workspace")).toBe("workspace")
expect(url.searchParams.get("roots")).toBe("true")
expect(url.searchParams.get("cursor")).toBe("123")
})
test("converts normalized prompts to legacy parts", async () => {
const setupResult = setup(() => new Response(null, { status: 204 }))
await setupResult.backend.common.sessions.prompt({
sessionID: "ses_1",
location: { directory: "/explicit", workspaceID: "explicit-workspace" },
id: "msg_1",
text: "hello",
selection: {
agent: "build",
model: { id: "model", providerID: "provider", variant: "high" },
},
files: [{ uri: "data:text/plain;base64,aGk=", name: "hi.txt", mime: "text/plain" }],
agents: [{ name: "explore", text: "@explore", start: 6, end: 14 }],
})
const request = setupResult.requests[0]
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({
messageID: "msg_1",
model: { providerID: "provider", modelID: "model" },
agent: "build",
variant: "high",
parts: [
{ type: "text", text: "hello" },
{ type: "file", mime: "text/plain", filename: "hi.txt", url: "data:text/plain;base64,aGk=" },
{ type: "agent", name: "explore", source: { value: "@explore", start: 6, end: 14 } },
],
})
})
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 () => {
const setupResult = setup((request) => {
const url = new URL(request.url)
if (url.pathname === "/find/file") {
return json(url.searchParams.get("type") === "file" ? ["a.txt", "shared"] : ["dir", "shared"])
}
return json({ type: "binary", content: "AAEC", encoding: "base64", mimeType: "application/octet-stream" })
})
const found = await setupResult.backend.common.files.find({ query: "a" })
const content = await setupResult.backend.common.files.read({ path: "a.bin" })
expect(found).toEqual([
{ path: "a.txt", type: "file" },
{ path: "shared", type: "directory" },
{ path: "dir", type: "directory" },
])
expect([...content.bytes]).toEqual([0, 1, 2])
expect(content.kind).toBe("binary")
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 () => {
const bodies: unknown[] = []
const setupResult = setup(async (request) => {
if (request.method === "GET") return json({ autoupdate: true, model: "old", disabled_providers: ["one"] })
bodies.push(await request.json())
return json({})
})
await setupResult.backend.capabilities.configuration?.updateGlobal({
model: "new",
disabledProviders: ["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"))
})
})
File diff suppressed because it is too large Load Diff
+745
View File
@@ -0,0 +1,745 @@
import { describe, expect, test } from "bun:test"
import { OpenCode } from "@opencode-ai/client"
import { credentialConnectionIDs, type PtyTransportConfig } from "./backend"
import { createV2Backend } from "./backend-v2"
function setup(
respond: (request: Request) => Response | Promise<Response>,
transport?: Partial<Pick<PtyTransportConfig, "sameOrigin" | "authToken" | "password">>,
) {
const requests: Request[] = []
const fetch = Object.assign(
async (input: RequestInfo | URL, init?: RequestInit) => {
const request = input instanceof Request ? input : new Request(input, init)
requests.push(request)
return respond(request)
},
{ preconnect: globalThis.fetch.preconnect },
) satisfies typeof globalThis.fetch
const client = OpenCode.make({ baseUrl: "http://localhost", fetch })
return {
requests,
backend: createV2Backend(
client,
{
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,
),
}
}
function json(data: unknown) {
return new Response(JSON.stringify(data), { headers: { "content-type": "application/json" } })
}
const session = {
id: "ses_1",
projectID: "project",
cost: 1.5,
tokens: { input: 1, output: 2, reasoning: 3, cache: { read: 4, write: 5 } },
time: { created: 10, updated: 20 },
title: "Session",
location: { directory: "/repo", workspaceID: "workspace" },
}
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 () => {
const setupResult = setup(() => json({ data: [session], cursor: { previous: "before", next: "after" } }))
const result = await setupResult.backend.common.sessions.list({
location: { directory: "/explicit", workspaceID: "explicit-workspace" },
limit: 10,
cursor: "cursor",
})
expect(result).toEqual({
items: [
{
id: "ses_1",
slug: "ses_1",
version: "",
parentID: undefined,
projectID: "project",
location: { directory: "/repo", workspaceID: "workspace" },
directory: "/repo",
workspaceID: "workspace",
title: "Session",
cost: 1.5,
tokens: { input: 1, output: 2, reasoning: 3, cache: { read: 4, write: 5 } },
time: { created: 10, updated: 20 },
revert: undefined,
},
],
newer: "before",
older: "after",
})
const url = new URL(setupResult.requests[0].url)
expect(url.pathname).toBe("/api/session")
expect(url.searchParams.get("directory")).toBe("/explicit")
expect(url.searchParams.get("workspace")).toBe("explicit-workspace")
expect(url.searchParams.has("parentID")).toBe(false)
expect(url.searchParams.get("cursor")).toBe("cursor")
})
test("paginates native sessions until roots:true contains only the requested roots", async () => {
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.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" })
const url = new URL(setupResult.requests[0].url)
expect(url.pathname).toBe("/api/fs/list")
expect(url.searchParams.get("location[directory]")).toBe("/default")
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 () => {
const setupResult = setup((request) =>
request.url.endsWith("/prompt")
? json({
data: {
admittedSeq: 1,
id: "msg_1",
sessionID: "ses_1",
timeCreated: 1,
type: "user",
data: { text: "hello" },
delivery: "steer",
},
})
: new Response(null, { status: 204 }),
)
await setupResult.backend.common.sessions.prompt({
sessionID: "ses_1",
id: "msg_1",
text: "hello",
selection: { agent: "build", model: { id: "model", providerID: "provider", variant: "high" } },
files: [{ uri: "data:text/plain;base64,aGk=", name: "hi.txt", source: { text: "hi", start: 0, end: 2 } }],
})
expect(setupResult.requests.map((item) => new URL(item.url).pathname)).toEqual([
"/api/session/ses_1/agent",
"/api/session/ses_1/model",
"/api/session/ses_1/prompt",
])
expect(await setupResult.requests[2].json()).toEqual({
id: "msg_1",
prompt: {
text: "hello",
files: [
{
uri: "data:text/plain;base64,aGk=",
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 () => {
const setupResult = setup(() => json({ data: { messageID: "msg_1", files: [] } }))
await setupResult.backend.capabilities.sessionExtrasV2?.stageRevert({
sessionID: "ses_1",
messageID: "msg_1",
files: ["a.txt"],
})
expect(await setupResult.requests[0].json()).toEqual({ messageID: "msg_1", files: true })
})
test("maps ordered app prompt parts to the native prompt shape", async () => {
const setupResult = setup(() =>
json({ data: { admittedSeq: 1, id: "msg_1", sessionID: "ses_1", timeCreated: 1, type: "user", data: {} } }),
)
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()).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()
expect(result.value).toMatchObject({
location: { directory: "/repo" },
event: {
type: "session.activity",
sessionID: "ses_1",
activity: { type: "running" },
},
})
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 () => {
const setupResult = setup(
() =>
new Response(
`data: ${JSON.stringify({
id: "evt_started",
type: "session.next.step.started",
durable: { aggregateID: "ses_1", seq: 7, version: 1 },
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 capability = setupResult.backend.capabilities.sessionExtrasV2
if (!capability) throw new Error("Missing V2 session capability")
const result = await capability.log({ sessionID: "ses_1" })[Symbol.asyncIterator]().next()
expect(result.value).toMatchObject({
sequence: 7,
event: { type: "session.activity", sessionID: "ses_1", activity: { type: "running" } },
})
expect(new URL(setupResult.requests[0].url).pathname).toBe("/api/session/ses_1/event")
})
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) => {
if (new URL(request.url).pathname === "/api/event") {
return new Response(
`data: ${JSON.stringify({
id: "evt_1",
type: "session.next.text.delta",
location: { directory: "/repo" },
data: {
timestamp: 1,
sessionID: "ses_1",
assistantMessageID: "msg_1",
textID: "text_1",
delta: "hi",
},
})}\n\n`,
{ headers: { "content-type": "text/event-stream" } },
)
}
return json({
data: {
id: "msg_1",
type: "assistant",
time: { created: 1 },
agent: "build",
model: { id: "model", providerID: "provider" },
content: [{ type: "text", id: "text_1", text: "hello" }],
},
})
})
const result = await setupResult.backend.common.events.subscribe()[Symbol.asyncIterator]().next()
expect(result.value).toEqual({
location: { directory: "/repo" },
event: {
type: "timeline.delta",
sessionID: "ses_1",
itemID: "msg_1",
contentID: "text_1",
field: "text",
delta: "hi",
},
})
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
@@ -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 },
},
}
}
File diff suppressed because it is too large Load Diff
+21 -13
View File
@@ -1,7 +1,7 @@
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 { 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 { createServerSyncContextInner } from "./server-sync"
import type { State } from "./global-sync/types"
@@ -23,8 +23,8 @@ export const createDirSyncContext = (
serverSync: ReturnType<typeof createServerSyncContextInner>,
serverSDK: ReturnType<typeof createServerSdkContext>,
) => {
const client = serverSDK.createClient({ directory, throwOnError: 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 data = new Proxy({} as State, {
get(_, property: keyof State) {
@@ -72,7 +72,7 @@ export const createDirSyncContext = (
if (match.found) return serverSync.data.project[match.index]
},
session: {
remember(session: Session) {
remember(session: AppSession) {
serverSync.session.remember(session)
index(session.id)
},
@@ -81,7 +81,7 @@ export const createDirSyncContext = (
if (session?.directory === directory) return session
},
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)
},
remove(input: { directory?: string; sessionID: string; messageID: string }) {
@@ -91,7 +91,7 @@ export const createDirSyncContext = (
addOptimisticMessage(input: {
sessionID: string
messageID: string
parts: Part[]
parts: AppPart[]
agent: string
model: { providerID: string; modelID: string }
variant?: string
@@ -110,7 +110,7 @@ export const createDirSyncContext = (
})
},
async sync(sessionID: string, options?: { force?: boolean }) {
await serverSync.session.sync(sessionID, options)
await serverSync.session.sync(sessionID, { ...options, location: { directory } })
index(sessionID)
},
diff: serverSync.session.diff,
@@ -121,17 +121,25 @@ export const createDirSyncContext = (
fetch: async (count = 10) => {
const [store, setStore] = current()
setStore("limit", (value) => value + count)
const response = await client.session.list()
const sessions = (response.data ?? [])
.filter((session) => !!session?.id)
const backend = await serverSDK.backend
const response = await backend.common.sessions.list({
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))
.slice(0, store.limit)
sessions.forEach(serverSync.session.remember)
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) => {
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](
"session",
produce((draft) => {
+33 -9
View File
@@ -79,10 +79,16 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({
const tree = createFileTreeStore({
scope,
normalizeDir: path.normalizeDir,
list: (dir) =>
sdk()
.client.file.list({ path: dir })
.then((x) => x.data ?? []),
list: async (dir) =>
[...(await (await sdk().backend).common.files.list({ location: { directory: scope() }, path: dir }))].map(
(node) => ({
name: node.name ?? getFilename(node.path),
path: node.path,
absolute: node.absolute ?? node.path,
type: node.type,
ignored: node.ignored,
}),
),
onError: (message) => {
showToast({
variant: "error",
@@ -181,10 +187,18 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({
setLoading(file)
const promise = sdk()
.client.file.read({ path: file })
.then((x) => {
.backend.then(async (client) => {
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)
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 }) =>
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(
(x) => (x.data ?? []).map(path.normalize),
(items) => items.map((item) => path.normalize(item.path)),
(error) => {
if (options?.signal?.aborted) throw error
return []
@@ -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_BYTES = 20 * 1024 * 1024
+1 -1
View File
@@ -1,5 +1,5 @@
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 = {
expanded: boolean
+1 -1
View File
@@ -1,4 +1,4 @@
import type { FileContent } from "@opencode-ai/sdk/v2"
import type { DecoratedFileContent as FileContent } from "../backend"
export type FileSelection = {
startLine: number
+7 -5
View File
@@ -1,8 +1,10 @@
import type { FileNode } from "@opencode-ai/sdk/v2"
import type { AppFileNode as FileNode } from "../backend"
type WatcherEvent = {
type: string
properties: unknown
path?: string
change?: string
properties?: unknown
}
type WatcherOps = {
@@ -16,11 +18,11 @@ type 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 =
typeof event.properties === "object" && event.properties ? (event.properties as Record<string, unknown>) : undefined
const rawPath = typeof props?.file === "string" ? props.file : undefined
const kind = typeof props?.event === "string" ? props.event : undefined
const rawPath = event.path ?? (typeof props?.file === "string" ? props.file : undefined)
const kind = event.change ?? (typeof props?.event === "string" ? props.event : undefined)
if (!rawPath) return
if (!kind) return
@@ -1,14 +1,15 @@
import { describe, expect, test } from "bun:test"
import { createStore } from "solid-js/store"
import { QueryClient } from "@tanstack/solid-query"
import type { Config, OpencodeClient, Project, Session } from "@opencode-ai/sdk/v2/client"
import type { NormalizedProviderListResponse } from "@opencode-ai/session-ui/context"
import type { AppClient, AppProject as Project, AppSession as Session } from "../backend"
import { createAppClient } from "../backend.test-fixture"
import type { ProviderStore } from "./types"
import { bootstrapDirectory, loadPathQuery, loadProvidersQuery } from "./bootstrap"
import type { State, VcsCache } from "./types"
import { createServerSession } from "../server-session"
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() {
return createStore<State>({
@@ -30,6 +31,7 @@ function directoryState() {
return this.session_status[id]?.type !== "idle"
},
session_diff: {},
todo: {},
permission: {},
question: {},
mcp_ready: true,
@@ -55,33 +57,35 @@ describe("bootstrapDirectory", () => {
scope: ServerScope.local,
mcp: false,
global: {
config: {} satisfies Config,
config: {},
path: { state: "", config: "", worktree: "/project", directory: "/project", home: "/home" },
project: [{ id: "project", worktree: "/project" } as Project],
provider,
},
sdk: {
app: { agents: async () => ({ data: [{ name: "build", mode: "primary" }] }) },
config: { get: async () => ({ data: {} }) },
session: { status: async () => ({ data: {} }) },
vcs: { get: async () => ({ data: undefined }) },
command: {
list: async () => {
mcpReads.push("command")
return { data: [] }
},
backend: {
version: "v1",
capabilities: {
configuration: { get: async () => ({}), getGlobal: async () => ({}), updateGlobal: async () => {} },
vcsInfo: { get: async () => ({}) },
},
permission: { list: async () => ({ data: [] }) },
question: { list: async () => ({ data: [] }) },
v2: { reference: { list: async () => ({ data: { data: [] } }) } },
mcp: {
status: async () => {
mcpReads.push("status")
return { data: {} }
common: {
catalog: {
agents: async () => [{ id: "build", name: "build", mode: "primary", hidden: false }],
providers: async () => ({ providers: new Map(), connected: [], defaults: {} }),
},
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 OpencodeClient,
} as unknown as AppClient,
store,
setStore,
vcsCache: { setStore() {} } as unknown as VcsCache,
@@ -101,22 +105,26 @@ describe("bootstrapDirectory", () => {
test("seeds session status even while warming session info stalls", async () => {
const [store, setStore] = directoryState()
const stalled = Promise.withResolvers<never>()
const client = {
app: { agents: async () => ({ data: [{ name: "build", mode: "primary" }] }) },
config: { get: async () => ({ data: {} }) },
session: {
status: async () => ({ data: { ses_busy: { type: "busy" } } }),
get: () => stalled.promise,
const backend = createAppClient({
version: "v1",
capabilities: {
configuration: { get: async () => ({}), getGlobal: async () => ({}), updateGlobal: async () => {} },
vcsInfo: { get: async () => ({}) },
},
vcs: { get: async () => ({ data: undefined }) },
command: { list: async () => ({ data: [] }) },
permission: { list: async () => ({ data: [] }) },
question: { list: async () => ({ data: [] }) },
v2: { reference: { list: async () => ({ data: { data: [] } }) } },
mcp: { status: async () => ({ data: {} }) },
provider: { list: async () => ({ data: { all: [], connected: [], default: {} } }) },
} as unknown as OpencodeClient
const session = createServerSession(client)
common: {
catalog: {
agents: async () => [{ id: "build", name: "build", mode: "primary", hidden: false }],
providers: async () => ({ providers: new Map(), connected: [], defaults: {} }),
},
sessions: { activity: async () => ({ ses_busy: { type: "busy" } }), get: () => stalled.promise },
projects: { current: async () => ({ id: "project", directory: "/project" }) },
commands: { list: async () => [] },
permissions: { pending: async () => [] },
questions: { pending: async () => [] },
references: { list: async () => [] },
},
})
const session = createServerSession(backend)
const stale: Session = {
id: "ses_stale",
slug: "ses_stale",
@@ -134,12 +142,12 @@ describe("bootstrapDirectory", () => {
scope: ServerScope.local,
mcp: false,
global: {
config: {} satisfies Config,
config: {},
path: { state: "", config: "", worktree: "/project", directory: "/project", home: "/home" },
project: [{ id: "project", worktree: "/project" } as Project],
provider,
},
sdk: client,
backend,
store,
setStore,
vcsCache: { setStore() {} } as unknown as VcsCache,
@@ -161,12 +169,12 @@ describe("bootstrapDirectory", () => {
describe("query keys", () => {
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
expect([...loadPathQuery(ServerScope.local, "/repo", client).queryKey]).toEqual(["local", "/repo", "path"])
expect([...loadPathQuery(remote, "/repo", client).queryKey]).toEqual(["https://debian.example", "/repo", "path"])
expect([...loadProvidersQuery(remote, null, client).queryKey]).toEqual([
expect([...loadPathQuery(ServerScope.local, "/repo", backend).queryKey]).toEqual(["local", "/repo", "path"])
expect([...loadPathQuery(remote, "/repo", backend).queryKey]).toEqual(["https://debian.example", "/repo", "path"])
expect([...loadProvidersQuery(remote, null, backend).queryKey]).toEqual([
"https://debian.example",
null,
"providers",
+167 -94
View File
@@ -1,35 +1,35 @@
import type {
Config,
OpencodeClient,
Path,
PermissionRequest,
Project,
ProviderAuthResponse,
QuestionRequest,
ReferenceInfo,
Session,
} from "@opencode-ai/sdk/v2/client"
AppClient,
AppConfig,
AppPathInfo,
AppPermissionRequest,
AppProject,
AppProviderAuthResponse,
AppQuestionRequest,
AppReference,
AppSession,
ProviderCatalog,
} from "../backend"
import { showToast } from "@/utils/toast"
import { getFilename } from "@opencode-ai/core/util/path"
import { retry } from "@opencode-ai/core/util/retry"
import { batch } from "solid-js"
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 { cmp, normalizeAgentList, normalizeProviderList } from "./utils"
import { cmp } from "./utils"
import { formatServerError } from "@/utils/server-errors"
import { QueryClient, queryOptions } from "@tanstack/solid-query"
import { loadMcpQuery, loadMcpResourcesQuery } from "../server-sync"
import { NormalizedProviderListResponse } from "@opencode-ai/session-ui/context"
import { ScopedKey, type ServerScope } from "@/utils/server-scope"
type GlobalStore = {
ready: boolean
path: Path
project: Project[]
provider: NormalizedProviderListResponse
provider_auth: ProviderAuthResponse
config: Config
path: AppPathInfo
project: AppProject[]
provider: ProviderStore
provider_auth: AppProviderAuthResponse
config: StoreConfig
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({
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({
queryKey: [scope, "project"],
queryFn: () =>
retry(() =>
sdk.project.list().then((x) => {
return (x.data ?? [])
.filter((p) => !!p?.id)
.filter((p) => !!p.worktree && !p.worktree.includes("opencode-test"))
.slice()
.sort((a, b) => cmp(a.id, b.id))
}),
backend
.then((client) => client.capabilities.projectList?.list() ?? [])
.then((projects) => {
return projects
.filter((p) => !!p?.id)
.filter((p) => !!p.worktree && !p.worktree.includes("opencode-test"))
.slice()
.sort((a, b) => cmp(a.id, b.id))
}),
),
})
export async function bootstrapGlobal(input: {
serverSDK: OpencodeClient
backend: Promise<AppClient>
scope: ServerScope
requestFailedTitle: string
translate: (key: string, vars?: Record<string, string | number>) => string
@@ -113,12 +115,12 @@ export async function bootstrapGlobal(input: {
queryClient: QueryClient
}) {
const slow = [
() => input.queryClient.fetchQuery(loadGlobalConfigQuery(input.scope, input.serverSDK)),
() => input.queryClient.fetchQuery(loadProvidersQuery(input.scope, null, input.serverSDK)),
() => input.queryClient.fetchQuery(loadPathQuery(input.scope, null, input.serverSDK)),
() => input.queryClient.fetchQuery(loadGlobalConfigQuery(input.scope, input.backend)),
() => input.queryClient.fetchQuery(loadProvidersQuery(input.scope, null, input.backend)),
() => input.queryClient.fetchQuery(loadPathQuery(input.scope, null, input.backend)),
() =>
input.queryClient
.fetchQuery(loadProjectsQuery(input.scope, input.serverSDK))
.fetchQuery(loadProjectsQuery(input.scope, input.backend))
.then((data) => input.setGlobalStore("project", data)),
]
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
}
function mergeSession(setStore: SetStoreFunction<State>, session: Session) {
function mergeSession(setStore: SetStoreFunction<State>, session: AppSession) {
setStore("session", (list) => {
const next = list.slice()
const idx = next.findIndex((item) => item.id >= session.id)
@@ -162,44 +164,54 @@ function warmSessions(input: {
ids: string[]
store: Store<State>
setStore: SetStoreFunction<State>
sdk: OpencodeClient
backend: AppClient
location: { directory: string }
}) {
const known = new Set(input.store.session.map((item) => item.id))
const ids = [...new Set(input.ids)].filter((id) => !!id && !known.has(id))
if (ids.length === 0) return Promise.resolve()
return Promise.all(
ids.map((sessionID) =>
retry(() => input.sdk.session.get({ sessionID })).then((x) => {
const session = x.data
if (!session?.id) return
mergeSession(input.setStore, session)
retry(() => input.backend.common.sessions.get({ sessionID, location: input.location })).then((x) => {
if (!x?.id) return
mergeSession(input.setStore, x)
}),
),
).then(() => undefined)
}
export const loadProvidersQuery = (scope: ServerScope, directory: string | null, sdk: OpencodeClient) =>
export const loadProvidersQuery = (scope: ServerScope, directory: string | null, backend: Promise<AppClient>) =>
queryOptions({
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({
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) =>
queryOptions<Path>({
export const loadPathQuery = (scope: ServerScope, directory: string | null, backend: Promise<AppClient>) =>
queryOptions<AppPathInfo>({
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) =>
queryOptions<ReferenceInfo[]>({
export const loadReferencesQuery = (scope: ServerScope, directory: string, backend: Promise<AppClient>) =>
queryOptions<readonly AppReference[]>({
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: [],
})
@@ -207,17 +219,17 @@ export async function bootstrapDirectory(input: {
directory: string
scope: ServerScope
mcp: boolean
sdk: OpencodeClient
backend: AppClient
store: Store<State>
setStore: SetStoreFunction<State>
vcsCache: VcsCache
loadSessions: (directory: string) => Promise<void> | void
translate: (key: string, vars?: Record<string, string | number>) => string
global: {
config: Config
path: Path
project: Project[]
provider: NormalizedProviderListResponse
config: StoreConfig
path: AppPathInfo
project: readonly AppProject[]
provider: ProviderStore
}
queryClient: QueryClient
session?: ServerSession
@@ -240,66 +252,90 @@ export async function bootstrapDirectory(input: {
() => Promise.resolve(input.loadSessions(input.directory)),
() =>
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)),
() =>
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(() =>
input.sdk.session.status().then(async (x) => {
input.backend.common.sessions.activity(location(input.directory)).then(async (statuses) => {
if (!input.session) {
input.setStore("session_status", x.data!)
input.setStore("session_status", mapActivity(statuses))
return
}
const statuses = x.data ?? {}
const mapped = mapActivity(statuses)
input.session.set(
"session_status",
produce((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]
}
}),
)
for (const [sessionID, status] of Object.entries(statuses)) {
for (const [sessionID, status] of Object.entries(mapped)) {
input.session.set("session_status", sessionID, reconcile(status))
}
// Warm session info only after seeding statuses so a stalled session
// fetch cannot park busy indicators behind it, mirroring how live
// session.status events apply first and resolve info in the background.
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 &&
(() => 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 &&
(() =>
input.queryClient.ensureQueryData(loadPathQuery(input.scope, input.directory, input.sdk)).then((data) => {
const next = projectID(data.directory ?? input.directory, input.global.project)
if (next) input.setStore("project", next)
})),
input.queryClient
.ensureQueryData(loadPathQuery(input.scope, input.directory, Promise.resolve(input.backend)))
.then((data) => {
const next = projectID(data.directory ?? input.directory, input.global.project)
if (next) input.setStore("project", next)
})),
() =>
retry(() =>
input.sdk.vcs.get().then((x) => {
const next = x.data ?? input.store.vcs
input.setStore("vcs", next)
if (next) input.vcsCache.setStore("value", next)
}),
(input.backend.capabilities.vcsInfo?.get(location(input.directory)) ?? Promise.resolve(undefined)).then(
(data) => {
const next = data ?? input.store.vcs
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.queryClient.fetchQuery(loadReferencesQuery(input.scope, input.directory, input.sdk)),
input.mcp &&
(() =>
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(() =>
input.sdk.permission.list().then((x) => {
const ids = (x.data ?? []).map((perm) => perm?.sessionID).filter((id): id is string => !!id)
const grouped = groupBySession(
(x.data ?? []).filter((perm): perm is PermissionRequest => !!perm?.id && !!perm.sessionID),
input.backend.common.permissions.pending(location(input.directory)).then((data) => {
const permissions = data.map(
(perm): AppPermissionRequest => perm,
)
const ids = permissions.map((perm) => perm.sessionID)
const grouped = groupBySession(permissions)
const warm = input.session
? 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(() =>
batch(() => {
const current = input.session?.data.permission ?? input.store.permission
@@ -323,12 +359,21 @@ export async function bootstrapDirectory(input: {
),
() =>
retry(() =>
input.sdk.question.list().then((x) => {
const ids = (x.data ?? []).map((question) => question?.sessionID).filter((id): id is string => !!id)
const grouped = groupBySession((x.data ?? []).filter((q): q is QuestionRequest => !!q?.id && !!q.sessionID))
input.backend.common.questions.pending(location(input.directory)).then((data) => {
const questions = data.map(
(question): AppQuestionRequest => question,
)
const ids = questions.map((question) => question.sessionID)
const grouped = groupBySession(questions)
const warm = input.session
? 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(() =>
batch(() => {
const current = input.session?.data.question ?? input.store.question
@@ -351,17 +396,25 @@ export async function bootstrapDirectory(input: {
}),
),
() => Promise.resolve(input.loadSessions(input.directory)),
input.mcp && (() => input.queryClient.fetchQuery(loadMcpQuery(input.scope, input.directory, input.sdk))),
input.mcp && (() => input.queryClient.fetchQuery(loadMcpResourcesQuery(input.scope, input.directory, input.sdk))),
input.mcp &&
(() =>
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) => {
const project = getFilename(input.directory)
showToast({
variant: "error",
title: input.translate("toast.project.reloadFailed.title", { project }),
description: formatServerError(err, input.translate),
})
}),
input.queryClient
.fetchQuery(loadProvidersQuery(input.scope, input.directory, Promise.resolve(input.backend)))
.catch((err) => {
const project = getFilename(input.directory)
showToast({
variant: "error",
title: input.translate("toast.project.reloadFailed.title", { project }),
description: formatServerError(err, input.translate),
})
}),
].filter(Boolean) as (() => Promise<any>)[]
await waitForPaint()
@@ -379,3 +432,23 @@ export async function bootstrapDirectory(input: {
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
}
@@ -1,8 +1,7 @@
import { beforeAll, describe, expect, mock, test } from "bun:test"
import { createRoot, getOwner, type Owner } from "solid-js"
import { createStore } from "solid-js/store"
import type { NormalizedProviderListResponse } from "@opencode-ai/session-ui/context"
import type { State } from "./types"
import type { ProviderStore, State } from "./types"
import type { QueryOptionsApi } from "../server-sync"
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 provider = { all: new Map(), connected: [], default: {} } satisfies NormalizedProviderListResponse
const provider = { all: new Map(), connected: [], default: {} } satisfies ProviderStore
const queryOptionsApi = {
globalConfig: () => ({ queryKey: ["globalConfig"], queryFn: async () => ({}) }),
@@ -1,7 +1,7 @@
import { createRoot, createSignal, getOwner, onCleanup, runWithOwner, type Owner } from "solid-js"
import { createStore, type SetStoreFunction, type Store } from "solid-js/store"
import { Persist, persisted } from "@/utils/persist"
import type { VcsInfo } from "@opencode-ai/sdk/v2/client"
import type { AppVcsInfo } from "../backend"
import {
DIR_IDLE_TTL_MS,
MAX_DIR_STORES,
@@ -10,6 +10,7 @@ import {
type IconCache,
type MetaCache,
type ProjectMeta,
type ProviderStore,
type State,
type VcsCache,
} from "./types"
@@ -17,7 +18,6 @@ import { canDisposeDirectory, pickDirectoriesToEvict } from "./eviction"
import { useQuery } from "@tanstack/solid-query"
import { QueryOptionsApi } from "../server-sync"
import { directoryKey, type DirectoryKey } from "./utils"
import { NormalizedProviderListResponse } from "@opencode-ai/session-ui/context"
import type { ServerScope } from "@/utils/server-scope"
export function createChildStoreManager(input: {
@@ -32,7 +32,7 @@ export function createChildStoreManager(input: {
translate: (key: string, vars?: Record<string, string | number>) => string
queryOptions: QueryOptionsApi
global: {
provider: NormalizedProviderListResponse
provider: ProviderStore
}
}) {
const children: Record<string, [Store<State>, SetStoreFunction<State>]> = {}
@@ -152,7 +152,7 @@ export function createChildStoreManager(input: {
const vcs = runWithOwner(input.owner, () =>
input.persist(
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"))
@@ -223,6 +223,7 @@ export function createChildStoreManager(input: {
return (type ?? "idle") !== "idle"
},
session_diff: {},
todo: {},
permission: {},
question: {},
get mcp_ready() {
@@ -1,5 +1,12 @@
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 type { State } from "./types"
import { applyDirectoryEvent, applyGlobalEvent, cleanupDroppedSessionCaches } from "./event-reducer"
@@ -39,7 +46,9 @@ const permissionRequest = (id: string, sessionID: string, title = id) =>
id,
sessionID,
permission: title,
action: title,
patterns: ["*"],
resources: ["*"],
metadata: {},
always: [],
}) as PermissionRequest
@@ -523,9 +532,9 @@ describe("applyDirectoryEvent", () => {
})
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({
value: { branch: "main", default_branch: "main" } as State["vcs"],
value: { branch: "main", defaultBranch: "main" } as State["vcs"],
})
applyDirectoryEvent({
@@ -542,8 +551,8 @@ describe("applyDirectoryEvent", () => {
},
})
expect(store.vcs).toEqual({ branch: "feature/test", default_branch: "main" })
expect(cacheStore.value).toEqual({ branch: "feature/test", default_branch: "main" })
expect(store.vcs).toEqual({ branch: "feature/test", defaultBranch: "main" })
expect(cacheStore.value).toEqual({ branch: "feature/test", defaultBranch: "main" })
})
test("routes disposal and lsp events to side-effect handlers", () => {
@@ -1,15 +1,18 @@
import { Binary } from "@opencode-ai/core/util/binary"
import { produce, reconcile, type SetStoreFunction, type Store } from "solid-js/store"
import type {
Message,
Part,
PermissionRequest,
Project,
QuestionRequest,
Session,
SessionStatus,
FileDiffInfo,
} from "@opencode-ai/sdk/v2/client"
AppEvent,
AppFileDiff,
AppMessage,
AppPart,
AppPermissionRequest,
AppProject,
AppQuestionRequest,
AppSession,
AppTodo,
SessionActivity,
} from "../backend"
import { timelineMessage } from "../backend"
import type { State, VcsCache } from "./types"
import { trimSessions } from "./session-trim"
import { dropSessionCaches } from "./session-cache"
@@ -31,19 +34,76 @@ const SESSION_CONTENT_EVENTS = new Set([
"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: {
event: { type: string; properties?: unknown }
project: Project[]
setGlobalProject: (next: Project[] | ((draft: Project[]) => Project[])) => void
event: AppEvent | LegacyEvent
project: AppProject[]
setGlobalProject: (next: AppProject[] | ((draft: AppProject[]) => AppProject[])) => 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()
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)
if (result.found) {
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
setStore(
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 stale = [
...Object.keys(store.message),
@@ -90,7 +159,7 @@ export function cleanupDroppedSessionCaches(store: Store<State>, setStore: SetSt
}
export function applyDirectoryEvent(input: {
event: { type: string; properties?: unknown }
event: AppEvent | DirectoryLegacyEvent
store: Store<State>
setStore: SetStoreFunction<State>
push: (directory: string) => void
@@ -98,11 +167,17 @@ export function applyDirectoryEvent(input: {
loadLsp: () => void
loadReferences?: () => void
vcsCache?: VcsCache
setSessionTodo?: (sessionID: string, todos: AppTodo[] | undefined) => void
retainedLimit?: number
sessionContent?: boolean
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
const limit = Math.max(input.store.limit, input.retainedLimit ?? 0)
switch (event.type) {
@@ -111,7 +186,7 @@ export function applyDirectoryEvent(input: {
return
}
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)
if (result.found) {
input.setStore("session", result.index, reconcile(info))
@@ -126,7 +201,7 @@ export function applyDirectoryEvent(input: {
break
}
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)
if (info.time.archived) {
if (input.store.session[result.index]!.time.archived === info.time.archived) break
@@ -155,8 +230,10 @@ export function applyDirectoryEvent(input: {
break
}
case "session.deleted": {
const info = (event.properties as { info: Session }).info
const result = Binary.search(input.store.session, info.id, (s) => s.id)
const properties = event.properties as { sessionID?: string; info?: { id: string } }
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) {
input.setStore(
"session",
@@ -165,23 +242,29 @@ export function applyDirectoryEvent(input: {
}),
)
}
cleanupSessionCaches(input.setStore, info.id)
if (info.parentID) break
cleanupSessionCaches(input.setStore, sessionID, input.setSessionTodo)
if (info?.parentID) break
input.setStore("sessionTotal", (value) => Math.max(0, value - 1))
break
}
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" }))
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": {
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))
break
}
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]
if (!messages) {
input.setStore("message", info.sessionID, [info])
@@ -222,7 +305,7 @@ export function applyDirectoryEvent(input: {
break
}
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
input.setStore(
produce((draft) => {
@@ -306,7 +389,7 @@ export function applyDirectoryEvent(input: {
break
}
case "permission.asked": {
const permission = event.properties as PermissionRequest
const permission = event.properties as AppPermissionRequest
const permissions = input.store.permission[permission.sessionID]
if (!permissions) {
input.setStore("permission", permission.sessionID, [permission])
@@ -342,7 +425,7 @@ export function applyDirectoryEvent(input: {
break
}
case "question.asked": {
const question = event.properties as QuestionRequest
const question = event.properties as AppQuestionRequest
const questions = input.store.question[question.sessionID]
if (!questions) {
input.setStore("question", question.sessionID, [question])
+3 -2
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: {
status: McpStatus["status"]
status: AppMcpStatus["status"]
connect: () => Promise<void>
disconnect: () => Promise<void>
authenticate: () => Promise<void>
@@ -9,6 +9,7 @@ export async function toggleMcp(input: {
}) {
await {
connected: input.disconnect,
pending: async () => {},
needs_auth: input.authenticate,
disabled: input.connect,
failed: input.connect,
@@ -1,12 +1,12 @@
import { describe, expect, test } from "bun:test"
import type {
Message,
Part,
PermissionRequest,
QuestionRequest,
SessionStatus,
FileDiffInfo,
} from "@opencode-ai/sdk/v2/client"
AppMessage as Message,
AppPart as Part,
AppPermissionRequest as PermissionRequest,
AppQuestionRequest as QuestionRequest,
AppFileDiff as SnapshotFileDiff,
SessionActivity as SessionStatus,
} from "../backend"
import { dropSessionCaches, pickSessionCacheEvictions } from "./session-cache"
const msg = (id: string, sessionID: string) =>
@@ -32,7 +32,8 @@ describe("app session cache", () => {
test("dropSessionCaches clears orphaned parts without message rows", () => {
const store: {
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>
part: Record<string, Part[] | undefined>
permission: Record<string, PermissionRequest[] | undefined>
@@ -41,6 +42,7 @@ describe("app session cache", () => {
} = {
session_status: { ses_1: { type: "busy" } as SessionStatus },
session_diff: { ses_1: [] },
todo: {},
message: {},
part: { msg_1: [part("prt_1", "ses_1", "msg_1")] },
permission: { ses_1: [] as PermissionRequest[] },
@@ -63,7 +65,8 @@ describe("app session cache", () => {
const m = msg("msg_1", "ses_1")
const store: {
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>
part: Record<string, Part[] | undefined>
permission: Record<string, PermissionRequest[] | undefined>
@@ -72,6 +75,7 @@ describe("app session cache", () => {
} = {
session_status: {},
session_diff: {},
todo: {},
message: { ses_1: [m] },
part: { [m.id]: [part("prt_1", "ses_1", m.id)] },
permission: {},
@@ -85,6 +89,21 @@ describe("app session cache", () => {
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", () => {
const seen = new Set(["ses_1", "ses_2", "ses_3"])
@@ -1,21 +1,13 @@
import type {
Message,
Part,
PermissionRequest,
QuestionRequest,
SessionStatus,
FileDiffInfo,
} from "@opencode-ai/sdk/v2/client"
export const SESSION_CACHE_LIMIT = 40
type SessionCache = {
session_status: Record<string, SessionStatus | undefined>
session_diff: Record<string, FileDiffInfo[] | undefined>
message: Record<string, Message[] | undefined>
part: Record<string, Part[] | undefined>
permission: Record<string, PermissionRequest[] | undefined>
question: Record<string, QuestionRequest[] | undefined>
session_status: Record<string, unknown>
session_diff: Record<string, unknown>
todo?: Record<string, unknown>
message: Record<string, readonly { id: string }[] | undefined>
part: Record<string, readonly { id: string; sessionID: string }[] | undefined>
permission: Record<string, unknown>
question: Record<string, unknown>
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) {
delete store.message[sessionID]
delete store.session_diff[sessionID]
delete store.todo?.[sessionID]
delete store.session_status[sessionID]
delete store.permission[sessionID]
delete store.question[sessionID]
@@ -1,5 +1,5 @@
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"
const session = (input: { id: string; parentID?: string; created: number; updated?: number; archived?: number }) =>
@@ -43,7 +43,7 @@ describe("trimSessions", () => {
const result = trimSessions(list, {
limit: 2,
permission: {
"child-kept-by-permission": [{ id: "perm-1" } as PermissionRequest],
"child-kept-by-permission": [{ id: "perm-1" }],
},
now,
})
@@ -1,21 +1,21 @@
import type { PermissionRequest, Session } from "@opencode-ai/sdk/v2/client"
import type { AppSession } from "../backend"
import { cmp } from "./utils"
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
}
export function compareSessionRecent(a: Session, b: Session) {
export function compareSessionRecent(a: AppSession, b: AppSession) {
const aUpdated = sessionUpdatedAt(a)
const bUpdated = sessionUpdatedAt(b)
if (aUpdated !== bUpdated) return bUpdated - aUpdated
return cmp(a.id, b.id)
}
export function takeRecentSessions(sessions: Session[], limit: number, cutoff: number) {
if (limit <= 0) return [] as Session[]
const selected: Session[] = []
export function takeRecentSessions(sessions: AppSession[], limit: number, cutoff: number) {
if (limit <= 0) return [] as AppSession[]
const selected: AppSession[] = []
const seen = new Set<string>()
for (const session of sessions) {
if (!session?.id) continue
@@ -31,8 +31,8 @@ export function takeRecentSessions(sessions: Session[], limit: number, cutoff: n
}
export function trimSessions(
input: Session[],
options: { limit: number; permission: Record<string, PermissionRequest[]>; now?: number },
input: AppSession[],
options: { limit: number; permission: Record<string, readonly unknown[]>; now?: number },
) {
const limit = Math.max(0, options.limit)
const cutoff = (options.now ?? Date.now()) - SESSION_RECENT_WINDOW
+53 -39
View File
@@ -1,22 +1,23 @@
import type {
Agent,
Command,
Config,
LspStatus,
McpResource,
McpStatus,
Message,
Part,
Path,
PermissionRequest,
QuestionRequest,
ReferenceInfo,
Session,
SessionStatus,
FileDiffInfo,
VcsInfo,
} from "@opencode-ai/sdk/v2/client"
import { NormalizedProviderListResponse } from "@opencode-ai/session-ui/context"
AppMessage,
AppPart,
AppPermissionRequest,
AppQuestionRequest,
AppSession,
AppFileDiff,
AppTodo,
AppCommand,
AppConfig,
AppAgent,
AppLspStatus,
AppMcpResource,
AppMcpStatus,
AppPathInfo,
AppProvider,
AppReference,
AppVcsInfo,
SessionActivity,
} from "../backend"
import type { Accessor } from "solid-js"
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 = {
status: "loading" | "partial" | "complete"
agent: Agent[]
command: Command[]
reference: ReferenceInfo[]
agent: AppAgent[]
command: readonly AppCommand[]
reference: readonly AppReference[]
project: string
projectMeta: ProjectMeta | undefined
icon: string | undefined
provider_ready: boolean
provider: NormalizedProviderListResponse
config: Config
path: Path
session: Session[]
provider: ProviderStore
config: StoreConfig
path: AppPathInfo
session: AppSession[]
sessionTotal: number
session_status: {
[sessionID: string]: SessionStatus
[sessionID: string]: SessionActivity
}
session_working(id: string): boolean
session_diff: {
[sessionID: string]: FileDiffInfo[]
[sessionID: string]: AppFileDiff[]
}
todo: {
[sessionID: string]: AppTodo[]
}
permission: {
[sessionID: string]: PermissionRequest[]
[sessionID: string]: AppPermissionRequest[]
}
question: {
[sessionID: string]: QuestionRequest[]
[sessionID: string]: AppQuestionRequest[]
}
mcp_ready: boolean
mcp: {
[name: string]: McpStatus
[name: string]: AppMcpStatus
}
mcp_resource: {
[key: string]: McpResource
[key: string]: AppMcpResource
}
lsp_ready: boolean
lsp: LspStatus[]
vcs: VcsInfo | undefined
lsp: readonly AppLspStatus[]
vcs: AppVcsInfo | undefined
limit: number
message: {
[sessionID: string]: Message[]
[sessionID: string]: AppMessage[]
}
part: {
[messageID: string]: Part[]
[messageID: string]: AppPart[]
}
part_text_accum_delta: {
[partID: string]: string
@@ -81,8 +95,8 @@ export type State = {
}
export type VcsCache = {
store: Store<{ value: VcsInfo | undefined }>
setStore: SetStoreFunction<{ value: VcsInfo | undefined }>
store: Store<{ value: AppVcsInfo | undefined }>
setStore: SetStoreFunction<{ value: AppVcsInfo | undefined }>
ready: Accessor<boolean>
}
@@ -127,11 +141,11 @@ export type DisposeCheck = {
export type RootLoadArgs = {
directory: string
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 = {
data?: Session[]
data?: AppSession[]
limit: number
limited: boolean
}
@@ -1,38 +1,5 @@
import { describe, expect, test } from "bun:test"
import type { Agent } from "@opencode-ai/sdk/v2/client"
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")])
})
})
import { directoryKey } from "./utils"
describe("directoryKey", () => {
test("normalizes slashes", () => {
+1 -36
View File
@@ -1,43 +1,8 @@
import type { Agent, Project, ProviderListResponse } from "@opencode-ai/sdk/v2/client"
import { NormalizedProviderListResponse } from "@opencode-ai/session-ui/context"
import type { AppProject as Project } from "../backend"
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)
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) {
if (!project.icon?.url && !project.icon?.override) return project
return {
+41 -6
View File
@@ -3,20 +3,25 @@ import { createEffect, createMemo, createRoot } from "solid-js"
import { createStore } from "solid-js/store"
import { createServerProjects, RECENTLY_CLOSED_DISPLAY_LIMIT, ServerConnection, useServer } from "./server"
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 { createServerSyncContext } from "./server-sync"
import { getOwner } from "solid-js/web"
import { QueryClient } from "@tanstack/solid-query"
import type { ServerScope } from "@/utils/server-scope"
import { usePlatform } from "./platform"
import { backendIdentity, createBackendForServer } from "./backend-client"
export const { use: useGlobal, provider: GlobalProvider } = createSimpleContext({
name: "Global",
init: () => {
const server = useServer()
const platform = usePlatform()
const checkServerHealth = useCheckServerHealth()
const serverHealth = useServerHealth(
() => server.list,
() => true,
checkServerHealth,
)
const [store, setStore] = createStore({
settings: {
@@ -37,7 +42,7 @@ export const { use: useGlobal, provider: GlobalProvider } = createSimpleContext(
const serverCtxs = new Map<
ServerConnection.Key,
{ dispose: () => void; serverCtx: ReturnType<typeof createServerCtx> }
{ dispose: () => void; identity: string; serverCtx: ReturnType<typeof createServerCtx> }
>()
const owner = getOwner()
@@ -45,10 +50,26 @@ export const { use: useGlobal, provider: GlobalProvider } = createSimpleContext(
const ensureServerCtx = (conn: ServerConnection.Any) => {
const key = ServerConnection.key(conn)
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 serverCtx = createServerCtx(conn, server.scope(key), server.projects.forServer(key))
return { dispose, serverCtx }
const serverCtx = createServerCtx(
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)
serverCtxs.set(key, root)
return root.serverCtx
@@ -97,6 +118,7 @@ function createServerCtx(
conn: ServerConnection.Any,
scope: ServerScope,
projects: ReturnType<typeof createServerProjects>,
backend: ReturnType<typeof createBackendForServer>,
) {
const queryClient = new QueryClient({
defaultOptions: {
@@ -107,7 +129,7 @@ function createServerCtx(
},
},
})
const sdk = createServerSdkContext(conn, scope)
const sdk = createServerSdkContext(conn, scope, backend)
const sync = createServerSyncContext(sdk)
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))
return {
backend,
queryClient,
sdk,
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>
function isLocalHost(url: string) {
+8 -3
View File
@@ -7,7 +7,7 @@ import { useServerSync } from "./server-sync"
import { useServerSDK } from "./server-sdk"
import { RECENTLY_CLOSED_DISPLAY_LIMIT, ServerConnection, useServer } from "./server"
import { usePlatform } from "./platform"
import { Project } from "@opencode-ai/sdk/v2"
import type { AppProject } from "./backend"
import { Persist, persisted, removePersisted } from "@/utils/persist"
import { pathKey } from "@/utils/path-key"
import { decode64 } from "@/utils/base64"
@@ -72,7 +72,7 @@ type TabHandoff = {
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 ReviewDiffStyle = "unified" | "split"
@@ -550,6 +550,7 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
setColors(worktree, color)
}
if (!project.id) continue
const projectID = project.id
const requested = colorRequested.get(worktree)
if (requested === color) continue
@@ -561,7 +562,11 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
}
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(() => {
if (colorRequested.get(worktree) === color) colorRequested.delete(worktree)
})
+9 -1
View File
@@ -64,7 +64,15 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
const models = useModels()
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 [saved, setSaved, , savedReady] = persisted(
+2 -2
View File
@@ -50,7 +50,7 @@ export const { use: useModels, provider: ModelsProvider } = createSimpleContext(
() =>
new Map(
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
}),
),
@@ -75,7 +75,7 @@ export const { use: useModels, provider: ModelsProvider } = createSimpleContext(
values(),
(groups) =>
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 }] : []
}),
),
+9 -10
View File
@@ -9,7 +9,6 @@ import { useLanguage } from "@/context/language"
import { useSettings } from "@/context/settings"
import { base64Encode } from "@opencode-ai/core/util/encode"
import { decode64 } from "@/utils/base64"
import { EventSessionError } from "@opencode-ai/sdk/v2"
import { Persist, persisted } from "@/utils/persist"
import { playSoundById } from "@/utils/sound"
import { useGlobal } from "./global"
@@ -32,7 +31,7 @@ type TurnCompleteNotification = NotificationBase & {
type ErrorNotification = NotificationBase & {
type: "error"
error: EventSessionError["properties"]["error"]
error: unknown
}
export type Notification = TurnCompleteNotification | ErrorNotification
@@ -325,8 +324,7 @@ function createServerNotificationState(input: {
return sessionID === activeSession
}
const handleSessionIdle = (directory: string, event: { properties: { sessionID?: string } }, time: number) => {
const sessionID = event.properties.sessionID
const handleSessionIdle = (directory: string, sessionID: string, time: number) => {
void lookup(directory, sessionID).then((session) => {
if (meta.disposed) return
if (!session) return
@@ -353,10 +351,10 @@ function createServerNotificationState(input: {
const handleSessionError = (
directory: string,
event: { properties: { sessionID?: string; error?: EventSessionError["properties"]["error"] } },
event: { sessionID?: string; error?: unknown },
time: number,
) => {
const sessionID = event.properties.sessionID
const sessionID = event.sessionID
void lookup(directory, sessionID).then((session) => {
if (meta.disposed) return
if (session?.parentID) return
@@ -365,7 +363,7 @@ function createServerNotificationState(input: {
void playSoundById(settings.sounds.errors())
}
const error = "error" in event.properties ? event.properties.error : undefined
const error = event.error
append({
directory,
time,
@@ -386,12 +384,13 @@ function createServerNotificationState(input: {
const unsub = serverSDK().event.listen((e) => {
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 time = Date.now()
if (event.type === "session.idle") {
handleSessionIdle(directory, event, time)
if (event.type === "session.activity") {
if (event.activity.type !== "idle") return
handleSessionIdle(directory, event.sessionID, time)
return
}
handleSessionError(directory, event, time)
@@ -1,5 +1,5 @@
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 { autoRespondsPermission, isDirectoryAutoAccepting } from "./permission-auto-respond"
+20 -10
View File
@@ -1,7 +1,6 @@
import { type Accessor, createEffect, createMemo, onCleanup } from "solid-js"
import { createStore, produce } from "solid-js/store"
import { createSimpleContext } from "@opencode-ai/ui/context"
import type { PermissionRequest } from "@opencode-ai/sdk/v2/client"
import { Persist, persisted } from "@/utils/persist"
import { useServerSDK } from "@/context/server-sdk"
import { useServerSync } from "./server-sync"
@@ -21,6 +20,8 @@ type PermissionRespondFn = (input: {
directory?: string
}) => void
type PermissionRequest = { id: string; sessionID: string }
function isNonAllowRule(rule: unknown) {
if (!rule) return false
if (typeof rule === "string") return rule !== "allow"
@@ -119,8 +120,17 @@ export const { use: usePermission, provider: PermissionProvider } = createSimple
}
const respond: PermissionRespondFn = (input) => {
const directory = input.directory ?? props.directory?.() ?? decode64(params.dir)
if (!directory) return
serverSDK()
.client.permission.respond(input)
.backend.then((client) =>
client.common.permissions.reply({
sessionID: input.sessionID,
requestID: input.permissionID,
reply: input.response,
location: { directory },
}),
)
.catch(() => {
responded.delete(input.permissionID)
})
@@ -164,9 +174,9 @@ export const { use: usePermission, provider: PermissionProvider } = createSimple
const unsubscribe = serverSDK().event.listen((e) => {
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
respondOnce(perm, e.name)
@@ -182,10 +192,10 @@ export const { use: usePermission, provider: PermissionProvider } = createSimple
)
serverSDK()
.client.permission.list({ directory })
.then((x) => {
.backend.then((client) => client.common.permissions.pending({ location: { directory } }))
.then((items) => {
if (!isAutoAcceptingDirectory(directory)) return
for (const perm of x.data ?? []) {
for (const perm of items) {
if (!perm?.id) continue
if (!shouldAutoRespond(perm, directory)) continue
respondOnce(perm, directory)
@@ -214,11 +224,11 @@ export const { use: usePermission, provider: PermissionProvider } = createSimple
)
serverSDK()
.client.permission.list({ directory })
.then((x) => {
.backend.then((client) => client.common.permissions.pending({ location: { directory } }))
.then((items) => {
if (enableVersion.get(key) !== version) return
if (!isAutoAccepting(sessionID, directory)) return
for (const perm of x.data ?? []) {
for (const perm of items) {
if (!perm?.id) continue
if (!shouldAutoRespond(perm, directory)) continue
respondOnce(perm, directory)
+1 -1
View File
@@ -1,5 +1,5 @@
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 { createStore, type SetStoreFunction } from "solid-js/store"
import type { FileSelection } from "@/context/file"
+58 -61
View File
@@ -1,6 +1,6 @@
import { describe, expect, test } from "bun:test"
import { coalesceServerEvents, enqueueServerEvent, resumeStreamAfterPageShow } from "./server-sdk"
import type { Event } from "@opencode-ai/sdk/v2/client"
import type { AppEvent } from "./backend"
describe("resumeStreamAfterPageShow", () => {
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") => ({
directory: "/repo",
payload: {
type: "message.part.delta",
properties: { messageID: "msg", partID, field, delta: value },
} as Event,
type: "timeline.delta",
sessionID: "ses",
itemID: "msg",
contentID: partID,
field,
delta: value,
} as AppEvent,
})
test("merges adjacent deltas for the same field", () => {
const first = delta("hello ")
const second = delta("world")
first.payload.id = "first"
second.payload.id = "second"
const result = coalesceServerEvents([first, second])
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", () => {
const status = {
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")])
expect(result.map((event) => event.payload.type)).toEqual([
"message.part.delta",
"message.part.delta",
"session.status",
"message.part.delta",
"timeline.delta",
"timeline.delta",
"session.activity",
"timeline.delta",
])
})
@@ -53,104 +55,99 @@ describe("coalesceServerEvents", () => {
const first = delta("a")
const other = delta("b", "text", "other")
const last = delta("c")
first.payload.id = "1"
other.payload.id = "2"
last.payload.id = "3"
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", () => {
const partUpdated = (text: string) =>
({
type: "message.part.updated",
properties: {
type: "timeline.updated",
item: {
type: "user",
id: "message",
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", () => {
const events: Array<{ directory: string; payload: Event }> = []
const enqueue = (payload: Event) => enqueueServerEvent(events, { directory: "/repo", payload })
const events: Array<{ directory: string; payload: AppEvent }> = []
const enqueue = (payload: AppEvent) => enqueueServerEvent(events, { directory: "/repo", payload })
enqueue(partUpdated("old"))
enqueue({ type: "message.removed", properties: { sessionID: "session", messageID: "message" } } as Event)
enqueue({ type: "timeline.removed", sessionID: "session", itemID: "message" })
enqueue({
type: "message.updated",
properties: {
type: "timeline.updated",
item: {
type: "user",
created: 1,
content: [],
sessionID: "session",
info: {
id: "message",
sessionID: "session",
role: "user",
time: { created: 1 },
agent: "build",
model: { providerID: "provider", modelID: "model" },
},
id: "message",
},
} as Event)
})
enqueue(partUpdated("new"))
expect(events.map((event) => event.payload.type)).toEqual([
"message.part.updated",
"message.removed",
"message.updated",
"message.part.updated",
"timeline.updated",
"timeline.removed",
"timeline.updated",
])
})
test("preserves deltas after a replacement snapshot", () => {
const events: Array<{ directory: string; payload: Event }> = []
const enqueue = (payload: Event) => enqueueServerEvent(events, { directory: "/repo", payload })
const events: Array<{ directory: string; payload: AppEvent }> = []
const enqueue = (payload: AppEvent) => enqueueServerEvent(events, { directory: "/repo", payload })
enqueue(partUpdated("a"))
enqueue(partUpdated("ab"))
enqueue({
type: "message.part.delta",
properties: { sessionID: "session", messageID: "message", partID: "part", field: "text", delta: "c" },
} as Event)
type: "timeline.delta",
sessionID: "session",
itemID: "message",
contentID: "part",
field: "text",
delta: "c",
})
const result = coalesceServerEvents(events)
expect(result.map((event) => event.payload.type)).toEqual(["message.part.updated", "message.part.delta"])
expect(result[0]?.payload).toMatchObject({ properties: { part: { text: "ab" } } })
expect(result[1]?.payload).toMatchObject({ properties: { delta: "c" } })
expect(result.map((event) => event.payload.type)).toEqual(["timeline.updated", "timeline.delta"])
expect(result[0]?.payload).toMatchObject({ item: { content: [{ text: "ab" }] } })
expect(result[1]?.payload).toMatchObject({ delta: "c" })
})
test("preserves updates after session deletion", () => {
const events: Array<{ directory: string; payload: Event }> = []
const enqueue = (payload: Event) => enqueueServerEvent(events, { directory: "/repo", payload })
const events: Array<{ directory: string; payload: AppEvent }> = []
const enqueue = (payload: AppEvent) => enqueueServerEvent(events, { directory: "/repo", payload })
enqueue(partUpdated("old"))
enqueue({
id: "event-delete",
type: "session.deleted",
properties: { sessionID: "session" },
} as Event)
sessionID: "session",
})
enqueue(partUpdated("new"))
expect(events.map((event) => event.payload.type)).toEqual([
"message.part.updated",
"timeline.updated",
"session.deleted",
"message.part.updated",
"timeline.updated",
])
})
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") =>
enqueueServerEvent(events, {
directory: "/repo",
payload: {
type: "session.status",
properties: {
sessionID: "session",
status: status === "retry" ? { type: "retry", attempt: 1, message: "retry", next: 1 } : { type: "busy" },
},
} as Event,
type: "session.activity",
sessionID: "session",
activity: status === "retry" ? { type: "retry", attempt: 1, message: "retry", next: 1 } : { type: "running" },
},
})
enqueue("retry")
+29 -68
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 { createGlobalEmitter } from "@solid-primitives/event-bus"
import { makeEventListener } from "@solid-primitives/event-listener"
import { type Accessor, batch, createMemo, onCleanup, onMount } from "solid-js"
import { createSdkForServer } from "@/utils/server"
import { useLanguage } from "./language"
import { usePlatform } from "./platform"
import { ServerConnection, useServer } from "./server"
@@ -15,14 +14,15 @@ const isAbortError = (error: unknown) =>
error !== null && typeof error === "object" && "name" in error && error.name === "AbortError"
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) => {
if (event.payload.type === "lsp.updated") return `lsp.updated:${event.directory}`
if (event.payload.type === "message.part.updated") {
const part = event.payload.properties.part
return `message.part.updated:${event.directory}:${part.messageID}:${part.id}`
if (event.payload.type === "timeline.updated") {
return `timeline.updated:${event.directory}:${event.payload.item.id}`
}
if (event.payload.type === "timeline.content.updated")
return `timeline.content.updated:${event.directory}:${event.payload.itemID}:${event.payload.content.id}`
return undefined
}
@@ -40,23 +40,22 @@ export function enqueueServerEvent(queue: QueuedServerEvent[], event: QueuedServ
export function coalesceServerEvents(events: QueuedServerEvent[]) {
const output: QueuedServerEvent[] = []
events.forEach((event) => {
if (event.payload.type !== "message.part.delta") {
if (event.payload.type !== "timeline.delta") {
output.push(event)
return
}
const props = event.payload.properties
const previous = output[output.length - 1]
if (
!previous ||
previous.payload.type !== "message.part.delta" ||
previous.payload.type !== "timeline.delta" ||
previous.directory !== event.directory ||
previous.payload.properties.messageID !== props.messageID ||
previous.payload.properties.partID !== props.partID ||
previous.payload.properties.field !== props.field
previous.payload.itemID !== event.payload.itemID ||
previous.payload.contentID !== event.payload.contentID ||
previous.payload.field !== event.payload.field
) {
output.push({
directory: event.directory,
payload: { ...event.payload, properties: { ...props } },
payload: { ...event.payload },
})
return
}
@@ -64,7 +63,7 @@ export function coalesceServerEvents(events: QueuedServerEvent[]) {
directory: event.directory,
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()
}
function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerScope) {
function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerScope, backend: Promise<AppClient>) {
const platform = usePlatform()
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<{
[key: string]: Event
[key: string]: AppEvent
}>()
type Queued = QueuedServerEvent
@@ -174,29 +168,13 @@ function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerS
}
abort.signal.addEventListener("abort", onAbort)
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()
resetHeartbeat()
for await (const event of events.stream) {
for await (const envelope of (await backend).common.events.subscribe({ signal: attempt.signal })) {
resetHeartbeat()
streamErrorLogged = false
if (event.payload.type !== "sync") {
const directory = event.directory ?? "global"
const payload = event.payload as Event
if (enqueueServerEvent(queue, { directory, payload })) schedule()
}
const directory = envelope.location?.directory ?? "global"
if (enqueueServerEvent(queue, { directory, payload: envelope.event })) schedule()
if (Date.now() - yielded < STREAM_YIELD_MS) continue
yielded = Date.now()
@@ -253,39 +231,30 @@ function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerS
flush()
})
const sdk = createSdkForServer({
server: server.http,
fetch: platform.fetch,
throwOnError: true,
})
return {
server,
scope,
url: server.http.url,
client: sdk,
event: {
on: emitter.on.bind(emitter),
listen: emitter.listen.bind(emitter),
start,
},
createClient(opts: Omit<Parameters<typeof createSdkForServer>[0], "server" | "fetch">) {
return createSdkForServer({
server: server.http,
fetch: platform.fetch,
...opts,
})
},
}
}
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>
}
export function createServerSdkContext(server: ServerConnection.Any, scope: ServerScope): ServerSDK {
const sdk = createServerSdkContextBase(server, scope)
export function createServerSdkContext(
server: ServerConnection.Any,
scope: ServerScope,
backend: Promise<AppClient>,
): ServerSDK {
const sdk = Object.assign(createServerSdkContextBase(server, scope, backend), { backend })
return Object.assign(sdk, {
ensureDirSdkContext: createRefCountMap((dir) => createDirSdkContext(dir, sdk)),
})
@@ -309,15 +278,10 @@ export const { use: useServerSDK, provider: ServerSDKProvider } = createSimpleCo
})
type SDKEventMap = {
[key in Event["type"]]: Extract<Event, { type: key }>
[key in AppEvent["type"]]: AppEvent
}
function createDirSdkContext(directory: string, serverSDK: ServerSDKBase) {
const client = serverSDK.createClient({
directory,
throwOnError: true,
})
function createDirSdkContext(directory: string, serverSDK: ServerSDKWithBackend) {
const emitter = createGlobalEmitter<SDKEventMap>()
const unsub = serverSDK.event.on(directory, (event) => {
@@ -328,13 +292,10 @@ function createDirSdkContext(directory: string, serverSDK: ServerSDKBase) {
return {
scope: serverSDK.scope,
directory,
client,
backend: serverSDK.backend,
event: emitter,
get url() {
return serverSDK.url
},
createClient(opts: Parameters<typeof serverSDK.createClient>[0]) {
return serverSDK.createClient(opts)
},
}
}
+177 -9
View File
@@ -1,7 +1,51 @@
import { describe, expect, test } from "bun:test"
import type { retry } from "@opencode-ai/core/util/retry"
import type { Message, OpencodeClient, Part, Session } from "@opencode-ai/sdk/v2/client"
import { createServerSession } from "./server-session"
import type {
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 => ({
id,
@@ -67,6 +111,81 @@ const singleResponse = (info: Message, parts: Part[] = []): SingleMessageRespons
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>>) {
let index = 0
const requests: unknown[] = []
@@ -81,7 +200,7 @@ function messageClient(...responses: Array<MessageResponse | Promise<MessageResp
return responses[index++]
},
},
} as unknown as OpencodeClient
} as FixtureClient
return Object.assign(client, {
requests,
requested(count: number) {
@@ -114,7 +233,7 @@ function rootMessageClient(
return roots[rootIndex++]
},
},
} as unknown as OpencodeClient
} as FixtureClient
return Object.assign(client, {
requests,
rootRequests,
@@ -152,7 +271,7 @@ function setup(sessions: Record<string, Session>) {
},
diff: async () => ({ data: [] }),
},
} as unknown as OpencodeClient
} as FixtureClient
return { get, messages, store: createServerSession(client) }
}
@@ -163,7 +282,10 @@ describe("server session", () => {
const result = await ctx.store.lineage.resolve("child")
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)
})
@@ -194,7 +316,9 @@ describe("server session", () => {
await store.sync("child")
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.history.more("child")).toBe(true)
})
@@ -278,7 +402,9 @@ describe("server session", () => {
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.part[stale.id]).toEqual([freshPart])
})
@@ -300,7 +426,9 @@ describe("server session", () => {
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.part[stale.id]).toEqual([refreshed, pending])
})
@@ -636,6 +764,21 @@ describe("server session", () => {
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 () => {
const pending = deferredResponse()
const message = userMessage("message")
@@ -1207,6 +1350,7 @@ describe("server session", () => {
await store.history.loadMore("child")
guard.active = false
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.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"])
})
})
+240 -71
View File
@@ -1,15 +1,19 @@
import { Binary } from "@opencode-ai/core/util/binary"
import { retry } from "@opencode-ai/core/util/retry"
import type {
Message,
OpencodeClient,
Part,
PermissionRequest,
QuestionRequest,
Session,
SessionStatus,
FileDiffInfo,
} from "@opencode-ai/sdk/v2/client"
AppClient,
AppEvent,
AppFileDiff,
AppMessage,
AppPart,
AppPermissionRequest,
AppQuestionRequest,
AppSession,
AppTodo,
LocationRef,
SessionActivity,
} from "./backend"
import { timelineMessage, timelineParts } from "./backend"
import { batch } from "solid-js"
import { createStore, produce, reconcile } from "solid-js/store"
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"
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 initialMessagePageSize = 2
const historyMessagePageSize = 200
@@ -26,15 +30,15 @@ const sessionInfoLimit = 2_048
const emptyIDs: ReadonlySet<string> = new Set()
type OptimisticItem = {
message: Message
parts: Part[]
confirmedParts?: Part[]
message: AppMessage
parts: AppPart[]
confirmedParts?: AppPart[]
confirmedMessage?: boolean
}
type MessagePage = {
session: Message[]
part: { id: string; part: Part[] }[]
session: AppMessage[]
part: { id: string; part: AppPart[] }[]
cursor?: string
complete: boolean
}
@@ -59,22 +63,31 @@ type MessageLoadBaseline = Pick<
>
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 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) {
const result = Binary.search(session, item.message.id, (message) => message.id)
if (!result.found) session.splice(result.index, 0, item.message)
const current = part.get(item.message.id)
const confirmed = result.found
? item.parts.filter((part) => Binary.search(current ?? [], part.id, (value) => value.id).found)
const current = part.get(item.message.id) ?? []
const matched = result.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 })
part.set(
item.message.id,
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)),
),
)
@@ -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>) {
const pending = map.get(key)
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))
}
export function createServerSession(client: OpencodeClient, options?: { retry?: typeof retry }) {
export function createServerSession(backend: Promise<AppClient> | AppClient, options?: { retry?: typeof retry }) {
const [data, setData] = createStore({
info: {} as Record<string, Session | undefined>,
session_status: {} as Record<string, SessionStatus>,
session_diff: {} as Record<string, FileDiffInfo[]>,
permission: {} as Record<string, PermissionRequest[]>,
question: {} as Record<string, QuestionRequest[]>,
message: {} as Record<string, Message[]>,
part: {} as Record<string, Part[]>,
info: {} as Record<string, AppSession | undefined>,
session_status: {} as Record<string, SessionActivity>,
session_diff: {} as Record<string, AppFileDiff[]>,
todo: {} as Record<string, AppTodo[]>,
permission: {} as Record<string, AppPermissionRequest[]>,
question: {} as Record<string, AppQuestionRequest[]>,
message: {} as Record<string, AppMessage[]>,
part: {} as Record<string, AppPart[]>,
part_text_accum_delta: {} as Record<string, string>,
session_working(id: string) {
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 inflightDiff = new Map<string, Promise<void>>()
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 deltaBases = new Map<string, { base: string; sessionID: string }>()
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,
) => {
for (const part of cache.part[messageID] ?? []) {
@@ -186,7 +208,15 @@ export function createServerSession(client: OpencodeClient, options?: { retry?:
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))
infoSeen.delete(session.id)
infoSeen.add(session.id)
@@ -236,11 +266,13 @@ export function createServerSession(client: OpencodeClient, options?: { retry?:
const pending = requests.get(sessionID)
if (pending) return pending
const active = generation(sessionID)
const request = client.session.get({ sessionID }).then((result) => {
if (!result.data) throw sessionNotFoundError(sessionID)
if (generations.get(sessionID) !== active) return result.data
return remember(result.data)
})
const request = Promise.resolve(backend)
.then((client) => client.common.sessions.get({ sessionID, location: location(sessionID) }))
.then((result) => {
if (!result) throw sessionNotFoundError(sessionID)
if (generations.get(sessionID) !== active) return result
return remember(result)
})
requests.set(sessionID, request)
const cleanup = () => {
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 })
}
const confirmOptimisticPart = (sessionID: string, messageID: string, part: Part) => {
const confirmOptimisticPart = (sessionID: string, messageID: string, part: AppPart) => {
const items = optimistic.get(sessionID)
const item = items?.get(messageID)
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 item = items?.get(messageID)
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 response = await (options?.retry ?? retry)(() => {
const response = await (options?.retry ?? retry)(async () => {
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 {
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) => ({
id: item.info.id,
part: item.parts.filter((part) => !!part?.id).sort((a, b) => cmp(a.id, b.id)),
id: item.message.id,
part: item.parts.sort((a, b) => cmp(a.id, b.id)),
})),
cursor: response.response.headers.get("x-next-cursor") ?? undefined,
complete: !response.response.headers.get("x-next-cursor"),
cursor: response.older,
complete: !response.older,
}
}
const fetchMessage = async (sessionID: string, messageID: string, onAttempt?: () => void) => {
const response = await (options?.retry ?? retry)(() => {
const response = await (options?.retry ?? retry)(async () => {
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 {
message: cleanMessage(response.data.info),
parts: response.data.parts.filter((part) => !!part?.id).sort((a, b) => cmp(a.id, b.id)),
message: cleanMessage(message),
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 dropped = (data.message[sessionID] ?? []).filter((message) => !messageIDs.has(message.id))
setData("message", sessionID, reconcile(messages, { key: "id" }))
@@ -559,7 +594,7 @@ export function createServerSession(client: OpencodeClient, options?: { retry?:
sessionID: string,
page: MessagePage,
load: MessageLoadState | undefined,
preserveUnfetched: boolean | ((message: Message) => boolean),
preserveUnfetched: boolean | ((message: AppMessage) => boolean),
cleanupOrphans: boolean,
) => {
const merged = mergeOptimisticPage(page, [...(optimistic.get(sessionID)?.values() ?? [])])
@@ -609,7 +644,7 @@ export function createServerSession(client: OpencodeClient, options?: { retry?:
let applied = false
try {
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),
undefined,
)
@@ -630,7 +665,7 @@ export function createServerSession(client: OpencodeClient, options?: { retry?:
const parentIDs = [
...new Set(
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 =
mode === "prepend" || (!result.complete && (!first || ((message: Message) => cmpMessage(message, first) < 0)))
mode === "prepend" || (!result.complete && (!first || ((message: AppMessage) => cmpMessage(message, first) < 0)))
applyMessagePage(
sessionID,
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)
return runInflight(inflight, sessionID, async () => {
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
}
const apply = (event: { type: string; properties?: unknown }) => {
const applyLegacy = (event: { type: string; properties?: unknown }) => {
const eventID = eventSessionID(event)
if (eventID) {
touch(eventID)
@@ -743,16 +782,16 @@ export function createServerSession(client: OpencodeClient, options?: { retry?:
}
switch (event.type) {
case "session.created":
remember((event.properties as { info: Session }).info)
remember((event.properties as { info: AppSession }).info)
return
case "session.updated": {
const info = (event.properties as { info: Session }).info
const info = (event.properties as { info: AppSession }).info
remember(info)
if (info.time.archived) evict([info.id])
return
}
case "session.deleted": {
const sessionID = (event.properties as { info: Session }).info.id
const sessionID = (event.properties as { sessionID: string }).sessionID
infoSeen.delete(sessionID)
setData(
"info",
@@ -762,17 +801,22 @@ export function createServerSession(client: OpencodeClient, options?: { retry?:
return
}
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" }))
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": {
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))
return
}
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)
load?.touchedMessages.add(info.id)
load?.removedMessages.delete(info.id)
@@ -832,7 +876,7 @@ export function createServerSession(client: OpencodeClient, options?: { retry?:
return
}
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
const messages = data.message[part.sessionID]
const load = messageLoads.get(part.sessionID)
@@ -971,7 +1015,7 @@ export function createServerSession(client: OpencodeClient, options?: { retry?:
return
}
case "permission.asked": {
const permission = event.properties as PermissionRequest
const permission = event.properties as AppPermissionRequest
const permissions = data.permission[permission.sessionID]
if (!permissions) {
setData("permission", permission.sessionID, [permission])
@@ -1001,7 +1045,7 @@ export function createServerSession(client: OpencodeClient, options?: { retry?:
return
}
case "question.asked": {
const question = event.properties as QuestionRequest
const question = event.properties as AppQuestionRequest
const questions = data.question[question.sessionID]
if (!questions) {
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 {
data,
set: setData,
@@ -1059,7 +1215,7 @@ export function createServerSession(client: OpencodeClient, options?: { retry?:
return Date.now() - (meta.at[sessionID] ?? 0) <= ttl
},
optimistic: {
add(input: { sessionID: string; message: Message; parts: Part[] }) {
add(input: { sessionID: string; message: AppMessage; parts: AppPart[] }) {
const parts = input.parts
.filter((part) => !!part?.id && !SKIP_PARTS.has(part.type))
.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()
return runInflight(inflightDiff, 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
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.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,
}
}
+95 -70
View File
@@ -1,11 +1,11 @@
import type {
Config,
McpResource,
OpencodeClient,
Path,
Project,
ProviderAuthResponse,
} from "@opencode-ai/sdk/v2/client"
AppClient,
AppConfig,
AppMcpResource,
AppPathInfo,
AppProject,
AppProviderAuthResponse,
} from "./backend"
import { showToast } from "@/utils/toast"
import { getFilename } from "@opencode-ai/core/util/path"
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 { estimateRootSessionTotal, loadRootSessionsWithFallback } from "./global-sync/session-load"
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 { formatServerError } from "@/utils/server-errors"
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 { createDirSyncContext } from "./directory-sync"
import { createSimpleContext } from "@opencode-ai/ui/context"
import { NormalizedProviderListResponse } from "@opencode-ai/session-ui/context"
import { createRefCountMap } from "@/utils/refcount"
import { useGlobal } from "./global"
import { ServerConnection, useServer } from "./server"
@@ -47,53 +46,63 @@ import { persisted } from "@/utils/persist"
import { toggleMcp } from "./global-sync/mcp"
import { createServerSession } from "./server-session"
type GlobalConfigUpdate = Pick<AppConfig, "shell" | "provider" | "disabledProviders">
type GlobalStore = {
ready: boolean
error?: InitError
path: Path
project: Project[]
provider: NormalizedProviderListResponse
provider_auth: ProviderAuthResponse
config: Config
path: AppPathInfo
project: AppProject[]
provider: ProviderStore
provider_auth: AppProviderAuthResponse
config: StoreConfig
reload: undefined | "pending" | "complete"
}
export const loadMcpQuery = (scope: ServerScope, directory: string, sdk: OpencodeClient) =>
export const loadMcpQuery = (scope: ServerScope, directory: string, backend: Promise<AppClient>) =>
queryOptions({
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) =>
queryOptions<Record<string, McpResource>>({
export const loadMcpResourcesQuery = (scope: ServerScope, directory: string, backend: Promise<AppClient>) =>
queryOptions<Record<string, AppMcpResource>>({
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: {},
})
export const loadLspQuery = (scope: ServerScope, directory: string, sdk: OpencodeClient) =>
export const loadLspQuery = (scope: ServerScope, directory: string, backend: Promise<AppClient>) =>
queryOptions({
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(
scope: ServerScope,
serverSDK: () => OpencodeClient,
sdkFor: (dir: PathKey) => OpencodeClient,
) {
function makeQueryOptionsApi(scope: ServerScope, backend: Promise<AppClient>) {
return {
globalConfig: () => loadGlobalConfigQuery(scope, serverSDK()),
projects: () => loadProjectsQuery(scope, serverSDK()),
providers: (directory: PathKey | null) =>
loadProvidersQuery(scope, directory, directory === null ? serverSDK() : sdkFor(directory)),
path: (directory: PathKey | null) =>
loadPathQuery(scope, directory, directory === null ? serverSDK() : sdkFor(directory)),
agents: (directory: PathKey) => loadAgentsQuery(scope, directory, sdkFor(directory)),
references: (directory: PathKey) => loadReferencesQuery(scope, directory, sdkFor(directory)),
mcp: (directory: PathKey) => loadMcpQuery(scope, directory, sdkFor(directory)),
mcpResources: (directory: PathKey) => loadMcpResourcesQuery(scope, directory, sdkFor(directory)),
lsp: (directory: PathKey) => loadLspQuery(scope, directory, sdkFor(directory)),
globalConfig: () => loadGlobalConfigQuery(scope, backend),
projects: () => loadProjectsQuery(scope, backend),
providers: (directory: PathKey | null) => loadProvidersQuery(scope, directory, backend),
path: (directory: PathKey | null) => loadPathQuery(scope, directory, backend),
agents: (directory: PathKey) => loadAgentsQuery(scope, directory, backend),
references: (directory: PathKey) => loadReferencesQuery(scope, directory, backend),
mcp: (directory: PathKey) => loadMcpQuery(scope, directory, backend),
mcpResources: (directory: PathKey) => loadMcpResourcesQuery(scope, directory, backend),
lsp: (directory: PathKey) => loadLspQuery(scope, directory, backend),
sessions: (directory: PathKey) => ({ queryKey: [scope, directory, "loadSessions"] as const }),
}
}
@@ -104,24 +113,11 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
const owner = getOwner()
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 sessionLoads = new Map<string, Promise<void>>()
const sessionMeta = new Map<string, { limit: number }>()
const sdkFor = (directory: string) => {
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 queryOptionsApi = makeQueryOptionsApi(serverSDK.scope, serverSDK.backend)
const [configQuery, providerQuery, pathQuery] = useQueries(() => ({
queries: [queryOptionsApi.globalConfig(), queryOptionsApi.providers(null), queryOptionsApi.path(null)],
@@ -164,13 +160,13 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
if (eventTimer !== undefined) clearTimeout(eventTimer)
})
const setProjects = (next: Project[] | ((draft: Project[]) => Project[])) => {
const setProjects = (next: AppProject[] | ((draft: AppProject[]) => AppProject[])) => {
setGlobalStore("project", next)
}
const setBootStore = ((...input: unknown[]) => {
if (input[0] === "project" && Array.isArray(input[1])) {
setProjects(input[1] as Project[])
setProjects(input[1] as AppProject[])
return input[1]
}
return (setGlobalStore as (...args: unknown[]) => unknown)(...input)
@@ -180,7 +176,7 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
queryKey: [serverSDK.scope, "bootstrap"],
queryFn: async () => {
await bootstrapGlobal({
serverSDK: serverSDK.client,
backend: serverSDK.backend,
scope: serverSDK.scope,
requestFailedTitle: language.t("common.requestFailed"),
translate: language.t,
@@ -195,7 +191,7 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
const set = ((...input: unknown[]) => {
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 (setGlobalStore as (...args: unknown[]) => unknown)(...input)
@@ -210,7 +206,7 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
bootstrapInstance,
})
const session = createServerSession(serverSDK.client)
const session = createServerSession(serverSDK.backend)
const children = createChildStoreManager({
owner,
@@ -223,9 +219,9 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
},
onMcp: (directory, setStore) => {
void retry(() =>
sdkFor(directory)
.command.list()
.then((x) => setStore("command", x.data ?? [])),
serverSDK.backend
.then((client) => client.common.commands.list({ location: { directory } }))
.then((commands) => setStore("command", reconcile(commands))),
).catch((err) => {
showToast({
variant: "error",
@@ -238,7 +234,6 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
const key = directoryKey(directory)
queue.clear(key)
sessionMeta.delete(key)
sdkCache.delete(key)
clearProviderRev(serverSDK.scope, key)
},
translate: language.t,
@@ -280,7 +275,12 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
loadRootSessionsWithFallback({
directory,
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) => {
const nonArchived = (x.data ?? [])
@@ -339,7 +339,7 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
const child = children.ensureChild(directory)
const cache = children.vcsCache.get(key)
if (!cache) return
const sdk = sdkFor(directory)
const backend = await serverSDK.backend
await bootstrapDirectory({
directory,
scope: serverSDK.scope,
@@ -350,7 +350,7 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
project: globalStore.project,
provider: globalStore.provider,
},
sdk,
backend,
store: child[0],
setStore: child[1],
vcsCache: cache,
@@ -375,7 +375,14 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
const event = e.details
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") {
applyGlobalEvent({
@@ -387,7 +394,7 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
},
setGlobalProject: setProjects,
})
if (event.type === "server.connected" || event.type === "global.disposed") {
if (event.type === "server.connected" || event.type === "server.disposed") {
if (recent) return
for (const directory of Object.keys(children.children)) {
queue.push(directory)
@@ -457,7 +464,16 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
}
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: () => {
bootstrap.refetch()
// Invalidate all provider queries so newly configured custom providers
@@ -484,23 +500,32 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
queryOptions: queryOptionsApi,
// bootstrap,
updateConfig: updateConfigMutation.mutateAsync,
refreshProviders: async () => {
await queryClient.invalidateQueries({
predicate: (query) => query.queryKey[0] === serverSDK.scope && query.queryKey[2] === "providers",
})
await bootstrap.refetch()
},
project: projectApi,
session,
mcp: {
toggle: async (directory: string, name: string) => {
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
await toggleMcp({
status,
connect: async () => {
await sdk.mcp.connect({ name })
await capability.connect({ ...location, name })
},
disconnect: async () => {
await sdk.mcp.disconnect({ name })
await capability.disconnect({ ...location, name })
},
authenticate: async () => {
await sdk.mcp.auth.authenticate({ name })
await capability.authenticate({ ...location, name })
},
refresh: async () => {
await queryClient.refetchQueries(queryOptionsApi.mcp(key))
@@ -1,5 +1,5 @@
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"
type Text = Extract<Part, { type: "text" }>
+12 -12
View File
@@ -2,25 +2,25 @@ import { Binary } from "@opencode-ai/core/util/binary"
import { createMemo } from "solid-js"
import { useServerSync } from "./server-sync"
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"])
function sortParts(parts: Part[]) {
function sortParts(parts: AppPart[]) {
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)
type OptimisticStore = {
message: Record<string, Message[] | undefined>
part: Record<string, Part[] | undefined>
message: Record<string, AppMessage[] | undefined>
part: Record<string, AppPart[] | undefined>
}
type OptimisticAddInput = {
sessionID: string
message: Message
parts: Part[]
message: AppMessage
parts: AppPart[]
}
type OptimisticRemoveInput = {
@@ -29,23 +29,23 @@ type OptimisticRemoveInput = {
}
type OptimisticItem = {
message: Message
parts: Part[]
message: AppMessage
parts: AppPart[]
}
type MessagePage = {
session: Message[]
part: { id: string; part: Part[] }[]
session: AppMessage[]
part: { id: string; part: AppPart[] }[]
cursor?: string
complete: boolean
}
const hasParts = (parts: Part[] | undefined, want: Part[]) => {
const hasParts = (parts: AppPart[] | undefined, want: AppPart[]) => {
if (!parts) return want.length === 0
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)
const next = [...parts]
let changed = false
+3 -3
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 { createStore, produce } from "solid-js/store"
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 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)
}
@@ -348,7 +348,7 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
for (const key of removed) memory.remove(key)
for (const key of removed) removeInfo(key)
},
rememberSessionInfo(tab: SessionTab, session: Session) {
rememberSessionInfo(tab: SessionTab, session: AppSession) {
const key = tabKey(tab)
const next = { title: session.title, directory: session.directory }
const current = info[key]
+35 -31
View File
@@ -149,6 +149,7 @@ function createWorkspaceTerminalSession(
scope: ServerScopeValue,
legacySessionID?: string,
) {
const location = { directory: sdk.directory }
const legacy = scope === ServerScope.local ? getLegacyTerminalStorageKeys(dir, legacySessionID) : []
const [store, setStore, _, ready] = persisted(
@@ -198,23 +199,27 @@ function createWorkspaceTerminalSession(
})
}
const unsub = sdk.event.on("pty.exited", (event: { properties: { id: string } }) => {
removeExited(event.properties.id)
const unsub = sdk.event.on("pty.exited", (event) => {
if (event.type !== "pty.exited") return
removeExited(event.ptyID)
})
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 previous = index >= 0 ? store.all[index] : undefined
if (index >= 0) {
setStore("all", index, (item) => ({ ...item, ...pty }))
}
client.pty
.update({
ptyID: pty.id,
title: pty.title,
size: pty.cols && pty.rows ? { rows: pty.rows, cols: pty.cols } : undefined,
})
sdk.backend
.then((client) =>
client.common.pty.update({
ptyID: pty.id,
title: pty.title,
size: pty.cols && pty.rows ? { rows: pty.rows, cols: pty.cols } : undefined,
location,
}),
)
.catch((error: unknown) => {
if (previous) {
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 pty = store.all[index]
if (!pty) return
const next = await client.pty
.create({
title: pty.title,
})
const next = await sdk.backend
.then((client) => client.common.pty.create({ title: pty.title, location }))
.catch((error: unknown) => {
console.error("Failed to clone terminal", error)
return undefined
})
if (!next?.data) return
if (!next) return
const active = store.active === pty.id
batch(() => {
setStore("all", index, {
id: next.data.id,
title: next.data.title ?? pty.title,
id: next.id,
title: next.title ?? pty.title,
titleNumber: pty.titleNumber,
buffer: undefined,
cursor: undefined,
@@ -252,7 +255,7 @@ function createWorkspaceTerminalSession(
cols: undefined,
})
if (active) {
setStore("active", next.data.id)
setStore("active", next.id)
}
})
}
@@ -270,14 +273,14 @@ function createWorkspaceTerminalSession(
new() {
const nextNumber = pickNextTerminalNumber()
sdk.client.pty
.create({ title: defaultTitle(nextNumber) })
.then((pty: { data?: { id?: string; title?: string } }) => {
const id = pty.data?.id
sdk.backend
.then((client) => client.common.pty.create({ title: defaultTitle(nextNumber), location }))
.then((pty) => {
const id = pty.id
if (!id) return
const newTerminal = {
id,
title: pty.data?.title ?? defaultTitle(nextNumber),
title: pty.title ?? defaultTitle(nextNumber),
titleNumber: nextNumber,
}
setStore("all", store.all.length, newTerminal)
@@ -288,7 +291,7 @@ function createWorkspaceTerminalSession(
})
},
update(pty: Partial<LocalPTY> & { id: string }) {
update(sdk.client, pty)
update(pty)
},
trim(id: string) {
const index = store.all.findIndex((x) => x.id === id)
@@ -303,10 +306,9 @@ function createWorkspaceTerminalSession(
})
},
async clone(id: string) {
await clone(sdk.client, id)
await clone(id)
},
bind() {
const client = sdk.client
return {
trim(id: string) {
const index = store.all.findIndex((x) => x.id === id)
@@ -314,10 +316,10 @@ function createWorkspaceTerminalSession(
setStore("all", index, (pty) => trimTerminal(pty))
},
update(pty: Partial<LocalPTY> & { id: string }) {
update(client, pty)
update(pty)
},
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) => {
console.error("Failed to close terminal", error)
})
await sdk.backend
.then((client) => client.common.pty.remove({ ptyID: id, location }))
.catch((error: unknown) => {
console.error("Failed to close terminal", error)
})
},
move(id: string, to: number) {
const index = store.all.findIndex((f) => f.id === id)
@@ -1,9 +1,9 @@
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"
const catalog = (id: string): NormalizedProviderListResponse => ({
all: new Map([[id, { id, name: id, source: "api", env: [], options: {}, models: {} }]]),
const catalog = (id: string): ProviderStore => ({
all: new Map([[id, { id, name: id, source: "api", models: {} }]]),
connected: [id],
default: { [id]: `${id}-model` },
})
+4 -4
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 = {
ready: boolean
providers: NormalizedProviderListResponse
providers: ProviderStore
}
type ProviderCatalogInput =
@@ -17,7 +17,7 @@ type ProviderCatalogInput =
explicit: false
directory?: string
catalog?: DirectoryCatalog
global: NormalizedProviderListResponse
global: ProviderStore
}
export function selectProviderCatalog(input: ProviderCatalogInput) {
+10 -2
View File
@@ -1,4 +1,4 @@
import type { Session } from "@opencode-ai/sdk/v2/client"
import type { AppSession as Session } from "@/context/backend"
import {
type ComponentProps,
createEffect,
@@ -508,7 +508,15 @@ export function NewHome() {
await archiveHomeSession({
server: ServerConnection.key(conn),
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: () =>
setStore(
produce((draft) => {
+105 -44
View File
@@ -25,8 +25,8 @@ import { IconButton } from "@opencode-ai/ui/icon-button"
import { Tooltip } from "@opencode-ai/ui/tooltip"
import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu"
import { Dialog } from "@opencode-ai/ui/dialog"
import { getFilename } from "@opencode-ai/core/util/path"
import { Session } from "@opencode-ai/sdk/v2/client"
import { getDirectory, getFilename } from "@opencode-ai/core/util/path"
import type { AppSession as Session } from "@/context/backend"
import { usePlatform } from "@/context/platform"
import { useSettings } from "@/context/settings"
import { createStore, produce, reconcile } from "solid-js/store"
@@ -66,6 +66,7 @@ import {
errorMessage,
latestRootSession,
sortedRootSessions,
workspaceCopyCreateInput,
} from "./layout/helpers"
import {
collectNewSessionDeepLinks,
@@ -85,6 +86,11 @@ import { SidebarContent } from "./layout/sidebar-shell"
export default function LegacyLayout(props: ParentProps) {
const serverSDK = useServerSDK()
const [backend] = createResource(
() => serverSDK().backend,
(value) => value,
)
const canArchive = () => !!backend()?.capabilities.sessionExtrasV1
const [store, setStore, , ready] = persisted(
Persist.serverGlobal(serverSDK().scope, "layout.page", ["layout.page.v1"]),
createStore({
@@ -398,7 +404,7 @@ export default function LegacyLayout(props: ParentProps) {
WorktreeState.failed(
serverSDK().scope,
e.name,
e.details.properties?.message ?? language.t("common.requestFailed"),
e.details.message ?? language.t("common.requestFailed"),
)
return
}
@@ -408,21 +414,21 @@ export default function LegacyLayout(props: ParentProps) {
e.details?.type === "question.rejected" ||
e.details?.type === "permission.replied"
) {
const props = e.details.properties as { sessionID: string }
const props = e.details
const sessionKey = `${e.name}:${props.sessionID}`
dismissSessionAlert(sessionKey)
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 =
e.details.type === "permission.asked"
e.details.type === "permission.requested"
? language.t("notification.permission.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 props = e.details.properties
if (e.details.type === "permission.asked" && permission.autoResponds(e.details.properties, directory)) return
const props = e.details.request
if (e.details.type === "permission.requested" && permission.autoResponds(e.details.request, directory)) return
const [store] = serverSync().child(directory, { bootstrap: false })
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 projectName = getFilename(directory)
const description =
e.details.type === "permission.asked"
e.details.type === "permission.requested"
? language.t("notification.permission.description", { sessionTitle, projectName })
: language.t("notification.question.description", { sessionTitle, projectName })
const href = `/${base64Encode(directory)}/session/${props.sessionID}`
@@ -441,7 +447,7 @@ export default function LegacyLayout(props: ParentProps) {
if (now - lastAlerted < cooldownMs) return
alertedAtBySession.set(sessionKey, now)
if (e.details.type === "permission.asked") {
if (e.details.type === "permission.requested") {
if (settings.sounds.permissionsEnabled()) {
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()) {
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 nextSession = sessions[index + 1] ?? sessions[index - 1]
await serverSDK().client.session.update({
directory: session.directory,
const capability = (await serverSDK().backend).capabilities.sessionExtrasV1
if (!capability) return
await capability.archive({
location: { directory: session.directory },
sessionID: session.id,
time: { archived: Date.now() },
archivedAt: Date.now(),
})
setStore(
produce((draft) => {
@@ -976,7 +984,7 @@ export default function LegacyLayout(props: ParentProps) {
title: language.t("command.session.archive"),
category: language.t("command.category.session"),
keybind: "mod+shift+backspace",
disabled: !params.dir || !params.id,
disabled: !params.dir || !params.id || !canArchive(),
onSelect: () => {
const session = currentSessions().find((s) => s.id === params.id)
if (session) void archiveSession(session)
@@ -1185,8 +1193,15 @@ export default function LegacyLayout(props: ParentProps) {
const refreshDirs = async (target?: string) => {
if (!target || target === root || canOpen(target)) return canOpen(target)
const listed = await serverSDK()
.client.worktree.list({ directory: root })
.then((x) => x.data ?? [])
.backend.then((client) => {
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[])
dirs = effectiveWorkspaceOrder(root, [root, ...listed], store.workspaceOrder[root])
return canOpen(target)
@@ -1231,8 +1246,11 @@ export default function LegacyLayout(props: ParentProps) {
dirs.map(async (item) => ({
path: { directory: item },
session: await serverSDK()
.client.session.list({ directory: item })
.then((x) => x.data ?? [])
.backend.then((client) =>
client.common.sessions
.list({ location: { directory: item } })
.then((page) => [...page.items]),
)
.catch(() => []),
})),
),
@@ -1293,7 +1311,9 @@ export default function LegacyLayout(props: ParentProps) {
const name = next === getFilename(project.worktree) ? "" : next
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
}
@@ -1387,8 +1407,15 @@ export default function LegacyLayout(props: ParentProps) {
setBusy(directory, true)
const result = await serverSDK()
.client.worktree.remove({ directory: root, worktreeRemoveInput: { directory } })
.then((x) => x.data)
.backend.then(async (client) => {
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) => {
showToast({
title: language.t("workspace.delete.failed.title"),
@@ -1444,9 +1471,12 @@ export default function LegacyLayout(props: ParentProps) {
})
const dismiss = () => toaster.dismiss(progress)
const sessions: Session[] = await serverSDK()
.client.session.list({ directory })
.then((x) => x.data ?? [])
const sessions = await serverSDK()
.backend.then((client) =>
client.common.sessions
.list({ location: { directory } })
.then((page) => [...page.items]),
)
.catch(() => [])
clearWorkspaceTerminals(
@@ -1456,12 +1486,26 @@ export default function LegacyLayout(props: ParentProps) {
serverSDK().scope,
)
await serverSDK()
.client.instance.dispose({ directory })
.backend.then((client) => client.capabilities.runtimeV1?.disposeLocation({ location: { directory } }))
.catch(() => undefined)
const result = await serverSDK()
.client.worktree.reset({ directory: root, worktreeResetInput: { directory } })
.then((x) => x.data)
.backend.then(async (client) => {
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) => {
showToast({
title: language.t("workspace.reset.failed.title"),
@@ -1482,10 +1526,14 @@ export default function LegacyLayout(props: ParentProps) {
.filter((session) => session.time.archived === undefined)
.map((session) =>
serverSDK()
.client.session.update({
sessionID: session.id,
directory: session.directory,
time: { archived: archivedAt },
.backend.then(async (client) => {
const capability = client.capabilities.sessionExtrasV1
if (!capability) return
await capability.archive({
location: { directory: session.directory },
sessionID: session.id,
archivedAt,
})
})
.catch(() => undefined),
),
@@ -1523,9 +1571,8 @@ export default function LegacyLayout(props: ParentProps) {
onMount(() => {
serverSDK()
.client.vcs.status({ directory: props.directory })
.then((x) => {
const files = x.data ?? []
.backend.then((client) => client.capabilities.vcs?.status({ location: { directory: props.directory } }) ?? [])
.then((files) => {
const dirty = files.length > 0
setData({ status: "ready", dirty })
})
@@ -1582,8 +1629,11 @@ export default function LegacyLayout(props: ParentProps) {
const refresh = async () => {
const sessions = await serverSDK()
.client.session.list({ directory: props.directory })
.then((x) => x.data ?? [])
.backend.then((client) =>
client.common.sessions
.list({ location: { directory: props.directory } })
.then((page) => [...page.items]),
)
.catch(() => [])
const active = sessions.filter((session) => session.time.archived === undefined)
setState({ sessions: active })
@@ -1591,9 +1641,8 @@ export default function LegacyLayout(props: ParentProps) {
onMount(() => {
serverSDK()
.client.vcs.status({ directory: props.directory })
.then((x) => {
const files = x.data ?? []
.backend.then((client) => client.capabilities.vcs?.status({ location: { directory: props.directory } }) ?? [])
.then((files) => {
const dirty = files.length > 0
setState({ status: "ready", dirty })
void refresh()
@@ -1822,8 +1871,17 @@ export default function LegacyLayout(props: ParentProps) {
const createWorkspace = async (project: LocalProject) => {
clearSidebarHoverState()
const created = await serverSDK()
.client.worktree.create({ directory: project.worktree })
.then((x) => x.data)
.backend.then((client) => {
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) => {
showToast({
title: language.t("workspace.create.failed.title"),
@@ -1834,7 +1892,8 @@ export default function LegacyLayout(props: ParentProps) {
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 key = pathKey(created.directory)
@@ -1867,6 +1926,7 @@ export default function LegacyLayout(props: ParentProps) {
clearHoverProjectSoon,
prefetchSession,
archiveSession,
canArchive,
workspaceName,
renameWorkspace,
editorOpen,
@@ -1913,6 +1973,7 @@ export default function LegacyLayout(props: ParentProps) {
clearHoverProjectSoon,
prefetchSession,
archiveSession,
canArchive,
},
}
+11 -1
View File
@@ -6,7 +6,7 @@ import {
parseDeepLink,
parseNewSessionDeepLink,
} from "./deep-links"
import { type Session } from "@opencode-ai/sdk/v2/client"
import type { AppSession as Session } from "@/context/backend"
import {
childSessionOnPath,
closeHomeProject,
@@ -19,12 +19,22 @@ import {
homeSessionServerStatus,
latestRootSession,
toggleHomeProjectSelection,
workspaceCopyCreateInput,
} from "./helpers"
import { pathKey } from "@/utils/path-key"
import { ServerConnection } from "@/context/server"
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">) =>
({
title: "",
+11 -2
View File
@@ -1,5 +1,5 @@
import { getFilename } from "@opencode-ai/core/util/path"
import { type Session } from "@opencode-ai/sdk/v2/client"
import { getDirectory, getFilename } from "@opencode-ai/core/util/path"
import type { AppSession as Session } from "@/context/backend"
import { pathKey } from "@/utils/path-key"
import type { ServerConnection } from "@/context/server"
import type { HomeProjectSelection } from "@/context/layout"
@@ -9,6 +9,15 @@ type SessionStore = {
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) {
const oneMinuteAgo = now - 60 * 1000
return (a: Session, b: Session) => {
@@ -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 { Icon } from "@opencode-ai/ui/icon"
import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
@@ -87,6 +87,7 @@ export type SessionItemProps = {
clearHoverProjectSoon: () => void
prefetchSession: (session: Session, priority?: "high" | "low") => void
archiveSession: (session: Session) => Promise<void>
canArchive: Accessor<boolean>
}
const SessionRow = (props: {
@@ -241,7 +242,7 @@ export const SessionItem = (props: SessionItemProps): JSX.Element => {
</Show>
</div>
<Show when={!props.level}>
<Show when={!props.level && props.canArchive()}>
<div
class="shrink-0 overflow-hidden transition-[width,opacity]"
classList={{
@@ -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 { Spinner } from "@opencode-ai/ui/spinner"
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 { useServerSync, useQueryOptions } from "@/context/server-sync"
import { useLanguage } from "@/context/language"
@@ -42,6 +42,7 @@ export type WorkspaceSidebarContext = {
clearHoverProjectSoon: () => void
prefetchSession: (session: Session, priority?: "high" | "low") => void
archiveSession: (session: Session) => Promise<void>
canArchive: Accessor<boolean>
workspaceName: (directory: string, projectId?: string, branch?: string) => string | undefined
renameWorkspace: (directory: string, next: string, projectId?: string, branch?: string) => void
editorOpen: (id: string) => boolean
@@ -272,6 +273,7 @@ const WorkspaceSessionList = (props: {
clearHoverProjectSoon={props.ctx.clearHoverProjectSoon}
prefetchSession={props.ctx.prefetchSession}
archiveSession={props.ctx.archiveSession}
canArchive={props.ctx.canArchive}
/>
)}
</For>
+67 -33
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 { createQuery, skipToken, useMutation, useQueryClient } from "@tanstack/solid-query"
import {
@@ -640,7 +646,7 @@ export default function Page() {
const project = sync().project
const vcs = sync().data.vcs
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("turn")
@@ -658,7 +664,7 @@ export default function Page() {
})
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 mode = vcsMode()
@@ -670,8 +676,13 @@ export default function Page() {
queryFn: mode
? () =>
sdk()
.client.vcs.diff({ mode })
.then((result) => list(result.data))
.backend.then((client) =>
client.capabilities.vcs?.diff({
location: { directory: sdk().directory },
mode: mode === "git" ? "working" : mode,
}) ?? [],
)
.then(list)
.catch((error) => {
console.debug("[session-review] failed to load vcs diff", { mode, error })
return []
@@ -717,9 +728,13 @@ export default function Page() {
staleTime: Number.POSITIVE_INFINITY,
retry: 2,
queryFn: () =>
sdk()
.client.vcs.diff({ mode, directory: scope, context })
.then((result) => result.data ?? []),
sdk().backend.then((client) =>
client.capabilities.vcs?.diff({
location: { directory: scope },
mode: mode === "git" ? "working" : mode,
context,
}) ?? [],
),
})
.then((diffs) => diffs.find((diff) => diff.file === file))
@@ -823,10 +838,13 @@ export default function Page() {
}
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) => {
if (!x.data) return
upsert(x.data)
upsert(x as Project)
},
onError: (err) => {
showToast({
@@ -891,13 +909,7 @@ export default function Page() {
)
const stopVcs = sdk().event.listen((evt) => {
if (evt.details.type !== "filesystem.changed") 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
if (evt.details.type !== "file.changed" || evt.details.path.startsWith(".git/")) return
refreshVcs()
})
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 session = target.session.get(sessionID)
if (!session) return
@@ -1691,11 +1701,12 @@ export default function Page() {
setFollowup("failed", input.sessionID, undefined)
const ok = await sendFollowupDraft({
client: sdk().client,
backend: sdk().backend,
sync: sync(),
serverSync: serverSync(),
draft: item,
optimisticBusy: item.sessionDirectory === sdk().directory,
commitRevert: !!sync().session.get(input.sessionID)?.revert,
}).catch((err) => {
setFollowup("failed", input.sessionID, input.id)
fail(err)
@@ -1787,13 +1798,14 @@ export default function Page() {
const halt = (sessionID: string) =>
busy(sessionID)
? sdk()
.client.session.abort({ sessionID })
.backend.then((client) =>
client.common.sessions.interrupt({ location: { directory: sdk().directory }, sessionID }),
)
.catch(() => {})
: Promise.resolve()
const revertMutation = useMutation(() => ({
mutationFn: async (input: { sessionID: string; messageID: string }) => {
const client = sdk().client
const target = sync()
const last = target.session.get(input.sessionID)?.revert
const value = draft(input.messageID)
@@ -1803,10 +1815,18 @@ export default function Page() {
roll(input.sessionID, { messageID: input.messageID }, target)
prompt.set(value)
},
request: () => halt(input.sessionID).then(() => client.session.revert(input)),
complete: (result) => {
if (result.data) merge(result.data, target)
},
request: () =>
halt(input.sessionID).then(() =>
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),
fail,
})
@@ -1818,7 +1838,6 @@ export default function Page() {
const sessionID = params.id
if (!sessionID) return
const client = sdk().client
const target = sync()
const next = userMessages().find((item) => item.id > id)
const last = target.session.get(sessionID)?.revert
@@ -1835,11 +1854,26 @@ export default function Page() {
},
request: () =>
!next
? halt(sessionID).then(() => client.session.unrevert({ sessionID }))
: halt(sessionID).then(() => client.session.revert({ sessionID, messageID: next.id })),
complete: (result) => {
if (result.data) merge(result.data, target)
},
? halt(sessionID).then(() =>
sdk().backend.then((client) => {
const value = { location: { directory: sdk().directory }, sessionID }
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),
fail,
})
@@ -1,5 +1,9 @@
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"
const session = (input: { id: string; parentID?: string }) =>
@@ -12,6 +16,12 @@ const permission = (id: string, sessionID: string) =>
({
id,
sessionID,
action: "read",
resources: ["*"],
permission: "read",
patterns: ["*"],
always: [],
metadata: {},
}) as PermissionRequest
const question = (id: string, sessionID: string) =>
@@ -1,6 +1,9 @@
import { createMemo } from "solid-js"
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 { showToast } from "@/utils/toast"
import { useLanguage } from "@/context/language"
@@ -51,7 +54,14 @@ export function createSessionComposerController() {
setStore("responding", perm.id)
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) => {
const description = err instanceof Error ? err.message : String(err)
showToast({ title: language.t("common.requestFailed"), description })
@@ -1,5 +1,5 @@
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 { DockPrompt } from "@opencode-ai/session-ui/dock-prompt"
import { Icon } from "@opencode-ai/ui/icon"
@@ -6,7 +6,7 @@ import { DockPrompt } from "@opencode-ai/session-ui/dock-prompt"
import { Icon } from "@opencode-ai/ui/icon"
import { useSpring } from "@opencode-ai/ui/motion-spring"
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 { useSDK } from "@/context/sdk"
import { makeEventListener } from "@solid-primitives/event-listener"
@@ -223,7 +223,15 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
}
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: () => {
props.onSubmit()
},
@@ -235,7 +243,14 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
}))
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: () => {
props.onSubmit()
},
@@ -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>(
session: Session[],
+11 -3
View File
@@ -1,6 +1,9 @@
import { createEffect, onCleanup, type JSX } from "solid-js"
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 type {
SessionReviewCommentActions,
@@ -54,8 +57,13 @@ export function SessionReviewTab(props: SessionReviewTabProps) {
const readFile = async (path: string) => {
return sdk()
.client.file.read({ path })
.then((x) => x.data)
.backend.then(async (client) => {
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) => {
console.debug("[session-review] failed to read file", { path, error })
return undefined
@@ -1,5 +1,5 @@
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"
const message = (input?: { agent?: string; model?: UserMessage["model"] }) =>
@@ -1,4 +1,4 @@
import type { UserMessage } from "@opencode-ai/sdk/v2"
import type { AppUserMessage as UserMessage } from "@/context/backend"
type Local = {
session: {
@@ -9,7 +9,7 @@ import { ResizeHandle } from "@opencode-ai/ui/resize-handle"
import { Mark } from "@opencode-ai/ui/logo"
import { DragDropProvider, DragDropSensors, DragOverlay, SortableProvider, closestCenter } 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 { useDialog } from "@opencode-ai/ui/context/dialog"
@@ -1,6 +1,7 @@
import {
createEffect,
createMemo,
createResource,
createSignal,
For,
Index,
@@ -46,12 +47,12 @@ import { TextField } from "@opencode-ai/ui/text-field"
import { TextReveal } from "@opencode-ai/ui/text-reveal"
import { TextShimmer } from "@opencode-ai/ui/text-shimmer"
import type {
AssistantMessage,
Message as MessageType,
Part as PartType,
ToolPart,
UserMessage,
} from "@opencode-ai/sdk/v2"
AppAssistantMessage as AssistantMessage,
AppMessage as MessageType,
AppPart as PartType,
AppUserMessage as UserMessage,
} from "@/context/backend"
type ToolPart = Extract<PartType, { type: "tool" }>
import { showToast } from "@/utils/toast"
import { getDirectory, getFilename } from "@opencode-ai/core/util/path"
import { Popover as KobaltePopover } from "@kobalte/core/popover"
@@ -290,7 +291,14 @@ export function MessageTimeline(props: {
const titleValue = createMemo(() => info()?.title)
const titleLabel = createMemo(() => sessionTitle(titleValue()))
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 parent = createMemo(() => {
const id = parentID()
@@ -647,14 +655,22 @@ export function MessageTimeline(props: {
}
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) => {
console.error("Failed to share session", err)
},
}))
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) => {
console.error("Failed to unshare session", err)
},
@@ -662,7 +678,15 @@ export function MessageTimeline(props: {
const titleMutation = useMutation(() => ({
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) => {
sync().set(
produce((draft) => {
@@ -806,7 +830,11 @@ export function MessageTimeline(props: {
const nextSession = index === -1 ? undefined : (sessions[index + 1] ?? sessions[index - 1])
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(() => {
sync().set(
produce((draft) => {
@@ -835,8 +863,11 @@ export function MessageTimeline(props: {
const nextSession = index === -1 ? undefined : (sessions[index + 1] ?? sessions[index - 1])
const result = await sdk()
.client.session.delete({ sessionID })
.then((x) => x.data)
.backend.then((client) => {
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) => {
showToast({
title: language.t("session.delete.failed.title"),
@@ -1203,7 +1234,10 @@ export function MessageTimeline(props: {
return (
<TimelineRowFrame row={retryRow}>
<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>
</TimelineRowFrame>
)
@@ -1549,9 +1583,11 @@ export function MessageTimeline(props: {
</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
</Show>
<DropdownMenu.Item onSelect={() => void archiveSession(id)}>
<DropdownMenu.ItemLabel>{language.t("common.archive")}</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
<Show when={canArchive()}>
<DropdownMenu.Item onSelect={() => void archiveSession(id)}>
<DropdownMenu.ItemLabel>{language.t("common.archive")}</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
</Show>
<DropdownMenu.Separator />
<DropdownMenu.Item
onSelect={() => dialog.show(() => <DialogDeleteSession sessionID={id} />)}
@@ -1620,9 +1656,11 @@ export function MessageTimeline(props: {
{language.t("session.share.action.share")}...
</MenuV2.Item>
</Show>
<MenuV2.Item onSelect={() => void archiveSession(id)}>
{language.t("common.archive")}
</MenuV2.Item>
<Show when={canArchive()}>
<MenuV2.Item onSelect={() => void archiveSession(id)}>
{language.t("common.archive")}
</MenuV2.Item>
</Show>
<MenuV2.Separator />
<MenuV2.Item onSelect={() => dialog.show(() => <DialogDeleteSession sessionID={id} />)}>
{language.t("common.delete")}...
@@ -1,5 +1,9 @@
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"
const user = (id: string) => ({ id, role: "user" }) as UserMessage
@@ -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 { useServerSync } from "@/context/server-sync"
import { useSync } from "@/context/sync"
@@ -1,5 +1,11 @@
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 { reuseTimelineRows } from "./row-reconciliation"
import { Timeline, TimelineRow } from "./rows"
@@ -1,5 +1,10 @@
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 { TimelineRow, type SummaryDiff } from "./timeline-row"
@@ -119,7 +124,7 @@ export namespace Timeline {
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
.flatMap((message) => getMessageParts(message.id))
.map((part) => (part.type === "reasoning" && part.text ? reasoningHeading(part.text) : undefined))
@@ -167,7 +172,9 @@ export namespace Timeline {
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"
}

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