summaryrefslogtreecommitdiffhomepage
path: root/packages
diff options
context:
space:
mode:
authorJames Long <[email protected]>2026-04-10 13:03:20 -0400
committerGitHub <[email protected]>2026-04-10 13:03:20 -0400
commit180ded6a27c49c0f95c8af5ff17ccacaa54eceab (patch)
tree9ef4812c57b8a7e4f2d156ba144a4c69bf29b370 /packages
parentbf601628db3c187478ff853fe33b91cec652355e (diff)
downloadopencode-180ded6a27c49c0f95c8af5ff17ccacaa54eceab.tar.gz
opencode-180ded6a27c49c0f95c8af5ff17ccacaa54eceab.zip
rector(core,tui): handle workspace state in project context, add workspace status, improve ui (#21896)
Diffstat (limited to 'packages')
-rw-r--r--packages/opencode/specs/tui-plugins.md5
-rw-r--r--packages/opencode/src/cli/cmd/tui/app.tsx19
-rw-r--r--packages/opencode/src/cli/cmd/tui/component/dialog-session-list.tsx80
-rw-r--r--packages/opencode/src/cli/cmd/tui/component/dialog-workspace-create.tsx121
-rw-r--r--packages/opencode/src/cli/cmd/tui/component/dialog-workspace-list.tsx319
-rw-r--r--packages/opencode/src/cli/cmd/tui/component/workspace/dialog-session-list.tsx151
-rw-r--r--packages/opencode/src/cli/cmd/tui/context/project.tsx53
-rw-r--r--packages/opencode/src/cli/cmd/tui/context/sync.tsx26
-rw-r--r--packages/opencode/src/cli/cmd/tui/plugin/api.tsx8
-rw-r--r--packages/opencode/src/cli/cmd/tui/ui/dialog-select.tsx53
-rw-r--r--packages/opencode/src/control-plane/workspace.ts145
-rw-r--r--packages/opencode/src/server/router.ts22
-rw-r--r--packages/opencode/src/server/routes/workspace.ts22
-rw-r--r--packages/opencode/src/session/index.ts17
-rw-r--r--packages/opencode/src/sync/index.ts10
-rw-r--r--packages/opencode/test/cli/tui/sync-provider.test.tsx1
-rw-r--r--packages/opencode/test/fixture/tui-plugin.ts7
-rw-r--r--packages/opencode/test/session/session.test.ts23
-rw-r--r--packages/plugin/src/tui.ts4
-rw-r--r--packages/sdk/js/src/v2/gen/sdk.gen.ts31
-rw-r--r--packages/sdk/js/src/v2/gen/types.gen.ts34
-rw-r--r--packages/sdk/openapi.json88
22 files changed, 629 insertions, 610 deletions
diff --git a/packages/opencode/specs/tui-plugins.md b/packages/opencode/specs/tui-plugins.md
index c5420586e..943125b79 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 2f8d1f7bb..8c4f596fd 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<string[]> }) {
dialog.replace(() => <DialogSessionList />)
},
},
- ...(Flag.OPENCODE_EXPERIMENTAL_WORKSPACES
- ? [
- {
- title: "Manage workspaces",
- value: "workspace.list",
- category: "Workspace",
- suggested: true,
- slash: {
- name: "workspaces",
- },
- onSelect: () => {
- dialog.replace(() => <DialogWorkspaceList />)
- },
- },
- ]
- : []),
{
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 775969bfc..9ecb21e82 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<string>()
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(() => (
+ <DialogWorkspaceCreate
+ onSelect={(workspaceID) =>
+ 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}{" "}
+ <span
+ style={{
+ fg:
+ workspaceStatus === "error"
+ ? theme.error
+ : workspaceStatus === "disconnected"
+ ? theme.textMuted
+ : theme.success,
+ }}
+ >
+ ■
+ </span>
+ </>
+ )
+ }
+ } 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 ? <Spinner /> : undefined,
}
})
@@ -102,6 +161,15 @@ export function DialogSessionList() {
dialog.replace(() => <DialogSessionRename session={option.value} />)
},
},
+ {
+ 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 000000000..40cc1013e
--- /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<typeof useSDK>, sync: ReturnType<typeof useSync>, 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<typeof useDialog>
+ route: ReturnType<typeof useRoute>
+ sdk: ReturnType<typeof useSDK>
+ sync: ReturnType<typeof useSync>
+ toast: ReturnType<typeof useToast>
+ 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> | void }) {
+ const dialog = useDialog()
+ const sync = useSync()
+ const project = useProject()
+ const sdk = useSDK()
+ const toast = useToast()
+ const [creating, setCreating] = createSignal<string>()
+
+ 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 (
+ <DialogSelect
+ title={creating() ? "Creating Workspace" : "New Workspace"}
+ skipFilter={true}
+ options={options()}
+ onSelect={(option) => {
+ 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 037cebb72..000000000
--- 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<typeof useSDK>, sync: ReturnType<typeof useSync>, 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<typeof useDialog>
- route: ReturnType<typeof useRoute>
- sdk: ReturnType<typeof useSDK>
- sync: ReturnType<typeof useSync>
- toast: ReturnType<typeof useToast>
- 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<void> }) {
- const dialog = useDialog()
- const sync = useSync()
- const sdk = useSDK()
- const toast = useToast()
- const [creating, setCreating] = createSignal<string>()
-
- 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 (
- <DialogSelect
- title={creating() ? "Creating Workspace" : "New Workspace"}
- skipFilter={true}
- options={options()}
- onSelect={(option) => {
- 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<string>()
- const [counts, setCounts] = createSignal<Record<string, number | null | undefined>>({})
-
- 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(() => <DialogSessionList localOnly={true} />)
- return
- }
- route.navigate({
- type: "home",
- })
- dialog.clear()
- return
- }
- const count = counts()[workspaceID]
- if (count && count > 0) {
- dialog.replace(() => <DialogSessionList workspaceID={workspaceID} />)
- 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(() => <DialogSessionList workspaceID={workspaceID} />)
- 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 (
- <DialogSelect
- title="Workspaces"
- skipFilter={true}
- options={options()}
- current={currentWorkspaceID()}
- onMove={() => {
- setToDelete(undefined)
- }}
- onSelect={(option) => {
- setToDelete(undefined)
- if (option.value === "__create__") {
- dialog.replace(() => <DialogWorkspaceCreate onSelect={(workspaceID) => 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 326f094a5..000000000
--- 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<string>()
- 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 ? <Spinner /> : undefined,
- }
- })
- })
-
- onMount(() => {
- dialog.setSize("large")
- })
-
- return (
- <DialogSelect
- title={props.workspaceID ? `Workspace Sessions` : props.localOnly ? "Local Sessions" : "Sessions"}
- options={options()}
- skipFilter={!props.localOnly}
- current={currentSessionID()}
- onFilter={setSearch}
- onMove={() => {
- 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(() => <DialogSessionRename session={option.value} />)
- },
- },
- ]}
- />
- )
-}
diff --git a/packages/opencode/src/cli/cmd/tui/context/project.tsx b/packages/opencode/src/cli/cmd/tui/context/project.tsx
index 522e72401..26e5c075d 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<string, WorkspaceStatus>,
+ },
})
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 bbdc74328..498db99a1 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 e43a9cc37..42bf78adb 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<typeof useSync>): 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 46821ccce..109b5f2f1 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<T> {
keybind?: {
keybind?: Keybind.Info
title: string
+ side?: "left" | "right"
disabled?: boolean
onTrigger: (option: DialogSelectOption<T>) => void
}[]
@@ -42,6 +43,7 @@ export interface DialogSelectOption<T = any> {
disabled?: boolean
bg?: RGBA
gutter?: JSX.Element
+ margin?: JSX.Element
onSelect?: (ctx: DialogContext) => void
}
@@ -234,6 +236,8 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
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 (
<box gap={1} paddingBottom={1}>
@@ -312,6 +316,7 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
<box
id={JSON.stringify(option.value)}
flexDirection="row"
+ position="relative"
onMouseMove={() => {
setStore("input", "mouse")
}}
@@ -335,6 +340,11 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
paddingRight={3}
gap={1}
>
+ <Show when={!current() && option.margin}>
+ <box position="absolute" left={1} flexShrink={0}>
+ {option.margin}
+ </box>
+ </Show>
<Option
title={option.title}
footer={flatten() ? (option.category ?? option.footer) : option.footer}
@@ -353,17 +363,38 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
</scrollbox>
</Show>
<Show when={keybinds().length} fallback={<box flexShrink={0} />}>
- <box paddingRight={2} paddingLeft={4} flexDirection="row" gap={2} flexShrink={0} paddingTop={1}>
- <For each={keybinds()}>
- {(item) => (
- <text>
- <span style={{ fg: theme.text }}>
- <b>{item.title}</b>{" "}
- </span>
- <span style={{ fg: theme.textMuted }}>{Keybind.toString(item.keybind)}</span>
- </text>
- )}
- </For>
+ <box
+ paddingRight={2}
+ paddingLeft={4}
+ flexDirection="row"
+ justifyContent="space-between"
+ flexShrink={0}
+ paddingTop={1}
+ >
+ <box flexDirection="row" gap={2}>
+ <For each={left()}>
+ {(item) => (
+ <text>
+ <span style={{ fg: theme.text }}>
+ <b>{item.title}</b>{" "}
+ </span>
+ <span style={{ fg: theme.textMuted }}>{Keybind.toString(item.keybind)}</span>
+ </text>
+ )}
+ </For>
+ </box>
+ <box flexDirection="row" gap={2}>
+ <For each={right()}>
+ {(item) => (
+ <text>
+ <span style={{ fg: theme.text }}>
+ <b>{item.title}</b>{" "}
+ </span>
+ <span style={{ fg: theme.textMuted }}>{Keybind.toString(item.keybind)}</span>
+ </text>
+ )}
+ </For>
+ </box>
</box>
</Show>
</box>
diff --git a/packages/opencode/src/control-plane/workspace.ts b/packages/opencode/src/control-plane/workspace.ts
index a030d0b6c..bbf79620c 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<typeof Info>
+
+ export const ConnectionStatus = z.object({
+ workspaceID: WorkspaceID.zod,
+ status: z.enum(["connected", "connecting", "disconnected", "error"]),
+ error: z.string().optional(),
+ })
+ export type ConnectionStatus = z.infer<typeof ConnectionStatus>
+
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<typeof Info>
-
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<WorkspaceID, ConnectionStatus>()
+ const aborts = new Map<WorkspaceID, AbortController>()
+
+ 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 dcc7924c6..f97724c2e 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 cd2d844ae..419321654 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 bbd6693c5..e57807e31 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<void> = 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 270950fd4..a40939191 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 extends Definition>(def: Def, data: Event<Def>["data"]) {
+ export function run<Def extends Definition>(def: Def, data: Event<Def>["data"], options?: { publish?: boolean }) {
const agg = (data as Record<string, string>)[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 ec686b368..3ef126ef4 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 7ddcc7733..26913222e 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<HostPluginApi["state"]["workspace"]>
session?: Partial<HostPluginApi["state"]["session"]>
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 0c18f92ba..75c74002a 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 8f8439fab..e6f832f7e 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<Workspace>
- get: (workspaceID: string) => Workspace | undefined
- }
session: {
count: () => number
diff: (sessionID: string) => ReadonlyArray<TuiSidebarFileItem>
diff --git a/packages/sdk/js/src/v2/gen/sdk.gen.ts b/packages/sdk/js/src/v2/gen/sdk.gen.ts
index b2e37db59..d06a504d6 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,
@@ -1164,6 +1165,36 @@ export class Workspace extends HeyApiClient {
}
/**
+ * Workspace status
+ *
+ * Get connection status for workspaces in the current project.
+ */
+ public status<ThrowOnError extends boolean = false>(
+ parameters?: {
+ directory?: string
+ workspace?: string
+ },
+ options?: Options<never, ThrowOnError>,
+ ) {
+ const params = buildClientParams(
+ [parameters],
+ [
+ {
+ args: [
+ { in: "query", key: "directory" },
+ { in: "query", key: "workspace" },
+ ],
+ },
+ ],
+ )
+ return (options?.client ?? this.client).get<ExperimentalWorkspaceStatusResponses, unknown, ThrowOnError>({
+ url: "/experimental/workspace/status",
+ ...options,
+ ...params,
+ })
+ }
+
+ /**
* Remove workspace
*
* Remove an existing workspace.
diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts
index 823c452f9..c1a77bfe8 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 40361c280..deece485e 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": {
@@ -9859,6 +9944,9 @@
"$ref": "#/components/schemas/Event.workspace.failed"
},
{
+ "$ref": "#/components/schemas/Event.workspace.status"
+ },
+ {
"$ref": "#/components/schemas/Event.question.asked"
},
{