Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion src/renderer/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -1467,6 +1471,7 @@ function App(): React.JSX.Element {
hideAutomationGeneratedWorkspaces,
showDotfilesByWorktree,
filterRepoIds,
activeView,
acknowledgedAgentsByPaneKey
])

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1047,7 +1047,7 @@ export default function AutomationsPage(): React.JSX.Element {
}, [automationHostTargetKey, isLoading, pendingAutomationRunNavigation, refresh])

const hydratePersistedUIState = useCallback(async (): Promise<void> => {
useAppStore.getState().hydratePersistedUI(await window.api.ui.get())
useAppStore.getState().hydratePersistedUI(await window.api.ui.get(), 'sync')
}, [])

useEffect(() => {
Expand Down
2 changes: 1 addition & 1 deletion src/renderer/src/hooks/useIpcEvents.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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')
})
)

Expand Down
3 changes: 2 additions & 1 deletion src/renderer/src/lib/startup-ui-hydration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand All @@ -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')
Expand Down
5 changes: 3 additions & 2 deletions src/renderer/src/lib/startup-ui-hydration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand All @@ -34,6 +34,7 @@ export function getStartupErrorFallbackUI(uiHydrated: boolean): PersistedUIState
return {
lastActiveRepoId: null,
lastActiveWorktreeId: null,
activeView: 'terminal',
sidebarWidth: 280,
rightSidebarOpen: true,
rightSidebarTab: 'explorer',
Expand Down
90 changes: 90 additions & 0 deletions src/renderer/src/store/slices/ui.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand Down
56 changes: 44 additions & 12 deletions src/renderer/src/store/slices/ui.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,8 @@ import type {
WorktreeCardMode,
WorkspaceHostOrder,
WorkspaceHostScope,
VisibleWorkspaceHostIds
VisibleWorkspaceHostIds,
TopLevelView
} from '../../../../shared/types'
import type { UsagePercentageDisplay } from '../../../../shared/usage-percentage-display'
import {
Expand Down Expand Up @@ -497,6 +498,37 @@ function hydratedUIPartialMatchesState(state: AppState, hydrated: Partial<UISlic
)
}

// Record keys are exhaustive over TopLevelView, so a new view can't be silently missed.
const TOP_LEVEL_VIEW_LOOKUP: Record<TopLevelView, true> = {
terminal: true,
settings: true,
tasks: true,
activity: true,
automations: true,
space: true,
skills: true,
mobile: true
}
const KNOWN_TOP_LEVEL_VIEWS = new Set<string>(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 {
Expand Down Expand Up @@ -590,15 +622,7 @@ export type UISlice = {
acknowledgedAgentsByPaneKey: Record<string, number>
acknowledgeAgents: (paneKeys: string[]) => void
unacknowledgeAgents: (paneKeys: string[]) => void
activeView:
| 'terminal'
| 'settings'
| 'tasks'
| 'activity'
| 'automations'
| 'space'
| 'skills'
| 'mobile'
activeView: TopLevelView
previousViewBeforeTasks:
| 'terminal'
| 'settings'
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -2324,7 +2348,7 @@ export const createUISlice: StateCreator<AppState, [], [], UISlice> = (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)
Expand Down Expand Up @@ -2523,6 +2547,14 @@ export const createUISlice: StateCreator<AppState, [], [], UISlice> = (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
Expand Down
1 change: 1 addition & 0 deletions src/shared/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -464,6 +464,7 @@ export function getDefaultUIState(): PersistedUIState {
return {
lastActiveRepoId: null,
lastActiveWorktreeId: null,
activeView: 'terminal',
sidebarWidth: 280,
rightSidebarOpen: true,
rightSidebarTab: 'explorer',
Expand Down
16 changes: 16 additions & 0 deletions src/shared/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3227,9 +3227,25 @@ export type WorkspaceHostScope = 'all' | 'local' | `ssh:${string}` | `runtime:${
export type VisibleWorkspaceHostIds = Exclude<WorkspaceHostScope, 'all'>[] | null
export type WorkspaceHostOrder = Exclude<WorkspaceHostScope, 'all'>[]

/** 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
Expand Down
Loading