Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
([#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
dismissed it instantly, discarding whatever it held: a half-filled sandbox
Expand Down
4 changes: 3 additions & 1 deletion DIVERGENCES.md

Large diffs are not rendered by default.

73 changes: 73 additions & 0 deletions packages/app/e2e/regression/prompt-at-menu-files.spec.ts
Original file line number Diff line number Diff line change
@@ -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)
})
3 changes: 2 additions & 1 deletion packages/app/e2e/utils/mock-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ export interface MockServerConfig {
fileContent?: (path: string) => unknown | Promise<unknown>
findFiles?: (input: { query: string; dirs?: string; limit?: number }) => unknown
sessionStatus?: unknown
agents?: unknown[]
}

export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
Expand All @@ -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,
}
Expand Down
77 changes: 77 additions & 0 deletions packages/app/test-browser/prompt-at-menu.test.ts
Original file line number Diff line number Diff line change
@@ -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<PromptInputV2Suggestion>({
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<typeof buildList>) => {
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()
})
})
})
23 changes: 15 additions & 8 deletions packages/core/src/filesystem/search.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 !== "/")
)
}),
})
}),
Expand Down
1 change: 1 addition & 0 deletions packages/session-ui/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -108,10 +108,15 @@ export function createPromptInputV2Controller(input: {
attachments.pick(() => fileInput?.click())
}
const contextList = useFilteredList<PromptInputV2Suggestion>({
// 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]
Expand Down
Loading