orca/docs/cmd-j-empty-query-ordering.md

9.8 KiB

Cmd+J empty-query ordering: use visit recency, not activity recency

Problem

When Cmd+J opens with no query, the sortedWorktrees memo in WorktreeJumpPalette.tsx orders the Worktrees section by Worktree.lastActivityAt before the empty-query cap is applied. The cap is conditional: with browser tabs present, Worktrees is capped at 5 so browser rows stay visible above the fold (see the __hint_worktree_cap__ branch in the same file); with no browser tabs, the list is uncapped.

For worktrees with low background signal — notably SSH-backed worktrees — lastActivityAt can remain old even when the user was just working there. Those worktrees get pushed below the visible empty-query rows by local worktrees that emitted incidental PTY/activity events. The user then has to type a substring to surface the worktree they just visited, which defeats the purpose of the empty-query switcher.

Reported symptom: an SSH worktree the user was working in minutes ago does not appear in the visible Cmd+J empty-query list; typing any substring surfaces it.

Product model

Cmd+J with an empty query is a fast switcher. It should answer: "where am I likely to jump next?"

That is different from both existing recency signals:

  • lastActivityAt answers "where did work happen?" Right for activity-aware surfaces, wrong for SSH or quiet worktrees where user focus is not accompanied by local PTY/activity signals.
  • worktreeNavHistory (see recordWorktreeVisit in the worktree-nav-history slice) answers "what is the Back/Forward stack?" That stack has index, forward-history, duplicate, and 'tasks' semantics that are useful for sequential navigation but unrelated to switcher ranking.

The switcher needs its own persisted focus-recency signal. This doc uses "focus recency" throughout.

Proposal

Persist a per-worktree focus-recency timestamp and use it as the primary ordering signal for Cmd+J's empty-query Worktrees section.

Store shape, added to src/renderer/src/store/slices/worktrees.ts (the slice that already owns activeWorktreeId and is the natural home for per-worktree UI recency):

lastVisitedAtByWorktreeId: Record<string, number>
markWorktreeVisited: (worktreeId: string, visitedAt?: number) => void

markWorktreeVisited must be monotonic: if the supplied (or current) timestamp is not strictly greater than the stored value, it is a no-op. This matters because CLI-driven and IPC-driven activations can race, and we do not want an older timestamp to regress recency.

Stamp site

Stamp from activateAndRevealWorktree (src/renderer/src/lib/worktree-activation.ts), immediately after the state.setActiveWorktree(worktreeId) call at line 91, synchronously, before any of the later view/terminal/reveal steps. This guarantees the stamp lands even if a subsequent async step fails, since the user already perceives the switch as successful once activeWorktreeId flips.

Do this in addition to, not gated on, the existing state.recordWorktreeVisit(worktreeId) call; the nav-history slice has different semantics (see "Why not use worktreeNavHistory").

Do not stamp from setActiveWorktree directly. That raw setter is invoked by hydration, session restore, and test setup — stamping there would reset focus recency for the restored workspace on every app launch.

Activation-path audit

Every user-initiated worktree switch must route through activateAndRevealWorktree. Before landing, audit direct callers of setActiveWorktree and classify each:

  • User switches (sidebar clicks, Cmd+J selections, CLI activations, status-bar/session jumps, deep links) — must go through activation.
  • Non-user transitions (store hydration, session restore, tests) — must NOT stamp.

The audit output belongs in the PR description. Do not stamp Worktree.lastActivityAt.

Ordering rule (empty query only)

  1. Start from visible worktrees: skip isArchived, and keep honoring hideDefaultBranchWorkspace via isDefaultBranchWorkspace.
  2. Build a separate switchableWorktrees list that excludes the currently active worktree. Keep the full visible list for loading/empty-state/count logic so the palette never claims there are no worktrees just because the only visible worktree is current.
  3. Sort switchableWorktrees by:
    • lastVisitedAtByWorktreeId[id] descending, when present.
    • lastActivityAt descending as the fallback for never-visited or pre-migration worktrees.
    • displayName.localeCompare as the final stable tie-breaker.
  4. Preserve the conditional cap: cap Worktrees at 5 only when browser rows exist (the existing __hint_worktree_cap__ logic); otherwise leave the Worktrees section uncapped.
  5. Preserve the existing "Type to see all N worktrees" hint, but compute N from switchable rows. Empty-state copy is based on the full visible list.

Typing any non-empty query still routes through sortWorktreesSmart. No change to the sidebar, sortEpoch, or lastActivityAt semantics.

