Преглед изворни кода

rector(core,tui): handle workspace state in project context, add workspace status, improve ui (#21896)

James Long пре 1 недеља
родитељ
комит
180ded6a27

+ 1 - 4
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`.
 

+ 1 - 18
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",

+ 74 - 6
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()
+          },
+        },
       ]}
     />
   )

+ 121 - 0
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)
+      }}
+    />
+  )
+}

+ 0 - 319
packages/opencode/src/cli/cmd/tui/component/dialog-workspace-list.tsx

@@ -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()
-          },
-        },
-      ]}
-    />
-  )
-}

+ 0 - 151
packages/opencode/src/cli/cmd/tui/component/workspace/dialog-session-list.tsx

@@ -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} />)
-          },
-        },
-      ]}
-    />
-  )
-}

+ 47 - 6
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,
     }

+ 2 - 24
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

+ 0 - 8
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

+ 42 - 11
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>

+ 103 - 42
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)
   }
 }

+ 20 - 2
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: {

+ 22 - 0
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({

+ 13 - 4
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) {

+ 6 - 4
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",

+ 0 - 1
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()

+ 1 - 6
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 ?? (() => []),

+ 23 - 0
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)
+  })
+})

+ 0 - 4
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>

+ 31 - 0
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<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
    *

+ 34 - 0
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: {

+ 88 - 0
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"
           },