diff options
| author | James Long <[email protected]> | 2026-04-16 23:35:36 -0400 |
|---|---|---|
| committer | GitHub <[email protected]> | 2026-04-16 23:35:36 -0400 |
| commit | 0bedea52b19515c69057866ec958769004147f66 (patch) | |
| tree | ceaf0affe90b40af29d4d68f3fbd981f306db407 /packages | |
| parent | fbbab9d6c8a03c4cd5bed0d13a85f52e3aca47ce (diff) | |
| download | opencode-0bedea52b19515c69057866ec958769004147f66.tar.gz opencode-0bedea52b19515c69057866ec958769004147f66.zip | |
fix(tui): tui resiliency when workspace is dead, disable directory filter in session list (#23013)
Diffstat (limited to 'packages')
| -rw-r--r-- | packages/opencode/src/cli/cmd/tui/context/project.tsx | 19 | ||||
| -rw-r--r-- | packages/opencode/src/cli/cmd/tui/context/sync.tsx | 32 | ||||
| -rw-r--r-- | packages/opencode/src/cli/cmd/tui/routes/session/index.tsx | 39 | ||||
| -rw-r--r-- | packages/opencode/src/session/session.ts | 7 | ||||
| -rw-r--r-- | packages/opencode/test/cli/tui/sync-provider.test.tsx | 280 |
5 files changed, 56 insertions, 321 deletions
diff --git a/packages/opencode/src/cli/cmd/tui/context/project.tsx b/packages/opencode/src/cli/cmd/tui/context/project.tsx index 26e5c075d..22dd94bc8 100644 --- a/packages/opencode/src/cli/cmd/tui/context/project.tsx +++ b/packages/opencode/src/cli/cmd/tui/context/project.tsx @@ -10,18 +10,21 @@ export const { use: useProject, provider: ProjectProvider } = createSimpleContex name: "Project", init: () => { const sdk = useSDK() + + const defaultPath = { + home: "", + state: "", + config: "", + worktree: "", + directory: sdk.directory ?? "", + } satisfies Path + const [store, setStore] = createStore({ project: { id: undefined as string | undefined, }, instance: { - path: { - home: "", - state: "", - config: "", - worktree: "", - directory: sdk.directory ?? "", - } satisfies Path, + path: defaultPath, }, workspace: { current: undefined as string | undefined, @@ -38,7 +41,7 @@ export const { use: useProject, provider: ProjectProvider } = createSimpleContex ]) batch(() => { - setStore("instance", "path", reconcile(path.data!)) + setStore("instance", "path", reconcile(path.data || defaultPath)) setStore("project", "id", project.data?.id) }) } diff --git a/packages/opencode/src/cli/cmd/tui/context/sync.tsx b/packages/opencode/src/cli/cmd/tui/context/sync.tsx index b5734e67d..57326e3a1 100644 --- a/packages/opencode/src/cli/cmd/tui/context/sync.tsx +++ b/packages/opencode/src/cli/cmd/tui/context/sync.tsx @@ -27,7 +27,7 @@ import { createSimpleContext } from "./helper" import type { Snapshot } from "@/snapshot" import { useExit } from "./exit" import { useArgs } from "./args" -import { batch, createEffect, on } from "solid-js" +import { batch, onMount } from "solid-js" import { Log } from "@/util" import { emptyConsoleState, type ConsoleState } from "@/config/console-state" @@ -108,6 +108,9 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ const project = useProject() const sdk = useSDK() + const fullSyncedSessions = new Set<string>() + let syncedWorkspace = project.workspace.current() + event.subscribe((event) => { switch (event.type) { case "server.instance.disposed": @@ -350,9 +353,13 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ const exit = useExit() const args = useArgs() - async function bootstrap() { - console.log("bootstrapping") + async function bootstrap(input: { fatal?: boolean } = {}) { + const fatal = input.fatal ?? true const workspace = project.workspace.current() + if (workspace !== syncedWorkspace) { + fullSyncedSessions.clear() + syncedWorkspace = workspace + } const start = Date.now() - 30 * 24 * 60 * 60 * 1000 const sessionListPromise = sdk.client.session .list({ start: start }) @@ -441,20 +448,17 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ name: e instanceof Error ? e.name : undefined, stack: e instanceof Error ? e.stack : undefined, }) - await exit(e) + if (fatal) { + await exit(e) + } else { + throw e + } }) } - const fullSyncedSessions = new Set<string>() - createEffect( - on( - () => project.workspace.current(), - () => { - fullSyncedSessions.clear() - void bootstrap() - }, - ), - ) + onMount(() => { + void bootstrap() + }) const result = { data: store, 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 7c40e6c3c..70a4b73b9 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx @@ -181,23 +181,30 @@ export function Session() { const sdk = useSDK() createEffect(async () => { - 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) - }) - .catch((e) => { - console.error(e) - toast.show({ - message: `Session not found: ${route.sessionID}`, - variant: "error", - }) - return navigate({ type: "home" }) + const previousWorkspace = project.workspace.current() + const result = await sdk.client.session.get({ sessionID: route.sessionID }, { throwOnError: true }) + if (!result.data) { + toast.show({ + message: `Session not found: ${route.sessionID}`, + variant: "error", }) + navigate({ type: "home" }) + return + } + + if (result.data.workspaceID !== previousWorkspace) { + project.workspace.set(result.data.workspaceID) + + // Sync all the data for this workspace. Note that this + // workspace may not exist anymore which is why this is not + // fatal. If it doesn't we still want to show the session + // (which will be non-interactive) + try { + await sync.bootstrap({ fatal: false }) + } catch (e) {} + } + await sync.session.sync(route.sessionID) + if (scroll) scroll.scrollBy(100_000) }) // Handle initial prompt from fork diff --git a/packages/opencode/src/session/session.ts b/packages/opencode/src/session/session.ts index a453b1981..077cc4309 100644 --- a/packages/opencode/src/session/session.ts +++ b/packages/opencode/src/session/session.ts @@ -6,7 +6,6 @@ import { Decimal } from "decimal.js" import z from "zod" import { type ProviderMetadata, type LanguageModelUsage } from "ai" import { Flag } from "../flag/flag" -import { Installation } from "../installation" import { InstallationVersion } from "../installation/version" import { Database, NotFoundError, eq, and, gte, isNull, desc, like, inArray, lt } from "../storage" @@ -713,8 +712,10 @@ export function* list(input?: { if (input?.workspaceID) { conditions.push(eq(SessionTable.workspace_id, input.workspaceID)) } - if (input?.directory) { - conditions.push(eq(SessionTable.directory, input.directory)) + if (!Flag.OPENCODE_EXPERIMENTAL_WORKSPACES) { + if (input?.directory) { + conditions.push(eq(SessionTable.directory, input.directory)) + } } if (input?.roots) { conditions.push(isNull(SessionTable.parent_id)) diff --git a/packages/opencode/test/cli/tui/sync-provider.test.tsx b/packages/opencode/test/cli/tui/sync-provider.test.tsx deleted file mode 100644 index e75e18619..000000000 --- a/packages/opencode/test/cli/tui/sync-provider.test.tsx +++ /dev/null @@ -1,280 +0,0 @@ -/** @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<typeof useProject> - let sync!: ReturnType<typeof useSync> - let done!: () => void - const ready = new Promise<void>((resolve) => { - done = resolve - }) - - const app = await testRender(() => ( - <SDKProvider - url="http://test" - directory="/tmp/root" - fetch={createFetch(log)} - events={{ subscribe: async () => () => {} }} - > - <ArgsProvider continue={false}> - <ExitProvider> - <ProjectProvider> - <SyncProvider> - <Probe - onReady={(ctx) => { - project = ctx.project - sync = ctx.sync - done() - }} - /> - </SyncProvider> - </ProjectProvider> - </ExitProvider> - </ArgsProvider> - </SDKProvider> - )) - - 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<typeof useProject>; sync: ReturnType<typeof useSync> }) => void -}) { - const project = useProject() - const sync = useSync() - - onMount(() => { - props.onReady({ project, sync }) - }) - - return <box /> -} - -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 === "/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")).toHaveLength(1) - - 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") - expect(log.filter((item) => item.path === "/session/ses_1")).toHaveLength(1) - } finally { - app.renderer.destroy() - } - }) -}) |
