fix(tasks): normalize provider settings contract (#2468)

- repair drifted task provider defaults on load and settings updates
- split availability filtering from restored-default source resolution
- use the restored source set for Tasks prefetch
- add regression coverage for invalid defaults and drifted settings
This commit is contained in:
Jinjing 2026-05-20 18:38:39 -07:00 committed by GitHub
parent e70c896039
commit 56cb5237a1
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 234 additions and 43 deletions

View File

@ -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,

View File

@ -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)

View File

@ -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,

View File

@ -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,

View File

@ -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' }],

View File

@ -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<AppState, [], [], SettingsSlice>
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(

View File

@ -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<AppState>)
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()

View File

@ -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<AppState, [], [], UISlice> = (set, get)
// be deduped. This removes ~300800ms 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

View File

@ -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'])
})

View File

@ -4,6 +4,34 @@ export const TASK_PROVIDERS: readonly TaskProvider[] = ['github', 'gitlab', 'lin
const TASK_PROVIDER_SET = new Set<TaskProvider>(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(