feat(sessions): pin browse modes — recency order, ungroup, pinned-only

Merged
GR
3mo ago
+1267-148

Summary

Three complaints about the 📌 Pinned zone from a live review (2026-08-20), answered from the same header line — vertical space is the scarce resource in a menu-bar popup, so this adds no new chrome.

ComplaintBeforeNow
"the zone isn't ordered by latest activity"pinnedAt ASC — oldest pin firstlastTimestamp DESC (recency), like every other list
"sometimes I want everything in one time-ordered list, without a pinned block on top"impossible: collapsing removed pins from the zone and the timeline at once, so they became invisiblecollapsing ungroups: pins fall back to their chronological slot with the ★
"sometimes I only want to browse — or search — the pinned list"impossiblenew only chip on the right of the header
▾ 📌 Pinned (7)                                   [only]   ← zone at top (default)
▸ 📌 Pinned (7)                                   [only]   ← ungrouped: pins sit in time order, marked ★
  📌 Pinned (7)                                   [ONLY]   ← list AND search scoped to pins

Both toggles persist in localStorage (codev-pinned-collapsed, codev-pinned-only).

Why ordering changed back

PR #136 deliberately chose pinnedAt ASC so a new pin appended at the zone bottom instead of reshuffling rows under the cursor. That reasoning was about the moment you pin; it cost the ordering used everywhere else, and buried the session touched five minutes ago beneath months-old pins. The hover-suppression added in the same PR (suppressHoverSelection()) already absorbs the layout movement it was avoiding.

Correctness details that are easy to miss

  • A pinned session is never folded away as a "minor session". While pins lived in the zone this could not happen; once ungrouped, a pinned session with ≤2 messages and no title would have been eaten by the junk fold. Pinning is an explicit "keep this", so the predicate now skips pins outright.
  • Pins outside the loaded ~100 stay visible in every mode. They exist only in the by-id resolved set, so ungrouping would have made them vanish from the zone and the timeline — the exact bug this PR set out to remove, in a rarer form. They are appended to the timeline instead (the loaded list is the top-N by recency, so anything outside it is older than every row above).
  • Pinned-only can never become a trap. The header stays rendered on an empty result set (and during a search), the empty state names the way out, and removing the last pin clears the stored preference — keyed off a real non-empty → empty transition, because at mount the marks are empty until the IPC load lands and a plain check would wipe a legitimately stored preference.

Implementation

src/session-list-view.ts — new pure module (no fs, no electron, no React). ~130 lines of "which rows appear, in which group, in which order" moved verbatim out of the 2000-line component, which had made the only available check "run the app". That is precisely where the bugs have been: PR #136 needed five rounds of live testing, all of them list/index interactions, and this PR now multiplies three independent browse states together.

10 new tests (63 total) cover the matrix — grouped / ungrouped / pinned-only / pinned-only + search — plus the two invariants above.

Mutation-verified rather than assumed. Reverting each guard in turn turns the suite red, and restoring it turns it green:

Reverted guardResult
pin guard in the minor predicate1 failed / 62 passed
out-of-window pin append2 failed / 61 passed
recency ordering (back to pinnedAt)1 failed / 62 passed
pinned-only scope filter1 failed / 62 passed
(restored)63 passed

Also fixes a type annotation the extraction surfaced: sessions carry epoch-ms numbers, but formatRelativeTime declared string. It only survived because every caller went through an any row. No behavior change — new Date() takes both.

npx tsc --noEmit clean.

Docs

docs/session-finding-plan.md §4.4 had drifted from what shipped — it still described dual placement ("the section is a shortcut, not a move") and pinnedAt desc ordering, both reversed during PR #136. Rewritten to the shipped design with the rejected alternatives kept as the record, and §8's two settled questions closed.

