From 165481813af96f33749348819b8828422b5f81f3 Mon Sep 17 00:00:00 2001 From: James Long Date: Thu, 9 Apr 2026 12:32:14 -0400 Subject: [PATCH 1/7] events --- packages/opencode/src/bus/global.ts | 2 + packages/opencode/src/bus/index.ts | 7 +- packages/opencode/src/cli/cmd/tui/app.tsx | 65 ++++++------- .../tui/component/dialog-workspace-list.tsx | 21 ++-- .../cmd/tui/component/prompt/autocomplete.tsx | 2 +- .../cli/cmd/tui/component/prompt/index.tsx | 4 +- .../src/cli/cmd/tui/context/directory.ts | 4 +- .../opencode/src/cli/cmd/tui/context/event.ts | 41 ++++++++ .../src/cli/cmd/tui/context/project.tsx | 65 +++++++++++++ .../src/cli/cmd/tui/context/route.tsx | 1 - .../opencode/src/cli/cmd/tui/context/sdk.tsx | 17 ++-- .../opencode/src/cli/cmd/tui/context/sync.tsx | 89 ++++++++++------- .../opencode/src/cli/cmd/tui/plugin/api.tsx | 6 +- .../opencode/src/cli/cmd/tui/routes/home.tsx | 13 ++- .../src/cli/cmd/tui/routes/session/index.tsx | 21 ++-- packages/opencode/src/cli/cmd/tui/thread.ts | 16 +--- packages/opencode/src/cli/cmd/tui/worker.ts | 96 +------------------ .../src/control-plane/workspace-context.ts | 22 +++++ .../opencode/src/control-plane/workspace.ts | 12 +-- packages/opencode/src/effect/instance-ref.ts | 4 + .../opencode/src/effect/instance-state.ts | 7 +- packages/opencode/src/effect/run-service.ts | 6 +- packages/opencode/src/project/instance.ts | 44 ++++++--- packages/opencode/src/project/project.ts | 1 + packages/opencode/src/server/router.ts | 43 ++++++--- packages/opencode/src/server/routes/global.ts | 2 + packages/opencode/src/worktree/index.ts | 7 ++ packages/sdk/js/src/v2/gen/types.gen.ts | 2 + packages/sdk/openapi.json | 6 ++ 29 files changed, 381 insertions(+), 245 deletions(-) create mode 100644 packages/opencode/src/cli/cmd/tui/context/event.ts create mode 100644 packages/opencode/src/cli/cmd/tui/context/project.tsx create mode 100644 packages/opencode/src/control-plane/workspace-context.ts diff --git a/packages/opencode/src/bus/global.ts b/packages/opencode/src/bus/global.ts index 43386dd6b2..e751b59faf 100644 --- a/packages/opencode/src/bus/global.ts +++ b/packages/opencode/src/bus/global.ts @@ -4,6 +4,8 @@ export const GlobalBus = new EventEmitter<{ event: [ { directory?: string + project?: string + workspace?: string payload: any }, ] diff --git a/packages/opencode/src/bus/index.ts b/packages/opencode/src/bus/index.ts index fe26a6672e..6db4eb04fc 100644 --- a/packages/opencode/src/bus/index.ts +++ b/packages/opencode/src/bus/index.ts @@ -1,9 +1,9 @@ import z from "zod" import { Effect, Exit, Layer, PubSub, Scope, ServiceMap, Stream } from "effect" import { Log } from "../util/log" -import { Instance } from "../project/instance" import { BusEvent } from "./bus-event" import { GlobalBus } from "./global" +import { WorkspaceContext } from "@/control-plane/workspace-context" import { InstanceState } from "@/effect/instance-state" import { makeRuntime } from "@/effect/run-service" @@ -91,8 +91,13 @@ export namespace Bus { yield* PubSub.publish(s.wildcard, payload) const dir = yield* InstanceState.directory + const context = yield* InstanceState.context + const workspace = yield* InstanceState.workspaceID + GlobalBus.emit("event", { directory: dir, + project: context.project.id, + workspace, payload, }) }) diff --git a/packages/opencode/src/cli/cmd/tui/app.tsx b/packages/opencode/src/cli/cmd/tui/app.tsx index 4161c025c1..2f8d1f7bbb 100644 --- a/packages/opencode/src/cli/cmd/tui/app.tsx +++ b/packages/opencode/src/cli/cmd/tui/app.tsx @@ -14,7 +14,6 @@ import { batch, Show, on, - onCleanup, } from "solid-js" import { win32DisableProcessedInput, win32InstallCtrlCGuard } from "./win32" import { Flag } from "@/flag/flag" @@ -23,6 +22,8 @@ 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 { useEvent } from "@tui/context/event" import { SDKProvider, useSDK } from "@tui/context/sdk" import { StartupLoading } from "@tui/component/startup-loading" import { SyncProvider, useSync } from "@tui/context/sync" @@ -54,7 +55,6 @@ import { KVProvider, useKV } from "./context/kv" import { Provider } from "@/provider/provider" import { ArgsProvider, useArgs, type Args } from "./context/args" import open from "open" -import { writeHeapSnapshot } from "v8" import { PromptRefProvider, usePromptRef } from "./context/prompt" import { TuiConfigProvider, useTuiConfig } from "./context/tui-config" import { TuiConfig } from "@/config/tui" @@ -216,27 +216,29 @@ export function tui(input: { headers={input.headers} events={input.events} > - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + @@ -260,6 +262,7 @@ function App(props: { onSnapshot?: () => Promise }) { const kv = useKV() const command = useCommandDialog() const keybind = useKeybind() + const event = useEvent() const sdk = useSDK() const toast = useToast() const themeState = useTheme() @@ -283,6 +286,7 @@ function App(props: { onSnapshot?: () => Promise }) { route, routes, bump: () => setRouteRev((x) => x + 1), + event, sdk, sync, theme: themeState, @@ -491,12 +495,9 @@ function App(props: { onSnapshot?: () => Promise }) { const current = promptRef.current // Don't require focus - if there's any text, preserve it const currentPrompt = current?.current?.input ? current.current : undefined - const workspaceID = - route.data.type === "session" ? sync.session.get(route.data.sessionID)?.workspaceID : undefined route.navigate({ type: "home", initialPrompt: currentPrompt, - workspaceID, }) dialog.clear() }, @@ -806,11 +807,11 @@ function App(props: { onSnapshot?: () => Promise }) { }, ]) - sdk.event.on(TuiEvent.CommandExecute.type, (evt) => { + event.on(TuiEvent.CommandExecute.type, (evt) => { command.trigger(evt.properties.command) }) - sdk.event.on(TuiEvent.ToastShow.type, (evt) => { + event.on(TuiEvent.ToastShow.type, (evt) => { toast.show({ title: evt.properties.title, message: evt.properties.message, @@ -819,14 +820,14 @@ function App(props: { onSnapshot?: () => Promise }) { }) }) - sdk.event.on(TuiEvent.SessionSelect.type, (evt) => { + event.on(TuiEvent.SessionSelect.type, (evt) => { route.navigate({ type: "session", sessionID: evt.properties.sessionID, }) }) - sdk.event.on("session.deleted", (evt) => { + event.on("session.deleted", (evt) => { if (route.data.type === "session" && route.data.sessionID === evt.properties.info.id) { route.navigate({ type: "home" }) toast.show({ @@ -836,7 +837,7 @@ function App(props: { onSnapshot?: () => Promise }) { } }) - sdk.event.on("session.error", (evt) => { + event.on("session.error", (evt) => { const error = evt.properties.error if (error && typeof error === "object" && error.name === "MessageAbortedError") return const message = errorMessage(error) @@ -848,7 +849,7 @@ function App(props: { onSnapshot?: () => Promise }) { }) }) - sdk.event.on("installation.update-available", async (evt) => { + event.on("installation.update-available", async (evt) => { const version = evt.properties.version const skipped = kv.get("skipped_version") 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 index 84127b5763..037cebb729 100644 --- a/packages/opencode/src/cli/cmd/tui/component/dialog-workspace-list.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/dialog-workspace-list.tsx @@ -1,5 +1,6 @@ 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" @@ -14,7 +15,7 @@ function scoped(sdk: ReturnType, sync: ReturnType return createOpencodeClient({ baseUrl: sdk.url, fetch: sdk.fetch, - directory: sync.data.path.directory || sdk.directory, + directory: sync.path.directory || sdk.directory, experimental_workspaceID: workspaceID, }) } @@ -149,6 +150,7 @@ function DialogWorkspaceCreate(props: { onSelect: (workspaceID: string) => Promi export function DialogWorkspaceList() { const dialog = useDialog() + const project = useProject() const route = useRoute() const sync = useSync() const sdk = useSDK() @@ -168,8 +170,9 @@ export function DialogWorkspaceList() { forceCreate, }) - async function selectWorkspace(workspaceID: string) { - if (workspaceID === "__local__") { + async function selectWorkspace(workspaceID: string | null) { + if (workspaceID == null) { + project.workspace.set(undefined) if (localCount() > 0) { dialog.replace(() => ) return @@ -199,12 +202,7 @@ export function DialogWorkspaceList() { await open(workspaceID) } - const currentWorkspaceID = createMemo(() => { - if (route.data.type === "session") { - return sync.session.get(route.data.sessionID)?.workspaceID ?? "__local__" - } - return "__local__" - }) + const currentWorkspaceID = createMemo(() => project.workspace.current()) const localCount = createMemo( () => sync.data.session.filter((session) => !session.workspaceID && !session.parentID).length, @@ -234,7 +232,7 @@ export function DialogWorkspaceList() { const options = createMemo(() => [ { title: "Local", - value: "__local__", + value: null, category: "Workspace", description: "Use the local machine", footer: `${localCount()} session${localCount() === 1 ? "" : "s"}`, @@ -292,7 +290,7 @@ export function DialogWorkspaceList() { keybind: keybind.all.session_delete?.[0], title: "delete", onTrigger: async (option) => { - if (option.value === "__create__" || option.value === "__local__") return + if (option.value === "__create__" || option.value === null) return if (toDelete() !== option.value) { setToDelete(option.value) return @@ -307,6 +305,7 @@ export function DialogWorkspaceList() { return } if (currentWorkspaceID() === option.value) { + project.workspace.set(undefined) route.navigate({ type: "home", }) diff --git a/packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx b/packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx index 1c5ede4d72..2118fe98e1 100644 --- a/packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx @@ -250,7 +250,7 @@ export function Autocomplete(props: { const width = props.anchor().width - 4 options.push( ...sortedFiles.map((item): AutocompleteOption => { - const baseDir = (sync.data.path.directory || process.cwd()).replace(/\/+$/, "") + const baseDir = (sync.path.directory || process.cwd()).replace(/\/+$/, "") const fullPath = `${baseDir}/${item}` const urlObj = pathToFileURL(fullPath) let filename = item 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 747c61fd0b..ba6df1d6bb 100644 --- a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx @@ -10,6 +10,7 @@ import { EmptyBorder, SplitBorder } from "@tui/component/border" import { useSDK } from "@tui/context/sdk" import { useRoute } from "@tui/context/route" import { useSync } from "@tui/context/sync" +import { useEvent } from "@tui/context/event" import { MessageID, PartID } from "@/session/schema" import { createStore, produce } from "solid-js/store" import { useKeybind } from "@tui/context/keybind" @@ -115,8 +116,9 @@ export function Prompt(props: PromptProps) { const agentStyleId = syntax().getStyleId("extmark.agent")! const pasteStyleId = syntax().getStyleId("extmark.paste")! let promptPartTypeId = 0 + const event = useEvent() - sdk.event.on(TuiEvent.PromptAppend.type, (evt) => { + event.on(TuiEvent.PromptAppend.type, (evt) => { if (!input || input.isDestroyed) return input.insertText(evt.properties.text) setTimeout(() => { diff --git a/packages/opencode/src/cli/cmd/tui/context/directory.ts b/packages/opencode/src/cli/cmd/tui/context/directory.ts index 17e5c180a1..81f2173980 100644 --- a/packages/opencode/src/cli/cmd/tui/context/directory.ts +++ b/packages/opencode/src/cli/cmd/tui/context/directory.ts @@ -1,11 +1,13 @@ import { createMemo } from "solid-js" +import { useProject } from "./project" import { useSync } from "./sync" import { Global } from "@/global" export function useDirectory() { + const project = useProject() const sync = useSync() return createMemo(() => { - const directory = sync.data.path.directory || process.cwd() + const directory = project.instance.path().directory || process.cwd() const result = directory.replace(Global.Path.home, "~") if (sync.data.vcs?.branch) return result + ":" + sync.data.vcs.branch return result diff --git a/packages/opencode/src/cli/cmd/tui/context/event.ts b/packages/opencode/src/cli/cmd/tui/context/event.ts new file mode 100644 index 0000000000..da073f6e92 --- /dev/null +++ b/packages/opencode/src/cli/cmd/tui/context/event.ts @@ -0,0 +1,41 @@ +import type { Event } from "@opencode-ai/sdk/v2" +import { useProject } from "./project" +import { useSDK } from "./sdk" + +export function useEvent() { + const project = useProject() + const sdk = useSDK() + + function subscribe(handler: (event: Event) => void) { + return sdk.event.on("event", (event) => { + // Special hack for truly global events + if (event.directory === "global") { + handler(event.payload) + } + + if (project.workspace.current()) { + if (event.workspace === project.workspace.current()) { + handler(event.payload) + } + + return + } + + if (event.directory === project.instance.directory()) { + handler(event.payload) + } + }) + } + + function on(type: T, handler: (event: Extract) => void) { + return subscribe((event) => { + if (event.type !== type) return + handler(event as Extract) + }) + } + + return { + subscribe, + on, + } +} diff --git a/packages/opencode/src/cli/cmd/tui/context/project.tsx b/packages/opencode/src/cli/cmd/tui/context/project.tsx new file mode 100644 index 0000000000..522e724013 --- /dev/null +++ b/packages/opencode/src/cli/cmd/tui/context/project.tsx @@ -0,0 +1,65 @@ +import { batch } from "solid-js" +import type { Path } from "@opencode-ai/sdk" +import { createStore, reconcile } from "solid-js/store" +import { createSimpleContext } from "./helper" +import { useSDK } from "./sdk" + +export const { use: useProject, provider: ProjectProvider } = createSimpleContext({ + name: "Project", + init: () => { + const sdk = useSDK() + const [store, setStore] = createStore({ + project: { + id: undefined as string | undefined, + }, + instance: { + path: { + state: "", + config: "", + worktree: "", + directory: sdk.directory ?? "", + } satisfies Path, + }, + workspace: undefined as string | undefined, + }) + + async function sync() { + const workspace = store.workspace + const [path, project] = await Promise.all([ + sdk.client.path.get({ workspace }), + sdk.client.project.current({ workspace }), + ]) + + batch(() => { + setStore("instance", "path", reconcile(path.data!)) + setStore("project", "id", project.data?.id) + }) + } + + return { + data: store, + project() { + return store.project.id + }, + instance: { + path() { + return store.instance.path + }, + directory() { + return store.instance.path.directory + }, + }, + workspace: { + current() { + return store.workspace + }, + set(next?: string | null) { + const workspace = next ?? undefined + if (store.workspace === workspace) return + setStore("workspace", workspace) + }, + }, + sync, + } + }, +}) diff --git a/packages/opencode/src/cli/cmd/tui/context/route.tsx b/packages/opencode/src/cli/cmd/tui/context/route.tsx index 939c2d5dc8..e9f463a13f 100644 --- a/packages/opencode/src/cli/cmd/tui/context/route.tsx +++ b/packages/opencode/src/cli/cmd/tui/context/route.tsx @@ -5,7 +5,6 @@ import type { PromptInfo } from "../component/prompt/history" export type HomeRoute = { type: "home" initialPrompt?: PromptInfo - workspaceID?: string } export type SessionRoute = { diff --git a/packages/opencode/src/cli/cmd/tui/context/sdk.tsx b/packages/opencode/src/cli/cmd/tui/context/sdk.tsx index 348c3ca1db..ad35aa45c2 100644 --- a/packages/opencode/src/cli/cmd/tui/context/sdk.tsx +++ b/packages/opencode/src/cli/cmd/tui/context/sdk.tsx @@ -1,10 +1,11 @@ -import { createOpencodeClient, type Event } from "@opencode-ai/sdk/v2" +import { createOpencodeClient } from "@opencode-ai/sdk/v2" +import type { GlobalEvent, Event } from "@opencode-ai/sdk/v2" import { createSimpleContext } from "./helper" import { createGlobalEmitter } from "@solid-primitives/event-bus" import { batch, onCleanup, onMount } from "solid-js" export type EventSource = { - subscribe: (directory: string | undefined, handler: (event: Event) => void) => Promise<() => void> + subscribe: (handler: (event: GlobalEvent) => void) => Promise<() => void> } export const { use: useSDK, provider: SDKProvider } = createSimpleContext({ @@ -32,10 +33,10 @@ export const { use: useSDK, provider: SDKProvider } = createSimpleContext({ let sdk = createSDK() const emitter = createGlobalEmitter<{ - [key in Event["type"]]: Extract + event: GlobalEvent }>() - let queue: Event[] = [] + let queue: GlobalEvent[] = [] let timer: Timer | undefined let last = 0 @@ -48,12 +49,12 @@ export const { use: useSDK, provider: SDKProvider } = createSimpleContext({ // Batch all event emissions so all store updates result in a single render batch(() => { for (const event of events) { - emitter.emit(event.type, event) + emitter.emit("event", event) } }) } - const handleEvent = (event: Event) => { + const handleEvent = (event: GlobalEvent) => { queue.push(event) const elapsed = Date.now() - last @@ -74,7 +75,7 @@ export const { use: useSDK, provider: SDKProvider } = createSimpleContext({ ;(async () => { while (true) { if (abort.signal.aborted || ctrl.signal.aborted) break - const events = await sdk.event.subscribe({}, { signal: ctrl.signal }) + const events = await sdk.global.event({ signal: ctrl.signal }) for await (const event of events.stream) { if (ctrl.signal.aborted) break @@ -89,7 +90,7 @@ export const { use: useSDK, provider: SDKProvider } = createSimpleContext({ onMount(async () => { if (props.events) { - const unsub = await props.events.subscribe(props.directory, handleEvent) + const unsub = await props.events.subscribe(handleEvent) onCleanup(unsub) } else { startSSE() diff --git a/packages/opencode/src/cli/cmd/tui/context/sync.tsx b/packages/opencode/src/cli/cmd/tui/context/sync.tsx index 11336d5002..fc88bd8a99 100644 --- a/packages/opencode/src/cli/cmd/tui/context/sync.tsx +++ b/packages/opencode/src/cli/cmd/tui/context/sync.tsx @@ -17,18 +17,19 @@ 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" +import { useEvent } from "@tui/context/event" import { useSDK } from "@tui/context/sdk" import { Binary } from "@opencode-ai/util/binary" import { createSimpleContext } from "./helper" import type { Snapshot } from "@/snapshot" import { useExit } from "./exit" import { useArgs } from "./args" -import { batch, onMount } from "solid-js" +import { batch, createEffect, on } from "solid-js" import { Log } from "@/util/log" -import type { Path } from "@opencode-ai/sdk" -import type { Workspace } from "@opencode-ai/sdk/v2" import { ConsoleState, emptyConsoleState, type ConsoleState as ConsoleStateType } from "@/config/console-state" export const { use: useSync, provider: SyncProvider } = createSimpleContext({ @@ -74,9 +75,8 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ [key: string]: McpResource } formatter: FormatterStatus[] - vcs: VcsInfo | undefined - path: Path workspaceList: Workspace[] + vcs: VcsInfo | undefined }>({ provider_next: { all: [], @@ -103,21 +103,25 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ mcp: {}, mcp_resource: {}, formatter: [], - vcs: undefined, - path: { state: "", config: "", worktree: "", directory: "" }, workspaceList: [], + vcs: undefined, }) + const event = useEvent() + 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) + } } - sdk.event.listen((e) => { - const event = e.details + event.subscribe((event) => { switch (event.type) { case "server.instance.disposed": bootstrap() @@ -344,7 +348,8 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ } case "lsp.updated": { - sdk.client.lsp.status().then((x) => setStore("lsp", x.data!)) + const workspace = project.workspace.current() + sdk.client.lsp.status({ workspace }).then((x) => setStore("lsp", x.data!)) break } @@ -360,25 +365,28 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ async function bootstrap() { console.log("bootstrapping") + const workspace = project.workspace.current() const start = Date.now() - 30 * 24 * 60 * 60 * 1000 const sessionListPromise = sdk.client.session - .list({ start: start }) + .list({ start: start, workspace }) .then((x) => (x.data ?? []).toSorted((a, b) => a.id.localeCompare(b.id))) // blocking - include session.list when continuing a session - const providersPromise = sdk.client.config.providers({}, { throwOnError: true }) - const providerListPromise = sdk.client.provider.list({}, { throwOnError: true }) + const providersPromise = sdk.client.config.providers({ workspace }, { throwOnError: true }) + const providerListPromise = sdk.client.provider.list({ workspace }, { throwOnError: true }) const consoleStatePromise = sdk.client.experimental.console - .get({}, { throwOnError: true }) + .get({ workspace }, { throwOnError: true }) .then((x) => ConsoleState.parse(x.data)) .catch(() => emptyConsoleState) - const agentsPromise = sdk.client.app.agents({}, { throwOnError: true }) - const configPromise = sdk.client.config.get({}, { throwOnError: true }) + const agentsPromise = sdk.client.app.agents({ workspace }, { throwOnError: true }) + const configPromise = sdk.client.config.get({ workspace }, { throwOnError: true }) + const projectPromise = project.sync() const blockingRequests: Promise[] = [ providersPromise, providerListPromise, agentsPromise, configPromise, + projectPromise, ...(args.continue ? [sessionListPromise] : []), ] @@ -423,17 +431,18 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ Promise.all([ ...(args.continue ? [] : [sessionListPromise.then((sessions) => setStore("session", reconcile(sessions)))]), consoleStatePromise.then((consoleState) => setStore("console_state", reconcile(consoleState))), - sdk.client.command.list().then((x) => setStore("command", reconcile(x.data ?? []))), - sdk.client.lsp.status().then((x) => setStore("lsp", reconcile(x.data!))), - sdk.client.mcp.status().then((x) => setStore("mcp", reconcile(x.data!))), - sdk.client.experimental.resource.list().then((x) => setStore("mcp_resource", reconcile(x.data ?? {}))), - sdk.client.formatter.status().then((x) => setStore("formatter", reconcile(x.data!))), - sdk.client.session.status().then((x) => { + sdk.client.command.list({ workspace }).then((x) => setStore("command", reconcile(x.data ?? []))), + sdk.client.lsp.status({ workspace }).then((x) => setStore("lsp", reconcile(x.data!))), + sdk.client.mcp.status({ workspace }).then((x) => setStore("mcp", reconcile(x.data!))), + sdk.client.experimental.resource + .list({ workspace }) + .then((x) => setStore("mcp_resource", reconcile(x.data ?? {}))), + sdk.client.formatter.status({ workspace }).then((x) => setStore("formatter", reconcile(x.data!))), + sdk.client.session.status({ workspace }).then((x) => { setStore("session_status", reconcile(x.data!)) }), - sdk.client.provider.auth().then((x) => setStore("provider_auth", reconcile(x.data ?? {}))), - sdk.client.vcs.get().then((x) => setStore("vcs", reconcile(x.data))), - sdk.client.path.get().then((x) => setStore("path", reconcile(x.data!))), + 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(), ]).then(() => { setStore("status", "complete") @@ -449,11 +458,16 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ }) } - onMount(() => { - bootstrap() - }) - const fullSyncedSessions = new Set() + createEffect( + on( + () => project.workspace.current(), + () => { + void bootstrap() + }, + ), + ) + const result = { data: store, set: setStore, @@ -463,6 +477,9 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ get ready() { return store.status !== "loading" }, + get path() { + return project.instance.path() + }, session: { get(sessionID: string) { const match = Binary.search(store.session, sessionID, (s) => s.id) @@ -481,11 +498,12 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ }, async sync(sessionID: string) { if (fullSyncedSessions.has(sessionID)) return + const workspace = project.workspace.current() const [session, messages, todo, diff] = await Promise.all([ - sdk.client.session.get({ sessionID }, { throwOnError: true }), - sdk.client.session.messages({ sessionID, limit: 100 }), - sdk.client.session.todo({ sessionID }), - sdk.client.session.diff({ sessionID }), + 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 }), ]) setStore( produce((draft) => { @@ -504,8 +522,11 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ }, }, workspace: { + list() { + return store.workspaceList + }, get(workspaceID: string) { - return store.workspaceList.find((workspace) => workspace.id === workspaceID) + return store.workspaceList.find((item) => item.id === workspaceID) }, sync: syncWorkspaces, }, diff --git a/packages/opencode/src/cli/cmd/tui/plugin/api.tsx b/packages/opencode/src/cli/cmd/tui/plugin/api.tsx index 3609f6cc1a..e43a9cc37c 100644 --- a/packages/opencode/src/cli/cmd/tui/plugin/api.tsx +++ b/packages/opencode/src/cli/cmd/tui/plugin/api.tsx @@ -1,6 +1,7 @@ import type { ParsedKey } from "@opentui/core" import type { TuiDialogSelectOption, TuiPluginApi, TuiRouteDefinition, TuiSlotProps } from "@opencode-ai/plugin/tui" import type { useCommandDialog } from "@tui/component/dialog-command" +import type { useEvent } from "@tui/context/event" import type { useKeybind } from "@tui/context/keybind" import type { useRoute } from "@tui/context/route" import type { useSDK } from "@tui/context/sdk" @@ -36,6 +37,7 @@ type Input = { route: ReturnType routes: RouteMap bump: () => void + event: ReturnType sdk: ReturnType sync: ReturnType theme: ReturnType @@ -136,7 +138,7 @@ function stateApi(sync: ReturnType): TuiPluginApi["state"] { return sync.data.provider }, get path() { - return sync.data.path + return sync.path }, get vcs() { if (!sync.data.vcs) return @@ -342,7 +344,7 @@ export function createTuiApi(input: Input): TuiPluginApi { get client() { return input.sdk.client }, - event: input.sdk.event, + event: input.event, renderer: input.renderer, slots: { register() { diff --git a/packages/opencode/src/cli/cmd/tui/routes/home.tsx b/packages/opencode/src/cli/cmd/tui/routes/home.tsx index 79b5c4d7ab..1cce7fb396 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/home.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/home.tsx @@ -1,6 +1,7 @@ import { Prompt, type PromptRef } from "@tui/component/prompt" import { createEffect, createSignal } from "solid-js" import { Logo } from "../component/logo" +import { useProject } from "../context/project" import { useSync } from "../context/sync" import { Toast } from "../ui/toast" import { useArgs } from "../context/args" @@ -18,6 +19,7 @@ const placeholder = { export function Home() { const sync = useSync() + const project = useProject() const route = useRouteData("home") const promptRef = usePromptRef() const [ref, setRef] = createSignal() @@ -63,11 +65,16 @@ export function Home() { - + } + workspaceID={project.workspace.current()} + right={} placeholders={placeholder} /> diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx index 32a9d13367..f687e642ff 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx @@ -15,6 +15,8 @@ import { import { Dynamic } from "solid-js/web" import path from "path" import { useRoute, useRouteData } from "@tui/context/route" +import { useEvent } from "@tui/context/event" +import { useProject } from "@tui/context/project" import { useSync } from "@tui/context/sync" import { SplitBorder } from "@tui/component/border" import { Spinner } from "@tui/component/spinner" @@ -116,6 +118,7 @@ export function Session() { const route = useRouteData("session") const { navigate } = useRoute() const sync = useSync() + const project = useProject() const tuiConfig = useTuiConfig() const kv = useKV() const { theme } = useTheme() @@ -172,10 +175,17 @@ export function Session() { const providers = createMemo(() => Model.index(sync.data.provider)) const scrollAcceleration = createMemo(() => getScrollAcceleration(tuiConfig)) + const event = useEvent() + const toast = useToast() + const sdk = useSDK() createEffect(async () => { - await sync.session - .sync(route.sessionID) + await sdk.client.session + .get({ sessionID: route.sessionID }, { throwOnError: true }) + .then((x) => { + project.workspace.set(x.data?.workspaceID) + }) + .then(() => sync.session.sync(route.sessionID)) .then(() => { if (scroll) scroll.scrollBy(100_000) }) @@ -189,13 +199,10 @@ export function Session() { }) }) - const toast = useToast() - const sdk = useSDK() - // Handle initial prompt from fork let seeded = false let lastSwitch: string | undefined = undefined - sdk.event.on("message.part.updated", (evt) => { + event.on("message.part.updated", (evt) => { const part = evt.properties.part if (part.type !== "tool") return if (part.sessionID !== route.sessionID) return @@ -1791,7 +1798,7 @@ function Bash(props: ToolProps) { const workdir = props.input.workdir if (!workdir || workdir === ".") return undefined - const base = sync.data.path.directory + const base = sync.path.directory if (!base) return undefined const absolute = path.resolve(base, workdir) diff --git a/packages/opencode/src/cli/cmd/tui/thread.ts b/packages/opencode/src/cli/cmd/tui/thread.ts index df5c416777..0534b147a5 100644 --- a/packages/opencode/src/cli/cmd/tui/thread.ts +++ b/packages/opencode/src/cli/cmd/tui/thread.ts @@ -10,7 +10,7 @@ import { errorMessage } from "@/util/error" import { withTimeout } from "@/util/timeout" import { withNetworkOptions, resolveNetworkOptions } from "@/cli/network" import { Filesystem } from "@/util/filesystem" -import type { Event } from "@opencode-ai/sdk/v2" +import type { GlobalEvent } from "@opencode-ai/sdk/v2" import type { EventSource } from "./context/sdk" import { win32DisableProcessedInput, win32InstallCtrlCGuard } from "./win32" import { TuiConfig } from "@/config/tui" @@ -43,18 +43,10 @@ function createWorkerFetch(client: RpcClient): typeof fetch { function createEventSource(client: RpcClient): EventSource { return { - subscribe: async (directory, handler) => { - const id = await client.call("subscribe", { directory }) - const unsub = client.on<{ id: string; event: Event }>("event", (e) => { - if (e.id === id) { - handler(e.event) - } + subscribe: async (handler) => { + return client.on("global.event", (e) => { + handler(e) }) - - return () => { - unsub() - client.call("unsubscribe", { id }) - } }, } } diff --git a/packages/opencode/src/cli/cmd/tui/worker.ts b/packages/opencode/src/cli/cmd/tui/worker.ts index 15e02b8634..a71b95ce4c 100644 --- a/packages/opencode/src/cli/cmd/tui/worker.ts +++ b/packages/opencode/src/cli/cmd/tui/worker.ts @@ -6,13 +6,10 @@ import { InstanceBootstrap } from "@/project/bootstrap" import { Rpc } from "@/util/rpc" import { upgrade } from "@/cli/upgrade" import { Config } from "@/config/config" -import { Bus } from "@/bus" import { GlobalBus } from "@/bus/global" -import type { Event } from "@opencode-ai/sdk/v2" +import type { GlobalEvent } from "@opencode-ai/sdk/v2" import { Flag } from "@/flag/flag" -import { setTimeout as sleep } from "node:timers/promises" import { writeHeapSnapshot } from "node:v8" -import { WorkspaceID } from "@/control-plane/schema" import { Heap } from "@/cli/heap" await Log.init({ @@ -45,87 +42,6 @@ GlobalBus.on("event", (event) => { let server: Awaited> | undefined -const eventStreams = new Map() - -function startEventStream(directory: string) { - const id = crypto.randomUUID() - - const abort = new AbortController() - const signal = abort.signal - - eventStreams.set(id, abort) - - async function run() { - while (!signal.aborted) { - const shouldReconnect = await Instance.provide({ - directory, - init: InstanceBootstrap, - fn: () => - new Promise((resolve) => { - Rpc.emit("event", { - type: "server.connected", - properties: {}, - } satisfies Event) - - let settled = false - const settle = (value: boolean) => { - if (settled) return - settled = true - signal.removeEventListener("abort", onAbort) - unsub() - resolve(value) - } - - const unsub = Bus.subscribeAll((event) => { - Rpc.emit("event", { - id, - event: event as Event, - }) - if (event.type === Bus.InstanceDisposed.type) { - settle(true) - } - }) - - const onAbort = () => { - settle(false) - } - - signal.addEventListener("abort", onAbort, { once: true }) - }), - }).catch((error) => { - Log.Default.error("event stream subscribe error", { - error: error instanceof Error ? error.message : error, - }) - return false - }) - - if (!shouldReconnect || signal.aborted) { - break - } - - if (!signal.aborted) { - await sleep(250) - } - } - } - - run().catch((error) => { - Log.Default.error("event stream error", { - error: error instanceof Error ? error.message : error, - }) - }) - - return id -} - -function stopEventStream(id: string) { - const abortController = eventStreams.get(id) - if (!abortController) return - - abortController.abort() - eventStreams.delete(id) -} - export const rpc = { async fetch(input: { url: string; method: string; headers: Record; body?: string }) { const headers = { ...input.headers } @@ -167,19 +83,9 @@ export const rpc = { async reload() { await Config.invalidate(true) }, - async subscribe(input: { directory: string | undefined }) { - return startEventStream(input.directory || process.cwd()) - }, - async unsubscribe(input: { id: string }) { - stopEventStream(input.id) - }, async shutdown() { Log.Default.info("worker shutting down") - for (const id of [...eventStreams.keys()]) { - stopEventStream(id) - } - await Instance.disposeAll() if (server) await server.stop(true) }, diff --git a/packages/opencode/src/control-plane/workspace-context.ts b/packages/opencode/src/control-plane/workspace-context.ts new file mode 100644 index 0000000000..37204d17d6 --- /dev/null +++ b/packages/opencode/src/control-plane/workspace-context.ts @@ -0,0 +1,22 @@ +import { Context } from "../util/context" +import type { WorkspaceID } from "../control-plane/schema" + +export interface WorkspaceContext { + workspaceID: string +} + +const context = Context.create("instance") + +export const WorkspaceContext = { + async provide(input: { workspaceID: WorkspaceID; fn: () => R }): Promise { + return context.provide({ workspaceID: input.workspaceID as string }, () => input.fn()) + }, + + get workspaceID() { + try { + return context.use().workspaceID + } catch (err) { + return undefined + } + }, +} diff --git a/packages/opencode/src/control-plane/workspace.ts b/packages/opencode/src/control-plane/workspace.ts index bb0fd60020..a030d0b6c8 100644 --- a/packages/opencode/src/control-plane/workspace.ts +++ b/packages/opencode/src/control-plane/workspace.ts @@ -134,12 +134,12 @@ export namespace Workspace { continue } - await parseSSE(res.body, stop, (event) => { - GlobalBus.emit("event", { - directory: space.id, - payload: event, - }) - }) + // await parseSSE(res.body, stop, (event) => { + // GlobalBus.emit("event", { + // directory: space.id, + // payload: event, + // }) + // }) // Wait 250ms and retry if SSE connection fails await sleep(250) diff --git a/packages/opencode/src/effect/instance-ref.ts b/packages/opencode/src/effect/instance-ref.ts index d3939b2640..07a510a4f8 100644 --- a/packages/opencode/src/effect/instance-ref.ts +++ b/packages/opencode/src/effect/instance-ref.ts @@ -4,3 +4,7 @@ import type { InstanceContext } from "@/project/instance" export const InstanceRef = ServiceMap.Reference("~opencode/InstanceRef", { defaultValue: () => undefined, }) + +export const WorkspaceRef = ServiceMap.Reference("~opencode/WorkspaceRef", { + defaultValue: () => undefined, +}) diff --git a/packages/opencode/src/effect/instance-state.ts b/packages/opencode/src/effect/instance-state.ts index cc5901fb5e..3da1357ee9 100644 --- a/packages/opencode/src/effect/instance-state.ts +++ b/packages/opencode/src/effect/instance-state.ts @@ -1,8 +1,9 @@ import { Effect, Fiber, ScopedCache, Scope, ServiceMap } from "effect" import { Instance, type InstanceContext } from "@/project/instance" import { Context } from "@/util/context" -import { InstanceRef } from "./instance-ref" +import { InstanceRef, WorkspaceRef } from "./instance-ref" import { registerDisposer } from "./instance-registry" +import { WorkspaceContext } from "@/control-plane/workspace-context" const TypeId = "~opencode/InstanceState" @@ -28,6 +29,10 @@ export namespace InstanceState { return (yield* InstanceRef) ?? Instance.current }) + export const workspaceID = Effect.gen(function* () { + return (yield* WorkspaceRef) ?? WorkspaceContext.workspaceID + }) + export const directory = Effect.map(context, (ctx) => ctx.directory) export const make = ( diff --git a/packages/opencode/src/effect/run-service.ts b/packages/opencode/src/effect/run-service.ts index f609986b58..c16adec627 100644 --- a/packages/opencode/src/effect/run-service.ts +++ b/packages/opencode/src/effect/run-service.ts @@ -2,15 +2,17 @@ import { Effect, Layer, ManagedRuntime } from "effect" import * as ServiceMap from "effect/ServiceMap" import { Instance } from "@/project/instance" import { Context } from "@/util/context" -import { InstanceRef } from "./instance-ref" +import { InstanceRef, WorkspaceRef } from "./instance-ref" import { Observability } from "./oltp" +import { WorkspaceContext } from "@/control-plane/workspace-context" export const memoMap = Layer.makeMemoMapUnsafe() function attach(effect: Effect.Effect): Effect.Effect { try { const ctx = Instance.current - return Effect.provideService(effect, InstanceRef, ctx) + const workspaceID = WorkspaceContext.workspaceID + return effect.pipe(Effect.provideService(InstanceRef, ctx), Effect.provideService(WorkspaceRef, workspaceID)) } catch (err) { if (!(err instanceof Context.NotFound)) throw err } diff --git a/packages/opencode/src/project/instance.ts b/packages/opencode/src/project/instance.ts index a0d6f2414a..33078183b9 100644 --- a/packages/opencode/src/project/instance.ts +++ b/packages/opencode/src/project/instance.ts @@ -5,6 +5,7 @@ import { iife } from "@/util/iife" import { Log } from "@/util/log" import { Context } from "../util/context" import { Project } from "./project" +import { WorkspaceContext } from "@/control-plane/workspace-context" import { State } from "./state" export interface InstanceContext { @@ -20,19 +21,9 @@ const disposal = { all: undefined as Promise | undefined, } -function emit(directory: string) { - GlobalBus.emit("event", { - directory, - payload: { - type: "server.instance.disposed", - properties: { - directory, - }, - }, - }) -} +function emitDisposed(directory: string) {} -function boot(input: { directory: string; init?: () => Promise; project?: Project.Info; worktree?: string }) { +function boot(input: { directory: string; init?: () => Promise; worktree?: string; project?: Project.Info }) { return iife(async () => { const ctx = input.project && input.worktree @@ -93,6 +84,7 @@ export const Instance = { get project() { return context.use().project }, + /** * Check if a path is within the project boundary. * Returns true if path is inside Instance.directory OR Instance.worktree. @@ -131,15 +123,39 @@ export const Instance = { await Promise.all([State.dispose(directory), disposeInstance(directory)]) cache.delete(directory) const next = track(directory, boot({ ...input, directory })) - emit(directory) + + GlobalBus.emit("event", { + directory, + project: input.project?.id, + workspace: WorkspaceContext.workspaceID, + payload: { + type: "server.instance.disposed", + properties: { + directory, + }, + }, + }) + return await next }, async dispose() { const directory = Instance.directory + const project = Instance.project Log.Default.info("disposing instance", { directory }) await Promise.all([State.dispose(directory), disposeInstance(directory)]) cache.delete(directory) - emit(directory) + + GlobalBus.emit("event", { + directory, + project: project.id, + workspace: WorkspaceContext.workspaceID, + payload: { + type: "server.instance.disposed", + properties: { + directory, + }, + }, + }) }, async disposeAll() { if (disposal.all) return disposal.all diff --git a/packages/opencode/src/project/project.ts b/packages/opencode/src/project/project.ts index f587f50b39..f8a9b929ab 100644 --- a/packages/opencode/src/project/project.ts +++ b/packages/opencode/src/project/project.ts @@ -137,6 +137,7 @@ export namespace Project { const emitUpdated = (data: Info) => Effect.sync(() => GlobalBus.emit("event", { + project: data.id, payload: { type: Event.Updated.type, properties: data }, }), ) diff --git a/packages/opencode/src/server/router.ts b/packages/opencode/src/server/router.ts index 55853d974d..029ff7f5fa 100644 --- a/packages/opencode/src/server/router.ts +++ b/packages/opencode/src/server/router.ts @@ -9,6 +9,9 @@ import { Filesystem } from "@/util/filesystem" import { Instance } from "@/project/instance" import { InstanceBootstrap } from "@/project/bootstrap" import { InstanceRoutes } from "./instance" +import { Session } from "@/session" +import { SessionID } from "@/session/schema" +import { WorkspaceContext } from "@/control-plane/workspace-context" type Rule = { method?: string; path: string; exact?: boolean; action: "local" | "forward" } @@ -26,6 +29,16 @@ function local(method: string, path: string) { return false } +async function getSessionWorkspace(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 session?.workspaceID +} + export function WorkspaceRouterMiddleware(upgrade: UpgradeWebSocket): MiddlewareHandler { const routes = lazy(() => InstanceRoutes(upgrade)) @@ -42,13 +55,14 @@ export function WorkspaceRouterMiddleware(upgrade: UpgradeWebSocket): Middleware ) const url = new URL(c.req.url) - const workspaceParam = url.searchParams.get("workspace") || c.req.header("x-opencode-workspace") - // TODO: If session is being routed, force it to lookup the - // project/workspace + const sessionWorkspaceID = await getSessionWorkspace(url) + const workspaceID = sessionWorkspaceID || url.searchParams.get("workspace") - // If no workspace is provided we use the "project" workspace - if (!workspaceParam) { + console.log("routing", url.pathname, workspaceID) + + // If no workspace is provided we use the project + if (!workspaceID) { return Instance.provide({ directory, init: InstanceBootstrap, @@ -58,8 +72,7 @@ export function WorkspaceRouterMiddleware(upgrade: UpgradeWebSocket): Middleware }) } - const workspaceID = WorkspaceID.make(workspaceParam) - const workspace = await Workspace.get(workspaceID) + const workspace = await Workspace.get(WorkspaceID.make(workspaceID)) if (!workspace) { return new Response(`Workspace not found: ${workspaceID}`, { status: 500, @@ -73,12 +86,16 @@ export function WorkspaceRouterMiddleware(upgrade: UpgradeWebSocket): Middleware const target = await adaptor.target(workspace) if (target.type === "local") { - return Instance.provide({ - directory: target.directory, - init: InstanceBootstrap, - async fn() { - return routes().fetch(c.req.raw, c.env) - }, + return WorkspaceContext.provide({ + workspaceID: WorkspaceID.make(workspaceID), + fn: () => + Instance.provide({ + directory: target.directory, + init: InstanceBootstrap, + async fn() { + return routes().fetch(c.req.raw, c.env) + }, + }), }) } diff --git a/packages/opencode/src/server/routes/global.ts b/packages/opencode/src/server/routes/global.ts index 16b9e559f2..ec7afcf23d 100644 --- a/packages/opencode/src/server/routes/global.ts +++ b/packages/opencode/src/server/routes/global.ts @@ -105,6 +105,8 @@ export const GlobalRoutes = lazy(() => z .object({ directory: z.string(), + project: z.string().optional(), + workspace: z.string().optional(), payload: BusEvent.payloads(), }) .meta({ diff --git a/packages/opencode/src/worktree/index.ts b/packages/opencode/src/worktree/index.ts index b34364ccd8..4592aa2549 100644 --- a/packages/opencode/src/worktree/index.ts +++ b/packages/opencode/src/worktree/index.ts @@ -245,6 +245,7 @@ export namespace Worktree { const boot = Effect.fnUntraced(function* (info: Info, startCommand?: string) { const ctx = yield* InstanceState.context + const workspaceID = yield* InstanceState.workspaceID const projectID = ctx.project.id const extra = startCommand?.trim() @@ -254,6 +255,8 @@ export namespace Worktree { log.error("worktree checkout failed", { directory: info.directory, message }) GlobalBus.emit("event", { directory: info.directory, + project: ctx.project.id, + workspace: workspaceID, payload: { type: Event.Failed.type, properties: { message } }, }) return @@ -271,6 +274,8 @@ export namespace Worktree { log.error("worktree bootstrap failed", { directory: info.directory, message }) GlobalBus.emit("event", { directory: info.directory, + project: ctx.project.id, + workspace: workspaceID, payload: { type: Event.Failed.type, properties: { message } }, }) return false @@ -280,6 +285,8 @@ export namespace Worktree { GlobalBus.emit("event", { directory: info.directory, + project: ctx.project.id, + workspace: workspaceID, payload: { type: Event.Ready.type, properties: { name: info.name, branch: info.branch }, diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index 38f4db09a3..f52da3a979 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -1011,6 +1011,8 @@ export type Event = export type GlobalEvent = { directory: string + project?: string + workspace?: string payload: Event } diff --git a/packages/sdk/openapi.json b/packages/sdk/openapi.json index 366bc1fc79..d5d8f6df2a 100644 --- a/packages/sdk/openapi.json +++ b/packages/sdk/openapi.json @@ -9926,6 +9926,12 @@ "directory": { "type": "string" }, + "project": { + "type": "string" + }, + "workspace": { + "type": "string" + }, "payload": { "$ref": "#/components/schemas/Event" } From 5a1be097274530a79d99894cf9036d5a00a4a2ee Mon Sep 17 00:00:00 2001 From: James Long Date: Thu, 9 Apr 2026 13:16:49 -0400 Subject: [PATCH 2/7] Clear sessions --- packages/opencode/src/cli/cmd/tui/context/sync.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/opencode/src/cli/cmd/tui/context/sync.tsx b/packages/opencode/src/cli/cmd/tui/context/sync.tsx index fc88bd8a99..bbdc743285 100644 --- a/packages/opencode/src/cli/cmd/tui/context/sync.tsx +++ b/packages/opencode/src/cli/cmd/tui/context/sync.tsx @@ -463,6 +463,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ on( () => project.workspace.current(), () => { + fullSyncedSessions.clear() void bootstrap() }, ), From 5d68c61d3158913195ea7353f9e83e172ab9ebd3 Mon Sep 17 00:00:00 2001 From: James Long Date: Thu, 9 Apr 2026 13:26:41 -0400 Subject: [PATCH 3/7] Fix types --- packages/opencode/src/cli/cmd/tui/routes/session/index.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx index f687e642ff..c6bc231fca 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx @@ -15,9 +15,9 @@ import { import { Dynamic } from "solid-js/web" import path from "path" import { useRoute, useRouteData } from "@tui/context/route" -import { useEvent } from "@tui/context/event" import { useProject } from "@tui/context/project" import { useSync } from "@tui/context/sync" +import { useEvent } from "@tui/context/event" import { SplitBorder } from "@tui/component/border" import { Spinner } from "@tui/component/spinner" import { selectedForeground, useTheme } from "@tui/context/theme" @@ -118,6 +118,7 @@ export function Session() { const route = useRouteData("session") const { navigate } = useRoute() const sync = useSync() + const event = useEvent() const project = useProject() const tuiConfig = useTuiConfig() const kv = useKV() @@ -175,7 +176,6 @@ export function Session() { const providers = createMemo(() => Model.index(sync.data.provider)) const scrollAcceleration = createMemo(() => getScrollAcceleration(tuiConfig)) - const event = useEvent() const toast = useToast() const sdk = useSDK() @@ -231,7 +231,7 @@ export function Session() { const dialog = useDialog() const renderer = useRenderer() - sdk.event.on("session.status", (evt) => { + event.on("session.status", (evt) => { if (evt.properties.sessionID !== route.sessionID) return if (evt.properties.status.type !== "retry") return if (evt.properties.status.message !== SessionRetry.GO_UPSELL_MESSAGE) return From 2b0fdcc4497403eb649afc92380ec9b748846c9b Mon Sep 17 00:00:00 2001 From: James Long Date: Thu, 9 Apr 2026 14:22:39 -0400 Subject: [PATCH 4/7] Add some tests --- .../test/cli/tui/sync-provider.test.tsx | 293 ++++++++++++++++++ .../opencode/test/cli/tui/use-event.test.tsx | 175 +++++++++++ 2 files changed, 468 insertions(+) create mode 100644 packages/opencode/test/cli/tui/sync-provider.test.tsx create mode 100644 packages/opencode/test/cli/tui/use-event.test.tsx diff --git a/packages/opencode/test/cli/tui/sync-provider.test.tsx b/packages/opencode/test/cli/tui/sync-provider.test.tsx new file mode 100644 index 0000000000..ec686b3688 --- /dev/null +++ b/packages/opencode/test/cli/tui/sync-provider.test.tsx @@ -0,0 +1,293 @@ +/** @jsxImportSource @opentui/solid */ +import { afterEach, describe, expect, test } from "bun:test" +import { testRender } from "@opentui/solid" +import { onMount } from "solid-js" +import { ArgsProvider } from "../../../src/cli/cmd/tui/context/args" +import { ExitProvider } from "../../../src/cli/cmd/tui/context/exit" +import { ProjectProvider, useProject } from "../../../src/cli/cmd/tui/context/project" +import { SDKProvider } from "../../../src/cli/cmd/tui/context/sdk" +import { SyncProvider, useSync } from "../../../src/cli/cmd/tui/context/sync" + +const sighup = new Set(process.listeners("SIGHUP")) + +afterEach(() => { + for (const fn of process.listeners("SIGHUP")) { + if (!sighup.has(fn)) process.off("SIGHUP", fn) + } +}) + +function json(data: unknown) { + return new Response(JSON.stringify(data), { + headers: { + "content-type": "application/json", + }, + }) +} + +async function wait(fn: () => boolean, timeout = 2000) { + const start = Date.now() + while (!fn()) { + if (Date.now() - start > timeout) throw new Error("timed out waiting for condition") + await Bun.sleep(10) + } +} + +function data(workspace?: string | null) { + const tag = workspace ?? "root" + return { + session: { + id: "ses_1", + title: `session-${tag}`, + workspaceID: workspace ?? undefined, + time: { + updated: 1, + }, + }, + message: { + info: { + id: "msg_1", + sessionID: "ses_1", + role: "assistant", + time: { + created: 1, + completed: 1, + }, + }, + parts: [ + { + id: "part_1", + messageID: "msg_1", + sessionID: "ses_1", + type: "text", + text: `part-${tag}`, + }, + ], + }, + todo: [ + { + id: `todo-${tag}`, + content: `todo-${tag}`, + status: "pending", + priority: "medium", + }, + ], + diff: [ + { + file: `${tag}.ts`, + patch: "", + additions: 0, + deletions: 0, + }, + ], + } +} + +type Hit = { + path: string + workspace?: string +} + +function createFetch(log: Hit[]) { + return Object.assign( + async (input: RequestInfo | URL, init?: RequestInit) => { + const req = new Request(input, init) + const url = new URL(req.url) + const workspace = url.searchParams.get("workspace") ?? req.headers.get("x-opencode-workspace") ?? undefined + log.push({ + path: url.pathname, + workspace, + }) + + if (url.pathname === "/config/providers") { + return json({ providers: [], default: {} }) + } + if (url.pathname === "/provider") { + return json({ all: [], default: {}, connected: [] }) + } + if (url.pathname === "/experimental/console") { + return json({}) + } + if (url.pathname === "/agent") { + return json([]) + } + if (url.pathname === "/config") { + return json({}) + } + if (url.pathname === "/project/current") { + return json({ id: `proj-${workspace ?? "root"}` }) + } + if (url.pathname === "/path") { + return json({ + state: `/tmp/${workspace ?? "root"}/state`, + config: `/tmp/${workspace ?? "root"}/config`, + worktree: "/tmp/worktree", + directory: `/tmp/${workspace ?? "root"}`, + }) + } + if (url.pathname === "/session") { + return json([]) + } + if (url.pathname === "/command") { + return json([]) + } + if (url.pathname === "/lsp") { + return json([]) + } + if (url.pathname === "/mcp") { + return json({}) + } + if (url.pathname === "/experimental/resource") { + return json({}) + } + if (url.pathname === "/formatter") { + return json([]) + } + if (url.pathname === "/session/status") { + return json({}) + } + if (url.pathname === "/provider/auth") { + return json({}) + } + if (url.pathname === "/vcs") { + return json({ branch: "main" }) + } + if (url.pathname === "/experimental/workspace") { + return json([{ id: "ws_a" }, { id: "ws_b" }]) + } + if (url.pathname === "/session/ses_1") { + return json(data(workspace).session) + } + if (url.pathname === "/session/ses_1/message") { + return json([data(workspace).message]) + } + if (url.pathname === "/session/ses_1/todo") { + return json(data(workspace).todo) + } + if (url.pathname === "/session/ses_1/diff") { + return json(data(workspace).diff) + } + + throw new Error(`unexpected request: ${req.method} ${url.pathname}`) + }, + { preconnect: fetch.preconnect.bind(fetch) }, + ) satisfies typeof fetch +} + +async function mount(log: Hit[]) { + let project!: ReturnType + let sync!: ReturnType + let done!: () => void + const ready = new Promise((resolve) => { + done = resolve + }) + + const app = await testRender(() => ( + () => {} }} + > + + + + + { + project = ctx.project + sync = ctx.sync + done() + }} + /> + + + + + + )) + + await ready + return { app, project, sync } +} + +async function waitBoot(log: Hit[], workspace?: string) { + await wait(() => log.some((item) => item.path === "/experimental/workspace")) + if (!workspace) return + await wait(() => log.some((item) => item.path === "/project/current" && item.workspace === workspace)) +} + +function Probe(props: { + onReady: (ctx: { project: ReturnType; sync: ReturnType }) => void +}) { + const project = useProject() + const sync = useSync() + + onMount(() => { + props.onReady({ project, sync }) + }) + + return +} + +describe("SyncProvider", () => { + test("re-runs bootstrap requests when the active workspace changes", async () => { + const log: Hit[] = [] + const { app, project } = await mount(log) + + try { + await waitBoot(log) + log.length = 0 + + project.workspace.set("ws_a") + + await waitBoot(log, "ws_a") + + 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() + } + }) + + test("clears full-sync cache when the active workspace changes", async () => { + const log: Hit[] = [] + const { app, project, sync } = await mount(log) + + try { + await waitBoot(log) + + log.length = 0 + project.workspace.set("ws_a") + await waitBoot(log, "ws_a") + expect(project.workspace.current()).toBe("ws_a") + + log.length = 0 + await sync.session.sync("ses_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") + } finally { + app.renderer.destroy() + } + }) +}) diff --git a/packages/opencode/test/cli/tui/use-event.test.tsx b/packages/opencode/test/cli/tui/use-event.test.tsx new file mode 100644 index 0000000000..5b0fcad3c9 --- /dev/null +++ b/packages/opencode/test/cli/tui/use-event.test.tsx @@ -0,0 +1,175 @@ +/** @jsxImportSource @opentui/solid */ +import { describe, expect, test } from "bun:test" +import { testRender } from "@opentui/solid" +import type { Event, GlobalEvent } from "@opencode-ai/sdk/v2" +import { onMount } from "solid-js" +import { ProjectProvider, useProject } from "../../../src/cli/cmd/tui/context/project" +import { SDKProvider } from "../../../src/cli/cmd/tui/context/sdk" +import { useEvent } from "../../../src/cli/cmd/tui/context/event" + +async function wait(fn: () => boolean, timeout = 2000) { + const start = Date.now() + while (!fn()) { + if (Date.now() - start > timeout) throw new Error("timed out waiting for condition") + await Bun.sleep(10) + } +} + +function event(payload: Event, input: { directory: string; workspace?: string }): GlobalEvent { + return { + directory: input.directory, + workspace: input.workspace, + payload, + } +} + +function vcs(branch: string): Event { + return { + type: "vcs.branch.updated", + properties: { + branch, + }, + } +} + +function update(version: string): Event { + return { + type: "installation.update-available", + properties: { + version, + }, + } +} + +function createSource() { + let fn: ((event: GlobalEvent) => void) | undefined + + return { + source: { + subscribe: async (handler: (event: GlobalEvent) => void) => { + fn = handler + return () => { + if (fn === handler) fn = undefined + } + }, + }, + emit(evt: GlobalEvent) { + if (!fn) throw new Error("event source not ready") + fn(evt) + }, + } +} + +async function mount() { + const source = createSource() + const seen: Event[] = [] + let project!: ReturnType + let done!: () => void + const ready = new Promise((resolve) => { + done = resolve + }) + + const app = await testRender(() => ( + + + { + project = ctx.project + done() + }} + seen={seen} + /> + + + )) + + await ready + return { app, emit: source.emit, project, seen } +} + +function Probe(props: { seen: Event[]; onReady: (ctx: { project: ReturnType }) => void }) { + const project = useProject() + const event = useEvent() + + onMount(() => { + event.subscribe((evt) => { + props.seen.push(evt) + }) + props.onReady({ project }) + }) + + return +} + +describe("useEvent", () => { + test("delivers matching directory events without an active workspace", async () => { + const { app, emit, seen } = await mount() + + try { + emit(event(vcs("main"), { directory: "/tmp/root" })) + + await wait(() => seen.length === 1) + + expect(seen).toEqual([vcs("main")]) + } finally { + app.renderer.destroy() + } + }) + + test("ignores non-matching directory events without an active workspace", async () => { + const { app, emit, seen } = await mount() + + try { + emit(event(vcs("other"), { directory: "/tmp/other" })) + await Bun.sleep(30) + + expect(seen).toHaveLength(0) + } finally { + app.renderer.destroy() + } + }) + + test("delivers matching workspace events when a workspace is active", async () => { + const { app, emit, project, seen } = await mount() + + try { + project.workspace.set("ws_a") + emit(event(vcs("ws"), { directory: "/tmp/other", workspace: "ws_a" })) + + await wait(() => seen.length === 1) + + expect(seen).toEqual([vcs("ws")]) + } finally { + app.renderer.destroy() + } + }) + + test("ignores non-matching workspace events when a workspace is active", async () => { + const { app, emit, project, seen } = await mount() + + try { + project.workspace.set("ws_a") + emit(event(vcs("ws"), { directory: "/tmp/root", workspace: "ws_b" })) + await Bun.sleep(30) + + expect(seen).toHaveLength(0) + } finally { + app.renderer.destroy() + } + }) + + test("delivers truly global events even when a workspace is active", async () => { + const { app, emit, project, seen } = await mount() + + try { + project.workspace.set("ws_a") + emit(event(update("1.2.3"), { directory: "global" })) + + await wait(() => seen.length === 1) + + expect(seen).toEqual([update("1.2.3")]) + } finally { + app.renderer.destroy() + } + }) +}) From e9e8aa5c9430afe237ee4beed87e50a2fe418b14 Mon Sep 17 00:00:00 2001 From: James Long Date: Thu, 9 Apr 2026 14:25:25 -0400 Subject: [PATCH 5/7] Fix global project event --- packages/opencode/src/project/project.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/opencode/src/project/project.ts b/packages/opencode/src/project/project.ts index f8a9b929ab..8db8df5d55 100644 --- a/packages/opencode/src/project/project.ts +++ b/packages/opencode/src/project/project.ts @@ -137,6 +137,7 @@ export namespace Project { const emitUpdated = (data: Info) => Effect.sync(() => GlobalBus.emit("event", { + directory: "global", project: data.id, payload: { type: Event.Updated.type, properties: data }, }), From 202b14353dd0e7c70e386b44529a173e174402bd Mon Sep 17 00:00:00 2001 From: James Long Date: Thu, 9 Apr 2026 15:27:31 -0400 Subject: [PATCH 6/7] wip --- .../workspace-refactor-pr-description.md | 44 +++ packages/opencode/src/cli/cmd/tui/app.tsx | 17 - .../cmd/tui/component/dialog-session-list.tsx | 162 ++++++--- .../tui/component/dialog-workspace-create.tsx | 119 +++++++ .../tui/component/dialog-workspace-list.tsx | 319 ------------------ .../workspace/dialog-session-list.tsx | 151 --------- .../src/cli/cmd/tui/ui/dialog-select.tsx | 7 + 7 files changed, 293 insertions(+), 526 deletions(-) create mode 100644 packages/opencode/specs/workspace-refactor-pr-description.md create mode 100644 packages/opencode/src/cli/cmd/tui/component/dialog-workspace-create.tsx delete mode 100644 packages/opencode/src/cli/cmd/tui/component/dialog-workspace-list.tsx delete mode 100644 packages/opencode/src/cli/cmd/tui/component/workspace/dialog-session-list.tsx diff --git a/packages/opencode/specs/workspace-refactor-pr-description.md b/packages/opencode/specs/workspace-refactor-pr-description.md new file mode 100644 index 0000000000..c72a0f5ec3 --- /dev/null +++ b/packages/opencode/specs/workspace-refactor-pr-description.md @@ -0,0 +1,44 @@ +## Summary + +This refactor moves the TUI toward a single source of truth for project and workspace state, and aligns event handling around a global event stream instead of per-directory subscriptions. + +The main goal is to make workspace switching and session loading behave consistently across the TUI, while simplifying the data flow between the frontend, worker transport, and backend. + +## Why + +The previous shape was splitting related state across multiple places: + +- some workspace/path state lived in sync state +- some UI behavior depended on route state +- event consumers were effectively relying on instance-scoped subscriptions + +That made workspace-aware behavior harder to reason about and more fragile when switching contexts. + +This change centralizes the active project/workspace/path state, makes sync react to that state instead of owning it, and updates the event pipeline so the backend emits richer global events and the TUI filters them based on the current context. The intent is to make the system easier to evolve as workspace support expands. + +## What Changed + +- centralized active project/workspace/path state in the TUI +- made sync derive from that state and re-bootstrap when the active workspace changes +- switched TUI event consumption to a global event stream filtered client-side +- propagated workspace/project metadata through the backend event path and runtime context +- updated the SDK/OpenAPI contract to reflect the richer global event shape +- added targeted TUI tests around workspace-driven sync behavior and event filtering + +## Risk + +This touches several cross-cutting paths, so the main risks are around behavior rather than typing: + +- workspace changes may still expose subtle ordering/race issues if older async bootstrap work finishes after newer state is selected +- event filtering is now more centralized, which is good, but also means mistakes there can hide or misroute UI updates +- session state now depends more heavily on the active workspace context being correct at the right time +- backend/frontend assumptions about global event metadata need to stay aligned, or certain updates may quietly stop appearing in the TUI + +Overall, the biggest risk is regressions during workspace transitions rather than steady-state usage. + +## Validation + +- added focused tests for reactive sync behavior on workspace changes +- added focused tests for `useEvent()` filtering behavior +- ran `bun typecheck` +- ran targeted TUI tests for the new coverage diff --git a/packages/opencode/src/cli/cmd/tui/app.tsx b/packages/opencode/src/cli/cmd/tui/app.tsx index 2f8d1f7bbb..23bfdd7173 100644 --- a/packages/opencode/src/cli/cmd/tui/app.tsx +++ b/packages/opencode/src/cli/cmd/tui/app.tsx @@ -36,7 +36,6 @@ import { DialogHelp } from "./ui/dialog-help" import { CommandProvider, useCommandDialog } from "@tui/component/dialog-command" import { DialogAgent } from "@tui/component/dialog-agent" import { DialogSessionList } from "@tui/component/dialog-session-list" -import { DialogWorkspaceList } from "@tui/component/dialog-workspace-list" import { DialogConsoleOrg } from "@tui/component/dialog-console-org" import { KeybindProvider, useKeybind } from "@tui/context/keybind" import { ThemeProvider, useTheme } from "@tui/context/theme" @@ -465,22 +464,6 @@ function App(props: { onSnapshot?: () => Promise }) { dialog.replace(() => ) }, }, - ...(Flag.OPENCODE_EXPERIMENTAL_WORKSPACES - ? [ - { - title: "Manage workspaces", - value: "workspace.list", - category: "Workspace", - suggested: true, - slash: { - name: "workspaces", - }, - onSelect: () => { - dialog.replace(() => ) - }, - }, - ] - : []), { title: "New session", suggested: route.data.type === "session", diff --git a/packages/opencode/src/cli/cmd/tui/component/dialog-session-list.tsx b/packages/opencode/src/cli/cmd/tui/component/dialog-session-list.tsx index 775969bfcb..73d37f9e3d 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 @@ -1,17 +1,50 @@ -import { useDialog } from "@tui/ui/dialog" -import { DialogSelect } from "@tui/ui/dialog-select" +import { RGBA } from "@opentui/core" import { useRoute } from "@tui/context/route" import { useSync } from "@tui/context/sync" -import { createMemo, createSignal, createResource, onMount, Show } from "solid-js" +import { useDialog } from "@tui/ui/dialog" +import { DialogSelect } from "@tui/ui/dialog-select" +import { createMemo, createResource, createSignal, onMount } 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 { useTheme } from "../context/theme" import { createDebouncedSignal } from "../util/signal" +import { useToast } from "../ui/toast" +import { DialogSessionRename } from "./dialog-session-rename" +import { DialogWorkspaceCreate, openWorkspaceSession } from "./dialog-workspace-create" import { Spinner } from "./spinner" +const color = [ + RGBA.fromHex("#ff7a90"), + RGBA.fromHex("#f8c555"), + RGBA.fromHex("#70d6a3"), + RGBA.fromHex("#57c7ff"), + RGBA.fromHex("#bb9af7"), + RGBA.fromHex("#ff9e64"), +] + +const shape = ["■", "◆", "▲", "▶", "▼", "◀", "●", "◉", "◈", "◊"] +const action = "__workspace_new__" + +function hash(text: string) { + let sum = 0 + for (const char of text) { + sum = (sum * 31 + char.charCodeAt(0)) >>> 0 + } + return sum +} + +function mark(id?: string) { + if (!id) { + return + } + const sum = hash(id) + return { + fg: color[sum % color.length]!, + text: shape[sum % shape.length]!, + } +} + export function DialogSessionList() { const dialog = useDialog() const route = useRoute() @@ -19,44 +52,60 @@ export function DialogSessionList() { const keybind = useKeybind() const { theme } = useTheme() const sdk = useSDK() - const kv = useKV() - + const toast = useToast() const [toDelete, setToDelete] = createSignal() const [search, setSearch] = createDebouncedSignal("", 150) - const [searchResults] = createResource(search, async (query) => { - if (!query) return undefined - const result = await sdk.client.session.list({ search: query, limit: 30 }) + const load = async (search?: string) => { + const result = await sdk.client.session.list({ + roots: true, + ...(search ? { search, limit: 30 } : {}), + }) return result.data ?? [] + } + + const [listed, listedActions] = createResource(async () => load()) + const [found] = createResource(search, async (query) => { + if (!query) return undefined + return load(query) }) - const currentSessionID = createMemo(() => (route.data.type === "session" ? route.data.sessionID : undefined)) - - const sessions = createMemo(() => searchResults() ?? sync.data.session) + const current = createMemo(() => (route.data.type === "session" ? route.data.sessionID : undefined)) + const sessions = createMemo(() => found() ?? listed() ?? sync.data.session) 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 date = new Date(x.time.updated) - let category = date.toDateString() - if (category === today) { - category = "Today" - } - const isDeleting = toDelete() === x.id - const status = sync.data.session_status?.[x.id] - const isWorking = status?.type === "busy" - return { - title: isDeleting ? `Press ${keybind.print("session_delete")} again to confirm` : x.title, - bg: isDeleting ? theme.error : undefined, - value: x.id, - category, - footer: Locale.time(x.time.updated), - gutter: isWorking ? : undefined, - } - }) + return [ + { + title: "+ New workspace session", + value: action, + category: "Actions", + description: "Create a new workspace, then open a session there", + }, + ...sessions() + .filter((item) => item.parentID === undefined) + .toSorted((a, b) => b.time.updated - a.time.updated) + .map((item) => { + const badge = mark(item.workspaceID) + const date = new Date(item.time.updated) + let category = date.toDateString() + if (category === today) { + category = "Today" + } + const deleting = toDelete() === item.id + const status = sync.data.session_status?.[item.id] + const working = status?.type === "busy" + return { + title: deleting ? `Press ${keybind.print("session_delete")} again to confirm` : item.title, + bg: deleting ? theme.error : undefined, + value: item.id, + category, + footer: Locale.time(item.time.updated), + gutter: working ? : undefined, + margin: badge ? {badge.text} : undefined, + } + }), + ] }) onMount(() => { @@ -68,12 +117,29 @@ export function DialogSessionList() { title="Sessions" options={options()} skipFilter={true} - current={currentSessionID()} + current={current()} onFilter={setSearch} onMove={() => { setToDelete(undefined) }} onSelect={(option) => { + if (option.value === action) { + dialog.replace(() => ( + + openWorkspaceSession({ + dialog, + route, + sdk, + sync, + toast, + workspaceID, + }) + } + /> + )) + return + } route.navigate({ type: "session", sessionID: option.value, @@ -85,10 +151,27 @@ export function DialogSessionList() { keybind: keybind.all.session_delete?.[0], title: "delete", onTrigger: async (option) => { + if (option.value === action) return if (toDelete() === option.value) { - sdk.client.session.delete({ - sessionID: option.value, - }) + const deleted = await sdk.client.session + .delete({ + sessionID: option.value, + }) + .then(() => true) + .catch(() => false) + if (!deleted) { + toast.show({ + message: "Failed to delete session", + variant: "error", + }) + setToDelete(undefined) + return + } + listedActions.mutate((items) => items?.filter((item) => item.id !== option.value)) + sync.set( + "session", + sync.data.session.filter((item) => item.id !== option.value), + ) setToDelete(undefined) return } @@ -99,6 +182,7 @@ export function DialogSessionList() { keybind: keybind.all.session_rename?.[0], title: "rename", onTrigger: async (option) => { + if (option.value === action) return dialog.replace(() => ) }, }, 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 0000000000..db3b51009e --- /dev/null +++ b/packages/opencode/src/cli/cmd/tui/component/dialog-workspace-create.tsx @@ -0,0 +1,119 @@ +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 { createMemo, createSignal, onMount } from "solid-js" +import { setTimeout as sleep } from "node:timers/promises" +import { useSDK } from "../context/sdk" +import { useToast } from "../ui/toast" + +function scoped(sdk: ReturnType, sync: ReturnType, workspaceID: string) { + return createOpencodeClient({ + baseUrl: sdk.url, + fetch: sdk.fetch, + directory: sync.path.directory || sdk.directory, + experimental_workspaceID: workspaceID, + }) +} + +export async function openWorkspaceSession(input: { + dialog: ReturnType + route: ReturnType + sdk: ReturnType + sync: ReturnType + toast: ReturnType + workspaceID: string +}) { + const client = scoped(input.sdk, input.sync, input.workspaceID) + while (true) { + const result = await client.session.create({ workspaceID: input.workspaceID }).catch(() => undefined) + if (!result) { + input.toast.show({ + message: "Failed to create workspace session", + variant: "error", + }) + return + } + if (result.response.status >= 500 && result.response.status < 600) { + await sleep(1000) + continue + } + if (!result.data) { + input.toast.show({ + message: "Failed to create workspace session", + variant: "error", + }) + return + } + input.route.navigate({ + type: "session", + sessionID: result.data.id, + }) + input.dialog.clear() + return + } +} + +export function DialogWorkspaceCreate(props: { onSelect: (workspaceID: string) => Promise | void }) { + const dialog = useDialog() + const sync = useSync() + const sdk = useSDK() + const toast = useToast() + const [creating, setCreating] = createSignal() + + onMount(() => { + dialog.setSize("medium") + }) + + const options = createMemo(() => { + const type = creating() + if (type) { + return [ + { + title: `Creating ${type} workspace...`, + value: "creating" as const, + description: "This can take a while for remote environments", + }, + ] + } + return [ + { + title: "Worktree", + value: "worktree" as const, + description: "Create a local git worktree", + }, + ] + }) + + const create = async (type: string) => { + if (creating()) return + setCreating(type) + + const result = await sdk.client.experimental.workspace.create({ type, branch: null }).catch(() => undefined) + const workspace = result?.data + if (!workspace) { + setCreating(undefined) + toast.show({ + message: "Failed to create workspace", + variant: "error", + }) + return + } + await sync.workspace.sync() + await props.onSelect(workspace.id) + setCreating(undefined) + } + + return ( + { + if (option.value === "creating") return + void create(option.value) + }} + /> + ) +} diff --git a/packages/opencode/src/cli/cmd/tui/component/dialog-workspace-list.tsx b/packages/opencode/src/cli/cmd/tui/component/dialog-workspace-list.tsx deleted file mode 100644 index 037cebb729..0000000000 --- a/packages/opencode/src/cli/cmd/tui/component/dialog-workspace-list.tsx +++ /dev/null @@ -1,319 +0,0 @@ -import { useDialog } from "@tui/ui/dialog" -import { DialogSelect } from "@tui/ui/dialog-select" -import { useProject } from "@tui/context/project" -import { useRoute } from "@tui/context/route" -import { useSync } from "@tui/context/sync" -import { createEffect, createMemo, createSignal, onMount } from "solid-js" -import { createOpencodeClient, type Session } from "@opencode-ai/sdk/v2" -import { useSDK } from "../context/sdk" -import { useToast } from "../ui/toast" -import { useKeybind } from "../context/keybind" -import { DialogSessionList } from "./workspace/dialog-session-list" -import { setTimeout as sleep } from "node:timers/promises" - -function scoped(sdk: ReturnType, sync: ReturnType, workspaceID?: string) { - return createOpencodeClient({ - baseUrl: sdk.url, - fetch: sdk.fetch, - directory: sync.path.directory || sdk.directory, - experimental_workspaceID: workspaceID, - }) -} - -async function openWorkspace(input: { - dialog: ReturnType - route: ReturnType - sdk: ReturnType - sync: ReturnType - toast: ReturnType - workspaceID: string - forceCreate?: boolean -}) { - const cacheSession = (session: Session) => { - input.sync.set( - "session", - [...input.sync.data.session.filter((item) => item.id !== session.id), session].toSorted((a, b) => - a.id.localeCompare(b.id), - ), - ) - } - - const client = scoped(input.sdk, input.sync, input.workspaceID) - const listed = input.forceCreate ? undefined : await client.session.list({ roots: true, limit: 1 }) - const session = listed?.data?.[0] - if (session?.id) { - cacheSession(session) - input.route.navigate({ - type: "session", - sessionID: session.id, - }) - input.dialog.clear() - return - } - let created: Session | undefined - while (!created) { - const result = await client.session.create({ workspaceID: input.workspaceID }).catch(() => undefined) - if (!result) { - input.toast.show({ - message: "Failed to open workspace", - variant: "error", - }) - return - } - if (result.response.status >= 500 && result.response.status < 600) { - await sleep(1000) - continue - } - if (!result.data) { - input.toast.show({ - message: "Failed to open workspace", - variant: "error", - }) - return - } - created = result.data - } - cacheSession(created) - input.route.navigate({ - type: "session", - sessionID: created.id, - }) - input.dialog.clear() -} - -function DialogWorkspaceCreate(props: { onSelect: (workspaceID: string) => Promise }) { - const dialog = useDialog() - const sync = useSync() - const sdk = useSDK() - const toast = useToast() - const [creating, setCreating] = createSignal() - - onMount(() => { - dialog.setSize("medium") - }) - - const options = createMemo(() => { - const type = creating() - if (type) { - return [ - { - title: `Creating ${type} workspace...`, - value: "creating" as const, - description: "This can take a while for remote environments", - }, - ] - } - return [ - { - title: "Worktree", - value: "worktree" as const, - description: "Create a local git worktree", - }, - ] - }) - - const createWorkspace = async (type: string) => { - if (creating()) return - setCreating(type) - - const result = await sdk.client.experimental.workspace.create({ type, branch: null }).catch((err) => { - console.log(err) - return undefined - }) - console.log(JSON.stringify(result, null, 2)) - const workspace = result?.data - if (!workspace) { - setCreating(undefined) - toast.show({ - message: "Failed to create workspace", - variant: "error", - }) - return - } - await sync.workspace.sync() - await props.onSelect(workspace.id) - setCreating(undefined) - } - - return ( - { - if (option.value === "creating") return - void createWorkspace(option.value) - }} - /> - ) -} - -export function DialogWorkspaceList() { - const dialog = useDialog() - const project = useProject() - const route = useRoute() - const sync = useSync() - const sdk = useSDK() - const toast = useToast() - const keybind = useKeybind() - const [toDelete, setToDelete] = createSignal() - const [counts, setCounts] = createSignal>({}) - - const open = (workspaceID: string, forceCreate?: boolean) => - openWorkspace({ - dialog, - route, - sdk, - sync, - toast, - workspaceID, - forceCreate, - }) - - async function selectWorkspace(workspaceID: string | null) { - if (workspaceID == null) { - project.workspace.set(undefined) - if (localCount() > 0) { - dialog.replace(() => ) - return - } - route.navigate({ - type: "home", - }) - dialog.clear() - return - } - const count = counts()[workspaceID] - if (count && count > 0) { - dialog.replace(() => ) - return - } - - if (count === 0) { - await open(workspaceID) - return - } - const client = scoped(sdk, sync, workspaceID) - const listed = await client.session.list({ roots: true, limit: 1 }).catch(() => undefined) - if (listed?.data?.length) { - dialog.replace(() => ) - return - } - await open(workspaceID) - } - - const currentWorkspaceID = createMemo(() => project.workspace.current()) - - const localCount = createMemo( - () => sync.data.session.filter((session) => !session.workspaceID && !session.parentID).length, - ) - - let run = 0 - createEffect(() => { - const workspaces = sync.data.workspaceList - const next = ++run - if (!workspaces.length) { - setCounts({}) - return - } - setCounts(Object.fromEntries(workspaces.map((workspace) => [workspace.id, undefined]))) - void Promise.all( - workspaces.map(async (workspace) => { - const client = scoped(sdk, sync, workspace.id) - const result = await client.session.list({ roots: true }).catch(() => undefined) - return [workspace.id, result ? (result.data?.length ?? 0) : null] as const - }), - ).then((entries) => { - if (run !== next) return - setCounts(Object.fromEntries(entries)) - }) - }) - - const options = createMemo(() => [ - { - title: "Local", - value: null, - category: "Workspace", - description: "Use the local machine", - footer: `${localCount()} session${localCount() === 1 ? "" : "s"}`, - }, - ...sync.data.workspaceList.map((workspace) => { - const count = counts()[workspace.id] - return { - title: - toDelete() === workspace.id - ? `Delete ${workspace.id}? Press ${keybind.print("session_delete")} again` - : workspace.id, - value: workspace.id, - category: workspace.type, - description: workspace.branch ? `Branch ${workspace.branch}` : undefined, - footer: - count === undefined - ? "Loading sessions..." - : count === null - ? "Sessions unavailable" - : `${count} session${count === 1 ? "" : "s"}`, - } - }), - { - title: "+ New workspace", - value: "__create__", - category: "Actions", - description: "Create a new workspace", - }, - ]) - - onMount(() => { - dialog.setSize("large") - void sync.workspace.sync() - }) - - return ( - { - setToDelete(undefined) - }} - onSelect={(option) => { - setToDelete(undefined) - if (option.value === "__create__") { - dialog.replace(() => open(workspaceID, true)} />) - return - } - void selectWorkspace(option.value) - }} - keybind={[ - { - keybind: keybind.all.session_delete?.[0], - title: "delete", - onTrigger: async (option) => { - if (option.value === "__create__" || option.value === null) return - if (toDelete() !== option.value) { - setToDelete(option.value) - return - } - const result = await sdk.client.experimental.workspace.remove({ id: option.value }).catch(() => undefined) - setToDelete(undefined) - if (result?.error) { - toast.show({ - message: "Failed to delete workspace", - variant: "error", - }) - return - } - if (currentWorkspaceID() === option.value) { - project.workspace.set(undefined) - route.navigate({ - type: "home", - }) - } - await sync.workspace.sync() - }, - }, - ]} - /> - ) -} diff --git a/packages/opencode/src/cli/cmd/tui/component/workspace/dialog-session-list.tsx b/packages/opencode/src/cli/cmd/tui/component/workspace/dialog-session-list.tsx deleted file mode 100644 index 326f094a56..0000000000 --- a/packages/opencode/src/cli/cmd/tui/component/workspace/dialog-session-list.tsx +++ /dev/null @@ -1,151 +0,0 @@ -import { useDialog } from "@tui/ui/dialog" -import { DialogSelect } from "@tui/ui/dialog-select" -import { useRoute } from "@tui/context/route" -import { useSync } from "@tui/context/sync" -import { createMemo, createSignal, createResource, onMount, Show } from "solid-js" -import { Locale } from "@/util/locale" -import { useKeybind } from "../../context/keybind" -import { useTheme } from "../../context/theme" -import { useSDK } from "../../context/sdk" -import { DialogSessionRename } from "../dialog-session-rename" -import { useKV } from "../../context/kv" -import { createDebouncedSignal } from "../../util/signal" -import { Spinner } from "../spinner" -import { useToast } from "../../ui/toast" - -export function DialogSessionList(props: { workspaceID?: string; localOnly?: boolean } = {}) { - const dialog = useDialog() - const route = useRoute() - const sync = useSync() - const keybind = useKeybind() - const { theme } = useTheme() - const sdk = useSDK() - const kv = useKV() - const toast = useToast() - const [toDelete, setToDelete] = createSignal() - const [search, setSearch] = createDebouncedSignal("", 150) - - const [listed, listedActions] = createResource( - () => props.workspaceID, - async (workspaceID) => { - if (!workspaceID) return undefined - const result = await sdk.client.session.list({ roots: true }) - return result.data ?? [] - }, - ) - - const [searchResults] = createResource(search, async (query) => { - if (!query || props.localOnly) return undefined - const result = await sdk.client.session.list({ - search: query, - limit: 30, - ...(props.workspaceID ? { roots: true } : {}), - }) - return result.data ?? [] - }) - - const currentSessionID = createMemo(() => (route.data.type === "session" ? route.data.sessionID : undefined)) - - const sessions = createMemo(() => { - if (searchResults()) return searchResults()! - if (props.workspaceID) return listed() ?? [] - if (props.localOnly) return sync.data.session.filter((session) => !session.workspaceID) - return sync.data.session - }) - - const options = createMemo(() => { - const today = new Date().toDateString() - return sessions() - .filter((x) => { - if (x.parentID !== undefined) return false - if (props.workspaceID && listed()) return true - if (props.workspaceID) return x.workspaceID === props.workspaceID - if (props.localOnly) return !x.workspaceID - return true - }) - .toSorted((a, b) => b.time.updated - a.time.updated) - .map((x) => { - const date = new Date(x.time.updated) - let category = date.toDateString() - if (category === today) { - category = "Today" - } - const isDeleting = toDelete() === x.id - const status = sync.data.session_status?.[x.id] - const isWorking = status?.type === "busy" - return { - title: isDeleting ? `Press ${keybind.print("session_delete")} again to confirm` : x.title, - bg: isDeleting ? theme.error : undefined, - value: x.id, - category, - footer: Locale.time(x.time.updated), - gutter: isWorking ? : undefined, - } - }) - }) - - onMount(() => { - dialog.setSize("large") - }) - - return ( - { - setToDelete(undefined) - }} - onSelect={(option) => { - route.navigate({ - type: "session", - sessionID: option.value, - }) - dialog.clear() - }} - keybind={[ - { - keybind: keybind.all.session_delete?.[0], - title: "delete", - onTrigger: async (option) => { - if (toDelete() === option.value) { - const deleted = await sdk.client.session - .delete({ - sessionID: option.value, - }) - .then(() => true) - .catch(() => false) - setToDelete(undefined) - if (!deleted) { - toast.show({ - message: "Failed to delete session", - variant: "error", - }) - return - } - if (props.workspaceID) { - listedActions.mutate((sessions) => sessions?.filter((session) => session.id !== option.value)) - return - } - sync.set( - "session", - sync.data.session.filter((session) => session.id !== option.value), - ) - return - } - setToDelete(option.value) - }, - }, - { - keybind: keybind.all.session_rename?.[0], - title: "rename", - onTrigger: async (option) => { - dialog.replace(() => ) - }, - }, - ]} - /> - ) -} diff --git a/packages/opencode/src/cli/cmd/tui/ui/dialog-select.tsx b/packages/opencode/src/cli/cmd/tui/ui/dialog-select.tsx index 46821cccec..c4e855933e 100644 --- a/packages/opencode/src/cli/cmd/tui/ui/dialog-select.tsx +++ b/packages/opencode/src/cli/cmd/tui/ui/dialog-select.tsx @@ -42,6 +42,7 @@ export interface DialogSelectOption { disabled?: boolean bg?: RGBA gutter?: JSX.Element + margin?: JSX.Element onSelect?: (ctx: DialogContext) => void } @@ -312,6 +313,7 @@ export function DialogSelect(props: DialogSelectProps) { { setStore("input", "mouse") }} @@ -335,6 +337,11 @@ export function DialogSelect(props: DialogSelectProps) { paddingRight={3} gap={1} > + + + {option.margin} + +