diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index eeaff50abb5..9e111a6cb4d 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -1151,7 +1151,7 @@ function App(): React.JSX.Element { // UI writer and clobber ui.json (sidebar width, sort, filters, etc.). const fallbackUI = getStartupErrorFallbackUI(uiHydrated) if (fallbackUI) { - actions.hydratePersistedUI(fallbackUI) + actions.hydratePersistedUI(fallbackUI, 'startup') } // Why (issue #1158): surface a sticky, dismissible toast so the // user knows they're in degraded "no-save" mode. Without this, every @@ -1441,6 +1441,10 @@ function App(): React.JSX.Element { hideAutomationGeneratedWorkspaces, showDotfilesByWorktree, filterRepoIds, + // Why: persist the active view so a reload restores it. openTaskPage etc. + // mutate activeView directly (not via setActiveView), so the value-keyed + // writer is what catches every transition. + activeView, // Why: rides the same debounced save so dashboard auto-acks (which fire // on focus/visibility) and the in-memory ack cleanup paths in // agent-status.ts (close/dismiss) both flow to disk through map @@ -1467,6 +1471,7 @@ function App(): React.JSX.Element { hideAutomationGeneratedWorkspaces, showDotfilesByWorktree, filterRepoIds, + activeView, acknowledgedAgentsByPaneKey ]) diff --git a/src/renderer/src/components/automations/AutomationsPage.tsx b/src/renderer/src/components/automations/AutomationsPage.tsx index 322f2170f4d..305e89ba35b 100644 --- a/src/renderer/src/components/automations/AutomationsPage.tsx +++ b/src/renderer/src/components/automations/AutomationsPage.tsx @@ -1047,7 +1047,7 @@ export default function AutomationsPage(): React.JSX.Element { }, [automationHostTargetKey, isLoading, pendingAutomationRunNavigation, refresh]) const hydratePersistedUIState = useCallback(async (): Promise => { - useAppStore.getState().hydratePersistedUI(await window.api.ui.get()) + useAppStore.getState().hydratePersistedUI(await window.api.ui.get(), 'sync') }, []) useEffect(() => { diff --git a/src/renderer/src/hooks/useIpcEvents.ts b/src/renderer/src/hooks/useIpcEvents.ts index aadcc09e230..cd72db3a44b 100644 --- a/src/renderer/src/hooks/useIpcEvents.ts +++ b/src/renderer/src/hooks/useIpcEvents.ts @@ -1226,7 +1226,7 @@ export function useIpcEvents(): void { // the desktop re-hydrates and the sidebar reflects it live — bi-directional. unsubs.push( window.api.ui.onStateChanged((ui) => { - useAppStore.getState().hydratePersistedUI(ui) + useAppStore.getState().hydratePersistedUI(ui, 'sync') }) ) diff --git a/src/renderer/src/lib/startup-ui-hydration.test.ts b/src/renderer/src/lib/startup-ui-hydration.test.ts index 0f6e5e0acfd..13e954ca260 100644 --- a/src/renderer/src/lib/startup-ui-hydration.test.ts +++ b/src/renderer/src/lib/startup-ui-hydration.test.ts @@ -39,7 +39,7 @@ describe('startup UI hydration fallback', () => { } expect(hydratePersistedUI).toHaveBeenCalledTimes(1) - expect(hydratePersistedUI).toHaveBeenCalledWith(persistedUI) + expect(hydratePersistedUI).toHaveBeenCalledWith(persistedUI, 'startup') }) it('returns fallback defaults when startup fails before persisted UI is hydrated', () => { @@ -51,6 +51,7 @@ describe('startup UI hydration fallback', () => { } expect(hydratePersistedUI).toHaveBeenCalledTimes(1) + expect(hydratePersistedUI.mock.calls[0][0].activeView).toBe('terminal') expect(hydratePersistedUI.mock.calls[0][0].sidebarWidth).toBe(280) expect(hydratePersistedUI.mock.calls[0][0].groupBy).toBe('repo') expect(hydratePersistedUI.mock.calls[0][0].sortBy).toBe('name') diff --git a/src/renderer/src/lib/startup-ui-hydration.ts b/src/renderer/src/lib/startup-ui-hydration.ts index d219a468ce6..ba8775cbf72 100644 --- a/src/renderer/src/lib/startup-ui-hydration.ts +++ b/src/renderer/src/lib/startup-ui-hydration.ts @@ -13,13 +13,13 @@ export function hydratePersistedUIAfterStartupRead({ }: { persistedUI: PersistedUIState cancelled: boolean - hydratePersistedUI: (ui: PersistedUIState) => void + hydratePersistedUI: (ui: PersistedUIState, source?: 'startup' | 'sync') => void }): boolean { if (cancelled) { return false } - hydratePersistedUI(persistedUI) + hydratePersistedUI(persistedUI, 'startup') return true } @@ -34,6 +34,7 @@ export function getStartupErrorFallbackUI(uiHydrated: boolean): PersistedUIState return { lastActiveRepoId: null, lastActiveWorktreeId: null, + activeView: 'terminal', sidebarWidth: 280, rightSidebarOpen: true, rightSidebarTab: 'explorer', diff --git a/src/renderer/src/store/slices/ui.test.ts b/src/renderer/src/store/slices/ui.test.ts index 3e5b8d928e5..6fe4000b4be 100644 --- a/src/renderer/src/store/slices/ui.test.ts +++ b/src/renderer/src/store/slices/ui.test.ts @@ -727,6 +727,96 @@ describe('createUISlice hydratePersistedUI', () => { expect(createUIStore().getState().workspaceHostOrder).toEqual([]) }) + it('defaults the persisted active view to terminal', () => { + expect(getDefaultUIState().activeView).toBe('terminal') + expect(createUIStore().getState().activeView).toBe('terminal') + }) + + it('restores the persisted active top-level view on hydration', () => { + const store = createUIStore() + + store.getState().hydratePersistedUI(makePersistedUI({ activeView: 'tasks' }), 'startup') + + expect(store.getState().activeView).toBe('tasks') + }) + + it('falls back to terminal when persisted active view is missing (older data)', () => { + const store = createUIStore() + store.setState({ activeView: 'tasks' }) + + store.getState().hydratePersistedUI( + { + ...makePersistedUI(), + activeView: undefined as unknown as PersistedUIState['activeView'] + }, + 'startup' + ) + + expect(store.getState().activeView).toBe('terminal') + }) + + it('falls back to terminal when the persisted active view is not a known view', () => { + const store = createUIStore() + + store.getState().hydratePersistedUI( + makePersistedUI({ + activeView: 'not-a-real-view' as unknown as PersistedUIState['activeView'] + }), + 'startup' + ) + + expect(store.getState().activeView).toBe('terminal') + }) + + it('drops a persisted activity view when experimental activity is disabled', () => { + const store = createUIStore() + store.setState({ + settings: { experimentalActivity: false } as AppState['settings'] + }) + + store.getState().hydratePersistedUI(makePersistedUI({ activeView: 'activity' }), 'startup') + + expect(store.getState().activeView).toBe('terminal') + }) + + it('restores a persisted activity view when experimental activity is enabled', () => { + const store = createUIStore() + store.setState({ + settings: { experimentalActivity: true } as AppState['settings'] + }) + + store.getState().hydratePersistedUI(makePersistedUI({ activeView: 'activity' }), 'startup') + + expect(store.getState().activeView).toBe('activity') + }) + + it('restores a default-on view (mobile) even when its nav button is hidden', () => { + const store = createUIStore() + store.setState({ + settings: { showMobileButton: false } as AppState['settings'] + }) + + store.getState().hydratePersistedUI(makePersistedUI({ activeView: 'mobile' }), 'startup') + + expect(store.getState().activeView).toBe('mobile') + }) + + it('does not overwrite the current view on a later cross-window sync hydration', () => { + const store = createUIStore() + store.getState().hydratePersistedUI(makePersistedUI({ activeView: 'tasks' }), 'startup') + expect(store.getState().activeView).toBe('tasks') + + store + .getState() + .hydratePersistedUI( + makePersistedUI({ activeView: 'terminal', rightSidebarOpen: false }), + 'sync' + ) + + expect(store.getState().activeView).toBe('tasks') + expect(store.getState().rightSidebarOpen).toBe(false) + }) + it('preserves the current right sidebar width when older persisted UI omits it', () => { const store = createUIStore() diff --git a/src/renderer/src/store/slices/ui.ts b/src/renderer/src/store/slices/ui.ts index bcc32db82f6..5992498c303 100644 --- a/src/renderer/src/store/slices/ui.ts +++ b/src/renderer/src/store/slices/ui.ts @@ -27,7 +27,8 @@ import type { WorktreeCardMode, WorkspaceHostOrder, WorkspaceHostScope, - VisibleWorkspaceHostIds + VisibleWorkspaceHostIds, + TopLevelView } from '../../../../shared/types' import type { UsagePercentageDisplay } from '../../../../shared/usage-percentage-display' import { @@ -497,6 +498,37 @@ function hydratedUIPartialMatchesState(state: AppState, hydrated: Partial = { + terminal: true, + settings: true, + tasks: true, + activity: true, + automations: true, + space: true, + skills: true, + mobile: true +} +const KNOWN_TOP_LEVEL_VIEWS = new Set(Object.keys(TOP_LEVEL_VIEW_LOOKUP)) + +function sanitizeHydratedActiveView( + value: PersistedUIState['activeView'], + experimentalActivityEnabled: boolean +): TopLevelView { + // Why: older data (pre-activeView) or a view a different build doesn't have + // falls back to terminal rather than rendering nothing. + if (typeof value !== 'string' || !KNOWN_TOP_LEVEL_VIEWS.has(value)) { + return 'terminal' + } + // Why: activity is hidden when its setting is off, so restoring it lands on a + // hidden page (same guard as closeSettingsPage). mobile/automations stay + // functional when hidden, so only activity is gated here. + if (value === 'activity' && !experimentalActivityEnabled) { + return 'terminal' + } + return value as TopLevelView +} + let agentSendTargetModeInstanceCounter = 0 function createAgentSendTargetModeInstanceId(): string { @@ -590,15 +622,7 @@ export type UISlice = { acknowledgedAgentsByPaneKey: Record acknowledgeAgents: (paneKeys: string[]) => void unacknowledgeAgents: (paneKeys: string[]) => void - activeView: - | 'terminal' - | 'settings' - | 'tasks' - | 'activity' - | 'automations' - | 'space' - | 'skills' - | 'mobile' + activeView: TopLevelView previousViewBeforeTasks: | 'terminal' | 'settings' @@ -950,7 +974,7 @@ export type UISlice = { setUIZoomLevel: (level: number) => void editorFontZoomLevel: number setEditorFontZoomLevel: (level: number) => void - hydratePersistedUI: (ui: PersistedUIState) => void + hydratePersistedUI: (ui: PersistedUIState, source?: 'startup' | 'sync') => void updateStatus: UpdateStatus setUpdateStatus: (status: UpdateStatus) => void // Why: cached changelog from the last 'available' status so the card still has @@ -2324,7 +2348,7 @@ export const createUISlice: StateCreator = (set, get) editorFontZoomLevel: 0, setEditorFontZoomLevel: (level) => set({ editorFontZoomLevel: level }), - hydratePersistedUI: (ui) => + hydratePersistedUI: (ui, source = 'sync') => set((s) => { const validRepoIds = new Set(s.repos.map((repo) => repo.id)) const persistedFilterRepoIds = sanitizePersistedRepoIds(ui.filterRepoIds) @@ -2523,6 +2547,14 @@ export const createUISlice: StateCreator = (set, get) workspaceCleanupDismissals: sanitizeWorkspaceCleanupDismissals( ui.workspaceCleanup?.dismissals ), + // Why: restore the view only from the startup hydration. The same action also + // runs on every cross-window ui:stateChanged broadcast (source 'sync', the + // default); re-applying activeView there would yank the user's current + // per-window view (navigation state, not a synced preference). + activeView: + source === 'startup' + ? sanitizeHydratedActiveView(ui.activeView, s.settings?.experimentalActivity === true) + : s.activeView, persistedUIReady: true } // Why: main rebroadcasts UI written by any client. Identical hydration must diff --git a/src/shared/constants.ts b/src/shared/constants.ts index e476825e245..44a195d7d1e 100644 --- a/src/shared/constants.ts +++ b/src/shared/constants.ts @@ -464,6 +464,7 @@ export function getDefaultUIState(): PersistedUIState { return { lastActiveRepoId: null, lastActiveWorktreeId: null, + activeView: 'terminal', sidebarWidth: 280, rightSidebarOpen: true, rightSidebarTab: 'explorer', diff --git a/src/shared/types.ts b/src/shared/types.ts index 06573cb9914..b88fef653dd 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -3227,9 +3227,25 @@ export type WorkspaceHostScope = 'all' | 'local' | `ssh:${string}` | `runtime:${ export type VisibleWorkspaceHostIds = Exclude[] | null export type WorkspaceHostOrder = Exclude[] +/** The active top-level section shown in the main content area. */ +export type TopLevelView = + | 'terminal' + | 'settings' + | 'tasks' + | 'activity' + | 'automations' + | 'space' + | 'skills' + | 'mobile' + export type PersistedUIState = { lastActiveRepoId: string | null lastActiveWorktreeId: string | null + /** Active top-level view at save time, restored on reload/relaunch so the app + * reopens where the user left off instead of snapping back to the terminal. + * Sanitized on hydration (unknown value or a now-gated view falls back to + * 'terminal'). */ + activeView: TopLevelView sidebarWidth: number rightSidebarOpen: boolean rightSidebarTab: RightSidebarTab diff --git a/tests/e2e/active-view-restart-restore.spec.ts b/tests/e2e/active-view-restart-restore.spec.ts new file mode 100644 index 00000000000..093e5774d76 --- /dev/null +++ b/tests/e2e/active-view-restart-restore.spec.ts @@ -0,0 +1,115 @@ +/** + * The active top-level view survives a full app restart. + * + * Reproduces the reported bug (renderer reload / relaunch always snapped back + * to the terminal, discarding whichever top-level view — Tasks, Automations, + * etc. — the user had open) and asserts the fix: activeView now rides the + * PersistedUIState pipeline and is restored on the first (startup) hydration. + * + * Restart-persistence lives in E2E, not a store unit test: it needs the real + * write -> orca-data.json -> ui.get() -> hydratePersistedUI round-trip across + * two Electron launches sharing one userDataDir, then the render layer proving + * the page actually came back — with a real repo/worktree attached so the + * relaunch also exercises the startup worktree hydration path (which must not + * force the view back to the terminal). + */ + +import { existsSync, readFileSync } from 'node:fs' +import type { ElectronApplication, Page } from '@stablyai/playwright-test' +import { test, expect } from './helpers/orca-app' +import { getStoreState, waitForSessionReady } from './helpers/store' +import { attachRepoAndOpenTerminal, createRestartSession } from './helpers/orca-restart' +import { TEST_REPO_PATH_FILE } from './global-setup' + +function seededRepoPathOrSkip(): string { + const repoPath = existsSync(TEST_REPO_PATH_FILE) + ? readFileSync(TEST_REPO_PATH_FILE, 'utf-8').trim() + : '' + test.skip(!repoPath || !existsSync(repoPath), 'Global setup did not produce a seeded test repo') + return repoPath +} + +async function readPersistedActiveView(page: Page): Promise { + return page.evaluate(() => window.api.ui.get().then((ui) => ui.activeView)) +} + +test('restores the active top-level view (Tasks) after an app restart', async (// oxlint-disable-next-line no-empty-pattern -- Playwright's second fixture arg is testInfo; the first must be an object destructure to opt out of the default fixture set. +{}, testInfo) => { + test.setTimeout(300_000) + const repoPath = seededRepoPathOrSkip() + const session = createRestartSession(testInfo) + let firstApp: ElectronApplication | null = null + let secondApp: ElectronApplication | null = null + try { + const first = await session.launch() + firstApp = first.app + await waitForSessionReady(first.page) + // Attach a repo + open its terminal so there is an active worktree; without + // one the app renders Landing instead of the view switch. This also settles + // startup worktree activation before we navigate. + await attachRepoAndOpenTerminal(first.page, repoPath) + + // Precondition: attaching lands on the terminal. + expect(await getStoreState(first.page, 'activeView')).toBe('terminal') + + // Navigate to a non-terminal top-level view (store drives setup; the DOM + // proves the outcome, per tests/e2e/AGENTS.md). + await first.page.evaluate(() => { + const store = window.__store + if (!store) { + throw new Error('window.__store is not available') + } + store.getState().openTaskPage() + }) + await expect + .poll(async () => getStoreState(first.page, 'activeView'), { timeout: 10_000 }) + .toBe('tasks') + // Locale-independent render proof: the tasks source-filter chrome is on + // screen (getByRole('Close tasks') is unusable — the label is localized). + await expect( + first.page.locator('[data-contextual-tour-target="tasks-source-filters"]') + ).toBeVisible({ timeout: 10_000 }) + // And the terminal grid is not the active surface. + await expect(first.page.locator('.xterm')).not.toBeVisible({ timeout: 10_000 }) + + // The debounced writer must flush the view to the main-process UI state + // before we quit, so the relaunch reads it back from disk. + await expect + .poll(async () => readPersistedActiveView(first.page), { timeout: 10_000 }) + .toBe('tasks') + + await session.close(firstApp) + firstApp = null + + // Relaunch against the same userDataDir — the real reload/restore path. + const second = await session.launch() + secondApp = second.app + await waitForSessionReady(second.page) + + // Fix: the restored launch reopens Tasks instead of resetting to terminal, + // and neither the cross-window sync re-hydration nor startup worktree + // hydration clobbers the restored view. + await expect + .poll(async () => getStoreState(second.page, 'activeView'), { timeout: 10_000 }) + .toBe('tasks') + // Render-layer proof: the Tasks page chrome is on screen and the terminal + // is not — i.e. the relaunch did not snap back to the terminal. + await expect( + second.page.locator('[data-contextual-tour-target="tasks-source-filters"]') + ).toBeVisible({ timeout: 10_000 }) + await expect(second.page.locator('.xterm')).not.toBeVisible({ timeout: 10_000 }) + } finally { + // Guard each step so a failing close still runs the remaining cleanup. + for (const app of [secondApp, firstApp]) { + if (!app) { + continue + } + try { + await session.close(app) + } catch { + // best-effort cleanup + } + } + await session.dispose() + } +})