fix(app): follow the first palette result until explicit selection (#54275)

This commit is contained in:
Kit Langton
2026-10-10 21:23:34 -07:00
committed by GitHub
Unverified
parent 0d61729b3f
commit e31136bdbd
2 changed files with 62 additions and 15 deletions
@@ -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<void>()
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<void>()
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()
+16 -13
View File
@@ -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) => {