Skip to content

Commit 180ded6

Browse files
authored
rector(core,tui): handle workspace state in project context, add workspace status, improve ui (#21896)
1 parent bf60162 commit 180ded6

22 files changed

Lines changed: 629 additions & 610 deletions

File tree

packages/opencode/specs/tui-plugins.md

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -202,7 +202,7 @@ Top-level API groups exposed to `tui(api, options, meta)`:
202202
- `api.kv.get`, `set`, `ready`
203203
- `api.state`
204204
- `api.theme.current`, `selected`, `has`, `set`, `install`, `mode`, `ready`
205-
- `api.client`, `api.scopedClient(workspaceID?)`, `api.workspace.current()`, `api.workspace.set(workspaceID?)`
205+
- `api.client`
206206
- `api.event.on(type, handler)`
207207
- `api.renderer`
208208
- `api.slots.register(plugin)`
@@ -270,7 +270,6 @@ Command behavior:
270270
- `provider`
271271
- `path.{state,config,worktree,directory}`
272272
- `vcs?.branch`
273-
- `workspace.list()` / `workspace.get(workspaceID)`
274273
- `session.count()`
275274
- `session.diff(sessionID)`
276275
- `session.todo(sessionID)`
@@ -282,8 +281,6 @@ Command behavior:
282281
- `lsp()`
283282
- `mcp()`
284283
- `api.client` always reflects the current runtime client.
285-
- `api.scopedClient(workspaceID?)` creates or reuses a client bound to a workspace.
286-
- `api.workspace.set(...)` rebinds the active workspace; `api.client` follows that rebind.
287284
- `api.event.on(type, handler)` subscribes to the TUI event stream and returns an unsubscribe function.
288285
- `api.renderer` exposes the raw `CliRenderer`.
289286

packages/opencode/src/cli/cmd/tui/app.tsx

Lines changed: 1 addition & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ import { DialogProvider, useDialog } from "@tui/ui/dialog"
2222
import { DialogProvider as DialogProviderList } from "@tui/component/dialog-provider"
2323
import { ErrorComponent } from "@tui/component/error-component"
2424
import { PluginRouteMissing } from "@tui/component/plugin-route-missing"
25-
import { ProjectProvider } from "@tui/context/project"
25+
import { ProjectProvider, useProject } from "@tui/context/project"
2626
import { useEvent } from "@tui/context/event"
2727
import { SDKProvider, useSDK } from "@tui/context/sdk"
2828
import { StartupLoading } from "@tui/component/startup-loading"
@@ -36,7 +36,6 @@ import { DialogHelp } from "./ui/dialog-help"
3636
import { CommandProvider, useCommandDialog } from "@tui/component/dialog-command"
3737
import { DialogAgent } from "@tui/component/dialog-agent"
3838
import { DialogSessionList } from "@tui/component/dialog-session-list"
39-
import { DialogWorkspaceList } from "@tui/component/dialog-workspace-list"
4039
import { DialogConsoleOrg } from "@tui/component/dialog-console-org"
4140
import { KeybindProvider, useKeybind } from "@tui/context/keybind"
4241
import { ThemeProvider, useTheme } from "@tui/context/theme"
@@ -465,22 +464,6 @@ function App(props: { onSnapshot?: () => Promise<string[]> }) {
465464
dialog.replace(() => <DialogSessionList />)
466465
},
467466
},
468-
...(Flag.OPENCODE_EXPERIMENTAL_WORKSPACES
469-
? [
470-
{
471-
title: "Manage workspaces",
472-
value: "workspace.list",
473-
category: "Workspace",
474-
suggested: true,
475-
slash: {
476-
name: "workspaces",
477-
},
478-
onSelect: () => {
479-
dialog.replace(() => <DialogWorkspaceList />)
480-
},
481-
},
482-
]
483-
: []),
484467
{
485468
title: "New session",
486469
suggested: route.data.type === "session",

packages/opencode/src/cli/cmd/tui/component/dialog-session-list.tsx

Lines changed: 74 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,25 +2,31 @@ import { useDialog } from "@tui/ui/dialog"
22
import { DialogSelect } from "@tui/ui/dialog-select"
33
import { useRoute } from "@tui/context/route"
44
import { useSync } from "@tui/context/sync"
5-
import { createMemo, createSignal, createResource, onMount, Show } from "solid-js"
5+
import { createMemo, createResource, createSignal, onMount } from "solid-js"
66
import { Locale } from "@/util/locale"
7+
import { useProject } from "@tui/context/project"
78
import { useKeybind } from "../context/keybind"
89
import { useTheme } from "../context/theme"
910
import { useSDK } from "../context/sdk"
11+
import { Flag } from "@/flag/flag"
1012
import { DialogSessionRename } from "./dialog-session-rename"
11-
import { useKV } from "../context/kv"
13+
import { Keybind } from "@/util/keybind"
1214
import { createDebouncedSignal } from "../util/signal"
15+
import { useToast } from "../ui/toast"
16+
import { DialogWorkspaceCreate, openWorkspaceSession } from "./dialog-workspace-create"
1317
import { Spinner } from "./spinner"
1418

19+
type WorkspaceStatus = "connected" | "connecting" | "disconnected" | "error"
20+
1521
export function DialogSessionList() {
1622
const dialog = useDialog()
1723
const route = useRoute()
1824
const sync = useSync()
25+
const project = useProject()
1926
const keybind = useKeybind()
2027
const { theme } = useTheme()
2128
const sdk = useSDK()
22-
const kv = useKV()
23-
29+
const toast = useToast()
2430
const [toDelete, setToDelete] = createSignal<string>()
2531
const [search, setSearch] = createDebouncedSignal("", 150)
2632

@@ -31,15 +37,68 @@ export function DialogSessionList() {
3137
})
3238

3339
const currentSessionID = createMemo(() => (route.data.type === "session" ? route.data.sessionID : undefined))
34-
3540
const sessions = createMemo(() => searchResults() ?? sync.data.session)
3641

42+
function createWorkspace() {
43+
dialog.replace(() => (
44+
<DialogWorkspaceCreate
45+
onSelect={(workspaceID) =>
46+
openWorkspaceSession({
47+
dialog,
48+
route,
49+
sdk,
50+
sync,
51+
toast,
52+
workspaceID,
53+
})
54+
}
55+
/>
56+
))
57+
}
58+
3759
const options = createMemo(() => {
3860
const today = new Date().toDateString()
3961
return sessions()
4062
.filter((x) => x.parentID === undefined)
4163
.toSorted((a, b) => b.time.updated - a.time.updated)
4264
.map((x) => {
65+
const workspace = x.workspaceID ? project.workspace.get(x.workspaceID) : undefined
66+
67+
let workspaceStatus: WorkspaceStatus | null = null
68+
if (x.workspaceID) {
69+
workspaceStatus = project.workspace.status(x.workspaceID) || "error"
70+
}
71+
72+
let footer = ""
73+
if (Flag.OPENCODE_EXPERIMENTAL_WORKSPACES) {
74+
if (x.workspaceID) {
75+
let desc = "unknown"
76+
if (workspace) {
77+
desc = `${workspace.type}: ${workspace.name}`
78+
}
79+
80+
footer = (
81+
<>
82+
{desc}{" "}
83+
<span
84+
style={{
85+
fg:
86+
workspaceStatus === "error"
87+
? theme.error
88+
: workspaceStatus === "disconnected"
89+
? theme.textMuted
90+
: theme.success,
91+
}}
92+
>
93+
94+
</span>
95+
</>
96+
)
97+
}
98+
} else {
99+
footer = Locale.time(x.time.updated)
100+
}
101+
43102
const date = new Date(x.time.updated)
44103
let category = date.toDateString()
45104
if (category === today) {
@@ -53,7 +112,7 @@ export function DialogSessionList() {
53112
bg: isDeleting ? theme.error : undefined,
54113
value: x.id,
55114
category,
56-
footer: Locale.time(x.time.updated),
115+
footer,
57116
gutter: isWorking ? <Spinner /> : undefined,
58117
}
59118
})
@@ -102,6 +161,15 @@ export function DialogSessionList() {
102161
dialog.replace(() => <DialogSessionRename session={option.value} />)
103162
},
104163
},
164+
{
165+
keybind: Keybind.parse("ctrl+w")[0],
166+
title: "new workspace",
167+
side: "right",
168+
disabled: !Flag.OPENCODE_EXPERIMENTAL_WORKSPACES,
169+
onTrigger: () => {
170+
createWorkspace()
171+
},
172+
},
105173
]}
106174
/>
107175
)
Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
import { createOpencodeClient } from "@opencode-ai/sdk/v2"
2+
import { useDialog } from "@tui/ui/dialog"
3+
import { DialogSelect } from "@tui/ui/dialog-select"
4+
import { useRoute } from "@tui/context/route"
5+
import { useSync } from "@tui/context/sync"
6+
import { useProject } from "@tui/context/project"
7+
import { createMemo, createSignal, onMount } from "solid-js"
8+
import { setTimeout as sleep } from "node:timers/promises"
9+
import { useSDK } from "../context/sdk"
10+
import { useToast } from "../ui/toast"
11+
12+
function scoped(sdk: ReturnType<typeof useSDK>, sync: ReturnType<typeof useSync>, workspaceID: string) {
13+
return createOpencodeClient({
14+
baseUrl: sdk.url,
15+
fetch: sdk.fetch,
16+
directory: sync.path.directory || sdk.directory,
17+
experimental_workspaceID: workspaceID,
18+
})
19+
}
20+
21+
export async function openWorkspaceSession(input: {
22+
dialog: ReturnType<typeof useDialog>
23+
route: ReturnType<typeof useRoute>
24+
sdk: ReturnType<typeof useSDK>
25+
sync: ReturnType<typeof useSync>
26+
toast: ReturnType<typeof useToast>
27+
workspaceID: string
28+
}) {
29+
const client = scoped(input.sdk, input.sync, input.workspaceID)
30+
while (true) {
31+
const result = await client.session.create({ workspaceID: input.workspaceID }).catch(() => undefined)
32+
if (!result) {
33+
input.toast.show({
34+
message: "Failed to create workspace session",
35+
variant: "error",
36+
})
37+
return
38+
}
39+
if (result.response.status >= 500 && result.response.status < 600) {
40+
await sleep(1000)
41+
continue
42+
}
43+
if (!result.data) {
44+
input.toast.show({
45+
message: "Failed to create workspace session",
46+
variant: "error",
47+
})
48+
return
49+
}
50+
input.route.navigate({
51+
type: "session",
52+
sessionID: result.data.id,
53+
})
54+
input.dialog.clear()
55+
return
56+
}
57+
}
58+
59+
export function DialogWorkspaceCreate(props: { onSelect: (workspaceID: string) => Promise<void> | void }) {
60+
const dialog = useDialog()
61+
const sync = useSync()
62+
const project = useProject()
63+
const sdk = useSDK()
64+
const toast = useToast()
65+
const [creating, setCreating] = createSignal<string>()
66+
67+
onMount(() => {
68+
dialog.setSize("medium")
69+
})
70+
71+
const options = createMemo(() => {
72+
const type = creating()
73+
if (type) {
74+
return [
75+
{
76+
title: `Creating ${type} workspace...`,
77+
value: "creating" as const,
78+
description: "This can take a while for remote environments",
79+
},
80+
]
81+
}
82+
return [
83+
{
84+
title: "Worktree",
85+
value: "worktree" as const,
86+
description: "Create a local git worktree",
87+
},
88+
]
89+
})
90+
91+
const create = async (type: string) => {
92+
if (creating()) return
93+
setCreating(type)
94+
95+
const result = await sdk.client.experimental.workspace.create({ type, branch: null }).catch(() => undefined)
96+
const workspace = result?.data
97+
if (!workspace) {
98+
setCreating(undefined)
99+
toast.show({
100+
message: "Failed to create workspace",
101+
variant: "error",
102+
})
103+
return
104+
}
105+
await project.workspace.sync()
106+
await props.onSelect(workspace.id)
107+
setCreating(undefined)
108+
}
109+
110+
return (
111+
<DialogSelect
112+
title={creating() ? "Creating Workspace" : "New Workspace"}
113+
skipFilter={true}
114+
options={options()}
115+
onSelect={(option) => {
116+
if (option.value === "creating") return
117+
void create(option.value)
118+
}}
119+
/>
120+
)
121+
}

0 commit comments

Comments
 (0)