diff --git a/docs/automations-navigation-stack.md b/docs/automations-navigation-stack.md new file mode 100644 index 000000000..89d6aee2c --- /dev/null +++ b/docs/automations-navigation-stack.md @@ -0,0 +1,75 @@ +# Automations Navigation Stack + +## Problem + +- `worktree-nav-history.ts` models view entries as `'tasks'` only; `'automations'` cannot be recorded or replayed. +- `openTaskPage` records a view visit before switching view; `openAutomationsPage` does not. +- `closeTaskPage` rewinds history index when closing from a `'tasks'` history node; `closeAutomationsPage` does not. +- Keyboard history navigation already works on Automations (`Cmd/Ctrl+Alt+Arrow`), but titlebar Back/Forward is hidden there. +- `setWorktreeNavViewActivator` is currently Tasks-sentinel oriented in types/comments and must be widened for Automations. + +## Goal + +Make Automations a first-class entry in the existing mixed worktree/page navigation stack, matching Tasks behavior for open, back/forward traversal, close-page rewind, and titlebar controls. + +## Non-goals + +- Do not add persistence for navigation history; the existing stack is session-only and renderer-local. +- Do not preserve per-automation detail selection through Back/Forward beyond existing `selectedAutomationId` state. +- Do not change Activity, Settings, Space, Skills, or terminal navigation behavior. +- Do not add new shortcuts; reuse existing cross-platform `Cmd/Ctrl+Alt+Arrow` handling. + +## Design + +1. Add explicit view-entry type. + - `type WorktreeNavHistoryViewEntry = 'tasks' | 'automations'`. + - `type WorktreeNavHistoryEntry = string | WorktreeNavHistoryViewEntry`. + - Update `recordViewVisit`, `ViewActivateFn`, and `setWorktreeNavViewActivator` signatures accordingly. + - Update `isLiveEntry` to treat both page sentinels as live. + +2. Generalize history replay branch. + - In `navigateToIndex`, dispatch page sentinels through `viewActivator(entry)` and worktree ids through `activator(id)`. + - Keep page replay on `setActiveView(entry)` (not `openTaskPage`/`openAutomationsPage`) to avoid mutating `previousViewBefore*` and avoid appending history during replay. + - Keep existing index semantics: update index only after successful activation path. + +3. Record and close Automations like Tasks. + - `openAutomationsPage`: call `recordViewVisit('automations')` before switching `activeView`. + - `closeAutomationsPage`: if current history node is `'automations'`, rewind to `findPrevLiveWorktreeHistoryIndex(state)` when available; otherwise keep index unchanged. + - This rewind must apply regardless of close trigger (Esc / header X / any direct `closeAutomationsPage` call site). + +4. Align titlebar controls with shortcut scope. + - Show titlebar Back/Forward when `activeView` is `terminal`, `tasks`, or `automations`. + - Keep shortcut logic unchanged; it already includes Automations. + +5. Tests. + - `worktree-nav-history.test.ts`: add Automations sentinel coverage for replay path, adjacent dedupe, dead-worktree skip, and rewind/forward behavior. + - `ui.test.ts`: add Automations open/close history-index parity tests with Tasks, including “only automations in history” no-op rewind. + - `App.tsx`: assert Back/Forward controls render on Automations (not optional; this is where current behavior regressed from shortcut scope). + - `worktree-activation` wiring test coverage (or equivalent integration assertion) should verify `setWorktreeNavViewActivator` accepts/replays both sentinels. + +## Known residual quirks + +- Replay uses `setActiveView(...)`, so `previousViewBeforeTasks/Automations` is not recomputed on back/forward landing. Close from a replayed page can return to stale `previousViewBefore*`; this is existing Tasks behavior. +- History is capped at 50 entries. Long sessions may evict older entries, including page sentinels; this is existing behavior. +- History is renderer-local and session-local (no persistence, no cross-window reconciliation). +- Liveness is evaluated against current store state at navigation time. If a target worktree becomes invalid between target selection and activation, `activateAndRevealWorktree` may fail and index stays put. + +## Edge cases + +- `A -> Automations -> B`, Back lands on Automations, Back again lands on A. +- `A -> Automations -> Automations` records only one Automations entry. +- `A -> Automations`, close rewinds index to A; Forward reopens Automations. +- `Automations` as the only history entry: close leaves index at `0` (do not force `-1`, or Forward target is lost). +- If the prior worktree was deleted while Automations is open, Back/Close rewind skips it and lands on the next live prior entry. +- Back-to-Automations must not call `openAutomationsPage`, or it would overwrite `previousViewBeforeAutomations` and append duplicate history. +- Shortcut labels and handling remain cross-platform (`⌘⌥` on Mac, `Ctrl+Alt` elsewhere). +- Multi-window: each renderer has an independent history stack; no cross-window reconciliation is attempted. + +## Rollout + +1. Update `worktree-nav-history.ts` types, live-entry predicate, and replay branch. +2. Update `ui.ts` to record and rewind Automations visits. +3. Update `worktree-activation.ts` comments/types for generalized view activator. +4. Update `App.tsx` titlebar visibility and comments. +5. Add/adjust unit tests for history slice, UI slice, and titlebar visibility. +6. Run `worktree-nav-history.test.ts` and `ui.test.ts`, then `pnpm typecheck` and `pnpm lint`. diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index ecb337163..1da07386e 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -91,6 +91,7 @@ import { import { applyDocumentTheme } from './lib/document-theme' import { isEditableTarget } from './lib/editable-target' import { getSelectedTextForFileSearch } from './lib/file-search-selection' +import { shouldShowWorktreeHistoryControls } from './lib/titlebar-worktree-history-controls' import { canGoBackWorktreeHistory, canGoForwardWorktreeHistory @@ -1048,10 +1049,10 @@ function App(): React.JSX.Element { (isMac ? e.metaKey && !e.ctrlKey : e.ctrlKey && !e.metaKey) && (e.code === 'ArrowLeft' || e.code === 'ArrowRight') ) { - // Why: Back/Forward traverse mixed worktree + Tasks visits, so the + // Why: Back/Forward traverse mixed worktree + page 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' && activeView !== 'automations') { + // or stack-backed pages). Still suppressed in Settings. + if (!shouldShowWorktreeHistoryControls(activeView)) { return } e.preventDefault() @@ -1264,12 +1265,10 @@ function App(): React.JSX.Element { )} - {/* 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, and in - Activity since that page owns its own back-out via the Close button - in ActivityTitlebarControls. */} - {(activeView === 'terminal' || activeView === 'tasks') && ( + {/* Why: Back/Forward traverse mixed worktree + page history, so the + cluster is shown wherever the history shortcut is live. Hidden in + Settings and non-stack page views. */} + {shouldShowWorktreeHistoryControls(activeView) && ( // Why: when the workspace sidebar is collapsed, this header shrink-wraps // and ml-auto has no spare width; keep a fixed gutter before Back.
diff --git a/src/renderer/src/lib/titlebar-worktree-history-controls.test.ts b/src/renderer/src/lib/titlebar-worktree-history-controls.test.ts new file mode 100644 index 000000000..ea2ae5709 --- /dev/null +++ b/src/renderer/src/lib/titlebar-worktree-history-controls.test.ts @@ -0,0 +1,17 @@ +import { describe, expect, it } from 'vitest' +import { shouldShowWorktreeHistoryControls } from './titlebar-worktree-history-controls' + +describe('shouldShowWorktreeHistoryControls', () => { + it('shows controls wherever worktree history navigation is supported', () => { + expect(shouldShowWorktreeHistoryControls('terminal')).toBe(true) + expect(shouldShowWorktreeHistoryControls('tasks')).toBe(true) + expect(shouldShowWorktreeHistoryControls('automations')).toBe(true) + }) + + it('hides controls on full-page views outside the history stack', () => { + expect(shouldShowWorktreeHistoryControls('settings')).toBe(false) + expect(shouldShowWorktreeHistoryControls('activity')).toBe(false) + expect(shouldShowWorktreeHistoryControls('space')).toBe(false) + expect(shouldShowWorktreeHistoryControls('skills')).toBe(false) + }) +}) diff --git a/src/renderer/src/lib/titlebar-worktree-history-controls.ts b/src/renderer/src/lib/titlebar-worktree-history-controls.ts new file mode 100644 index 000000000..1e35e2da4 --- /dev/null +++ b/src/renderer/src/lib/titlebar-worktree-history-controls.ts @@ -0,0 +1,5 @@ +import type { UISlice } from '@/store/slices/ui' + +export function shouldShowWorktreeHistoryControls(activeView: UISlice['activeView']): boolean { + return activeView === 'terminal' || activeView === 'tasks' || activeView === 'automations' +} diff --git a/src/renderer/src/lib/worktree-activation.ts b/src/renderer/src/lib/worktree-activation.ts index 61266cc14..db1ddcc6e 100644 --- a/src/renderer/src/lib/worktree-activation.ts +++ b/src/renderer/src/lib/worktree-activation.ts @@ -289,10 +289,9 @@ export function ensureWorktreeHasInitialTerminal( // 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"). +// Why: page entries in nav history replay through setActiveView(...) +// (not open*Page) so back/forward does not mutate previousViewBefore* or +// append duplicate history. See navigateToIndex for the replay branch. setWorktreeNavViewActivator((entry) => { useAppStore.getState().setActiveView(entry) }) diff --git a/src/renderer/src/store/slices/ui.test.ts b/src/renderer/src/store/slices/ui.test.ts index 609051d8d..207ed83c5 100644 --- a/src/renderer/src/store/slices/ui.test.ts +++ b/src/renderer/src/store/slices/ui.test.ts @@ -2,7 +2,7 @@ import { createStore, type StoreApi } from 'zustand/vanilla' import { afterEach, describe, expect, it, vi } from 'vitest' import { getDefaultUIState } from '../../../../shared/constants' -import type { PersistedUIState } from '../../../../shared/types' +import type { PersistedUIState, Worktree } from '../../../../shared/types' import { createUISlice } from './ui' import { createWorktreeNavHistorySlice } from './worktree-nav-history' import type { AppState } from '../types' @@ -13,18 +13,23 @@ afterEach(() => { }) function createUIStore(): StoreApi { - // Only the UI slice, repo ids, and right sidebar width fallback are needed - // for persisted UI hydration tests. The worktree-nav-history slice is also - // included because openTaskPage records a Tasks visit via recordViewVisit. + // Only the UI slice, repo/worktree ids, and right sidebar width fallback are + // needed for these tests. The worktree-nav-history slice is also included + // because page opens record view visits. // eslint-disable-next-line @typescript-eslint/no-explicit-any return createStore()((...args: any[]) => ({ repos: [], + worktreesByRepo: {}, rightSidebarWidth: 280, ...createWorktreeNavHistorySlice(...(args as Parameters)), ...createUISlice(...(args as Parameters)) })) as unknown as StoreApi } +function makeWorktree(id: string): Worktree { + return { id } as unknown as Worktree +} + function makePersistedUI(overrides: Partial = {}): PersistedUIState { return { ...getDefaultUIState(), @@ -482,6 +487,76 @@ describe('createUISlice settings navigation', () => { }) }) +describe('createUISlice page navigation history', () => { + it('records and rewinds Tasks visits on close', () => { + const store = createUIStore() + store.setState({ worktreesByRepo: { 'repo-1': [makeWorktree('a')] } }) + + store.getState().recordWorktreeVisit('a') + store.getState().openTaskPage() + expect(store.getState().worktreeNavHistory).toEqual(['a', 'tasks']) + expect(store.getState().worktreeNavHistoryIndex).toBe(1) + + store.getState().closeTaskPage() + expect(store.getState().activeView).toBe('terminal') + expect(store.getState().worktreeNavHistoryIndex).toBe(0) + }) + + it('records and rewinds Automations visits on close', () => { + const store = createUIStore() + store.setState({ worktreesByRepo: { 'repo-1': [makeWorktree('a')] } }) + + store.getState().recordWorktreeVisit('a') + store.getState().openAutomationsPage() + expect(store.getState().worktreeNavHistory).toEqual(['a', 'automations']) + expect(store.getState().worktreeNavHistoryIndex).toBe(1) + + store.getState().closeAutomationsPage() + expect(store.getState().activeView).toBe('terminal') + expect(store.getState().worktreeNavHistoryIndex).toBe(0) + }) + + it('dedupes repeated Automations opens against the current history entry', () => { + const store = createUIStore() + store.setState({ worktreesByRepo: { 'repo-1': [makeWorktree('a')] } }) + + store.getState().recordWorktreeVisit('a') + store.getState().openAutomationsPage() + store.getState().openAutomationsPage() + + expect(store.getState().activeView).toBe('automations') + expect(store.getState().worktreeNavHistory).toEqual(['a', 'automations']) + expect(store.getState().worktreeNavHistoryIndex).toBe(1) + }) + + it('keeps the Automations history index when Automations is the only entry', () => { + const store = createUIStore() + + store.getState().openAutomationsPage() + expect(store.getState().worktreeNavHistory).toEqual(['automations']) + expect(store.getState().worktreeNavHistoryIndex).toBe(0) + + store.getState().closeAutomationsPage() + expect(store.getState().activeView).toBe('terminal') + expect(store.getState().worktreeNavHistoryIndex).toBe(0) + }) + + it('skips deleted prior worktrees when closing Automations', () => { + const store = createUIStore() + store.setState({ + activeView: 'automations', + previousViewBeforeAutomations: 'terminal', + worktreesByRepo: { 'repo-1': [makeWorktree('c')] }, + worktreeNavHistory: ['c', 'a', 'automations'], + worktreeNavHistoryIndex: 2 + }) + + store.getState().closeAutomationsPage() + expect(store.getState().activeView).toBe('terminal') + expect(store.getState().worktreeNavHistoryIndex).toBe(0) + }) +}) + describe('createUISlice feature tour nudge', () => { it('shows and dismisses the feature tour nudge', () => { const store = createUIStore() diff --git a/src/renderer/src/store/slices/ui.ts b/src/renderer/src/store/slices/ui.ts index 40b739d70..98c7c8f39 100644 --- a/src/renderer/src/store/slices/ui.ts +++ b/src/renderer/src/store/slices/ui.ts @@ -640,16 +640,29 @@ export const createUISlice: StateCreator = (set, get) })), selectedAutomationId: null, setSelectedAutomationId: (id) => set({ selectedAutomationId: id }), - openAutomationsPage: () => + openAutomationsPage: () => { + get().recordViewVisit('automations') set((state) => ({ activeView: 'automations', previousViewBeforeAutomations: state.activeView === 'automations' ? state.previousViewBeforeAutomations : state.activeView - })), + })) + }, closeAutomationsPage: () => - set((state) => ({ - activeView: state.previousViewBeforeAutomations - })), + set((state) => { + const currentEntry = state.worktreeNavHistory[state.worktreeNavHistoryIndex] + let nextHistoryIndex = state.worktreeNavHistoryIndex + if (currentEntry === 'automations') { + const prev = findPrevLiveWorktreeHistoryIndex(state) + if (prev !== null) { + nextHistoryIndex = prev + } + } + return { + activeView: state.previousViewBeforeAutomations, + worktreeNavHistoryIndex: nextHistoryIndex + } + }), openSpacePage: () => set((state) => ({ activeView: 'space', diff --git a/src/renderer/src/store/slices/worktree-nav-history-view-entries.test.ts b/src/renderer/src/store/slices/worktree-nav-history-view-entries.test.ts new file mode 100644 index 000000000..bfa147667 --- /dev/null +++ b/src/renderer/src/store/slices/worktree-nav-history-view-entries.test.ts @@ -0,0 +1,131 @@ +import { createStore, type StoreApi } from 'zustand/vanilla' +import { afterEach, describe, expect, it } from 'vitest' +import type { AppState } from '../types' +import type { Worktree } from '../../../../shared/types' +import { + createWorktreeNavHistorySlice, + findPrevLiveWorktreeHistoryIndex, + setWorktreeNavActivator, + setWorktreeNavViewActivator, + type WorktreeNavHistoryViewEntry +} from './worktree-nav-history' + +type MinimalState = Pick< + AppState, + | 'worktreeNavHistory' + | 'worktreeNavHistoryIndex' + | 'isNavigatingHistory' + | 'recordWorktreeVisit' + | 'recordViewVisit' + | 'goBackWorktree' + | 'goForwardWorktree' + | 'worktreesByRepo' +> + +function makeWorktree(id: string): Worktree { + return { id } as unknown as Worktree +} + +function createHistoryStore(worktreeIds: string[] = []): StoreApi { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return createStore()((set, get, api) => ({ + worktreesByRepo: { + 'repo-1': worktreeIds.map(makeWorktree) + }, + ...createWorktreeNavHistorySlice( + set as Parameters[0], + get as Parameters[1], + api as Parameters[2] + ) + })) as unknown as StoreApi +} + +const viewCases: { entry: WorktreeNavHistoryViewEntry; label: string }[] = [ + { entry: 'tasks', label: 'Tasks' }, + { entry: 'automations', label: 'Automations' } +] + +describe('worktree-nav-history slice: view entries', () => { + afterEach(() => { + setWorktreeNavActivator(null) + setWorktreeNavViewActivator(null) + }) + + for (const { entry, label } of viewCases) { + it(`A -> ${label} -> B, back lands on ${label} 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(entry) + store.getState().recordWorktreeVisit('b') + + expect(store.getState().worktreeNavHistory).toEqual(['a', entry, 'b']) + expect(store.getState().worktreeNavHistoryIndex).toBe(2) + + store.getState().goBackWorktree() + expect(viewed).toEqual([entry]) + expect(store.getState().worktreeNavHistoryIndex).toBe(1) + + store.getState().goBackWorktree() + expect(activated).toEqual(['a']) + expect(store.getState().worktreeNavHistoryIndex).toBe(0) + }) + + it(`dedupes ${label} against the current ${label} entry`, () => { + const store = createHistoryStore(['a']) + store.getState().recordWorktreeVisit('a') + store.getState().recordViewVisit(entry) + store.getState().recordViewVisit(entry) + store.getState().recordViewVisit(entry) + + expect(store.getState().worktreeNavHistory).toEqual(['a', entry]) + expect(store.getState().worktreeNavHistoryIndex).toBe(1) + }) + + it(`skips a dead worktree when backing to a prior ${label} entry`, () => { + const store = createHistoryStore([]) + const viewed: string[] = [] + setWorktreeNavViewActivator((v) => { + viewed.push(v) + }) + + store.setState({ + worktreeNavHistory: [entry, 'b', entry], + worktreeNavHistoryIndex: 2 + }) + + store.getState().goBackWorktree() + expect(viewed).toEqual([entry]) + expect(store.getState().worktreeNavHistoryIndex).toBe(0) + }) + + it(`close-page-style rewind for ${label} preserves forward replay`, () => { + const store = createHistoryStore(['a']) + store.getState().recordWorktreeVisit('a') + store.getState().recordViewVisit(entry) + expect(store.getState().worktreeNavHistoryIndex).toBe(1) + + const prev = findPrevLiveWorktreeHistoryIndex(store.getState() as AppState) + expect(prev).toBe(0) + store.setState({ worktreeNavHistoryIndex: prev ?? store.getState().worktreeNavHistoryIndex }) + + const viewed: string[] = [] + setWorktreeNavViewActivator((v) => { + viewed.push(v) + }) + + store.getState().goForwardWorktree() + expect(viewed).toEqual([entry]) + expect(store.getState().worktreeNavHistoryIndex).toBe(1) + }) + } +}) 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 a24c5311c..28b6ea0f2 100644 --- a/src/renderer/src/store/slices/worktree-nav-history.test.ts +++ b/src/renderer/src/store/slices/worktree-nav-history.test.ts @@ -6,9 +6,7 @@ import { canGoBackWorktreeHistory, canGoForwardWorktreeHistory, createWorktreeNavHistorySlice, - findPrevLiveWorktreeHistoryIndex, - setWorktreeNavActivator, - setWorktreeNavViewActivator + setWorktreeNavActivator } from './worktree-nav-history' type MinimalState = Pick< @@ -185,104 +183,6 @@ 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 9aa1479a7..d83667f9f 100644 --- a/src/renderer/src/store/slices/worktree-nav-history.ts +++ b/src/renderer/src/store/slices/worktree-nav-history.ts @@ -8,12 +8,13 @@ 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 +// Why: entries are worktree IDs OR page sentinels for full-page visits. +// 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 +// across ~20 sites would churn for no behavior win. View entries are // always live (never skipped by findPrev/NextLiveWorktreeHistoryIndex). -export type WorktreeNavHistoryEntry = string | 'tasks' +export type WorktreeNavHistoryViewEntry = 'tasks' | 'automations' +export type WorktreeNavHistoryEntry = string | WorktreeNavHistoryViewEntry export type WorktreeNavHistorySlice = { // Linear history, oldest -> newest. @@ -28,13 +29,13 @@ export type WorktreeNavHistorySlice = { isNavigatingHistory: boolean recordWorktreeVisit: (worktreeId: string) => void - recordViewVisit: (entry: 'tasks') => void + recordViewVisit: (entry: WorktreeNavHistoryViewEntry) => void goBackWorktree: () => void goForwardWorktree: () => void } type ActivateFn = (worktreeId: string) => unknown -type ViewActivateFn = (entry: 'tasks') => void +type ViewActivateFn = (entry: WorktreeNavHistoryViewEntry) => void // Why: the slice must call activateAndRevealWorktree from goBack/goForward, but // importing it directly would create a cycle (activation imports the store). @@ -47,18 +48,17 @@ 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 +// Why: installed by App-level init so the slice can dispatch page entries +// to setActiveView(...) 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. +// Why: view entries short-circuit as live unconditionally — findWorktreeById +// takes a worktree id and would always return undefined for page sentinels. function isLiveEntry(entry: WorktreeNavHistoryEntry, state: AppState): boolean { - if (entry === 'tasks') { + if (entry === 'tasks' || entry === 'automations') { return true } return findWorktreeById(state.worktreesByRepo, entry) !== undefined @@ -70,8 +70,8 @@ function appendHistoryEntry( ): { 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. + // stack (user left B, returned to A). Same rule covers page re-opens: + // Tasks data changes and repeated Automations opens collapse to one entry. if (s.worktreeNavHistory[s.worktreeNavHistoryIndex] === entry) { return s } @@ -179,10 +179,10 @@ function navigateToIndex( const prevNavigating = get().isNavigatingHistory set({ isNavigatingHistory: true } as Partial) try { - if (targetEntry === 'tasks') { + if (targetEntry === 'tasks' || targetEntry === 'automations') { if (!viewActivator) { // Why: a silent no-op would mean the back/forward chord lands on a - // Tasks history entry and appears broken. See setWorktreeNavActivator + // page history entry and appears broken. See setWorktreeNavActivator // rationale above. console.warn( `go${direction === 'back' ? 'Back' : 'Forward'}Worktree: view activator not registered` @@ -190,10 +190,10 @@ function navigateToIndex( 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 + // than open*Page so we don't mutate previousViewBefore* or fire + // page-open side effects during replay. activateAndRevealWorktree on the // other branch already switches activeView back to 'terminal'. - viewActivator('tasks') + viewActivator(targetEntry) set({ worktreeNavHistoryIndex: targetIndex } as Partial) } else { if (!activator) {