diff --git a/src/daemon/session-manager.ts b/src/daemon/session-manager.ts index 763dc7cd..0aacdd8f 100644 --- a/src/daemon/session-manager.ts +++ b/src/daemon/session-manager.ts @@ -4737,7 +4737,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 357acb35..1bb0bafe 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 }); + }); }); diff --git a/src/tui/App.tsx b/src/tui/App.tsx index 390b77d2..1ec9cd93 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 b1071610..3a5fadd1 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 3dcbd5e0..c3561cd2 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" },