From 0e26116f686395a4909388b03d08ee94cad67141 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Fri, 31 Jul 2026 12:38:48 -0400 Subject: [PATCH] fix(tui): stabilize open picker --- packages/tui/src/component/dialog-open.tsx | 24 ++- .../tui/src/component/dialog-session-list.tsx | 15 +- packages/tui/src/ui/dialog-select.tsx | 8 +- packages/tui/test/cli/tui/data.test.tsx | 16 +- .../tui/test/cli/tui/dialog-open.test.tsx | 165 +++++++++++++++--- .../tui/test/cli/tui/dialog-select.test.tsx | 29 ++- 6 files changed, 221 insertions(+), 36 deletions(-) diff --git a/packages/tui/src/component/dialog-open.tsx b/packages/tui/src/component/dialog-open.tsx index a1a6a1aa2d..7eb73c38b3 100644 --- a/packages/tui/src/component/dialog-open.tsx +++ b/packages/tui/src/component/dialog-open.tsx @@ -37,8 +37,8 @@ export function DialogOpen() { const dimensions = useTerminalDimensions() const shortcuts = Keymap.useShortcuts() const [filter, setFilter] = createSignal("") + const [selectionMoved, setSelectionMoved] = createSignal(false) - data.project.invalidate() void data.project.sync().catch(() => {}) // One background fetch fills in recent sessions from other projects; the menu renders @@ -52,8 +52,12 @@ export function DialogOpen() { { initialValue: [] }, ) - const openTabs = createMemo(() => new Set(sessionTabs.tabs().map((tab) => tab.sessionID))) - const currentSessionID = createMemo(() => (route.data.type === "session" ? route.data.sessionID : undefined)) + const openTabs = createMemo( + () => new Set(sessionTabs.enabled() ? sessionTabs.tabs().map((tab) => tab.sessionID) : []), + ) + const currentSessionID = createMemo(() => + route.data.type === "session" ? data.session.root(route.data.sessionID) : undefined, + ) const sessions = createMemo(() => { const seen = new Set() return [...data.session.list(), ...fetched()] @@ -69,7 +73,7 @@ export function DialogOpen() { const tabs = openTabs() // With an empty query the menu shows what is not already one keystroke away: open tabs are // visible in the strip, so recents exclude them. Typing widens the pool to every session so - // matching a tab by name still switches to it. + // matching a loaded tab by name still switches to it. const recent = filter().trim() ? sessions() : sessions() @@ -78,7 +82,9 @@ export function DialogOpen() { const sessionOptions = recent.map((session) => { const project = data.project.get(session.projectID) const name = project?.canonical === "/" ? undefined : project?.name || path.basename(project?.canonical ?? "") - const running = data.session.family(session.id).some((id) => data.session.status(id) === "running") + const running = + data.session.status(session.id) === "running" || + data.session.family(session.id).some((id) => data.session.status(id) === "running") return { title: withTimestampedFallback(session), value: { type: "session", sessionID: session.id } as OpenTarget, @@ -98,7 +104,7 @@ export function DialogOpen() { const projectOptions = data.project .list() .filter((project) => { - if (project.canonical === "/" || project.id === current?.id || seen.has(project.canonical)) return false + if (project.canonical === "/" || seen.has(project.canonical)) return false seen.add(project.canonical) return true }) @@ -113,6 +119,10 @@ export function DialogOpen() { searchText: footer, value: { type: "project", directory: project.canonical } as OpenTarget, category: "Projects", + gutter: + project.canonical === current?.canonical + ? () => + : undefined, } }) @@ -128,6 +138,8 @@ export function DialogOpen() { options={options()} current={currentSessionID() ? ({ type: "session", sessionID: currentSessionID()! } as OpenTarget) : undefined} focusCurrent={false} + preserveSelection={selectionMoved()} + onMove={() => setSelectionMoved(true)} onFilter={setFilter} noMatchView={ diff --git a/packages/tui/src/component/dialog-session-list.tsx b/packages/tui/src/component/dialog-session-list.tsx index 50fcb2264e..ff27245da4 100644 --- a/packages/tui/src/component/dialog-session-list.tsx +++ b/packages/tui/src/component/dialog-session-list.tsx @@ -89,7 +89,8 @@ export function DialogSessionList() { const sessions = createMemo(() => { const query = filter().trim() const local = localSessions() - if (query !== search().trim() || searchResults.loading) return searchResults.latest?.sessions ?? local + if (query !== search().trim()) return searchResults.latest?.sessions ?? local + if (searchResults.loading) return searchResults.latest?.sessions ?? [] const result = searchResults() if (result?.query !== query || result.allProjects !== allProjects() || result.error) return local return result.sessions @@ -154,11 +155,13 @@ export function DialogSessionList() { footer, bg: deleting ? theme.background.action.destructive.focused : undefined, fg: deleting ? theme.text.action.destructive.focused : undefined, - gutter: data.session.family(session.id).some((id) => data.session.status(id) === "running") - ? () => - : slot === undefined - ? undefined - : () => {slot}, + gutter: + data.session.status(session.id) === "running" || + data.session.family(session.id).some((id) => data.session.status(id) === "running") + ? () => + : slot === undefined + ? undefined + : () => {slot}, } } diff --git a/packages/tui/src/ui/dialog-select.tsx b/packages/tui/src/ui/dialog-select.tsx index 969b3f7569..9a259710a2 100644 --- a/packages/tui/src/ui/dialog-select.tsx +++ b/packages/tui/src/ui/dialog-select.tsx @@ -245,7 +245,7 @@ export function DialogSelect(props: DialogSelectProps) { on( () => props.options, () => { - if (!props.preserveSelection && props.current === undefined) { + if (!props.preserveSelection && (props.current === undefined || props.focusCurrent === false)) { const count = flat().length if (count === 0) return const next = reconcileSelection(store.selected, count) @@ -281,7 +281,11 @@ export function DialogSelect(props: DialogSelectProps) { setStore("selected", index) selection = option if (!moved) return - if ((!props.preserveSelection && props.current === undefined) || store.filter.length > 0) return + if ( + (!props.preserveSelection && (props.current === undefined || props.focusCurrent === false)) || + store.filter.length > 0 + ) + return scrollAfterLayout(false, option.value) return } diff --git a/packages/tui/test/cli/tui/data.test.tsx b/packages/tui/test/cli/tui/data.test.tsx index 61b7070551..46a6defbf7 100644 --- a/packages/tui/test/cli/tui/data.test.tsx +++ b/packages/tui/test/cli/tui/data.test.tsx @@ -106,11 +106,18 @@ test("does not preload session summaries into the data context", async () => { } }) -test("proactively syncs project metadata", async () => { +test("proactively syncs project metadata newest first", async () => { const events = createEventStream() const calls = createFetch((url) => { if (url.pathname !== "/api/project") return return json([ + { + id: "proj_old", + canonical: "/old/project", + name: "Old project", + time: { created: 1, updated: 1 }, + sandboxes: [], + }, { id: "proj_test", canonical: worktree, @@ -149,6 +156,13 @@ test("proactively syncs project metadata", async () => { time: { created: 1, updated: 2 }, sandboxes: [], }, + { + id: "proj_old", + canonical: "/old/project", + name: "Old project", + time: { created: 1, updated: 1 }, + sandboxes: [], + }, ]) } finally { app.renderer.destroy() diff --git a/packages/tui/test/cli/tui/dialog-open.test.tsx b/packages/tui/test/cli/tui/dialog-open.test.tsx index 14ec4c7ec8..8431b684b4 100644 --- a/packages/tui/test/cli/tui/dialog-open.test.tsx +++ b/packages/tui/test/cli/tui/dialog-open.test.tsx @@ -18,16 +18,14 @@ import { StorageProvider } from "../../../src/context/storage" import { ThemeProvider } from "../../../src/context/theme" import { DialogProvider, useDialog } from "../../../src/ui/dialog" import { ToastProvider } from "../../../src/ui/toast" -import { createApi, createEventStream, createFetch, json } from "../../fixture/tui-client" +import { createApi, createEventStream, createFetch, json, type FetchHandler } from "../../fixture/tui-client" import { TestTuiContexts } from "../../fixture/tui-environment" import { createTuiResolvedConfig } from "../../fixture/tui-runtime" test("selecting an unhydrated session preserves its location", async () => { - const state = mkdtempSync(path.join(tmpdir(), "opencode-dialog-open-")) - const events = createEventStream() const remote = { directory: "/tmp/opencode/remote", workspaceID: "ws_remote" } - const calls = createFetch((url) => { - if (url.pathname !== "/api/session") return + const fixture = await renderOpen((url) => { + if (url.pathname !== "/api/session") return undefined return json({ data: [ { @@ -42,7 +40,129 @@ test("selecting an unhydrated session preserves its location", async () => { ], cursor: {}, }) - }, events) + }) + + try { + await fixture.app.waitForFrame((frame) => frame.includes("Remote session")) + expect(fixture.data.session.get("ses_remote")).toBeUndefined() + + fixture.app.mockInput.pressEnter() + await fixture.app.waitFor(() => fixture.route.data.type === "session") + + expect(fixture.route.data).toEqual({ type: "session", sessionID: "ses_remote" }) + expect(fixture.location.ref).toEqual(remote) + } finally { + fixture.dispose() + } +}) + +test("shows the current project and opens its root", async () => { + const root = "/tmp/opencode/project" + const subfolder = `${root}/packages/tui` + const fixture = await renderOpen( + (url) => { + if (url.pathname === "/api/project") + return json([ + { + id: "proj_current", + canonical: root, + name: "OpenCode", + time: { created: 1, updated: 2 }, + sandboxes: [], + }, + ]) + if (url.pathname === "/api/location") + return json({ + directory: subfolder, + project: { id: "proj_current", directory: root, canonical: root }, + }) + return undefined + }, + async ({ data, location }) => { + await data.location.sync({ directory: subfolder }) + location.set({ directory: subfolder }) + }, + ) + + try { + const frame = await fixture.app.waitForFrame((value) => value.includes("OpenCode") && value.includes("●")) + expect(frame).toContain(root) + + fixture.app.mockInput.pressEnter() + await fixture.app.waitFor(() => fixture.route.data.type === "home") + + expect(fixture.route.data).toEqual({ type: "home", location: { directory: root } }) + expect(fixture.location.ref).toEqual({ directory: root }) + } finally { + fixture.dispose() + } +}) + +test("preserves a moved project when sessions arrive", async () => { + let resolveSessions!: (response: Response) => void + const sessions = new Promise((resolve) => (resolveSessions = resolve)) + const fixture = await renderOpen((url) => { + if (url.pathname === "/api/session") return sessions + if (url.pathname === "/api/project") + return json([ + { + id: "proj_first", + canonical: "/tmp/opencode/first", + name: "First project", + time: { created: 1, updated: 2 }, + sandboxes: [], + }, + { + id: "proj_second", + canonical: "/tmp/opencode/second", + name: "Second project", + time: { created: 1, updated: 1 }, + sandboxes: [], + }, + ]) + return undefined + }) + + try { + await fixture.app.waitForFrame((frame) => frame.includes("Second project")) + fixture.app.mockInput.pressArrow("down") + + resolveSessions( + json({ + data: [ + { + id: "ses_recent", + projectID: "proj_first", + cost: 0, + tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + time: { created: 2, updated: 3 }, + title: "Recent session", + location: { directory: "/tmp/opencode/first" }, + }, + ], + cursor: {}, + }), + ) + await fixture.app.waitForFrame((frame) => frame.includes("Recent session")) + fixture.app.mockInput.pressEnter() + await fixture.app.waitFor(() => fixture.route.data.type === "home") + + expect(fixture.route.data).toEqual({ type: "home", location: { directory: "/tmp/opencode/second" } }) + } finally { + fixture.dispose() + } +}) + +async function renderOpen( + handler: FetchHandler, + beforeOpen?: (contexts: { + data: ReturnType + location: ReturnType + }) => void | Promise, +) { + const state = mkdtempSync(path.join(tmpdir(), "opencode-dialog-open-")) + const events = createEventStream() + const calls = createFetch(handler, events) let route!: ReturnType let location!: ReturnType let data!: ReturnType @@ -52,7 +172,9 @@ test("selecting an unhydrated session preserves its location", async () => { route = useRoute() location = useLocation() data = useData() - onMount(() => dialog.replace(() => )) + onMount( + () => void Promise.resolve(beforeOpen?.({ data, location })).then(() => dialog.replace(() => )), + ) return null } @@ -90,17 +212,20 @@ test("selecting an unhydrated session preserves its location", async () => { ) app.renderer.start() - try { - await app.waitForFrame((frame) => frame.includes("Remote session")) - expect(data.session.get("ses_remote")).toBeUndefined() - - app.mockInput.pressEnter() - await app.waitFor(() => route.data.type === "session") - - expect(route.data).toEqual({ type: "session", sessionID: "ses_remote" }) - expect(location.ref).toEqual(remote) - } finally { - app.renderer.destroy() - rmSync(state, { recursive: true, force: true }) + return { + app, + get route() { + return route + }, + get location() { + return location + }, + get data() { + return data + }, + dispose() { + app.renderer.destroy() + rmSync(state, { recursive: true, force: true }) + }, } -}) +} diff --git a/packages/tui/test/cli/tui/dialog-select.test.tsx b/packages/tui/test/cli/tui/dialog-select.test.tsx index 434f4d5dc6..0e3357149a 100644 --- a/packages/tui/test/cli/tui/dialog-select.test.tsx +++ b/packages/tui/test/cli/tui/dialog-select.test.tsx @@ -82,7 +82,12 @@ async function renderSelect( return app } -async function mountSelect(root: string, initial: DialogSelectOption[], current?: string) { +async function mountSelect( + root: string, + initial: DialogSelectOption[], + current?: string, + focusCurrent?: boolean, +) { const state = path.join(root, "state") await mkdir(state, { recursive: true }) const config = createTuiResolvedConfig() @@ -118,6 +123,7 @@ async function mountSelect(root: string, initial: DialogSelectOption[], title="Mutable options" options={options()} current={current} + focusCurrent={focusCurrent} onMove={(option) => moved.push(option.value)} onSelect={(option) => selected.push(option.value)} /> @@ -352,3 +358,24 @@ test("keeps the current option selected when options reorder", async () => { select.app.renderer.destroy() } }) + +test("keeps the first row selected when current is only a marker", async () => { + await using tmp = await tmpdir() + const project = { title: "project", value: "project" } + const select = await mountSelect(tmp.path, [project], "current", false) + + try { + select.replaceOptions([ + { title: "recent session", value: "recent" }, + project, + { title: "current session", value: "current" }, + ]) + await select.app.waitForFrame((frame) => frame.includes("recent session")) + select.app.mockInput.pressEnter() + await select.app.waitFor(() => select.selected.length === 1) + + expect(select.selected).toEqual(["recent"]) + } finally { + select.app.renderer.destroy() + } +})