fix(cli): record back/forward history for CLI-activated worktrees (#1166)

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Brennan Benson 2026-04-27 12:43:02 -07:00 committed by GitHub
parent 75c6f25887
commit dd1ed7c697
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 213 additions and 16 deletions

View File

@ -1338,3 +1338,205 @@ describe('useIpcEvents shortcut hint clearing', () => {
expect(activateAndRevealWorktree).toHaveBeenCalledWith('wt-2')
})
})
describe('useIpcEvents CLI-created worktree activation', () => {
beforeEach(() => {
vi.resetModules()
vi.unstubAllGlobals()
})
// Why: regression guard. The CLI "create agent" flow emits
// `ui:activateWorktree` to switch the user to the new workspace. A prior
// implementation hand-rolled the activation (setActiveRepo + setActiveView
// + setActiveWorktree + ensureWorktreeHasInitialTerminal +
// revealWorktreeInSidebar), which bypassed recordWorktreeVisit and left
// the back/forward buttons ignoring the CLI-driven switch. This test pins
// the handler to the canonical `activateAndRevealWorktree` helper, which
// is the single place that records the visit in history.
it('routes CLI-driven activation through activateAndRevealWorktree so back/forward history is recorded', async () => {
const activateAndRevealWorktree = vi.fn()
const fetchWorktrees = vi.fn().mockResolvedValue(undefined)
const activateWorktreeListenerRef: {
current:
| ((data: {
repoId: string
worktreeId: string
setup?: { runnerScriptPath: string; envVars: Record<string, string> }
}) => void)
| null
} = { current: null }
vi.doMock('react', async () => {
const actual = await vi.importActual<typeof ReactModule>('react')
return {
...actual,
useEffect: (effect: () => void | (() => void)) => {
effect()
}
}
})
vi.doMock('../store', () => ({
useAppStore: {
getState: () => ({
fetchRepos: vi.fn(),
fetchWorktrees,
setUpdateStatus: vi.fn(),
activeModal: null,
closeModal: vi.fn(),
openModal: vi.fn(),
activeWorktreeId: 'wt-old',
activeView: 'terminal',
setActiveView: vi.fn(),
setActiveRepo: vi.fn(),
setActiveWorktree: vi.fn(),
revealWorktreeInSidebar: vi.fn(),
setIsFullScreen: vi.fn(),
updateBrowserPageState: vi.fn(),
activeTabType: 'terminal',
editorFontZoomLevel: 0,
setEditorFontZoomLevel: vi.fn(),
setRateLimitsFromPush: vi.fn(),
setSshConnectionState: vi.fn(),
setSshTargetLabels: vi.fn(),
setPortForwards: vi.fn(),
clearPortForwards: vi.fn(),
setDetectedPorts: vi.fn(),
enqueueSshCredentialRequest: vi.fn(),
removeSshCredentialRequest: vi.fn(),
clearTabPtyId: vi.fn(),
settings: { terminalFontSize: 13 }
})
}
}))
vi.doMock('@/lib/ui-zoom', () => ({
applyUIZoom: vi.fn()
}))
vi.doMock('@/lib/worktree-activation', () => ({
activateAndRevealWorktree,
ensureWorktreeHasInitialTerminal: vi.fn()
}))
vi.doMock('@/components/sidebar/visible-worktrees', () => ({
getVisibleWorktreeIds: () => []
}))
vi.doMock('@/lib/editor-font-zoom', () => ({
nextEditorFontZoomLevel: vi.fn(() => 0),
computeEditorFontSize: vi.fn(() => 13)
}))
vi.doMock('@/components/settings/SettingsConstants', () => ({
zoomLevelToPercent: vi.fn(() => 100),
ZOOM_MIN: -3,
ZOOM_MAX: 3
}))
vi.doMock('@/lib/zoom-events', () => ({
dispatchZoomLevelChanged: vi.fn()
}))
vi.stubGlobal('window', {
api: {
repos: { onChanged: () => () => {} },
worktrees: { onChanged: () => () => {} },
ui: {
onOpenSettings: () => () => {},
onToggleLeftSidebar: () => () => {},
onToggleRightSidebar: () => () => {},
onToggleWorktreePalette: () => () => {},
onOpenQuickOpen: () => () => {},
onOpenNewWorkspace: () => () => {},
onJumpToWorktreeIndex: () => () => {},
onWorktreeHistoryNavigate: () => () => {},
onActivateWorktree: (
listener: (data: {
repoId: string
worktreeId: string
setup?: { runnerScriptPath: string; envVars: Record<string, string> }
}) => void
) => {
activateWorktreeListenerRef.current = listener
return () => {}
},
onCreateTerminal: () => () => {},
onRequestTerminalCreate: () => () => {},
replyTerminalCreate: () => {},
onSplitTerminal: () => () => {},
onRenameTerminal: () => () => {},
onFocusTerminal: () => () => {},
onCloseTerminal: () => () => {},
onNewBrowserTab: () => () => {},
onRequestTabCreate: () => () => {},
replyTabCreate: () => {},
onRequestTabClose: () => () => {},
replyTabClose: () => {},
onNewTerminalTab: () => () => {},
onCloseActiveTab: () => () => {},
onSwitchTab: () => () => {},
onSwitchTerminalTab: () => () => {},
onToggleStatusBar: () => () => {},
onFullscreenChanged: () => () => {},
onTerminalZoom: () => () => {},
getZoomLevel: () => 0,
set: vi.fn()
},
updater: {
getStatus: () => Promise.resolve({ state: 'idle' }),
onStatus: () => () => {},
onClearDismissal: () => () => {}
},
browser: {
onGuestLoadFailed: () => () => {},
onOpenLinkInOrcaTab: () => () => {},
onNavigationUpdate: () => () => {},
onActivateView: () => () => {}
},
rateLimits: {
get: () => Promise.resolve({ limits: {}, lastUpdatedAt: Date.now() }),
onUpdate: () => () => {}
},
ssh: {
listTargets: () => Promise.resolve([]),
listPortForwards: () => Promise.resolve([]),
listDetectedPorts: () => Promise.resolve([]),
getState: () => Promise.resolve(null),
onStateChanged: () => () => {},
onCredentialRequest: () => () => {},
onPortForwardsChanged: () => () => {},
onDetectedPortsChanged: () => () => {},
onCredentialResolved: () => () => {}
}
}
})
const { useIpcEvents } = await import('./useIpcEvents')
useIpcEvents()
await Promise.resolve()
if (typeof activateWorktreeListenerRef.current !== 'function') {
throw new Error('Expected onActivateWorktree listener to be registered')
}
const setup = { runnerScriptPath: '/tmp/setup.sh', envVars: { FOO: 'bar' } }
activateWorktreeListenerRef.current({
repoId: 'repo-1',
worktreeId: 'wt-new',
setup
})
// Wait for the async IPC handler (it awaits fetchWorktrees before activating).
await new Promise((resolve) => setTimeout(resolve, 0))
await new Promise((resolve) => setTimeout(resolve, 0))
// Worktrees must be fetched first so activateAndRevealWorktree can resolve
// the CLI-created worktree out of store state.
expect(fetchWorktrees).toHaveBeenCalledWith('repo-1')
// The core regression guard: the handler must delegate to the canonical
// activation helper (which records the visit in history) rather than
// hand-rolling the activation steps and skipping recordWorktreeVisit.
// `setup` must be passed through the `setup` opt — not positionally
// mis-aliased into `startup`, which was a latent bug in the original
// hand-rolled path.
expect(activateAndRevealWorktree).toHaveBeenCalledTimes(1)
expect(activateAndRevealWorktree).toHaveBeenCalledWith('wt-new', { setup })
})
})

