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

This commit is contained in:
Kit Langton
2026-08-12 13:17:13 -04:00
committed by GitHub
parent fd30b9765d
commit 17cae23dce
4 changed files with 55 additions and 37 deletions
+11 -4
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
}