Skip to content

feat: reach cross-workspace session search from the TUI - #309

Merged
saucam merged 3 commits into
mainfrom
feat/tui-cross-workspace-search
Aug 29, 2026
Merged

feat: reach cross-workspace session search from the TUI#309
saucam merged 3 commits into
mainfrom
feat/tui-cross-workspace-search

Conversation

@saucam

@saucam saucam commented Aug 29, 2026

Copy link
Copy Markdown
Collaborator

Cross-workspace session resolution shipped in #51 — global fusion plus a cross-encoder rerank, 73% precision@1 / 92% recall@5 at 88 ms p95 on the labelled fixture, documented in docs/session-resolution.md as the linchpin of the conductor.

It had no user-facing entry point. This PR gives it one, and fixes a fallback that silently returned nothing.

1. fix: the documented no-anchor fallback never worked

session.search.workdir documents: when scope="workspace" and no workdir is supplied, the daemon infers from the client's focus, and failing that "falls back to cross-workspace."

#search initialises workspaceId to "" and forwards it unless scope is "all". But the engine decides global-vs-scoped with:

const global = opts.workspaceId === undefined;

So "" isn't the fallback — it's a scoped search against a workspace id no workspace has, matching nothing. This is the same trap the adjacent comment already documents for scope:"all"; the no-anchor case never got the same treatment.

Regression test drives SessionManager with no sessions registered (so #guessCallerWorkdir has nothing to anchor on) and asserts the key is absent. Verified it fails against the previous condition.

2. feat: Tab toggles scope in the search modal

Every human surface hard-coded workspace scope:

const resp = await client.search(q, workdir, 10, "workspace");

ws.ts already declared the parameter as "workspace" | "all", and a test asserted the daemon honours "all" — but nothing ever sent it. The only caller reaching the global path was fleet_find, the conductor's agent tool. The capability was reachable by an agent, not by the person at the terminal.

  • Tab toggles and re-runs the current query, so the two regimes are directly comparable on the same input
  • Header shows the active scope; empty-state hint explains what each covers; footer advertises the key
  • Cross-workspace rows show the workdir basename — a bare session name is ambiguous once results span repos. Uses SessionSearchHit.workdir, already populated by the daemon's enrichment step, so no protocol change.

Behaviour

Before After
Session focused workspace workspace (unchanged)
Nothing focused workspaceId:"" → zero hits cross-workspace
Cross-workspace unreachable from TUI Tab

Whether global should become the default with a session focused is a separate call, deliberately not made here.

Verification

  • bun run typecheck clean
  • bun run lint clean (359 files)
  • bun test — 2384 pass, 19 skip, 0 fail
  • New regression test confirmed to fail without the daemon fix

Not covered

Telegram's /search still hard-codes workspace scope. Left alone — it has no equivalent of Tab, so it needs a UX decision (a /search --all flag or a separate command) rather than a mechanical change.

Co-authored-by: Claude Opus 5 (1M context) noreply@anthropic.com

🤖 Generated with Claude Code

saucam and others added 3 commits August 29, 2026 21:43
…lves

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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
@saucam
saucam merged commit 442ed7a into main Aug 29, 2026
4 checks passed
@saucam
saucam deleted the feat/tui-cross-workspace-search branch August 29, 2026 15:36
saucam added a commit that referenced this pull request Aug 30, 2026
#309 gave the TUI a Tab toggle between this-workspace and all-workspaces
search. Telegram was left on the old path, so the same capability was still
unreachable there. Two problems, not one.

The scope was wrong even when it worked. `/search` sent scope:"workspace"
with no workdir, so the daemon fell through to `#guessCallerWorkdir`, which
returns the workdir of the most recently CREATED session by that user. That
is unrelated to what the Telegram user is attached to — you could be attached
to alpha, ask about alpha, and silently search beta because beta was started
later. The handler now anchors on the attached session's workdir.

With nothing attached there is no workspace worth scoping to, so the search
runs cross-workspace instead of scoping to an arbitrary repo. Same rule the
TUI modal uses for its initial scope.

Telegram has no Tab, so the widen control is an inline-keyboard button on the
results: "🌐 Search all workspaces". It is offered on zero hits too, which is
exactly when widening is most useful. callback_data caps at 64 bytes and
queries are free text, so the query is parked in `#pendingSearches` keyed
`${userId}:${short}` — the same shape as approvals and provider dialogs —
with oldest-first eviction at 200 entries and single-use tokens.

Cross-workspace hits show the repo name, as in the TUI: two repos can each
have a "fix auth" session, so a bare session name is ambiguous once results
span workspaces.

`basename` is total in both frontends. A hit whose session is no longer live
can carry an empty workdir — the daemon already renders those as "(unknown)"
— and a missing repo label must not break the result list. The TUI copy had
the same latent hazard and is fixed alongside.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
saucam added a commit that referenced this pull request Sep 1, 2026
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) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants