Selaa lähdekoodia

refactor(tui): improve workspace management (#22691)

James Long 1 viikko sitten
vanhempi
sitoutus
06afd33291

+ 101 - 0
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<boolean | void>
+  onRestore?: () => boolean | void | Promise<boolean | void>
+  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 (
+    <box paddingLeft={2} paddingRight={2} gap={1}>
+      <box flexDirection="row" justifyContent="space-between">
+        <text attributes={TextAttributes.BOLD} fg={theme.text}>
+          Failed to Delete Session
+        </text>
+        <text fg={theme.textMuted} onMouseUp={() => dialog.clear()}>
+          esc
+        </text>
+      </box>
+      <text fg={theme.textMuted} wrapMode="word">
+        {`The session "${props.session}" could not be deleted because the workspace "${props.workspace}" is not available.`}
+      </text>
+      <text fg={theme.textMuted} wrapMode="word">
+        Choose how you want to recover this broken workspace session.
+      </text>
+      <box flexDirection="column" paddingBottom={1} gap={1}>
+        <For each={options}>
+          {(item) => (
+            <box
+              flexDirection="column"
+              paddingLeft={1}
+              paddingRight={1}
+              paddingTop={1}
+              paddingBottom={1}
+              backgroundColor={item.id === store.active ? theme.primary : undefined}
+              onMouseUp={() => {
+                setStore("active", item.id)
+                void confirm()
+              }}
+            >
+              <text
+                attributes={TextAttributes.BOLD}
+                fg={item.id === store.active ? theme.selectedListItemText : theme.text}
+              >
+                {item.title}
+              </text>
+              <text fg={item.id === store.active ? theme.selectedListItemText : theme.textMuted} wrapMode="word">
+                {item.description}
+              </text>
+            </box>
+          )}
+        </For>
+      </box>
+    </box>
+  )
+}

+ 92 - 5
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<string>()
   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<ReturnType<typeof sessions>[number]>) {
+    const workspace = project.workspace.get(session.workspaceID!)
+    const list = () => dialog.replace(() => <DialogSessionList />)
+    dialog.replace(() => (
+      <DialogSessionDeleteFailed
+        session={session.title}
+        workspace={workspace?.name ?? session.workspaceID!}
+        onDone={list}
+        onDelete={async () => {
+          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(() => (
+            <DialogWorkspaceCreate
+              onSelect={(workspaceID) =>
+                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
             }

+ 132 - 4
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<typeof useSDK>, sync: ReturnType<typeof useSync>, 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<typeof useDialog>
+  sdk: ReturnType<typeof useSDK>
+  sync: ReturnType<typeof useSync>
+  project: ReturnType<typeof useProject>
+  toast: ReturnType<typeof useToast>
+  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> | 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)
   }

+ 1 - 3
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)

+ 12 - 5
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) => {

+ 3 - 3
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<WorkspaceContext>("instance")
 
 export const WorkspaceContext = {
   async provide<R>(input: { workspaceID: WorkspaceID; fn: () => R }): Promise<R> {
-    return context.provide({ workspaceID: input.workspaceID as string }, () => input.fn())
+    return context.provide({ workspaceID: input.workspaceID }, () => input.fn())
   },
 
-  restore<R>(workspaceID: string, fn: () => R): R {
+  restore<R>(workspaceID: WorkspaceID, fn: () => R): R {
     return context.provide({ workspaceID }, fn)
   },
 

+ 2 - 1
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: <A, E, R>(effect: Effect.Effect<A, E, R>) => Fiber.Fiber<A, E>
 }
 
-function restore<R>(instance: InstanceContext | undefined, workspace: string | undefined, fn: () => R): R {
+function restore<R>(instance: InstanceContext | undefined, workspace: WorkspaceID | undefined, fn: () => R): R {
   if (instance && workspace !== undefined) {
     return WorkspaceContext.restore(workspace, () => Instance.restore(instance, fn))
   }

+ 2 - 1
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<InstanceContext | undefined>("~opencode/InstanceRef", {
   defaultValue: () => undefined,
 })
 
-export const WorkspaceRef = Context.Reference<string | undefined>("~opencode/WorkspaceRef", {
+export const WorkspaceRef = Context.Reference<WorkspaceID | undefined>("~opencode/WorkspaceRef", {
   defaultValue: () => undefined,
 })

+ 2 - 1
packages/opencode/src/session/session.ts

@@ -519,12 +519,13 @@ export const layer: Layer.Layer<Service, never, Bus.Service | Storage.Service> =
       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,
       })
     })
 

+ 2 - 14
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()
     }