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) {