diff --git a/packages/opencode/specs/tui-plugins.md b/packages/opencode/specs/tui-plugins.md index c5420586e32..943125b79c6 100644 --- a/packages/opencode/specs/tui-plugins.md +++ b/packages/opencode/specs/tui-plugins.md @@ -202,7 +202,7 @@ Top-level API groups exposed to `tui(api, options, meta)`: - `api.kv.get`, `set`, `ready` - `api.state` - `api.theme.current`, `selected`, `has`, `set`, `install`, `mode`, `ready` -- `api.client`, `api.scopedClient(workspaceID?)`, `api.workspace.current()`, `api.workspace.set(workspaceID?)` +- `api.client` - `api.event.on(type, handler)` - `api.renderer` - `api.slots.register(plugin)` @@ -270,7 +270,6 @@ Command behavior: - `provider` - `path.{state,config,worktree,directory}` - `vcs?.branch` - - `workspace.list()` / `workspace.get(workspaceID)` - `session.count()` - `session.diff(sessionID)` - `session.todo(sessionID)` @@ -282,8 +281,6 @@ Command behavior: - `lsp()` - `mcp()` - `api.client` always reflects the current runtime client. -- `api.scopedClient(workspaceID?)` creates or reuses a client bound to a workspace. -- `api.workspace.set(...)` rebinds the active workspace; `api.client` follows that rebind. - `api.event.on(type, handler)` subscribes to the TUI event stream and returns an unsubscribe function. - `api.renderer` exposes the raw `CliRenderer`. diff --git a/packages/opencode/src/cli/cmd/tui/app.tsx b/packages/opencode/src/cli/cmd/tui/app.tsx index 2f8d1f7bbb1..8c4f596fd33 100644 --- a/packages/opencode/src/cli/cmd/tui/app.tsx +++ b/packages/opencode/src/cli/cmd/tui/app.tsx @@ -22,7 +22,7 @@ import { DialogProvider, useDialog } from "@tui/ui/dialog" import { DialogProvider as DialogProviderList } from "@tui/component/dialog-provider" import { ErrorComponent } from "@tui/component/error-component" import { PluginRouteMissing } from "@tui/component/plugin-route-missing" -import { ProjectProvider } from "@tui/context/project" +import { ProjectProvider, useProject } from "@tui/context/project" import { useEvent } from "@tui/context/event" import { SDKProvider, useSDK } from "@tui/context/sdk" import { StartupLoading } from "@tui/component/startup-loading" @@ -36,7 +36,6 @@ import { DialogHelp } from "./ui/dialog-help" import { CommandProvider, useCommandDialog } from "@tui/component/dialog-command" import { DialogAgent } from "@tui/component/dialog-agent" import { DialogSessionList } from "@tui/component/dialog-session-list" -import { DialogWorkspaceList } from "@tui/component/dialog-workspace-list" import { DialogConsoleOrg } from "@tui/component/dialog-console-org" import { KeybindProvider, useKeybind } from "@tui/context/keybind" import { ThemeProvider, useTheme } from "@tui/context/theme" @@ -465,22 +464,6 @@ function App(props: { onSnapshot?: () => Promise }) { dialog.replace(() => ) }, }, - ...(Flag.OPENCODE_EXPERIMENTAL_WORKSPACES - ? [ - { - title: "Manage workspaces", - value: "workspace.list", - category: "Workspace", - suggested: true, - slash: { - name: "workspaces", - }, - onSelect: () => { - dialog.replace(() => ) - }, - }, - ] - : []), { title: "New session", suggested: route.data.type === "session", diff --git a/packages/opencode/src/cli/cmd/tui/component/dialog-session-list.tsx b/packages/opencode/src/cli/cmd/tui/component/dialog-session-list.tsx index 775969bfcb3..9ecb21e82a5 100644 --- a/packages/opencode/src/cli/cmd/tui/component/dialog-session-list.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/dialog-session-list.tsx @@ -2,25 +2,31 @@ import { useDialog } from "@tui/ui/dialog" import { DialogSelect } from "@tui/ui/dialog-select" import { useRoute } from "@tui/context/route" import { useSync } from "@tui/context/sync" -import { createMemo, createSignal, createResource, onMount, Show } from "solid-js" +import { createMemo, createResource, createSignal, onMount } from "solid-js" import { Locale } from "@/util/locale" +import { useProject } from "@tui/context/project" import { useKeybind } from "../context/keybind" import { useTheme } from "../context/theme" import { useSDK } from "../context/sdk" +import { Flag } from "@/flag/flag" import { DialogSessionRename } from "./dialog-session-rename" -import { useKV } from "../context/kv" +import { Keybind } from "@/util/keybind" import { createDebouncedSignal } from "../util/signal" +import { useToast } from "../ui/toast" +import { DialogWorkspaceCreate, openWorkspaceSession } from "./dialog-workspace-create" import { Spinner } from "./spinner" +type WorkspaceStatus = "connected" | "connecting" | "disconnected" | "error" + export function DialogSessionList() { const dialog = useDialog() const route = useRoute() const sync = useSync() + const project = useProject() const keybind = useKeybind() const { theme } = useTheme() const sdk = useSDK() - const kv = useKV() - + const toast = useToast() const [toDelete, setToDelete] = createSignal() const [search, setSearch] = createDebouncedSignal("", 150) @@ -31,15 +37,68 @@ export function DialogSessionList() { }) const currentSessionID = createMemo(() => (route.data.type === "session" ? route.data.sessionID : undefined)) - const sessions = createMemo(() => searchResults() ?? sync.data.session) + function createWorkspace() { + dialog.replace(() => ( + + openWorkspaceSession({ + dialog, + route, + sdk, + sync, + toast, + workspaceID, + }) + } + /> + )) + } + const options = createMemo(() => { const today = new Date().toDateString() return sessions() .filter((x) => x.parentID === undefined) .toSorted((a, b) => b.time.updated - a.time.updated) .map((x) => { + const workspace = x.workspaceID ? project.workspace.get(x.workspaceID) : undefined + + let workspaceStatus: WorkspaceStatus | null = null + if (x.workspaceID) { + workspaceStatus = project.workspace.status(x.workspaceID) || "error" + } + + let footer = "" + if (Flag.OPENCODE_EXPERIMENTAL_WORKSPACES) { + if (x.workspaceID) { + let desc = "unknown" + if (workspace) { + desc = `${workspace.type}: ${workspace.name}` + } + + footer = ( + <> + {desc}{" "} + + ■ + + + ) + } + } else { + footer = Locale.time(x.time.updated) + } + const date = new Date(x.time.updated) let category = date.toDateString() if (category === today) { @@ -53,7 +112,7 @@ export function DialogSessionList() { bg: isDeleting ? theme.error : undefined, value: x.id, category, - footer: Locale.time(x.time.updated), + footer, gutter: isWorking ? : undefined, } }) @@ -102,6 +161,15 @@ export function DialogSessionList() { dialog.replace(() => ) }, }, + { + keybind: Keybind.parse("ctrl+w")[0], + title: "new workspace", + side: "right", + disabled: !Flag.OPENCODE_EXPERIMENTAL_WORKSPACES, + onTrigger: () => { + createWorkspace() + }, + }, ]} /> ) diff --git a/packages/opencode/src/cli/cmd/tui/component/dialog-workspace-create.tsx b/packages/opencode/src/cli/cmd/tui/component/dialog-workspace-create.tsx new file mode 100644 index 00000000000..40cc1013e09 --- /dev/null +++ b/packages/opencode/src/cli/cmd/tui/component/dialog-workspace-create.tsx @@ -0,0 +1,121 @@ +import { createOpencodeClient } from "@opencode-ai/sdk/v2" +import { useDialog } from "@tui/ui/dialog" +import { DialogSelect } from "@tui/ui/dialog-select" +import { useRoute } from "@tui/context/route" +import { useSync } from "@tui/context/sync" +import { useProject } from "@tui/context/project" +import { createMemo, createSignal, onMount } from "solid-js" +import { setTimeout as sleep } from "node:timers/promises" +import { useSDK } from "../context/sdk" +import { useToast } from "../ui/toast" + +function scoped(sdk: ReturnType, sync: ReturnType, workspaceID: string) { + return createOpencodeClient({ + baseUrl: sdk.url, + fetch: sdk.fetch, + directory: sync.path.directory || sdk.directory, + experimental_workspaceID: workspaceID, + }) +} + +export async function openWorkspaceSession(input: { + dialog: ReturnType + route: ReturnType + sdk: ReturnType + sync: ReturnType + toast: ReturnType + workspaceID: string +}) { + const client = scoped(input.sdk, input.sync, input.workspaceID) + while (true) { + const result = await client.session.create({ workspaceID: input.workspaceID }).catch(() => undefined) + if (!result) { + input.toast.show({ + message: "Failed to create workspace session", + variant: "error", + }) + return + } + if (result.response.status >= 500 && result.response.status < 600) { + await sleep(1000) + continue + } + if (!result.data) { + input.toast.show({ + message: "Failed to create workspace session", + variant: "error", + }) + return + } + input.route.navigate({ + type: "session", + sessionID: result.data.id, + }) + input.dialog.clear() + return + } +} + +export function DialogWorkspaceCreate(props: { onSelect: (workspaceID: string) => Promise | void }) { + const dialog = useDialog() + const sync = useSync() + const project = useProject() + const sdk = useSDK() + const toast = useToast() + const [creating, setCreating] = createSignal() + + onMount(() => { + dialog.setSize("medium") + }) + + const options = createMemo(() => { + const type = creating() + if (type) { + return [ + { + title: `Creating ${type} workspace...`, + value: "creating" as const, + description: "This can take a while for remote environments", + }, + ] + } + return [ + { + title: "Worktree", + value: "worktree" as const, + description: "Create a local git worktree", + }, + ] + }) + + const create = async (type: string) => { + if (creating()) return + setCreating(type) + + const result = await sdk.client.experimental.workspace.create({ type, branch: null }).catch(() => undefined) + const workspace = result?.data + if (!workspace) { + setCreating(undefined) + toast.show({ + message: "Failed to create workspace", + variant: "error", + }) + return + } + await project.workspace.sync() + await props.onSelect(workspace.id) + setCreating(undefined) + } + + return ( + { + if (option.value === "creating") return + void create(option.value) + }} + /> + ) +} diff --git a/packages/opencode/src/cli/cmd/tui/component/dialog-workspace-list.tsx b/packages/opencode/src/cli/cmd/tui/component/dialog-workspace-list.tsx deleted file mode 100644 index 037cebb7297..00000000000 --- a/packages/opencode/src/cli/cmd/tui/component/dialog-workspace-list.tsx +++ /dev/null @@ -1,319 +0,0 @@ -import { useDialog } from "@tui/ui/dialog" -import { DialogSelect } from "@tui/ui/dialog-select" -import { useProject } from "@tui/context/project" -import { useRoute } from "@tui/context/route" -import { useSync } from "@tui/context/sync" -import { createEffect, createMemo, createSignal, onMount } from "solid-js" -import { createOpencodeClient, type Session } from "@opencode-ai/sdk/v2" -import { useSDK } from "../context/sdk" -import { useToast } from "../ui/toast" -import { useKeybind } from "../context/keybind" -import { DialogSessionList } from "./workspace/dialog-session-list" -import { setTimeout as sleep } from "node:timers/promises" - -function scoped(sdk: ReturnType, sync: ReturnType, workspaceID?: string) { - return createOpencodeClient({ - baseUrl: sdk.url, - fetch: sdk.fetch, - directory: sync.path.directory || sdk.directory, - experimental_workspaceID: workspaceID, - }) -} - -async function openWorkspace(input: { - dialog: ReturnType - route: ReturnType - sdk: ReturnType - sync: ReturnType - toast: ReturnType - workspaceID: string - forceCreate?: boolean -}) { - const cacheSession = (session: Session) => { - input.sync.set( - "session", - [...input.sync.data.session.filter((item) => item.id !== session.id), session].toSorted((a, b) => - a.id.localeCompare(b.id), - ), - ) - } - - const client = scoped(input.sdk, input.sync, input.workspaceID) - const listed = input.forceCreate ? undefined : await client.session.list({ roots: true, limit: 1 }) - const session = listed?.data?.[0] - if (session?.id) { - cacheSession(session) - input.route.navigate({ - type: "session", - sessionID: session.id, - }) - input.dialog.clear() - return - } - let created: Session | undefined - while (!created) { - const result = await client.session.create({ workspaceID: input.workspaceID }).catch(() => undefined) - if (!result) { - input.toast.show({ - message: "Failed to open workspace", - variant: "error", - }) - return - } - if (result.response.status >= 500 && result.response.status < 600) { - await sleep(1000) - continue - } - if (!result.data) { - input.toast.show({ - message: "Failed to open workspace", - variant: "error", - }) - return - } - created = result.data - } - cacheSession(created) - input.route.navigate({ - type: "session", - sessionID: created.id, - }) - input.dialog.clear() -} - -function DialogWorkspaceCreate(props: { onSelect: (workspaceID: string) => Promise }) { - const dialog = useDialog() - const sync = useSync() - const sdk = useSDK() - const toast = useToast() - const [creating, setCreating] = createSignal() - - onMount(() => { - dialog.setSize("medium") - }) - - const options = createMemo(() => { - const type = creating() - if (type) { - return [ - { - title: `Creating ${type} workspace...`, - value: "creating" as const, - description: "This can take a while for remote environments", - }, - ] - } - return [ - { - title: "Worktree", - value: "worktree" as const, - description: "Create a local git worktree", - }, - ] - }) - - const createWorkspace = async (type: string) => { - if (creating()) return - setCreating(type) - - const result = await sdk.client.experimental.workspace.create({ type, branch: null }).catch((err) => { - console.log(err) - return undefined - }) - console.log(JSON.stringify(result, null, 2)) - const workspace = result?.data - if (!workspace) { - setCreating(undefined) - toast.show({ - message: "Failed to create workspace", - variant: "error", - }) - return - } - await sync.workspace.sync() - await props.onSelect(workspace.id) - setCreating(undefined) - } - - return ( - { - if (option.value === "creating") return - void createWorkspace(option.value) - }} - /> - ) -} - -export function DialogWorkspaceList() { - const dialog = useDialog() - const project = useProject() - const route = useRoute() - const sync = useSync() - const sdk = useSDK() - const toast = useToast() - const keybind = useKeybind() - const [toDelete, setToDelete] = createSignal() - const [counts, setCounts] = createSignal>({}) - - const open = (workspaceID: string, forceCreate?: boolean) => - openWorkspace({ - dialog, - route, - sdk, - sync, - toast, - workspaceID, - forceCreate, - }) - - async function selectWorkspace(workspaceID: string | null) { - if (workspaceID == null) { - project.workspace.set(undefined) - if (localCount() > 0) { - dialog.replace(() => ) - return - } - route.navigate({ - type: "home", - }) - dialog.clear() - return - } - const count = counts()[workspaceID] - if (count && count > 0) { - dialog.replace(() => ) - return - } - - if (count === 0) { - await open(workspaceID) - return - } - const client = scoped(sdk, sync, workspaceID) - const listed = await client.session.list({ roots: true, limit: 1 }).catch(() => undefined) - if (listed?.data?.length) { - dialog.replace(() => ) - return - } - await open(workspaceID) - } - - const currentWorkspaceID = createMemo(() => project.workspace.current()) - - const localCount = createMemo( - () => sync.data.session.filter((session) => !session.workspaceID && !session.parentID).length, - ) - - let run = 0 - createEffect(() => { - const workspaces = sync.data.workspaceList - const next = ++run - if (!workspaces.length) { - setCounts({}) - return - } - setCounts(Object.fromEntries(workspaces.map((workspace) => [workspace.id, undefined]))) - void Promise.all( - workspaces.map(async (workspace) => { - const client = scoped(sdk, sync, workspace.id) - const result = await client.session.list({ roots: true }).catch(() => undefined) - return [workspace.id, result ? (result.data?.length ?? 0) : null] as const - }), - ).then((entries) => { - if (run !== next) return - setCounts(Object.fromEntries(entries)) - }) - }) - - const options = createMemo(() => [ - { - title: "Local", - value: null, - category: "Workspace", - description: "Use the local machine", - footer: `${localCount()} session${localCount() === 1 ? "" : "s"}`, - }, - ...sync.data.workspaceList.map((workspace) => { - const count = counts()[workspace.id] - return { - title: - toDelete() === workspace.id - ? `Delete ${workspace.id}? Press ${keybind.print("session_delete")} again` - : workspace.id, - value: workspace.id, - category: workspace.type, - description: workspace.branch ? `Branch ${workspace.branch}` : undefined, - footer: - count === undefined - ? "Loading sessions..." - : count === null - ? "Sessions unavailable" - : `${count} session${count === 1 ? "" : "s"}`, - } - }), - { - title: "+ New workspace", - value: "__create__", - category: "Actions", - description: "Create a new workspace", - }, - ]) - - onMount(() => { - dialog.setSize("large") - void sync.workspace.sync() - }) - - return ( - { - setToDelete(undefined) - }} - onSelect={(option) => { - setToDelete(undefined) - if (option.value === "__create__") { - dialog.replace(() => open(workspaceID, true)} />) - return - } - void selectWorkspace(option.value) - }} - keybind={[ - { - keybind: keybind.all.session_delete?.[0], - title: "delete", - onTrigger: async (option) => { - if (option.value === "__create__" || option.value === null) return - if (toDelete() !== option.value) { - setToDelete(option.value) - return - } - const result = await sdk.client.experimental.workspace.remove({ id: option.value }).catch(() => undefined) - setToDelete(undefined) - if (result?.error) { - toast.show({ - message: "Failed to delete workspace", - variant: "error", - }) - return - } - if (currentWorkspaceID() === option.value) { - project.workspace.set(undefined) - route.navigate({ - type: "home", - }) - } - await sync.workspace.sync() - }, - }, - ]} - /> - ) -} diff --git a/packages/opencode/src/cli/cmd/tui/component/workspace/dialog-session-list.tsx b/packages/opencode/src/cli/cmd/tui/component/workspace/dialog-session-list.tsx deleted file mode 100644 index 326f094a56f..00000000000 --- a/packages/opencode/src/cli/cmd/tui/component/workspace/dialog-session-list.tsx +++ /dev/null @@ -1,151 +0,0 @@ -import { useDialog } from "@tui/ui/dialog" -import { DialogSelect } from "@tui/ui/dialog-select" -import { useRoute } from "@tui/context/route" -import { useSync } from "@tui/context/sync" -import { createMemo, createSignal, createResource, onMount, Show } from "solid-js" -import { Locale } from "@/util/locale" -import { useKeybind } from "../../context/keybind" -import { useTheme } from "../../context/theme" -import { useSDK } from "../../context/sdk" -import { DialogSessionRename } from "../dialog-session-rename" -import { useKV } from "../../context/kv" -import { createDebouncedSignal } from "../../util/signal" -import { Spinner } from "../spinner" -import { useToast } from "../../ui/toast" - -export function DialogSessionList(props: { workspaceID?: string; localOnly?: boolean } = {}) { - const dialog = useDialog() - const route = useRoute() - const sync = useSync() - const keybind = useKeybind() - const { theme } = useTheme() - const sdk = useSDK() - const kv = useKV() - const toast = useToast() - const [toDelete, setToDelete] = createSignal() - const [search, setSearch] = createDebouncedSignal("", 150) - - const [listed, listedActions] = createResource( - () => props.workspaceID, - async (workspaceID) => { - if (!workspaceID) return undefined - const result = await sdk.client.session.list({ roots: true }) - return result.data ?? [] - }, - ) - - const [searchResults] = createResource(search, async (query) => { - if (!query || props.localOnly) return undefined - const result = await sdk.client.session.list({ - search: query, - limit: 30, - ...(props.workspaceID ? { roots: true } : {}), - }) - return result.data ?? [] - }) - - const currentSessionID = createMemo(() => (route.data.type === "session" ? route.data.sessionID : undefined)) - - const sessions = createMemo(() => { - if (searchResults()) return searchResults()! - if (props.workspaceID) return listed() ?? [] - if (props.localOnly) return sync.data.session.filter((session) => !session.workspaceID) - return sync.data.session - }) - - const options = createMemo(() => { - const today = new Date().toDateString() - return sessions() - .filter((x) => { - if (x.parentID !== undefined) return false - if (props.workspaceID && listed()) return true - if (props.workspaceID) return x.workspaceID === props.workspaceID - if (props.localOnly) return !x.workspaceID - return true - }) - .toSorted((a, b) => b.time.updated - a.time.updated) - .map((x) => { - const date = new Date(x.time.updated) - let category = date.toDateString() - if (category === today) { - category = "Today" - } - const isDeleting = toDelete() === x.id - const status = sync.data.session_status?.[x.id] - const isWorking = status?.type === "busy" - return { - title: isDeleting ? `Press ${keybind.print("session_delete")} again to confirm` : x.title, - bg: isDeleting ? theme.error : undefined, - value: x.id, - category, - footer: Locale.time(x.time.updated), - gutter: isWorking ? : undefined, - } - }) - }) - - onMount(() => { - dialog.setSize("large") - }) - - return ( - { - setToDelete(undefined) - }} - onSelect={(option) => { - route.navigate({ - type: "session", - sessionID: option.value, - }) - dialog.clear() - }} - keybind={[ - { - keybind: keybind.all.session_delete?.[0], - title: "delete", - onTrigger: async (option) => { - if (toDelete() === option.value) { - const deleted = await sdk.client.session - .delete({ - sessionID: option.value, - }) - .then(() => true) - .catch(() => false) - setToDelete(undefined) - if (!deleted) { - toast.show({ - message: "Failed to delete session", - variant: "error", - }) - return - } - if (props.workspaceID) { - listedActions.mutate((sessions) => sessions?.filter((session) => session.id !== option.value)) - return - } - sync.set( - "session", - sync.data.session.filter((session) => session.id !== option.value), - ) - return - } - setToDelete(option.value) - }, - }, - { - keybind: keybind.all.session_rename?.[0], - title: "rename", - onTrigger: async (option) => { - dialog.replace(() => ) - }, - }, - ]} - /> - ) -} diff --git a/packages/opencode/src/cli/cmd/tui/context/project.tsx b/packages/opencode/src/cli/cmd/tui/context/project.tsx index 522e724013d..26e5c075d7e 100644 --- a/packages/opencode/src/cli/cmd/tui/context/project.tsx +++ b/packages/opencode/src/cli/cmd/tui/context/project.tsx @@ -1,9 +1,11 @@ import { batch } from "solid-js" -import type { Path } from "@opencode-ai/sdk" +import type { Path, Workspace } from "@opencode-ai/sdk/v2" import { createStore, reconcile } from "solid-js/store" import { createSimpleContext } from "./helper" import { useSDK } from "./sdk" +type WorkspaceStatus = "connected" | "connecting" | "disconnected" | "error" + export const { use: useProject, provider: ProjectProvider } = createSimpleContext({ name: "Project", init: () => { @@ -14,17 +16,22 @@ export const { use: useProject, provider: ProjectProvider } = createSimpleContex }, instance: { path: { + home: "", state: "", config: "", worktree: "", directory: sdk.directory ?? "", } satisfies Path, }, - workspace: undefined as string | undefined, + workspace: { + current: undefined as string | undefined, + list: [] as Workspace[], + status: {} as Record, + }, }) async function sync() { - const workspace = store.workspace + const workspace = store.workspace.current const [path, project] = await Promise.all([ sdk.client.path.get({ workspace }), sdk.client.project.current({ workspace }), @@ -36,6 +43,27 @@ export const { use: useProject, provider: ProjectProvider } = createSimpleContex }) } + async function syncWorkspace() { + const listed = await sdk.client.experimental.workspace.list().catch(() => undefined) + if (!listed?.data) return + const status = await sdk.client.experimental.workspace.status().catch(() => undefined) + const next = Object.fromEntries((status?.data ?? []).map((item) => [item.workspaceID, item.status])) + + batch(() => { + setStore("workspace", "list", reconcile(listed.data)) + setStore("workspace", "status", reconcile(next)) + if (!listed.data.some((item) => item.id === store.workspace.current)) { + setStore("workspace", "current", undefined) + } + }) + } + + sdk.event.on("event", (event) => { + if (event.payload.type === "workspace.status") { + setStore("workspace", "status", event.payload.properties.workspaceID, event.payload.properties.status) + } + }) + return { data: store, project() { @@ -51,13 +79,26 @@ export const { use: useProject, provider: ProjectProvider } = createSimpleContex }, workspace: { current() { - return store.workspace + return store.workspace.current }, set(next?: string | null) { const workspace = next ?? undefined - if (store.workspace === workspace) return - setStore("workspace", workspace) + if (store.workspace.current === workspace) return + setStore("workspace", "current", workspace) + }, + list() { + return store.workspace.list + }, + get(workspaceID: string) { + return store.workspace.list.find((item) => item.id === workspaceID) + }, + status(workspaceID: string) { + return store.workspace.status[workspaceID] + }, + statuses() { + return store.workspace.status }, + sync: syncWorkspace, }, sync, } diff --git a/packages/opencode/src/cli/cmd/tui/context/sync.tsx b/packages/opencode/src/cli/cmd/tui/context/sync.tsx index bbdc7432850..498db99a1b7 100644 --- a/packages/opencode/src/cli/cmd/tui/context/sync.tsx +++ b/packages/opencode/src/cli/cmd/tui/context/sync.tsx @@ -17,7 +17,6 @@ import type { ProviderListResponse, ProviderAuthMethod, VcsInfo, - Workspace, } from "@opencode-ai/sdk/v2" import { createStore, produce, reconcile } from "solid-js/store" import { useProject } from "@tui/context/project" @@ -75,7 +74,6 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ [key: string]: McpResource } formatter: FormatterStatus[] - workspaceList: Workspace[] vcs: VcsInfo | undefined }>({ provider_next: { @@ -103,7 +101,6 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ mcp: {}, mcp_resource: {}, formatter: [], - workspaceList: [], vcs: undefined, }) @@ -111,16 +108,6 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ const project = useProject() const sdk = useSDK() - async function syncWorkspaces() { - const workspace = project.workspace.current() - const result = await sdk.client.experimental.workspace.list().catch(() => undefined) - if (!result?.data) return - setStore("workspaceList", reconcile(result.data)) - if (!result.data.some((item) => item.id === workspace)) { - project.workspace.set(undefined) - } - } - event.subscribe((event) => { switch (event.type) { case "server.instance.disposed": @@ -368,7 +355,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ const workspace = project.workspace.current() const start = Date.now() - 30 * 24 * 60 * 60 * 1000 const sessionListPromise = sdk.client.session - .list({ start: start, workspace }) + .list({ start: start }) .then((x) => (x.data ?? []).toSorted((a, b) => a.id.localeCompare(b.id))) // blocking - include session.list when continuing a session @@ -443,7 +430,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ }), sdk.client.provider.auth({ workspace }).then((x) => setStore("provider_auth", reconcile(x.data ?? {}))), sdk.client.vcs.get({ workspace }).then((x) => setStore("vcs", reconcile(x.data))), - syncWorkspaces(), + project.workspace.sync(), ]).then(() => { setStore("status", "complete") }) @@ -522,15 +509,6 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ fullSyncedSessions.add(sessionID) }, }, - workspace: { - list() { - return store.workspaceList - }, - get(workspaceID: string) { - return store.workspaceList.find((item) => item.id === workspaceID) - }, - sync: syncWorkspaces, - }, bootstrap, } return result diff --git a/packages/opencode/src/cli/cmd/tui/plugin/api.tsx b/packages/opencode/src/cli/cmd/tui/plugin/api.tsx index e43a9cc37cf..42bf78adbfa 100644 --- a/packages/opencode/src/cli/cmd/tui/plugin/api.tsx +++ b/packages/opencode/src/cli/cmd/tui/plugin/api.tsx @@ -146,14 +146,6 @@ function stateApi(sync: ReturnType): TuiPluginApi["state"] { branch: sync.data.vcs.branch, } }, - workspace: { - list() { - return sync.data.workspaceList - }, - get(workspaceID) { - return sync.workspace.get(workspaceID) - }, - }, session: { count() { return sync.data.session.length diff --git a/packages/opencode/src/cli/cmd/tui/ui/dialog-select.tsx b/packages/opencode/src/cli/cmd/tui/ui/dialog-select.tsx index 46821cccec7..109b5f2f111 100644 --- a/packages/opencode/src/cli/cmd/tui/ui/dialog-select.tsx +++ b/packages/opencode/src/cli/cmd/tui/ui/dialog-select.tsx @@ -26,6 +26,7 @@ export interface DialogSelectProps { keybind?: { keybind?: Keybind.Info title: string + side?: "left" | "right" disabled?: boolean onTrigger: (option: DialogSelectOption) => void }[] @@ -42,6 +43,7 @@ export interface DialogSelectOption { disabled?: boolean bg?: RGBA gutter?: JSX.Element + margin?: JSX.Element onSelect?: (ctx: DialogContext) => void } @@ -234,6 +236,8 @@ export function DialogSelect(props: DialogSelectProps) { props.ref?.(ref) const keybinds = createMemo(() => props.keybind?.filter((x) => !x.disabled && x.keybind) ?? []) + const left = createMemo(() => keybinds().filter((item) => item.side !== "right")) + const right = createMemo(() => keybinds().filter((item) => item.side === "right")) return ( @@ -312,6 +316,7 @@ export function DialogSelect(props: DialogSelectProps) { { setStore("input", "mouse") }} @@ -335,6 +340,11 @@ export function DialogSelect(props: DialogSelectProps) { paddingRight={3} gap={1} > + + + {option.margin} + + diff --git a/packages/opencode/src/control-plane/workspace.ts b/packages/opencode/src/control-plane/workspace.ts index a030d0b6c8d..bbf79620c19 100644 --- a/packages/opencode/src/control-plane/workspace.ts +++ b/packages/opencode/src/control-plane/workspace.ts @@ -5,7 +5,9 @@ import { Database, eq } from "@/storage/db" import { Project } from "@/project/project" import { BusEvent } from "@/bus/bus-event" import { GlobalBus } from "@/bus/global" +import { SyncEvent } from "@/sync" import { Log } from "@/util/log" +import { Filesystem } from "@/util/filesystem" import { ProjectID } from "@/project/schema" import { WorkspaceTable } from "./workspace.sql" import { getAdaptor } from "./adaptors" @@ -14,6 +16,18 @@ import { WorkspaceID } from "./schema" import { parseSSE } from "./sse" export namespace Workspace { + export const Info = WorkspaceInfo.meta({ + ref: "Workspace", + }) + export type Info = z.infer + + export const ConnectionStatus = z.object({ + workspaceID: WorkspaceID.zod, + status: z.enum(["connected", "connecting", "disconnected", "error"]), + error: z.string().optional(), + }) + export type ConnectionStatus = z.infer + export const Event = { Ready: BusEvent.define( "workspace.ready", @@ -27,13 +41,9 @@ export namespace Workspace { message: z.string(), }), ), + Status: BusEvent.define("workspace.status", ConnectionStatus), } - export const Info = WorkspaceInfo.meta({ - ref: "Workspace", - }) - export type Info = z.infer - function fromRow(row: typeof WorkspaceTable.$inferSelect): Info { return { id: row.id, @@ -85,6 +95,9 @@ export namespace Workspace { }) await adaptor.create(config) + + startSync(info) + return info }) @@ -92,18 +105,24 @@ export namespace Workspace { const rows = Database.use((db) => db.select().from(WorkspaceTable).where(eq(WorkspaceTable.project_id, project.id)).all(), ) - return rows.map(fromRow).sort((a, b) => a.id.localeCompare(b.id)) + const spaces = rows.map(fromRow).sort((a, b) => a.id.localeCompare(b.id)) + for (const space of spaces) startSync(space) + return spaces } export const get = fn(WorkspaceID.zod, async (id) => { const row = Database.use((db) => db.select().from(WorkspaceTable).where(eq(WorkspaceTable.id, id)).get()) if (!row) return - return fromRow(row) + const space = fromRow(row) + startSync(space) + return space }) export const remove = fn(WorkspaceID.zod, async (id) => { const row = Database.use((db) => db.select().from(WorkspaceTable).where(eq(WorkspaceTable.id, id)).get()) if (row) { + stopSync(id) + const info = fromRow(row) const adaptor = await getAdaptor(row.type) adaptor.remove(info) @@ -111,58 +130,100 @@ export namespace Workspace { return info } }) + + const connections = new Map() + const aborts = new Map() + + function setStatus(id: WorkspaceID, status: ConnectionStatus["status"], error?: string) { + const prev = connections.get(id) + if (prev?.status === status && prev?.error === error) return + const next = { workspaceID: id, status, error } + connections.set(id, next) + GlobalBus.emit("event", { + directory: "global", + workspace: id, + payload: { + type: Event.Status.type, + properties: next, + }, + }) + } + + export function status(): ConnectionStatus[] { + return [...connections.values()] + } + const log = Log.create({ service: "workspace-sync" }) - async function workspaceEventLoop(space: Info, stop: AbortSignal) { - while (!stop.aborted) { - const adaptor = await getAdaptor(space.type) - const target = await Promise.resolve(adaptor.target(space)) + async function workspaceEventLoop(space: Info, signal: AbortSignal) { + log.info("starting sync: " + space.id) - if (target.type === "local") { - return - } + while (!signal.aborted) { + log.info("connecting to sync: " + space.id) - const baseURL = String(target.url).replace(/\/?$/, "/") + setStatus(space.id, "connecting") + const adaptor = await getAdaptor(space.type) + const target = await adaptor.target(space) + + if (target.type === "local") return - const res = await fetch(new URL(baseURL + "/event"), { - method: "GET", - signal: stop, + const res = await fetch(target.url + "/sync/event", { method: "GET", signal }).catch((err: unknown) => { + setStatus(space.id, "error", String(err)) + return undefined }) + if (!res || !res.ok || !res.body) { + log.info("failed to connect to sync: " + res?.status) - if (!res.ok || !res.body) { + setStatus(space.id, "error", res ? `HTTP ${res.status}` : "no response") await sleep(1000) continue } - - // await parseSSE(res.body, stop, (event) => { - // GlobalBus.emit("event", { - // directory: space.id, - // payload: event, - // }) - // }) - - // Wait 250ms and retry if SSE connection fails + setStatus(space.id, "connected") + await parseSSE(res.body, signal, (evt) => { + const event = evt as SyncEvent.SerializedEvent + + try { + if (!event.type.startsWith("server.")) { + SyncEvent.replay(event) + } + } catch (err) { + log.warn("failed to replay sync event", { + workspaceID: space.id, + error: err, + }) + } + }) + setStatus(space.id, "disconnected") + log.info("disconnected to sync: " + space.id) await sleep(250) } } - export function startSyncing(project: Project.Info) { - const stop = new AbortController() - const spaces = list(project).filter((space) => space.type !== "worktree") + function startSync(space: Info) { + if (space.type === "worktree") { + void Filesystem.exists(space.directory!).then((exists) => { + setStatus(space.id, exists ? "connected" : "error", exists ? undefined : "directory does not exist") + }) + return + } - spaces.forEach((space) => { - void workspaceEventLoop(space, stop.signal).catch((error) => { - log.warn("workspace sync listener failed", { - workspaceID: space.id, - error, - }) + if (aborts.has(space.id)) return + const abort = new AbortController() + aborts.set(space.id, abort) + setStatus(space.id, "disconnected") + + void workspaceEventLoop(space, abort.signal).catch((error) => { + setStatus(space.id, "error", String(error)) + log.warn("workspace sync listener failed", { + workspaceID: space.id, + error, }) }) + } - return { - async stop() { - stop.abort() - }, - } + function stopSync(id: WorkspaceID) { + aborts.get(id)?.abort() + aborts.delete(id) + connections.delete(id) } } diff --git a/packages/opencode/src/server/router.ts b/packages/opencode/src/server/router.ts index dcc7924c68c..f97724c2ecc 100644 --- a/packages/opencode/src/server/router.ts +++ b/packages/opencode/src/server/router.ts @@ -29,13 +29,20 @@ function local(method: string, path: string) { return false } -async function getSessionWorkspace(url: URL) { +function getSessionID(url: URL) { if (url.pathname === "/session/status") return null const id = url.pathname.match(/^\/session\/([^/]+)(?:\/|$)/)?.[1] if (!id) return null - const session = await Session.get(SessionID.make(id)).catch(() => undefined) + return SessionID.make(id) +} + +async function getSessionWorkspace(url: URL) { + const id = getSessionID(url) + if (!id) return null + + const session = await Session.get(id).catch(() => undefined) return session?.workspaceID } @@ -71,7 +78,18 @@ export function WorkspaceRouterMiddleware(upgrade: UpgradeWebSocket): Middleware } const workspace = await Workspace.get(WorkspaceID.make(workspaceID)) + if (!workspace) { + // Special-case deleting a session in case user's data in a + // weird state. Allow them to forcefully delete a synced session + // even if the remote workspace is not in their data. + // + // The lets the `DELETE /session/:id` endpoint through and we've + // made sure that it will run without an instance + if (url.pathname.match(/\/session\/[^/]+$/) && c.req.method === "DELETE") { + return routes().fetch(c.req.raw, c.env) + } + return new Response(`Workspace not found: ${workspaceID}`, { status: 500, headers: { diff --git a/packages/opencode/src/server/routes/workspace.ts b/packages/opencode/src/server/routes/workspace.ts index cd2d844aedf..41932165410 100644 --- a/packages/opencode/src/server/routes/workspace.ts +++ b/packages/opencode/src/server/routes/workspace.ts @@ -62,6 +62,28 @@ export const WorkspaceRoutes = lazy(() => return c.json(Workspace.list(Instance.project)) }, ) + .get( + "/status", + describeRoute({ + summary: "Workspace status", + description: "Get connection status for workspaces in the current project.", + operationId: "experimental.workspace.status", + responses: { + 200: { + description: "Workspace status", + content: { + "application/json": { + schema: resolver(z.array(Workspace.ConnectionStatus)), + }, + }, + }, + }, + }), + async (c) => { + const ids = new Set(Workspace.list(Instance.project).map((item) => item.id)) + return c.json(Workspace.status().filter((item) => ids.has(item.workspaceID))) + }, + ) .delete( "/:id", describeRoute({ diff --git a/packages/opencode/src/session/index.ts b/packages/opencode/src/session/index.ts index bbd6693c53a..e57807e31ab 100644 --- a/packages/opencode/src/session/index.ts +++ b/packages/opencode/src/session/index.ts @@ -413,26 +413,35 @@ export namespace Session { }) const children = Effect.fn("Session.children")(function* (parentID: SessionID) { - const ctx = yield* InstanceState.context const rows = yield* db((d) => d .select() .from(SessionTable) - .where(and(eq(SessionTable.project_id, ctx.project.id), eq(SessionTable.parent_id, parentID))) + .where(and(eq(SessionTable.parent_id, parentID))) .all(), ) return rows.map(fromRow) }) - const remove: (sessionID: SessionID) => Effect.Effect = Effect.fnUntraced(function* (sessionID: SessionID) { + const remove: Interface["remove"] = Effect.fnUntraced(function* (sessionID: SessionID) { try { const session = yield* get(sessionID) const kids = yield* children(sessionID) for (const child of kids) { yield* remove(child.id) } + + // `remove` needs to work in all cases, such as a broken + // sessions that run cleanup. In certain cases these will + // run without any instance state, so we need to turn off + // publishing of events in that case + const hasInstance = yield* InstanceState.directory.pipe( + Effect.as(true), + Effect.catchCause(() => Effect.succeed(false)), + ) + yield* Effect.sync(() => { - SyncEvent.run(Event.Deleted, { sessionID, info: session }) + SyncEvent.run(Event.Deleted, { sessionID, info: session }, { publish: hasInstance }) SyncEvent.remove(sessionID) }) } catch (e) { diff --git a/packages/opencode/src/sync/index.ts b/packages/opencode/src/sync/index.ts index 270950fd4b8..a409391915e 100644 --- a/packages/opencode/src/sync/index.ts +++ b/packages/opencode/src/sync/index.ts @@ -165,7 +165,7 @@ export namespace SyncEvent { // and it validets all the sequence ids // * when loading events from db, apply zod validation to ensure shape - export function replay(event: SerializedEvent, options?: { republish: boolean }) { + export function replay(event: SerializedEvent, options?: { publish: boolean }) { const def = registry.get(event.type) if (!def) { throw new Error(`Unknown event type: ${event.type}`) @@ -189,10 +189,10 @@ export namespace SyncEvent { throw new Error(`Sequence mismatch for aggregate "${event.aggregateID}": expected ${expected}, got ${event.seq}`) } - process(def, event, { publish: !!options?.republish }) + process(def, event, { publish: !!options?.publish }) } - export function run(def: Def, data: Event["data"]) { + export function run(def: Def, data: Event["data"], options?: { publish?: boolean }) { const agg = (data as Record)[def.aggregate] // This should never happen: we've enforced it via typescript in // the definition @@ -204,6 +204,8 @@ export namespace SyncEvent { throw new Error(`SyncEvent.run: running old versions of events is not allowed: ${def.type}`) } + const { publish = true } = options || {} + // Note that this is an "immediate" transaction which is critical. // We need to make sure we can safely read and write with nothing // else changing the data from under us @@ -218,7 +220,7 @@ export namespace SyncEvent { const seq = row?.seq != null ? row.seq + 1 : 0 const event = { id, seq, aggregateID: agg, data } - process(def, event, { publish: true }) + process(def, event, { publish }) }, { behavior: "immediate", diff --git a/packages/opencode/test/cli/tui/sync-provider.test.tsx b/packages/opencode/test/cli/tui/sync-provider.test.tsx index ec686b36882..3ef126ef4ca 100644 --- a/packages/opencode/test/cli/tui/sync-provider.test.tsx +++ b/packages/opencode/test/cli/tui/sync-provider.test.tsx @@ -244,7 +244,6 @@ describe("SyncProvider", () => { expect(log.some((item) => item.path === "/path" && item.workspace === "ws_a")).toBe(true) expect(log.some((item) => item.path === "/config" && item.workspace === "ws_a")).toBe(true) - expect(log.some((item) => item.path === "/session" && item.workspace === "ws_a")).toBe(true) expect(log.some((item) => item.path === "/command" && item.workspace === "ws_a")).toBe(true) } finally { app.renderer.destroy() diff --git a/packages/opencode/test/fixture/tui-plugin.ts b/packages/opencode/test/fixture/tui-plugin.ts index 7ddcc773382..26913222e89 100644 --- a/packages/opencode/test/fixture/tui-plugin.ts +++ b/packages/opencode/test/fixture/tui-plugin.ts @@ -93,7 +93,6 @@ type Opts = { provider?: HostPluginApi["state"]["provider"] path?: HostPluginApi["state"]["path"] vcs?: HostPluginApi["state"]["vcs"] - workspace?: Partial session?: Partial part?: HostPluginApi["state"]["part"] lsp?: HostPluginApi["state"]["lsp"] @@ -277,15 +276,11 @@ export function createTuiPluginApi(opts: Opts = {}): HostPluginApi { return opts.state?.provider ?? [] }, get path() { - return opts.state?.path ?? { state: "", config: "", worktree: "", directory: "" } + return opts.state?.path ?? { home: "", state: "", config: "", worktree: "", directory: "" } }, get vcs() { return opts.state?.vcs }, - workspace: { - list: opts.state?.workspace?.list ?? (() => []), - get: opts.state?.workspace?.get ?? (() => undefined), - }, session: { count: opts.state?.session?.count ?? (() => 0), diff: opts.state?.session?.diff ?? (() => []), diff --git a/packages/opencode/test/session/session.test.ts b/packages/opencode/test/session/session.test.ts index 0c18f92ba91..75c74002a76 100644 --- a/packages/opencode/test/session/session.test.ts +++ b/packages/opencode/test/session/session.test.ts @@ -6,6 +6,7 @@ import { Log } from "../../src/util/log" import { Instance } from "../../src/project/instance" import { MessageV2 } from "../../src/session/message-v2" import { MessageID, PartID } from "../../src/session/schema" +import { tmpdir } from "../fixture/fixture" const projectRoot = path.join(__dirname, "../..") Log.init({ print: false }) @@ -140,3 +141,25 @@ describe("step-finish token propagation via Bus event", () => { { timeout: 30000 }, ) }) + +describe("Session", () => { + test("remove works without an instance", async () => { + await using tmp = await tmpdir({ git: true }) + + const session = await Instance.provide({ + directory: tmp.path, + fn: async () => Session.create({ title: "remove-without-instance" }), + }) + + await expect(async () => { + await Session.remove(session.id) + }).not.toThrow() + + let missing = false + await Session.get(session.id).catch(() => { + missing = true + }) + + expect(missing).toBe(true) + }) +}) diff --git a/packages/plugin/src/tui.ts b/packages/plugin/src/tui.ts index 8f8439fab45..e6f832f7e1e 100644 --- a/packages/plugin/src/tui.ts +++ b/packages/plugin/src/tui.ts @@ -272,10 +272,6 @@ export type TuiState = { directory: string } readonly vcs: { branch?: string } | undefined - readonly workspace: { - list: () => ReadonlyArray - get: (workspaceID: string) => Workspace | undefined - } session: { count: () => number diff: (sessionID: string) => ReadonlyArray diff --git a/packages/sdk/js/src/v2/gen/sdk.gen.ts b/packages/sdk/js/src/v2/gen/sdk.gen.ts index b2e37db59b7..d06a504d6c3 100644 --- a/packages/sdk/js/src/v2/gen/sdk.gen.ts +++ b/packages/sdk/js/src/v2/gen/sdk.gen.ts @@ -34,6 +34,7 @@ import type { ExperimentalWorkspaceListResponses, ExperimentalWorkspaceRemoveErrors, ExperimentalWorkspaceRemoveResponses, + ExperimentalWorkspaceStatusResponses, FileListResponses, FilePartInput, FilePartSource, @@ -1163,6 +1164,36 @@ export class Workspace extends HeyApiClient { }) } + /** + * Workspace status + * + * Get connection status for workspaces in the current project. + */ + public status( + parameters?: { + directory?: string + workspace?: string + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + ], + }, + ], + ) + return (options?.client ?? this.client).get({ + url: "/experimental/workspace/status", + ...options, + ...params, + }) + } + /** * Remove workspace * diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index 823c452f9d0..c1a77bfe884 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -330,6 +330,15 @@ export type EventWorkspaceFailed = { } } +export type EventWorkspaceStatus = { + type: "workspace.status" + properties: { + workspaceID: string + status: "connected" | "connecting" | "disconnected" | "error" + error?: string + } +} + export type QuestionOption = { /** * Display text (1-5 words, concise) @@ -988,6 +997,7 @@ export type Event = | EventCommandExecuted | EventWorkspaceReady | EventWorkspaceFailed + | EventWorkspaceStatus | EventQuestionAsked | EventQuestionReplied | EventQuestionRejected @@ -2857,6 +2867,30 @@ export type ExperimentalWorkspaceCreateResponses = { export type ExperimentalWorkspaceCreateResponse = ExperimentalWorkspaceCreateResponses[keyof ExperimentalWorkspaceCreateResponses] +export type ExperimentalWorkspaceStatusData = { + body?: never + path?: never + query?: { + directory?: string + workspace?: string + } + url: "/experimental/workspace/status" +} + +export type ExperimentalWorkspaceStatusResponses = { + /** + * Workspace status + */ + 200: Array<{ + workspaceID: string + status: "connected" | "connecting" | "disconnected" | "error" + error?: string + }> +} + +export type ExperimentalWorkspaceStatusResponse = + ExperimentalWorkspaceStatusResponses[keyof ExperimentalWorkspaceStatusResponses] + export type ExperimentalWorkspaceRemoveData = { body?: never path: { diff --git a/packages/sdk/openapi.json b/packages/sdk/openapi.json index 40361c280d4..deece485e01 100644 --- a/packages/sdk/openapi.json +++ b/packages/sdk/openapi.json @@ -1656,6 +1656,64 @@ ] } }, + "/experimental/workspace/status": { + "get": { + "operationId": "experimental.workspace.status", + "parameters": [ + { + "in": "query", + "name": "directory", + "schema": { + "type": "string" + } + }, + { + "in": "query", + "name": "workspace", + "schema": { + "type": "string" + } + } + ], + "summary": "Workspace status", + "description": "Get connection status for workspaces in the current project.", + "responses": { + "200": { + "description": "Workspace status", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "type": "object", + "properties": { + "workspaceID": { + "type": "string", + "pattern": "^wrk.*" + }, + "status": { + "type": "string", + "enum": ["connected", "connecting", "disconnected", "error"] + }, + "error": { + "type": "string" + } + }, + "required": ["workspaceID", "status"] + } + } + } + } + } + }, + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.experimental.workspace.status({\n ...\n})" + } + ] + } + }, "/experimental/workspace/{id}": { "delete": { "operationId": "experimental.workspace.remove", @@ -7966,6 +8024,33 @@ }, "required": ["type", "properties"] }, + "Event.workspace.status": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "workspace.status" + }, + "properties": { + "type": "object", + "properties": { + "workspaceID": { + "type": "string", + "pattern": "^wrk.*" + }, + "status": { + "type": "string", + "enum": ["connected", "connecting", "disconnected", "error"] + }, + "error": { + "type": "string" + } + }, + "required": ["workspaceID", "status"] + } + }, + "required": ["type", "properties"] + }, "QuestionOption": { "type": "object", "properties": { @@ -9858,6 +9943,9 @@ { "$ref": "#/components/schemas/Event.workspace.failed" }, + { + "$ref": "#/components/schemas/Event.workspace.status" + }, { "$ref": "#/components/schemas/Event.question.asked" },