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: 6 additions & 1 deletion src/daemon/session-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
});
Expand Down
46 changes: 46 additions & 0 deletions src/tests/search-sessions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Record<string, unknown>> = [];
const fakeMemory = {
searchSessions: async (opts: Record<string, unknown>) => {
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 });
});
});
14 changes: 9 additions & 5 deletions src/tui/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<import("../protocol/types.js").SessionSearchHit[]> => {
const onSearch = async (
q: string,
scope: import("./components/Modal.js").SearchScope,
): Promise<import("../protocol/types.js").SessionSearchHit[]> => {
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;
}
Expand Down
52 changes: 43 additions & 9 deletions src/tui/components/Modal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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[];
Expand All @@ -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<SessionSearchHit[]>;
onSearch?: (query: string, scope: SearchScope) => Promise<SessionSearchHit[]>;
/** Set the focused session's model. */
onSetModel?: (model: string) => Promise<void>;
}
Expand Down Expand Up @@ -57,6 +64,9 @@ export function Modal(props: Props) {
return (
<SearchModal
query={props.modal.query}
// With no focused session there is no workspace to scope to, so
// open straight into cross-workspace rather than a dead scope.
initialScope={props.focusedSession ? "workspace" : "all"}
onSearch={props.onSearch}
onSelect={props.onSelectSession}
onCancel={props.onCancel}
Expand Down Expand Up @@ -237,24 +247,28 @@ function ConfirmDestroyModal({

function SearchModal({
query,
initialScope,
onSearch,
onSelect,
onCancel,
}: {
query: string;
onSearch?: (q: string) => Promise<SessionSearchHit[]>;
initialScope: SearchScope;
onSearch?: (q: string, scope: SearchScope) => Promise<SessionSearchHit[]>;
onSelect: (id: string) => void;
onCancel: () => void;
}) {
const [q, setQ] = useState(query);
const [scope, setScope] = useState<SearchScope>(initialScope);
const [hits, setHits] = useState<SessionSearchHit[]>([]);
const [isSearching, setIsSearching] = useState(false);
const [error, setError] = useState<string | null>(null);
const [idx, setIdx] = useState(0);

// 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;
Expand All @@ -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);
Expand All @@ -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);
Expand All @@ -301,6 +316,10 @@ function SearchModal({
>
<Text bold color="cyan">
🔍 Search across sessions
<Text dimColor>{" · scope: "}</Text>
<Text color={scope === "all" ? "green" : "yellow"}>
{scope === "all" ? "all workspaces" : "this workspace"}
</Text>
</Text>
<Box marginTop={1}>
<Text>{"query: "}</Text>
Expand All @@ -316,9 +335,11 @@ function SearchModal({
{q.trim().length === 0 ? (
<Box marginTop={1}>
<Text dimColor>
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."}
</Text>
</Box>
) : isSearching && hits.length === 0 ? (
Expand All @@ -336,14 +357,15 @@ function SearchModal({
key={hit.sessionId}
hit={hit}
selected={i === idx}
showWorkspace={scope === "all"}
/>
))}
</Box>
)}

<Box marginTop={1}>
<Text dimColor>
↑↓ navigate · Enter to focus session · Esc to close
↑↓ navigate · Enter to focus session · Tab to switch scope · Esc to close
</Text>
</Box>
</Box>
Expand All @@ -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 (
<Box flexDirection="column" marginBottom={0}>
<Text color={selected ? "cyan" : "white"} bold={selected}>
{bar}
{hit.sessionName}
{where && <Text dimColor>{` [${where}]`}</Text>}
<Text dimColor>
{` — ${hit.matchCount} match${hit.matchCount === 1 ? "" : "es"} · ${when}`}
</Text>
Expand All @@ -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";
Expand Down
2 changes: 1 addition & 1 deletion src/tui/components/SlashHint.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 <alias>)" },
{ name: "/who", description: "Show the identity chain (user → agent → sub-agents)" },
{ name: "/help", description: "Show keybindings" },
Expand Down
Loading