From 4ccfc166d59e4ed5da26becb3a3556f041df215a Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Mon, 5 Oct 2026 18:17:51 -0700 Subject: [PATCH] fix(sidebar): preserve host filters across stale UI updates (#25737) --- .../src/app-shell/use-persisted-ui-writer.ts | 2 + .../workspace-view-cross-client-sync.test.tsx | 34 +++++++++++ .../persisted-ui-write-baseline.test.ts | 13 +++++ .../slices/persisted-ui-write-baseline.ts | 7 +++ .../store/slices/ui-host-filter-sync.test.ts | 58 +++++++++++++++++++ .../slices/ui-hydration-view-layout.test.ts | 14 ++--- .../slices/ui/ui-slice-preference-actions.ts | 6 -- 7 files changed, 118 insertions(+), 16 deletions(-) create mode 100644 src/renderer/src/store/slices/ui-host-filter-sync.test.ts 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 3b0556a161e..ecb78183e03 100644 --- a/src/renderer/src/app-shell/use-persisted-ui-writer.ts +++ b/src/renderer/src/app-shell/use-persisted-ui-writer.ts @@ -156,6 +156,8 @@ export function usePersistedUIWriter(): void { sortBy: s.sortBy, projectOrderBy: s.projectOrderBy, showSleepingWorkspaces: s.showSleepingWorkspaces, + workspaceHostScope: s.workspaceHostScope, + visibleWorkspaceHostIds: s.visibleWorkspaceHostIds, hideDefaultBranchWorkspace: s.hideDefaultBranchWorkspace, hideAutomationGeneratedWorkspaces: s.hideAutomationGeneratedWorkspaces, hideCliCreatedWorkspaces: s.hideCliCreatedWorkspaces, 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 be236272bf4..f3982a1b98e 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('saves a revealed remote host after an unrelated older broadcast', async () => { + act(() => { + authority.set({ workspaceHostScope: 'local', visibleWorkspaceHostIds: ['local'] }) + }) + deliverBroadcasts() + act(() => { + authority.set({ sidebarWidth: 320 }) + store.getState().setVisibleWorkspaceHostIds(['local', 'runtime:m4air']) + }) + pendingBroadcasts.reverse() + deliverBroadcasts() + expect(store.getState().visibleWorkspaceHostIds).toEqual(['local', 'runtime:m4air']) + await flushDesktopDebounce() + deliverBroadcasts() + expect(authority.get().visibleWorkspaceHostIds).toEqual(['local', 'runtime:m4air']) + expect(authority.get().sidebarWidth).toBe(320) + }) + + it('saves All hosts after flipping back during a host-selection write', async () => { + holdAcks = true + act(() => store.getState().setWorkspaceHostScope('local')) + await flushDesktopDebounce() + act(() => store.getState().setWorkspaceHostScope('all')) + deliverBroadcasts() + expect(store.getState().visibleWorkspaceHostIds).toBeNull() + expect(store.getState().workspaceHostScope).toBe('all') + await resolveAcks() + holdAcks = false + await flushDesktopDebounce() + deliverBroadcasts() + expect(authority.get().visibleWorkspaceHostIds).toBeNull() + expect(authority.get().workspaceHostScope).toBe('all') + }) + it('persists a left sidebar close across an unrelated sync and restores it on startup', async () => { act(() => { store.getState().toggleSidebar() 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 0efae37ca0c..6f6ee831b2f 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 @@ -21,6 +21,8 @@ function makeBaseline(overrides: Partial = {}): Persis sortBy: 'recent', projectOrderBy: 'manual', showSleepingWorkspaces: true, + workspaceHostScope: 'all', + visibleWorkspaceHostIds: null, hideDefaultBranchWorkspace: false, hideAutomationGeneratedWorkspaces: false, hideCliCreatedWorkspaces: false, @@ -58,11 +60,13 @@ describe('PERSISTED_UI_WRITE_BASELINE_FIELDS', () => { describe('diffPersistedUIWriteFields', () => { it('is empty when values are equal even across fresh array/record identities', () => { const a = makeBaseline({ + visibleWorkspaceHostIds: ['local', 'runtime:m4air'], filterRepoIds: ['r1', 'r2'], showDotfilesByWorktree: { w1: true }, acknowledgedAgentsByPaneKey: { p1: 5 } }) const b = makeBaseline({ + visibleWorkspaceHostIds: ['local', 'runtime:m4air'], filterRepoIds: ['r1', 'r2'], showDotfilesByWorktree: { w1: true }, acknowledgedAgentsByPaneKey: { p1: 5 } @@ -70,6 +74,15 @@ describe('diffPersistedUIWriteFields', () => { expect(diffPersistedUIWriteFields(a, b)).toEqual({}) }) + it('distinguishes All hosts from a selected host list', () => { + expect( + diffPersistedUIWriteFields( + makeBaseline({ visibleWorkspaceHostIds: null }), + makeBaseline({ visibleWorkspaceHostIds: ['local'] }) + ) + ).toEqual({ visibleWorkspaceHostIds: null }) + }) + it('reports only the diverged fields, valued from the current mirror', () => { const baseline = makeBaseline() const current = makeBaseline({ showSleepingWorkspaces: false, filterRepoIds: ['r1'] }) 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 47cd8a46b3f..11945ea7c44 100644 --- a/src/renderer/src/store/slices/persisted-ui-write-baseline.ts +++ b/src/renderer/src/store/slices/persisted-ui-write-baseline.ts @@ -21,6 +21,8 @@ export type PersistedUIWriteBaseline = { sortBy: PersistedUIState['sortBy'] projectOrderBy: PersistedUIState['projectOrderBy'] showSleepingWorkspaces: boolean + workspaceHostScope: PersistedUIState['workspaceHostScope'] + visibleWorkspaceHostIds: PersistedUIState['visibleWorkspaceHostIds'] hideDefaultBranchWorkspace: boolean hideAutomationGeneratedWorkspaces: boolean hideCliCreatedWorkspaces: boolean @@ -52,6 +54,8 @@ const PERSISTED_UI_WRITE_BASELINE_FIELD_SET = { sortBy: true, projectOrderBy: true, showSleepingWorkspaces: true, + workspaceHostScope: true, + visibleWorkspaceHostIds: true, hideDefaultBranchWorkspace: true, hideAutomationGeneratedWorkspaces: true, hideCliCreatedWorkspaces: true, @@ -104,6 +108,9 @@ function writeFieldEqual(field: keyof PersistedUIWriteBaseline, a: unknown, b: u if (field === 'filterRepoIds') { return stringArrayEqual(a as readonly string[], b as readonly string[]) } + if (field === 'visibleWorkspaceHostIds') { + return a === b || (Array.isArray(a) && Array.isArray(b) && stringArrayEqual(a, b)) + } if ( field === 'explorerDisplayRootByWorktree' || field === 'showDotfilesByWorktree' || diff --git a/src/renderer/src/store/slices/ui-host-filter-sync.test.ts b/src/renderer/src/store/slices/ui-host-filter-sync.test.ts new file mode 100644 index 00000000000..566b45e4c4e --- /dev/null +++ b/src/renderer/src/store/slices/ui-host-filter-sync.test.ts @@ -0,0 +1,58 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { createUIStore, makePersistedUI } from './ui-slice-test-harness' + +beforeEach(() => { + vi.stubGlobal('window', { api: { ui: { set: vi.fn().mockResolvedValue(undefined) } } }) +}) +afterEach(() => vi.unstubAllGlobals()) + +describe('host visibility across saved UI broadcasts', () => { + it('keeps a newly revealed remote host when an older UI snapshot arrives', () => { + const store = createUIStore() + const previous = makePersistedUI({ + workspaceHostScope: 'local', + visibleWorkspaceHostIds: ['local'] + }) + store.getState().hydratePersistedUI(previous) + + store.getState().setVisibleWorkspaceHostIds(['local', 'runtime:m4air']) + store.getState().hydratePersistedUI(previous, 'sync') + + expect(store.getState().visibleWorkspaceHostIds).toEqual(['local', 'runtime:m4air']) + }) + + it('keeps All hosts while the prior selection is being saved', () => { + const store = createUIStore() + const previous = makePersistedUI({ workspaceHostScope: 'all', visibleWorkspaceHostIds: null }) + store.getState().hydratePersistedUI(previous) + const fields = ['workspaceHostScope', 'visibleWorkspaceHostIds'] as const + store.getState().setWorkspaceHostScope('local') + store.getState().notePersistedUIWriteStarted(fields) + store.getState().setWorkspaceHostScope('all') + + store + .getState() + .hydratePersistedUI( + makePersistedUI({ workspaceHostScope: 'local', visibleWorkspaceHostIds: ['local'] }), + 'sync' + ) + + expect(store.getState().workspaceHostScope).toBe('all') + expect(store.getState().visibleWorkspaceHostIds).toBeNull() + }) + + it('accepts another client’s host selection when there is no local edit', () => { + const store = createUIStore() + store.getState().hydratePersistedUI(makePersistedUI()) + store.getState().hydratePersistedUI( + makePersistedUI({ + workspaceHostScope: 'runtime:m4air', + visibleWorkspaceHostIds: ['runtime:m4air'] + }), + 'sync' + ) + + expect(store.getState().workspaceHostScope).toBe('runtime:m4air') + expect(store.getState().visibleWorkspaceHostIds).toEqual(['runtime:m4air']) + }) +}) 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 7173826d473..3392201aca6 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 @@ -480,7 +480,7 @@ describe('createUISlice hydratePersistedUI', () => { expect(setUI).not.toHaveBeenCalled() }) - it('persists workspace host scope changes', () => { + it('updates workspace host scope for the guarded UI writer', () => { const setUI = vi.fn(() => Promise.resolve()) vi.stubGlobal('window', { api: { ui: { set: setUI } } }) const store = createUIStore() @@ -489,13 +489,10 @@ describe('createUISlice hydratePersistedUI', () => { expect(store.getState().workspaceHostScope).toBe('runtime:env-1') expect(store.getState().visibleWorkspaceHostIds).toEqual(['runtime:env-1']) - expect(setUI).toHaveBeenCalledWith({ - workspaceHostScope: 'runtime:env-1', - visibleWorkspaceHostIds: ['runtime:env-1'] - }) + expect(setUI).not.toHaveBeenCalled() }) - it('persists visible workspace host changes independently of focused host', () => { + it('updates host visibility independently of focused host for the guarded UI writer', () => { const setUI = vi.fn(() => Promise.resolve()) vi.stubGlobal('window', { api: { ui: { set: setUI } } }) const store = createUIStore() @@ -505,10 +502,7 @@ describe('createUISlice hydratePersistedUI', () => { expect(store.getState().workspaceHostScope).toBe('runtime:env-1') expect(store.getState().visibleWorkspaceHostIds).toEqual(['local', 'runtime:env-1']) - expect(setUI).toHaveBeenLastCalledWith({ - workspaceHostScope: 'runtime:env-1', - visibleWorkspaceHostIds: ['local', 'runtime:env-1'] - }) + expect(setUI).not.toHaveBeenCalled() }) it('persists workspace host order changes', () => { diff --git a/src/renderer/src/store/slices/ui/ui-slice-preference-actions.ts b/src/renderer/src/store/slices/ui/ui-slice-preference-actions.ts index 27a30efbc53..65979e26db1 100644 --- a/src/renderer/src/store/slices/ui/ui-slice-preference-actions.ts +++ b/src/renderer/src/store/slices/ui/ui-slice-preference-actions.ts @@ -67,9 +67,6 @@ export function createUiPreferenceActions(set: UISliceSet, get: UISliceGet): Par const normalized = normalizeExecutionHostScope(scope) const visibleWorkspaceHostIds = normalized === 'all' ? null : [normalized] set({ workspaceHostScope: normalized, visibleWorkspaceHostIds }) - window.api.ui - .set({ workspaceHostScope: normalized, visibleWorkspaceHostIds }) - .catch(console.error) }, visibleWorkspaceHostIds: null, setVisibleWorkspaceHostIds: (ids) => { @@ -82,9 +79,6 @@ export function createUiPreferenceActions(set: UISliceSet, get: UISliceGet): Par workspaceHostScope = normalized[0] } set({ visibleWorkspaceHostIds: normalized, workspaceHostScope }) - window.api.ui - .set({ visibleWorkspaceHostIds: normalized, workspaceHostScope }) - .catch(console.error) }, workspaceHostOrder: [], setWorkspaceHostOrder: (ids) => {