From bdf5f66ffd5527b126a539cf81948c77cd3f6141 Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Mon, 27 Apr 2026 23:49:34 -0700 Subject: [PATCH] feat(nav): back/forward traverses Tasks page visits (#1207) Widens worktree nav history entries to string | 'tasks' so the titlebar back/forward buttons and Cmd/Ctrl+Alt+Arrow shortcut work from the Tasks page. openTaskPage records a 'tasks' entry; closeTaskPage rewinds the index when parked on one; goBack/goForward dispatches Tasks entries via a separate view activator to bypass openTaskPage side effects. Co-authored-by: Orca --- docs/back-button-tasks-page.md | 138 +++++++++++ src/renderer/src/App.tsx | 17 +- src/renderer/src/lib/worktree-activation.ts | 13 +- src/renderer/src/store/slices/ui.ts | 36 ++- .../store/slices/worktree-nav-history.test.ts | 103 +++++++- .../src/store/slices/worktree-nav-history.ts | 223 +++++++++++------- 6 files changed, 429 insertions(+), 101 deletions(-) create mode 100644 docs/back-button-tasks-page.md diff --git a/docs/back-button-tasks-page.md b/docs/back-button-tasks-page.md new file mode 100644 index 000000000..370117cf5 --- /dev/null +++ b/docs/back-button-tasks-page.md @@ -0,0 +1,138 @@ +# Back/Forward support for the Tasks page + +## Goal + +Make the titlebar back/forward buttons and the `Cmd/Ctrl+Alt+←/→` shortcut +traverse Tasks visits in addition to worktree activations. Today both are +no-ops outside `activeView === 'terminal'` (see `App.tsx:616`, `App.tsx:900`). + +## Current shape + +- History lives in `src/renderer/src/store/slices/worktree-nav-history.ts`. +- Entries are `string[]` (worktree IDs), recorded from + `worktree-activation.ts:96` via `recordWorktreeVisit`. +- `goBackWorktree`/`goForwardWorktree` skip dead worktrees via + `findPrev/NextLiveWorktreeHistoryIndex`, gated by an `activator` ref and + `isNavigatingHistory` to prevent re-recording. +- UI (pre-change): titlebar buttons hidden unless + `activeView === 'terminal'`; shortcut ignored outside terminal view. + +## Minimal implementation (downscoped) + +1. **History entry type** → `string | 'tasks'`. + - `findPrev/NextLiveWorktreeHistoryIndex` must short-circuit on `'tasks'` + before calling `findWorktreeById` (which takes a worktree id, not a + view tag) — Tasks entries are unconditionally live. + - Keep `recordWorktreeVisit(worktreeId: string)` signature to avoid churn + in `terminals-hydration.test.ts`. Add a sibling + `recordViewVisit(entry: 'tasks')` that shares the dedupe/truncate/cap + logic. + - Keep existing names (`worktreeNavHistory`, `goBackWorktree`, + `canGoBackWorktreeHistory`). They're now slight misnomers since + entries may be `'tasks'`, but renaming touches ~20 call sites for no + behavioral win. Add a one-line comment at the top of the slice + stating the entry type and the intentional keeping of the name, so + future readers don't assume it's worktree-only. + +2. **Record Tasks visits** → call `recordViewVisit('tasks')` from + `openTaskPage` (`ui.ts:189`). No `isNavigatingHistory` guard needed: + back-to-Tasks routes through `setActiveView('tasks')` per step 3, which + never touches `openTaskPage`. The slice's existing adjacent-entry dedupe + covers any other re-entry. Note: `openTaskPage` is called with varying + `taskPageData` (e.g. `{ taskSource: 'github' }` vs `'linear'` from + `SidebarNav.tsx:88,102`); all collapse to a single `'tasks'` entry and + dedupe against the current entry, so toggling presets produces no extra + history entries. This is consistent with the out-of-scope "no per-entry + snapshotting" decision. + +3. **Dispatch in goBack/goForward** → based on entry kind: + - worktreeId → existing `activator(worktreeId)` path. When current entry + is `'tasks'`, no extra view handling is needed: + `activateAndRevealWorktree` at `worktree-activation.ts:82-84` already + switches `activeView` back to `'terminal'`. + - `'tasks'` → `setActiveView('tasks')` (not `openTaskPage` — avoids + mutating `previousViewBeforeTasks` and the SWR prefetch). + +4. **Fix Esc-close history desync.** `closeTaskPage` (`ui.ts:210`) currently + sets `activeView` to `previousViewBeforeTasks` without touching the + history index, so after `A → Tasks → Esc` the index still points at the + `'tasks'` entry and Back becomes a visual no-op (activator re-activates A) + while Forward re-opens Tasks. Fix: in `closeTaskPage`, if the current + history entry is `'tasks'`, move the index to the previous live entry + (same scan as `findPrevLiveWorktreeHistoryIndex`). Sub-case: if there is + no previous live entry (e.g. user's first action was open Tasks, then + Esc — history `['tasks']` at index 0), leave the index unchanged at 0. + Accept the minor cost (Back becomes a visual no-op until a real visit + records a new entry) rather than setting to -1, which would lose the + only forward target. Guard with `isNavigatingHistory` is unnecessary + here — `closeTaskPage` is never invoked from the history path. + +5. **Unhide UI on Tasks** (allowlist, not denylist — `activeView` union is + `'terminal' | 'settings' | 'tasks'`, but an allowlist won't silently + include future views): + - Replace the `activeView !== 'terminal'` early-return at `App.tsx:616` + with `activeView !== 'terminal' && activeView !== 'tasks'`. Update the + adjacent comment (`App.tsx:613-615`) — it currently claims the + shortcut is a no-op outside terminal because the buttons are hidden; + replace with: back/forward traverse worktree + Tasks visits, so the + shortcut is active whenever the button cluster is (terminal or + Tasks); still suppressed elsewhere (Settings). + - Change the titlebar cluster guard at `App.tsx:900` to + `activeView === 'terminal' || activeView === 'tasks'`. Update the + adjacent comment (`App.tsx:896-899`) — drop the "terminal view only" + framing; explain the cluster is shown wherever the history shortcut + is live, and hidden in Settings to keep that view modal-ish. + +6. **Tests**: extend `worktree-nav-history.test.ts` for mixed entries: + - `A → Tasks → B`, back lands on Tasks, back again on A. + - Tasks dedupe against current entry. + - Dead worktree between Tasks entries is skipped. + - `A → Tasks → closeTaskPage()`: index moves back to A; subsequent Back + is a no-op, Forward re-opens Tasks. + - `Tasks (only entry) → closeTaskPage()`: index stays at 0, Back is a + visual no-op. + +## Explicitly out of scope + +- **Settings in history.** Stays modal-ish; closes via + `previousViewBeforeSettings`. +- **Per-entry `taskPageData` snapshotting.** Back-to-Tasks opens Tasks with + default filters/source. Revisit if users complain. +- **Session persistence of history.** Keep in-memory only (it already is). + +## Edge cases handled + +- Worktree deletion mid-session → skipped by existing live-check; Tasks + entries always live. +- Dedupe semantics → same adjacency rule works for `'tasks'`. +- Activator failure on worktree → unchanged (`result !== false` gate). Tasks + dispatch can't fail. +- Editable-target guard at `App.tsx:600` still fires first → typing in the + Tasks search box + `Cmd+Alt+←` remains a no-op. +- `MAX_HISTORY = 50` → Tasks entries consume slots; worst-case effective + worktree depth halves to ~25. Acceptable. + +## Known residual quirks (accepted) + +1. **Prefetch loss on back-to-Tasks.** Routing through `setActiveView('tasks')` + skips the SWR prefetch at `ui.ts:201-208`; back-to-Tasks is ~300–800ms + slower than a fresh open via the sidebar. Not a regression vs today + (back-to-Tasks isn't possible at all today). + +2. **Titlebar layout shift.** Revealing the button cluster on Tasks changes + the titlebar — needs a visual check that nothing Tasks-specific collides. + +## Files touched + +- `src/renderer/src/store/slices/worktree-nav-history.ts` — entry type, + `recordViewVisit`, dispatch in `goBack/goForwardWorktree`. +- `src/renderer/src/store/slices/worktree-nav-history.test.ts` — new cases. +- `src/renderer/src/store/slices/ui.ts` — `openTaskPage` records via + `recordViewVisit`; `closeTaskPage` rewinds history index when the current + entry is `'tasks'`. +- `src/renderer/src/App.tsx` — widen the two `activeView === 'terminal'` + guards to include `'tasks'`, and update the adjacent "why" comments at + `App.tsx:613-615` and `App.tsx:896-899` to reflect the new invariant. + +Estimated diff: ~30 lines of slice logic, 1 call site in `ui.ts`, 2 guards +in `App.tsx`, plus tests. diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index b50dfea0c..5f4974b40 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -610,10 +610,10 @@ function App(): React.JSX.Element { (isMac ? e.metaKey && !e.ctrlKey : e.ctrlKey && !e.metaKey) && (e.code === 'ArrowLeft' || e.code === 'ArrowRight') ) { - // Why: hidden buttons in non-terminal views mean the shortcut must be - // a no-op there too — navigating worktree history from Settings or - // Tasks is not a meaningful action. - if (activeView !== 'terminal') { + // Why: Back/Forward traverse mixed worktree + Tasks visits, so the + // shortcut is active wherever the titlebar button cluster is (terminal + // or tasks). Still suppressed in Settings to keep that view modal-ish. + if (activeView !== 'terminal' && activeView !== 'tasks') { return } dispatchClearModifierHints() @@ -893,11 +893,10 @@ function App(): React.JSX.Element { ) : null} - {/* Why: Back/Forward navigate worktree-activation history. Only - meaningful while viewing a worktree (terminal view); hidden in - Settings/Tasks/Landing to keep the titlebar compact and the - semantics unambiguous. */} - {activeView === 'terminal' && ( + {/* Why: Back/Forward traverse mixed worktree + Tasks history, so the + cluster is shown wherever the history shortcut is live (terminal or + tasks). Hidden in Settings to keep that view modal-ish. */} + {(activeView === 'terminal' || activeView === 'tasks') && (
diff --git a/src/renderer/src/lib/worktree-activation.ts b/src/renderer/src/lib/worktree-activation.ts index 3973768ee..2eee77d4a 100644 --- a/src/renderer/src/lib/worktree-activation.ts +++ b/src/renderer/src/lib/worktree-activation.ts @@ -3,7 +3,10 @@ import { shouldAutoCreateInitialTerminal } from '@/components/terminal/initial-t import { buildSetupRunnerCommand } from './setup-runner' import { useAppStore } from '@/store' import { findWorktreeById } from '@/store/slices/worktree-helpers' -import { setWorktreeNavActivator } from '@/store/slices/worktree-nav-history' +import { + setWorktreeNavActivator, + setWorktreeNavViewActivator +} from '@/store/slices/worktree-nav-history' // Why: issue commands can originate from two sources with different shapes — // (1) a repo-level runner script generated by main (WorktreeSetupLaunch), or @@ -211,3 +214,11 @@ export function ensureWorktreeHasInitialTerminal( // @/store, which activation already imports from. Registering the activator // at module init here lets the slice call back without importing this file. setWorktreeNavActivator(activateAndRevealWorktree) + +// Why: Tasks entries in the nav history dispatch via setActiveView('tasks') +// (not openTaskPage) — see the 'tasks' branch in navigateToIndex. Going this +// route avoids mutating previousViewBeforeTasks and skips the SWR prefetch +// (an accepted residual from the design doc; see "Known residual quirks"). +setWorktreeNavViewActivator((entry) => { + useAppStore.getState().setActiveView(entry) +}) diff --git a/src/renderer/src/store/slices/ui.ts b/src/renderer/src/store/slices/ui.ts index be8aeb7b3..58d9b2ef2 100644 --- a/src/renderer/src/store/slices/ui.ts +++ b/src/renderer/src/store/slices/ui.ts @@ -1,6 +1,7 @@ /* eslint-disable max-lines */ import type { StateCreator } from 'zustand' import type { AppState } from '../types' +import { findPrevLiveWorktreeHistoryIndex } from './worktree-nav-history' import type { ChangelogData, PersistedUIState, @@ -198,6 +199,13 @@ export const createUISlice: StateCreator = (set, get) taskPageData: {}, newWorkspaceDraft: null, openTaskPage: (data = {}) => { + // Why: record a Tasks visit in the shared back/forward history so the + // titlebar Back/Forward buttons can return to Tasks. All task-source + // variants (github/linear presets) collapse to a single 'tasks' entry; + // the slice's adjacent-entry dedupe drops re-opens. No isNavigatingHistory + // guard needed — back-to-Tasks routes through setActiveView('tasks') and + // never re-enters openTaskPage. + get().recordViewVisit('tasks') set((state) => ({ activeView: 'tasks', previousViewBeforeTasks: @@ -219,10 +227,30 @@ export const createUISlice: StateCreator = (set, get) } }, closeTaskPage: () => - set((state) => ({ - activeView: state.previousViewBeforeTasks, - taskPageData: {} - })), + set((state) => { + // Why: Esc-close from Tasks must rewind the history index if we're + // currently parked on a 'tasks' entry. Without this, A → Tasks → Esc + // leaves the index at the 'tasks' entry, making Back a visual no-op + // (activator re-activates A) and Forward re-opens Tasks. If there is no + // earlier live entry (e.g. history is just ['tasks']), leave the index + // at 0 — setting it to -1 would lose the only forward target, while the + // resulting Back visual no-op self-heals as soon as a real visit records + // a new entry. closeTaskPage never runs from the history-nav path, so no + // isNavigatingHistory guard is needed. + const currentEntry = state.worktreeNavHistory[state.worktreeNavHistoryIndex] + let nextHistoryIndex = state.worktreeNavHistoryIndex + if (currentEntry === 'tasks') { + const prev = findPrevLiveWorktreeHistoryIndex(state) + if (prev !== null) { + nextHistoryIndex = prev + } + } + return { + activeView: state.previousViewBeforeTasks, + taskPageData: {}, + worktreeNavHistoryIndex: nextHistoryIndex + } + }), setNewWorkspaceDraft: (draft) => set({ newWorkspaceDraft: draft }), clearNewWorkspaceDraft: () => set({ newWorkspaceDraft: null }), openSettingsPage: () => diff --git a/src/renderer/src/store/slices/worktree-nav-history.test.ts b/src/renderer/src/store/slices/worktree-nav-history.test.ts index 8a1666aae..a24c5311c 100644 --- a/src/renderer/src/store/slices/worktree-nav-history.test.ts +++ b/src/renderer/src/store/slices/worktree-nav-history.test.ts @@ -6,7 +6,9 @@ import { canGoBackWorktreeHistory, canGoForwardWorktreeHistory, createWorktreeNavHistorySlice, - setWorktreeNavActivator + findPrevLiveWorktreeHistoryIndex, + setWorktreeNavActivator, + setWorktreeNavViewActivator } from './worktree-nav-history' type MinimalState = Pick< @@ -15,6 +17,7 @@ type MinimalState = Pick< | 'worktreeNavHistoryIndex' | 'isNavigatingHistory' | 'recordWorktreeVisit' + | 'recordViewVisit' | 'goBackWorktree' | 'goForwardWorktree' | 'worktreesByRepo' @@ -182,6 +185,104 @@ describe('worktree-nav-history slice: goBack / goForward', () => { }) }) +describe('worktree-nav-history slice: Tasks entries', () => { + afterEach(() => { + setWorktreeNavActivator(null) + setWorktreeNavViewActivator(null) + }) + + it('A -> Tasks -> B, back lands on Tasks then A', () => { + const store = createHistoryStore(['a', 'b']) + const activated: string[] = [] + const viewed: string[] = [] + setWorktreeNavActivator((id) => { + activated.push(id as string) + return { primaryTabId: null } + }) + setWorktreeNavViewActivator((v) => { + viewed.push(v) + }) + + store.getState().recordWorktreeVisit('a') + store.getState().recordViewVisit('tasks') + store.getState().recordWorktreeVisit('b') + + expect(store.getState().worktreeNavHistory).toEqual(['a', 'tasks', 'b']) + expect(store.getState().worktreeNavHistoryIndex).toBe(2) + + store.getState().goBackWorktree() + expect(viewed).toEqual(['tasks']) + expect(store.getState().worktreeNavHistoryIndex).toBe(1) + + store.getState().goBackWorktree() + expect(activated).toEqual(['a']) + expect(store.getState().worktreeNavHistoryIndex).toBe(0) + }) + + it('dedupes Tasks against the current Tasks entry', () => { + const store = createHistoryStore(['a']) + store.getState().recordWorktreeVisit('a') + store.getState().recordViewVisit('tasks') + store.getState().recordViewVisit('tasks') + store.getState().recordViewVisit('tasks') + + expect(store.getState().worktreeNavHistory).toEqual(['a', 'tasks']) + expect(store.getState().worktreeNavHistoryIndex).toBe(1) + }) + + it('skips a dead worktree between two Tasks entries', () => { + // 'b' is deleted; history is [tasks, b, tasks]. + const store = createHistoryStore([]) + const viewed: string[] = [] + setWorktreeNavViewActivator((v) => { + viewed.push(v) + }) + + store.setState({ + worktreeNavHistory: ['tasks', 'b', 'tasks'], + worktreeNavHistoryIndex: 2 + }) + + store.getState().goBackWorktree() + expect(viewed).toEqual(['tasks']) + expect(store.getState().worktreeNavHistoryIndex).toBe(0) + }) + + it('closeTaskPage-style rewind: A -> Tasks, rewind moves index to A', () => { + const store = createHistoryStore(['a']) + store.getState().recordWorktreeVisit('a') + store.getState().recordViewVisit('tasks') + expect(store.getState().worktreeNavHistoryIndex).toBe(1) + + // Simulate closeTaskPage's rewind logic. + const prev = findPrevLiveWorktreeHistoryIndex(store.getState() as AppState) + expect(prev).toBe(0) + store.setState({ worktreeNavHistoryIndex: prev ?? store.getState().worktreeNavHistoryIndex }) + + // Forward re-opens Tasks. + setWorktreeNavActivator(() => ({ primaryTabId: null })) + const viewed: string[] = [] + setWorktreeNavViewActivator((v) => { + viewed.push(v) + }) + + store.getState().goForwardWorktree() + expect(viewed).toEqual(['tasks']) + expect(store.getState().worktreeNavHistoryIndex).toBe(1) + }) + + it('closeTaskPage-style rewind with only-Tasks history leaves index at 0', () => { + const store = createHistoryStore([]) + store.getState().recordViewVisit('tasks') + expect(store.getState().worktreeNavHistoryIndex).toBe(0) + + const prev = findPrevLiveWorktreeHistoryIndex(store.getState() as AppState) + expect(prev).toBeNull() + // closeTaskPage leaves the index unchanged when there's no prior live entry. + expect(store.getState().worktreeNavHistoryIndex).toBe(0) + }) +}) + describe('worktree-nav-history selectors', () => { it('reports back availability only when a live prior entry exists', () => { const store = createHistoryStore(['c']) diff --git a/src/renderer/src/store/slices/worktree-nav-history.ts b/src/renderer/src/store/slices/worktree-nav-history.ts index d7900d9e7..9aa1479a7 100644 --- a/src/renderer/src/store/slices/worktree-nav-history.ts +++ b/src/renderer/src/store/slices/worktree-nav-history.ts @@ -8,9 +8,16 @@ import { findWorktreeById } from './worktree-helpers' // linear skip-deleted scan in goBack/goForward stays trivially cheap. const MAX_HISTORY = 50 +// Why: entries are worktree IDs OR the sentinel 'tasks' for visits to the +// Tasks page. The slice, selector, and action names retain the +// "worktree"/"WorktreeHistory" prefix for call-site stability — renaming +// across ~20 sites would churn for no behavior win. Tasks entries are +// always live (never skipped by findPrev/NextLiveWorktreeHistoryIndex). +export type WorktreeNavHistoryEntry = string | 'tasks' + export type WorktreeNavHistorySlice = { // Linear history, oldest -> newest. - worktreeNavHistory: string[] + worktreeNavHistory: WorktreeNavHistoryEntry[] // Index into worktreeNavHistory; points at the currently-active entry. // -1 means empty (no worktree ever activated this session). worktreeNavHistoryIndex: number @@ -21,26 +28,76 @@ export type WorktreeNavHistorySlice = { isNavigatingHistory: boolean recordWorktreeVisit: (worktreeId: string) => void + recordViewVisit: (entry: 'tasks') => void goBackWorktree: () => void goForwardWorktree: () => void } type ActivateFn = (worktreeId: string) => unknown +type ViewActivateFn = (entry: 'tasks') => void // Why: the slice must call activateAndRevealWorktree from goBack/goForward, but // importing it directly would create a cycle (activation imports the store). // Install the reference at module init via setWorktreeNavActivator and keep // the slice itself unaware of the activation module. let activator: ActivateFn | null = null +let viewActivator: ViewActivateFn | null = null export function setWorktreeNavActivator(fn: ActivateFn | null): void { activator = fn } +// Why: installed by App-level init so the slice can dispatch 'tasks' entries +// to setActiveView('tasks') without importing the UI slice directly (the UI +// slice already transitively depends on this module via the store creator). +export function setWorktreeNavViewActivator(fn: ViewActivateFn | null): void { + viewActivator = fn +} + +// Why: Tasks entries short-circuit as live unconditionally — findWorktreeById +// takes a worktree id and would always return undefined for the 'tasks' +// sentinel, which would incorrectly treat Tasks entries as dead. +function isLiveEntry(entry: WorktreeNavHistoryEntry, state: AppState): boolean { + if (entry === 'tasks') { + return true + } + return findWorktreeById(state.worktreesByRepo, entry) !== undefined +} + +function appendHistoryEntry( + s: { worktreeNavHistory: WorktreeNavHistoryEntry[]; worktreeNavHistoryIndex: number }, + entry: WorktreeNavHistoryEntry +): { worktreeNavHistory: WorktreeNavHistoryEntry[]; worktreeNavHistoryIndex: number } { + // Why: re-visiting the same entry must not pollute history. The de-dup + // applies only to the current entry so that A -> B -> A remains a valid + // stack (user left B, returned to A). Same rule covers Tasks re-opens + // with different taskPageData — all collapse to a single 'tasks' entry. + if (s.worktreeNavHistory[s.worktreeNavHistoryIndex] === entry) { + return s + } + + // Truncate any forward entries, then append and advance the index. + const truncated = s.worktreeNavHistory.slice(0, s.worktreeNavHistoryIndex + 1) + truncated.push(entry) + let nextIndex = s.worktreeNavHistoryIndex + 1 + + // Why: cap eviction drops the oldest entries. The index must shift left + // by the same count so it still points at the just-appended current entry. + if (truncated.length > MAX_HISTORY) { + const evict = truncated.length - MAX_HISTORY + truncated.splice(0, evict) + nextIndex = Math.max(0, nextIndex - evict) + } + + return { + worktreeNavHistory: truncated, + worktreeNavHistoryIndex: nextIndex + } +} + export function findPrevLiveWorktreeHistoryIndex(state: AppState): number | null { for (let i = state.worktreeNavHistoryIndex - 1; i >= 0; i--) { - const id = state.worktreeNavHistory[i] - if (findWorktreeById(state.worktreesByRepo, id)) { + if (isLiveEntry(state.worktreeNavHistory[i], state)) { return i } } @@ -49,8 +106,7 @@ export function findPrevLiveWorktreeHistoryIndex(state: AppState): number | null export function findNextLiveWorktreeHistoryIndex(state: AppState): number | null { for (let i = state.worktreeNavHistoryIndex + 1; i < state.worktreeNavHistory.length; i++) { - const id = state.worktreeNavHistory[i] - if (findWorktreeById(state.worktreesByRepo, id)) { + if (isLiveEntry(state.worktreeNavHistory[i], state)) { return i } } @@ -76,95 +132,90 @@ export const createWorktreeNavHistorySlice: StateCreator< isNavigatingHistory: false, recordWorktreeVisit: (worktreeId) => { - set((s) => { - // Why: re-activating the same worktree must not pollute history. The - // de-dup applies only to the current entry so that A -> B -> A remains - // a valid stack (user left B, returned to A via the sidebar). - if (s.worktreeNavHistory[s.worktreeNavHistoryIndex] === worktreeId) { - return s - } + set((s) => appendHistoryEntry(s, worktreeId)) + }, - // Truncate any forward entries, then append and advance the index. - const truncated = s.worktreeNavHistory.slice(0, s.worktreeNavHistoryIndex + 1) - truncated.push(worktreeId) - let nextIndex = s.worktreeNavHistoryIndex + 1 - - // Why: cap eviction drops the oldest entries. The index must shift left - // by the same count so it still points at the just-appended current entry. - if (truncated.length > MAX_HISTORY) { - const evict = truncated.length - MAX_HISTORY - truncated.splice(0, evict) - nextIndex = Math.max(0, nextIndex - evict) - } - - return { - worktreeNavHistory: truncated, - worktreeNavHistoryIndex: nextIndex - } - }) + recordViewVisit: (entry) => { + set((s) => appendHistoryEntry(s, entry)) }, goBackWorktree: () => { - const state = get() - if (state.worktreeNavHistoryIndex <= 0) { - return - } - const targetIndex = findPrevLiveWorktreeHistoryIndex(state) - if (targetIndex === null) { - return - } - if (!activator) { - // Why: a silent no-op here would mean the back/forward chord simply - // does nothing with no diagnostic. The activator is registered at - // module init by worktree-activation.ts, so a missing activator means - // either test setup forgot to install one or the production import - // graph regressed. - console.warn('goBackWorktree called before worktree activator was registered') - return - } - const targetId = state.worktreeNavHistory[targetIndex] - // Why: capture-and-restore (not force false) so re-entrant navigation - // (e.g. a store subscriber synchronously triggers another goBack) does - // not race on the boolean — the outer call's `finally` restores its own - // prior value rather than clobbering state set by an inner call. - const prevNavigating = get().isNavigatingHistory - set({ isNavigatingHistory: true }) - try { - // Why: activateAndRevealWorktree returns `ActivateAndRevealResult | false`; - // `false` is the only observable failure signal. Advance the index only on - // success so the slice stays consistent with what the user actually sees. - const result = activator(targetId) - if (result !== false) { - set({ worktreeNavHistoryIndex: targetIndex }) - } - } finally { - set({ isNavigatingHistory: prevNavigating }) - } + navigateToIndex(get, set, 'back') }, goForwardWorktree: () => { - const state = get() + navigateToIndex(get, set, 'forward') + } +}) + +function navigateToIndex( + get: () => AppState, + set: (partial: Partial) => void, + direction: 'back' | 'forward' +): void { + const state = get() + if (direction === 'back') { + if (state.worktreeNavHistoryIndex <= 0) { + return + } + } else { if (state.worktreeNavHistoryIndex >= state.worktreeNavHistory.length - 1) { return } - const targetIndex = findNextLiveWorktreeHistoryIndex(state) - if (targetIndex === null) { - return - } - if (!activator) { - console.warn('goForwardWorktree called before worktree activator was registered') - return - } - const targetId = state.worktreeNavHistory[targetIndex] - const prevNavigating = get().isNavigatingHistory - set({ isNavigatingHistory: true }) - try { - const result = activator(targetId) - if (result !== false) { - set({ worktreeNavHistoryIndex: targetIndex }) - } - } finally { - set({ isNavigatingHistory: prevNavigating }) - } } -}) + const targetIndex = + direction === 'back' + ? findPrevLiveWorktreeHistoryIndex(state) + : findNextLiveWorktreeHistoryIndex(state) + if (targetIndex === null) { + return + } + const targetEntry = state.worktreeNavHistory[targetIndex] + + // Why: capture-and-restore (not force false) so re-entrant navigation + // (e.g. a store subscriber synchronously triggers another goBack) does + // not race on the boolean — the outer call's `finally` restores its own + // prior value rather than clobbering state set by an inner call. + const prevNavigating = get().isNavigatingHistory + set({ isNavigatingHistory: true } as Partial) + try { + if (targetEntry === 'tasks') { + if (!viewActivator) { + // Why: a silent no-op would mean the back/forward chord lands on a + // Tasks history entry and appears broken. See setWorktreeNavActivator + // rationale above. + console.warn( + `go${direction === 'back' ? 'Back' : 'Forward'}Worktree: view activator not registered` + ) + return + } + // Why: dispatch via setActiveView (installed as viewActivator) rather + // than openTaskPage so we don't mutate previousViewBeforeTasks or fire + // the SWR prefetch on back-to-Tasks. activateAndRevealWorktree on the + // other branch already switches activeView back to 'terminal'. + viewActivator('tasks') + set({ worktreeNavHistoryIndex: targetIndex } as Partial) + } else { + if (!activator) { + // Why: a silent no-op here would mean the back/forward chord simply + // does nothing with no diagnostic. The activator is registered at + // module init by worktree-activation.ts, so a missing activator means + // either test setup forgot to install one or the production import + // graph regressed. + console.warn( + `go${direction === 'back' ? 'Back' : 'Forward'}Worktree called before worktree activator was registered` + ) + return + } + // Why: activateAndRevealWorktree returns `ActivateAndRevealResult | false`; + // `false` is the only observable failure signal. Advance the index only on + // success so the slice stays consistent with what the user actually sees. + const result = activator(targetEntry) + if (result !== false) { + set({ worktreeNavHistoryIndex: targetIndex } as Partial) + } + } + } finally { + set({ isNavigatingHistory: prevNavigating } as Partial) + } +}