diff --git a/packages/app/component-tests/composer.spec.ts b/packages/app/component-tests/composer.spec.ts index 95dde796838..c37bd337d81 100644 --- a/packages/app/component-tests/composer.spec.ts +++ b/packages/app/component-tests/composer.spec.ts @@ -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") diff --git a/packages/app/src/composer/composer.stories.tsx b/packages/app/src/composer/composer.stories.tsx index ffb89cc6a30..d0428fe9277 100644 --- a/packages/app/src/composer/composer.stories.tsx +++ b/packages/app/src/composer/composer.stories.tsx @@ -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({ 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((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} + + + ) } @@ -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: () => } + function ConstrainedCommandSuggestionsStory() { let boundary: HTMLDivElement | undefined diff --git a/packages/app/src/composer/editor/interaction.ts b/packages/app/src/composer/editor/interaction.ts index ca2801525d8..9e416ff40e3 100644 --- a/packages/app/src/composer/editor/interaction.ts +++ b/packages/app/src/composer/editor/interaction.ts @@ -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"],