summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorJames Long <[email protected]>2026-04-16 12:24:40 -0400
committerGitHub <[email protected]>2026-04-16 12:24:40 -0400
commit06afd332913e1ad4b067a0f1a1c906ca8376bc45 (patch)
treece500cb48812b223314bd8800a3f7ef7ab0936cb
parent305460b25fc673f707a238f180d93e58d80f1ee9 (diff)
downloadopencode-06afd332913e1ad4b067a0f1a1c906ca8376bc45.tar.gz
opencode-06afd332913e1ad4b067a0f1a1c906ca8376bc45.zip
refactor(tui): improve workspace management (#22691)
-rw-r--r--packages/opencode/src/cli/cmd/tui/component/dialog-session-delete-failed.tsx101
-rw-r--r--packages/opencode/src/cli/cmd/tui/component/dialog-session-list.tsx97
-rw-r--r--packages/opencode/src/cli/cmd/tui/component/dialog-workspace-create.tsx136
-rw-r--r--packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx4
-rw-r--r--packages/opencode/src/cli/cmd/tui/context/sync.tsx17
-rw-r--r--packages/opencode/src/control-plane/workspace-context.ts6
-rw-r--r--packages/opencode/src/effect/bridge.ts3
-rw-r--r--packages/opencode/src/effect/instance-ref.ts3
-rw-r--r--packages/opencode/src/session/session.ts3
-rw-r--r--packages/opencode/test/cli/tui/sync-provider.test.tsx16
10 files changed, 349 insertions, 37 deletions
diff --git a/packages/opencode/src/cli/cmd/tui/component/dialog-session-delete-failed.tsx b/packages/opencode/src/cli/cmd/tui/component/dialog-session-delete-failed.tsx
new file mode 100644
index 000000000..4a22a0c49
--- /dev/null
+++ b/packages/opencode/src/cli/cmd/tui/component/dialog-session-delete-failed.tsx
@@ -0,0 +1,101 @@
+import { TextAttributes } from "@opentui/core"
+import { useTheme } from "../context/theme"
+import { useDialog } from "../ui/dialog"
+import { createStore } from "solid-js/store"
+import { For } from "solid-js"
+import { useKeyboard } from "@opentui/solid"
+
+export function DialogSessionDeleteFailed(props: {
+ session: string
+ workspace: string
+ onDelete?: () => boolean | void | Promise<boolean | void>
+ onRestore?: () => boolean | void | Promise<boolean | void>
+ onDone?: () => void
+}) {
+ const dialog = useDialog()
+ const { theme } = useTheme()
+ const [store, setStore] = createStore({
+ active: "delete" as "delete" | "restore",
+ })
+
+ const options = [
+ {
+ id: "delete" as const,
+ title: "Delete workspace",
+ description: "Delete the workspace and all sessions attached to it.",
+ run: props.onDelete,
+ },
+ {
+ id: "restore" as const,
+ title: "Restore to new workspace",
+ description: "Try to restore this session into a new workspace.",
+ run: props.onRestore,
+ },
+ ]
+
+ async function confirm() {
+ const result = await options.find((item) => item.id === store.active)?.run?.()
+ if (result === false) return
+ props.onDone?.()
+ if (!props.onDone) dialog.clear()
+ }
+
+ useKeyboard((evt) => {
+ if (evt.name === "return") {
+ void confirm()
+ }
+ if (evt.name === "left" || evt.name === "up") {
+ setStore("active", "delete")
+ }
+ if (evt.name === "right" || evt.name === "down") {
+ setStore("active", "restore")
+ }
+ })
+
+ return (
+ <box paddingLeft={2} paddingRight={2} gap={1}>
+ <box flexDirection="row" justifyContent="space-between">
+ <text attributes={TextAttributes.BOLD} fg={theme.text}>
+ Failed to Delete Session
+ </text>
+ <text fg={theme.textMuted} onMouseUp={() => dialog.clear()}>
+ esc
+ </text>
+ </box>
+ <text fg={theme.textMuted} wrapMode="word">
+ {`The session "${props.session}" could not be deleted because the workspace "${props.workspace}" is not available.`}
+ </text>
+ <text fg={theme.textMuted} wrapMode="word">
+ Choose how you want to recover this broken workspace session.
+ </text>
+ <box flexDirection="column" paddingBottom={1} gap={1}>
+ <For each={options}>
+ {(item) => (
+ <box
+ flexDirection="column"
+ paddingLeft={1}
+ paddingRight={1}
+ paddingTop={1}
+ paddingBottom={1}
+ backgroundColor={item.id === store.active ? theme.primary : undefined}
+ onMouseUp={() => {
+ setStore("active", item.id)
+ void confirm()
+ }}
+ >
+ <text
+ attributes={TextAttributes.BOLD}
+ fg={item.id === store.active ? theme.selectedListItemText : theme.text}
+ >
+ {item.title}
+ </text>
+ <text fg={item.id === store.active ? theme.selectedListItemText : theme.textMuted} wrapMode="word">
+ {item.description}
+ </text>
+ </box>
+ )}
+ </For>
+ </box>
+ </box>
+ )
+}
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 f58b73c9a..75c79dcdd 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
@@ -13,8 +13,10 @@ import { DialogSessionRename } from "./dialog-session-rename"
import { Keybind } from "@/util"
import { createDebouncedSignal } from "../util/signal"
import { useToast } from "../ui/toast"
-import { DialogWorkspaceCreate, openWorkspaceSession } from "./dialog-workspace-create"
+import { DialogWorkspaceCreate, openWorkspaceSession, restoreWorkspaceSession } from "./dialog-workspace-create"
import { Spinner } from "./spinner"
+import { errorMessage } from "@/util/error"
+import { DialogSessionDeleteFailed } from "./dialog-session-delete-failed"
type WorkspaceStatus = "connected" | "connecting" | "disconnected" | "error"
@@ -30,7 +32,7 @@ export function DialogSessionList() {
const [toDelete, setToDelete] = createSignal<string>()
const [search, setSearch] = createDebouncedSignal("", 150)
- const [searchResults] = createResource(search, async (query) => {
+ const [searchResults, { refetch }] = createResource(search, async (query) => {
if (!query) return undefined
const result = await sdk.client.session.list({ search: query, limit: 30 })
return result.data ?? []
@@ -56,6 +58,57 @@ export function DialogSessionList() {
))
}
+ function recover(session: NonNullable<ReturnType<typeof sessions>[number]>) {
+ const workspace = project.workspace.get(session.workspaceID!)
+ const list = () => dialog.replace(() => <DialogSessionList />)
+ dialog.replace(() => (
+ <DialogSessionDeleteFailed
+ session={session.title}
+ workspace={workspace?.name ?? session.workspaceID!}
+ onDone={list}
+ onDelete={async () => {
+ const current = currentSessionID()
+ const info = current ? sync.data.session.find((item) => item.id === current) : undefined
+ const result = await sdk.client.experimental.workspace.remove({ id: session.workspaceID! })
+ if (result.error) {
+ toast.show({
+ variant: "error",
+ title: "Failed to delete workspace",
+ message: errorMessage(result.error),
+ })
+ return false
+ }
+ await project.workspace.sync()
+ await sync.session.refresh()
+ if (search()) await refetch()
+ if (info?.workspaceID === session.workspaceID) {
+ route.navigate({ type: "home" })
+ }
+ return true
+ }}
+ onRestore={() => {
+ dialog.replace(() => (
+ <DialogWorkspaceCreate
+ onSelect={(workspaceID) =>
+ restoreWorkspaceSession({
+ dialog,
+ sdk,
+ sync,
+ project,
+ toast,
+ workspaceID,
+ sessionID: session.id,
+ done: list,
+ })
+ }
+ />
+ ))
+ return false
+ }}
+ />
+ ))
+ }
+
const options = createMemo(() => {
const today = new Date().toDateString()
return sessions()
@@ -145,9 +198,43 @@ export function DialogSessionList() {
title: "delete",
onTrigger: async (option) => {
if (toDelete() === option.value) {
- void sdk.client.session.delete({
- sessionID: option.value,
- })
+ const session = sessions().find((item) => item.id === option.value)
+ const status = session?.workspaceID ? project.workspace.status(session.workspaceID) : undefined
+
+ try {
+ const result = await sdk.client.session.delete({
+ sessionID: option.value,
+ })
+ if (result.error) {
+ if (session?.workspaceID) {
+ recover(session)
+ } else {
+ toast.show({
+ variant: "error",
+ title: "Failed to delete session",
+ message: errorMessage(result.error),
+ })
+ }
+ setToDelete(undefined)
+ return
+ }
+ } catch (err) {
+ if (session?.workspaceID) {
+ recover(session)
+ } else {
+ toast.show({
+ variant: "error",
+ title: "Failed to delete session",
+ message: errorMessage(err),
+ })
+ }
+ setToDelete(undefined)
+ return
+ }
+ if (status && status !== "connected") {
+ await sync.session.refresh()
+ }
+ if (search()) await refetch()
setToDelete(undefined)
return
}
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
index 447a1c325..ca504d864 100644
--- a/packages/opencode/src/cli/cmd/tui/component/dialog-workspace-create.tsx
+++ b/packages/opencode/src/cli/cmd/tui/component/dialog-workspace-create.tsx
@@ -6,6 +6,8 @@ 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 { errorData, errorMessage } from "@/util/error"
+import * as Log from "@/util/log"
import { useSDK } from "../context/sdk"
import { useToast } from "../ui/toast"
@@ -15,6 +17,8 @@ type Adaptor = {
description: string
}
+const log = Log.Default.clone().tag("service", "tui-workspace")
+
function scoped(sdk: ReturnType<typeof useSDK>, sync: ReturnType<typeof useSync>, workspaceID: string) {
return createOpencodeClient({
baseUrl: sdk.url,
@@ -33,8 +37,20 @@ export async function openWorkspaceSession(input: {
workspaceID: string
}) {
const client = scoped(input.sdk, input.sync, input.workspaceID)
+ log.info("workspace session create requested", {
+ workspaceID: input.workspaceID,
+ })
+
+ console.log("opening!")
while (true) {
- const result = await client.session.create({ workspaceID: input.workspaceID }).catch(() => undefined)
+ console.log("creating")
+ const result = await client.session.create({ workspace: input.workspaceID }).catch((err) => {
+ log.error("workspace session create request failed", {
+ workspaceID: input.workspaceID,
+ error: errorData(err),
+ })
+ return undefined
+ })
if (!result) {
input.toast.show({
message: "Failed to create workspace session",
@@ -42,26 +58,113 @@ export async function openWorkspaceSession(input: {
})
return
}
- if (result.response.status >= 500 && result.response.status < 600) {
+ log.info("workspace session create response", {
+ workspaceID: input.workspaceID,
+ status: result.response?.status,
+ sessionID: result.data?.id,
+ })
+ if (result.response?.status && result.response.status >= 500 && result.response.status < 600) {
+ log.warn("workspace session create retrying after server error", {
+ workspaceID: input.workspaceID,
+ status: result.response.status,
+ })
await sleep(1000)
continue
}
if (!result.data) {
+ log.error("workspace session create returned no data", {
+ workspaceID: input.workspaceID,
+ status: result.response?.status,
+ })
input.toast.show({
message: "Failed to create workspace session",
variant: "error",
})
return
}
+
input.route.navigate({
type: "session",
sessionID: result.data.id,
})
+ log.info("workspace session create complete", {
+ workspaceID: input.workspaceID,
+ sessionID: result.data.id,
+ })
input.dialog.clear()
return
}
}
+export async function restoreWorkspaceSession(input: {
+ dialog: ReturnType<typeof useDialog>
+ sdk: ReturnType<typeof useSDK>
+ sync: ReturnType<typeof useSync>
+ project: ReturnType<typeof useProject>
+ toast: ReturnType<typeof useToast>
+ workspaceID: string
+ sessionID: string
+ done?: () => void
+}) {
+ log.info("session restore requested", {
+ workspaceID: input.workspaceID,
+ sessionID: input.sessionID,
+ })
+ const result = await input.sdk.client.experimental.workspace
+ .sessionRestore({ id: input.workspaceID, sessionID: input.sessionID })
+ .catch((err) => {
+ log.error("session restore request failed", {
+ workspaceID: input.workspaceID,
+ sessionID: input.sessionID,
+ error: errorData(err),
+ })
+ return undefined
+ })
+ if (!result?.data) {
+ log.error("session restore failed", {
+ workspaceID: input.workspaceID,
+ sessionID: input.sessionID,
+ status: result?.response?.status,
+ error: result?.error ? errorData(result.error) : undefined,
+ })
+ input.toast.show({
+ message: `Failed to restore session: ${errorMessage(result?.error ?? "no response")}`,
+ variant: "error",
+ })
+ return
+ }
+
+ log.info("session restore response", {
+ workspaceID: input.workspaceID,
+ sessionID: input.sessionID,
+ status: result.response?.status,
+ total: result.data.total,
+ })
+
+ await Promise.all([input.project.workspace.sync(), input.sync.session.refresh()]).catch((err) => {
+ log.error("session restore refresh failed", {
+ workspaceID: input.workspaceID,
+ sessionID: input.sessionID,
+ error: errorData(err),
+ })
+ throw err
+ })
+
+ log.info("session restore complete", {
+ workspaceID: input.workspaceID,
+ sessionID: input.sessionID,
+ total: result.data.total,
+ })
+
+ input.toast.show({
+ message: "Session restored into the new workspace",
+ variant: "success",
+ })
+ input.done?.()
+ if (input.done) return
+ input.dialog.clear()
+}
+
export function DialogWorkspaceCreate(props: { onSelect: (workspaceID: string) => Promise<void> | void }) {
const dialog = useDialog()
const sync = useSync()
@@ -123,18 +226,43 @@ export function DialogWorkspaceCreate(props: { onSelect: (workspaceID: string) =
const create = async (type: string) => {
if (creating()) return
setCreating(type)
+ log.info("workspace create requested", {
+ type,
+ })
+
+ const result = await sdk.client.experimental.workspace.create({ type, branch: null }).catch((err) => {
+ log.error("workspace create request failed", {
+ type,
+ error: errorData(err),
+ })
+ return undefined
+ })
- const result = await sdk.client.experimental.workspace.create({ type, branch: null }).catch(() => undefined)
const workspace = result?.data
if (!workspace) {
setCreating(undefined)
+ log.error("workspace create failed", {
+ type,
+ status: result?.response.status,
+ error: result?.error ? errorData(result.error) : undefined,
+ })
toast.show({
- message: "Failed to create workspace",
+ message: `Failed to create workspace: ${errorMessage(result?.error ?? "no response")}`,
variant: "error",
})
return
}
+ log.info("workspace create response", {
+ type,
+ workspaceID: workspace.id,
+ status: result.response?.status,
+ })
+
await project.workspace.sync()
+ log.info("workspace create synced", {
+ type,
+ workspaceID: workspace.id,
+ })
await props.onSelect(workspace.id)
setCreating(undefined)
}
diff --git a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx
index b4ab82729..e64a16eb8 100644
--- a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx
+++ b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx
@@ -617,9 +617,7 @@ export function Prompt(props: PromptProps) {
let sessionID = props.sessionID
if (sessionID == null) {
- const res = await sdk.client.session.create({
- workspaceID: props.workspaceID,
- })
+ const res = await sdk.client.session.create({ workspace: props.workspaceID })
if (res.error) {
console.log("Creating a session failed:", res.error)
diff --git a/packages/opencode/src/cli/cmd/tui/context/sync.tsx b/packages/opencode/src/cli/cmd/tui/context/sync.tsx
index 46227e28a..38b445744 100644
--- a/packages/opencode/src/cli/cmd/tui/context/sync.tsx
+++ b/packages/opencode/src/cli/cmd/tui/context/sync.tsx
@@ -474,6 +474,13 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
if (match.found) return store.session[match.index]
return undefined
},
+ async refresh() {
+ const start = Date.now() - 30 * 24 * 60 * 60 * 1000
+ const list = await sdk.client.session
+ .list({ start })
+ .then((x) => (x.data ?? []).toSorted((a, b) => a.id.localeCompare(b.id)))
+ setStore("session", reconcile(list))
+ },
status(sessionID: string) {
const session = result.session.get(sessionID)
if (!session) return "idle"
@@ -485,13 +492,13 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
return last.time.completed ? "idle" : "working"
},
async sync(sessionID: string) {
+ console.log('YO', sessionID, fullSyncedSessions.has(sessionID))
if (fullSyncedSessions.has(sessionID)) return
- const workspace = project.workspace.current()
const [session, messages, todo, diff] = await Promise.all([
- sdk.client.session.get({ sessionID, workspace }, { throwOnError: true }),
- sdk.client.session.messages({ sessionID, limit: 100, workspace }),
- sdk.client.session.todo({ sessionID, workspace }),
- sdk.client.session.diff({ sessionID, workspace }),
+ sdk.client.session.get({ sessionID }, { throwOnError: true }),
+ sdk.client.session.messages({ sessionID, limit: 100 }),
+ sdk.client.session.todo({ sessionID }),
+ sdk.client.session.diff({ sessionID }),
])
setStore(
produce((draft) => {
diff --git a/packages/opencode/src/control-plane/workspace-context.ts b/packages/opencode/src/control-plane/workspace-context.ts
index 565472a24..3d4fa5bae 100644
--- a/packages/opencode/src/control-plane/workspace-context.ts
+++ b/packages/opencode/src/control-plane/workspace-context.ts
@@ -2,17 +2,17 @@ import { LocalContext } from "../util"
import type { WorkspaceID } from "../control-plane/schema"
export interface WorkspaceContext {
- workspaceID: string
+ workspaceID: WorkspaceID
}
const context = LocalContext.create<WorkspaceContext>("instance")
export const WorkspaceContext = {
async provide<R>(input: { workspaceID: WorkspaceID; fn: () => R }): Promise<R> {
- return context.provide({ workspaceID: input.workspaceID as string }, () => input.fn())
+ return context.provide({ workspaceID: input.workspaceID }, () => input.fn())
},
- restore<R>(workspaceID: string, fn: () => R): R {
+ restore<R>(workspaceID: WorkspaceID, fn: () => R): R {
return context.provide({ workspaceID }, fn)
},
diff --git a/packages/opencode/src/effect/bridge.ts b/packages/opencode/src/effect/bridge.ts
index d79fc74f4..03e5aefd2 100644
--- a/packages/opencode/src/effect/bridge.ts
+++ b/packages/opencode/src/effect/bridge.ts
@@ -1,6 +1,7 @@
import { Effect, Fiber } from "effect"
import { WorkspaceContext } from "@/control-plane/workspace-context"
import { Instance, type InstanceContext } from "@/project/instance"
+import type { WorkspaceID } from "@/control-plane/schema"
import { LocalContext } from "@/util"
import { InstanceRef, WorkspaceRef } from "./instance-ref"
import { attachWith } from "./run-service"
@@ -10,7 +11,7 @@ export interface Shape {
readonly fork: <A, E, R>(effect: Effect.Effect<A, E, R>) => Fiber.Fiber<A, E>
}
-function restore<R>(instance: InstanceContext | undefined, workspace: string | undefined, fn: () => R): R {
+function restore<R>(instance: InstanceContext | undefined, workspace: WorkspaceID | undefined, fn: () => R): R {
if (instance && workspace !== undefined) {
return WorkspaceContext.restore(workspace, () => Instance.restore(instance, fn))
}
diff --git a/packages/opencode/src/effect/instance-ref.ts b/packages/opencode/src/effect/instance-ref.ts
index 301316c77..effc560c5 100644
--- a/packages/opencode/src/effect/instance-ref.ts
+++ b/packages/opencode/src/effect/instance-ref.ts
@@ -1,10 +1,11 @@
import { Context } from "effect"
import type { InstanceContext } from "@/project/instance"
+import type { WorkspaceID } from "@/control-plane/schema"
export const InstanceRef = Context.Reference<InstanceContext | undefined>("~opencode/InstanceRef", {
defaultValue: () => undefined,
})
-export const WorkspaceRef = Context.Reference<string | undefined>("~opencode/WorkspaceRef", {
+export const WorkspaceRef = Context.Reference<WorkspaceID | undefined>("~opencode/WorkspaceRef", {
defaultValue: () => undefined,
})
diff --git a/packages/opencode/src/session/session.ts b/packages/opencode/src/session/session.ts
index e288aec73..8c5fc29e4 100644
--- a/packages/opencode/src/session/session.ts
+++ b/packages/opencode/src/session/session.ts
@@ -519,12 +519,13 @@ export const layer: Layer.Layer<Service, never, Bus.Service | Storage.Service> =
workspaceID?: WorkspaceID
}) {
const directory = yield* InstanceState.directory
+ const workspace = yield* InstanceState.workspaceID
return yield* createNext({
parentID: input?.parentID,
directory,
title: input?.title,
permission: input?.permission,
- workspaceID: input?.workspaceID,
+ workspaceID: workspace,
})
})
diff --git a/packages/opencode/test/cli/tui/sync-provider.test.tsx b/packages/opencode/test/cli/tui/sync-provider.test.tsx
index 3ef126ef4..e75e18619 100644
--- a/packages/opencode/test/cli/tui/sync-provider.test.tsx
+++ b/packages/opencode/test/cli/tui/sync-provider.test.tsx
@@ -264,27 +264,15 @@ describe("SyncProvider", () => {
log.length = 0
await sync.session.sync("ses_1")
+ expect(log.filter((item) => item.path === "/session/ses_1")).toHaveLength(1)
- expect(log.filter((item) => item.path === "/session/ses_1" && item.workspace === "ws_a")).toHaveLength(1)
- expect(sync.data.todo.ses_1[0]?.content).toBe("todo-ws_a")
- expect(sync.data.message.ses_1[0]?.id).toBe("msg_1")
- expect(sync.data.part.msg_1[0]).toMatchObject({ type: "text", text: "part-ws_a" })
- expect(sync.data.session_diff.ses_1[0]?.file).toBe("ws_a.ts")
-
- log.length = 0
project.workspace.set("ws_b")
await waitBoot(log, "ws_b")
expect(project.workspace.current()).toBe("ws_b")
log.length = 0
await sync.session.sync("ses_1")
- await wait(() => log.some((item) => item.path === "/session/ses_1" && item.workspace === "ws_b"))
-
- expect(log.filter((item) => item.path === "/session/ses_1" && item.workspace === "ws_b")).toHaveLength(1)
- expect(sync.data.todo.ses_1[0]?.content).toBe("todo-ws_b")
- expect(sync.data.message.ses_1[0]?.id).toBe("msg_1")
- expect(sync.data.part.msg_1[0]).toMatchObject({ type: "text", text: "part-ws_b" })
- expect(sync.data.session_diff.ses_1[0]?.file).toBe("ws_b.ts")
+ expect(log.filter((item) => item.path === "/session/ses_1")).toHaveLength(1)
} finally {
app.renderer.destroy()
}