summaryrefslogtreecommitdiffhomepage
path: root/packages
diff options
context:
space:
mode:
authorJames Long <[email protected]>2026-04-16 23:35:36 -0400
committerGitHub <[email protected]>2026-04-16 23:35:36 -0400
commit0bedea52b19515c69057866ec958769004147f66 (patch)
treeceaf0affe90b40af29d4d68f3fbd981f306db407 /packages
parentfbbab9d6c8a03c4cd5bed0d13a85f52e3aca47ce (diff)
downloadopencode-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.tsx19
-rw-r--r--packages/opencode/src/cli/cmd/tui/context/sync.tsx32
-rw-r--r--packages/opencode/src/cli/cmd/tui/routes/session/index.tsx39
-rw-r--r--packages/opencode/src/session/session.ts7
-rw-r--r--packages/opencode/test/cli/tui/sync-provider.test.tsx280
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()
- }
- })
-})