fix(app): keep the @ menu open while it searches (#59425)

This commit is contained in:
Kit Langton
2026-10-26 15:53:59 -07:00
committed by GitHub
Unverified
parent fa5b4bd618
commit e1eefcafc2
3 changed files with 82 additions and 8 deletions
@@ -500,6 +500,37 @@ story("grows suggestions while preserving visible timeline context", async ({ mo
await expect(suggestions).toHaveCSS("max-height", "304px")
})
story("keeps the @ menu and its last results open while the next search is pending", async ({ mount }) => {
const component = await mount("opencode-composer-flow--slow-file-search")
const input = component.getByRole("textbox", { name: "Prompt", exact: true })
const menu = component.locator('[data-component="composer-suggestions"]')
const row = (id: string) => menu.locator(`[data-suggestion-id="${id}"]`)
const pending = (query: string) => component.getByRole("button", { name: `Return files for ${query}`, exact: true })
await input.pressSequentially("@r")
await pending("r").click()
await expect(row("file:r/plan.md")).toBeVisible()
await expect(row("agent:review")).toHaveAttribute("data-active", "")
const height = (await menu.boundingBox())!.height
await input.pressSequentially("e")
await expect(pending("re")).toBeVisible()
await expect(row("file:r/plan.md")).toBeVisible()
expect((await menu.boundingBox())!.height).toBe(height)
await pending("re").click()
await expect(row("file:re/plan.md")).toBeVisible()
await expect(row("file:r/plan.md")).toHaveCount(0)
await expect(row("agent:review")).toHaveAttribute("data-active", "")
// Only a settled search with nothing to show empties the menu, and it stays open to say so.
await input.pressSequentially("z")
await expect(pending("rez")).toBeVisible()
await expect(row("file:re/plan.md")).toBeVisible()
await pending("rez").click()
await expect(menu.getByText("No matching items", { exact: true })).toBeVisible()
})
story("ignores a detached suggestion boundary", async ({ mount }) => {
const component = await mount("opencode-composer-flow--detached-command-suggestion-boundary")
await expect(component.locator('[data-component="composer-suggestions"]')).toHaveCSS("max-height", "304px")
+43 -1
View File
@@ -62,8 +62,13 @@ function ComposerStory(props: {
manySuggestions?: boolean
history?: boolean
manyHistory?: boolean
/** File search waits for the story's "Return files" button, like a slow server. */
fileSearch?: boolean
suggestionBoundary?: () => HTMLElement | undefined
}) {
const [fileSearch, setFileSearch] = createStore<{ pending?: string }>({})
let returnFiles: (() => void) | undefined
const [draft, setDraft] = createStore<ComposerPersistedState>({
prompt: props.prompt ?? [{ type: "text", content: "", start: 0, end: 0 }],
cursor: props.prompt ? promptLength(props.prompt) : 0,
@@ -150,7 +155,15 @@ function ComposerStory(props: {
store: [draft, setDraft],
commands: () => commands,
context: () => context,
searchContextFiles: () => [],
searchContextFiles: (query, signal) => {
if (!props.fileSearch) return []
return new Promise<ComposerSuggestion[]>((resolve) => {
returnFiles = () => resolve(query.includes("z") ? [] : storyFiles(query))
setFileSearch("pending", query)
signal?.addEventListener("abort", () => setFileSearch("pending", undefined), { once: true })
})
},
history: props.history
? {
entries: (mode) =>
@@ -316,6 +329,20 @@ function ComposerStory(props: {
{story.activity}
</output>
<Composer model={model} borderUnderlay suggestionBoundary={props.suggestionBoundary} />
<Show when={props.fileSearch}>
<button
type="button"
class="self-start text-12-regular text-text-weak"
disabled={!fileSearch.pending}
onMouseDown={(event) => event.preventDefault()}
onClick={() => {
setFileSearch("pending", undefined)
returnFiles?.()
}}
>
{fileSearch.pending === undefined ? "No file search pending" : `Return files for ${fileSearch.pending}`}
</button>
</Show>
</div>
)
}
@@ -324,6 +351,19 @@ const text = (content: string): ComposerPersistedState["prompt"] => [
{ type: "text", content, start: 0, end: content.length },
]
const storyFiles = (query: string): ComposerSuggestion[] =>
["plan.md", "notes.md", "capture.ts", "corpus.ts", "cursor.png", "claude.png"].map((name) => {
const path = `${query}/${name}`
return {
id: `file:${path}`,
kind: "file",
label: path,
path,
mention: { type: "file", path, content: `@${path}`, start: 0, end: 0 },
}
})
type FlowArgs = {
prompt?: ComposerPersistedState["prompt"]
working: boolean
@@ -437,6 +477,8 @@ export const SlashSuggestions = { args: { suggestions: "command" } }
export const ContextSuggestions = { args: { suggestions: "context" } }
export const SlowFileSearch = { render: () => <ComposerStory fileSearch /> }
function ConstrainedCommandSuggestionsStory() {
let boundary: HTMLDivElement | undefined
@@ -161,12 +161,13 @@ export function createComposerEditor(input: {
)
}
const [found, setFound] = createStore<{ query: string; files: ComposerSuggestion[] }>({ query: "", files: [] })
const [found, setFound] = createStore<{ files: ComposerSuggestion[] }>({ files: [] })
let fileQuery: string | undefined
let fileAbort = new AbortController()
onCleanup(() => abort(fileAbort))
// Debounced and abortable: each keystroke or close replaces the pending file search instead of queueing behind it.
// The last results stay listed until the next ones arrive, so the menu keeps its rows and height while typing.
const findFiles = (query?: string) => {
if (query === fileQuery) return
abort(fileAbort)
@@ -174,14 +175,16 @@ export function createComposerEditor(input: {
fileQuery = query
fileAbort = controller
if (query === undefined) return
if (query === undefined || !query.trim() || /\s/.test(query)) {
if (found.files.length) setFound("files", [])
if (!query.trim() || /\s/.test(query)) return
return
}
const timer = setTimeout(
() =>
void Promise.resolve(input.searchContextFiles(query, controller.signal)).then(
(files) => !controller.signal.aborted && setFound({ query, files }),
(files) => !controller.signal.aborted && setFound("files", files),
() => undefined,
),
150,
@@ -210,9 +213,7 @@ export function createComposerEditor(input: {
const seen = new Set(recent.map((item) => item.id))
const files = found.query === query ? found.files : []
return [...fixed, ...sessions, ...recent, ...files.filter((item) => !seen.has(item.id))]
return [...fixed, ...sessions, ...recent, ...found.files.filter((item) => !seen.has(item.id))]
},
key: (item) => item.id,
filterKeys: ["label"],