diff --git a/src/main/native-chat/agent-session-journal/journal-submission-positions.test.ts b/src/main/native-chat/agent-session-journal/journal-submission-positions.test.ts index d8aa5b4870c..2ae99a42b45 100644 --- a/src/main/native-chat/agent-session-journal/journal-submission-positions.test.ts +++ b/src/main/native-chat/agent-session-journal/journal-submission-positions.test.ts @@ -7,6 +7,7 @@ import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, describe, expect, it } from 'vitest' +import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' import { agentSessionFailureFact } from '../../../shared/agent-session-failure' import { agentSessionFailureWords } from '../../../shared/agent-session-failure-words' import { @@ -26,7 +27,7 @@ const IDENTITY: AgentSessionJournalIdentity = { workspaceId: 'workspace-1', hostId: 'host-1', agent: 'codex', - providerHandle: { kind: 'codex', threadId: 'thread-1' } + providerHandle: codexProviderHandle('thread-1') } const BODY: AgentJournalMessageItem = { kind: 'message', diff --git a/src/main/persistence-ui-state.test.ts b/src/main/persistence-ui-state.test.ts index 10bea531533..4324e61cdf5 100644 --- a/src/main/persistence-ui-state.test.ts +++ b/src/main/persistence-ui-state.test.ts @@ -92,6 +92,19 @@ describe('Store', () => { expect(ui.dismissedUpdateVersion).toBeNull() }) + it.each([false, true])( + 'restores sidebarOpen=%s from disk without changing the right sidebar', + async (sidebarOpen) => { + const store = await createStore() + store.updateUI({ sidebarOpen, rightSidebarOpen: false }) + store.flush() + + const reloaded = await createStore() + expect(reloaded.getUI().sidebarOpen).toBe(sidebarOpen) + expect(reloaded.getUI().rightSidebarOpen).toBe(false) + } + ) + it('round-trips and normalizes the host-qualified manual repo order', async () => { const store = await createStore() store.updateUI({ diff --git a/src/main/runtime/rpc/methods/client-ui-pairing-local-fields.test.ts b/src/main/runtime/rpc/methods/client-ui-pairing-local-fields.test.ts index 4e3beb3a771..9bb125d6065 100644 --- a/src/main/runtime/rpc/methods/client-ui-pairing-local-fields.test.ts +++ b/src/main/runtime/rpc/methods/client-ui-pairing-local-fields.test.ts @@ -46,6 +46,7 @@ describe('client UI RPC pairing-local field seams', () => { { hostId: 'runtime:web-11111111-2222-3333-4444-555555555555', repoId: 'repo-a' } ], workspaceHostOrder: ['runtime:web-11111111-2222-3333-4444-555555555555', 'local'], + sidebarOpen: false, agentsVisibleHostIds: ['runtime:web-11111111-2222-3333-4444-555555555555'], agentsFilterRepoIds: ['repo-a'], agentsHideWorkspacesFromOtherDevices: true, diff --git a/src/renderer/src/app-shell/use-persisted-ui-writer.ts b/src/renderer/src/app-shell/use-persisted-ui-writer.ts index 168fb87f302..3b0556a161e 100644 --- a/src/renderer/src/app-shell/use-persisted-ui-writer.ts +++ b/src/renderer/src/app-shell/use-persisted-ui-writer.ts @@ -145,6 +145,7 @@ export function usePersistedUIWriter(): void { const ui = useAppStore( useShallow((s): PersistedUIWriteBaseline => ({ sidebarWidth: s.sidebarWidth, + sidebarOpen: s.sidebarOpen, rightSidebarOpen: s.rightSidebarOpen, rightSidebarTab: s.rightSidebarTab, rightSidebarExplorerView: s.rightSidebarExplorerView, diff --git a/src/renderer/src/app-shell/workspace-view-cross-client-sync.test.tsx b/src/renderer/src/app-shell/workspace-view-cross-client-sync.test.tsx index f5d404b4e10..be236272bf4 100644 --- a/src/renderer/src/app-shell/workspace-view-cross-client-sync.test.tsx +++ b/src/renderer/src/app-shell/workspace-view-cross-client-sync.test.tsx @@ -270,6 +270,40 @@ describe('workspace view preferences: cross-client persistence (STA-5781)', () = expect(after.hideCliCreatedWorkspaces).toBe(before.hideCliCreatedWorkspaces) }) + it('persists a left sidebar close across an unrelated sync and restores it on startup', async () => { + act(() => { + store.getState().toggleSidebar() + authority.set({ sidebarWidth: 320 }) + }) + deliverBroadcasts() + expect(store.getState().sidebarOpen).toBe(false) + await flushDesktopDebounce() + expect(authority.get().sidebarOpen).toBe(false) + + const restarted = createUIStore() + restarted.getState().hydratePersistedUI(authority.get(), 'startup') + expect(restarted.getState().sidebarOpen).toBe(false) + expect(restarted.getState().sidebarWidth).toBe(320) + }) + + it('persists a left sidebar reopen while the close acknowledgement is still pending', async () => { + holdAcks = true + act(() => store.getState().toggleSidebar()) + await flushDesktopDebounce() + expect(authority.get().sidebarOpen).toBe(false) + + act(() => store.getState().toggleSidebar()) + deliverBroadcasts() + expect(store.getState().sidebarOpen).toBe(true) + await resolveAcks() + await flushDesktopDebounce() + await resolveAcks() + deliverBroadcasts() + expect(authority.get().sidebarOpen).toBe(true) + expect(store.getState().sidebarOpen).toBe(true) + expect(store.getState().persistedUIWriteInFlightCounts).toEqual({}) + }) + it('a mobile tap must not revert a desktop change the mobile mirror has not seen', async () => { const mobile = createMobileClient(authority) mobile.sync() diff --git a/src/renderer/src/store/slices/persisted-ui-write-baseline.test.ts b/src/renderer/src/store/slices/persisted-ui-write-baseline.test.ts index f39ef11b03f..0efae37ca0c 100644 --- a/src/renderer/src/store/slices/persisted-ui-write-baseline.test.ts +++ b/src/renderer/src/store/slices/persisted-ui-write-baseline.test.ts @@ -10,6 +10,7 @@ import { function makeBaseline(overrides: Partial = {}): PersistedUIWriteBaseline { return { sidebarWidth: 280, + sidebarOpen: true, rightSidebarOpen: true, rightSidebarTab: 'explorer', rightSidebarExplorerView: 'files', @@ -145,3 +146,10 @@ describe('persistedUIWriteFieldsToWireUpdate', () => { expect(update).toEqual({ hideDefaultBranchWorkspace: true, groupBy: 'none' }) }) }) + +describe('sidebarOpen write round-trip', () => { + it('sends a left sidebar close to main under its persisted key', () => { + const changed = diffPersistedUIWriteFields(makeBaseline({ sidebarOpen: false }), makeBaseline()) + expect(persistedUIWriteFieldsToWireUpdate(changed)).toEqual({ sidebarOpen: false }) + }) +}) diff --git a/src/renderer/src/store/slices/persisted-ui-write-baseline.ts b/src/renderer/src/store/slices/persisted-ui-write-baseline.ts index 158e1b31bf2..47cd8a46b3f 100644 --- a/src/renderer/src/store/slices/persisted-ui-write-baseline.ts +++ b/src/renderer/src/store/slices/persisted-ui-write-baseline.ts @@ -10,6 +10,7 @@ import type { PersistedUIState } from '../../../../shared/persisted-ui-state-typ */ export type PersistedUIWriteBaseline = { sidebarWidth: number + sidebarOpen: boolean rightSidebarOpen: boolean rightSidebarTab: PersistedUIState['rightSidebarTab'] rightSidebarExplorerView: PersistedUIState['rightSidebarExplorerView'] @@ -40,6 +41,7 @@ export type PersistedUIWriteBaseline = { // this module exists to close (see ui-state-schema-parity.ts for the same lesson). const PERSISTED_UI_WRITE_BASELINE_FIELD_SET = { sidebarWidth: true, + sidebarOpen: true, rightSidebarOpen: true, rightSidebarTab: true, rightSidebarExplorerView: true, diff --git a/src/renderer/src/store/slices/ui-contextual-tours.test.ts b/src/renderer/src/store/slices/ui-contextual-tours.test.ts index 7a9adc8af7b..3db02f6e587 100644 --- a/src/renderer/src/store/slices/ui-contextual-tours.test.ts +++ b/src/renderer/src/store/slices/ui-contextual-tours.test.ts @@ -384,8 +384,7 @@ describe('createUISlice contextual tours', () => { stubContextualTourTargets([ '[data-contextual-tour-target="terminal-pane-split-target"], [data-contextual-tour-target="workspace-agent-terminal-tip"]' ]) - store.setState({ sidebarOpen: false }) - store.getState().hydratePersistedUI(makeAutoTourEligibleUI()) + store.getState().hydratePersistedUI(makeAutoTourEligibleUI({ sidebarOpen: false })) store .getState() .requestContextualTour('workspace-agent-sessions', 'setup_guide_parallel_work', false, { diff --git a/src/renderer/src/store/slices/ui-hydration-view-layout.test.ts b/src/renderer/src/store/slices/ui-hydration-view-layout.test.ts index fd01ee2d2a7..7173826d473 100644 --- a/src/renderer/src/store/slices/ui-hydration-view-layout.test.ts +++ b/src/renderer/src/store/slices/ui-hydration-view-layout.test.ts @@ -225,6 +225,43 @@ describe('createUISlice hydratePersistedUI', () => { expect(store.getState().rightSidebarWidth).toBe(360) }) + it('hydrates a persisted closed left sidebar preference', () => { + const store = createUIStore() + + store.getState().hydratePersistedUI(makePersistedUI({ sidebarOpen: false })) + + expect(store.getState().sidebarOpen).toBe(false) + }) + + it('hydrates a persisted open left sidebar preference', () => { + const store = createUIStore() + + store.getState().hydratePersistedUI(makePersistedUI({ sidebarOpen: true })) + + expect(store.getState().sidebarOpen).toBe(true) + }) + + it('hydrates a missing left sidebar preference as open', () => { + const store = createUIStore() + + store.setState({ sidebarOpen: false }) + store.getState().hydratePersistedUI({ ...makePersistedUI(), sidebarOpen: undefined }) + + expect(store.getState().sidebarOpen).toBe(true) + }) + + it('keeps an unsaved left sidebar close when a sync omits the left sidebar preference', () => { + const store = createUIStore() + store.getState().hydratePersistedUI(makePersistedUI(), 'startup') + + store.getState().setSidebarOpen(false) + store.getState().hydratePersistedUI({ ...makePersistedUI(), sidebarOpen: undefined }, 'sync') + + // Why: the baseline must stay open or the writer sees no diff and the close never persists. + expect(store.getState().sidebarOpen).toBe(false) + expect(store.getState().persistedUIWriteBaseline?.sidebarOpen).toBe(true) + }) + it('hydrates a persisted closed right sidebar preference', () => { const store = createUIStore() diff --git a/src/renderer/src/store/slices/ui/ui-slice-hydration-actions.ts b/src/renderer/src/store/slices/ui/ui-slice-hydration-actions.ts index c8da9a29dcc..9df5868597d 100644 --- a/src/renderer/src/store/slices/ui/ui-slice-hydration-actions.ts +++ b/src/renderer/src/store/slices/ui/ui-slice-hydration-actions.ts @@ -88,10 +88,6 @@ export function createUiHydrationActions(set: UISliceSet, _get: UISliceGet): Par // Migration: one-shot old-'recent'→'smart' runs in main (_sortBySmartMigrated), not here, so a deliberate 'recent' choice survives restart. const sortBy = ui.sortBy const statusBarItemsWithGrok = hydrateStatusBarItems(ui) - const rightSidebarRoute = normalizeRightSidebarRoute( - ui.rightSidebarTab, - ui.rightSidebarExplorerView - ) const hydrated = { // Why: persisted widths may be stale/corrupt/hand-edited; clamp during hydration so invalid values can't break layout. sidebarWidth: sanitizePersistedSidebarWidth( @@ -114,9 +110,9 @@ export function createUiHydrationActions(set: UISliceSet, _get: UISliceGet): Par undefined, s.combinedDiffFileTreeWidth ), + sidebarOpen: typeof ui.sidebarOpen === 'boolean' ? ui.sidebarOpen : true, rightSidebarOpen: typeof ui.rightSidebarOpen === 'boolean' ? ui.rightSidebarOpen : true, - rightSidebarTab: rightSidebarRoute.rightSidebarTab, - rightSidebarExplorerView: rightSidebarRoute.rightSidebarExplorerView, + ...normalizeRightSidebarRoute(ui.rightSidebarTab, ui.rightSidebarExplorerView), groupBy: (ui.groupBy as UISlice['groupBy'] | 'parent') === 'parent' ? 'repo' : ui.groupBy, sortBy, // Why: main-process getUI() already normalized this (defaulting to 'manual'); read it through without migrating. diff --git a/src/renderer/src/web/preload-api/web-preference-normalization.ts b/src/renderer/src/web/preload-api/web-preference-normalization.ts index 37b57d0e401..1b805b3c223 100644 --- a/src/renderer/src/web/preload-api/web-preference-normalization.ts +++ b/src/renderer/src/web/preload-api/web-preference-normalization.ts @@ -69,6 +69,7 @@ export function mergeHostWebUIState( hideWorkspacesFromOtherDevices: local.hideWorkspacesFromOtherDevices === true, manualRepoOrder: local.manualRepoOrder, workspaceHostOrder: local.workspaceHostOrder, + sidebarOpen: local.sidebarOpen, agentsVisibleHostIds: local.agentsVisibleHostIds, agentsFilterRepoIds: local.agentsFilterRepoIds, agentsHideWorkspacesFromOtherDevices: local.agentsHideWorkspacesFromOtherDevices === true, diff --git a/src/renderer/src/web/web-preload-api-ui-pairing-local-fields.test.ts b/src/renderer/src/web/web-preload-api-ui-pairing-local-fields.test.ts index e1f9ede3818..7a1cffeaedf 100644 --- a/src/renderer/src/web/web-preload-api-ui-pairing-local-fields.test.ts +++ b/src/renderer/src/web/web-preload-api-ui-pairing-local-fields.test.ts @@ -21,14 +21,13 @@ describe('web UI preload API pairing-local fields', () => { vi.doUnmock('./web-runtime-client') }) - // Census-driven, matching the host-side seam tests: a field added to PAIRING_LOCAL_UI_FIELDS - // without wiring the web read seam fails here rather than shipping. The host sample differs from - // the browser's for every field, so only the pin makes this pass. + // Different host samples expose any missing browser-local pin. const browserLocalUiSamples: Record = { automationHostFilter: { kind: 'host', hostKey: 'browser-local-host-key' }, hideWorkspacesFromOtherDevices: true, manualRepoOrder: [{ hostId: 'runtime:web-env-1', repoId: 'repo-b' }], workspaceHostOrder: ['runtime:web-env-1', 'local'], + sidebarOpen: false, agentsVisibleHostIds: ['runtime:web-env-1'], agentsFilterRepoIds: ['repo-b'], agentsHideWorkspacesFromOtherDevices: true, @@ -47,6 +46,7 @@ describe('web UI preload API pairing-local fields', () => { hideWorkspacesFromOtherDevices: false, manualRepoOrder: [{ hostId: 'local', repoId: 'repo-a' }], workspaceHostOrder: ['local', 'ssh:box'], + sidebarOpen: true, agentsVisibleHostIds: ['local'], agentsFilterRepoIds: ['repo-a'], agentsHideWorkspacesFromOtherDevices: false, @@ -61,9 +61,13 @@ describe('web UI preload API pairing-local fields', () => { manuallyUnreadTurnsByPaneKey: { 'tab-2:leaf-2': 654 } } - it.each(PAIRING_LOCAL_UI_FIELDS.map((field) => [field] as const))( - 'keeps the browser-local %s and never sends it to the host', - async (field) => { + it.each( + PAIRING_LOCAL_UI_FIELDS.flatMap((field) => + (['set', 'setWithAck'] as const).map((method) => [field, method] as const) + ) + )( + 'keeps the browser-local %s and never sends it to the host through %s', + async (field, method) => { const runtimeCalls: { method: string; params: unknown }[] = [] vi.doMock('./web-runtime-client', () => ({ WebRuntimeClient: class { @@ -81,14 +85,18 @@ describe('web UI preload API pairing-local fields', () => { } })) - const browserLocal = { [field]: browserLocalUiSamples[field] } as Partial + const browserLocal: Partial = { [field]: browserLocalUiSamples[field] } const globals = installBrowserGlobals('Linux') writeStoredRuntimeEnvironment(globals.storage) - globals.storage.setItem('orca.web.ui.v1', JSON.stringify(browserLocal)) const { installWebPreloadApi } = await import('./web-preload-api') installWebPreloadApi() - await globals.window.api.ui.set({ ...browserLocal, sidebarWidth: 280 }) + const write = globals.window.api.ui[method] + expect(write).toBeTypeOf('function') + if (!write) { + throw new Error('Missing UI write method') + } + await write({ ...browserLocal, sidebarWidth: 280 }) expect(runtimeCalls[0]).toEqual({ method: 'ui.set', params: { sidebarWidth: 280 } }) await expect(globals.window.api.ui.get()).resolves.toMatchObject(browserLocal) diff --git a/src/shared/pairing-local-ui-fields.test.ts b/src/shared/pairing-local-ui-fields.test.ts index 780b471a1ca..24042ffaa50 100644 --- a/src/shared/pairing-local-ui-fields.test.ts +++ b/src/shared/pairing-local-ui-fields.test.ts @@ -10,6 +10,7 @@ describe('pairing-local UI fields', () => { 'hideWorkspacesFromOtherDevices', 'manualRepoOrder', 'workspaceHostOrder', + 'sidebarOpen', 'agentsVisibleHostIds', 'agentsFilterRepoIds', 'agentsHideWorkspacesFromOtherDevices', diff --git a/src/shared/pairing-local-ui-fields.ts b/src/shared/pairing-local-ui-fields.ts index 65e036699ac..b514db0a3b9 100644 --- a/src/shared/pairing-local-ui-fields.ts +++ b/src/shared/pairing-local-ui-fields.ts @@ -12,6 +12,8 @@ export const PAIRING_LOCAL_UI_FIELDS = [ 'hideWorkspacesFromOtherDevices', 'manualRepoOrder', 'workspaceHostOrder', + // A phone-width browser and a desktop window each choose their own left sidebar, and old hosts reject the unknown key. + 'sidebarOpen', // Agent View filters and presentation belong to each client's host catalog and viewport. 'agentsVisibleHostIds', 'agentsFilterRepoIds', diff --git a/src/shared/persisted-ui-state-types.ts b/src/shared/persisted-ui-state-types.ts index f7708d50206..ecd51429533 100644 --- a/src/shared/persisted-ui-state-types.ts +++ b/src/shared/persisted-ui-state-types.ts @@ -32,6 +32,7 @@ export type PersistedUIState = { /** Active top-level view at save time, restored on relaunch; sanitized to 'terminal' if unknown or now-gated. */ activeView: TopLevelView sidebarWidth: number + sidebarOpen?: boolean rightSidebarOpen: boolean rightSidebarTab: RightSidebarTab rightSidebarExplorerView: RightSidebarExplorerView diff --git a/src/shared/rpc-contract/client-ui-params.ts b/src/shared/rpc-contract/client-ui-params.ts index 08113208e23..9f41e92674e 100644 --- a/src/shared/rpc-contract/client-ui-params.ts +++ b/src/shared/rpc-contract/client-ui-params.ts @@ -127,6 +127,7 @@ export const UiUpdateFields = z // Why: sync hydration ignores this persisted startup view, so paired windows stay put. activeView: TopLevelViewSchema.optional(), sidebarWidth: z.number().finite().optional(), + sidebarOpen: z.boolean().optional(), rightSidebarOpen: z.boolean().optional(), rightSidebarTab: RightSidebarTabParam.optional(), rightSidebarExplorerView: z.enum(['files', 'search']).optional(),