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

fix(tui): stabilize repeated open menu (#42086)

Kit Langton 4 дней назад
Родитель
Сommit
17cae23dce

+ 11 - 4
packages/tui/src/app.tsx

@@ -2,7 +2,7 @@ import { render, useRenderer, useTerminalDimensions } from "@opentui/solid"
 import { registerOpencodeSpinner } from "./component/register-spinner"
 import { Deferred, Effect } from "effect"
 import { Service, type Endpoint } from "@opencode-ai/client/effect/service"
-import { OpenCode } from "@opencode-ai/client"
+import { OpenCode, type SessionInfo } from "@opencode-ai/client"
 import { Global } from "@opencode-ai/util/global"
 import { ClipboardProvider, useClipboard } from "./context/clipboard"
 import { LogProvider, useLog, type LogSink } from "./context/log"
@@ -69,7 +69,7 @@ import { DialogThemeList } from "./component/dialog-theme-list"
 import { DialogHelp } from "./ui/dialog-help"
 import { DialogAgent } from "./component/dialog-agent"
 import { DialogSessionList } from "./component/dialog-session-list"
-import { DialogOpen } from "./component/dialog-open"
+import { DialogOpen, DialogOpenKey, loadDialogOpen } from "./component/dialog-open"
 import { SessionTabs } from "./component/session-tabs"
 import { sessionTabsFitVertically } from "./ui/layout"
 import { ThemeErrorToast } from "./component/theme-error-toast"
@@ -478,6 +478,7 @@ function App(props: { pair?: DialogPairCredentials }) {
   const promptRef = usePromptRef()
   const plugins = usePlugin()
   const clipboard = useClipboard()
+  let openingOpen: Promise<SessionInfo[]> | undefined
   // Toast once when an MCP server enters a failed or needs-auth state so the user knows to act,
   // without having to open the status panel. Tracking the last alerted status avoids re-toasting
   // the same problem on every refresh while still re-alerting if the state changes.
@@ -680,8 +681,14 @@ function App(props: { pair?: DialogPairCredentials }) {
         title: "Open session or project",
         category: "Session",
         slash: { name: "open", aliases: ["projects", "project"] },
-        run: () => {
-          dialog.replace(() => <DialogOpen />)
+        run: async () => {
+          if (dialog.key === DialogOpenKey || openingOpen) return
+          const previous = dialog.stack.at(-1)
+          openingOpen = loadDialogOpen(data, client)
+          const sessions = await openingOpen
+          openingOpen = undefined
+          if (dialog.stack.at(-1) !== previous) return
+          dialog.replace(() => <DialogOpen sessions={sessions} />, undefined, { key: DialogOpenKey, size: "large" })
         },
       },
       ...Array.from({ length: 9 }, (_, i) => ({

+ 15 - 17
packages/tui/src/component/dialog-open.tsx

@@ -1,4 +1,4 @@
-import { createMemo, createResource, createSignal, onMount } from "solid-js"
+import { createMemo, createResource, createSignal } from "solid-js"
 import type { SessionInfo } from "@opencode-ai/client"
 import { useTerminalDimensions } from "@opentui/solid"
 import { dialogWidth, useDialog } from "../ui/dialog"
@@ -20,10 +20,22 @@ import { Spinner } from "./spinner"
 import { projectName } from "../util/project"
 
 const RECENT_LIMIT = 8
+export const DialogOpenKey = Symbol("DialogOpen")
 
 type OpenTarget = { type: "session"; sessionID: string } | { type: "project"; directory: string }
 
-export function DialogOpen() {
+export async function loadDialogOpen(data: ReturnType<typeof useData>, client: ReturnType<typeof useClient>) {
+  const [, sessions] = await Promise.all([
+    data.project.sync().catch(() => {}),
+    client.api.session
+      .list({ limit: 50, order: "desc", parentID: null })
+      .then((response) => response.data)
+      .catch(() => [] as SessionInfo[]),
+  ])
+  return sessions
+}
+
+export function DialogOpen(props: { sessions: SessionInfo[] }) {
   const dialog = useDialog()
   const route = useRoute()
   const data = useData()
@@ -39,18 +51,6 @@ export function DialogOpen() {
   const [filter, setFilter] = createSignal("")
   const [selectionMoved, setSelectionMoved] = createSignal(false)
 
-  void data.project.sync().catch(() => {})
-
-  // One background fetch fills in recent sessions from other projects; the menu renders
-  // immediately from the local store and never blocks on the network.
-  const [fetched] = createResource(
-    () =>
-      client.api.session
-        .list({ limit: 50, order: "desc", parentID: null })
-        .then((response) => response.data)
-        .catch(() => [] as SessionInfo[]),
-    { initialValue: [] },
-  )
   const [matched] = createResource(
     () => {
       const value = filter().trim()
@@ -72,7 +72,7 @@ export function DialogOpen() {
   const sessions = createMemo(() => {
     const seen = new Set<string>()
     const match = matched()
-    return [...data.session.list(), ...fetched(), ...(match ? [match] : [])]
+    return [...data.session.list(), ...props.sessions, ...(match ? [match] : [])]
       .filter((session) => {
         if (session.parentID || seen.has(session.id)) return false
         seen.add(session.id)
@@ -142,8 +142,6 @@ export function DialogOpen() {
     return [...sessionOptions, ...projectOptions]
   })
 
-  onMount(() => dialog.setSize("large"))
-
   return (
     <DialogSelect
       title="Open"

+ 16 - 9
packages/tui/src/ui/dialog.tsx

@@ -75,6 +75,7 @@ function init() {
     stack: [] as {
       element: JSX.Element
       onClose?: () => void
+      key?: unknown
     }[],
     size: "medium" as DialogSize,
     centered: false,
@@ -155,7 +156,7 @@ function init() {
       })
       refocus()
     },
-    replace(input: any, onClose?: () => void) {
+    replace(input: any, onClose?: () => void, options?: { key?: unknown; size?: DialogSize }) {
       if (store.stack.length === 0) {
         focus = renderer.currentFocusedRenderable
         focus?.blur()
@@ -163,14 +164,17 @@ function init() {
       for (const item of store.stack) {
         if (item.onClose) item.onClose()
       }
-      setStore("size", "medium")
-      setStore("centered", false)
-      setStore("stack", [
-        {
-          element: input,
-          onClose,
-        },
-      ])
+      batch(() => {
+        setStore("size", options?.size ?? "medium")
+        setStore("centered", false)
+        setStore("stack", [
+          {
+            element: input,
+            onClose,
+            key: options?.key,
+          },
+        ])
+      })
     },
     get stack() {
       return store.stack
@@ -181,6 +185,9 @@ function init() {
     get centered() {
       return store.centered
     },
+    get key() {
+      return store.stack.at(-1)?.key
+    },
     setSize(size: "medium" | "large" | "xlarge") {
       setStore("size", size)
     },

+ 13 - 7
packages/tui/test/cli/tui/dialog-open.test.tsx

@@ -2,9 +2,9 @@
 import { expect, test } from "bun:test"
 import { testRender } from "@opentui/solid"
 import { onMount } from "solid-js"
-import { DialogOpen } from "../../../src/component/dialog-open"
+import { DialogOpen, DialogOpenKey, loadDialogOpen } from "../../../src/component/dialog-open"
 import { ConfigProvider } from "../../../src/config"
-import { ClientProvider } from "../../../src/context/client"
+import { ClientProvider, useClient } from "../../../src/context/client"
 import { DataProvider, useData } from "../../../src/context/data"
 import { Keymap } from "../../../src/context/keymap"
 import { LocationProvider, useLocation } from "../../../src/context/location"
@@ -131,7 +131,7 @@ test("shows the current project and opens its root", async () => {
   }
 })
 
-test("preserves a moved project when sessions arrive", async () => {
+test("waits for sessions before showing the populated picker", async () => {
   let resolveSessions!: (response: Response) => void
   const sessions = new Promise<Response>((resolve) => (resolveSessions = resolve))
   const fixture = await renderOpen((url) => {
@@ -157,8 +157,8 @@ test("preserves a moved project when sessions arrive", async () => {
   })
 
   try {
-    await fixture.app.waitForFrame((frame) => frame.includes("Second project"))
-    fixture.app.mockInput.pressArrow("down")
+    await fixture.app.renderOnce()
+    expect(fixture.app.captureCharFrame()).not.toContain("Search sessions and projects")
 
     resolveSessions(
       json({
@@ -176,7 +176,9 @@ test("preserves a moved project when sessions arrive", async () => {
         cursor: {},
       }),
     )
-    await fixture.app.waitForFrame((frame) => frame.includes("Recent session"))
+    await fixture.app.waitForFrame((frame) => frame.includes("Recent session") && frame.includes("Second project"))
+    fixture.app.mockInput.pressArrow("down")
+    fixture.app.mockInput.pressArrow("down")
     fixture.app.mockInput.pressEnter()
     await fixture.app.waitFor(() => fixture.route.data.type === "home")
 
@@ -292,12 +294,16 @@ async function renderOpen(
 
   function Probe() {
     const dialog = useDialog()
+    const client = useClient()
     route = useRoute()
     location = useLocation()
     data = useData()
     storage = useStorage()
     onMount(
-      () => void Promise.resolve(beforeOpen?.({ data, location })).then(() => dialog.replace(() => <DialogOpen />)),
+      () =>
+        void Promise.all([beforeOpen?.({ data, location }), loadDialogOpen(data, client)]).then(([, sessions]) =>
+          dialog.replace(() => <DialogOpen sessions={sessions} />, undefined, { key: DialogOpenKey, size: "large" }),
+        ),
     )
     return null
   }