Current worktree handling

Cmd+J is a switch surface. Exclude the current worktree from empty-query rows in v1. Keep two separate lists so empty-state logic is not affected:

  • visibleWorktreesForState: includes the current worktree and drives "loading", "has any worktrees", and empty-state decisions.
  • switchableWorktreesForRows: excludes the current worktree and drives the actual empty-query Worktrees rows.

A "Current" row variant is out of scope.

Why not use worktreeNavHistory

worktreeNavHistory records activations but is the wrong abstraction for Cmd+J ordering.

  • Back/Forward history is an indexed stack. Cmd+J is an unordered switcher ranked by likely target.
  • History contains 'tasks' entries; Cmd+J rows should not need to understand task-page sentinels.
  • Back/Forward navigation can leave forward entries in the stack. A raw newest-to-oldest walk either incorrectly includes future entries or needs custom interpretation of worktreeNavHistoryIndex.
  • History dedupe rules are stack-oriented. A per-worktree timestamp is simpler and directly models the switcher need.

Keep worktreeNavHistory for Back/Forward.

Migration and persistence

Persist lastVisitedAtByWorktreeId via the same zustand persist path that survives app restart.

  • Downgrade: older builds will drop the unknown key on rehydrate (zustand partialize strips anything the slice doesn't declare). No custom migration needed; record this explicitly in the PR so nobody invents one.
  • Pruning: drop entries whose worktree IDs are no longer present — after worktree hydration completes, not on raw rehydrate. Repos load async; pruning too early would nuke timestamps for worktrees whose repo hasn't yet hydrated.
  • Seeding active on restore: if, after hydration, the active worktree has no stored timestamp, seed it with the current time from the hydration-complete handler — not by calling markWorktreeVisited from setActiveWorktree. The two paths have intentionally different semantics (seeding is a migration fixup; stamping is focus recency).
  • Never-visited worktrees stay without timestamps and fall back to lastActivityAt.

The map is bounded by live worktree IDs, so no history cap is needed.

Non-goals

  • Changing sidebar sort order.
  • Changing lastActivityAt semantics or when it is stamped.
  • Changing the typed-query path; smart-sort remains authoritative.
  • Making Cmd+J mirror Back/Forward history.
  • Persisting Cmd+J UI state such as query, scroll position, or selection.
  • Adding a "Current" row variant.

Implementation sketch

  • Add lastVisitedAtByWorktreeId and markWorktreeVisited to the worktrees slice; persist via the existing persist config.
  • In activateAndRevealWorktree, call markWorktreeVisited(worktreeId) immediately after state.setActiveWorktree(worktreeId) (line 91), synchronously. Focus recency, not work activity.
  • Update the sortedWorktrees memo in WorktreeJumpPalette.tsx so the empty-query branch uses subscribed store inputs: lastVisitedAtByWorktreeId, activeWorktreeId, visible worktrees, and existing palette filters. Avoid reading useAppStore.getState() inside a memo as the only source of ordering data; that can produce stale UI.
  • Keep the typed-query branch on sortWorktreesSmart.
  • Keep browser-tab search ordering unchanged unless a separate browser visit-recency issue is discovered.
  • Extract a pure function orderEmptyQueryWorktrees so ordering, current-worktree exclusion, and fallback behavior are testable without mounting the whole palette.

Tests

Add focused tests for the ordering helper:

  • Recently visited SSH/quiet worktree ranks above a locally active worktree with newer lastActivityAt.
  • Never-visited worktrees fall back to lastActivityAt.
  • Current worktree is excluded from empty-query rows but still counted for empty-state logic.
  • Worktrees cap remains conditional on browser rows.
  • hideDefaultBranchWorkspace and isArchived still filter rows.
  • Non-empty query still uses sortWorktreesSmart order.
  • Hydration seeds the active worktree's timestamp when missing.
  • markWorktreeVisited is monotonic: an older timestamp does not regress the stored value.

Do not put these tests in worktree-palette-search.test.ts unless the pure search function itself changes. That file verifies matching behavior and input order preservation, not empty-query ranking.

Risks

  • Activation paths that bypass activateAndRevealWorktree. Any user-visible switch that calls setActiveWorktree directly will skip the stamp. Mitigation: the activation-path audit above.
  • False empty states after current-worktree exclusion. Mitigation: separate visibleWorktreesForState and switchableWorktreesForRows.
  • Pruning before hydration. Mitigation: prune in the hydration-complete handler, not on raw rehydrate.