Skip to content
Open
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
2 changes: 1 addition & 1 deletion app/electron/tray-settings.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
4 changes: 4 additions & 0 deletions app/electron/tray-settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -417,6 +417,10 @@ export function sanitizeDockPatch(patch: unknown): Record<string, unknown> {
}
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)
Expand Down
59 changes: 59 additions & 0 deletions tests/windows-dock-autoseed.test.ts
Original file line number Diff line number Diff line change
@@ -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> = {}): 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()
})
})
13 changes: 8 additions & 5 deletions windows/src/Dock.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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 }))
Expand Down
35 changes: 30 additions & 5 deletions windows/src/lib/dockPrefs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>()
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<DockPrefs> | 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
Expand Down
9 changes: 6 additions & 3 deletions windows/src/settings/GeneralPane.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand Down Expand Up @@ -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 })}
/>
}
/>
Expand Down
Loading