refactor(app): route backend through adapters
This commit is contained in:
parent
4c0feeebb4
commit
08ea08e830
130 changed files with 5302 additions and 1953 deletions
1
bun.lock
1
bun.lock
|
|
@ -840,7 +840,6 @@
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@kobalte/core": "catalog:",
|
"@kobalte/core": "catalog:",
|
||||||
"@opencode-ai/core": "workspace:*",
|
"@opencode-ai/core": "workspace:*",
|
||||||
"@opencode-ai/sdk": "workspace:*",
|
|
||||||
"@opencode-ai/ui": "workspace:*",
|
"@opencode-ai/ui": "workspace:*",
|
||||||
"@pierre/diffs": "catalog:",
|
"@pierre/diffs": "catalog:",
|
||||||
"@shikijs/stream": "catalog:",
|
"@shikijs/stream": "catalog:",
|
||||||
|
|
|
||||||
|
|
@ -169,7 +169,7 @@ export function createCommandPaletteModel(props: { filesOnly?: () => boolean; on
|
||||||
const name = store.vcs?.branch ?? getFilename(directory)
|
const name = store.vcs?.branch ?? getFilename(directory)
|
||||||
return `${kind} : ${name || path}`
|
return `${kind} : ${name || path}`
|
||||||
},
|
},
|
||||||
load: (directory) => serverSDK.client.session.list({ directory, roots: true }),
|
load: async (directory) => (await serverSDK.backend).common.sessions.list({ location: { directory }, roots: true }),
|
||||||
untitled: () => language.t("command.session.new"),
|
untitled: () => language.t("command.session.new"),
|
||||||
category: () => language.t("command.category.session"),
|
category: () => language.t("command.category.session"),
|
||||||
})
|
})
|
||||||
|
|
@ -233,7 +233,7 @@ function createCommandEntry(option: CommandOption, category: string): CommandPal
|
||||||
function createSessionEntries(props: {
|
function createSessionEntries(props: {
|
||||||
workspaces: () => string[]
|
workspaces: () => string[]
|
||||||
label: (directory: string) => string
|
label: (directory: string) => string
|
||||||
load: (directory: string) => ReturnType<ServerSDK["client"]["session"]["list"]>
|
load: (directory: string) => ReturnType<Awaited<ServerSDK["backend"]>["common"]["sessions"]["list"]>
|
||||||
untitled: () => string
|
untitled: () => string
|
||||||
category: () => string
|
category: () => string
|
||||||
}) {
|
}) {
|
||||||
|
|
@ -263,7 +263,7 @@ function createSessionEntries(props: {
|
||||||
return props
|
return props
|
||||||
.load(directory)
|
.load(directory)
|
||||||
.then((result) =>
|
.then((result) =>
|
||||||
(result.data ?? [])
|
result.items
|
||||||
.filter((session) => !!session?.id)
|
.filter((session) => !!session?.id)
|
||||||
.map((session) => ({
|
.map((session) => ({
|
||||||
id: session.id,
|
id: session.id,
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import type { ProviderAuthAuthorization, ProviderAuthMethod } from "@opencode-ai/sdk/v2/client"
|
import type { ProviderAuthorization, ProviderAuthMethod } from "@/context/backend"
|
||||||
import { Button } from "@opencode-ai/ui/button"
|
import { Button } from "@opencode-ai/ui/button"
|
||||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||||
import { Dialog } from "@opencode-ai/ui/dialog"
|
import { Dialog } from "@opencode-ai/ui/dialog"
|
||||||
|
|
@ -31,6 +31,7 @@ import { popularProviders, useProviders } from "@/hooks/use-providers"
|
||||||
import { CustomProviderForm } from "./dialog-custom-provider"
|
import { CustomProviderForm } from "./dialog-custom-provider"
|
||||||
|
|
||||||
const CUSTOM_ID = "_custom"
|
const CUSTOM_ID = "_custom"
|
||||||
|
type AuthMethod = ProviderAuthMethod & { readonly id?: string }
|
||||||
|
|
||||||
export function useProviderConnectController(options: { onBack?: () => void } = {}) {
|
export function useProviderConnectController(options: { onBack?: () => void } = {}) {
|
||||||
const [store, setStore] = createStore({ selected: undefined as string | undefined })
|
const [store, setStore] = createStore({ selected: undefined as string | undefined })
|
||||||
|
|
@ -176,6 +177,7 @@ function ProviderConnection(props: {
|
||||||
const providers = useProviders(props.directory)
|
const providers = useProviders(props.directory)
|
||||||
|
|
||||||
const alive = { value: true }
|
const alive = { value: true }
|
||||||
|
const connected = { value: false }
|
||||||
const timer = { current: undefined as ReturnType<typeof setTimeout> | undefined }
|
const timer = { current: undefined as ReturnType<typeof setTimeout> | undefined }
|
||||||
|
|
||||||
onCleanup(() => {
|
onCleanup(() => {
|
||||||
|
|
@ -188,7 +190,16 @@ function ProviderConnection(props: {
|
||||||
const provider = createMemo(
|
const provider = createMemo(
|
||||||
() => providers.all().get(props.provider) ?? serverSync().data.provider.all.get(props.provider)!,
|
() => providers.all().get(props.provider) ?? serverSync().data.provider.all.get(props.provider)!,
|
||||||
)
|
)
|
||||||
const fallback = createMemo<ProviderAuthMethod[]>(() => [
|
const integrationID = () => {
|
||||||
|
const value = provider()
|
||||||
|
if (!("integrationID" in value) || typeof value.integrationID !== "string") return props.provider
|
||||||
|
return value.integrationID
|
||||||
|
}
|
||||||
|
const location = () => {
|
||||||
|
const directory = props.directory?.()
|
||||||
|
return directory ? { location: { directory } } : {}
|
||||||
|
}
|
||||||
|
const fallback = createMemo<AuthMethod[]>(() => [
|
||||||
{
|
{
|
||||||
type: "api" as const,
|
type: "api" as const,
|
||||||
label: language.t("provider.connect.method.apiKey"),
|
label: language.t("provider.connect.method.apiKey"),
|
||||||
|
|
@ -197,19 +208,52 @@ function ProviderConnection(props: {
|
||||||
const [auth] = createResource(
|
const [auth] = createResource(
|
||||||
() => props.provider,
|
() => props.provider,
|
||||||
async () => {
|
async () => {
|
||||||
|
const backend = await serverSDK().backend
|
||||||
|
if (backend.version === "v2") {
|
||||||
|
const capability = backend.capabilities.integrationsV2
|
||||||
|
if (!capability) throw new Error("Server does not support provider integrations")
|
||||||
|
const integration = await capability.get({ ...location(), integrationID: integrationID() })
|
||||||
|
if (!alive.value) return fallback()
|
||||||
|
return (
|
||||||
|
integration?.methods.flatMap((method): AuthMethod[] => {
|
||||||
|
if (method.type === "environment") return []
|
||||||
|
if (method.type === "key") return [{ type: "api", label: method.label }]
|
||||||
|
return [{ type: "oauth", id: method.id, label: method.label, prompts: method.prompts }]
|
||||||
|
}) ?? fallback()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
const cached = serverSync().data.provider_auth[props.provider]
|
const cached = serverSync().data.provider_auth[props.provider]
|
||||||
if (cached) return cached
|
if (cached) return cached
|
||||||
const res = await serverSDK().client.provider.auth()
|
const capability = backend.capabilities.providerAuthV1
|
||||||
|
if (!capability) throw new Error("Server does not support provider authentication")
|
||||||
|
const result = await capability.methods(location())
|
||||||
if (!alive.value) return fallback()
|
if (!alive.value) return fallback()
|
||||||
serverSync().set("provider_auth", res.data ?? {})
|
const normalized = Object.fromEntries(
|
||||||
return res.data?.[props.provider] ?? fallback()
|
Object.entries(result).map(([id, methods]) => [
|
||||||
|
id,
|
||||||
|
methods.map((method) => ({
|
||||||
|
...method,
|
||||||
|
prompts: method.prompts?.map((prompt) =>
|
||||||
|
prompt.type === "select"
|
||||||
|
? { ...prompt, options: prompt.options.map((option) => ({ ...option })) }
|
||||||
|
: { ...prompt },
|
||||||
|
),
|
||||||
|
})),
|
||||||
|
]),
|
||||||
|
)
|
||||||
|
serverSync().set("provider_auth", normalized)
|
||||||
|
return normalized[props.provider] ?? fallback()
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
const loading = createMemo(() => auth.loading && !serverSync().data.provider_auth[props.provider])
|
const loading = createMemo(() => auth.loading && !serverSync().data.provider_auth[props.provider])
|
||||||
const methods = createMemo(() => auth.latest ?? serverSync().data.provider_auth[props.provider] ?? fallback())
|
const methods = createMemo<AuthMethod[]>(() => [
|
||||||
|
...(auth.latest ?? serverSync().data.provider_auth[props.provider] ?? fallback()),
|
||||||
|
])
|
||||||
const [store, setStore] = createStore({
|
const [store, setStore] = createStore({
|
||||||
methodIndex: undefined as undefined | number,
|
methodIndex: undefined as undefined | number,
|
||||||
authorization: undefined as undefined | ProviderAuthAuthorization,
|
authorization: undefined as undefined | ProviderAuthorization,
|
||||||
|
attemptID: undefined as string | undefined,
|
||||||
promptInputs: undefined as undefined | Record<string, string>,
|
promptInputs: undefined as undefined | Record<string, string>,
|
||||||
state: "pending" as undefined | "pending" | "complete" | "error" | "prompt",
|
state: "pending" as undefined | "pending" | "complete" | "error" | "prompt",
|
||||||
error: undefined as string | undefined,
|
error: undefined as string | undefined,
|
||||||
|
|
@ -221,7 +265,7 @@ function ProviderConnection(props: {
|
||||||
| { type: "auth.prompt" }
|
| { type: "auth.prompt" }
|
||||||
| { type: "auth.inputs"; inputs: Record<string, string> }
|
| { type: "auth.inputs"; inputs: Record<string, string> }
|
||||||
| { type: "auth.pending" }
|
| { type: "auth.pending" }
|
||||||
| { type: "auth.complete"; authorization: ProviderAuthAuthorization }
|
| { type: "auth.complete"; authorization: ProviderAuthorization; attemptID?: string }
|
||||||
| { type: "auth.error"; error: string }
|
| { type: "auth.error"; error: string }
|
||||||
|
|
||||||
function dispatch(action: Action) {
|
function dispatch(action: Action) {
|
||||||
|
|
@ -230,6 +274,7 @@ function ProviderConnection(props: {
|
||||||
if (action.type === "method.select") {
|
if (action.type === "method.select") {
|
||||||
draft.methodIndex = action.index
|
draft.methodIndex = action.index
|
||||||
draft.authorization = undefined
|
draft.authorization = undefined
|
||||||
|
draft.attemptID = undefined
|
||||||
draft.promptInputs = undefined
|
draft.promptInputs = undefined
|
||||||
draft.state = undefined
|
draft.state = undefined
|
||||||
draft.error = undefined
|
draft.error = undefined
|
||||||
|
|
@ -238,6 +283,7 @@ function ProviderConnection(props: {
|
||||||
if (action.type === "method.reset") {
|
if (action.type === "method.reset") {
|
||||||
draft.methodIndex = undefined
|
draft.methodIndex = undefined
|
||||||
draft.authorization = undefined
|
draft.authorization = undefined
|
||||||
|
draft.attemptID = undefined
|
||||||
draft.promptInputs = undefined
|
draft.promptInputs = undefined
|
||||||
draft.state = undefined
|
draft.state = undefined
|
||||||
draft.error = undefined
|
draft.error = undefined
|
||||||
|
|
@ -262,6 +308,7 @@ function ProviderConnection(props: {
|
||||||
if (action.type === "auth.complete") {
|
if (action.type === "auth.complete") {
|
||||||
draft.state = "complete"
|
draft.state = "complete"
|
||||||
draft.authorization = action.authorization
|
draft.authorization = action.authorization
|
||||||
|
draft.attemptID = action.attemptID
|
||||||
draft.error = undefined
|
draft.error = undefined
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
@ -273,6 +320,16 @@ function ProviderConnection(props: {
|
||||||
|
|
||||||
const method = createMemo(() => (store.methodIndex !== undefined ? methods().at(store.methodIndex!) : undefined))
|
const method = createMemo(() => (store.methodIndex !== undefined ? methods().at(store.methodIndex!) : undefined))
|
||||||
|
|
||||||
|
onCleanup(() => {
|
||||||
|
if (!store.attemptID || connected.value) return
|
||||||
|
void (async () => {
|
||||||
|
const backend = await serverSDK().backend
|
||||||
|
await backend.capabilities.integrationsV2
|
||||||
|
?.cancelAttempt({ ...location(), attemptID: store.attemptID! })
|
||||||
|
.catch(() => undefined)
|
||||||
|
})()
|
||||||
|
})
|
||||||
|
|
||||||
const methodLabel = (value?: { type?: string; label?: string }) => {
|
const methodLabel = (value?: { type?: string; label?: string }) => {
|
||||||
if (!value) return ""
|
if (!value) return ""
|
||||||
if (value.type === "api") return language.t("provider.connect.method.apiKey")
|
if (value.type === "api") return language.t("provider.connect.method.apiKey")
|
||||||
|
|
@ -322,15 +379,33 @@ function ProviderConnection(props: {
|
||||||
}
|
}
|
||||||
dispatch({ type: "auth.pending" })
|
dispatch({ type: "auth.pending" })
|
||||||
const start = Date.now()
|
const start = Date.now()
|
||||||
await serverSDK()
|
const backend = await serverSDK().backend
|
||||||
.client.provider.oauth.authorize(
|
const request =
|
||||||
{
|
backend.version === "v1"
|
||||||
providerID: props.provider,
|
? (() => {
|
||||||
method: index,
|
const capability = backend.capabilities.providerAuthV1
|
||||||
inputs,
|
if (!capability) throw new Error("Server does not support provider authentication")
|
||||||
},
|
return capability.authorize({ ...location(), providerID: props.provider, method: index, values: inputs })
|
||||||
{ throwOnError: true },
|
})()
|
||||||
)
|
: (() => {
|
||||||
|
const capability = backend.capabilities.integrationsV2
|
||||||
|
if (!capability) throw new Error("Server does not support provider integrations")
|
||||||
|
if (!method.id) throw new Error("Provider OAuth method is missing an ID")
|
||||||
|
return capability
|
||||||
|
.connectOauth({
|
||||||
|
...location(),
|
||||||
|
integrationID: integrationID(),
|
||||||
|
methodID: method.id,
|
||||||
|
values: inputs ?? {},
|
||||||
|
})
|
||||||
|
.then((attempt) => ({
|
||||||
|
url: attempt.url,
|
||||||
|
method: attempt.mode,
|
||||||
|
instructions: attempt.instructions,
|
||||||
|
attemptID: attempt.attemptID,
|
||||||
|
}))
|
||||||
|
})()
|
||||||
|
await request
|
||||||
.then((x) => {
|
.then((x) => {
|
||||||
if (!alive.value) return
|
if (!alive.value) return
|
||||||
const elapsed = Date.now() - start
|
const elapsed = Date.now() - start
|
||||||
|
|
@ -341,11 +416,19 @@ function ProviderConnection(props: {
|
||||||
timer.current = setTimeout(() => {
|
timer.current = setTimeout(() => {
|
||||||
timer.current = undefined
|
timer.current = undefined
|
||||||
if (!alive.value) return
|
if (!alive.value) return
|
||||||
dispatch({ type: "auth.complete", authorization: x.data! })
|
dispatch({
|
||||||
|
type: "auth.complete",
|
||||||
|
authorization: x,
|
||||||
|
attemptID: "attemptID" in x && typeof x.attemptID === "string" ? x.attemptID : undefined,
|
||||||
|
})
|
||||||
}, delay)
|
}, delay)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
dispatch({ type: "auth.complete", authorization: x.data! })
|
dispatch({
|
||||||
|
type: "auth.complete",
|
||||||
|
authorization: x,
|
||||||
|
attemptID: "attemptID" in x && typeof x.attemptID === "string" ? x.attemptID : undefined,
|
||||||
|
})
|
||||||
})
|
})
|
||||||
.catch((e) => {
|
.catch((e) => {
|
||||||
if (!alive.value) return
|
if (!alive.value) return
|
||||||
|
|
@ -445,7 +528,7 @@ function ProviderConnection(props: {
|
||||||
<div>
|
<div>
|
||||||
<List
|
<List
|
||||||
class="px-3"
|
class="px-3"
|
||||||
items={select()?.options ?? []}
|
items={[...(select()?.options ?? [])]}
|
||||||
key={(x) => x.value}
|
key={(x) => x.value}
|
||||||
current={select()?.options.find((x) => x.value === formStore.value[select()!.key])}
|
current={select()?.options.find((x) => x.value === formStore.value[select()!.key])}
|
||||||
onSelect={(value) => {
|
onSelect={(value) => {
|
||||||
|
|
@ -498,7 +581,10 @@ function ProviderConnection(props: {
|
||||||
})
|
})
|
||||||
|
|
||||||
async function complete() {
|
async function complete() {
|
||||||
await serverSDK().client.global.dispose()
|
const backend = await serverSDK().backend
|
||||||
|
if (backend.version === "v1") await backend.capabilities.runtimeV1?.disposeAll()
|
||||||
|
if (backend.version === "v2") await serverSync().refreshProviders()
|
||||||
|
connected.value = true
|
||||||
dialog.close()
|
dialog.close()
|
||||||
showToast({
|
showToast({
|
||||||
variant: "success",
|
variant: "success",
|
||||||
|
|
@ -570,14 +656,20 @@ function ProviderConnection(props: {
|
||||||
}
|
}
|
||||||
|
|
||||||
setFormStore("error", undefined)
|
setFormStore("error", undefined)
|
||||||
await serverSDK().client.auth.set({
|
const backend = await serverSDK().backend
|
||||||
providerID: props.provider,
|
if (backend.version === "v1") {
|
||||||
auth: {
|
const capability = backend.capabilities.providerAuthV1
|
||||||
type: "api",
|
if (!capability) throw new Error("Server does not support provider authentication")
|
||||||
|
await capability.setApiKey({ providerID: props.provider, key: apiKey, metadata: store.promptInputs })
|
||||||
|
} else {
|
||||||
|
const capability = backend.capabilities.integrationsV2
|
||||||
|
if (!capability) throw new Error("Server does not support provider integrations")
|
||||||
|
await capability.connectKey({
|
||||||
|
...location(),
|
||||||
|
integrationID: integrationID(),
|
||||||
key: apiKey,
|
key: apiKey,
|
||||||
...(store.promptInputs ? { metadata: store.promptInputs } : {}),
|
})
|
||||||
},
|
}
|
||||||
})
|
|
||||||
await complete()
|
await complete()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -642,13 +734,20 @@ function ProviderConnection(props: {
|
||||||
}
|
}
|
||||||
|
|
||||||
setFormStore("error", undefined)
|
setFormStore("error", undefined)
|
||||||
const result = await serverSDK()
|
const result = await (async () => {
|
||||||
.client.provider.oauth.callback({
|
const backend = await serverSDK().backend
|
||||||
providerID: props.provider,
|
if (backend.version === "v1") {
|
||||||
method: store.methodIndex,
|
const capability = backend.capabilities.providerAuthV1
|
||||||
code,
|
if (!capability) throw new Error("Server does not support provider authentication")
|
||||||
})
|
await capability.callback({ providerID: props.provider, method: store.methodIndex!, code })
|
||||||
.then((value) => (value.error ? { ok: false as const, error: value.error } : { ok: true as const }))
|
return
|
||||||
|
}
|
||||||
|
const capability = backend.capabilities.integrationsV2
|
||||||
|
if (!capability) throw new Error("Server does not support provider integrations")
|
||||||
|
if (!store.attemptID) throw new Error("Provider OAuth attempt is missing")
|
||||||
|
await capability.completeAttempt({ ...location(), attemptID: store.attemptID, code })
|
||||||
|
})()
|
||||||
|
.then(() => ({ ok: true as const }))
|
||||||
.catch((error) => ({ ok: false as const, error }))
|
.catch((error) => ({ ok: false as const, error }))
|
||||||
if (result.ok) {
|
if (result.ok) {
|
||||||
await complete()
|
await complete()
|
||||||
|
|
@ -695,12 +794,26 @@ function ProviderConnection(props: {
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
void (async () => {
|
void (async () => {
|
||||||
const result = await serverSDK()
|
const result = await (async () => {
|
||||||
.client.provider.oauth.callback({
|
const backend = await serverSDK().backend
|
||||||
providerID: props.provider,
|
if (backend.version === "v1") {
|
||||||
method: store.methodIndex,
|
const capability = backend.capabilities.providerAuthV1
|
||||||
})
|
if (!capability) throw new Error("Server does not support provider authentication")
|
||||||
.then((value) => (value.error ? { ok: false as const, error: value.error } : { ok: true as const }))
|
await capability.callback({ providerID: props.provider, method: store.methodIndex! })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const capability = backend.capabilities.integrationsV2
|
||||||
|
if (!capability) throw new Error("Server does not support provider integrations")
|
||||||
|
if (!store.attemptID) throw new Error("Provider OAuth attempt is missing")
|
||||||
|
while (alive.value) {
|
||||||
|
const status = await capability.attemptStatus({ ...location(), attemptID: store.attemptID })
|
||||||
|
if (status.status === "complete") return
|
||||||
|
if (status.status === "failed") throw new Error(status.error ?? "Authorization failed")
|
||||||
|
if (status.status === "expired") throw new Error("Authorization expired")
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 500))
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
.then(() => ({ ok: true as const }))
|
||||||
.catch((error) => ({ ok: false as const, error }))
|
.catch((error) => ({ ok: false as const, error }))
|
||||||
|
|
||||||
if (!alive.value) return
|
if (!alive.value) return
|
||||||
|
|
|
||||||
|
|
@ -118,7 +118,7 @@ export function CustomProviderForm() {
|
||||||
const output = validateCustomProvider({
|
const output = validateCustomProvider({
|
||||||
form,
|
form,
|
||||||
t: language.t,
|
t: language.t,
|
||||||
disabledProviders: serverSync().data.config.disabled_providers ?? [],
|
disabledProviders: [...(serverSync().data.config.disabledProviders ?? [])],
|
||||||
existingProviderIDs: new Set(serverSync().data.provider.all.keys()),
|
existingProviderIDs: new Set(serverSync().data.provider.all.keys()),
|
||||||
})
|
})
|
||||||
batch(() => {
|
batch(() => {
|
||||||
|
|
@ -131,23 +131,26 @@ export function CustomProviderForm() {
|
||||||
|
|
||||||
const saveMutation = useMutation(() => ({
|
const saveMutation = useMutation(() => ({
|
||||||
mutationFn: async (result: NonNullable<ReturnType<typeof validate>>) => {
|
mutationFn: async (result: NonNullable<ReturnType<typeof validate>>) => {
|
||||||
const disabledProviders = serverSync().data.config.disabled_providers ?? []
|
const disabledProviders = serverSync().data.config.disabledProviders ?? []
|
||||||
const nextDisabled = disabledProviders.filter((id) => id !== result.providerID)
|
const nextDisabled = disabledProviders.filter((id) => id !== result.providerID)
|
||||||
|
const backend = await serverSDK().backend
|
||||||
|
|
||||||
if (result.key) {
|
if (result.key && backend.version === "v1") {
|
||||||
await serverSDK().client.auth.set({
|
const capability = backend.capabilities.providerAuthV1
|
||||||
providerID: result.providerID,
|
if (!capability) throw new Error("Server does not support provider authentication")
|
||||||
auth: {
|
await capability.setApiKey({ providerID: result.providerID, key: result.key })
|
||||||
type: "api",
|
|
||||||
key: result.key,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
await serverSync().updateConfig({
|
await serverSync().updateConfig({
|
||||||
provider: { [result.providerID]: result.config },
|
provider: { [result.providerID]: result.config },
|
||||||
disabled_providers: nextDisabled,
|
disabledProviders: nextDisabled,
|
||||||
})
|
})
|
||||||
|
if (result.key && backend.version === "v2") {
|
||||||
|
const capability = backend.capabilities.integrationsV2
|
||||||
|
if (!capability) throw new Error("Server does not support provider integrations")
|
||||||
|
await capability.connectKey({ integrationID: result.providerID, key: result.key })
|
||||||
|
await serverSync().refreshProviders()
|
||||||
|
}
|
||||||
return result
|
return result
|
||||||
},
|
},
|
||||||
onSuccess: (result) => {
|
onSuccess: (result) => {
|
||||||
|
|
|
||||||
|
|
@ -80,9 +80,11 @@ export function DialogEditProject(props: { project: LocalProject; server: Server
|
||||||
const start = store.startup.trim()
|
const start = store.startup.trim()
|
||||||
|
|
||||||
if (props.project.id && props.project.id !== "global") {
|
if (props.project.id && props.project.id !== "global") {
|
||||||
await serverSDK().client.project.update({
|
const editing = (await serverSDK().backend).capabilities.projectEditing
|
||||||
|
if (!editing) throw new Error("Project editing is not supported by this server")
|
||||||
|
await editing.update({
|
||||||
projectID: props.project.id,
|
projectID: props.project.id,
|
||||||
directory: props.project.worktree,
|
location: { directory: props.project.worktree },
|
||||||
name,
|
name,
|
||||||
icon: { color: store.color || "", override: store.iconOverride || "" },
|
icon: { color: store.color || "", override: store.iconOverride || "" },
|
||||||
commands: { start },
|
commands: { start },
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@ import { Dialog } from "@opencode-ai/ui/dialog"
|
||||||
import { List } from "@opencode-ai/ui/list"
|
import { List } from "@opencode-ai/ui/list"
|
||||||
import { showToast } from "@/utils/toast"
|
import { showToast } from "@/utils/toast"
|
||||||
import { extractPromptFromParts } from "@/utils/prompt"
|
import { extractPromptFromParts } from "@/utils/prompt"
|
||||||
import type { TextPart as SDKTextPart } from "@opencode-ai/sdk/v2/client"
|
import type { AppPart } from "@/context/backend"
|
||||||
import { base64Encode } from "@opencode-ai/core/util/encode"
|
import { base64Encode } from "@opencode-ai/core/util/encode"
|
||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
|
|
||||||
|
|
@ -42,7 +42,9 @@ export const DialogFork: Component = () => {
|
||||||
if (message.role !== "user") continue
|
if (message.role !== "user") continue
|
||||||
|
|
||||||
const parts = sync().data.part[message.id] ?? []
|
const parts = sync().data.part[message.id] ?? []
|
||||||
const textPart = parts.find((x): x is SDKTextPart => x.type === "text" && !x.synthetic && !x.ignored)
|
const textPart = parts.find(
|
||||||
|
(x): x is Extract<AppPart, { type: "text" }> => x.type === "text" && !x.synthetic && !x.ignored,
|
||||||
|
)
|
||||||
if (!textPart) continue
|
if (!textPart) continue
|
||||||
|
|
||||||
result.push({
|
result.push({
|
||||||
|
|
@ -69,15 +71,15 @@ export const DialogFork: Component = () => {
|
||||||
const dir = base64Encode(sdk().directory)
|
const dir = base64Encode(sdk().directory)
|
||||||
|
|
||||||
sdk()
|
sdk()
|
||||||
.client.session.fork({ sessionID, messageID: item.id })
|
.backend.then((client) => {
|
||||||
|
const capability = client.capabilities.sessionActionsV1
|
||||||
|
if (!capability) throw new Error("Session forking is not supported by this server")
|
||||||
|
return capability.fork({ location: { directory: sdk().directory }, sessionID, messageID: item.id })
|
||||||
|
})
|
||||||
.then((forked) => {
|
.then((forked) => {
|
||||||
if (!forked.data) {
|
|
||||||
showToast({ title: language.t("common.requestFailed") })
|
|
||||||
return
|
|
||||||
}
|
|
||||||
dialog.close()
|
dialog.close()
|
||||||
prompt.set(restored, undefined, { dir, id: forked.data.id })
|
prompt.set(restored, undefined, { dir, id: forked.id })
|
||||||
navigate(`/${dir}/session/${forked.data.id}`)
|
navigate(`/${dir}/session/${forked.id}`)
|
||||||
})
|
})
|
||||||
.catch((err: unknown) => {
|
.catch((err: unknown) => {
|
||||||
const message = err instanceof Error ? err.message : String(err)
|
const message = err instanceof Error ? err.message : String(err)
|
||||||
|
|
|
||||||
|
|
@ -68,9 +68,8 @@ export function DialogSelectDirectoryV2(props: DialogSelectDirectoryV2Props) {
|
||||||
const [fallbackPath] = createResource(
|
const [fallbackPath] = createResource(
|
||||||
() => (missingBase() ? true : undefined),
|
() => (missingBase() ? true : undefined),
|
||||||
() =>
|
() =>
|
||||||
sdk.client.path
|
sdk.backend
|
||||||
.get()
|
.then((client) => client.capabilities.pathInfo?.get())
|
||||||
.then((result) => result.data)
|
|
||||||
.catch(() => undefined),
|
.catch(() => undefined),
|
||||||
{ initialValue: undefined },
|
{ initialValue: undefined },
|
||||||
)
|
)
|
||||||
|
|
@ -83,20 +82,26 @@ export function DialogSelectDirectoryV2(props: DialogSelectDirectoryV2Props) {
|
||||||
fallbackPath()?.home ||
|
fallbackPath()?.home ||
|
||||||
fallbackPath()?.directory,
|
fallbackPath()?.directory,
|
||||||
)
|
)
|
||||||
const search = createDirectorySearch({ sdk, home, base: () => root() || start() })
|
const search = createDirectorySearch({ backend: sdk.backend, home, base: () => root() || start() })
|
||||||
const [suggestions] = createResource(input, async (value) => {
|
const [suggestions] = createResource(input, async (value) => {
|
||||||
const typed = cleanPickerInput(value).replace(/\/+$/, "")
|
const typed = cleanPickerInput(value).replace(/\/+$/, "")
|
||||||
const current = displayPickerPath(root(), value, home()).replace(/\/+$/, "")
|
const current = displayPickerPath(root(), value, home()).replace(/\/+$/, "")
|
||||||
if (!typed || typed === current) return { query: value, items: [] }
|
if (!typed || typed === current) return { query: value, items: [] }
|
||||||
const directories = (await search(value)).map((absolute) => ({ absolute, type: "directory" as const }))
|
const directories = (await search(value)).map((absolute) => ({ absolute, type: "directory" as const }))
|
||||||
if (!policy.includeFiles) return { query: value, items: directories.slice(0, 5) }
|
if (!policy.includeFiles) return { query: value, items: directories.slice(0, 5) }
|
||||||
const files = await sdk.client.find
|
const files = await sdk.backend
|
||||||
.files({ directory: root(), query: pickerFileSearchQuery(root(), value, home()), type: "file", limit: 20 })
|
.then((client) =>
|
||||||
.then((result) => result.data ?? [])
|
client.common.files.find({
|
||||||
|
location: { directory: root() },
|
||||||
|
query: pickerFileSearchQuery(root(), value, home()),
|
||||||
|
type: "file",
|
||||||
|
limit: 20,
|
||||||
|
}),
|
||||||
|
)
|
||||||
.catch(() => [])
|
.catch(() => [])
|
||||||
const results = [
|
const results = [
|
||||||
...directories,
|
...directories,
|
||||||
...files.map((path) => ({ absolute: absoluteTreePath(root(), path), type: "file" as const })),
|
...files.map((file) => ({ absolute: file.absolute ?? absoluteTreePath(root(), file.path), type: "file" as const })),
|
||||||
]
|
]
|
||||||
return {
|
return {
|
||||||
query: value,
|
query: value,
|
||||||
|
|
@ -115,9 +120,9 @@ export function DialogSelectDirectoryV2(props: DialogSelectDirectoryV2Props) {
|
||||||
existing ??
|
existing ??
|
||||||
loads.schedule(`${generation}:${key}`, eager ? "background" : "user", () => {
|
loads.schedule(`${generation}:${key}`, eager ? "background" : "user", () => {
|
||||||
if (!activeTreeNavigation(generation, navigation)) return Promise.resolve(undefined)
|
if (!activeTreeNavigation(generation, navigation)) return Promise.resolve(undefined)
|
||||||
return sdk.client.file
|
return sdk.backend
|
||||||
.list({ directory: absolute, path: "" })
|
.then((client) => client.common.files.list({ location: { directory: absolute }, path: "" }))
|
||||||
.then((result) => result.data ?? [])
|
.then((nodes) => nodes.map((node) => ({ name: node.name ?? node.path, type: node.type })))
|
||||||
.catch(() => undefined)
|
.catch(() => undefined)
|
||||||
})
|
})
|
||||||
listings.set(key, request)
|
listings.set(key, request)
|
||||||
|
|
|
||||||
|
|
@ -60,9 +60,8 @@ export function DialogSelectDirectory(props: DialogSelectDirectoryProps) {
|
||||||
const [fallbackPath] = createResource(
|
const [fallbackPath] = createResource(
|
||||||
() => (missingBase() ? true : undefined),
|
() => (missingBase() ? true : undefined),
|
||||||
async () => {
|
async () => {
|
||||||
return sdk.client.path
|
return sdk.backend
|
||||||
.get()
|
.then((client) => client.capabilities.pathInfo?.get())
|
||||||
.then((x) => x.data)
|
|
||||||
.catch(() => undefined)
|
.catch(() => undefined)
|
||||||
},
|
},
|
||||||
{ initialValue: undefined },
|
{ initialValue: undefined },
|
||||||
|
|
@ -74,7 +73,7 @@ export function DialogSelectDirectory(props: DialogSelectDirectoryProps) {
|
||||||
)
|
)
|
||||||
|
|
||||||
const directories = createDirectorySearch({
|
const directories = createDirectorySearch({
|
||||||
sdk,
|
backend: sdk.backend,
|
||||||
home,
|
home,
|
||||||
base: start,
|
base: start,
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,7 @@ import {
|
||||||
pickerRoot,
|
pickerRoot,
|
||||||
pickerAbsoluteInput,
|
pickerAbsoluteInput,
|
||||||
} from "./directory-picker-domain"
|
} from "./directory-picker-domain"
|
||||||
|
import { createAppClient } from "@/context/backend.test-fixture"
|
||||||
|
|
||||||
test("maps server directory entries into Pierre paths", () => {
|
test("maps server directory entries into Pierre paths", () => {
|
||||||
expect(
|
expect(
|
||||||
|
|
@ -132,18 +133,20 @@ test("scopes file autocomplete to the current browser root", () => {
|
||||||
|
|
||||||
test("resolves directory autocomplete from the current browser root", async () => {
|
test("resolves directory autocomplete from the current browser root", async () => {
|
||||||
const directories: string[] = []
|
const directories: string[] = []
|
||||||
const sdk = {
|
const backend = Promise.resolve(
|
||||||
client: {
|
createAppClient({
|
||||||
find: {
|
common: {
|
||||||
files: (input: { directory: string }) => {
|
files: {
|
||||||
directories.push(input.directory)
|
find: (input) => {
|
||||||
return Promise.resolve({ data: [] })
|
directories.push(input.location?.directory ?? "")
|
||||||
|
return Promise.resolve([])
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
}),
|
||||||
} as unknown as Parameters<typeof createDirectorySearch>[0]["sdk"]
|
)
|
||||||
let base = "/repo"
|
let base = "/repo"
|
||||||
const search = createDirectorySearch({ sdk, home: () => "/home/luke", base: () => base })
|
const search = createDirectorySearch({ backend, home: () => "/home/luke", base: () => base })
|
||||||
|
|
||||||
await search("components")
|
await search("components")
|
||||||
base = "/repo/src"
|
base = "/repo/src"
|
||||||
|
|
|
||||||
|
|
@ -247,7 +247,7 @@ export function nativePickerPath(path: string) {
|
||||||
}
|
}
|
||||||
import { getFilename } from "@opencode-ai/core/util/path"
|
import { getFilename } from "@opencode-ai/core/util/path"
|
||||||
import fuzzysort from "fuzzysort"
|
import fuzzysort from "fuzzysort"
|
||||||
import { ServerSDK } from "@/context/server-sdk"
|
import type { AppClient } from "@/context/backend"
|
||||||
|
|
||||||
export function cleanPickerInput(value: string) {
|
export function cleanPickerInput(value: string) {
|
||||||
const first = (value ?? "").split(/\r?\n/)[0] ?? ""
|
const first = (value ?? "").split(/\r?\n/)[0] ?? ""
|
||||||
|
|
@ -321,7 +321,11 @@ export function displayPickerPath(path: string, input: string, home: string) {
|
||||||
return pickerTilde(value, home) || value
|
return pickerTilde(value, home) || value
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createDirectorySearch(args: { sdk: ServerSDK; base: () => string | undefined; home: () => string }) {
|
export function createDirectorySearch(args: {
|
||||||
|
backend: Promise<AppClient>
|
||||||
|
base: () => string | undefined
|
||||||
|
home: () => string
|
||||||
|
}) {
|
||||||
const cache = new Map<string, Promise<Array<{ name: string; absolute: string }>>>()
|
const cache = new Map<string, Promise<Array<{ name: string; absolute: string }>>>()
|
||||||
let current = 0
|
let current = 0
|
||||||
|
|
||||||
|
|
@ -342,14 +346,16 @@ export function createDirectorySearch(args: { sdk: ServerSDK; base: () => string
|
||||||
const key = trimPickerPath(directory)
|
const key = trimPickerPath(directory)
|
||||||
const existing = cache.get(key)
|
const existing = cache.get(key)
|
||||||
if (existing) return existing
|
if (existing) return existing
|
||||||
const request = args.sdk.client.file
|
const request = args.backend
|
||||||
.list({ directory: key, path: "" })
|
.then((client) => client.common.files.list({ location: { directory: key }, path: "" }))
|
||||||
.then((result) => result.data ?? [])
|
|
||||||
.catch(() => [])
|
.catch(() => [])
|
||||||
.then((nodes) =>
|
.then((nodes) =>
|
||||||
nodes
|
nodes
|
||||||
.filter((node) => node.type === "directory")
|
.filter((node) => node.type === "directory")
|
||||||
.map((node) => ({ name: node.name, absolute: trimPickerPath(normalizePickerDrive(node.absolute)) })),
|
.map((node) => ({
|
||||||
|
name: node.name ?? getFilename(node.path),
|
||||||
|
absolute: trimPickerPath(normalizePickerDrive(node.absolute ?? joinPickerPath(key, node.path))),
|
||||||
|
})),
|
||||||
)
|
)
|
||||||
cache.set(key, request)
|
cache.set(key, request)
|
||||||
return request
|
return request
|
||||||
|
|
@ -371,12 +377,18 @@ export function createDirectorySearch(args: { sdk: ServerSDK; base: () => string
|
||||||
const pathInput = raw.startsWith("~") || !!pickerRoot(raw) || raw.includes("/")
|
const pathInput = raw.startsWith("~") || !!pickerRoot(raw) || raw.includes("/")
|
||||||
const query = normalizePickerDrive(input.path)
|
const query = normalizePickerDrive(input.path)
|
||||||
if (!pathInput) {
|
if (!pathInput) {
|
||||||
const results = await args.sdk.client.find
|
const results = await args.backend
|
||||||
.files({ directory: input.directory, query, type: "directory", limit: 50 })
|
.then((client) =>
|
||||||
.then((result) => result.data ?? [])
|
client.common.files.find({
|
||||||
|
location: { directory: input.directory },
|
||||||
|
query,
|
||||||
|
type: "directory",
|
||||||
|
limit: 50,
|
||||||
|
}),
|
||||||
|
)
|
||||||
.catch(() => [])
|
.catch(() => [])
|
||||||
if (!active()) return []
|
if (!active()) return []
|
||||||
return results.map((path) => joinPickerPath(input.directory, path)).slice(0, 50)
|
return results.map((item) => item.absolute ?? joinPickerPath(input.directory, item.path)).slice(0, 50)
|
||||||
}
|
}
|
||||||
const segments = query.replace(/^\/+/, "").split("/")
|
const segments = query.replace(/^\/+/, "").split("/")
|
||||||
const head = segments.slice(0, -1).filter((part) => part && part !== ".")
|
const head = segments.slice(0, -1).filter((part) => part && part !== ".")
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import type { FileNode } from "@opencode-ai/sdk/v2"
|
import type { AppFileNode as FileNode } from "@/context/backend"
|
||||||
|
|
||||||
export type FileTreeV2Model = {
|
export type FileTreeV2Model = {
|
||||||
children: ReadonlyMap<string, readonly FileTreeV2Node[]>
|
children: ReadonlyMap<string, readonly FileTreeV2Node[]>
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,7 @@ import {
|
||||||
type ParentProps,
|
type ParentProps,
|
||||||
} from "solid-js"
|
} from "solid-js"
|
||||||
import { Dynamic } from "solid-js/web"
|
import { Dynamic } from "solid-js/web"
|
||||||
import type { FileNode } from "@opencode-ai/sdk/v2"
|
import type { AppFileNode as FileNode } from "@/context/backend"
|
||||||
import { Icon } from "@opencode-ai/ui/v2/icon"
|
import { Icon } from "@opencode-ai/ui/v2/icon"
|
||||||
import { pathToFileUrl, withFileDragImage, type Kind } from "@/components/file-tree"
|
import { pathToFileUrl, withFileDragImage, type Kind } from "@/components/file-tree"
|
||||||
import { createVirtualizer, defaultRangeExtractor } from "@tanstack/solid-virtual"
|
import { createVirtualizer, defaultRangeExtractor } from "@tanstack/solid-virtual"
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,7 @@ import {
|
||||||
type ParentProps,
|
type ParentProps,
|
||||||
} from "solid-js"
|
} from "solid-js"
|
||||||
import { Dynamic } from "solid-js/web"
|
import { Dynamic } from "solid-js/web"
|
||||||
import type { FileNode } from "@opencode-ai/sdk/v2"
|
import type { AppFileNode as FileNode } from "@/context/backend"
|
||||||
|
|
||||||
const MAX_DEPTH = 128
|
const MAX_DEPTH = 128
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -74,7 +74,7 @@ import { promptPlaceholder } from "./prompt-input/placeholder"
|
||||||
import { createPromptInputTransientState } from "./prompt-input/transient-state"
|
import { createPromptInputTransientState } from "./prompt-input/transient-state"
|
||||||
import { showToast } from "@/utils/toast"
|
import { showToast } from "@/utils/toast"
|
||||||
import { ImagePreview } from "@opencode-ai/ui/image-preview"
|
import { ImagePreview } from "@opencode-ai/ui/image-preview"
|
||||||
import type { ReferenceInfo } from "@opencode-ai/sdk/v2/client"
|
import type { AppReference as ReferenceInfo } from "@/context/backend"
|
||||||
|
|
||||||
export type PromptInputState = ReturnType<typeof usePrompt>
|
export type PromptInputState = ReturnType<typeof usePrompt>
|
||||||
|
|
||||||
|
|
@ -678,7 +678,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||||
type: "resource",
|
type: "resource",
|
||||||
name: resource.name,
|
name: resource.name,
|
||||||
uri: resource.uri,
|
uri: resource.uri,
|
||||||
client: resource.client,
|
client: resource.server,
|
||||||
display: resource.name,
|
display: resource.name,
|
||||||
description: resource.description,
|
description: resource.description,
|
||||||
mime: resource.mimeType,
|
mime: resource.mimeType,
|
||||||
|
|
|
||||||
|
|
@ -46,6 +46,8 @@ describe("buildRequestParts", () => {
|
||||||
).toBe(true)
|
).toBe(true)
|
||||||
|
|
||||||
expect(result.optimisticParts).toHaveLength(result.requestParts.length)
|
expect(result.optimisticParts).toHaveLength(result.requestParts.length)
|
||||||
|
expect(result.optimisticParts.map((part) => part.id)).toEqual(result.requestParts.map((part) => part.id))
|
||||||
|
expect(result.optimisticParts.map((part) => part.type)).toEqual(result.requestParts.map((part) => part.type))
|
||||||
expect(result.optimisticParts.every((part) => part.sessionID === "ses_1" && part.messageID === "msg_1")).toBe(true)
|
expect(result.optimisticParts.every((part) => part.sessionID === "ses_1" && part.messageID === "msg_1")).toBe(true)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,12 @@
|
||||||
import { getFilename } from "@opencode-ai/core/util/path"
|
import { getFilename } from "@opencode-ai/core/util/path"
|
||||||
import { type AgentPartInput, type FilePartInput, type Part, type TextPartInput } from "@opencode-ai/sdk/v2/client"
|
import type { AppPart as Part, PromptPart } from "@/context/backend"
|
||||||
import type { FileSelection } from "@/context/file"
|
import type { FileSelection } from "@/context/file"
|
||||||
import { encodeFilePath } from "@/context/file/path"
|
import { encodeFilePath } from "@/context/file/path"
|
||||||
import type { AgentPart, FileAttachmentPart, ImageAttachmentPart, Prompt } from "@/context/prompt"
|
import type { AgentPart, FileAttachmentPart, ImageAttachmentPart, Prompt } from "@/context/prompt"
|
||||||
import { Identifier } from "@/utils/id"
|
import { Identifier } from "@/utils/id"
|
||||||
import { createCommentMetadata, formatCommentNote } from "@/utils/comment-note"
|
import { createCommentMetadata, formatCommentNote } from "@/utils/comment-note"
|
||||||
|
|
||||||
type PromptRequestPart = (TextPartInput | FilePartInput | AgentPartInput) & { id: string }
|
type PromptRequestPart = PromptPart
|
||||||
|
|
||||||
type ContextFile = {
|
type ContextFile = {
|
||||||
key: string
|
key: string
|
||||||
|
|
|
||||||
|
|
@ -81,6 +81,34 @@ const clientFor = (directory: string) => {
|
||||||
|
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
const rootClient = clientFor("/repo/main")
|
const rootClient = clientFor("/repo/main")
|
||||||
|
const backend = Promise.resolve({
|
||||||
|
common: {
|
||||||
|
sessions: {
|
||||||
|
create: async (input: { location?: { directory?: string } }) => {
|
||||||
|
const directory = input.location?.directory ?? "/repo/main"
|
||||||
|
createdSessions.push(directory)
|
||||||
|
return {
|
||||||
|
id: `session-${createdSessions.length}`,
|
||||||
|
projectID: "project",
|
||||||
|
location: { directory },
|
||||||
|
title: `New session ${createdSessions.length}`,
|
||||||
|
cost: 0,
|
||||||
|
time: { created: Date.now() },
|
||||||
|
}
|
||||||
|
},
|
||||||
|
prompt: async () => undefined,
|
||||||
|
command: async () => undefined,
|
||||||
|
interrupt: async () => undefined,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
capabilities: {
|
||||||
|
sessionExtrasV1: {
|
||||||
|
shell: async (input: { location?: { directory?: string } }) => {
|
||||||
|
sentShell.push(input.location?.directory ?? "/repo/main")
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
mock.module("@solidjs/router", () => ({
|
mock.module("@solidjs/router", () => ({
|
||||||
useNavigate: () => () => undefined,
|
useNavigate: () => () => undefined,
|
||||||
|
|
@ -89,13 +117,6 @@ beforeAll(async () => {
|
||||||
useSearchParams: () => [search, () => undefined],
|
useSearchParams: () => [search, () => undefined],
|
||||||
}))
|
}))
|
||||||
|
|
||||||
mock.module("@opencode-ai/sdk/v2/client", () => ({
|
|
||||||
createOpencodeClient: (input: { directory: string }) => {
|
|
||||||
createdClients.push(input.directory)
|
|
||||||
return clientFor(input.directory)
|
|
||||||
},
|
|
||||||
}))
|
|
||||||
|
|
||||||
mock.module("@opencode-ai/ui/toast", () => ({
|
mock.module("@opencode-ai/ui/toast", () => ({
|
||||||
Toast: { Region: () => null },
|
Toast: { Region: () => null },
|
||||||
showToast: () => 0,
|
showToast: () => 0,
|
||||||
|
|
@ -161,6 +182,7 @@ beforeAll(async () => {
|
||||||
scope: "local",
|
scope: "local",
|
||||||
directory: "/repo/main",
|
directory: "/repo/main",
|
||||||
client: rootClient,
|
client: rootClient,
|
||||||
|
backend,
|
||||||
url: "http://localhost:4096",
|
url: "http://localhost:4096",
|
||||||
createClient(opts: any) {
|
createClient(opts: any) {
|
||||||
return clientFor(opts.directory)
|
return clientFor(opts.directory)
|
||||||
|
|
@ -282,7 +304,7 @@ describe("prompt submit worktree selection", () => {
|
||||||
selected = "/repo/worktree-b"
|
selected = "/repo/worktree-b"
|
||||||
await submit.handleSubmit(event)
|
await submit.handleSubmit(event)
|
||||||
|
|
||||||
expect(createdClients).toEqual(["/repo/worktree-a", "/repo/worktree-b"])
|
expect(createdClients).toEqual([])
|
||||||
expect(createdSessions).toEqual(["/repo/worktree-a", "/repo/worktree-b"])
|
expect(createdSessions).toEqual(["/repo/worktree-a", "/repo/worktree-b"])
|
||||||
expect(sentShell).toEqual(["/repo/worktree-a", "/repo/worktree-b"])
|
expect(sentShell).toEqual(["/repo/worktree-a", "/repo/worktree-b"])
|
||||||
expect(syncedDirectories).toEqual(["/repo/worktree-a", "/repo/worktree-a", "/repo/worktree-b", "/repo/worktree-b"])
|
expect(syncedDirectories).toEqual(["/repo/worktree-a", "/repo/worktree-a", "/repo/worktree-b", "/repo/worktree-b"])
|
||||||
|
|
@ -441,7 +463,13 @@ describe("prompt submit worktree selection", () => {
|
||||||
|
|
||||||
await submit.handleSubmit(event)
|
await submit.handleSubmit(event)
|
||||||
|
|
||||||
expect(storedSessions["/repo/worktree-a"]).toEqual([{ id: "session-1", title: "New session 1" }])
|
expect(storedSessions["/repo/worktree-a"]).toEqual([
|
||||||
|
expect.objectContaining({
|
||||||
|
id: "session-1",
|
||||||
|
title: "New session 1",
|
||||||
|
location: { directory: "/repo/worktree-a" },
|
||||||
|
}),
|
||||||
|
])
|
||||||
expect(optimisticSeeded).toEqual([true])
|
expect(optimisticSeeded).toEqual([true])
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,3 @@
|
||||||
import type { Message, Session } from "@opencode-ai/sdk/v2/client"
|
|
||||||
import { showToast } from "@/utils/toast"
|
import { showToast } from "@/utils/toast"
|
||||||
import { base64Encode } from "@opencode-ai/core/util/encode"
|
import { base64Encode } from "@opencode-ai/core/util/encode"
|
||||||
import { Binary } from "@opencode-ai/core/util/binary"
|
import { Binary } from "@opencode-ai/core/util/binary"
|
||||||
|
|
@ -11,7 +10,7 @@ import { useLayout } from "@/context/layout"
|
||||||
import { useLocal, type ModelSelection } from "@/context/local"
|
import { useLocal, type ModelSelection } from "@/context/local"
|
||||||
import { usePermission } from "@/context/permission"
|
import { usePermission } from "@/context/permission"
|
||||||
import { type ContextItem, type ImageAttachmentPart, type Prompt, type usePrompt } from "@/context/prompt"
|
import { type ContextItem, type ImageAttachmentPart, type Prompt, type usePrompt } from "@/context/prompt"
|
||||||
import { useSDK, type DirectorySDK } from "@/context/sdk"
|
import { useSDK } from "@/context/sdk"
|
||||||
import { useSync, type DirectorySync } from "@/context/sync"
|
import { useSync, type DirectorySync } from "@/context/sync"
|
||||||
import { Identifier } from "@/utils/id"
|
import { Identifier } from "@/utils/id"
|
||||||
import { Worktree as WorktreeState } from "@/utils/worktree"
|
import { Worktree as WorktreeState } from "@/utils/worktree"
|
||||||
|
|
@ -20,6 +19,7 @@ import { setCursorPosition } from "./editor-dom"
|
||||||
import { formatServerError } from "@/utils/server-errors"
|
import { formatServerError } from "@/utils/server-errors"
|
||||||
import { ScopedKey } from "@/utils/server-scope"
|
import { ScopedKey } from "@/utils/server-scope"
|
||||||
import { createPromptSubmissionState } from "./submission-state"
|
import { createPromptSubmissionState } from "./submission-state"
|
||||||
|
import type { AppClient, AppMessage, AppSession } from "@/context/backend"
|
||||||
|
|
||||||
type PendingPrompt = {
|
type PendingPrompt = {
|
||||||
abort: AbortController
|
abort: AbortController
|
||||||
|
|
@ -39,13 +39,14 @@ export type FollowupDraft = {
|
||||||
}
|
}
|
||||||
|
|
||||||
type FollowupSendInput = {
|
type FollowupSendInput = {
|
||||||
client: DirectorySDK["client"]
|
backend: Promise<AppClient>
|
||||||
serverSync: ServerSync
|
serverSync: ServerSync
|
||||||
sync: DirectorySync
|
sync: DirectorySync
|
||||||
draft: FollowupDraft
|
draft: FollowupDraft
|
||||||
messageID?: string
|
messageID?: string
|
||||||
optimisticBusy?: boolean
|
optimisticBusy?: boolean
|
||||||
before?: () => Promise<boolean> | boolean
|
before?: () => Promise<boolean> | boolean
|
||||||
|
commitRevert?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
const draftText = (prompt: Prompt) => prompt.map((part) => ("content" in part ? part.content : "")).join("")
|
const draftText = (prompt: Prompt) => prompt.map((part) => ("content" in part ? part.content : "")).join("")
|
||||||
|
|
@ -53,6 +54,8 @@ const draftText = (prompt: Prompt) => prompt.map((part) => ("content" in part ?
|
||||||
const draftImages = (prompt: Prompt) => prompt.filter((part): part is ImageAttachmentPart => part.type === "image")
|
const draftImages = (prompt: Prompt) => prompt.filter((part): part is ImageAttachmentPart => part.type === "image")
|
||||||
|
|
||||||
export async function sendFollowupDraft(input: FollowupSendInput) {
|
export async function sendFollowupDraft(input: FollowupSendInput) {
|
||||||
|
const backend = await input.backend
|
||||||
|
const location = { directory: input.draft.sessionDirectory }
|
||||||
const text = draftText(input.draft.prompt)
|
const text = draftText(input.draft.prompt)
|
||||||
const images = draftImages(input.draft.prompt)
|
const images = draftImages(input.draft.prompt)
|
||||||
const setBusy = () => {
|
const setBusy = () => {
|
||||||
|
|
@ -81,19 +84,26 @@ export async function sendFollowupDraft(input: FollowupSendInput) {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
await input.client.session.command({
|
if (input.commitRevert)
|
||||||
|
await backend.capabilities.sessionExtrasV2?.commitRevert({ location, sessionID: input.draft.sessionID })
|
||||||
|
const capability = backend.capabilities.sessionActionsV1
|
||||||
|
if (!capability) throw new Error("Commands are not supported by this server")
|
||||||
|
await capability.command({
|
||||||
|
location,
|
||||||
sessionID: input.draft.sessionID,
|
sessionID: input.draft.sessionID,
|
||||||
|
id: input.messageID,
|
||||||
command: cmd,
|
command: cmd,
|
||||||
arguments: tail.join(" "),
|
arguments: tail.join(" "),
|
||||||
agent: input.draft.agent,
|
agent: input.draft.agent,
|
||||||
model: `${input.draft.model.providerID}/${input.draft.model.modelID}`,
|
model: {
|
||||||
variant: input.draft.variant,
|
providerID: input.draft.model.providerID,
|
||||||
parts: images.map((attachment) => ({
|
id: input.draft.model.modelID,
|
||||||
id: Identifier.ascending("part"),
|
variant: input.draft.variant,
|
||||||
type: "file" as const,
|
},
|
||||||
|
files: images.map((attachment) => ({
|
||||||
|
uri: attachment.dataUrl,
|
||||||
mime: attachment.mime,
|
mime: attachment.mime,
|
||||||
url: attachment.dataUrl,
|
name: attachment.filename,
|
||||||
filename: attachment.filename,
|
|
||||||
})),
|
})),
|
||||||
})
|
})
|
||||||
return true
|
return true
|
||||||
|
|
@ -114,7 +124,7 @@ export async function sendFollowupDraft(input: FollowupSendInput) {
|
||||||
sessionDirectory: input.draft.sessionDirectory,
|
sessionDirectory: input.draft.sessionDirectory,
|
||||||
})
|
})
|
||||||
|
|
||||||
const message: Message = {
|
const message: AppMessage = {
|
||||||
id: messageID,
|
id: messageID,
|
||||||
sessionID: input.draft.sessionID,
|
sessionID: input.draft.sessionID,
|
||||||
role: "user",
|
role: "user",
|
||||||
|
|
@ -152,13 +162,22 @@ export async function sendFollowupDraft(input: FollowupSendInput) {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
await input.client.session.promptAsync({
|
if (input.commitRevert)
|
||||||
|
await backend.capabilities.sessionExtrasV2?.commitRevert({ location, sessionID: input.draft.sessionID })
|
||||||
|
await backend.common.sessions.prompt({
|
||||||
|
location,
|
||||||
sessionID: input.draft.sessionID,
|
sessionID: input.draft.sessionID,
|
||||||
agent: input.draft.agent,
|
id: messageID,
|
||||||
model: input.draft.model,
|
text,
|
||||||
messageID,
|
|
||||||
parts: requestParts,
|
parts: requestParts,
|
||||||
variant: input.draft.variant,
|
selection: {
|
||||||
|
agent: input.draft.agent,
|
||||||
|
model: {
|
||||||
|
providerID: input.draft.model.providerID,
|
||||||
|
id: input.draft.model.modelID,
|
||||||
|
variant: input.draft.variant,
|
||||||
|
},
|
||||||
|
},
|
||||||
})
|
})
|
||||||
return true
|
return true
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|
@ -172,7 +191,7 @@ export async function sendFollowupDraft(input: FollowupSendInput) {
|
||||||
|
|
||||||
type PromptSubmitInput = {
|
type PromptSubmitInput = {
|
||||||
prompt: ReturnType<typeof usePrompt>
|
prompt: ReturnType<typeof usePrompt>
|
||||||
info: Accessor<{ id: string } | undefined>
|
info: Accessor<{ id: string; revert?: { messageID: string } } | undefined>
|
||||||
imageAttachments: Accessor<ImageAttachmentPart[]>
|
imageAttachments: Accessor<ImageAttachmentPart[]>
|
||||||
commentCount: Accessor<number>
|
commentCount: Accessor<number>
|
||||||
autoAccept: Accessor<boolean>
|
autoAccept: Accessor<boolean>
|
||||||
|
|
@ -233,9 +252,9 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
||||||
return Promise.resolve()
|
return Promise.resolve()
|
||||||
}
|
}
|
||||||
return sdk()
|
return sdk()
|
||||||
.client.session.abort({
|
.backend.then((client) =>
|
||||||
sessionID,
|
client.common.sessions.interrupt({ location: { directory: sdk().directory }, sessionID }),
|
||||||
})
|
)
|
||||||
.catch(() => {})
|
.catch(() => {})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -262,10 +281,10 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const seed = (dir: string, info: Session) => {
|
const seed = (dir: string, info: AppSession) => {
|
||||||
serverSync().session.remember(info)
|
serverSync().session.remember(info)
|
||||||
const [, setStore] = serverSync().child(dir)
|
const [, setStore] = serverSync().child(dir)
|
||||||
setStore("session", (list: Session[]) => {
|
setStore("session", (list: AppSession[]) => {
|
||||||
const result = Binary.search(list, info.id, (item) => item.id)
|
const result = Binary.search(list, info.id, (item) => item.id)
|
||||||
const next = [...list]
|
const next = [...list]
|
||||||
if (result.found) {
|
if (result.found) {
|
||||||
|
|
@ -313,19 +332,18 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
||||||
input.resetHistoryNavigation()
|
input.resetHistoryNavigation()
|
||||||
|
|
||||||
const projectDirectory = sdk().directory
|
const projectDirectory = sdk().directory
|
||||||
|
const backend = await sdk().backend
|
||||||
const isNewSession = !params.id
|
const isNewSession = !params.id
|
||||||
const shouldAutoAccept = isNewSession && input.autoAccept()
|
const shouldAutoAccept = isNewSession && input.autoAccept()
|
||||||
const worktreeSelection = input.newSessionWorktree?.() || "main"
|
const worktreeSelection = input.newSessionWorktree?.() || "main"
|
||||||
|
|
||||||
let sessionDirectory = projectDirectory
|
let sessionDirectory = projectDirectory
|
||||||
let client = sdk().client
|
|
||||||
|
|
||||||
if (isNewSession) {
|
if (isNewSession) {
|
||||||
if (worktreeSelection === "create") {
|
if (worktreeSelection === "create") {
|
||||||
const createdWorktree = await client.worktree
|
const createdWorktree = await backend.capabilities.worktreesV1
|
||||||
.create({ directory: projectDirectory })
|
?.create({ location: { directory: projectDirectory } })
|
||||||
.then((x) => x.data)
|
.catch((err: unknown) => {
|
||||||
.catch((err) => {
|
|
||||||
showToast({
|
showToast({
|
||||||
title: language.t("prompt.toast.worktreeCreateFailed.title"),
|
title: language.t("prompt.toast.worktreeCreateFailed.title"),
|
||||||
description: errorMessage(err),
|
description: errorMessage(err),
|
||||||
|
|
@ -349,10 +367,6 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (sessionDirectory !== projectDirectory) {
|
if (sessionDirectory !== projectDirectory) {
|
||||||
client = sdk().createClient({
|
|
||||||
directory: sessionDirectory,
|
|
||||||
throwOnError: true,
|
|
||||||
})
|
|
||||||
serverSync().child(sessionDirectory)
|
serverSync().child(sessionDirectory)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -361,9 +375,10 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
||||||
|
|
||||||
let session = input.info()
|
let session = input.info()
|
||||||
if (!session && isNewSession) {
|
if (!session && isNewSession) {
|
||||||
const created = await client.session
|
const created = await sdk()
|
||||||
.create()
|
.backend.then((backend) =>
|
||||||
.then((x) => x.data ?? undefined)
|
backend.common.sessions.create({ location: { directory: sessionDirectory } }),
|
||||||
|
)
|
||||||
.catch((err) => {
|
.catch((err) => {
|
||||||
showToast({
|
showToast({
|
||||||
title: language.t("prompt.toast.sessionCreateFailed.title"),
|
title: language.t("prompt.toast.sessionCreateFailed.title"),
|
||||||
|
|
@ -447,12 +462,22 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
||||||
|
|
||||||
if (mode === "shell") {
|
if (mode === "shell") {
|
||||||
clearInput()
|
clearInput()
|
||||||
client.session
|
sdk()
|
||||||
.shell({
|
.backend.then(async (backend) => {
|
||||||
sessionID: session.id,
|
const location = { directory: sessionDirectory }
|
||||||
agent,
|
if (session.revert)
|
||||||
model,
|
await backend.capabilities.sessionExtrasV2?.commitRevert({ location, sessionID: session.id })
|
||||||
command: text,
|
if (backend.capabilities.sessionExtrasV1)
|
||||||
|
return backend.capabilities.sessionExtrasV1.shell({
|
||||||
|
location,
|
||||||
|
sessionID: session.id,
|
||||||
|
agent,
|
||||||
|
model: { id: model.modelID, providerID: model.providerID },
|
||||||
|
command: text,
|
||||||
|
})
|
||||||
|
if (backend.capabilities.sessionExtrasV2?.shell)
|
||||||
|
return backend.capabilities.sessionExtrasV2.shell({ location, sessionID: session.id, command: text })
|
||||||
|
throw new Error("Shell prompts are not supported by this server")
|
||||||
})
|
})
|
||||||
.catch((err) => {
|
.catch((err) => {
|
||||||
showToast({
|
showToast({
|
||||||
|
|
@ -470,21 +495,27 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
||||||
const customCommand = sync().data.command.find((c) => c.name === commandName)
|
const customCommand = sync().data.command.find((c) => c.name === commandName)
|
||||||
if (customCommand) {
|
if (customCommand) {
|
||||||
clearInput()
|
clearInput()
|
||||||
client.session
|
sdk()
|
||||||
.command({
|
.backend.then(async (backend) => {
|
||||||
sessionID: session.id,
|
const location = { directory: sessionDirectory }
|
||||||
command: commandName,
|
if (session.revert)
|
||||||
arguments: args.join(" "),
|
await backend.capabilities.sessionExtrasV2?.commitRevert({ location, sessionID: session.id })
|
||||||
agent,
|
const capability = backend.capabilities.sessionActionsV1
|
||||||
model: `${model.providerID}/${model.modelID}`,
|
if (!capability) throw new Error("Commands are not supported by this server")
|
||||||
variant,
|
return capability.command({
|
||||||
parts: images.map((attachment) => ({
|
location,
|
||||||
id: Identifier.ascending("part"),
|
sessionID: session.id,
|
||||||
type: "file" as const,
|
id: Identifier.ascending("message"),
|
||||||
mime: attachment.mime,
|
command: commandName,
|
||||||
url: attachment.dataUrl,
|
arguments: args.join(" "),
|
||||||
filename: attachment.filename,
|
agent,
|
||||||
})),
|
model: { id: model.modelID, providerID: model.providerID, variant },
|
||||||
|
files: images.map((attachment) => ({
|
||||||
|
uri: attachment.dataUrl,
|
||||||
|
mime: attachment.mime,
|
||||||
|
name: attachment.filename,
|
||||||
|
})),
|
||||||
|
})
|
||||||
})
|
})
|
||||||
.catch((err) => {
|
.catch((err) => {
|
||||||
showToast({
|
showToast({
|
||||||
|
|
@ -570,12 +601,13 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
||||||
}
|
}
|
||||||
|
|
||||||
void sendFollowupDraft({
|
void sendFollowupDraft({
|
||||||
client,
|
backend: sdk().backend,
|
||||||
sync: sync(),
|
sync: sync(),
|
||||||
serverSync: serverSync(),
|
serverSync: serverSync(),
|
||||||
draft,
|
draft,
|
||||||
messageID,
|
messageID,
|
||||||
optimisticBusy: sessionDirectory === projectDirectory,
|
optimisticBusy: sessionDirectory === projectDirectory,
|
||||||
|
commitRevert: !!session.revert,
|
||||||
before: waitForWorktree,
|
before: waitForWorktree,
|
||||||
}).catch((err) => {
|
}).catch((err) => {
|
||||||
pending.delete(pendingKey(session.id))
|
pending.delete(pendingKey(session.id))
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { describe, expect, test } from "bun:test"
|
import { describe, expect, test } from "bun:test"
|
||||||
import type { Message, Part } from "@opencode-ai/sdk/v2/client"
|
import type { AppMessage as Message, AppPart as Part } from "@/context/backend"
|
||||||
import { estimateSessionContextBreakdown } from "./session-context-breakdown"
|
import { estimateSessionContextBreakdown } from "./session-context-breakdown"
|
||||||
|
|
||||||
const user = (id: string) => {
|
const user = (id: string) => {
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import type { Message, Part } from "@opencode-ai/sdk/v2/client"
|
import type { AppMessage, AppPart } from "@/context/backend"
|
||||||
|
|
||||||
export type SessionContextBreakdownKey = "system" | "user" | "assistant" | "tool" | "other"
|
export type SessionContextBreakdownKey = "system" | "user" | "assistant" | "tool" | "other"
|
||||||
|
|
||||||
|
|
@ -13,14 +13,14 @@ const estimateTokens = (chars: number) => Math.ceil(chars / 4)
|
||||||
const toPercent = (tokens: number, input: number) => (tokens / input) * 100
|
const toPercent = (tokens: number, input: number) => (tokens / input) * 100
|
||||||
const toPercentLabel = (tokens: number, input: number) => Math.round(toPercent(tokens, input) * 10) / 10
|
const toPercentLabel = (tokens: number, input: number) => Math.round(toPercent(tokens, input) * 10) / 10
|
||||||
|
|
||||||
const charsFromUserPart = (part: Part) => {
|
const charsFromUserPart = (part: AppPart) => {
|
||||||
if (part.type === "text") return part.text.length
|
if (part.type === "text") return part.text.length
|
||||||
if (part.type === "file") return part.source?.text.value.length ?? 0
|
if (part.type === "file") return part.source?.text.value.length ?? 0
|
||||||
if (part.type === "agent") return part.source?.value.length ?? 0
|
if (part.type === "agent") return part.source?.value.length ?? 0
|
||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
|
|
||||||
const charsFromAssistantPart = (part: Part) => {
|
const charsFromAssistantPart = (part: AppPart) => {
|
||||||
if (part.type === "text") return { assistant: part.text.length, tool: 0 }
|
if (part.type === "text") return { assistant: part.text.length, tool: 0 }
|
||||||
if (part.type === "reasoning") return { assistant: part.text.length, tool: 0 }
|
if (part.type === "reasoning") return { assistant: part.text.length, tool: 0 }
|
||||||
if (part.type !== "tool") return { assistant: 0, tool: 0 }
|
if (part.type !== "tool") return { assistant: 0, tool: 0 }
|
||||||
|
|
@ -68,8 +68,8 @@ const build = (
|
||||||
}
|
}
|
||||||
|
|
||||||
export function estimateSessionContextBreakdown(args: {
|
export function estimateSessionContextBreakdown(args: {
|
||||||
messages: Message[]
|
messages: AppMessage[]
|
||||||
parts: Record<string, Part[] | undefined>
|
parts: Record<string, AppPart[] | undefined>
|
||||||
input: number
|
input: number
|
||||||
systemPrompt?: string
|
systemPrompt?: string
|
||||||
}) {
|
}) {
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { describe, expect, test } from "bun:test"
|
import { describe, expect, test } from "bun:test"
|
||||||
import type { Message } from "@opencode-ai/sdk/v2/client"
|
import type { AppMessage as Message } from "@/context/backend"
|
||||||
import { getSessionContext } from "./session-context-metrics"
|
import { getSessionContext } from "./session-context-metrics"
|
||||||
|
|
||||||
const assistant = (
|
const assistant = (
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import type { AssistantMessage, Message } from "@opencode-ai/sdk/v2/client"
|
import type { AppAssistantMessage, AppMessage } from "@/context/backend"
|
||||||
|
|
||||||
type Provider = {
|
type Provider = {
|
||||||
id: string
|
id: string
|
||||||
|
|
@ -14,7 +14,7 @@ type Model = {
|
||||||
}
|
}
|
||||||
|
|
||||||
type Context = {
|
type Context = {
|
||||||
message: AssistantMessage
|
message: AppAssistantMessage
|
||||||
provider?: Provider
|
provider?: Provider
|
||||||
model?: Model
|
model?: Model
|
||||||
providerLabel: string
|
providerLabel: string
|
||||||
|
|
@ -25,11 +25,11 @@ type Context = {
|
||||||
usage: number | null
|
usage: number | null
|
||||||
}
|
}
|
||||||
|
|
||||||
const tokenTotal = (msg: AssistantMessage) => {
|
const tokenTotal = (msg: AppAssistantMessage) => {
|
||||||
return msg.tokens.input + msg.tokens.output + msg.tokens.reasoning + msg.tokens.cache.read + msg.tokens.cache.write
|
return msg.tokens.input + msg.tokens.output + msg.tokens.reasoning + msg.tokens.cache.read + msg.tokens.cache.write
|
||||||
}
|
}
|
||||||
|
|
||||||
const lastAssistantWithTokens = (messages: Message[]) => {
|
const lastAssistantWithTokens = (messages: AppMessage[]) => {
|
||||||
for (let i = messages.length - 1; i >= 0; i--) {
|
for (let i = messages.length - 1; i >= 0; i--) {
|
||||||
const msg = messages[i]
|
const msg = messages[i]
|
||||||
if (msg.role !== "assistant") continue
|
if (msg.role !== "assistant") continue
|
||||||
|
|
@ -38,7 +38,7 @@ const lastAssistantWithTokens = (messages: Message[]) => {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const build = (messages: Message[] = [], providers: Provider[] = []): Context | undefined => {
|
const build = (messages: AppMessage[] = [], providers: Provider[] = []): Context | undefined => {
|
||||||
const message = lastAssistantWithTokens(messages)
|
const message = lastAssistantWithTokens(messages)
|
||||||
if (!message) return undefined
|
if (!message) return undefined
|
||||||
|
|
||||||
|
|
@ -60,6 +60,6 @@ const build = (messages: Message[] = [], providers: Provider[] = []): Context |
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getSessionContext(messages: Message[] = [], providers: Provider[] = []) {
|
export function getSessionContext(messages: AppMessage[] = [], providers: Provider[] = []) {
|
||||||
return build(messages, providers)
|
return build(messages, providers)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,7 @@ import { StickyAccordionHeader } from "@opencode-ai/ui/sticky-accordion-header"
|
||||||
import { File } from "@opencode-ai/session-ui/file"
|
import { File } from "@opencode-ai/session-ui/file"
|
||||||
import { Markdown } from "@opencode-ai/session-ui/markdown"
|
import { Markdown } from "@opencode-ai/session-ui/markdown"
|
||||||
import { ScrollView } from "@opencode-ai/ui/scroll-view"
|
import { ScrollView } from "@opencode-ai/ui/scroll-view"
|
||||||
import type { Message, Part, UserMessage } from "@opencode-ai/sdk/v2/client"
|
import type { AppMessage as Message, AppPart as Part, AppUserMessage as UserMessage } from "@/context/backend"
|
||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
import { useProviders } from "@/hooks/use-providers"
|
import { useProviders } from "@/hooks/use-providers"
|
||||||
import { useSDK } from "@/context/sdk"
|
import { useSDK } from "@/context/sdk"
|
||||||
|
|
|
||||||
|
|
@ -126,11 +126,11 @@ export const SettingsGeneral: Component = () => {
|
||||||
const serverSdk = useServerSDK()
|
const serverSdk = useServerSDK()
|
||||||
|
|
||||||
const [shells] = createResource(
|
const [shells] = createResource(
|
||||||
() =>
|
async () => {
|
||||||
serverSdk()
|
const capability = (await serverSdk().backend).capabilities.shellDiscovery
|
||||||
.client.pty.shells()
|
if (!capability) return []
|
||||||
.then((res) => res.data ?? [])
|
return capability.list().catch(() => [] as ShellOption[])
|
||||||
.catch(() => [] as ShellOption[]),
|
},
|
||||||
{ initialValue: [] as ShellOption[] },
|
{ initialValue: [] as ShellOption[] },
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ import { DialogConnectProvider, useProviderConnectController } from "./dialog-co
|
||||||
import { DialogCustomProvider } from "./dialog-custom-provider"
|
import { DialogCustomProvider } from "./dialog-custom-provider"
|
||||||
import { SettingsList } from "./settings-list"
|
import { SettingsList } from "./settings-list"
|
||||||
import { SettingsServerPicker, SettingsServerScope } from "./settings-server-picker"
|
import { SettingsServerPicker, SettingsServerScope } from "./settings-server-picker"
|
||||||
|
import { credentialConnectionIDs } from "@/context/backend"
|
||||||
|
|
||||||
type ProviderSource = "env" | "api" | "config" | "custom"
|
type ProviderSource = "env" | "api" | "config" | "custom"
|
||||||
type ProviderItem = ReturnType<ReturnType<typeof useProviders>["connected"]>[number]
|
type ProviderItem = ReturnType<ReturnType<typeof useProviders>["connected"]>[number]
|
||||||
|
|
@ -96,12 +97,12 @@ const SettingsProvidersContent: Component<{ onBack?: () => void }> = (props) =>
|
||||||
}
|
}
|
||||||
|
|
||||||
const disableProvider = async (providerID: string, name: string) => {
|
const disableProvider = async (providerID: string, name: string) => {
|
||||||
const before = serverSync().data.config.disabled_providers ?? []
|
const before = serverSync().data.config.disabledProviders ?? []
|
||||||
const next = before.includes(providerID) ? before : [...before, providerID]
|
const next = before.includes(providerID) ? before : [...before, providerID]
|
||||||
serverSync().set("config", "disabled_providers", next)
|
serverSync().set("config", "disabledProviders", next)
|
||||||
|
|
||||||
await serverSync()
|
await serverSync()
|
||||||
.updateConfig({ disabled_providers: next })
|
.updateConfig({ disabledProviders: next })
|
||||||
.then(() => {
|
.then(() => {
|
||||||
showToast({
|
showToast({
|
||||||
variant: "success",
|
variant: "success",
|
||||||
|
|
@ -111,29 +112,47 @@ const SettingsProvidersContent: Component<{ onBack?: () => void }> = (props) =>
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
.catch((err: unknown) => {
|
.catch((err: unknown) => {
|
||||||
serverSync().set("config", "disabled_providers", before)
|
serverSync().set("config", "disabledProviders", before)
|
||||||
const message = err instanceof Error ? err.message : String(err)
|
const message = err instanceof Error ? err.message : String(err)
|
||||||
showToast({ title: language.t("common.requestFailed"), description: message })
|
showToast({ title: language.t("common.requestFailed"), description: message })
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const disconnect = async (providerID: string, name: string) => {
|
const disconnect = async (item: ProviderItem) => {
|
||||||
if (isConfigCustom(providerID)) {
|
const backend = await serverSDK().backend
|
||||||
await serverSDK()
|
const remove = async () => {
|
||||||
.client.auth.remove({ providerID })
|
if (backend.version === "v1") {
|
||||||
.catch(() => undefined)
|
const capability = backend.capabilities.providerAuthV1
|
||||||
await disableProvider(providerID, name)
|
if (!capability) throw new Error("Server does not support provider authentication")
|
||||||
|
await capability.remove({ providerID: item.id })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const capability = backend.capabilities.integrationsV2
|
||||||
|
if (!capability) throw new Error("Server does not support provider integrations")
|
||||||
|
const integrationID =
|
||||||
|
"integrationID" in item && typeof item.integrationID === "string" ? item.integrationID : item.id
|
||||||
|
const integration = await capability.get({ integrationID })
|
||||||
|
await Promise.all(
|
||||||
|
credentialConnectionIDs(integration?.connections ?? []).map((credentialID) =>
|
||||||
|
capability.removeCredential({ credentialID }),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isConfigCustom(item.id)) {
|
||||||
|
await remove().catch(() => undefined)
|
||||||
|
await disableProvider(item.id, item.name)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
await serverSDK()
|
await remove()
|
||||||
.client.auth.remove({ providerID })
|
|
||||||
.then(async () => {
|
.then(async () => {
|
||||||
await serverSDK().client.global.dispose()
|
if (backend.version === "v1") await backend.capabilities.runtimeV1?.disposeAll()
|
||||||
|
if (backend.version === "v2") await serverSync().refreshProviders()
|
||||||
showToast({
|
showToast({
|
||||||
variant: "success",
|
variant: "success",
|
||||||
icon: "circle-check",
|
icon: "circle-check",
|
||||||
title: language.t("provider.disconnect.toast.disconnected.title", { provider: name }),
|
title: language.t("provider.disconnect.toast.disconnected.title", { provider: item.name }),
|
||||||
description: language.t("provider.disconnect.toast.disconnected.description", { provider: name }),
|
description: language.t("provider.disconnect.toast.disconnected.description", { provider: item.name }),
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
.catch((err: unknown) => {
|
.catch((err: unknown) => {
|
||||||
|
|
@ -179,7 +198,7 @@ const SettingsProvidersContent: Component<{ onBack?: () => void }> = (props) =>
|
||||||
</span>
|
</span>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Button size="large" variant="ghost" onClick={() => void disconnect(item.id, item.name)}>
|
<Button size="large" variant="ghost" onClick={() => void disconnect(item)}>
|
||||||
{language.t("common.disconnect")}
|
{language.t("common.disconnect")}
|
||||||
</Button>
|
</Button>
|
||||||
</Show>
|
</Show>
|
||||||
|
|
|
||||||
|
|
@ -121,11 +121,11 @@ export const SettingsGeneralV2: Component<{
|
||||||
const themeOptions = createMemo<ThemeOption[]>(() => theme.ids().map((id) => ({ id, name: theme.name(id) })))
|
const themeOptions = createMemo<ThemeOption[]>(() => theme.ids().map((id) => ({ id, name: theme.name(id) })))
|
||||||
|
|
||||||
const [shells] = createResource(
|
const [shells] = createResource(
|
||||||
() =>
|
async () => {
|
||||||
serverSdk()
|
const capability = (await serverSdk().backend).capabilities.shellDiscovery
|
||||||
.client.pty.shells()
|
if (!capability) return []
|
||||||
.then((res) => res.data ?? [])
|
return capability.list().catch(() => [] as ShellOption[])
|
||||||
.catch(() => [] as ShellOption[]),
|
},
|
||||||
{ initialValue: [] as ShellOption[] },
|
{ initialValue: [] as ShellOption[] },
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ import { DialogConnectProvider, useProviderConnectController } from "../dialog-c
|
||||||
import { DialogCustomProvider } from "../dialog-custom-provider"
|
import { DialogCustomProvider } from "../dialog-custom-provider"
|
||||||
import { SettingsListV2 } from "./parts/list"
|
import { SettingsListV2 } from "./parts/list"
|
||||||
import "./settings-v2.css"
|
import "./settings-v2.css"
|
||||||
|
import { credentialConnectionIDs } from "@/context/backend"
|
||||||
|
|
||||||
type ProviderSource = "env" | "api" | "config" | "custom"
|
type ProviderSource = "env" | "api" | "config" | "custom"
|
||||||
type ProviderItem = ReturnType<ReturnType<typeof useProviders>["connected"]>[number]
|
type ProviderItem = ReturnType<ReturnType<typeof useProviders>["connected"]>[number]
|
||||||
|
|
@ -90,12 +91,12 @@ export const SettingsProvidersV2: Component<{ onBack?: () => void }> = (props) =
|
||||||
}
|
}
|
||||||
|
|
||||||
const disableProvider = async (providerID: string, name: string) => {
|
const disableProvider = async (providerID: string, name: string) => {
|
||||||
const before = serverSync().data.config.disabled_providers ?? []
|
const before = serverSync().data.config.disabledProviders ?? []
|
||||||
const next = before.includes(providerID) ? before : [...before, providerID]
|
const next = before.includes(providerID) ? before : [...before, providerID]
|
||||||
serverSync().set("config", "disabled_providers", next)
|
serverSync().set("config", "disabledProviders", next)
|
||||||
|
|
||||||
await serverSync()
|
await serverSync()
|
||||||
.updateConfig({ disabled_providers: next })
|
.updateConfig({ disabledProviders: next })
|
||||||
.then(() => {
|
.then(() => {
|
||||||
showToast({
|
showToast({
|
||||||
variant: "success",
|
variant: "success",
|
||||||
|
|
@ -105,29 +106,47 @@ export const SettingsProvidersV2: Component<{ onBack?: () => void }> = (props) =
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
.catch((err: unknown) => {
|
.catch((err: unknown) => {
|
||||||
serverSync().set("config", "disabled_providers", before)
|
serverSync().set("config", "disabledProviders", before)
|
||||||
const message = err instanceof Error ? err.message : String(err)
|
const message = err instanceof Error ? err.message : String(err)
|
||||||
showToast({ title: language.t("common.requestFailed"), description: message })
|
showToast({ title: language.t("common.requestFailed"), description: message })
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const disconnect = async (providerID: string, name: string) => {
|
const disconnect = async (item: ProviderItem) => {
|
||||||
if (isConfigCustom(providerID)) {
|
const backend = await serverSdk().backend
|
||||||
await serverSdk()
|
const remove = async () => {
|
||||||
.client.auth.remove({ providerID })
|
if (backend.version === "v1") {
|
||||||
.catch(() => undefined)
|
const capability = backend.capabilities.providerAuthV1
|
||||||
await disableProvider(providerID, name)
|
if (!capability) throw new Error("Server does not support provider authentication")
|
||||||
|
await capability.remove({ providerID: item.id })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const capability = backend.capabilities.integrationsV2
|
||||||
|
if (!capability) throw new Error("Server does not support provider integrations")
|
||||||
|
const integrationID =
|
||||||
|
"integrationID" in item && typeof item.integrationID === "string" ? item.integrationID : item.id
|
||||||
|
const integration = await capability.get({ integrationID })
|
||||||
|
await Promise.all(
|
||||||
|
credentialConnectionIDs(integration?.connections ?? []).map((credentialID) =>
|
||||||
|
capability.removeCredential({ credentialID }),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isConfigCustom(item.id)) {
|
||||||
|
await remove().catch(() => undefined)
|
||||||
|
await disableProvider(item.id, item.name)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
await serverSdk()
|
await remove()
|
||||||
.client.auth.remove({ providerID })
|
|
||||||
.then(async () => {
|
.then(async () => {
|
||||||
await serverSdk().client.global.dispose()
|
if (backend.version === "v1") await backend.capabilities.runtimeV1?.disposeAll()
|
||||||
|
if (backend.version === "v2") await serverSync().refreshProviders()
|
||||||
showToast({
|
showToast({
|
||||||
variant: "success",
|
variant: "success",
|
||||||
icon: "circle-check",
|
icon: "circle-check",
|
||||||
title: language.t("provider.disconnect.toast.disconnected.title", { provider: name }),
|
title: language.t("provider.disconnect.toast.disconnected.title", { provider: item.name }),
|
||||||
description: language.t("provider.disconnect.toast.disconnected.description", { provider: name }),
|
description: language.t("provider.disconnect.toast.disconnected.description", { provider: item.name }),
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
.catch((err: unknown) => {
|
.catch((err: unknown) => {
|
||||||
|
|
@ -175,7 +194,7 @@ export const SettingsProvidersV2: Component<{ onBack?: () => void }> = (props) =
|
||||||
</span>
|
</span>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<ButtonV2 size="normal" variant="ghost-muted" onClick={() => void disconnect(item.id, item.name)}>
|
<ButtonV2 size="normal" variant="ghost-muted" onClick={() => void disconnect(item)}>
|
||||||
{language.t("common.disconnect")}
|
{language.t("common.disconnect")}
|
||||||
</ButtonV2>
|
</ButtonV2>
|
||||||
</Show>
|
</Show>
|
||||||
|
|
|
||||||
|
|
@ -110,8 +110,8 @@ export const SettingsServersV2: Component = () => {
|
||||||
<div class="settings-v2-servers-copy">
|
<div class="settings-v2-servers-copy">
|
||||||
<span class="settings-v2-servers-name">{serverName(item)}</span>
|
<span class="settings-v2-servers-name">{serverName(item)}</span>
|
||||||
<span class="settings-v2-servers-meta">
|
<span class="settings-v2-servers-meta">
|
||||||
<Show when={health()?.version}>v{health()?.version}</Show>
|
<Show when={health()?.installationVersion}>v{health()?.installationVersion}</Show>
|
||||||
<Show when={health()?.version && item.type === "http"}> • </Show>
|
<Show when={health()?.installationVersion && item.type === "http"}> • </Show>
|
||||||
<Show
|
<Show
|
||||||
when={item.type === "http" && item.http.username}
|
when={item.type === "http" && item.http.username}
|
||||||
fallback={<Show when={item.type === "http"}>{language.t("server.row.noUsername")}</Show>}
|
fallback={<Show when={item.type === "http"}>{language.t("server.row.noUsername")}</Show>}
|
||||||
|
|
|
||||||
|
|
@ -11,12 +11,10 @@ import { matchKeybind, parseKeybind } from "@/context/command"
|
||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
import { usePlatform } from "@/context/platform"
|
import { usePlatform } from "@/context/platform"
|
||||||
import { useSDK } from "@/context/sdk"
|
import { useSDK } from "@/context/sdk"
|
||||||
import { useServerSDK } from "@/context/server-sdk"
|
|
||||||
import { terminalFontFamily, useSettings } from "@/context/settings"
|
import { terminalFontFamily, useSettings } from "@/context/settings"
|
||||||
import type { LocalPTY } from "@/context/terminal"
|
import type { LocalPTY } from "@/context/terminal"
|
||||||
import { disposeIfDisposable, getHoveredLinkText, setOptionIfSupported } from "@/utils/runtime-adapters"
|
import { disposeIfDisposable, getHoveredLinkText, setOptionIfSupported } from "@/utils/runtime-adapters"
|
||||||
import { terminalWriter } from "@/utils/terminal-writer"
|
import { terminalWriter } from "@/utils/terminal-writer"
|
||||||
import { terminalWebSocketURL } from "@/utils/terminal-websocket-url"
|
|
||||||
|
|
||||||
const TOGGLE_TERMINAL_ID = "terminal.toggle"
|
const TOGGLE_TERMINAL_ID = "terminal.toggle"
|
||||||
const DEFAULT_TOGGLE_TERMINAL_KEYBIND = "ctrl+`"
|
const DEFAULT_TOGGLE_TERMINAL_KEYBIND = "ctrl+`"
|
||||||
|
|
@ -174,16 +172,8 @@ export const Terminal = (props: TerminalProps) => {
|
||||||
const settings = useSettings()
|
const settings = useSettings()
|
||||||
const theme = useTheme()
|
const theme = useTheme()
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
// Terminal captures its connection for the PTY lifetime, so callers must key it per server/session.
|
|
||||||
const connection = useServerSDK()().server
|
|
||||||
const directory = sdk().directory
|
const directory = sdk().directory
|
||||||
const client = sdk().client
|
const backend = sdk().backend
|
||||||
const url = sdk().url
|
|
||||||
const auth = connection.http
|
|
||||||
const username = auth?.username ?? "opencode"
|
|
||||||
const password = auth?.password ?? ""
|
|
||||||
const authToken = connection.type === "http" ? connection.authToken : false
|
|
||||||
const sameOrigin = new URL(url, location.href).origin === location.origin
|
|
||||||
let container!: HTMLDivElement
|
let container!: HTMLDivElement
|
||||||
const [local, others] = splitProps(props, ["pty", "class", "classList", "autoFocus", "onConnect", "onConnectError"])
|
const [local, others] = splitProps(props, ["pty", "class", "classList", "autoFocus", "onConnect", "onConnectError"])
|
||||||
const id = local.pty.id
|
const id = local.pty.id
|
||||||
|
|
@ -233,11 +223,14 @@ export const Terminal = (props: TerminalProps) => {
|
||||||
}
|
}
|
||||||
|
|
||||||
const pushSize = (cols: number, rows: number) => {
|
const pushSize = (cols: number, rows: number) => {
|
||||||
return client.pty
|
return backend
|
||||||
.update({
|
.then((client) =>
|
||||||
ptyID: id,
|
client.common.pty.update({
|
||||||
size: { cols, rows },
|
ptyID: id,
|
||||||
})
|
size: { cols, rows },
|
||||||
|
location: { directory },
|
||||||
|
}),
|
||||||
|
)
|
||||||
.catch((err) => {
|
.catch((err) => {
|
||||||
debugTerminal("failed to sync terminal size", err)
|
debugTerminal("failed to sync terminal size", err)
|
||||||
})
|
})
|
||||||
|
|
@ -490,33 +483,32 @@ export const Terminal = (props: TerminalProps) => {
|
||||||
}
|
}
|
||||||
|
|
||||||
const gone = () =>
|
const gone = () =>
|
||||||
client.pty
|
backend
|
||||||
.get({ ptyID: id }, { throwOnError: false })
|
.then((client) => {
|
||||||
.then((result) => result.response.status === 404)
|
const transport = client.capabilities.ptyTransport
|
||||||
|
if (transport) return transport.exists({ ptyID: id, location: { directory } }).then((exists) => !exists)
|
||||||
|
return client.common.pty.get({ ptyID: id, location: { directory } }).then(() => false)
|
||||||
|
})
|
||||||
.catch((err) => {
|
.catch((err) => {
|
||||||
debugTerminal("failed to inspect terminal session", err)
|
debugTerminal("failed to inspect terminal session", err)
|
||||||
return false
|
return false
|
||||||
})
|
})
|
||||||
|
|
||||||
const connectToken = async () => {
|
const connectToken = async () => {
|
||||||
const result = await client.pty
|
const transport = (await backend).capabilities.ptyTransport
|
||||||
.connectToken(
|
if (!transport) return
|
||||||
{ ptyID: id, directory },
|
const result = await transport
|
||||||
{
|
.connectToken({ ptyID: id, location: { directory } })
|
||||||
throwOnError: false,
|
|
||||||
headers: { "x-opencode-ticket": "1" },
|
|
||||||
},
|
|
||||||
)
|
|
||||||
.catch((err: unknown) => {
|
.catch((err: unknown) => {
|
||||||
if (err instanceof Error && err.message.includes("Request is not supported")) return
|
if (err instanceof Error && err.message.includes("Request is not supported")) return
|
||||||
throw err
|
throw err
|
||||||
})
|
})
|
||||||
if (!result) return
|
if (!result) return
|
||||||
if (result.response.status === 200 && result.data?.ticket) return result.data.ticket
|
if (result.status === 200 && result.ticket) return result.ticket
|
||||||
if (result.response.status === 404 || result.response.status === 405) return
|
if (result.status === 404 || result.status === 405) return
|
||||||
if (result.response.status === 403)
|
if (result.status === 403)
|
||||||
throw new Error("PTY connect ticket rejected by origin or CSRF checks. Check the server CORS config.")
|
throw new Error("PTY connect ticket rejected by origin or CSRF checks. Check the server CORS config.")
|
||||||
throw new Error(`PTY connect ticket failed with ${result.response.status}`)
|
throw new Error(`PTY connect ticket failed with ${result.status}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
const retry = (err: unknown) => {
|
const retry = (err: unknown) => {
|
||||||
|
|
@ -549,18 +541,13 @@ export const Terminal = (props: TerminalProps) => {
|
||||||
if (once.value) return
|
if (once.value) return
|
||||||
if (disposed) return
|
if (disposed) return
|
||||||
|
|
||||||
|
const transport = (await backend).capabilities.ptyTransport
|
||||||
|
if (!transport) {
|
||||||
|
fail(new Error("PTY transport is not supported by this server"))
|
||||||
|
return
|
||||||
|
}
|
||||||
const socket = new WebSocket(
|
const socket = new WebSocket(
|
||||||
terminalWebSocketURL({
|
transport.connectURL({ ptyID: id, location: { directory }, cursor: seek, ticket }),
|
||||||
url,
|
|
||||||
id,
|
|
||||||
directory,
|
|
||||||
cursor: seek,
|
|
||||||
ticket,
|
|
||||||
sameOrigin,
|
|
||||||
username,
|
|
||||||
password,
|
|
||||||
authToken,
|
|
||||||
}),
|
|
||||||
)
|
)
|
||||||
socket.binaryType = "arraybuffer"
|
socket.binaryType = "arraybuffer"
|
||||||
ws = socket
|
ws = socket
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@ import { ServerConnection, serverName } from "@/context/server"
|
||||||
import { displayName, projectForSession } from "@/pages/layout/helpers"
|
import { displayName, projectForSession } from "@/pages/layout/helpers"
|
||||||
import { SessionTabAvatar } from "@/pages/layout/session-tab-avatar"
|
import { SessionTabAvatar } from "@/pages/layout/session-tab-avatar"
|
||||||
import { showToast } from "@/utils/toast"
|
import { showToast } from "@/utils/toast"
|
||||||
import type { Session } from "@opencode-ai/sdk/v2"
|
import type { AppSession } from "@/context/backend"
|
||||||
import { canOpenTabRename, forwardTabRef } from "./titlebar-tab-gesture"
|
import { canOpenTabRename, forwardTabRef } from "./titlebar-tab-gesture"
|
||||||
import { TabPreviewPopover } from "./titlebar-tab-popover"
|
import { TabPreviewPopover } from "./titlebar-tab-popover"
|
||||||
import "./titlebar-tab-nav.css"
|
import "./titlebar-tab-nav.css"
|
||||||
|
|
@ -21,7 +21,7 @@ export function TabNavItem(props: {
|
||||||
ref?: Ref<HTMLDivElement>
|
ref?: Ref<HTMLDivElement>
|
||||||
href: string
|
href: string
|
||||||
server: ServerConnection.Key
|
server: ServerConnection.Key
|
||||||
session: () => Session | undefined
|
session: () => AppSession | undefined
|
||||||
fallbackTitle?: string
|
fallbackTitle?: string
|
||||||
onTitleChange?: (title: string) => void
|
onTitleChange?: (title: string) => void
|
||||||
onTitleChangeFailed?: (title: string) => void
|
onTitleChangeFailed?: (title: string) => void
|
||||||
|
|
@ -120,8 +120,10 @@ export function TabNavItem(props: {
|
||||||
const ctx = serverCtx()
|
const ctx = serverCtx()
|
||||||
const session = props.session()
|
const session = props.session()
|
||||||
if (!ctx || !session) return
|
if (!ctx || !session) return
|
||||||
const client = ctx.sdk.createClient({ directory: session.directory, throwOnError: true })
|
const client = await ctx.sdk.backend
|
||||||
await client.session.update({ sessionID: session.id, title })
|
const capability = client.capabilities.sessionActionsV1
|
||||||
|
if (!capability) throw new Error("Session renaming is not supported by this server")
|
||||||
|
await capability.rename({ location: { directory: session.directory }, sessionID: session.id, title })
|
||||||
}
|
}
|
||||||
|
|
||||||
const closeRename = async (save: boolean) => {
|
const closeRename = async (save: boolean) => {
|
||||||
|
|
|
||||||
|
|
@ -267,9 +267,8 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
|
||||||
return conn ? { route, sdk: global.ensureServerCtx(conn).sdk } : undefined
|
return conn ? { route, sdk: global.ensureServerCtx(conn).sdk } : undefined
|
||||||
},
|
},
|
||||||
({ route, sdk }) =>
|
({ route, sdk }) =>
|
||||||
sdk.client.session
|
sdk.backend
|
||||||
.get({ sessionID: route.sessionId })
|
.then((client) => client.common.sessions.get({ sessionID: route.sessionId }))
|
||||||
.then((x) => x.data)
|
|
||||||
.catch(() => {}),
|
.catch(() => {}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
70
packages/app/src/context/backend-client.test.ts
Normal file
70
packages/app/src/context/backend-client.test.ts
Normal file
|
|
@ -0,0 +1,70 @@
|
||||||
|
import { describe, expect, test } from "bun:test"
|
||||||
|
import type { ServerHealth } from "@/utils/server-health"
|
||||||
|
import { backendIdentity, createBackendForServer } from "./backend-client"
|
||||||
|
|
||||||
|
const server = {
|
||||||
|
type: "http" as const,
|
||||||
|
http: {
|
||||||
|
url: "http://localhost:4096",
|
||||||
|
username: "user",
|
||||||
|
password: "secret",
|
||||||
|
},
|
||||||
|
authToken: false,
|
||||||
|
}
|
||||||
|
|
||||||
|
function setup(health: ServerHealth) {
|
||||||
|
const requests: Request[] = []
|
||||||
|
const fetch = (async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||||
|
const request = input instanceof Request ? input : new Request(input, init)
|
||||||
|
requests.push(request)
|
||||||
|
if (new URL(request.url).pathname === "/project")
|
||||||
|
return new Response("[]", { headers: { "content-type": "application/json" } })
|
||||||
|
return new Response(JSON.stringify({ healthy: true, version: "1.2.3" }), {
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
})
|
||||||
|
}) as typeof globalThis.fetch
|
||||||
|
return {
|
||||||
|
requests,
|
||||||
|
fetch,
|
||||||
|
backend: createBackendForServer({ server, browserUrl: "https://app.example.test", fetch, health: Promise.resolve(health) }),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("createBackendForServer", () => {
|
||||||
|
test("changes backend identity when credentials for the same URL change", () => {
|
||||||
|
expect(backendIdentity(server)).not.toBe(
|
||||||
|
backendIdentity({ ...server, http: { ...server.http, password: "replacement" } }),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("selects v2 and configures fetch and authentication", async () => {
|
||||||
|
const result = setup({ healthy: true, version: "v2" })
|
||||||
|
const backend = await result.backend
|
||||||
|
|
||||||
|
expect(backend.version).toBe("v2")
|
||||||
|
expect(result.requests).toHaveLength(0)
|
||||||
|
await backend.common.health.get()
|
||||||
|
expect(new URL(result.requests[0].url).pathname).toBe("/api/health")
|
||||||
|
expect(result.requests[0].headers.get("authorization")).toBe(`Basic ${btoa("user:secret")}`)
|
||||||
|
|
||||||
|
expect(backend.capabilities.projectList).toBeUndefined()
|
||||||
|
expect(backend.capabilities.vcs).toBeUndefined()
|
||||||
|
expect(backend.capabilities.mcp).toBeUndefined()
|
||||||
|
expect(result.requests).toHaveLength(1)
|
||||||
|
expect(backend.version).toBe("v2")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("selects v1 after fallback detection", async () => {
|
||||||
|
const result = setup({ healthy: true, version: "v1", installationVersion: "1.2.3" })
|
||||||
|
const backend = await result.backend
|
||||||
|
|
||||||
|
expect(backend.version).toBe("v1")
|
||||||
|
expect(result.requests).toHaveLength(0)
|
||||||
|
await backend.common.health.get()
|
||||||
|
expect(new URL(result.requests[0].url).pathname).toBe("/global/health")
|
||||||
|
expect(result.requests[0].headers.get("authorization")).toBe(`Basic ${btoa("user:secret")}`)
|
||||||
|
expect(backend.capabilities.projectList).toBeDefined()
|
||||||
|
expect(backend.capabilities.vcs).toBeDefined()
|
||||||
|
expect(backend.capabilities.mcp).toBeDefined()
|
||||||
|
})
|
||||||
|
})
|
||||||
64
packages/app/src/context/backend-client.ts
Normal file
64
packages/app/src/context/backend-client.ts
Normal file
|
|
@ -0,0 +1,64 @@
|
||||||
|
import { OpenCode } from "@opencode-ai/client"
|
||||||
|
import { createOpencodeClient } from "@opencode-ai/sdk-v1/v2/client"
|
||||||
|
import type { ServerHealth } from "@/utils/server-health"
|
||||||
|
import { authTokenFromCredentials } from "@/utils/server"
|
||||||
|
import type { ServerConnection } from "./server"
|
||||||
|
import type { LocationRef } from "./backend"
|
||||||
|
import { createV1Backend } from "./backend-v1"
|
||||||
|
import { createV2Backend } from "./backend-v2"
|
||||||
|
|
||||||
|
function options(server: ServerConnection.HttpBase, fetch: typeof globalThis.fetch) {
|
||||||
|
return {
|
||||||
|
baseUrl: server.url,
|
||||||
|
fetch,
|
||||||
|
headers: server.password
|
||||||
|
? {
|
||||||
|
Authorization: `Basic ${authTokenFromCredentials({ username: server.username, password: server.password })}`,
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createV1RawClient(server: ServerConnection.HttpBase, fetch: typeof globalThis.fetch) {
|
||||||
|
return createOpencodeClient(options(server, fetch))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createV2RawClient(server: ServerConnection.HttpBase, fetch: typeof globalThis.fetch) {
|
||||||
|
return OpenCode.make(options(server, fetch))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function backendIdentity(server: ServerConnection.Any) {
|
||||||
|
return `${server.type}\n${server.http.url}\n${server.http.username ?? ""}\n${server.http.password ?? ""}\n${
|
||||||
|
server.type === "http" && server.authToken === true ? "token" : ""
|
||||||
|
}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createBackendForServer(input: {
|
||||||
|
server: ServerConnection.Any
|
||||||
|
browserUrl: string
|
||||||
|
fetch: typeof globalThis.fetch
|
||||||
|
eventFetch?: typeof globalThis.fetch
|
||||||
|
health: Promise<ServerHealth>
|
||||||
|
defaultLocation?: LocationRef
|
||||||
|
}) {
|
||||||
|
const health = await input.health
|
||||||
|
const eventFetch = input.eventFetch ?? input.fetch
|
||||||
|
const transport = {
|
||||||
|
baseUrl: input.server.http.url,
|
||||||
|
fetch: input.fetch,
|
||||||
|
username: input.server.http.username,
|
||||||
|
password: input.server.http.password,
|
||||||
|
sameOrigin: new URL(input.server.http.url, input.browserUrl).origin === new URL(input.browserUrl).origin,
|
||||||
|
authToken: input.server.type === "http" && input.server.authToken === true,
|
||||||
|
}
|
||||||
|
if (health.version === "v2")
|
||||||
|
return createV2Backend(
|
||||||
|
createV2RawClient(input.server.http, input.fetch),
|
||||||
|
transport,
|
||||||
|
input.defaultLocation,
|
||||||
|
createV2RawClient(input.server.http, eventFetch),
|
||||||
|
)
|
||||||
|
const legacy = createV1RawClient(input.server.http, input.fetch)
|
||||||
|
const eventLegacy = eventFetch === input.fetch ? legacy : createV1RawClient(input.server.http, eventFetch)
|
||||||
|
return createV1Backend(legacy, input.defaultLocation, eventLegacy, transport)
|
||||||
|
}
|
||||||
|
|
@ -1,8 +1,13 @@
|
||||||
import { describe, expect, test } from "bun:test"
|
import { describe, expect, test } from "bun:test"
|
||||||
import { createOpencodeClient } from "@opencode-ai/sdk-v1/v2/client"
|
import { createOpencodeClient } from "@opencode-ai/sdk-v1/v2/client"
|
||||||
|
import type { PtyTransportConfig } from "./backend"
|
||||||
import { createV1Backend } from "./backend-v1"
|
import { createV1Backend } from "./backend-v1"
|
||||||
|
|
||||||
function setup(respond: (request: Request) => Response | Promise<Response>) {
|
function setup(
|
||||||
|
respond: (request: Request) => Response | Promise<Response>,
|
||||||
|
withDefault = false,
|
||||||
|
transport?: Partial<Pick<PtyTransportConfig, "sameOrigin" | "authToken">>,
|
||||||
|
) {
|
||||||
const requests: Request[] = []
|
const requests: Request[] = []
|
||||||
const fetch = Object.assign(
|
const fetch = Object.assign(
|
||||||
async (input: RequestInfo | URL, init?: RequestInit) => {
|
async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||||
|
|
@ -12,9 +17,22 @@ function setup(respond: (request: Request) => Response | Promise<Response>) {
|
||||||
},
|
},
|
||||||
{ preconnect: globalThis.fetch.preconnect },
|
{ preconnect: globalThis.fetch.preconnect },
|
||||||
) satisfies typeof globalThis.fetch
|
) satisfies typeof globalThis.fetch
|
||||||
|
const client = createOpencodeClient({ baseUrl: "http://localhost", fetch })
|
||||||
return {
|
return {
|
||||||
requests,
|
requests,
|
||||||
backend: createV1Backend(createOpencodeClient({ baseUrl: "http://localhost", fetch })),
|
backend: createV1Backend(
|
||||||
|
client,
|
||||||
|
withDefault ? { directory: "/default", workspaceID: "default-workspace" } : undefined,
|
||||||
|
client,
|
||||||
|
{
|
||||||
|
baseUrl: "http://localhost",
|
||||||
|
fetch,
|
||||||
|
username: "user",
|
||||||
|
password: "secret",
|
||||||
|
sameOrigin: transport?.sameOrigin ?? false,
|
||||||
|
authToken: transport?.authToken ?? false,
|
||||||
|
},
|
||||||
|
),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -35,6 +53,21 @@ const session = {
|
||||||
}
|
}
|
||||||
|
|
||||||
describe("createV1Backend", () => {
|
describe("createV1Backend", () => {
|
||||||
|
test("preserves location in OAuth callbacks", async () => {
|
||||||
|
const setupResult = setup(() => json({}))
|
||||||
|
|
||||||
|
await setupResult.backend.capabilities.providerAuthV1?.callback({
|
||||||
|
providerID: "provider",
|
||||||
|
method: 1,
|
||||||
|
code: "code",
|
||||||
|
location: { directory: "/repo", workspaceID: "workspace" },
|
||||||
|
})
|
||||||
|
|
||||||
|
const url = new URL(setupResult.requests[0].url)
|
||||||
|
expect(url.searchParams.get("directory")).toBe("/repo")
|
||||||
|
expect(url.searchParams.get("workspace")).toBe("workspace")
|
||||||
|
})
|
||||||
|
|
||||||
test("normalizes session pagination and location", async () => {
|
test("normalizes session pagination and location", async () => {
|
||||||
const setupResult = setup(() => json([session], { "x-next-cursor": "456" }))
|
const setupResult = setup(() => json([session], { "x-next-cursor": "456" }))
|
||||||
|
|
||||||
|
|
@ -49,9 +82,13 @@ describe("createV1Backend", () => {
|
||||||
items: [
|
items: [
|
||||||
{
|
{
|
||||||
id: "ses_1",
|
id: "ses_1",
|
||||||
|
slug: "one",
|
||||||
|
version: "1",
|
||||||
parentID: undefined,
|
parentID: undefined,
|
||||||
projectID: "project",
|
projectID: "project",
|
||||||
location: { directory: "/repo", workspaceID: undefined },
|
location: { directory: "/repo", workspaceID: undefined },
|
||||||
|
directory: "/repo",
|
||||||
|
workspaceID: undefined,
|
||||||
title: "Session",
|
title: "Session",
|
||||||
cost: 0,
|
cost: 0,
|
||||||
tokens: undefined,
|
tokens: undefined,
|
||||||
|
|
@ -60,7 +97,7 @@ describe("createV1Backend", () => {
|
||||||
revert: undefined,
|
revert: undefined,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
next: "456",
|
older: "456",
|
||||||
})
|
})
|
||||||
const url = new URL(setupResult.requests[0].url)
|
const url = new URL(setupResult.requests[0].url)
|
||||||
expect(url.pathname).toBe("/experimental/session")
|
expect(url.pathname).toBe("/experimental/session")
|
||||||
|
|
@ -75,6 +112,7 @@ describe("createV1Backend", () => {
|
||||||
|
|
||||||
await setupResult.backend.common.sessions.prompt({
|
await setupResult.backend.common.sessions.prompt({
|
||||||
sessionID: "ses_1",
|
sessionID: "ses_1",
|
||||||
|
location: { directory: "/explicit", workspaceID: "explicit-workspace" },
|
||||||
id: "msg_1",
|
id: "msg_1",
|
||||||
text: "hello",
|
text: "hello",
|
||||||
selection: {
|
selection: {
|
||||||
|
|
@ -87,6 +125,8 @@ describe("createV1Backend", () => {
|
||||||
|
|
||||||
const request = setupResult.requests[0]
|
const request = setupResult.requests[0]
|
||||||
expect(new URL(request.url).pathname).toBe("/session/ses_1/prompt_async")
|
expect(new URL(request.url).pathname).toBe("/session/ses_1/prompt_async")
|
||||||
|
expect(new URL(request.url).searchParams.get("directory")).toBe("/explicit")
|
||||||
|
expect(new URL(request.url).searchParams.get("workspace")).toBe("explicit-workspace")
|
||||||
expect(await request.json()).toEqual({
|
expect(await request.json()).toEqual({
|
||||||
messageID: "msg_1",
|
messageID: "msg_1",
|
||||||
model: { providerID: "provider", modelID: "model" },
|
model: { providerID: "provider", modelID: "model" },
|
||||||
|
|
@ -100,6 +140,29 @@ describe("createV1Backend", () => {
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("preserves ordered prompt part IDs and metadata", async () => {
|
||||||
|
const setupResult = setup(() => new Response(null, { status: 204 }))
|
||||||
|
|
||||||
|
await setupResult.backend.common.sessions.prompt({
|
||||||
|
sessionID: "ses_1",
|
||||||
|
id: "msg_1",
|
||||||
|
text: "visible",
|
||||||
|
parts: [
|
||||||
|
{ id: "part_text", type: "text", text: "visible" },
|
||||||
|
{ id: "part_note", type: "text", text: "note", synthetic: true, metadata: { source: "review" } },
|
||||||
|
{ id: "part_file", type: "file", mime: "text/plain", url: "file:///repo/a.ts", filename: "a.ts" },
|
||||||
|
{ id: "part_agent", type: "agent", name: "build", source: { value: "@build", start: 7, end: 13 } },
|
||||||
|
],
|
||||||
|
})
|
||||||
|
|
||||||
|
expect((await setupResult.requests[0].json()).parts).toEqual([
|
||||||
|
{ id: "part_text", type: "text", text: "visible" },
|
||||||
|
{ id: "part_note", type: "text", text: "note", synthetic: true, metadata: { source: "review" } },
|
||||||
|
{ id: "part_file", type: "file", mime: "text/plain", url: "file:///repo/a.ts", filename: "a.ts" },
|
||||||
|
{ id: "part_agent", type: "agent", name: "build", source: { value: "@build", start: 7, end: 13 } },
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
test("combines mixed file search and decodes binary content", async () => {
|
test("combines mixed file search and decodes binary content", async () => {
|
||||||
const setupResult = setup((request) => {
|
const setupResult = setup((request) => {
|
||||||
const url = new URL(request.url)
|
const url = new URL(request.url)
|
||||||
|
|
@ -122,6 +185,139 @@ describe("createV1Backend", () => {
|
||||||
expect(content.mimeType).toBe("application/octet-stream")
|
expect(content.mimeType).toBe("application/octet-stream")
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("preserves project, provider, and file metadata", async () => {
|
||||||
|
const setupResult = setup((request) => {
|
||||||
|
const path = new URL(request.url).pathname
|
||||||
|
if (path === "/project")
|
||||||
|
return json([
|
||||||
|
{ id: "project", worktree: "/repo", vcs: "git", time: { created: 1, initialized: 2 }, sandboxes: [] },
|
||||||
|
])
|
||||||
|
if (path === "/provider")
|
||||||
|
return json({
|
||||||
|
all: [{ id: "provider", name: "Provider", source: "config", env: [], options: {}, models: {} }],
|
||||||
|
connected: [],
|
||||||
|
default: {},
|
||||||
|
})
|
||||||
|
return json([{ name: "a.txt", path: "a.txt", absolute: "/repo/a.txt", type: "file", ignored: false }])
|
||||||
|
})
|
||||||
|
|
||||||
|
const projectList = setupResult.backend.capabilities.projectList
|
||||||
|
if (!projectList) throw new Error("Missing project list capability")
|
||||||
|
const projects = await projectList.list()
|
||||||
|
const providers = await setupResult.backend.common.catalog.providers()
|
||||||
|
const files = await setupResult.backend.common.files.list({})
|
||||||
|
|
||||||
|
expect(projects[0]).toMatchObject({ vcs: "git", time: { created: 1, initialized: 2 } })
|
||||||
|
expect(providers.providers.get("provider")?.source).toBe("config")
|
||||||
|
expect(files).toEqual([{ name: "a.txt", path: "a.txt", absolute: "/repo/a.txt", type: "file", ignored: false }])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("uses explicit session locations and preserves mutation confirmations", async () => {
|
||||||
|
const setupResult = setup((request) => {
|
||||||
|
const path = new URL(request.url).pathname
|
||||||
|
if (request.method === "DELETE" || path.startsWith("/experimental/worktree")) return json(true)
|
||||||
|
if (request.method === "GET" && path === "/session/ses_1/message") return json([], { "x-next-cursor": "older" })
|
||||||
|
return json(session)
|
||||||
|
}, true)
|
||||||
|
|
||||||
|
const history = await setupResult.backend.common.sessions.history({
|
||||||
|
sessionID: "ses_1",
|
||||||
|
location: { directory: "/explicit", workspaceID: "explicit-workspace" },
|
||||||
|
})
|
||||||
|
const removed = await setupResult.backend.capabilities.sessionActionsV1?.remove({ sessionID: "ses_1" })
|
||||||
|
const reverted = await setupResult.backend.capabilities.sessionExtrasV1?.revert({
|
||||||
|
sessionID: "ses_1",
|
||||||
|
messageID: "msg_1",
|
||||||
|
})
|
||||||
|
const cleared = await setupResult.backend.capabilities.sessionExtrasV1?.clearRevert({ sessionID: "ses_1" })
|
||||||
|
const worktreeRemoved = await setupResult.backend.capabilities.worktreesV1?.remove({ directory: "/copy" })
|
||||||
|
const worktreeReset = await setupResult.backend.capabilities.worktreesV1?.reset({ directory: "/copy" })
|
||||||
|
|
||||||
|
expect(history.older).toBe("older")
|
||||||
|
expect(removed).toBe(true)
|
||||||
|
expect(reverted?.id).toBe("ses_1")
|
||||||
|
expect(cleared?.id).toBe("ses_1")
|
||||||
|
expect(worktreeRemoved).toBe(true)
|
||||||
|
expect(worktreeReset).toBe(true)
|
||||||
|
const urls = setupResult.requests.map((request) => new URL(request.url))
|
||||||
|
expect(urls[0].searchParams.get("directory")).toBe("/explicit")
|
||||||
|
expect(urls[1].searchParams.get("directory")).toBe("/default")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("normalizes idle and compatibility events", async () => {
|
||||||
|
const events = [
|
||||||
|
{ type: "session.status", properties: { sessionID: "ses_1", status: { type: "idle" } } },
|
||||||
|
{
|
||||||
|
type: "todo.updated",
|
||||||
|
properties: { sessionID: "ses_1", todos: [{ content: "Ship", status: "pending", priority: "high" }] },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "message.part.delta",
|
||||||
|
properties: { sessionID: "ses_1", messageID: "msg_1", partID: "part_1", field: "text", delta: "hi" },
|
||||||
|
},
|
||||||
|
{ type: "message.part.removed", properties: { sessionID: "ses_1", messageID: "msg_1", partID: "part_1" } },
|
||||||
|
{ type: "worktree.ready", properties: { name: "copy", branch: "copy" } },
|
||||||
|
{ type: "lsp.updated", properties: {} },
|
||||||
|
{ type: "reference.updated", properties: {} },
|
||||||
|
{ type: "mcp.tools.changed", properties: { server: "docs" } },
|
||||||
|
{ type: "server.instance.disposed", properties: { directory: "/repo" } },
|
||||||
|
]
|
||||||
|
const setupResult = setup(
|
||||||
|
() =>
|
||||||
|
new Response(events.map((payload) => `data: ${JSON.stringify({ directory: "/repo", payload })}\n\n`).join(""), {
|
||||||
|
headers: { "content-type": "text/event-stream" },
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
const iterator = setupResult.backend.common.events.subscribe()[Symbol.asyncIterator]()
|
||||||
|
const result = await Promise.all(events.map(() => iterator.next()))
|
||||||
|
|
||||||
|
expect(result.map((item) => item.value?.event.type)).toEqual([
|
||||||
|
"session.activity",
|
||||||
|
"todo.updated",
|
||||||
|
"timeline.delta",
|
||||||
|
"timeline.part.removed",
|
||||||
|
"worktree.ready",
|
||||||
|
"lsp.updated",
|
||||||
|
"reference.updated",
|
||||||
|
"mcp.updated",
|
||||||
|
"instance.disposed",
|
||||||
|
])
|
||||||
|
expect(result[0].value?.event).toEqual({ type: "session.activity", sessionID: "ses_1", activity: { type: "idle" } })
|
||||||
|
expect(result[1].value?.event).toMatchObject({ todos: [{ priority: "high" }] })
|
||||||
|
})
|
||||||
|
|
||||||
|
test("updates the V1 projection cache for deltas and removals", async () => {
|
||||||
|
const info = {
|
||||||
|
id: "msg_1",
|
||||||
|
sessionID: "ses_1",
|
||||||
|
role: "user",
|
||||||
|
time: { created: 1 },
|
||||||
|
agent: "build",
|
||||||
|
model: { providerID: "p", modelID: "m" },
|
||||||
|
}
|
||||||
|
const part = { id: "part_1", sessionID: "ses_1", messageID: "msg_1", type: "text", text: "a" }
|
||||||
|
const events = [
|
||||||
|
{ type: "message.updated", properties: { info } },
|
||||||
|
{ type: "message.part.updated", properties: { sessionID: "ses_1", part } },
|
||||||
|
{ type: "message.part.delta", properties: { sessionID: "ses_1", messageID: "msg_1", partID: "part_1", field: "text", delta: "b" } },
|
||||||
|
{ type: "message.updated", properties: { info } },
|
||||||
|
{ type: "message.part.removed", properties: { sessionID: "ses_1", messageID: "msg_1", partID: "part_1" } },
|
||||||
|
{ type: "message.updated", properties: { info } },
|
||||||
|
]
|
||||||
|
const setupResult = setup(() => new Response(events.map((payload) => `data: ${JSON.stringify({ directory: "/repo", payload })}\n\n`).join(""), { headers: { "content-type": "text/event-stream" } }))
|
||||||
|
const iterator = setupResult.backend.common.events.subscribe()[Symbol.asyncIterator]()
|
||||||
|
await iterator.next()
|
||||||
|
await iterator.next()
|
||||||
|
await iterator.next()
|
||||||
|
const afterDelta = await iterator.next()
|
||||||
|
await iterator.next()
|
||||||
|
const afterRemoval = await iterator.next()
|
||||||
|
|
||||||
|
expect(afterDelta.value?.event).toMatchObject({ item: { content: [{ id: "part_1", text: "ab" }] } })
|
||||||
|
expect(afterRemoval.value?.event).toMatchObject({ item: { content: [] } })
|
||||||
|
expect(setupResult.requests).toHaveLength(1)
|
||||||
|
})
|
||||||
|
|
||||||
test("merges global config updates with untouched fields", async () => {
|
test("merges global config updates with untouched fields", async () => {
|
||||||
const bodies: unknown[] = []
|
const bodies: unknown[] = []
|
||||||
const setupResult = setup(async (request) => {
|
const setupResult = setup(async (request) => {
|
||||||
|
|
@ -137,4 +333,75 @@ describe("createV1Backend", () => {
|
||||||
|
|
||||||
expect(bodies).toEqual([{ autoupdate: true, model: "new", disabled_providers: ["two"] }])
|
expect(bodies).toEqual([{ autoupdate: true, model: "new", disabled_providers: ["two"] }])
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("uses legacy PTY endpoints, location queries, status, tickets, and auth fallback", async () => {
|
||||||
|
const setupResult = setup((request) => {
|
||||||
|
const path = new URL(request.url).pathname
|
||||||
|
if (path.endsWith("/connect-token")) return new Response(null, { status: 405 })
|
||||||
|
if (request.method === "GET") return new Response(null, { status: 404 })
|
||||||
|
return new Response(null, { status: 204 })
|
||||||
|
})
|
||||||
|
|
||||||
|
await setupResult.backend.common.permissions.reply({
|
||||||
|
sessionID: "ses_1",
|
||||||
|
requestID: "per_1",
|
||||||
|
reply: "once",
|
||||||
|
location: { directory: "/explicit", workspaceID: "workspace" },
|
||||||
|
})
|
||||||
|
await setupResult.backend.common.questions.reject({
|
||||||
|
sessionID: "ses_1",
|
||||||
|
requestID: "que_1",
|
||||||
|
location: { directory: "/explicit", workspaceID: "workspace" },
|
||||||
|
})
|
||||||
|
const transport = setupResult.backend.capabilities.ptyTransport
|
||||||
|
const ticket = await transport?.connectToken({
|
||||||
|
ptyID: "pty_1",
|
||||||
|
location: { directory: "/explicit", workspaceID: "workspace" },
|
||||||
|
})
|
||||||
|
const exists = await transport?.exists({
|
||||||
|
ptyID: "pty_1",
|
||||||
|
location: { directory: "/explicit", workspaceID: "workspace" },
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(ticket).toEqual({ status: 405, ticket: undefined })
|
||||||
|
expect(exists).toBe(false)
|
||||||
|
expect(setupResult.requests.map((request) => new URL(request.url).searchParams.get("directory"))).toEqual([
|
||||||
|
"/explicit",
|
||||||
|
"/explicit",
|
||||||
|
"/explicit",
|
||||||
|
"/explicit",
|
||||||
|
])
|
||||||
|
expect(setupResult.requests[2].headers.get("x-opencode-ticket")).toBe("1")
|
||||||
|
|
||||||
|
const fallback = transport?.connectURL({
|
||||||
|
ptyID: "pty/1",
|
||||||
|
location: { directory: "/explicit", workspaceID: "workspace" },
|
||||||
|
cursor: 12,
|
||||||
|
})
|
||||||
|
expect(fallback?.pathname).toBe("/pty/pty%2F1/connect")
|
||||||
|
expect(fallback?.protocol).toBe("ws:")
|
||||||
|
expect(fallback?.searchParams.get("directory")).toBe("/explicit")
|
||||||
|
expect(fallback?.searchParams.get("workspace")).toBe("workspace")
|
||||||
|
expect(fallback?.searchParams.get("cursor")).toBe("12")
|
||||||
|
expect(fallback?.searchParams.get("auth_token")).toBe(btoa("user:secret"))
|
||||||
|
|
||||||
|
const ticketURL = transport?.connectURL({
|
||||||
|
ptyID: "pty_1",
|
||||||
|
location: { directory: "/explicit" },
|
||||||
|
cursor: -1,
|
||||||
|
ticket: "ticket value",
|
||||||
|
})
|
||||||
|
expect(ticketURL?.searchParams.get("ticket")).toBe("ticket value")
|
||||||
|
expect(ticketURL?.searchParams.has("auth_token")).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("preserves same-origin auth-token policy for PTY URLs", () => {
|
||||||
|
const saved = setup(() => new Response(), false, { sameOrigin: true }).backend.capabilities.ptyTransport
|
||||||
|
const token = setup(() => new Response(), false, { sameOrigin: true, authToken: true }).backend.capabilities
|
||||||
|
.ptyTransport
|
||||||
|
const input = { ptyID: "pty_1", location: { directory: "/repo" }, cursor: 0 }
|
||||||
|
|
||||||
|
expect(saved?.connectURL(input).searchParams.has("auth_token")).toBe(false)
|
||||||
|
expect(token?.connectURL(input).searchParams.get("auth_token")).toBe(btoa("user:secret"))
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -34,6 +34,7 @@ import type {
|
||||||
Page,
|
Page,
|
||||||
PromptFile,
|
PromptFile,
|
||||||
PromptInput,
|
PromptInput,
|
||||||
|
PtyTransportConfig,
|
||||||
ProviderCatalog,
|
ProviderCatalog,
|
||||||
RequestOptions,
|
RequestOptions,
|
||||||
SessionActivity,
|
SessionActivity,
|
||||||
|
|
@ -47,7 +48,12 @@ type CachedMessage = {
|
||||||
parts: Part[]
|
parts: Part[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createV1Backend(client: OpencodeClient, defaultLocation?: LocationRef): AppClient {
|
export function createV1Backend(
|
||||||
|
client: OpencodeClient,
|
||||||
|
defaultLocation?: LocationRef,
|
||||||
|
eventClient: OpencodeClient = client,
|
||||||
|
transportConfig?: PtyTransportConfig,
|
||||||
|
): AppClient {
|
||||||
const messages = new Map<string, CachedMessage>()
|
const messages = new Map<string, CachedMessage>()
|
||||||
|
|
||||||
const options = (input?: RequestOptions) => ({ signal: input?.signal, throwOnError: true as const })
|
const options = (input?: RequestOptions) => ({ signal: input?.signal, throwOnError: true as const })
|
||||||
|
|
@ -57,13 +63,58 @@ export function createV1Backend(client: OpencodeClient, defaultLocation?: Locati
|
||||||
return toTimelineItem(input.info, input.parts)
|
return toTimelineItem(input.info, input.parts)
|
||||||
}
|
}
|
||||||
|
|
||||||
const loadMessage = async (input: { sessionID: string; messageID: string }, request?: RequestOptions) => {
|
const loadMessage = async (
|
||||||
|
input: LocationInput & { sessionID: string; messageID: string },
|
||||||
|
request?: RequestOptions,
|
||||||
|
force?: boolean,
|
||||||
|
) => {
|
||||||
const cached = messages.get(input.messageID)
|
const cached = messages.get(input.messageID)
|
||||||
if (cached) return cached
|
if (cached && !force) return cached
|
||||||
const result = await client.session.message({ ...input, ...legacyLocation(defaultLocation) }, options(request))
|
const result = await client.session.message(
|
||||||
|
{ sessionID: input.sessionID, messageID: input.messageID, ...location(input) },
|
||||||
|
options(request),
|
||||||
|
)
|
||||||
messages.set(input.messageID, result.data)
|
messages.set(input.messageID, result.data)
|
||||||
return result.data
|
return result.data
|
||||||
}
|
}
|
||||||
|
const sessionActions = {
|
||||||
|
remove: async (input: LocationInput & { sessionID: string }, request?: RequestOptions) => {
|
||||||
|
const result = await client.session.delete(
|
||||||
|
{ sessionID: input.sessionID, ...location(input) },
|
||||||
|
options(request),
|
||||||
|
)
|
||||||
|
return result.data
|
||||||
|
},
|
||||||
|
fork: async (input: LocationInput & { sessionID: string; messageID?: string }, request?: RequestOptions) => {
|
||||||
|
const result = await client.session.fork(
|
||||||
|
{ sessionID: input.sessionID, messageID: input.messageID, ...location(input) },
|
||||||
|
options(request),
|
||||||
|
)
|
||||||
|
return toSession(result.data)
|
||||||
|
},
|
||||||
|
rename: async (input: LocationInput & { sessionID: string; title: string }, request?: RequestOptions) => {
|
||||||
|
await client.session.update(
|
||||||
|
{ sessionID: input.sessionID, title: input.title, ...location(input) },
|
||||||
|
options(request),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
command: async (input: CommandInput, request?: RequestOptions) => {
|
||||||
|
await client.session.command(
|
||||||
|
{
|
||||||
|
sessionID: input.sessionID,
|
||||||
|
messageID: input.id,
|
||||||
|
command: input.command,
|
||||||
|
arguments: input.arguments ?? "",
|
||||||
|
agent: input.agent,
|
||||||
|
model: input.model && `${input.model.providerID}/${input.model.id}`,
|
||||||
|
variant: input.model?.variant,
|
||||||
|
parts: input.files?.map(toFilePart),
|
||||||
|
...location(input),
|
||||||
|
},
|
||||||
|
options(request),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
version: "v1",
|
version: "v1",
|
||||||
|
|
@ -75,10 +126,6 @@ export function createV1Backend(client: OpencodeClient, defaultLocation?: Locati
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
projects: {
|
projects: {
|
||||||
list: async (request) => {
|
|
||||||
const result = await client.project.list(undefined, options(request))
|
|
||||||
return result.data.map(toProject)
|
|
||||||
},
|
|
||||||
current: async (input, request) => {
|
current: async (input, request) => {
|
||||||
const params = location(input)
|
const params = location(input)
|
||||||
const [project, path] = await Promise.all([
|
const [project, path] = await Promise.all([
|
||||||
|
|
@ -130,7 +177,7 @@ export function createV1Backend(client: OpencodeClient, defaultLocation?: Locati
|
||||||
)
|
)
|
||||||
return {
|
return {
|
||||||
items: result.data.map(toSession),
|
items: result.data.map(toSession),
|
||||||
next: result.response.headers.get("x-next-cursor") ?? undefined,
|
older: result.response.headers.get("x-next-cursor") ?? undefined,
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
create: async (input, request) => {
|
create: async (input, request) => {
|
||||||
|
|
@ -148,22 +195,6 @@ export function createV1Backend(client: OpencodeClient, defaultLocation?: Locati
|
||||||
const result = await client.session.get({ sessionID: input.sessionID, ...location(input) }, options(request))
|
const result = await client.session.get({ sessionID: input.sessionID, ...location(input) }, options(request))
|
||||||
return toSession(result.data)
|
return toSession(result.data)
|
||||||
},
|
},
|
||||||
remove: async (input, request) => {
|
|
||||||
await client.session.delete({ sessionID: input.sessionID, ...location(input) }, options(request))
|
|
||||||
},
|
|
||||||
fork: async (input, request) => {
|
|
||||||
const result = await client.session.fork(
|
|
||||||
{ sessionID: input.sessionID, messageID: input.messageID, ...location(input) },
|
|
||||||
options(request),
|
|
||||||
)
|
|
||||||
return toSession(result.data)
|
|
||||||
},
|
|
||||||
rename: async (input, request) => {
|
|
||||||
await client.session.update(
|
|
||||||
{ sessionID: input.sessionID, title: input.title, ...location(input) },
|
|
||||||
options(request),
|
|
||||||
)
|
|
||||||
},
|
|
||||||
interrupt: async (input, request) => {
|
interrupt: async (input, request) => {
|
||||||
await client.session.abort({ sessionID: input.sessionID, ...location(input) }, options(request))
|
await client.session.abort({ sessionID: input.sessionID, ...location(input) }, options(request))
|
||||||
},
|
},
|
||||||
|
|
@ -182,16 +213,16 @@ export function createV1Backend(client: OpencodeClient, defaultLocation?: Locati
|
||||||
sessionID: input.sessionID,
|
sessionID: input.sessionID,
|
||||||
limit: input.limit,
|
limit: input.limit,
|
||||||
before: input.cursor,
|
before: input.cursor,
|
||||||
...legacyLocation(defaultLocation),
|
...location(input),
|
||||||
},
|
},
|
||||||
options(request),
|
options(request),
|
||||||
)
|
)
|
||||||
return {
|
return {
|
||||||
items: result.data.map(cache),
|
items: result.data.map(cache),
|
||||||
next: result.response.headers.get("x-next-cursor") ?? undefined,
|
older: result.response.headers.get("x-next-cursor") ?? undefined,
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
message: async (input, request) => cache(await loadMessage(input, request)),
|
message: async (input, request) => cache(await loadMessage(input, request, true)),
|
||||||
prompt: async (input, request) => {
|
prompt: async (input, request) => {
|
||||||
await client.session.promptAsync(
|
await client.session.promptAsync(
|
||||||
{
|
{
|
||||||
|
|
@ -204,23 +235,7 @@ export function createV1Backend(client: OpencodeClient, defaultLocation?: Locati
|
||||||
},
|
},
|
||||||
variant: input.selection?.model?.variant,
|
variant: input.selection?.model?.variant,
|
||||||
parts: toPromptParts(input),
|
parts: toPromptParts(input),
|
||||||
...legacyLocation(defaultLocation),
|
...location(input),
|
||||||
},
|
|
||||||
options(request),
|
|
||||||
)
|
|
||||||
},
|
|
||||||
command: async (input, request) => {
|
|
||||||
await client.session.command(
|
|
||||||
{
|
|
||||||
sessionID: input.sessionID,
|
|
||||||
messageID: input.id,
|
|
||||||
command: input.command,
|
|
||||||
arguments: input.arguments ?? "",
|
|
||||||
agent: input.agent,
|
|
||||||
model: input.model && `${input.model.providerID}/${input.model.id}`,
|
|
||||||
variant: input.model?.variant,
|
|
||||||
parts: input.files?.map(toFilePart),
|
|
||||||
...legacyLocation(defaultLocation),
|
|
||||||
},
|
},
|
||||||
options(request),
|
options(request),
|
||||||
)
|
)
|
||||||
|
|
@ -229,7 +244,13 @@ export function createV1Backend(client: OpencodeClient, defaultLocation?: Locati
|
||||||
files: {
|
files: {
|
||||||
list: async (input, request) => {
|
list: async (input, request) => {
|
||||||
const result = await client.file.list({ path: input.path ?? "", ...location(input) }, options(request))
|
const result = await client.file.list({ path: input.path ?? "", ...location(input) }, options(request))
|
||||||
return result.data.map((item) => ({ path: item.path, type: item.type }))
|
return result.data.map((item) => ({
|
||||||
|
path: item.path,
|
||||||
|
name: item.name,
|
||||||
|
absolute: item.absolute,
|
||||||
|
type: item.type,
|
||||||
|
ignored: false,
|
||||||
|
}))
|
||||||
},
|
},
|
||||||
find: async (input, request) => {
|
find: async (input, request) => {
|
||||||
const find = async (type: FileEntry["type"]) => {
|
const find = async (type: FileEntry["type"]) => {
|
||||||
|
|
@ -259,7 +280,7 @@ export function createV1Backend(client: OpencodeClient, defaultLocation?: Locati
|
||||||
requestID: input.requestID,
|
requestID: input.requestID,
|
||||||
reply: input.reply,
|
reply: input.reply,
|
||||||
message: input.message,
|
message: input.message,
|
||||||
...legacyLocation(defaultLocation),
|
...location(input),
|
||||||
},
|
},
|
||||||
options(request),
|
options(request),
|
||||||
)
|
)
|
||||||
|
|
@ -275,52 +296,13 @@ export function createV1Backend(client: OpencodeClient, defaultLocation?: Locati
|
||||||
{
|
{
|
||||||
requestID: input.requestID,
|
requestID: input.requestID,
|
||||||
answers: input.answers.map((answer) => [...answer]),
|
answers: input.answers.map((answer) => [...answer]),
|
||||||
...legacyLocation(defaultLocation),
|
...location(input),
|
||||||
},
|
},
|
||||||
options(request),
|
options(request),
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
reject: async (input, request) => {
|
reject: async (input, request) => {
|
||||||
await client.question.reject(
|
await client.question.reject({ requestID: input.requestID, ...location(input) }, options(request))
|
||||||
{ requestID: input.requestID, ...legacyLocation(defaultLocation) },
|
|
||||||
options(request),
|
|
||||||
)
|
|
||||||
},
|
|
||||||
},
|
|
||||||
vcs: {
|
|
||||||
status: async (input, request) => {
|
|
||||||
const result = await client.vcs.status(location(input), options(request))
|
|
||||||
return result.data
|
|
||||||
},
|
|
||||||
diff: async (input, request) => {
|
|
||||||
const result = await client.vcs.diff(
|
|
||||||
{
|
|
||||||
mode: input.mode === "working" ? "git" : "branch",
|
|
||||||
context: input.context,
|
|
||||||
...location(input),
|
|
||||||
},
|
|
||||||
options(request),
|
|
||||||
)
|
|
||||||
return result.data
|
|
||||||
},
|
|
||||||
},
|
|
||||||
mcp: {
|
|
||||||
list: async (input, request) => {
|
|
||||||
const result = await client.mcp.status(location(input), options(request))
|
|
||||||
return Object.entries(result.data).map(([name, status]) => ({ name, status }))
|
|
||||||
},
|
|
||||||
resources: async (input, request) => {
|
|
||||||
const result = await client.experimental.resource.list(location(input), options(request))
|
|
||||||
return {
|
|
||||||
resources: Object.values(result.data).map((item) => ({
|
|
||||||
server: item.client,
|
|
||||||
name: item.name,
|
|
||||||
uri: item.uri,
|
|
||||||
description: item.description,
|
|
||||||
mimeType: item.mimeType,
|
|
||||||
})),
|
|
||||||
templates: [],
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
pty: {
|
pty: {
|
||||||
|
|
@ -365,7 +347,7 @@ export function createV1Backend(client: OpencodeClient, defaultLocation?: Locati
|
||||||
events: {
|
events: {
|
||||||
subscribe: (request) => ({
|
subscribe: (request) => ({
|
||||||
async *[Symbol.asyncIterator]() {
|
async *[Symbol.asyncIterator]() {
|
||||||
const result = await client.global.event(options(request))
|
const result = await eventClient.global.event(options(request))
|
||||||
for await (const input of result.stream) {
|
for await (const input of result.stream) {
|
||||||
const event = await toEvent(input, messages, loadMessage)
|
const event = await toEvent(input, messages, loadMessage)
|
||||||
yield {
|
yield {
|
||||||
|
|
@ -379,11 +361,51 @@ export function createV1Backend(client: OpencodeClient, defaultLocation?: Locati
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
disposeLocation: async (input, request) => {
|
|
||||||
await client.instance.dispose(location(input), options(request))
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
capabilities: {
|
capabilities: {
|
||||||
|
projectList: {
|
||||||
|
list: async (request) => {
|
||||||
|
const result = await client.project.list(undefined, options(request))
|
||||||
|
return result.data.map(toProject)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
vcs: {
|
||||||
|
status: async (input, request) => {
|
||||||
|
const result = await client.vcs.status(location(input), options(request))
|
||||||
|
return result.data
|
||||||
|
},
|
||||||
|
diff: async (input, request) => {
|
||||||
|
const result = await client.vcs.diff(
|
||||||
|
{
|
||||||
|
mode: input.mode === "working" ? "git" : "branch",
|
||||||
|
context: input.context,
|
||||||
|
...location(input),
|
||||||
|
},
|
||||||
|
options(request),
|
||||||
|
)
|
||||||
|
return result.data
|
||||||
|
},
|
||||||
|
},
|
||||||
|
mcp: {
|
||||||
|
list: async (input, request) => {
|
||||||
|
const result = await client.mcp.status(location(input), options(request))
|
||||||
|
return Object.entries(result.data).map(([name, status]) => ({ name, status }))
|
||||||
|
},
|
||||||
|
resources: async (input, request) => {
|
||||||
|
const result = await client.experimental.resource.list(location(input), options(request))
|
||||||
|
return {
|
||||||
|
resources: Object.values(result.data).map((item) => ({
|
||||||
|
server: item.client,
|
||||||
|
name: item.name,
|
||||||
|
uri: item.uri,
|
||||||
|
description: item.description,
|
||||||
|
mimeType: item.mimeType,
|
||||||
|
})),
|
||||||
|
templates: [],
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
sessionActionsV1: sessionActions,
|
||||||
configuration: {
|
configuration: {
|
||||||
getGlobal: async (request) => {
|
getGlobal: async (request) => {
|
||||||
const result = await client.global.config.get(options(request))
|
const result = await client.global.config.get(options(request))
|
||||||
|
|
@ -468,57 +490,74 @@ export function createV1Backend(client: OpencodeClient, defaultLocation?: Locati
|
||||||
return { directory: result.data.directory, branch: result.data.branch }
|
return { directory: result.data.directory, branch: result.data.branch }
|
||||||
},
|
},
|
||||||
remove: async (input, request) => {
|
remove: async (input, request) => {
|
||||||
await client.worktree.remove(
|
const result = await client.worktree.remove(
|
||||||
{ ...location(input), worktreeRemoveInput: { directory: input.directory } },
|
{ ...location(input), worktreeRemoveInput: { directory: input.directory } },
|
||||||
options(request),
|
options(request),
|
||||||
)
|
)
|
||||||
|
return result.data
|
||||||
},
|
},
|
||||||
reset: async (input, request) => {
|
reset: async (input, request) => {
|
||||||
await client.worktree.reset(
|
const result = await client.worktree.reset(
|
||||||
{ ...location(input), worktreeResetInput: { directory: input.directory } },
|
{ ...location(input), worktreeResetInput: { directory: input.directory } },
|
||||||
options(request),
|
options(request),
|
||||||
)
|
)
|
||||||
|
return result.data
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
sessionExtrasV1: {
|
sessionExtrasV1: {
|
||||||
archive: async (sessionID, archivedAt, request) => {
|
archive: async (input, request) => {
|
||||||
await client.session.update(
|
await client.session.update(
|
||||||
{ sessionID, time: { archived: archivedAt }, ...legacyLocation(defaultLocation) },
|
{ sessionID: input.sessionID, time: { archived: input.archivedAt }, ...location(input) },
|
||||||
options(request),
|
options(request),
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
share: async (sessionID, request) => {
|
share: async (input, request) => {
|
||||||
const result = await client.session.share({ sessionID, ...legacyLocation(defaultLocation) }, options(request))
|
const result = await client.session.share(
|
||||||
if (!result.data.share) throw new Error(`Session ${sessionID} was shared without a URL`)
|
{ sessionID: input.sessionID, ...location(input) },
|
||||||
|
options(request),
|
||||||
|
)
|
||||||
|
if (!result.data.share) throw new Error(`Session ${input.sessionID} was shared without a URL`)
|
||||||
return result.data.share.url
|
return result.data.share.url
|
||||||
},
|
},
|
||||||
unshare: async (sessionID, request) => {
|
unshare: async (input, request) => {
|
||||||
await client.session.unshare({ sessionID, ...legacyLocation(defaultLocation) }, options(request))
|
await client.session.unshare({ sessionID: input.sessionID, ...location(input) }, options(request))
|
||||||
},
|
},
|
||||||
diff: async (sessionID, request) => {
|
diff: async (input, request) => {
|
||||||
const result = await client.session.diff({ sessionID, ...legacyLocation(defaultLocation) }, options(request))
|
const result = await client.session.diff({ sessionID: input.sessionID, ...location(input) }, options(request))
|
||||||
return result.data.flatMap((item) => (item.file ? [{ ...item, file: item.file }] : []))
|
return result.data.flatMap((item) => (item.file ? [{ ...item, file: item.file }] : []))
|
||||||
},
|
},
|
||||||
todos: async (sessionID, request) => {
|
todos: async (input, request) => {
|
||||||
const result = await client.session.todo({ sessionID, ...legacyLocation(defaultLocation) }, options(request))
|
const result = await client.session.todo({ sessionID: input.sessionID, ...location(input) }, options(request))
|
||||||
return result.data.map((item) => ({ content: item.content, status: item.status }))
|
return result.data.map((item) => ({
|
||||||
|
content: item.content,
|
||||||
|
status: item.status,
|
||||||
|
priority: item.priority,
|
||||||
|
}))
|
||||||
},
|
},
|
||||||
summarize: async (sessionID, model, request) => {
|
summarize: async (input, request) => {
|
||||||
await client.session.summarize(
|
await client.session.summarize(
|
||||||
{
|
{
|
||||||
sessionID,
|
sessionID: input.sessionID,
|
||||||
providerID: model.providerID,
|
providerID: input.model.providerID,
|
||||||
modelID: model.id,
|
modelID: input.model.id,
|
||||||
...legacyLocation(defaultLocation),
|
...location(input),
|
||||||
},
|
},
|
||||||
options(request),
|
options(request),
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
revert: async (sessionID, messageID, request) => {
|
revert: async (input, request) => {
|
||||||
await client.session.revert({ sessionID, messageID, ...legacyLocation(defaultLocation) }, options(request))
|
const result = await client.session.revert(
|
||||||
|
{ sessionID: input.sessionID, messageID: input.messageID, ...location(input) },
|
||||||
|
options(request),
|
||||||
|
)
|
||||||
|
return toSession(result.data)
|
||||||
},
|
},
|
||||||
clearRevert: async (sessionID, request) => {
|
clearRevert: async (input, request) => {
|
||||||
await client.session.unrevert({ sessionID, ...legacyLocation(defaultLocation) }, options(request))
|
const result = await client.session.unrevert(
|
||||||
|
{ sessionID: input.sessionID, ...location(input) },
|
||||||
|
options(request),
|
||||||
|
)
|
||||||
|
return toSession(result.data)
|
||||||
},
|
},
|
||||||
shell: async (input, request) => {
|
shell: async (input, request) => {
|
||||||
await client.session.shell(
|
await client.session.shell(
|
||||||
|
|
@ -569,11 +608,26 @@ export function createV1Backend(client: OpencodeClient, defaultLocation?: Locati
|
||||||
return toDecoratedFile(result.data)
|
return toDecoratedFile(result.data)
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
ptyTransport: {
|
ptyTransport: transportConfig && {
|
||||||
connectToken: async (input, request) => {
|
connectToken: async (input, request) => {
|
||||||
const result = await client.pty.connectToken({ ptyID: input.ptyID, ...location(input) }, options(request))
|
const result = await client.pty.connectToken(
|
||||||
return { ticket: result.data.ticket }
|
{ ptyID: input.ptyID, ...location(input) },
|
||||||
|
{
|
||||||
|
signal: request?.signal,
|
||||||
|
throwOnError: false,
|
||||||
|
headers: { "x-opencode-ticket": "1" },
|
||||||
|
},
|
||||||
|
)
|
||||||
|
return { status: result.response.status, ticket: result.data?.ticket }
|
||||||
},
|
},
|
||||||
|
exists: async (input, request) => {
|
||||||
|
const result = await client.pty.get(
|
||||||
|
{ ptyID: input.ptyID, ...location(input) },
|
||||||
|
{ signal: request?.signal, throwOnError: false },
|
||||||
|
)
|
||||||
|
return result.response.status !== 404
|
||||||
|
},
|
||||||
|
connectURL: (input) => ptyConnectURL(transportConfig, "/pty", input, legacyLocation(input.location)),
|
||||||
},
|
},
|
||||||
shellDiscovery: {
|
shellDiscovery: {
|
||||||
list: async (input, request) => {
|
list: async (input, request) => {
|
||||||
|
|
@ -582,6 +636,9 @@ export function createV1Backend(client: OpencodeClient, defaultLocation?: Locati
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
runtimeV1: {
|
runtimeV1: {
|
||||||
|
disposeLocation: async (input, request) => {
|
||||||
|
await client.instance.dispose(location(input), options(request))
|
||||||
|
},
|
||||||
disposeAll: async (request) => {
|
disposeAll: async (request) => {
|
||||||
await client.global.dispose(options(request))
|
await client.global.dispose(options(request))
|
||||||
},
|
},
|
||||||
|
|
@ -590,10 +647,31 @@ export function createV1Backend(client: OpencodeClient, defaultLocation?: Locati
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function ptyConnectURL(
|
||||||
|
config: PtyTransportConfig,
|
||||||
|
root: string,
|
||||||
|
input: { ptyID: string; cursor: number; ticket?: string },
|
||||||
|
location: { directory?: string; workspace?: string },
|
||||||
|
) {
|
||||||
|
const url = new URL(`${config.baseUrl.replace(/\/+$/, "")}${root}/${encodeURIComponent(input.ptyID)}/connect`)
|
||||||
|
if (location.directory) url.searchParams.set("directory", location.directory)
|
||||||
|
if (location.workspace) url.searchParams.set("workspace", location.workspace)
|
||||||
|
url.searchParams.set("cursor", String(input.cursor))
|
||||||
|
url.protocol = url.protocol === "https:" ? "wss:" : "ws:"
|
||||||
|
if (input.ticket) {
|
||||||
|
url.searchParams.set("ticket", input.ticket)
|
||||||
|
return url
|
||||||
|
}
|
||||||
|
if (config.password && (!config.sameOrigin || config.authToken))
|
||||||
|
url.searchParams.set("auth_token", btoa(`${config.username ?? "opencode"}:${config.password}`))
|
||||||
|
return url
|
||||||
|
}
|
||||||
|
|
||||||
function legacyLocation(input?: LocationRef) {
|
function legacyLocation(input?: LocationRef) {
|
||||||
|
if (!input) return {}
|
||||||
return {
|
return {
|
||||||
directory: input?.directory,
|
directory: input.directory,
|
||||||
workspace: input?.workspaceID,
|
workspace: input.workspaceID,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -609,6 +687,8 @@ function toProject(input: Project): AppProject {
|
||||||
return {
|
return {
|
||||||
id: input.id,
|
id: input.id,
|
||||||
worktree: input.worktree,
|
worktree: input.worktree,
|
||||||
|
vcs: input.vcs,
|
||||||
|
time: { ...input.time, updated: input.time.updated ?? input.time.created },
|
||||||
name: input.name,
|
name: input.name,
|
||||||
icon: input.icon,
|
icon: input.icon,
|
||||||
commands: input.commands,
|
commands: input.commands,
|
||||||
|
|
@ -619,9 +699,13 @@ function toProject(input: Project): AppProject {
|
||||||
function toSession(input: Session): AppSession {
|
function toSession(input: Session): AppSession {
|
||||||
return {
|
return {
|
||||||
id: input.id,
|
id: input.id,
|
||||||
|
slug: input.slug,
|
||||||
|
version: input.version,
|
||||||
parentID: input.parentID,
|
parentID: input.parentID,
|
||||||
projectID: input.projectID,
|
projectID: input.projectID,
|
||||||
location: { directory: input.directory, workspaceID: input.workspaceID },
|
location: { directory: input.directory, workspaceID: input.workspaceID },
|
||||||
|
directory: input.directory,
|
||||||
|
workspaceID: input.workspaceID,
|
||||||
title: input.title,
|
title: input.title,
|
||||||
cost: input.cost ?? 0,
|
cost: input.cost ?? 0,
|
||||||
tokens: input.tokens,
|
tokens: input.tokens,
|
||||||
|
|
@ -657,6 +741,7 @@ function toProvider(input: Provider): AppProvider {
|
||||||
return {
|
return {
|
||||||
id: input.id,
|
id: input.id,
|
||||||
name: input.name,
|
name: input.name,
|
||||||
|
source: input.source,
|
||||||
models: Object.fromEntries(
|
models: Object.fromEntries(
|
||||||
Object.entries(input.models).flatMap(([id, model]) =>
|
Object.entries(input.models).flatMap(([id, model]) =>
|
||||||
model.status === "deprecated" ? [] : [[id, toModel(model)]],
|
model.status === "deprecated" ? [] : [[id, toModel(model)]],
|
||||||
|
|
@ -714,8 +799,8 @@ function toReference(input: import("@opencode-ai/sdk-v1/v2/client").ReferenceInf
|
||||||
return input
|
return input
|
||||||
}
|
}
|
||||||
|
|
||||||
function toActivity(input: import("@opencode-ai/sdk-v1/v2/client").SessionStatus): SessionActivity | undefined {
|
function toActivity(input: import("@opencode-ai/sdk-v1/v2/client").SessionStatus): SessionActivity {
|
||||||
if (input.type === "idle") return
|
if (input.type === "idle") return { type: "idle" }
|
||||||
if (input.type === "busy") return { type: "running" }
|
if (input.type === "busy") return { type: "running" }
|
||||||
return input
|
return input
|
||||||
}
|
}
|
||||||
|
|
@ -734,7 +819,10 @@ function toTimelineItem(info: Message, parts: readonly Part[]): TimelineItem {
|
||||||
providerID: info.model.providerID,
|
providerID: info.model.providerID,
|
||||||
variant: info.model.variant,
|
variant: info.model.variant,
|
||||||
},
|
},
|
||||||
raw: { info, parts },
|
format: info.format,
|
||||||
|
summary: info.summary,
|
||||||
|
system: info.system,
|
||||||
|
tools: info.tools,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
|
|
@ -749,7 +837,12 @@ function toTimelineItem(info: Message, parts: readonly Part[]): TimelineItem {
|
||||||
model: { id: info.modelID, providerID: info.providerID, variant: info.variant },
|
model: { id: info.modelID, providerID: info.providerID, variant: info.variant },
|
||||||
tokens: info.tokens,
|
tokens: info.tokens,
|
||||||
error: info.error,
|
error: info.error,
|
||||||
raw: { info, parts },
|
mode: info.mode,
|
||||||
|
path: info.path,
|
||||||
|
cost: info.cost,
|
||||||
|
structured: info.structured,
|
||||||
|
finish: info.finish,
|
||||||
|
summary: info.summary,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -763,9 +856,11 @@ function toTimelineContent(input: Part): TimelineContent[] {
|
||||||
synthetic: input.synthetic,
|
synthetic: input.synthetic,
|
||||||
ignored: input.ignored,
|
ignored: input.ignored,
|
||||||
metadata: input.metadata,
|
metadata: input.metadata,
|
||||||
|
time: input.time,
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
if (input.type === "reasoning") return [{ type: input.type, id: input.id, text: input.text }]
|
if (input.type === "reasoning")
|
||||||
|
return [{ type: input.type, id: input.id, text: input.text, metadata: input.metadata, time: input.time }]
|
||||||
if (input.type === "file")
|
if (input.type === "file")
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
|
|
@ -794,14 +889,52 @@ function toTimelineContent(input: Part): TimelineContent[] {
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
if (input.type === "tool")
|
if (input.type === "tool")
|
||||||
return [{ type: input.type, id: input.id, callID: input.callID, tool: input.tool, state: toToolState(input.state) }]
|
return [
|
||||||
|
{
|
||||||
|
type: input.type,
|
||||||
|
id: input.id,
|
||||||
|
callID: input.callID,
|
||||||
|
tool: input.tool,
|
||||||
|
state: toToolState(input.state),
|
||||||
|
metadata: input.metadata,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
if (input.type === "subtask")
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
type: input.type,
|
||||||
|
id: input.id,
|
||||||
|
prompt: input.prompt,
|
||||||
|
description: input.description,
|
||||||
|
agent: input.agent,
|
||||||
|
model: input.model && { id: input.model.modelID, providerID: input.model.providerID },
|
||||||
|
command: input.command,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
if (input.type === "step-start") return [{ type: input.type, id: input.id, snapshot: input.snapshot }]
|
||||||
|
if (input.type === "step-finish")
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
type: input.type,
|
||||||
|
id: input.id,
|
||||||
|
reason: input.reason,
|
||||||
|
snapshot: input.snapshot,
|
||||||
|
cost: input.cost,
|
||||||
|
tokens: input.tokens,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
if (input.type === "snapshot") return [{ type: input.type, id: input.id, snapshot: input.snapshot }]
|
||||||
|
if (input.type === "patch") return [{ type: input.type, id: input.id, hash: input.hash, files: input.files }]
|
||||||
|
if (input.type === "retry")
|
||||||
|
return [{ type: input.type, id: input.id, attempt: input.attempt, error: input.error, time: input.time }]
|
||||||
|
if (input.type === "compaction") return [{ type: input.type, id: input.id, auto: input.auto }]
|
||||||
return []
|
return []
|
||||||
}
|
}
|
||||||
|
|
||||||
function toToolState(input: import("@opencode-ai/sdk-v1/v2/client").ToolState): ToolState {
|
function toToolState(input: import("@opencode-ai/sdk-v1/v2/client").ToolState): ToolState {
|
||||||
if (input.status === "pending") return { status: input.status, input: input.input, raw: input.raw }
|
if (input.status === "pending") return { status: input.status, input: input.input, raw: input.raw }
|
||||||
if (input.status === "running")
|
if (input.status === "running")
|
||||||
return { status: input.status, input: input.input, title: input.title, metadata: input.metadata }
|
return { status: input.status, input: input.input, title: input.title, metadata: input.metadata, time: input.time }
|
||||||
if (input.status === "completed")
|
if (input.status === "completed")
|
||||||
return {
|
return {
|
||||||
status: input.status,
|
status: input.status,
|
||||||
|
|
@ -809,8 +942,10 @@ function toToolState(input: import("@opencode-ai/sdk-v1/v2/client").ToolState):
|
||||||
output: input.output,
|
output: input.output,
|
||||||
title: input.title,
|
title: input.title,
|
||||||
metadata: input.metadata,
|
metadata: input.metadata,
|
||||||
|
time: input.time,
|
||||||
|
attachments: input.attachments,
|
||||||
}
|
}
|
||||||
return { status: input.status, input: input.input, error: input.error, metadata: input.metadata }
|
return { status: input.status, input: input.input, error: input.error, metadata: input.metadata, time: input.time }
|
||||||
}
|
}
|
||||||
|
|
||||||
function toFilePart(input: PromptFile) {
|
function toFilePart(input: PromptFile) {
|
||||||
|
|
@ -830,6 +965,7 @@ function toFilePart(input: PromptFile) {
|
||||||
}
|
}
|
||||||
|
|
||||||
function toPromptParts(input: PromptInput) {
|
function toPromptParts(input: PromptInput) {
|
||||||
|
if (input.parts) return input.parts.map(toPromptPart)
|
||||||
return [
|
return [
|
||||||
{ type: "text" as const, text: input.text },
|
{ type: "text" as const, text: input.text },
|
||||||
...(input.files?.map(toFilePart) ?? []),
|
...(input.files?.map(toFilePart) ?? []),
|
||||||
|
|
@ -844,6 +980,11 @@ function toPromptParts(input: PromptInput) {
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function toPromptPart(input: NonNullable<PromptInput["parts"]>[number]) {
|
||||||
|
if (input.type === "file" && input.source?.type === "resource") return { ...input, source: undefined }
|
||||||
|
return { ...input }
|
||||||
|
}
|
||||||
|
|
||||||
function toFileContent(input: import("@opencode-ai/sdk-v1/v2/client").FileContent): FileContent {
|
function toFileContent(input: import("@opencode-ai/sdk-v1/v2/client").FileContent): FileContent {
|
||||||
if (input.encoding !== "base64") {
|
if (input.encoding !== "base64") {
|
||||||
return { bytes: new TextEncoder().encode(input.content), kind: input.type, mimeType: input.mimeType }
|
return { bytes: new TextEncoder().encode(input.content), kind: input.type, mimeType: input.mimeType }
|
||||||
|
|
@ -862,7 +1003,7 @@ function toDecoratedFile(input: import("@opencode-ai/sdk-v1/v2/client").FileCont
|
||||||
diff: input.diff,
|
diff: input.diff,
|
||||||
encoding: input.encoding,
|
encoding: input.encoding,
|
||||||
mimeType: input.mimeType,
|
mimeType: input.mimeType,
|
||||||
patch: input.patch && { hunks: input.patch.hunks.map((hunk) => ({ lines: hunk.lines })) },
|
patch: input.patch,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -872,7 +1013,10 @@ function toPermission(input: import("@opencode-ai/sdk-v1/v2/client").PermissionR
|
||||||
sessionID: input.sessionID,
|
sessionID: input.sessionID,
|
||||||
action: input.permission,
|
action: input.permission,
|
||||||
resources: input.patterns,
|
resources: input.patterns,
|
||||||
metadata: input.metadata,
|
permission: input.permission,
|
||||||
|
patterns: input.patterns,
|
||||||
|
always: input.always,
|
||||||
|
metadata: input.metadata ?? {},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -880,7 +1024,11 @@ function toQuestion(input: import("@opencode-ai/sdk-v1/v2/client").QuestionReque
|
||||||
return {
|
return {
|
||||||
id: input.id,
|
id: input.id,
|
||||||
sessionID: input.sessionID,
|
sessionID: input.sessionID,
|
||||||
questions: input.questions,
|
questions: input.questions.map((question) => ({
|
||||||
|
...question,
|
||||||
|
header: question.header ?? "",
|
||||||
|
options: question.options.map((option) => ({ ...option, description: option.description ?? "" })),
|
||||||
|
})),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -891,22 +1039,54 @@ function toPty(input: import("@opencode-ai/sdk-v1/v2/client").Pty) {
|
||||||
async function toEvent(
|
async function toEvent(
|
||||||
envelope: GlobalEvent,
|
envelope: GlobalEvent,
|
||||||
messages: Map<string, CachedMessage>,
|
messages: Map<string, CachedMessage>,
|
||||||
loadMessage: (input: { sessionID: string; messageID: string }) => Promise<CachedMessage>,
|
loadMessage: (input: LocationInput & { sessionID: string; messageID: string }) => Promise<CachedMessage>,
|
||||||
): Promise<AppEvent> {
|
): Promise<AppEvent> {
|
||||||
const input = envelope.payload as Event
|
const input = envelope.payload as Event
|
||||||
if (input.type === "server.connected") return { type: input.type }
|
const eventLocation =
|
||||||
|
envelope.directory === "global"
|
||||||
|
? undefined
|
||||||
|
: { location: { directory: envelope.directory, workspaceID: envelope.workspace } }
|
||||||
|
if (input.type === "server.connected") {
|
||||||
|
messages.clear()
|
||||||
|
return { type: input.type }
|
||||||
|
}
|
||||||
if (input.type === "global.disposed") return { type: "server.disposed" }
|
if (input.type === "global.disposed") return { type: "server.disposed" }
|
||||||
if (input.type === "server.instance.disposed")
|
if (input.type === "server.instance.disposed")
|
||||||
return { type: "server.disposed", location: { directory: input.properties.directory } }
|
return { type: "instance.disposed", location: { directory: input.properties.directory } }
|
||||||
if (input.type === "project.updated") return { type: input.type, project: toProject(input.properties) }
|
if (input.type === "project.updated") return { type: input.type, project: toProject(input.properties) }
|
||||||
if (input.type === "session.created" || input.type === "session.updated")
|
if (input.type === "session.created" || input.type === "session.updated")
|
||||||
return { type: input.type, session: toSession(input.properties.info) }
|
return { type: input.type, session: toSession(input.properties.info) }
|
||||||
if (input.type === "session.deleted") return { type: input.type, sessionID: input.properties.sessionID }
|
if (input.type === "session.deleted") {
|
||||||
|
for (const [messageID, message] of messages) {
|
||||||
|
if (message.info.sessionID === input.properties.sessionID) messages.delete(messageID)
|
||||||
|
}
|
||||||
|
return { type: input.type, sessionID: input.properties.sessionID }
|
||||||
|
}
|
||||||
if (input.type === "session.status") {
|
if (input.type === "session.status") {
|
||||||
const activity = toActivity(input.properties.status)
|
const activity = toActivity(input.properties.status)
|
||||||
if (activity) return { type: "session.activity", sessionID: input.properties.sessionID, activity }
|
if (activity.type === "idle") {
|
||||||
return { type: "unknown", raw: input }
|
for (const [messageID, message] of messages) {
|
||||||
|
if (message.info.sessionID === input.properties.sessionID) messages.delete(messageID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { type: "session.activity", sessionID: input.properties.sessionID, activity }
|
||||||
}
|
}
|
||||||
|
if (input.type === "session.diff")
|
||||||
|
return {
|
||||||
|
type: input.type,
|
||||||
|
sessionID: input.properties.sessionID,
|
||||||
|
diff: input.properties.diff.flatMap((item) => (item.file ? [{ ...item, file: item.file }] : [])),
|
||||||
|
}
|
||||||
|
if (input.type === "todo.updated")
|
||||||
|
return {
|
||||||
|
type: input.type,
|
||||||
|
sessionID: input.properties.sessionID,
|
||||||
|
todos: input.properties.todos.map((todo) => ({
|
||||||
|
content: todo.content,
|
||||||
|
status: todo.status,
|
||||||
|
priority: todo.priority,
|
||||||
|
})),
|
||||||
|
}
|
||||||
if (input.type === "session.error")
|
if (input.type === "session.error")
|
||||||
return { type: input.type, sessionID: input.properties.sessionID, error: input.properties.error }
|
return { type: input.type, sessionID: input.properties.sessionID, error: input.properties.error }
|
||||||
if (input.type === "message.updated") {
|
if (input.type === "message.updated") {
|
||||||
|
|
@ -917,7 +1097,7 @@ async function toEvent(
|
||||||
}
|
}
|
||||||
if (input.type === "message.part.updated") {
|
if (input.type === "message.part.updated") {
|
||||||
const messageID = input.properties.part.messageID
|
const messageID = input.properties.part.messageID
|
||||||
const value = await loadMessage({ sessionID: input.properties.sessionID, messageID })
|
const value = await loadMessage({ sessionID: input.properties.sessionID, messageID, ...eventLocation })
|
||||||
const parts = [...value.parts.filter((part) => part.id !== input.properties.part.id), input.properties.part]
|
const parts = [...value.parts.filter((part) => part.id !== input.properties.part.id), input.properties.part]
|
||||||
const next = { info: value.info, parts }
|
const next = { info: value.info, parts }
|
||||||
messages.set(messageID, next)
|
messages.set(messageID, next)
|
||||||
|
|
@ -928,10 +1108,40 @@ async function toEvent(
|
||||||
return { type: "timeline.removed", sessionID: input.properties.sessionID, itemID: input.properties.messageID }
|
return { type: "timeline.removed", sessionID: input.properties.sessionID, itemID: input.properties.messageID }
|
||||||
}
|
}
|
||||||
if (input.type === "message.part.removed") {
|
if (input.type === "message.part.removed") {
|
||||||
const value = await loadMessage({ sessionID: input.properties.sessionID, messageID: input.properties.messageID })
|
const cached = messages.get(input.properties.messageID)
|
||||||
const next = { info: value.info, parts: value.parts.filter((part) => part.id !== input.properties.partID) }
|
if (cached)
|
||||||
messages.set(input.properties.messageID, next)
|
messages.set(input.properties.messageID, {
|
||||||
return { type: "timeline.updated", item: toTimelineItem(next.info, next.parts) }
|
...cached,
|
||||||
|
parts: cached.parts.filter((part) => part.id !== input.properties.partID),
|
||||||
|
})
|
||||||
|
return {
|
||||||
|
type: "timeline.part.removed",
|
||||||
|
sessionID: input.properties.sessionID,
|
||||||
|
itemID: input.properties.messageID,
|
||||||
|
contentID: input.properties.partID,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (input.type === "message.part.delta") {
|
||||||
|
const cached = messages.get(input.properties.messageID)
|
||||||
|
const part = cached?.parts.find((part) => part.id === input.properties.partID)
|
||||||
|
if (cached && part) {
|
||||||
|
const current = part[input.properties.field as keyof Part]
|
||||||
|
if (typeof current === "string")
|
||||||
|
messages.set(input.properties.messageID, {
|
||||||
|
...cached,
|
||||||
|
parts: cached.parts.map((item) =>
|
||||||
|
item.id === part.id ? { ...item, [input.properties.field]: current + input.properties.delta } : item,
|
||||||
|
),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
type: "timeline.delta",
|
||||||
|
sessionID: input.properties.sessionID,
|
||||||
|
itemID: input.properties.messageID,
|
||||||
|
contentID: input.properties.partID,
|
||||||
|
field: input.properties.field,
|
||||||
|
delta: input.properties.delta,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (input.type === "permission.asked")
|
if (input.type === "permission.asked")
|
||||||
return { type: "permission.requested", request: toPermission(input.properties) }
|
return { type: "permission.requested", request: toPermission(input.properties) }
|
||||||
|
|
@ -942,8 +1152,11 @@ async function toEvent(
|
||||||
id: input.properties.id,
|
id: input.properties.id,
|
||||||
sessionID: input.properties.sessionID,
|
sessionID: input.properties.sessionID,
|
||||||
action: input.properties.action,
|
action: input.properties.action,
|
||||||
resources: input.properties.resources,
|
resources: [...input.properties.resources],
|
||||||
metadata: input.properties.metadata,
|
permission: input.properties.action,
|
||||||
|
patterns: [...input.properties.resources],
|
||||||
|
always: [],
|
||||||
|
metadata: input.properties.metadata ?? {},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
if (input.type === "permission.replied" || input.type === "permission.v2.replied")
|
if (input.type === "permission.replied" || input.type === "permission.v2.replied")
|
||||||
|
|
@ -961,6 +1174,12 @@ async function toEvent(
|
||||||
if (input.type === "file.watcher.updated")
|
if (input.type === "file.watcher.updated")
|
||||||
return { type: "file.changed", path: input.properties.file, change: input.properties.event }
|
return { type: "file.changed", path: input.properties.file, change: input.properties.event }
|
||||||
if (input.type === "vcs.branch.updated") return { type: input.type, branch: input.properties.branch }
|
if (input.type === "vcs.branch.updated") return { type: input.type, branch: input.properties.branch }
|
||||||
|
if (input.type === "worktree.ready")
|
||||||
|
return { type: input.type, name: input.properties.name, branch: input.properties.branch }
|
||||||
|
if (input.type === "worktree.failed") return { type: input.type, message: input.properties.message }
|
||||||
|
if (input.type === "lsp.updated") return { type: input.type }
|
||||||
|
if (input.type === "reference.updated") return { type: input.type }
|
||||||
|
if (input.type === "mcp.tools.changed") return { type: "mcp.updated", server: input.properties.server }
|
||||||
if (input.type === "pty.exited") return { type: input.type, ptyID: input.properties.id }
|
if (input.type === "pty.exited") return { type: input.type, ptyID: input.properties.id }
|
||||||
return { type: "unknown", raw: input }
|
return { type: "unknown", raw: input }
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,12 @@
|
||||||
import { describe, expect, test } from "bun:test"
|
import { describe, expect, test } from "bun:test"
|
||||||
import { OpenCode } from "@opencode-ai/client"
|
import { OpenCode } from "@opencode-ai/client"
|
||||||
|
import { credentialConnectionIDs, type PtyTransportConfig } from "./backend"
|
||||||
import { createV2Backend } from "./backend-v2"
|
import { createV2Backend } from "./backend-v2"
|
||||||
|
|
||||||
function setup(respond: (request: Request) => Response | Promise<Response>) {
|
function setup(
|
||||||
|
respond: (request: Request) => Response | Promise<Response>,
|
||||||
|
transport?: Partial<Pick<PtyTransportConfig, "sameOrigin" | "authToken" | "password">>,
|
||||||
|
) {
|
||||||
const requests: Request[] = []
|
const requests: Request[] = []
|
||||||
const fetch = Object.assign(
|
const fetch = Object.assign(
|
||||||
async (input: RequestInfo | URL, init?: RequestInit) => {
|
async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||||
|
|
@ -12,12 +16,22 @@ function setup(respond: (request: Request) => Response | Promise<Response>) {
|
||||||
},
|
},
|
||||||
{ preconnect: globalThis.fetch.preconnect },
|
{ preconnect: globalThis.fetch.preconnect },
|
||||||
) satisfies typeof globalThis.fetch
|
) satisfies typeof globalThis.fetch
|
||||||
|
const client = OpenCode.make({ baseUrl: "http://localhost", fetch })
|
||||||
return {
|
return {
|
||||||
requests,
|
requests,
|
||||||
backend: createV2Backend(OpenCode.make({ baseUrl: "http://localhost", fetch }), {
|
backend: createV2Backend(
|
||||||
directory: "/default",
|
client,
|
||||||
workspaceID: "default-workspace",
|
{
|
||||||
}),
|
baseUrl: "http://localhost",
|
||||||
|
fetch,
|
||||||
|
username: "user",
|
||||||
|
password: transport && "password" in transport ? transport.password : "secret",
|
||||||
|
sameOrigin: transport?.sameOrigin ?? false,
|
||||||
|
authToken: transport?.authToken ?? false,
|
||||||
|
},
|
||||||
|
{ directory: "/default", workspaceID: "default-workspace" },
|
||||||
|
client,
|
||||||
|
),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -36,12 +50,45 @@ const session = {
|
||||||
}
|
}
|
||||||
|
|
||||||
describe("createV2Backend", () => {
|
describe("createV2Backend", () => {
|
||||||
|
test("uses no legacy endpoints for bootstrap and common reads", async () => {
|
||||||
|
const setupResult = setup((request) => {
|
||||||
|
const path = new URL(request.url).pathname
|
||||||
|
if (path === "/api/health") return json({ healthy: true, version: "v2" })
|
||||||
|
if (path === "/api/location")
|
||||||
|
return json({
|
||||||
|
directory: "/default",
|
||||||
|
workspaceID: "default-workspace",
|
||||||
|
project: { id: "project", directory: "/default" },
|
||||||
|
})
|
||||||
|
return json({ location: { directory: "/default" }, data: [] })
|
||||||
|
})
|
||||||
|
|
||||||
|
await Promise.all([
|
||||||
|
setupResult.backend.common.health.get(),
|
||||||
|
setupResult.backend.common.projects.current(),
|
||||||
|
setupResult.backend.common.catalog.providers(),
|
||||||
|
setupResult.backend.common.catalog.agents(),
|
||||||
|
setupResult.backend.common.commands.list(),
|
||||||
|
setupResult.backend.common.references.list(),
|
||||||
|
setupResult.backend.common.files.list({}),
|
||||||
|
setupResult.backend.common.files.find({ query: "src" }),
|
||||||
|
setupResult.backend.common.permissions.pending(),
|
||||||
|
setupResult.backend.common.questions.pending(),
|
||||||
|
setupResult.backend.common.pty.list(),
|
||||||
|
])
|
||||||
|
|
||||||
|
const paths = setupResult.requests.map((request) => new URL(request.url).pathname)
|
||||||
|
expect(paths.every((path) => path.startsWith("/api/"))).toBe(true)
|
||||||
|
expect(paths.some((path) => path === "/project" || path.startsWith("/vcs") || path.startsWith("/mcp"))).toBe(
|
||||||
|
false,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
test("normalizes session pages and preserves location precedence", async () => {
|
test("normalizes session pages and preserves location precedence", async () => {
|
||||||
const setupResult = setup(() => json({ data: [session], cursor: { previous: "before", next: "after" } }))
|
const setupResult = setup(() => json({ data: [session], cursor: { previous: "before", next: "after" } }))
|
||||||
|
|
||||||
const result = await setupResult.backend.common.sessions.list({
|
const result = await setupResult.backend.common.sessions.list({
|
||||||
location: { directory: "/explicit", workspaceID: "explicit-workspace" },
|
location: { directory: "/explicit", workspaceID: "explicit-workspace" },
|
||||||
roots: true,
|
|
||||||
limit: 10,
|
limit: 10,
|
||||||
cursor: "cursor",
|
cursor: "cursor",
|
||||||
})
|
})
|
||||||
|
|
@ -50,9 +97,13 @@ describe("createV2Backend", () => {
|
||||||
items: [
|
items: [
|
||||||
{
|
{
|
||||||
id: "ses_1",
|
id: "ses_1",
|
||||||
|
slug: "ses_1",
|
||||||
|
version: "",
|
||||||
parentID: undefined,
|
parentID: undefined,
|
||||||
projectID: "project",
|
projectID: "project",
|
||||||
location: { directory: "/repo", workspaceID: "workspace" },
|
location: { directory: "/repo", workspaceID: "workspace" },
|
||||||
|
directory: "/repo",
|
||||||
|
workspaceID: "workspace",
|
||||||
title: "Session",
|
title: "Session",
|
||||||
cost: 1.5,
|
cost: 1.5,
|
||||||
tokens: { input: 1, output: 2, reasoning: 3, cache: { read: 4, write: 5 } },
|
tokens: { input: 1, output: 2, reasoning: 3, cache: { read: 4, write: 5 } },
|
||||||
|
|
@ -60,27 +111,187 @@ describe("createV2Backend", () => {
|
||||||
revert: undefined,
|
revert: undefined,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
previous: "before",
|
newer: "before",
|
||||||
next: "after",
|
older: "after",
|
||||||
})
|
})
|
||||||
const url = new URL(setupResult.requests[0].url)
|
const url = new URL(setupResult.requests[0].url)
|
||||||
expect(url.pathname).toBe("/api/session")
|
expect(url.pathname).toBe("/api/session")
|
||||||
expect(url.searchParams.get("directory")).toBe("/explicit")
|
expect(url.searchParams.get("directory")).toBe("/explicit")
|
||||||
expect(url.searchParams.get("workspace")).toBe("explicit-workspace")
|
expect(url.searchParams.get("workspace")).toBe("explicit-workspace")
|
||||||
expect(url.searchParams.get("parentID")).toBe("null")
|
expect(url.searchParams.has("parentID")).toBe(false)
|
||||||
expect(url.searchParams.get("cursor")).toBe("cursor")
|
expect(url.searchParams.get("cursor")).toBe("cursor")
|
||||||
})
|
})
|
||||||
|
|
||||||
test("uses the default location and maps binary file responses", async () => {
|
test("paginates native sessions until roots:true contains only the requested roots", async () => {
|
||||||
const setupResult = setup(() => new Response(Uint8Array.from([0, 1, 2])))
|
const child = { ...session, id: "child", parentID: "root_1" }
|
||||||
|
const root1 = { ...session, id: "root_1" }
|
||||||
|
const root2 = { ...session, id: "root_2" }
|
||||||
|
const setupResult = setup((request) => {
|
||||||
|
const cursor = new URL(request.url).searchParams.get("cursor")
|
||||||
|
if (!cursor) return json({ data: [child], cursor: { next: "one" } })
|
||||||
|
if (cursor === "one") return json({ data: [root1], cursor: { next: "two" } })
|
||||||
|
return json({ data: [root2], cursor: { next: "three" } })
|
||||||
|
})
|
||||||
|
|
||||||
const result = await setupResult.backend.common.files.read({ path: "dir/a.bin" })
|
const result = await setupResult.backend.common.sessions.list({ roots: true, limit: 2 })
|
||||||
|
|
||||||
|
expect(result.items.map((item) => item.id)).toEqual(["root_1", "root_2"])
|
||||||
|
expect(result.older).toBe("three")
|
||||||
|
expect(setupResult.requests.map((request) => new URL(request.url).searchParams.get("limit"))).toEqual(["1", "1", "1"])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("uses only native endpoints for bootstrap operations and binary file reads", async () => {
|
||||||
|
const setupResult = setup((request) => {
|
||||||
|
if (new URL(request.url).pathname === "/api/fs/read/dir%2Fa.txt")
|
||||||
|
return new Response(Uint8Array.from([0, 1, 255]), { headers: { "content-type": "application/octet-stream" } })
|
||||||
|
return json({ location: { directory: "/default" }, data: [] })
|
||||||
|
})
|
||||||
|
|
||||||
|
await setupResult.backend.common.files.list({ path: "dir" })
|
||||||
|
const content = await setupResult.backend.common.files.read({ path: "dir/a.txt" })
|
||||||
|
|
||||||
expect([...result.bytes]).toEqual([0, 1, 2])
|
|
||||||
const url = new URL(setupResult.requests[0].url)
|
const url = new URL(setupResult.requests[0].url)
|
||||||
expect(url.pathname).toBe("/api/fs/read/dir/a.bin")
|
expect(url.pathname).toBe("/api/fs/list")
|
||||||
expect(url.searchParams.get("location[directory]")).toBe("/default")
|
expect(url.searchParams.get("location[directory]")).toBe("/default")
|
||||||
expect(url.searchParams.get("location[workspace]")).toBe("default-workspace")
|
expect(url.searchParams.get("location[workspace]")).toBe("default-workspace")
|
||||||
|
expect(content).toEqual({
|
||||||
|
bytes: Uint8Array.from([0, 1, 255]),
|
||||||
|
kind: "binary",
|
||||||
|
mimeType: "application/octet-stream",
|
||||||
|
})
|
||||||
|
const readURL = new URL(setupResult.requests[1].url)
|
||||||
|
expect(readURL.pathname).toBe("/api/fs/read/dir%2Fa.txt")
|
||||||
|
expect(readURL.searchParams.get("location[directory]")).toBe("/default")
|
||||||
|
expect(readURL.searchParams.get("location[workspace]")).toBe("default-workspace")
|
||||||
|
expect(setupResult.requests[1].headers.get("authorization")).toBe(`Basic ${btoa("user:secret")}`)
|
||||||
|
expect(setupResult.requests.every((request) => new URL(request.url).pathname.startsWith("/api/"))).toBe(true)
|
||||||
|
expect(setupResult.backend.version).toBe("v2")
|
||||||
|
expect(Object.keys(setupResult.backend.capabilities).sort()).toEqual([
|
||||||
|
"integrationsV2",
|
||||||
|
"projectCopiesV2",
|
||||||
|
"ptyTransport",
|
||||||
|
"savedPermissionsV2",
|
||||||
|
"sessionExtrasV2",
|
||||||
|
])
|
||||||
|
expect(setupResult.backend.capabilities.providerAuthV1).toBeUndefined()
|
||||||
|
expect(setupResult.backend.capabilities.worktreesV1).toBeUndefined()
|
||||||
|
expect(setupResult.backend.capabilities.sessionExtrasV1).toBeUndefined()
|
||||||
|
expect(setupResult.backend.capabilities.runtimeV1).toBeUndefined()
|
||||||
|
expect(setupResult.backend.capabilities.projectList).toBeUndefined()
|
||||||
|
expect(setupResult.backend.capabilities.vcs).toBeUndefined()
|
||||||
|
expect(setupResult.backend.capabilities.mcp).toBeUndefined()
|
||||||
|
expect(setupResult.backend.capabilities.sessionExtrasV2?.move).toBeUndefined()
|
||||||
|
expect(setupResult.backend.capabilities.projectCopiesV2?.directories).toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
test("uses native PTY endpoints and preserves status through the v2 adapter", async () => {
|
||||||
|
const setupResult = setup((request) =>
|
||||||
|
request.method === "GET" ? new Response(null, { status: 404 }) : new Response(null, { status: 403 }),
|
||||||
|
)
|
||||||
|
const transport = setupResult.backend.capabilities.ptyTransport
|
||||||
|
|
||||||
|
const ticket = await transport?.connectToken({
|
||||||
|
ptyID: "pty_1",
|
||||||
|
location: { directory: "/explicit", workspaceID: "workspace" },
|
||||||
|
})
|
||||||
|
const exists = await transport?.exists({
|
||||||
|
ptyID: "pty_1",
|
||||||
|
location: { directory: "/explicit", workspaceID: "workspace" },
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(ticket).toEqual({ status: 403, ticket: undefined })
|
||||||
|
expect(exists).toBe(false)
|
||||||
|
expect(setupResult.requests[0].headers.get("x-opencode-ticket")).toBe("1")
|
||||||
|
const tokenURL = new URL(setupResult.requests[0].url)
|
||||||
|
const existsURL = new URL(setupResult.requests[1].url)
|
||||||
|
expect(tokenURL.pathname).toBe("/api/pty/pty_1/connect-token")
|
||||||
|
expect(existsURL.pathname).toBe("/api/pty/pty_1")
|
||||||
|
expect(tokenURL.searchParams.get("location[directory]")).toBe("/explicit")
|
||||||
|
expect(tokenURL.searchParams.get("location[workspace]")).toBe("workspace")
|
||||||
|
expect(setupResult.requests[0].headers.get("authorization")).toBe(`Basic ${btoa("user:secret")}`)
|
||||||
|
|
||||||
|
expect(() =>
|
||||||
|
transport?.connectURL({
|
||||||
|
ptyID: "pty/1",
|
||||||
|
location: { directory: "/explicit", workspaceID: "workspace" },
|
||||||
|
cursor: 8,
|
||||||
|
}),
|
||||||
|
).toThrow("require a ticket")
|
||||||
|
|
||||||
|
const ticketURL = transport?.connectURL({
|
||||||
|
ptyID: "pty_1",
|
||||||
|
location: { directory: "/explicit" },
|
||||||
|
cursor: 0,
|
||||||
|
ticket: "ticket value",
|
||||||
|
})
|
||||||
|
expect(ticketURL?.searchParams.get("ticket")).toBe("ticket value")
|
||||||
|
expect(ticketURL?.searchParams.has("auth_token")).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("allows ticketless same-origin native PTY URLs without credential queries", () => {
|
||||||
|
const transport = setup(() => new Response(), { sameOrigin: true, password: undefined }).backend.capabilities
|
||||||
|
.ptyTransport
|
||||||
|
const url = transport?.connectURL({ ptyID: "pty_1", location: { directory: "/repo" }, cursor: 0 })
|
||||||
|
|
||||||
|
expect(url?.searchParams.has("auth_token")).toBe(false)
|
||||||
|
expect(url?.pathname).toBe("/api/pty/pty_1/connect")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("preserves native provider integration IDs", async () => {
|
||||||
|
const setupResult = setup((request) => {
|
||||||
|
const path = new URL(request.url).pathname
|
||||||
|
if (path === "/api/provider")
|
||||||
|
return json({
|
||||||
|
location: { directory: "/default" },
|
||||||
|
data: [
|
||||||
|
{
|
||||||
|
id: "provider",
|
||||||
|
integrationID: "integration",
|
||||||
|
name: "Provider",
|
||||||
|
api: { type: "native", settings: {} },
|
||||||
|
request: { headers: {}, body: {} },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
return json({ location: { directory: "/default" }, data: [] })
|
||||||
|
})
|
||||||
|
|
||||||
|
const result = await setupResult.backend.common.catalog.providers()
|
||||||
|
|
||||||
|
expect(result.providers.get("provider")?.integrationID).toBe("integration")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("preserves integration connection kinds", async () => {
|
||||||
|
const setupResult = setup(() =>
|
||||||
|
json({
|
||||||
|
location: { directory: "/default" },
|
||||||
|
data: [
|
||||||
|
{
|
||||||
|
id: "integration",
|
||||||
|
name: "Integration",
|
||||||
|
methods: [],
|
||||||
|
connections: [
|
||||||
|
{ type: "credential", id: "credential", label: "Saved" },
|
||||||
|
{ type: "environment", name: "TOKEN" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
const integrations = await setupResult.backend.capabilities.integrationsV2?.list()
|
||||||
|
expect(integrations).toEqual([
|
||||||
|
{
|
||||||
|
id: "integration",
|
||||||
|
name: "Integration",
|
||||||
|
methods: [],
|
||||||
|
connections: [
|
||||||
|
{ id: "credential", label: "Saved", kind: "credential" },
|
||||||
|
{ id: "TOKEN", label: "TOKEN", kind: "environment" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
])
|
||||||
|
expect(credentialConnectionIDs(integrations?.[0]?.connections ?? [])).toEqual(["credential"])
|
||||||
})
|
})
|
||||||
|
|
||||||
test("switches prompt selection before admission", async () => {
|
test("switches prompt selection before admission", async () => {
|
||||||
|
|
@ -115,17 +326,82 @@ describe("createV2Backend", () => {
|
||||||
])
|
])
|
||||||
expect(await setupResult.requests[2].json()).toEqual({
|
expect(await setupResult.requests[2].json()).toEqual({
|
||||||
id: "msg_1",
|
id: "msg_1",
|
||||||
text: "hello",
|
prompt: {
|
||||||
files: [
|
text: "hello",
|
||||||
{
|
files: [
|
||||||
uri: "data:text/plain;base64,aGk=",
|
{
|
||||||
name: "hi.txt",
|
uri: "data:text/plain;base64,aGk=",
|
||||||
mention: { start: 0, end: 2, text: "hi" },
|
mime: "application/octet-stream",
|
||||||
},
|
name: "hi.txt",
|
||||||
],
|
source: { start: 0, end: 2, text: "hi" },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("serializes selection and prompt admission per session", async () => {
|
||||||
|
const firstPrompt = Promise.withResolvers<void>()
|
||||||
|
const firstPromptStarted = Promise.withResolvers<void>()
|
||||||
|
const setupResult = setup(async (request) => {
|
||||||
|
const path = new URL(request.url).pathname
|
||||||
|
if (path.endsWith("/prompt") && setupResult.requests.filter((item) => item.url.endsWith("/prompt")).length === 1) {
|
||||||
|
firstPromptStarted.resolve()
|
||||||
|
await firstPrompt.promise
|
||||||
|
}
|
||||||
|
return path.endsWith("/prompt")
|
||||||
|
? json({ data: { id: "msg", sessionID: "ses_1", timeCreated: 1, type: "user", data: { text: "" } } })
|
||||||
|
: new Response(null, { status: 204 })
|
||||||
|
})
|
||||||
|
|
||||||
|
const first = setupResult.backend.common.sessions.prompt({
|
||||||
|
sessionID: "ses_1",
|
||||||
|
id: "msg_1",
|
||||||
|
text: "first",
|
||||||
|
selection: { agent: "build" },
|
||||||
|
})
|
||||||
|
await firstPromptStarted.promise
|
||||||
|
const second = setupResult.backend.common.sessions.prompt({
|
||||||
|
sessionID: "ses_1",
|
||||||
|
id: "msg_2",
|
||||||
|
text: "second",
|
||||||
|
selection: { agent: "plan" },
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(setupResult.requests.map((item) => new URL(item.url).pathname)).toEqual([
|
||||||
|
"/api/session/ses_1/agent",
|
||||||
|
"/api/session/ses_1/prompt",
|
||||||
|
])
|
||||||
|
firstPrompt.resolve()
|
||||||
|
await Promise.all([first, second])
|
||||||
|
expect(setupResult.requests.map((item) => new URL(item.url).pathname)).toEqual([
|
||||||
|
"/api/session/ses_1/agent",
|
||||||
|
"/api/session/ses_1/prompt",
|
||||||
|
"/api/session/ses_1/agent",
|
||||||
|
"/api/session/ses_1/prompt",
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("does not switch a selection already present in session state", async () => {
|
||||||
|
const setupResult = setup((request) =>
|
||||||
|
request.url.endsWith("/prompt")
|
||||||
|
? json({ data: { id: "msg", sessionID: "ses_1", timeCreated: 1, type: "user", data: { text: "" } } })
|
||||||
|
: json({ data: { ...session, agent: "build", model: { id: "model", providerID: "provider" } } }),
|
||||||
|
)
|
||||||
|
await setupResult.backend.common.sessions.get({ sessionID: "ses_1" })
|
||||||
|
await setupResult.backend.common.sessions.prompt({
|
||||||
|
sessionID: "ses_1",
|
||||||
|
id: "msg_1",
|
||||||
|
text: "hello",
|
||||||
|
selection: { agent: "build", model: { id: "model", providerID: "provider" } },
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(setupResult.requests.map((item) => new URL(item.url).pathname)).toEqual([
|
||||||
|
"/api/session/ses_1",
|
||||||
|
"/api/session/ses_1/prompt",
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
test("requests file staging when selected revert files are present", async () => {
|
test("requests file staging when selected revert files are present", async () => {
|
||||||
const setupResult = setup(() => json({ data: { messageID: "msg_1", files: [] } }))
|
const setupResult = setup(() => json({ data: { messageID: "msg_1", files: [] } }))
|
||||||
|
|
||||||
|
|
@ -138,75 +414,266 @@ describe("createV2Backend", () => {
|
||||||
expect(await setupResult.requests[0].json()).toEqual({ messageID: "msg_1", files: true })
|
expect(await setupResult.requests[0].json()).toEqual({ messageID: "msg_1", files: true })
|
||||||
})
|
})
|
||||||
|
|
||||||
test("normalizes retry events before message projection refresh", async () => {
|
test("maps ordered app prompt parts to the native prompt shape", async () => {
|
||||||
const setupResult = setup(() =>
|
const setupResult = setup(() =>
|
||||||
new Response(
|
json({ data: { admittedSeq: 1, id: "msg_1", sessionID: "ses_1", timeCreated: 1, type: "user", data: {} } }),
|
||||||
`data: ${JSON.stringify({
|
)
|
||||||
id: "evt_retry",
|
|
||||||
created: 1,
|
await setupResult.backend.common.sessions.prompt({
|
||||||
type: "session.retry.scheduled",
|
sessionID: "ses_1",
|
||||||
durable: { aggregateID: "ses_1", seq: 3, version: 1 },
|
id: "msg_1",
|
||||||
location: { directory: "/repo" },
|
text: "visible",
|
||||||
data: {
|
parts: [
|
||||||
sessionID: "ses_1",
|
{ id: "part_text", type: "text", text: "visible" },
|
||||||
assistantMessageID: "msg_1",
|
{ id: "part_note", type: "text", text: "note", synthetic: true, metadata: { source: "review" } },
|
||||||
attempt: 2,
|
{ id: "part_file", type: "file", mime: "text/plain", url: "file:///repo/a.ts", filename: "a.ts" },
|
||||||
at: 1234,
|
{ id: "part_agent", type: "agent", name: "build", source: { value: "@build", start: 7, end: 13 } },
|
||||||
error: { type: "rate_limit", message: "try again" },
|
],
|
||||||
},
|
})
|
||||||
})}\n\n`,
|
|
||||||
{ headers: { "content-type": "text/event-stream" } },
|
expect(await setupResult.requests[0].json()).toEqual({
|
||||||
),
|
id: "msg_1",
|
||||||
|
prompt: {
|
||||||
|
text: "visiblenote",
|
||||||
|
files: [{ uri: "file:///repo/a.ts", mime: "text/plain", name: "a.ts" }],
|
||||||
|
agents: [{ name: "build", source: { text: "@build", start: 7, end: 13 } }],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test("commits a staged revert through the V2 capability", async () => {
|
||||||
|
const setupResult = setup(() => new Response(null, { status: 204 }))
|
||||||
|
|
||||||
|
await setupResult.backend.capabilities.sessionExtrasV2?.commitRevert({ sessionID: "ses_1" })
|
||||||
|
|
||||||
|
expect(new URL(setupResult.requests[0].url).pathname).toBe("/api/session/ses_1/revert/commit")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("normalizes current session activity events without projection refresh", async () => {
|
||||||
|
const setupResult = setup(
|
||||||
|
() =>
|
||||||
|
new Response(
|
||||||
|
`data: ${JSON.stringify({
|
||||||
|
id: "evt_started",
|
||||||
|
type: "session.next.step.started",
|
||||||
|
durable: { aggregateID: "ses_1", seq: 3, version: 1 },
|
||||||
|
location: { directory: "/repo" },
|
||||||
|
data: {
|
||||||
|
timestamp: 1,
|
||||||
|
sessionID: "ses_1",
|
||||||
|
assistantMessageID: "msg_1",
|
||||||
|
agent: "build",
|
||||||
|
model: { id: "model", providerID: "provider" },
|
||||||
|
},
|
||||||
|
})}\n\n`,
|
||||||
|
{ headers: { "content-type": "text/event-stream" } },
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
const result = await setupResult.backend.common.events.subscribe()[Symbol.asyncIterator]().next()
|
const result = await setupResult.backend.common.events.subscribe()[Symbol.asyncIterator]().next()
|
||||||
|
|
||||||
expect(result.value).toEqual({
|
expect(result.value).toMatchObject({
|
||||||
location: { directory: "/repo" },
|
location: { directory: "/repo" },
|
||||||
event: {
|
event: {
|
||||||
type: "session.activity",
|
type: "session.activity",
|
||||||
sessionID: "ses_1",
|
sessionID: "ses_1",
|
||||||
activity: { type: "retry", attempt: 2, message: "try again", next: 1234 },
|
activity: { type: "running" },
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
expect(setupResult.requests).toHaveLength(1)
|
expect(setupResult.requests).toHaveLength(1)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("maps completed native steps to idle activity", async () => {
|
||||||
|
const setupResult = setup(
|
||||||
|
() =>
|
||||||
|
new Response(
|
||||||
|
`data: ${JSON.stringify({
|
||||||
|
id: "evt_ended",
|
||||||
|
type: "session.next.step.ended",
|
||||||
|
data: {
|
||||||
|
timestamp: 2,
|
||||||
|
sessionID: "ses_1",
|
||||||
|
assistantMessageID: "msg_1",
|
||||||
|
finish: "stop",
|
||||||
|
cost: 1,
|
||||||
|
tokens: { input: 1, output: 1, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||||
|
},
|
||||||
|
})}\n\n`,
|
||||||
|
{ headers: { "content-type": "text/event-stream" } },
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
const result = await setupResult.backend.common.events.subscribe()[Symbol.asyncIterator]().next()
|
||||||
|
|
||||||
|
expect(result.value?.event).toEqual({
|
||||||
|
type: "session.activity",
|
||||||
|
sessionID: "ses_1",
|
||||||
|
activity: { type: "idle" },
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test("projects failed steps as completed assistant errors and clears running", async () => {
|
||||||
|
const events = [
|
||||||
|
{
|
||||||
|
id: "start",
|
||||||
|
type: "session.next.step.started",
|
||||||
|
data: { timestamp: 1, sessionID: "ses_1", assistantMessageID: "msg_1", agent: "build", model: { id: "m", providerID: "p" } },
|
||||||
|
},
|
||||||
|
{ id: "text", type: "session.next.text.started", data: { timestamp: 2, sessionID: "ses_1", assistantMessageID: "msg_1", textID: "text_1" } },
|
||||||
|
{ id: "delta", type: "session.next.text.delta", data: { timestamp: 3, sessionID: "ses_1", assistantMessageID: "msg_1", textID: "text_1", delta: "partial" } },
|
||||||
|
{ id: "failed", type: "session.next.step.failed", data: { timestamp: 4, sessionID: "ses_1", assistantMessageID: "msg_1", error: { type: "unknown", message: "boom" } } },
|
||||||
|
]
|
||||||
|
const setupResult = setup(() => new Response(events.map((event) => `data: ${JSON.stringify(event)}\n\n`).join(""), { headers: { "content-type": "text/event-stream" } }))
|
||||||
|
const iterator = setupResult.backend.common.events.subscribe()[Symbol.asyncIterator]()
|
||||||
|
await iterator.next()
|
||||||
|
await iterator.next()
|
||||||
|
await iterator.next()
|
||||||
|
|
||||||
|
expect((await iterator.next()).value?.event).toMatchObject({
|
||||||
|
type: "session.activity",
|
||||||
|
activity: { type: "idle" },
|
||||||
|
item: { completed: 4, error: { data: { message: "boom" } }, content: [{ id: "text_1", text: "partial" }] },
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test("does not infer assistant parents across history pages or direct fetches", async () => {
|
||||||
|
const assistant = { id: "assistant", type: "assistant", time: { created: 2 }, agent: "build", model: { id: "m", providerID: "p" }, content: [] }
|
||||||
|
const user = { id: "user", type: "user", time: { created: 1 }, text: "hello" }
|
||||||
|
const setupResult = setup((request) => {
|
||||||
|
const url = new URL(request.url)
|
||||||
|
if (url.pathname.endsWith("/message/assistant")) return json({ data: assistant })
|
||||||
|
if (url.searchParams.get("cursor")) return json({ data: [user], cursor: {} })
|
||||||
|
return json({ data: [assistant], cursor: { next: "older" } })
|
||||||
|
})
|
||||||
|
|
||||||
|
const first = await setupResult.backend.common.sessions.history({ sessionID: "ses_1" })
|
||||||
|
await setupResult.backend.common.sessions.history({ sessionID: "ses_1", cursor: first.older })
|
||||||
|
const direct = await setupResult.backend.common.sessions.message({ sessionID: "ses_1", messageID: "assistant" })
|
||||||
|
|
||||||
|
expect(first.items[0]).toMatchObject({ type: "assistant", parentID: undefined })
|
||||||
|
expect(direct).toMatchObject({ type: "assistant", parentID: undefined })
|
||||||
|
})
|
||||||
|
|
||||||
|
test("normalizes lifecycle and provider refresh events without HTTP fallbacks", async () => {
|
||||||
|
const events = [
|
||||||
|
{ id: "moved", type: "session.next.moved", data: { timestamp: 1, sessionID: "ses_1", location: { directory: "/next" } } },
|
||||||
|
{ id: "revert", type: "session.next.revert.staged", data: { timestamp: 2, sessionID: "ses_1", revert: { messageID: "msg_1" } } },
|
||||||
|
{ id: "integration", type: "integration.updated", data: {} },
|
||||||
|
{ id: "unknown", type: "session.next.context.updated", data: { timestamp: 3, sessionID: "ses_1", messageID: "msg_1", text: "x" } },
|
||||||
|
]
|
||||||
|
const setupResult = setup(() => new Response(events.map((event) => `data: ${JSON.stringify(event)}\n\n`).join(""), { headers: { "content-type": "text/event-stream" } }))
|
||||||
|
const iterator = setupResult.backend.common.events.subscribe()[Symbol.asyncIterator]()
|
||||||
|
|
||||||
|
expect((await iterator.next()).value?.event).toEqual({ type: "session.moved", sessionID: "ses_1", location: { directory: "/next" } })
|
||||||
|
expect((await iterator.next()).value?.event).toEqual({ type: "session.revert", sessionID: "ses_1", revert: { messageID: "msg_1" } })
|
||||||
|
expect((await iterator.next()).value?.event).toEqual({ type: "provider.updated" })
|
||||||
|
expect((await iterator.next()).value?.event.type).toBe("unknown")
|
||||||
|
expect(setupResult.requests).toHaveLength(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("normalizes native session create, update, and delete lifecycle events", async () => {
|
||||||
|
const info = {
|
||||||
|
id: "ses_1",
|
||||||
|
slug: "one",
|
||||||
|
version: "2",
|
||||||
|
projectID: "project",
|
||||||
|
directory: "/repo",
|
||||||
|
title: "Session",
|
||||||
|
time: { created: 1, updated: 2 },
|
||||||
|
}
|
||||||
|
const events = [
|
||||||
|
{ id: "created", type: "session.created", data: { sessionID: "ses_1", info } },
|
||||||
|
{ id: "updated", type: "session.updated", data: { sessionID: "ses_1", info: { ...info, title: "Renamed" } } },
|
||||||
|
{ id: "deleted", type: "session.deleted", data: { sessionID: "ses_1", info } },
|
||||||
|
]
|
||||||
|
const setupResult = setup(() => new Response(events.map((event) => `data: ${JSON.stringify(event)}\n\n`).join(""), { headers: { "content-type": "text/event-stream" } }))
|
||||||
|
const iterator = setupResult.backend.common.events.subscribe()[Symbol.asyncIterator]()
|
||||||
|
|
||||||
|
expect((await iterator.next()).value?.event).toMatchObject({ type: "session.created", session: { id: "ses_1", title: "Session" } })
|
||||||
|
expect((await iterator.next()).value?.event).toMatchObject({ type: "session.updated", session: { id: "ses_1", title: "Renamed" } })
|
||||||
|
expect((await iterator.next()).value?.event).toEqual({ type: "session.deleted", sessionID: "ses_1" })
|
||||||
|
})
|
||||||
|
|
||||||
test("normalizes durable session log events through the event mapper", async () => {
|
test("normalizes durable session log events through the event mapper", async () => {
|
||||||
const setupResult = setup(() =>
|
const setupResult = setup(
|
||||||
new Response(
|
() =>
|
||||||
`data: ${JSON.stringify({
|
new Response(
|
||||||
id: "evt_started",
|
`data: ${JSON.stringify({
|
||||||
created: 1,
|
id: "evt_started",
|
||||||
type: "session.execution.started",
|
type: "session.next.step.started",
|
||||||
durable: { aggregateID: "ses_1", seq: 7, version: 1 },
|
durable: { aggregateID: "ses_1", seq: 7, version: 1 },
|
||||||
data: { sessionID: "ses_1" },
|
data: {
|
||||||
})}\n\n`,
|
timestamp: 1,
|
||||||
{ headers: { "content-type": "text/event-stream" } },
|
sessionID: "ses_1",
|
||||||
),
|
assistantMessageID: "msg_1",
|
||||||
|
agent: "build",
|
||||||
|
model: { id: "model", providerID: "provider" },
|
||||||
|
},
|
||||||
|
})}\n\n`,
|
||||||
|
{ headers: { "content-type": "text/event-stream" } },
|
||||||
|
),
|
||||||
)
|
)
|
||||||
const capability = setupResult.backend.capabilities.sessionExtrasV2
|
const capability = setupResult.backend.capabilities.sessionExtrasV2
|
||||||
if (!capability) throw new Error("Missing V2 session capability")
|
if (!capability) throw new Error("Missing V2 session capability")
|
||||||
|
|
||||||
const result = await capability.log({ sessionID: "ses_1" })[Symbol.asyncIterator]().next()
|
const result = await capability.log({ sessionID: "ses_1" })[Symbol.asyncIterator]().next()
|
||||||
|
|
||||||
expect(result.value).toEqual({
|
expect(result.value).toMatchObject({
|
||||||
sequence: 7,
|
sequence: 7,
|
||||||
event: { type: "session.activity", sessionID: "ses_1", activity: { type: "running" } },
|
event: { type: "session.activity", sessionID: "ses_1", activity: { type: "running" } },
|
||||||
})
|
})
|
||||||
expect(new URL(setupResult.requests[0].url).pathname).toBe("/api/experimental/session/ses_1/log")
|
expect(new URL(setupResult.requests[0].url).pathname).toBe("/api/session/ses_1/event")
|
||||||
})
|
})
|
||||||
|
|
||||||
test("refreshes message projections for streamed V2 fragments", async () => {
|
test("normalizes native todo and part removal events", async () => {
|
||||||
|
const events = [
|
||||||
|
{
|
||||||
|
id: "evt_todo",
|
||||||
|
type: "todo.updated",
|
||||||
|
data: { sessionID: "ses_1", todos: [{ content: "Ship", status: "pending", priority: "high" }] },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "evt_removed",
|
||||||
|
type: "message.part.removed",
|
||||||
|
data: { sessionID: "ses_1", messageID: "msg_1", partID: "part_1" },
|
||||||
|
},
|
||||||
|
]
|
||||||
|
const setupResult = setup(
|
||||||
|
() =>
|
||||||
|
new Response(events.map((event) => `data: ${JSON.stringify(event)}\n\n`).join(""), {
|
||||||
|
headers: { "content-type": "text/event-stream" },
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
const iterator = setupResult.backend.common.events.subscribe()[Symbol.asyncIterator]()
|
||||||
|
|
||||||
|
expect((await iterator.next()).value?.event).toEqual({
|
||||||
|
type: "todo.updated",
|
||||||
|
sessionID: "ses_1",
|
||||||
|
todos: [{ content: "Ship", status: "pending", priority: "high" }],
|
||||||
|
})
|
||||||
|
expect((await iterator.next()).value?.event).toEqual({
|
||||||
|
type: "timeline.part.removed",
|
||||||
|
sessionID: "ses_1",
|
||||||
|
itemID: "msg_1",
|
||||||
|
contentID: "part_1",
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test("preserves streamed V2 timeline deltas without projection refresh", async () => {
|
||||||
const setupResult = setup((request) => {
|
const setupResult = setup((request) => {
|
||||||
if (new URL(request.url).pathname === "/api/event") {
|
if (new URL(request.url).pathname === "/api/event") {
|
||||||
return new Response(
|
return new Response(
|
||||||
`data: ${JSON.stringify({
|
`data: ${JSON.stringify({
|
||||||
id: "evt_1",
|
id: "evt_1",
|
||||||
created: 1,
|
type: "session.next.text.delta",
|
||||||
type: "session.text.delta",
|
|
||||||
location: { directory: "/repo" },
|
location: { directory: "/repo" },
|
||||||
data: { sessionID: "ses_1", assistantMessageID: "msg_1", ordinal: 0, delta: "hi" },
|
data: {
|
||||||
|
timestamp: 1,
|
||||||
|
sessionID: "ses_1",
|
||||||
|
assistantMessageID: "msg_1",
|
||||||
|
textID: "text_1",
|
||||||
|
delta: "hi",
|
||||||
|
},
|
||||||
})}\n\n`,
|
})}\n\n`,
|
||||||
{ headers: { "content-type": "text/event-stream" } },
|
{ headers: { "content-type": "text/event-stream" } },
|
||||||
)
|
)
|
||||||
|
|
@ -218,7 +685,7 @@ describe("createV2Backend", () => {
|
||||||
time: { created: 1 },
|
time: { created: 1 },
|
||||||
agent: "build",
|
agent: "build",
|
||||||
model: { id: "model", providerID: "provider" },
|
model: { id: "model", providerID: "provider" },
|
||||||
content: [{ type: "text", text: "hello" }],
|
content: [{ type: "text", id: "text_1", text: "hello" }],
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
@ -228,29 +695,51 @@ describe("createV2Backend", () => {
|
||||||
expect(result.value).toEqual({
|
expect(result.value).toEqual({
|
||||||
location: { directory: "/repo" },
|
location: { directory: "/repo" },
|
||||||
event: {
|
event: {
|
||||||
type: "timeline.updated",
|
type: "timeline.delta",
|
||||||
item: {
|
sessionID: "ses_1",
|
||||||
type: "assistant",
|
itemID: "msg_1",
|
||||||
id: "msg_1",
|
contentID: "text_1",
|
||||||
sessionID: "ses_1",
|
field: "text",
|
||||||
created: 1,
|
delta: "hi",
|
||||||
completed: undefined,
|
|
||||||
content: [{ type: "text", id: "msg_1:text:0", text: "hello" }],
|
|
||||||
agent: "build",
|
|
||||||
model: { id: "model", providerID: "provider" },
|
|
||||||
tokens: undefined,
|
|
||||||
error: undefined,
|
|
||||||
raw: {
|
|
||||||
id: "msg_1",
|
|
||||||
type: "assistant",
|
|
||||||
time: { created: 1 },
|
|
||||||
agent: "build",
|
|
||||||
model: { id: "model", providerID: "provider" },
|
|
||||||
content: [{ type: "text", text: "hello" }],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
expect(new URL(setupResult.requests[1].url).pathname).toBe("/api/session/ses_1/message/msg_1")
|
expect(setupResult.requests).toHaveLength(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("projects native fragment starts without blocking the event stream on HTTP", async () => {
|
||||||
|
const events = [
|
||||||
|
{
|
||||||
|
id: "evt_step",
|
||||||
|
type: "session.next.step.started",
|
||||||
|
data: {
|
||||||
|
timestamp: 1,
|
||||||
|
sessionID: "ses_1",
|
||||||
|
assistantMessageID: "msg_1",
|
||||||
|
agent: "build",
|
||||||
|
model: { id: "model", providerID: "provider" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "evt_text",
|
||||||
|
type: "session.next.text.started",
|
||||||
|
data: { timestamp: 2, sessionID: "ses_1", assistantMessageID: "msg_1", textID: "text_1" },
|
||||||
|
},
|
||||||
|
]
|
||||||
|
const setupResult = setup(
|
||||||
|
() =>
|
||||||
|
new Response(events.map((event) => `data: ${JSON.stringify(event)}\n\n`).join(""), {
|
||||||
|
headers: { "content-type": "text/event-stream" },
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
const iterator = setupResult.backend.common.events.subscribe()[Symbol.asyncIterator]()
|
||||||
|
|
||||||
|
await iterator.next()
|
||||||
|
expect((await iterator.next()).value?.event).toEqual({
|
||||||
|
type: "timeline.content.updated",
|
||||||
|
sessionID: "ses_1",
|
||||||
|
itemID: "msg_1",
|
||||||
|
content: { type: "text", id: "text_1", text: "" },
|
||||||
|
})
|
||||||
|
expect(setupResult.requests).toHaveLength(1)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
56
packages/app/src/context/backend.test-fixture.ts
Normal file
56
packages/app/src/context/backend.test-fixture.ts
Normal file
|
|
@ -0,0 +1,56 @@
|
||||||
|
import type { AppClient, Capabilities, CommonClient } from "./backend"
|
||||||
|
|
||||||
|
type PartialApi<T> = {
|
||||||
|
[K in keyof T]?: T[K] extends (...args: never[]) => unknown ? T[K] : PartialApi<T[K]>
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createAppClient(input: {
|
||||||
|
version?: AppClient["version"]
|
||||||
|
common?: PartialApi<CommonClient>
|
||||||
|
capabilities?: Capabilities
|
||||||
|
} = {}): AppClient {
|
||||||
|
const unsupported = async () => {
|
||||||
|
throw new Error("Backend fixture method is not configured")
|
||||||
|
}
|
||||||
|
const defaults: CommonClient = {
|
||||||
|
health: { get: unsupported },
|
||||||
|
projects: { current: unsupported },
|
||||||
|
catalog: { providers: unsupported, agents: unsupported },
|
||||||
|
commands: { list: unsupported },
|
||||||
|
references: { list: unsupported },
|
||||||
|
sessions: {
|
||||||
|
list: unsupported,
|
||||||
|
create: unsupported,
|
||||||
|
get: unsupported,
|
||||||
|
interrupt: unsupported,
|
||||||
|
activity: unsupported,
|
||||||
|
history: unsupported,
|
||||||
|
message: unsupported,
|
||||||
|
prompt: unsupported,
|
||||||
|
},
|
||||||
|
files: { list: unsupported, find: unsupported, read: unsupported },
|
||||||
|
permissions: { pending: unsupported, reply: unsupported },
|
||||||
|
questions: { pending: unsupported, reply: unsupported, reject: unsupported },
|
||||||
|
pty: { list: unsupported, create: unsupported, get: unsupported, update: unsupported, remove: unsupported },
|
||||||
|
events: {
|
||||||
|
async *subscribe() {},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
version: input.version ?? "v1",
|
||||||
|
capabilities: input.capabilities ?? {},
|
||||||
|
common: {
|
||||||
|
health: { ...defaults.health, ...input.common?.health },
|
||||||
|
projects: { ...defaults.projects, ...input.common?.projects },
|
||||||
|
catalog: { ...defaults.catalog, ...input.common?.catalog },
|
||||||
|
commands: { ...defaults.commands, ...input.common?.commands },
|
||||||
|
references: { ...defaults.references, ...input.common?.references },
|
||||||
|
sessions: { ...defaults.sessions, ...input.common?.sessions },
|
||||||
|
files: { ...defaults.files, ...input.common?.files },
|
||||||
|
permissions: { ...defaults.permissions, ...input.common?.permissions },
|
||||||
|
questions: { ...defaults.questions, ...input.common?.questions },
|
||||||
|
pty: { ...defaults.pty, ...input.common?.pty },
|
||||||
|
events: { ...defaults.events, ...input.common?.events },
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -21,8 +21,10 @@ export type ModelRef = {
|
||||||
|
|
||||||
export type Page<T> = {
|
export type Page<T> = {
|
||||||
readonly items: readonly T[]
|
readonly items: readonly T[]
|
||||||
readonly previous?: string
|
/** Cursor for items older than this page. */
|
||||||
readonly next?: string
|
readonly older?: string
|
||||||
|
/** Cursor for items newer than this page. */
|
||||||
|
readonly newer?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export type Health = {
|
export type Health = {
|
||||||
|
|
@ -34,6 +36,12 @@ export type Health = {
|
||||||
export type AppProject = {
|
export type AppProject = {
|
||||||
readonly id: string
|
readonly id: string
|
||||||
readonly worktree: string
|
readonly worktree: string
|
||||||
|
readonly vcs?: "git"
|
||||||
|
readonly time: {
|
||||||
|
readonly created: number
|
||||||
|
readonly updated: number
|
||||||
|
readonly initialized?: number
|
||||||
|
}
|
||||||
readonly name?: string
|
readonly name?: string
|
||||||
readonly icon?: {
|
readonly icon?: {
|
||||||
readonly url?: string
|
readonly url?: string
|
||||||
|
|
@ -43,7 +51,7 @@ export type AppProject = {
|
||||||
readonly commands?: {
|
readonly commands?: {
|
||||||
readonly start?: string
|
readonly start?: string
|
||||||
}
|
}
|
||||||
readonly sandboxes: readonly string[]
|
sandboxes: string[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export type CurrentProject = {
|
export type CurrentProject = {
|
||||||
|
|
@ -83,6 +91,8 @@ export type AppModel = {
|
||||||
export type AppProvider = {
|
export type AppProvider = {
|
||||||
readonly id: string
|
readonly id: string
|
||||||
readonly name: string
|
readonly name: string
|
||||||
|
readonly source?: "env" | "config" | "custom" | "api"
|
||||||
|
readonly integrationID?: string
|
||||||
readonly models: Readonly<Record<string, AppModel>>
|
readonly models: Readonly<Record<string, AppModel>>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -137,15 +147,19 @@ export type TokenUsage = {
|
||||||
|
|
||||||
export type AppSession = {
|
export type AppSession = {
|
||||||
readonly id: string
|
readonly id: string
|
||||||
|
readonly slug: string
|
||||||
|
readonly version: string
|
||||||
readonly parentID?: string
|
readonly parentID?: string
|
||||||
readonly projectID: string
|
readonly projectID: string
|
||||||
readonly location: LocationRef
|
readonly location?: LocationRef
|
||||||
readonly title: string
|
readonly directory: string
|
||||||
readonly cost: number
|
readonly workspaceID?: string
|
||||||
|
title: string
|
||||||
|
readonly cost?: number
|
||||||
readonly tokens?: TokenUsage
|
readonly tokens?: TokenUsage
|
||||||
readonly time: {
|
readonly time: {
|
||||||
readonly created: number
|
readonly created: number
|
||||||
readonly updated?: number
|
readonly updated: number
|
||||||
readonly archived?: number
|
readonly archived?: number
|
||||||
}
|
}
|
||||||
readonly share?: {
|
readonly share?: {
|
||||||
|
|
@ -157,6 +171,8 @@ export type AppSession = {
|
||||||
}
|
}
|
||||||
|
|
||||||
export type SessionActivity =
|
export type SessionActivity =
|
||||||
|
| { readonly type: "idle" }
|
||||||
|
| { readonly type: "busy" }
|
||||||
| { readonly type: "running" }
|
| { readonly type: "running" }
|
||||||
| {
|
| {
|
||||||
readonly type: "retry"
|
readonly type: "retry"
|
||||||
|
|
@ -205,28 +221,139 @@ export type ToolState =
|
||||||
| {
|
| {
|
||||||
readonly status: "pending"
|
readonly status: "pending"
|
||||||
readonly input: Readonly<Record<string, unknown>>
|
readonly input: Readonly<Record<string, unknown>>
|
||||||
readonly raw?: string
|
readonly raw: string
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
readonly status: "running"
|
readonly status: "running"
|
||||||
readonly input: Readonly<Record<string, unknown>>
|
readonly input: Readonly<Record<string, unknown>>
|
||||||
readonly title?: string
|
readonly title?: string
|
||||||
readonly metadata?: Readonly<Record<string, unknown>>
|
readonly metadata?: Readonly<Record<string, unknown>>
|
||||||
|
readonly time: { readonly start: number }
|
||||||
|
readonly content?: readonly ToolOutputContent[]
|
||||||
|
readonly provider?: ToolProviderInfo
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
readonly status: "completed"
|
readonly status: "completed"
|
||||||
readonly input: Readonly<Record<string, unknown>>
|
readonly input: Readonly<Record<string, unknown>>
|
||||||
readonly output: string
|
readonly output: string
|
||||||
readonly title?: string
|
readonly title: string
|
||||||
readonly metadata?: Readonly<Record<string, unknown>>
|
readonly metadata: Readonly<Record<string, unknown>>
|
||||||
|
readonly time: { readonly start: number; readonly end: number; readonly compacted?: number }
|
||||||
|
readonly attachments?: AppFilePart[]
|
||||||
|
readonly content?: readonly ToolOutputContent[]
|
||||||
|
readonly outputPaths?: readonly string[]
|
||||||
|
readonly result?: unknown
|
||||||
|
readonly provider?: ToolProviderInfo
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
readonly status: "error"
|
readonly status: "error"
|
||||||
readonly input: Readonly<Record<string, unknown>>
|
readonly input: Readonly<Record<string, unknown>>
|
||||||
readonly error: string
|
readonly error: string
|
||||||
readonly metadata?: Readonly<Record<string, unknown>>
|
readonly metadata?: Readonly<Record<string, unknown>>
|
||||||
|
readonly time: { readonly start: number; readonly end: number }
|
||||||
|
readonly content?: readonly ToolOutputContent[]
|
||||||
|
readonly result?: unknown
|
||||||
|
readonly provider?: ToolProviderInfo
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type ToolOutputContent =
|
||||||
|
| { readonly type: "text"; readonly text: string }
|
||||||
|
| { readonly type: "file"; readonly uri: string; readonly mime: string; readonly name?: string }
|
||||||
|
|
||||||
|
export type ToolProviderInfo = {
|
||||||
|
readonly executed: boolean
|
||||||
|
readonly metadata?: Readonly<Record<string, Readonly<Record<string, unknown>>>>
|
||||||
|
readonly resultMetadata?: Readonly<Record<string, Readonly<Record<string, unknown>>>>
|
||||||
|
}
|
||||||
|
|
||||||
|
export type AppMessage = AppUserMessage | AppAssistantMessage
|
||||||
|
|
||||||
|
export type AppUserMessage = {
|
||||||
|
readonly id: string
|
||||||
|
readonly sessionID: string
|
||||||
|
readonly role: "user"
|
||||||
|
readonly time: { readonly created: number }
|
||||||
|
readonly format?:
|
||||||
|
| { readonly type: "text" }
|
||||||
|
| { readonly type: "json_schema"; readonly schema: Readonly<Record<string, unknown>>; readonly retryCount?: number }
|
||||||
|
readonly summary?: {
|
||||||
|
readonly title?: string
|
||||||
|
readonly body?: string
|
||||||
|
readonly diffs: (Omit<AppFileDiff, "file"> & { readonly file?: string })[]
|
||||||
|
}
|
||||||
|
readonly agent: string
|
||||||
|
readonly model: { readonly providerID: string; readonly modelID: string; readonly variant?: string }
|
||||||
|
readonly system?: string
|
||||||
|
readonly tools?: Readonly<Record<string, boolean>>
|
||||||
|
}
|
||||||
|
|
||||||
|
export type AppAssistantMessage = {
|
||||||
|
readonly id: string
|
||||||
|
readonly sessionID: string
|
||||||
|
readonly role: "assistant"
|
||||||
|
readonly time: { readonly created: number; readonly completed?: number }
|
||||||
|
readonly parentID: string
|
||||||
|
readonly modelID: string
|
||||||
|
readonly providerID: string
|
||||||
|
readonly mode: string
|
||||||
|
readonly agent: string
|
||||||
|
readonly path: { readonly cwd: string; readonly root: string }
|
||||||
|
readonly summary?: boolean
|
||||||
|
readonly cost: number
|
||||||
|
readonly tokens: TokenUsage & { readonly total?: number }
|
||||||
|
readonly structured?: unknown
|
||||||
|
readonly variant?: string
|
||||||
|
readonly finish?: string
|
||||||
|
readonly error?: AppMessageError
|
||||||
|
}
|
||||||
|
|
||||||
|
export type AppMessageError =
|
||||||
|
| { readonly name: "ProviderAuthError"; readonly data: { readonly providerID: string; readonly message: string } }
|
||||||
|
| { readonly name: "UnknownError"; readonly data: { readonly message: string; readonly ref?: string } }
|
||||||
|
| { readonly name: "MessageOutputLengthError"; readonly data: Readonly<Record<string, unknown>> }
|
||||||
|
| { readonly name: "MessageAbortedError"; readonly data: { readonly message: string } }
|
||||||
|
| { readonly name: "StructuredOutputError"; readonly data: { readonly message: string; readonly retries: number } }
|
||||||
|
| { readonly name: "ContextOverflowError"; readonly data: { readonly message: string; readonly responseBody?: string } }
|
||||||
|
| { readonly name: "ContentFilterError"; readonly data: { readonly message: string } }
|
||||||
|
| { readonly name: "APIError"; readonly data: { readonly message: string; readonly statusCode?: number; readonly isRetryable: boolean; readonly responseHeaders?: Readonly<Record<string, string>>; readonly responseBody?: string; readonly metadata?: Readonly<Record<string, string>> } }
|
||||||
|
|
||||||
|
export type AppRetryError = Extract<AppMessageError, { readonly name: "APIError" }>
|
||||||
|
|
||||||
|
export type AppSessionError = AppMessageError | { readonly type: "unknown"; readonly message: string }
|
||||||
|
|
||||||
|
type AppPartBase = {
|
||||||
|
readonly id: string
|
||||||
|
readonly sessionID: string
|
||||||
|
readonly messageID: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type AppFilePart = AppPartBase & {
|
||||||
|
readonly type: "file"
|
||||||
|
readonly mime: string
|
||||||
|
readonly filename?: string
|
||||||
|
readonly url: string
|
||||||
|
readonly source?: AppFilePartSource
|
||||||
|
}
|
||||||
|
|
||||||
|
export type AppFilePartSource =
|
||||||
|
| { readonly type: "file"; readonly path: string; readonly text: { readonly value: string; readonly start: number; readonly end: number } }
|
||||||
|
| { readonly type: "symbol"; readonly path: string; readonly name: string; readonly kind: number; readonly range: { readonly start: { readonly line: number; readonly character: number }; readonly end: { readonly line: number; readonly character: number } }; readonly text: { readonly value: string; readonly start: number; readonly end: number } }
|
||||||
|
| { readonly type: "resource"; readonly clientName: string; readonly uri: string; readonly text: { readonly value: string; readonly start: number; readonly end: number } }
|
||||||
|
|
||||||
|
export type AppPart =
|
||||||
|
| (AppPartBase & { readonly type: "text"; readonly text: string; readonly synthetic?: boolean; readonly ignored?: boolean; readonly time?: { readonly start: number; readonly end?: number }; readonly metadata?: Readonly<Record<string, unknown>> })
|
||||||
|
| (AppPartBase & { readonly type: "reasoning"; readonly text: string; readonly metadata?: Readonly<Record<string, unknown>>; readonly time: { readonly start: number; readonly end?: number } })
|
||||||
|
| AppFilePart
|
||||||
|
| (AppPartBase & { readonly type: "agent"; readonly name: string; readonly source?: { readonly value: string; readonly start: number; readonly end: number } })
|
||||||
|
| (AppPartBase & { readonly type: "tool"; readonly callID: string; readonly tool: string; readonly state: ToolState; readonly metadata?: Readonly<Record<string, unknown>> })
|
||||||
|
| (AppPartBase & { readonly type: "subtask"; readonly prompt: string; readonly description: string; readonly agent: string; readonly model?: { readonly providerID: string; readonly modelID: string }; readonly command?: string })
|
||||||
|
| (AppPartBase & { readonly type: "step-start"; readonly snapshot?: string })
|
||||||
|
| (AppPartBase & { readonly type: "step-finish"; readonly reason: string; readonly snapshot?: string; readonly cost: number; readonly tokens: TokenUsage & { readonly total?: number } })
|
||||||
|
| (AppPartBase & { readonly type: "snapshot"; readonly snapshot: string })
|
||||||
|
| (AppPartBase & { readonly type: "patch"; readonly hash: string; readonly files: string[] })
|
||||||
|
| (AppPartBase & { readonly type: "retry"; readonly attempt: number; readonly error: AppRetryError; readonly time: { readonly created: number } })
|
||||||
|
| (AppPartBase & { readonly type: "compaction"; readonly auto: boolean })
|
||||||
|
|
||||||
export type TimelineContent =
|
export type TimelineContent =
|
||||||
| {
|
| {
|
||||||
readonly type: "text"
|
readonly type: "text"
|
||||||
|
|
@ -235,11 +362,14 @@ export type TimelineContent =
|
||||||
readonly synthetic?: boolean
|
readonly synthetic?: boolean
|
||||||
readonly ignored?: boolean
|
readonly ignored?: boolean
|
||||||
readonly metadata?: Readonly<Record<string, unknown>>
|
readonly metadata?: Readonly<Record<string, unknown>>
|
||||||
|
readonly time?: { readonly start: number; readonly end?: number }
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
readonly type: "reasoning"
|
readonly type: "reasoning"
|
||||||
readonly id: string
|
readonly id: string
|
||||||
readonly text: string
|
readonly text: string
|
||||||
|
readonly metadata?: Readonly<Record<string, unknown>>
|
||||||
|
readonly time?: { readonly start: number; readonly end?: number }
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
readonly type: "file"
|
readonly type: "file"
|
||||||
|
|
@ -261,7 +391,15 @@ export type TimelineContent =
|
||||||
readonly callID?: string
|
readonly callID?: string
|
||||||
readonly tool: string
|
readonly tool: string
|
||||||
readonly state: ToolState
|
readonly state: ToolState
|
||||||
|
readonly metadata?: Readonly<Record<string, unknown>>
|
||||||
}
|
}
|
||||||
|
| { readonly type: "subtask"; readonly id: string; readonly prompt: string; readonly description: string; readonly agent: string; readonly model?: ModelRef; readonly command?: string }
|
||||||
|
| { readonly type: "step-start"; readonly id: string; readonly snapshot?: string }
|
||||||
|
| { readonly type: "step-finish"; readonly id: string; readonly reason: string; readonly snapshot?: string; readonly cost: number; readonly tokens: TokenUsage & { readonly total?: number } }
|
||||||
|
| { readonly type: "snapshot"; readonly id: string; readonly snapshot: string }
|
||||||
|
| { readonly type: "patch"; readonly id: string; readonly hash: string; readonly files: string[] }
|
||||||
|
| { readonly type: "retry"; readonly id: string; readonly attempt: number; readonly error: AppRetryError; readonly time: { readonly created: number } }
|
||||||
|
| { readonly type: "compaction"; readonly id: string; readonly auto: boolean }
|
||||||
|
|
||||||
export type TimelineItem =
|
export type TimelineItem =
|
||||||
| {
|
| {
|
||||||
|
|
@ -272,7 +410,10 @@ export type TimelineItem =
|
||||||
readonly content: readonly TimelineContent[]
|
readonly content: readonly TimelineContent[]
|
||||||
readonly agent?: string
|
readonly agent?: string
|
||||||
readonly model?: ModelRef
|
readonly model?: ModelRef
|
||||||
readonly raw?: unknown
|
readonly format?: AppUserMessage["format"]
|
||||||
|
readonly summary?: AppUserMessage["summary"]
|
||||||
|
readonly system?: string
|
||||||
|
readonly tools?: Readonly<Record<string, boolean>>
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
readonly type: "assistant"
|
readonly type: "assistant"
|
||||||
|
|
@ -285,17 +426,158 @@ export type TimelineItem =
|
||||||
readonly agent?: string
|
readonly agent?: string
|
||||||
readonly model?: ModelRef
|
readonly model?: ModelRef
|
||||||
readonly tokens?: TokenUsage
|
readonly tokens?: TokenUsage
|
||||||
readonly error?: unknown
|
readonly error?: AppMessageError
|
||||||
readonly raw?: unknown
|
readonly mode?: string
|
||||||
|
readonly path?: AppAssistantMessage["path"]
|
||||||
|
readonly cost?: number
|
||||||
|
readonly structured?: unknown
|
||||||
|
readonly finish?: string
|
||||||
|
readonly summary?: boolean
|
||||||
|
readonly snapshot?: { readonly start?: string; readonly end?: string; readonly files?: readonly string[] }
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
readonly type: "agent-switch" | "model-switch" | "synthetic" | "system" | "skill" | "shell" | "compaction"
|
readonly type: "agent-switch" | "model-switch" | "synthetic" | "system" | "skill" | "shell" | "compaction"
|
||||||
readonly id: string
|
readonly id: string
|
||||||
readonly sessionID: string
|
readonly sessionID: string
|
||||||
readonly created: number
|
readonly created: number
|
||||||
readonly raw?: unknown
|
readonly metadata?: Readonly<Record<string, unknown>>
|
||||||
|
readonly text?: string
|
||||||
|
readonly reason?: "auto" | "manual"
|
||||||
|
readonly summary?: string
|
||||||
|
readonly recent?: string
|
||||||
|
readonly callID?: string
|
||||||
|
readonly command?: string
|
||||||
|
readonly output?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function timelineMessage(item: TimelineItem): AppMessage | undefined {
|
||||||
|
if (item.type === "user")
|
||||||
|
return {
|
||||||
|
id: item.id,
|
||||||
|
sessionID: item.sessionID,
|
||||||
|
role: "user",
|
||||||
|
time: { created: item.created },
|
||||||
|
format: item.format,
|
||||||
|
summary: item.summary,
|
||||||
|
agent: item.agent ?? "",
|
||||||
|
model: {
|
||||||
|
providerID: item.model?.providerID ?? "",
|
||||||
|
modelID: item.model?.id ?? "",
|
||||||
|
variant: item.model?.variant,
|
||||||
|
},
|
||||||
|
system: item.system,
|
||||||
|
tools: item.tools,
|
||||||
|
}
|
||||||
|
if (item.type !== "assistant") return
|
||||||
|
return {
|
||||||
|
id: item.id,
|
||||||
|
sessionID: item.sessionID,
|
||||||
|
role: "assistant",
|
||||||
|
time: { created: item.created, completed: item.completed },
|
||||||
|
parentID: item.parentID ?? "",
|
||||||
|
modelID: item.model?.id ?? "",
|
||||||
|
providerID: item.model?.providerID ?? "",
|
||||||
|
variant: item.model?.variant,
|
||||||
|
mode: item.mode ?? item.agent ?? "",
|
||||||
|
agent: item.agent ?? "",
|
||||||
|
path: item.path ?? { cwd: "", root: "" },
|
||||||
|
summary: item.summary,
|
||||||
|
cost: item.cost ?? 0,
|
||||||
|
tokens: item.tokens ?? { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||||
|
structured: item.structured,
|
||||||
|
finish: item.finish,
|
||||||
|
error: item.error,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function timelineParts(item: TimelineItem): AppPart[] {
|
||||||
|
if (item.type !== "user" && item.type !== "assistant") return []
|
||||||
|
return item.content.map((content): AppPart => {
|
||||||
|
const base = { id: content.id, sessionID: item.sessionID, messageID: item.id }
|
||||||
|
if (content.type === "file")
|
||||||
|
return {
|
||||||
|
...base,
|
||||||
|
type: content.type,
|
||||||
|
mime: content.mime ?? "application/octet-stream",
|
||||||
|
filename: content.name,
|
||||||
|
url: content.uri,
|
||||||
|
source:
|
||||||
|
content.source?.type === "resource"
|
||||||
|
? {
|
||||||
|
type: content.source.type,
|
||||||
|
clientName: content.source.clientName,
|
||||||
|
uri: content.source.uri,
|
||||||
|
text: {
|
||||||
|
value: content.source.text.text,
|
||||||
|
start: content.source.text.start,
|
||||||
|
end: content.source.text.end,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: content.source?.type === "symbol"
|
||||||
|
? {
|
||||||
|
type: content.source.type,
|
||||||
|
path: content.source.path,
|
||||||
|
name: content.source.name ?? "",
|
||||||
|
kind: content.source.kind ?? 0,
|
||||||
|
range: {
|
||||||
|
start: { line: 0, character: content.source.text.start },
|
||||||
|
end: { line: 0, character: content.source.text.end },
|
||||||
|
},
|
||||||
|
text: {
|
||||||
|
value: content.source.text.text,
|
||||||
|
start: content.source.text.start,
|
||||||
|
end: content.source.text.end,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: content.source && {
|
||||||
|
type: "file",
|
||||||
|
path: content.source.path,
|
||||||
|
text: {
|
||||||
|
value: content.source.text.text,
|
||||||
|
start: content.source.text.start,
|
||||||
|
end: content.source.text.end,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
if (content.type === "agent")
|
||||||
|
return {
|
||||||
|
...base,
|
||||||
|
type: content.type,
|
||||||
|
name: content.name,
|
||||||
|
source: content.source && {
|
||||||
|
value: content.source.text,
|
||||||
|
start: content.source.start,
|
||||||
|
end: content.source.end,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
if (content.type === "tool")
|
||||||
|
return {
|
||||||
|
...base,
|
||||||
|
type: content.type,
|
||||||
|
callID: content.callID ?? content.id,
|
||||||
|
tool: content.tool,
|
||||||
|
state: content.state,
|
||||||
|
metadata: content.metadata,
|
||||||
|
}
|
||||||
|
if (content.type === "subtask")
|
||||||
|
return {
|
||||||
|
...base,
|
||||||
|
...content,
|
||||||
|
model: content.model && { providerID: content.model.providerID, modelID: content.model.id },
|
||||||
|
}
|
||||||
|
if (content.type === "reasoning")
|
||||||
|
return {
|
||||||
|
...base,
|
||||||
|
...content,
|
||||||
|
time: content.time ?? {
|
||||||
|
start: item.created,
|
||||||
|
end: item.type === "assistant" ? item.completed : undefined,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
return { ...base, ...content }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
export type PromptFile = {
|
export type PromptFile = {
|
||||||
readonly uri: string
|
readonly uri: string
|
||||||
readonly name?: string
|
readonly name?: string
|
||||||
|
|
@ -312,12 +594,60 @@ export type PromptAgentMention = {
|
||||||
readonly text?: string
|
readonly text?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export type PromptInput = {
|
export type PromptPart =
|
||||||
|
| {
|
||||||
|
readonly id: string
|
||||||
|
readonly type: "text"
|
||||||
|
readonly text: string
|
||||||
|
readonly synthetic?: boolean
|
||||||
|
readonly ignored?: boolean
|
||||||
|
readonly time?: { readonly start: number; readonly end?: number }
|
||||||
|
readonly metadata?: Readonly<Record<string, unknown>>
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
readonly id: string
|
||||||
|
readonly type: "file"
|
||||||
|
readonly mime: string
|
||||||
|
readonly url: string
|
||||||
|
readonly filename?: string
|
||||||
|
readonly source?:
|
||||||
|
| {
|
||||||
|
readonly type: "file"
|
||||||
|
readonly path: string
|
||||||
|
readonly text: { readonly value: string; readonly start: number; readonly end: number }
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
readonly type: "symbol"
|
||||||
|
readonly path: string
|
||||||
|
readonly name: string
|
||||||
|
readonly kind: number
|
||||||
|
readonly range: {
|
||||||
|
readonly start: { readonly line: number; readonly character: number }
|
||||||
|
readonly end: { readonly line: number; readonly character: number }
|
||||||
|
}
|
||||||
|
readonly text: { readonly value: string; readonly start: number; readonly end: number }
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
readonly type: "resource"
|
||||||
|
readonly clientName: string
|
||||||
|
readonly uri: string
|
||||||
|
readonly text: { readonly value: string; readonly start: number; readonly end: number }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
readonly id: string
|
||||||
|
readonly type: "agent"
|
||||||
|
readonly name: string
|
||||||
|
readonly source?: { readonly value: string; readonly start: number; readonly end: number }
|
||||||
|
}
|
||||||
|
|
||||||
|
export type PromptInput = LocationInput & {
|
||||||
readonly sessionID: string
|
readonly sessionID: string
|
||||||
readonly id: string
|
readonly id: string
|
||||||
readonly text: string
|
readonly text: string
|
||||||
readonly files?: readonly PromptFile[]
|
readonly files?: readonly PromptFile[]
|
||||||
readonly agents?: readonly PromptAgentMention[]
|
readonly agents?: readonly PromptAgentMention[]
|
||||||
|
readonly parts?: readonly PromptPart[]
|
||||||
readonly selection?: {
|
readonly selection?: {
|
||||||
readonly agent?: string
|
readonly agent?: string
|
||||||
readonly model?: ModelRef
|
readonly model?: ModelRef
|
||||||
|
|
@ -325,7 +655,7 @@ export type PromptInput = {
|
||||||
readonly delivery?: "steer" | "queue"
|
readonly delivery?: "steer" | "queue"
|
||||||
}
|
}
|
||||||
|
|
||||||
export type CommandInput = {
|
export type CommandInput = LocationInput & {
|
||||||
readonly sessionID: string
|
readonly sessionID: string
|
||||||
readonly id?: string
|
readonly id?: string
|
||||||
readonly command: string
|
readonly command: string
|
||||||
|
|
@ -338,9 +668,19 @@ export type CommandInput = {
|
||||||
|
|
||||||
export type FileEntry = {
|
export type FileEntry = {
|
||||||
readonly path: string
|
readonly path: string
|
||||||
|
readonly name?: string
|
||||||
|
readonly absolute?: string
|
||||||
readonly type: "file" | "directory"
|
readonly type: "file" | "directory"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type AppFileNode = {
|
||||||
|
readonly name: string
|
||||||
|
readonly path: string
|
||||||
|
readonly absolute: string
|
||||||
|
readonly type: "file" | "directory"
|
||||||
|
readonly ignored: boolean
|
||||||
|
}
|
||||||
|
|
||||||
export type FileContent = {
|
export type FileContent = {
|
||||||
readonly bytes: Uint8Array
|
readonly bytes: Uint8Array
|
||||||
readonly kind?: "text" | "binary"
|
readonly kind?: "text" | "binary"
|
||||||
|
|
@ -355,20 +695,26 @@ export type AppFileDiff = {
|
||||||
readonly status?: "added" | "deleted" | "modified"
|
readonly status?: "added" | "deleted" | "modified"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type AppSnapshotFileDiff = Omit<AppFileDiff, "file"> & { readonly file?: string }
|
||||||
|
export type AppVcsFileDiff = AppFileDiff
|
||||||
|
|
||||||
export type AppPermissionRequest = {
|
export type AppPermissionRequest = {
|
||||||
readonly id: string
|
readonly id: string
|
||||||
readonly sessionID: string
|
readonly sessionID: string
|
||||||
readonly action: string
|
readonly action: string
|
||||||
readonly resources: readonly string[]
|
readonly resources: readonly string[]
|
||||||
readonly metadata?: Readonly<Record<string, unknown>>
|
readonly permission: string
|
||||||
|
readonly patterns: string[]
|
||||||
|
readonly always: string[]
|
||||||
|
readonly metadata: Record<string, unknown>
|
||||||
}
|
}
|
||||||
|
|
||||||
export type AppQuestion = {
|
export type AppQuestion = {
|
||||||
readonly question: string
|
readonly question: string
|
||||||
readonly header?: string
|
readonly header: string
|
||||||
readonly options: readonly {
|
readonly options: {
|
||||||
readonly label: string
|
readonly label: string
|
||||||
readonly description?: string
|
readonly description: string
|
||||||
}[]
|
}[]
|
||||||
readonly multiple?: boolean
|
readonly multiple?: boolean
|
||||||
readonly custom?: boolean
|
readonly custom?: boolean
|
||||||
|
|
@ -377,7 +723,15 @@ export type AppQuestion = {
|
||||||
export type AppQuestionRequest = {
|
export type AppQuestionRequest = {
|
||||||
readonly id: string
|
readonly id: string
|
||||||
readonly sessionID: string
|
readonly sessionID: string
|
||||||
readonly questions: readonly AppQuestion[]
|
readonly questions: AppQuestion[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export type AppQuestionAnswer = string[]
|
||||||
|
|
||||||
|
export type AppSessionNotFoundError = {
|
||||||
|
readonly _tag: "SessionNotFoundError"
|
||||||
|
readonly sessionID: string
|
||||||
|
readonly message: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export type AppMcpStatus =
|
export type AppMcpStatus =
|
||||||
|
|
@ -423,20 +777,56 @@ export type AppEventEnvelope = {
|
||||||
export type AppEvent =
|
export type AppEvent =
|
||||||
| { readonly type: "server.connected" }
|
| { readonly type: "server.connected" }
|
||||||
| { readonly type: "server.disposed"; readonly location?: LocationRef }
|
| { readonly type: "server.disposed"; readonly location?: LocationRef }
|
||||||
|
| { readonly type: "instance.disposed"; readonly location: LocationRef }
|
||||||
| { readonly type: "project.updated"; readonly project: AppProject }
|
| { readonly type: "project.updated"; readonly project: AppProject }
|
||||||
| { readonly type: "session.created"; readonly session: AppSession }
|
| { readonly type: "session.created"; readonly session: AppSession }
|
||||||
| { readonly type: "session.updated"; readonly session: AppSession }
|
| { readonly type: "session.updated"; readonly session: AppSession }
|
||||||
| { readonly type: "session.deleted"; readonly sessionID: string }
|
| { readonly type: "session.deleted"; readonly sessionID: string }
|
||||||
| { readonly type: "session.activity"; readonly sessionID: string; readonly activity: SessionActivity }
|
| { readonly type: "session.moved"; readonly sessionID: string; readonly location: LocationRef }
|
||||||
| { readonly type: "session.error"; readonly sessionID?: string; readonly error?: unknown }
|
| { readonly type: "session.revert"; readonly sessionID: string; readonly revert?: { readonly messageID: string } }
|
||||||
|
| {
|
||||||
|
readonly type: "session.activity"
|
||||||
|
readonly sessionID: string
|
||||||
|
readonly activity: SessionActivity
|
||||||
|
readonly item?: TimelineItem
|
||||||
|
}
|
||||||
|
| { readonly type: "session.diff"; readonly sessionID: string; readonly diff: readonly AppFileDiff[] }
|
||||||
|
| { readonly type: "todo.updated"; readonly sessionID: string; readonly todos: readonly AppTodo[] }
|
||||||
|
| { readonly type: "session.error"; readonly sessionID?: string; readonly error?: AppSessionError }
|
||||||
| { readonly type: "timeline.updated"; readonly item: TimelineItem }
|
| { readonly type: "timeline.updated"; readonly item: TimelineItem }
|
||||||
|
| {
|
||||||
|
readonly type: "timeline.content.updated"
|
||||||
|
readonly sessionID: string
|
||||||
|
readonly itemID: string
|
||||||
|
readonly content: TimelineContent
|
||||||
|
}
|
||||||
| { readonly type: "timeline.removed"; readonly sessionID: string; readonly itemID: string }
|
| { readonly type: "timeline.removed"; readonly sessionID: string; readonly itemID: string }
|
||||||
|
| {
|
||||||
|
readonly type: "timeline.delta"
|
||||||
|
readonly sessionID: string
|
||||||
|
readonly itemID: string
|
||||||
|
readonly contentID: string
|
||||||
|
readonly field: string
|
||||||
|
readonly delta: string
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
readonly type: "timeline.part.removed"
|
||||||
|
readonly sessionID: string
|
||||||
|
readonly itemID: string
|
||||||
|
readonly contentID: string
|
||||||
|
}
|
||||||
| { readonly type: "permission.requested"; readonly request: AppPermissionRequest }
|
| { readonly type: "permission.requested"; readonly request: AppPermissionRequest }
|
||||||
| { readonly type: "permission.replied"; readonly sessionID: string; readonly requestID: string }
|
| { readonly type: "permission.replied"; readonly sessionID: string; readonly requestID: string }
|
||||||
| { readonly type: "question.requested"; readonly request: AppQuestionRequest }
|
| { readonly type: "question.requested"; readonly request: AppQuestionRequest }
|
||||||
| { readonly type: "question.replied" | "question.rejected"; readonly sessionID: string; readonly requestID: string }
|
| { readonly type: "question.replied" | "question.rejected"; readonly sessionID: string; readonly requestID: string }
|
||||||
| { readonly type: "file.changed"; readonly path: string; readonly change: "add" | "change" | "unlink" }
|
| { readonly type: "file.changed"; readonly path: string; readonly change: "add" | "change" | "unlink" }
|
||||||
| { readonly type: "vcs.branch.updated"; readonly branch?: string }
|
| { readonly type: "vcs.branch.updated"; readonly branch?: string }
|
||||||
|
| { readonly type: "worktree.ready"; readonly name: string; readonly branch?: string }
|
||||||
|
| { readonly type: "worktree.failed"; readonly message: string }
|
||||||
|
| { readonly type: "lsp.updated" }
|
||||||
|
| { readonly type: "reference.updated" }
|
||||||
|
| { readonly type: "mcp.updated"; readonly server?: string }
|
||||||
|
| { readonly type: "provider.updated" }
|
||||||
| { readonly type: "pty.exited"; readonly ptyID: string }
|
| { readonly type: "pty.exited"; readonly ptyID: string }
|
||||||
| { readonly type: "unknown"; readonly raw: unknown }
|
| { readonly type: "unknown"; readonly raw: unknown }
|
||||||
|
|
||||||
|
|
@ -445,10 +835,13 @@ export interface HealthApi {
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ProjectApi {
|
export interface ProjectApi {
|
||||||
list(options?: RequestOptions): Promise<readonly AppProject[]>
|
|
||||||
current(input?: LocationInput, options?: RequestOptions): Promise<CurrentProject>
|
current(input?: LocationInput, options?: RequestOptions): Promise<CurrentProject>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ProjectListCapability {
|
||||||
|
list(options?: RequestOptions): Promise<readonly AppProject[]>
|
||||||
|
}
|
||||||
|
|
||||||
export interface CatalogApi {
|
export interface CatalogApi {
|
||||||
providers(input?: LocationInput, options?: RequestOptions): Promise<ProviderCatalog>
|
providers(input?: LocationInput, options?: RequestOptions): Promise<ProviderCatalog>
|
||||||
agents(input?: LocationInput, options?: RequestOptions): Promise<readonly AppAgent[]>
|
agents(input?: LocationInput, options?: RequestOptions): Promise<readonly AppAgent[]>
|
||||||
|
|
@ -469,7 +862,21 @@ export interface SessionApi {
|
||||||
options?: RequestOptions,
|
options?: RequestOptions,
|
||||||
): Promise<AppSession>
|
): Promise<AppSession>
|
||||||
get(input: LocationInput & { readonly sessionID: string }, options?: RequestOptions): Promise<AppSession>
|
get(input: LocationInput & { readonly sessionID: string }, options?: RequestOptions): Promise<AppSession>
|
||||||
remove(input: LocationInput & { readonly sessionID: string }, options?: RequestOptions): Promise<void>
|
interrupt(input: LocationInput & { readonly sessionID: string }, options?: RequestOptions): Promise<void>
|
||||||
|
activity(input?: LocationInput, options?: RequestOptions): Promise<Readonly<Record<string, SessionActivity>>>
|
||||||
|
history(
|
||||||
|
input: LocationInput & { readonly sessionID: string; readonly limit?: number; readonly cursor?: string },
|
||||||
|
options?: RequestOptions,
|
||||||
|
): Promise<Page<TimelineItem>>
|
||||||
|
message(
|
||||||
|
input: LocationInput & { readonly sessionID: string; readonly messageID: string },
|
||||||
|
options?: RequestOptions,
|
||||||
|
): Promise<TimelineItem>
|
||||||
|
prompt(input: PromptInput, options?: RequestOptions): Promise<void>
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SessionActionsV1Capability {
|
||||||
|
remove(input: LocationInput & { readonly sessionID: string }, options?: RequestOptions): Promise<boolean>
|
||||||
fork(
|
fork(
|
||||||
input: LocationInput & { readonly sessionID: string; readonly messageID?: string },
|
input: LocationInput & { readonly sessionID: string; readonly messageID?: string },
|
||||||
options?: RequestOptions,
|
options?: RequestOptions,
|
||||||
|
|
@ -478,22 +885,11 @@ export interface SessionApi {
|
||||||
input: LocationInput & { readonly sessionID: string; readonly title: string },
|
input: LocationInput & { readonly sessionID: string; readonly title: string },
|
||||||
options?: RequestOptions,
|
options?: RequestOptions,
|
||||||
): Promise<void>
|
): Promise<void>
|
||||||
interrupt(input: LocationInput & { readonly sessionID: string }, options?: RequestOptions): Promise<void>
|
|
||||||
activity(input?: LocationInput, options?: RequestOptions): Promise<Readonly<Record<string, SessionActivity>>>
|
|
||||||
history(
|
|
||||||
input: { readonly sessionID: string; readonly limit?: number; readonly cursor?: string },
|
|
||||||
options?: RequestOptions,
|
|
||||||
): Promise<Page<TimelineItem>>
|
|
||||||
message(
|
|
||||||
input: { readonly sessionID: string; readonly messageID: string },
|
|
||||||
options?: RequestOptions,
|
|
||||||
): Promise<TimelineItem>
|
|
||||||
prompt(input: PromptInput, options?: RequestOptions): Promise<void>
|
|
||||||
command(input: CommandInput, options?: RequestOptions): Promise<void>
|
command(input: CommandInput, options?: RequestOptions): Promise<void>
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface FileApi {
|
export interface FileApi {
|
||||||
list(input: LocationInput & { readonly path?: string }, options?: RequestOptions): Promise<readonly FileEntry[]>
|
list(input: LocationInput & { readonly path?: string }, options?: RequestOptions): Promise<readonly AppFileNode[]>
|
||||||
find(
|
find(
|
||||||
input: LocationInput & {
|
input: LocationInput & {
|
||||||
readonly query: string
|
readonly query: string
|
||||||
|
|
@ -508,7 +904,7 @@ export interface FileApi {
|
||||||
export interface PermissionApi {
|
export interface PermissionApi {
|
||||||
pending(input?: LocationInput, options?: RequestOptions): Promise<readonly AppPermissionRequest[]>
|
pending(input?: LocationInput, options?: RequestOptions): Promise<readonly AppPermissionRequest[]>
|
||||||
reply(
|
reply(
|
||||||
input: {
|
input: LocationInput & {
|
||||||
readonly sessionID: string
|
readonly sessionID: string
|
||||||
readonly requestID: string
|
readonly requestID: string
|
||||||
readonly reply: "once" | "always" | "reject"
|
readonly reply: "once" | "always" | "reject"
|
||||||
|
|
@ -521,14 +917,17 @@ export interface PermissionApi {
|
||||||
export interface QuestionApi {
|
export interface QuestionApi {
|
||||||
pending(input?: LocationInput, options?: RequestOptions): Promise<readonly AppQuestionRequest[]>
|
pending(input?: LocationInput, options?: RequestOptions): Promise<readonly AppQuestionRequest[]>
|
||||||
reply(
|
reply(
|
||||||
input: {
|
input: LocationInput & {
|
||||||
readonly sessionID: string
|
readonly sessionID: string
|
||||||
readonly requestID: string
|
readonly requestID: string
|
||||||
readonly answers: readonly (readonly string[])[]
|
readonly answers: readonly (readonly string[])[]
|
||||||
},
|
},
|
||||||
options?: RequestOptions,
|
options?: RequestOptions,
|
||||||
): Promise<void>
|
): Promise<void>
|
||||||
reject(input: { readonly sessionID: string; readonly requestID: string }, options?: RequestOptions): Promise<void>
|
reject(
|
||||||
|
input: LocationInput & { readonly sessionID: string; readonly requestID: string },
|
||||||
|
options?: RequestOptions,
|
||||||
|
): Promise<void>
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface VcsApi {
|
export interface VcsApi {
|
||||||
|
|
@ -598,11 +997,8 @@ export interface CommonClient {
|
||||||
readonly files: FileApi
|
readonly files: FileApi
|
||||||
readonly permissions: PermissionApi
|
readonly permissions: PermissionApi
|
||||||
readonly questions: QuestionApi
|
readonly questions: QuestionApi
|
||||||
readonly vcs: VcsApi
|
|
||||||
readonly mcp: McpApi
|
|
||||||
readonly pty: PtyApi
|
readonly pty: PtyApi
|
||||||
readonly events: EventApi
|
readonly events: EventApi
|
||||||
disposeLocation(input: LocationInput, options?: RequestOptions): Promise<void>
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export type AppProviderConfig = {
|
export type AppProviderConfig = {
|
||||||
|
|
@ -655,6 +1051,8 @@ export type ProviderAuthMethod = {
|
||||||
readonly prompts?: readonly ProviderAuthPrompt[]
|
readonly prompts?: readonly ProviderAuthPrompt[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type AppProviderAuthResponse = Readonly<Record<string, readonly ProviderAuthMethod[]>>
|
||||||
|
|
||||||
export type ProviderAuthorization = {
|
export type ProviderAuthorization = {
|
||||||
readonly url: string
|
readonly url: string
|
||||||
readonly method: "auto" | "code"
|
readonly method: "auto" | "code"
|
||||||
|
|
@ -710,14 +1108,15 @@ export type AppWorktree = {
|
||||||
export interface WorktreesV1Capability {
|
export interface WorktreesV1Capability {
|
||||||
list(input: LocationInput, options?: RequestOptions): Promise<readonly string[]>
|
list(input: LocationInput, options?: RequestOptions): Promise<readonly string[]>
|
||||||
create(input: LocationInput, options?: RequestOptions): Promise<AppWorktree>
|
create(input: LocationInput, options?: RequestOptions): Promise<AppWorktree>
|
||||||
remove(input: LocationInput & { readonly directory: string }, options?: RequestOptions): Promise<void>
|
remove(input: LocationInput & { readonly directory: string }, options?: RequestOptions): Promise<boolean>
|
||||||
reset(input: LocationInput & { readonly directory: string }, options?: RequestOptions): Promise<void>
|
reset(input: LocationInput & { readonly directory: string }, options?: RequestOptions): Promise<boolean>
|
||||||
}
|
}
|
||||||
|
|
||||||
export type AppTodo = {
|
export type AppTodo = {
|
||||||
readonly id?: string
|
readonly id?: string
|
||||||
readonly content: string
|
readonly content: string
|
||||||
readonly status: "pending" | "in_progress" | "completed" | "cancelled" | (string & {})
|
readonly status: "pending" | "in_progress" | "completed" | "cancelled" | (string & {})
|
||||||
|
readonly priority: "high" | "medium" | "low" | (string & {})
|
||||||
}
|
}
|
||||||
|
|
||||||
export type LegacySessionShellInput = LocationInput & {
|
export type LegacySessionShellInput = LocationInput & {
|
||||||
|
|
@ -729,14 +1128,23 @@ export type LegacySessionShellInput = LocationInput & {
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SessionExtrasV1Capability {
|
export interface SessionExtrasV1Capability {
|
||||||
archive(sessionID: string, archivedAt: number, options?: RequestOptions): Promise<void>
|
archive(
|
||||||
share(sessionID: string, options?: RequestOptions): Promise<string>
|
input: LocationInput & { readonly sessionID: string; readonly archivedAt: number },
|
||||||
unshare(sessionID: string, options?: RequestOptions): Promise<void>
|
options?: RequestOptions,
|
||||||
diff(sessionID: string, options?: RequestOptions): Promise<readonly AppFileDiff[]>
|
): Promise<void>
|
||||||
todos(sessionID: string, options?: RequestOptions): Promise<readonly AppTodo[]>
|
share(input: LocationInput & { readonly sessionID: string }, options?: RequestOptions): Promise<string>
|
||||||
summarize(sessionID: string, model: ModelRef, options?: RequestOptions): Promise<void>
|
unshare(input: LocationInput & { readonly sessionID: string }, options?: RequestOptions): Promise<void>
|
||||||
revert(sessionID: string, messageID: string, options?: RequestOptions): Promise<void>
|
diff(input: LocationInput & { readonly sessionID: string }, options?: RequestOptions): Promise<readonly AppFileDiff[]>
|
||||||
clearRevert(sessionID: string, options?: RequestOptions): Promise<void>
|
todos(input: LocationInput & { readonly sessionID: string }, options?: RequestOptions): Promise<readonly AppTodo[]>
|
||||||
|
summarize(
|
||||||
|
input: LocationInput & { readonly sessionID: string; readonly model: ModelRef },
|
||||||
|
options?: RequestOptions,
|
||||||
|
): Promise<void>
|
||||||
|
revert(
|
||||||
|
input: LocationInput & { readonly sessionID: string; readonly messageID: string },
|
||||||
|
options?: RequestOptions,
|
||||||
|
): Promise<AppSession>
|
||||||
|
clearRevert(input: LocationInput & { readonly sessionID: string }, options?: RequestOptions): Promise<AppSession>
|
||||||
shell(input: LegacySessionShellInput, options?: RequestOptions): Promise<void>
|
shell(input: LegacySessionShellInput, options?: RequestOptions): Promise<void>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -784,7 +1192,18 @@ export type DecoratedFileContent = {
|
||||||
readonly encoding?: "base64"
|
readonly encoding?: "base64"
|
||||||
readonly mimeType?: string
|
readonly mimeType?: string
|
||||||
readonly patch?: {
|
readonly patch?: {
|
||||||
readonly hunks: readonly { readonly lines: readonly string[] }[]
|
readonly oldFileName: string
|
||||||
|
readonly newFileName: string
|
||||||
|
readonly oldHeader?: string
|
||||||
|
readonly newHeader?: string
|
||||||
|
readonly hunks: readonly {
|
||||||
|
readonly oldStart: number
|
||||||
|
readonly oldLines: number
|
||||||
|
readonly newStart: number
|
||||||
|
readonly newLines: number
|
||||||
|
readonly lines: readonly string[]
|
||||||
|
}[]
|
||||||
|
readonly index?: string
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -793,11 +1212,28 @@ export interface DecoratedFileCapability {
|
||||||
}
|
}
|
||||||
|
|
||||||
export type PtyTicket = {
|
export type PtyTicket = {
|
||||||
readonly ticket: string
|
readonly status: number
|
||||||
|
readonly ticket?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type PtyTransportConfig = {
|
||||||
|
readonly baseUrl: string
|
||||||
|
readonly fetch: typeof globalThis.fetch
|
||||||
|
readonly username?: string
|
||||||
|
readonly password?: string
|
||||||
|
readonly sameOrigin: boolean
|
||||||
|
readonly authToken: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PtyTransportCapability {
|
export interface PtyTransportCapability {
|
||||||
connectToken(input: LocationInput & { readonly ptyID: string }, options?: RequestOptions): Promise<PtyTicket>
|
connectToken(input: LocationInput & { readonly ptyID: string }, options?: RequestOptions): Promise<PtyTicket>
|
||||||
|
exists(input: LocationInput & { readonly ptyID: string }, options?: RequestOptions): Promise<boolean>
|
||||||
|
connectURL(input: {
|
||||||
|
readonly ptyID: string
|
||||||
|
readonly location: LocationRef
|
||||||
|
readonly cursor: number
|
||||||
|
readonly ticket?: string
|
||||||
|
}): URL
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ShellOption = {
|
export type ShellOption = {
|
||||||
|
|
@ -811,6 +1247,7 @@ export interface ShellDiscoveryCapability {
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface RuntimeV1Capability {
|
export interface RuntimeV1Capability {
|
||||||
|
disposeLocation(input: LocationInput, options?: RequestOptions): Promise<void>
|
||||||
disposeAll(options?: RequestOptions): Promise<void>
|
disposeAll(options?: RequestOptions): Promise<void>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -833,6 +1270,11 @@ export type IntegrationMethod =
|
||||||
export type IntegrationConnection = {
|
export type IntegrationConnection = {
|
||||||
readonly id: string
|
readonly id: string
|
||||||
readonly label: string
|
readonly label: string
|
||||||
|
readonly kind: "credential" | "environment"
|
||||||
|
}
|
||||||
|
|
||||||
|
export function credentialConnectionIDs(connections: readonly IntegrationConnection[]) {
|
||||||
|
return connections.filter((connection) => connection.kind === "credential").map((connection) => connection.id)
|
||||||
}
|
}
|
||||||
|
|
||||||
export type IntegrationInfo = {
|
export type IntegrationInfo = {
|
||||||
|
|
@ -914,18 +1356,37 @@ export type InstructionEntry = {
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SessionExtrasV2Capability {
|
export interface SessionExtrasV2Capability {
|
||||||
switchAgent(input: { readonly sessionID: string; readonly agent: string }, options?: RequestOptions): Promise<void>
|
diff?(
|
||||||
switchModel(input: { readonly sessionID: string; readonly model: ModelRef }, options?: RequestOptions): Promise<void>
|
input: LocationInput & { readonly sessionID: string },
|
||||||
move(
|
options?: RequestOptions,
|
||||||
input: { readonly sessionID: string; readonly directory: string; readonly moveChanges?: boolean },
|
): Promise<readonly AppFileDiff[]>
|
||||||
|
todos?(
|
||||||
|
input: LocationInput & { readonly sessionID: string },
|
||||||
|
options?: RequestOptions,
|
||||||
|
): Promise<readonly AppTodo[]>
|
||||||
|
switchAgent(
|
||||||
|
input: LocationInput & { readonly sessionID: string; readonly agent: string },
|
||||||
options?: RequestOptions,
|
options?: RequestOptions,
|
||||||
): Promise<void>
|
): Promise<void>
|
||||||
skill(
|
switchModel(
|
||||||
input: { readonly sessionID: string; readonly id?: string; readonly skill: string; readonly resume?: boolean },
|
input: LocationInput & { readonly sessionID: string; readonly model: ModelRef },
|
||||||
options?: RequestOptions,
|
options?: RequestOptions,
|
||||||
): Promise<void>
|
): Promise<void>
|
||||||
synthetic(
|
move?(
|
||||||
input: {
|
input: LocationInput & { readonly sessionID: string; readonly directory: string; readonly moveChanges?: boolean },
|
||||||
|
options?: RequestOptions,
|
||||||
|
): Promise<void>
|
||||||
|
skill?(
|
||||||
|
input: LocationInput & {
|
||||||
|
readonly sessionID: string
|
||||||
|
readonly id?: string
|
||||||
|
readonly skill: string
|
||||||
|
readonly resume?: boolean
|
||||||
|
},
|
||||||
|
options?: RequestOptions,
|
||||||
|
): Promise<void>
|
||||||
|
synthetic?(
|
||||||
|
input: LocationInput & {
|
||||||
readonly sessionID: string
|
readonly sessionID: string
|
||||||
readonly id?: string
|
readonly id?: string
|
||||||
readonly text: string
|
readonly text: string
|
||||||
|
|
@ -936,40 +1397,50 @@ export interface SessionExtrasV2Capability {
|
||||||
},
|
},
|
||||||
options?: RequestOptions,
|
options?: RequestOptions,
|
||||||
): Promise<PendingSessionInput>
|
): Promise<PendingSessionInput>
|
||||||
shell(
|
shell?(
|
||||||
input: { readonly sessionID: string; readonly id?: string; readonly command: string },
|
input: LocationInput & { readonly sessionID: string; readonly id?: string; readonly command: string },
|
||||||
options?: RequestOptions,
|
options?: RequestOptions,
|
||||||
): Promise<void>
|
): Promise<void>
|
||||||
compact(
|
compact?(
|
||||||
input: { readonly sessionID: string; readonly id?: string },
|
input: LocationInput & { readonly sessionID: string; readonly id?: string },
|
||||||
options?: RequestOptions,
|
options?: RequestOptions,
|
||||||
): Promise<PendingSessionInput>
|
): Promise<PendingSessionInput>
|
||||||
wait(input: { readonly sessionID: string }, options?: RequestOptions): Promise<void>
|
wait(input: LocationInput & { readonly sessionID: string }, options?: RequestOptions): Promise<void>
|
||||||
context(input: { readonly sessionID: string }, options?: RequestOptions): Promise<readonly TimelineItem[]>
|
context(
|
||||||
pending(input: { readonly sessionID: string }, options?: RequestOptions): Promise<readonly PendingSessionInput[]>
|
input: LocationInput & { readonly sessionID: string },
|
||||||
instructionEntries(
|
options?: RequestOptions,
|
||||||
input: { readonly sessionID: string },
|
): Promise<readonly TimelineItem[]>
|
||||||
|
pending?(
|
||||||
|
input: LocationInput & { readonly sessionID: string },
|
||||||
|
options?: RequestOptions,
|
||||||
|
): Promise<readonly PendingSessionInput[]>
|
||||||
|
instructionEntries?(
|
||||||
|
input: LocationInput & { readonly sessionID: string },
|
||||||
options?: RequestOptions,
|
options?: RequestOptions,
|
||||||
): Promise<readonly InstructionEntry[]>
|
): Promise<readonly InstructionEntry[]>
|
||||||
putInstructionEntry(
|
putInstructionEntry?(
|
||||||
input: { readonly sessionID: string; readonly key: string; readonly value: JsonValue },
|
input: LocationInput & { readonly sessionID: string; readonly key: string; readonly value: JsonValue },
|
||||||
options?: RequestOptions,
|
options?: RequestOptions,
|
||||||
): Promise<void>
|
): Promise<void>
|
||||||
removeInstructionEntry(
|
removeInstructionEntry?(
|
||||||
input: { readonly sessionID: string; readonly key: string },
|
input: LocationInput & { readonly sessionID: string; readonly key: string },
|
||||||
options?: RequestOptions,
|
options?: RequestOptions,
|
||||||
): Promise<void>
|
): Promise<void>
|
||||||
log(
|
log(
|
||||||
input: { readonly sessionID: string; readonly after?: number; readonly follow?: boolean },
|
input: LocationInput & { readonly sessionID: string; readonly after?: number; readonly follow?: boolean },
|
||||||
options?: RequestOptions,
|
options?: RequestOptions,
|
||||||
): AsyncIterable<SessionLogItem>
|
): AsyncIterable<SessionLogItem>
|
||||||
background(input: { readonly sessionID: string }, options?: RequestOptions): Promise<void>
|
background?(input: LocationInput & { readonly sessionID: string }, options?: RequestOptions): Promise<void>
|
||||||
stageRevert(
|
stageRevert(
|
||||||
input: { readonly sessionID: string; readonly messageID: string; readonly files?: readonly string[] },
|
input: LocationInput & {
|
||||||
|
readonly sessionID: string
|
||||||
|
readonly messageID: string
|
||||||
|
readonly files?: readonly string[]
|
||||||
|
},
|
||||||
options?: RequestOptions,
|
options?: RequestOptions,
|
||||||
): Promise<{ readonly messageID: string }>
|
): Promise<{ readonly messageID: string }>
|
||||||
clearRevert(input: { readonly sessionID: string }, options?: RequestOptions): Promise<void>
|
clearRevert(input: LocationInput & { readonly sessionID: string }, options?: RequestOptions): Promise<void>
|
||||||
commitRevert(input: { readonly sessionID: string }, options?: RequestOptions): Promise<void>
|
commitRevert(input: LocationInput & { readonly sessionID: string }, options?: RequestOptions): Promise<void>
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ProjectDirectory = {
|
export type ProjectDirectory = {
|
||||||
|
|
@ -978,7 +1449,7 @@ export type ProjectDirectory = {
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ProjectCopiesV2Capability {
|
export interface ProjectCopiesV2Capability {
|
||||||
directories(
|
directories?(
|
||||||
input: LocationInput & { readonly projectID: string },
|
input: LocationInput & { readonly projectID: string },
|
||||||
options?: RequestOptions,
|
options?: RequestOptions,
|
||||||
): Promise<readonly ProjectDirectory[]>
|
): Promise<readonly ProjectDirectory[]>
|
||||||
|
|
@ -1161,6 +1632,9 @@ export interface DiscoveryV2Capability {
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Capabilities {
|
export interface Capabilities {
|
||||||
|
readonly projectList?: ProjectListCapability
|
||||||
|
readonly vcs?: VcsApi
|
||||||
|
readonly mcp?: McpApi
|
||||||
readonly configuration?: ConfigurationCapability
|
readonly configuration?: ConfigurationCapability
|
||||||
readonly providerAuthV1?: ProviderAuthV1Capability
|
readonly providerAuthV1?: ProviderAuthV1Capability
|
||||||
readonly integrationsV2?: IntegrationsV2Capability
|
readonly integrationsV2?: IntegrationsV2Capability
|
||||||
|
|
@ -1168,6 +1642,7 @@ export interface Capabilities {
|
||||||
readonly worktreesV1?: WorktreesV1Capability
|
readonly worktreesV1?: WorktreesV1Capability
|
||||||
readonly projectCopiesV2?: ProjectCopiesV2Capability
|
readonly projectCopiesV2?: ProjectCopiesV2Capability
|
||||||
readonly sessionExtrasV1?: SessionExtrasV1Capability
|
readonly sessionExtrasV1?: SessionExtrasV1Capability
|
||||||
|
readonly sessionActionsV1?: SessionActionsV1Capability
|
||||||
readonly sessionExtrasV2?: SessionExtrasV2Capability
|
readonly sessionExtrasV2?: SessionExtrasV2Capability
|
||||||
readonly lsp?: LspCapability
|
readonly lsp?: LspCapability
|
||||||
readonly mcpControl?: McpControlCapability
|
readonly mcpControl?: McpControlCapability
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import { Binary } from "@opencode-ai/core/util/binary"
|
import { Binary } from "@opencode-ai/core/util/binary"
|
||||||
import type { Message, Part, Session } from "@opencode-ai/sdk/v2/client"
|
import type { AppMessage, AppPart, AppSession } from "./backend"
|
||||||
import { createMemo } from "solid-js"
|
import { createMemo } from "solid-js"
|
||||||
import { produce, reconcile, type SetStoreFunction } from "solid-js/store"
|
import { createStore, produce, reconcile, type SetStoreFunction } from "solid-js/store"
|
||||||
import type { createServerSdkContext } from "./server-sdk"
|
import type { createServerSdkContext } from "./server-sdk"
|
||||||
import type { createServerSyncContextInner } from "./server-sync"
|
import type { createServerSyncContextInner } from "./server-sync"
|
||||||
import type { State } from "./global-sync/types"
|
import type { State } from "./global-sync/types"
|
||||||
|
|
@ -23,8 +23,8 @@ export const createDirSyncContext = (
|
||||||
serverSync: ReturnType<typeof createServerSyncContextInner>,
|
serverSync: ReturnType<typeof createServerSyncContextInner>,
|
||||||
serverSDK: ReturnType<typeof createServerSdkContext>,
|
serverSDK: ReturnType<typeof createServerSdkContext>,
|
||||||
) => {
|
) => {
|
||||||
const client = serverSDK.createClient({ directory, throwOnError: true })
|
|
||||||
const current = createMemo(() => serverSync.child(directory, { mcp: true }))
|
const current = createMemo(() => serverSync.child(directory, { mcp: true }))
|
||||||
|
const [sessionPage, setSessionPage] = createStore({ cursor: undefined as string | undefined, complete: false })
|
||||||
const absolute = (path: string) => (current()[0].path.directory + "/" + path).replace("//", "/")
|
const absolute = (path: string) => (current()[0].path.directory + "/" + path).replace("//", "/")
|
||||||
const data = new Proxy({} as State, {
|
const data = new Proxy({} as State, {
|
||||||
get(_, property: keyof State) {
|
get(_, property: keyof State) {
|
||||||
|
|
@ -72,7 +72,7 @@ export const createDirSyncContext = (
|
||||||
if (match.found) return serverSync.data.project[match.index]
|
if (match.found) return serverSync.data.project[match.index]
|
||||||
},
|
},
|
||||||
session: {
|
session: {
|
||||||
remember(session: Session) {
|
remember(session: AppSession) {
|
||||||
serverSync.session.remember(session)
|
serverSync.session.remember(session)
|
||||||
index(session.id)
|
index(session.id)
|
||||||
},
|
},
|
||||||
|
|
@ -81,7 +81,7 @@ export const createDirSyncContext = (
|
||||||
if (session?.directory === directory) return session
|
if (session?.directory === directory) return session
|
||||||
},
|
},
|
||||||
optimistic: {
|
optimistic: {
|
||||||
add(input: { directory?: string; sessionID: string; message: Message; parts: Part[] }) {
|
add(input: { directory?: string; sessionID: string; message: AppMessage; parts: AppPart[] }) {
|
||||||
serverSync.session.optimistic.add(input)
|
serverSync.session.optimistic.add(input)
|
||||||
},
|
},
|
||||||
remove(input: { directory?: string; sessionID: string; messageID: string }) {
|
remove(input: { directory?: string; sessionID: string; messageID: string }) {
|
||||||
|
|
@ -91,7 +91,7 @@ export const createDirSyncContext = (
|
||||||
addOptimisticMessage(input: {
|
addOptimisticMessage(input: {
|
||||||
sessionID: string
|
sessionID: string
|
||||||
messageID: string
|
messageID: string
|
||||||
parts: Part[]
|
parts: AppPart[]
|
||||||
agent: string
|
agent: string
|
||||||
model: { providerID: string; modelID: string }
|
model: { providerID: string; modelID: string }
|
||||||
variant?: string
|
variant?: string
|
||||||
|
|
@ -110,7 +110,7 @@ export const createDirSyncContext = (
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
async sync(sessionID: string, options?: { force?: boolean }) {
|
async sync(sessionID: string, options?: { force?: boolean }) {
|
||||||
await serverSync.session.sync(sessionID, options)
|
await serverSync.session.sync(sessionID, { ...options, location: { directory } })
|
||||||
index(sessionID)
|
index(sessionID)
|
||||||
},
|
},
|
||||||
diff: serverSync.session.diff,
|
diff: serverSync.session.diff,
|
||||||
|
|
@ -121,17 +121,25 @@ export const createDirSyncContext = (
|
||||||
fetch: async (count = 10) => {
|
fetch: async (count = 10) => {
|
||||||
const [store, setStore] = current()
|
const [store, setStore] = current()
|
||||||
setStore("limit", (value) => value + count)
|
setStore("limit", (value) => value + count)
|
||||||
const response = await client.session.list()
|
const backend = await serverSDK.backend
|
||||||
const sessions = (response.data ?? [])
|
const response = await backend.common.sessions.list({
|
||||||
.filter((session) => !!session?.id)
|
location: { directory },
|
||||||
|
roots: true,
|
||||||
|
limit: count,
|
||||||
|
cursor: sessionPage.cursor,
|
||||||
|
})
|
||||||
|
const sessions = [...new Map([...store.session, ...response.items].map((session) => [session.id, session])).values()]
|
||||||
.sort((a, b) => cmp(a.id, b.id))
|
.sort((a, b) => cmp(a.id, b.id))
|
||||||
.slice(0, store.limit)
|
|
||||||
sessions.forEach(serverSync.session.remember)
|
sessions.forEach(serverSync.session.remember)
|
||||||
setStore("session", reconcile(sessions, { key: "id" }))
|
setStore("session", reconcile(sessions, { key: "id" }))
|
||||||
|
setSessionPage({ cursor: response.older, complete: !response.older })
|
||||||
},
|
},
|
||||||
more: createMemo(() => current()[0].session.length >= current()[0].limit),
|
more: createMemo(() => !sessionPage.complete),
|
||||||
archive: async (sessionID: string) => {
|
archive: async (sessionID: string) => {
|
||||||
await serverSDK.client.session.update({ sessionID, time: { archived: Date.now() } })
|
const backend = await serverSDK.backend
|
||||||
|
const capability = backend.capabilities.sessionExtrasV1
|
||||||
|
if (!capability) throw new Error("Server does not support session archiving")
|
||||||
|
await capability.archive({ sessionID, archivedAt: Date.now(), location: { directory } })
|
||||||
current()[1](
|
current()[1](
|
||||||
"session",
|
"session",
|
||||||
produce((draft) => {
|
produce((draft) => {
|
||||||
|
|
|
||||||
|
|
@ -79,10 +79,16 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({
|
||||||
const tree = createFileTreeStore({
|
const tree = createFileTreeStore({
|
||||||
scope,
|
scope,
|
||||||
normalizeDir: path.normalizeDir,
|
normalizeDir: path.normalizeDir,
|
||||||
list: (dir) =>
|
list: async (dir) =>
|
||||||
sdk()
|
[...(await (await sdk().backend).common.files.list({ location: { directory: scope() }, path: dir }))].map(
|
||||||
.client.file.list({ path: dir })
|
(node) => ({
|
||||||
.then((x) => x.data ?? []),
|
name: node.name ?? getFilename(node.path),
|
||||||
|
path: node.path,
|
||||||
|
absolute: node.absolute ?? node.path,
|
||||||
|
type: node.type,
|
||||||
|
ignored: node.ignored,
|
||||||
|
}),
|
||||||
|
),
|
||||||
onError: (message) => {
|
onError: (message) => {
|
||||||
showToast({
|
showToast({
|
||||||
variant: "error",
|
variant: "error",
|
||||||
|
|
@ -181,10 +187,18 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({
|
||||||
setLoading(file)
|
setLoading(file)
|
||||||
|
|
||||||
const promise = sdk()
|
const promise = sdk()
|
||||||
.client.file.read({ path: file })
|
.backend.then(async (client) => {
|
||||||
.then((x) => {
|
|
||||||
if (scope() !== directory) return
|
if (scope() !== directory) return
|
||||||
const content = x.data
|
const content: FileState["content"] = client.capabilities.decoratedFiles
|
||||||
|
? await client.capabilities.decoratedFiles.read({ location: { directory }, path: file })
|
||||||
|
: await client.common.files.read({ location: { directory }, path: file }).then((value) => {
|
||||||
|
if (value.kind !== "text") return
|
||||||
|
return {
|
||||||
|
type: "text" as const,
|
||||||
|
content: new TextDecoder().decode(value.bytes),
|
||||||
|
mimeType: value.mimeType,
|
||||||
|
}
|
||||||
|
})
|
||||||
setLoaded(file, content)
|
setLoaded(file, content)
|
||||||
|
|
||||||
if (!content) return
|
if (!content) return
|
||||||
|
|
@ -205,9 +219,19 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({
|
||||||
|
|
||||||
const search = (query: string, dirs: "true" | "false", options?: { limit?: number; signal?: AbortSignal }) =>
|
const search = (query: string, dirs: "true" | "false", options?: { limit?: number; signal?: AbortSignal }) =>
|
||||||
sdk()
|
sdk()
|
||||||
.client.find.files({ query, dirs, limit: options?.limit }, { signal: options?.signal })
|
.backend.then((client) =>
|
||||||
|
client.common.files.find(
|
||||||
|
{
|
||||||
|
location: { directory: scope() },
|
||||||
|
query,
|
||||||
|
type: dirs === "true" ? undefined : "file",
|
||||||
|
limit: options?.limit,
|
||||||
|
},
|
||||||
|
{ signal: options?.signal },
|
||||||
|
),
|
||||||
|
)
|
||||||
.then(
|
.then(
|
||||||
(x) => (x.data ?? []).map(path.normalize),
|
(items) => items.map((item) => path.normalize(item.path)),
|
||||||
(error) => {
|
(error) => {
|
||||||
if (options?.signal?.aborted) throw error
|
if (options?.signal?.aborted) throw error
|
||||||
return []
|
return []
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import type { FileContent } from "@opencode-ai/sdk/v2"
|
import type { DecoratedFileContent as FileContent } from "../backend"
|
||||||
|
|
||||||
const MAX_FILE_CONTENT_ENTRIES = 40
|
const MAX_FILE_CONTENT_ENTRIES = 40
|
||||||
const MAX_FILE_CONTENT_BYTES = 20 * 1024 * 1024
|
const MAX_FILE_CONTENT_BYTES = 20 * 1024 * 1024
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { createStore, produce, reconcile } from "solid-js/store"
|
import { createStore, produce, reconcile } from "solid-js/store"
|
||||||
import type { FileNode } from "@opencode-ai/sdk/v2"
|
import type { AppFileNode as FileNode } from "../backend"
|
||||||
|
|
||||||
type DirectoryState = {
|
type DirectoryState = {
|
||||||
expanded: boolean
|
expanded: boolean
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import type { FileContent } from "@opencode-ai/sdk/v2"
|
import type { DecoratedFileContent as FileContent } from "../backend"
|
||||||
|
|
||||||
export type FileSelection = {
|
export type FileSelection = {
|
||||||
startLine: number
|
startLine: number
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,10 @@
|
||||||
import type { FileNode } from "@opencode-ai/sdk/v2"
|
import type { AppFileNode as FileNode } from "../backend"
|
||||||
|
|
||||||
type WatcherEvent = {
|
type WatcherEvent = {
|
||||||
type: string
|
type: string
|
||||||
properties: unknown
|
path?: string
|
||||||
|
change?: string
|
||||||
|
properties?: unknown
|
||||||
}
|
}
|
||||||
|
|
||||||
type WatcherOps = {
|
type WatcherOps = {
|
||||||
|
|
@ -16,11 +18,11 @@ type WatcherOps = {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function invalidateFromWatcher(event: WatcherEvent, ops: WatcherOps) {
|
export function invalidateFromWatcher(event: WatcherEvent, ops: WatcherOps) {
|
||||||
if (event.type !== "filesystem.changed") return
|
if (event.type !== "filesystem.changed" && event.type !== "file.changed" && event.type !== "file.watcher.updated") return
|
||||||
const props =
|
const props =
|
||||||
typeof event.properties === "object" && event.properties ? (event.properties as Record<string, unknown>) : undefined
|
typeof event.properties === "object" && event.properties ? (event.properties as Record<string, unknown>) : undefined
|
||||||
const rawPath = typeof props?.file === "string" ? props.file : undefined
|
const rawPath = event.path ?? (typeof props?.file === "string" ? props.file : undefined)
|
||||||
const kind = typeof props?.event === "string" ? props.event : undefined
|
const kind = event.change ?? (typeof props?.event === "string" ? props.event : undefined)
|
||||||
if (!rawPath) return
|
if (!rawPath) return
|
||||||
if (!kind) return
|
if (!kind) return
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,15 @@
|
||||||
import { describe, expect, test } from "bun:test"
|
import { describe, expect, test } from "bun:test"
|
||||||
import { createStore } from "solid-js/store"
|
import { createStore } from "solid-js/store"
|
||||||
import { QueryClient } from "@tanstack/solid-query"
|
import { QueryClient } from "@tanstack/solid-query"
|
||||||
import type { Config, OpencodeClient, Project, Session } from "@opencode-ai/sdk/v2/client"
|
import type { AppClient, AppProject as Project, AppSession as Session } from "../backend"
|
||||||
import type { NormalizedProviderListResponse } from "@opencode-ai/session-ui/context"
|
import { createAppClient } from "../backend.test-fixture"
|
||||||
|
import type { ProviderStore } from "./types"
|
||||||
import { bootstrapDirectory, loadPathQuery, loadProvidersQuery } from "./bootstrap"
|
import { bootstrapDirectory, loadPathQuery, loadProvidersQuery } from "./bootstrap"
|
||||||
import type { State, VcsCache } from "./types"
|
import type { State, VcsCache } from "./types"
|
||||||
import { createServerSession } from "../server-session"
|
import { createServerSession } from "../server-session"
|
||||||
import { ServerScope } from "@/utils/server-scope"
|
import { ServerScope } from "@/utils/server-scope"
|
||||||
|
|
||||||
const provider = { all: new Map(), connected: [], default: {} } satisfies NormalizedProviderListResponse
|
const provider = { all: new Map(), connected: [], default: {} } satisfies ProviderStore
|
||||||
|
|
||||||
function directoryState() {
|
function directoryState() {
|
||||||
return createStore<State>({
|
return createStore<State>({
|
||||||
|
|
@ -30,6 +31,7 @@ function directoryState() {
|
||||||
return this.session_status[id]?.type !== "idle"
|
return this.session_status[id]?.type !== "idle"
|
||||||
},
|
},
|
||||||
session_diff: {},
|
session_diff: {},
|
||||||
|
todo: {},
|
||||||
permission: {},
|
permission: {},
|
||||||
question: {},
|
question: {},
|
||||||
mcp_ready: true,
|
mcp_ready: true,
|
||||||
|
|
@ -55,33 +57,35 @@ describe("bootstrapDirectory", () => {
|
||||||
scope: ServerScope.local,
|
scope: ServerScope.local,
|
||||||
mcp: false,
|
mcp: false,
|
||||||
global: {
|
global: {
|
||||||
config: {} satisfies Config,
|
config: {},
|
||||||
path: { state: "", config: "", worktree: "/project", directory: "/project", home: "/home" },
|
path: { state: "", config: "", worktree: "/project", directory: "/project", home: "/home" },
|
||||||
project: [{ id: "project", worktree: "/project" } as Project],
|
project: [{ id: "project", worktree: "/project" } as Project],
|
||||||
provider,
|
provider,
|
||||||
},
|
},
|
||||||
sdk: {
|
backend: {
|
||||||
app: { agents: async () => ({ data: [{ name: "build", mode: "primary" }] }) },
|
version: "v1",
|
||||||
config: { get: async () => ({ data: {} }) },
|
capabilities: {
|
||||||
session: { status: async () => ({ data: {} }) },
|
configuration: { get: async () => ({}), getGlobal: async () => ({}), updateGlobal: async () => {} },
|
||||||
vcs: { get: async () => ({ data: undefined }) },
|
vcsInfo: { get: async () => ({}) },
|
||||||
command: {
|
|
||||||
list: async () => {
|
|
||||||
mcpReads.push("command")
|
|
||||||
return { data: [] }
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
permission: { list: async () => ({ data: [] }) },
|
common: {
|
||||||
question: { list: async () => ({ data: [] }) },
|
catalog: {
|
||||||
v2: { reference: { list: async () => ({ data: { data: [] } }) } },
|
agents: async () => [{ id: "build", name: "build", mode: "primary", hidden: false }],
|
||||||
mcp: {
|
providers: async () => ({ providers: new Map(), connected: [], defaults: {} }),
|
||||||
status: async () => {
|
|
||||||
mcpReads.push("status")
|
|
||||||
return { data: {} }
|
|
||||||
},
|
},
|
||||||
|
sessions: { activity: async () => ({}) },
|
||||||
|
projects: { current: async () => ({ id: "project", directory: "/project" }) },
|
||||||
|
commands: {
|
||||||
|
list: async () => {
|
||||||
|
mcpReads.push("command")
|
||||||
|
return []
|
||||||
|
},
|
||||||
|
},
|
||||||
|
permissions: { pending: async () => [] },
|
||||||
|
questions: { pending: async () => [] },
|
||||||
|
references: { list: async () => [] },
|
||||||
},
|
},
|
||||||
provider: { list: async () => ({ data: { all: [], connected: [], default: {} } }) },
|
} as unknown as AppClient,
|
||||||
} as unknown as OpencodeClient,
|
|
||||||
store,
|
store,
|
||||||
setStore,
|
setStore,
|
||||||
vcsCache: { setStore() {} } as unknown as VcsCache,
|
vcsCache: { setStore() {} } as unknown as VcsCache,
|
||||||
|
|
@ -101,22 +105,26 @@ describe("bootstrapDirectory", () => {
|
||||||
test("seeds session status even while warming session info stalls", async () => {
|
test("seeds session status even while warming session info stalls", async () => {
|
||||||
const [store, setStore] = directoryState()
|
const [store, setStore] = directoryState()
|
||||||
const stalled = Promise.withResolvers<never>()
|
const stalled = Promise.withResolvers<never>()
|
||||||
const client = {
|
const backend = createAppClient({
|
||||||
app: { agents: async () => ({ data: [{ name: "build", mode: "primary" }] }) },
|
version: "v1",
|
||||||
config: { get: async () => ({ data: {} }) },
|
capabilities: {
|
||||||
session: {
|
configuration: { get: async () => ({}), getGlobal: async () => ({}), updateGlobal: async () => {} },
|
||||||
status: async () => ({ data: { ses_busy: { type: "busy" } } }),
|
vcsInfo: { get: async () => ({}) },
|
||||||
get: () => stalled.promise,
|
|
||||||
},
|
},
|
||||||
vcs: { get: async () => ({ data: undefined }) },
|
common: {
|
||||||
command: { list: async () => ({ data: [] }) },
|
catalog: {
|
||||||
permission: { list: async () => ({ data: [] }) },
|
agents: async () => [{ id: "build", name: "build", mode: "primary", hidden: false }],
|
||||||
question: { list: async () => ({ data: [] }) },
|
providers: async () => ({ providers: new Map(), connected: [], defaults: {} }),
|
||||||
v2: { reference: { list: async () => ({ data: { data: [] } }) } },
|
},
|
||||||
mcp: { status: async () => ({ data: {} }) },
|
sessions: { activity: async () => ({ ses_busy: { type: "busy" } }), get: () => stalled.promise },
|
||||||
provider: { list: async () => ({ data: { all: [], connected: [], default: {} } }) },
|
projects: { current: async () => ({ id: "project", directory: "/project" }) },
|
||||||
} as unknown as OpencodeClient
|
commands: { list: async () => [] },
|
||||||
const session = createServerSession(client)
|
permissions: { pending: async () => [] },
|
||||||
|
questions: { pending: async () => [] },
|
||||||
|
references: { list: async () => [] },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
const session = createServerSession(backend)
|
||||||
const stale: Session = {
|
const stale: Session = {
|
||||||
id: "ses_stale",
|
id: "ses_stale",
|
||||||
slug: "ses_stale",
|
slug: "ses_stale",
|
||||||
|
|
@ -134,12 +142,12 @@ describe("bootstrapDirectory", () => {
|
||||||
scope: ServerScope.local,
|
scope: ServerScope.local,
|
||||||
mcp: false,
|
mcp: false,
|
||||||
global: {
|
global: {
|
||||||
config: {} satisfies Config,
|
config: {},
|
||||||
path: { state: "", config: "", worktree: "/project", directory: "/project", home: "/home" },
|
path: { state: "", config: "", worktree: "/project", directory: "/project", home: "/home" },
|
||||||
project: [{ id: "project", worktree: "/project" } as Project],
|
project: [{ id: "project", worktree: "/project" } as Project],
|
||||||
provider,
|
provider,
|
||||||
},
|
},
|
||||||
sdk: client,
|
backend,
|
||||||
store,
|
store,
|
||||||
setStore,
|
setStore,
|
||||||
vcsCache: { setStore() {} } as unknown as VcsCache,
|
vcsCache: { setStore() {} } as unknown as VcsCache,
|
||||||
|
|
@ -161,12 +169,12 @@ describe("bootstrapDirectory", () => {
|
||||||
|
|
||||||
describe("query keys", () => {
|
describe("query keys", () => {
|
||||||
test("partitions identical directories by server scope", () => {
|
test("partitions identical directories by server scope", () => {
|
||||||
const client = {} as OpencodeClient
|
const backend = Promise.resolve({} as AppClient)
|
||||||
const remote = "https://debian.example" as typeof ServerScope.local
|
const remote = "https://debian.example" as typeof ServerScope.local
|
||||||
|
|
||||||
expect([...loadPathQuery(ServerScope.local, "/repo", client).queryKey]).toEqual(["local", "/repo", "path"])
|
expect([...loadPathQuery(ServerScope.local, "/repo", backend).queryKey]).toEqual(["local", "/repo", "path"])
|
||||||
expect([...loadPathQuery(remote, "/repo", client).queryKey]).toEqual(["https://debian.example", "/repo", "path"])
|
expect([...loadPathQuery(remote, "/repo", backend).queryKey]).toEqual(["https://debian.example", "/repo", "path"])
|
||||||
expect([...loadProvidersQuery(remote, null, client).queryKey]).toEqual([
|
expect([...loadProvidersQuery(remote, null, backend).queryKey]).toEqual([
|
||||||
"https://debian.example",
|
"https://debian.example",
|
||||||
null,
|
null,
|
||||||
"providers",
|
"providers",
|
||||||
|
|
|
||||||
|
|
@ -1,35 +1,35 @@
|
||||||
import type {
|
import type {
|
||||||
Config,
|
AppClient,
|
||||||
OpencodeClient,
|
AppConfig,
|
||||||
Path,
|
AppPathInfo,
|
||||||
PermissionRequest,
|
AppPermissionRequest,
|
||||||
Project,
|
AppProject,
|
||||||
ProviderAuthResponse,
|
AppProviderAuthResponse,
|
||||||
QuestionRequest,
|
AppQuestionRequest,
|
||||||
ReferenceInfo,
|
AppReference,
|
||||||
Session,
|
AppSession,
|
||||||
} from "@opencode-ai/sdk/v2/client"
|
ProviderCatalog,
|
||||||
|
} from "../backend"
|
||||||
import { showToast } from "@/utils/toast"
|
import { showToast } from "@/utils/toast"
|
||||||
import { getFilename } from "@opencode-ai/core/util/path"
|
import { getFilename } from "@opencode-ai/core/util/path"
|
||||||
import { retry } from "@opencode-ai/core/util/retry"
|
import { retry } from "@opencode-ai/core/util/retry"
|
||||||
import { batch } from "solid-js"
|
import { batch } from "solid-js"
|
||||||
import { produce, reconcile, type SetStoreFunction, type Store } from "solid-js/store"
|
import { produce, reconcile, type SetStoreFunction, type Store } from "solid-js/store"
|
||||||
import type { State, VcsCache } from "./types"
|
import type { ProviderStore, State, StoreConfig, VcsCache } from "./types"
|
||||||
import type { ServerSession } from "../server-session"
|
import type { ServerSession } from "../server-session"
|
||||||
import { cmp, normalizeAgentList, normalizeProviderList } from "./utils"
|
import { cmp } from "./utils"
|
||||||
import { formatServerError } from "@/utils/server-errors"
|
import { formatServerError } from "@/utils/server-errors"
|
||||||
import { QueryClient, queryOptions } from "@tanstack/solid-query"
|
import { QueryClient, queryOptions } from "@tanstack/solid-query"
|
||||||
import { loadMcpQuery, loadMcpResourcesQuery } from "../server-sync"
|
import { loadMcpQuery, loadMcpResourcesQuery } from "../server-sync"
|
||||||
import { NormalizedProviderListResponse } from "@opencode-ai/session-ui/context"
|
|
||||||
import { ScopedKey, type ServerScope } from "@/utils/server-scope"
|
import { ScopedKey, type ServerScope } from "@/utils/server-scope"
|
||||||
|
|
||||||
type GlobalStore = {
|
type GlobalStore = {
|
||||||
ready: boolean
|
ready: boolean
|
||||||
path: Path
|
path: AppPathInfo
|
||||||
project: Project[]
|
project: AppProject[]
|
||||||
provider: NormalizedProviderListResponse
|
provider: ProviderStore
|
||||||
provider_auth: ProviderAuthResponse
|
provider_auth: AppProviderAuthResponse
|
||||||
config: Config
|
config: StoreConfig
|
||||||
reload: undefined | "pending" | "complete"
|
reload: undefined | "pending" | "complete"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -82,29 +82,31 @@ function showErrors(input: {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export const loadGlobalConfigQuery = (scope: ServerScope, sdk: OpencodeClient) =>
|
export const loadGlobalConfigQuery = (scope: ServerScope, backend: Promise<AppClient>) =>
|
||||||
queryOptions({
|
queryOptions({
|
||||||
queryKey: [scope, "config"],
|
queryKey: [scope, "config"],
|
||||||
queryFn: () => retry(() => sdk.global.config.get().then((x) => x.data!)),
|
queryFn: () => retry(async () => (await backend).capabilities.configuration?.getGlobal() ?? {}),
|
||||||
})
|
})
|
||||||
|
|
||||||
export const loadProjectsQuery = (scope: ServerScope, sdk: OpencodeClient) =>
|
export const loadProjectsQuery = (scope: ServerScope, backend: Promise<AppClient>) =>
|
||||||
queryOptions({
|
queryOptions({
|
||||||
queryKey: [scope, "project"],
|
queryKey: [scope, "project"],
|
||||||
queryFn: () =>
|
queryFn: () =>
|
||||||
retry(() =>
|
retry(() =>
|
||||||
sdk.project.list().then((x) => {
|
backend
|
||||||
return (x.data ?? [])
|
.then((client) => client.capabilities.projectList?.list() ?? [])
|
||||||
.filter((p) => !!p?.id)
|
.then((projects) => {
|
||||||
.filter((p) => !!p.worktree && !p.worktree.includes("opencode-test"))
|
return projects
|
||||||
.slice()
|
.filter((p) => !!p?.id)
|
||||||
.sort((a, b) => cmp(a.id, b.id))
|
.filter((p) => !!p.worktree && !p.worktree.includes("opencode-test"))
|
||||||
}),
|
.slice()
|
||||||
|
.sort((a, b) => cmp(a.id, b.id))
|
||||||
|
}),
|
||||||
),
|
),
|
||||||
})
|
})
|
||||||
|
|
||||||
export async function bootstrapGlobal(input: {
|
export async function bootstrapGlobal(input: {
|
||||||
serverSDK: OpencodeClient
|
backend: Promise<AppClient>
|
||||||
scope: ServerScope
|
scope: ServerScope
|
||||||
requestFailedTitle: string
|
requestFailedTitle: string
|
||||||
translate: (key: string, vars?: Record<string, string | number>) => string
|
translate: (key: string, vars?: Record<string, string | number>) => string
|
||||||
|
|
@ -113,12 +115,12 @@ export async function bootstrapGlobal(input: {
|
||||||
queryClient: QueryClient
|
queryClient: QueryClient
|
||||||
}) {
|
}) {
|
||||||
const slow = [
|
const slow = [
|
||||||
() => input.queryClient.fetchQuery(loadGlobalConfigQuery(input.scope, input.serverSDK)),
|
() => input.queryClient.fetchQuery(loadGlobalConfigQuery(input.scope, input.backend)),
|
||||||
() => input.queryClient.fetchQuery(loadProvidersQuery(input.scope, null, input.serverSDK)),
|
() => input.queryClient.fetchQuery(loadProvidersQuery(input.scope, null, input.backend)),
|
||||||
() => input.queryClient.fetchQuery(loadPathQuery(input.scope, null, input.serverSDK)),
|
() => input.queryClient.fetchQuery(loadPathQuery(input.scope, null, input.backend)),
|
||||||
() =>
|
() =>
|
||||||
input.queryClient
|
input.queryClient
|
||||||
.fetchQuery(loadProjectsQuery(input.scope, input.serverSDK))
|
.fetchQuery(loadProjectsQuery(input.scope, input.backend))
|
||||||
.then((data) => input.setGlobalStore("project", data)),
|
.then((data) => input.setGlobalStore("project", data)),
|
||||||
]
|
]
|
||||||
await runAll(slow)
|
await runAll(slow)
|
||||||
|
|
@ -140,11 +142,11 @@ function groupBySession<T extends { id: string; sessionID: string }>(input: T[])
|
||||||
}, {})
|
}, {})
|
||||||
}
|
}
|
||||||
|
|
||||||
function projectID(directory: string, projects: Project[]) {
|
function projectID(directory: string, projects: readonly AppProject[]) {
|
||||||
return projects.find((project) => project.worktree === directory || project.sandboxes?.includes(directory))?.id
|
return projects.find((project) => project.worktree === directory || project.sandboxes?.includes(directory))?.id
|
||||||
}
|
}
|
||||||
|
|
||||||
function mergeSession(setStore: SetStoreFunction<State>, session: Session) {
|
function mergeSession(setStore: SetStoreFunction<State>, session: AppSession) {
|
||||||
setStore("session", (list) => {
|
setStore("session", (list) => {
|
||||||
const next = list.slice()
|
const next = list.slice()
|
||||||
const idx = next.findIndex((item) => item.id >= session.id)
|
const idx = next.findIndex((item) => item.id >= session.id)
|
||||||
|
|
@ -162,44 +164,54 @@ function warmSessions(input: {
|
||||||
ids: string[]
|
ids: string[]
|
||||||
store: Store<State>
|
store: Store<State>
|
||||||
setStore: SetStoreFunction<State>
|
setStore: SetStoreFunction<State>
|
||||||
sdk: OpencodeClient
|
backend: AppClient
|
||||||
|
location: { directory: string }
|
||||||
}) {
|
}) {
|
||||||
const known = new Set(input.store.session.map((item) => item.id))
|
const known = new Set(input.store.session.map((item) => item.id))
|
||||||
const ids = [...new Set(input.ids)].filter((id) => !!id && !known.has(id))
|
const ids = [...new Set(input.ids)].filter((id) => !!id && !known.has(id))
|
||||||
if (ids.length === 0) return Promise.resolve()
|
if (ids.length === 0) return Promise.resolve()
|
||||||
return Promise.all(
|
return Promise.all(
|
||||||
ids.map((sessionID) =>
|
ids.map((sessionID) =>
|
||||||
retry(() => input.sdk.session.get({ sessionID })).then((x) => {
|
retry(() => input.backend.common.sessions.get({ sessionID, location: input.location })).then((x) => {
|
||||||
const session = x.data
|
if (!x?.id) return
|
||||||
if (!session?.id) return
|
mergeSession(input.setStore, x)
|
||||||
mergeSession(input.setStore, session)
|
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
).then(() => undefined)
|
).then(() => undefined)
|
||||||
}
|
}
|
||||||
|
|
||||||
export const loadProvidersQuery = (scope: ServerScope, directory: string | null, sdk: OpencodeClient) =>
|
export const loadProvidersQuery = (scope: ServerScope, directory: string | null, backend: Promise<AppClient>) =>
|
||||||
queryOptions({
|
queryOptions({
|
||||||
queryKey: [scope, directory, "providers"],
|
queryKey: [scope, directory, "providers"],
|
||||||
queryFn: () => retry(() => sdk.provider.list().then((x) => normalizeProviderList(x.data!))),
|
queryFn: () =>
|
||||||
|
retry(() => backend.then((client) => client.common.catalog.providers(location(directory)).then(toProviderStore))),
|
||||||
})
|
})
|
||||||
|
|
||||||
export const loadAgentsQuery = (scope: ServerScope, directory: string | null, sdk: OpencodeClient) =>
|
export const loadAgentsQuery = (scope: ServerScope, directory: string, backend: Promise<AppClient>) =>
|
||||||
queryOptions({
|
queryOptions({
|
||||||
queryKey: [scope, directory, "agents"],
|
queryKey: [scope, directory, "agents"],
|
||||||
queryFn: () => retry(() => sdk.app.agents().then((x) => normalizeAgentList(x.data))),
|
queryFn: () =>
|
||||||
|
retry(() =>
|
||||||
|
backend.then((client) => client.common.catalog.agents(location(directory)).then((agents) => [...agents])),
|
||||||
|
),
|
||||||
})
|
})
|
||||||
|
|
||||||
export const loadPathQuery = (scope: ServerScope, directory: string | null, sdk: OpencodeClient) =>
|
export const loadPathQuery = (scope: ServerScope, directory: string | null, backend: Promise<AppClient>) =>
|
||||||
queryOptions<Path>({
|
queryOptions<AppPathInfo>({
|
||||||
queryKey: [scope, directory, "path"],
|
queryKey: [scope, directory, "path"],
|
||||||
queryFn: () => retry(() => sdk.path.get().then((x) => x.data!)),
|
queryFn: async () => {
|
||||||
|
const client = await backend
|
||||||
|
return retry(
|
||||||
|
() => client.capabilities.pathInfo?.get(location(directory)) ?? Promise.resolve(emptyPath(directory)),
|
||||||
|
)
|
||||||
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
export const loadReferencesQuery = (scope: ServerScope, directory: string, sdk: OpencodeClient) =>
|
export const loadReferencesQuery = (scope: ServerScope, directory: string, backend: Promise<AppClient>) =>
|
||||||
queryOptions<ReferenceInfo[]>({
|
queryOptions<readonly AppReference[]>({
|
||||||
queryKey: [scope, directory, "references"] as const,
|
queryKey: [scope, directory, "references"] as const,
|
||||||
queryFn: () => retry(() => sdk.v2.reference.list().then((x) => x.data?.data ?? [])).catch(() => []),
|
queryFn: () =>
|
||||||
|
retry(() => backend.then((client) => client.common.references.list(location(directory)))).catch(() => []),
|
||||||
placeholderData: [],
|
placeholderData: [],
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -207,17 +219,17 @@ export async function bootstrapDirectory(input: {
|
||||||
directory: string
|
directory: string
|
||||||
scope: ServerScope
|
scope: ServerScope
|
||||||
mcp: boolean
|
mcp: boolean
|
||||||
sdk: OpencodeClient
|
backend: AppClient
|
||||||
store: Store<State>
|
store: Store<State>
|
||||||
setStore: SetStoreFunction<State>
|
setStore: SetStoreFunction<State>
|
||||||
vcsCache: VcsCache
|
vcsCache: VcsCache
|
||||||
loadSessions: (directory: string) => Promise<void> | void
|
loadSessions: (directory: string) => Promise<void> | void
|
||||||
translate: (key: string, vars?: Record<string, string | number>) => string
|
translate: (key: string, vars?: Record<string, string | number>) => string
|
||||||
global: {
|
global: {
|
||||||
config: Config
|
config: StoreConfig
|
||||||
path: Path
|
path: AppPathInfo
|
||||||
project: Project[]
|
project: readonly AppProject[]
|
||||||
provider: NormalizedProviderListResponse
|
provider: ProviderStore
|
||||||
}
|
}
|
||||||
queryClient: QueryClient
|
queryClient: QueryClient
|
||||||
session?: ServerSession
|
session?: ServerSession
|
||||||
|
|
@ -240,66 +252,90 @@ export async function bootstrapDirectory(input: {
|
||||||
() => Promise.resolve(input.loadSessions(input.directory)),
|
() => Promise.resolve(input.loadSessions(input.directory)),
|
||||||
() =>
|
() =>
|
||||||
input.queryClient
|
input.queryClient
|
||||||
.ensureQueryData(loadAgentsQuery(input.scope, input.directory, input.sdk))
|
.ensureQueryData(loadAgentsQuery(input.scope, input.directory, Promise.resolve(input.backend)))
|
||||||
.then((data) => input.setStore("agent", data)),
|
.then((data) => input.setStore("agent", data)),
|
||||||
() =>
|
() =>
|
||||||
retry(() => input.sdk.config.get().then((x) => input.setStore("config", reconcile(x.data!, { merge: false })))),
|
retry(
|
||||||
|
() =>
|
||||||
|
input.backend.capabilities.configuration
|
||||||
|
?.get(location(input.directory))
|
||||||
|
.then((config) => input.setStore("config", reconcile(config, { merge: false }))) ?? Promise.resolve(),
|
||||||
|
),
|
||||||
() =>
|
() =>
|
||||||
retry(() =>
|
retry(() =>
|
||||||
input.sdk.session.status().then(async (x) => {
|
input.backend.common.sessions.activity(location(input.directory)).then(async (statuses) => {
|
||||||
if (!input.session) {
|
if (!input.session) {
|
||||||
input.setStore("session_status", x.data!)
|
input.setStore("session_status", mapActivity(statuses))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const statuses = x.data ?? {}
|
const mapped = mapActivity(statuses)
|
||||||
input.session.set(
|
input.session.set(
|
||||||
"session_status",
|
"session_status",
|
||||||
produce((draft) => {
|
produce((draft) => {
|
||||||
for (const sessionID of Object.keys(draft)) {
|
for (const sessionID of Object.keys(draft)) {
|
||||||
if (statuses[sessionID]) continue
|
if (mapped[sessionID]) continue
|
||||||
if (input.session?.get(sessionID)?.directory === input.directory) delete draft[sessionID]
|
if (input.session?.get(sessionID)?.directory === input.directory) delete draft[sessionID]
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
for (const [sessionID, status] of Object.entries(statuses)) {
|
for (const [sessionID, status] of Object.entries(mapped)) {
|
||||||
input.session.set("session_status", sessionID, reconcile(status))
|
input.session.set("session_status", sessionID, reconcile(status))
|
||||||
}
|
}
|
||||||
// Warm session info only after seeding statuses so a stalled session
|
// Warm session info only after seeding statuses so a stalled session
|
||||||
// fetch cannot park busy indicators behind it, mirroring how live
|
// fetch cannot park busy indicators behind it, mirroring how live
|
||||||
// session.status events apply first and resolve info in the background.
|
// session.status events apply first and resolve info in the background.
|
||||||
await Promise.all(
|
await Promise.all(
|
||||||
Object.keys(statuses).map((sessionID) => input.session!.resolve(sessionID).catch(() => undefined)),
|
Object.keys(mapped).map((sessionID) => input.session!.resolve(sessionID).catch(() => undefined)),
|
||||||
)
|
)
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
!seededProject &&
|
!seededProject &&
|
||||||
(() => retry(() => input.sdk.project.current()).then((x) => input.setStore("project", x.data!.id))),
|
(() =>
|
||||||
|
retry(() => input.backend.common.projects.current(location(input.directory))).then((project) =>
|
||||||
|
input.setStore("project", project.id),
|
||||||
|
)),
|
||||||
!seededPath &&
|
!seededPath &&
|
||||||
(() =>
|
(() =>
|
||||||
input.queryClient.ensureQueryData(loadPathQuery(input.scope, input.directory, input.sdk)).then((data) => {
|
input.queryClient
|
||||||
const next = projectID(data.directory ?? input.directory, input.global.project)
|
.ensureQueryData(loadPathQuery(input.scope, input.directory, Promise.resolve(input.backend)))
|
||||||
if (next) input.setStore("project", next)
|
.then((data) => {
|
||||||
})),
|
const next = projectID(data.directory ?? input.directory, input.global.project)
|
||||||
|
if (next) input.setStore("project", next)
|
||||||
|
})),
|
||||||
() =>
|
() =>
|
||||||
retry(() =>
|
retry(() =>
|
||||||
input.sdk.vcs.get().then((x) => {
|
(input.backend.capabilities.vcsInfo?.get(location(input.directory)) ?? Promise.resolve(undefined)).then(
|
||||||
const next = x.data ?? input.store.vcs
|
(data) => {
|
||||||
input.setStore("vcs", next)
|
const next = data ?? input.store.vcs
|
||||||
if (next) input.vcsCache.setStore("value", next)
|
input.setStore("vcs", next)
|
||||||
}),
|
if (next) input.vcsCache.setStore("value", next)
|
||||||
|
},
|
||||||
|
),
|
||||||
),
|
),
|
||||||
input.mcp && (() => retry(() => input.sdk.command.list().then((x) => input.setStore("command", x.data ?? [])))),
|
input.mcp &&
|
||||||
() => input.queryClient.fetchQuery(loadReferencesQuery(input.scope, input.directory, input.sdk)),
|
(() =>
|
||||||
|
retry(() => input.backend.common.commands.list(location(input.directory))).then((commands) =>
|
||||||
|
input.setStore("command", reconcile(commands)),
|
||||||
|
)),
|
||||||
|
() =>
|
||||||
|
input.queryClient.fetchQuery(loadReferencesQuery(input.scope, input.directory, Promise.resolve(input.backend))),
|
||||||
() =>
|
() =>
|
||||||
retry(() =>
|
retry(() =>
|
||||||
input.sdk.permission.list().then((x) => {
|
input.backend.common.permissions.pending(location(input.directory)).then((data) => {
|
||||||
const ids = (x.data ?? []).map((perm) => perm?.sessionID).filter((id): id is string => !!id)
|
const permissions = data.map(
|
||||||
const grouped = groupBySession(
|
(perm): AppPermissionRequest => perm,
|
||||||
(x.data ?? []).filter((perm): perm is PermissionRequest => !!perm?.id && !!perm.sessionID),
|
|
||||||
)
|
)
|
||||||
|
const ids = permissions.map((perm) => perm.sessionID)
|
||||||
|
const grouped = groupBySession(permissions)
|
||||||
const warm = input.session
|
const warm = input.session
|
||||||
? Promise.all(ids.map((sessionID) => input.session!.resolve(sessionID))).then(() => undefined)
|
? Promise.all(ids.map((sessionID) => input.session!.resolve(sessionID))).then(() => undefined)
|
||||||
: warmSessions({ ids, store: input.store, setStore: input.setStore, sdk: input.sdk })
|
: warmSessions({
|
||||||
|
ids,
|
||||||
|
store: input.store,
|
||||||
|
setStore: input.setStore,
|
||||||
|
backend: input.backend,
|
||||||
|
location: { directory: input.directory },
|
||||||
|
})
|
||||||
return warm.then(() =>
|
return warm.then(() =>
|
||||||
batch(() => {
|
batch(() => {
|
||||||
const current = input.session?.data.permission ?? input.store.permission
|
const current = input.session?.data.permission ?? input.store.permission
|
||||||
|
|
@ -323,12 +359,21 @@ export async function bootstrapDirectory(input: {
|
||||||
),
|
),
|
||||||
() =>
|
() =>
|
||||||
retry(() =>
|
retry(() =>
|
||||||
input.sdk.question.list().then((x) => {
|
input.backend.common.questions.pending(location(input.directory)).then((data) => {
|
||||||
const ids = (x.data ?? []).map((question) => question?.sessionID).filter((id): id is string => !!id)
|
const questions = data.map(
|
||||||
const grouped = groupBySession((x.data ?? []).filter((q): q is QuestionRequest => !!q?.id && !!q.sessionID))
|
(question): AppQuestionRequest => question,
|
||||||
|
)
|
||||||
|
const ids = questions.map((question) => question.sessionID)
|
||||||
|
const grouped = groupBySession(questions)
|
||||||
const warm = input.session
|
const warm = input.session
|
||||||
? Promise.all(ids.map((sessionID) => input.session!.resolve(sessionID))).then(() => undefined)
|
? Promise.all(ids.map((sessionID) => input.session!.resolve(sessionID))).then(() => undefined)
|
||||||
: warmSessions({ ids, store: input.store, setStore: input.setStore, sdk: input.sdk })
|
: warmSessions({
|
||||||
|
ids,
|
||||||
|
store: input.store,
|
||||||
|
setStore: input.setStore,
|
||||||
|
backend: input.backend,
|
||||||
|
location: { directory: input.directory },
|
||||||
|
})
|
||||||
return warm.then(() =>
|
return warm.then(() =>
|
||||||
batch(() => {
|
batch(() => {
|
||||||
const current = input.session?.data.question ?? input.store.question
|
const current = input.session?.data.question ?? input.store.question
|
||||||
|
|
@ -351,17 +396,25 @@ export async function bootstrapDirectory(input: {
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
() => Promise.resolve(input.loadSessions(input.directory)),
|
() => Promise.resolve(input.loadSessions(input.directory)),
|
||||||
input.mcp && (() => input.queryClient.fetchQuery(loadMcpQuery(input.scope, input.directory, input.sdk))),
|
input.mcp &&
|
||||||
input.mcp && (() => input.queryClient.fetchQuery(loadMcpResourcesQuery(input.scope, input.directory, input.sdk))),
|
(() =>
|
||||||
|
input.queryClient.fetchQuery(loadMcpQuery(input.scope, input.directory, Promise.resolve(input.backend)))),
|
||||||
|
input.mcp &&
|
||||||
|
(() =>
|
||||||
|
input.queryClient.fetchQuery(
|
||||||
|
loadMcpResourcesQuery(input.scope, input.directory, Promise.resolve(input.backend)),
|
||||||
|
)),
|
||||||
() =>
|
() =>
|
||||||
input.queryClient.fetchQuery(loadProvidersQuery(input.scope, input.directory, input.sdk)).catch((err) => {
|
input.queryClient
|
||||||
const project = getFilename(input.directory)
|
.fetchQuery(loadProvidersQuery(input.scope, input.directory, Promise.resolve(input.backend)))
|
||||||
showToast({
|
.catch((err) => {
|
||||||
variant: "error",
|
const project = getFilename(input.directory)
|
||||||
title: input.translate("toast.project.reloadFailed.title", { project }),
|
showToast({
|
||||||
description: formatServerError(err, input.translate),
|
variant: "error",
|
||||||
})
|
title: input.translate("toast.project.reloadFailed.title", { project }),
|
||||||
}),
|
description: formatServerError(err, input.translate),
|
||||||
|
})
|
||||||
|
}),
|
||||||
].filter(Boolean) as (() => Promise<any>)[]
|
].filter(Boolean) as (() => Promise<any>)[]
|
||||||
|
|
||||||
await waitForPaint()
|
await waitForPaint()
|
||||||
|
|
@ -379,3 +432,23 @@ export async function bootstrapDirectory(input: {
|
||||||
if (loading && slowErrs.length === 0) input.setStore("status", "complete")
|
if (loading && slowErrs.length === 0) input.setStore("status", "complete")
|
||||||
})()
|
})()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function location(directory: string | null) {
|
||||||
|
return directory === null ? undefined : { location: { directory } }
|
||||||
|
}
|
||||||
|
|
||||||
|
function emptyPath(directory: string | null): AppPathInfo {
|
||||||
|
return { home: "", directory: directory ?? "", state: "", config: "", worktree: "" }
|
||||||
|
}
|
||||||
|
|
||||||
|
function toProviderStore(input: ProviderCatalog): ProviderStore {
|
||||||
|
return {
|
||||||
|
all: input.providers,
|
||||||
|
connected: [...input.connected],
|
||||||
|
default: { ...input.defaults },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapActivity(input: Awaited<ReturnType<AppClient["common"]["sessions"]["activity"]>>) {
|
||||||
|
return input
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,7 @@
|
||||||
import { beforeAll, describe, expect, mock, test } from "bun:test"
|
import { beforeAll, describe, expect, mock, test } from "bun:test"
|
||||||
import { createRoot, getOwner, type Owner } from "solid-js"
|
import { createRoot, getOwner, type Owner } from "solid-js"
|
||||||
import { createStore } from "solid-js/store"
|
import { createStore } from "solid-js/store"
|
||||||
import type { NormalizedProviderListResponse } from "@opencode-ai/session-ui/context"
|
import type { ProviderStore, State } from "./types"
|
||||||
import type { State } from "./types"
|
|
||||||
import type { QueryOptionsApi } from "../server-sync"
|
import type { QueryOptionsApi } from "../server-sync"
|
||||||
import { ServerScope } from "@/utils/server-scope"
|
import { ServerScope } from "@/utils/server-scope"
|
||||||
|
|
||||||
|
|
@ -16,7 +15,7 @@ const persist: typeof import("@/utils/persist").persisted = (_target, store) =>
|
||||||
]
|
]
|
||||||
|
|
||||||
const child = () => createStore({} as State)
|
const child = () => createStore({} as State)
|
||||||
const provider = { all: new Map(), connected: [], default: {} } satisfies NormalizedProviderListResponse
|
const provider = { all: new Map(), connected: [], default: {} } satisfies ProviderStore
|
||||||
|
|
||||||
const queryOptionsApi = {
|
const queryOptionsApi = {
|
||||||
globalConfig: () => ({ queryKey: ["globalConfig"], queryFn: async () => ({}) }),
|
globalConfig: () => ({ queryKey: ["globalConfig"], queryFn: async () => ({}) }),
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import { createRoot, createSignal, getOwner, onCleanup, runWithOwner, type Owner } from "solid-js"
|
import { createRoot, createSignal, getOwner, onCleanup, runWithOwner, type Owner } from "solid-js"
|
||||||
import { createStore, type SetStoreFunction, type Store } from "solid-js/store"
|
import { createStore, type SetStoreFunction, type Store } from "solid-js/store"
|
||||||
import { Persist, persisted } from "@/utils/persist"
|
import { Persist, persisted } from "@/utils/persist"
|
||||||
import type { VcsInfo } from "@opencode-ai/sdk/v2/client"
|
import type { AppVcsInfo } from "../backend"
|
||||||
import {
|
import {
|
||||||
DIR_IDLE_TTL_MS,
|
DIR_IDLE_TTL_MS,
|
||||||
MAX_DIR_STORES,
|
MAX_DIR_STORES,
|
||||||
|
|
@ -10,6 +10,7 @@ import {
|
||||||
type IconCache,
|
type IconCache,
|
||||||
type MetaCache,
|
type MetaCache,
|
||||||
type ProjectMeta,
|
type ProjectMeta,
|
||||||
|
type ProviderStore,
|
||||||
type State,
|
type State,
|
||||||
type VcsCache,
|
type VcsCache,
|
||||||
} from "./types"
|
} from "./types"
|
||||||
|
|
@ -17,7 +18,6 @@ import { canDisposeDirectory, pickDirectoriesToEvict } from "./eviction"
|
||||||
import { useQuery } from "@tanstack/solid-query"
|
import { useQuery } from "@tanstack/solid-query"
|
||||||
import { QueryOptionsApi } from "../server-sync"
|
import { QueryOptionsApi } from "../server-sync"
|
||||||
import { directoryKey, type DirectoryKey } from "./utils"
|
import { directoryKey, type DirectoryKey } from "./utils"
|
||||||
import { NormalizedProviderListResponse } from "@opencode-ai/session-ui/context"
|
|
||||||
import type { ServerScope } from "@/utils/server-scope"
|
import type { ServerScope } from "@/utils/server-scope"
|
||||||
|
|
||||||
export function createChildStoreManager(input: {
|
export function createChildStoreManager(input: {
|
||||||
|
|
@ -32,7 +32,7 @@ export function createChildStoreManager(input: {
|
||||||
translate: (key: string, vars?: Record<string, string | number>) => string
|
translate: (key: string, vars?: Record<string, string | number>) => string
|
||||||
queryOptions: QueryOptionsApi
|
queryOptions: QueryOptionsApi
|
||||||
global: {
|
global: {
|
||||||
provider: NormalizedProviderListResponse
|
provider: ProviderStore
|
||||||
}
|
}
|
||||||
}) {
|
}) {
|
||||||
const children: Record<string, [Store<State>, SetStoreFunction<State>]> = {}
|
const children: Record<string, [Store<State>, SetStoreFunction<State>]> = {}
|
||||||
|
|
@ -152,7 +152,7 @@ export function createChildStoreManager(input: {
|
||||||
const vcs = runWithOwner(input.owner, () =>
|
const vcs = runWithOwner(input.owner, () =>
|
||||||
input.persist(
|
input.persist(
|
||||||
Persist.serverWorkspace(input.scope, directory, "vcs", ["vcs.v1"]),
|
Persist.serverWorkspace(input.scope, directory, "vcs", ["vcs.v1"]),
|
||||||
createStore({ value: undefined as VcsInfo | undefined }),
|
createStore({ value: undefined as AppVcsInfo | undefined }),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
if (!vcs) throw new Error(input.translate("error.childStore.persistedCacheCreateFailed"))
|
if (!vcs) throw new Error(input.translate("error.childStore.persistedCacheCreateFailed"))
|
||||||
|
|
@ -223,6 +223,7 @@ export function createChildStoreManager(input: {
|
||||||
return (type ?? "idle") !== "idle"
|
return (type ?? "idle") !== "idle"
|
||||||
},
|
},
|
||||||
session_diff: {},
|
session_diff: {},
|
||||||
|
todo: {},
|
||||||
permission: {},
|
permission: {},
|
||||||
question: {},
|
question: {},
|
||||||
get mcp_ready() {
|
get mcp_ready() {
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,12 @@
|
||||||
import { describe, expect, test } from "bun:test"
|
import { describe, expect, test } from "bun:test"
|
||||||
import type { Message, Part, PermissionRequest, Project, QuestionRequest, Session } from "@opencode-ai/sdk/v2/client"
|
import type {
|
||||||
|
AppMessage as Message,
|
||||||
|
AppPart as Part,
|
||||||
|
AppPermissionRequest as PermissionRequest,
|
||||||
|
AppProject as Project,
|
||||||
|
AppQuestionRequest as QuestionRequest,
|
||||||
|
AppSession as Session,
|
||||||
|
} from "../backend"
|
||||||
import { createStore } from "solid-js/store"
|
import { createStore } from "solid-js/store"
|
||||||
import type { State } from "./types"
|
import type { State } from "./types"
|
||||||
import { applyDirectoryEvent, applyGlobalEvent, cleanupDroppedSessionCaches } from "./event-reducer"
|
import { applyDirectoryEvent, applyGlobalEvent, cleanupDroppedSessionCaches } from "./event-reducer"
|
||||||
|
|
@ -39,7 +46,9 @@ const permissionRequest = (id: string, sessionID: string, title = id) =>
|
||||||
id,
|
id,
|
||||||
sessionID,
|
sessionID,
|
||||||
permission: title,
|
permission: title,
|
||||||
|
action: title,
|
||||||
patterns: ["*"],
|
patterns: ["*"],
|
||||||
|
resources: ["*"],
|
||||||
metadata: {},
|
metadata: {},
|
||||||
always: [],
|
always: [],
|
||||||
}) as PermissionRequest
|
}) as PermissionRequest
|
||||||
|
|
@ -523,9 +532,9 @@ describe("applyDirectoryEvent", () => {
|
||||||
})
|
})
|
||||||
|
|
||||||
test("updates vcs branch in store and cache", () => {
|
test("updates vcs branch in store and cache", () => {
|
||||||
const [store, setStore] = createStore(baseState({ vcs: { branch: "main", default_branch: "main" } }))
|
const [store, setStore] = createStore(baseState({ vcs: { branch: "main", defaultBranch: "main" } }))
|
||||||
const [cacheStore, setCacheStore] = createStore({
|
const [cacheStore, setCacheStore] = createStore({
|
||||||
value: { branch: "main", default_branch: "main" } as State["vcs"],
|
value: { branch: "main", defaultBranch: "main" } as State["vcs"],
|
||||||
})
|
})
|
||||||
|
|
||||||
applyDirectoryEvent({
|
applyDirectoryEvent({
|
||||||
|
|
@ -542,8 +551,8 @@ describe("applyDirectoryEvent", () => {
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
expect(store.vcs).toEqual({ branch: "feature/test", default_branch: "main" })
|
expect(store.vcs).toEqual({ branch: "feature/test", defaultBranch: "main" })
|
||||||
expect(cacheStore.value).toEqual({ branch: "feature/test", default_branch: "main" })
|
expect(cacheStore.value).toEqual({ branch: "feature/test", defaultBranch: "main" })
|
||||||
})
|
})
|
||||||
|
|
||||||
test("routes disposal and lsp events to side-effect handlers", () => {
|
test("routes disposal and lsp events to side-effect handlers", () => {
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,18 @@
|
||||||
import { Binary } from "@opencode-ai/core/util/binary"
|
import { Binary } from "@opencode-ai/core/util/binary"
|
||||||
import { produce, reconcile, type SetStoreFunction, type Store } from "solid-js/store"
|
import { produce, reconcile, type SetStoreFunction, type Store } from "solid-js/store"
|
||||||
import type {
|
import type {
|
||||||
Message,
|
AppEvent,
|
||||||
Part,
|
AppFileDiff,
|
||||||
PermissionRequest,
|
AppMessage,
|
||||||
Project,
|
AppPart,
|
||||||
QuestionRequest,
|
AppPermissionRequest,
|
||||||
Session,
|
AppProject,
|
||||||
SessionStatus,
|
AppQuestionRequest,
|
||||||
FileDiffInfo,
|
AppSession,
|
||||||
} from "@opencode-ai/sdk/v2/client"
|
AppTodo,
|
||||||
|
SessionActivity,
|
||||||
|
} from "../backend"
|
||||||
|
import { timelineMessage } from "../backend"
|
||||||
import type { State, VcsCache } from "./types"
|
import type { State, VcsCache } from "./types"
|
||||||
import { trimSessions } from "./session-trim"
|
import { trimSessions } from "./session-trim"
|
||||||
import { dropSessionCaches } from "./session-cache"
|
import { dropSessionCaches } from "./session-cache"
|
||||||
|
|
@ -31,19 +34,76 @@ const SESSION_CONTENT_EVENTS = new Set([
|
||||||
"question.rejected",
|
"question.rejected",
|
||||||
])
|
])
|
||||||
|
|
||||||
|
type LegacyEvent = { type: string; properties?: unknown }
|
||||||
|
type DirectoryLegacyEvent =
|
||||||
|
| { type: string; properties: unknown }
|
||||||
|
| { type: "server.instance.disposed"; properties?: undefined }
|
||||||
|
|
||||||
|
function legacyEvent(event: AppEvent): LegacyEvent | undefined {
|
||||||
|
if (event.type === "instance.disposed") return { type: "server.instance.disposed", properties: event }
|
||||||
|
if (event.type === "session.created" || event.type === "session.updated")
|
||||||
|
return { type: event.type, properties: { info: event.session } }
|
||||||
|
if (event.type === "session.deleted") return { type: event.type, properties: { sessionID: event.sessionID } }
|
||||||
|
if (event.type === "session.activity")
|
||||||
|
return { type: "session.status", properties: { sessionID: event.sessionID, status: event.activity } }
|
||||||
|
if (event.type === "session.diff" || event.type === "todo.updated") return { type: event.type, properties: event }
|
||||||
|
if (event.type === "timeline.updated") {
|
||||||
|
const message = timelineMessage(event.item)
|
||||||
|
return message ? { type: "message.updated", properties: { info: message } } : undefined
|
||||||
|
}
|
||||||
|
if (event.type === "timeline.content.updated") return undefined
|
||||||
|
if (event.type === "timeline.removed")
|
||||||
|
return {
|
||||||
|
type: "message.removed",
|
||||||
|
properties: { sessionID: event.sessionID, messageID: event.itemID },
|
||||||
|
}
|
||||||
|
if (event.type === "timeline.part.removed")
|
||||||
|
return {
|
||||||
|
type: "message.part.removed",
|
||||||
|
properties: { sessionID: event.sessionID, messageID: event.itemID, partID: event.contentID },
|
||||||
|
}
|
||||||
|
if (event.type === "timeline.delta")
|
||||||
|
return {
|
||||||
|
type: "message.part.delta",
|
||||||
|
properties: {
|
||||||
|
sessionID: event.sessionID,
|
||||||
|
messageID: event.itemID,
|
||||||
|
partID: event.contentID,
|
||||||
|
field: event.field,
|
||||||
|
delta: event.delta,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
if (event.type === "permission.requested") return { type: "permission.asked", properties: event.request }
|
||||||
|
if (event.type === "permission.replied" || event.type === "question.replied" || event.type === "question.rejected")
|
||||||
|
return { type: event.type, properties: event }
|
||||||
|
if (event.type === "question.requested") return { type: "question.asked", properties: event.request }
|
||||||
|
return { type: event.type, properties: event }
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeDirectoryEvent(event: AppEvent | DirectoryLegacyEvent) {
|
||||||
|
if ("properties" in event) return event
|
||||||
|
if (event.type === "server.instance.disposed") return event
|
||||||
|
return legacyEvent(event)
|
||||||
|
}
|
||||||
|
|
||||||
export function applyGlobalEvent(input: {
|
export function applyGlobalEvent(input: {
|
||||||
event: { type: string; properties?: unknown }
|
event: AppEvent | LegacyEvent
|
||||||
project: Project[]
|
project: AppProject[]
|
||||||
setGlobalProject: (next: Project[] | ((draft: Project[]) => Project[])) => void
|
setGlobalProject: (next: AppProject[] | ((draft: AppProject[]) => AppProject[])) => void
|
||||||
refresh: () => void
|
refresh: () => void
|
||||||
}) {
|
}) {
|
||||||
if (input.event.type === "global.disposed" || input.event.type === "server.connected") {
|
if (
|
||||||
|
input.event.type === "server.disposed" ||
|
||||||
|
input.event.type === "global.disposed" ||
|
||||||
|
input.event.type === "server.connected" ||
|
||||||
|
input.event.type === "provider.updated"
|
||||||
|
) {
|
||||||
input.refresh()
|
input.refresh()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (input.event.type !== "project.updated") return
|
if (input.event.type !== "project.updated") return
|
||||||
const properties = input.event.properties as Project
|
const properties = "project" in input.event ? input.event.project : (input.event.properties as AppProject)
|
||||||
const result = Binary.search(input.project, properties.id, (s) => s.id)
|
const result = Binary.search(input.project, properties.id, (s) => s.id)
|
||||||
if (result.found) {
|
if (result.found) {
|
||||||
input.setGlobalProject(
|
input.setGlobalProject(
|
||||||
|
|
@ -60,7 +120,11 @@ export function applyGlobalEvent(input: {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function cleanupSessionCaches(setStore: SetStoreFunction<State>, sessionID: string) {
|
function cleanupSessionCaches(
|
||||||
|
setStore: SetStoreFunction<State>,
|
||||||
|
sessionID: string,
|
||||||
|
setSessionTodo?: (sessionID: string, todos: AppTodo[] | undefined) => void,
|
||||||
|
) {
|
||||||
if (!sessionID) return
|
if (!sessionID) return
|
||||||
setStore(
|
setStore(
|
||||||
produce((draft) => {
|
produce((draft) => {
|
||||||
|
|
@ -69,7 +133,12 @@ function cleanupSessionCaches(setStore: SetStoreFunction<State>, sessionID: stri
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function cleanupDroppedSessionCaches(store: Store<State>, setStore: SetStoreFunction<State>, next: Session[]) {
|
export function cleanupDroppedSessionCaches(
|
||||||
|
store: Store<State>,
|
||||||
|
setStore: SetStoreFunction<State>,
|
||||||
|
next: AppSession[],
|
||||||
|
setSessionTodo?: (sessionID: string, todos: AppTodo[] | undefined) => void,
|
||||||
|
) {
|
||||||
const keep = new Set(next.map((item) => item.id))
|
const keep = new Set(next.map((item) => item.id))
|
||||||
const stale = [
|
const stale = [
|
||||||
...Object.keys(store.message),
|
...Object.keys(store.message),
|
||||||
|
|
@ -90,7 +159,7 @@ export function cleanupDroppedSessionCaches(store: Store<State>, setStore: SetSt
|
||||||
}
|
}
|
||||||
|
|
||||||
export function applyDirectoryEvent(input: {
|
export function applyDirectoryEvent(input: {
|
||||||
event: { type: string; properties?: unknown }
|
event: AppEvent | DirectoryLegacyEvent
|
||||||
store: Store<State>
|
store: Store<State>
|
||||||
setStore: SetStoreFunction<State>
|
setStore: SetStoreFunction<State>
|
||||||
push: (directory: string) => void
|
push: (directory: string) => void
|
||||||
|
|
@ -98,11 +167,17 @@ export function applyDirectoryEvent(input: {
|
||||||
loadLsp: () => void
|
loadLsp: () => void
|
||||||
loadReferences?: () => void
|
loadReferences?: () => void
|
||||||
vcsCache?: VcsCache
|
vcsCache?: VcsCache
|
||||||
|
setSessionTodo?: (sessionID: string, todos: AppTodo[] | undefined) => void
|
||||||
retainedLimit?: number
|
retainedLimit?: number
|
||||||
sessionContent?: boolean
|
sessionContent?: boolean
|
||||||
permission?: State["permission"]
|
permission?: State["permission"]
|
||||||
}) {
|
}) {
|
||||||
const event = input.event
|
if (input.event.type === "server.instance.disposed") {
|
||||||
|
input.push(input.directory)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const event = normalizeDirectoryEvent(input.event)
|
||||||
|
if (!event) return
|
||||||
if (input.sessionContent === false && SESSION_CONTENT_EVENTS.has(event.type)) return
|
if (input.sessionContent === false && SESSION_CONTENT_EVENTS.has(event.type)) return
|
||||||
const limit = Math.max(input.store.limit, input.retainedLimit ?? 0)
|
const limit = Math.max(input.store.limit, input.retainedLimit ?? 0)
|
||||||
switch (event.type) {
|
switch (event.type) {
|
||||||
|
|
@ -111,7 +186,7 @@ export function applyDirectoryEvent(input: {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
case "session.created": {
|
case "session.created": {
|
||||||
const info = (event.properties as { info: Session }).info
|
const info = (event.properties as { info: AppSession }).info
|
||||||
const result = Binary.search(input.store.session, info.id, (s) => s.id)
|
const result = Binary.search(input.store.session, info.id, (s) => s.id)
|
||||||
if (result.found) {
|
if (result.found) {
|
||||||
input.setStore("session", result.index, reconcile(info))
|
input.setStore("session", result.index, reconcile(info))
|
||||||
|
|
@ -126,7 +201,7 @@ export function applyDirectoryEvent(input: {
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
case "session.updated": {
|
case "session.updated": {
|
||||||
const info = (event.properties as { info: Session }).info
|
const info = (event.properties as { info: AppSession }).info
|
||||||
const result = Binary.search(input.store.session, info.id, (s) => s.id)
|
const result = Binary.search(input.store.session, info.id, (s) => s.id)
|
||||||
if (info.time.archived) {
|
if (info.time.archived) {
|
||||||
if (input.store.session[result.index]!.time.archived === info.time.archived) break
|
if (input.store.session[result.index]!.time.archived === info.time.archived) break
|
||||||
|
|
@ -155,8 +230,10 @@ export function applyDirectoryEvent(input: {
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
case "session.deleted": {
|
case "session.deleted": {
|
||||||
const info = (event.properties as { info: Session }).info
|
const properties = event.properties as { sessionID?: string; info?: { id: string } }
|
||||||
const result = Binary.search(input.store.session, info.id, (s) => s.id)
|
const sessionID = properties.sessionID ?? properties.info?.id ?? ""
|
||||||
|
const result = Binary.search(input.store.session, sessionID, (s) => s.id)
|
||||||
|
const info = result.found ? input.store.session[result.index] : undefined
|
||||||
if (result.found) {
|
if (result.found) {
|
||||||
input.setStore(
|
input.setStore(
|
||||||
"session",
|
"session",
|
||||||
|
|
@ -165,23 +242,29 @@ export function applyDirectoryEvent(input: {
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
cleanupSessionCaches(input.setStore, info.id)
|
cleanupSessionCaches(input.setStore, sessionID, input.setSessionTodo)
|
||||||
if (info.parentID) break
|
if (info?.parentID) break
|
||||||
input.setStore("sessionTotal", (value) => Math.max(0, value - 1))
|
input.setStore("sessionTotal", (value) => Math.max(0, value - 1))
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
case "session.diff": {
|
case "session.diff": {
|
||||||
const props = event.properties as { sessionID: string; diff: FileDiffInfo[] }
|
const props = event.properties as { sessionID: string; diff: AppFileDiff[] }
|
||||||
input.setStore("session_diff", props.sessionID, reconcile(list(props.diff), { key: "file" }))
|
input.setStore("session_diff", props.sessionID, reconcile(list(props.diff), { key: "file" }))
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
case "todo.updated": {
|
||||||
|
const props = event.properties as { sessionID: string; todos: AppTodo[] }
|
||||||
|
input.setStore("todo", props.sessionID, reconcile(props.todos, { key: "id" }))
|
||||||
|
input.setSessionTodo?.(props.sessionID, props.todos)
|
||||||
|
break
|
||||||
|
}
|
||||||
case "session.status": {
|
case "session.status": {
|
||||||
const props = event.properties as { sessionID: string; status: SessionStatus }
|
const props = event.properties as { sessionID: string; status: SessionActivity }
|
||||||
input.setStore("session_status", props.sessionID, reconcile(props.status))
|
input.setStore("session_status", props.sessionID, reconcile(props.status))
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
case "message.updated": {
|
case "message.updated": {
|
||||||
const info = clean((event.properties as { info: Message }).info)
|
const info = clean((event.properties as { info: AppMessage }).info)
|
||||||
const messages = input.store.message[info.sessionID]
|
const messages = input.store.message[info.sessionID]
|
||||||
if (!messages) {
|
if (!messages) {
|
||||||
input.setStore("message", info.sessionID, [info])
|
input.setStore("message", info.sessionID, [info])
|
||||||
|
|
@ -222,7 +305,7 @@ export function applyDirectoryEvent(input: {
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
case "message.part.updated": {
|
case "message.part.updated": {
|
||||||
const part = (event.properties as { part: Part }).part
|
const part = (event.properties as { part: AppPart }).part
|
||||||
if (SKIP_PARTS.has(part.type)) break
|
if (SKIP_PARTS.has(part.type)) break
|
||||||
input.setStore(
|
input.setStore(
|
||||||
produce((draft) => {
|
produce((draft) => {
|
||||||
|
|
@ -306,7 +389,7 @@ export function applyDirectoryEvent(input: {
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
case "permission.asked": {
|
case "permission.asked": {
|
||||||
const permission = event.properties as PermissionRequest
|
const permission = event.properties as AppPermissionRequest
|
||||||
const permissions = input.store.permission[permission.sessionID]
|
const permissions = input.store.permission[permission.sessionID]
|
||||||
if (!permissions) {
|
if (!permissions) {
|
||||||
input.setStore("permission", permission.sessionID, [permission])
|
input.setStore("permission", permission.sessionID, [permission])
|
||||||
|
|
@ -342,7 +425,7 @@ export function applyDirectoryEvent(input: {
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
case "question.asked": {
|
case "question.asked": {
|
||||||
const question = event.properties as QuestionRequest
|
const question = event.properties as AppQuestionRequest
|
||||||
const questions = input.store.question[question.sessionID]
|
const questions = input.store.question[question.sessionID]
|
||||||
if (!questions) {
|
if (!questions) {
|
||||||
input.setStore("question", question.sessionID, [question])
|
input.setStore("question", question.sessionID, [question])
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import type { McpStatus } from "@opencode-ai/sdk/v2/client"
|
import type { AppMcpStatus } from "../backend"
|
||||||
|
|
||||||
export async function toggleMcp(input: {
|
export async function toggleMcp(input: {
|
||||||
status: McpStatus["status"]
|
status: AppMcpStatus["status"]
|
||||||
connect: () => Promise<void>
|
connect: () => Promise<void>
|
||||||
disconnect: () => Promise<void>
|
disconnect: () => Promise<void>
|
||||||
authenticate: () => Promise<void>
|
authenticate: () => Promise<void>
|
||||||
|
|
@ -9,6 +9,7 @@ export async function toggleMcp(input: {
|
||||||
}) {
|
}) {
|
||||||
await {
|
await {
|
||||||
connected: input.disconnect,
|
connected: input.disconnect,
|
||||||
|
pending: async () => {},
|
||||||
needs_auth: input.authenticate,
|
needs_auth: input.authenticate,
|
||||||
disabled: input.connect,
|
disabled: input.connect,
|
||||||
failed: input.connect,
|
failed: input.connect,
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,12 @@
|
||||||
import { describe, expect, test } from "bun:test"
|
import { describe, expect, test } from "bun:test"
|
||||||
import type {
|
import type {
|
||||||
Message,
|
AppMessage as Message,
|
||||||
Part,
|
AppPart as Part,
|
||||||
PermissionRequest,
|
AppPermissionRequest as PermissionRequest,
|
||||||
QuestionRequest,
|
AppQuestionRequest as QuestionRequest,
|
||||||
SessionStatus,
|
AppFileDiff as SnapshotFileDiff,
|
||||||
FileDiffInfo,
|
SessionActivity as SessionStatus,
|
||||||
} from "@opencode-ai/sdk/v2/client"
|
} from "../backend"
|
||||||
import { dropSessionCaches, pickSessionCacheEvictions } from "./session-cache"
|
import { dropSessionCaches, pickSessionCacheEvictions } from "./session-cache"
|
||||||
|
|
||||||
const msg = (id: string, sessionID: string) =>
|
const msg = (id: string, sessionID: string) =>
|
||||||
|
|
@ -32,7 +32,8 @@ describe("app session cache", () => {
|
||||||
test("dropSessionCaches clears orphaned parts without message rows", () => {
|
test("dropSessionCaches clears orphaned parts without message rows", () => {
|
||||||
const store: {
|
const store: {
|
||||||
session_status: Record<string, SessionStatus | undefined>
|
session_status: Record<string, SessionStatus | undefined>
|
||||||
session_diff: Record<string, FileDiffInfo[] | undefined>
|
session_diff: Record<string, SnapshotFileDiff[] | undefined>
|
||||||
|
todo: Record<string, unknown>
|
||||||
message: Record<string, Message[] | undefined>
|
message: Record<string, Message[] | undefined>
|
||||||
part: Record<string, Part[] | undefined>
|
part: Record<string, Part[] | undefined>
|
||||||
permission: Record<string, PermissionRequest[] | undefined>
|
permission: Record<string, PermissionRequest[] | undefined>
|
||||||
|
|
@ -41,6 +42,7 @@ describe("app session cache", () => {
|
||||||
} = {
|
} = {
|
||||||
session_status: { ses_1: { type: "busy" } as SessionStatus },
|
session_status: { ses_1: { type: "busy" } as SessionStatus },
|
||||||
session_diff: { ses_1: [] },
|
session_diff: { ses_1: [] },
|
||||||
|
todo: {},
|
||||||
message: {},
|
message: {},
|
||||||
part: { msg_1: [part("prt_1", "ses_1", "msg_1")] },
|
part: { msg_1: [part("prt_1", "ses_1", "msg_1")] },
|
||||||
permission: { ses_1: [] as PermissionRequest[] },
|
permission: { ses_1: [] as PermissionRequest[] },
|
||||||
|
|
@ -63,7 +65,8 @@ describe("app session cache", () => {
|
||||||
const m = msg("msg_1", "ses_1")
|
const m = msg("msg_1", "ses_1")
|
||||||
const store: {
|
const store: {
|
||||||
session_status: Record<string, SessionStatus | undefined>
|
session_status: Record<string, SessionStatus | undefined>
|
||||||
session_diff: Record<string, FileDiffInfo[] | undefined>
|
session_diff: Record<string, SnapshotFileDiff[] | undefined>
|
||||||
|
todo: Record<string, unknown>
|
||||||
message: Record<string, Message[] | undefined>
|
message: Record<string, Message[] | undefined>
|
||||||
part: Record<string, Part[] | undefined>
|
part: Record<string, Part[] | undefined>
|
||||||
permission: Record<string, PermissionRequest[] | undefined>
|
permission: Record<string, PermissionRequest[] | undefined>
|
||||||
|
|
@ -72,6 +75,7 @@ describe("app session cache", () => {
|
||||||
} = {
|
} = {
|
||||||
session_status: {},
|
session_status: {},
|
||||||
session_diff: {},
|
session_diff: {},
|
||||||
|
todo: {},
|
||||||
message: { ses_1: [m] },
|
message: { ses_1: [m] },
|
||||||
part: { [m.id]: [part("prt_1", "ses_1", m.id)] },
|
part: { [m.id]: [part("prt_1", "ses_1", m.id)] },
|
||||||
permission: {},
|
permission: {},
|
||||||
|
|
@ -85,6 +89,21 @@ describe("app session cache", () => {
|
||||||
expect(store.part[m.id]).toBeUndefined()
|
expect(store.part[m.id]).toBeUndefined()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("dropSessionCaches accepts V2 stores without todo caches", () => {
|
||||||
|
const store = {
|
||||||
|
session_status: { ses_1: { type: "running" } },
|
||||||
|
session_diff: { ses_1: [] },
|
||||||
|
message: { ses_1: [] },
|
||||||
|
part: {},
|
||||||
|
permission: { ses_1: [] },
|
||||||
|
question: { ses_1: [] },
|
||||||
|
part_text_accum_delta: {},
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(() => dropSessionCaches(store, ["ses_1"])).not.toThrow()
|
||||||
|
expect(store.session_status.ses_1).toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
test("pickSessionCacheEvictions preserves requested sessions", () => {
|
test("pickSessionCacheEvictions preserves requested sessions", () => {
|
||||||
const seen = new Set(["ses_1", "ses_2", "ses_3"])
|
const seen = new Set(["ses_1", "ses_2", "ses_3"])
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,21 +1,13 @@
|
||||||
import type {
|
|
||||||
Message,
|
|
||||||
Part,
|
|
||||||
PermissionRequest,
|
|
||||||
QuestionRequest,
|
|
||||||
SessionStatus,
|
|
||||||
FileDiffInfo,
|
|
||||||
} from "@opencode-ai/sdk/v2/client"
|
|
||||||
|
|
||||||
export const SESSION_CACHE_LIMIT = 40
|
export const SESSION_CACHE_LIMIT = 40
|
||||||
|
|
||||||
type SessionCache = {
|
type SessionCache = {
|
||||||
session_status: Record<string, SessionStatus | undefined>
|
session_status: Record<string, unknown>
|
||||||
session_diff: Record<string, FileDiffInfo[] | undefined>
|
session_diff: Record<string, unknown>
|
||||||
message: Record<string, Message[] | undefined>
|
todo?: Record<string, unknown>
|
||||||
part: Record<string, Part[] | undefined>
|
message: Record<string, readonly { id: string }[] | undefined>
|
||||||
permission: Record<string, PermissionRequest[] | undefined>
|
part: Record<string, readonly { id: string; sessionID: string }[] | undefined>
|
||||||
question: Record<string, QuestionRequest[] | undefined>
|
permission: Record<string, unknown>
|
||||||
|
question: Record<string, unknown>
|
||||||
part_text_accum_delta: Record<string, string | undefined>
|
part_text_accum_delta: Record<string, string | undefined>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -35,6 +27,7 @@ export function dropSessionCaches(store: SessionCache, sessionIDs: Iterable<stri
|
||||||
for (const sessionID of stale) {
|
for (const sessionID of stale) {
|
||||||
delete store.message[sessionID]
|
delete store.message[sessionID]
|
||||||
delete store.session_diff[sessionID]
|
delete store.session_diff[sessionID]
|
||||||
|
delete store.todo?.[sessionID]
|
||||||
delete store.session_status[sessionID]
|
delete store.session_status[sessionID]
|
||||||
delete store.permission[sessionID]
|
delete store.permission[sessionID]
|
||||||
delete store.question[sessionID]
|
delete store.question[sessionID]
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { describe, expect, test } from "bun:test"
|
import { describe, expect, test } from "bun:test"
|
||||||
import type { PermissionRequest, Session } from "@opencode-ai/sdk/v2/client"
|
import type { AppSession as Session } from "../backend"
|
||||||
import { trimSessions } from "./session-trim"
|
import { trimSessions } from "./session-trim"
|
||||||
|
|
||||||
const session = (input: { id: string; parentID?: string; created: number; updated?: number; archived?: number }) =>
|
const session = (input: { id: string; parentID?: string; created: number; updated?: number; archived?: number }) =>
|
||||||
|
|
@ -43,7 +43,7 @@ describe("trimSessions", () => {
|
||||||
const result = trimSessions(list, {
|
const result = trimSessions(list, {
|
||||||
limit: 2,
|
limit: 2,
|
||||||
permission: {
|
permission: {
|
||||||
"child-kept-by-permission": [{ id: "perm-1" } as PermissionRequest],
|
"child-kept-by-permission": [{ id: "perm-1" }],
|
||||||
},
|
},
|
||||||
now,
|
now,
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -1,21 +1,21 @@
|
||||||
import type { PermissionRequest, Session } from "@opencode-ai/sdk/v2/client"
|
import type { AppSession } from "../backend"
|
||||||
import { cmp } from "./utils"
|
import { cmp } from "./utils"
|
||||||
import { SESSION_RECENT_LIMIT, SESSION_RECENT_WINDOW } from "./types"
|
import { SESSION_RECENT_LIMIT, SESSION_RECENT_WINDOW } from "./types"
|
||||||
|
|
||||||
export function sessionUpdatedAt(session: Session) {
|
export function sessionUpdatedAt(session: AppSession) {
|
||||||
return session.time.updated ?? session.time.created
|
return session.time.updated ?? session.time.created
|
||||||
}
|
}
|
||||||
|
|
||||||
export function compareSessionRecent(a: Session, b: Session) {
|
export function compareSessionRecent(a: AppSession, b: AppSession) {
|
||||||
const aUpdated = sessionUpdatedAt(a)
|
const aUpdated = sessionUpdatedAt(a)
|
||||||
const bUpdated = sessionUpdatedAt(b)
|
const bUpdated = sessionUpdatedAt(b)
|
||||||
if (aUpdated !== bUpdated) return bUpdated - aUpdated
|
if (aUpdated !== bUpdated) return bUpdated - aUpdated
|
||||||
return cmp(a.id, b.id)
|
return cmp(a.id, b.id)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function takeRecentSessions(sessions: Session[], limit: number, cutoff: number) {
|
export function takeRecentSessions(sessions: AppSession[], limit: number, cutoff: number) {
|
||||||
if (limit <= 0) return [] as Session[]
|
if (limit <= 0) return [] as AppSession[]
|
||||||
const selected: Session[] = []
|
const selected: AppSession[] = []
|
||||||
const seen = new Set<string>()
|
const seen = new Set<string>()
|
||||||
for (const session of sessions) {
|
for (const session of sessions) {
|
||||||
if (!session?.id) continue
|
if (!session?.id) continue
|
||||||
|
|
@ -31,8 +31,8 @@ export function takeRecentSessions(sessions: Session[], limit: number, cutoff: n
|
||||||
}
|
}
|
||||||
|
|
||||||
export function trimSessions(
|
export function trimSessions(
|
||||||
input: Session[],
|
input: AppSession[],
|
||||||
options: { limit: number; permission: Record<string, PermissionRequest[]>; now?: number },
|
options: { limit: number; permission: Record<string, readonly unknown[]>; now?: number },
|
||||||
) {
|
) {
|
||||||
const limit = Math.max(0, options.limit)
|
const limit = Math.max(0, options.limit)
|
||||||
const cutoff = (options.now ?? Date.now()) - SESSION_RECENT_WINDOW
|
const cutoff = (options.now ?? Date.now()) - SESSION_RECENT_WINDOW
|
||||||
|
|
|
||||||
|
|
@ -1,22 +1,23 @@
|
||||||
import type {
|
import type {
|
||||||
Agent,
|
AppMessage,
|
||||||
Command,
|
AppPart,
|
||||||
Config,
|
AppPermissionRequest,
|
||||||
LspStatus,
|
AppQuestionRequest,
|
||||||
McpResource,
|
AppSession,
|
||||||
McpStatus,
|
AppFileDiff,
|
||||||
Message,
|
AppTodo,
|
||||||
Part,
|
AppCommand,
|
||||||
Path,
|
AppConfig,
|
||||||
PermissionRequest,
|
AppAgent,
|
||||||
QuestionRequest,
|
AppLspStatus,
|
||||||
ReferenceInfo,
|
AppMcpResource,
|
||||||
Session,
|
AppMcpStatus,
|
||||||
SessionStatus,
|
AppPathInfo,
|
||||||
FileDiffInfo,
|
AppProvider,
|
||||||
VcsInfo,
|
AppReference,
|
||||||
} from "@opencode-ai/sdk/v2/client"
|
AppVcsInfo,
|
||||||
import { NormalizedProviderListResponse } from "@opencode-ai/session-ui/context"
|
SessionActivity,
|
||||||
|
} from "../backend"
|
||||||
import type { Accessor } from "solid-js"
|
import type { Accessor } from "solid-js"
|
||||||
import type { SetStoreFunction, Store } from "solid-js/store"
|
import type { SetStoreFunction, Store } from "solid-js/store"
|
||||||
|
|
||||||
|
|
@ -31,49 +32,62 @@ export type ProjectMeta = {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type StoreConfig = {
|
||||||
|
-readonly [Key in keyof AppConfig]: AppConfig[Key]
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ProviderStore = {
|
||||||
|
all: ReadonlyMap<string, AppProvider>
|
||||||
|
connected: readonly string[]
|
||||||
|
default: Readonly<Record<string, string>>
|
||||||
|
}
|
||||||
|
|
||||||
export type State = {
|
export type State = {
|
||||||
status: "loading" | "partial" | "complete"
|
status: "loading" | "partial" | "complete"
|
||||||
agent: Agent[]
|
agent: AppAgent[]
|
||||||
command: Command[]
|
command: readonly AppCommand[]
|
||||||
reference: ReferenceInfo[]
|
reference: readonly AppReference[]
|
||||||
project: string
|
project: string
|
||||||
projectMeta: ProjectMeta | undefined
|
projectMeta: ProjectMeta | undefined
|
||||||
icon: string | undefined
|
icon: string | undefined
|
||||||
provider_ready: boolean
|
provider_ready: boolean
|
||||||
provider: NormalizedProviderListResponse
|
provider: ProviderStore
|
||||||
config: Config
|
config: StoreConfig
|
||||||
path: Path
|
path: AppPathInfo
|
||||||
session: Session[]
|
session: AppSession[]
|
||||||
sessionTotal: number
|
sessionTotal: number
|
||||||
session_status: {
|
session_status: {
|
||||||
[sessionID: string]: SessionStatus
|
[sessionID: string]: SessionActivity
|
||||||
}
|
}
|
||||||
session_working(id: string): boolean
|
session_working(id: string): boolean
|
||||||
session_diff: {
|
session_diff: {
|
||||||
[sessionID: string]: FileDiffInfo[]
|
[sessionID: string]: AppFileDiff[]
|
||||||
|
}
|
||||||
|
todo: {
|
||||||
|
[sessionID: string]: AppTodo[]
|
||||||
}
|
}
|
||||||
permission: {
|
permission: {
|
||||||
[sessionID: string]: PermissionRequest[]
|
[sessionID: string]: AppPermissionRequest[]
|
||||||
}
|
}
|
||||||
question: {
|
question: {
|
||||||
[sessionID: string]: QuestionRequest[]
|
[sessionID: string]: AppQuestionRequest[]
|
||||||
}
|
}
|
||||||
mcp_ready: boolean
|
mcp_ready: boolean
|
||||||
mcp: {
|
mcp: {
|
||||||
[name: string]: McpStatus
|
[name: string]: AppMcpStatus
|
||||||
}
|
}
|
||||||
mcp_resource: {
|
mcp_resource: {
|
||||||
[key: string]: McpResource
|
[key: string]: AppMcpResource
|
||||||
}
|
}
|
||||||
lsp_ready: boolean
|
lsp_ready: boolean
|
||||||
lsp: LspStatus[]
|
lsp: readonly AppLspStatus[]
|
||||||
vcs: VcsInfo | undefined
|
vcs: AppVcsInfo | undefined
|
||||||
limit: number
|
limit: number
|
||||||
message: {
|
message: {
|
||||||
[sessionID: string]: Message[]
|
[sessionID: string]: AppMessage[]
|
||||||
}
|
}
|
||||||
part: {
|
part: {
|
||||||
[messageID: string]: Part[]
|
[messageID: string]: AppPart[]
|
||||||
}
|
}
|
||||||
part_text_accum_delta: {
|
part_text_accum_delta: {
|
||||||
[partID: string]: string
|
[partID: string]: string
|
||||||
|
|
@ -81,8 +95,8 @@ export type State = {
|
||||||
}
|
}
|
||||||
|
|
||||||
export type VcsCache = {
|
export type VcsCache = {
|
||||||
store: Store<{ value: VcsInfo | undefined }>
|
store: Store<{ value: AppVcsInfo | undefined }>
|
||||||
setStore: SetStoreFunction<{ value: VcsInfo | undefined }>
|
setStore: SetStoreFunction<{ value: AppVcsInfo | undefined }>
|
||||||
ready: Accessor<boolean>
|
ready: Accessor<boolean>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -127,11 +141,11 @@ export type DisposeCheck = {
|
||||||
export type RootLoadArgs = {
|
export type RootLoadArgs = {
|
||||||
directory: string
|
directory: string
|
||||||
limit: number
|
limit: number
|
||||||
list: (query: { directory: string; roots: true; limit?: number }) => Promise<{ data?: Session[] }>
|
list: (query: { directory: string; roots: true; limit?: number }) => Promise<{ data?: AppSession[] }>
|
||||||
}
|
}
|
||||||
|
|
||||||
export type RootLoadResult = {
|
export type RootLoadResult = {
|
||||||
data?: Session[]
|
data?: AppSession[]
|
||||||
limit: number
|
limit: number
|
||||||
limited: boolean
|
limited: boolean
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,38 +1,5 @@
|
||||||
import { describe, expect, test } from "bun:test"
|
import { describe, expect, test } from "bun:test"
|
||||||
import type { Agent } from "@opencode-ai/sdk/v2/client"
|
import { directoryKey } from "./utils"
|
||||||
import { directoryKey, normalizeAgentList } from "./utils"
|
|
||||||
|
|
||||||
const agent = (name = "build") =>
|
|
||||||
({
|
|
||||||
name,
|
|
||||||
mode: "primary",
|
|
||||||
permission: {},
|
|
||||||
options: {},
|
|
||||||
}) as Agent
|
|
||||||
|
|
||||||
describe("normalizeAgentList", () => {
|
|
||||||
test("keeps array payloads", () => {
|
|
||||||
expect(normalizeAgentList([agent("build"), agent("docs")])).toEqual([agent("build"), agent("docs")])
|
|
||||||
})
|
|
||||||
|
|
||||||
test("wraps a single agent payload", () => {
|
|
||||||
expect(normalizeAgentList(agent("docs"))).toEqual([agent("docs")])
|
|
||||||
})
|
|
||||||
|
|
||||||
test("extracts agents from keyed objects", () => {
|
|
||||||
expect(
|
|
||||||
normalizeAgentList({
|
|
||||||
build: agent("build"),
|
|
||||||
docs: agent("docs"),
|
|
||||||
}),
|
|
||||||
).toEqual([agent("build"), agent("docs")])
|
|
||||||
})
|
|
||||||
|
|
||||||
test("drops invalid payloads", () => {
|
|
||||||
expect(normalizeAgentList({ name: "AbortError" })).toEqual([])
|
|
||||||
expect(normalizeAgentList([{ name: "build" }, agent("docs")])).toEqual([agent("docs")])
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe("directoryKey", () => {
|
describe("directoryKey", () => {
|
||||||
test("normalizes slashes", () => {
|
test("normalizes slashes", () => {
|
||||||
|
|
|
||||||
|
|
@ -1,43 +1,8 @@
|
||||||
import type { Agent, Project, ProviderListResponse } from "@opencode-ai/sdk/v2/client"
|
import type { AppProject as Project } from "../backend"
|
||||||
import { NormalizedProviderListResponse } from "@opencode-ai/session-ui/context"
|
|
||||||
export { pathKey as directoryKey, type PathKey as DirectoryKey } from "@/utils/path-key"
|
export { pathKey as directoryKey, type PathKey as DirectoryKey } from "@/utils/path-key"
|
||||||
|
|
||||||
export const cmp = (a: string, b: string) => (a < b ? -1 : a > b ? 1 : 0)
|
export const cmp = (a: string, b: string) => (a < b ? -1 : a > b ? 1 : 0)
|
||||||
|
|
||||||
function isAgent(input: unknown): input is Agent {
|
|
||||||
if (!input || typeof input !== "object") return false
|
|
||||||
const item = input as { name?: unknown; mode?: unknown }
|
|
||||||
if (typeof item.name !== "string") return false
|
|
||||||
return item.mode === "subagent" || item.mode === "primary" || item.mode === "all"
|
|
||||||
}
|
|
||||||
|
|
||||||
export function normalizeAgentList(input: unknown): Agent[] {
|
|
||||||
if (Array.isArray(input)) return input.filter(isAgent)
|
|
||||||
if (isAgent(input)) return [input]
|
|
||||||
if (!input || typeof input !== "object") return []
|
|
||||||
return Object.values(input).filter(isAgent)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function normalizeProviderList(input: ProviderListResponse): NormalizedProviderListResponse {
|
|
||||||
return {
|
|
||||||
...input,
|
|
||||||
all: new Map(
|
|
||||||
input.all.map(
|
|
||||||
(provider) =>
|
|
||||||
[
|
|
||||||
provider.id,
|
|
||||||
{
|
|
||||||
...provider,
|
|
||||||
models: Object.fromEntries(
|
|
||||||
Object.entries(provider.models).filter(([, info]) => info.status !== "deprecated"),
|
|
||||||
),
|
|
||||||
},
|
|
||||||
] as const,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function sanitizeProject(project: Project) {
|
export function sanitizeProject(project: Project) {
|
||||||
if (!project.icon?.url && !project.icon?.override) return project
|
if (!project.icon?.url && !project.icon?.override) return project
|
||||||
return {
|
return {
|
||||||
|
|
|
||||||
|
|
@ -3,20 +3,25 @@ import { createEffect, createMemo, createRoot } from "solid-js"
|
||||||
import { createStore } from "solid-js/store"
|
import { createStore } from "solid-js/store"
|
||||||
import { createServerProjects, RECENTLY_CLOSED_DISPLAY_LIMIT, ServerConnection, useServer } from "./server"
|
import { createServerProjects, RECENTLY_CLOSED_DISPLAY_LIMIT, ServerConnection, useServer } from "./server"
|
||||||
import { pathKey } from "@/utils/path-key"
|
import { pathKey } from "@/utils/path-key"
|
||||||
import { useServerHealth } from "@/utils/server-health"
|
import { useCheckServerHealth, useServerHealth } from "@/utils/server-health"
|
||||||
import { createServerSdkContext } from "./server-sdk"
|
import { createServerSdkContext } from "./server-sdk"
|
||||||
import { createServerSyncContext } from "./server-sync"
|
import { createServerSyncContext } from "./server-sync"
|
||||||
import { getOwner } from "solid-js/web"
|
import { getOwner } from "solid-js/web"
|
||||||
import { QueryClient } from "@tanstack/solid-query"
|
import { QueryClient } from "@tanstack/solid-query"
|
||||||
import type { ServerScope } from "@/utils/server-scope"
|
import type { ServerScope } from "@/utils/server-scope"
|
||||||
|
import { usePlatform } from "./platform"
|
||||||
|
import { backendIdentity, createBackendForServer } from "./backend-client"
|
||||||
|
|
||||||
export const { use: useGlobal, provider: GlobalProvider } = createSimpleContext({
|
export const { use: useGlobal, provider: GlobalProvider } = createSimpleContext({
|
||||||
name: "Global",
|
name: "Global",
|
||||||
init: () => {
|
init: () => {
|
||||||
const server = useServer()
|
const server = useServer()
|
||||||
|
const platform = usePlatform()
|
||||||
|
const checkServerHealth = useCheckServerHealth()
|
||||||
const serverHealth = useServerHealth(
|
const serverHealth = useServerHealth(
|
||||||
() => server.list,
|
() => server.list,
|
||||||
() => true,
|
() => true,
|
||||||
|
checkServerHealth,
|
||||||
)
|
)
|
||||||
const [store, setStore] = createStore({
|
const [store, setStore] = createStore({
|
||||||
settings: {
|
settings: {
|
||||||
|
|
@ -37,7 +42,7 @@ export const { use: useGlobal, provider: GlobalProvider } = createSimpleContext(
|
||||||
|
|
||||||
const serverCtxs = new Map<
|
const serverCtxs = new Map<
|
||||||
ServerConnection.Key,
|
ServerConnection.Key,
|
||||||
{ dispose: () => void; serverCtx: ReturnType<typeof createServerCtx> }
|
{ dispose: () => void; identity: string; serverCtx: ReturnType<typeof createServerCtx> }
|
||||||
>()
|
>()
|
||||||
|
|
||||||
const owner = getOwner()
|
const owner = getOwner()
|
||||||
|
|
@ -45,10 +50,26 @@ export const { use: useGlobal, provider: GlobalProvider } = createSimpleContext(
|
||||||
const ensureServerCtx = (conn: ServerConnection.Any) => {
|
const ensureServerCtx = (conn: ServerConnection.Any) => {
|
||||||
const key = ServerConnection.key(conn)
|
const key = ServerConnection.key(conn)
|
||||||
const existing = serverCtxs.get(key)
|
const existing = serverCtxs.get(key)
|
||||||
if (existing) return existing.serverCtx
|
const identity = backendIdentity(conn)
|
||||||
|
if (existing?.identity === identity) return existing.serverCtx
|
||||||
|
if (existing) {
|
||||||
|
existing.dispose()
|
||||||
|
serverCtxs.delete(key)
|
||||||
|
}
|
||||||
const root = createRoot((dispose) => {
|
const root = createRoot((dispose) => {
|
||||||
const serverCtx = createServerCtx(conn, server.scope(key), server.projects.forServer(key))
|
const serverCtx = createServerCtx(
|
||||||
return { dispose, serverCtx }
|
conn,
|
||||||
|
server.scope(key),
|
||||||
|
server.projects.forServer(key),
|
||||||
|
createBackendForServer({
|
||||||
|
server: conn,
|
||||||
|
browserUrl: location.href,
|
||||||
|
fetch: platform.fetch ?? globalThis.fetch,
|
||||||
|
eventFetch: eventStreamFetch(conn.http.url, platform.fetch),
|
||||||
|
health: checkServerHealth(conn.http),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
return { dispose, identity, serverCtx }
|
||||||
}, owner as any)
|
}, owner as any)
|
||||||
serverCtxs.set(key, root)
|
serverCtxs.set(key, root)
|
||||||
return root.serverCtx
|
return root.serverCtx
|
||||||
|
|
@ -97,6 +118,7 @@ function createServerCtx(
|
||||||
conn: ServerConnection.Any,
|
conn: ServerConnection.Any,
|
||||||
scope: ServerScope,
|
scope: ServerScope,
|
||||||
projects: ReturnType<typeof createServerProjects>,
|
projects: ReturnType<typeof createServerProjects>,
|
||||||
|
backend: ReturnType<typeof createBackendForServer>,
|
||||||
) {
|
) {
|
||||||
const queryClient = new QueryClient({
|
const queryClient = new QueryClient({
|
||||||
defaultOptions: {
|
defaultOptions: {
|
||||||
|
|
@ -107,7 +129,7 @@ function createServerCtx(
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
const sdk = createServerSdkContext(conn, scope)
|
const sdk = createServerSdkContext(conn, scope, backend)
|
||||||
const sync = createServerSyncContext(sdk)
|
const sync = createServerSyncContext(sdk)
|
||||||
|
|
||||||
function enrich(project: { worktree: string; expanded: boolean }) {
|
function enrich(project: { worktree: string; expanded: boolean }) {
|
||||||
|
|
@ -141,6 +163,7 @@ function createServerCtx(
|
||||||
(conn?.type === "sidecar" && conn.variant === "base") || (conn?.type === "http" && isLocalHost(conn.http.url))
|
(conn?.type === "sidecar" && conn.variant === "base") || (conn?.type === "http" && isLocalHost(conn.http.url))
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
backend,
|
||||||
queryClient,
|
queryClient,
|
||||||
sdk,
|
sdk,
|
||||||
sync,
|
sync,
|
||||||
|
|
@ -153,6 +176,18 @@ function createServerCtx(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function eventStreamFetch(url: string, fetch?: typeof globalThis.fetch) {
|
||||||
|
if (!fetch) return globalThis.fetch
|
||||||
|
try {
|
||||||
|
const parsed = new URL(url)
|
||||||
|
const loopback = parsed.hostname === "localhost" || parsed.hostname === "127.0.0.1" || parsed.hostname === "::1"
|
||||||
|
if (parsed.protocol === "http:" && !loopback) return fetch
|
||||||
|
} catch {
|
||||||
|
return globalThis.fetch
|
||||||
|
}
|
||||||
|
return globalThis.fetch
|
||||||
|
}
|
||||||
|
|
||||||
export type ServerCtx = ReturnType<typeof createServerCtx>
|
export type ServerCtx = ReturnType<typeof createServerCtx>
|
||||||
|
|
||||||
function isLocalHost(url: string) {
|
function isLocalHost(url: string) {
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@ import { useServerSync } from "./server-sync"
|
||||||
import { useServerSDK } from "./server-sdk"
|
import { useServerSDK } from "./server-sdk"
|
||||||
import { RECENTLY_CLOSED_DISPLAY_LIMIT, ServerConnection, useServer } from "./server"
|
import { RECENTLY_CLOSED_DISPLAY_LIMIT, ServerConnection, useServer } from "./server"
|
||||||
import { usePlatform } from "./platform"
|
import { usePlatform } from "./platform"
|
||||||
import { Project } from "@opencode-ai/sdk/v2"
|
import type { AppProject } from "./backend"
|
||||||
import { Persist, persisted, removePersisted } from "@/utils/persist"
|
import { Persist, persisted, removePersisted } from "@/utils/persist"
|
||||||
import { pathKey } from "@/utils/path-key"
|
import { pathKey } from "@/utils/path-key"
|
||||||
import { decode64 } from "@/utils/base64"
|
import { decode64 } from "@/utils/base64"
|
||||||
|
|
@ -72,7 +72,7 @@ type TabHandoff = {
|
||||||
at: number
|
at: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export type LocalProject = Partial<Project> & { worktree: string; expanded: boolean }
|
export type LocalProject = Partial<AppProject> & { worktree: string; expanded: boolean }
|
||||||
export type HomeProjectSelection = { server: ServerConnection.Key; directory?: string }
|
export type HomeProjectSelection = { server: ServerConnection.Key; directory?: string }
|
||||||
|
|
||||||
export type ReviewDiffStyle = "unified" | "split"
|
export type ReviewDiffStyle = "unified" | "split"
|
||||||
|
|
@ -550,6 +550,7 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
|
||||||
setColors(worktree, color)
|
setColors(worktree, color)
|
||||||
}
|
}
|
||||||
if (!project.id) continue
|
if (!project.id) continue
|
||||||
|
const projectID = project.id
|
||||||
|
|
||||||
const requested = colorRequested.get(worktree)
|
const requested = colorRequested.get(worktree)
|
||||||
if (requested === color) continue
|
if (requested === color) continue
|
||||||
|
|
@ -561,7 +562,11 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
|
||||||
}
|
}
|
||||||
|
|
||||||
void serverSdk()
|
void serverSdk()
|
||||||
.client.project.update({ projectID: project.id, directory: worktree, icon: { color } })
|
.backend.then((client) => {
|
||||||
|
const editing = client.capabilities.projectEditing
|
||||||
|
if (!editing) throw new Error("Project editing is not supported by this server")
|
||||||
|
return editing.update({ projectID, location: { directory: worktree }, icon: { color } })
|
||||||
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
if (colorRequested.get(worktree) === color) colorRequested.delete(worktree)
|
if (colorRequested.get(worktree) === color) colorRequested.delete(worktree)
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -64,7 +64,15 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
||||||
const models = useModels()
|
const models = useModels()
|
||||||
|
|
||||||
const id = createMemo(() => params.id || undefined)
|
const id = createMemo(() => params.id || undefined)
|
||||||
const list = createMemo(() => sync().data.agent.filter((item) => item.mode !== "subagent" && !item.hidden))
|
const list = createMemo(() =>
|
||||||
|
sync()
|
||||||
|
.data.agent.filter((item) => item.mode !== "subagent" && !item.hidden)
|
||||||
|
.map((item) => ({
|
||||||
|
...item,
|
||||||
|
model: item.model && { providerID: item.model.providerID, modelID: item.model.id },
|
||||||
|
variant: item.model?.variant,
|
||||||
|
})),
|
||||||
|
)
|
||||||
const connected = createMemo(() => new Set(providers.connected().map((item) => item.id)))
|
const connected = createMemo(() => new Set(providers.connected().map((item) => item.id)))
|
||||||
|
|
||||||
const [saved, setSaved, , savedReady] = persisted(
|
const [saved, setSaved, , savedReady] = persisted(
|
||||||
|
|
|
||||||
|
|
@ -50,7 +50,7 @@ export const { use: useModels, provider: ModelsProvider } = createSimpleContext(
|
||||||
() =>
|
() =>
|
||||||
new Map(
|
new Map(
|
||||||
available().map((model) => {
|
available().map((model) => {
|
||||||
const parsed = DateTime.fromISO(model.release_date)
|
const parsed = DateTime.fromISO(model.releaseDate ?? "")
|
||||||
return [modelKey({ providerID: model.provider.id, modelID: model.id }), parsed] as const
|
return [modelKey({ providerID: model.provider.id, modelID: model.id }), parsed] as const
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
|
|
@ -75,7 +75,7 @@ export const { use: useModels, provider: ModelsProvider } = createSimpleContext(
|
||||||
values(),
|
values(),
|
||||||
(groups) =>
|
(groups) =>
|
||||||
groups.flatMap((g) => {
|
groups.flatMap((g) => {
|
||||||
const first = firstBy(g, [(x) => x.release_date, "desc"])
|
const first = firstBy(g, [(x) => x.releaseDate ?? "", "desc"])
|
||||||
return first ? [{ modelID: first.id, providerID: first.provider.id }] : []
|
return first ? [{ modelID: first.id, providerID: first.provider.id }] : []
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,6 @@ import { useLanguage } from "@/context/language"
|
||||||
import { useSettings } from "@/context/settings"
|
import { useSettings } from "@/context/settings"
|
||||||
import { base64Encode } from "@opencode-ai/core/util/encode"
|
import { base64Encode } from "@opencode-ai/core/util/encode"
|
||||||
import { decode64 } from "@/utils/base64"
|
import { decode64 } from "@/utils/base64"
|
||||||
import { EventSessionError } from "@opencode-ai/sdk/v2"
|
|
||||||
import { Persist, persisted } from "@/utils/persist"
|
import { Persist, persisted } from "@/utils/persist"
|
||||||
import { playSoundById } from "@/utils/sound"
|
import { playSoundById } from "@/utils/sound"
|
||||||
import { useGlobal } from "./global"
|
import { useGlobal } from "./global"
|
||||||
|
|
@ -32,7 +31,7 @@ type TurnCompleteNotification = NotificationBase & {
|
||||||
|
|
||||||
type ErrorNotification = NotificationBase & {
|
type ErrorNotification = NotificationBase & {
|
||||||
type: "error"
|
type: "error"
|
||||||
error: EventSessionError["properties"]["error"]
|
error: unknown
|
||||||
}
|
}
|
||||||
|
|
||||||
export type Notification = TurnCompleteNotification | ErrorNotification
|
export type Notification = TurnCompleteNotification | ErrorNotification
|
||||||
|
|
@ -325,8 +324,7 @@ function createServerNotificationState(input: {
|
||||||
return sessionID === activeSession
|
return sessionID === activeSession
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleSessionIdle = (directory: string, event: { properties: { sessionID?: string } }, time: number) => {
|
const handleSessionIdle = (directory: string, sessionID: string, time: number) => {
|
||||||
const sessionID = event.properties.sessionID
|
|
||||||
void lookup(directory, sessionID).then((session) => {
|
void lookup(directory, sessionID).then((session) => {
|
||||||
if (meta.disposed) return
|
if (meta.disposed) return
|
||||||
if (!session) return
|
if (!session) return
|
||||||
|
|
@ -353,10 +351,10 @@ function createServerNotificationState(input: {
|
||||||
|
|
||||||
const handleSessionError = (
|
const handleSessionError = (
|
||||||
directory: string,
|
directory: string,
|
||||||
event: { properties: { sessionID?: string; error?: EventSessionError["properties"]["error"] } },
|
event: { sessionID?: string; error?: unknown },
|
||||||
time: number,
|
time: number,
|
||||||
) => {
|
) => {
|
||||||
const sessionID = event.properties.sessionID
|
const sessionID = event.sessionID
|
||||||
void lookup(directory, sessionID).then((session) => {
|
void lookup(directory, sessionID).then((session) => {
|
||||||
if (meta.disposed) return
|
if (meta.disposed) return
|
||||||
if (session?.parentID) return
|
if (session?.parentID) return
|
||||||
|
|
@ -365,7 +363,7 @@ function createServerNotificationState(input: {
|
||||||
void playSoundById(settings.sounds.errors())
|
void playSoundById(settings.sounds.errors())
|
||||||
}
|
}
|
||||||
|
|
||||||
const error = "error" in event.properties ? event.properties.error : undefined
|
const error = event.error
|
||||||
append({
|
append({
|
||||||
directory,
|
directory,
|
||||||
time,
|
time,
|
||||||
|
|
@ -386,12 +384,13 @@ function createServerNotificationState(input: {
|
||||||
|
|
||||||
const unsub = serverSDK().event.listen((e) => {
|
const unsub = serverSDK().event.listen((e) => {
|
||||||
const event = e.details
|
const event = e.details
|
||||||
if (event.type !== "session.idle" && event.type !== "session.error") return
|
if (event.type !== "session.activity" && event.type !== "session.error") return
|
||||||
|
|
||||||
const directory = e.name
|
const directory = e.name
|
||||||
const time = Date.now()
|
const time = Date.now()
|
||||||
if (event.type === "session.idle") {
|
if (event.type === "session.activity") {
|
||||||
handleSessionIdle(directory, event, time)
|
if (event.activity.type !== "idle") return
|
||||||
|
handleSessionIdle(directory, event.sessionID, time)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
handleSessionError(directory, event, time)
|
handleSessionError(directory, event, time)
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { describe, expect, test } from "bun:test"
|
import { describe, expect, test } from "bun:test"
|
||||||
import type { PermissionRequest, Session } from "@opencode-ai/sdk/v2/client"
|
import type { AppPermissionRequest as PermissionRequest, AppSession as Session } from "./backend"
|
||||||
import { base64Encode } from "@opencode-ai/core/util/encode"
|
import { base64Encode } from "@opencode-ai/core/util/encode"
|
||||||
import { autoRespondsPermission, isDirectoryAutoAccepting } from "./permission-auto-respond"
|
import { autoRespondsPermission, isDirectoryAutoAccepting } from "./permission-auto-respond"
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
import { type Accessor, createEffect, createMemo, onCleanup } from "solid-js"
|
import { type Accessor, createEffect, createMemo, onCleanup } from "solid-js"
|
||||||
import { createStore, produce } from "solid-js/store"
|
import { createStore, produce } from "solid-js/store"
|
||||||
import { createSimpleContext } from "@opencode-ai/ui/context"
|
import { createSimpleContext } from "@opencode-ai/ui/context"
|
||||||
import type { PermissionRequest } from "@opencode-ai/sdk/v2/client"
|
|
||||||
import { Persist, persisted } from "@/utils/persist"
|
import { Persist, persisted } from "@/utils/persist"
|
||||||
import { useServerSDK } from "@/context/server-sdk"
|
import { useServerSDK } from "@/context/server-sdk"
|
||||||
import { useServerSync } from "./server-sync"
|
import { useServerSync } from "./server-sync"
|
||||||
|
|
@ -21,6 +20,8 @@ type PermissionRespondFn = (input: {
|
||||||
directory?: string
|
directory?: string
|
||||||
}) => void
|
}) => void
|
||||||
|
|
||||||
|
type PermissionRequest = { id: string; sessionID: string }
|
||||||
|
|
||||||
function isNonAllowRule(rule: unknown) {
|
function isNonAllowRule(rule: unknown) {
|
||||||
if (!rule) return false
|
if (!rule) return false
|
||||||
if (typeof rule === "string") return rule !== "allow"
|
if (typeof rule === "string") return rule !== "allow"
|
||||||
|
|
@ -119,8 +120,17 @@ export const { use: usePermission, provider: PermissionProvider } = createSimple
|
||||||
}
|
}
|
||||||
|
|
||||||
const respond: PermissionRespondFn = (input) => {
|
const respond: PermissionRespondFn = (input) => {
|
||||||
|
const directory = input.directory ?? props.directory?.() ?? decode64(params.dir)
|
||||||
|
if (!directory) return
|
||||||
serverSDK()
|
serverSDK()
|
||||||
.client.permission.respond(input)
|
.backend.then((client) =>
|
||||||
|
client.common.permissions.reply({
|
||||||
|
sessionID: input.sessionID,
|
||||||
|
requestID: input.permissionID,
|
||||||
|
reply: input.response,
|
||||||
|
location: { directory },
|
||||||
|
}),
|
||||||
|
)
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
responded.delete(input.permissionID)
|
responded.delete(input.permissionID)
|
||||||
})
|
})
|
||||||
|
|
@ -164,9 +174,9 @@ export const { use: usePermission, provider: PermissionProvider } = createSimple
|
||||||
|
|
||||||
const unsubscribe = serverSDK().event.listen((e) => {
|
const unsubscribe = serverSDK().event.listen((e) => {
|
||||||
const event = e.details
|
const event = e.details
|
||||||
if (event?.type !== "permission.asked") return
|
if (event?.type !== "permission.requested") return
|
||||||
|
|
||||||
const perm = event.properties
|
const perm = event.request
|
||||||
if (!shouldAutoRespond(perm, e.name)) return
|
if (!shouldAutoRespond(perm, e.name)) return
|
||||||
|
|
||||||
respondOnce(perm, e.name)
|
respondOnce(perm, e.name)
|
||||||
|
|
@ -182,10 +192,10 @@ export const { use: usePermission, provider: PermissionProvider } = createSimple
|
||||||
)
|
)
|
||||||
|
|
||||||
serverSDK()
|
serverSDK()
|
||||||
.client.permission.list({ directory })
|
.backend.then((client) => client.common.permissions.pending({ location: { directory } }))
|
||||||
.then((x) => {
|
.then((items) => {
|
||||||
if (!isAutoAcceptingDirectory(directory)) return
|
if (!isAutoAcceptingDirectory(directory)) return
|
||||||
for (const perm of x.data ?? []) {
|
for (const perm of items) {
|
||||||
if (!perm?.id) continue
|
if (!perm?.id) continue
|
||||||
if (!shouldAutoRespond(perm, directory)) continue
|
if (!shouldAutoRespond(perm, directory)) continue
|
||||||
respondOnce(perm, directory)
|
respondOnce(perm, directory)
|
||||||
|
|
@ -214,11 +224,11 @@ export const { use: usePermission, provider: PermissionProvider } = createSimple
|
||||||
)
|
)
|
||||||
|
|
||||||
serverSDK()
|
serverSDK()
|
||||||
.client.permission.list({ directory })
|
.backend.then((client) => client.common.permissions.pending({ location: { directory } }))
|
||||||
.then((x) => {
|
.then((items) => {
|
||||||
if (enableVersion.get(key) !== version) return
|
if (enableVersion.get(key) !== version) return
|
||||||
if (!isAutoAccepting(sessionID, directory)) return
|
if (!isAutoAccepting(sessionID, directory)) return
|
||||||
for (const perm of x.data ?? []) {
|
for (const perm of items) {
|
||||||
if (!perm?.id) continue
|
if (!perm?.id) continue
|
||||||
if (!shouldAutoRespond(perm, directory)) continue
|
if (!shouldAutoRespond(perm, directory)) continue
|
||||||
respondOnce(perm, directory)
|
respondOnce(perm, directory)
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { checksum } from "@opencode-ai/core/util/encode"
|
import { checksum } from "@opencode-ai/core/util/encode"
|
||||||
import type { FilePartSource } from "@opencode-ai/sdk/v2/client"
|
import type { AppFilePartSource as FilePartSource } from "./backend"
|
||||||
import { batch, createMemo, type Accessor } from "solid-js"
|
import { batch, createMemo, type Accessor } from "solid-js"
|
||||||
import { createStore, type SetStoreFunction } from "solid-js/store"
|
import { createStore, type SetStoreFunction } from "solid-js/store"
|
||||||
import type { FileSelection } from "@/context/file"
|
import type { FileSelection } from "@/context/file"
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { describe, expect, test } from "bun:test"
|
import { describe, expect, test } from "bun:test"
|
||||||
import { coalesceServerEvents, enqueueServerEvent, resumeStreamAfterPageShow } from "./server-sdk"
|
import { coalesceServerEvents, enqueueServerEvent, resumeStreamAfterPageShow } from "./server-sdk"
|
||||||
import type { Event } from "@opencode-ai/sdk/v2/client"
|
import type { AppEvent } from "./backend"
|
||||||
|
|
||||||
describe("resumeStreamAfterPageShow", () => {
|
describe("resumeStreamAfterPageShow", () => {
|
||||||
test("restarts a stream only after a back-forward cache restore", () => {
|
test("restarts a stream only after a back-forward cache restore", () => {
|
||||||
|
|
@ -18,34 +18,36 @@ describe("coalesceServerEvents", () => {
|
||||||
const delta = (value: string, field = "text", partID = "part") => ({
|
const delta = (value: string, field = "text", partID = "part") => ({
|
||||||
directory: "/repo",
|
directory: "/repo",
|
||||||
payload: {
|
payload: {
|
||||||
type: "message.part.delta",
|
type: "timeline.delta",
|
||||||
properties: { messageID: "msg", partID, field, delta: value },
|
sessionID: "ses",
|
||||||
} as Event,
|
itemID: "msg",
|
||||||
|
contentID: partID,
|
||||||
|
field,
|
||||||
|
delta: value,
|
||||||
|
} as AppEvent,
|
||||||
})
|
})
|
||||||
|
|
||||||
test("merges adjacent deltas for the same field", () => {
|
test("merges adjacent deltas for the same field", () => {
|
||||||
const first = delta("hello ")
|
const first = delta("hello ")
|
||||||
const second = delta("world")
|
const second = delta("world")
|
||||||
first.payload.id = "first"
|
|
||||||
second.payload.id = "second"
|
|
||||||
const result = coalesceServerEvents([first, second])
|
const result = coalesceServerEvents([first, second])
|
||||||
|
|
||||||
expect(result).toHaveLength(1)
|
expect(result).toHaveLength(1)
|
||||||
expect(result[0]?.payload).toMatchObject({ id: "second", properties: { delta: "hello world" } })
|
expect(result[0]?.payload).toMatchObject({ delta: "hello world" })
|
||||||
})
|
})
|
||||||
|
|
||||||
test("preserves event boundaries and distinct fields", () => {
|
test("preserves event boundaries and distinct fields", () => {
|
||||||
const status = {
|
const status = {
|
||||||
directory: "/repo",
|
directory: "/repo",
|
||||||
payload: { type: "session.status", properties: { sessionID: "ses", status: { type: "idle" } } } as Event,
|
payload: { type: "session.activity", sessionID: "ses", activity: { type: "idle" } } as AppEvent,
|
||||||
}
|
}
|
||||||
const result = coalesceServerEvents([delta("a"), delta("b", "metadata"), status, delta("c")])
|
const result = coalesceServerEvents([delta("a"), delta("b", "metadata"), status, delta("c")])
|
||||||
|
|
||||||
expect(result.map((event) => event.payload.type)).toEqual([
|
expect(result.map((event) => event.payload.type)).toEqual([
|
||||||
"message.part.delta",
|
"timeline.delta",
|
||||||
"message.part.delta",
|
"timeline.delta",
|
||||||
"session.status",
|
"session.activity",
|
||||||
"message.part.delta",
|
"timeline.delta",
|
||||||
])
|
])
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -53,104 +55,99 @@ describe("coalesceServerEvents", () => {
|
||||||
const first = delta("a")
|
const first = delta("a")
|
||||||
const other = delta("b", "text", "other")
|
const other = delta("b", "text", "other")
|
||||||
const last = delta("c")
|
const last = delta("c")
|
||||||
first.payload.id = "1"
|
|
||||||
other.payload.id = "2"
|
|
||||||
last.payload.id = "3"
|
|
||||||
|
|
||||||
const result = coalesceServerEvents([first, other, last])
|
const result = coalesceServerEvents([first, other, last])
|
||||||
|
|
||||||
expect(result.map((event) => event.payload.id)).toEqual(["1", "2", "3"])
|
expect(result.map((event) => event.payload.type)).toEqual(["timeline.delta", "timeline.delta", "timeline.delta"])
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe("enqueueServerEvent", () => {
|
describe("enqueueServerEvent", () => {
|
||||||
const partUpdated = (text: string) =>
|
const partUpdated = (text: string) =>
|
||||||
({
|
({
|
||||||
type: "message.part.updated",
|
type: "timeline.updated",
|
||||||
properties: {
|
item: {
|
||||||
|
type: "user",
|
||||||
|
id: "message",
|
||||||
sessionID: "session",
|
sessionID: "session",
|
||||||
part: { id: "part", sessionID: "session", messageID: "message", type: "text", text },
|
created: 1,
|
||||||
|
content: [{ id: "part", type: "text", text }],
|
||||||
},
|
},
|
||||||
}) as Event
|
}) as AppEvent
|
||||||
|
|
||||||
test("preserves part updates across message remove and re-add barriers", () => {
|
test("preserves part updates across message remove and re-add barriers", () => {
|
||||||
const events: Array<{ directory: string; payload: Event }> = []
|
const events: Array<{ directory: string; payload: AppEvent }> = []
|
||||||
const enqueue = (payload: Event) => enqueueServerEvent(events, { directory: "/repo", payload })
|
const enqueue = (payload: AppEvent) => enqueueServerEvent(events, { directory: "/repo", payload })
|
||||||
|
|
||||||
enqueue(partUpdated("old"))
|
enqueue(partUpdated("old"))
|
||||||
enqueue({ type: "message.removed", properties: { sessionID: "session", messageID: "message" } } as Event)
|
enqueue({ type: "timeline.removed", sessionID: "session", itemID: "message" })
|
||||||
enqueue({
|
enqueue({
|
||||||
type: "message.updated",
|
type: "timeline.updated",
|
||||||
properties: {
|
item: {
|
||||||
|
type: "user",
|
||||||
|
created: 1,
|
||||||
|
content: [],
|
||||||
sessionID: "session",
|
sessionID: "session",
|
||||||
info: {
|
id: "message",
|
||||||
id: "message",
|
|
||||||
sessionID: "session",
|
|
||||||
role: "user",
|
|
||||||
time: { created: 1 },
|
|
||||||
agent: "build",
|
|
||||||
model: { providerID: "provider", modelID: "model" },
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
} as Event)
|
})
|
||||||
enqueue(partUpdated("new"))
|
enqueue(partUpdated("new"))
|
||||||
|
|
||||||
expect(events.map((event) => event.payload.type)).toEqual([
|
expect(events.map((event) => event.payload.type)).toEqual([
|
||||||
"message.part.updated",
|
"timeline.updated",
|
||||||
"message.removed",
|
"timeline.removed",
|
||||||
"message.updated",
|
"timeline.updated",
|
||||||
"message.part.updated",
|
|
||||||
])
|
])
|
||||||
})
|
})
|
||||||
|
|
||||||
test("preserves deltas after a replacement snapshot", () => {
|
test("preserves deltas after a replacement snapshot", () => {
|
||||||
const events: Array<{ directory: string; payload: Event }> = []
|
const events: Array<{ directory: string; payload: AppEvent }> = []
|
||||||
const enqueue = (payload: Event) => enqueueServerEvent(events, { directory: "/repo", payload })
|
const enqueue = (payload: AppEvent) => enqueueServerEvent(events, { directory: "/repo", payload })
|
||||||
|
|
||||||
enqueue(partUpdated("a"))
|
enqueue(partUpdated("a"))
|
||||||
enqueue(partUpdated("ab"))
|
enqueue(partUpdated("ab"))
|
||||||
enqueue({
|
enqueue({
|
||||||
type: "message.part.delta",
|
type: "timeline.delta",
|
||||||
properties: { sessionID: "session", messageID: "message", partID: "part", field: "text", delta: "c" },
|
sessionID: "session",
|
||||||
} as Event)
|
itemID: "message",
|
||||||
|
contentID: "part",
|
||||||
|
field: "text",
|
||||||
|
delta: "c",
|
||||||
|
})
|
||||||
|
|
||||||
const result = coalesceServerEvents(events)
|
const result = coalesceServerEvents(events)
|
||||||
expect(result.map((event) => event.payload.type)).toEqual(["message.part.updated", "message.part.delta"])
|
expect(result.map((event) => event.payload.type)).toEqual(["timeline.updated", "timeline.delta"])
|
||||||
expect(result[0]?.payload).toMatchObject({ properties: { part: { text: "ab" } } })
|
expect(result[0]?.payload).toMatchObject({ item: { content: [{ text: "ab" }] } })
|
||||||
expect(result[1]?.payload).toMatchObject({ properties: { delta: "c" } })
|
expect(result[1]?.payload).toMatchObject({ delta: "c" })
|
||||||
})
|
})
|
||||||
|
|
||||||
test("preserves updates after session deletion", () => {
|
test("preserves updates after session deletion", () => {
|
||||||
const events: Array<{ directory: string; payload: Event }> = []
|
const events: Array<{ directory: string; payload: AppEvent }> = []
|
||||||
const enqueue = (payload: Event) => enqueueServerEvent(events, { directory: "/repo", payload })
|
const enqueue = (payload: AppEvent) => enqueueServerEvent(events, { directory: "/repo", payload })
|
||||||
|
|
||||||
enqueue(partUpdated("old"))
|
enqueue(partUpdated("old"))
|
||||||
enqueue({
|
enqueue({
|
||||||
id: "event-delete",
|
|
||||||
type: "session.deleted",
|
type: "session.deleted",
|
||||||
properties: { sessionID: "session" },
|
sessionID: "session",
|
||||||
} as Event)
|
})
|
||||||
enqueue(partUpdated("new"))
|
enqueue(partUpdated("new"))
|
||||||
|
|
||||||
expect(events.map((event) => event.payload.type)).toEqual([
|
expect(events.map((event) => event.payload.type)).toEqual([
|
||||||
"message.part.updated",
|
"timeline.updated",
|
||||||
"session.deleted",
|
"session.deleted",
|
||||||
"message.part.updated",
|
"timeline.updated",
|
||||||
])
|
])
|
||||||
})
|
})
|
||||||
|
|
||||||
test("does not coalesce edge-triggered session statuses", () => {
|
test("does not coalesce edge-triggered session statuses", () => {
|
||||||
const events: Array<{ directory: string; payload: Event }> = []
|
const events: Array<{ directory: string; payload: AppEvent }> = []
|
||||||
const enqueue = (status: "retry" | "busy") =>
|
const enqueue = (status: "retry" | "busy") =>
|
||||||
enqueueServerEvent(events, {
|
enqueueServerEvent(events, {
|
||||||
directory: "/repo",
|
directory: "/repo",
|
||||||
payload: {
|
payload: {
|
||||||
type: "session.status",
|
type: "session.activity",
|
||||||
properties: {
|
sessionID: "session",
|
||||||
sessionID: "session",
|
activity: status === "retry" ? { type: "retry", attempt: 1, message: "retry", next: 1 } : { type: "running" },
|
||||||
status: status === "retry" ? { type: "retry", attempt: 1, message: "retry", next: 1 } : { type: "busy" },
|
},
|
||||||
},
|
|
||||||
} as Event,
|
|
||||||
})
|
})
|
||||||
|
|
||||||
enqueue("retry")
|
enqueue("retry")
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,8 @@
|
||||||
import type { Event } from "@opencode-ai/sdk/v2/client"
|
import type { AppClient, AppEvent } from "./backend"
|
||||||
import { createSimpleContext } from "@opencode-ai/ui/context"
|
import { createSimpleContext } from "@opencode-ai/ui/context"
|
||||||
import { createGlobalEmitter } from "@solid-primitives/event-bus"
|
import { createGlobalEmitter } from "@solid-primitives/event-bus"
|
||||||
import { makeEventListener } from "@solid-primitives/event-listener"
|
import { makeEventListener } from "@solid-primitives/event-listener"
|
||||||
import { type Accessor, batch, createMemo, onCleanup, onMount } from "solid-js"
|
import { type Accessor, batch, createMemo, onCleanup, onMount } from "solid-js"
|
||||||
import { createSdkForServer } from "@/utils/server"
|
|
||||||
import { useLanguage } from "./language"
|
import { useLanguage } from "./language"
|
||||||
import { usePlatform } from "./platform"
|
import { usePlatform } from "./platform"
|
||||||
import { ServerConnection, useServer } from "./server"
|
import { ServerConnection, useServer } from "./server"
|
||||||
|
|
@ -15,14 +14,15 @@ const isAbortError = (error: unknown) =>
|
||||||
error !== null && typeof error === "object" && "name" in error && error.name === "AbortError"
|
error !== null && typeof error === "object" && "name" in error && error.name === "AbortError"
|
||||||
|
|
||||||
const isStreamClosed = (error: unknown, signal?: AbortSignal) => isAbortError(error) || signal?.aborted === true
|
const isStreamClosed = (error: unknown, signal?: AbortSignal) => isAbortError(error) || signal?.aborted === true
|
||||||
type QueuedServerEvent = { directory: string; payload: Event }
|
type QueuedServerEvent = { directory: string; payload: AppEvent }
|
||||||
|
|
||||||
const coalescedKey = (event: QueuedServerEvent) => {
|
const coalescedKey = (event: QueuedServerEvent) => {
|
||||||
if (event.payload.type === "lsp.updated") return `lsp.updated:${event.directory}`
|
if (event.payload.type === "lsp.updated") return `lsp.updated:${event.directory}`
|
||||||
if (event.payload.type === "message.part.updated") {
|
if (event.payload.type === "timeline.updated") {
|
||||||
const part = event.payload.properties.part
|
return `timeline.updated:${event.directory}:${event.payload.item.id}`
|
||||||
return `message.part.updated:${event.directory}:${part.messageID}:${part.id}`
|
|
||||||
}
|
}
|
||||||
|
if (event.payload.type === "timeline.content.updated")
|
||||||
|
return `timeline.content.updated:${event.directory}:${event.payload.itemID}:${event.payload.content.id}`
|
||||||
return undefined
|
return undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -40,23 +40,22 @@ export function enqueueServerEvent(queue: QueuedServerEvent[], event: QueuedServ
|
||||||
export function coalesceServerEvents(events: QueuedServerEvent[]) {
|
export function coalesceServerEvents(events: QueuedServerEvent[]) {
|
||||||
const output: QueuedServerEvent[] = []
|
const output: QueuedServerEvent[] = []
|
||||||
events.forEach((event) => {
|
events.forEach((event) => {
|
||||||
if (event.payload.type !== "message.part.delta") {
|
if (event.payload.type !== "timeline.delta") {
|
||||||
output.push(event)
|
output.push(event)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const props = event.payload.properties
|
|
||||||
const previous = output[output.length - 1]
|
const previous = output[output.length - 1]
|
||||||
if (
|
if (
|
||||||
!previous ||
|
!previous ||
|
||||||
previous.payload.type !== "message.part.delta" ||
|
previous.payload.type !== "timeline.delta" ||
|
||||||
previous.directory !== event.directory ||
|
previous.directory !== event.directory ||
|
||||||
previous.payload.properties.messageID !== props.messageID ||
|
previous.payload.itemID !== event.payload.itemID ||
|
||||||
previous.payload.properties.partID !== props.partID ||
|
previous.payload.contentID !== event.payload.contentID ||
|
||||||
previous.payload.properties.field !== props.field
|
previous.payload.field !== event.payload.field
|
||||||
) {
|
) {
|
||||||
output.push({
|
output.push({
|
||||||
directory: event.directory,
|
directory: event.directory,
|
||||||
payload: { ...event.payload, properties: { ...props } },
|
payload: { ...event.payload },
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
@ -64,7 +63,7 @@ export function coalesceServerEvents(events: QueuedServerEvent[]) {
|
||||||
directory: event.directory,
|
directory: event.directory,
|
||||||
payload: {
|
payload: {
|
||||||
...event.payload,
|
...event.payload,
|
||||||
properties: { ...props, delta: previous.payload.properties.delta + props.delta },
|
delta: previous.payload.delta + event.payload.delta,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
@ -76,7 +75,7 @@ export function resumeStreamAfterPageShow(event: PageTransitionEvent, start: ()
|
||||||
start()
|
start()
|
||||||
}
|
}
|
||||||
|
|
||||||
function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerScope) {
|
function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerScope, backend: Promise<AppClient>) {
|
||||||
const platform = usePlatform()
|
const platform = usePlatform()
|
||||||
const abort = new AbortController()
|
const abort = new AbortController()
|
||||||
|
|
||||||
|
|
@ -91,13 +90,8 @@ function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerS
|
||||||
}
|
}
|
||||||
})()
|
})()
|
||||||
|
|
||||||
const eventSdk = createSdkForServer({
|
|
||||||
signal: abort.signal,
|
|
||||||
fetch: eventFetch,
|
|
||||||
server: server.http,
|
|
||||||
})
|
|
||||||
const emitter = createGlobalEmitter<{
|
const emitter = createGlobalEmitter<{
|
||||||
[key: string]: Event
|
[key: string]: AppEvent
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
type Queued = QueuedServerEvent
|
type Queued = QueuedServerEvent
|
||||||
|
|
@ -174,29 +168,13 @@ function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerS
|
||||||
}
|
}
|
||||||
abort.signal.addEventListener("abort", onAbort)
|
abort.signal.addEventListener("abort", onAbort)
|
||||||
try {
|
try {
|
||||||
const events = await eventSdk.global.event({
|
|
||||||
signal: attempt.signal,
|
|
||||||
onSseError: (error) => {
|
|
||||||
if (isStreamClosed(error, attempt?.signal)) return
|
|
||||||
if (streamErrorLogged) return
|
|
||||||
streamErrorLogged = true
|
|
||||||
console.error("[global-sdk] event stream error", {
|
|
||||||
url: server.http.url,
|
|
||||||
fetch: eventFetch ? "platform" : "webview",
|
|
||||||
error,
|
|
||||||
})
|
|
||||||
},
|
|
||||||
})
|
|
||||||
let yielded = Date.now()
|
let yielded = Date.now()
|
||||||
resetHeartbeat()
|
resetHeartbeat()
|
||||||
for await (const event of events.stream) {
|
for await (const envelope of (await backend).common.events.subscribe({ signal: attempt.signal })) {
|
||||||
resetHeartbeat()
|
resetHeartbeat()
|
||||||
streamErrorLogged = false
|
streamErrorLogged = false
|
||||||
if (event.payload.type !== "sync") {
|
const directory = envelope.location?.directory ?? "global"
|
||||||
const directory = event.directory ?? "global"
|
if (enqueueServerEvent(queue, { directory, payload: envelope.event })) schedule()
|
||||||
const payload = event.payload as Event
|
|
||||||
if (enqueueServerEvent(queue, { directory, payload })) schedule()
|
|
||||||
}
|
|
||||||
|
|
||||||
if (Date.now() - yielded < STREAM_YIELD_MS) continue
|
if (Date.now() - yielded < STREAM_YIELD_MS) continue
|
||||||
yielded = Date.now()
|
yielded = Date.now()
|
||||||
|
|
@ -253,39 +231,30 @@ function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerS
|
||||||
flush()
|
flush()
|
||||||
})
|
})
|
||||||
|
|
||||||
const sdk = createSdkForServer({
|
|
||||||
server: server.http,
|
|
||||||
fetch: platform.fetch,
|
|
||||||
throwOnError: true,
|
|
||||||
})
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
server,
|
server,
|
||||||
scope,
|
scope,
|
||||||
url: server.http.url,
|
url: server.http.url,
|
||||||
client: sdk,
|
|
||||||
event: {
|
event: {
|
||||||
on: emitter.on.bind(emitter),
|
on: emitter.on.bind(emitter),
|
||||||
listen: emitter.listen.bind(emitter),
|
listen: emitter.listen.bind(emitter),
|
||||||
start,
|
start,
|
||||||
},
|
},
|
||||||
createClient(opts: Omit<Parameters<typeof createSdkForServer>[0], "server" | "fetch">) {
|
|
||||||
return createSdkForServer({
|
|
||||||
server: server.http,
|
|
||||||
fetch: platform.fetch,
|
|
||||||
...opts,
|
|
||||||
})
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
type ServerSDKBase = ReturnType<typeof createServerSdkContextBase>
|
type ServerSDKBase = ReturnType<typeof createServerSdkContextBase>
|
||||||
export type ServerSDK = ServerSDKBase & {
|
type ServerSDKWithBackend = ServerSDKBase & { backend: Promise<AppClient> }
|
||||||
|
export type ServerSDK = ServerSDKWithBackend & {
|
||||||
ensureDirSdkContext: (directory: string) => ReturnType<typeof createDirSdkContext>
|
ensureDirSdkContext: (directory: string) => ReturnType<typeof createDirSdkContext>
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createServerSdkContext(server: ServerConnection.Any, scope: ServerScope): ServerSDK {
|
export function createServerSdkContext(
|
||||||
const sdk = createServerSdkContextBase(server, scope)
|
server: ServerConnection.Any,
|
||||||
|
scope: ServerScope,
|
||||||
|
backend: Promise<AppClient>,
|
||||||
|
): ServerSDK {
|
||||||
|
const sdk = Object.assign(createServerSdkContextBase(server, scope, backend), { backend })
|
||||||
return Object.assign(sdk, {
|
return Object.assign(sdk, {
|
||||||
ensureDirSdkContext: createRefCountMap((dir) => createDirSdkContext(dir, sdk)),
|
ensureDirSdkContext: createRefCountMap((dir) => createDirSdkContext(dir, sdk)),
|
||||||
})
|
})
|
||||||
|
|
@ -309,15 +278,10 @@ export const { use: useServerSDK, provider: ServerSDKProvider } = createSimpleCo
|
||||||
})
|
})
|
||||||
|
|
||||||
type SDKEventMap = {
|
type SDKEventMap = {
|
||||||
[key in Event["type"]]: Extract<Event, { type: key }>
|
[key in AppEvent["type"]]: AppEvent
|
||||||
}
|
}
|
||||||
|
|
||||||
function createDirSdkContext(directory: string, serverSDK: ServerSDKBase) {
|
function createDirSdkContext(directory: string, serverSDK: ServerSDKWithBackend) {
|
||||||
const client = serverSDK.createClient({
|
|
||||||
directory,
|
|
||||||
throwOnError: true,
|
|
||||||
})
|
|
||||||
|
|
||||||
const emitter = createGlobalEmitter<SDKEventMap>()
|
const emitter = createGlobalEmitter<SDKEventMap>()
|
||||||
|
|
||||||
const unsub = serverSDK.event.on(directory, (event) => {
|
const unsub = serverSDK.event.on(directory, (event) => {
|
||||||
|
|
@ -328,13 +292,10 @@ function createDirSdkContext(directory: string, serverSDK: ServerSDKBase) {
|
||||||
return {
|
return {
|
||||||
scope: serverSDK.scope,
|
scope: serverSDK.scope,
|
||||||
directory,
|
directory,
|
||||||
client,
|
backend: serverSDK.backend,
|
||||||
event: emitter,
|
event: emitter,
|
||||||
get url() {
|
get url() {
|
||||||
return serverSDK.url
|
return serverSDK.url
|
||||||
},
|
},
|
||||||
createClient(opts: Parameters<typeof serverSDK.createClient>[0]) {
|
|
||||||
return serverSDK.createClient(opts)
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,51 @@
|
||||||
import { describe, expect, test } from "bun:test"
|
import { describe, expect, test } from "bun:test"
|
||||||
import type { retry } from "@opencode-ai/core/util/retry"
|
import type { retry } from "@opencode-ai/core/util/retry"
|
||||||
import type { Message, OpencodeClient, Part, Session } from "@opencode-ai/sdk/v2/client"
|
import type {
|
||||||
import { createServerSession } from "./server-session"
|
AppFileDiff,
|
||||||
|
AppMessage as Message,
|
||||||
|
AppPart as Part,
|
||||||
|
AppSession as Session,
|
||||||
|
AppTodo,
|
||||||
|
TimelineContent,
|
||||||
|
TimelineItem,
|
||||||
|
} from "./backend"
|
||||||
|
import { createAppClient } from "./backend.test-fixture"
|
||||||
|
import { createServerSession as createAppServerSession } from "./server-session"
|
||||||
|
|
||||||
|
type FixtureClient = {
|
||||||
|
session: {
|
||||||
|
get(input: unknown): Promise<{ data: Session }>
|
||||||
|
messages(input: unknown): MessageResponse | Promise<MessageResponse>
|
||||||
|
message?(input: unknown): SingleMessageResponse | Promise<SingleMessageResponse>
|
||||||
|
diff?(input: unknown): Promise<{ data: AppFileDiff[] }>
|
||||||
|
todo?(input: unknown): Promise<{ data: AppTodo[] }>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const createServerSession = (client: FixtureClient, options?: { retry?: typeof retry }) =>
|
||||||
|
createAppServerSession(
|
||||||
|
createAppClient({
|
||||||
|
version: "v1",
|
||||||
|
common: {
|
||||||
|
sessions: {
|
||||||
|
get: async (input) => (await client.session.get(input)).data,
|
||||||
|
history: async (input) => {
|
||||||
|
const result = await client.session.messages(input)
|
||||||
|
return {
|
||||||
|
items: result.data.map((item) => timelineItem(item.info, item.parts)),
|
||||||
|
older: result.response.headers.get("x-next-cursor") ?? undefined,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
message: async (input) => {
|
||||||
|
if (!client.session.message) throw new Error("Message fixture is not configured")
|
||||||
|
const result = await client.session.message(input)
|
||||||
|
return timelineItem(result.data.info, result.data.parts)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
options,
|
||||||
|
)
|
||||||
|
|
||||||
const session = (id: string, parentID?: string): Session => ({
|
const session = (id: string, parentID?: string): Session => ({
|
||||||
id,
|
id,
|
||||||
|
|
@ -67,6 +111,81 @@ const singleResponse = (info: Message, parts: Part[] = []): SingleMessageRespons
|
||||||
|
|
||||||
const deferredResponse = () => Promise.withResolvers<MessageResponse>()
|
const deferredResponse = () => Promise.withResolvers<MessageResponse>()
|
||||||
|
|
||||||
|
function timelineItem(info: Message, parts: Part[]): TimelineItem {
|
||||||
|
const content = parts.map(toTimelineContent)
|
||||||
|
if (info.role === "user")
|
||||||
|
return {
|
||||||
|
type: "user",
|
||||||
|
id: info.id,
|
||||||
|
sessionID: info.sessionID,
|
||||||
|
created: info.time.created,
|
||||||
|
content,
|
||||||
|
agent: info.agent,
|
||||||
|
model: { id: info.model.modelID, providerID: info.model.providerID, variant: info.model.variant },
|
||||||
|
format: info.format,
|
||||||
|
summary: info.summary,
|
||||||
|
system: info.system,
|
||||||
|
tools: info.tools,
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
type: "assistant",
|
||||||
|
id: info.id,
|
||||||
|
sessionID: info.sessionID,
|
||||||
|
parentID: info.parentID,
|
||||||
|
created: info.time.created,
|
||||||
|
completed: info.time.completed,
|
||||||
|
content,
|
||||||
|
agent: info.agent,
|
||||||
|
model: { id: info.modelID, providerID: info.providerID, variant: info.variant },
|
||||||
|
tokens: info.tokens,
|
||||||
|
error: info.error,
|
||||||
|
mode: info.mode,
|
||||||
|
path: info.path,
|
||||||
|
cost: info.cost,
|
||||||
|
structured: info.structured,
|
||||||
|
finish: info.finish,
|
||||||
|
summary: info.summary,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function toTimelineContent(part: Part): TimelineContent {
|
||||||
|
if (part.type === "agent")
|
||||||
|
return {
|
||||||
|
type: part.type,
|
||||||
|
id: part.id,
|
||||||
|
name: part.name,
|
||||||
|
source: part.source && { text: part.source.value, start: part.source.start, end: part.source.end },
|
||||||
|
}
|
||||||
|
if (part.type === "subtask")
|
||||||
|
return {
|
||||||
|
...part,
|
||||||
|
model: part.model && { id: part.model.modelID, providerID: part.model.providerID },
|
||||||
|
}
|
||||||
|
if (part.type !== "file") return { ...part }
|
||||||
|
return {
|
||||||
|
type: "file",
|
||||||
|
id: part.id,
|
||||||
|
uri: part.url,
|
||||||
|
name: part.filename,
|
||||||
|
mime: part.mime,
|
||||||
|
source:
|
||||||
|
part.source?.type === "resource"
|
||||||
|
? {
|
||||||
|
type: part.source.type,
|
||||||
|
clientName: part.source.clientName,
|
||||||
|
uri: part.source.uri,
|
||||||
|
text: { text: part.source.text.value, start: part.source.text.start, end: part.source.text.end },
|
||||||
|
}
|
||||||
|
: part.source && {
|
||||||
|
type: part.source.type,
|
||||||
|
path: part.source.path,
|
||||||
|
name: part.source.type === "symbol" ? part.source.name : undefined,
|
||||||
|
kind: part.source.type === "symbol" ? part.source.kind : undefined,
|
||||||
|
text: { text: part.source.text.value, start: part.source.text.start, end: part.source.text.end },
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function messageClient(...responses: Array<MessageResponse | Promise<MessageResponse>>) {
|
function messageClient(...responses: Array<MessageResponse | Promise<MessageResponse>>) {
|
||||||
let index = 0
|
let index = 0
|
||||||
const requests: unknown[] = []
|
const requests: unknown[] = []
|
||||||
|
|
@ -81,7 +200,7 @@ function messageClient(...responses: Array<MessageResponse | Promise<MessageResp
|
||||||
return responses[index++]
|
return responses[index++]
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
} as unknown as OpencodeClient
|
} as FixtureClient
|
||||||
return Object.assign(client, {
|
return Object.assign(client, {
|
||||||
requests,
|
requests,
|
||||||
requested(count: number) {
|
requested(count: number) {
|
||||||
|
|
@ -114,7 +233,7 @@ function rootMessageClient(
|
||||||
return roots[rootIndex++]
|
return roots[rootIndex++]
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
} as unknown as OpencodeClient
|
} as FixtureClient
|
||||||
return Object.assign(client, {
|
return Object.assign(client, {
|
||||||
requests,
|
requests,
|
||||||
rootRequests,
|
rootRequests,
|
||||||
|
|
@ -152,7 +271,7 @@ function setup(sessions: Record<string, Session>) {
|
||||||
},
|
},
|
||||||
diff: async () => ({ data: [] }),
|
diff: async () => ({ data: [] }),
|
||||||
},
|
},
|
||||||
} as unknown as OpencodeClient
|
} as FixtureClient
|
||||||
return { get, messages, store: createServerSession(client) }
|
return { get, messages, store: createServerSession(client) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -163,7 +282,10 @@ describe("server session", () => {
|
||||||
const result = await ctx.store.lineage.resolve("child")
|
const result = await ctx.store.lineage.resolve("child")
|
||||||
|
|
||||||
expect(result.root.id).toBe("root")
|
expect(result.root.id).toBe("root")
|
||||||
expect(ctx.get).toEqual([{ sessionID: "child" }, { sessionID: "root" }])
|
expect(ctx.get).toEqual([
|
||||||
|
{ sessionID: "child", location: undefined },
|
||||||
|
{ sessionID: "root", location: undefined },
|
||||||
|
])
|
||||||
expect(ctx.store.lineage.peek("child")).toEqual(result)
|
expect(ctx.store.lineage.peek("child")).toEqual(result)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -194,7 +316,9 @@ describe("server session", () => {
|
||||||
await store.sync("child")
|
await store.sync("child")
|
||||||
|
|
||||||
expect(client.requests).toEqual([{ sessionID: "child", limit: 2, before: undefined }])
|
expect(client.requests).toEqual([{ sessionID: "child", limit: 2, before: undefined }])
|
||||||
expect(client.rootRequests).toEqual([{ sessionID: "child", messageID: user.id }])
|
expect(client.rootRequests).toEqual([
|
||||||
|
{ sessionID: "child", messageID: user.id, location: { directory: "/repo" } },
|
||||||
|
])
|
||||||
expect(store.data.message.child).toEqual([user, ...assistants])
|
expect(store.data.message.child).toEqual([user, ...assistants])
|
||||||
expect(store.history.more("child")).toBe(true)
|
expect(store.history.more("child")).toBe(true)
|
||||||
})
|
})
|
||||||
|
|
@ -278,7 +402,9 @@ describe("server session", () => {
|
||||||
|
|
||||||
await store.sync("child", { force: true })
|
await store.sync("child", { force: true })
|
||||||
|
|
||||||
expect(client.rootRequests).toEqual([{ sessionID: "child", messageID: stale.id }])
|
expect(client.rootRequests).toEqual([
|
||||||
|
{ sessionID: "child", messageID: stale.id, location: { directory: "/repo" } },
|
||||||
|
])
|
||||||
expect(store.data.message.child).toEqual([fresh, assistant])
|
expect(store.data.message.child).toEqual([fresh, assistant])
|
||||||
expect(store.data.part[stale.id]).toEqual([freshPart])
|
expect(store.data.part[stale.id]).toEqual([freshPart])
|
||||||
})
|
})
|
||||||
|
|
@ -300,7 +426,9 @@ describe("server session", () => {
|
||||||
|
|
||||||
await store.sync("child", { force: true })
|
await store.sync("child", { force: true })
|
||||||
|
|
||||||
expect(client.rootRequests).toEqual([{ sessionID: "child", messageID: stale.id }])
|
expect(client.rootRequests).toEqual([
|
||||||
|
{ sessionID: "child", messageID: stale.id, location: { directory: "/repo" } },
|
||||||
|
])
|
||||||
expect(store.data.message.child).toEqual([fresh, assistant])
|
expect(store.data.message.child).toEqual([fresh, assistant])
|
||||||
expect(store.data.part[stale.id]).toEqual([refreshed, pending])
|
expect(store.data.part[stale.id]).toEqual([refreshed, pending])
|
||||||
})
|
})
|
||||||
|
|
@ -636,6 +764,21 @@ describe("server session", () => {
|
||||||
expect(store.data.part[message.id]).toEqual([fetched])
|
expect(store.data.part[message.id]).toEqual([fetched])
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("reconciles semantically identical native parts to optimistic IDs without duplicates", async () => {
|
||||||
|
const pending = deferredResponse()
|
||||||
|
const message = userMessage("message")
|
||||||
|
const optimistic = textPart(message.id, { id: "optimistic", text: "hello" })
|
||||||
|
const fetched = textPart(message.id, { id: "message:text", text: "hello" })
|
||||||
|
const store = createServerSession(messageClient(pending.promise))
|
||||||
|
const loading = store.sync("child")
|
||||||
|
|
||||||
|
store.optimistic.add({ sessionID: "child", message, parts: [optimistic] })
|
||||||
|
pending.resolve(response([{ info: message, parts: [fetched] }]))
|
||||||
|
await loading
|
||||||
|
|
||||||
|
expect(store.data.part[message.id]).toEqual([optimistic])
|
||||||
|
})
|
||||||
|
|
||||||
test("rolls back only unconfirmed optimistic parts", async () => {
|
test("rolls back only unconfirmed optimistic parts", async () => {
|
||||||
const pending = deferredResponse()
|
const pending = deferredResponse()
|
||||||
const message = userMessage("message")
|
const message = userMessage("message")
|
||||||
|
|
@ -1207,6 +1350,7 @@ describe("server session", () => {
|
||||||
|
|
||||||
await store.history.loadMore("child")
|
await store.history.loadMore("child")
|
||||||
|
|
||||||
|
guard.active = false
|
||||||
expect(store.data.message.child).toEqual([older, latest])
|
expect(store.data.message.child).toEqual([older, latest])
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -1429,4 +1573,28 @@ describe("server session", () => {
|
||||||
expect(ctx.store.data.message.active?.map((message) => message.id)).toEqual(["message"])
|
expect(ctx.store.data.message.active?.map((message) => message.id)).toEqual(["message"])
|
||||||
expect(ctx.store.data.session_status["session-0"]).toBeUndefined()
|
expect(ctx.store.data.session_status["session-0"]).toBeUndefined()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("force-resyncs pinned sessions after a stream reconnect", async () => {
|
||||||
|
const first = userMessage("first")
|
||||||
|
const second = userMessage("second", { time: { created: 2 } })
|
||||||
|
let messages = response([{ info: first, parts: [] }])
|
||||||
|
let requests = 0
|
||||||
|
const store = createServerSession({
|
||||||
|
session: {
|
||||||
|
get: async () => ({ data: session("child") }),
|
||||||
|
messages: async () => {
|
||||||
|
requests++
|
||||||
|
return messages
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
store.pin("child")
|
||||||
|
await store.sync("child")
|
||||||
|
messages = response([{ info: first, parts: [] }, { info: second, parts: [] }])
|
||||||
|
|
||||||
|
await store.resync()
|
||||||
|
|
||||||
|
expect(requests).toBe(2)
|
||||||
|
expect(store.data.message.child.map((message) => message.id)).toEqual(["first", "second"])
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,19 @@
|
||||||
import { Binary } from "@opencode-ai/core/util/binary"
|
import { Binary } from "@opencode-ai/core/util/binary"
|
||||||
import { retry } from "@opencode-ai/core/util/retry"
|
import { retry } from "@opencode-ai/core/util/retry"
|
||||||
import type {
|
import type {
|
||||||
Message,
|
AppClient,
|
||||||
OpencodeClient,
|
AppEvent,
|
||||||
Part,
|
AppFileDiff,
|
||||||
PermissionRequest,
|
AppMessage,
|
||||||
QuestionRequest,
|
AppPart,
|
||||||
Session,
|
AppPermissionRequest,
|
||||||
SessionStatus,
|
AppQuestionRequest,
|
||||||
FileDiffInfo,
|
AppSession,
|
||||||
} from "@opencode-ai/sdk/v2/client"
|
AppTodo,
|
||||||
|
LocationRef,
|
||||||
|
SessionActivity,
|
||||||
|
} from "./backend"
|
||||||
|
import { timelineMessage, timelineParts } from "./backend"
|
||||||
import { batch } from "solid-js"
|
import { batch } from "solid-js"
|
||||||
import { createStore, produce, reconcile } from "solid-js/store"
|
import { createStore, produce, reconcile } from "solid-js/store"
|
||||||
import { diffs as cleanDiffs, message as cleanMessage } from "@/utils/diffs"
|
import { diffs as cleanDiffs, message as cleanMessage } from "@/utils/diffs"
|
||||||
|
|
@ -18,7 +22,7 @@ import { rootSession } from "@/utils/session-route"
|
||||||
import { dropSessionCaches, pickSessionCacheEvictions, SESSION_CACHE_LIMIT } from "./global-sync/session-cache"
|
import { dropSessionCaches, pickSessionCacheEvictions, SESSION_CACHE_LIMIT } from "./global-sync/session-cache"
|
||||||
|
|
||||||
const cmp = (a: string, b: string) => (a < b ? -1 : a > b ? 1 : 0)
|
const cmp = (a: string, b: string) => (a < b ? -1 : a > b ? 1 : 0)
|
||||||
const cmpMessage = (a: Message, b: Message) => a.time.created - b.time.created || cmp(a.id, b.id)
|
const cmpMessage = (a: AppMessage, b: AppMessage) => a.time.created - b.time.created || cmp(a.id, b.id)
|
||||||
const SKIP_PARTS = new Set(["patch", "step-start", "step-finish"])
|
const SKIP_PARTS = new Set(["patch", "step-start", "step-finish"])
|
||||||
const initialMessagePageSize = 2
|
const initialMessagePageSize = 2
|
||||||
const historyMessagePageSize = 200
|
const historyMessagePageSize = 200
|
||||||
|
|
@ -26,15 +30,15 @@ const sessionInfoLimit = 2_048
|
||||||
const emptyIDs: ReadonlySet<string> = new Set()
|
const emptyIDs: ReadonlySet<string> = new Set()
|
||||||
|
|
||||||
type OptimisticItem = {
|
type OptimisticItem = {
|
||||||
message: Message
|
message: AppMessage
|
||||||
parts: Part[]
|
parts: AppPart[]
|
||||||
confirmedParts?: Part[]
|
confirmedParts?: AppPart[]
|
||||||
confirmedMessage?: boolean
|
confirmedMessage?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
type MessagePage = {
|
type MessagePage = {
|
||||||
session: Message[]
|
session: AppMessage[]
|
||||||
part: { id: string; part: Part[] }[]
|
part: { id: string; part: AppPart[] }[]
|
||||||
cursor?: string
|
cursor?: string
|
||||||
complete: boolean
|
complete: boolean
|
||||||
}
|
}
|
||||||
|
|
@ -59,22 +63,31 @@ type MessageLoadBaseline = Pick<
|
||||||
>
|
>
|
||||||
|
|
||||||
function mergeOptimisticPage(page: MessagePage, items: OptimisticItem[]) {
|
function mergeOptimisticPage(page: MessagePage, items: OptimisticItem[]) {
|
||||||
if (items.length === 0) return { ...page, observed: [] as { messageID: string; parts: Part[] }[] }
|
if (items.length === 0) return { ...page, observed: [] as { messageID: string; parts: AppPart[] }[] }
|
||||||
const session = [...page.session]
|
const session = [...page.session]
|
||||||
const part = new Map(page.part.map((item) => [item.id, item.part]))
|
const part = new Map(page.part.map((item) => [item.id, item.part]))
|
||||||
const observed: { messageID: string; parts: Part[] }[] = []
|
const observed: { messageID: string; parts: AppPart[] }[] = []
|
||||||
for (const item of items) {
|
for (const item of items) {
|
||||||
const result = Binary.search(session, item.message.id, (message) => message.id)
|
const result = Binary.search(session, item.message.id, (message) => message.id)
|
||||||
if (!result.found) session.splice(result.index, 0, item.message)
|
if (!result.found) session.splice(result.index, 0, item.message)
|
||||||
const current = part.get(item.message.id)
|
const current = part.get(item.message.id) ?? []
|
||||||
const confirmed = result.found
|
const matched = result.found
|
||||||
? item.parts.filter((part) => Binary.search(current ?? [], part.id, (value) => value.id).found)
|
? item.parts.flatMap((optimistic) => {
|
||||||
|
const fetched = current.find((value) => value.id === optimistic.id || samePromptPart(value, optimistic))
|
||||||
|
return fetched ? [{ optimistic, fetched }] : []
|
||||||
|
})
|
||||||
: []
|
: []
|
||||||
|
const confirmed = matched.map((value) => value.optimistic)
|
||||||
if (result.found) observed.push({ messageID: item.message.id, parts: confirmed })
|
if (result.found) observed.push({ messageID: item.message.id, parts: confirmed })
|
||||||
part.set(
|
part.set(
|
||||||
item.message.id,
|
item.message.id,
|
||||||
merge(
|
merge(
|
||||||
result.found ? (current ?? []) : merge(item.confirmedParts ?? [], current ?? []),
|
result.found
|
||||||
|
? current.map((value) => {
|
||||||
|
const match = matched.find((item) => item.fetched.id === value.id)
|
||||||
|
return match ? { ...value, id: match.optimistic.id } : value
|
||||||
|
})
|
||||||
|
: merge(item.confirmedParts ?? [], current),
|
||||||
item.parts.filter((part) => !confirmed.includes(part)),
|
item.parts.filter((part) => !confirmed.includes(part)),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
@ -87,6 +100,14 @@ function mergeOptimisticPage(page: MessagePage, items: OptimisticItem[]) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function samePromptPart(a: AppPart, b: AppPart) {
|
||||||
|
if (a.type !== b.type) return false
|
||||||
|
if (a.type === "text" && b.type === "text") return a.text === b.text
|
||||||
|
if (a.type === "file" && b.type === "file") return a.url === b.url && a.filename === b.filename && a.mime === b.mime
|
||||||
|
if (a.type === "agent" && b.type === "agent") return a.name === b.name
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
function runInflight(map: Map<string, Promise<void>>, key: string, task: () => Promise<void>) {
|
function runInflight(map: Map<string, Promise<void>>, key: string, task: () => Promise<void>) {
|
||||||
const pending = map.get(key)
|
const pending = map.get(key)
|
||||||
if (pending) return pending
|
if (pending) return pending
|
||||||
|
|
@ -134,21 +155,22 @@ function reconcileFetched<T extends { id: string }>(
|
||||||
return [...result.values()].sort((a, b) => cmp(a.id, b.id))
|
return [...result.values()].sort((a, b) => cmp(a.id, b.id))
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createServerSession(client: OpencodeClient, options?: { retry?: typeof retry }) {
|
export function createServerSession(backend: Promise<AppClient> | AppClient, options?: { retry?: typeof retry }) {
|
||||||
const [data, setData] = createStore({
|
const [data, setData] = createStore({
|
||||||
info: {} as Record<string, Session | undefined>,
|
info: {} as Record<string, AppSession | undefined>,
|
||||||
session_status: {} as Record<string, SessionStatus>,
|
session_status: {} as Record<string, SessionActivity>,
|
||||||
session_diff: {} as Record<string, FileDiffInfo[]>,
|
session_diff: {} as Record<string, AppFileDiff[]>,
|
||||||
permission: {} as Record<string, PermissionRequest[]>,
|
todo: {} as Record<string, AppTodo[]>,
|
||||||
question: {} as Record<string, QuestionRequest[]>,
|
permission: {} as Record<string, AppPermissionRequest[]>,
|
||||||
message: {} as Record<string, Message[]>,
|
question: {} as Record<string, AppQuestionRequest[]>,
|
||||||
part: {} as Record<string, Part[]>,
|
message: {} as Record<string, AppMessage[]>,
|
||||||
|
part: {} as Record<string, AppPart[]>,
|
||||||
part_text_accum_delta: {} as Record<string, string>,
|
part_text_accum_delta: {} as Record<string, string>,
|
||||||
session_working(id: string) {
|
session_working(id: string) {
|
||||||
return (this.session_status[id]?.type ?? "idle") !== "idle"
|
return (this.session_status[id]?.type ?? "idle") !== "idle"
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
const requests = new Map<string, Promise<Session>>()
|
const requests = new Map<string, Promise<AppSession>>()
|
||||||
const inflight = new Map<string, Promise<void>>()
|
const inflight = new Map<string, Promise<void>>()
|
||||||
const inflightDiff = new Map<string, Promise<void>>()
|
const inflightDiff = new Map<string, Promise<void>>()
|
||||||
const optimistic = new Map<string, Map<string, OptimisticItem>>()
|
const optimistic = new Map<string, Map<string, OptimisticItem>>()
|
||||||
|
|
@ -158,7 +180,7 @@ export function createServerSession(client: OpencodeClient, options?: { retry?:
|
||||||
const removedMessages = new Map<string, Set<string>>()
|
const removedMessages = new Map<string, Set<string>>()
|
||||||
const deltaBases = new Map<string, { base: string; sessionID: string }>()
|
const deltaBases = new Map<string, { base: string; sessionID: string }>()
|
||||||
const deleteMessageParts = (
|
const deleteMessageParts = (
|
||||||
cache: { part: Record<string, Part[] | undefined>; part_text_accum_delta: Record<string, string | undefined> },
|
cache: { part: Record<string, AppPart[] | undefined>; part_text_accum_delta: Record<string, string | undefined> },
|
||||||
messageID: string,
|
messageID: string,
|
||||||
) => {
|
) => {
|
||||||
for (const part of cache.part[messageID] ?? []) {
|
for (const part of cache.part[messageID] ?? []) {
|
||||||
|
|
@ -186,7 +208,15 @@ export function createServerSession(client: OpencodeClient, options?: { retry?:
|
||||||
at: {} as Record<string, number | undefined>,
|
at: {} as Record<string, number | undefined>,
|
||||||
})
|
})
|
||||||
|
|
||||||
const remember = (session: Session) => {
|
const locations = new Map<string, LocationRef>()
|
||||||
|
const location = (sessionID: string) => {
|
||||||
|
const session = data.info[sessionID]
|
||||||
|
return session?.location ?? locations.get(sessionID) ?? (session?.directory ? { directory: session.directory } : undefined)
|
||||||
|
}
|
||||||
|
|
||||||
|
const remember = (session: AppSession) => {
|
||||||
|
if (session.location) locations.set(session.id, session.location)
|
||||||
|
if (session.parentID && session.location) locations.set(session.parentID, session.location)
|
||||||
setData("info", session.id, reconcile(session))
|
setData("info", session.id, reconcile(session))
|
||||||
infoSeen.delete(session.id)
|
infoSeen.delete(session.id)
|
||||||
infoSeen.add(session.id)
|
infoSeen.add(session.id)
|
||||||
|
|
@ -236,11 +266,13 @@ export function createServerSession(client: OpencodeClient, options?: { retry?:
|
||||||
const pending = requests.get(sessionID)
|
const pending = requests.get(sessionID)
|
||||||
if (pending) return pending
|
if (pending) return pending
|
||||||
const active = generation(sessionID)
|
const active = generation(sessionID)
|
||||||
const request = client.session.get({ sessionID }).then((result) => {
|
const request = Promise.resolve(backend)
|
||||||
if (!result.data) throw sessionNotFoundError(sessionID)
|
.then((client) => client.common.sessions.get({ sessionID, location: location(sessionID) }))
|
||||||
if (generations.get(sessionID) !== active) return result.data
|
.then((result) => {
|
||||||
return remember(result.data)
|
if (!result) throw sessionNotFoundError(sessionID)
|
||||||
})
|
if (generations.get(sessionID) !== active) return result
|
||||||
|
return remember(result)
|
||||||
|
})
|
||||||
requests.set(sessionID, request)
|
requests.set(sessionID, request)
|
||||||
const cleanup = () => {
|
const cleanup = () => {
|
||||||
if (requests.get(sessionID) === request) requests.delete(sessionID)
|
if (requests.get(sessionID) === request) requests.delete(sessionID)
|
||||||
|
|
@ -297,7 +329,7 @@ export function createServerSession(client: OpencodeClient, options?: { retry?:
|
||||||
items.set(messageID, { ...item, parts, confirmedParts, confirmedMessage: true })
|
items.set(messageID, { ...item, parts, confirmedParts, confirmedMessage: true })
|
||||||
}
|
}
|
||||||
|
|
||||||
const confirmOptimisticPart = (sessionID: string, messageID: string, part: Part) => {
|
const confirmOptimisticPart = (sessionID: string, messageID: string, part: AppPart) => {
|
||||||
const items = optimistic.get(sessionID)
|
const items = optimistic.get(sessionID)
|
||||||
const item = items?.get(messageID)
|
const item = items?.get(messageID)
|
||||||
if (!items || !item) return
|
if (!items || !item) return
|
||||||
|
|
@ -314,7 +346,7 @@ export function createServerSession(client: OpencodeClient, options?: { retry?:
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const confirmOptimistic = (sessionID: string, messageID: string, confirmedParts: Part[]) => {
|
const confirmOptimistic = (sessionID: string, messageID: string, confirmedParts: AppPart[]) => {
|
||||||
const items = optimistic.get(sessionID)
|
const items = optimistic.get(sessionID)
|
||||||
const item = items?.get(messageID)
|
const item = items?.get(messageID)
|
||||||
if (!items || !item) return
|
if (!items || !item) return
|
||||||
|
|
@ -461,35 +493,38 @@ export function createServerSession(client: OpencodeClient, options?: { retry?:
|
||||||
)
|
)
|
||||||
|
|
||||||
const fetchMessages = async (sessionID: string, limit: number, before?: string, onAttempt?: () => void) => {
|
const fetchMessages = async (sessionID: string, limit: number, before?: string, onAttempt?: () => void) => {
|
||||||
const response = await (options?.retry ?? retry)(() => {
|
const response = await (options?.retry ?? retry)(async () => {
|
||||||
onAttempt?.()
|
onAttempt?.()
|
||||||
return client.session.messages({ sessionID, limit, before })
|
return (await backend).common.sessions.history({ sessionID, limit, cursor: before, location: location(sessionID) })
|
||||||
})
|
})
|
||||||
const items = (response.data ?? []).filter((item) => !!item?.info?.id)
|
const items = response.items
|
||||||
|
.map((item) => ({ message: timelineMessage(item), parts: timelineParts(item) }))
|
||||||
|
.filter((item): item is { message: AppMessage; parts: AppPart[] } => !!item.message)
|
||||||
return {
|
return {
|
||||||
session: items.map((item) => cleanMessage(item.info)).sort((a, b) => cmp(a.id, b.id)),
|
session: items.map((item) => cleanMessage(item.message)).sort((a, b) => cmp(a.id, b.id)),
|
||||||
part: items.map((item) => ({
|
part: items.map((item) => ({
|
||||||
id: item.info.id,
|
id: item.message.id,
|
||||||
part: item.parts.filter((part) => !!part?.id).sort((a, b) => cmp(a.id, b.id)),
|
part: item.parts.sort((a, b) => cmp(a.id, b.id)),
|
||||||
})),
|
})),
|
||||||
cursor: response.response.headers.get("x-next-cursor") ?? undefined,
|
cursor: response.older,
|
||||||
complete: !response.response.headers.get("x-next-cursor"),
|
complete: !response.older,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const fetchMessage = async (sessionID: string, messageID: string, onAttempt?: () => void) => {
|
const fetchMessage = async (sessionID: string, messageID: string, onAttempt?: () => void) => {
|
||||||
const response = await (options?.retry ?? retry)(() => {
|
const response = await (options?.retry ?? retry)(async () => {
|
||||||
onAttempt?.()
|
onAttempt?.()
|
||||||
return client.session.message({ sessionID, messageID })
|
return (await backend).common.sessions.message({ sessionID, messageID, location: location(sessionID) })
|
||||||
})
|
})
|
||||||
if (!response.data?.info?.id) throw new Error(`Message not found: ${messageID}`)
|
const message = timelineMessage(response)
|
||||||
|
if (!message) throw new Error(`Message not found: ${messageID}`)
|
||||||
return {
|
return {
|
||||||
message: cleanMessage(response.data.info),
|
message: cleanMessage(message),
|
||||||
parts: response.data.parts.filter((part) => !!part?.id).sort((a, b) => cmp(a.id, b.id)),
|
parts: timelineParts(response).sort((a, b) => cmp(a.id, b.id)),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const replaceMessages = (sessionID: string, messages: Message[]) => {
|
const replaceMessages = (sessionID: string, messages: AppMessage[]) => {
|
||||||
const messageIDs = new Set(messages.map((message) => message.id))
|
const messageIDs = new Set(messages.map((message) => message.id))
|
||||||
const dropped = (data.message[sessionID] ?? []).filter((message) => !messageIDs.has(message.id))
|
const dropped = (data.message[sessionID] ?? []).filter((message) => !messageIDs.has(message.id))
|
||||||
setData("message", sessionID, reconcile(messages, { key: "id" }))
|
setData("message", sessionID, reconcile(messages, { key: "id" }))
|
||||||
|
|
@ -559,7 +594,7 @@ export function createServerSession(client: OpencodeClient, options?: { retry?:
|
||||||
sessionID: string,
|
sessionID: string,
|
||||||
page: MessagePage,
|
page: MessagePage,
|
||||||
load: MessageLoadState | undefined,
|
load: MessageLoadState | undefined,
|
||||||
preserveUnfetched: boolean | ((message: Message) => boolean),
|
preserveUnfetched: boolean | ((message: AppMessage) => boolean),
|
||||||
cleanupOrphans: boolean,
|
cleanupOrphans: boolean,
|
||||||
) => {
|
) => {
|
||||||
const merged = mergeOptimisticPage(page, [...(optimistic.get(sessionID)?.values() ?? [])])
|
const merged = mergeOptimisticPage(page, [...(optimistic.get(sessionID)?.values() ?? [])])
|
||||||
|
|
@ -609,7 +644,7 @@ export function createServerSession(client: OpencodeClient, options?: { retry?:
|
||||||
let applied = false
|
let applied = false
|
||||||
try {
|
try {
|
||||||
const page = await fetchMessages(sessionID, limit, before, () => resetMessageLoad(sessionID, load))
|
const page = await fetchMessages(sessionID, limit, before, () => resetMessageLoad(sessionID, load))
|
||||||
const first = page.session.reduce<Message | undefined>(
|
const first = page.session.reduce<AppMessage | undefined>(
|
||||||
(oldest, message) => (!oldest || cmpMessage(message, oldest) < 0 ? message : oldest),
|
(oldest, message) => (!oldest || cmpMessage(message, oldest) < 0 ? message : oldest),
|
||||||
undefined,
|
undefined,
|
||||||
)
|
)
|
||||||
|
|
@ -630,7 +665,7 @@ export function createServerSession(client: OpencodeClient, options?: { retry?:
|
||||||
const parentIDs = [
|
const parentIDs = [
|
||||||
...new Set(
|
...new Set(
|
||||||
page.session.flatMap((message) =>
|
page.session.flatMap((message) =>
|
||||||
message.role === "assistant" && !users.has(message.parentID) ? [message.parentID] : [],
|
message.role === "assistant" && message.parentID && !users.has(message.parentID) ? [message.parentID] : [],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
]
|
]
|
||||||
|
|
@ -659,7 +694,7 @@ export function createServerSession(client: OpencodeClient, options?: { retry?:
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
const preserveUnfetched =
|
const preserveUnfetched =
|
||||||
mode === "prepend" || (!result.complete && (!first || ((message: Message) => cmpMessage(message, first) < 0)))
|
mode === "prepend" || (!result.complete && (!first || ((message: AppMessage) => cmpMessage(message, first) < 0)))
|
||||||
applyMessagePage(
|
applyMessagePage(
|
||||||
sessionID,
|
sessionID,
|
||||||
result,
|
result,
|
||||||
|
|
@ -682,7 +717,11 @@ export function createServerSession(client: OpencodeClient, options?: { retry?:
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const sync = (sessionID: string, options?: { force?: boolean; messageLimit?: number }) => {
|
const sync = (
|
||||||
|
sessionID: string,
|
||||||
|
options?: { force?: boolean; messageLimit?: number; location?: LocationRef },
|
||||||
|
) => {
|
||||||
|
if (options?.location) locations.set(sessionID, options.location)
|
||||||
touch(sessionID)
|
touch(sessionID)
|
||||||
return runInflight(inflight, sessionID, async () => {
|
return runInflight(inflight, sessionID, async () => {
|
||||||
const cached = data.message[sessionID] !== undefined && meta.limit[sessionID] !== undefined
|
const cached = data.message[sessionID] !== undefined && meta.limit[sessionID] !== undefined
|
||||||
|
|
@ -729,7 +768,7 @@ export function createServerSession(client: OpencodeClient, options?: { retry?:
|
||||||
return properties.part.sessionID
|
return properties.part.sessionID
|
||||||
}
|
}
|
||||||
|
|
||||||
const apply = (event: { type: string; properties?: unknown }) => {
|
const applyLegacy = (event: { type: string; properties?: unknown }) => {
|
||||||
const eventID = eventSessionID(event)
|
const eventID = eventSessionID(event)
|
||||||
if (eventID) {
|
if (eventID) {
|
||||||
touch(eventID)
|
touch(eventID)
|
||||||
|
|
@ -743,16 +782,16 @@ export function createServerSession(client: OpencodeClient, options?: { retry?:
|
||||||
}
|
}
|
||||||
switch (event.type) {
|
switch (event.type) {
|
||||||
case "session.created":
|
case "session.created":
|
||||||
remember((event.properties as { info: Session }).info)
|
remember((event.properties as { info: AppSession }).info)
|
||||||
return
|
return
|
||||||
case "session.updated": {
|
case "session.updated": {
|
||||||
const info = (event.properties as { info: Session }).info
|
const info = (event.properties as { info: AppSession }).info
|
||||||
remember(info)
|
remember(info)
|
||||||
if (info.time.archived) evict([info.id])
|
if (info.time.archived) evict([info.id])
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
case "session.deleted": {
|
case "session.deleted": {
|
||||||
const sessionID = (event.properties as { info: Session }).info.id
|
const sessionID = (event.properties as { sessionID: string }).sessionID
|
||||||
infoSeen.delete(sessionID)
|
infoSeen.delete(sessionID)
|
||||||
setData(
|
setData(
|
||||||
"info",
|
"info",
|
||||||
|
|
@ -762,17 +801,22 @@ export function createServerSession(client: OpencodeClient, options?: { retry?:
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
case "session.diff": {
|
case "session.diff": {
|
||||||
const props = event.properties as { sessionID: string; diff: FileDiffInfo[] }
|
const props = event.properties as { sessionID: string; diff: AppFileDiff[] }
|
||||||
setData("session_diff", props.sessionID, reconcile(cleanDiffs(props.diff), { key: "file" }))
|
setData("session_diff", props.sessionID, reconcile(cleanDiffs(props.diff), { key: "file" }))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
case "todo.updated": {
|
||||||
|
const props = event.properties as { sessionID: string; todos: AppTodo[] }
|
||||||
|
setData("todo", props.sessionID, reconcile(props.todos, { key: "id" }))
|
||||||
|
return
|
||||||
|
}
|
||||||
case "session.status": {
|
case "session.status": {
|
||||||
const props = event.properties as { sessionID: string; status: SessionStatus }
|
const props = event.properties as { sessionID: string; status: SessionActivity }
|
||||||
setData("session_status", props.sessionID, reconcile(props.status))
|
setData("session_status", props.sessionID, reconcile(props.status))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
case "message.updated": {
|
case "message.updated": {
|
||||||
const info = cleanMessage((event.properties as { info: Message }).info)
|
const info = cleanMessage((event.properties as { info: AppMessage }).info)
|
||||||
const load = messageLoads.get(info.sessionID)
|
const load = messageLoads.get(info.sessionID)
|
||||||
load?.touchedMessages.add(info.id)
|
load?.touchedMessages.add(info.id)
|
||||||
load?.removedMessages.delete(info.id)
|
load?.removedMessages.delete(info.id)
|
||||||
|
|
@ -832,7 +876,7 @@ export function createServerSession(client: OpencodeClient, options?: { retry?:
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
case "message.part.updated": {
|
case "message.part.updated": {
|
||||||
const part = (event.properties as { part: Part }).part
|
const part = (event.properties as { part: AppPart }).part
|
||||||
if (SKIP_PARTS.has(part.type)) return
|
if (SKIP_PARTS.has(part.type)) return
|
||||||
const messages = data.message[part.sessionID]
|
const messages = data.message[part.sessionID]
|
||||||
const load = messageLoads.get(part.sessionID)
|
const load = messageLoads.get(part.sessionID)
|
||||||
|
|
@ -971,7 +1015,7 @@ export function createServerSession(client: OpencodeClient, options?: { retry?:
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
case "permission.asked": {
|
case "permission.asked": {
|
||||||
const permission = event.properties as PermissionRequest
|
const permission = event.properties as AppPermissionRequest
|
||||||
const permissions = data.permission[permission.sessionID]
|
const permissions = data.permission[permission.sessionID]
|
||||||
if (!permissions) {
|
if (!permissions) {
|
||||||
setData("permission", permission.sessionID, [permission])
|
setData("permission", permission.sessionID, [permission])
|
||||||
|
|
@ -1001,7 +1045,7 @@ export function createServerSession(client: OpencodeClient, options?: { retry?:
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
case "question.asked": {
|
case "question.asked": {
|
||||||
const question = event.properties as QuestionRequest
|
const question = event.properties as AppQuestionRequest
|
||||||
const questions = data.question[question.sessionID]
|
const questions = data.question[question.sessionID]
|
||||||
if (!questions) {
|
if (!questions) {
|
||||||
setData("question", question.sessionID, [question])
|
setData("question", question.sessionID, [question])
|
||||||
|
|
@ -1033,6 +1077,118 @@ export function createServerSession(client: OpencodeClient, options?: { retry?:
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const apply = (event: AppEvent | { type: string; properties: unknown }, eventLocation?: LocationRef) => {
|
||||||
|
if ("properties" in event) {
|
||||||
|
applyLegacy(event)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const sessionID =
|
||||||
|
event.type === "session.created" || event.type === "session.updated"
|
||||||
|
? event.session.id
|
||||||
|
: "sessionID" in event && typeof event.sessionID === "string"
|
||||||
|
? event.sessionID
|
||||||
|
: event.type === "timeline.updated"
|
||||||
|
? event.item.sessionID
|
||||||
|
: undefined
|
||||||
|
if (sessionID && eventLocation) locations.set(sessionID, eventLocation)
|
||||||
|
if (event.type === "session.created" || event.type === "session.updated") {
|
||||||
|
applyLegacy({ type: event.type, properties: { info: event.session } })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (event.type === "session.deleted") {
|
||||||
|
applyLegacy({ type: event.type, properties: { sessionID: event.sessionID } })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (event.type === "session.moved") {
|
||||||
|
const current = data.info[event.sessionID]
|
||||||
|
if (!current) return
|
||||||
|
remember({
|
||||||
|
...current,
|
||||||
|
location: event.location,
|
||||||
|
directory: event.location.directory,
|
||||||
|
workspaceID: event.location.workspaceID,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (event.type === "session.revert") {
|
||||||
|
const current = data.info[event.sessionID]
|
||||||
|
if (current) remember({ ...current, revert: event.revert })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (event.type === "session.activity") {
|
||||||
|
applyLegacy({ type: "session.status", properties: { sessionID: event.sessionID, status: event.activity } })
|
||||||
|
if (event.item) apply({ type: "timeline.updated", item: event.item }, eventLocation)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (event.type === "session.diff" || event.type === "todo.updated") {
|
||||||
|
applyLegacy({ type: event.type, properties: event })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (event.type === "timeline.updated") {
|
||||||
|
const message = timelineMessage(event.item)
|
||||||
|
if (!message) return
|
||||||
|
applyLegacy({ type: "message.updated", properties: { info: message } })
|
||||||
|
timelineParts(event.item).forEach((part) =>
|
||||||
|
applyLegacy({ type: "message.part.updated", properties: { part } }),
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (event.type === "timeline.content.updated") {
|
||||||
|
const message = data.message[event.sessionID]?.find((item) => item.id === event.itemID)
|
||||||
|
if (!message) return
|
||||||
|
const part = timelineParts({
|
||||||
|
type: message.role,
|
||||||
|
id: message.id,
|
||||||
|
sessionID: message.sessionID,
|
||||||
|
created: message.time.created,
|
||||||
|
completed: message.role === "assistant" ? message.time.completed : undefined,
|
||||||
|
content: [event.content],
|
||||||
|
})[0]
|
||||||
|
if (part) applyLegacy({ type: "message.part.updated", properties: { part } })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (event.type === "timeline.removed") {
|
||||||
|
applyLegacy({
|
||||||
|
type: "message.removed",
|
||||||
|
properties: { sessionID: event.sessionID, messageID: event.itemID },
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (event.type === "timeline.part.removed") {
|
||||||
|
applyLegacy({
|
||||||
|
type: "message.part.removed",
|
||||||
|
properties: {
|
||||||
|
sessionID: event.sessionID,
|
||||||
|
messageID: event.itemID,
|
||||||
|
partID: event.contentID,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (event.type === "timeline.delta") {
|
||||||
|
applyLegacy({
|
||||||
|
type: "message.part.delta",
|
||||||
|
properties: {
|
||||||
|
sessionID: event.sessionID,
|
||||||
|
messageID: event.itemID,
|
||||||
|
partID: event.contentID,
|
||||||
|
field: event.field,
|
||||||
|
delta: event.delta,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (event.type === "permission.requested") {
|
||||||
|
applyLegacy({ type: "permission.asked", properties: event.request })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (event.type === "permission.replied" || event.type === "question.replied" || event.type === "question.rejected") {
|
||||||
|
applyLegacy({ type: event.type, properties: event })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (event.type === "question.requested") applyLegacy({ type: "question.asked", properties: event.request })
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
data,
|
data,
|
||||||
set: setData,
|
set: setData,
|
||||||
|
|
@ -1059,7 +1215,7 @@ export function createServerSession(client: OpencodeClient, options?: { retry?:
|
||||||
return Date.now() - (meta.at[sessionID] ?? 0) <= ttl
|
return Date.now() - (meta.at[sessionID] ?? 0) <= ttl
|
||||||
},
|
},
|
||||||
optimistic: {
|
optimistic: {
|
||||||
add(input: { sessionID: string; message: Message; parts: Part[] }) {
|
add(input: { sessionID: string; message: AppMessage; parts: AppPart[] }) {
|
||||||
const parts = input.parts
|
const parts = input.parts
|
||||||
.filter((part) => !!part?.id && !SKIP_PARTS.has(part.type))
|
.filter((part) => !!part?.id && !SKIP_PARTS.has(part.type))
|
||||||
.sort((a, b) => cmp(a.id, b.id))
|
.sort((a, b) => cmp(a.id, b.id))
|
||||||
|
|
@ -1122,9 +1278,14 @@ export function createServerSession(client: OpencodeClient, options?: { retry?:
|
||||||
if (data.session_diff[sessionID] !== undefined && !options?.force) return Promise.resolve()
|
if (data.session_diff[sessionID] !== undefined && !options?.force) return Promise.resolve()
|
||||||
return runInflight(inflightDiff, sessionID, () => {
|
return runInflight(inflightDiff, sessionID, () => {
|
||||||
const active = generation(sessionID)
|
const active = generation(sessionID)
|
||||||
return retry(() => client.session.diff({ sessionID })).then((result) => {
|
return retry(async () => {
|
||||||
|
const client = await backend
|
||||||
|
const capability = client.capabilities.sessionExtrasV1 ?? client.capabilities.sessionExtrasV2
|
||||||
|
if (!capability?.diff) return []
|
||||||
|
return capability.diff({ sessionID, location: location(sessionID) })
|
||||||
|
}).then((result) => {
|
||||||
if (generations.get(sessionID) !== active) return
|
if (generations.get(sessionID) !== active) return
|
||||||
setData("session_diff", sessionID, reconcile(cleanDiffs(result.data), { key: "file" }))
|
setData("session_diff", sessionID, reconcile(cleanDiffs(result), { key: "file" }))
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
|
|
@ -1155,6 +1316,14 @@ export function createServerSession(client: OpencodeClient, options?: { retry?:
|
||||||
if (!count || count === 1) pinned.delete(sessionID)
|
if (!count || count === 1) pinned.delete(sessionID)
|
||||||
if (count && count > 1) pinned.set(sessionID, count - 1)
|
if (count && count > 1) pinned.set(sessionID, count - 1)
|
||||||
},
|
},
|
||||||
|
async resync() {
|
||||||
|
const sessionIDs = new Set([
|
||||||
|
...pinned.keys(),
|
||||||
|
...optimistic.keys(),
|
||||||
|
...Object.entries(data.session_status).flatMap(([sessionID, status]) => status.type === "idle" ? [] : [sessionID]),
|
||||||
|
])
|
||||||
|
await Promise.all([...sessionIDs].map((sessionID) => sync(sessionID, { force: true })))
|
||||||
|
},
|
||||||
apply,
|
apply,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,11 @@
|
||||||
import type {
|
import type {
|
||||||
Config,
|
AppClient,
|
||||||
McpResource,
|
AppConfig,
|
||||||
OpencodeClient,
|
AppMcpResource,
|
||||||
Path,
|
AppPathInfo,
|
||||||
Project,
|
AppProject,
|
||||||
ProviderAuthResponse,
|
AppProviderAuthResponse,
|
||||||
} from "@opencode-ai/sdk/v2/client"
|
} from "./backend"
|
||||||
import { showToast } from "@/utils/toast"
|
import { showToast } from "@/utils/toast"
|
||||||
import { getFilename } from "@opencode-ai/core/util/path"
|
import { getFilename } from "@opencode-ai/core/util/path"
|
||||||
import { type Accessor, batch, createMemo, getOwner, onCleanup, onMount, untrack } from "solid-js"
|
import { type Accessor, batch, createMemo, getOwner, onCleanup, onMount, untrack } from "solid-js"
|
||||||
|
|
@ -28,7 +28,7 @@ import { createChildStoreManager } from "./global-sync/child-store"
|
||||||
import { applyDirectoryEvent, applyGlobalEvent } from "./global-sync/event-reducer"
|
import { applyDirectoryEvent, applyGlobalEvent } from "./global-sync/event-reducer"
|
||||||
import { estimateRootSessionTotal, loadRootSessionsWithFallback } from "./global-sync/session-load"
|
import { estimateRootSessionTotal, loadRootSessionsWithFallback } from "./global-sync/session-load"
|
||||||
import { trimSessions } from "./global-sync/session-trim"
|
import { trimSessions } from "./global-sync/session-trim"
|
||||||
import type { ProjectMeta } from "./global-sync/types"
|
import type { ProjectMeta, ProviderStore, StoreConfig } from "./global-sync/types"
|
||||||
import { SESSION_RECENT_LIMIT } from "./global-sync/types"
|
import { SESSION_RECENT_LIMIT } from "./global-sync/types"
|
||||||
import { formatServerError } from "@/utils/server-errors"
|
import { formatServerError } from "@/utils/server-errors"
|
||||||
import { queryOptions, useMutation, useQueries, useQuery, useQueryClient } from "@tanstack/solid-query"
|
import { queryOptions, useMutation, useQueries, useQuery, useQueryClient } from "@tanstack/solid-query"
|
||||||
|
|
@ -37,7 +37,6 @@ import { directoryKey } from "./global-sync/utils"
|
||||||
import { PathKey } from "@/utils/path-key"
|
import { PathKey } from "@/utils/path-key"
|
||||||
import { createDirSyncContext } from "./directory-sync"
|
import { createDirSyncContext } from "./directory-sync"
|
||||||
import { createSimpleContext } from "@opencode-ai/ui/context"
|
import { createSimpleContext } from "@opencode-ai/ui/context"
|
||||||
import { NormalizedProviderListResponse } from "@opencode-ai/session-ui/context"
|
|
||||||
import { createRefCountMap } from "@/utils/refcount"
|
import { createRefCountMap } from "@/utils/refcount"
|
||||||
import { useGlobal } from "./global"
|
import { useGlobal } from "./global"
|
||||||
import { ServerConnection, useServer } from "./server"
|
import { ServerConnection, useServer } from "./server"
|
||||||
|
|
@ -47,53 +46,63 @@ import { persisted } from "@/utils/persist"
|
||||||
import { toggleMcp } from "./global-sync/mcp"
|
import { toggleMcp } from "./global-sync/mcp"
|
||||||
import { createServerSession } from "./server-session"
|
import { createServerSession } from "./server-session"
|
||||||
|
|
||||||
|
type GlobalConfigUpdate = Pick<AppConfig, "shell" | "provider" | "disabledProviders">
|
||||||
|
|
||||||
type GlobalStore = {
|
type GlobalStore = {
|
||||||
ready: boolean
|
ready: boolean
|
||||||
error?: InitError
|
error?: InitError
|
||||||
path: Path
|
path: AppPathInfo
|
||||||
project: Project[]
|
project: AppProject[]
|
||||||
provider: NormalizedProviderListResponse
|
provider: ProviderStore
|
||||||
provider_auth: ProviderAuthResponse
|
provider_auth: AppProviderAuthResponse
|
||||||
config: Config
|
config: StoreConfig
|
||||||
reload: undefined | "pending" | "complete"
|
reload: undefined | "pending" | "complete"
|
||||||
}
|
}
|
||||||
|
|
||||||
export const loadMcpQuery = (scope: ServerScope, directory: string, sdk: OpencodeClient) =>
|
export const loadMcpQuery = (scope: ServerScope, directory: string, backend: Promise<AppClient>) =>
|
||||||
queryOptions({
|
queryOptions({
|
||||||
queryKey: [scope, directory, "mcp"] as const,
|
queryKey: [scope, directory, "mcp"] as const,
|
||||||
queryFn: () => sdk.mcp.status().then((r) => r.data ?? {}),
|
queryFn: () =>
|
||||||
|
backend
|
||||||
|
.then((client) => client.capabilities.mcp?.list({ location: { directory } }) ?? [])
|
||||||
|
.then((servers) => Object.fromEntries(servers.map((server) => [server.name, server.status]))),
|
||||||
})
|
})
|
||||||
|
|
||||||
export const loadMcpResourcesQuery = (scope: ServerScope, directory: string, sdk: OpencodeClient) =>
|
export const loadMcpResourcesQuery = (scope: ServerScope, directory: string, backend: Promise<AppClient>) =>
|
||||||
queryOptions<Record<string, McpResource>>({
|
queryOptions<Record<string, AppMcpResource>>({
|
||||||
queryKey: [scope, directory, "mcpResources"] as const,
|
queryKey: [scope, directory, "mcpResources"] as const,
|
||||||
queryFn: () => sdk.experimental.resource.list().then((r) => r.data ?? {}),
|
queryFn: () =>
|
||||||
|
backend
|
||||||
|
.then(
|
||||||
|
(client) =>
|
||||||
|
client.capabilities.mcp?.resources({ location: { directory } }) ?? { resources: [], templates: [] },
|
||||||
|
)
|
||||||
|
.then((result) =>
|
||||||
|
Object.fromEntries(result.resources.map((resource) => [`${resource.server}:${resource.uri}`, resource])),
|
||||||
|
),
|
||||||
placeholderData: {},
|
placeholderData: {},
|
||||||
})
|
})
|
||||||
|
|
||||||
export const loadLspQuery = (scope: ServerScope, directory: string, sdk: OpencodeClient) =>
|
export const loadLspQuery = (scope: ServerScope, directory: string, backend: Promise<AppClient>) =>
|
||||||
queryOptions({
|
queryOptions({
|
||||||
queryKey: [scope, directory, "lsp"] as const,
|
queryKey: [scope, directory, "lsp"] as const,
|
||||||
queryFn: () => sdk.lsp.status().then((r) => r.data ?? []),
|
queryFn: async () => {
|
||||||
|
const client = await backend
|
||||||
|
return client.capabilities.lsp?.status({ location: { directory } }) ?? []
|
||||||
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
function makeQueryOptionsApi(
|
function makeQueryOptionsApi(scope: ServerScope, backend: Promise<AppClient>) {
|
||||||
scope: ServerScope,
|
|
||||||
serverSDK: () => OpencodeClient,
|
|
||||||
sdkFor: (dir: PathKey) => OpencodeClient,
|
|
||||||
) {
|
|
||||||
return {
|
return {
|
||||||
globalConfig: () => loadGlobalConfigQuery(scope, serverSDK()),
|
globalConfig: () => loadGlobalConfigQuery(scope, backend),
|
||||||
projects: () => loadProjectsQuery(scope, serverSDK()),
|
projects: () => loadProjectsQuery(scope, backend),
|
||||||
providers: (directory: PathKey | null) =>
|
providers: (directory: PathKey | null) => loadProvidersQuery(scope, directory, backend),
|
||||||
loadProvidersQuery(scope, directory, directory === null ? serverSDK() : sdkFor(directory)),
|
path: (directory: PathKey | null) => loadPathQuery(scope, directory, backend),
|
||||||
path: (directory: PathKey | null) =>
|
agents: (directory: PathKey) => loadAgentsQuery(scope, directory, backend),
|
||||||
loadPathQuery(scope, directory, directory === null ? serverSDK() : sdkFor(directory)),
|
references: (directory: PathKey) => loadReferencesQuery(scope, directory, backend),
|
||||||
agents: (directory: PathKey) => loadAgentsQuery(scope, directory, sdkFor(directory)),
|
mcp: (directory: PathKey) => loadMcpQuery(scope, directory, backend),
|
||||||
references: (directory: PathKey) => loadReferencesQuery(scope, directory, sdkFor(directory)),
|
mcpResources: (directory: PathKey) => loadMcpResourcesQuery(scope, directory, backend),
|
||||||
mcp: (directory: PathKey) => loadMcpQuery(scope, directory, sdkFor(directory)),
|
lsp: (directory: PathKey) => loadLspQuery(scope, directory, backend),
|
||||||
mcpResources: (directory: PathKey) => loadMcpResourcesQuery(scope, directory, sdkFor(directory)),
|
|
||||||
lsp: (directory: PathKey) => loadLspQuery(scope, directory, sdkFor(directory)),
|
|
||||||
sessions: (directory: PathKey) => ({ queryKey: [scope, directory, "loadSessions"] as const }),
|
sessions: (directory: PathKey) => ({ queryKey: [scope, directory, "loadSessions"] as const }),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -104,24 +113,11 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
|
||||||
const owner = getOwner()
|
const owner = getOwner()
|
||||||
if (!owner) throw new Error("ServerSync must be created within owner")
|
if (!owner) throw new Error("ServerSync must be created within owner")
|
||||||
|
|
||||||
const sdkCache = new Map<string, OpencodeClient>()
|
|
||||||
const booting = new Map<string, Promise<void>>()
|
const booting = new Map<string, Promise<void>>()
|
||||||
const sessionLoads = new Map<string, Promise<void>>()
|
const sessionLoads = new Map<string, Promise<void>>()
|
||||||
const sessionMeta = new Map<string, { limit: number }>()
|
const sessionMeta = new Map<string, { limit: number }>()
|
||||||
|
|
||||||
const sdkFor = (directory: string) => {
|
const queryOptionsApi = makeQueryOptionsApi(serverSDK.scope, serverSDK.backend)
|
||||||
const key = directoryKey(directory)
|
|
||||||
const cached = sdkCache.get(key)
|
|
||||||
if (cached) return cached
|
|
||||||
const sdk = serverSDK.createClient({
|
|
||||||
directory,
|
|
||||||
throwOnError: true,
|
|
||||||
})
|
|
||||||
sdkCache.set(key, sdk)
|
|
||||||
return sdk
|
|
||||||
}
|
|
||||||
|
|
||||||
const queryOptionsApi = makeQueryOptionsApi(serverSDK.scope, () => serverSDK.client, sdkFor)
|
|
||||||
|
|
||||||
const [configQuery, providerQuery, pathQuery] = useQueries(() => ({
|
const [configQuery, providerQuery, pathQuery] = useQueries(() => ({
|
||||||
queries: [queryOptionsApi.globalConfig(), queryOptionsApi.providers(null), queryOptionsApi.path(null)],
|
queries: [queryOptionsApi.globalConfig(), queryOptionsApi.providers(null), queryOptionsApi.path(null)],
|
||||||
|
|
@ -164,13 +160,13 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
|
||||||
if (eventTimer !== undefined) clearTimeout(eventTimer)
|
if (eventTimer !== undefined) clearTimeout(eventTimer)
|
||||||
})
|
})
|
||||||
|
|
||||||
const setProjects = (next: Project[] | ((draft: Project[]) => Project[])) => {
|
const setProjects = (next: AppProject[] | ((draft: AppProject[]) => AppProject[])) => {
|
||||||
setGlobalStore("project", next)
|
setGlobalStore("project", next)
|
||||||
}
|
}
|
||||||
|
|
||||||
const setBootStore = ((...input: unknown[]) => {
|
const setBootStore = ((...input: unknown[]) => {
|
||||||
if (input[0] === "project" && Array.isArray(input[1])) {
|
if (input[0] === "project" && Array.isArray(input[1])) {
|
||||||
setProjects(input[1] as Project[])
|
setProjects(input[1] as AppProject[])
|
||||||
return input[1]
|
return input[1]
|
||||||
}
|
}
|
||||||
return (setGlobalStore as (...args: unknown[]) => unknown)(...input)
|
return (setGlobalStore as (...args: unknown[]) => unknown)(...input)
|
||||||
|
|
@ -180,7 +176,7 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
|
||||||
queryKey: [serverSDK.scope, "bootstrap"],
|
queryKey: [serverSDK.scope, "bootstrap"],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
await bootstrapGlobal({
|
await bootstrapGlobal({
|
||||||
serverSDK: serverSDK.client,
|
backend: serverSDK.backend,
|
||||||
scope: serverSDK.scope,
|
scope: serverSDK.scope,
|
||||||
requestFailedTitle: language.t("common.requestFailed"),
|
requestFailedTitle: language.t("common.requestFailed"),
|
||||||
translate: language.t,
|
translate: language.t,
|
||||||
|
|
@ -195,7 +191,7 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
|
||||||
|
|
||||||
const set = ((...input: unknown[]) => {
|
const set = ((...input: unknown[]) => {
|
||||||
if (input[0] === "project" && (Array.isArray(input[1]) || typeof input[1] === "function")) {
|
if (input[0] === "project" && (Array.isArray(input[1]) || typeof input[1] === "function")) {
|
||||||
setProjects(input[1] as Project[] | ((draft: Project[]) => Project[]))
|
setProjects(input[1] as AppProject[] | ((draft: AppProject[]) => AppProject[]))
|
||||||
return input[1]
|
return input[1]
|
||||||
}
|
}
|
||||||
return (setGlobalStore as (...args: unknown[]) => unknown)(...input)
|
return (setGlobalStore as (...args: unknown[]) => unknown)(...input)
|
||||||
|
|
@ -210,7 +206,7 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
|
||||||
bootstrapInstance,
|
bootstrapInstance,
|
||||||
})
|
})
|
||||||
|
|
||||||
const session = createServerSession(serverSDK.client)
|
const session = createServerSession(serverSDK.backend)
|
||||||
|
|
||||||
const children = createChildStoreManager({
|
const children = createChildStoreManager({
|
||||||
owner,
|
owner,
|
||||||
|
|
@ -223,9 +219,9 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
|
||||||
},
|
},
|
||||||
onMcp: (directory, setStore) => {
|
onMcp: (directory, setStore) => {
|
||||||
void retry(() =>
|
void retry(() =>
|
||||||
sdkFor(directory)
|
serverSDK.backend
|
||||||
.command.list()
|
.then((client) => client.common.commands.list({ location: { directory } }))
|
||||||
.then((x) => setStore("command", x.data ?? [])),
|
.then((commands) => setStore("command", reconcile(commands))),
|
||||||
).catch((err) => {
|
).catch((err) => {
|
||||||
showToast({
|
showToast({
|
||||||
variant: "error",
|
variant: "error",
|
||||||
|
|
@ -238,7 +234,6 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
|
||||||
const key = directoryKey(directory)
|
const key = directoryKey(directory)
|
||||||
queue.clear(key)
|
queue.clear(key)
|
||||||
sessionMeta.delete(key)
|
sessionMeta.delete(key)
|
||||||
sdkCache.delete(key)
|
|
||||||
clearProviderRev(serverSDK.scope, key)
|
clearProviderRev(serverSDK.scope, key)
|
||||||
},
|
},
|
||||||
translate: language.t,
|
translate: language.t,
|
||||||
|
|
@ -280,7 +275,12 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
|
||||||
loadRootSessionsWithFallback({
|
loadRootSessionsWithFallback({
|
||||||
directory,
|
directory,
|
||||||
limit,
|
limit,
|
||||||
list: (query) => serverSDK.client.session.list(query),
|
list: (query) =>
|
||||||
|
serverSDK.backend.then((client) =>
|
||||||
|
client.common.sessions
|
||||||
|
.list({ location: { directory: query.directory }, roots: query.roots, limit: query.limit })
|
||||||
|
.then((page) => ({ data: [...page.items] })),
|
||||||
|
),
|
||||||
})
|
})
|
||||||
.then((x) => {
|
.then((x) => {
|
||||||
const nonArchived = (x.data ?? [])
|
const nonArchived = (x.data ?? [])
|
||||||
|
|
@ -339,7 +339,7 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
|
||||||
const child = children.ensureChild(directory)
|
const child = children.ensureChild(directory)
|
||||||
const cache = children.vcsCache.get(key)
|
const cache = children.vcsCache.get(key)
|
||||||
if (!cache) return
|
if (!cache) return
|
||||||
const sdk = sdkFor(directory)
|
const backend = await serverSDK.backend
|
||||||
await bootstrapDirectory({
|
await bootstrapDirectory({
|
||||||
directory,
|
directory,
|
||||||
scope: serverSDK.scope,
|
scope: serverSDK.scope,
|
||||||
|
|
@ -350,7 +350,7 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
|
||||||
project: globalStore.project,
|
project: globalStore.project,
|
||||||
provider: globalStore.provider,
|
provider: globalStore.provider,
|
||||||
},
|
},
|
||||||
sdk,
|
backend,
|
||||||
store: child[0],
|
store: child[0],
|
||||||
setStore: child[1],
|
setStore: child[1],
|
||||||
vcsCache: cache,
|
vcsCache: cache,
|
||||||
|
|
@ -375,7 +375,14 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
|
||||||
const event = e.details
|
const event = e.details
|
||||||
const recent = bootingRoot || Date.now() - bootedAt < 1500
|
const recent = bootingRoot || Date.now() - bootedAt < 1500
|
||||||
|
|
||||||
session.apply(event)
|
session.apply(event, directory === "global" ? undefined : { directory })
|
||||||
|
|
||||||
|
if (event.type === "server.connected") void session.resync()
|
||||||
|
if (event.type === "provider.updated") {
|
||||||
|
if (!recent) bootstrap.refetch()
|
||||||
|
if (directory !== "global") queue.push(directory)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
if (directory === "global") {
|
if (directory === "global") {
|
||||||
applyGlobalEvent({
|
applyGlobalEvent({
|
||||||
|
|
@ -387,7 +394,7 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
|
||||||
},
|
},
|
||||||
setGlobalProject: setProjects,
|
setGlobalProject: setProjects,
|
||||||
})
|
})
|
||||||
if (event.type === "server.connected" || event.type === "global.disposed") {
|
if (event.type === "server.connected" || event.type === "server.disposed") {
|
||||||
if (recent) return
|
if (recent) return
|
||||||
for (const directory of Object.keys(children.children)) {
|
for (const directory of Object.keys(children.children)) {
|
||||||
queue.push(directory)
|
queue.push(directory)
|
||||||
|
|
@ -457,7 +464,16 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
|
||||||
}
|
}
|
||||||
|
|
||||||
const updateConfigMutation = useMutation(() => ({
|
const updateConfigMutation = useMutation(() => ({
|
||||||
mutationFn: (config: Config) => serverSDK.client.global.config.update({ config }),
|
mutationFn: async (config: GlobalConfigUpdate) => {
|
||||||
|
const backend = await serverSDK.backend
|
||||||
|
const capability = backend.capabilities.configuration
|
||||||
|
if (!capability) throw new Error("Server does not support configuration updates")
|
||||||
|
await capability.updateGlobal({
|
||||||
|
shell: config.shell,
|
||||||
|
provider: config.provider,
|
||||||
|
disabledProviders: config.disabledProviders,
|
||||||
|
})
|
||||||
|
},
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
bootstrap.refetch()
|
bootstrap.refetch()
|
||||||
// Invalidate all provider queries so newly configured custom providers
|
// Invalidate all provider queries so newly configured custom providers
|
||||||
|
|
@ -484,23 +500,32 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
|
||||||
queryOptions: queryOptionsApi,
|
queryOptions: queryOptionsApi,
|
||||||
// bootstrap,
|
// bootstrap,
|
||||||
updateConfig: updateConfigMutation.mutateAsync,
|
updateConfig: updateConfigMutation.mutateAsync,
|
||||||
|
refreshProviders: async () => {
|
||||||
|
await queryClient.invalidateQueries({
|
||||||
|
predicate: (query) => query.queryKey[0] === serverSDK.scope && query.queryKey[2] === "providers",
|
||||||
|
})
|
||||||
|
await bootstrap.refetch()
|
||||||
|
},
|
||||||
project: projectApi,
|
project: projectApi,
|
||||||
session,
|
session,
|
||||||
mcp: {
|
mcp: {
|
||||||
toggle: async (directory: string, name: string) => {
|
toggle: async (directory: string, name: string) => {
|
||||||
const key = directoryKey(directory)
|
const key = directoryKey(directory)
|
||||||
const sdk = sdkFor(key)
|
const backend = await serverSDK.backend
|
||||||
|
const capability = backend.capabilities.mcpControl
|
||||||
|
if (!capability) throw new Error("Server does not support MCP controls")
|
||||||
|
const location = { location: { directory: key } }
|
||||||
const status = children.child(key, { bootstrap: false })[0].mcp[name].status
|
const status = children.child(key, { bootstrap: false })[0].mcp[name].status
|
||||||
await toggleMcp({
|
await toggleMcp({
|
||||||
status,
|
status,
|
||||||
connect: async () => {
|
connect: async () => {
|
||||||
await sdk.mcp.connect({ name })
|
await capability.connect({ ...location, name })
|
||||||
},
|
},
|
||||||
disconnect: async () => {
|
disconnect: async () => {
|
||||||
await sdk.mcp.disconnect({ name })
|
await capability.disconnect({ ...location, name })
|
||||||
},
|
},
|
||||||
authenticate: async () => {
|
authenticate: async () => {
|
||||||
await sdk.mcp.auth.authenticate({ name })
|
await capability.authenticate({ ...location, name })
|
||||||
},
|
},
|
||||||
refresh: async () => {
|
refresh: async () => {
|
||||||
await queryClient.refetchQueries(queryOptionsApi.mcp(key))
|
await queryClient.refetchQueries(queryOptionsApi.mcp(key))
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { describe, expect, test } from "bun:test"
|
import { describe, expect, test } from "bun:test"
|
||||||
import type { Message, Part } from "@opencode-ai/sdk/v2/client"
|
import type { AppMessage as Message, AppPart as Part } from "./backend"
|
||||||
import { applyOptimisticAdd, applyOptimisticRemove, mergeOptimisticPage } from "./sync"
|
import { applyOptimisticAdd, applyOptimisticRemove, mergeOptimisticPage } from "./sync"
|
||||||
|
|
||||||
type Text = Extract<Part, { type: "text" }>
|
type Text = Extract<Part, { type: "text" }>
|
||||||
|
|
|
||||||
|
|
@ -2,25 +2,25 @@ import { Binary } from "@opencode-ai/core/util/binary"
|
||||||
import { createMemo } from "solid-js"
|
import { createMemo } from "solid-js"
|
||||||
import { useServerSync } from "./server-sync"
|
import { useServerSync } from "./server-sync"
|
||||||
import { useSDK } from "./sdk"
|
import { useSDK } from "./sdk"
|
||||||
import type { Message, Part } from "@opencode-ai/sdk/v2/client"
|
import type { AppMessage, AppPart } from "./backend"
|
||||||
|
|
||||||
const SKIP_PARTS = new Set(["patch", "step-start", "step-finish"])
|
const SKIP_PARTS = new Set(["patch", "step-start", "step-finish"])
|
||||||
|
|
||||||
function sortParts(parts: Part[]) {
|
function sortParts(parts: AppPart[]) {
|
||||||
return parts.filter((part) => !!part?.id).sort((a, b) => cmp(a.id, b.id))
|
return parts.filter((part) => !!part?.id).sort((a, b) => cmp(a.id, b.id))
|
||||||
}
|
}
|
||||||
|
|
||||||
const cmp = (a: string, b: string) => (a < b ? -1 : a > b ? 1 : 0)
|
const cmp = (a: string, b: string) => (a < b ? -1 : a > b ? 1 : 0)
|
||||||
|
|
||||||
type OptimisticStore = {
|
type OptimisticStore = {
|
||||||
message: Record<string, Message[] | undefined>
|
message: Record<string, AppMessage[] | undefined>
|
||||||
part: Record<string, Part[] | undefined>
|
part: Record<string, AppPart[] | undefined>
|
||||||
}
|
}
|
||||||
|
|
||||||
type OptimisticAddInput = {
|
type OptimisticAddInput = {
|
||||||
sessionID: string
|
sessionID: string
|
||||||
message: Message
|
message: AppMessage
|
||||||
parts: Part[]
|
parts: AppPart[]
|
||||||
}
|
}
|
||||||
|
|
||||||
type OptimisticRemoveInput = {
|
type OptimisticRemoveInput = {
|
||||||
|
|
@ -29,23 +29,23 @@ type OptimisticRemoveInput = {
|
||||||
}
|
}
|
||||||
|
|
||||||
type OptimisticItem = {
|
type OptimisticItem = {
|
||||||
message: Message
|
message: AppMessage
|
||||||
parts: Part[]
|
parts: AppPart[]
|
||||||
}
|
}
|
||||||
|
|
||||||
type MessagePage = {
|
type MessagePage = {
|
||||||
session: Message[]
|
session: AppMessage[]
|
||||||
part: { id: string; part: Part[] }[]
|
part: { id: string; part: AppPart[] }[]
|
||||||
cursor?: string
|
cursor?: string
|
||||||
complete: boolean
|
complete: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
const hasParts = (parts: Part[] | undefined, want: Part[]) => {
|
const hasParts = (parts: AppPart[] | undefined, want: AppPart[]) => {
|
||||||
if (!parts) return want.length === 0
|
if (!parts) return want.length === 0
|
||||||
return want.every((part) => Binary.search(parts, part.id, (item) => item.id).found)
|
return want.every((part) => Binary.search(parts, part.id, (item) => item.id).found)
|
||||||
}
|
}
|
||||||
|
|
||||||
const mergeParts = (parts: Part[] | undefined, want: Part[]) => {
|
const mergeParts = (parts: AppPart[] | undefined, want: AppPart[]) => {
|
||||||
if (!parts) return sortParts(want)
|
if (!parts) return sortParts(want)
|
||||||
const next = [...parts]
|
const next = [...parts]
|
||||||
let changed = false
|
let changed = false
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import type { Session } from "@opencode-ai/sdk/v2/client"
|
import type { AppSession } from "./backend"
|
||||||
import { createSimpleContext } from "@opencode-ai/ui/context"
|
import { createSimpleContext } from "@opencode-ai/ui/context"
|
||||||
import { createStore, produce } from "solid-js/store"
|
import { createStore, produce } from "solid-js/store"
|
||||||
import { Persist, persisted, removePersisted, draftPersistedKeys } from "@/utils/persist"
|
import { Persist, persisted, removePersisted, draftPersistedKeys } from "@/utils/persist"
|
||||||
|
|
@ -45,7 +45,7 @@ export const tabHref = (tab: Tab) =>
|
||||||
|
|
||||||
export const tabKey = (tab: Tab) => (tab.type === "draft" ? `draft:${tab.draftID}` : `${tab.server}\n${tabHref(tab)}`)
|
export const tabKey = (tab: Tab) => (tab.type === "draft" ? `draft:${tab.draftID}` : `${tab.server}\n${tabHref(tab)}`)
|
||||||
|
|
||||||
export function sessionHasOpenTab(tabs: Tab[], server: ServerConnection.Key, session: Session) {
|
export function sessionHasOpenTab(tabs: Tab[], server: ServerConnection.Key, session: AppSession) {
|
||||||
return tabs.some((tab) => tab.type === "session" && tab.server === server && tab.sessionId === session.id)
|
return tabs.some((tab) => tab.type === "session" && tab.server === server && tab.sessionId === session.id)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -348,7 +348,7 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
|
||||||
for (const key of removed) memory.remove(key)
|
for (const key of removed) memory.remove(key)
|
||||||
for (const key of removed) removeInfo(key)
|
for (const key of removed) removeInfo(key)
|
||||||
},
|
},
|
||||||
rememberSessionInfo(tab: SessionTab, session: Session) {
|
rememberSessionInfo(tab: SessionTab, session: AppSession) {
|
||||||
const key = tabKey(tab)
|
const key = tabKey(tab)
|
||||||
const next = { title: session.title, directory: session.directory }
|
const next = { title: session.title, directory: session.directory }
|
||||||
const current = info[key]
|
const current = info[key]
|
||||||
|
|
|
||||||
|
|
@ -149,6 +149,7 @@ function createWorkspaceTerminalSession(
|
||||||
scope: ServerScopeValue,
|
scope: ServerScopeValue,
|
||||||
legacySessionID?: string,
|
legacySessionID?: string,
|
||||||
) {
|
) {
|
||||||
|
const location = { directory: sdk.directory }
|
||||||
const legacy = scope === ServerScope.local ? getLegacyTerminalStorageKeys(dir, legacySessionID) : []
|
const legacy = scope === ServerScope.local ? getLegacyTerminalStorageKeys(dir, legacySessionID) : []
|
||||||
|
|
||||||
const [store, setStore, _, ready] = persisted(
|
const [store, setStore, _, ready] = persisted(
|
||||||
|
|
@ -198,23 +199,27 @@ function createWorkspaceTerminalSession(
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const unsub = sdk.event.on("pty.exited", (event: { properties: { id: string } }) => {
|
const unsub = sdk.event.on("pty.exited", (event) => {
|
||||||
removeExited(event.properties.id)
|
if (event.type !== "pty.exited") return
|
||||||
|
removeExited(event.ptyID)
|
||||||
})
|
})
|
||||||
onCleanup(unsub)
|
onCleanup(unsub)
|
||||||
|
|
||||||
const update = (client: DirectorySDK["client"], pty: Partial<LocalPTY> & { id: string }) => {
|
const update = (pty: Partial<LocalPTY> & { id: string }) => {
|
||||||
const index = store.all.findIndex((x) => x.id === pty.id)
|
const index = store.all.findIndex((x) => x.id === pty.id)
|
||||||
const previous = index >= 0 ? store.all[index] : undefined
|
const previous = index >= 0 ? store.all[index] : undefined
|
||||||
if (index >= 0) {
|
if (index >= 0) {
|
||||||
setStore("all", index, (item) => ({ ...item, ...pty }))
|
setStore("all", index, (item) => ({ ...item, ...pty }))
|
||||||
}
|
}
|
||||||
client.pty
|
sdk.backend
|
||||||
.update({
|
.then((client) =>
|
||||||
ptyID: pty.id,
|
client.common.pty.update({
|
||||||
title: pty.title,
|
ptyID: pty.id,
|
||||||
size: pty.cols && pty.rows ? { rows: pty.rows, cols: pty.cols } : undefined,
|
title: pty.title,
|
||||||
})
|
size: pty.cols && pty.rows ? { rows: pty.rows, cols: pty.cols } : undefined,
|
||||||
|
location,
|
||||||
|
}),
|
||||||
|
)
|
||||||
.catch((error: unknown) => {
|
.catch((error: unknown) => {
|
||||||
if (previous) {
|
if (previous) {
|
||||||
const currentIndex = store.all.findIndex((item) => item.id === pty.id)
|
const currentIndex = store.all.findIndex((item) => item.id === pty.id)
|
||||||
|
|
@ -224,26 +229,24 @@ function createWorkspaceTerminalSession(
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const clone = async (client: DirectorySDK["client"], id: string) => {
|
const clone = async (id: string) => {
|
||||||
const index = store.all.findIndex((x) => x.id === id)
|
const index = store.all.findIndex((x) => x.id === id)
|
||||||
const pty = store.all[index]
|
const pty = store.all[index]
|
||||||
if (!pty) return
|
if (!pty) return
|
||||||
const next = await client.pty
|
const next = await sdk.backend
|
||||||
.create({
|
.then((client) => client.common.pty.create({ title: pty.title, location }))
|
||||||
title: pty.title,
|
|
||||||
})
|
|
||||||
.catch((error: unknown) => {
|
.catch((error: unknown) => {
|
||||||
console.error("Failed to clone terminal", error)
|
console.error("Failed to clone terminal", error)
|
||||||
return undefined
|
return undefined
|
||||||
})
|
})
|
||||||
if (!next?.data) return
|
if (!next) return
|
||||||
|
|
||||||
const active = store.active === pty.id
|
const active = store.active === pty.id
|
||||||
|
|
||||||
batch(() => {
|
batch(() => {
|
||||||
setStore("all", index, {
|
setStore("all", index, {
|
||||||
id: next.data.id,
|
id: next.id,
|
||||||
title: next.data.title ?? pty.title,
|
title: next.title ?? pty.title,
|
||||||
titleNumber: pty.titleNumber,
|
titleNumber: pty.titleNumber,
|
||||||
buffer: undefined,
|
buffer: undefined,
|
||||||
cursor: undefined,
|
cursor: undefined,
|
||||||
|
|
@ -252,7 +255,7 @@ function createWorkspaceTerminalSession(
|
||||||
cols: undefined,
|
cols: undefined,
|
||||||
})
|
})
|
||||||
if (active) {
|
if (active) {
|
||||||
setStore("active", next.data.id)
|
setStore("active", next.id)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
@ -270,14 +273,14 @@ function createWorkspaceTerminalSession(
|
||||||
new() {
|
new() {
|
||||||
const nextNumber = pickNextTerminalNumber()
|
const nextNumber = pickNextTerminalNumber()
|
||||||
|
|
||||||
sdk.client.pty
|
sdk.backend
|
||||||
.create({ title: defaultTitle(nextNumber) })
|
.then((client) => client.common.pty.create({ title: defaultTitle(nextNumber), location }))
|
||||||
.then((pty: { data?: { id?: string; title?: string } }) => {
|
.then((pty) => {
|
||||||
const id = pty.data?.id
|
const id = pty.id
|
||||||
if (!id) return
|
if (!id) return
|
||||||
const newTerminal = {
|
const newTerminal = {
|
||||||
id,
|
id,
|
||||||
title: pty.data?.title ?? defaultTitle(nextNumber),
|
title: pty.title ?? defaultTitle(nextNumber),
|
||||||
titleNumber: nextNumber,
|
titleNumber: nextNumber,
|
||||||
}
|
}
|
||||||
setStore("all", store.all.length, newTerminal)
|
setStore("all", store.all.length, newTerminal)
|
||||||
|
|
@ -288,7 +291,7 @@ function createWorkspaceTerminalSession(
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
update(pty: Partial<LocalPTY> & { id: string }) {
|
update(pty: Partial<LocalPTY> & { id: string }) {
|
||||||
update(sdk.client, pty)
|
update(pty)
|
||||||
},
|
},
|
||||||
trim(id: string) {
|
trim(id: string) {
|
||||||
const index = store.all.findIndex((x) => x.id === id)
|
const index = store.all.findIndex((x) => x.id === id)
|
||||||
|
|
@ -303,10 +306,9 @@ function createWorkspaceTerminalSession(
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
async clone(id: string) {
|
async clone(id: string) {
|
||||||
await clone(sdk.client, id)
|
await clone(id)
|
||||||
},
|
},
|
||||||
bind() {
|
bind() {
|
||||||
const client = sdk.client
|
|
||||||
return {
|
return {
|
||||||
trim(id: string) {
|
trim(id: string) {
|
||||||
const index = store.all.findIndex((x) => x.id === id)
|
const index = store.all.findIndex((x) => x.id === id)
|
||||||
|
|
@ -314,10 +316,10 @@ function createWorkspaceTerminalSession(
|
||||||
setStore("all", index, (pty) => trimTerminal(pty))
|
setStore("all", index, (pty) => trimTerminal(pty))
|
||||||
},
|
},
|
||||||
update(pty: Partial<LocalPTY> & { id: string }) {
|
update(pty: Partial<LocalPTY> & { id: string }) {
|
||||||
update(client, pty)
|
update(pty)
|
||||||
},
|
},
|
||||||
async clone(id: string) {
|
async clone(id: string) {
|
||||||
await clone(client, id)
|
await clone(id)
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
@ -353,9 +355,11 @@ function createWorkspaceTerminalSession(
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
await sdk.client.pty.remove({ ptyID: id }).catch((error: unknown) => {
|
await sdk.backend
|
||||||
console.error("Failed to close terminal", error)
|
.then((client) => client.common.pty.remove({ ptyID: id, location }))
|
||||||
})
|
.catch((error: unknown) => {
|
||||||
|
console.error("Failed to close terminal", error)
|
||||||
|
})
|
||||||
},
|
},
|
||||||
move(id: string, to: number) {
|
move(id: string, to: number) {
|
||||||
const index = store.all.findIndex((f) => f.id === id)
|
const index = store.all.findIndex((f) => f.id === id)
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,9 @@
|
||||||
import { expect, test } from "bun:test"
|
import { expect, test } from "bun:test"
|
||||||
import type { NormalizedProviderListResponse } from "@opencode-ai/session-ui/context"
|
import type { ProviderStore } from "@/context/global-sync/types"
|
||||||
import { selectProviderCatalog } from "./provider-catalog"
|
import { selectProviderCatalog } from "./provider-catalog"
|
||||||
|
|
||||||
const catalog = (id: string): NormalizedProviderListResponse => ({
|
const catalog = (id: string): ProviderStore => ({
|
||||||
all: new Map([[id, { id, name: id, source: "api", env: [], options: {}, models: {} }]]),
|
all: new Map([[id, { id, name: id, source: "api", models: {} }]]),
|
||||||
connected: [id],
|
connected: [id],
|
||||||
default: { [id]: `${id}-model` },
|
default: { [id]: `${id}-model` },
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,10 @@
|
||||||
import type { NormalizedProviderListResponse } from "@opencode-ai/session-ui/context"
|
import type { ProviderStore } from "@/context/global-sync/types"
|
||||||
|
|
||||||
const emptyProviderCatalog: NormalizedProviderListResponse = { all: new Map(), connected: [], default: {} }
|
const emptyProviderCatalog: ProviderStore = { all: new Map(), connected: [], default: {} }
|
||||||
|
|
||||||
type DirectoryCatalog = {
|
type DirectoryCatalog = {
|
||||||
ready: boolean
|
ready: boolean
|
||||||
providers: NormalizedProviderListResponse
|
providers: ProviderStore
|
||||||
}
|
}
|
||||||
|
|
||||||
type ProviderCatalogInput =
|
type ProviderCatalogInput =
|
||||||
|
|
@ -17,7 +17,7 @@ type ProviderCatalogInput =
|
||||||
explicit: false
|
explicit: false
|
||||||
directory?: string
|
directory?: string
|
||||||
catalog?: DirectoryCatalog
|
catalog?: DirectoryCatalog
|
||||||
global: NormalizedProviderListResponse
|
global: ProviderStore
|
||||||
}
|
}
|
||||||
|
|
||||||
export function selectProviderCatalog(input: ProviderCatalogInput) {
|
export function selectProviderCatalog(input: ProviderCatalogInput) {
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import type { Session } from "@opencode-ai/sdk/v2/client"
|
import type { AppSession as Session } from "@/context/backend"
|
||||||
import {
|
import {
|
||||||
type ComponentProps,
|
type ComponentProps,
|
||||||
createEffect,
|
createEffect,
|
||||||
|
|
@ -508,7 +508,15 @@ export function NewHome() {
|
||||||
await archiveHomeSession({
|
await archiveHomeSession({
|
||||||
server: ServerConnection.key(conn),
|
server: ServerConnection.key(conn),
|
||||||
session,
|
session,
|
||||||
update: (value) => ctx.sdk.client.session.update(value),
|
update: async (value) => {
|
||||||
|
const capability = (await ctx.sdk.backend).capabilities.sessionExtrasV1
|
||||||
|
if (!capability) throw new Error("Session archiving is not supported by this server")
|
||||||
|
await capability.archive({
|
||||||
|
location: { directory: value.directory },
|
||||||
|
sessionID: value.sessionID,
|
||||||
|
archivedAt: value.time.archived,
|
||||||
|
})
|
||||||
|
},
|
||||||
remove: () =>
|
remove: () =>
|
||||||
setStore(
|
setStore(
|
||||||
produce((draft) => {
|
produce((draft) => {
|
||||||
|
|
|
||||||
|
|
@ -25,8 +25,8 @@ import { IconButton } from "@opencode-ai/ui/icon-button"
|
||||||
import { Tooltip } from "@opencode-ai/ui/tooltip"
|
import { Tooltip } from "@opencode-ai/ui/tooltip"
|
||||||
import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu"
|
import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu"
|
||||||
import { Dialog } from "@opencode-ai/ui/dialog"
|
import { Dialog } from "@opencode-ai/ui/dialog"
|
||||||
import { getFilename } from "@opencode-ai/core/util/path"
|
import { getDirectory, getFilename } from "@opencode-ai/core/util/path"
|
||||||
import { Session } from "@opencode-ai/sdk/v2/client"
|
import type { AppSession as Session } from "@/context/backend"
|
||||||
import { usePlatform } from "@/context/platform"
|
import { usePlatform } from "@/context/platform"
|
||||||
import { useSettings } from "@/context/settings"
|
import { useSettings } from "@/context/settings"
|
||||||
import { createStore, produce, reconcile } from "solid-js/store"
|
import { createStore, produce, reconcile } from "solid-js/store"
|
||||||
|
|
@ -66,6 +66,7 @@ import {
|
||||||
errorMessage,
|
errorMessage,
|
||||||
latestRootSession,
|
latestRootSession,
|
||||||
sortedRootSessions,
|
sortedRootSessions,
|
||||||
|
workspaceCopyCreateInput,
|
||||||
} from "./layout/helpers"
|
} from "./layout/helpers"
|
||||||
import {
|
import {
|
||||||
collectNewSessionDeepLinks,
|
collectNewSessionDeepLinks,
|
||||||
|
|
@ -85,6 +86,11 @@ import { SidebarContent } from "./layout/sidebar-shell"
|
||||||
|
|
||||||
export default function LegacyLayout(props: ParentProps) {
|
export default function LegacyLayout(props: ParentProps) {
|
||||||
const serverSDK = useServerSDK()
|
const serverSDK = useServerSDK()
|
||||||
|
const [backend] = createResource(
|
||||||
|
() => serverSDK().backend,
|
||||||
|
(value) => value,
|
||||||
|
)
|
||||||
|
const canArchive = () => !!backend()?.capabilities.sessionExtrasV1
|
||||||
const [store, setStore, , ready] = persisted(
|
const [store, setStore, , ready] = persisted(
|
||||||
Persist.serverGlobal(serverSDK().scope, "layout.page", ["layout.page.v1"]),
|
Persist.serverGlobal(serverSDK().scope, "layout.page", ["layout.page.v1"]),
|
||||||
createStore({
|
createStore({
|
||||||
|
|
@ -398,7 +404,7 @@ export default function LegacyLayout(props: ParentProps) {
|
||||||
WorktreeState.failed(
|
WorktreeState.failed(
|
||||||
serverSDK().scope,
|
serverSDK().scope,
|
||||||
e.name,
|
e.name,
|
||||||
e.details.properties?.message ?? language.t("common.requestFailed"),
|
e.details.message ?? language.t("common.requestFailed"),
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
@ -408,21 +414,21 @@ export default function LegacyLayout(props: ParentProps) {
|
||||||
e.details?.type === "question.rejected" ||
|
e.details?.type === "question.rejected" ||
|
||||||
e.details?.type === "permission.replied"
|
e.details?.type === "permission.replied"
|
||||||
) {
|
) {
|
||||||
const props = e.details.properties as { sessionID: string }
|
const props = e.details
|
||||||
const sessionKey = `${e.name}:${props.sessionID}`
|
const sessionKey = `${e.name}:${props.sessionID}`
|
||||||
dismissSessionAlert(sessionKey)
|
dismissSessionAlert(sessionKey)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (e.details?.type !== "permission.asked" && e.details?.type !== "question.asked") return
|
if (e.details?.type !== "permission.requested" && e.details?.type !== "question.requested") return
|
||||||
const title =
|
const title =
|
||||||
e.details.type === "permission.asked"
|
e.details.type === "permission.requested"
|
||||||
? language.t("notification.permission.title")
|
? language.t("notification.permission.title")
|
||||||
: language.t("notification.question.title")
|
: language.t("notification.question.title")
|
||||||
const icon = e.details.type === "permission.asked" ? ("checklist" as const) : ("bubble-5" as const)
|
const icon = e.details.type === "permission.requested" ? ("checklist" as const) : ("bubble-5" as const)
|
||||||
const directory = e.name
|
const directory = e.name
|
||||||
const props = e.details.properties
|
const props = e.details.request
|
||||||
if (e.details.type === "permission.asked" && permission.autoResponds(e.details.properties, directory)) return
|
if (e.details.type === "permission.requested" && permission.autoResponds(e.details.request, directory)) return
|
||||||
|
|
||||||
const [store] = serverSync().child(directory, { bootstrap: false })
|
const [store] = serverSync().child(directory, { bootstrap: false })
|
||||||
const session = store.session.find((s) => s.id === props.sessionID)
|
const session = store.session.find((s) => s.id === props.sessionID)
|
||||||
|
|
@ -431,7 +437,7 @@ export default function LegacyLayout(props: ParentProps) {
|
||||||
const sessionTitle = session?.title ?? language.t("command.session.new")
|
const sessionTitle = session?.title ?? language.t("command.session.new")
|
||||||
const projectName = getFilename(directory)
|
const projectName = getFilename(directory)
|
||||||
const description =
|
const description =
|
||||||
e.details.type === "permission.asked"
|
e.details.type === "permission.requested"
|
||||||
? language.t("notification.permission.description", { sessionTitle, projectName })
|
? language.t("notification.permission.description", { sessionTitle, projectName })
|
||||||
: language.t("notification.question.description", { sessionTitle, projectName })
|
: language.t("notification.question.description", { sessionTitle, projectName })
|
||||||
const href = `/${base64Encode(directory)}/session/${props.sessionID}`
|
const href = `/${base64Encode(directory)}/session/${props.sessionID}`
|
||||||
|
|
@ -441,7 +447,7 @@ export default function LegacyLayout(props: ParentProps) {
|
||||||
if (now - lastAlerted < cooldownMs) return
|
if (now - lastAlerted < cooldownMs) return
|
||||||
alertedAtBySession.set(sessionKey, now)
|
alertedAtBySession.set(sessionKey, now)
|
||||||
|
|
||||||
if (e.details.type === "permission.asked") {
|
if (e.details.type === "permission.requested") {
|
||||||
if (settings.sounds.permissionsEnabled()) {
|
if (settings.sounds.permissionsEnabled()) {
|
||||||
void playSoundById(settings.sounds.permissions())
|
void playSoundById(settings.sounds.permissions())
|
||||||
}
|
}
|
||||||
|
|
@ -450,7 +456,7 @@ export default function LegacyLayout(props: ParentProps) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (e.details.type === "question.asked") {
|
if (e.details.type === "question.requested") {
|
||||||
if (settings.notifications.agent()) {
|
if (settings.notifications.agent()) {
|
||||||
void platform.notify(title, description, href)
|
void platform.notify(title, description, href)
|
||||||
}
|
}
|
||||||
|
|
@ -874,10 +880,12 @@ export default function LegacyLayout(props: ParentProps) {
|
||||||
const index = sessions.findIndex((s) => s.id === session.id)
|
const index = sessions.findIndex((s) => s.id === session.id)
|
||||||
const nextSession = sessions[index + 1] ?? sessions[index - 1]
|
const nextSession = sessions[index + 1] ?? sessions[index - 1]
|
||||||
|
|
||||||
await serverSDK().client.session.update({
|
const capability = (await serverSDK().backend).capabilities.sessionExtrasV1
|
||||||
directory: session.directory,
|
if (!capability) return
|
||||||
|
await capability.archive({
|
||||||
|
location: { directory: session.directory },
|
||||||
sessionID: session.id,
|
sessionID: session.id,
|
||||||
time: { archived: Date.now() },
|
archivedAt: Date.now(),
|
||||||
})
|
})
|
||||||
setStore(
|
setStore(
|
||||||
produce((draft) => {
|
produce((draft) => {
|
||||||
|
|
@ -976,7 +984,7 @@ export default function LegacyLayout(props: ParentProps) {
|
||||||
title: language.t("command.session.archive"),
|
title: language.t("command.session.archive"),
|
||||||
category: language.t("command.category.session"),
|
category: language.t("command.category.session"),
|
||||||
keybind: "mod+shift+backspace",
|
keybind: "mod+shift+backspace",
|
||||||
disabled: !params.dir || !params.id,
|
disabled: !params.dir || !params.id || !canArchive(),
|
||||||
onSelect: () => {
|
onSelect: () => {
|
||||||
const session = currentSessions().find((s) => s.id === params.id)
|
const session = currentSessions().find((s) => s.id === params.id)
|
||||||
if (session) void archiveSession(session)
|
if (session) void archiveSession(session)
|
||||||
|
|
@ -1185,8 +1193,15 @@ export default function LegacyLayout(props: ParentProps) {
|
||||||
const refreshDirs = async (target?: string) => {
|
const refreshDirs = async (target?: string) => {
|
||||||
if (!target || target === root || canOpen(target)) return canOpen(target)
|
if (!target || target === root || canOpen(target)) return canOpen(target)
|
||||||
const listed = await serverSDK()
|
const listed = await serverSDK()
|
||||||
.client.worktree.list({ directory: root })
|
.backend.then((client) => {
|
||||||
.then((x) => x.data ?? [])
|
const location = { directory: root }
|
||||||
|
if (client.capabilities.worktreesV1) return client.capabilities.worktreesV1.list({ location })
|
||||||
|
if (project?.id && client.capabilities.projectCopiesV2?.directories)
|
||||||
|
return client.capabilities.projectCopiesV2
|
||||||
|
.directories({ projectID: project.id, location })
|
||||||
|
.then((items) => items.map((item) => item.directory))
|
||||||
|
return []
|
||||||
|
})
|
||||||
.catch(() => [] as string[])
|
.catch(() => [] as string[])
|
||||||
dirs = effectiveWorkspaceOrder(root, [root, ...listed], store.workspaceOrder[root])
|
dirs = effectiveWorkspaceOrder(root, [root, ...listed], store.workspaceOrder[root])
|
||||||
return canOpen(target)
|
return canOpen(target)
|
||||||
|
|
@ -1231,8 +1246,11 @@ export default function LegacyLayout(props: ParentProps) {
|
||||||
dirs.map(async (item) => ({
|
dirs.map(async (item) => ({
|
||||||
path: { directory: item },
|
path: { directory: item },
|
||||||
session: await serverSDK()
|
session: await serverSDK()
|
||||||
.client.session.list({ directory: item })
|
.backend.then((client) =>
|
||||||
.then((x) => x.data ?? [])
|
client.common.sessions
|
||||||
|
.list({ location: { directory: item } })
|
||||||
|
.then((page) => [...page.items]),
|
||||||
|
)
|
||||||
.catch(() => []),
|
.catch(() => []),
|
||||||
})),
|
})),
|
||||||
),
|
),
|
||||||
|
|
@ -1293,7 +1311,9 @@ export default function LegacyLayout(props: ParentProps) {
|
||||||
const name = next === getFilename(project.worktree) ? "" : next
|
const name = next === getFilename(project.worktree) ? "" : next
|
||||||
|
|
||||||
if (project.id && project.id !== "global") {
|
if (project.id && project.id !== "global") {
|
||||||
await serverSDK().client.project.update({ projectID: project.id, directory: project.worktree, name })
|
const editing = (await serverSDK().backend).capabilities.projectEditing
|
||||||
|
if (!editing) throw new Error("Project editing is not supported by this server")
|
||||||
|
await editing.update({ projectID: project.id, location: { directory: project.worktree }, name })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1387,8 +1407,15 @@ export default function LegacyLayout(props: ParentProps) {
|
||||||
setBusy(directory, true)
|
setBusy(directory, true)
|
||||||
|
|
||||||
const result = await serverSDK()
|
const result = await serverSDK()
|
||||||
.client.worktree.remove({ directory: root, worktreeRemoveInput: { directory } })
|
.backend.then(async (client) => {
|
||||||
.then((x) => x.data)
|
const location = { directory: root }
|
||||||
|
if (client.capabilities.worktreesV1) return client.capabilities.worktreesV1.remove({ location, directory })
|
||||||
|
const copies = client.capabilities.projectCopiesV2
|
||||||
|
const project = layout.projects.list().find((item) => item.worktree === root)
|
||||||
|
if (!copies || !project?.id) throw new Error("Project copy removal is not supported by this server")
|
||||||
|
await copies.remove({ projectID: project.id, location, directory, force: true })
|
||||||
|
return true
|
||||||
|
})
|
||||||
.catch((err) => {
|
.catch((err) => {
|
||||||
showToast({
|
showToast({
|
||||||
title: language.t("workspace.delete.failed.title"),
|
title: language.t("workspace.delete.failed.title"),
|
||||||
|
|
@ -1444,9 +1471,12 @@ export default function LegacyLayout(props: ParentProps) {
|
||||||
})
|
})
|
||||||
const dismiss = () => toaster.dismiss(progress)
|
const dismiss = () => toaster.dismiss(progress)
|
||||||
|
|
||||||
const sessions: Session[] = await serverSDK()
|
const sessions = await serverSDK()
|
||||||
.client.session.list({ directory })
|
.backend.then((client) =>
|
||||||
.then((x) => x.data ?? [])
|
client.common.sessions
|
||||||
|
.list({ location: { directory } })
|
||||||
|
.then((page) => [...page.items]),
|
||||||
|
)
|
||||||
.catch(() => [])
|
.catch(() => [])
|
||||||
|
|
||||||
clearWorkspaceTerminals(
|
clearWorkspaceTerminals(
|
||||||
|
|
@ -1456,12 +1486,26 @@ export default function LegacyLayout(props: ParentProps) {
|
||||||
serverSDK().scope,
|
serverSDK().scope,
|
||||||
)
|
)
|
||||||
await serverSDK()
|
await serverSDK()
|
||||||
.client.instance.dispose({ directory })
|
.backend.then((client) => client.capabilities.runtimeV1?.disposeLocation({ location: { directory } }))
|
||||||
.catch(() => undefined)
|
.catch(() => undefined)
|
||||||
|
|
||||||
const result = await serverSDK()
|
const result = await serverSDK()
|
||||||
.client.worktree.reset({ directory: root, worktreeResetInput: { directory } })
|
.backend.then(async (client) => {
|
||||||
.then((x) => x.data)
|
const location = { directory: root }
|
||||||
|
if (client.capabilities.worktreesV1) return client.capabilities.worktreesV1.reset({ location, directory })
|
||||||
|
const copies = client.capabilities.projectCopiesV2
|
||||||
|
const project = layout.projects.list().find((item) => item.worktree === root)
|
||||||
|
if (!copies || !project?.id) throw new Error("Project copy reset is not supported by this server")
|
||||||
|
await copies.remove({ projectID: project.id, location, directory, force: true })
|
||||||
|
await copies.create({
|
||||||
|
projectID: project.id,
|
||||||
|
location,
|
||||||
|
strategy: "git_worktree",
|
||||||
|
directory: getDirectory(directory),
|
||||||
|
name: getFilename(directory),
|
||||||
|
})
|
||||||
|
return true
|
||||||
|
})
|
||||||
.catch((err) => {
|
.catch((err) => {
|
||||||
showToast({
|
showToast({
|
||||||
title: language.t("workspace.reset.failed.title"),
|
title: language.t("workspace.reset.failed.title"),
|
||||||
|
|
@ -1482,10 +1526,14 @@ export default function LegacyLayout(props: ParentProps) {
|
||||||
.filter((session) => session.time.archived === undefined)
|
.filter((session) => session.time.archived === undefined)
|
||||||
.map((session) =>
|
.map((session) =>
|
||||||
serverSDK()
|
serverSDK()
|
||||||
.client.session.update({
|
.backend.then(async (client) => {
|
||||||
sessionID: session.id,
|
const capability = client.capabilities.sessionExtrasV1
|
||||||
directory: session.directory,
|
if (!capability) return
|
||||||
time: { archived: archivedAt },
|
await capability.archive({
|
||||||
|
location: { directory: session.directory },
|
||||||
|
sessionID: session.id,
|
||||||
|
archivedAt,
|
||||||
|
})
|
||||||
})
|
})
|
||||||
.catch(() => undefined),
|
.catch(() => undefined),
|
||||||
),
|
),
|
||||||
|
|
@ -1523,9 +1571,8 @@ export default function LegacyLayout(props: ParentProps) {
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
serverSDK()
|
serverSDK()
|
||||||
.client.vcs.status({ directory: props.directory })
|
.backend.then((client) => client.capabilities.vcs?.status({ location: { directory: props.directory } }) ?? [])
|
||||||
.then((x) => {
|
.then((files) => {
|
||||||
const files = x.data ?? []
|
|
||||||
const dirty = files.length > 0
|
const dirty = files.length > 0
|
||||||
setData({ status: "ready", dirty })
|
setData({ status: "ready", dirty })
|
||||||
})
|
})
|
||||||
|
|
@ -1582,8 +1629,11 @@ export default function LegacyLayout(props: ParentProps) {
|
||||||
|
|
||||||
const refresh = async () => {
|
const refresh = async () => {
|
||||||
const sessions = await serverSDK()
|
const sessions = await serverSDK()
|
||||||
.client.session.list({ directory: props.directory })
|
.backend.then((client) =>
|
||||||
.then((x) => x.data ?? [])
|
client.common.sessions
|
||||||
|
.list({ location: { directory: props.directory } })
|
||||||
|
.then((page) => [...page.items]),
|
||||||
|
)
|
||||||
.catch(() => [])
|
.catch(() => [])
|
||||||
const active = sessions.filter((session) => session.time.archived === undefined)
|
const active = sessions.filter((session) => session.time.archived === undefined)
|
||||||
setState({ sessions: active })
|
setState({ sessions: active })
|
||||||
|
|
@ -1591,9 +1641,8 @@ export default function LegacyLayout(props: ParentProps) {
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
serverSDK()
|
serverSDK()
|
||||||
.client.vcs.status({ directory: props.directory })
|
.backend.then((client) => client.capabilities.vcs?.status({ location: { directory: props.directory } }) ?? [])
|
||||||
.then((x) => {
|
.then((files) => {
|
||||||
const files = x.data ?? []
|
|
||||||
const dirty = files.length > 0
|
const dirty = files.length > 0
|
||||||
setState({ status: "ready", dirty })
|
setState({ status: "ready", dirty })
|
||||||
void refresh()
|
void refresh()
|
||||||
|
|
@ -1822,8 +1871,17 @@ export default function LegacyLayout(props: ParentProps) {
|
||||||
const createWorkspace = async (project: LocalProject) => {
|
const createWorkspace = async (project: LocalProject) => {
|
||||||
clearSidebarHoverState()
|
clearSidebarHoverState()
|
||||||
const created = await serverSDK()
|
const created = await serverSDK()
|
||||||
.client.worktree.create({ directory: project.worktree })
|
.backend.then((client) => {
|
||||||
.then((x) => x.data)
|
const location = { directory: project.worktree }
|
||||||
|
if (client.capabilities.worktreesV1) return client.capabilities.worktreesV1.create({ location })
|
||||||
|
const copies = client.capabilities.projectCopiesV2
|
||||||
|
const input = workspaceCopyCreateInput(project)
|
||||||
|
if (!copies || !input) throw new Error("Project copy creation is not supported by this server")
|
||||||
|
return copies.create({
|
||||||
|
...input,
|
||||||
|
location,
|
||||||
|
})
|
||||||
|
})
|
||||||
.catch((err) => {
|
.catch((err) => {
|
||||||
showToast({
|
showToast({
|
||||||
title: language.t("workspace.create.failed.title"),
|
title: language.t("workspace.create.failed.title"),
|
||||||
|
|
@ -1834,7 +1892,8 @@ export default function LegacyLayout(props: ParentProps) {
|
||||||
|
|
||||||
if (!created?.directory) return
|
if (!created?.directory) return
|
||||||
|
|
||||||
setWorkspaceName(created.directory, created.branch ?? getFilename(created.directory), project.id, created.branch)
|
const branch = "branch" in created && typeof created.branch === "string" ? created.branch : undefined
|
||||||
|
setWorkspaceName(created.directory, branch ?? getFilename(created.directory), project.id, branch)
|
||||||
|
|
||||||
const local = project.worktree
|
const local = project.worktree
|
||||||
const key = pathKey(created.directory)
|
const key = pathKey(created.directory)
|
||||||
|
|
@ -1867,6 +1926,7 @@ export default function LegacyLayout(props: ParentProps) {
|
||||||
clearHoverProjectSoon,
|
clearHoverProjectSoon,
|
||||||
prefetchSession,
|
prefetchSession,
|
||||||
archiveSession,
|
archiveSession,
|
||||||
|
canArchive,
|
||||||
workspaceName,
|
workspaceName,
|
||||||
renameWorkspace,
|
renameWorkspace,
|
||||||
editorOpen,
|
editorOpen,
|
||||||
|
|
@ -1913,6 +1973,7 @@ export default function LegacyLayout(props: ParentProps) {
|
||||||
clearHoverProjectSoon,
|
clearHoverProjectSoon,
|
||||||
prefetchSession,
|
prefetchSession,
|
||||||
archiveSession,
|
archiveSession,
|
||||||
|
canArchive,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@ import {
|
||||||
parseDeepLink,
|
parseDeepLink,
|
||||||
parseNewSessionDeepLink,
|
parseNewSessionDeepLink,
|
||||||
} from "./deep-links"
|
} from "./deep-links"
|
||||||
import { type Session } from "@opencode-ai/sdk/v2/client"
|
import type { AppSession as Session } from "@/context/backend"
|
||||||
import {
|
import {
|
||||||
childSessionOnPath,
|
childSessionOnPath,
|
||||||
closeHomeProject,
|
closeHomeProject,
|
||||||
|
|
@ -19,12 +19,22 @@ import {
|
||||||
homeSessionServerStatus,
|
homeSessionServerStatus,
|
||||||
latestRootSession,
|
latestRootSession,
|
||||||
toggleHomeProjectSelection,
|
toggleHomeProjectSelection,
|
||||||
|
workspaceCopyCreateInput,
|
||||||
} from "./helpers"
|
} from "./helpers"
|
||||||
import { pathKey } from "@/utils/path-key"
|
import { pathKey } from "@/utils/path-key"
|
||||||
import { ServerConnection } from "@/context/server"
|
import { ServerConnection } from "@/context/server"
|
||||||
|
|
||||||
const serverKey = ServerConnection.Key.make
|
const serverKey = ServerConnection.Key.make
|
||||||
|
|
||||||
|
test("builds a v2 git worktree copy request from the project root", () => {
|
||||||
|
expect(workspaceCopyCreateInput({ id: "project", worktree: "/repos/opencode" })).toEqual({
|
||||||
|
projectID: "project",
|
||||||
|
strategy: "git_worktree",
|
||||||
|
directory: "/repos/",
|
||||||
|
})
|
||||||
|
expect(workspaceCopyCreateInput({ worktree: "/repos/opencode" })).toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
const session = (input: Partial<Session> & Pick<Session, "id" | "directory">) =>
|
const session = (input: Partial<Session> & Pick<Session, "id" | "directory">) =>
|
||||||
({
|
({
|
||||||
title: "",
|
title: "",
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { getFilename } from "@opencode-ai/core/util/path"
|
import { getDirectory, getFilename } from "@opencode-ai/core/util/path"
|
||||||
import { type Session } from "@opencode-ai/sdk/v2/client"
|
import type { AppSession as Session } from "@/context/backend"
|
||||||
import { pathKey } from "@/utils/path-key"
|
import { pathKey } from "@/utils/path-key"
|
||||||
import type { ServerConnection } from "@/context/server"
|
import type { ServerConnection } from "@/context/server"
|
||||||
import type { HomeProjectSelection } from "@/context/layout"
|
import type { HomeProjectSelection } from "@/context/layout"
|
||||||
|
|
@ -9,6 +9,15 @@ type SessionStore = {
|
||||||
path: { directory: string }
|
path: { directory: string }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function workspaceCopyCreateInput(project: { id?: string; worktree: string }) {
|
||||||
|
if (!project.id) return
|
||||||
|
return {
|
||||||
|
projectID: project.id,
|
||||||
|
strategy: "git_worktree",
|
||||||
|
directory: getDirectory(project.worktree),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function sortSessions(now: number) {
|
function sortSessions(now: number) {
|
||||||
const oneMinuteAgo = now - 60 * 1000
|
const oneMinuteAgo = now - 60 * 1000
|
||||||
return (a: Session, b: Session) => {
|
return (a: Session, b: Session) => {
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import type { Session } from "@opencode-ai/sdk/v2/client"
|
import type { AppSession as Session } from "@/context/backend"
|
||||||
import { Avatar } from "@opencode-ai/ui/avatar"
|
import { Avatar } from "@opencode-ai/ui/avatar"
|
||||||
import { Icon } from "@opencode-ai/ui/icon"
|
import { Icon } from "@opencode-ai/ui/icon"
|
||||||
import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
|
import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
|
||||||
|
|
@ -87,6 +87,7 @@ export type SessionItemProps = {
|
||||||
clearHoverProjectSoon: () => void
|
clearHoverProjectSoon: () => void
|
||||||
prefetchSession: (session: Session, priority?: "high" | "low") => void
|
prefetchSession: (session: Session, priority?: "high" | "low") => void
|
||||||
archiveSession: (session: Session) => Promise<void>
|
archiveSession: (session: Session) => Promise<void>
|
||||||
|
canArchive: Accessor<boolean>
|
||||||
}
|
}
|
||||||
|
|
||||||
const SessionRow = (props: {
|
const SessionRow = (props: {
|
||||||
|
|
@ -241,7 +242,7 @@ export const SessionItem = (props: SessionItemProps): JSX.Element => {
|
||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Show when={!props.level}>
|
<Show when={!props.level && props.canArchive()}>
|
||||||
<div
|
<div
|
||||||
class="shrink-0 overflow-hidden transition-[width,opacity]"
|
class="shrink-0 overflow-hidden transition-[width,opacity]"
|
||||||
classList={{
|
classList={{
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,7 @@ import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
|
||||||
import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
|
import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
|
||||||
import { Spinner } from "@opencode-ai/ui/spinner"
|
import { Spinner } from "@opencode-ai/ui/spinner"
|
||||||
import { Tooltip } from "@opencode-ai/ui/tooltip"
|
import { Tooltip } from "@opencode-ai/ui/tooltip"
|
||||||
import { type Session } from "@opencode-ai/sdk/v2/client"
|
import type { AppSession as Session } from "@/context/backend"
|
||||||
import { type LocalProject } from "@/context/layout"
|
import { type LocalProject } from "@/context/layout"
|
||||||
import { useServerSync, useQueryOptions } from "@/context/server-sync"
|
import { useServerSync, useQueryOptions } from "@/context/server-sync"
|
||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
|
|
@ -42,6 +42,7 @@ export type WorkspaceSidebarContext = {
|
||||||
clearHoverProjectSoon: () => void
|
clearHoverProjectSoon: () => void
|
||||||
prefetchSession: (session: Session, priority?: "high" | "low") => void
|
prefetchSession: (session: Session, priority?: "high" | "low") => void
|
||||||
archiveSession: (session: Session) => Promise<void>
|
archiveSession: (session: Session) => Promise<void>
|
||||||
|
canArchive: Accessor<boolean>
|
||||||
workspaceName: (directory: string, projectId?: string, branch?: string) => string | undefined
|
workspaceName: (directory: string, projectId?: string, branch?: string) => string | undefined
|
||||||
renameWorkspace: (directory: string, next: string, projectId?: string, branch?: string) => void
|
renameWorkspace: (directory: string, next: string, projectId?: string, branch?: string) => void
|
||||||
editorOpen: (id: string) => boolean
|
editorOpen: (id: string) => boolean
|
||||||
|
|
@ -272,6 +273,7 @@ const WorkspaceSessionList = (props: {
|
||||||
clearHoverProjectSoon={props.ctx.clearHoverProjectSoon}
|
clearHoverProjectSoon={props.ctx.clearHoverProjectSoon}
|
||||||
prefetchSession={props.ctx.prefetchSession}
|
prefetchSession={props.ctx.prefetchSession}
|
||||||
archiveSession={props.ctx.archiveSession}
|
archiveSession={props.ctx.archiveSession}
|
||||||
|
canArchive={props.ctx.canArchive}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</For>
|
</For>
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,10 @@
|
||||||
import type { Project, UserMessage, VcsFileDiff } from "@opencode-ai/sdk/v2"
|
import type {
|
||||||
|
AppFilePart as FilePart,
|
||||||
|
AppProject as Project,
|
||||||
|
AppUserMessage as UserMessage,
|
||||||
|
AppVcsFileDiff as VcsFileDiff,
|
||||||
|
} from "@/context/backend"
|
||||||
|
import { getFilename } from "@opencode-ai/core/util/path"
|
||||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||||
import { createQuery, skipToken, useMutation, useQueryClient } from "@tanstack/solid-query"
|
import { createQuery, skipToken, useMutation, useQueryClient } from "@tanstack/solid-query"
|
||||||
import {
|
import {
|
||||||
|
|
@ -640,7 +646,7 @@ export default function Page() {
|
||||||
const project = sync().project
|
const project = sync().project
|
||||||
const vcs = sync().data.vcs
|
const vcs = sync().data.vcs
|
||||||
if (project?.vcs === "git") list.push("git")
|
if (project?.vcs === "git") list.push("git")
|
||||||
if (project?.vcs === "git" && vcs?.branch && vcs?.default_branch && vcs.branch !== vcs.default_branch) {
|
if (project?.vcs === "git" && vcs?.branch && vcs?.defaultBranch && vcs.branch !== vcs.defaultBranch) {
|
||||||
list.push("branch")
|
list.push("branch")
|
||||||
}
|
}
|
||||||
list.push("turn")
|
list.push("turn")
|
||||||
|
|
@ -658,7 +664,7 @@ export default function Page() {
|
||||||
})
|
})
|
||||||
const vcsKey = createMemo(
|
const vcsKey = createMemo(
|
||||||
() =>
|
() =>
|
||||||
["session-vcs", sdk().directory, sync().data.vcs?.branch ?? "", sync().data.vcs?.default_branch ?? ""] as const,
|
["session-vcs", sdk().directory, sync().data.vcs?.branch ?? "", sync().data.vcs?.defaultBranch ?? ""] as const,
|
||||||
)
|
)
|
||||||
const vcsQuery = createQuery(() => {
|
const vcsQuery = createQuery(() => {
|
||||||
const mode = vcsMode()
|
const mode = vcsMode()
|
||||||
|
|
@ -670,8 +676,13 @@ export default function Page() {
|
||||||
queryFn: mode
|
queryFn: mode
|
||||||
? () =>
|
? () =>
|
||||||
sdk()
|
sdk()
|
||||||
.client.vcs.diff({ mode })
|
.backend.then((client) =>
|
||||||
.then((result) => list(result.data))
|
client.capabilities.vcs?.diff({
|
||||||
|
location: { directory: sdk().directory },
|
||||||
|
mode: mode === "git" ? "working" : mode,
|
||||||
|
}) ?? [],
|
||||||
|
)
|
||||||
|
.then(list)
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
console.debug("[session-review] failed to load vcs diff", { mode, error })
|
console.debug("[session-review] failed to load vcs diff", { mode, error })
|
||||||
return []
|
return []
|
||||||
|
|
@ -717,9 +728,13 @@ export default function Page() {
|
||||||
staleTime: Number.POSITIVE_INFINITY,
|
staleTime: Number.POSITIVE_INFINITY,
|
||||||
retry: 2,
|
retry: 2,
|
||||||
queryFn: () =>
|
queryFn: () =>
|
||||||
sdk()
|
sdk().backend.then((client) =>
|
||||||
.client.vcs.diff({ mode, directory: scope, context })
|
client.capabilities.vcs?.diff({
|
||||||
.then((result) => result.data ?? []),
|
location: { directory: scope },
|
||||||
|
mode: mode === "git" ? "working" : mode,
|
||||||
|
context,
|
||||||
|
}) ?? [],
|
||||||
|
),
|
||||||
})
|
})
|
||||||
.then((diffs) => diffs.find((diff) => diff.file === file))
|
.then((diffs) => diffs.find((diff) => diff.file === file))
|
||||||
|
|
||||||
|
|
@ -823,10 +838,13 @@ export default function Page() {
|
||||||
}
|
}
|
||||||
|
|
||||||
const gitMutation = useMutation(() => ({
|
const gitMutation = useMutation(() => ({
|
||||||
mutationFn: () => sdk().client.project.initGit(),
|
mutationFn: async () => {
|
||||||
|
const editing = (await sdk().backend).capabilities.projectEditing
|
||||||
|
if (!editing) throw new Error("Git initialization is not supported by this server")
|
||||||
|
return editing.initGit({ location: { directory: sdk().directory } })
|
||||||
|
},
|
||||||
onSuccess: (x) => {
|
onSuccess: (x) => {
|
||||||
if (!x.data) return
|
upsert(x as Project)
|
||||||
upsert(x.data)
|
|
||||||
},
|
},
|
||||||
onError: (err) => {
|
onError: (err) => {
|
||||||
showToast({
|
showToast({
|
||||||
|
|
@ -891,13 +909,7 @@ export default function Page() {
|
||||||
)
|
)
|
||||||
|
|
||||||
const stopVcs = sdk().event.listen((evt) => {
|
const stopVcs = sdk().event.listen((evt) => {
|
||||||
if (evt.details.type !== "filesystem.changed") return
|
if (evt.details.type !== "file.changed" || evt.details.path.startsWith(".git/")) return
|
||||||
const props =
|
|
||||||
typeof evt.details.properties === "object" && evt.details.properties
|
|
||||||
? (evt.details.properties as Record<string, unknown>)
|
|
||||||
: undefined
|
|
||||||
const file = typeof props?.file === "string" ? props.file : undefined
|
|
||||||
if (!file || file.startsWith(".git/")) return
|
|
||||||
refreshVcs()
|
refreshVcs()
|
||||||
})
|
})
|
||||||
onCleanup(stopVcs)
|
onCleanup(stopVcs)
|
||||||
|
|
@ -1659,8 +1671,6 @@ export default function Page() {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const merge = (next: NonNullable<ReturnType<typeof info>>, target = sync()) => target.session.remember(next)
|
|
||||||
|
|
||||||
const roll = (sessionID: string, next: NonNullable<ReturnType<typeof info>>["revert"], target = sync()) => {
|
const roll = (sessionID: string, next: NonNullable<ReturnType<typeof info>>["revert"], target = sync()) => {
|
||||||
const session = target.session.get(sessionID)
|
const session = target.session.get(sessionID)
|
||||||
if (!session) return
|
if (!session) return
|
||||||
|
|
@ -1691,11 +1701,12 @@ export default function Page() {
|
||||||
setFollowup("failed", input.sessionID, undefined)
|
setFollowup("failed", input.sessionID, undefined)
|
||||||
|
|
||||||
const ok = await sendFollowupDraft({
|
const ok = await sendFollowupDraft({
|
||||||
client: sdk().client,
|
backend: sdk().backend,
|
||||||
sync: sync(),
|
sync: sync(),
|
||||||
serverSync: serverSync(),
|
serverSync: serverSync(),
|
||||||
draft: item,
|
draft: item,
|
||||||
optimisticBusy: item.sessionDirectory === sdk().directory,
|
optimisticBusy: item.sessionDirectory === sdk().directory,
|
||||||
|
commitRevert: !!sync().session.get(input.sessionID)?.revert,
|
||||||
}).catch((err) => {
|
}).catch((err) => {
|
||||||
setFollowup("failed", input.sessionID, input.id)
|
setFollowup("failed", input.sessionID, input.id)
|
||||||
fail(err)
|
fail(err)
|
||||||
|
|
@ -1787,13 +1798,14 @@ export default function Page() {
|
||||||
const halt = (sessionID: string) =>
|
const halt = (sessionID: string) =>
|
||||||
busy(sessionID)
|
busy(sessionID)
|
||||||
? sdk()
|
? sdk()
|
||||||
.client.session.abort({ sessionID })
|
.backend.then((client) =>
|
||||||
|
client.common.sessions.interrupt({ location: { directory: sdk().directory }, sessionID }),
|
||||||
|
)
|
||||||
.catch(() => {})
|
.catch(() => {})
|
||||||
: Promise.resolve()
|
: Promise.resolve()
|
||||||
|
|
||||||
const revertMutation = useMutation(() => ({
|
const revertMutation = useMutation(() => ({
|
||||||
mutationFn: async (input: { sessionID: string; messageID: string }) => {
|
mutationFn: async (input: { sessionID: string; messageID: string }) => {
|
||||||
const client = sdk().client
|
|
||||||
const target = sync()
|
const target = sync()
|
||||||
const last = target.session.get(input.sessionID)?.revert
|
const last = target.session.get(input.sessionID)?.revert
|
||||||
const value = draft(input.messageID)
|
const value = draft(input.messageID)
|
||||||
|
|
@ -1803,10 +1815,18 @@ export default function Page() {
|
||||||
roll(input.sessionID, { messageID: input.messageID }, target)
|
roll(input.sessionID, { messageID: input.messageID }, target)
|
||||||
prompt.set(value)
|
prompt.set(value)
|
||||||
},
|
},
|
||||||
request: () => halt(input.sessionID).then(() => client.session.revert(input)),
|
request: () =>
|
||||||
complete: (result) => {
|
halt(input.sessionID).then(() =>
|
||||||
if (result.data) merge(result.data, target)
|
sdk().backend.then((client) => {
|
||||||
},
|
const value = { ...input, location: { directory: sdk().directory } }
|
||||||
|
if (client.capabilities.sessionExtrasV1)
|
||||||
|
return client.capabilities.sessionExtrasV1.revert(value).then(() => undefined)
|
||||||
|
if (client.capabilities.sessionExtrasV2)
|
||||||
|
return client.capabilities.sessionExtrasV2.stageRevert(value).then(() => undefined)
|
||||||
|
throw new Error("Session revert is not supported by this server")
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
complete: () => undefined,
|
||||||
rollback: () => roll(input.sessionID, last, target),
|
rollback: () => roll(input.sessionID, last, target),
|
||||||
fail,
|
fail,
|
||||||
})
|
})
|
||||||
|
|
@ -1818,7 +1838,6 @@ export default function Page() {
|
||||||
const sessionID = params.id
|
const sessionID = params.id
|
||||||
if (!sessionID) return
|
if (!sessionID) return
|
||||||
|
|
||||||
const client = sdk().client
|
|
||||||
const target = sync()
|
const target = sync()
|
||||||
const next = userMessages().find((item) => item.id > id)
|
const next = userMessages().find((item) => item.id > id)
|
||||||
const last = target.session.get(sessionID)?.revert
|
const last = target.session.get(sessionID)?.revert
|
||||||
|
|
@ -1835,11 +1854,26 @@ export default function Page() {
|
||||||
},
|
},
|
||||||
request: () =>
|
request: () =>
|
||||||
!next
|
!next
|
||||||
? halt(sessionID).then(() => client.session.unrevert({ sessionID }))
|
? halt(sessionID).then(() =>
|
||||||
: halt(sessionID).then(() => client.session.revert({ sessionID, messageID: next.id })),
|
sdk().backend.then((client) => {
|
||||||
complete: (result) => {
|
const value = { location: { directory: sdk().directory }, sessionID }
|
||||||
if (result.data) merge(result.data, target)
|
if (client.capabilities.sessionExtrasV1)
|
||||||
},
|
return client.capabilities.sessionExtrasV1.clearRevert(value).then(() => undefined)
|
||||||
|
if (client.capabilities.sessionExtrasV2) return client.capabilities.sessionExtrasV2.clearRevert(value)
|
||||||
|
throw new Error("Session revert is not supported by this server")
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
: halt(sessionID).then(() =>
|
||||||
|
sdk().backend.then((client) => {
|
||||||
|
const value = { location: { directory: sdk().directory }, sessionID, messageID: next.id }
|
||||||
|
if (client.capabilities.sessionExtrasV1)
|
||||||
|
return client.capabilities.sessionExtrasV1.revert(value).then(() => undefined)
|
||||||
|
if (client.capabilities.sessionExtrasV2)
|
||||||
|
return client.capabilities.sessionExtrasV2.stageRevert(value).then(() => undefined)
|
||||||
|
throw new Error("Session revert is not supported by this server")
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
complete: () => undefined,
|
||||||
rollback: () => roll(sessionID, last, target),
|
rollback: () => roll(sessionID, last, target),
|
||||||
fail,
|
fail,
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,9 @@
|
||||||
import { describe, expect, test } from "bun:test"
|
import { describe, expect, test } from "bun:test"
|
||||||
import type { PermissionRequest, QuestionRequest, Session } from "@opencode-ai/sdk/v2/client"
|
import type {
|
||||||
|
AppPermissionRequest as PermissionRequest,
|
||||||
|
AppQuestionRequest as QuestionRequest,
|
||||||
|
AppSession as Session,
|
||||||
|
} from "@/context/backend"
|
||||||
import { sessionPermissionRequest, sessionQuestionRequest } from "./session-request-tree"
|
import { sessionPermissionRequest, sessionQuestionRequest } from "./session-request-tree"
|
||||||
|
|
||||||
const session = (input: { id: string; parentID?: string }) =>
|
const session = (input: { id: string; parentID?: string }) =>
|
||||||
|
|
@ -12,6 +16,12 @@ const permission = (id: string, sessionID: string) =>
|
||||||
({
|
({
|
||||||
id,
|
id,
|
||||||
sessionID,
|
sessionID,
|
||||||
|
action: "read",
|
||||||
|
resources: ["*"],
|
||||||
|
permission: "read",
|
||||||
|
patterns: ["*"],
|
||||||
|
always: [],
|
||||||
|
metadata: {},
|
||||||
}) as PermissionRequest
|
}) as PermissionRequest
|
||||||
|
|
||||||
const question = (id: string, sessionID: string) =>
|
const question = (id: string, sessionID: string) =>
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,9 @@
|
||||||
import { createMemo } from "solid-js"
|
import { createMemo } from "solid-js"
|
||||||
import { createStore } from "solid-js/store"
|
import { createStore } from "solid-js/store"
|
||||||
import type { PermissionRequest, QuestionRequest } from "@opencode-ai/sdk/v2"
|
import type {
|
||||||
|
AppPermissionRequest as PermissionRequest,
|
||||||
|
AppQuestionRequest as QuestionRequest,
|
||||||
|
} from "@/context/backend"
|
||||||
import { useParams } from "@solidjs/router"
|
import { useParams } from "@solidjs/router"
|
||||||
import { showToast } from "@/utils/toast"
|
import { showToast } from "@/utils/toast"
|
||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
|
|
@ -51,7 +54,14 @@ export function createSessionComposerController() {
|
||||||
|
|
||||||
setStore("responding", perm.id)
|
setStore("responding", perm.id)
|
||||||
sdk()
|
sdk()
|
||||||
.client.permission.respond({ sessionID: perm.sessionID, permissionID: perm.id, response })
|
.backend.then((client) =>
|
||||||
|
client.common.permissions.reply({
|
||||||
|
sessionID: perm.sessionID,
|
||||||
|
requestID: perm.id,
|
||||||
|
reply: response,
|
||||||
|
location: { directory: sdk().directory },
|
||||||
|
}),
|
||||||
|
)
|
||||||
.catch((err: unknown) => {
|
.catch((err: unknown) => {
|
||||||
const description = err instanceof Error ? err.message : String(err)
|
const description = err instanceof Error ? err.message : String(err)
|
||||||
showToast({ title: language.t("common.requestFailed"), description })
|
showToast({ title: language.t("common.requestFailed"), description })
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { For, Show } from "solid-js"
|
import { For, Show } from "solid-js"
|
||||||
import type { PermissionRequest } from "@opencode-ai/sdk/v2"
|
import type { AppPermissionRequest as PermissionRequest } from "@/context/backend"
|
||||||
import { Button } from "@opencode-ai/ui/button"
|
import { Button } from "@opencode-ai/ui/button"
|
||||||
import { DockPrompt } from "@opencode-ai/session-ui/dock-prompt"
|
import { DockPrompt } from "@opencode-ai/session-ui/dock-prompt"
|
||||||
import { Icon } from "@opencode-ai/ui/icon"
|
import { Icon } from "@opencode-ai/ui/icon"
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@ import { DockPrompt } from "@opencode-ai/session-ui/dock-prompt"
|
||||||
import { Icon } from "@opencode-ai/ui/icon"
|
import { Icon } from "@opencode-ai/ui/icon"
|
||||||
import { useSpring } from "@opencode-ai/ui/motion-spring"
|
import { useSpring } from "@opencode-ai/ui/motion-spring"
|
||||||
import { showToast } from "@/utils/toast"
|
import { showToast } from "@/utils/toast"
|
||||||
import type { QuestionAnswer, QuestionRequest } from "@opencode-ai/sdk/v2"
|
import type { AppQuestionAnswer as QuestionAnswer, AppQuestionRequest as QuestionRequest } from "@/context/backend"
|
||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
import { useSDK } from "@/context/sdk"
|
import { useSDK } from "@/context/sdk"
|
||||||
import { makeEventListener } from "@solid-primitives/event-listener"
|
import { makeEventListener } from "@solid-primitives/event-listener"
|
||||||
|
|
@ -223,7 +223,15 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
|
||||||
}
|
}
|
||||||
|
|
||||||
const replyMutation = useMutation(() => ({
|
const replyMutation = useMutation(() => ({
|
||||||
mutationFn: (answers: QuestionAnswer[]) => sdk().client.question.reply({ requestID: props.request.id, answers }),
|
mutationFn: (answers: QuestionAnswer[]) =>
|
||||||
|
sdk().backend.then((client) =>
|
||||||
|
client.common.questions.reply({
|
||||||
|
sessionID: props.request.sessionID,
|
||||||
|
requestID: props.request.id,
|
||||||
|
answers,
|
||||||
|
location: { directory: sdk().directory },
|
||||||
|
}),
|
||||||
|
),
|
||||||
onMutate: () => {
|
onMutate: () => {
|
||||||
props.onSubmit()
|
props.onSubmit()
|
||||||
},
|
},
|
||||||
|
|
@ -235,7 +243,14 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
|
||||||
}))
|
}))
|
||||||
|
|
||||||
const rejectMutation = useMutation(() => ({
|
const rejectMutation = useMutation(() => ({
|
||||||
mutationFn: () => sdk().client.question.reject({ requestID: props.request.id }),
|
mutationFn: () =>
|
||||||
|
sdk().backend.then((client) =>
|
||||||
|
client.common.questions.reject({
|
||||||
|
sessionID: props.request.sessionID,
|
||||||
|
requestID: props.request.id,
|
||||||
|
location: { directory: sdk().directory },
|
||||||
|
}),
|
||||||
|
),
|
||||||
onMutate: () => {
|
onMutate: () => {
|
||||||
props.onSubmit()
|
props.onSubmit()
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,8 @@
|
||||||
import type { PermissionRequest, QuestionRequest, Session } from "@opencode-ai/sdk/v2/client"
|
import type {
|
||||||
|
AppPermissionRequest as PermissionRequest,
|
||||||
|
AppQuestionRequest as QuestionRequest,
|
||||||
|
AppSession as Session,
|
||||||
|
} from "@/context/backend"
|
||||||
|
|
||||||
function sessionTreeRequest<T>(
|
function sessionTreeRequest<T>(
|
||||||
session: Session[],
|
session: Session[],
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,9 @@
|
||||||
import { createEffect, onCleanup, type JSX } from "solid-js"
|
import { createEffect, onCleanup, type JSX } from "solid-js"
|
||||||
import { makeEventListener } from "@solid-primitives/event-listener"
|
import { makeEventListener } from "@solid-primitives/event-listener"
|
||||||
import type { FileDiffInfo, VcsFileDiff } from "@opencode-ai/sdk/v2"
|
import type {
|
||||||
|
AppFileDiff as FileDiffInfo,
|
||||||
|
AppVcsFileDiff as VcsFileDiff,
|
||||||
|
} from "@/context/backend"
|
||||||
import { SessionReview } from "@opencode-ai/session-ui/session-review"
|
import { SessionReview } from "@opencode-ai/session-ui/session-review"
|
||||||
import type {
|
import type {
|
||||||
SessionReviewCommentActions,
|
SessionReviewCommentActions,
|
||||||
|
|
@ -54,8 +57,13 @@ export function SessionReviewTab(props: SessionReviewTabProps) {
|
||||||
|
|
||||||
const readFile = async (path: string) => {
|
const readFile = async (path: string) => {
|
||||||
return sdk()
|
return sdk()
|
||||||
.client.file.read({ path })
|
.backend.then(async (client) => {
|
||||||
.then((x) => x.data)
|
const input = { location: { directory: sdk().directory }, path }
|
||||||
|
if (client.capabilities.decoratedFiles) return client.capabilities.decoratedFiles.read(input)
|
||||||
|
const content = await client.common.files.read(input)
|
||||||
|
if (content.kind !== "text") return
|
||||||
|
return { type: "text" as const, content: new TextDecoder().decode(content.bytes), mimeType: content.mimeType }
|
||||||
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
console.debug("[session-review] failed to read file", { path, error })
|
console.debug("[session-review] failed to read file", { path, error })
|
||||||
return undefined
|
return undefined
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { describe, expect, test } from "bun:test"
|
import { describe, expect, test } from "bun:test"
|
||||||
import type { UserMessage } from "@opencode-ai/sdk/v2"
|
import type { AppUserMessage as UserMessage } from "@/context/backend"
|
||||||
import { resetSessionModel, restorePromptModel, syncPromptModel, syncSessionModel } from "./session-model-helpers"
|
import { resetSessionModel, restorePromptModel, syncPromptModel, syncSessionModel } from "./session-model-helpers"
|
||||||
|
|
||||||
const message = (input?: { agent?: string; model?: UserMessage["model"] }) =>
|
const message = (input?: { agent?: string; model?: UserMessage["model"] }) =>
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import type { UserMessage } from "@opencode-ai/sdk/v2"
|
import type { AppUserMessage as UserMessage } from "@/context/backend"
|
||||||
|
|
||||||
type Local = {
|
type Local = {
|
||||||
session: {
|
session: {
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@ import { ResizeHandle } from "@opencode-ai/ui/resize-handle"
|
||||||
import { Mark } from "@opencode-ai/ui/logo"
|
import { Mark } from "@opencode-ai/ui/logo"
|
||||||
import { DragDropProvider, DragDropSensors, DragOverlay, SortableProvider, closestCenter } from "@thisbeyond/solid-dnd"
|
import { DragDropProvider, DragDropSensors, DragOverlay, SortableProvider, closestCenter } from "@thisbeyond/solid-dnd"
|
||||||
import type { DragEvent } from "@thisbeyond/solid-dnd"
|
import type { DragEvent } from "@thisbeyond/solid-dnd"
|
||||||
import type { FileDiffInfo, VcsFileDiff } from "@opencode-ai/sdk/v2"
|
import type { AppFileDiff as FileDiffInfo, AppVcsFileDiff as VcsFileDiff } from "@/context/backend"
|
||||||
import { ConstrainDragYAxis, getDraggableId } from "@/utils/solid-dnd"
|
import { ConstrainDragYAxis, getDraggableId } from "@/utils/solid-dnd"
|
||||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import {
|
import {
|
||||||
createEffect,
|
createEffect,
|
||||||
createMemo,
|
createMemo,
|
||||||
|
createResource,
|
||||||
createSignal,
|
createSignal,
|
||||||
For,
|
For,
|
||||||
Index,
|
Index,
|
||||||
|
|
@ -46,12 +47,12 @@ import { TextField } from "@opencode-ai/ui/text-field"
|
||||||
import { TextReveal } from "@opencode-ai/ui/text-reveal"
|
import { TextReveal } from "@opencode-ai/ui/text-reveal"
|
||||||
import { TextShimmer } from "@opencode-ai/ui/text-shimmer"
|
import { TextShimmer } from "@opencode-ai/ui/text-shimmer"
|
||||||
import type {
|
import type {
|
||||||
AssistantMessage,
|
AppAssistantMessage as AssistantMessage,
|
||||||
Message as MessageType,
|
AppMessage as MessageType,
|
||||||
Part as PartType,
|
AppPart as PartType,
|
||||||
ToolPart,
|
AppUserMessage as UserMessage,
|
||||||
UserMessage,
|
} from "@/context/backend"
|
||||||
} from "@opencode-ai/sdk/v2"
|
type ToolPart = Extract<PartType, { type: "tool" }>
|
||||||
import { showToast } from "@/utils/toast"
|
import { showToast } from "@/utils/toast"
|
||||||
import { getDirectory, getFilename } from "@opencode-ai/core/util/path"
|
import { getDirectory, getFilename } from "@opencode-ai/core/util/path"
|
||||||
import { Popover as KobaltePopover } from "@kobalte/core/popover"
|
import { Popover as KobaltePopover } from "@kobalte/core/popover"
|
||||||
|
|
@ -290,7 +291,14 @@ export function MessageTimeline(props: {
|
||||||
const titleValue = createMemo(() => info()?.title)
|
const titleValue = createMemo(() => info()?.title)
|
||||||
const titleLabel = createMemo(() => sessionTitle(titleValue()))
|
const titleLabel = createMemo(() => sessionTitle(titleValue()))
|
||||||
const shareUrl = createMemo(() => info()?.share?.url)
|
const shareUrl = createMemo(() => info()?.share?.url)
|
||||||
const shareEnabled = createMemo(() => sync().data.config.share !== "disabled")
|
const [backend] = createResource(
|
||||||
|
() => sdk().backend,
|
||||||
|
(value) => value,
|
||||||
|
)
|
||||||
|
const shareEnabled = createMemo(
|
||||||
|
() => sync().data.config.share !== "disabled" && !!backend()?.capabilities.sessionExtrasV1,
|
||||||
|
)
|
||||||
|
const canArchive = createMemo(() => !!backend()?.capabilities.sessionExtrasV1)
|
||||||
const parentID = createMemo(() => info()?.parentID)
|
const parentID = createMemo(() => info()?.parentID)
|
||||||
const parent = createMemo(() => {
|
const parent = createMemo(() => {
|
||||||
const id = parentID()
|
const id = parentID()
|
||||||
|
|
@ -647,14 +655,22 @@ export function MessageTimeline(props: {
|
||||||
}
|
}
|
||||||
|
|
||||||
const shareMutation = useMutation(() => ({
|
const shareMutation = useMutation(() => ({
|
||||||
mutationFn: (id: string) => serverSDK().client.session.share({ sessionID: id }),
|
mutationFn: async (id: string) => {
|
||||||
|
const capability = (await serverSDK().backend).capabilities.sessionExtrasV1
|
||||||
|
if (!capability) throw new Error("Session sharing is not supported by this server")
|
||||||
|
return capability.share({ location: { directory: sdk().directory }, sessionID: id })
|
||||||
|
},
|
||||||
onError: (err) => {
|
onError: (err) => {
|
||||||
console.error("Failed to share session", err)
|
console.error("Failed to share session", err)
|
||||||
},
|
},
|
||||||
}))
|
}))
|
||||||
|
|
||||||
const unshareMutation = useMutation(() => ({
|
const unshareMutation = useMutation(() => ({
|
||||||
mutationFn: (id: string) => serverSDK().client.session.unshare({ sessionID: id }),
|
mutationFn: async (id: string) => {
|
||||||
|
const capability = (await serverSDK().backend).capabilities.sessionExtrasV1
|
||||||
|
if (!capability) throw new Error("Session sharing is not supported by this server")
|
||||||
|
return capability.unshare({ location: { directory: sdk().directory }, sessionID: id })
|
||||||
|
},
|
||||||
onError: (err) => {
|
onError: (err) => {
|
||||||
console.error("Failed to unshare session", err)
|
console.error("Failed to unshare session", err)
|
||||||
},
|
},
|
||||||
|
|
@ -662,7 +678,15 @@ export function MessageTimeline(props: {
|
||||||
|
|
||||||
const titleMutation = useMutation(() => ({
|
const titleMutation = useMutation(() => ({
|
||||||
mutationFn: (input: { id: string; title: string }) =>
|
mutationFn: (input: { id: string; title: string }) =>
|
||||||
sdk().client.session.update({ sessionID: input.id, title: input.title }),
|
sdk().backend.then((client) => {
|
||||||
|
const capability = client.capabilities.sessionActionsV1
|
||||||
|
if (!capability) throw new Error("Session renaming is not supported by this server")
|
||||||
|
return capability.rename({
|
||||||
|
location: { directory: sdk().directory },
|
||||||
|
sessionID: input.id,
|
||||||
|
title: input.title,
|
||||||
|
})
|
||||||
|
}),
|
||||||
onSuccess: (_, input) => {
|
onSuccess: (_, input) => {
|
||||||
sync().set(
|
sync().set(
|
||||||
produce((draft) => {
|
produce((draft) => {
|
||||||
|
|
@ -806,7 +830,11 @@ export function MessageTimeline(props: {
|
||||||
const nextSession = index === -1 ? undefined : (sessions[index + 1] ?? sessions[index - 1])
|
const nextSession = index === -1 ? undefined : (sessions[index + 1] ?? sessions[index - 1])
|
||||||
|
|
||||||
await sdk()
|
await sdk()
|
||||||
.client.session.update({ sessionID, time: { archived: Date.now() } })
|
.backend.then(async (client) => {
|
||||||
|
const capability = client.capabilities.sessionExtrasV1
|
||||||
|
if (!capability) throw new Error("Session archiving is not supported by this server")
|
||||||
|
await capability.archive({ location: { directory: sdk().directory }, sessionID, archivedAt: Date.now() })
|
||||||
|
})
|
||||||
.then(() => {
|
.then(() => {
|
||||||
sync().set(
|
sync().set(
|
||||||
produce((draft) => {
|
produce((draft) => {
|
||||||
|
|
@ -835,8 +863,11 @@ export function MessageTimeline(props: {
|
||||||
const nextSession = index === -1 ? undefined : (sessions[index + 1] ?? sessions[index - 1])
|
const nextSession = index === -1 ? undefined : (sessions[index + 1] ?? sessions[index - 1])
|
||||||
|
|
||||||
const result = await sdk()
|
const result = await sdk()
|
||||||
.client.session.delete({ sessionID })
|
.backend.then((client) => {
|
||||||
.then((x) => x.data)
|
const capability = client.capabilities.sessionActionsV1
|
||||||
|
if (!capability) throw new Error("Session deletion is not supported by this server")
|
||||||
|
return capability.remove({ location: { directory: sdk().directory }, sessionID })
|
||||||
|
})
|
||||||
.catch((err) => {
|
.catch((err) => {
|
||||||
showToast({
|
showToast({
|
||||||
title: language.t("session.delete.failed.title"),
|
title: language.t("session.delete.failed.title"),
|
||||||
|
|
@ -1203,7 +1234,10 @@ export function MessageTimeline(props: {
|
||||||
return (
|
return (
|
||||||
<TimelineRowFrame row={retryRow}>
|
<TimelineRowFrame row={retryRow}>
|
||||||
<div data-slot="session-turn-message-container" class="w-full px-4 md:px-5">
|
<div data-slot="session-turn-message-container" class="w-full px-4 md:px-5">
|
||||||
<SessionRetry status={sessionStatus()} show={activeMessageID() === retryRow().userMessageID} />
|
<SessionRetry
|
||||||
|
status={((status) => (status.type === "running" ? { type: "busy" as const } : status))(sessionStatus())}
|
||||||
|
show={activeMessageID() === retryRow().userMessageID}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</TimelineRowFrame>
|
</TimelineRowFrame>
|
||||||
)
|
)
|
||||||
|
|
@ -1549,9 +1583,11 @@ export function MessageTimeline(props: {
|
||||||
</DropdownMenu.ItemLabel>
|
</DropdownMenu.ItemLabel>
|
||||||
</DropdownMenu.Item>
|
</DropdownMenu.Item>
|
||||||
</Show>
|
</Show>
|
||||||
<DropdownMenu.Item onSelect={() => void archiveSession(id)}>
|
<Show when={canArchive()}>
|
||||||
<DropdownMenu.ItemLabel>{language.t("common.archive")}</DropdownMenu.ItemLabel>
|
<DropdownMenu.Item onSelect={() => void archiveSession(id)}>
|
||||||
</DropdownMenu.Item>
|
<DropdownMenu.ItemLabel>{language.t("common.archive")}</DropdownMenu.ItemLabel>
|
||||||
|
</DropdownMenu.Item>
|
||||||
|
</Show>
|
||||||
<DropdownMenu.Separator />
|
<DropdownMenu.Separator />
|
||||||
<DropdownMenu.Item
|
<DropdownMenu.Item
|
||||||
onSelect={() => dialog.show(() => <DialogDeleteSession sessionID={id} />)}
|
onSelect={() => dialog.show(() => <DialogDeleteSession sessionID={id} />)}
|
||||||
|
|
@ -1620,9 +1656,11 @@ export function MessageTimeline(props: {
|
||||||
{language.t("session.share.action.share")}...
|
{language.t("session.share.action.share")}...
|
||||||
</MenuV2.Item>
|
</MenuV2.Item>
|
||||||
</Show>
|
</Show>
|
||||||
<MenuV2.Item onSelect={() => void archiveSession(id)}>
|
<Show when={canArchive()}>
|
||||||
{language.t("common.archive")}
|
<MenuV2.Item onSelect={() => void archiveSession(id)}>
|
||||||
</MenuV2.Item>
|
{language.t("common.archive")}
|
||||||
|
</MenuV2.Item>
|
||||||
|
</Show>
|
||||||
<MenuV2.Separator />
|
<MenuV2.Separator />
|
||||||
<MenuV2.Item onSelect={() => dialog.show(() => <DialogDeleteSession sessionID={id} />)}>
|
<MenuV2.Item onSelect={() => dialog.show(() => <DialogDeleteSession sessionID={id} />)}>
|
||||||
{language.t("common.delete")}...
|
{language.t("common.delete")}...
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,9 @@
|
||||||
import { describe, expect, test } from "bun:test"
|
import { describe, expect, test } from "bun:test"
|
||||||
import type { AssistantMessage, Message, UserMessage } from "@opencode-ai/sdk/v2"
|
import type {
|
||||||
|
AppAssistantMessage as AssistantMessage,
|
||||||
|
AppMessage as Message,
|
||||||
|
AppUserMessage as UserMessage,
|
||||||
|
} from "@/context/backend"
|
||||||
import { isTimelineReady, loadOlderTimeline, selectUserMessages, selectVisibleUserMessages } from "./model"
|
import { isTimelineReady, loadOlderTimeline, selectUserMessages, selectVisibleUserMessages } from "./model"
|
||||||
|
|
||||||
const user = (id: string) => ({ id, role: "user" }) as UserMessage
|
const user = (id: string) => ({ id, role: "user" }) as UserMessage
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import type { Message, UserMessage } from "@opencode-ai/sdk/v2"
|
import type { AppMessage as Message, AppUserMessage as UserMessage } from "@/context/backend"
|
||||||
import { createMemo, createResource, onCleanup, untrack, type Accessor } from "solid-js"
|
import { createMemo, createResource, onCleanup, untrack, type Accessor } from "solid-js"
|
||||||
import { useServerSync } from "@/context/server-sync"
|
import { useServerSync } from "@/context/server-sync"
|
||||||
import { useSync } from "@/context/sync"
|
import { useSync } from "@/context/sync"
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,11 @@
|
||||||
import { Binary } from "@opencode-ai/core/util/binary"
|
import { Binary } from "@opencode-ai/core/util/binary"
|
||||||
import type { AssistantMessage, Message, Part, SessionStatus, UserMessage } from "@opencode-ai/sdk/v2"
|
import type {
|
||||||
|
AppAssistantMessage as AssistantMessage,
|
||||||
|
AppMessage as Message,
|
||||||
|
AppPart as Part,
|
||||||
|
AppUserMessage as UserMessage,
|
||||||
|
SessionActivity as SessionStatus,
|
||||||
|
} from "@/context/backend"
|
||||||
import { createMemo, mapArray, type Accessor } from "solid-js"
|
import { createMemo, mapArray, type Accessor } from "solid-js"
|
||||||
import { reuseTimelineRows } from "./row-reconciliation"
|
import { reuseTimelineRows } from "./row-reconciliation"
|
||||||
import { Timeline, TimelineRow } from "./rows"
|
import { Timeline, TimelineRow } from "./rows"
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,10 @@
|
||||||
import { parseCommentNote, readCommentMetadata } from "@/utils/comment-note"
|
import { parseCommentNote, readCommentMetadata } from "@/utils/comment-note"
|
||||||
import { AssistantMessage, Part, SessionStatus, SnapshotFileDiff, UserMessage } from "@opencode-ai/sdk/v2"
|
import type {
|
||||||
|
AppAssistantMessage as AssistantMessage,
|
||||||
|
AppPart as Part,
|
||||||
|
AppUserMessage as UserMessage,
|
||||||
|
SessionActivity as SessionStatus,
|
||||||
|
} from "@/context/backend"
|
||||||
import { groupParts, renderable, type PartGroup } from "@opencode-ai/session-ui/message-part"
|
import { groupParts, renderable, type PartGroup } from "@opencode-ai/session-ui/message-part"
|
||||||
import { TimelineRow, type SummaryDiff } from "./timeline-row"
|
import { TimelineRow, type SummaryDiff } from "./timeline-row"
|
||||||
|
|
||||||
|
|
@ -119,7 +124,7 @@ export namespace Timeline {
|
||||||
assistantGroupIndex += 1
|
assistantGroupIndex += 1
|
||||||
})
|
})
|
||||||
|
|
||||||
if (isActive && status === "busy" && !error && (showReasoning ? assistantPartRefs.length === 0 : true)) {
|
if (isActive && (status === "busy" || status === "running") && !error && (showReasoning ? assistantPartRefs.length === 0 : true)) {
|
||||||
const heading = assistantMessages
|
const heading = assistantMessages
|
||||||
.flatMap((message) => getMessageParts(message.id))
|
.flatMap((message) => getMessageParts(message.id))
|
||||||
.map((part) => (part.type === "reasoning" && part.text ? reasoningHeading(part.text) : undefined))
|
.map((part) => (part.type === "reasoning" && part.text ? reasoningHeading(part.text) : undefined))
|
||||||
|
|
@ -167,7 +172,9 @@ export namespace Timeline {
|
||||||
return rows
|
return rows
|
||||||
}
|
}
|
||||||
|
|
||||||
function isSummaryDiff(value: SnapshotFileDiff): value is SummaryDiff {
|
function isSummaryDiff(
|
||||||
|
value: NonNullable<UserMessage["summary"]>["diffs"][number],
|
||||||
|
): value is SummaryDiff {
|
||||||
return typeof value.file === "string"
|
return typeof value.file === "string"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import type { SnapshotFileDiff } from "@opencode-ai/sdk/v2"
|
import type { AppFileDiff as SnapshotFileDiff } from "@/context/backend"
|
||||||
import type { PartGroup } from "@opencode-ai/session-ui/message-part"
|
import type { PartGroup } from "@opencode-ai/session-ui/message-part"
|
||||||
import { Data, Equal } from "effect"
|
import { Data, Equal } from "effect"
|
||||||
|
|
||||||
|
|
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue