From b2d7116d3cb4e626d9556fe389786a4f14a4981e Mon Sep 17 00:00:00 2001 From: Yash Datta Date: Sat, 29 Aug 2026 21:43:26 +0800 Subject: [PATCH 1/2] fix: session.search falls back to cross-workspace when no anchor resolves MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The protocol documents this fallback on `session.search.workdir`: when `scope="workspace"` and no workdir is supplied, the daemon infers one from the requesting client's focus — and if the client has no focus either, "it falls back to cross-workspace". It did not. `#search` initialises `workspaceId` to `""` and only overwrites it when an anchor path resolves, then forwards it unless the scope is `"all"`. The memory engine decides global-vs-scoped with const global = opts.workspaceId === undefined; so `""` is not the fallback — it is a scoped search against a workspace id that belongs to no workspace, which matches nothing. The documented cross-workspace fallback silently returned zero hits instead. This is the same trap the adjacent comment already describes for `scope:"all"`, which was fixed by omitting the key; the no-anchor case needed the same treatment and did not get it. The condition now omits `workspaceId` whenever it is empty, so both paths reach the engine as `undefined`. Reachable today from the TUI, whose search modal passes `scope:"workspace"` with the focused session's workdir: with no session focused, the workdir is undefined, and if the daemon cannot guess one from the caller either, the search came back empty rather than searching everywhere. The regression test drives SessionManager with no sessions registered, so `#guessCallerWorkdir` has nothing to anchor on, and asserts the key is absent from the engine call. It fails against the previous condition. Co-authored-by: Claude Opus 5 (1M context) --- src/daemon/session-manager.ts | 7 ++++- src/tests/search-sessions.test.ts | 46 +++++++++++++++++++++++++++++++ 2 files changed, 52 insertions(+), 1 deletion(-) diff --git a/src/daemon/session-manager.ts b/src/daemon/session-manager.ts index a016567..4ec1469 100644 --- a/src/daemon/session-manager.ts +++ b/src/daemon/session-manager.ts @@ -4730,7 +4730,12 @@ mcpHub: this.#mcpHub, // Global scope = OMIT workspaceId (the engine treats undefined as // "every workspace"); passing "" selected a nonexistent empty // workspace and scope:"all" always returned zero hits. - ...(scope === "all" ? {} : { workspaceId }), + // + // The same trap applies when scope="workspace" but no anchor could be + // resolved (no msg.workdir and no caller focus). The protocol says that + // case "falls back to cross-workspace", so omit the key instead of + // passing "" — which matched nothing and silently returned zero hits. + ...(scope === "all" || !workspaceId ? {} : { workspaceId }), limit, sessionNames, }); diff --git a/src/tests/search-sessions.test.ts b/src/tests/search-sessions.test.ts index 357acb3..1bb0baf 100644 --- a/src/tests/search-sessions.test.ts +++ b/src/tests/search-sessions.test.ts @@ -338,4 +338,50 @@ describe("SessionManager session.search scope plumbing", () => { store.close(); rmSync(dir, { recursive: true, force: true }); }); + + it('scope:"workspace" with no resolvable anchor falls back to cross-workspace', async () => { + // The protocol documents this fallback: when scope="workspace" and no + // workdir is supplied, the daemon infers from caller focus — and with no + // focus either, searches every workspace. Previously it passed + // workspaceId:"" which matched a workspace that does not exist, so the + // search silently returned zero hits instead of falling back. + const { SessionManager } = await import("../daemon/session-manager.js"); + const { Store } = await import("../daemon/store.js"); + const { TranscriptStore } = await import("../daemon/transcript.js"); + + const dir = mkdtempSync(join(tmpdir(), "codeoid-search-fallback-")); + const store = new Store(join(dir, "db.sqlite")); + const transcript = new TranscriptStore(join(dir, "transcripts")); + + const calls: Array> = []; + const fakeMemory = { + searchSessions: async (opts: Record) => { + calls.push(opts); + return []; + }, + } as unknown as MemoryEngine; + + const manager = new SessionManager(store, transcript, undefined, undefined, fakeMemory); + const auth = { + sub: "user:t", + scopes: ["session:list"], + delegationDepth: 0, + accountId: "acc", + projectId: "proj", + }; + // No sessions exist, so #guessCallerWorkdir has nothing to anchor on. + const fakeClient = { id: "c1", auth, send: () => {} }; + + await manager.handle( + { type: "session.search", id: "r1", query: "auth bug", scope: "workspace" } as never, + auth as never, + fakeClient as never, + ); + + expect(calls.length).toBe(1); + expect("workspaceId" in calls[0]!).toBe(false); + + store.close(); + rmSync(dir, { recursive: true, force: true }); + }); }); From ca27046c16fb74c7b9c77a5c10a8d1393ccad4c4 Mon Sep 17 00:00:00 2001 From: Yash Datta Date: Sat, 29 Aug 2026 21:43:39 +0800 Subject: [PATCH 2/2] feat: toggle search scope with Tab in the TUI search modal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cross-workspace session resolution — global fusion plus a cross-encoder rerank, 73% precision@1 on the labelled fixture — had no user-facing entry point. The engine shipped in #51 and the wire protocol carries `scope`, but every human surface hard-coded `scope:"workspace"`: // src/tui/App.tsx const resp = await client.search(q, workdir, 10, "workspace"); `ws.ts` already declared the parameter as `"workspace" | "all"` and there was a test asserting the daemon honours `"all"`, but nothing ever sent it. The only caller that reached the global path was `fleet_find`, the conductor's agent tool — so the capability was reachable by an agent and not by the person at the terminal. Tab now toggles the modal between the two regimes and re-runs the current query, which also makes the two directly comparable on the same input. The header states the active scope, the empty-state hint explains what each covers, and the footer advertises the key. Cross-workspace hits carry the originating repo. A bare session name is ambiguous once results span workspaces — two repos can each have a "fix auth" session — so rows show the workdir basename when, and only when, the scope is global. `SessionSearchHit.workdir` is already populated by the daemon's enrichment step, so this needs no protocol change. The default is unchanged for anyone with a session focused: workspace scope, as before. With nothing focused there is no workspace to anchor to, so the modal opens directly in cross-workspace rather than starting in a scope that has nothing to search — the daemon-side fallback in the previous commit makes that honest rather than empty. Whether global should become the default even with a session focused is a separate call, and deliberately not made here. Co-authored-by: Claude Opus 5 (1M context) --- src/tui/App.tsx | 14 ++++++--- src/tui/components/Modal.tsx | 52 ++++++++++++++++++++++++++------ src/tui/components/SlashHint.tsx | 2 +- 3 files changed, 53 insertions(+), 15 deletions(-) diff --git a/src/tui/App.tsx b/src/tui/App.tsx index 390b77d..1ec9cd9 100644 --- a/src/tui/App.tsx +++ b/src/tui/App.tsx @@ -883,14 +883,18 @@ export function App({ config }: Props) { /** * Search handler passed into the modal. Returns hits directly so the * modal stays in control of its async state (loading, error, stale). - * Uses the focused session's workdir to anchor the workspace scope; - * falls back to cross-workspace when nothing is focused. + * `scope` comes from the modal (Tab toggles it): "workspace" anchors on + * the focused session's workdir, "all" runs cross-workspace resolution. + * The anchor is omitted for "all" so the daemon searches every workspace. */ - const onSearch = async (q: string): Promise => { + const onSearch = async ( + q: string, + scope: import("./components/Modal.js").SearchScope, + ): Promise => { const client = wsRef.current; if (!client) return []; - const workdir = focusedSession?.info.workdir; - const resp = await client.search(q, workdir, 10, "workspace"); + const workdir = scope === "all" ? undefined : focusedSession?.info.workdir; + const resp = await client.search(q, workdir, 10, scope); if (resp.type === "session.search.result") { return resp.sessions; } diff --git a/src/tui/components/Modal.tsx b/src/tui/components/Modal.tsx index b107161..3a5fadd 100644 --- a/src/tui/components/Modal.tsx +++ b/src/tui/components/Modal.tsx @@ -11,6 +11,13 @@ import type { ModalState, TuiSession } from "../types.js"; import type { SessionSearchHit } from "../../protocol/types.js"; import { MODEL_CATALOG, findModel, type ModelDescriptor } from "../../daemon/models.js"; +/** + * Which sessions a search covers. `workspace` scopes to the focused session's + * repo; `all` runs the cross-workspace resolution path (global fusion + + * cross-encoder rerank) — see docs/session-resolution.md. + */ +export type SearchScope = "workspace" | "all"; + interface Props { modal: ModalState; sessions: TuiSession[]; @@ -20,7 +27,7 @@ interface Props { onConfirmDestroy: (sessionId: string) => void; onCancel: () => void; /** Run a full-text + semantic session search. Resolves to result hits. */ - onSearch?: (query: string) => Promise; + onSearch?: (query: string, scope: SearchScope) => Promise; /** Set the focused session's model. */ onSetModel?: (model: string) => Promise; } @@ -57,6 +64,9 @@ export function Modal(props: Props) { return ( Promise; + initialScope: SearchScope; + onSearch?: (q: string, scope: SearchScope) => Promise; onSelect: (id: string) => void; onCancel: () => void; }) { const [q, setQ] = useState(query); + const [scope, setScope] = useState(initialScope); const [hits, setHits] = useState([]); const [isSearching, setIsSearching] = useState(false); const [error, setError] = useState(null); @@ -254,7 +267,8 @@ function SearchModal({ // Debounce — hit the daemon after the user stops typing for 200 ms. // Any typing in between cancels prior in-flight requests via a ref - // guard so old results don't race ahead of new ones. + // guard so old results don't race ahead of new ones. Toggling scope + // re-runs the same query through the other regime. const requestSeq = useRef(0); useEffect(() => { if (!onSearch) return; @@ -270,7 +284,7 @@ function SearchModal({ setIsSearching(true); setError(null); try { - const results = await onSearch(trimmed); + const results = await onSearch(trimmed, scope); if (seq !== requestSeq.current) return; // stale response setHits(results); setIdx(0); @@ -283,10 +297,11 @@ function SearchModal({ } }, 200); return () => clearTimeout(timer); - }, [q, onSearch]); + }, [q, scope, onSearch]); useInput((_input, key) => { if (key.escape) onCancel(); + if (key.tab) setScope((s) => (s === "workspace" ? "all" : "workspace")); if (key.upArrow) setIdx((i) => Math.max(0, i - 1)); if (key.downArrow) setIdx((i) => Math.min(Math.max(0, hits.length - 1), i + 1)); if (key.return && hits[idx]) onSelect(hits[idx]!.sessionId); @@ -301,6 +316,10 @@ function SearchModal({ > 🔍 Search across sessions + {" · scope: "} + + {scope === "all" ? "all workspaces" : "this workspace"} + {"query: "} @@ -316,9 +335,11 @@ function SearchModal({ {q.trim().length === 0 ? ( - Hybrid search (FTS5 keywords + vector + recency). Searches every - message, tool call, and assistant reply across all sessions in - this workspace. + Hybrid search (FTS5 keywords + vector + recency) over every + message, tool call, and assistant reply.{" "} + {scope === "all" + ? "Searching sessions in every workspace, ranked as one batch and reranked by a cross-encoder." + : "Searching sessions in this workspace only — press Tab to search every workspace."} ) : isSearching && hits.length === 0 ? ( @@ -336,6 +357,7 @@ function SearchModal({ key={hit.sessionId} hit={hit} selected={i === idx} + showWorkspace={scope === "all"} /> ))} @@ -343,7 +365,7 @@ function SearchModal({ - ↑↓ navigate · Enter to focus session · Esc to close + ↑↓ navigate · Enter to focus session · Tab to switch scope · Esc to close @@ -353,18 +375,23 @@ function SearchModal({ function SearchHitRow({ hit, selected, + showWorkspace, }: { hit: SessionSearchHit; selected: boolean; + /** Cross-workspace results are ambiguous without the originating repo. */ + showWorkspace?: boolean; }) { const bar = selected ? "▸ " : " "; const when = formatAgo(hit.lastMatchAt); const top = hit.snippets[0]; + const where = showWorkspace ? basename(hit.workdir) : ""; return ( {bar} {hit.sessionName} + {where && {` [${where}]`}} {` — ${hit.matchCount} match${hit.matchCount === 1 ? "" : "es"} · ${when}`} @@ -386,6 +413,13 @@ function SearchHitRow({ ); } +/** Last path segment of a workdir, for labelling cross-workspace hits. */ +function basename(p: string): string { + const trimmed = p.replace(/[/\\]+$/, ""); + const cut = Math.max(trimmed.lastIndexOf("/"), trimmed.lastIndexOf("\\")); + return cut === -1 ? trimmed : trimmed.slice(cut + 1); +} + function formatAgo(when: number): string { const dt = Math.max(0, Date.now() - when); if (dt < 60_000) return "just now"; diff --git a/src/tui/components/SlashHint.tsx b/src/tui/components/SlashHint.tsx index 3dcbd5e..c3561cd 100644 --- a/src/tui/components/SlashHint.tsx +++ b/src/tui/components/SlashHint.tsx @@ -25,7 +25,7 @@ export const SLASH_COMMANDS: SlashCommand[] = [ { name: "/unpin", description: "Unpin a previously-pinned file" }, { name: "/context", description: "Show detailed context-budget breakdown — or attach files if paths given" }, { name: "/rotate", description: "Rotate the Claude Code backing session (fresh context, memory preserved)" }, - { name: "/search", description: "Search all sessions (Ctrl-F) — keywords + semantic + recency across every message" }, + { name: "/search", description: "Search sessions (Ctrl-F) — keywords + semantic + recency; Tab toggles this workspace ↔ all" }, { name: "/model", description: "Switch the Claude model for this session (interactive picker, or /model )" }, { name: "/who", description: "Show the identity chain (user → agent → sub-agents)" }, { name: "/help", description: "Show keybindings" },