feat(nav): add automations to worktree history (#2454)

This commit is contained in:
Jinjing 2026-05-20 15:26:11 -07:00 committed by GitHub
parent ee27616388
commit ee9b0cefa4
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 356 additions and 142 deletions

View File

@ -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`.

View File

@ -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 {
</Tooltip>
)}
</div>
{/* 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.
<div className="ml-auto mr-3 flex items-center pl-2">

View File

@ -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)
})
})

View File

@ -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'
}

View File

@ -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)
})

View File

@ -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<AppState> {
// 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<any>()((...args: any[]) => ({
repos: [],
worktreesByRepo: {},
rightSidebarWidth: 280,
...createWorktreeNavHistorySlice(...(args as Parameters<typeof createWorktreeNavHistorySlice>)),
...createUISlice(...(args as Parameters<typeof createUISlice>))
})) as unknown as StoreApi<AppState>
}
function makeWorktree(id: string): Worktree {
return { id } as unknown as Worktree
}
function makePersistedUI(overrides: Partial<PersistedUIState> = {}): 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()

View File

@ -640,16 +640,29 @@ export const createUISlice: StateCreator<AppState, [], [], UISlice> = (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',

View File

@ -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<MinimalState> {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return createStore<any>()((set, get, api) => ({
worktreesByRepo: {
'repo-1': worktreeIds.map(makeWorktree)
},
...createWorktreeNavHistorySlice(
set as Parameters<typeof createWorktreeNavHistorySlice>[0],
get as Parameters<typeof createWorktreeNavHistorySlice>[1],
api as Parameters<typeof createWorktreeNavHistorySlice>[2]
)
})) as unknown as StoreApi<MinimalState>
}
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)
})
}
})

View File

@ -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'])

View File

@ -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<AppState>)
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<AppState>)
} else {
if (!activator) {