diff --git a/src/main/persistence.test.ts b/src/main/persistence.test.ts index 081920aec..a23af5a24 100644 --- a/src/main/persistence.test.ts +++ b/src/main/persistence.test.ts @@ -693,6 +693,38 @@ describe('Store', () => { expect(store.getSettings().visibleTaskProviders).toEqual(['gitlab']) }) + it('repairs drifted task provider defaults on load', async () => { + writeDataFile({ + schemaVersion: 1, + repos: [], + worktreeMeta: {}, + settings: { visibleTaskProviders: ['linear'], defaultTaskSource: 'github' }, + ui: {}, + githubCache: { pr: {}, issue: {} }, + workspaceSession: {} + }) + + const store = await createStore() + expect(store.getSettings().defaultTaskSource).toBe('github') + expect(store.getSettings().visibleTaskProviders).toEqual(['github', 'linear']) + }) + + it('normalizes invalid task provider defaults on load', async () => { + writeDataFile({ + schemaVersion: 1, + repos: [], + worktreeMeta: {}, + settings: { visibleTaskProviders: ['gitlab'], defaultTaskSource: 'jira' as never }, + ui: {}, + githubCache: { pr: {}, issue: {} }, + workspaceSession: {} + }) + + const store = await createStore() + expect(store.getSettings().defaultTaskSource).toBe('gitlab') + expect(store.getSettings().visibleTaskProviders).toEqual(['gitlab']) + }) + it('normalizes persisted open-in applications on load', async () => { writeDataFile({ schemaVersion: 1, diff --git a/src/main/persistence.ts b/src/main/persistence.ts index d11009d40..8f2b39b6a 100644 --- a/src/main/persistence.ts +++ b/src/main/persistence.ts @@ -78,7 +78,7 @@ import { pruneLocalTerminalScrollbackBuffers } from '../shared/workspace-session import { pruneWorkspaceSessionBrowserHistory } from '../shared/workspace-session-browser-history' import { getRepoIdFromWorktreeId, getWorktreePathBasenameFromId } from '../shared/worktree-id' import { normalizeTerminalQuickCommands } from '../shared/terminal-quick-commands' -import { normalizeVisibleTaskProviders } from '../shared/task-providers' +import { normalizeTaskProviderSettings } from '../shared/task-providers' import { normalizeOpenInApplications } from '../shared/open-in-applications' import { DEFAULT_WORKSPACE_STATUS_ID, @@ -1359,6 +1359,10 @@ export class Store { const migratedExperimentalActivity = experimentalActivityDefaultedOffForAllUsers ? (parsed.settings?.experimentalActivity ?? false) : false + const taskProviderSettings = normalizeTaskProviderSettings({ + visibleTaskProviders: parsed.settings?.visibleTaskProviders, + defaultTaskSource: parsed.settings?.defaultTaskSource + }) result = { ...defaults, ...parsed, @@ -1382,9 +1386,8 @@ export class Store { terminalQuickCommands: normalizeTerminalQuickCommands( parsed.settings?.terminalQuickCommands ), - visibleTaskProviders: normalizeVisibleTaskProviders( - parsed.settings?.visibleTaskProviders - ), + defaultTaskSource: taskProviderSettings.defaultTaskSource, + visibleTaskProviders: taskProviderSettings.visibleTaskProviders, openInApplications: normalizeOpenInApplications(parsed.settings?.openInApplications), notifications: normalizeNotificationSettings(parsed.settings?.notifications), voice: { @@ -2262,10 +2265,19 @@ export class Store { updates.terminalQuickCommands ) } - if ('visibleTaskProviders' in updates) { - sanitizedUpdates.visibleTaskProviders = normalizeVisibleTaskProviders( - updates.visibleTaskProviders - ) + if ('visibleTaskProviders' in updates || 'defaultTaskSource' in updates) { + const taskProviderSettings = normalizeTaskProviderSettings({ + visibleTaskProviders: + 'visibleTaskProviders' in updates + ? updates.visibleTaskProviders + : this.state.settings.visibleTaskProviders, + defaultTaskSource: + 'defaultTaskSource' in updates + ? updates.defaultTaskSource + : this.state.settings.defaultTaskSource + }) + sanitizedUpdates.defaultTaskSource = taskProviderSettings.defaultTaskSource + sanitizedUpdates.visibleTaskProviders = taskProviderSettings.visibleTaskProviders } if ('openInApplications' in updates) { sanitizedUpdates.openInApplications = normalizeOpenInApplications(updates.openInApplications) diff --git a/src/renderer/src/components/TaskPage.tsx b/src/renderer/src/components/TaskPage.tsx index 83841cc3d..5285f725e 100644 --- a/src/renderer/src/components/TaskPage.tsx +++ b/src/renderer/src/components/TaskPage.tsx @@ -138,8 +138,8 @@ import { linearUpdateIssue } from '@/runtime/runtime-linear-client' import { - filterAvailableTaskProviders, normalizeVisibleTaskProviders, + restoreAvailableDefaultTaskProvider, resolveVisibleTaskProvider } from '../../../shared/task-providers' @@ -1801,7 +1801,7 @@ export default function TaskPage(): React.JSX.Element { const defaultTaskSource = settings?.defaultTaskSource ?? 'github' const visibleTaskProviders = useMemo( () => - filterAvailableTaskProviders( + restoreAvailableDefaultTaskProvider( preferredVisibleTaskProviders, { gitlabInstalled: preflightStatus?.glab?.installed === true, diff --git a/src/renderer/src/components/sidebar/SidebarNav.tsx b/src/renderer/src/components/sidebar/SidebarNav.tsx index f1b16f6ee..01ee324cc 100644 --- a/src/renderer/src/components/sidebar/SidebarNav.tsx +++ b/src/renderer/src/components/sidebar/SidebarNav.tsx @@ -9,8 +9,8 @@ import { getTaskPresetQuery, PER_REPO_FETCH_LIMIT } from '@/lib/new-workspace' import { LinearIcon } from '@/components/icons/LinearIcon' import { migrationUnsupportedToAgentStatusEntry } from '@/lib/migration-unsupported-agent-entry' import { - filterAvailableTaskProviders, normalizeVisibleTaskProviders, + restoreAvailableDefaultTaskProvider, resolveVisibleTaskProvider } from '../../../../shared/task-providers' @@ -49,7 +49,7 @@ const SidebarNav = React.memo(function SidebarNav() { ) const visibleTaskProviders = React.useMemo( () => - filterAvailableTaskProviders( + restoreAvailableDefaultTaskProvider( preferredVisibleTaskProviders, { gitlabInstalled: preflightStatus?.glab?.installed === true, diff --git a/src/renderer/src/store/slices/settings.test.ts b/src/renderer/src/store/slices/settings.test.ts index 6dc4aa27a..d04fd0e49 100644 --- a/src/renderer/src/store/slices/settings.test.ts +++ b/src/renderer/src/store/slices/settings.test.ts @@ -86,6 +86,29 @@ beforeEach(() => { }) describe('createSettingsSlice runtime switching', () => { + it('repairs drifted task provider settings before sending updates', async () => { + settingsSet.mockResolvedValueOnce({ + visibleTaskProviders: ['github', 'linear'], + defaultTaskSource: 'github' + }) + const store = createTestStore() + store.setState({ + settings: { + visibleTaskProviders: ['linear'], + defaultTaskSource: 'github' + } as AppState['settings'] + }) + + await store.getState().updateSettings({ + visibleTaskProviders: ['linear'] + }) + + expect(settingsSet).toHaveBeenCalledWith({ + visibleTaskProviders: ['github', 'linear'], + defaultTaskSource: 'github' + }) + }) + it('rebases local state to the authoritative settings:set response', async () => { settingsSet.mockResolvedValueOnce({ openInApplications: [{ id: 'cursor', label: 'Cursor', command: 'cursor' }], diff --git a/src/renderer/src/store/slices/settings.ts b/src/renderer/src/store/slices/settings.ts index 9f4da4017..15cd1412c 100644 --- a/src/renderer/src/store/slices/settings.ts +++ b/src/renderer/src/store/slices/settings.ts @@ -8,7 +8,7 @@ import { getRemoteRuntimeTerminalHandle } from '@/runtime/runtime-terminal-stream' import { normalizeTerminalQuickCommands } from '../../../../shared/terminal-quick-commands' -import { normalizeVisibleTaskProviders } from '../../../../shared/task-providers' +import { normalizeTaskProviderSettings } from '../../../../shared/task-providers' import { normalizeOpenInApplications } from '../../../../shared/open-in-applications' import { createSettingsSearchState, type SettingsSearchState } from './settings-search-state' @@ -244,10 +244,19 @@ export const createSettingsSlice: StateCreator updates.terminalQuickCommands ) } - if ('visibleTaskProviders' in updates) { - sanitizedUpdates.visibleTaskProviders = normalizeVisibleTaskProviders( - updates.visibleTaskProviders - ) + if ('visibleTaskProviders' in updates || 'defaultTaskSource' in updates) { + const taskProviderSettings = normalizeTaskProviderSettings({ + visibleTaskProviders: + 'visibleTaskProviders' in updates + ? updates.visibleTaskProviders + : get().settings?.visibleTaskProviders, + defaultTaskSource: + 'defaultTaskSource' in updates + ? updates.defaultTaskSource + : get().settings?.defaultTaskSource + }) + sanitizedUpdates.defaultTaskSource = taskProviderSettings.defaultTaskSource + sanitizedUpdates.visibleTaskProviders = taskProviderSettings.visibleTaskProviders } if ('openInApplications' in updates) { sanitizedUpdates.openInApplications = normalizeOpenInApplications( diff --git a/src/renderer/src/store/slices/ui.test.ts b/src/renderer/src/store/slices/ui.test.ts index fa96f047e..a0adeeeed 100644 --- a/src/renderer/src/store/slices/ui.test.ts +++ b/src/renderer/src/store/slices/ui.test.ts @@ -493,6 +493,44 @@ describe('createUISlice hydratePersistedUI', () => { }) describe('createUISlice settings navigation', () => { + it('prefetches the restored default task source when provider settings drifted', () => { + const store = createUIStore() + const prefetchWorkItems = vi.fn() + const prefetchLinearIssues = vi.fn() + + store.setState({ + repos: [ + { + id: 'repo-1', + path: '/repo', + displayName: 'Repo', + badgeColor: 'blue', + addedAt: 1, + kind: 'git' + } + ], + settings: { + visibleTaskProviders: ['linear'], + defaultTaskSource: 'github', + defaultTaskViewPreset: 'all' + } as unknown as AppState['settings'], + linearStatus: { connected: true } as AppState['linearStatus'], + preflightStatus: { glab: { installed: false } } as AppState['preflightStatus'], + prefetchWorkItems, + prefetchLinearIssues + } as unknown as Partial) + + store.getState().openTaskPage() + + expect(prefetchWorkItems).toHaveBeenCalledWith( + 'repo-1', + '/repo', + expect.any(Number), + 'is:issue is:open' + ) + expect(prefetchLinearIssues).not.toHaveBeenCalled() + }) + it('returns to the tasks page after visiting settings from an in-progress draft', () => { const store = createUIStore() diff --git a/src/renderer/src/store/slices/ui.ts b/src/renderer/src/store/slices/ui.ts index 7d0f6b400..988a4b371 100644 --- a/src/renderer/src/store/slices/ui.ts +++ b/src/renderer/src/store/slices/ui.ts @@ -26,6 +26,7 @@ import { normalizeFeatureTipIds, type FeatureTipId } from '../../../../shared/fe import { PER_REPO_FETCH_LIMIT } from '../../../../shared/work-items' import { normalizeVisibleTaskProviders, + restoreAvailableDefaultTaskProvider, resolveVisibleTaskProvider } from '../../../../shared/task-providers' import { @@ -559,7 +560,17 @@ export const createUISlice: StateCreator = (set, get) // be deduped. This removes ~300–800ms of perceived latency on initial // page load. const state = get() - const visibleTaskProviders = normalizeVisibleTaskProviders(state.settings?.visibleTaskProviders) + const preferredVisibleTaskProviders = normalizeVisibleTaskProviders( + state.settings?.visibleTaskProviders + ) + const visibleTaskProviders = restoreAvailableDefaultTaskProvider( + preferredVisibleTaskProviders, + { + gitlabInstalled: state.preflightStatus?.glab?.installed === true, + linearConnected: state.linearStatus?.connected === true + }, + state.settings?.defaultTaskSource + ) const resolvedSource = resolveVisibleTaskProvider( data.taskSource ?? state.settings?.defaultTaskSource, visibleTaskProviders diff --git a/src/shared/task-providers.test.ts b/src/shared/task-providers.test.ts index 2dc991907..6e27a4e0b 100644 --- a/src/shared/task-providers.test.ts +++ b/src/shared/task-providers.test.ts @@ -1,7 +1,9 @@ import { describe, expect, it } from 'vitest' import { filterAvailableTaskProviders, + normalizeTaskProviderSettings, normalizeVisibleTaskProviders, + restoreAvailableDefaultTaskProvider, resolveVisibleTaskProvider } from './task-providers' @@ -17,6 +19,30 @@ describe('task providers', () => { expect(normalizeVisibleTaskProviders([])).toEqual(['github', 'gitlab', 'linear']) }) + it('restores a valid saved default when provider settings drifted', () => { + expect( + normalizeTaskProviderSettings({ + visibleTaskProviders: ['linear'], + defaultTaskSource: 'github' + }) + ).toEqual({ + defaultTaskSource: 'github', + visibleTaskProviders: ['github', 'linear'] + }) + }) + + it('normalizes invalid saved defaults to the first visible provider', () => { + expect( + normalizeTaskProviderSettings({ + visibleTaskProviders: ['gitlab'], + defaultTaskSource: 'jira' + }) + ).toEqual({ + defaultTaskSource: 'gitlab', + visibleTaskProviders: ['gitlab'] + }) + }) + it('resolves hidden preferred providers to the first visible provider', () => { expect(resolveVisibleTaskProvider('github', ['linear'])).toBe('linear') }) @@ -32,7 +58,7 @@ describe('task providers', () => { it('keeps an available saved default visible when provider visibility drifted', () => { expect( - filterAvailableTaskProviders( + restoreAvailableDefaultTaskProvider( ['linear'], { gitlabInstalled: false, @@ -45,7 +71,7 @@ describe('task providers', () => { it('preserves intentionally narrowed providers when the saved default matches them', () => { expect( - filterAvailableTaskProviders( + restoreAvailableDefaultTaskProvider( ['linear'], { gitlabInstalled: false, @@ -58,7 +84,7 @@ describe('task providers', () => { it('does not restore an unavailable saved default', () => { expect( - filterAvailableTaskProviders( + restoreAvailableDefaultTaskProvider( ['linear'], { gitlabInstalled: false, @@ -69,15 +95,15 @@ describe('task providers', () => { ).toEqual(['linear']) }) - it('ignores malformed saved defaults when every preferred provider is unavailable', () => { + it('ignores invalid saved defaults while restoring visible providers', () => { expect( - filterAvailableTaskProviders( + restoreAvailableDefaultTaskProvider( ['gitlab'], { gitlabInstalled: false, linearConnected: true }, - 'jira' as never + 'jira' ) ).toEqual(['github']) }) diff --git a/src/shared/task-providers.ts b/src/shared/task-providers.ts index 70499698d..3cf6c3b20 100644 --- a/src/shared/task-providers.ts +++ b/src/shared/task-providers.ts @@ -4,6 +4,34 @@ export const TASK_PROVIDERS: readonly TaskProvider[] = ['github', 'gitlab', 'lin const TASK_PROVIDER_SET = new Set(TASK_PROVIDERS) +export function isTaskProvider(value: unknown): value is TaskProvider { + return TASK_PROVIDER_SET.has(value as TaskProvider) +} + +export function normalizeTaskProviderSettings(value: { + visibleTaskProviders: unknown + defaultTaskSource: unknown +}): { visibleTaskProviders: TaskProvider[]; defaultTaskSource: TaskProvider } { + const visibleTaskProviders = normalizeVisibleTaskProviders(value.visibleTaskProviders) + const defaultTaskSource = isTaskProvider(value.defaultTaskSource) + ? value.defaultTaskSource + : resolveVisibleTaskProvider('github', visibleTaskProviders) + + if (visibleTaskProviders.includes(defaultTaskSource)) { + return { visibleTaskProviders, defaultTaskSource } + } + + // Why: older profiles can keep a saved default while the visible-provider + // list drifted. Persist the default back into the list so every surface + // reads the same settings contract. + return { + defaultTaskSource, + visibleTaskProviders: TASK_PROVIDERS.filter( + (provider) => provider === defaultTaskSource || visibleTaskProviders.includes(provider) + ) + } +} + export function normalizeVisibleTaskProviders(value: unknown): TaskProvider[] { if (!Array.isArray(value)) { return [...TASK_PROVIDERS] @@ -31,36 +59,48 @@ export type TaskProviderAvailability = { export function filterAvailableTaskProviders( visibleProviders: readonly TaskProvider[], - availability: TaskProviderAvailability, - preferredProvider?: TaskProvider | null + availability: TaskProviderAvailability ): TaskProvider[] { - const normalizedPreferredProvider = - preferredProvider && TASK_PROVIDER_SET.has(preferredProvider) ? preferredProvider : null - const isProviderAvailable = (provider: TaskProvider): boolean => { - if (provider === 'github') { - return true - } - if (provider === 'gitlab') { - return availability.gitlabInstalled - } - return availability.linearConnected - } + const available = visibleProviders.filter((provider) => + isTaskProviderAvailable(provider, availability) + ) - const available = visibleProviders.filter(isProviderAvailable) + return available.length > 0 ? available : ['github'] +} + +export function restoreAvailableDefaultTaskProvider( + visibleProviders: readonly TaskProvider[], + availability: TaskProviderAvailability, + preferredProvider: unknown +): TaskProvider[] { + const available = filterAvailableTaskProviders(visibleProviders, availability) // Why: older or drifted settings can hide the saved default while another // provider becomes available. Keep that default reachable after hydration. if ( - normalizedPreferredProvider && - isProviderAvailable(normalizedPreferredProvider) && - !available.includes(normalizedPreferredProvider) + isTaskProvider(preferredProvider) && + isTaskProviderAvailable(preferredProvider, availability) && + !available.includes(preferredProvider) ) { return TASK_PROVIDERS.filter( - (provider) => provider === normalizedPreferredProvider || available.includes(provider) + (provider) => provider === preferredProvider || available.includes(provider) ) } - return available.length > 0 ? available : ['github'] + return available +} + +function isTaskProviderAvailable( + provider: TaskProvider, + availability: TaskProviderAvailability +): boolean { + if (provider === 'github') { + return true + } + if (provider === 'gitlab') { + return availability.gitlabInstalled + } + return availability.linearConnected } export function resolveVisibleTaskProvider(