From e31136bdbdb43e934d8bbd81e75a2065a2625699 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Sat, 10 Oct 2026 21:23:34 -0700 Subject: [PATCH] fix(app): follow the first palette result until explicit selection (#54275) --- .../e2e/regression/command-palette.spec.ts | 48 ++++++++++++++++++- packages/app/src/shell/commands/dialog.tsx | 29 ++++++----- 2 files changed, 62 insertions(+), 15 deletions(-) diff --git a/packages/app/e2e/regression/command-palette.spec.ts b/packages/app/e2e/regression/command-palette.spec.ts index 04d22150bdf..8084eb7b045 100644 --- a/packages/app/e2e/regression/command-palette.spec.ts +++ b/packages/app/e2e/regression/command-palette.spec.ts @@ -85,13 +85,52 @@ test("appends search results without resetting the selected command", async ({ p await expect(dialog.getByRole("option", { name: "Copy Project ID", exact: true })).toHaveCount(0) }) -test("keeps the automatically selected file when session results arrive later", async ({ page }) => { - const warnings = captureConsoleWarnings(page) +test("selects the first session when it arrives above automatically selected files", async ({ page }) => { const { dialog, input } = await openCommandPalette(page) const sessions = Promise.withResolvers() await page.route("**/api/fs/find?*", (route) => route.fulfill({ json: { data: [{ path: "README.md", type: "file" }] } }), ) + await page.route("**/api/session?*", async (route) => { + await sessions.promise + await route.fulfill({ + json: { + data: Array.from({ length: 18 }, (_, index) => ({ + ...paletteSession, + id: index === 0 ? paletteSession.id : `ses_palette_readme_${index}`, + location: { directory: paletteSession.directory }, + title: `README work ${index + 1}`, + })), + }, + }) + }) + await input.fill("README") + await expect(dialog.getByRole("option", { name: "/ README.md", exact: true })).toHaveAttribute( + "aria-selected", + "true", + ) + sessions.resolve() + const first = dialog.getByRole("option", { name: /README work 1 command-palette/ }) + const second = dialog.getByRole("option", { name: /README work 2 command-palette/ }) + await expect(first).toHaveAttribute("aria-selected", "true") + await expect(first).toBeInViewport() + await input.press("ArrowDown") + await expect(second).toHaveAttribute("aria-selected", "true") + await expect(second).toBeInViewport() + await input.press("ArrowUp") + await expect(first).toHaveAttribute("aria-selected", "true") + await input.press("Enter") + await expect(dialog).toHaveCount(0) + await expect(page.getByRole("heading", { name: paletteSession.title, exact: true })).toBeVisible() +}) + +test("keeps a keyboard-selected file when session results arrive later", async ({ page }) => { + const warnings = captureConsoleWarnings(page) + const { dialog, input } = await openCommandPalette(page) + const sessions = Promise.withResolvers() + await page.route("**/api/fs/find?*", (route) => + route.fulfill({ json: { data: [{ path: "README.txt", type: "file" }, { path: "README.md", type: "file" }] } }), + ) await page.route("**/api/session?*", async (route) => { await sessions.promise await route.fulfill({ @@ -102,6 +141,11 @@ test("keeps the automatically selected file when session results arrive later", }) await input.fill("README") const file = dialog.getByRole("option", { name: "/ README.md", exact: true }) + await expect(dialog.getByRole("option", { name: "/ README.txt", exact: true })).toHaveAttribute( + "aria-selected", + "true", + ) + await input.press("ArrowDown") await expect(file).toHaveAttribute("aria-selected", "true") sessions.resolve() await expect(dialog.getByRole("option", { name: /README work/ })).toBeVisible() diff --git a/packages/app/src/shell/commands/dialog.tsx b/packages/app/src/shell/commands/dialog.tsx index 70a9a3e0d76..203415586a6 100644 --- a/packages/app/src/shell/commands/dialog.tsx +++ b/packages/app/src/shell/commands/dialog.tsx @@ -5,7 +5,7 @@ import { Dialog, DialogBody } from "@opencode/ui/dialog" import { Icon } from "@opencode/ui/icon" import { Keybind } from "@opencode/ui/keybind" import { TextInput } from "@opencode/ui/text-input" -import { createEffect, createMemo, For, Match, Show, Switch } from "solid-js" +import { createEffect, createMemo, For, Match, onCleanup, Show, Switch } from "solid-js" import { createStore } from "solid-js/store" import { formatKeybindParts } from "@/shell/commands/command" import { useLanguage } from "@/runtime/i18n/language" @@ -70,13 +70,13 @@ export function CommandPaletteView(props: { }) { const language = useLanguage() const tabs = useTabs() - const [store, setStore] = createStore({ query: "", active: undefined as string | undefined }) + const [store, setStore] = createStore<{ query: string; active?: string }>({ query: "" }) const search = createCommandPaletteSearch({ query: () => store.query, items: props.items, sources: props.sources }) const visibleEntries = search.items const groupedEntries = createMemo(() => groups(visibleEntries())) - // Keep keyboard selection stable when another search source adds results. + // Follow the first result until the user explicitly chooses a row. const activeEntry = createMemo( () => visibleEntries().find((entry) => entry.id === store.active) ?? visibleEntries()[0], ) @@ -85,28 +85,31 @@ export function CommandPaletteView(props: { () => new Set(tabs.store.flatMap((tab) => (tab.type === "session" ? [`${tab.server}\0${tab.sessionId}`] : []))), ) - createEffect(() => { - // Pin automatic selection too: a later source can insert rows before it. - const id = activeEntry()?.id - - if (store.active !== id) setStore("active", id) - }) - createEffect(() => { props.highlight(activeEntry()) }) let resultsRef: HTMLDivElement | undefined + createEffect(() => { + // Incoming results can move an explicitly selected row below the viewport. + visibleEntries() + + if (!activeEntry()) return + + const frame = requestAnimationFrame(() => { + resultsRef?.querySelector("[data-active]")?.scrollIntoView({ block: "nearest" }) + }) + + onCleanup(() => cancelAnimationFrame(frame)) + }) + const move = (delta: -1 | 1) => { const count = visibleEntries().length if (count === 0) return const index = visibleEntries().findIndex((entry) => entry.id === activeEntry()?.id) setStore("active", visibleEntries()[(index + delta + count) % count].id) - requestAnimationFrame(() => { - resultsRef?.querySelector("[data-active]")?.scrollIntoView({ block: "nearest" }) - }) } const handleKeyDown = (event: KeyboardEvent) => {