feat: suggest files from every folder in a multi-root workspace - #13589
feat: suggest files from every folder in a multi-root workspace#13589sylwester-liljegren wants to merge 4 commits into
Conversation
Typing @ only searched workspaceFolders[0], so files in folders added through "Add Folder to Workspace..." were unreachable without the file picker. File search now fans out across every workspace folder. Entries outside the session's own project are returned as absolute paths, which keeps them mention-only: buildFileAttachments already refuses to attach paths outside the session directory, so the agent must Read them under the usual external_directory permission check. That is the same boundary "Browse files..." relies on, and it is unchanged. Fan-out is declined unless the session's directory is itself one of the workspace folders, so worktree and Agent Manager sessions stay scoped to their own tree. All roots are ranked in one pass rather than per root, because scoring each root separately and concatenating let fuzzy path noise in the first root outrank an exact filename match in another. Workspace folder order now only breaks ties between equally good matches. semantic_search still covers a single indexed root. Its description claimed it searched the entire current workspace, which is wrong once mentions span several folders, and it recommended Grep and Glob for outside-root work even though both are bounded to the same root. Empty results now also report whether the index was complete, still building, disabled, or failed, so a miss is no longer mistaken for absent code.
| const items = mergeFileSearchItems({ | ||
| query, | ||
| files: paths, | ||
| folders: multi ? folders.slice(0, MULTI_FOLDER_LIMIT) : folders, |
There was a problem hiding this comment.
[WARNING]: Multi-root folder cap is applied before ranking
MULTI_FOLDER_LIMIT slices folders before mergeFileSearchItems ranks them, while files correctly cap after ranking. fetchBackend already returns up to 50 folders per root, so in a typical repo the primary root fills the cap and every secondary folder is dropped.
On empty @, folders from added workspace folders will not appear — the same inversion this PR already fixed for files. Rank first, then slice.
Reply with @kilocode-bot fix it to have Kilo Code address this issue.
| const extras = split.secondary | ||
| const multi = extras.length > 0 | ||
|
|
||
| const [primary, secondary] = await Promise.all([ |
There was a problem hiding this comment.
[WARNING]: One failing extra root drops the whole @ search
Promise.all waits on every root and rejects if any gather throws. fetchBackend uses allSettled, but gather still await open(root) → getIgnoreController.initialize(), which is documented to let readFileSync permission errors propagate.
handleFileSearch has no try/catch and never posts a fileSearchResult on throw, so a single unreadable extra folder can leave the mention dropdown empty. Isolate per-root failures and still return primary hits.
Reply with @kilocode-bot fix it to have Kilo Code address this issue.
| const tabs = await open(root) | ||
| const active = activeIn(root) | ||
| if (!absolute) return { files: files.map(slash), folders: folders.map(slash), open: tabs, active } | ||
| const abs = (value: string) => slash(path.resolve(root, value)) |
There was a problem hiding this comment.
[SUGGESTION]: Ranking secondary hits on absolute paths skews fuzzy match quality
Secondary paths are absolutized before mergeFileSearchResults scores them. Basename scoring is fine, but path scoring and depth() run on the full filesystem prefix, so a query that matches Users, a username, or a parent directory path-matches every extra-root file while primary hits stay relative.
Rank on the per-root relative path, then convert secondary results to absolute for the mention/attachment boundary.
Reply with @kilocode-bot fix it to have Kilo Code address this issue.
| await controller.initialize() | ||
| return controller | ||
| })() | ||
| this.ignoreControllers.set(workspaceDir, pending) |
There was a problem hiding this comment.
[WARNING]: A rejected ignore-controller init is cached forever
The in-flight promise is stored before initialize() completes. The old one-entry cache only assigned after a successful init, so a failure was retried. A rejected entry now sticks until 16 other directories evict it — typical workspaces never reach that.
The next @ search for that root reuses the rejected promise, and combined with Promise.all in handleFileSearch that can disable mentions until reload. Delete the map entry on rejection if it is still this promise.
Reply with @kilocode-bot fix it to have Kilo Code address this issue.
Code Review SummaryStatus: No Issues Found | Recommendation: Merge Files Reviewed (9 files)
Previous Review Summaries (2 snapshots, latest commit 3661761)Current summary above is authoritative. Previous snapshots are kept for context only. Previous review (commit 3661761)Status: No Issues Found | Recommendation: Merge Files Reviewed (5 files)
Previous review (commit 29fc848)Status: 4 Issues Found | Recommendation: Address before merge Overview
Fix these issues in Kilo Cloud Issue Details (click to expand)WARNING
SUGGESTION
Files Reviewed (15 files)
Reviewed by grok-4.6 · Input: 95.5K · Output: 21.7K · Cached: 1.1M Review guidance: REVIEW.md from base branch |
Update semantic_search tests for the new output. The tool now names the searched root and explains an empty result in terms of index state, so three assertions in the existing suite were stale. Index status is stubbed there so the wording no longer depends on real indexing progress. Rank entries from added folders on their root-relative path. They are absolutised before insertion so the attachment boundary still rejects them, but scoring the absolute form let a query match the filesystem prefix — a username or a parent directory — on every file under that root, and inflated their depth in tiebreaks. Cap folders after ranking rather than before. The primary root can return more folders than the multi-root allowance on its own, so slicing the input handed it the whole budget and dropped every added folder before its entries competed. This is the same inversion already fixed for files. Isolate per-root failures. Extra folders are arbitrary user-chosen directories, and FileIgnoreController.initialize lets permission errors from reading .kilocodeignore propagate. One unreadable folder rejected the Promise.all and left the mention dropdown empty, since the handler never posted a result on throw. Evict a rejected ignore-controller init instead of caching it. The in-flight promise is stored before initialize resolves, so a failure stuck until 16 other directories displaced it; the old one-entry cache only stored a controller after a successful init.
|
All four review findings addressed in 3661761, plus the CI failure. Confirming each: Multi-root folder cap applied before ranking — correct, and it was the same inversion this PR had already fixed for files. The primary root can return up to 50 folders on its own, so it consumed the entire allowance and every added folder was dropped before its entries competed. Folders are now capped after One failing extra root drops the whole Ranking secondary hits on absolute paths skews fuzzy match quality — correct, and sharper than it first looks. A rejected ignore-controller init is cached forever — correct, and a regression I introduced: the old one-entry cache only stored a controller after a successful CI failure was separate and my miss: Local verification after the fixes: 270 extension unit tests across the 9 files covering every changed module, plus |
|
Unsure on the implications of this, all of this indexing has been historically tricky and hard to get right. |
|
@marius-kilocode My preliminary plan was to avoid touching on indexing parts, but my agent did push back and told me that if left unchanged, it would have caused confusion for Kilo Code when using the semantic_search tool in a multi-root workspace setting, hence the changes on those parts. What if I split this PR into two minor PRs: One PR that actually does make the mentions search work appropriately in a multi-root workspace setting and the other PR focuses on implementing appropriate indexing management in a multi-root workspace setting. Because you seem to be more worried about the indexing parts and not much about the mentions part, am I right? |
|
The problem is that you will need an index to keep @ fast or not? Right now that could be fine within the same vscode workspace as long as we don't have any performance/memory implications for users. |
|
@marius-kilocode Are you talking about indexing as in local codebase indexing / semantic_search or the index for searching files using "@"? |
|
I am talking about [index for searching files using "@"]. I don't mean semantic search. |
|
We build up a bunch of indexes that affect performance. The larger the scope of such an index get's the more likely this happens, especially since user repos tend to be very noisy on file level. |
|
Okay, now I get your point. I'll look into this later and see what options we have at our disposal to mitigate the risks of performance degradation. |
|
@marius-kilocode Could you look at #13592 first? If we can agree on a solution that is seemingly more performant over there, then we can take address this PR afterwards. |
The @ menu was reworked upstream: mention entries are centralised behind MentionEntry, and rankMentionResults now scores every offer — entries, past chats and files — against one query on a single scale. Resolved MentionResult by taking upstream's MentionEntry collapse and re-applying the owning-folder field to the file variants. That reranking reaches files this PR returns as absolute paths, and labels() fed it the whole path, so the filesystem prefix could match a username or a parent directory on every file under an added folder. This is the same skew already fixed host-side in mergeFileSearchResults, and the webview would have re-introduced it. FileSearchItem now carries the path relative to its owning folder, and labels() scores that instead. The absolute path is still what gets inserted, so the attachment boundary is unchanged. Equal scores keep the host's order because the sort is stable, so the session's own project still wins ties.
Each workspace folder searched costs its own file index and watcher in the backend, held for an hour, and repos tend to be noisy at file level. Fanning out on the empty search that fills the initial dropdown meant merely opening the menu paid for every folder, whether or not the user went on to search across them. A bare @ now searches the session's own project alone, exactly as before this feature, and the other folders are searched from the first typed character. Past chats already work this way, fetched on the first character rather than on every @. The number of added folders one query can search is also bounded, so an unusually large workspace cannot grow the backend's index count without limit. Badges follow the shape of the workspace rather than what a particular query happened to search, so rows do not sprout a badge on the first keystroke.
|
@marius-kilocode Merge conflicts resolved, and I've pushed two changes that address the index cost directly. A bare @ no longer touches the other folders. It searches the session's own project only — exactly as before this PR — and the other workspace folders are searched from the first typed character. This is the same rule the past-chats list already follows (useFileMention.ts:661-663: "Only a typed query can match a chat title, so the list is fetched on the first character rather than on every bare @"). Opening the menu and moving on now costs nothing extra. The number of folders one query can search is capped at four. Worth flagging explicitly: in a workspace with more than five folders, the extras beyond the cap aren't searched. That's a deliberate trade to bound the index count rather than an oversight — happy to change the number or the policy if you'd prefer a different one. On what you'd still see: the first search in each added folder pays for that folder's index, then it's fast, with the backend holding it for the 60-minute idle TTL. So this changes when the cost is paid and bounds how many you can pay for, but it doesn't make the per-folder cost disappear. I don't want to claim more than that. |
|
Hi @marius-kilocode , what is the status with this PR? Do you desire any further changes or perhaps you see some other blockers that need to be addressed? |
Issue
No existing issue — searched
Kilo-Org/kilocodefor "multi-root workspace mention" and "Add Folder to Workspace" and found nothing related. Reported directly by a user hitting it in a multi-root workspace. Happy to file one if maintainers prefer the issue-first flow.Context
In a multi-root VS Code workspace, typing
@only ever suggested files from the first workspace folder. Any folder added through Add Folder to Workspace… was invisible to file search, so the only way to reference a file in it was the "Browse files…" picker, one file at a time.The cause is a single-root assumption that runs the whole way down:
KiloProvider.getRootDirectory()returnsworkspaceFolders[0],handleFileSearchpasses that one directory tofind.files, and the CLI binds aLocationto it that hard-fails on anything outside. Open tabs and the active editor from other folders were dropped by the same relative-path filter.Implementation
File search now fans out across every workspace folder in parallel and merges the results. No backend change was needed:
find.filesalready accepts adirectory, which is the same mechanism Agent Manager worktrees use against one sharedkilo serve.The security boundary is deliberately unchanged. Results from folders other than the session's own project are returned as absolute paths.
buildFileAttachmentsalready refuses to attach any path outside the session directory, so those entries are mention-only and the agent mustReadthem under the normalexternal_directorypermission check. This is exactly the boundary "Browse files…" already relies on, and the guard itself is untouched — widening it would let attachment bypass the permission system, which the comment aboveisInsideWorkspaceexplicitly warns against. There is a regression test named for this feature so a future change cannot quietly loosen it.Fan-out is declined unless the session's directory is itself one of the workspace folders. A session routed to a git worktree or an Agent Manager project has a directory outside that list, and silently widening its search to unrelated projects would be wrong.
Ranking is the part worth reviewing closely. The first version ranked each root separately and concatenated, primary first. That looked reasonable but was wrong:
mergeFileSearchResults's strongest rule — a basename match beats a path-only match — never got to compare across roots, so an exactCLAUDE.mdin an added folder ranked below files whose names don't even contain acand only matched as a scattered subsequence of their full path. All roots are now ranked in one pass, with workspace folder order demoted to a tiebreak that applies only between equally good matches. Anti-flooding moved from ordering to a post-ranking cap, applied only in multi-root workspaces.Every entry is labelled with its workspace folder when the workspace has more than one, including the session's own project — labelling only the added folders left the rest looking like they belonged to nowhere. Single-folder workspaces get no badges and no caps; that path is byte-identical to before, which the existing suite covers.
getIgnoreControllerchanged from a one-entry cache to a bounded map. Multi-root search asks about several roots per keystroke, and the old cache would have re-read.kilocodeignorefrom disk on every alternating lookup.semantic_search
semantic_searchstill covers a single indexed root, and this PR does not change that. But it becomes actively misleading once mentions span several folders, so two things are fixed:GrepandGlobfor exploring outside the root — both are bounded to the same root and cannot reach outside it either, soReadis the only correct answer. That advice was already wrong before this PR.KiloIndexing.searchreturns[]when the index is disabled, still building, or broken, which was indistinguishable from a genuine miss. Empty results now state which, so a model does not read "no results" as "this code does not exist".Making the tool actually search other roots would require registering them as indexing projects, which means separate consent and a full embedding pass per folder. That is deliberately out of scope here.
Screenshots / Video
@in a multi-root workspace lists only the first folder's files; a file in an added folder cannot be found by nameHow to Test
Manual/local verification
Executed by the agent:
bun run typecheckinpackages/kilo-vscodeandpackages/opencode— passbun run lintandbun run knipinpackages/kilo-vscode— passbun testacross the 9 unit files covering every changed module (file-search,file-search-results,file-search-items,file-mention-utils,use-file-mention,file-ignore-controller,notebook-context,agent-manager-worktree-reference,agent-manager-arch) — 266 pass, 0 failbun run script/test-runner.tsforkilocode/tool/semantic-search-output.test.ts,kilocode/tool-registry-indexing.test.ts,kilocode/tool-registry-semantic-import-failure.test.ts— passbun run script/check-opencode-annotations.ts --worktree— "No shared upstream source files changed"; all CLI changes sit in the marker-exemptsrc/kilocode/pathNew tests added: exact filename match in an added folder outranks fuzzy matches in the session's project; identical filenames in two roots resolve to the session's project first; labels present in multi-root and absent in single-root; fan-out declined when the session directory is not a workspace folder; and a file from another workspace folder is not auto-attached.
Executed by a human (PR author), in the Extension Development Host:
@CLAUDE.mdmatch ranking last — both fixed and covered by tests aboveReviewer test steps
@— files from both folders should be listed, each with a workspace folder badgeReadwith anexternal_directoryapproval prompt rather than the file arriving pre-attached@still lists only that worktree's filesBlocked checks and substitute verification
bun turbo typecheckat the repo root (and therefore thepre-pushhook) could not complete:@kilocode/kilo-jetbrains#typecheckfails with "Cannot find a Java installation on your machine matching languageVersion=21" on this machine. This PR touches no Kotlin and no JetBrains files. Substitute verification was runningtypecheckdirectly in the two affected packages, both passing; the push used--no-verifyfor this reason only.bun run test:unitsuite inpackages/kilo-vscode(351 files) exceeded a 10-minute budget locally. An untouched CLI test file times out the same way under barebun teston this machine, so the slowness is environmental rather than introduced here. Substitute verification was running every unit file that references the changed modules, listed above.Checklist
Get in Touch