diff --git a/packages/opencode/src/cli/cmd/tui/component/dialog-session-delete-failed.tsx b/packages/opencode/src/cli/cmd/tui/component/dialog-session-delete-failed.tsx new file mode 100644 index 0000000000..4a22a0c492 --- /dev/null +++ b/packages/opencode/src/cli/cmd/tui/component/dialog-session-delete-failed.tsx @@ -0,0 +1,101 @@ +import { TextAttributes } from "@opentui/core" +import { useTheme } from "../context/theme" +import { useDialog } from "../ui/dialog" +import { createStore } from "solid-js/store" +import { For } from "solid-js" +import { useKeyboard } from "@opentui/solid" + +export function DialogSessionDeleteFailed(props: { + session: string + workspace: string + onDelete?: () => boolean | void | Promise + onRestore?: () => boolean | void | Promise + onDone?: () => void +}) { + const dialog = useDialog() + const { theme } = useTheme() + const [store, setStore] = createStore({ + active: "delete" as "delete" | "restore", + }) + + const options = [ + { + id: "delete" as const, + title: "Delete workspace", + description: "Delete the workspace and all sessions attached to it.", + run: props.onDelete, + }, + { + id: "restore" as const, + title: "Restore to new workspace", + description: "Try to restore this session into a new workspace.", + run: props.onRestore, + }, + ] + + async function confirm() { + const result = await options.find((item) => item.id === store.active)?.run?.() + if (result === false) return + props.onDone?.() + if (!props.onDone) dialog.clear() + } + + useKeyboard((evt) => { + if (evt.name === "return") { + void confirm() + } + if (evt.name === "left" || evt.name === "up") { + setStore("active", "delete") + } + if (evt.name === "right" || evt.name === "down") { + setStore("active", "restore") + } + }) + + return ( + + + + Failed to Delete Session + + dialog.clear()}> + esc + + + + {`The session "${props.session}" could not be deleted because the workspace "${props.workspace}" is not available.`} + + + Choose how you want to recover this broken workspace session. + + + + {(item) => ( + { + setStore("active", item.id) + void confirm() + }} + > + + {item.title} + + + {item.description} + + + )} + + + + ) +} 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 f58b73c9a7..75c79dcdd8 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 @@ -13,8 +13,10 @@ import { DialogSessionRename } from "./dialog-session-rename" import { Keybind } from "@/util" import { createDebouncedSignal } from "../util/signal" import { useToast } from "../ui/toast" -import { DialogWorkspaceCreate, openWorkspaceSession } from "./dialog-workspace-create" +import { DialogWorkspaceCreate, openWorkspaceSession, restoreWorkspaceSession } from "./dialog-workspace-create" import { Spinner } from "./spinner" +import { errorMessage } from "@/util/error" +import { DialogSessionDeleteFailed } from "./dialog-session-delete-failed" type WorkspaceStatus = "connected" | "connecting" | "disconnected" | "error" @@ -30,7 +32,7 @@ export function DialogSessionList() { const [toDelete, setToDelete] = createSignal() const [search, setSearch] = createDebouncedSignal("", 150) - const [searchResults] = createResource(search, async (query) => { + const [searchResults, { refetch }] = createResource(search, async (query) => { if (!query) return undefined const result = await sdk.client.session.list({ search: query, limit: 30 }) return result.data ?? [] @@ -56,6 +58,57 @@ export function DialogSessionList() { )) } + function recover(session: NonNullable[number]>) { + const workspace = project.workspace.get(session.workspaceID!) + const list = () => dialog.replace(() => ) + dialog.replace(() => ( + { + const current = currentSessionID() + const info = current ? sync.data.session.find((item) => item.id === current) : undefined + const result = await sdk.client.experimental.workspace.remove({ id: session.workspaceID! }) + if (result.error) { + toast.show({ + variant: "error", + title: "Failed to delete workspace", + message: errorMessage(result.error), + }) + return false + } + await project.workspace.sync() + await sync.session.refresh() + if (search()) await refetch() + if (info?.workspaceID === session.workspaceID) { + route.navigate({ type: "home" }) + } + return true + }} + onRestore={() => { + dialog.replace(() => ( + + restoreWorkspaceSession({ + dialog, + sdk, + sync, + project, + toast, + workspaceID, + sessionID: session.id, + done: list, + }) + } + /> + )) + return false + }} + /> + )) + } + const options = createMemo(() => { const today = new Date().toDateString() return sessions() @@ -145,9 +198,43 @@ export function DialogSessionList() { title: "delete", onTrigger: async (option) => { if (toDelete() === option.value) { - void sdk.client.session.delete({ - sessionID: option.value, - }) + const session = sessions().find((item) => item.id === option.value) + const status = session?.workspaceID ? project.workspace.status(session.workspaceID) : undefined + + try { + const result = await sdk.client.session.delete({ + sessionID: option.value, + }) + if (result.error) { + if (session?.workspaceID) { + recover(session) + } else { + toast.show({ + variant: "error", + title: "Failed to delete session", + message: errorMessage(result.error), + }) + } + setToDelete(undefined) + return + } + } catch (err) { + if (session?.workspaceID) { + recover(session) + } else { + toast.show({ + variant: "error", + title: "Failed to delete session", + message: errorMessage(err), + }) + } + setToDelete(undefined) + return + } + if (status && status !== "connected") { + await sync.session.refresh() + } + if (search()) await refetch() setToDelete(undefined) return } 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 index 447a1c3258..ca504d864d 100644 --- a/packages/opencode/src/cli/cmd/tui/component/dialog-workspace-create.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/dialog-workspace-create.tsx @@ -6,6 +6,8 @@ import { useSync } from "@tui/context/sync" import { useProject } from "@tui/context/project" import { createMemo, createSignal, onMount } from "solid-js" import { setTimeout as sleep } from "node:timers/promises" +import { errorData, errorMessage } from "@/util/error" +import * as Log from "@/util/log" import { useSDK } from "../context/sdk" import { useToast } from "../ui/toast" @@ -15,6 +17,8 @@ type Adaptor = { description: string } +const log = Log.Default.clone().tag("service", "tui-workspace") + function scoped(sdk: ReturnType, sync: ReturnType, workspaceID: string) { return createOpencodeClient({ baseUrl: sdk.url, @@ -33,8 +37,20 @@ export async function openWorkspaceSession(input: { workspaceID: string }) { const client = scoped(input.sdk, input.sync, input.workspaceID) + log.info("workspace session create requested", { + workspaceID: input.workspaceID, + }) + + console.log("opening!") while (true) { - const result = await client.session.create({ workspaceID: input.workspaceID }).catch(() => undefined) + console.log("creating") + const result = await client.session.create({ workspace: input.workspaceID }).catch((err) => { + log.error("workspace session create request failed", { + workspaceID: input.workspaceID, + error: errorData(err), + }) + return undefined + }) if (!result) { input.toast.show({ message: "Failed to create workspace session", @@ -42,26 +58,113 @@ export async function openWorkspaceSession(input: { }) return } - if (result.response.status >= 500 && result.response.status < 600) { + log.info("workspace session create response", { + workspaceID: input.workspaceID, + status: result.response?.status, + sessionID: result.data?.id, + }) + if (result.response?.status && result.response.status >= 500 && result.response.status < 600) { + log.warn("workspace session create retrying after server error", { + workspaceID: input.workspaceID, + status: result.response.status, + }) await sleep(1000) continue } if (!result.data) { + log.error("workspace session create returned no data", { + workspaceID: input.workspaceID, + status: result.response?.status, + }) input.toast.show({ message: "Failed to create workspace session", variant: "error", }) return } + input.route.navigate({ type: "session", sessionID: result.data.id, }) + log.info("workspace session create complete", { + workspaceID: input.workspaceID, + sessionID: result.data.id, + }) input.dialog.clear() return } } +export async function restoreWorkspaceSession(input: { + dialog: ReturnType + sdk: ReturnType + sync: ReturnType + project: ReturnType + toast: ReturnType + workspaceID: string + sessionID: string + done?: () => void +}) { + log.info("session restore requested", { + workspaceID: input.workspaceID, + sessionID: input.sessionID, + }) + const result = await input.sdk.client.experimental.workspace + .sessionRestore({ id: input.workspaceID, sessionID: input.sessionID }) + .catch((err) => { + log.error("session restore request failed", { + workspaceID: input.workspaceID, + sessionID: input.sessionID, + error: errorData(err), + }) + return undefined + }) + if (!result?.data) { + log.error("session restore failed", { + workspaceID: input.workspaceID, + sessionID: input.sessionID, + status: result?.response?.status, + error: result?.error ? errorData(result.error) : undefined, + }) + input.toast.show({ + message: `Failed to restore session: ${errorMessage(result?.error ?? "no response")}`, + variant: "error", + }) + return + } + + log.info("session restore response", { + workspaceID: input.workspaceID, + sessionID: input.sessionID, + status: result.response?.status, + total: result.data.total, + }) + + await Promise.all([input.project.workspace.sync(), input.sync.session.refresh()]).catch((err) => { + log.error("session restore refresh failed", { + workspaceID: input.workspaceID, + sessionID: input.sessionID, + error: errorData(err), + }) + throw err + }) + + log.info("session restore complete", { + workspaceID: input.workspaceID, + sessionID: input.sessionID, + total: result.data.total, + }) + + input.toast.show({ + message: "Session restored into the new workspace", + variant: "success", + }) + input.done?.() + if (input.done) return + input.dialog.clear() +} + export function DialogWorkspaceCreate(props: { onSelect: (workspaceID: string) => Promise | void }) { const dialog = useDialog() const sync = useSync() @@ -123,18 +226,43 @@ export function DialogWorkspaceCreate(props: { onSelect: (workspaceID: string) = const create = async (type: string) => { if (creating()) return setCreating(type) + log.info("workspace create requested", { + type, + }) + + const result = await sdk.client.experimental.workspace.create({ type, branch: null }).catch((err) => { + log.error("workspace create request failed", { + type, + error: errorData(err), + }) + return undefined + }) - const result = await sdk.client.experimental.workspace.create({ type, branch: null }).catch(() => undefined) const workspace = result?.data if (!workspace) { setCreating(undefined) + log.error("workspace create failed", { + type, + status: result?.response.status, + error: result?.error ? errorData(result.error) : undefined, + }) toast.show({ - message: "Failed to create workspace", + message: `Failed to create workspace: ${errorMessage(result?.error ?? "no response")}`, variant: "error", }) return } + log.info("workspace create response", { + type, + workspaceID: workspace.id, + status: result.response?.status, + }) + await project.workspace.sync() + log.info("workspace create synced", { + type, + workspaceID: workspace.id, + }) await props.onSelect(workspace.id) setCreating(undefined) } 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 b4ab82729f..e64a16eb8a 100644 --- a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx @@ -617,9 +617,7 @@ export function Prompt(props: PromptProps) { let sessionID = props.sessionID if (sessionID == null) { - const res = await sdk.client.session.create({ - workspaceID: props.workspaceID, - }) + const res = await sdk.client.session.create({ workspace: props.workspaceID }) if (res.error) { console.log("Creating a session failed:", res.error) diff --git a/packages/opencode/src/cli/cmd/tui/context/sync.tsx b/packages/opencode/src/cli/cmd/tui/context/sync.tsx index 46227e28aa..38b4457445 100644 --- a/packages/opencode/src/cli/cmd/tui/context/sync.tsx +++ b/packages/opencode/src/cli/cmd/tui/context/sync.tsx @@ -474,6 +474,13 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ if (match.found) return store.session[match.index] return undefined }, + async refresh() { + const start = Date.now() - 30 * 24 * 60 * 60 * 1000 + const list = await sdk.client.session + .list({ start }) + .then((x) => (x.data ?? []).toSorted((a, b) => a.id.localeCompare(b.id))) + setStore("session", reconcile(list)) + }, status(sessionID: string) { const session = result.session.get(sessionID) if (!session) return "idle" @@ -485,13 +492,13 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ return last.time.completed ? "idle" : "working" }, async sync(sessionID: string) { + console.log('YO', sessionID, fullSyncedSessions.has(sessionID)) if (fullSyncedSessions.has(sessionID)) return - const workspace = project.workspace.current() const [session, messages, todo, diff] = await Promise.all([ - 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 }), + sdk.client.session.get({ sessionID }, { throwOnError: true }), + sdk.client.session.messages({ sessionID, limit: 100 }), + sdk.client.session.todo({ sessionID }), + sdk.client.session.diff({ sessionID }), ]) setStore( produce((draft) => { diff --git a/packages/opencode/src/control-plane/workspace-context.ts b/packages/opencode/src/control-plane/workspace-context.ts index 565472a24f..3d4fa5baef 100644 --- a/packages/opencode/src/control-plane/workspace-context.ts +++ b/packages/opencode/src/control-plane/workspace-context.ts @@ -2,17 +2,17 @@ import { LocalContext } from "../util" import type { WorkspaceID } from "../control-plane/schema" export interface WorkspaceContext { - workspaceID: string + workspaceID: WorkspaceID } const context = LocalContext.create("instance") export const WorkspaceContext = { async provide(input: { workspaceID: WorkspaceID; fn: () => R }): Promise { - return context.provide({ workspaceID: input.workspaceID as string }, () => input.fn()) + return context.provide({ workspaceID: input.workspaceID }, () => input.fn()) }, - restore(workspaceID: string, fn: () => R): R { + restore(workspaceID: WorkspaceID, fn: () => R): R { return context.provide({ workspaceID }, fn) }, diff --git a/packages/opencode/src/effect/bridge.ts b/packages/opencode/src/effect/bridge.ts index d79fc74f47..03e5aefd23 100644 --- a/packages/opencode/src/effect/bridge.ts +++ b/packages/opencode/src/effect/bridge.ts @@ -1,6 +1,7 @@ import { Effect, Fiber } from "effect" import { WorkspaceContext } from "@/control-plane/workspace-context" import { Instance, type InstanceContext } from "@/project/instance" +import type { WorkspaceID } from "@/control-plane/schema" import { LocalContext } from "@/util" import { InstanceRef, WorkspaceRef } from "./instance-ref" import { attachWith } from "./run-service" @@ -10,7 +11,7 @@ export interface Shape { readonly fork: (effect: Effect.Effect) => Fiber.Fiber } -function restore(instance: InstanceContext | undefined, workspace: string | undefined, fn: () => R): R { +function restore(instance: InstanceContext | undefined, workspace: WorkspaceID | undefined, fn: () => R): R { if (instance && workspace !== undefined) { return WorkspaceContext.restore(workspace, () => Instance.restore(instance, fn)) } diff --git a/packages/opencode/src/effect/instance-ref.ts b/packages/opencode/src/effect/instance-ref.ts index 301316c771..effc560c58 100644 --- a/packages/opencode/src/effect/instance-ref.ts +++ b/packages/opencode/src/effect/instance-ref.ts @@ -1,10 +1,11 @@ import { Context } from "effect" import type { InstanceContext } from "@/project/instance" +import type { WorkspaceID } from "@/control-plane/schema" export const InstanceRef = Context.Reference("~opencode/InstanceRef", { defaultValue: () => undefined, }) -export const WorkspaceRef = Context.Reference("~opencode/WorkspaceRef", { +export const WorkspaceRef = Context.Reference("~opencode/WorkspaceRef", { defaultValue: () => undefined, }) diff --git a/packages/opencode/src/session/session.ts b/packages/opencode/src/session/session.ts index e288aec73a..8c5fc29e4a 100644 --- a/packages/opencode/src/session/session.ts +++ b/packages/opencode/src/session/session.ts @@ -519,12 +519,13 @@ export const layer: Layer.Layer = workspaceID?: WorkspaceID }) { const directory = yield* InstanceState.directory + const workspace = yield* InstanceState.workspaceID return yield* createNext({ parentID: input?.parentID, directory, title: input?.title, permission: input?.permission, - workspaceID: input?.workspaceID, + workspaceID: workspace, }) }) diff --git a/packages/opencode/test/cli/tui/sync-provider.test.tsx b/packages/opencode/test/cli/tui/sync-provider.test.tsx index 3ef126ef4c..e75e186199 100644 --- a/packages/opencode/test/cli/tui/sync-provider.test.tsx +++ b/packages/opencode/test/cli/tui/sync-provider.test.tsx @@ -264,27 +264,15 @@ describe("SyncProvider", () => { log.length = 0 await sync.session.sync("ses_1") + expect(log.filter((item) => item.path === "/session/ses_1")).toHaveLength(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") + expect(log.filter((item) => item.path === "/session/ses_1")).toHaveLength(1) } finally { app.renderer.destroy() }