# 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): ```ts lastVisitedAtByWorktreeId: Record 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.