View File

@ -2,10 +2,7 @@
import { useEffect } from 'react'
import { useAppStore } from '../store'
import { applyUIZoom } from '@/lib/ui-zoom'
import {
activateAndRevealWorktree,
ensureWorktreeHasInitialTerminal
} from '@/lib/worktree-activation'
import { activateAndRevealWorktree } from '@/lib/worktree-activation'
import { SPLIT_TERMINAL_PANE_EVENT, CLOSE_TERMINAL_PANE_EVENT } from '@/constants/terminal'
import type { SplitTerminalPaneDetail, CloseTerminalPaneDetail } from '@/constants/terminal'
import { getVisibleWorktreeIds } from '@/components/sidebar/visible-worktrees'
@ -141,18 +138,16 @@ export function useIpcEvents(): void {
unsubs.push(
window.api.ui.onActivateWorktree(({ repoId, worktreeId, setup }) => {
void (async () => {
const store = useAppStore.getState()
await store.fetchWorktrees(repoId)
// Why: CLI-created worktrees should feel identical to UI-created
// worktrees. The renderer owns the "active worktree -> first tab"
// behavior today, so we explicitly replay that activation sequence
// after the runtime creates a worktree outside the renderer.
store.setActiveRepo(repoId)
store.setActiveView('terminal')
store.setActiveWorktree(worktreeId)
ensureWorktreeHasInitialTerminal(store, worktreeId, setup)
store.revealWorktreeInSidebar(worktreeId)
// Why: fetch worktrees first so the activation helper can resolve
// the CLI-created worktree via findWorktreeById — it arrived from
// the main process and is not yet in the renderer state.
await useAppStore.getState().fetchWorktrees(repoId)
// Why: route through activateAndRevealWorktree so CLI-created
// worktrees share the canonical activation path with UI-created
// ones. This records the visit in the back/forward history stack
// (recordWorktreeVisit), without which the nav buttons would
// ignore the CLI-driven workspace switch.
activateAndRevealWorktree(worktreeId, { setup })
})().catch((error) => {
console.error('Failed to activate CLI-created worktree:', error)
})