§4.5 / §4.6 / §6 now carry the measurements from the same review, so the next two PRs don't re-derive them:

  • Titles: median 44 chars, 64% exceed the UI's 35-char hard slice, and 48 of 125 titles (38%) share their first 35 chars with another title — the largest group is 8 sessions all rendering as fred-ff nextjs backend and mcp arch. The title column, the primary identification signal in this app, is ambiguous for 38% of sessions before search is involved at all.
  • PR references: of 2,506 (session, PR-number) pairs mentioned in prompts, only 19.4% carry both #N and the URL — 80.6% are reachable by one query form only.
  • Frecency: 3 of the frecency top-10 fall outside the recency top-20, and 3 of 7 real pins land in the frecency top-10 while three others rank #26/#63/#72 — so a "Frequent" list is complementary to pins, not a replacement, and its marginal value is modest.

How to test (yarn make)

  1. Order — the zone should now lead with the pin you touched most recently, not the one you pinned first.
  2. Ungroup — click ▾ 📌 Pinned (N): the zone disappears and every pinned session reappears in its normal chronological position with a gold ★. Nothing should be missing; count the rows.
  3. Only — click only: the list is just your pins. Type in the search box — results stay scoped to pins. Click only again to leave.
  4. Only + no match — with only on, search for gibberish: the header must stay visible so you can click your way out.
  5. Junk pin — pin a ≤2-message untitled session, then ungroup: it must stay in the list, not fall into the minor fold.
  6. Old pin — pin something well down the list (or found via deep search), then ungroup: it should appear once, at the bottom.
  7. ⌘D / ⇧⌘D should behave exactly as before in all three modes.

Out of scope (next PRs, per the same review)

  • Row readability: middle-ellipsis titles, hover-for-full-title, match-aware highlight windows (§4.5).
  • PR-reference canonicalization #123 ⇄ URL, and the B4 search filters it unlocks (§4.6).
  • The "Frequent" frecency scope (§6, D4).

Note

Draft PR #119 (feat/worktree-launch, 2026-04-27) also touches src/switcher-ui.tsx; its only overlap here is the formatRelativeTime line. That branch is four months behind main and will need a rebase regardless.

🤖 On behalf of @grimmerk — generated with Claude Code


Summary by cubic

Adds three browse modes for pinned sessions and makes the marks store authoritative to prevent accidental pin loss. Old: the pinned zone sorted by pin time and collapsing hid pins; unreadable stores were treated as empty and could be overwritten. New: the zone sorts by last activity, collapsing ungroups pins into the timeline, an “only” chip scopes list and search, and unknown reads are neither broadcast nor written over.

  • Pinned sessions never fold into “minor”; pins outside the loaded window show exactly once when ungrouped; the header count reflects the scoped total while “only” is on.
  • List building is a pure, tested module (src/session-list-view.ts); mergeSessionsById widens search candidates to include by-id pins; search re-applies the live query when deep results or enrichment arrive; formatRelativeTime accepts numbers.
  • UI: the header is always rendered; “only” scopes both list and search; toggles persist via effects; selection/order remain stable when toggling.

Marks store (authoritative-or-unknown)

  • Reads return {marks, known}; ENOENT is authoritative; any lossy read (malformed, future-version, or normalization would change bytes) is known:false.
  • mutateMarksFile/mutateSessionMarks refuse to write when known:false; the watcher drops unknown-read broadcasts; IPC getSessionMarks includes known; the renderer ignores unknown initial reads and only clears pinned‑only after an authoritative read.
  • All four main-side mutations now go through mutateMarksFile to avoid overwriting a store we could not read. Tests cover authority, write refusal, watcher behavior, and list invariants.

Summary by CodeRabbit

  • New Features

    • Added a dedicated pinned-session area with recency sorting.
    • Added controls to group pinned sessions or display them chronologically.
    • Added a persistent “Pinned only” filter for browsing and search.
    • Improved search matching for older pinned sessions by title, branch, and pull request.
    • Kept unresolved and out-of-window pinned sessions visible.
    • Updated session counts to reflect active filters.
    • Preserved active sessions and excluded pinned sessions from minor-session folding.
  • Bug Fixes

    • Prevented session marks from being overwritten when stored data cannot be read.
  • Documentation

    • Updated guidance for pinned-session browsing, search, grouping, ordering, and filtering.