From 05c0bcbaf85b0b8b86c2d30929f11be670dfe1e6 Mon Sep 17 00:00:00 2001 From: minhn4 Date: Sat, 22 Aug 2026 16:32:32 +0700 Subject: [PATCH 1/2] fix(app): search for files on a bare @ in the composer menu MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #289 reordered the @ menu's groups so files lead, but the resolver short-circuited an empty query to the fixed entries plus open tabs and never searched — so with no tabs open the menu held no files to order, which is what users saw. Search on every query. Also drop fff's worktree-root entry, which an empty query now surfaces as a leading bare '/' that names no file; the ripgrep fallback never emitted it. Co-Authored-By: Claude Fable 5 --- CHANGELOG.md | 7 ++ DIVERGENCES.md | 4 +- .../regression/prompt-at-menu-files.spec.ts | 73 ++++++++++++++++++ packages/app/e2e/utils/mock-server.ts | 3 +- .../app/test-browser/prompt-at-menu.test.ts | 77 +++++++++++++++++++ packages/core/src/filesystem/search.ts | 23 ++++-- packages/session-ui/package.json | 1 + .../v2/components/prompt-input/interaction.ts | 7 +- 8 files changed, 184 insertions(+), 11 deletions(-) create mode 100644 packages/app/e2e/regression/prompt-at-menu-files.spec.ts create mode 100644 packages/app/test-browser/prompt-at-menu.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 409b0cb660..923a6bedc3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -284,6 +284,13 @@ already in use` and no way forward short of finding the file in a temporary ### Desktop / Web UI +- **Typing `@` lists your files again** — the `@` menu was meant to offer files + first, but on a bare `@` it showed only agents and references: it filled the + menu without ever searching, so unless you already had editor tabs open there + were no files in it to rank. It now searches straight away, so `@` opens on + your files with agents and references below them. + ([#297](https://github.com/quickbeard/codev-code/pull/297)) + - **Dialogs no longer close when you click beside them, and every one has a close button** — a stray click anywhere in the dimmed area around a dialog dismissed it instantly, discarding whatever it held: a half-filled sandbox diff --git a/DIVERGENCES.md b/DIVERGENCES.md index 2f81d62080..7bef921757 100644 --- a/DIVERGENCES.md +++ b/DIVERGENCES.md @@ -280,7 +280,7 @@ from the squash message first, or use a plain merge. | Sandbox project label fallback: `packages/app/src/context/global.tsx` | `enrich()` (the per-server project-list decorator) gains one fork line: when the merged project record has no `name` yet, it fills it from the fork-only `sandboxProjectHostName(conn, worktree, primary)` (`packages/app/src/sandbox/projects.ts`), which maps a sandbox connection's guest paths back to host folder basenames — `conn.projectDir` for the primary (matched by the synced primary directory, or the `/workspace` literal before sync loads) and `conn.mounts[].path` for extras. Non-sandbox connections and named projects are untouched (the helper returns `undefined` / the branch is skipped). | A fresh sandbox project renders `displayName()` = name-or-worktree-basename, and inside the guest the primary worktree is `/workspace`, so every new sandbox flashed the label "workspace" until `adoptFolderName`'s server-side rename round-tripped (guest reports the project → `project.update` → `project.updated` event). The fallback is display-only; the durable rename through the server is unchanged and takes precedence the moment it lands. | Keep the one `if (!base.name)` line after upstream's `base` construction and take upstream's surrounding `enrich` changes. If upstream renames or restructures `enrich`, re-apply the fallback wherever project records are decorated for the sidebar list. The helper stays in the fork-only sandbox module — do not inline it. | | Sandbox/WSL delete cleanup + confirmation: `packages/app/src/context/server.tsx`, `packages/app/src/components/dialog-select-server.tsx`, `packages/app/src/i18n/{en,es}.ts` | `server.tsx` gains `forget(key)` beside `remove(key)`: it deletes the persisted per-server `projects`/`lastProject`/`recentlyClosed` map entries for one key. `dialog-select-server.tsx` `handleRemove` (already diverged, see the renderer-slice row) additionally: for `sandbox:` and `wsl:` keys, awaits a confirmation through the fork-only `packages/app/src/sandbox/dialog-delete.tsx` / `packages/app/src/wsl/dialog-delete.tsx` (lazy-imported, pushed with `dialog.push` so it stacks on whatever dialog opened the menu) and returns without touching anything when declined; and calls `server.forget(key)` right after `server.remove(key)` for both key kinds. The controller gains a `useDialog()` binding for this. i18n gains the `sandbox.delete.*` and `wsl.delete.*` families. The two dialogs state different consequences on purpose: deleting a sandbox erases its disk (sessions, terminals, guest files — permanently; mounted host folders survive), while removing a WSL server destroys nothing — the app never unregisters distributions — it only forgets the app-side project state. | `remove()` only edits the stored HTTP server list; the project maps are keyed by server key and outlive the server, which is right for an HTTP server that still exists remotely but wrong for a deleted sandbox or removed distro — a recreated sandbox with the same name inherited the old projects (`server.projects["sandbox:"]`) and, via the leftover `recentlyClosed`, silently refused to seed folders closed in the previous sandbox's life; a re-added distro likewise inherited its previous project list. And deleting a sandbox erases guest data permanently, so it must not be a one-click menu action; the confirmations live in `handleRemove` because the settings rows and the generic server list all funnel through it. | Keep the confirm-and-forget block at the top of `handleRemove` and the `forget` calls between `server.remove(key)` and the awaited platform removals; take upstream's surrounding changes. `forget` is fork-only API — if upstream reworks the persisted `server` record shape, re-point the three deletes at the new maps. Do not gate `forget` behind the platform removal succeeding: the tab/workspace cleanup ahead of it already treats the server as gone. HTTP removals deliberately keep their maps — the server still exists remotely and re-adding it should restore its projects. If the app ever gains real distro unregistration, the WSL dialog's "nothing is deleted" wording must change with it. | | Vanished-stream retry + visibility + persisted retry parts: `packages/opencode/src/provider/error.ts`, `packages/opencode/src/session/{processor,retry,prompt,message-v2}.ts`, `packages/session-ui/src/components/message-part.tsx`, `packages/tui/src/routes/session/index.tsx`, `packages/ui/src/i18n/{en,es}.ts`, tests `packages/opencode/test/session/{retry,message-v2,processor-effect}.test.ts`, `packages/opencode/test/cli/run/run-process.test.ts` | error.ts: new `ProviderError.StreamVanishedError`. processor.ts: `ProcessorContext.produced` (set on `text-delta`/`tool-input-start`/`tool-call`, reset per attempt); after the stream drains, a turn with no output, no error, and finish `undefined`/`"unknown"` fails with `StreamVanishedError` so the existing `SessionRetry.policy` retries it (visible "retrying… attempt #N" status). retry.ts: `retryable()` gains an optional `attempt` param (policy passes `meta.attempt`); `STREAM_VANISHED_CODE` matches `metadata.code` and stops after `STREAM_VANISHED_MAX_RETRIES` (3) so a deterministic provider quirk cannot re-bill turns forever. message-v2.ts: maps `StreamVanishedError` → `APIError{isRetryable:true, metadata.code}`; new `transportInterruptCode()` walks the cause chain for `ECONNRESET`/`EPIPE`/`ETIMEDOUT`/`UND_ERR_SOCKET` so undici's mid-stream `TypeError("terminated")` (cause `SocketError "other side closed"`) becomes a retryable `APIError` instead of an unretryable `Unknown` (AbortedError when `ctx.aborted`). prompt.ts: the run-loop exit records a visible `APIError` ("the reply may be incomplete") on a message whose finish is `"unknown"` with no error, instead of going idle silently — mirrors upstream's own content-filter precedent. Persisted retry parts: the schema's dormant `RetryPart` (already in `@opencode-ai/schema/session-v1` and the generated SDKs, previously never written or rendered) is now written per retry attempt — `SessionRetry.policy`'s `set` callback gains the parsed `error`, and processor.ts persists `{type:"retry", attempt, error, time}` via `session.updatePart` before publishing the live retry status — and rendered by a new `PART_MAPPING["retry"]` component in session-ui's message-part.tsx (error-card style, message + "retried - attempt #N" line; new i18n key `ui.messagePart.retry.retried` en/es) and by a `PART_MAPPING["retry"]` entry in the TUI's routes/session/index.tsx (a muted one-liner aligned with the transcript — error-colored `Retried` label, `(attempt #N) · `, mirroring the footer's live `Retrying (attempt N) · message` wording; fork-only view test `packages/tui/test/retry-part.view.test.tsx`). Upstream's two `codev run` subprocess tests that pin the old contract ("unknown stream finish preserves partial output and exits 0" — renamed here to "…retries and preserves partial output" — and "--format json records partial output for an unknown stream finish") are updated to the retrying behaviour: each queues an explicit reply after `llm.fail(...)` so the retry consumes it instead of the test server's auto-"ok", stdout carries both turns, and the json test asserts the extra `step_start,text,step_finish` trio with the vanished step still finishing `"unknown"` and the retried one `"stop"`. What they lock in is unchanged: an SSE error item is not fatal — exit stays 0, pre-failure output survives, and the provider message never reaches stderr. | A provider (or corporate proxy) that drops the SSE mid-turn ended the run with no retry, no error card, no banner — the agent "just stopped" and only a user-typed "continue" resumed it. Empty turns are safely retried (nothing to duplicate); turns with output are not rerun but now say why they ended. | Keep our `produced` tracking, the post-drain fail, the classifier cap, the transport-code walk, the loop-exit error, and the two retry-part renderers (session-ui and TUI `PART_MAPPING["retry"]` entries); take upstream's surrounding changes. If upstream ships its own abnormal-finish handling, prefer theirs and drop ours where they overlap. Good upstream-PR candidate. The two `run-process.test.ts` expectations are ours only because the retry is: if upstream's version of that file conflicts, re-apply the queued-reply shape on top of their test body; if their behaviour is adopted instead, revert these two tests to upstream wholesale. | -| `packages/session-ui/src/v2/components/prompt-input/interaction.ts` | The `@` mention popup's `contextList` grouping delegates to the fork-only `at-options.ts` beside it (`atSuggestionCategory` + `compareAtCategories`, unit-tested in the fork-only `at-options.test.ts`): suggestion groups rank recent → file → reference → agent → resource instead of upstream's reference → agent → resource → recent → file, so file results lead in the V2 composer — the prompt the desktop and web apps render now that the legacy layout is retired. The grouped menu scrolls, so no row-reserve logic is needed (unlike the TUI/run-footer variants), and default selection stays index 0, so Enter accepts the top file match. | Files are the dominant `@` use case; the earlier files-first reorder (see the `@` autocomplete row above) covered the TUI, the legacy web prompt, and the `codev run` footer but missed the V2 composer. | Keep the two-line delegation to `at-options.ts`; take upstream's surrounding changes. If upstream restructures the suggestion list, re-apply the ranking wherever context suggestions are grouped. | +| `packages/session-ui/src/v2/components/prompt-input/interaction.ts` | The `@` mention popup's `contextList` grouping delegates to the fork-only `at-options.ts` beside it (`atSuggestionCategory` + `compareAtCategories`, unit-tested in the fork-only `at-options.test.ts`): suggestion groups rank recent → file → reference → agent → resource instead of upstream's reference → agent → resource → recent → file, so file results lead in the V2 composer — the prompt the desktop and web apps render now that the legacy layout is retired. The grouped menu scrolls, so no row-reserve logic is needed (unlike the TUI/run-footer variants), and default selection stays index 0, so Enter accepts the top file match. `contextList`'s resolver also searches on an empty query — it used to return the fixed entries plus open tabs without asking, so a bare `@` listed no files at all and the ordering above had nothing to order (the same empty-query gap the TUI and run-footer fixed in the `@` autocomplete row above). Covered by the fork-only `packages/app/test-browser/prompt-at-menu.test.ts` and the e2e `packages/app/e2e/regression/prompt-at-menu-files.spec.ts`. | Files are the dominant `@` use case; the earlier files-first reorder (see the `@` autocomplete row above) covered the TUI, the legacy web prompt, and the `codev run` footer but missed the V2 composer. | Keep the two-line delegation to `at-options.ts`; take upstream's surrounding changes. If upstream restructures the suggestion list, re-apply the ranking wherever context suggestions are grouped. | | Legacy prompt input removed: deleted `packages/app/src/components/prompt-input.tsx` and its now-orphaned submodules `packages/app/src/components/prompt-input/{attachments,files,paste,transient-state}.ts`, `{context-items,drag-overlay,image-attachments,slash-popover}.tsx`, `image-attachments.css`, `attachments.test.ts`, `packages/app/test-browser/{prompt-attachments,prompt-transient-state,prompt-at-options}.test.ts` (plus the fork-only `at-options{,.test}.ts`); edits in `packages/app/src/pages/session.tsx`, `packages/app/src/pages/session/composer/{session-composer-region-controller.ts,todo-panel-motion.stories.tsx}` | The fork hardcodes `newLayoutDesigns` to `true` (see the settings.tsx row), so the legacy `PromptInput` — the `fallback` of session.tsx's `` — could never render. session.tsx now creates the V2 controller unconditionally and always mounts `PromptInputV2Composer` (no `Show`); `session-composer-region-controller.ts` imports `PromptInputState` from `prompt-input/contracts` (where it is defined) instead of the deleted barrel; the todo-panel-motion story fills its composer-region `promptInput` slot with a static styled placeholder instead of the legacy component (its unused `controls` mock removed). Kept: `prompt-input/{contracts,history,history-store,placeholder,submit,editor-dom,build-request-parts,submission-state}.ts` and their tests — the V2 composer path still uses them. | Dead code: retired with the legacy layout, kept compiling for months, and PR #56-style fixes had to be applied to it for nothing. | Keep deleted — `git rm` the upstream side of modify/delete conflicts on these files, and delete any new module upstream adds under `prompt-input/` that only the legacy `PromptInput` imports. Take upstream changes to the kept shared modules. If upstream restructures session.tsx's composer block, re-apply "always render the V2 composer" and keep the legacy fallback out; if upstream rewires the story's input slot, keep our placeholder. If the fork ever wants the legacy layout back, restore from upstream, not from history. | | Work side is opt-in: `packages/app/src/context/settings.tsx`, `packages/app/src/components/settings-v2/general.tsx`, `packages/app/src/i18n/{en,es}.ts` | The Work side and the Code/Work tab strip render only when `workSideAvailable()` (fork-only `packages/app/src/workspace/side.ts`) says so: sandbox-capable platform AND (a sandbox exists OR the new `general.showWork` preference is on). settings.tsx gains `showWork` (default `false`) with its accessor/setter beside `showCustomAgents`; settings-v2's Advanced section gains a "Show Work" switch, wrapped in `` so non-sandbox builds never offer it; i18n gains `settings.general.row.showWork.{title,description}`. The fork-only wiring is in `workspace/tabs.tsx`: a `useWorkAvailable()` hook feeds both `useWorkspaceSide()`'s `effectiveSide` call and `WorkspaceTabs`'s `` (which previously gated on `platform.sandboxes`), so the side and its strip can never disagree — a hidden strip with Work selected would strand the user on sandbox servers with no way back. An undefined sandbox list means "not loaded", not "none", and keeps Work (the same race the Code/Work split row calls out); the main process answers `sandbox-get-state` synchronously, so that window is sub-frame. Tests: `packages/app/test-browser/workspace-side.test.ts` covers the four `workSideAvailable` branches. | Sandboxes are a heavyweight opt-in feature (a VM per sandbox), but every desktop profile paid for them in the sidebar: a Code/Work strip with a Work side that had nothing in it and offered to build one. Hiding both until a sandbox exists makes the strip appear exactly when there are two sides worth picking between; the preference is the discoverable way in for someone who wants Work before creating anything, and Settings → Servers → + → Add sandbox is the other. Owning a sandbox shows Work whatever the preference says. | Keep the `workSideAvailable` gate on both `useWorkspaceSide` and `WorkspaceTabs` — gating only one reintroduces the stranded-side bug. Take upstream's surrounding settings/i18n changes. If upstream adds its own sandbox-visibility preference, prefer theirs and drop `showWork`. Never collapse on an unloaded sandbox list. | @@ -290,6 +290,8 @@ from the squash message first, or use a plain merge. | Gateway key recovery: `packages/app/src/desktop-menu.ts`, `packages/app/src/i18n/{en,es}.ts`, `packages/desktop/src/preload/{index,types}.ts` | A "Get a new API Key..." item dispatching the fork-only `gateway.apiKey.new` command — in the macOS app menu under Settings, and in the Windows/Linux **File** menu (`platforms: ["windows"]`) under its Settings, the same split Settings itself uses since those builds render no app menu. i18n gains `command.gateway.apiKey.new`. Preload gains the `gateway-sso-ensure-key` channel on the existing `gatewaySso` namespace. The logic is fork-only: `packages/desktop/src/main/gateway-sso/controller.ts` gains `ensureKey()`, the desktop twin of codev-hub's `ensureFreshGatewayKey` — validate the stored key against the gateway's `/key/info` (`@codev/gateway`'s already-exported `validateApiKey`, previously with no production caller), bounded by a 2.5s race so a slow gateway cannot delay startup; a definite 401/403 tries `silentSso` + `fetchApiKey` and re-saves the record with every other field intact, returning the credential for the renderer to merge into the local server's auth registry (only the renderer writes to the server); anything else is left alone. `packages/app/src/components/gateway/gateway-configure-gate.tsx` (fork-only) registers the command behind its existing driver election and runs the pre-flight once per window after sync settles, opening `DialogConfigure` only on `needs-login`. `DialogConfigure` gains a `dismissable` prop driving both `setPersistent` and `hideClose`: the gate's copy stays trapped (the provider-setup screen the dialog rework deliberately exempts), while the two new entry points — the menu command and the pre-flight — are escapable, since a provider is configured in both cases and the dialog otherwise has no close button, no backdrop dismissal and no Escape. | Gateway keys expire on a schedule CoDev does not control, and nothing in the desktop app noticed: `DialogConfigure` had exactly one caller, the gate, which fires only when no provider is connected — and a dead key is still a connected provider, so every message 401'd with the sign-in flow unreachable. The silent re-mint mirrors what the hub already does for CLI launches, so the common case costs the user nothing. | Keep the menu item on both platforms (dropping the `platforms: ["windows"]` twin loses it entirely on Windows/Linux, which render no app menu) and keep the pre-flight's "only a definite rejection acts" rule — treating an unreachable gateway as a dead key would lock people out on a flaky network. Take upstream's surrounding menu/i18n/preload changes. | +| `packages/core/src/filesystem/search.ts`, `packages/app/e2e/utils/mock-server.ts` | `fffLayer`'s `find` drops the worktree-root entry: fff ranks the root itself into an empty-query result, where it leads every list as a bare `/` — a mention naming no file — while the ripgrep fallback never emits it. The e2e mock server gains an `agents` config field (defaulting to the previous single primary agent) so a spec can supply subagents; the `@` menu hides primary agents, so the default list left nothing to rank files against. | Empty-query `@` menus (see the `@` autocomplete rows) made the root visible for the first time, at the top of the list. Both search layers should answer the same way. | Keep the root filter and the `agents` default; take upstream's surrounding changes. | + ## Known rebrand gaps - **UI branding**: TUI banner, default-theme accent, desktop/web app names, favicon/PWA diff --git a/packages/app/e2e/regression/prompt-at-menu-files.spec.ts b/packages/app/e2e/regression/prompt-at-menu-files.spec.ts new file mode 100644 index 0000000000..302406d421 --- /dev/null +++ b/packages/app/e2e/regression/prompt-at-menu-files.spec.ts @@ -0,0 +1,73 @@ +import { expect, test } from "@playwright/test" +import { base64Encode } from "@opencode-ai/core/util/encode" +import { mockOpenCodeServer } from "../utils/mock-server" +import { expectAppVisible } from "../utils/waits" + +const directory = "C:/OpenCode/PromptAtMenu" +const projectID = "proj_prompt_at_menu" +const sessionID = "ses_prompt_at_menu" + +// A bare "@" used to list only agents and references: the menu resolved its +// empty query without ever searching for files, so anyone with no editor tabs +// open saw none — which is most of what "@" is for. +test("a bare @ lists files, before agents", async ({ page }) => { + const queries: string[] = [] + await mockOpenCodeServer(page, { + directory, + project: { + id: projectID, + worktree: directory, + vcs: "git", + name: "prompt-at-menu", + time: { created: 1700000000000, updated: 1700000000000 }, + sandboxes: [], + }, + provider: { all: [], connected: [], default: {} }, + // Subagents: the menu hides primary agents, so the default mock list would + // leave nothing to rank files against. + agents: [ + { name: "build", mode: "primary" }, + { name: "explore", mode: "subagent" }, + { name: "general", mode: "subagent" }, + ], + sessions: [ + { + id: sessionID, + slug: "prompt-at-menu", + projectID, + directory, + title: "Prompt at menu", + version: "dev", + time: { created: 1700000000000, updated: 1700000000000 }, + }, + ], + pageMessages: () => ({ items: [] }), + findFiles: ({ query }) => { + queries.push(query) + return ["src/index.ts", "package.json"] + }, + }) + await page.addInitScript(() => { + localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: true } })) + }) + + await page.goto(`/${base64Encode(directory)}/session/${sessionID}`) + const composer = page.locator('[data-component="prompt-input-v2"]') + const input = composer.locator('[data-component="prompt-input"]') + await expectAppVisible(composer) + + await input.click() + await page.keyboard.type("@") + + const options = page.locator("[data-suggestion-id]") + await expect(options.first()).toBeVisible() + // The empty query reached the search backend at all. + expect(queries).toContain("") + + const labels = await options.allInnerTexts() + const firstFile = labels.findIndex((label) => label.includes("src/index.ts")) + const firstAgent = labels.findIndex((label) => label.includes("explore") || label.includes("general")) + expect(firstFile).toBeGreaterThan(-1) + expect(firstAgent).toBeGreaterThan(-1) + expect(firstFile).toBeLessThan(firstAgent) +}) diff --git a/packages/app/e2e/utils/mock-server.ts b/packages/app/e2e/utils/mock-server.ts index 2bfba5871a..e8b2bf1270 100644 --- a/packages/app/e2e/utils/mock-server.ts +++ b/packages/app/e2e/utils/mock-server.ts @@ -24,6 +24,7 @@ export interface MockServerConfig { fileContent?: (path: string) => unknown | Promise findFiles?: (input: { query: string; dirs?: string; limit?: number }) => unknown sessionStatus?: unknown + agents?: unknown[] } export async function mockOpenCodeServer(page: Page, config: MockServerConfig) { @@ -40,7 +41,7 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) { }, "/project": [config.project], "/project/current": config.project, - "/agent": [{ name: "build", mode: "primary" }], + "/agent": config.agents ?? [{ name: "build", mode: "primary" }], "/vcs": { branch: "main", default_branch: "main" }, "/session": config.sessions, } diff --git a/packages/app/test-browser/prompt-at-menu.test.ts b/packages/app/test-browser/prompt-at-menu.test.ts new file mode 100644 index 0000000000..f2e40cbf0d --- /dev/null +++ b/packages/app/test-browser/prompt-at-menu.test.ts @@ -0,0 +1,77 @@ +import { describe, expect, test } from "bun:test" +import { createRoot } from "solid-js" +import { useFilteredList } from "@opencode-ai/ui/hooks" +import { atSuggestionCategory, compareAtCategories } from "@opencode-ai/session-ui/v2/prompt-input/at-options" +import type { PromptInputV2Suggestion } from "@opencode-ai/session-ui/v2/prompt-input/types" + +// The `@` menu's item resolver, mirroring createPromptInputV2Controller's +// contextList. Rebuilt here rather than driven through the controller, which +// needs an editor, a store and a dozen callbacks to stand up; what is worth +// pinning is which items a query produces and in what order. +const buildList = ( + context: PromptInputV2Suggestion[], + searchContextFiles: (query: string) => PromptInputV2Suggestion[], + queries: string[], +) => + useFilteredList({ + items: async (query) => { + const fixed = context.filter((item) => item.kind !== "file") + const recent = context.filter((item) => item.kind === "file" && item.recent) + const seen = new Set(recent.map((item) => item.id)) + queries.push(query) + const files = searchContextFiles(query).filter((item) => !seen.has(item.id)) + return [...fixed, ...recent, ...files] + }, + key: (item) => item.id, + filterKeys: ["label"], + skipFilter: (item) => item.kind === "file" && !item.recent, + groupBy: atSuggestionCategory, + sortGroupsBy: compareAtCategories, + }) + +const agents: PromptInputV2Suggestion[] = [ + { id: "agent:general", kind: "agent", label: "@general" }, + { id: "agent:explore", kind: "agent", label: "@explore" }, +] + +const found: PromptInputV2Suggestion[] = [ + { id: "file:src/index.ts", kind: "file", label: "src/index.ts", path: "src/index.ts" }, + { id: "file:package.json", kind: "file", label: "package.json", path: "package.json" }, +] + +const settle = async (list: ReturnType) => { + for (let i = 0; i < 100 && list.flat().length === 0; i++) { + await new Promise((resolve) => setTimeout(resolve, 10)) + } + return list.flat() +} + +describe("@ menu items", () => { + // A bare "@" with no open tabs listed only agents: the empty query returned + // the fixed entries without ever asking for files, which is most of what "@" + // is for. The search backend ranks its own top entries for an empty needle. + test("searches for files on a bare @", async () => { + await createRoot(async (dispose) => { + const queries: string[] = [] + const flat = await settle(buildList(agents, () => found, queries)) + + expect(queries).toContain("") + expect(flat.map((item) => item.label)).toEqual(["src/index.ts", "package.json", "@general", "@explore"]) + dispose() + }) + }) + + // Open tabs still lead, and the search must not list one a second time. + test("keeps open tabs first and does not duplicate them", async () => { + await createRoot(async (dispose) => { + const withRecent: PromptInputV2Suggestion[] = [ + ...agents, + { id: "file:src/index.ts", kind: "file", label: "src/index.ts", path: "src/index.ts", recent: true }, + ] + const flat = await settle(buildList(withRecent, () => found, [])) + + expect(flat.map((item) => item.label)).toEqual(["src/index.ts", "package.json", "@general", "@explore"]) + dispose() + }) + }) +}) diff --git a/packages/core/src/filesystem/search.ts b/packages/core/src/filesystem/search.ts index 7958ac7072..f186835975 100644 --- a/packages/core/src/filesystem/search.ts +++ b/packages/core/src/filesystem/search.ts @@ -238,15 +238,22 @@ export const fffLayer = Layer.effect( score: found.value.scores[index]?.total ?? 0, })) })() - return items - .sort((a, b) => b.score - a.score || a.path.length - b.path.length) - .map((item) => { - const relative = item.path.replaceAll("\\", "/").replace(/\/$/, "") - return FileSystem.Entry.make({ - path: RelativePath.make(relative + (item.type === "directory" ? "/" : "")), - type: item.type, + return ( + items + .sort((a, b) => b.score - a.score || a.path.length - b.path.length) + .map((item) => { + const relative = item.path.replaceAll("\\", "/").replace(/\/$/, "") + return FileSystem.Entry.make({ + path: RelativePath.make(relative + (item.type === "directory" ? "/" : "")), + type: item.type, + }) }) - }) + // fff ranks the worktree root itself into an empty-query result, + // where it leads every list as a bare "/" — a mention that names no + // file. The ripgrep fallback never emits it, so drop it here and + // let both layers answer the same way. + .filter((entry) => entry.path !== "/") + ) }), }) }), diff --git a/packages/session-ui/package.json b/packages/session-ui/package.json index 6d092aa658..6f3b226478 100644 --- a/packages/session-ui/package.json +++ b/packages/session-ui/package.json @@ -20,6 +20,7 @@ "./v2/*.css": "./src/v2/components/*.css", "./v2/*": "./src/v2/components/*.tsx", "./v2/prompt-input": "./src/v2/components/prompt-input/index.tsx", + "./v2/prompt-input/at-options": "./src/v2/components/prompt-input/at-options.ts", "./v2/prompt-input/interaction": "./src/v2/components/prompt-input/interaction.ts", "./v2/prompt-input/store": "./src/v2/components/prompt-input/store.ts", "./v2/prompt-input/types": "./src/v2/components/prompt-input/types.ts" diff --git a/packages/session-ui/src/v2/components/prompt-input/interaction.ts b/packages/session-ui/src/v2/components/prompt-input/interaction.ts index 2be48a459f..5536ebaf9f 100644 --- a/packages/session-ui/src/v2/components/prompt-input/interaction.ts +++ b/packages/session-ui/src/v2/components/prompt-input/interaction.ts @@ -108,10 +108,15 @@ export function createPromptInputV2Controller(input: { attachments.pick(() => fileInput?.click()) } const contextList = useFilteredList({ + // A bare "@" searches too. The empty query used to short-circuit to the + // fixed entries plus open tabs, so someone with no tabs open saw only + // agents and references — no files at all, which is most of what "@" is + // for. The search backend ranks its own top entries for an empty needle + // (the same behaviour the TUI menu relies on), so asking costs one call + // and fills the menu. items: async (query) => { const fixed = input.context().filter((item) => item.kind !== "file") const recent = input.context().filter((item) => item.kind === "file" && item.recent) - if (!query.trim()) return [...fixed, ...recent] const seen = new Set(recent.map((item) => item.id)) const files = (await input.searchContextFiles(query)).filter((item) => !seen.has(item.id)) return [...fixed, ...recent, ...files] From 9cfb016f313b7f31f7d5c1798c641782f6cf8096 Mon Sep 17 00:00:00 2001 From: minhn4 Date: Sat, 22 Aug 2026 16:33:08 +0700 Subject: [PATCH 2/2] docs: correct the PR link for the @ menu fix Co-Authored-By: Claude Fable 5 --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 923a6bedc3..94df0cd7e7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -289,7 +289,7 @@ already in use` and no way forward short of finding the file in a temporary menu without ever searching, so unless you already had editor tabs open there were no files in it to rank. It now searches straight away, so `@` opens on your files with agents and references below them. - ([#297](https://github.com/quickbeard/codev-code/pull/297)) + ([#298](https://github.com/quickbeard/codev-code/pull/298)) - **Dialogs no longer close when you click beside them, and every one has a close button** — a stray click anywhere in the dimmed area around a dialog