feat(nav): add automations to worktree history (#2454)
This commit is contained in:
parent
ee27616388
commit
ee9b0cefa4
|
|
@ -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`.
|
||||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
@ -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'
|
||||
}
|
||||
|
|
@ -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)
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
}
|
||||
})
|
||||
|
|
@ -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'])
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
Loading…
Reference in New Issue