diff --git a/packages/opencode/src/bus/global.ts b/packages/opencode/src/bus/global.ts index 43386dd6b..e751b59fa 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 fe26a6672..6db4eb04f 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 4161c025c..2f8d1f7bb 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 84127b576..037cebb72 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 1c5ede4d7..2118fe98e 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 747c61fd0..ba6df1d6b 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 17e5c180a..81f217398 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 000000000..da073f6e9 --- /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 000000000..522e72401 --- /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 939c2d5dc..e9f463a13 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 348c3ca1d..ad35aa45c 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 11336d500..fc88bd8a9 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 3609f6cc1..e43a9cc37 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 79b5c4d7a..1cce7fb39 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 32a9d1336..f687e642f 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 df5c41677..0534b147a 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 15e02b863..a71b95ce4 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 000000000..37204d17d --- /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 bb0fd6002..a030d0b6c 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 d3939b264..07a510a4f 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 cc5901fb5..3da1357ee 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 f609986b5..c16adec62 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 a0d6f2414..33078183b 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 f587f50b3..f8a9b929a 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 55853d974..029ff7f5f 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 16b9e559f..ec7afcf23 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 b34364ccd..4592aa254 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 38f4db09a..f52da3a97 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 366bc1fc7..d5d8f6df2 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" }