mirror of
https://github.com/anomalyco/opencode.git
synced 2026-11-14 03:56:55 +03:00
fix(app): keep the @ menu open while it searches (#59425)
This commit is contained in:
committed by
GitHub
Unverified
parent
fa5b4bd618
commit
e1eefcafc2
@@ -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")
|
||||
|
||||
@@ -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"],
|
||||
|
||||
Reference in New Issue
Block a user