diff --git a/packages/tui/src/app.tsx b/packages/tui/src/app.tsx index ebb2b6e5fb..ac3c68aac0 100644 --- a/packages/tui/src/app.tsx +++ b/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 | 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(() => ) + 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(() => , undefined, { key: DialogOpenKey, size: "large" }) }, }, ...Array.from({ length: 9 }, (_, i) => ({ diff --git a/packages/tui/src/component/dialog-open.tsx b/packages/tui/src/component/dialog-open.tsx index b19caf7f88..62cf0adbb3 100644 --- a/packages/tui/src/component/dialog-open.tsx +++ b/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, client: ReturnType) { + 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() 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 ( 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) }, diff --git a/packages/tui/test/cli/tui/dialog-open.test.tsx b/packages/tui/test/cli/tui/dialog-open.test.tsx index 0937178b6f..f963e8ee27 100644 --- a/packages/tui/test/cli/tui/dialog-open.test.tsx +++ b/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((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(() => )), + () => + void Promise.all([beforeOpen?.({ data, location }), loadDialogOpen(data, client)]).then(([, sessions]) => + dialog.replace(() => , undefined, { key: DialogOpenKey, size: "large" }), + ), ) return null }