From 923e6e90968af222b8b3145de467398bedc2dca3 Mon Sep 17 00:00:00 2001 From: Yash Datta Date: Mon, 31 Aug 2026 23:44:40 +0800 Subject: [PATCH] feat: let web search widen past the focused session's workspace Third and last surface. #309 gave the TUI a Tab toggle and #311 gave Telegram a button; the web UI still had no way to ask for a cross-workspace search while a session was focused. Its anchoring was already right -- it passed the focused session's workdir, which is what Telegram had to be fixed to do -- but the scope was derived and never overridable: ...(focusedSession()?.workdir ? { workdir: focusedSession()!.workdir, scope: "workspace" } : { scope: "all" }) So global search was reachable only by accident, when nothing happened to be focused. With a session open you searched one directory, and the 73% precision@1 path the feature exists for was unreachable. A segmented control in the modal header now switches This workspace / All workspaces. It is an override on top of the derived default rather than a replacement for it, so focusing a session later does not silently undo a choice the user made. The control is hidden entirely when nothing is focused, because there is no workspace to scope to and a toggle with one meaningful position is noise. The debounce effect now depends on scope as well as query, so flipping re-runs the current query instead of leaving stale hits from the other regime on screen. Zero hits under workspace scope also offers a widen button inline -- the same reasoning as the Telegram keyboard, since an empty scoped result is exactly when widening helps. Ctrl+K is taken for open/close, so this is a click target rather than a key. Results already render hit.workdir, so global hits were legible without further work. Worth recording, since it is easy to misread: a workspace is a DIRECTORY, not a session family. workspaceIdFromPath hashes workdir + account_id + project_id, so every session that ever ran in that directory under the same tenant shares the workspace, related or not -- and a fork isolated into a git worktree gets a different path, so it lands OUTSIDE its parent's scope. That is precisely the history cross-workspace search recovers. Co-authored-by: Claude Opus 5 (1M context) --- web/src/components/SearchModal.test.tsx | 89 ++++++++++++++++++++++- web/src/components/SearchModal.tsx | 95 +++++++++++++++++++++++-- 2 files changed, 176 insertions(+), 8 deletions(-) diff --git a/web/src/components/SearchModal.test.tsx b/web/src/components/SearchModal.test.tsx index f46590c..8cd66c1 100644 --- a/web/src/components/SearchModal.test.tsx +++ b/web/src/components/SearchModal.test.tsx @@ -11,7 +11,11 @@ vi.mock("../state/connection", () => ({ })); import SearchModal from "./SearchModal"; -import { _resetSessionsForTest } from "../state/sessions"; +import { + _resetSessionsForTest, + focusSession, + ingestSessionList, +} from "../state/sessions"; beforeEach(() => { vi.useFakeTimers(); @@ -85,3 +89,86 @@ describe("SearchModal stale in-flight results", () => { expect(queryByText(/search exploded/)).toBeNull(); }); }); + +describe("SearchModal scope toggle", () => { + /** Seed one focused session so there is a workspace worth scoping to. */ + function seedFocused(workdir = "/repos/alpha") { + ingestSessionList([ + { + id: "sess-a", + name: "alpha", + workdir, + status: "idle", + createdBy: "user:t", + createdAt: new Date().toISOString(), + } as never, + ]); + focusSession("sess-a"); + } + + function lastSearch(): Record { + return requestMock.mock.calls.at(-1)![0] as Record; + } + + it("scopes to the focused session's workdir by default", async () => { + seedFocused(); + const { input } = openModal(); + + fireEvent.input(input, { target: { value: "auth token" } }); + await vi.advanceTimersByTimeAsync(250); + + expect(lastSearch().scope).toBe("workspace"); + expect(lastSearch().workdir).toBe("/repos/alpha"); + }); + + it("searches every workspace when nothing is focused", async () => { + const { input } = openModal(); + + fireEvent.input(input, { target: { value: "auth token" } }); + await vi.advanceTimersByTimeAsync(250); + + expect(lastSearch().scope).toBe("all"); + // No anchor may ride along — the daemon decides global on its absence. + expect(lastSearch().workdir).toBeUndefined(); + }); + + it("re-runs the query globally when the scope toggle is used", async () => { + seedFocused(); + const { input, getByText } = openModal(); + + fireEvent.input(input, { target: { value: "auth token" } }); + await vi.advanceTimersByTimeAsync(250); + expect(lastSearch().scope).toBe("workspace"); + const before = requestMock.mock.calls.length; + + fireEvent.click(getByText("All workspaces")); + await vi.advanceTimersByTimeAsync(250); + + // Same query, other regime — no retyping required. + expect(requestMock.mock.calls.length).toBeGreaterThan(before); + expect(lastSearch().scope).toBe("all"); + expect(lastSearch().query).toBe("auth token"); + expect(lastSearch().workdir).toBeUndefined(); + }); + + it("hides the toggle when there is no workspace to scope to", () => { + const { queryByText } = openModal(); + expect(queryByText("All workspaces")).toBeNull(); + expect(queryByText("This workspace")).toBeNull(); + }); + + it("offers a widen button when a scoped search finds nothing", async () => { + seedFocused(); + const { input, findByText } = openModal(); + + fireEvent.input(input, { target: { value: "auth token" } }); + await vi.advanceTimersByTimeAsync(250); + + // Zero hits under workspace scope is exactly when widening helps. + expect(await findByText(/No matches in this workspace/)).toBeTruthy(); + fireEvent.click(await findByText("Search all workspaces")); + await vi.advanceTimersByTimeAsync(250); + + expect(lastSearch().scope).toBe("all"); + }); +}); diff --git a/web/src/components/SearchModal.tsx b/web/src/components/SearchModal.tsx index b1cf774..c74d1e3 100644 --- a/web/src/components/SearchModal.tsx +++ b/web/src/components/SearchModal.tsx @@ -27,6 +27,18 @@ import type { const DEBOUNCE_MS = 220; +/** + * Which sessions a search covers. `workspace` scopes to the focused session's + * directory; `all` runs the cross-workspace resolution path (global fusion + + * cross-encoder rerank) — see docs/session-resolution.md. + * + * A workspace is a DIRECTORY, not a session family: every session that ever + * ran in that workdir under the same tenant shares it. Forks isolated into a + * git worktree get their own path, so they land OUTSIDE the parent's scope — + * which is precisely the case cross-workspace search exists to recover. + */ +type SearchScope = "workspace" | "all"; + const SearchModal: Component = () => { const [open, setOpen] = createSignal(false); const [query, setQuery] = createSignal(""); @@ -34,6 +46,15 @@ const SearchModal: Component = () => { const [busy, setBusy] = createSignal(false); const [error, setError] = createSignal(null); const [highlight, setHighlight] = createSignal(0); + // Null until the user chooses; the effective scope falls back to "scoped if + // something is focused". Keeping the override separate from the derived + // default means focusing a session later doesn't silently undo the choice. + const [scopeOverride, setScopeOverride] = createSignal(null); + const scope = createMemo( + () => scopeOverride() ?? (focusedSession()?.workdir ? "workspace" : "all"), + ); + /** Scoping is only meaningful when there is a workspace to scope to. */ + const canScope = createMemo(() => Boolean(focusedSession()?.workdir)); let inputRef: HTMLInputElement | undefined; let debounceTimer: ReturnType | undefined; @@ -45,6 +66,7 @@ const SearchModal: Component = () => { setError(null); setHighlight(0); setBusy(false); + setScopeOverride(null); } // Global Ctrl+K to toggle, Esc to close. @@ -71,9 +93,11 @@ const SearchModal: Component = () => { }), ); - // Debounce + dispatch search on every query change. + // Debounce + dispatch search on every query OR scope change, so flipping + // the scope re-runs the current query through the other regime rather than + // leaving stale hits from the previous one on screen. createEffect( - on(query, async (q) => { + on([query, scope] as const, async ([q]) => { if (debounceTimer) clearTimeout(debounceTimer); const trimmed = q.trim(); if (trimmed.length < 2) { @@ -97,9 +121,12 @@ const SearchModal: Component = () => { id, query: trimmed, limit: 10, - ...(focusedSession()?.workdir - ? { workdir: focusedSession()!.workdir, scope: "workspace" } - : { scope: "all" }), + // Omit the anchor entirely when global — passing a workdir + // alongside scope:"all" is contradictory, and the daemon + // decides global-vs-scoped on the workspaceId's absence. + ...(scope() === "workspace" && focusedSession()?.workdir + ? { workdir: focusedSession()!.workdir, scope: "workspace" as const } + : { scope: "all" as const }), }, { waitForResult: (m) => @@ -172,7 +199,41 @@ const SearchModal: Component = () => { autocomplete="off" spellcheck={false} /> - + +
+ + +
+
+ Ctrl+K @@ -184,6 +245,8 @@ const SearchModal: Component = () => { highlight={highlight()} setHighlight={setHighlight} onPick={pick} + scope={scope()} + onWiden={() => setScopeOverride("all")} /> @@ -199,6 +262,8 @@ const ResultsBody: Component<{ highlight: number; setHighlight: (n: number) => void; onPick: (idx: number) => void; + scope: SearchScope; + onWiden: () => void; }> = (props) => { const empty = createMemo( () => @@ -216,11 +281,27 @@ const ResultsBody: Component<{
searching…
-
No matches.
+
+

No matches in {props.scope === "all" ? "any workspace" : "this workspace"}.

+ + + +

Type at least 2 characters to search across sessions.

+

+ {props.scope === "all" + ? "Searching every workspace." + : "Searching this session's directory only."} +