fix(tui): stabilize repeated open menu (#42086)
This commit is contained in:
@@ -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) => ({
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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)
|
||||
},
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user