From 3c8d026056c3c2b3b6a84381884cefededbbf0cd Mon Sep 17 00:00:00 2001 From: Brandin Date: Sun, 18 Oct 2026 17:00:24 -0400 Subject: [PATCH] fix: keep a saved Capacity Dock provider across quota refreshes The dock rewrote its provider list on every quota refresh until a provider switch was flipped. A refresh that saw Cursor and missed Codex saved Cursor as the resting provider. Auto-seed now keeps the saved resting provider and the providers already on the rail. It appends a newly connected provider only when there is a free slot. Choosing a resting provider in settings or by clicking a ring latches that choice, so later refreshes stop rewriting it. The resting menu keeps showing the saved provider while a quota read is missing. --- app/electron/tray-settings.test.ts | 2 +- app/electron/tray-settings.ts | 4 ++ tests/windows-dock-autoseed.test.ts | 59 ++++++++++++++++++++++++++++ windows/src/Dock.tsx | 13 +++--- windows/src/lib/dockPrefs.ts | 35 ++++++++++++++--- windows/src/settings/GeneralPane.tsx | 9 +++-- 6 files changed, 108 insertions(+), 14 deletions(-) create mode 100644 tests/windows-dock-autoseed.test.ts diff --git a/app/electron/tray-settings.test.ts b/app/electron/tray-settings.test.ts index 1e8b28938..708c4ebe7 100644 --- a/app/electron/tray-settings.test.ts +++ b/app/electron/tray-settings.test.ts @@ -181,7 +181,7 @@ describe('sanitizeDockPatch', () => { expect(sanitizeDockPatch({ theme: 'glass' })).toEqual({ theme: 'glass' }) expect(sanitizeDockPatch({ gaugeShape: 'squircle' })).toEqual({ gaugeShape: 'squircle' }) expect(sanitizeDockPatch({ claudeProfiles: 'separate' })).toEqual({ claudeProfiles: 'separate' }) - expect(sanitizeDockPatch({ preferred: 'codex' })).toEqual({ preferred: 'codex' }) + expect(sanitizeDockPatch({ preferred: 'codex' })).toEqual({ preferred: 'codex', manualSelection: true }) }) // Editing the set by hand is what stops the tray app seeding it from whatever is connected. diff --git a/app/electron/tray-settings.ts b/app/electron/tray-settings.ts index 5884c34df..6f344cb1e 100644 --- a/app/electron/tray-settings.ts +++ b/app/electron/tray-settings.ts @@ -417,6 +417,10 @@ export function sanitizeDockPatch(patch: unknown): Record { } if ('preferred' in patch) { out.preferred = typeof patch.preferred === 'string' && PROVIDER_ID.test(patch.preferred) ? patch.preferred : null + // Picking who the rail rests on is the user taking the set over, same as editing the + // switches. Until this latches, the tray app's auto-seed rewrites that choice whenever + // the connected list changes. + if (out.preferred !== null) out.manualSelection = true } if ('providers' in patch) { const providers = Array.isArray(patch.providers) diff --git a/tests/windows-dock-autoseed.test.ts b/tests/windows-dock-autoseed.test.ts new file mode 100644 index 000000000..584ec453e --- /dev/null +++ b/tests/windows-dock-autoseed.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, it, vi } from 'vitest' + +vi.mock('@tauri-apps/api/core', () => ({ invoke: vi.fn() })) +vi.mock('@tauri-apps/api/event', () => ({ listen: vi.fn(async () => () => {}) })) + +import { autoSeed, DEFAULT_DOCK_PREFS, DOCK_MAX_AUTO_PROVIDERS, type DockPrefs } from '../windows/src/lib/dockPrefs' + +function prefs(over: Partial = {}): DockPrefs { + return { ...DEFAULT_DOCK_PREFS, enabled: true, ...over } +} + +describe('Capacity Dock auto-seed', () => { + it('leaves the rail alone once the user has taken the set', () => { + const current = prefs({ providers: ['codex'], preferred: 'codex', manualSelection: true }) + expect(autoSeed(current, ['cursor'])).toBeNull() + }) + + it('does not hand a saved Codex rail to Cursor when Codex misses one refresh', () => { + const current = prefs({ + providers: ['codex', 'cursor'], + preferred: 'codex', + keepExpanded: true, + }) + expect(autoSeed(current, ['cursor'])).toBeNull() + }) + + it('adds a newly connected provider without moving the resting one', () => { + const current = prefs({ providers: ['codex'], preferred: 'codex', keepExpanded: true }) + expect(autoSeed(current, ['codex', 'cursor'])).toEqual({ providers: ['codex', 'cursor'] }) + }) + + it('puts a resting provider the set does not list yet onto the rail', () => { + const current = prefs({ providers: ['cursor'], preferred: 'codex' }) + expect(autoSeed(current, ['cursor'])).toEqual({ providers: ['codex', 'cursor'] }) + }) + + it('seeds a fresh rail from whoever is connected and rests on the first', () => { + expect(autoSeed(prefs(), ['codex', 'cursor'])).toEqual({ + providers: ['codex', 'cursor'], + preferred: 'codex', + }) + }) + + it('waits, rather than writing an empty rail, when nobody is connected yet', () => { + expect(autoSeed(prefs(), [])).toBeNull() + }) + + it('does not clear a rail when a refresh reports nobody', () => { + const current = prefs({ providers: ['codex'], preferred: 'codex', keepExpanded: true }) + expect(autoSeed(current, [])).toBeNull() + }) + + it('does not evict the resting provider to make room at the cap', () => { + const held = ['codex', 'claude', 'gemini', 'copilot', 'antigravity'] + expect(held).toHaveLength(DOCK_MAX_AUTO_PROVIDERS) + const current = prefs({ providers: held, preferred: 'codex' }) + expect(autoSeed(current, ['cursor', ...held])).toBeNull() + }) +}) diff --git a/windows/src/Dock.tsx b/windows/src/Dock.tsx index 40ad3fb18..09d5aee50 100644 --- a/windows/src/Dock.tsx +++ b/windows/src/Dock.tsx @@ -695,9 +695,10 @@ export function Dock() { if (prefsLoaded) void refreshQuota() }, [prefs.claudeProfiles, prefsLoaded]) - // Until the user edits the set in the settings window, the dock follows what is connected, - // up to the mac's five. The write reaches the settings window through the same event, so a - // window open on the Capacity Dock section fills its switches in as the answer arrives. + // Until the user edits the set in the settings window, a newly connected provider fills a + // free slot. The resting provider stays the one already saved. The write reaches the + // settings window through the same event, so a window open on the Capacity Dock section + // fills its switches in as the answer arrives. useEffect(() => { if (!prefsLoaded || quota.fetchedAt === null) return const connected = quota.providers.filter((p) => p.available).map((p) => p.id) @@ -1022,8 +1023,10 @@ export function Dock() { cancel('collapse') if (provider.id !== resolvedPreferredId) { track('dock_provider_switch', { provider: provider.id }) - setPrefs((current) => ({ ...current, preferred: provider.id })) - void invoke('dock_set_preferred', { id: provider.id }) + // A click is the same choice as the settings menu. Latch it, or the next quota + // refresh's auto-seed still treats the rail as unowned and can move it back. + setPrefs((current) => ({ ...current, preferred: provider.id, manualSelection: true })) + void writeDockPrefs({ preferred: provider.id, manualSelection: true }).then(setPrefs) setInteraction((i) => (i.pinned ? i : { ...i, pinned: true })) } else { setInteraction((i) => ({ ...i, pinned: !i.pinned })) diff --git a/windows/src/lib/dockPrefs.ts b/windows/src/lib/dockPrefs.ts index 860d430d5..a930ceff1 100644 --- a/windows/src/lib/dockPrefs.ts +++ b/windows/src/lib/dockPrefs.ts @@ -153,18 +153,43 @@ export function normalizedPreferred(preferred: string | null, selected: string[] return selected[0] ?? null } -/// CapacityDockPreferences.autoSeedFromConnected: until the user edits the provider set, the -/// dock mirrors whatever is connected, capped, so a fresh install shows what is actually -/// active rather than nothing. Returns the patch to write, or null when there is nothing to do. +/// Ids in order, skipping duplicates, capped at the rail's auto-seed limit. +function cappedIds(ids: string[]): string[] { + const out: string[] = [] + const seen = new Set() + for (const id of ids) { + if (seen.has(id)) continue + seen.add(id) + out.push(id) + if (out.length >= DOCK_MAX_AUTO_PROVIDERS) break + } + return out +} + +/// CapacityDockPreferences.autoSeedFromConnected: until the user edits the provider set, a +/// newly connected provider fills a free slot. The resting provider and anyone already on +/// the rail stay, in their current order, so one quota refresh that misses Codex cannot +/// replace the rail with Cursor and leave that as the saved choice. Returns the patch to +/// write, or null when there is nothing to do. export function autoSeed(prefs: DockPrefs, connected: string[]): Partial | null { if (prefs.manualSelection) return null - const desired = connected.slice(0, DOCK_MAX_AUTO_PROVIDERS) // Nothing connected yet is a reason to wait, not a reason to empty the dock. + if (connected.length === 0 && prefs.providers.length === 0) return null + // A resting provider that this refresh did not report is kept at the front of the + // candidates so the cap cannot drop it to make room for whoever did answer. + const pinned = prefs.preferred !== null && !prefs.providers.includes(prefs.preferred) + ? [prefs.preferred] + : [] + const desired = cappedIds([...pinned, ...prefs.providers, ...connected]) if (desired.length === 0) return null if (desired.length === prefs.providers.length && desired.every((id, i) => prefs.providers[i] === id)) { return null } - return { providers: desired, preferred: normalizedPreferred(prefs.preferred, desired) } + const preferred = normalizedPreferred(prefs.preferred, desired) + // Leave a saved resting provider out of the patch when it did not change. The file is + // shared with the desktop app, and a key this write does not mean to change stays as it was. + if (preferred === prefs.preferred) return { providers: desired } + return { providers: desired, preferred } } /// CapacityDockProviderSelection.canDeselect: the rail must never end up with nothing to diff --git a/windows/src/settings/GeneralPane.tsx b/windows/src/settings/GeneralPane.tsx index 1b5f1594f..45cb93fcb 100644 --- a/windows/src/settings/GeneralPane.tsx +++ b/windows/src/settings/GeneralPane.tsx @@ -324,8 +324,11 @@ function CapacityDockSection({ quota }: { quota: QuotaState }) { const all = quota.providers.map(p => p.id) const manageable = manageableProviders(all, prefs.providers, isConnected) // The rail can only rest on a provider it is actually showing. With nothing chosen yet it - // shows everything connected, which is what an empty selection means. - const restable = (prefs.providers.length > 0 ? prefs.providers : all).filter(isConnected) + // shows everything connected, which is what an empty selection means. The saved resting + // provider stays in the menu while its quota read is missing, so the control does not + // present whoever else answered as if the user had picked them. + const candidates = prefs.providers.length > 0 ? prefs.providers : all + const restable = candidates.filter(id => isConnected(id) || id === prefs.preferred) const resting = restable.includes(prefs.preferred ?? '') ? prefs.preferred! : restable[0] ?? '' const toggleProvider = (id: string, on: boolean) => { @@ -362,7 +365,7 @@ function CapacityDockSection({ quota }: { quota: QuotaState }) { ariaLabel="Resting provider" value={resting} options={restable.map(id => ({ id, label: nameOf(id) }))} - onChange={preferred => apply({ preferred })} + onChange={preferred => apply({ preferred, manualSelection: true })} /> } />