Просмотр исходного кода

feat(tui): minor UX improvements for workspaces (#23146)

James Long 4 дней назад
Родитель
Сommit
b275b8580d

+ 2 - 7
packages/opencode/src/cli/cmd/tui/component/dialog-session-list.tsx

@@ -139,15 +139,10 @@ export function DialogSessionList() {
                 {desc}{" "}
                 <span
                   style={{
-                    fg:
-                      workspaceStatus === "error"
-                        ? theme.error
-                        : workspaceStatus === "disconnected"
-                          ? theme.textMuted
-                          : theme.success,
+                    fg: workspaceStatus === "connected" ? theme.success : theme.error,
                   }}
                 >
-                  
+                  ●
                 </span>
               </>
             )

+ 10 - 1
packages/opencode/src/cli/cmd/tui/component/dialog-workspace-create.tsx

@@ -139,7 +139,16 @@ export async function restoreWorkspaceSession(input: {
     total: result.data.total,
   })
 
-  await Promise.all([input.project.workspace.sync(), input.sync.session.refresh()]).catch((err) => {
+  input.project.workspace.set(input.workspaceID)
+
+  try {
+    await input.sync.bootstrap({ fatal: false })
+  } catch (e) {}
+
+  await Promise.all([
+    input.project.workspace.sync(),
+    input.sync.session.sync(input.sessionID),
+  ]).catch((err) => {
     log.error("session restore refresh failed", {
       workspaceID: input.workspaceID,
       sessionID: input.sessionID,

+ 83 - 0
packages/opencode/src/cli/cmd/tui/component/dialog-workspace-unavailable.tsx

@@ -0,0 +1,83 @@
+import { TextAttributes } from "@opentui/core"
+import { useKeyboard } from "@opentui/solid"
+import { createStore } from "solid-js/store"
+import { For } from "solid-js"
+import { useTheme } from "../context/theme"
+import { useDialog } from "../ui/dialog"
+
+export function DialogWorkspaceUnavailable(props: {
+  onRestore?: () => boolean | void | Promise<boolean | void>
+}) {
+  const dialog = useDialog()
+  const { theme } = useTheme()
+  const [store, setStore] = createStore({
+    active: "restore" as "cancel" | "restore",
+  })
+
+  const options = ["cancel", "restore"] as const
+
+  async function confirm() {
+    if (store.active === "cancel") {
+      dialog.clear()
+      return
+    }
+    const result = await props.onRestore?.()
+    if (result === false) return
+  }
+
+  useKeyboard((evt) => {
+    if (evt.name === "return") {
+      evt.preventDefault()
+      evt.stopPropagation()
+      void confirm()
+      return
+    }
+    if (evt.name === "left") {
+      evt.preventDefault()
+      evt.stopPropagation()
+      setStore("active", "cancel")
+      return
+    }
+    if (evt.name === "right") {
+      evt.preventDefault()
+      evt.stopPropagation()
+      setStore("active", "restore")
+    }
+  })
+
+  return (
+    <box paddingLeft={2} paddingRight={2} gap={1}>
+      <box flexDirection="row" justifyContent="space-between">
+        <text attributes={TextAttributes.BOLD} fg={theme.text}>
+          Workspace Unavailable
+        </text>
+        <text fg={theme.textMuted} onMouseUp={() => dialog.clear()}>
+          esc
+        </text>
+      </box>
+      <text fg={theme.textMuted} wrapMode="word">
+        This session is attached to a workspace that is no longer available.
+      </text>
+      <text fg={theme.textMuted} wrapMode="word">
+        Would you like to restore this session into a new workspace?
+      </text>
+      <box flexDirection="row" justifyContent="flex-end" paddingBottom={1} gap={1}>
+        <For each={options}>
+          {(item) => (
+            <box
+              paddingLeft={2}
+              paddingRight={2}
+              backgroundColor={item === store.active ? theme.primary : undefined}
+              onMouseUp={() => {
+                setStore("active", item)
+                void confirm()
+              }}
+            >
+              <text fg={item === store.active ? theme.selectedListItemText : theme.textMuted}>{item}</text>
+            </box>
+          )}
+        </For>
+      </box>
+    </box>
+  )
+}

+ 44 - 9
packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx

@@ -9,6 +9,7 @@ import { tint, useTheme } from "@tui/context/theme"
 import { EmptyBorder, SplitBorder } from "@tui/component/border"
 import { useSDK } from "@tui/context/sdk"
 import { useRoute } from "@tui/context/route"
+import { useProject } from "@tui/context/project"
 import { useSync } from "@tui/context/sync"
 import { useEvent } from "@tui/context/event"
 import { MessageID, PartID } from "@/session/schema"
@@ -38,6 +39,8 @@ import { useKV } from "../../context/kv"
 import { createFadeIn } from "../../util/signal"
 import { useTextareaKeybindings } from "../textarea-keybindings"
 import { DialogSkill } from "../dialog-skill"
+import { DialogWorkspaceCreate, restoreWorkspaceSession } from "../dialog-workspace-create"
+import { DialogWorkspaceUnavailable } from "../dialog-workspace-unavailable"
 import { useArgs } from "@tui/context/args"
 
 export type PromptProps = {
@@ -92,6 +95,7 @@ export function Prompt(props: PromptProps) {
   const args = useArgs()
   const sdk = useSDK()
   const route = useRoute()
+  const project = useProject()
   const sync = useSync()
   const dialog = useDialog()
   const toast = useToast()
@@ -241,9 +245,11 @@ export function Prompt(props: PromptProps) {
         keybind: "input_submit",
         category: "Prompt",
         hidden: true,
-        onSelect: (dialog) => {
+        onSelect: async (dialog) => {
           if (!input.focused) return
-          void submit()
+          const handled = await submit()
+          if (!handled) return
+
           dialog.clear()
         },
       },
@@ -628,20 +634,48 @@ export function Prompt(props: PromptProps) {
       setStore("prompt", "input", input.plainText)
       syncExtmarksWithPromptParts()
     }
-    if (props.disabled) return
-    if (autocomplete?.visible) return
-    if (!store.prompt.input) return
+    if (props.disabled) return false
+    if (autocomplete?.visible) return false
+    if (!store.prompt.input) return false
     const agent = local.agent.current()
-    if (!agent) return
+    if (!agent) return false
     const trimmed = store.prompt.input.trim()
     if (trimmed === "exit" || trimmed === "quit" || trimmed === ":q") {
       void exit()
-      return
+      return true
     }
     const selectedModel = local.model.current()
     if (!selectedModel) {
       void promptModelWarning()
-      return
+      return false
+    }
+
+    const workspaceSession = props.sessionID ? sync.session.get(props.sessionID) : undefined
+    const workspaceID = workspaceSession?.workspaceID
+    const workspaceStatus = workspaceID ? (project.workspace.status(workspaceID) ?? "error") : undefined
+    if (props.sessionID && workspaceID && workspaceStatus !== "connected") {
+      dialog.replace(() => (
+        <DialogWorkspaceUnavailable
+          onRestore={() => {
+            dialog.replace(() => (
+              <DialogWorkspaceCreate
+                onSelect={(nextWorkspaceID) =>
+                  restoreWorkspaceSession({
+                    dialog,
+                    sdk,
+                    sync,
+                    project,
+                    toast,
+                    workspaceID: nextWorkspaceID,
+                    sessionID: props.sessionID!,
+                  })
+                }
+              />
+            ))
+          }}
+        />
+      ))
+      return false
     }
 
     let sessionID = props.sessionID
@@ -656,7 +690,7 @@ export function Prompt(props: PromptProps) {
           variant: "error",
         })
 
-        return
+        return true
       }
 
       sessionID = res.data.id
@@ -770,6 +804,7 @@ export function Prompt(props: PromptProps) {
         })
       }, 50)
     input.clear()
+    return true
   }
   const exit = useExit()
 

+ 20 - 0
packages/opencode/src/cli/cmd/tui/routes/session/sidebar.tsx

@@ -1,3 +1,4 @@
+import { useProject } from "@tui/context/project"
 import { useSync } from "@tui/context/sync"
 import { createMemo, Show } from "solid-js"
 import { useTheme } from "../../context/theme"
@@ -8,10 +9,23 @@ import { TuiPluginRuntime } from "../../plugin"
 import { getScrollAcceleration } from "../../util/scroll"
 
 export function Sidebar(props: { sessionID: string; overlay?: boolean }) {
+  const project = useProject()
   const sync = useSync()
   const { theme } = useTheme()
   const tuiConfig = useTuiConfig()
   const session = createMemo(() => sync.session.get(props.sessionID))
+  const workspaceStatus = () => {
+    const workspaceID = session()?.workspaceID
+    if (!workspaceID) return "error"
+    return project.workspace.status(workspaceID) ?? "error"
+  }
+  const workspaceLabel = () => {
+    const workspaceID = session()?.workspaceID
+    if (!workspaceID) return "unknown"
+    const info = project.workspace.get(workspaceID)
+    if (!info) return "unknown"
+    return `${info.type}: ${info.name}`
+  }
   const scrollAcceleration = createMemo(() => getScrollAcceleration(tuiConfig))
 
   return (
@@ -48,6 +62,12 @@ export function Sidebar(props: { sessionID: string; overlay?: boolean }) {
                 <text fg={theme.text}>
                   <b>{session()!.title}</b>
                 </text>
+                <Show when={session()!.workspaceID}>
+                  <text fg={theme.textMuted}>
+                    <span style={{ fg: workspaceStatus() === "connected" ? theme.success : theme.error }}>●</span>{" "}
+                    {workspaceLabel()}
+                  </text>
+                </Show>
                 <Show when={session()!.share?.url}>
                   <text fg={theme.textMuted}>{session()!.share!.url}</text>
                 </Show>