Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f346024f0e | |||
| 56ea8a4a49 | |||
| efaeda00a1 | |||
| 08ea08e830 | |||
| 4c0feeebb4 | |||
| 4d314f6e04 |
@@ -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"],
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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[]>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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>}
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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"))
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,1211 @@
|
||||
import type {
|
||||
Config,
|
||||
Event,
|
||||
GlobalEvent,
|
||||
Message,
|
||||
Model,
|
||||
OpencodeClient,
|
||||
Part,
|
||||
Project,
|
||||
Provider,
|
||||
Session,
|
||||
} from "@opencode-ai/sdk-v1/v2/client"
|
||||
import type {
|
||||
AppAgent,
|
||||
AppClient,
|
||||
AppCommand,
|
||||
AppConfig,
|
||||
AppEvent,
|
||||
AppEventEnvelope,
|
||||
AppFileDiff,
|
||||
AppModel,
|
||||
AppPermissionRequest,
|
||||
AppProject,
|
||||
AppProvider,
|
||||
AppQuestionRequest,
|
||||
AppReference,
|
||||
AppSession,
|
||||
CommandInput,
|
||||
DecoratedFileContent,
|
||||
FileContent,
|
||||
FileEntry,
|
||||
LocationInput,
|
||||
LocationRef,
|
||||
Page,
|
||||
PromptFile,
|
||||
PromptInput,
|
||||
PtyTransportConfig,
|
||||
ProviderCatalog,
|
||||
RequestOptions,
|
||||
SessionActivity,
|
||||
TimelineContent,
|
||||
TimelineItem,
|
||||
ToolState,
|
||||
} from "./backend"
|
||||
|
||||
type CachedMessage = {
|
||||
info: Message
|
||||
parts: Part[]
|
||||
}
|
||||
|
||||
export function createV1Backend(
|
||||
client: OpencodeClient,
|
||||
defaultLocation?: LocationRef,
|
||||
eventClient: OpencodeClient = client,
|
||||
transportConfig?: PtyTransportConfig,
|
||||
): AppClient {
|
||||
const messages = new Map<string, CachedMessage>()
|
||||
|
||||
const options = (input?: RequestOptions) => ({ signal: input?.signal, throwOnError: true as const })
|
||||
const location = (input?: LocationInput) => legacyLocation(input?.location ?? defaultLocation)
|
||||
const cache = (input: CachedMessage) => {
|
||||
messages.set(input.info.id, input)
|
||||
return toTimelineItem(input.info, input.parts)
|
||||
}
|
||||
|
||||
const loadMessage = async (
|
||||
input: LocationInput & { sessionID: string; messageID: string },
|
||||
request?: RequestOptions,
|
||||
force?: boolean,
|
||||
) => {
|
||||
const cached = messages.get(input.messageID)
|
||||
if (cached && !force) return cached
|
||||
const result = await client.session.message(
|
||||
{ sessionID: input.sessionID, messageID: input.messageID, ...location(input) },
|
||||
options(request),
|
||||
)
|
||||
messages.set(input.messageID, result.data)
|
||||
return result.data
|
||||
}
|
||||
const sessionActions = {
|
||||
remove: async (input: LocationInput & { sessionID: string }, request?: RequestOptions) => {
|
||||
const result = await client.session.delete(
|
||||
{ sessionID: input.sessionID, ...location(input) },
|
||||
options(request),
|
||||
)
|
||||
return result.data
|
||||
},
|
||||
fork: async (input: LocationInput & { sessionID: string; messageID?: string }, request?: RequestOptions) => {
|
||||
const result = await client.session.fork(
|
||||
{ sessionID: input.sessionID, messageID: input.messageID, ...location(input) },
|
||||
options(request),
|
||||
)
|
||||
return toSession(result.data)
|
||||
},
|
||||
rename: async (input: LocationInput & { sessionID: string; title: string }, request?: RequestOptions) => {
|
||||
await client.session.update(
|
||||
{ sessionID: input.sessionID, title: input.title, ...location(input) },
|
||||
options(request),
|
||||
)
|
||||
},
|
||||
command: async (input: CommandInput, request?: RequestOptions) => {
|
||||
await client.session.command(
|
||||
{
|
||||
sessionID: input.sessionID,
|
||||
messageID: input.id,
|
||||
command: input.command,
|
||||
arguments: input.arguments ?? "",
|
||||
agent: input.agent,
|
||||
model: input.model && `${input.model.providerID}/${input.model.id}`,
|
||||
variant: input.model?.variant,
|
||||
parts: input.files?.map(toFilePart),
|
||||
...location(input),
|
||||
},
|
||||
options(request),
|
||||
)
|
||||
},
|
||||
}
|
||||
|
||||
return {
|
||||
version: "v1",
|
||||
common: {
|
||||
health: {
|
||||
get: async (request) => {
|
||||
const result = await client.global.health(options(request))
|
||||
return result.data
|
||||
},
|
||||
},
|
||||
projects: {
|
||||
current: async (input, request) => {
|
||||
const params = location(input)
|
||||
const [project, path] = await Promise.all([
|
||||
client.project.current(params, options(request)),
|
||||
client.path.get(params, options(request)),
|
||||
])
|
||||
return { id: project.data.id, directory: path.data.directory }
|
||||
},
|
||||
},
|
||||
catalog: {
|
||||
providers: async (input, request) => {
|
||||
const result = await client.provider.list(location(input), options(request))
|
||||
return toProviderCatalog(result.data)
|
||||
},
|
||||
agents: async (input, request) => {
|
||||
const result = await client.app.agents(location(input), options(request))
|
||||
return normalizeAgents(result.data).map(toAgent)
|
||||
},
|
||||
},
|
||||
commands: {
|
||||
list: async (input, request) => {
|
||||
const result = await client.command.list(location(input), options(request))
|
||||
return result.data.map(toCommand)
|
||||
},
|
||||
},
|
||||
references: {
|
||||
list: async (input, request) => {
|
||||
const result = await client.v2.reference.list(
|
||||
{ location: apiLocation(input?.location ?? defaultLocation) },
|
||||
options(request),
|
||||
)
|
||||
return result.data.data.map(toReference)
|
||||
},
|
||||
},
|
||||
sessions: {
|
||||
list: async (input, request) => {
|
||||
const cursor = input?.cursor === undefined ? undefined : Number(input.cursor)
|
||||
if (cursor !== undefined && !Number.isFinite(cursor))
|
||||
throw new Error(`Invalid session cursor: ${input?.cursor}`)
|
||||
const result = await client.experimental.session.list(
|
||||
{
|
||||
...location(input),
|
||||
roots: input?.roots,
|
||||
limit: input?.limit,
|
||||
search: input?.search,
|
||||
cursor,
|
||||
},
|
||||
options(request),
|
||||
)
|
||||
return {
|
||||
items: result.data.map(toSession),
|
||||
older: result.response.headers.get("x-next-cursor") ?? undefined,
|
||||
}
|
||||
},
|
||||
create: async (input, request) => {
|
||||
const result = await client.session.create(
|
||||
{
|
||||
...location(input),
|
||||
agent: input?.agent,
|
||||
model: input?.model,
|
||||
},
|
||||
options(request),
|
||||
)
|
||||
return toSession(result.data)
|
||||
},
|
||||
get: async (input, request) => {
|
||||
const result = await client.session.get({ sessionID: input.sessionID, ...location(input) }, options(request))
|
||||
return toSession(result.data)
|
||||
},
|
||||
interrupt: async (input, request) => {
|
||||
await client.session.abort({ sessionID: input.sessionID, ...location(input) }, options(request))
|
||||
},
|
||||
activity: async (input, request) => {
|
||||
const result = await client.session.status(location(input), options(request))
|
||||
return Object.fromEntries(
|
||||
Object.entries(result.data).flatMap(([sessionID, status]) => {
|
||||
const activity = toActivity(status)
|
||||
return activity ? [[sessionID, activity] as const] : []
|
||||
}),
|
||||
)
|
||||
},
|
||||
history: async (input, request) => {
|
||||
const result = await client.session.messages(
|
||||
{
|
||||
sessionID: input.sessionID,
|
||||
limit: input.limit,
|
||||
before: input.cursor,
|
||||
...location(input),
|
||||
},
|
||||
options(request),
|
||||
)
|
||||
return {
|
||||
items: result.data.map(cache),
|
||||
older: result.response.headers.get("x-next-cursor") ?? undefined,
|
||||
}
|
||||
},
|
||||
message: async (input, request) => cache(await loadMessage(input, request, true)),
|
||||
prompt: async (input, request) => {
|
||||
await client.session.promptAsync(
|
||||
{
|
||||
sessionID: input.sessionID,
|
||||
messageID: input.id,
|
||||
agent: input.selection?.agent,
|
||||
model: input.selection?.model && {
|
||||
providerID: input.selection.model.providerID,
|
||||
modelID: input.selection.model.id,
|
||||
},
|
||||
variant: input.selection?.model?.variant,
|
||||
parts: toPromptParts(input),
|
||||
...location(input),
|
||||
},
|
||||
options(request),
|
||||
)
|
||||
},
|
||||
},
|
||||
files: {
|
||||
list: async (input, request) => {
|
||||
const result = await client.file.list({ path: input.path ?? "", ...location(input) }, options(request))
|
||||
return result.data.map((item) => ({
|
||||
path: item.path,
|
||||
name: item.name,
|
||||
absolute: item.absolute,
|
||||
type: item.type,
|
||||
ignored: false,
|
||||
}))
|
||||
},
|
||||
find: async (input, request) => {
|
||||
const find = async (type: FileEntry["type"]) => {
|
||||
const result = await client.find.files(
|
||||
{ query: input.query, type, limit: input.limit, ...location(input) },
|
||||
options(request),
|
||||
)
|
||||
return result.data.map((path) => ({ path, type }))
|
||||
}
|
||||
if (input.type) return find(input.type)
|
||||
const result = await Promise.all([find("file"), find("directory")])
|
||||
return [...new Map(result.flat().map((item) => [item.path, item])).values()]
|
||||
},
|
||||
read: async (input, request) => {
|
||||
const result = await client.file.read({ path: input.path, ...location(input) }, options(request))
|
||||
return toFileContent(result.data)
|
||||
},
|
||||
},
|
||||
permissions: {
|
||||
pending: async (input, request) => {
|
||||
const result = await client.permission.list(location(input), options(request))
|
||||
return result.data.map(toPermission)
|
||||
},
|
||||
reply: async (input, request) => {
|
||||
await client.permission.reply(
|
||||
{
|
||||
requestID: input.requestID,
|
||||
reply: input.reply,
|
||||
message: input.message,
|
||||
...location(input),
|
||||
},
|
||||
options(request),
|
||||
)
|
||||
},
|
||||
},
|
||||
questions: {
|
||||
pending: async (input, request) => {
|
||||
const result = await client.question.list(location(input), options(request))
|
||||
return result.data.map(toQuestion)
|
||||
},
|
||||
reply: async (input, request) => {
|
||||
await client.question.reply(
|
||||
{
|
||||
requestID: input.requestID,
|
||||
answers: input.answers.map((answer) => [...answer]),
|
||||
...location(input),
|
||||
},
|
||||
options(request),
|
||||
)
|
||||
},
|
||||
reject: async (input, request) => {
|
||||
await client.question.reject({ requestID: input.requestID, ...location(input) }, options(request))
|
||||
},
|
||||
},
|
||||
pty: {
|
||||
list: async (input, request) => {
|
||||
const result = await client.pty.list(location(input), options(request))
|
||||
return result.data.map(toPty)
|
||||
},
|
||||
create: async (input, request) => {
|
||||
const result = await client.pty.create(
|
||||
{
|
||||
title: input.title,
|
||||
command: input.command,
|
||||
args: input.args ? [...input.args] : undefined,
|
||||
cwd: input.cwd,
|
||||
env: input.env ? { ...input.env } : undefined,
|
||||
...location(input),
|
||||
},
|
||||
options(request),
|
||||
)
|
||||
return toPty(result.data)
|
||||
},
|
||||
get: async (input, request) => {
|
||||
const result = await client.pty.get({ ptyID: input.ptyID, ...location(input) }, options(request))
|
||||
return toPty(result.data)
|
||||
},
|
||||
update: async (input, request) => {
|
||||
const result = await client.pty.update(
|
||||
{
|
||||
ptyID: input.ptyID,
|
||||
title: input.title,
|
||||
size: input.size,
|
||||
...location(input),
|
||||
},
|
||||
options(request),
|
||||
)
|
||||
return toPty(result.data)
|
||||
},
|
||||
remove: async (input, request) => {
|
||||
await client.pty.remove({ ptyID: input.ptyID, ...location(input) }, options(request))
|
||||
},
|
||||
},
|
||||
events: {
|
||||
subscribe: (request) => ({
|
||||
async *[Symbol.asyncIterator]() {
|
||||
const result = await eventClient.global.event(options(request))
|
||||
for await (const input of result.stream) {
|
||||
const event = await toEvent(input, messages, loadMessage)
|
||||
yield {
|
||||
location:
|
||||
input.directory === "global"
|
||||
? undefined
|
||||
: { directory: input.directory, workspaceID: input.workspace },
|
||||
event,
|
||||
} satisfies AppEventEnvelope
|
||||
}
|
||||
},
|
||||
}),
|
||||
},
|
||||
},
|
||||
capabilities: {
|
||||
projectList: {
|
||||
list: async (request) => {
|
||||
const result = await client.project.list(undefined, options(request))
|
||||
return result.data.map(toProject)
|
||||
},
|
||||
},
|
||||
vcs: {
|
||||
status: async (input, request) => {
|
||||
const result = await client.vcs.status(location(input), options(request))
|
||||
return result.data
|
||||
},
|
||||
diff: async (input, request) => {
|
||||
const result = await client.vcs.diff(
|
||||
{
|
||||
mode: input.mode === "working" ? "git" : "branch",
|
||||
context: input.context,
|
||||
...location(input),
|
||||
},
|
||||
options(request),
|
||||
)
|
||||
return result.data
|
||||
},
|
||||
},
|
||||
mcp: {
|
||||
list: async (input, request) => {
|
||||
const result = await client.mcp.status(location(input), options(request))
|
||||
return Object.entries(result.data).map(([name, status]) => ({ name, status }))
|
||||
},
|
||||
resources: async (input, request) => {
|
||||
const result = await client.experimental.resource.list(location(input), options(request))
|
||||
return {
|
||||
resources: Object.values(result.data).map((item) => ({
|
||||
server: item.client,
|
||||
name: item.name,
|
||||
uri: item.uri,
|
||||
description: item.description,
|
||||
mimeType: item.mimeType,
|
||||
})),
|
||||
templates: [],
|
||||
}
|
||||
},
|
||||
},
|
||||
sessionActionsV1: sessionActions,
|
||||
configuration: {
|
||||
getGlobal: async (request) => {
|
||||
const result = await client.global.config.get(options(request))
|
||||
return toConfig(result.data)
|
||||
},
|
||||
updateGlobal: async (config, request) => {
|
||||
const current = await client.global.config.get(options(request))
|
||||
await client.global.config.update({ config: { ...current.data, ...fromConfig(config) } }, options(request))
|
||||
},
|
||||
get: async (input, request) => {
|
||||
const result = await client.config.get(location(input), options(request))
|
||||
return toConfig(result.data)
|
||||
},
|
||||
},
|
||||
providerAuthV1: {
|
||||
methods: async (input, request) => {
|
||||
const result = await client.provider.auth(location(input), options(request))
|
||||
return result.data
|
||||
},
|
||||
authorize: async (input, request) => {
|
||||
const result = await client.provider.oauth.authorize(
|
||||
{
|
||||
providerID: input.providerID,
|
||||
method: input.method,
|
||||
inputs: input.values ? { ...input.values } : undefined,
|
||||
...location(input),
|
||||
},
|
||||
options(request),
|
||||
)
|
||||
return result.data
|
||||
},
|
||||
callback: async (input, request) => {
|
||||
await client.provider.oauth.callback(
|
||||
{
|
||||
providerID: input.providerID,
|
||||
method: input.method,
|
||||
code: input.code,
|
||||
...location(input),
|
||||
},
|
||||
options(request),
|
||||
)
|
||||
},
|
||||
setApiKey: async (input, request) => {
|
||||
await client.auth.set(
|
||||
{
|
||||
providerID: input.providerID,
|
||||
auth: { type: "api", key: input.key, metadata: input.metadata && { ...input.metadata } },
|
||||
},
|
||||
options(request),
|
||||
)
|
||||
},
|
||||
remove: async (input, request) => {
|
||||
await client.auth.remove(input, options(request))
|
||||
},
|
||||
},
|
||||
projectEditing: {
|
||||
update: async (input, request) => {
|
||||
const result = await client.project.update(
|
||||
{
|
||||
projectID: input.projectID,
|
||||
name: input.name,
|
||||
icon: input.icon,
|
||||
commands: input.commands,
|
||||
...location(input),
|
||||
},
|
||||
options(request),
|
||||
)
|
||||
return toProject(result.data)
|
||||
},
|
||||
initGit: async (input, request) => {
|
||||
const result = await client.project.initGit(location(input), options(request))
|
||||
return toProject(result.data)
|
||||
},
|
||||
},
|
||||
worktreesV1: {
|
||||
list: async (input, request) => {
|
||||
const result = await client.worktree.list(location(input), options(request))
|
||||
return result.data
|
||||
},
|
||||
create: async (input, request) => {
|
||||
const result = await client.worktree.create(location(input), options(request))
|
||||
return { directory: result.data.directory, branch: result.data.branch }
|
||||
},
|
||||
remove: async (input, request) => {
|
||||
const result = await client.worktree.remove(
|
||||
{ ...location(input), worktreeRemoveInput: { directory: input.directory } },
|
||||
options(request),
|
||||
)
|
||||
return result.data
|
||||
},
|
||||
reset: async (input, request) => {
|
||||
const result = await client.worktree.reset(
|
||||
{ ...location(input), worktreeResetInput: { directory: input.directory } },
|
||||
options(request),
|
||||
)
|
||||
return result.data
|
||||
},
|
||||
},
|
||||
sessionExtrasV1: {
|
||||
archive: async (input, request) => {
|
||||
await client.session.update(
|
||||
{ sessionID: input.sessionID, time: { archived: input.archivedAt }, ...location(input) },
|
||||
options(request),
|
||||
)
|
||||
},
|
||||
share: async (input, request) => {
|
||||
const result = await client.session.share(
|
||||
{ sessionID: input.sessionID, ...location(input) },
|
||||
options(request),
|
||||
)
|
||||
if (!result.data.share) throw new Error(`Session ${input.sessionID} was shared without a URL`)
|
||||
return result.data.share.url
|
||||
},
|
||||
unshare: async (input, request) => {
|
||||
await client.session.unshare({ sessionID: input.sessionID, ...location(input) }, options(request))
|
||||
},
|
||||
diff: async (input, request) => {
|
||||
const result = await client.session.diff({ sessionID: input.sessionID, ...location(input) }, options(request))
|
||||
return result.data.flatMap((item) => (item.file ? [{ ...item, file: item.file }] : []))
|
||||
},
|
||||
todos: async (input, request) => {
|
||||
const result = await client.session.todo({ sessionID: input.sessionID, ...location(input) }, options(request))
|
||||
return result.data.map((item) => ({
|
||||
content: item.content,
|
||||
status: item.status,
|
||||
priority: item.priority,
|
||||
}))
|
||||
},
|
||||
summarize: async (input, request) => {
|
||||
await client.session.summarize(
|
||||
{
|
||||
sessionID: input.sessionID,
|
||||
providerID: input.model.providerID,
|
||||
modelID: input.model.id,
|
||||
...location(input),
|
||||
},
|
||||
options(request),
|
||||
)
|
||||
},
|
||||
revert: async (input, request) => {
|
||||
const result = await client.session.revert(
|
||||
{ sessionID: input.sessionID, messageID: input.messageID, ...location(input) },
|
||||
options(request),
|
||||
)
|
||||
return toSession(result.data)
|
||||
},
|
||||
clearRevert: async (input, request) => {
|
||||
const result = await client.session.unrevert(
|
||||
{ sessionID: input.sessionID, ...location(input) },
|
||||
options(request),
|
||||
)
|
||||
return toSession(result.data)
|
||||
},
|
||||
shell: async (input, request) => {
|
||||
await client.session.shell(
|
||||
{
|
||||
sessionID: input.sessionID,
|
||||
messageID: input.id,
|
||||
command: input.command,
|
||||
agent: input.agent,
|
||||
model: input.model && { providerID: input.model.providerID, modelID: input.model.id },
|
||||
...location(input),
|
||||
},
|
||||
options(request),
|
||||
)
|
||||
},
|
||||
},
|
||||
lsp: {
|
||||
status: async (input, request) => {
|
||||
const result = await client.lsp.status(location(input), options(request))
|
||||
return result.data.map((item) => ({ id: item.id, name: item.name, status: item.status }))
|
||||
},
|
||||
},
|
||||
mcpControl: {
|
||||
connect: async (input, request) => {
|
||||
await client.mcp.connect({ name: input.name, ...location(input) }, options(request))
|
||||
},
|
||||
disconnect: async (input, request) => {
|
||||
await client.mcp.disconnect({ name: input.name, ...location(input) }, options(request))
|
||||
},
|
||||
authenticate: async (input, request) => {
|
||||
await client.mcp.auth.authenticate({ name: input.name, ...location(input) }, options(request))
|
||||
},
|
||||
},
|
||||
pathInfo: {
|
||||
get: async (input, request) => {
|
||||
const result = await client.path.get(location(input), options(request))
|
||||
return result.data
|
||||
},
|
||||
},
|
||||
vcsInfo: {
|
||||
get: async (input, request) => {
|
||||
const result = await client.vcs.get(location(input), options(request))
|
||||
return { branch: result.data.branch, defaultBranch: result.data.default_branch }
|
||||
},
|
||||
},
|
||||
decoratedFiles: {
|
||||
read: async (input, request) => {
|
||||
const result = await client.file.read({ path: input.path, ...location(input) }, options(request))
|
||||
return toDecoratedFile(result.data)
|
||||
},
|
||||
},
|
||||
ptyTransport: transportConfig && {
|
||||
connectToken: async (input, request) => {
|
||||
const result = await client.pty.connectToken(
|
||||
{ ptyID: input.ptyID, ...location(input) },
|
||||
{
|
||||
signal: request?.signal,
|
||||
throwOnError: false,
|
||||
headers: { "x-opencode-ticket": "1" },
|
||||
},
|
||||
)
|
||||
return { status: result.response.status, ticket: result.data?.ticket }
|
||||
},
|
||||
exists: async (input, request) => {
|
||||
const result = await client.pty.get(
|
||||
{ ptyID: input.ptyID, ...location(input) },
|
||||
{ signal: request?.signal, throwOnError: false },
|
||||
)
|
||||
return result.response.status !== 404
|
||||
},
|
||||
connectURL: (input) => ptyConnectURL(transportConfig, "/pty", input, legacyLocation(input.location)),
|
||||
},
|
||||
shellDiscovery: {
|
||||
list: async (input, request) => {
|
||||
const result = await client.pty.shells(location(input), options(request))
|
||||
return result.data
|
||||
},
|
||||
},
|
||||
runtimeV1: {
|
||||
disposeLocation: async (input, request) => {
|
||||
await client.instance.dispose(location(input), options(request))
|
||||
},
|
||||
disposeAll: async (request) => {
|
||||
await client.global.dispose(options(request))
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function ptyConnectURL(
|
||||
config: PtyTransportConfig,
|
||||
root: string,
|
||||
input: { ptyID: string; cursor: number; ticket?: string },
|
||||
location: { directory?: string; workspace?: string },
|
||||
) {
|
||||
const url = new URL(`${config.baseUrl.replace(/\/+$/, "")}${root}/${encodeURIComponent(input.ptyID)}/connect`)
|
||||
if (location.directory) url.searchParams.set("directory", location.directory)
|
||||
if (location.workspace) url.searchParams.set("workspace", location.workspace)
|
||||
url.searchParams.set("cursor", String(input.cursor))
|
||||
url.protocol = url.protocol === "https:" ? "wss:" : "ws:"
|
||||
if (input.ticket) {
|
||||
url.searchParams.set("ticket", input.ticket)
|
||||
return url
|
||||
}
|
||||
if (config.password && (!config.sameOrigin || config.authToken))
|
||||
url.searchParams.set("auth_token", btoa(`${config.username ?? "opencode"}:${config.password}`))
|
||||
return url
|
||||
}
|
||||
|
||||
function legacyLocation(input?: LocationRef) {
|
||||
if (!input) return {}
|
||||
return {
|
||||
directory: input.directory,
|
||||
workspace: input.workspaceID,
|
||||
}
|
||||
}
|
||||
|
||||
function apiLocation(input?: LocationRef) {
|
||||
if (!input) return
|
||||
return {
|
||||
directory: input.directory,
|
||||
workspace: input.workspaceID,
|
||||
}
|
||||
}
|
||||
|
||||
function toProject(input: Project): AppProject {
|
||||
return {
|
||||
id: input.id,
|
||||
worktree: input.worktree,
|
||||
vcs: input.vcs,
|
||||
time: { ...input.time, updated: input.time.updated ?? input.time.created },
|
||||
name: input.name,
|
||||
icon: input.icon,
|
||||
commands: input.commands,
|
||||
sandboxes: input.sandboxes,
|
||||
}
|
||||
}
|
||||
|
||||
function toSession(input: Session): AppSession {
|
||||
return {
|
||||
id: input.id,
|
||||
slug: input.slug,
|
||||
version: input.version,
|
||||
parentID: input.parentID,
|
||||
projectID: input.projectID,
|
||||
location: { directory: input.directory, workspaceID: input.workspaceID },
|
||||
directory: input.directory,
|
||||
workspaceID: input.workspaceID,
|
||||
title: input.title,
|
||||
cost: input.cost ?? 0,
|
||||
tokens: input.tokens,
|
||||
time: input.time,
|
||||
share: input.share,
|
||||
revert: input.revert && { messageID: input.revert.messageID },
|
||||
}
|
||||
}
|
||||
|
||||
function toModel(input: Model): AppModel {
|
||||
return {
|
||||
id: input.id,
|
||||
providerID: input.providerID,
|
||||
name: input.name,
|
||||
family: input.family,
|
||||
releaseDate: input.release_date,
|
||||
cost: {
|
||||
input: input.cost.input,
|
||||
output: input.cost.output,
|
||||
cacheRead: input.cost.cache.read,
|
||||
cacheWrite: input.cost.cache.write,
|
||||
},
|
||||
capabilities: {
|
||||
reasoning: input.capabilities.reasoning,
|
||||
input: input.capabilities.input,
|
||||
},
|
||||
limit: input.limit,
|
||||
variants: input.variants,
|
||||
}
|
||||
}
|
||||
|
||||
function toProvider(input: Provider): AppProvider {
|
||||
return {
|
||||
id: input.id,
|
||||
name: input.name,
|
||||
source: input.source,
|
||||
models: Object.fromEntries(
|
||||
Object.entries(input.models).flatMap(([id, model]) =>
|
||||
model.status === "deprecated" ? [] : [[id, toModel(model)]],
|
||||
),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
function toProviderCatalog(input: {
|
||||
all: Provider[]
|
||||
connected: string[]
|
||||
default: Record<string, string>
|
||||
}): ProviderCatalog {
|
||||
return {
|
||||
providers: new Map(input.all.map((provider) => [provider.id, toProvider(provider)])),
|
||||
connected: input.connected,
|
||||
defaults: input.default,
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeAgents(input: unknown) {
|
||||
const valid = (item: unknown): item is import("@opencode-ai/sdk-v1/v2/client").Agent => {
|
||||
if (!item || typeof item !== "object") return false
|
||||
if (!("name" in item) || typeof item.name !== "string") return false
|
||||
if (!("mode" in item)) return false
|
||||
return item.mode === "subagent" || item.mode === "primary" || item.mode === "all"
|
||||
}
|
||||
if (Array.isArray(input)) return input.filter(valid)
|
||||
if (valid(input)) return [input]
|
||||
if (!input || typeof input !== "object") return []
|
||||
return Object.values(input).filter(valid)
|
||||
}
|
||||
|
||||
function toAgent(input: import("@opencode-ai/sdk-v1/v2/client").Agent): AppAgent {
|
||||
return {
|
||||
id: input.name,
|
||||
name: input.name,
|
||||
description: input.description,
|
||||
mode: input.mode,
|
||||
hidden: input.hidden ?? false,
|
||||
color: input.color,
|
||||
model: input.model && {
|
||||
id: input.model.modelID,
|
||||
providerID: input.model.providerID,
|
||||
variant: input.variant,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function toCommand(input: import("@opencode-ai/sdk-v1/v2/client").Command): AppCommand {
|
||||
return { name: input.name, description: input.description, source: input.source }
|
||||
}
|
||||
|
||||
function toReference(input: import("@opencode-ai/sdk-v1/v2/client").ReferenceInfo): AppReference {
|
||||
return input
|
||||
}
|
||||
|
||||
function toActivity(input: import("@opencode-ai/sdk-v1/v2/client").SessionStatus): SessionActivity {
|
||||
if (input.type === "idle") return { type: "idle" }
|
||||
if (input.type === "busy") return { type: "running" }
|
||||
return input
|
||||
}
|
||||
|
||||
function toTimelineItem(info: Message, parts: readonly Part[]): TimelineItem {
|
||||
if (info.role === "user") {
|
||||
return {
|
||||
type: "user",
|
||||
id: info.id,
|
||||
sessionID: info.sessionID,
|
||||
created: info.time.created,
|
||||
content: parts.flatMap(toTimelineContent),
|
||||
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: parts.flatMap(toTimelineContent),
|
||||
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(input: Part): TimelineContent[] {
|
||||
if (input.type === "text")
|
||||
return [
|
||||
{
|
||||
type: input.type,
|
||||
id: input.id,
|
||||
text: input.text,
|
||||
synthetic: input.synthetic,
|
||||
ignored: input.ignored,
|
||||
metadata: input.metadata,
|
||||
time: input.time,
|
||||
},
|
||||
]
|
||||
if (input.type === "reasoning")
|
||||
return [{ type: input.type, id: input.id, text: input.text, metadata: input.metadata, time: input.time }]
|
||||
if (input.type === "file")
|
||||
return [
|
||||
{
|
||||
type: input.type,
|
||||
id: input.id,
|
||||
uri: input.url,
|
||||
name: input.filename,
|
||||
mime: input.mime,
|
||||
source: input.source && {
|
||||
...input.source,
|
||||
text: {
|
||||
text: input.source.text.value,
|
||||
start: input.source.text.start,
|
||||
end: input.source.text.end,
|
||||
},
|
||||
},
|
||||
},
|
||||
]
|
||||
if (input.type === "agent")
|
||||
return [
|
||||
{
|
||||
type: input.type,
|
||||
id: input.id,
|
||||
name: input.name,
|
||||
source: input.source && { text: input.source.value, start: input.source.start, end: input.source.end },
|
||||
},
|
||||
]
|
||||
if (input.type === "tool")
|
||||
return [
|
||||
{
|
||||
type: input.type,
|
||||
id: input.id,
|
||||
callID: input.callID,
|
||||
tool: input.tool,
|
||||
state: toToolState(input.state),
|
||||
metadata: input.metadata,
|
||||
},
|
||||
]
|
||||
if (input.type === "subtask")
|
||||
return [
|
||||
{
|
||||
type: input.type,
|
||||
id: input.id,
|
||||
prompt: input.prompt,
|
||||
description: input.description,
|
||||
agent: input.agent,
|
||||
model: input.model && { id: input.model.modelID, providerID: input.model.providerID },
|
||||
command: input.command,
|
||||
},
|
||||
]
|
||||
if (input.type === "step-start") return [{ type: input.type, id: input.id, snapshot: input.snapshot }]
|
||||
if (input.type === "step-finish")
|
||||
return [
|
||||
{
|
||||
type: input.type,
|
||||
id: input.id,
|
||||
reason: input.reason,
|
||||
snapshot: input.snapshot,
|
||||
cost: input.cost,
|
||||
tokens: input.tokens,
|
||||
},
|
||||
]
|
||||
if (input.type === "snapshot") return [{ type: input.type, id: input.id, snapshot: input.snapshot }]
|
||||
if (input.type === "patch") return [{ type: input.type, id: input.id, hash: input.hash, files: input.files }]
|
||||
if (input.type === "retry")
|
||||
return [{ type: input.type, id: input.id, attempt: input.attempt, error: input.error, time: input.time }]
|
||||
if (input.type === "compaction") return [{ type: input.type, id: input.id, auto: input.auto }]
|
||||
return []
|
||||
}
|
||||
|
||||
function toToolState(input: import("@opencode-ai/sdk-v1/v2/client").ToolState): ToolState {
|
||||
if (input.status === "pending") return { status: input.status, input: input.input, raw: input.raw }
|
||||
if (input.status === "running")
|
||||
return { status: input.status, input: input.input, title: input.title, metadata: input.metadata, time: input.time }
|
||||
if (input.status === "completed")
|
||||
return {
|
||||
status: input.status,
|
||||
input: input.input,
|
||||
output: input.output,
|
||||
title: input.title,
|
||||
metadata: input.metadata,
|
||||
time: input.time,
|
||||
attachments: input.attachments,
|
||||
}
|
||||
return { status: input.status, input: input.input, error: input.error, metadata: input.metadata, time: input.time }
|
||||
}
|
||||
|
||||
function toFilePart(input: PromptFile) {
|
||||
return {
|
||||
type: "file" as const,
|
||||
mime: input.mime ?? "text/plain",
|
||||
filename: input.name,
|
||||
url: input.uri,
|
||||
source: input.source?.path
|
||||
? {
|
||||
type: "file" as const,
|
||||
path: input.source.path,
|
||||
text: { value: input.source.text, start: input.source.start, end: input.source.end },
|
||||
}
|
||||
: undefined,
|
||||
}
|
||||
}
|
||||
|
||||
function toPromptParts(input: PromptInput) {
|
||||
if (input.parts) return input.parts.map(toPromptPart)
|
||||
return [
|
||||
{ type: "text" as const, text: input.text },
|
||||
...(input.files?.map(toFilePart) ?? []),
|
||||
...(input.agents?.map((agent) => ({
|
||||
type: "agent" as const,
|
||||
name: agent.name,
|
||||
source:
|
||||
agent.text !== undefined && agent.start !== undefined && agent.end !== undefined
|
||||
? { value: agent.text, start: agent.start, end: agent.end }
|
||||
: undefined,
|
||||
})) ?? []),
|
||||
]
|
||||
}
|
||||
|
||||
function toPromptPart(input: NonNullable<PromptInput["parts"]>[number]) {
|
||||
if (input.type === "file" && input.source?.type === "resource") return { ...input, source: undefined }
|
||||
return { ...input }
|
||||
}
|
||||
|
||||
function toFileContent(input: import("@opencode-ai/sdk-v1/v2/client").FileContent): FileContent {
|
||||
if (input.encoding !== "base64") {
|
||||
return { bytes: new TextEncoder().encode(input.content), kind: input.type, mimeType: input.mimeType }
|
||||
}
|
||||
return {
|
||||
bytes: Uint8Array.from(atob(input.content), (character) => character.charCodeAt(0)),
|
||||
kind: input.type,
|
||||
mimeType: input.mimeType,
|
||||
}
|
||||
}
|
||||
|
||||
function toDecoratedFile(input: import("@opencode-ai/sdk-v1/v2/client").FileContent): DecoratedFileContent {
|
||||
return {
|
||||
type: input.type,
|
||||
content: input.content,
|
||||
diff: input.diff,
|
||||
encoding: input.encoding,
|
||||
mimeType: input.mimeType,
|
||||
patch: input.patch,
|
||||
}
|
||||
}
|
||||
|
||||
function toPermission(input: import("@opencode-ai/sdk-v1/v2/client").PermissionRequest): AppPermissionRequest {
|
||||
return {
|
||||
id: input.id,
|
||||
sessionID: input.sessionID,
|
||||
action: input.permission,
|
||||
resources: input.patterns,
|
||||
permission: input.permission,
|
||||
patterns: input.patterns,
|
||||
always: input.always,
|
||||
metadata: input.metadata ?? {},
|
||||
}
|
||||
}
|
||||
|
||||
function toQuestion(input: import("@opencode-ai/sdk-v1/v2/client").QuestionRequest): AppQuestionRequest {
|
||||
return {
|
||||
id: input.id,
|
||||
sessionID: input.sessionID,
|
||||
questions: input.questions.map((question) => ({
|
||||
...question,
|
||||
header: question.header ?? "",
|
||||
options: question.options.map((option) => ({ ...option, description: option.description ?? "" })),
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
function toPty(input: import("@opencode-ai/sdk-v1/v2/client").Pty) {
|
||||
return { id: input.id, title: input.title }
|
||||
}
|
||||
|
||||
async function toEvent(
|
||||
envelope: GlobalEvent,
|
||||
messages: Map<string, CachedMessage>,
|
||||
loadMessage: (input: LocationInput & { sessionID: string; messageID: string }) => Promise<CachedMessage>,
|
||||
): Promise<AppEvent> {
|
||||
const input = envelope.payload as Event
|
||||
const eventLocation =
|
||||
envelope.directory === "global"
|
||||
? undefined
|
||||
: { location: { directory: envelope.directory, workspaceID: envelope.workspace } }
|
||||
if (input.type === "server.connected") {
|
||||
messages.clear()
|
||||
return { type: input.type }
|
||||
}
|
||||
if (input.type === "global.disposed") return { type: "server.disposed" }
|
||||
if (input.type === "server.instance.disposed")
|
||||
return { type: "instance.disposed", location: { directory: input.properties.directory } }
|
||||
if (input.type === "project.updated") return { type: input.type, project: toProject(input.properties) }
|
||||
if (input.type === "session.created" || input.type === "session.updated")
|
||||
return { type: input.type, session: toSession(input.properties.info) }
|
||||
if (input.type === "session.deleted") {
|
||||
for (const [messageID, message] of messages) {
|
||||
if (message.info.sessionID === input.properties.sessionID) messages.delete(messageID)
|
||||
}
|
||||
return { type: input.type, sessionID: input.properties.sessionID }
|
||||
}
|
||||
if (input.type === "session.status") {
|
||||
const activity = toActivity(input.properties.status)
|
||||
if (activity.type === "idle") {
|
||||
for (const [messageID, message] of messages) {
|
||||
if (message.info.sessionID === input.properties.sessionID) messages.delete(messageID)
|
||||
}
|
||||
}
|
||||
return { type: "session.activity", sessionID: input.properties.sessionID, activity }
|
||||
}
|
||||
if (input.type === "session.diff")
|
||||
return {
|
||||
type: input.type,
|
||||
sessionID: input.properties.sessionID,
|
||||
diff: input.properties.diff.flatMap((item) => (item.file ? [{ ...item, file: item.file }] : [])),
|
||||
}
|
||||
if (input.type === "todo.updated")
|
||||
return {
|
||||
type: input.type,
|
||||
sessionID: input.properties.sessionID,
|
||||
todos: input.properties.todos.map((todo) => ({
|
||||
content: todo.content,
|
||||
status: todo.status,
|
||||
priority: todo.priority,
|
||||
})),
|
||||
}
|
||||
if (input.type === "session.error")
|
||||
return { type: input.type, sessionID: input.properties.sessionID, error: input.properties.error }
|
||||
if (input.type === "message.updated") {
|
||||
const cached = messages.get(input.properties.info.id)
|
||||
const value = { info: input.properties.info, parts: cached?.parts ?? [] }
|
||||
messages.set(input.properties.info.id, value)
|
||||
return { type: "timeline.updated", item: toTimelineItem(value.info, value.parts) }
|
||||
}
|
||||
if (input.type === "message.part.updated") {
|
||||
const messageID = input.properties.part.messageID
|
||||
const value = await loadMessage({ sessionID: input.properties.sessionID, messageID, ...eventLocation })
|
||||
const parts = [...value.parts.filter((part) => part.id !== input.properties.part.id), input.properties.part]
|
||||
const next = { info: value.info, parts }
|
||||
messages.set(messageID, next)
|
||||
return { type: "timeline.updated", item: toTimelineItem(next.info, next.parts) }
|
||||
}
|
||||
if (input.type === "message.removed") {
|
||||
messages.delete(input.properties.messageID)
|
||||
return { type: "timeline.removed", sessionID: input.properties.sessionID, itemID: input.properties.messageID }
|
||||
}
|
||||
if (input.type === "message.part.removed") {
|
||||
const cached = messages.get(input.properties.messageID)
|
||||
if (cached)
|
||||
messages.set(input.properties.messageID, {
|
||||
...cached,
|
||||
parts: cached.parts.filter((part) => part.id !== input.properties.partID),
|
||||
})
|
||||
return {
|
||||
type: "timeline.part.removed",
|
||||
sessionID: input.properties.sessionID,
|
||||
itemID: input.properties.messageID,
|
||||
contentID: input.properties.partID,
|
||||
}
|
||||
}
|
||||
if (input.type === "message.part.delta") {
|
||||
const cached = messages.get(input.properties.messageID)
|
||||
const part = cached?.parts.find((part) => part.id === input.properties.partID)
|
||||
if (cached && part) {
|
||||
const current = part[input.properties.field as keyof Part]
|
||||
if (typeof current === "string")
|
||||
messages.set(input.properties.messageID, {
|
||||
...cached,
|
||||
parts: cached.parts.map((item) =>
|
||||
item.id === part.id ? { ...item, [input.properties.field]: current + input.properties.delta } : item,
|
||||
),
|
||||
})
|
||||
}
|
||||
return {
|
||||
type: "timeline.delta",
|
||||
sessionID: input.properties.sessionID,
|
||||
itemID: input.properties.messageID,
|
||||
contentID: input.properties.partID,
|
||||
field: input.properties.field,
|
||||
delta: input.properties.delta,
|
||||
}
|
||||
}
|
||||
if (input.type === "permission.asked")
|
||||
return { type: "permission.requested", request: toPermission(input.properties) }
|
||||
if (input.type === "permission.v2.asked")
|
||||
return {
|
||||
type: "permission.requested",
|
||||
request: {
|
||||
id: input.properties.id,
|
||||
sessionID: input.properties.sessionID,
|
||||
action: input.properties.action,
|
||||
resources: [...input.properties.resources],
|
||||
permission: input.properties.action,
|
||||
patterns: [...input.properties.resources],
|
||||
always: [],
|
||||
metadata: input.properties.metadata ?? {},
|
||||
},
|
||||
}
|
||||
if (input.type === "permission.replied" || input.type === "permission.v2.replied")
|
||||
return {
|
||||
type: "permission.replied",
|
||||
sessionID: input.properties.sessionID,
|
||||
requestID: input.properties.requestID,
|
||||
}
|
||||
if (input.type === "question.asked" || input.type === "question.v2.asked")
|
||||
return { type: "question.requested", request: toQuestion(input.properties) }
|
||||
if (input.type === "question.replied" || input.type === "question.v2.replied")
|
||||
return { type: "question.replied", sessionID: input.properties.sessionID, requestID: input.properties.requestID }
|
||||
if (input.type === "question.rejected" || input.type === "question.v2.rejected")
|
||||
return { type: "question.rejected", sessionID: input.properties.sessionID, requestID: input.properties.requestID }
|
||||
if (input.type === "file.watcher.updated")
|
||||
return { type: "file.changed", path: input.properties.file, change: input.properties.event }
|
||||
if (input.type === "vcs.branch.updated") return { type: input.type, branch: input.properties.branch }
|
||||
if (input.type === "worktree.ready")
|
||||
return { type: input.type, name: input.properties.name, branch: input.properties.branch }
|
||||
if (input.type === "worktree.failed") return { type: input.type, message: input.properties.message }
|
||||
if (input.type === "lsp.updated") return { type: input.type }
|
||||
if (input.type === "reference.updated") return { type: input.type }
|
||||
if (input.type === "mcp.tools.changed") return { type: "mcp.updated", server: input.properties.server }
|
||||
if (input.type === "pty.exited") return { type: input.type, ptyID: input.properties.id }
|
||||
return { type: "unknown", raw: input }
|
||||
}
|
||||
|
||||
function toConfig(input: Config): AppConfig {
|
||||
return {
|
||||
shell: input.shell,
|
||||
model: input.model,
|
||||
share: input.share,
|
||||
plugin: input.plugin,
|
||||
disabledProviders: input.disabled_providers,
|
||||
provider: input.provider,
|
||||
permission: input.permission,
|
||||
}
|
||||
}
|
||||
|
||||
function fromConfig(input: AppConfig): Config {
|
||||
return {
|
||||
shell: input.shell,
|
||||
model: input.model,
|
||||
share: input.share,
|
||||
plugin: input.plugin?.map((plugin): NonNullable<Config["plugin"]>[number] =>
|
||||
typeof plugin === "string" ? plugin : [plugin[0], { ...plugin[1] }],
|
||||
),
|
||||
disabled_providers: input.disabledProviders ? [...input.disabledProviders] : undefined,
|
||||
provider: input.provider as Config["provider"],
|
||||
permission: input.permission as Config["permission"],
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,1390 @@
|
||||
import type {
|
||||
AgentInfo,
|
||||
FormCreateInput,
|
||||
FormInfo,
|
||||
IntegrationInfo,
|
||||
McpServer,
|
||||
ModelInfo,
|
||||
OpenCodeClient,
|
||||
OpenCodeEvent,
|
||||
PermissionV2Request,
|
||||
Project,
|
||||
QuestionV2Request,
|
||||
SessionInfo,
|
||||
SessionMessageAssistant,
|
||||
SessionMessageInfo,
|
||||
SessionPendingInfo,
|
||||
ShellInfo1,
|
||||
} from "@opencode-ai/client"
|
||||
import type {
|
||||
AppAgent,
|
||||
AppClient,
|
||||
AppEvent,
|
||||
AppFileDiff,
|
||||
AppMcpServer,
|
||||
AppModel,
|
||||
AppPermissionRequest,
|
||||
AppProject,
|
||||
AppQuestionRequest,
|
||||
AppSession,
|
||||
FormField,
|
||||
IntegrationAttempt,
|
||||
IntegrationAttemptStatus,
|
||||
IntegrationConnection,
|
||||
IntegrationMethod,
|
||||
LocationInput,
|
||||
LocationRef,
|
||||
PendingSessionInput,
|
||||
PromptFile,
|
||||
PromptInput,
|
||||
PtyTransportConfig,
|
||||
RequestOptions,
|
||||
SessionActivity,
|
||||
SessionLogItem,
|
||||
ShellProcess,
|
||||
TimelineContent,
|
||||
TimelineItem,
|
||||
} from "./backend"
|
||||
|
||||
export function createV2Backend(
|
||||
client: OpenCodeClient,
|
||||
transportConfig: PtyTransportConfig,
|
||||
defaultLocation?: LocationRef,
|
||||
eventClient: OpenCodeClient = client,
|
||||
): AppClient {
|
||||
const request = (input?: RequestOptions) => ({ signal: input?.signal })
|
||||
const location = (input?: LocationInput) => toLocation(input?.location ?? defaultLocation)
|
||||
const projectedContent = new Map<string, TimelineContent>()
|
||||
const projectedItems = new Map<string, TimelineItem>()
|
||||
const projectedSessions = new Map<string, AppSession>()
|
||||
const projectedPrompts = new Map<string, { sessionID: string; content: TimelineContent[] }>()
|
||||
const selections = new Map<string, { agent?: string; model?: { id: string; providerID: string; variant?: string } }>()
|
||||
const sessionAdmissions = new Map<string, Promise<void>>()
|
||||
const projectSession = (input: SessionInfo) => {
|
||||
const result = toSession(input)
|
||||
projectedSessions.set(result.id, result)
|
||||
selections.set(result.id, { agent: input.agent, model: input.model })
|
||||
return result
|
||||
}
|
||||
const loadSession = async (input: LocationInput & { sessionID: string }, options?: RequestOptions) =>
|
||||
projectSession(await client.session.get({ sessionID: input.sessionID }, request(options)))
|
||||
|
||||
return {
|
||||
version: "v2",
|
||||
common: {
|
||||
health: { get: (options) => client.health.get(request(options)) },
|
||||
projects: {
|
||||
current: async (input, options) =>
|
||||
(await client.location.get({ location: location(input) }, request(options))).project,
|
||||
},
|
||||
catalog: {
|
||||
providers: async (input, options) => {
|
||||
const params = { location: location(input) }
|
||||
const [providers, models] = await Promise.all([
|
||||
client.provider.list(params, request(options)),
|
||||
client.model.list(params, request(options)),
|
||||
])
|
||||
return {
|
||||
providers: new Map(
|
||||
providers.data.map((provider) => [
|
||||
provider.id,
|
||||
{
|
||||
id: provider.id,
|
||||
name: provider.name,
|
||||
integrationID: provider.integrationID,
|
||||
models: Object.fromEntries(
|
||||
models.data
|
||||
.filter((model) => model.providerID === provider.id && model.status !== "deprecated")
|
||||
.map((model) => [model.id, toModel(model)]),
|
||||
),
|
||||
},
|
||||
]),
|
||||
),
|
||||
connected: providers.data.filter((provider) => !provider.disabled).map((provider) => provider.id),
|
||||
defaults: {},
|
||||
}
|
||||
},
|
||||
agents: async (input, options) =>
|
||||
(await client.agent.list({ location: location(input) }, request(options))).data.map(toAgent),
|
||||
},
|
||||
commands: {
|
||||
list: async (input, options) =>
|
||||
(await client.command.list({ location: location(input) }, request(options))).data.map((item) => ({
|
||||
name: item.name,
|
||||
description: item.description,
|
||||
})),
|
||||
},
|
||||
references: {
|
||||
list: async (input, options) =>
|
||||
(await client.reference.list({ location: location(input) }, request(options))).data.map((item) => ({
|
||||
name: item.name,
|
||||
path: item.path,
|
||||
description: item.description,
|
||||
hidden: item.hidden,
|
||||
source: item.source,
|
||||
})),
|
||||
},
|
||||
sessions: {
|
||||
list: async (input, options) => {
|
||||
const params = {
|
||||
workspace: input?.location?.workspaceID ?? defaultLocation?.workspaceID,
|
||||
directory: input?.location?.directory ?? defaultLocation?.directory,
|
||||
search: input?.search,
|
||||
}
|
||||
const results = new Map<string, SessionInfo>()
|
||||
const target = input?.limit ?? Number.POSITIVE_INFINITY
|
||||
let cursor = input?.cursor
|
||||
let newer: string | undefined
|
||||
const cursors = new Set<string>()
|
||||
do {
|
||||
if (cursor) {
|
||||
if (cursors.has(cursor)) break
|
||||
cursors.add(cursor)
|
||||
}
|
||||
const result = await client.session.list(
|
||||
{ ...params, limit: input?.roots ? 1 : input?.limit, cursor },
|
||||
request(options),
|
||||
)
|
||||
if (newer === undefined) newer = result.cursor.previous ?? undefined
|
||||
result.data.filter((item) => !input?.roots || !item.parentID).forEach((item) => results.set(item.id, item))
|
||||
cursor = result.cursor.next ?? undefined
|
||||
if (!input?.roots || results.size >= target) break
|
||||
} while (cursor)
|
||||
return {
|
||||
items: [...results.values()].slice(0, target).map(projectSession),
|
||||
newer,
|
||||
older: cursor,
|
||||
}
|
||||
},
|
||||
create: async (input, options) =>
|
||||
projectSession(
|
||||
await client.session.create(
|
||||
{ location: input?.location ?? defaultLocation, agent: input?.agent, model: input?.model },
|
||||
request(options),
|
||||
),
|
||||
),
|
||||
get: loadSession,
|
||||
interrupt: async (input, options) => {
|
||||
await client.session.interrupt({ sessionID: input.sessionID }, request(options))
|
||||
},
|
||||
activity: (_input, options) => client.session.active(request(options)),
|
||||
history: async (input, options) => {
|
||||
const result = await client.message.list(
|
||||
{ sessionID: input.sessionID, limit: input.limit, cursor: input.cursor },
|
||||
request(options),
|
||||
)
|
||||
return {
|
||||
items: result.data.map((item) =>
|
||||
toTimelineItem(item, input.sessionID, undefined, projectedPrompts.get(item.id)?.content),
|
||||
),
|
||||
newer: result.cursor.previous ?? undefined,
|
||||
older: result.cursor.next ?? undefined,
|
||||
}
|
||||
},
|
||||
message: async (input, options) =>
|
||||
toTimelineItem(
|
||||
await client.session.message({ sessionID: input.sessionID, messageID: input.messageID }, request(options)),
|
||||
input.sessionID,
|
||||
undefined,
|
||||
projectedPrompts.get(input.messageID)?.content,
|
||||
),
|
||||
prompt: (input, options) => {
|
||||
const previous = sessionAdmissions.get(input.sessionID) ?? Promise.resolve()
|
||||
const admission = previous.catch(() => undefined).then(async () => {
|
||||
const selected = selections.get(input.sessionID)
|
||||
if (input.selection?.agent && input.selection.agent !== selected?.agent) {
|
||||
await client.session.switchAgent(
|
||||
{ sessionID: input.sessionID, agent: input.selection.agent },
|
||||
request(options),
|
||||
)
|
||||
selections.set(input.sessionID, { ...selected, agent: input.selection.agent })
|
||||
}
|
||||
const current = selections.get(input.sessionID)
|
||||
if (input.selection?.model && !sameModel(input.selection.model, current?.model)) {
|
||||
await client.session.switchModel(
|
||||
{ sessionID: input.sessionID, model: input.selection.model },
|
||||
request(options),
|
||||
)
|
||||
selections.set(input.sessionID, { ...current, model: input.selection.model })
|
||||
}
|
||||
const parts = input.parts
|
||||
if (parts) projectedPrompts.set(input.id, { sessionID: input.sessionID, content: toPromptContent(parts) })
|
||||
const response = await transportConfig.fetch(
|
||||
nativeSessionURL(transportConfig, input.sessionID, "/prompt"),
|
||||
{
|
||||
method: "POST",
|
||||
signal: options?.signal,
|
||||
headers: transportHeaders(transportConfig, false, true),
|
||||
body: JSON.stringify({
|
||||
id: input.id,
|
||||
prompt: {
|
||||
text:
|
||||
parts
|
||||
?.filter((part) => part.type === "text")
|
||||
.map((part) => part.text)
|
||||
.join("") ?? input.text,
|
||||
files:
|
||||
parts
|
||||
?.filter((part) => part.type === "file")
|
||||
.map((file) => ({
|
||||
uri: file.url,
|
||||
mime: file.mime,
|
||||
name: file.filename,
|
||||
source: file.source && {
|
||||
start: file.source.text.start,
|
||||
end: file.source.text.end,
|
||||
text: file.source.text.value,
|
||||
},
|
||||
})) ?? input.files?.map(toPromptFile),
|
||||
agents:
|
||||
parts
|
||||
?.filter((part) => part.type === "agent")
|
||||
.map((agent) => ({
|
||||
name: agent.name,
|
||||
source: agent.source && {
|
||||
start: agent.source.start,
|
||||
end: agent.source.end,
|
||||
text: agent.source.value,
|
||||
},
|
||||
})) ??
|
||||
input.agents?.map((agent) => ({
|
||||
name: agent.name,
|
||||
source:
|
||||
agent.start === undefined || agent.end === undefined || agent.text === undefined
|
||||
? undefined
|
||||
: { start: agent.start, end: agent.end, text: agent.text },
|
||||
})),
|
||||
},
|
||||
delivery: input.delivery,
|
||||
}),
|
||||
},
|
||||
)
|
||||
if (response.ok) return
|
||||
projectedPrompts.delete(input.id)
|
||||
throw new Error(`Failed to submit prompt: ${response.status} ${response.statusText}`)
|
||||
})
|
||||
sessionAdmissions.set(input.sessionID, admission)
|
||||
return admission.finally(() => {
|
||||
if (sessionAdmissions.get(input.sessionID) === admission) sessionAdmissions.delete(input.sessionID)
|
||||
})
|
||||
},
|
||||
},
|
||||
files: {
|
||||
list: async (input, options) =>
|
||||
(await client.file.list({ location: location(input), path: input.path }, request(options))).data.map((item) => ({
|
||||
...item,
|
||||
name: item.path.split(/[\\/]/).at(-1) ?? item.path,
|
||||
absolute: item.path,
|
||||
ignored: false,
|
||||
})),
|
||||
find: async (input, options) =>
|
||||
(
|
||||
await client.file.find(
|
||||
{ location: location(input), query: input.query, type: input.type, limit: input.limit },
|
||||
request(options),
|
||||
)
|
||||
).data,
|
||||
read: async (input, options) => {
|
||||
const response = await transportConfig.fetch(
|
||||
nativeFileURL(transportConfig, input.path, input.location ?? defaultLocation),
|
||||
{ signal: options?.signal, headers: transportHeaders(transportConfig) },
|
||||
)
|
||||
if (!response.ok) throw new Error(`Failed to read file: ${response.status} ${response.statusText}`)
|
||||
const mimeType = response.headers.get("content-type") ?? undefined
|
||||
return {
|
||||
bytes: new Uint8Array(await response.arrayBuffer()),
|
||||
kind: mimeType?.startsWith("text/") ? "text" : "binary",
|
||||
mimeType,
|
||||
}
|
||||
},
|
||||
},
|
||||
permissions: {
|
||||
pending: async (input, options) =>
|
||||
(await client.permission.request.list({ location: location(input) }, request(options))).data.map(toPermission),
|
||||
reply: async (input, options) => {
|
||||
await client.permission.reply(input, request(options))
|
||||
},
|
||||
},
|
||||
questions: {
|
||||
pending: async (input, options) =>
|
||||
(await client.question.request.list({ location: location(input) }, request(options))).data.map(toQuestion),
|
||||
reply: async (input, options) => {
|
||||
await client.question.reply({ ...input, answers: input.answers.map((answer) => [...answer]) }, request(options))
|
||||
},
|
||||
reject: async (input, options) => {
|
||||
await client.question.reject(input, request(options))
|
||||
},
|
||||
},
|
||||
pty: {
|
||||
list: async (input, options) =>
|
||||
(await client.pty.list({ location: location(input) }, request(options))).data.map(toPty),
|
||||
create: async (input, options) =>
|
||||
toPty(
|
||||
(
|
||||
await client.pty.create(
|
||||
{
|
||||
location: location(input),
|
||||
title: input.title,
|
||||
command: input.command,
|
||||
args: input.args,
|
||||
cwd: input.cwd,
|
||||
env: input.env,
|
||||
},
|
||||
request(options),
|
||||
)
|
||||
).data,
|
||||
),
|
||||
get: async (input, options) =>
|
||||
toPty((await client.pty.get({ ptyID: input.ptyID, location: location(input) }, request(options))).data),
|
||||
update: async (input, options) =>
|
||||
toPty(
|
||||
(
|
||||
await client.pty.update(
|
||||
{ ptyID: input.ptyID, location: location(input), title: input.title, size: input.size },
|
||||
request(options),
|
||||
)
|
||||
).data,
|
||||
),
|
||||
remove: async (input, options) => {
|
||||
await client.pty.remove({ ptyID: input.ptyID, location: location(input) }, request(options))
|
||||
},
|
||||
},
|
||||
events: {
|
||||
subscribe: (options) => ({
|
||||
async *[Symbol.asyncIterator]() {
|
||||
for await (const input of eventClient.event.subscribe(request(options))) {
|
||||
yield {
|
||||
location: input.location,
|
||||
event: toEvent(input, projectedContent, projectedItems, projectedSessions, projectedPrompts),
|
||||
}
|
||||
}
|
||||
},
|
||||
}),
|
||||
},
|
||||
},
|
||||
capabilities: {
|
||||
ptyTransport: {
|
||||
connectToken: async (input, options) => {
|
||||
const response = await transportConfig.fetch(
|
||||
nativePtyURL(transportConfig, input.ptyID, "/connect-token", input.location ?? defaultLocation),
|
||||
{
|
||||
method: "POST",
|
||||
signal: options?.signal,
|
||||
headers: transportHeaders(transportConfig, true),
|
||||
},
|
||||
)
|
||||
const body = response.ok ? ((await response.json()) as { data?: { ticket?: string } }) : undefined
|
||||
return { status: response.status, ticket: body?.data?.ticket }
|
||||
},
|
||||
exists: async (input, options) => {
|
||||
const response = await transportConfig.fetch(
|
||||
nativePtyURL(transportConfig, input.ptyID, "", input.location ?? defaultLocation),
|
||||
{ signal: options?.signal, headers: transportHeaders(transportConfig) },
|
||||
)
|
||||
return response.status !== 404
|
||||
},
|
||||
connectURL: (input) => {
|
||||
const url = nativePtyURL(transportConfig, input.ptyID, "/connect", input.location)
|
||||
url.searchParams.set("cursor", String(input.cursor))
|
||||
url.protocol = url.protocol === "https:" ? "wss:" : "ws:"
|
||||
if (input.ticket) {
|
||||
url.searchParams.set("ticket", input.ticket)
|
||||
return url
|
||||
}
|
||||
if (!transportConfig.sameOrigin || transportConfig.password || transportConfig.authToken)
|
||||
throw new Error("Native PTY connections require a ticket for cross-origin or authenticated access")
|
||||
return url
|
||||
},
|
||||
},
|
||||
integrationsV2: {
|
||||
list: async (input, options) =>
|
||||
(await client.integration.list({ location: location(input) }, request(options))).data.map(toIntegration),
|
||||
get: async (input, options) => {
|
||||
const result = await client.integration.get(
|
||||
{ integrationID: input.integrationID, location: location(input) },
|
||||
request(options),
|
||||
)
|
||||
return result.data ? toIntegration(result.data) : null
|
||||
},
|
||||
connectKey: async (input, options) => {
|
||||
await client.integration.connect.key({ ...input, location: location(input) }, request(options))
|
||||
},
|
||||
connectOauth: async (input, options) =>
|
||||
toIntegrationAttempt(
|
||||
(
|
||||
await client.integration.connect.oauth(
|
||||
{
|
||||
integrationID: input.integrationID,
|
||||
methodID: input.methodID,
|
||||
inputs: input.values,
|
||||
label: input.label,
|
||||
location: location(input),
|
||||
},
|
||||
request(options),
|
||||
)
|
||||
).data,
|
||||
),
|
||||
attemptStatus: async (input, options) =>
|
||||
toAttemptStatus(
|
||||
(
|
||||
await client.integration.attempt.status(
|
||||
{ attemptID: input.attemptID, location: location(input) },
|
||||
request(options),
|
||||
)
|
||||
).data,
|
||||
),
|
||||
completeAttempt: async (input, options) => {
|
||||
await client.integration.attempt.complete({ ...input, location: location(input) }, request(options))
|
||||
},
|
||||
cancelAttempt: async (input, options) => {
|
||||
await client.integration.attempt.cancel({ ...input, location: location(input) }, request(options))
|
||||
},
|
||||
renameCredential: async (input, options) => {
|
||||
await client.credential.update({ ...input, location: location(input) }, request(options))
|
||||
},
|
||||
removeCredential: async (input, options) => {
|
||||
await client.credential.remove({ ...input, location: location(input) }, request(options))
|
||||
},
|
||||
},
|
||||
sessionExtrasV2: {
|
||||
switchAgent: async (input, options) => {
|
||||
await client.session.switchAgent(input, request(options))
|
||||
selections.set(input.sessionID, { ...selections.get(input.sessionID), agent: input.agent })
|
||||
},
|
||||
switchModel: async (input, options) => {
|
||||
await client.session.switchModel(input, request(options))
|
||||
selections.set(input.sessionID, { ...selections.get(input.sessionID), model: input.model })
|
||||
},
|
||||
wait: async (input, options) => {
|
||||
await client.session.wait(input, request(options))
|
||||
},
|
||||
context: async (input, options) =>
|
||||
(await client.session.context(input, request(options))).map((item) => toTimelineItem(item, input.sessionID)),
|
||||
log: (input, options) => ({
|
||||
async *[Symbol.asyncIterator]() {
|
||||
for await (const item of nativeSessionEvents(transportConfig, input.sessionID, input.after, options)) {
|
||||
yield {
|
||||
sequence: eventSequence(item),
|
||||
event: toEvent(item, projectedContent, projectedItems, projectedSessions, projectedPrompts),
|
||||
} satisfies SessionLogItem
|
||||
}
|
||||
},
|
||||
}),
|
||||
stageRevert: async (input, options) => {
|
||||
const result = await client.session.revert.stage(
|
||||
{ sessionID: input.sessionID, messageID: input.messageID, files: input.files?.length ? true : undefined },
|
||||
request(options),
|
||||
)
|
||||
return { messageID: result.messageID }
|
||||
},
|
||||
clearRevert: async (input, options) => {
|
||||
await client.session.revert.clear(input, request(options))
|
||||
},
|
||||
commitRevert: async (input, options) => {
|
||||
await client.session.revert.commit(input, request(options))
|
||||
},
|
||||
},
|
||||
projectCopiesV2: {
|
||||
create: (input, options) => client.projectCopy.create({ ...input, location: location(input) }, request(options)),
|
||||
remove: async (input, options) => {
|
||||
await client.projectCopy.remove({ ...input, location: location(input) }, request(options))
|
||||
},
|
||||
refresh: async (input, options) => {
|
||||
await client.projectCopy.refresh({ ...input, location: location(input) }, request(options))
|
||||
},
|
||||
},
|
||||
savedPermissionsV2: {
|
||||
list: (input, options) => client.permission.saved.list(input, request(options)),
|
||||
remove: async (input, options) => {
|
||||
await client.permission.saved.remove(input, request(options))
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function toLocation(input?: LocationRef) {
|
||||
if (!input) return
|
||||
return { directory: input.directory, workspace: input.workspaceID }
|
||||
}
|
||||
|
||||
function sameModel(
|
||||
left: { id: string; providerID: string; variant?: string },
|
||||
right?: { id: string; providerID: string; variant?: string },
|
||||
) {
|
||||
return left.id === right?.id && left.providerID === right.providerID && left.variant === right.variant
|
||||
}
|
||||
|
||||
function nativeSessionURL(config: PtyTransportConfig, sessionID: string, suffix: string) {
|
||||
return new URL(`${config.baseUrl.replace(/\/+$/, "")}/api/session/${encodeURIComponent(sessionID)}${suffix}`)
|
||||
}
|
||||
|
||||
function nativePtyURL(config: PtyTransportConfig, ptyID: string, suffix: string, location?: LocationRef) {
|
||||
const url = new URL(`${config.baseUrl.replace(/\/+$/, "")}/api/pty/${encodeURIComponent(ptyID)}${suffix}`)
|
||||
if (location?.directory) url.searchParams.set("location[directory]", location.directory)
|
||||
if (location?.workspaceID) url.searchParams.set("location[workspace]", location.workspaceID)
|
||||
return url
|
||||
}
|
||||
|
||||
function nativeFileURL(config: PtyTransportConfig, path: string, location?: LocationRef) {
|
||||
const url = new URL(`${config.baseUrl.replace(/\/+$/, "")}/api/fs/read/${encodeURIComponent(path)}`)
|
||||
if (location?.directory) url.searchParams.set("location[directory]", location.directory)
|
||||
if (location?.workspaceID) url.searchParams.set("location[workspace]", location.workspaceID)
|
||||
return url
|
||||
}
|
||||
|
||||
function transportHeaders(config: PtyTransportConfig, ticket = false, json = false) {
|
||||
const headers = new Headers()
|
||||
if (config.password)
|
||||
headers.set("authorization", `Basic ${btoa(`${config.username ?? "opencode"}:${config.password}`)}`)
|
||||
if (ticket) headers.set("x-opencode-ticket", "1")
|
||||
if (json) headers.set("content-type", "application/json")
|
||||
return headers
|
||||
}
|
||||
|
||||
function toProject(input: Project): AppProject {
|
||||
return {
|
||||
id: input.id,
|
||||
worktree: input.worktree,
|
||||
vcs: input.vcs === "git" ? "git" : undefined,
|
||||
time: input.time,
|
||||
name: input.name,
|
||||
icon: input.icon,
|
||||
commands: input.commands,
|
||||
sandboxes: input.sandboxes,
|
||||
}
|
||||
}
|
||||
|
||||
function toSession(input: SessionInfo): AppSession {
|
||||
return {
|
||||
id: input.id,
|
||||
slug: input.id,
|
||||
version: "",
|
||||
parentID: input.parentID,
|
||||
projectID: input.projectID,
|
||||
location: input.location,
|
||||
directory: input.location.directory,
|
||||
workspaceID: input.location.workspaceID,
|
||||
title: input.title,
|
||||
cost: input.cost,
|
||||
tokens: input.tokens,
|
||||
time: input.time,
|
||||
revert: input.revert && { messageID: input.revert.messageID },
|
||||
}
|
||||
}
|
||||
|
||||
function toModel(input: ModelInfo): AppModel {
|
||||
const cost = input.cost[0]
|
||||
return {
|
||||
id: input.id,
|
||||
providerID: input.providerID,
|
||||
name: input.name,
|
||||
family: input.family,
|
||||
releaseDate: new Date(input.time.released).toISOString().slice(0, 10),
|
||||
cost: cost && { input: cost.input, output: cost.output, cacheRead: cost.cache.read, cacheWrite: cost.cache.write },
|
||||
capabilities: {
|
||||
reasoning: input.capabilities.output.includes("reasoning"),
|
||||
input: {
|
||||
text: input.capabilities.input.includes("text"),
|
||||
image: input.capabilities.input.includes("image"),
|
||||
audio: input.capabilities.input.includes("audio"),
|
||||
video: input.capabilities.input.includes("video"),
|
||||
pdf: input.capabilities.input.includes("pdf"),
|
||||
},
|
||||
},
|
||||
limit: { context: input.limit.context, output: input.limit.output },
|
||||
variants: Object.fromEntries(input.variants.map((variant) => [variant.id, variant])),
|
||||
}
|
||||
}
|
||||
|
||||
function toAgent(input: AgentInfo): AppAgent {
|
||||
return {
|
||||
id: input.id,
|
||||
name: input.name,
|
||||
description: input.description,
|
||||
mode: input.mode,
|
||||
hidden: input.hidden,
|
||||
color: input.color,
|
||||
model: input.model,
|
||||
}
|
||||
}
|
||||
|
||||
function toTimelineItem(
|
||||
input: SessionMessageInfo,
|
||||
sessionID: string,
|
||||
parentID?: string,
|
||||
projectedPrompt?: TimelineContent[],
|
||||
): TimelineItem {
|
||||
if (input.type === "user") {
|
||||
return {
|
||||
type: "user",
|
||||
id: input.id,
|
||||
sessionID,
|
||||
created: input.time.created,
|
||||
content: projectedPrompt ?? [
|
||||
{ type: "text", id: `${input.id}:text`, text: input.text },
|
||||
...(input.files?.map((file, index) => ({
|
||||
type: "file" as const,
|
||||
id: `${input.id}:file:${index}`,
|
||||
uri: file.source.type === "uri" ? file.source.uri : `data:${file.mime};base64,${file.data}`,
|
||||
name: file.name,
|
||||
mime: file.mime,
|
||||
})) ?? []),
|
||||
...(input.agents?.map((agent, index) => ({
|
||||
type: "agent" as const,
|
||||
id: `${input.id}:agent:${index}`,
|
||||
name: agent.name,
|
||||
source: agent.mention,
|
||||
})) ?? []),
|
||||
],
|
||||
}
|
||||
}
|
||||
if (input.type === "assistant") return toAssistant(input, sessionID, parentID)
|
||||
const type =
|
||||
input.type === "agent-switched"
|
||||
? "agent-switch"
|
||||
: input.type === "model-switched"
|
||||
? "model-switch"
|
||||
: input.type
|
||||
return { type, id: input.id, sessionID, created: input.time.created }
|
||||
}
|
||||
|
||||
function toAssistant(input: SessionMessageAssistant, sessionID: string, parentID?: string): TimelineItem {
|
||||
return {
|
||||
type: "assistant",
|
||||
id: input.id,
|
||||
sessionID,
|
||||
parentID,
|
||||
created: input.time.created,
|
||||
completed: input.time.completed,
|
||||
content: input.content.flatMap((item, index): TimelineContent[] => {
|
||||
if (item.type === "text") return [{ type: "text", id: `${input.id}:text:${index}`, text: item.text }]
|
||||
if (item.type === "reasoning")
|
||||
return [{ type: "reasoning", id: `${input.id}:reasoning:${index}`, text: item.text }]
|
||||
if (item.state.status === "streaming")
|
||||
return [
|
||||
{
|
||||
type: "tool",
|
||||
id: item.id,
|
||||
tool: item.name,
|
||||
state: { status: "pending", input: {}, raw: item.state.input },
|
||||
},
|
||||
]
|
||||
if (item.state.status === "running")
|
||||
return [
|
||||
{
|
||||
type: "tool",
|
||||
id: item.id,
|
||||
tool: item.name,
|
||||
state: {
|
||||
status: "running",
|
||||
input: item.state.input,
|
||||
metadata: item.state.structured,
|
||||
time: { start: input.time.created },
|
||||
},
|
||||
},
|
||||
]
|
||||
if (item.state.status === "error")
|
||||
return [
|
||||
{
|
||||
type: "tool",
|
||||
id: item.id,
|
||||
tool: item.name,
|
||||
state: {
|
||||
status: "error",
|
||||
input: item.state.input,
|
||||
error: item.state.error.message,
|
||||
metadata: item.state.structured,
|
||||
time: { start: input.time.created, end: input.time.completed ?? input.time.created },
|
||||
},
|
||||
},
|
||||
]
|
||||
return [
|
||||
{
|
||||
type: "tool",
|
||||
id: item.id,
|
||||
tool: item.name,
|
||||
state: {
|
||||
status: "completed",
|
||||
input: item.state.input,
|
||||
output: item.state.content
|
||||
.map((content) => (content.type === "text" ? content.text : content.uri))
|
||||
.join("\n"),
|
||||
title: "",
|
||||
metadata: item.state.structured,
|
||||
time: { start: input.time.created, end: input.time.completed ?? input.time.created },
|
||||
},
|
||||
},
|
||||
]
|
||||
}),
|
||||
agent: input.agent,
|
||||
model: input.model,
|
||||
tokens: input.tokens,
|
||||
error: input.error && { name: "UnknownError", data: { message: input.error.message } },
|
||||
}
|
||||
}
|
||||
|
||||
function toPromptFile(input: PromptFile) {
|
||||
return {
|
||||
uri: input.uri,
|
||||
mime: input.mime ?? "application/octet-stream",
|
||||
name: input.name,
|
||||
source: input.source && { start: input.source.start, end: input.source.end, text: input.source.text },
|
||||
}
|
||||
}
|
||||
|
||||
function toPromptContent(parts: NonNullable<PromptInput["parts"]>): TimelineContent[] {
|
||||
return parts.map((part) => {
|
||||
if (part.type === "text") return { ...part }
|
||||
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 },
|
||||
}
|
||||
return {
|
||||
type: part.type,
|
||||
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 toPermission(input: PermissionV2Request): AppPermissionRequest {
|
||||
return {
|
||||
id: input.id,
|
||||
sessionID: input.sessionID,
|
||||
action: input.action,
|
||||
resources: input.resources,
|
||||
permission: input.action,
|
||||
patterns: [...input.resources],
|
||||
always: [],
|
||||
metadata: input.metadata ? { ...input.metadata } : {},
|
||||
}
|
||||
}
|
||||
|
||||
function toQuestion(input: QuestionV2Request): AppQuestionRequest {
|
||||
return {
|
||||
id: input.id,
|
||||
sessionID: input.sessionID,
|
||||
questions: input.questions.map((question) => ({
|
||||
...question,
|
||||
header: question.header ?? "",
|
||||
options: question.options.map((option) => ({ ...option, description: option.description ?? "" })),
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
function toFileDiff(input: { file: string; patch: string; additions: number; deletions: number; status: "added" | "deleted" | "modified" }): AppFileDiff {
|
||||
return input
|
||||
}
|
||||
|
||||
function toMcpServer(input: McpServer): AppMcpServer {
|
||||
return { name: input.name, status: input.status, integrationID: input.integrationID }
|
||||
}
|
||||
|
||||
function toPty(input: { id: string; title: string }) {
|
||||
return { id: input.id, title: input.title }
|
||||
}
|
||||
|
||||
function toIntegration(input: IntegrationInfo) {
|
||||
return {
|
||||
id: input.id,
|
||||
name: input.name,
|
||||
methods: input.methods.map((method): IntegrationMethod => {
|
||||
if (method.type === "oauth") return method
|
||||
if (method.type === "key") return { type: "key", label: method.label ?? "API key" }
|
||||
return { type: "environment", label: method.names.join(", ") }
|
||||
}),
|
||||
connections: input.connections.map((connection): IntegrationConnection =>
|
||||
connection.type === "credential"
|
||||
? { id: connection.id, label: connection.label, kind: "credential" }
|
||||
: { id: connection.name, label: connection.name, kind: "environment" },
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
function toIntegrationAttempt(input: {
|
||||
attemptID: string
|
||||
url: string
|
||||
instructions: string
|
||||
mode: "auto" | "code"
|
||||
time: { created: number | string; expires: number | string }
|
||||
}): IntegrationAttempt {
|
||||
return {
|
||||
...input,
|
||||
time: { created: Number(input.time.created), expires: Number(input.time.expires) },
|
||||
}
|
||||
}
|
||||
|
||||
function toAttemptStatus(input: import("@opencode-ai/client").IntegrationAttemptStatus): IntegrationAttemptStatus {
|
||||
if (input.status === "failed") return { status: input.status, error: input.message }
|
||||
return { status: input.status }
|
||||
}
|
||||
|
||||
function toPending(input: SessionPendingInfo): PendingSessionInput {
|
||||
return {
|
||||
id: input.id,
|
||||
sessionID: input.sessionID,
|
||||
sequence: input.admittedSeq,
|
||||
created: input.timeCreated,
|
||||
delivery: "delivery" in input ? input.delivery : undefined,
|
||||
raw: input,
|
||||
}
|
||||
}
|
||||
|
||||
function toForm(input: FormInfo) {
|
||||
return {
|
||||
id: input.id,
|
||||
sessionID: input.sessionID,
|
||||
title: input.title,
|
||||
metadata: input.metadata,
|
||||
fields: input.fields.map((field): FormField => {
|
||||
if (field.type === "external")
|
||||
return {
|
||||
type: field.type,
|
||||
key: field.key,
|
||||
label: field.title ?? field.key,
|
||||
url: field.url,
|
||||
description: field.description,
|
||||
}
|
||||
if (field.type === "multiselect")
|
||||
return {
|
||||
type: field.type,
|
||||
key: field.key,
|
||||
label: field.title ?? field.key,
|
||||
options: field.options.map((option) => option.value),
|
||||
required: field.required,
|
||||
description: field.description,
|
||||
}
|
||||
return {
|
||||
type: field.type,
|
||||
key: field.key,
|
||||
label: field.title ?? field.key,
|
||||
required: field.required,
|
||||
description: field.description,
|
||||
}
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
function fromFormField(input: FormField): FormCreateInput["fields"][number] {
|
||||
if (input.type === "external")
|
||||
return { type: input.type, key: input.key, title: input.label, url: input.url, description: input.description }
|
||||
if (input.type === "multiselect")
|
||||
return {
|
||||
type: input.type,
|
||||
key: input.key,
|
||||
title: input.label,
|
||||
options: input.options.map((option) => ({ value: option, label: option })),
|
||||
required: input.required,
|
||||
description: input.description,
|
||||
}
|
||||
return {
|
||||
type: input.type,
|
||||
key: input.key,
|
||||
title: input.label,
|
||||
required: input.type === "boolean" ? undefined : input.required,
|
||||
description: input.description,
|
||||
}
|
||||
}
|
||||
|
||||
function toClientRecord(input: Readonly<Record<string, import("./backend").JsonValue>>) {
|
||||
return Object.fromEntries(Object.entries(input).map(([key, value]) => [key, toClientJson(value)]))
|
||||
}
|
||||
|
||||
function toClientJson(input: import("./backend").JsonValue): import("@opencode-ai/client").JsonValue {
|
||||
if (isJsonArray(input)) return input.map(toClientJson)
|
||||
if (input && typeof input === "object") return toClientRecord(input)
|
||||
return input
|
||||
}
|
||||
|
||||
function isJsonArray(input: import("./backend").JsonValue): input is readonly import("./backend").JsonValue[] {
|
||||
return Array.isArray(input)
|
||||
}
|
||||
|
||||
function toShell(input: ShellInfo1): ShellProcess {
|
||||
return {
|
||||
id: input.id,
|
||||
command: input.command,
|
||||
cwd: input.cwd,
|
||||
status: input.status === "running" ? "running" : "exited",
|
||||
created: input.time.started,
|
||||
exitCode: input.exit,
|
||||
metadata: input.metadata,
|
||||
}
|
||||
}
|
||||
|
||||
type NativeEventData = {
|
||||
sessionID: string
|
||||
assistantMessageID: string
|
||||
messageID: string
|
||||
partID: string
|
||||
requestID: string
|
||||
textID: string
|
||||
reasoningID: string
|
||||
callID: string
|
||||
timestamp: number
|
||||
agent: string
|
||||
model: { id: string; providerID: string; variant?: string }
|
||||
finish: string
|
||||
cost: number
|
||||
tokens: import("./backend").TokenUsage
|
||||
error: { message: string }
|
||||
location: LocationRef
|
||||
revert: { messageID: string }
|
||||
prompt: { text: string; files?: { uri: string; name?: string; mime?: string }[]; agents?: { name: string; source?: import("./backend").SourceText }[] }
|
||||
text: string
|
||||
delta: string
|
||||
field: string
|
||||
name: string
|
||||
tool: string
|
||||
input: Record<string, unknown>
|
||||
structured: Record<string, unknown>
|
||||
content: import("./backend").ToolOutputContent[]
|
||||
outputPaths: string[]
|
||||
result: unknown
|
||||
provider: import("./backend").ToolProviderInfo
|
||||
providerMetadata: Record<string, unknown>
|
||||
info: unknown
|
||||
todos: { content: string; status: string; priority: string }[]
|
||||
diff: { file?: string; patch?: string; additions: number; deletions: number; status?: "added" | "deleted" | "modified" }[]
|
||||
}
|
||||
|
||||
function toEvent(
|
||||
input: OpenCodeEvent,
|
||||
projectedContent: Map<string, TimelineContent>,
|
||||
projectedItems: Map<string, TimelineItem>,
|
||||
projectedSessions: Map<string, AppSession>,
|
||||
projectedPrompts: Map<string, { sessionID: string; content: TimelineContent[] }>,
|
||||
): AppEvent {
|
||||
const type = input.type as string
|
||||
const data = input.data as unknown as NativeEventData
|
||||
if (type === "server.connected") {
|
||||
projectedContent.clear()
|
||||
projectedItems.clear()
|
||||
projectedSessions.clear()
|
||||
projectedPrompts.clear()
|
||||
return { type: "server.connected" }
|
||||
}
|
||||
if (type === "session.created" || type === "session.updated") {
|
||||
const session = eventSession(data.info)
|
||||
if (!session) return { type: "unknown", raw: input }
|
||||
projectedSessions.set(session.id, session)
|
||||
return { type: type === "session.created" ? "session.created" : "session.updated", session }
|
||||
}
|
||||
if (type === "session.deleted") {
|
||||
projectedSessions.delete(data.sessionID)
|
||||
clearSessionProjection(data.sessionID, projectedContent, projectedItems)
|
||||
clearSessionPrompts(data.sessionID, projectedPrompts)
|
||||
return { type: "session.deleted", sessionID: data.sessionID }
|
||||
}
|
||||
if (type === "session.next.moved") {
|
||||
const current = projectedSessions.get(data.sessionID)
|
||||
if (current)
|
||||
projectedSessions.set(current.id, {
|
||||
...current,
|
||||
location: data.location,
|
||||
directory: data.location.directory,
|
||||
workspaceID: data.location.workspaceID,
|
||||
})
|
||||
return { type: "session.moved", sessionID: data.sessionID, location: data.location }
|
||||
}
|
||||
if (type === "session.next.revert.staged") {
|
||||
const revert = { messageID: data.revert.messageID }
|
||||
const current = projectedSessions.get(data.sessionID)
|
||||
if (current) projectedSessions.set(current.id, { ...current, revert })
|
||||
return { type: "session.revert", sessionID: data.sessionID, revert }
|
||||
}
|
||||
if (type === "session.next.revert.cleared" || type === "session.next.revert.committed") {
|
||||
const current = projectedSessions.get(data.sessionID)
|
||||
if (current) projectedSessions.set(current.id, { ...current, revert: undefined })
|
||||
return { type: "session.revert", sessionID: data.sessionID }
|
||||
}
|
||||
if (
|
||||
type === "integration.updated" ||
|
||||
type === "integration.connection.updated" ||
|
||||
type === "catalog.updated" ||
|
||||
type === "models-dev.refreshed"
|
||||
)
|
||||
return { type: "provider.updated" }
|
||||
if (type === "permission.v2.asked")
|
||||
return { type: "permission.requested", request: toPermission(input.data as PermissionV2Request) }
|
||||
if (type === "permission.v2.replied")
|
||||
return { type: "permission.replied", sessionID: data.sessionID, requestID: data.requestID }
|
||||
if (type === "question.v2.asked")
|
||||
return { type: "question.requested", request: toQuestion(input.data as QuestionV2Request) }
|
||||
if (type === "question.v2.replied" || type === "question.v2.rejected")
|
||||
return {
|
||||
type: type === "question.v2.replied" ? "question.replied" : "question.rejected",
|
||||
sessionID: data.sessionID,
|
||||
requestID: data.requestID,
|
||||
}
|
||||
if (type === "file.watcher.updated" || type === "filesystem.changed") {
|
||||
const changed = input.data as unknown as { file: string; event: "add" | "change" | "unlink" }
|
||||
return { type: "file.changed", path: changed.file, change: changed.event }
|
||||
}
|
||||
if (type === "pty.exited") return { type: "pty.exited", ptyID: (input.data as unknown as { id: string }).id }
|
||||
if (type === "message.removed")
|
||||
return { type: "timeline.removed", sessionID: data.sessionID, itemID: data.messageID }
|
||||
if (type === "session.next.prompted" || type === "session.next.prompt.admitted")
|
||||
return {
|
||||
type: "timeline.updated",
|
||||
item: {
|
||||
type: "user",
|
||||
id: data.messageID,
|
||||
sessionID: data.sessionID,
|
||||
created: data.timestamp,
|
||||
content: projectedPrompts.get(data.messageID)?.content ?? [
|
||||
{ type: "text", id: `${data.messageID}:text`, text: data.prompt.text },
|
||||
...(data.prompt.files?.map((file, index) => ({
|
||||
type: "file" as const,
|
||||
id: `${data.messageID}:file:${index}`,
|
||||
uri: file.uri,
|
||||
name: file.name,
|
||||
mime: file.mime,
|
||||
})) ?? []),
|
||||
...(data.prompt.agents?.map((agent, index) => ({
|
||||
type: "agent" as const,
|
||||
id: `${data.messageID}:agent:${index}`,
|
||||
name: agent.name,
|
||||
source: agent.source,
|
||||
})) ?? []),
|
||||
],
|
||||
},
|
||||
}
|
||||
if (type === "session.next.step.started") {
|
||||
const item = {
|
||||
type: "assistant" as const,
|
||||
id: data.assistantMessageID,
|
||||
sessionID: data.sessionID,
|
||||
created: data.timestamp,
|
||||
content: [] as TimelineContent[],
|
||||
agent: data.agent,
|
||||
model: data.model,
|
||||
}
|
||||
projectedItems.set(item.id, item)
|
||||
return { type: "session.activity", sessionID: data.sessionID, activity: { type: "running" }, item }
|
||||
}
|
||||
if (type === "session.next.step.ended") {
|
||||
const current = projectedItems.get(data.assistantMessageID)
|
||||
const item =
|
||||
current?.type === "assistant"
|
||||
? {
|
||||
...current,
|
||||
completed: data.timestamp,
|
||||
finish: data.finish,
|
||||
cost: data.cost,
|
||||
tokens: data.tokens,
|
||||
content: projectedMessageContent(data.assistantMessageID, projectedContent),
|
||||
}
|
||||
: undefined
|
||||
projectedItems.delete(data.assistantMessageID)
|
||||
clearMessageContent(data.assistantMessageID, projectedContent)
|
||||
clearSessionPrompts(data.sessionID, projectedPrompts)
|
||||
return { type: "session.activity", sessionID: data.sessionID, activity: { type: "idle" }, item }
|
||||
}
|
||||
if (type === "session.next.step.failed") {
|
||||
const current = projectedItems.get(data.assistantMessageID)
|
||||
const item =
|
||||
current?.type === "assistant"
|
||||
? {
|
||||
...current,
|
||||
completed: data.timestamp,
|
||||
error: { name: "UnknownError" as const, data: { message: data.error.message } },
|
||||
content: projectedMessageContent(data.assistantMessageID, projectedContent),
|
||||
}
|
||||
: undefined
|
||||
projectedItems.delete(data.assistantMessageID)
|
||||
clearMessageContent(data.assistantMessageID, projectedContent)
|
||||
clearSessionPrompts(data.sessionID, projectedPrompts)
|
||||
return { type: "session.activity", sessionID: data.sessionID, activity: { type: "idle" }, item }
|
||||
}
|
||||
if (type === "session.next.text.started" || type === "session.next.text.ended") {
|
||||
const content = {
|
||||
type: "text" as const,
|
||||
id: data.textID,
|
||||
text: type === "session.next.text.ended" ? data.text : "",
|
||||
}
|
||||
projectedContent.set(`${data.assistantMessageID}:${content.id}`, content)
|
||||
return {
|
||||
type: "timeline.content.updated",
|
||||
sessionID: data.sessionID,
|
||||
itemID: data.assistantMessageID,
|
||||
content,
|
||||
}
|
||||
}
|
||||
if (type === "session.next.reasoning.started" || type === "session.next.reasoning.ended") {
|
||||
const content = {
|
||||
type: "reasoning" as const,
|
||||
id: data.reasoningID,
|
||||
text: type === "session.next.reasoning.ended" ? data.text : "",
|
||||
metadata: data.providerMetadata,
|
||||
time: { start: data.timestamp, end: type === "session.next.reasoning.ended" ? data.timestamp : undefined },
|
||||
}
|
||||
projectedContent.set(`${data.assistantMessageID}:${content.id}`, content)
|
||||
return {
|
||||
type: "timeline.content.updated",
|
||||
sessionID: data.sessionID,
|
||||
itemID: data.assistantMessageID,
|
||||
content,
|
||||
}
|
||||
}
|
||||
if (
|
||||
type === "session.next.tool.input.started" ||
|
||||
type === "session.next.tool.input.ended" ||
|
||||
type === "session.next.tool.called" ||
|
||||
type === "session.next.tool.progress" ||
|
||||
type === "session.next.tool.success" ||
|
||||
type === "session.next.tool.failed"
|
||||
) {
|
||||
const key = `${data.assistantMessageID}:${data.callID}`
|
||||
const previous = projectedContent.get(key)
|
||||
const tool = previous?.type === "tool" ? previous.tool : data.name ?? data.tool
|
||||
const priorInput = previous?.type === "tool" ? previous.state.input : {}
|
||||
const state = (() => {
|
||||
if (type === "session.next.tool.input.started")
|
||||
return { status: "pending" as const, input: {}, raw: "" }
|
||||
if (type === "session.next.tool.input.ended")
|
||||
return { status: "pending" as const, input: {}, raw: data.text }
|
||||
if (type === "session.next.tool.called")
|
||||
return {
|
||||
status: "running" as const,
|
||||
input: data.input,
|
||||
time: { start: data.timestamp },
|
||||
provider: data.provider,
|
||||
}
|
||||
if (type === "session.next.tool.progress")
|
||||
return {
|
||||
status: "running" as const,
|
||||
input: priorInput,
|
||||
metadata: data.structured,
|
||||
time: { start: data.timestamp },
|
||||
content: data.content,
|
||||
}
|
||||
if (type === "session.next.tool.failed")
|
||||
return {
|
||||
status: "error" as const,
|
||||
input: priorInput,
|
||||
error: data.error.message,
|
||||
time: { start: data.timestamp, end: data.timestamp },
|
||||
result: data.result,
|
||||
provider: data.provider,
|
||||
}
|
||||
return {
|
||||
status: "completed" as const,
|
||||
input: priorInput,
|
||||
output: data.content.map((content) => (content.type === "text" ? content.text : content.uri)).join("\n"),
|
||||
title: "",
|
||||
metadata: data.structured,
|
||||
time: { start: data.timestamp, end: data.timestamp },
|
||||
content: data.content,
|
||||
outputPaths: data.outputPaths,
|
||||
result: data.result,
|
||||
provider: data.provider,
|
||||
attachments: data.content.flatMap((content, index) =>
|
||||
content.type === "file"
|
||||
? [
|
||||
{
|
||||
id: `${data.callID}:attachment:${index}`,
|
||||
sessionID: data.sessionID,
|
||||
messageID: data.assistantMessageID,
|
||||
type: "file" as const,
|
||||
mime: content.mime,
|
||||
filename: content.name,
|
||||
url: content.uri,
|
||||
},
|
||||
]
|
||||
: [],
|
||||
),
|
||||
}
|
||||
})()
|
||||
const content = { type: "tool" as const, id: data.callID, callID: data.callID, tool, state }
|
||||
projectedContent.set(key, content)
|
||||
return {
|
||||
type: "timeline.content.updated",
|
||||
sessionID: data.sessionID,
|
||||
itemID: data.assistantMessageID,
|
||||
content,
|
||||
}
|
||||
}
|
||||
if (type === "todo.updated")
|
||||
return {
|
||||
type: "todo.updated",
|
||||
sessionID: data.sessionID,
|
||||
todos: data.todos.map((todo) => ({
|
||||
content: todo.content,
|
||||
status: todo.status,
|
||||
priority: todo.priority,
|
||||
})),
|
||||
}
|
||||
if (type === "reference.updated") return { type: "reference.updated" }
|
||||
if (type === "session.diff")
|
||||
return {
|
||||
type: "session.diff",
|
||||
sessionID: data.sessionID,
|
||||
diff: data.diff.flatMap((item) => (item.file ? [{ ...item, file: item.file }] : [])),
|
||||
}
|
||||
if (type === "message.part.removed") {
|
||||
projectedContent.delete(`${data.messageID}:${data.partID}`)
|
||||
return {
|
||||
type: "timeline.part.removed",
|
||||
sessionID: data.sessionID,
|
||||
itemID: data.messageID,
|
||||
contentID: data.partID,
|
||||
}
|
||||
}
|
||||
if (type === "message.part.delta")
|
||||
return projectDelta(
|
||||
{
|
||||
type: "timeline.delta",
|
||||
sessionID: data.sessionID,
|
||||
itemID: data.messageID,
|
||||
contentID: data.partID,
|
||||
field: data.field,
|
||||
delta: data.delta,
|
||||
},
|
||||
projectedContent,
|
||||
)
|
||||
if (type === "session.next.text.delta" || type === "session.next.reasoning.delta")
|
||||
return projectDelta(
|
||||
{
|
||||
type: "timeline.delta",
|
||||
sessionID: data.sessionID,
|
||||
itemID: data.assistantMessageID,
|
||||
contentID: type === "session.next.text.delta" ? data.textID : data.reasoningID,
|
||||
field: "text",
|
||||
delta: data.delta,
|
||||
},
|
||||
projectedContent,
|
||||
)
|
||||
return { type: "unknown", raw: input }
|
||||
}
|
||||
|
||||
function clearSessionPrompts(
|
||||
sessionID: string,
|
||||
projected: Map<string, { sessionID: string; content: TimelineContent[] }>,
|
||||
) {
|
||||
for (const [messageID, prompt] of projected) if (prompt.sessionID === sessionID) projected.delete(messageID)
|
||||
}
|
||||
|
||||
function projectDelta(event: Extract<AppEvent, { type: "timeline.delta" }>, projected: Map<string, TimelineContent>) {
|
||||
const key = `${event.itemID}:${event.contentID}`
|
||||
const content = projected.get(key)
|
||||
if (content && event.field === "text" && "text" in content)
|
||||
projected.set(key, { ...content, text: content.text + event.delta })
|
||||
return event
|
||||
}
|
||||
|
||||
function projectedMessageContent(messageID: string, projected: Map<string, TimelineContent>) {
|
||||
return [...projected.entries()].filter(([key]) => key.startsWith(`${messageID}:`)).map(([, content]) => content)
|
||||
}
|
||||
|
||||
function clearMessageContent(messageID: string, projected: Map<string, TimelineContent>) {
|
||||
for (const key of projected.keys()) if (key.startsWith(`${messageID}:`)) projected.delete(key)
|
||||
}
|
||||
|
||||
function clearSessionProjection(
|
||||
sessionID: string,
|
||||
projectedContent: Map<string, TimelineContent>,
|
||||
projectedItems: Map<string, TimelineItem>,
|
||||
) {
|
||||
const messages = [...projectedItems.values()].filter((item) => item.sessionID === sessionID).map((item) => item.id)
|
||||
messages.forEach((messageID) => clearMessageContent(messageID, projectedContent))
|
||||
for (const [id, item] of projectedItems) if (item.sessionID === sessionID) projectedItems.delete(id)
|
||||
}
|
||||
|
||||
function eventSession(input: unknown): AppSession | undefined {
|
||||
const info = record(input)
|
||||
if (!info || typeof info.id !== "string" || typeof info.projectID !== "string" || typeof info.title !== "string") return
|
||||
const location = record(info.location)
|
||||
const directory =
|
||||
typeof location?.directory === "string"
|
||||
? location.directory
|
||||
: typeof info.directory === "string"
|
||||
? info.directory
|
||||
: undefined
|
||||
if (!directory) return
|
||||
const time = record(info.time)
|
||||
if (typeof time?.created !== "number" || typeof time.updated !== "number") return
|
||||
const workspaceID =
|
||||
typeof location?.workspaceID === "string"
|
||||
? location.workspaceID
|
||||
: typeof info.workspaceID === "string"
|
||||
? info.workspaceID
|
||||
: undefined
|
||||
return {
|
||||
id: info.id,
|
||||
slug: typeof info.slug === "string" ? info.slug : info.id,
|
||||
version: typeof info.version === "string" ? info.version : "",
|
||||
parentID: typeof info.parentID === "string" ? info.parentID : undefined,
|
||||
projectID: info.projectID,
|
||||
location: { directory, workspaceID },
|
||||
directory,
|
||||
workspaceID,
|
||||
title: info.title,
|
||||
time: {
|
||||
created: time.created,
|
||||
updated: time.updated,
|
||||
archived: typeof time.archived === "number" ? time.archived : undefined,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function record(input: unknown): Record<string, unknown> | undefined {
|
||||
if (!input || typeof input !== "object" || Array.isArray(input)) return
|
||||
return input as Record<string, unknown>
|
||||
}
|
||||
|
||||
function eventSequence(input: OpenCodeEvent) {
|
||||
const durable = record(record(input)?.durable)
|
||||
return typeof durable?.seq === "number" ? durable.seq : 0
|
||||
}
|
||||
|
||||
async function* nativeSessionEvents(
|
||||
config: PtyTransportConfig,
|
||||
sessionID: string,
|
||||
after: number | undefined,
|
||||
options?: RequestOptions,
|
||||
): AsyncIterable<OpenCodeEvent> {
|
||||
const url = nativeSessionURL(config, sessionID, "/event")
|
||||
if (after !== undefined) url.searchParams.set("after", String(after))
|
||||
const response = await config.fetch(url, { signal: options?.signal, headers: transportHeaders(config) })
|
||||
if (!response.ok) throw new Error(`Failed to read session events: ${response.status} ${response.statusText}`)
|
||||
if (!response.body) return
|
||||
const reader = response.body.pipeThrough(new TextDecoderStream()).getReader()
|
||||
let buffer = ""
|
||||
while (true) {
|
||||
const result = await reader.read()
|
||||
buffer += result.value ?? ""
|
||||
const records = buffer.split("\n\n")
|
||||
buffer = records.pop() ?? ""
|
||||
for (const entry of records) {
|
||||
const payload = entry
|
||||
.split("\n")
|
||||
.filter((line) => line.startsWith("data:"))
|
||||
.map((line) => line.slice(5).trimStart())
|
||||
.join("\n")
|
||||
if (payload) yield JSON.parse(payload) as OpenCodeEvent
|
||||
}
|
||||
if (result.done) break
|
||||
}
|
||||
}
|
||||
@@ -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 },
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,1665 @@
|
||||
export type JsonValue = null | boolean | number | string | readonly JsonValue[] | { readonly [key: string]: JsonValue }
|
||||
|
||||
export type RequestOptions = {
|
||||
readonly signal?: AbortSignal
|
||||
}
|
||||
|
||||
export type LocationRef = {
|
||||
readonly directory: string
|
||||
readonly workspaceID?: string
|
||||
}
|
||||
|
||||
export type LocationInput = {
|
||||
readonly location?: LocationRef
|
||||
}
|
||||
|
||||
export type ModelRef = {
|
||||
readonly id: string
|
||||
readonly providerID: string
|
||||
readonly variant?: string
|
||||
}
|
||||
|
||||
export type Page<T> = {
|
||||
readonly items: readonly T[]
|
||||
/** Cursor for items older than this page. */
|
||||
readonly older?: string
|
||||
/** Cursor for items newer than this page. */
|
||||
readonly newer?: string
|
||||
}
|
||||
|
||||
export type Health = {
|
||||
readonly healthy: boolean
|
||||
readonly version?: string
|
||||
readonly pid?: number
|
||||
}
|
||||
|
||||
export type AppProject = {
|
||||
readonly id: string
|
||||
readonly worktree: string
|
||||
readonly vcs?: "git"
|
||||
readonly time: {
|
||||
readonly created: number
|
||||
readonly updated: number
|
||||
readonly initialized?: number
|
||||
}
|
||||
readonly name?: string
|
||||
readonly icon?: {
|
||||
readonly url?: string
|
||||
readonly override?: string
|
||||
readonly color?: string
|
||||
}
|
||||
readonly commands?: {
|
||||
readonly start?: string
|
||||
}
|
||||
sandboxes: string[]
|
||||
}
|
||||
|
||||
export type CurrentProject = {
|
||||
readonly id: string
|
||||
readonly directory: string
|
||||
}
|
||||
|
||||
export type AppModel = {
|
||||
readonly id: string
|
||||
readonly providerID: string
|
||||
readonly name: string
|
||||
readonly family?: string
|
||||
readonly releaseDate?: string
|
||||
readonly cost?: {
|
||||
readonly input: number
|
||||
readonly output?: number
|
||||
readonly cacheRead?: number
|
||||
readonly cacheWrite?: number
|
||||
}
|
||||
readonly capabilities: {
|
||||
readonly reasoning: boolean
|
||||
readonly input: {
|
||||
readonly text: boolean
|
||||
readonly image: boolean
|
||||
readonly audio: boolean
|
||||
readonly video: boolean
|
||||
readonly pdf: boolean
|
||||
}
|
||||
}
|
||||
readonly limit: {
|
||||
readonly context: number
|
||||
readonly output?: number
|
||||
}
|
||||
readonly variants?: Readonly<Record<string, unknown>>
|
||||
}
|
||||
|
||||
export type AppProvider = {
|
||||
readonly id: string
|
||||
readonly name: string
|
||||
readonly source?: "env" | "config" | "custom" | "api"
|
||||
readonly integrationID?: string
|
||||
readonly models: Readonly<Record<string, AppModel>>
|
||||
}
|
||||
|
||||
export type ProviderCatalog = {
|
||||
readonly providers: ReadonlyMap<string, AppProvider>
|
||||
readonly connected: readonly string[]
|
||||
readonly defaults: Readonly<Record<string, string>>
|
||||
}
|
||||
|
||||
export type AppAgent = {
|
||||
readonly id: string
|
||||
readonly name: string
|
||||
readonly description?: string
|
||||
readonly mode: "subagent" | "primary" | "all"
|
||||
readonly hidden: boolean
|
||||
readonly color?: string
|
||||
readonly model?: ModelRef
|
||||
}
|
||||
|
||||
export type AppCommand = {
|
||||
readonly name: string
|
||||
readonly description?: string
|
||||
readonly source?: "command" | "mcp" | "skill"
|
||||
}
|
||||
|
||||
export type AppReference = {
|
||||
readonly name: string
|
||||
readonly path: string
|
||||
readonly description?: string
|
||||
readonly hidden?: boolean
|
||||
readonly source:
|
||||
| {
|
||||
readonly type: "local"
|
||||
readonly path: string
|
||||
}
|
||||
| {
|
||||
readonly type: "git"
|
||||
readonly repository: string
|
||||
readonly branch?: string
|
||||
}
|
||||
}
|
||||
|
||||
export type TokenUsage = {
|
||||
readonly input: number
|
||||
readonly output: number
|
||||
readonly reasoning: number
|
||||
readonly cache: {
|
||||
readonly read: number
|
||||
readonly write: number
|
||||
}
|
||||
}
|
||||
|
||||
export type AppSession = {
|
||||
readonly id: string
|
||||
readonly slug: string
|
||||
readonly version: string
|
||||
readonly parentID?: string
|
||||
readonly projectID: string
|
||||
readonly location?: LocationRef
|
||||
readonly directory: string
|
||||
readonly workspaceID?: string
|
||||
title: string
|
||||
readonly cost?: number
|
||||
readonly tokens?: TokenUsage
|
||||
readonly time: {
|
||||
readonly created: number
|
||||
readonly updated: number
|
||||
readonly archived?: number
|
||||
}
|
||||
readonly share?: {
|
||||
readonly url: string
|
||||
}
|
||||
readonly revert?: {
|
||||
readonly messageID: string
|
||||
}
|
||||
}
|
||||
|
||||
export type SessionActivity =
|
||||
| { readonly type: "idle" }
|
||||
| { readonly type: "busy" }
|
||||
| { readonly type: "running" }
|
||||
| {
|
||||
readonly type: "retry"
|
||||
readonly attempt: number
|
||||
readonly message: string
|
||||
readonly next: number
|
||||
readonly action?: {
|
||||
readonly reason: string
|
||||
readonly provider: string
|
||||
readonly title: string
|
||||
readonly message: string
|
||||
readonly label: string
|
||||
readonly link?: string
|
||||
}
|
||||
}
|
||||
|
||||
export type SessionListInput = LocationInput & {
|
||||
readonly roots?: boolean
|
||||
readonly limit?: number
|
||||
readonly search?: string
|
||||
readonly cursor?: string
|
||||
}
|
||||
|
||||
export type SourceText = {
|
||||
readonly text: string
|
||||
readonly start: number
|
||||
readonly end: number
|
||||
}
|
||||
|
||||
export type FileSource =
|
||||
| {
|
||||
readonly type: "file" | "symbol"
|
||||
readonly path: string
|
||||
readonly name?: string
|
||||
readonly kind?: number
|
||||
readonly text: SourceText
|
||||
}
|
||||
| {
|
||||
readonly type: "resource"
|
||||
readonly clientName: string
|
||||
readonly uri: string
|
||||
readonly text: SourceText
|
||||
}
|
||||
|
||||
export type ToolState =
|
||||
| {
|
||||
readonly status: "pending"
|
||||
readonly input: Readonly<Record<string, unknown>>
|
||||
readonly raw: string
|
||||
}
|
||||
| {
|
||||
readonly status: "running"
|
||||
readonly input: Readonly<Record<string, unknown>>
|
||||
readonly title?: string
|
||||
readonly metadata?: Readonly<Record<string, unknown>>
|
||||
readonly time: { readonly start: number }
|
||||
readonly content?: readonly ToolOutputContent[]
|
||||
readonly provider?: ToolProviderInfo
|
||||
}
|
||||
| {
|
||||
readonly status: "completed"
|
||||
readonly input: Readonly<Record<string, unknown>>
|
||||
readonly output: string
|
||||
readonly title: string
|
||||
readonly metadata: Readonly<Record<string, unknown>>
|
||||
readonly time: { readonly start: number; readonly end: number; readonly compacted?: number }
|
||||
readonly attachments?: AppFilePart[]
|
||||
readonly content?: readonly ToolOutputContent[]
|
||||
readonly outputPaths?: readonly string[]
|
||||
readonly result?: unknown
|
||||
readonly provider?: ToolProviderInfo
|
||||
}
|
||||
| {
|
||||
readonly status: "error"
|
||||
readonly input: Readonly<Record<string, unknown>>
|
||||
readonly error: string
|
||||
readonly metadata?: Readonly<Record<string, unknown>>
|
||||
readonly time: { readonly start: number; readonly end: number }
|
||||
readonly content?: readonly ToolOutputContent[]
|
||||
readonly result?: unknown
|
||||
readonly provider?: ToolProviderInfo
|
||||
}
|
||||
|
||||
export type ToolOutputContent =
|
||||
| { readonly type: "text"; readonly text: string }
|
||||
| { readonly type: "file"; readonly uri: string; readonly mime: string; readonly name?: string }
|
||||
|
||||
export type ToolProviderInfo = {
|
||||
readonly executed: boolean
|
||||
readonly metadata?: Readonly<Record<string, Readonly<Record<string, unknown>>>>
|
||||
readonly resultMetadata?: Readonly<Record<string, Readonly<Record<string, unknown>>>>
|
||||
}
|
||||
|
||||
export type AppMessage = AppUserMessage | AppAssistantMessage
|
||||
|
||||
export type AppUserMessage = {
|
||||
readonly id: string
|
||||
readonly sessionID: string
|
||||
readonly role: "user"
|
||||
readonly time: { readonly created: number }
|
||||
readonly format?:
|
||||
| { readonly type: "text" }
|
||||
| { readonly type: "json_schema"; readonly schema: Readonly<Record<string, unknown>>; readonly retryCount?: number }
|
||||
readonly summary?: {
|
||||
readonly title?: string
|
||||
readonly body?: string
|
||||
readonly diffs: (Omit<AppFileDiff, "file"> & { readonly file?: string })[]
|
||||
}
|
||||
readonly agent: string
|
||||
readonly model: { readonly providerID: string; readonly modelID: string; readonly variant?: string }
|
||||
readonly system?: string
|
||||
readonly tools?: Readonly<Record<string, boolean>>
|
||||
}
|
||||
|
||||
export type AppAssistantMessage = {
|
||||
readonly id: string
|
||||
readonly sessionID: string
|
||||
readonly role: "assistant"
|
||||
readonly time: { readonly created: number; readonly completed?: number }
|
||||
readonly parentID: string
|
||||
readonly modelID: string
|
||||
readonly providerID: string
|
||||
readonly mode: string
|
||||
readonly agent: string
|
||||
readonly path: { readonly cwd: string; readonly root: string }
|
||||
readonly summary?: boolean
|
||||
readonly cost: number
|
||||
readonly tokens: TokenUsage & { readonly total?: number }
|
||||
readonly structured?: unknown
|
||||
readonly variant?: string
|
||||
readonly finish?: string
|
||||
readonly error?: AppMessageError
|
||||
}
|
||||
|
||||
export type AppMessageError =
|
||||
| { readonly name: "ProviderAuthError"; readonly data: { readonly providerID: string; readonly message: string } }
|
||||
| { readonly name: "UnknownError"; readonly data: { readonly message: string; readonly ref?: string } }
|
||||
| { readonly name: "MessageOutputLengthError"; readonly data: Readonly<Record<string, unknown>> }
|
||||
| { readonly name: "MessageAbortedError"; readonly data: { readonly message: string } }
|
||||
| { readonly name: "StructuredOutputError"; readonly data: { readonly message: string; readonly retries: number } }
|
||||
| { readonly name: "ContextOverflowError"; readonly data: { readonly message: string; readonly responseBody?: string } }
|
||||
| { readonly name: "ContentFilterError"; readonly data: { readonly message: string } }
|
||||
| { readonly name: "APIError"; readonly data: { readonly message: string; readonly statusCode?: number; readonly isRetryable: boolean; readonly responseHeaders?: Readonly<Record<string, string>>; readonly responseBody?: string; readonly metadata?: Readonly<Record<string, string>> } }
|
||||
|
||||
export type AppRetryError = Extract<AppMessageError, { readonly name: "APIError" }>
|
||||
|
||||
export type AppSessionError = AppMessageError | { readonly type: "unknown"; readonly message: string }
|
||||
|
||||
type AppPartBase = {
|
||||
readonly id: string
|
||||
readonly sessionID: string
|
||||
readonly messageID: string
|
||||
}
|
||||
|
||||
export type AppFilePart = AppPartBase & {
|
||||
readonly type: "file"
|
||||
readonly mime: string
|
||||
readonly filename?: string
|
||||
readonly url: string
|
||||
readonly source?: AppFilePartSource
|
||||
}
|
||||
|
||||
export type AppFilePartSource =
|
||||
| { readonly type: "file"; readonly path: string; readonly text: { readonly value: string; readonly start: number; readonly end: number } }
|
||||
| { readonly type: "symbol"; readonly path: string; readonly name: string; readonly kind: number; readonly range: { readonly start: { readonly line: number; readonly character: number }; readonly end: { readonly line: number; readonly character: number } }; readonly text: { readonly value: string; readonly start: number; readonly end: number } }
|
||||
| { readonly type: "resource"; readonly clientName: string; readonly uri: string; readonly text: { readonly value: string; readonly start: number; readonly end: number } }
|
||||
|
||||
export type AppPart =
|
||||
| (AppPartBase & { readonly type: "text"; readonly text: string; readonly synthetic?: boolean; readonly ignored?: boolean; readonly time?: { readonly start: number; readonly end?: number }; readonly metadata?: Readonly<Record<string, unknown>> })
|
||||
| (AppPartBase & { readonly type: "reasoning"; readonly text: string; readonly metadata?: Readonly<Record<string, unknown>>; readonly time: { readonly start: number; readonly end?: number } })
|
||||
| AppFilePart
|
||||
| (AppPartBase & { readonly type: "agent"; readonly name: string; readonly source?: { readonly value: string; readonly start: number; readonly end: number } })
|
||||
| (AppPartBase & { readonly type: "tool"; readonly callID: string; readonly tool: string; readonly state: ToolState; readonly metadata?: Readonly<Record<string, unknown>> })
|
||||
| (AppPartBase & { readonly type: "subtask"; readonly prompt: string; readonly description: string; readonly agent: string; readonly model?: { readonly providerID: string; readonly modelID: string }; readonly command?: string })
|
||||
| (AppPartBase & { readonly type: "step-start"; readonly snapshot?: string })
|
||||
| (AppPartBase & { readonly type: "step-finish"; readonly reason: string; readonly snapshot?: string; readonly cost: number; readonly tokens: TokenUsage & { readonly total?: number } })
|
||||
| (AppPartBase & { readonly type: "snapshot"; readonly snapshot: string })
|
||||
| (AppPartBase & { readonly type: "patch"; readonly hash: string; readonly files: string[] })
|
||||
| (AppPartBase & { readonly type: "retry"; readonly attempt: number; readonly error: AppRetryError; readonly time: { readonly created: number } })
|
||||
| (AppPartBase & { readonly type: "compaction"; readonly auto: boolean })
|
||||
|
||||
export type TimelineContent =
|
||||
| {
|
||||
readonly type: "text"
|
||||
readonly id: string
|
||||
readonly text: string
|
||||
readonly synthetic?: boolean
|
||||
readonly ignored?: boolean
|
||||
readonly metadata?: Readonly<Record<string, unknown>>
|
||||
readonly time?: { readonly start: number; readonly end?: number }
|
||||
}
|
||||
| {
|
||||
readonly type: "reasoning"
|
||||
readonly id: string
|
||||
readonly text: string
|
||||
readonly metadata?: Readonly<Record<string, unknown>>
|
||||
readonly time?: { readonly start: number; readonly end?: number }
|
||||
}
|
||||
| {
|
||||
readonly type: "file"
|
||||
readonly id: string
|
||||
readonly uri: string
|
||||
readonly name?: string
|
||||
readonly mime?: string
|
||||
readonly source?: FileSource
|
||||
}
|
||||
| {
|
||||
readonly type: "agent"
|
||||
readonly id: string
|
||||
readonly name: string
|
||||
readonly source?: SourceText
|
||||
}
|
||||
| {
|
||||
readonly type: "tool"
|
||||
readonly id: string
|
||||
readonly callID?: string
|
||||
readonly tool: string
|
||||
readonly state: ToolState
|
||||
readonly metadata?: Readonly<Record<string, unknown>>
|
||||
}
|
||||
| { readonly type: "subtask"; readonly id: string; readonly prompt: string; readonly description: string; readonly agent: string; readonly model?: ModelRef; readonly command?: string }
|
||||
| { readonly type: "step-start"; readonly id: string; readonly snapshot?: string }
|
||||
| { readonly type: "step-finish"; readonly id: string; readonly reason: string; readonly snapshot?: string; readonly cost: number; readonly tokens: TokenUsage & { readonly total?: number } }
|
||||
| { readonly type: "snapshot"; readonly id: string; readonly snapshot: string }
|
||||
| { readonly type: "patch"; readonly id: string; readonly hash: string; readonly files: string[] }
|
||||
| { readonly type: "retry"; readonly id: string; readonly attempt: number; readonly error: AppRetryError; readonly time: { readonly created: number } }
|
||||
| { readonly type: "compaction"; readonly id: string; readonly auto: boolean }
|
||||
|
||||
export type TimelineItem =
|
||||
| {
|
||||
readonly type: "user"
|
||||
readonly id: string
|
||||
readonly sessionID: string
|
||||
readonly created: number
|
||||
readonly content: readonly TimelineContent[]
|
||||
readonly agent?: string
|
||||
readonly model?: ModelRef
|
||||
readonly format?: AppUserMessage["format"]
|
||||
readonly summary?: AppUserMessage["summary"]
|
||||
readonly system?: string
|
||||
readonly tools?: Readonly<Record<string, boolean>>
|
||||
}
|
||||
| {
|
||||
readonly type: "assistant"
|
||||
readonly id: string
|
||||
readonly sessionID: string
|
||||
readonly parentID?: string
|
||||
readonly created: number
|
||||
readonly completed?: number
|
||||
readonly content: readonly TimelineContent[]
|
||||
readonly agent?: string
|
||||
readonly model?: ModelRef
|
||||
readonly tokens?: TokenUsage
|
||||
readonly error?: AppMessageError
|
||||
readonly mode?: string
|
||||
readonly path?: AppAssistantMessage["path"]
|
||||
readonly cost?: number
|
||||
readonly structured?: unknown
|
||||
readonly finish?: string
|
||||
readonly summary?: boolean
|
||||
readonly snapshot?: { readonly start?: string; readonly end?: string; readonly files?: readonly string[] }
|
||||
}
|
||||
| {
|
||||
readonly type: "agent-switch" | "model-switch" | "synthetic" | "system" | "skill" | "shell" | "compaction"
|
||||
readonly id: string
|
||||
readonly sessionID: string
|
||||
readonly created: number
|
||||
readonly metadata?: Readonly<Record<string, unknown>>
|
||||
readonly text?: string
|
||||
readonly reason?: "auto" | "manual"
|
||||
readonly summary?: string
|
||||
readonly recent?: string
|
||||
readonly callID?: string
|
||||
readonly command?: string
|
||||
readonly output?: string
|
||||
}
|
||||
|
||||
export function timelineMessage(item: TimelineItem): AppMessage | undefined {
|
||||
if (item.type === "user")
|
||||
return {
|
||||
id: item.id,
|
||||
sessionID: item.sessionID,
|
||||
role: "user",
|
||||
time: { created: item.created },
|
||||
format: item.format,
|
||||
summary: item.summary,
|
||||
agent: item.agent ?? "",
|
||||
model: {
|
||||
providerID: item.model?.providerID ?? "",
|
||||
modelID: item.model?.id ?? "",
|
||||
variant: item.model?.variant,
|
||||
},
|
||||
system: item.system,
|
||||
tools: item.tools,
|
||||
}
|
||||
if (item.type !== "assistant") return
|
||||
return {
|
||||
id: item.id,
|
||||
sessionID: item.sessionID,
|
||||
role: "assistant",
|
||||
time: { created: item.created, completed: item.completed },
|
||||
parentID: item.parentID ?? "",
|
||||
modelID: item.model?.id ?? "",
|
||||
providerID: item.model?.providerID ?? "",
|
||||
variant: item.model?.variant,
|
||||
mode: item.mode ?? item.agent ?? "",
|
||||
agent: item.agent ?? "",
|
||||
path: item.path ?? { cwd: "", root: "" },
|
||||
summary: item.summary,
|
||||
cost: item.cost ?? 0,
|
||||
tokens: item.tokens ?? { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||
structured: item.structured,
|
||||
finish: item.finish,
|
||||
error: item.error,
|
||||
}
|
||||
}
|
||||
|
||||
export function timelineParts(item: TimelineItem): AppPart[] {
|
||||
if (item.type !== "user" && item.type !== "assistant") return []
|
||||
return item.content.map((content): AppPart => {
|
||||
const base = { id: content.id, sessionID: item.sessionID, messageID: item.id }
|
||||
if (content.type === "file")
|
||||
return {
|
||||
...base,
|
||||
type: content.type,
|
||||
mime: content.mime ?? "application/octet-stream",
|
||||
filename: content.name,
|
||||
url: content.uri,
|
||||
source:
|
||||
content.source?.type === "resource"
|
||||
? {
|
||||
type: content.source.type,
|
||||
clientName: content.source.clientName,
|
||||
uri: content.source.uri,
|
||||
text: {
|
||||
value: content.source.text.text,
|
||||
start: content.source.text.start,
|
||||
end: content.source.text.end,
|
||||
},
|
||||
}
|
||||
: content.source?.type === "symbol"
|
||||
? {
|
||||
type: content.source.type,
|
||||
path: content.source.path,
|
||||
name: content.source.name ?? "",
|
||||
kind: content.source.kind ?? 0,
|
||||
range: {
|
||||
start: { line: 0, character: content.source.text.start },
|
||||
end: { line: 0, character: content.source.text.end },
|
||||
},
|
||||
text: {
|
||||
value: content.source.text.text,
|
||||
start: content.source.text.start,
|
||||
end: content.source.text.end,
|
||||
},
|
||||
}
|
||||
: content.source && {
|
||||
type: "file",
|
||||
path: content.source.path,
|
||||
text: {
|
||||
value: content.source.text.text,
|
||||
start: content.source.text.start,
|
||||
end: content.source.text.end,
|
||||
},
|
||||
},
|
||||
}
|
||||
if (content.type === "agent")
|
||||
return {
|
||||
...base,
|
||||
type: content.type,
|
||||
name: content.name,
|
||||
source: content.source && {
|
||||
value: content.source.text,
|
||||
start: content.source.start,
|
||||
end: content.source.end,
|
||||
},
|
||||
}
|
||||
if (content.type === "tool")
|
||||
return {
|
||||
...base,
|
||||
type: content.type,
|
||||
callID: content.callID ?? content.id,
|
||||
tool: content.tool,
|
||||
state: content.state,
|
||||
metadata: content.metadata,
|
||||
}
|
||||
if (content.type === "subtask")
|
||||
return {
|
||||
...base,
|
||||
...content,
|
||||
model: content.model && { providerID: content.model.providerID, modelID: content.model.id },
|
||||
}
|
||||
if (content.type === "reasoning")
|
||||
return {
|
||||
...base,
|
||||
...content,
|
||||
time: content.time ?? {
|
||||
start: item.created,
|
||||
end: item.type === "assistant" ? item.completed : undefined,
|
||||
},
|
||||
}
|
||||
return { ...base, ...content }
|
||||
})
|
||||
}
|
||||
|
||||
export type PromptFile = {
|
||||
readonly uri: string
|
||||
readonly name?: string
|
||||
readonly mime?: string
|
||||
readonly source?: SourceText & {
|
||||
readonly path?: string
|
||||
}
|
||||
}
|
||||
|
||||
export type PromptAgentMention = {
|
||||
readonly name: string
|
||||
readonly start?: number
|
||||
readonly end?: number
|
||||
readonly text?: string
|
||||
}
|
||||
|
||||
export type PromptPart =
|
||||
| {
|
||||
readonly id: string
|
||||
readonly type: "text"
|
||||
readonly text: string
|
||||
readonly synthetic?: boolean
|
||||
readonly ignored?: boolean
|
||||
readonly time?: { readonly start: number; readonly end?: number }
|
||||
readonly metadata?: Readonly<Record<string, unknown>>
|
||||
}
|
||||
| {
|
||||
readonly id: string
|
||||
readonly type: "file"
|
||||
readonly mime: string
|
||||
readonly url: string
|
||||
readonly filename?: string
|
||||
readonly source?:
|
||||
| {
|
||||
readonly type: "file"
|
||||
readonly path: string
|
||||
readonly text: { readonly value: string; readonly start: number; readonly end: number }
|
||||
}
|
||||
| {
|
||||
readonly type: "symbol"
|
||||
readonly path: string
|
||||
readonly name: string
|
||||
readonly kind: number
|
||||
readonly range: {
|
||||
readonly start: { readonly line: number; readonly character: number }
|
||||
readonly end: { readonly line: number; readonly character: number }
|
||||
}
|
||||
readonly text: { readonly value: string; readonly start: number; readonly end: number }
|
||||
}
|
||||
| {
|
||||
readonly type: "resource"
|
||||
readonly clientName: string
|
||||
readonly uri: string
|
||||
readonly text: { readonly value: string; readonly start: number; readonly end: number }
|
||||
}
|
||||
}
|
||||
| {
|
||||
readonly id: string
|
||||
readonly type: "agent"
|
||||
readonly name: string
|
||||
readonly source?: { readonly value: string; readonly start: number; readonly end: number }
|
||||
}
|
||||
|
||||
export type PromptInput = LocationInput & {
|
||||
readonly sessionID: string
|
||||
readonly id: string
|
||||
readonly text: string
|
||||
readonly files?: readonly PromptFile[]
|
||||
readonly agents?: readonly PromptAgentMention[]
|
||||
readonly parts?: readonly PromptPart[]
|
||||
readonly selection?: {
|
||||
readonly agent?: string
|
||||
readonly model?: ModelRef
|
||||
}
|
||||
readonly delivery?: "steer" | "queue"
|
||||
}
|
||||
|
||||
export type CommandInput = LocationInput & {
|
||||
readonly sessionID: string
|
||||
readonly id?: string
|
||||
readonly command: string
|
||||
readonly arguments?: string
|
||||
readonly agent?: string
|
||||
readonly model?: ModelRef
|
||||
readonly files?: readonly PromptFile[]
|
||||
readonly delivery?: "steer" | "queue"
|
||||
}
|
||||
|
||||
export type FileEntry = {
|
||||
readonly path: string
|
||||
readonly name?: string
|
||||
readonly absolute?: string
|
||||
readonly type: "file" | "directory"
|
||||
}
|
||||
|
||||
export type AppFileNode = {
|
||||
readonly name: string
|
||||
readonly path: string
|
||||
readonly absolute: string
|
||||
readonly type: "file" | "directory"
|
||||
readonly ignored: boolean
|
||||
}
|
||||
|
||||
export type FileContent = {
|
||||
readonly bytes: Uint8Array
|
||||
readonly kind?: "text" | "binary"
|
||||
readonly mimeType?: string
|
||||
}
|
||||
|
||||
export type AppFileDiff = {
|
||||
readonly file: string
|
||||
readonly patch?: string
|
||||
readonly additions: number
|
||||
readonly deletions: number
|
||||
readonly status?: "added" | "deleted" | "modified"
|
||||
}
|
||||
|
||||
export type AppSnapshotFileDiff = Omit<AppFileDiff, "file"> & { readonly file?: string }
|
||||
export type AppVcsFileDiff = AppFileDiff
|
||||
|
||||
export type AppPermissionRequest = {
|
||||
readonly id: string
|
||||
readonly sessionID: string
|
||||
readonly action: string
|
||||
readonly resources: readonly string[]
|
||||
readonly permission: string
|
||||
readonly patterns: string[]
|
||||
readonly always: string[]
|
||||
readonly metadata: Record<string, unknown>
|
||||
}
|
||||
|
||||
export type AppQuestion = {
|
||||
readonly question: string
|
||||
readonly header: string
|
||||
readonly options: {
|
||||
readonly label: string
|
||||
readonly description: string
|
||||
}[]
|
||||
readonly multiple?: boolean
|
||||
readonly custom?: boolean
|
||||
}
|
||||
|
||||
export type AppQuestionRequest = {
|
||||
readonly id: string
|
||||
readonly sessionID: string
|
||||
readonly questions: AppQuestion[]
|
||||
}
|
||||
|
||||
export type AppQuestionAnswer = string[]
|
||||
|
||||
export type AppSessionNotFoundError = {
|
||||
readonly _tag: "SessionNotFoundError"
|
||||
readonly sessionID: string
|
||||
readonly message: string
|
||||
}
|
||||
|
||||
export type AppMcpStatus =
|
||||
| { readonly status: "connected" }
|
||||
| { readonly status: "pending" }
|
||||
| { readonly status: "disabled" }
|
||||
| { readonly status: "needs_auth" }
|
||||
| { readonly status: "failed"; readonly error: string }
|
||||
| { readonly status: "needs_client_registration"; readonly error: string }
|
||||
|
||||
export type AppMcpServer = {
|
||||
readonly name: string
|
||||
readonly status: AppMcpStatus
|
||||
readonly integrationID?: string
|
||||
}
|
||||
|
||||
export type AppMcpResource = {
|
||||
readonly server: string
|
||||
readonly name: string
|
||||
readonly uri: string
|
||||
readonly description?: string
|
||||
readonly mimeType?: string
|
||||
}
|
||||
|
||||
export type AppMcpResourceTemplate = {
|
||||
readonly server: string
|
||||
readonly name: string
|
||||
readonly uriTemplate: string
|
||||
readonly description?: string
|
||||
readonly mimeType?: string
|
||||
}
|
||||
|
||||
export type AppPty = {
|
||||
readonly id: string
|
||||
readonly title: string
|
||||
}
|
||||
|
||||
export type AppEventEnvelope = {
|
||||
readonly location?: LocationRef
|
||||
readonly event: AppEvent
|
||||
}
|
||||
|
||||
export type AppEvent =
|
||||
| { readonly type: "server.connected" }
|
||||
| { readonly type: "server.disposed"; readonly location?: LocationRef }
|
||||
| { readonly type: "instance.disposed"; readonly location: LocationRef }
|
||||
| { readonly type: "project.updated"; readonly project: AppProject }
|
||||
| { readonly type: "session.created"; readonly session: AppSession }
|
||||
| { readonly type: "session.updated"; readonly session: AppSession }
|
||||
| { readonly type: "session.deleted"; readonly sessionID: string }
|
||||
| { readonly type: "session.moved"; readonly sessionID: string; readonly location: LocationRef }
|
||||
| { readonly type: "session.revert"; readonly sessionID: string; readonly revert?: { readonly messageID: string } }
|
||||
| {
|
||||
readonly type: "session.activity"
|
||||
readonly sessionID: string
|
||||
readonly activity: SessionActivity
|
||||
readonly item?: TimelineItem
|
||||
}
|
||||
| { readonly type: "session.diff"; readonly sessionID: string; readonly diff: readonly AppFileDiff[] }
|
||||
| { readonly type: "todo.updated"; readonly sessionID: string; readonly todos: readonly AppTodo[] }
|
||||
| { readonly type: "session.error"; readonly sessionID?: string; readonly error?: AppSessionError }
|
||||
| { readonly type: "timeline.updated"; readonly item: TimelineItem }
|
||||
| {
|
||||
readonly type: "timeline.content.updated"
|
||||
readonly sessionID: string
|
||||
readonly itemID: string
|
||||
readonly content: TimelineContent
|
||||
}
|
||||
| { readonly type: "timeline.removed"; readonly sessionID: string; readonly itemID: string }
|
||||
| {
|
||||
readonly type: "timeline.delta"
|
||||
readonly sessionID: string
|
||||
readonly itemID: string
|
||||
readonly contentID: string
|
||||
readonly field: string
|
||||
readonly delta: string
|
||||
}
|
||||
| {
|
||||
readonly type: "timeline.part.removed"
|
||||
readonly sessionID: string
|
||||
readonly itemID: string
|
||||
readonly contentID: string
|
||||
}
|
||||
| { readonly type: "permission.requested"; readonly request: AppPermissionRequest }
|
||||
| { readonly type: "permission.replied"; readonly sessionID: string; readonly requestID: string }
|
||||
| { readonly type: "question.requested"; readonly request: AppQuestionRequest }
|
||||
| { readonly type: "question.replied" | "question.rejected"; readonly sessionID: string; readonly requestID: string }
|
||||
| { readonly type: "file.changed"; readonly path: string; readonly change: "add" | "change" | "unlink" }
|
||||
| { readonly type: "vcs.branch.updated"; readonly branch?: string }
|
||||
| { readonly type: "worktree.ready"; readonly name: string; readonly branch?: string }
|
||||
| { readonly type: "worktree.failed"; readonly message: string }
|
||||
| { readonly type: "lsp.updated" }
|
||||
| { readonly type: "reference.updated" }
|
||||
| { readonly type: "mcp.updated"; readonly server?: string }
|
||||
| { readonly type: "provider.updated" }
|
||||
| { readonly type: "pty.exited"; readonly ptyID: string }
|
||||
| { readonly type: "unknown"; readonly raw: unknown }
|
||||
|
||||
export interface HealthApi {
|
||||
get(options?: RequestOptions): Promise<Health>
|
||||
}
|
||||
|
||||
export interface ProjectApi {
|
||||
current(input?: LocationInput, options?: RequestOptions): Promise<CurrentProject>
|
||||
}
|
||||
|
||||
export interface ProjectListCapability {
|
||||
list(options?: RequestOptions): Promise<readonly AppProject[]>
|
||||
}
|
||||
|
||||
export interface CatalogApi {
|
||||
providers(input?: LocationInput, options?: RequestOptions): Promise<ProviderCatalog>
|
||||
agents(input?: LocationInput, options?: RequestOptions): Promise<readonly AppAgent[]>
|
||||
}
|
||||
|
||||
export interface CommandApi {
|
||||
list(input?: LocationInput, options?: RequestOptions): Promise<readonly AppCommand[]>
|
||||
}
|
||||
|
||||
export interface ReferenceApi {
|
||||
list(input?: LocationInput, options?: RequestOptions): Promise<readonly AppReference[]>
|
||||
}
|
||||
|
||||
export interface SessionApi {
|
||||
list(input?: SessionListInput, options?: RequestOptions): Promise<Page<AppSession>>
|
||||
create(
|
||||
input?: LocationInput & { readonly agent?: string; readonly model?: ModelRef },
|
||||
options?: RequestOptions,
|
||||
): Promise<AppSession>
|
||||
get(input: LocationInput & { readonly sessionID: string }, options?: RequestOptions): Promise<AppSession>
|
||||
interrupt(input: LocationInput & { readonly sessionID: string }, options?: RequestOptions): Promise<void>
|
||||
activity(input?: LocationInput, options?: RequestOptions): Promise<Readonly<Record<string, SessionActivity>>>
|
||||
history(
|
||||
input: LocationInput & { readonly sessionID: string; readonly limit?: number; readonly cursor?: string },
|
||||
options?: RequestOptions,
|
||||
): Promise<Page<TimelineItem>>
|
||||
message(
|
||||
input: LocationInput & { readonly sessionID: string; readonly messageID: string },
|
||||
options?: RequestOptions,
|
||||
): Promise<TimelineItem>
|
||||
prompt(input: PromptInput, options?: RequestOptions): Promise<void>
|
||||
}
|
||||
|
||||
export interface SessionActionsV1Capability {
|
||||
remove(input: LocationInput & { readonly sessionID: string }, options?: RequestOptions): Promise<boolean>
|
||||
fork(
|
||||
input: LocationInput & { readonly sessionID: string; readonly messageID?: string },
|
||||
options?: RequestOptions,
|
||||
): Promise<AppSession>
|
||||
rename(
|
||||
input: LocationInput & { readonly sessionID: string; readonly title: string },
|
||||
options?: RequestOptions,
|
||||
): Promise<void>
|
||||
command(input: CommandInput, options?: RequestOptions): Promise<void>
|
||||
}
|
||||
|
||||
export interface FileApi {
|
||||
list(input: LocationInput & { readonly path?: string }, options?: RequestOptions): Promise<readonly AppFileNode[]>
|
||||
find(
|
||||
input: LocationInput & {
|
||||
readonly query: string
|
||||
readonly type?: "file" | "directory"
|
||||
readonly limit?: number
|
||||
},
|
||||
options?: RequestOptions,
|
||||
): Promise<readonly FileEntry[]>
|
||||
read(input: LocationInput & { readonly path: string }, options?: RequestOptions): Promise<FileContent>
|
||||
}
|
||||
|
||||
export interface PermissionApi {
|
||||
pending(input?: LocationInput, options?: RequestOptions): Promise<readonly AppPermissionRequest[]>
|
||||
reply(
|
||||
input: LocationInput & {
|
||||
readonly sessionID: string
|
||||
readonly requestID: string
|
||||
readonly reply: "once" | "always" | "reject"
|
||||
readonly message?: string
|
||||
},
|
||||
options?: RequestOptions,
|
||||
): Promise<void>
|
||||
}
|
||||
|
||||
export interface QuestionApi {
|
||||
pending(input?: LocationInput, options?: RequestOptions): Promise<readonly AppQuestionRequest[]>
|
||||
reply(
|
||||
input: LocationInput & {
|
||||
readonly sessionID: string
|
||||
readonly requestID: string
|
||||
readonly answers: readonly (readonly string[])[]
|
||||
},
|
||||
options?: RequestOptions,
|
||||
): Promise<void>
|
||||
reject(
|
||||
input: LocationInput & { readonly sessionID: string; readonly requestID: string },
|
||||
options?: RequestOptions,
|
||||
): Promise<void>
|
||||
}
|
||||
|
||||
export interface VcsApi {
|
||||
status(
|
||||
input?: LocationInput,
|
||||
options?: RequestOptions,
|
||||
): Promise<
|
||||
readonly {
|
||||
readonly file: string
|
||||
readonly additions: number
|
||||
readonly deletions: number
|
||||
readonly status: "added" | "deleted" | "modified"
|
||||
}[]
|
||||
>
|
||||
diff(
|
||||
input: LocationInput & { readonly mode: "working" | "branch"; readonly context?: number },
|
||||
options?: RequestOptions,
|
||||
): Promise<readonly AppFileDiff[]>
|
||||
}
|
||||
|
||||
export interface McpApi {
|
||||
list(input?: LocationInput, options?: RequestOptions): Promise<readonly AppMcpServer[]>
|
||||
resources(
|
||||
input?: LocationInput,
|
||||
options?: RequestOptions,
|
||||
): Promise<{
|
||||
readonly resources: readonly AppMcpResource[]
|
||||
readonly templates: readonly AppMcpResourceTemplate[]
|
||||
}>
|
||||
}
|
||||
|
||||
export interface PtyApi {
|
||||
list(input?: LocationInput, options?: RequestOptions): Promise<readonly AppPty[]>
|
||||
create(
|
||||
input: LocationInput & {
|
||||
readonly title?: string
|
||||
readonly command?: string
|
||||
readonly args?: readonly string[]
|
||||
readonly cwd?: string
|
||||
readonly env?: Readonly<Record<string, string>>
|
||||
},
|
||||
options?: RequestOptions,
|
||||
): Promise<AppPty>
|
||||
get(input: LocationInput & { readonly ptyID: string }, options?: RequestOptions): Promise<AppPty>
|
||||
update(
|
||||
input: LocationInput & {
|
||||
readonly ptyID: string
|
||||
readonly title?: string
|
||||
readonly size?: { readonly rows: number; readonly cols: number }
|
||||
},
|
||||
options?: RequestOptions,
|
||||
): Promise<AppPty>
|
||||
remove(input: LocationInput & { readonly ptyID: string }, options?: RequestOptions): Promise<void>
|
||||
}
|
||||
|
||||
export interface EventApi {
|
||||
subscribe(options?: RequestOptions): AsyncIterable<AppEventEnvelope>
|
||||
}
|
||||
|
||||
export interface CommonClient {
|
||||
readonly health: HealthApi
|
||||
readonly projects: ProjectApi
|
||||
readonly catalog: CatalogApi
|
||||
readonly commands: CommandApi
|
||||
readonly references: ReferenceApi
|
||||
readonly sessions: SessionApi
|
||||
readonly files: FileApi
|
||||
readonly permissions: PermissionApi
|
||||
readonly questions: QuestionApi
|
||||
readonly pty: PtyApi
|
||||
readonly events: EventApi
|
||||
}
|
||||
|
||||
export type AppProviderConfig = {
|
||||
readonly npm?: string
|
||||
readonly name?: string
|
||||
readonly env?: readonly string[]
|
||||
readonly options?: {
|
||||
readonly baseURL?: string
|
||||
readonly headers?: Readonly<Record<string, string>>
|
||||
readonly [key: string]: unknown
|
||||
}
|
||||
readonly models?: Readonly<Record<string, { readonly name?: string }>>
|
||||
}
|
||||
|
||||
export type AppConfig = {
|
||||
readonly shell?: string
|
||||
readonly model?: string
|
||||
readonly share?: "manual" | "auto" | "disabled"
|
||||
readonly plugin?: readonly (string | readonly [string, Readonly<Record<string, unknown>>])[]
|
||||
readonly disabledProviders?: readonly string[]
|
||||
readonly provider?: Readonly<Record<string, AppProviderConfig>>
|
||||
readonly permission?: unknown
|
||||
}
|
||||
|
||||
export interface ConfigurationCapability {
|
||||
getGlobal(options?: RequestOptions): Promise<AppConfig>
|
||||
updateGlobal(config: AppConfig, options?: RequestOptions): Promise<void>
|
||||
get(input?: LocationInput, options?: RequestOptions): Promise<AppConfig>
|
||||
}
|
||||
|
||||
export type ProviderAuthPrompt =
|
||||
| {
|
||||
readonly type: "text"
|
||||
readonly key: string
|
||||
readonly message: string
|
||||
readonly placeholder?: string
|
||||
readonly when?: { readonly key: string; readonly op: "eq" | "neq"; readonly value: string }
|
||||
}
|
||||
| {
|
||||
readonly type: "select"
|
||||
readonly key: string
|
||||
readonly message: string
|
||||
readonly options: readonly { readonly label: string; readonly value: string; readonly hint?: string }[]
|
||||
readonly when?: { readonly key: string; readonly op: "eq" | "neq"; readonly value: string }
|
||||
}
|
||||
|
||||
export type ProviderAuthMethod = {
|
||||
readonly type: "oauth" | "api"
|
||||
readonly label: string
|
||||
readonly prompts?: readonly ProviderAuthPrompt[]
|
||||
}
|
||||
|
||||
export type AppProviderAuthResponse = Readonly<Record<string, readonly ProviderAuthMethod[]>>
|
||||
|
||||
export type ProviderAuthorization = {
|
||||
readonly url: string
|
||||
readonly method: "auto" | "code"
|
||||
readonly instructions: string
|
||||
}
|
||||
|
||||
export interface ProviderAuthV1Capability {
|
||||
methods(
|
||||
input?: LocationInput,
|
||||
options?: RequestOptions,
|
||||
): Promise<Readonly<Record<string, readonly ProviderAuthMethod[]>>>
|
||||
authorize(
|
||||
input: LocationInput & {
|
||||
readonly providerID: string
|
||||
readonly method: number
|
||||
readonly values?: Readonly<Record<string, string>>
|
||||
},
|
||||
options?: RequestOptions,
|
||||
): Promise<ProviderAuthorization>
|
||||
callback(
|
||||
input: LocationInput & { readonly providerID: string; readonly method: number; readonly code?: string },
|
||||
options?: RequestOptions,
|
||||
): Promise<void>
|
||||
setApiKey(
|
||||
input: {
|
||||
readonly providerID: string
|
||||
readonly key: string
|
||||
readonly metadata?: Readonly<Record<string, string>>
|
||||
},
|
||||
options?: RequestOptions,
|
||||
): Promise<void>
|
||||
remove(input: { readonly providerID: string }, options?: RequestOptions): Promise<void>
|
||||
}
|
||||
|
||||
export interface ProjectEditingCapability {
|
||||
update(
|
||||
input: LocationInput & {
|
||||
readonly projectID: string
|
||||
readonly name?: string
|
||||
readonly icon?: { readonly override?: string; readonly color?: string }
|
||||
readonly commands?: { readonly start?: string }
|
||||
},
|
||||
options?: RequestOptions,
|
||||
): Promise<AppProject>
|
||||
initGit(input?: LocationInput, options?: RequestOptions): Promise<AppProject>
|
||||
}
|
||||
|
||||
export type AppWorktree = {
|
||||
readonly directory: string
|
||||
readonly branch?: string
|
||||
}
|
||||
|
||||
export interface WorktreesV1Capability {
|
||||
list(input: LocationInput, options?: RequestOptions): Promise<readonly string[]>
|
||||
create(input: LocationInput, options?: RequestOptions): Promise<AppWorktree>
|
||||
remove(input: LocationInput & { readonly directory: string }, options?: RequestOptions): Promise<boolean>
|
||||
reset(input: LocationInput & { readonly directory: string }, options?: RequestOptions): Promise<boolean>
|
||||
}
|
||||
|
||||
export type AppTodo = {
|
||||
readonly id?: string
|
||||
readonly content: string
|
||||
readonly status: "pending" | "in_progress" | "completed" | "cancelled" | (string & {})
|
||||
readonly priority: "high" | "medium" | "low" | (string & {})
|
||||
}
|
||||
|
||||
export type LegacySessionShellInput = LocationInput & {
|
||||
readonly sessionID: string
|
||||
readonly id?: string
|
||||
readonly command: string
|
||||
readonly agent: string
|
||||
readonly model?: ModelRef
|
||||
}
|
||||
|
||||
export interface SessionExtrasV1Capability {
|
||||
archive(
|
||||
input: LocationInput & { readonly sessionID: string; readonly archivedAt: number },
|
||||
options?: RequestOptions,
|
||||
): Promise<void>
|
||||
share(input: LocationInput & { readonly sessionID: string }, options?: RequestOptions): Promise<string>
|
||||
unshare(input: LocationInput & { readonly sessionID: string }, options?: RequestOptions): Promise<void>
|
||||
diff(input: LocationInput & { readonly sessionID: string }, options?: RequestOptions): Promise<readonly AppFileDiff[]>
|
||||
todos(input: LocationInput & { readonly sessionID: string }, options?: RequestOptions): Promise<readonly AppTodo[]>
|
||||
summarize(
|
||||
input: LocationInput & { readonly sessionID: string; readonly model: ModelRef },
|
||||
options?: RequestOptions,
|
||||
): Promise<void>
|
||||
revert(
|
||||
input: LocationInput & { readonly sessionID: string; readonly messageID: string },
|
||||
options?: RequestOptions,
|
||||
): Promise<AppSession>
|
||||
clearRevert(input: LocationInput & { readonly sessionID: string }, options?: RequestOptions): Promise<AppSession>
|
||||
shell(input: LegacySessionShellInput, options?: RequestOptions): Promise<void>
|
||||
}
|
||||
|
||||
export type AppLspStatus = {
|
||||
readonly id: string
|
||||
readonly name: string
|
||||
readonly status: "connected" | "error"
|
||||
}
|
||||
|
||||
export interface LspCapability {
|
||||
status(input?: LocationInput, options?: RequestOptions): Promise<readonly AppLspStatus[]>
|
||||
}
|
||||
|
||||
export interface McpControlCapability {
|
||||
connect(input: LocationInput & { readonly name: string }, options?: RequestOptions): Promise<void>
|
||||
disconnect(input: LocationInput & { readonly name: string }, options?: RequestOptions): Promise<void>
|
||||
authenticate(input: LocationInput & { readonly name: string }, options?: RequestOptions): Promise<void>
|
||||
}
|
||||
|
||||
export type AppPathInfo = {
|
||||
readonly home: string
|
||||
readonly directory: string
|
||||
readonly state?: string
|
||||
readonly config?: string
|
||||
readonly worktree?: string
|
||||
}
|
||||
|
||||
export interface PathInfoCapability {
|
||||
get(input?: LocationInput, options?: RequestOptions): Promise<AppPathInfo>
|
||||
}
|
||||
|
||||
export type AppVcsInfo = {
|
||||
readonly branch?: string
|
||||
readonly defaultBranch?: string
|
||||
}
|
||||
|
||||
export interface VcsInfoCapability {
|
||||
get(input?: LocationInput, options?: RequestOptions): Promise<AppVcsInfo>
|
||||
}
|
||||
|
||||
export type DecoratedFileContent = {
|
||||
readonly type: "text" | "binary"
|
||||
readonly content: string
|
||||
readonly diff?: string
|
||||
readonly encoding?: "base64"
|
||||
readonly mimeType?: string
|
||||
readonly patch?: {
|
||||
readonly oldFileName: string
|
||||
readonly newFileName: string
|
||||
readonly oldHeader?: string
|
||||
readonly newHeader?: string
|
||||
readonly hunks: readonly {
|
||||
readonly oldStart: number
|
||||
readonly oldLines: number
|
||||
readonly newStart: number
|
||||
readonly newLines: number
|
||||
readonly lines: readonly string[]
|
||||
}[]
|
||||
readonly index?: string
|
||||
}
|
||||
}
|
||||
|
||||
export interface DecoratedFileCapability {
|
||||
read(input: LocationInput & { readonly path: string }, options?: RequestOptions): Promise<DecoratedFileContent>
|
||||
}
|
||||
|
||||
export type PtyTicket = {
|
||||
readonly status: number
|
||||
readonly ticket?: string
|
||||
}
|
||||
|
||||
export type PtyTransportConfig = {
|
||||
readonly baseUrl: string
|
||||
readonly fetch: typeof globalThis.fetch
|
||||
readonly username?: string
|
||||
readonly password?: string
|
||||
readonly sameOrigin: boolean
|
||||
readonly authToken: boolean
|
||||
}
|
||||
|
||||
export interface PtyTransportCapability {
|
||||
connectToken(input: LocationInput & { readonly ptyID: string }, options?: RequestOptions): Promise<PtyTicket>
|
||||
exists(input: LocationInput & { readonly ptyID: string }, options?: RequestOptions): Promise<boolean>
|
||||
connectURL(input: {
|
||||
readonly ptyID: string
|
||||
readonly location: LocationRef
|
||||
readonly cursor: number
|
||||
readonly ticket?: string
|
||||
}): URL
|
||||
}
|
||||
|
||||
export type ShellOption = {
|
||||
readonly name: string
|
||||
readonly path: string
|
||||
readonly acceptable: boolean
|
||||
}
|
||||
|
||||
export interface ShellDiscoveryCapability {
|
||||
list(input?: LocationInput, options?: RequestOptions): Promise<readonly ShellOption[]>
|
||||
}
|
||||
|
||||
export interface RuntimeV1Capability {
|
||||
disposeLocation(input: LocationInput, options?: RequestOptions): Promise<void>
|
||||
disposeAll(options?: RequestOptions): Promise<void>
|
||||
}
|
||||
|
||||
export type IntegrationMethod =
|
||||
| {
|
||||
readonly type: "oauth"
|
||||
readonly id: string
|
||||
readonly label: string
|
||||
readonly prompts?: readonly ProviderAuthPrompt[]
|
||||
}
|
||||
| {
|
||||
readonly type: "key"
|
||||
readonly label: string
|
||||
}
|
||||
| {
|
||||
readonly type: "environment"
|
||||
readonly label: string
|
||||
}
|
||||
|
||||
export type IntegrationConnection = {
|
||||
readonly id: string
|
||||
readonly label: string
|
||||
readonly kind: "credential" | "environment"
|
||||
}
|
||||
|
||||
export function credentialConnectionIDs(connections: readonly IntegrationConnection[]) {
|
||||
return connections.filter((connection) => connection.kind === "credential").map((connection) => connection.id)
|
||||
}
|
||||
|
||||
export type IntegrationInfo = {
|
||||
readonly id: string
|
||||
readonly name: string
|
||||
readonly methods: readonly IntegrationMethod[]
|
||||
readonly connections: readonly IntegrationConnection[]
|
||||
}
|
||||
|
||||
export type IntegrationAttempt = {
|
||||
readonly attemptID: string
|
||||
readonly url: string
|
||||
readonly instructions: string
|
||||
readonly mode: "auto" | "code"
|
||||
readonly time: {
|
||||
readonly created: number
|
||||
readonly expires: number
|
||||
}
|
||||
}
|
||||
|
||||
export type IntegrationAttemptStatus =
|
||||
| { readonly status: "pending" }
|
||||
| { readonly status: "complete" }
|
||||
| { readonly status: "failed"; readonly error?: string }
|
||||
| { readonly status: "expired" }
|
||||
|
||||
export interface IntegrationsV2Capability {
|
||||
list(input?: LocationInput, options?: RequestOptions): Promise<readonly IntegrationInfo[]>
|
||||
get(
|
||||
input: LocationInput & { readonly integrationID: string },
|
||||
options?: RequestOptions,
|
||||
): Promise<IntegrationInfo | null>
|
||||
connectKey(
|
||||
input: LocationInput & { readonly integrationID: string; readonly key: string; readonly label?: string },
|
||||
options?: RequestOptions,
|
||||
): Promise<void>
|
||||
connectOauth(
|
||||
input: LocationInput & {
|
||||
readonly integrationID: string
|
||||
readonly methodID: string
|
||||
readonly values: Readonly<Record<string, string>>
|
||||
readonly label?: string
|
||||
},
|
||||
options?: RequestOptions,
|
||||
): Promise<IntegrationAttempt>
|
||||
attemptStatus(
|
||||
input: LocationInput & { readonly attemptID: string },
|
||||
options?: RequestOptions,
|
||||
): Promise<IntegrationAttemptStatus>
|
||||
completeAttempt(
|
||||
input: LocationInput & { readonly attemptID: string; readonly code?: string },
|
||||
options?: RequestOptions,
|
||||
): Promise<void>
|
||||
cancelAttempt(input: LocationInput & { readonly attemptID: string }, options?: RequestOptions): Promise<void>
|
||||
renameCredential(
|
||||
input: LocationInput & { readonly credentialID: string; readonly label: string },
|
||||
options?: RequestOptions,
|
||||
): Promise<void>
|
||||
removeCredential(input: LocationInput & { readonly credentialID: string }, options?: RequestOptions): Promise<void>
|
||||
}
|
||||
|
||||
export type PendingSessionInput = {
|
||||
readonly id: string
|
||||
readonly sessionID: string
|
||||
readonly sequence: number
|
||||
readonly created: number
|
||||
readonly delivery?: "steer" | "queue"
|
||||
readonly raw: unknown
|
||||
}
|
||||
|
||||
export type SessionLogItem = {
|
||||
readonly sequence: number
|
||||
readonly event: AppEvent | { readonly type: "unknown"; readonly raw: unknown }
|
||||
}
|
||||
|
||||
export type InstructionEntry = {
|
||||
readonly key: string
|
||||
readonly value: JsonValue
|
||||
}
|
||||
|
||||
export interface SessionExtrasV2Capability {
|
||||
diff?(
|
||||
input: LocationInput & { readonly sessionID: string },
|
||||
options?: RequestOptions,
|
||||
): Promise<readonly AppFileDiff[]>
|
||||
todos?(
|
||||
input: LocationInput & { readonly sessionID: string },
|
||||
options?: RequestOptions,
|
||||
): Promise<readonly AppTodo[]>
|
||||
switchAgent(
|
||||
input: LocationInput & { readonly sessionID: string; readonly agent: string },
|
||||
options?: RequestOptions,
|
||||
): Promise<void>
|
||||
switchModel(
|
||||
input: LocationInput & { readonly sessionID: string; readonly model: ModelRef },
|
||||
options?: RequestOptions,
|
||||
): Promise<void>
|
||||
move?(
|
||||
input: LocationInput & { readonly sessionID: string; readonly directory: string; readonly moveChanges?: boolean },
|
||||
options?: RequestOptions,
|
||||
): Promise<void>
|
||||
skill?(
|
||||
input: LocationInput & {
|
||||
readonly sessionID: string
|
||||
readonly id?: string
|
||||
readonly skill: string
|
||||
readonly resume?: boolean
|
||||
},
|
||||
options?: RequestOptions,
|
||||
): Promise<void>
|
||||
synthetic?(
|
||||
input: LocationInput & {
|
||||
readonly sessionID: string
|
||||
readonly id?: string
|
||||
readonly text: string
|
||||
readonly description?: string
|
||||
readonly metadata?: Readonly<Record<string, JsonValue>>
|
||||
readonly delivery?: "steer" | "queue"
|
||||
readonly resume?: boolean
|
||||
},
|
||||
options?: RequestOptions,
|
||||
): Promise<PendingSessionInput>
|
||||
shell?(
|
||||
input: LocationInput & { readonly sessionID: string; readonly id?: string; readonly command: string },
|
||||
options?: RequestOptions,
|
||||
): Promise<void>
|
||||
compact?(
|
||||
input: LocationInput & { readonly sessionID: string; readonly id?: string },
|
||||
options?: RequestOptions,
|
||||
): Promise<PendingSessionInput>
|
||||
wait(input: LocationInput & { readonly sessionID: string }, options?: RequestOptions): Promise<void>
|
||||
context(
|
||||
input: LocationInput & { readonly sessionID: string },
|
||||
options?: RequestOptions,
|
||||
): Promise<readonly TimelineItem[]>
|
||||
pending?(
|
||||
input: LocationInput & { readonly sessionID: string },
|
||||
options?: RequestOptions,
|
||||
): Promise<readonly PendingSessionInput[]>
|
||||
instructionEntries?(
|
||||
input: LocationInput & { readonly sessionID: string },
|
||||
options?: RequestOptions,
|
||||
): Promise<readonly InstructionEntry[]>
|
||||
putInstructionEntry?(
|
||||
input: LocationInput & { readonly sessionID: string; readonly key: string; readonly value: JsonValue },
|
||||
options?: RequestOptions,
|
||||
): Promise<void>
|
||||
removeInstructionEntry?(
|
||||
input: LocationInput & { readonly sessionID: string; readonly key: string },
|
||||
options?: RequestOptions,
|
||||
): Promise<void>
|
||||
log(
|
||||
input: LocationInput & { readonly sessionID: string; readonly after?: number; readonly follow?: boolean },
|
||||
options?: RequestOptions,
|
||||
): AsyncIterable<SessionLogItem>
|
||||
background?(input: LocationInput & { readonly sessionID: string }, options?: RequestOptions): Promise<void>
|
||||
stageRevert(
|
||||
input: LocationInput & {
|
||||
readonly sessionID: string
|
||||
readonly messageID: string
|
||||
readonly files?: readonly string[]
|
||||
},
|
||||
options?: RequestOptions,
|
||||
): Promise<{ readonly messageID: string }>
|
||||
clearRevert(input: LocationInput & { readonly sessionID: string }, options?: RequestOptions): Promise<void>
|
||||
commitRevert(input: LocationInput & { readonly sessionID: string }, options?: RequestOptions): Promise<void>
|
||||
}
|
||||
|
||||
export type ProjectDirectory = {
|
||||
readonly directory: string
|
||||
readonly strategy?: string
|
||||
}
|
||||
|
||||
export interface ProjectCopiesV2Capability {
|
||||
directories?(
|
||||
input: LocationInput & { readonly projectID: string },
|
||||
options?: RequestOptions,
|
||||
): Promise<readonly ProjectDirectory[]>
|
||||
create(
|
||||
input: LocationInput & {
|
||||
readonly projectID: string
|
||||
readonly strategy: string
|
||||
readonly directory: string
|
||||
readonly name?: string
|
||||
},
|
||||
options?: RequestOptions,
|
||||
): Promise<{ readonly directory: string }>
|
||||
remove(
|
||||
input: LocationInput & { readonly projectID: string; readonly directory: string; readonly force: boolean },
|
||||
options?: RequestOptions,
|
||||
): Promise<void>
|
||||
refresh(input: LocationInput & { readonly projectID: string }, options?: RequestOptions): Promise<void>
|
||||
}
|
||||
|
||||
export type FormField =
|
||||
| {
|
||||
readonly type: "string"
|
||||
readonly key: string
|
||||
readonly label: string
|
||||
readonly required?: boolean
|
||||
readonly description?: string
|
||||
}
|
||||
| {
|
||||
readonly type: "number" | "integer"
|
||||
readonly key: string
|
||||
readonly label: string
|
||||
readonly required?: boolean
|
||||
readonly description?: string
|
||||
}
|
||||
| {
|
||||
readonly type: "boolean"
|
||||
readonly key: string
|
||||
readonly label: string
|
||||
readonly description?: string
|
||||
}
|
||||
| {
|
||||
readonly type: "multiselect"
|
||||
readonly key: string
|
||||
readonly label: string
|
||||
readonly options: readonly string[]
|
||||
readonly required?: boolean
|
||||
readonly description?: string
|
||||
}
|
||||
| {
|
||||
readonly type: "external"
|
||||
readonly key: string
|
||||
readonly label: string
|
||||
readonly url: string
|
||||
readonly description?: string
|
||||
}
|
||||
|
||||
export type FormInfo = {
|
||||
readonly id: string
|
||||
readonly sessionID: string
|
||||
readonly title: string
|
||||
readonly metadata?: Readonly<Record<string, JsonValue>>
|
||||
readonly fields: readonly FormField[]
|
||||
}
|
||||
|
||||
export type FormAnswer = Readonly<Record<string, string | number | boolean | readonly string[]>>
|
||||
|
||||
export type FormState =
|
||||
| { readonly status: "pending" }
|
||||
| { readonly status: "answered"; readonly answer: FormAnswer }
|
||||
| { readonly status: "cancelled" }
|
||||
|
||||
export interface FormsV2Capability {
|
||||
pending(input?: LocationInput, options?: RequestOptions): Promise<readonly FormInfo[]>
|
||||
list(input: { readonly sessionID: string }, options?: RequestOptions): Promise<readonly FormInfo[]>
|
||||
create(
|
||||
input: {
|
||||
readonly sessionID: string
|
||||
readonly id?: string
|
||||
readonly title: string
|
||||
readonly metadata?: Readonly<Record<string, JsonValue>>
|
||||
readonly fields: readonly FormField[]
|
||||
},
|
||||
options?: RequestOptions,
|
||||
): Promise<FormInfo>
|
||||
get(input: { readonly sessionID: string; readonly formID: string }, options?: RequestOptions): Promise<FormInfo>
|
||||
state(input: { readonly sessionID: string; readonly formID: string }, options?: RequestOptions): Promise<FormState>
|
||||
reply(
|
||||
input: { readonly sessionID: string; readonly formID: string; readonly answer: FormAnswer },
|
||||
options?: RequestOptions,
|
||||
): Promise<void>
|
||||
cancel(input: { readonly sessionID: string; readonly formID: string }, options?: RequestOptions): Promise<void>
|
||||
}
|
||||
|
||||
export type SavedPermission = {
|
||||
readonly id: string
|
||||
readonly projectID: string
|
||||
readonly action: string
|
||||
readonly resource: string
|
||||
}
|
||||
|
||||
export interface SavedPermissionsV2Capability {
|
||||
list(input?: { readonly projectID?: string }, options?: RequestOptions): Promise<readonly SavedPermission[]>
|
||||
remove(input: { readonly id: string }, options?: RequestOptions): Promise<void>
|
||||
}
|
||||
|
||||
export type ShellProcess = {
|
||||
readonly id: string
|
||||
readonly command: string
|
||||
readonly cwd: string
|
||||
readonly status: "running" | "exited"
|
||||
readonly created: number
|
||||
readonly exitCode?: number
|
||||
readonly metadata?: Readonly<Record<string, JsonValue>>
|
||||
}
|
||||
|
||||
export type ShellOutput = {
|
||||
readonly output: string
|
||||
readonly cursor: number
|
||||
readonly size: number
|
||||
readonly truncated: boolean
|
||||
}
|
||||
|
||||
export interface ShellsV2Capability {
|
||||
list(input?: LocationInput, options?: RequestOptions): Promise<readonly ShellProcess[]>
|
||||
create(
|
||||
input: LocationInput & {
|
||||
readonly command: string
|
||||
readonly cwd?: string
|
||||
readonly timeout: number
|
||||
readonly metadata?: Readonly<Record<string, JsonValue>>
|
||||
},
|
||||
options?: RequestOptions,
|
||||
): Promise<ShellProcess>
|
||||
get(input: LocationInput & { readonly id: string }, options?: RequestOptions): Promise<ShellProcess>
|
||||
setTimeout(
|
||||
input: LocationInput & { readonly id: string; readonly timeout: number },
|
||||
options?: RequestOptions,
|
||||
): Promise<ShellProcess>
|
||||
output(
|
||||
input: LocationInput & { readonly id: string; readonly cursor?: number; readonly limit?: number },
|
||||
options?: RequestOptions,
|
||||
): Promise<ShellOutput>
|
||||
remove(input: LocationInput & { readonly id: string }, options?: RequestOptions): Promise<void>
|
||||
}
|
||||
|
||||
export type ServerInfo = {
|
||||
readonly urls: readonly string[]
|
||||
}
|
||||
|
||||
export type LocationInfo = LocationRef & {
|
||||
readonly project: CurrentProject
|
||||
}
|
||||
|
||||
export type PluginInfo = {
|
||||
readonly id: string
|
||||
}
|
||||
|
||||
export type SkillInfo = {
|
||||
readonly id: string
|
||||
readonly name: string
|
||||
readonly description?: string
|
||||
readonly slash?: boolean
|
||||
readonly autoinvoke?: boolean
|
||||
readonly location: LocationRef
|
||||
readonly content: string
|
||||
}
|
||||
|
||||
export interface DiscoveryV2Capability {
|
||||
server(options?: RequestOptions): Promise<ServerInfo>
|
||||
location(input?: LocationInput, options?: RequestOptions): Promise<LocationInfo>
|
||||
plugins(input?: LocationInput, options?: RequestOptions): Promise<readonly PluginInfo[]>
|
||||
skills(input?: LocationInput, options?: RequestOptions): Promise<readonly SkillInfo[]>
|
||||
models(input?: LocationInput, options?: RequestOptions): Promise<readonly AppModel[]>
|
||||
defaultModel(input?: LocationInput, options?: RequestOptions): Promise<AppModel | null>
|
||||
generateText(
|
||||
input: LocationInput & { readonly prompt: string; readonly model?: ModelRef },
|
||||
options?: RequestOptions,
|
||||
): Promise<string>
|
||||
loadedLocations(options?: RequestOptions): Promise<readonly LocationRef[]>
|
||||
}
|
||||
|
||||
export interface Capabilities {
|
||||
readonly projectList?: ProjectListCapability
|
||||
readonly vcs?: VcsApi
|
||||
readonly mcp?: McpApi
|
||||
readonly configuration?: ConfigurationCapability
|
||||
readonly providerAuthV1?: ProviderAuthV1Capability
|
||||
readonly integrationsV2?: IntegrationsV2Capability
|
||||
readonly projectEditing?: ProjectEditingCapability
|
||||
readonly worktreesV1?: WorktreesV1Capability
|
||||
readonly projectCopiesV2?: ProjectCopiesV2Capability
|
||||
readonly sessionExtrasV1?: SessionExtrasV1Capability
|
||||
readonly sessionActionsV1?: SessionActionsV1Capability
|
||||
readonly sessionExtrasV2?: SessionExtrasV2Capability
|
||||
readonly lsp?: LspCapability
|
||||
readonly mcpControl?: McpControlCapability
|
||||
readonly pathInfo?: PathInfoCapability
|
||||
readonly vcsInfo?: VcsInfoCapability
|
||||
readonly decoratedFiles?: DecoratedFileCapability
|
||||
readonly ptyTransport?: PtyTransportCapability
|
||||
readonly shellDiscovery?: ShellDiscoveryCapability
|
||||
readonly shellsV2?: ShellsV2Capability
|
||||
readonly formsV2?: FormsV2Capability
|
||||
readonly savedPermissionsV2?: SavedPermissionsV2Capability
|
||||
readonly discoveryV2?: DiscoveryV2Capability
|
||||
readonly runtimeV1?: RuntimeV1Capability
|
||||
}
|
||||
|
||||
export interface AppClient {
|
||||
readonly version: "v1" | "v2"
|
||||
readonly common: CommonClient
|
||||
readonly capabilities: Capabilities
|
||||
}
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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,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,4 +1,4 @@
|
||||
import type { FileContent } from "@opencode-ai/sdk/v2"
|
||||
import type { DecoratedFileContent as FileContent } from "../backend"
|
||||
|
||||
export type FileSelection = {
|
||||
startLine: number
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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])
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,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 {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,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"
|
||||
|
||||
|
||||
@@ -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,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"
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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" }>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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` },
|
||||
})
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -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: "",
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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[],
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user