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:
parent
e70c896039
commit
56cb5237a1
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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' }],
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
||||
|
|
|
|||
|
|
@ -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 ~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
|
||||
|
|
|
|||
|
|
@ -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'])
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
Loading…
Reference in New Issue