Skip to content

Section the "Run in" picker into Active / Settled#212

Open
Zeus-Deus wants to merge 2 commits into
mainfrom
fix-project-selector-list
Open

Section the "Run in" picker into Active / Settled#212
Zeus-Deus wants to merge 2 commits into
mainfrom
fix-project-selector-list

Conversation

@Zeus-Deus

@Zeus-Deus Zeus-Deus commented Jul 27, 2026

Copy link
Copy Markdown
Owner

Problem

The Thread Scope location picker lists every project that has a live workspace — the same set the sidebar draws from. Since the inbox landed, settling a workspace parks its sidebar card without closing anything, so that set only ever grows.

The picker and the sidebar had drifted into two different models of the same data. The sidebar means "what I'm working on"; the picker meant "everything I've ever opened and not explicitly closed." Auto-settle actively widens that gap, since settling is now the cheap, encouraged gesture and each one leaves a permanent picker entry that appears nowhere in the sidebar.

At a dozen-plus adopted projects that reads as a wall of equals: flat, unsorted, uncapped, running off the top of the viewport, with the project you touched a minute ago sitting below one you settled months ago.

Approach

Rather than hiding settled projects (loses a real use case — starting a thread in a settled project is how you resurrect parked work) or switching to the recent_projects table (makes picker and sidebar disagree in a new way), the picker now speaks the sidebar's own vocabulary, reading the same store so the two can't disagree.

  • Active — at least one unsettled workspace. Most-recently-active first via the inbox activity stamps.
  • Settled · still open — every workspace settled. Deprioritized, never hidden. Newest-settled first (matching the sidebar's settled section), collapsed to 4 behind "Show N more". The currently-targeted project always shows, even from the hidden tail — a checkmark you can't see reads as a lost selection.
  • Section headings appear only when something is settled; a small install keeps the flat list it always had.
  • Search appears only past 6 project rows and reveals the full settled tail, so list length stops mattering.
  • The list is capped and scrollable, bounded by --radix-popover-content-available-height so the taller popover can't clip off the top of a short window. Open another project… moved outside the scroll area — the escape hatch must never scroll away.

Notes for review

The picker never writes the inbox store. Selecting a settled project deliberately does not un-settle it — the inbox already resurfaces a settled workspace the moment its agent goes working, which is exactly what first send does. Un-settling on mere selection would also fire while only browsing locations.

Ordering degrades honestly. WorkspaceSnapshot carries no last-activity timestamp, and the inbox activity map is only stamped while the expanded sidebar is mounted. Rather than invent a parallel recency store, unstamped projects rank last and keep their app-state order — strictly better than insertion order, never dependent on a signal we can't guarantee. Hard recency would need backend stamping; that's a separate change.

Avatar loading is now scoped to visible rows. It used to fetch 3 UI-state keys for every known project on every open (~57 IPC round trips at 18 projects), most for rows behind the collapsed tail. The effect deliberately does not cancel when the visible set changes mid-flight — that drops a batch and strands rows on default avatars; only a reopen invalidates a result.

Testing

  • 32 new tests: 18 unit tests on the partition/collapse logic (project-scope-list.test.ts), 14 component tests covering sectioning, ordering, collapse, search, laziness, and the no-un-settle contract.
  • Full suite green: 3076 tests / 210 files. tsc --noEmit and vite build clean.
  • Verified live in the browser at 18 projects and at 4 — sections, expansion, search-to-1, and the flat small-install fallback.

⚠️ npm run verify can't complete in a fresh worktree: cargo check needs the gitignored src-tauri/binaries/agent-browser-* sidecar. This change touches zero Rust, so the frontend gates were run individually.

The location picker lists every project with a live workspace — the same
set the sidebar draws from. Since the inbox landed, settling a workspace
parks its sidebar card without closing anything, so that set only ever
grows: the picker and the sidebar drifted into two different models of
the same data. The sidebar means "what I'm working on"; the picker meant
"everything I've ever opened and not explicitly closed".

At a dozen-plus adopted projects that read as a wall of equals — flat,
unsorted, uncapped, running off the top of the viewport, with the project
you touched a minute ago below one you settled months ago.

The picker now speaks the sidebar's own vocabulary, reading the same
store so the two can't disagree:

- Active — at least one unsettled workspace, most-recently-active first
  via the inbox activity stamps. Unstamped projects rank last and keep
  their app-state order, so ordering is strictly better than insertion
  order rather than dependent on a signal we can't guarantee.
- Settled - still open — every workspace settled. Deprioritized, never
  hidden (starting a thread here is how you resurrect parked work),
  newest-settled first, collapsed behind "Show N more". The targeted
  project always shows even from the hidden tail.
- Headings and the search field appear only when they earn their space,
  so a small install keeps the flat list it always had.
- The list is capped and scrollable, bounded by the available height so
  the taller popover can't clip off the top of a short window, and
  "Open another project..." moved outside the scroll area.

Selecting a settled project deliberately does not un-settle it: the
inbox already resurfaces a settled workspace the moment its agent goes
working, which is exactly what first send does. Un-settling on mere
selection would also fire while only browsing locations.

Avatar loading is scoped to the rows on screen. It used to fetch three
UI-state keys for every known project on every open; the collapsed list
now costs a handful, and expanding or searching pays only for what it
newly reveals.
@Zeus-Deus

Copy link
Copy Markdown
Owner Author

Review — Opus 5 (high effort)

The sectioning work is good — reading the sidebar's own store so the two surfaces can't disagree is the right call over hiding settled projects or switching to recent_projects, and partitionProjectScopes / visibleSettledProjects are correct: stable sort so unstamped projects keep app-state order, Math.max(0, ...) seeded against the empty-spread -Infinity, the active project escaping the collapsed tail, and hiddenSettledCount staying truthful because the escaped row is appended rather than swapped in. The CHECKED_ACCENT trick works too — I checked command.tsx, and CommandItem does render a trailing CheckIcon gated on data-checked, so [&>svg:last-child] really is targeting it and not the avatar.

But there's a blocking bug.

The search field never gets focus, so typing does nothing

onOpenAutoFocus={focusCmdkRootOnOpen} is retained on PopoverContent, and the popover now contains a CommandInput. That handler calls event.preventDefault() and focuses the [cmdk-root] div instead. Neither shadcn's CommandInput nor cmdk's Command.Input sets autoFocus, and cmdk's root onKeyDown only handles arrows / Home / End / Enter — printable characters go nowhere.

I verified this rather than inferring it, with a throwaway harness (Popover + Command + CommandInput + two items, identical except for the handler):

WITH    focusCmdkRootOnOpen -> activeElement: DIV [cmdk-root]
                               input value after typing "beta": ""
                               non-matching item still listed: true

WITHOUT focusCmdkRootOnOpen -> activeElement: INPUT
                               input value after typing "beta": "beta"
                               non-matching item still listed: false

So: open the picker, type, nothing happens. You have to click the search box first.

The test suite can't catch this because it drives the input with user.type(screen.getByPlaceholderText("Search projects…"), buried)user.type clicks the element before typing, which focuses it. Swapping that line for await user.keyboard(buried) after openPicker() reproduces the failure.

Fix: drop onOpenAutoFocus when showSearch is true (which is what #205 does, for exactly this reason), or pass autoFocus through to CommandInput.

And the docstring that led you here is wrong. focus-cmdk-root.ts says:

Safe on popovers that still have a CommandInput too — the input's own autoFocus already handles that case, and we'd just be redundantly focusing the root.

There is no such autoFocus, and focusing the root is not redundant — it actively steals focus from the input. Worth fixing there, because two shipping pickers pair the handler with a CommandInput today and have the same latent bug: launch-model-picker.tsx and MultiProviderModelPicker.tsx. Both are search boxes that need a click before they'll take a keystroke.

Design conflict with #205

#205 adds a different search implementation to this same popover. It matters which lands:

  • This PR uses cmdk's built-in filter over value={\${g.projectName} ${g.projectPath}`}`, i.e. name and path.
  • feat(chat): fuzzy-search the "Run in" project picker #205 explicitly tried and reverted name-and-path matching, because a short query is a subsequence of nearly every long path so the list stops narrowing — caught in the browser, not in tests. It matches name-only, switching to paths when the query contains /.

cmdk's command-score is not a plain subsequence matcher, so it may hold up better than #205's reverted attempt did. But that's worth actually checking against a real 18-project list with a two-character query, not assumed — and the two PRs cannot both land as written.

Smaller notes

  • CommandList carries max-h-[220px] while PopoverContent carries max-h-[var(--radix-popover-content-available-height)]. The available-height binding only ever engages below 220px, so the "can't clip off the top of a short window" guarantee is really coming from the fixed cap plus collisionPadding. Fine, just not what the comment implies.
  • Check is still imported and used by the checkout/branch pickers further down the file, so no dead import. ✓

@Zeus-Deus

Copy link
Copy Markdown
Owner Author

Review by GPT 5.6 Sol xhigh found a cross-PR lifecycle mismatch with open PR #206.

partitionProjectScopes classifies a project as Settled only when every workspace ID is in settled; everything else is Active. #206 adds snoozed as a second parked lifecycle and explicitly excludes snoozed workspaces from the sidebar's active project counts. After both land, a project whose workspaces are all snoozed will be labelled Active in Run in while the sidebar reports zero active cards. That breaks this PR's central promise that the picker reads the same store so the two surfaces cannot disagree.

Please resolve this while rebasing the two PRs: include snoozed in the partition and decide whether the UI calls the combined bucket Parked or distinguishes Snoozed from Settled. Add all-snoozed and mixed settled/snoozed/active project cases.

The standalone focused suites passed 45/45 tests. Pairwise merge-tree checks also show textual conflicts with #205, #206, and #211, so this needs an integration rebase rather than a mechanical merge.

The location picker's new search field never received focus: the
popover's onOpenAutoFocus handler focused the cmdk root div, and since
neither shadcn's CommandInput nor cmdk's Command.Input sets autoFocus —
and cmdk's root keydown only handles navigation keys — printable
keystrokes went nowhere until the user clicked the box. The same
handler/CommandInput pairing shipped in the branch picker, the launch
model picker, and the multi-provider model picker.

Fix it centrally: the open-autofocus handler (now focusCmdkOnOpen) aims
at [cmdk-input] when the popover has one and falls back to [cmdk-root]
otherwise, so input-less pickers keep their arrow-key navigation.
Dropping the handler on input-bearing popovers would not have been
enough: in the multi-provider picker the provider rail's buttons precede
the input in the DOM, so Radix's first-tabbable default would focus the
rail. The handler's docstring claimed the input's own autoFocus made
root-focus safe — no such autoFocus exists; the docstring now describes
the real contract. Regression tests type with NO prior click on the
input, the interaction that masked the bug (userEvent.type clicks
first, focusing the input as a side effect).

Also stop matching project paths in the location search. Project paths
share a long common prefix (/home/…/projects/), so any short query that
grazed it subsequence-matched every row at an identical score and the
filter read as dead — the same failure that got name-and-path matching
reverted in the workspace switcher (#205). The item value keeps the
path after a tab separator purely to stay unique across same-basename
projects (cmdk keys selection state on value); a custom filter strips
it before scoring.

partitionProjectScopes now documents that any future parked lifecycle
beyond settled must be folded into the Active/Settled partition, or
all-parked projects would read as Active here.
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.

1 participant