From d88b62f1155c2d684145b00a2a2caf7d02f87884 Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Sun, 17 May 2026 19:27:44 -0700 Subject: [PATCH] Gate task providers by availability (#2189) * Gate task providers by availability Implement provider availability gating documented in docs/task-provider-availability.md. * Remove task provider availability design doc * Restore task source after provider availability checks - Keep saved GitLab/Linear defaults from being lost while provider checks hydrate - Ignore stale Linear status responses after connect or workspace changes * fix: address review findings --- .gitignore | 1 + src/renderer/src/components/TaskPage.tsx | 51 ++++++- .../new-workspace/SmartWorkspaceNameField.tsx | 107 ++++++++++++--- .../components/settings/IntegrationsPane.tsx | 128 +++++++----------- .../src/components/sidebar/SidebarNav.tsx | 26 +++- src/renderer/src/store/index.ts | 2 + .../src/store/slices/diffComments.test.ts | 2 + src/renderer/src/store/slices/linear.test.ts | 101 +++++++++++++- src/renderer/src/store/slices/linear.ts | 98 ++++++++++---- .../src/store/slices/preflight.test.ts | 108 +++++++++++++++ src/renderer/src/store/slices/preflight.ts | 80 +++++++++++ .../slices/store-session-cascades.test.ts | 2 + .../src/store/slices/store-test-helpers.ts | 2 + src/renderer/src/store/slices/tabs.test.ts | 2 + src/renderer/src/store/types.ts | 2 + src/shared/task-providers.test.ts | 24 +++- src/shared/task-providers.ts | 22 +++ 17 files changed, 622 insertions(+), 136 deletions(-) create mode 100644 src/renderer/src/store/slices/preflight.test.ts create mode 100644 src/renderer/src/store/slices/preflight.ts diff --git a/.gitignore b/.gitignore index ce5220229..f1b49cd3c 100644 --- a/.gitignore +++ b/.gitignore @@ -82,6 +82,7 @@ docs/** !docs/reference/** !docs/STYLEGUIDE.md !docs/configurable-open-in-menu.md +!docs/task-provider-availability.md # Stably CLI (only docs/ are tracked) .stably/* diff --git a/src/renderer/src/components/TaskPage.tsx b/src/renderer/src/components/TaskPage.tsx index 062ba0edd..f0160b84a 100644 --- a/src/renderer/src/components/TaskPage.tsx +++ b/src/renderer/src/components/TaskPage.tsx @@ -93,6 +93,7 @@ import type { import { shouldSuppressEnterSubmit } from '@/lib/new-workspace-enter-guard' import { linearCreateIssue, linearGetIssue } from '@/runtime/runtime-linear-client' import { + filterAvailableTaskProviders, normalizeVisibleTaskProviders, resolveVisibleTaskProvider } from '../../../shared/task-providers' @@ -528,6 +529,8 @@ export default function TaskPage(): React.JSX.Element { const workItemsInvalidationNonce = useAppStore((s) => s.workItemsInvalidationNonce) const linearStatus = useAppStore((s) => s.linearStatus) const linearStatusChecked = useAppStore((s) => s.linearStatusChecked) + const preflightStatus = useAppStore((s) => s.preflightStatus) + const preflightStatusChecked = useAppStore((s) => s.preflightStatusChecked) const connectLinear = useAppStore((s) => s.connectLinear) const selectLinearWorkspace = useAppStore((s) => s.selectLinearWorkspace) const searchLinearIssues = useAppStore((s) => s.searchLinearIssues) @@ -536,6 +539,7 @@ export default function TaskPage(): React.JSX.Element { const getCachedLinearTeams = useAppStore((s) => s.getCachedLinearTeams) const listLinearTeams = useAppStore((s) => s.listLinearTeams) const checkLinearConnection = useAppStore((s) => s.checkLinearConnection) + const refreshPreflightStatus = useAppStore((s) => s.refreshPreflightStatus) const eligibleRepos = useMemo(() => repos.filter((repo) => isGitRepoKind(repo)), [repos]) // Why: initial selection resolution honors (1) an explicit preselection from @@ -612,10 +616,18 @@ export default function TaskPage(): React.JSX.Element { linearStatus.activeWorkspaceId ?? linearWorkspaces[0]?.id ?? null - const visibleTaskProviders = useMemo( + const preferredVisibleTaskProviders = useMemo( () => normalizeVisibleTaskProviders(settings?.visibleTaskProviders), [settings?.visibleTaskProviders] ) + const visibleTaskProviders = useMemo( + () => + filterAvailableTaskProviders(preferredVisibleTaskProviders, { + gitlabInstalled: preflightStatus?.glab?.installed === true, + linearConnected: linearStatus.connected === true + }), + [linearStatus.connected, preferredVisibleTaskProviders, preflightStatus?.glab?.installed] + ) const visibleSourceOptions = useMemo( () => SOURCE_OPTIONS.filter((source) => visibleTaskProviders.includes(source.id)), [visibleTaskProviders] @@ -630,9 +642,12 @@ export default function TaskPage(): React.JSX.Element { const initialTaskQuery = getTaskPresetQuery(defaultTaskViewPreset) const defaultTaskSource = settings?.defaultTaskSource ?? 'github' + const preferredTaskSource = pageData.taskSource ?? defaultTaskSource const [taskSource, setTaskSource] = useState( - resolveVisibleTaskProvider(pageData.taskSource ?? defaultTaskSource, visibleTaskProviders) + resolveVisibleTaskProvider(preferredTaskSource, visibleTaskProviders) ) + const taskSourceManuallyChangedRef = useRef(false) + const lastPageTaskSourceRef = useRef(pageData.taskSource) const taskResumeAppliedRef = useRef(false) const githubSearchPersistReadyRef = useRef(false) const linearSearchPersistReadyRef = useRef(false) @@ -642,11 +657,30 @@ export default function TaskPage(): React.JSX.Element { // icon in the sidebar while the task page is already open. useState only // initializes once, so sync from the store when the value changes. useEffect(() => { + const pageTaskSourceChanged = lastPageTaskSourceRef.current !== pageData.taskSource + lastPageTaskSourceRef.current = pageData.taskSource if (pageData.taskSource) { + if (pageTaskSourceChanged) { + taskSourceManuallyChangedRef.current = false + } else if (taskSourceManuallyChangedRef.current) { + return + } setTaskSource(resolveVisibleTaskProvider(pageData.taskSource, visibleTaskProviders)) } }, [pageData.taskSource, visibleTaskProviders]) + useEffect(() => { + if (taskSourceManuallyChangedRef.current) { + return + } + // Why: GitLab/Linear availability hydrates after mount. If the saved + // default was unavailable during the first render, restore it once the + // relevant check proves the provider can be shown. + if (visibleTaskProviders.includes(preferredTaskSource) && taskSource !== preferredTaskSource) { + setTaskSource(preferredTaskSource) + } + }, [preferredTaskSource, taskSource, visibleTaskProviders]) + useEffect(() => { if (!visibleTaskProviders.includes(taskSource)) { setTaskSource(resolveVisibleTaskProvider(settings?.defaultTaskSource, visibleTaskProviders)) @@ -1782,12 +1816,14 @@ export default function TaskPage(): React.JSX.Element { selectedLinearIssue ]) - // Why: check Linear connection status on mount so the UI can show the - // correct connected/disconnected state without requiring a settings visit. useEffect(() => { - void checkLinearConnection() - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []) + if (!preflightStatusChecked) { + void refreshPreflightStatus() + } + if (!linearStatusChecked) { + void checkLinearConnection() + } + }, [checkLinearConnection, linearStatusChecked, preflightStatusChecked, refreshPreflightStatus]) // Why: debounce the Linear search input so we don't fire a request on every // keystroke — matches the 300ms cadence used for GitHub search. @@ -2031,6 +2067,7 @@ export default function TaskPage(): React.JSX.Element { type="button" disabled={source.disabled} onClick={() => { + taskSourceManuallyChangedRef.current = true setTaskSource(source.id) void updateSettings({ defaultTaskSource: source.id }).catch(() => { toast.error('Failed to save default task source.') diff --git a/src/renderer/src/components/new-workspace/SmartWorkspaceNameField.tsx b/src/renderer/src/components/new-workspace/SmartWorkspaceNameField.tsx index 925baa124..48cd8ff34 100644 --- a/src/renderer/src/components/new-workspace/SmartWorkspaceNameField.tsx +++ b/src/renderer/src/components/new-workspace/SmartWorkspaceNameField.tsx @@ -42,6 +42,7 @@ import { parseGitLabIssueOrMRLink } from '@/lib/gitlab-links' import { cn } from '@/lib/utils' import { LinearIcon } from '@/components/icons/LinearIcon' import { searchRuntimeRepoBaseRefDetails } from '@/runtime/runtime-repo-client' +import { filterAvailableTaskProviders } from '../../../../shared/task-providers' import type { BaseRefSearchResult, GitHubWorkItem, @@ -156,6 +157,9 @@ export default function SmartWorkspaceNameField({ linearStatus, linearStatusChecked, listLinearIssues, + preflightStatus, + preflightStatusChecked, + refreshPreflightStatus, searchLinearIssues, settings } = useAppStore( @@ -167,6 +171,9 @@ export default function SmartWorkspaceNameField({ linearStatus: s.linearStatus, linearStatusChecked: s.linearStatusChecked, listLinearIssues: s.listLinearIssues, + preflightStatus: s.preflightStatus, + preflightStatusChecked: s.preflightStatusChecked, + refreshPreflightStatus: s.refreshPreflightStatus, searchLinearIssues: s.searchLinearIssues, settings: s.settings })) @@ -196,6 +203,29 @@ export default function SmartWorkspaceNameField({ link: NonNullable> matchingRepo: RepoOption | null } | null>(null) + const availableTaskProviders = useMemo( + () => + filterAvailableTaskProviders(['github', 'gitlab', 'linear'], { + gitlabInstalled: preflightStatus?.glab?.installed === true, + linearConnected: linearStatus.connected === true + }), + [linearStatus.connected, preflightStatus?.glab?.installed] + ) + const gitlabAvailable = availableTaskProviders.includes('gitlab') + const linearAvailable = availableTaskProviders.includes('linear') + const availableModes = useMemo( + () => + MODES.filter((item) => { + if (item.id === 'gitlab') { + return gitlabAvailable + } + if (item.id === 'linear') { + return linearAvailable + } + return true + }), + [gitlabAvailable, linearAvailable] + ) const setInputNode = useCallback( (node: HTMLInputElement | null) => { @@ -211,14 +241,34 @@ export default function SmartWorkspaceNameField({ if (disabled) { return } - // Why: the composer can be opened before any other Linear-aware surface - // (TaskPage, IntegrationsPane) has had a chance to refresh status, leaving - // `linearStatus.connected=false` even when the user is actually connected. - // Trigger a check on mount if it hasn't run this session. + if (!preflightStatusChecked) { + void refreshPreflightStatus() + } if (!linearStatusChecked) { void checkLinearConnection() } - }, [checkLinearConnection, disabled, linearStatusChecked]) + }, [ + checkLinearConnection, + disabled, + linearStatusChecked, + preflightStatusChecked, + refreshPreflightStatus + ]) + + useEffect(() => { + if ((mode === 'gitlab' && gitlabAvailable) || (mode === 'linear' && linearAvailable)) { + return + } + if (mode !== 'gitlab' && mode !== 'linear') { + return + } + setMode('smart') + setGitlabItems([]) + setLinearIssues([]) + setGitlabLoading(false) + setLinearLoading(false) + setCommandValue('') + }, [gitlabAvailable, linearAvailable, mode]) useEffect(() => { if (!disabled) { @@ -255,7 +305,7 @@ export default function SmartWorkspaceNameField({ const parsedGhLink = useMemo(() => parseGitHubIssueOrPRLink(debouncedQuery), [debouncedQuery]) const shouldQueryGithub = mode === 'smart' || mode === 'github' const shouldQueryBranches = mode === 'smart' || mode === 'branches' - const shouldQueryLinear = mode === 'smart' || mode === 'linear' + const shouldQueryLinear = linearAvailable && (mode === 'smart' || mode === 'linear') useEffect(() => { if (disabled || !shouldQueryGithub || !selectedRepo?.path) { @@ -462,7 +512,7 @@ export default function SmartWorkspaceNameField({ // GitLabWorkItem via the IPC. Skipped silently when the host hook // hasn't supplied an onGitLabItemSelect handler. const parsedGlLink = useMemo(() => parseGitLabIssueOrMRLink(debouncedQuery), [debouncedQuery]) - const shouldQueryGitlab = mode === 'smart' || mode === 'gitlab' + const shouldQueryGitlab = gitlabAvailable && (mode === 'smart' || mode === 'gitlab') useEffect(() => { if ( !shouldQueryGitlab || @@ -473,7 +523,7 @@ export default function SmartWorkspaceNameField({ ) { // Why: don't clobber list-mode items here — the listMRs effect below // is the sole writer when the user is in 'gitlab' mode without a URL. - if (parsedGlLink === null && mode !== 'gitlab') { + if (!shouldQueryGitlab || (parsedGlLink === null && mode !== 'gitlab')) { setGitlabItems([]) } setGitlabLoading(false) @@ -529,7 +579,11 @@ export default function SmartWorkspaceNameField({ // MR list view. Smart mode includes GitLab MRs alongside GitHub // items so the unified picker actually surfaces both providers. useEffect(() => { - if (disabled || (mode !== 'gitlab' && mode !== 'smart') || !onGitLabItemSelect) { + if (!shouldQueryGitlab || disabled || !onGitLabItemSelect) { + if (!shouldQueryGitlab) { + setGitlabItems([]) + setGitlabLoading(false) + } return } if (!selectedRepo?.path || selectedRepo.connectionId) { @@ -576,7 +630,15 @@ export default function SmartWorkspaceNameField({ return () => { stale = true } - }, [disabled, mode, mrStateFilter, onGitLabItemSelect, parsedGlLink, selectedRepo]) + }, [ + disabled, + mode, + mrStateFilter, + onGitLabItemSelect, + parsedGlLink, + selectedRepo, + shouldQueryGitlab + ]) const rows = useMemo(() => { const trimmed = value.trim() @@ -617,7 +679,7 @@ export default function SmartWorkspaceNameField({ })) ) } - if (mode === 'smart' || mode === 'gitlab') { + if (gitlabAvailable && (mode === 'smart' || mode === 'gitlab')) { nextRows.push( ...gitlabItems.map((item) => ({ kind: 'gitlab' as const, @@ -639,7 +701,7 @@ export default function SmartWorkspaceNameField({ })) ) } - if (mode === 'smart' || mode === 'linear') { + if (linearAvailable && (mode === 'smart' || mode === 'linear')) { nextRows.push( ...linearIssues.map((issue) => ({ kind: 'linear' as const, @@ -649,7 +711,16 @@ export default function SmartWorkspaceNameField({ ) } return nextRows.slice(0, RESULT_LIMIT + 1) - }, [branches, githubItems, gitlabItems, linearIssues, mode, value]) + }, [ + branches, + githubItems, + gitlabAvailable, + gitlabItems, + linearIssues, + linearAvailable, + mode, + value + ]) // Why: source rows (GitHub/branches/Linear) are driven by debouncedQuery, // so they're stale until the user pauses typing for SEARCH_DEBOUNCE_MS. @@ -677,11 +748,11 @@ export default function SmartWorkspaceNameField({ if (/^#\d+$/.test(trimmed) || parseGitHubIssueOrPRLink(trimmed) !== null) { return 'github' } - if (/^[A-Za-z][A-Za-z0-9_]*-\d+$/.test(trimmed)) { + if (linearAvailable && /^[A-Za-z][A-Za-z0-9_]*-\d+$/.test(trimmed)) { return 'linear' } return null - }, [value]) + }, [linearAvailable, value]) useEffect(() => { if (rows.length === 0) { @@ -801,7 +872,9 @@ export default function SmartWorkspaceNameField({ const placeholder = disabled ? (disabledPlaceholder ?? 'Unavailable') : mode === 'smart' - ? 'Type a name, #1234, branch, GitHub or Linear URL' + ? linearAvailable + ? 'Type a name, #1234, branch, GitHub or Linear URL' + : 'Type a name, #1234, branch, or GitHub URL' : mode === 'github' ? 'Search GitHub PRs and issues' : mode === 'branches' @@ -851,7 +924,7 @@ export default function SmartWorkspaceNameField({ input.focus({ preventScroll: true }) }} > - {MODES.map(({ id, label, Icon }) => ( + {availableModes.map(({ id, label, Icon }) => ( s.linearStatus) + const preflightStatus = useAppStore((s) => s.preflightStatus) const connectLinear = useAppStore((s) => s.connectLinear) const disconnectLinear = useAppStore((s) => s.disconnectLinear) const disconnectLinearWorkspace = useAppStore((s) => s.disconnectLinearWorkspace) const checkLinearConnection = useAppStore((s) => s.checkLinearConnection) + const refreshPreflightStatus = useAppStore((s) => s.refreshPreflightStatus) const testLinearConnection = useAppStore((s) => s.testLinearConnection) const linearWorkspaces = linearStatus.workspaces ?? [] @@ -151,45 +153,46 @@ export function IntegrationsPane(): React.JSX.Element { useEffect(() => { void checkLinearConnection() - void window.api.preflight.check().then((status) => { - if (!status.gh.installed) { - setGhStatus('not-installed') - } else if (!status.gh.authenticated) { - setGhStatus('not-authenticated') - } else { - setGhStatus('connected') - } - // Why: glab is optional on PreflightStatus — older preload payloads - // may not carry it. Fall through to 'not-installed' in that case so - // the card still renders something actionable. - const glab = status.glab - if (!glab || !glab.installed) { - setGlabStatus('not-installed') - } else if (!glab.authenticated) { - setGlabStatus('not-authenticated') - } else { - setGlabStatus('connected') - } - const bitbucket = status.bitbucket - setBitbucketAccount(bitbucket?.account ?? null) - if (!bitbucket?.configured) { - setBitbucketStatus('not-configured') - } else if (!bitbucket.authenticated) { - setBitbucketStatus('not-authenticated') - } else { - setBitbucketStatus('connected') - } - const azureDevOps = status.azureDevOps - setAzureDevOpsAccount(azureDevOps?.account ?? null) - setAzureDevOpsBaseUrl(azureDevOps?.baseUrl ?? null) - setAzureDevOpsStatus(tokenApiStatusFromPreflight(azureDevOps)) - const gitea = status.gitea - setGiteaAccount(gitea?.account ?? null) - setGiteaBaseUrl(gitea?.baseUrl ?? null) - setGiteaStatus(giteaStatusFromPreflight(gitea)) - }) - // eslint-disable-next-line react-hooks/exhaustive-deps -- one-shot mount check - }, []) + void refreshPreflightStatus() + }, [checkLinearConnection, refreshPreflightStatus]) + + useEffect(() => { + if (!preflightStatus) { + return + } + if (!preflightStatus.gh.installed) { + setGhStatus('not-installed') + } else if (!preflightStatus.gh.authenticated) { + setGhStatus('not-authenticated') + } else { + setGhStatus('connected') + } + const glab = preflightStatus.glab + if (!glab || !glab.installed) { + setGlabStatus('not-installed') + } else if (!glab.authenticated) { + setGlabStatus('not-authenticated') + } else { + setGlabStatus('connected') + } + const bitbucket = preflightStatus.bitbucket + setBitbucketAccount(bitbucket?.account ?? null) + if (!bitbucket?.configured) { + setBitbucketStatus('not-configured') + } else if (!bitbucket.authenticated) { + setBitbucketStatus('not-authenticated') + } else { + setBitbucketStatus('connected') + } + const azureDevOps = preflightStatus.azureDevOps + setAzureDevOpsAccount(azureDevOps?.account ?? null) + setAzureDevOpsBaseUrl(azureDevOps?.baseUrl ?? null) + setAzureDevOpsStatus(tokenApiStatusFromPreflight(azureDevOps)) + const gitea = preflightStatus.gitea + setGiteaAccount(gitea?.account ?? null) + setGiteaBaseUrl(gitea?.baseUrl ?? null) + setGiteaStatus(giteaStatusFromPreflight(gitea)) + }, [preflightStatus]) const handleLinearConnect = async (): Promise => { if (!linearApiKeyDraft.trim()) { @@ -249,64 +252,27 @@ export function IntegrationsPane(): React.JSX.Element { const handleRefreshGlab = (): void => { setGlabStatus('checking') - void window.api.preflight.check({ force: true }).then((status) => { - const glab = status.glab - if (!glab || !glab.installed) { - setGlabStatus('not-installed') - } else if (!glab.authenticated) { - setGlabStatus('not-authenticated') - } else { - setGlabStatus('connected') - } - }) + void refreshPreflightStatus({ force: true }) } const handleRefreshGh = (): void => { setGhStatus('checking') - void window.api.preflight.check({ force: true }).then((status) => { - if (!status.gh.installed) { - setGhStatus('not-installed') - } else if (!status.gh.authenticated) { - setGhStatus('not-authenticated') - } else { - setGhStatus('connected') - } - }) + void refreshPreflightStatus({ force: true }) } const handleRefreshBitbucket = (): void => { setBitbucketStatus('checking') - void window.api.preflight.check({ force: true }).then((status) => { - const bitbucket = status.bitbucket - setBitbucketAccount(bitbucket?.account ?? null) - if (!bitbucket?.configured) { - setBitbucketStatus('not-configured') - } else if (!bitbucket.authenticated) { - setBitbucketStatus('not-authenticated') - } else { - setBitbucketStatus('connected') - } - }) + void refreshPreflightStatus({ force: true }) } const handleRefreshAzureDevOps = (): void => { setAzureDevOpsStatus('checking') - void window.api.preflight.check({ force: true }).then((status) => { - const azureDevOps = status.azureDevOps - setAzureDevOpsAccount(azureDevOps?.account ?? null) - setAzureDevOpsBaseUrl(azureDevOps?.baseUrl ?? null) - setAzureDevOpsStatus(tokenApiStatusFromPreflight(azureDevOps)) - }) + void refreshPreflightStatus({ force: true }) } const handleRefreshGitea = (): void => { setGiteaStatus('checking') - void window.api.preflight.check({ force: true }).then((status) => { - const gitea = status.gitea - setGiteaAccount(gitea?.account ?? null) - setGiteaBaseUrl(gitea?.baseUrl ?? null) - setGiteaStatus(giteaStatusFromPreflight(gitea)) - }) + void refreshPreflightStatus({ force: true }) } return ( diff --git a/src/renderer/src/components/sidebar/SidebarNav.tsx b/src/renderer/src/components/sidebar/SidebarNav.tsx index 2bc8c6f72..c54377555 100644 --- a/src/renderer/src/components/sidebar/SidebarNav.tsx +++ b/src/renderer/src/components/sidebar/SidebarNav.tsx @@ -8,6 +8,7 @@ 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, resolveVisibleTaskProvider } from '../../../../shared/task-providers' @@ -28,15 +29,38 @@ const SidebarNav = React.memo(function SidebarNav() { const showTasksButton = useAppStore((s) => s.settings?.showTasksButton !== false) const rawVisibleTaskProviders = useAppStore((s) => s.settings?.visibleTaskProviders) const defaultTaskSource = useAppStore((s) => s.settings?.defaultTaskSource ?? 'github') - const visibleTaskProviders = React.useMemo( + const preflightStatus = useAppStore((s) => s.preflightStatus) + const preflightStatusChecked = useAppStore((s) => s.preflightStatusChecked) + const refreshPreflightStatus = useAppStore((s) => s.refreshPreflightStatus) + const linearStatus = useAppStore((s) => s.linearStatus) + const linearStatusChecked = useAppStore((s) => s.linearStatusChecked) + const checkLinearConnection = useAppStore((s) => s.checkLinearConnection) + const preferredVisibleTaskProviders = React.useMemo( () => normalizeVisibleTaskProviders(rawVisibleTaskProviders), [rawVisibleTaskProviders] ) + const visibleTaskProviders = React.useMemo( + () => + filterAvailableTaskProviders(preferredVisibleTaskProviders, { + gitlabInstalled: preflightStatus?.glab?.installed === true, + linearConnected: linearStatus.connected === true + }), + [linearStatus.connected, preferredVisibleTaskProviders, preflightStatus?.glab?.installed] + ) const resolvedDefaultTaskSource = React.useMemo( () => resolveVisibleTaskProvider(defaultTaskSource, visibleTaskProviders), [defaultTaskSource, visibleTaskProviders] ) + React.useEffect(() => { + if (!preflightStatusChecked) { + void refreshPreflightStatus() + } + if (!linearStatusChecked) { + void checkLinearConnection() + } + }, [checkLinearConnection, linearStatusChecked, preflightStatusChecked, refreshPreflightStatus]) + // Why: warm the GitHub work-item cache on hover/focus so by the time the // user's click finishes the round-trip has either completed or is already // in-flight. Shaves ~200–600ms off perceived page-load latency. diff --git a/src/renderer/src/store/index.ts b/src/renderer/src/store/index.ts index 3d91c8065..b69ef2715 100644 --- a/src/renderer/src/store/index.ts +++ b/src/renderer/src/store/index.ts @@ -10,6 +10,7 @@ import { createSettingsSlice } from './slices/settings' import { createGitHubSlice } from './slices/github' import { createHostedReviewSlice } from './slices/hosted-review' import { createLinearSlice } from './slices/linear' +import { createPreflightSlice } from './slices/preflight' import { createEditorSlice } from './slices/editor' import { createStatsSlice } from './slices/stats' import { createMemorySlice } from './slices/memory' @@ -40,6 +41,7 @@ export const useAppStore = create()((...a) => ({ ...createGitHubSlice(...a), ...createHostedReviewSlice(...a), ...createLinearSlice(...a), + ...createPreflightSlice(...a), ...createEditorSlice(...a), ...createStatsSlice(...a), ...createMemorySlice(...a), diff --git a/src/renderer/src/store/slices/diffComments.test.ts b/src/renderer/src/store/slices/diffComments.test.ts index e0e73e369..af4a6cf68 100644 --- a/src/renderer/src/store/slices/diffComments.test.ts +++ b/src/renderer/src/store/slices/diffComments.test.ts @@ -113,6 +113,7 @@ import { createSettingsSlice } from './settings' import { createGitHubSlice } from './github' import { createHostedReviewSlice } from './hosted-review' import { createLinearSlice } from './linear' +import { createPreflightSlice } from './preflight' import { createEditorSlice } from './editor' import { createStatsSlice } from './stats' import { createMemorySlice } from './memory' @@ -142,6 +143,7 @@ function createTestStore() { ...createGitHubSlice(...a), ...createHostedReviewSlice(...a), ...createLinearSlice(...a), + ...createPreflightSlice(...a), ...createEditorSlice(...a), ...createStatsSlice(...a), ...createMemorySlice(...a), diff --git a/src/renderer/src/store/slices/linear.test.ts b/src/renderer/src/store/slices/linear.test.ts index 56c6a29de..ab4925113 100644 --- a/src/renderer/src/store/slices/linear.test.ts +++ b/src/renderer/src/store/slices/linear.test.ts @@ -1,17 +1,25 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { create } from 'zustand' import type { AppState } from '../types' -import type { LinearIssue, LinearTeam } from '../../../../shared/types' +import type { + LinearConnectionStatus, + LinearIssue, + LinearTeam, + LinearViewer +} from '../../../../shared/types' import { createLinearSlice } from './linear' const linearStatus = vi.fn() +const linearConnect = vi.fn() +const linearDisconnect = vi.fn() const linearListIssues = vi.fn() const linearSearchIssues = vi.fn() const linearListTeams = vi.fn() +const linearTestConnection = vi.fn() vi.mock('@/runtime/runtime-linear-client', () => ({ - linearConnect: vi.fn(), - linearDisconnect: vi.fn(), + linearConnect: (...args: unknown[]) => linearConnect(...args), + linearDisconnect: (...args: unknown[]) => linearDisconnect(...args), linearDisconnectWorkspace: vi.fn(), linearGetIssue: vi.fn(), linearListIssues: (...args: unknown[]) => linearListIssues(...args), @@ -19,7 +27,7 @@ vi.mock('@/runtime/runtime-linear-client', () => ({ linearSearchIssues: (...args: unknown[]) => linearSearchIssues(...args), linearSelectWorkspace: vi.fn(), linearStatus: (...args: unknown[]) => linearStatus(...args), - linearTestConnection: vi.fn() + linearTestConnection: (...args: unknown[]) => linearTestConnection(...args) })) vi.mock('../../hooks/useIssueMetadata', () => ({ @@ -242,3 +250,88 @@ describe('createLinearSlice caching', () => { expect(store.getState().linearIssueCache['workspace-1::issue-id'].fetchedAt).toBe(0) }) }) + +describe('createLinearSlice', () => { + beforeEach(() => { + linearStatus.mockReset() + linearConnect.mockReset() + linearDisconnect.mockReset() + linearTestConnection.mockReset() + }) + + it('dedupes concurrent connection checks', async () => { + const pending = deferred() + linearStatus.mockReturnValueOnce(pending.promise) + const store = createTestStore() + + const first = store.getState().checkLinearConnection() + const second = store.getState().checkLinearConnection() + + expect(linearStatus).toHaveBeenCalledTimes(1) + pending.resolve({ + connected: true, + viewer: { + displayName: 'Test User', + email: 'test@example.com', + organizationName: 'Test Org' + } + }) + await Promise.all([first, second]) + + expect(store.getState().linearStatus.connected).toBe(true) + expect(store.getState().linearStatusChecked).toBe(true) + }) + + it('ignores stale status checks after a successful connect', async () => { + const staleMountCheck = deferred() + const freshConnectCheck = deferred() + const viewer = { + displayName: 'Test User', + email: 'test@example.com', + organizationName: 'Test Org' + } + linearStatus.mockReturnValueOnce(staleMountCheck.promise).mockReturnValueOnce(freshConnectCheck.promise) + linearConnect.mockResolvedValueOnce({ ok: true, viewer }) + const store = createTestStore() + + const mountCheck = store.getState().checkLinearConnection() + await store.getState().connectLinear('linear-key') + + expect(linearStatus).toHaveBeenCalledTimes(2) + expect(store.getState().linearStatus.connected).toBe(true) + + freshConnectCheck.resolve({ connected: true, viewer }) + await Promise.resolve() + + staleMountCheck.resolve({ connected: false, viewer: null }) + await mountCheck + + expect(store.getState().linearStatus.connected).toBe(true) + expect(store.getState().linearStatus.viewer?.email).toBe('test@example.com') + }) + + it('ignores stale direct status writes after a newer mutation', async () => { + const testResult = deferred<{ ok: true; viewer: LinearViewer }>() + const staleStatus = deferred() + const viewer = { + displayName: 'Test User', + email: 'test@example.com', + organizationName: 'Test Org' + } + linearTestConnection.mockReturnValueOnce(testResult.promise) + linearStatus.mockReturnValueOnce(staleStatus.promise) + linearDisconnect.mockResolvedValueOnce(undefined) + const store = createTestStore() + + const testPromise = store.getState().testLinearConnection() + testResult.resolve({ ok: true, viewer }) + await Promise.resolve() + + await store.getState().disconnectLinear() + staleStatus.resolve({ connected: true, viewer }) + await testPromise + + expect(store.getState().linearStatus.connected).toBe(false) + expect(store.getState().linearStatus.viewer).toBeNull() + }) +}) diff --git a/src/renderer/src/store/slices/linear.ts b/src/renderer/src/store/slices/linear.ts index 25031d59e..9dbd1999b 100644 --- a/src/renderer/src/store/slices/linear.ts +++ b/src/renderer/src/store/slices/linear.ts @@ -63,6 +63,8 @@ type InflightLinearListRequest = { const inflightSearchRequests = new Map() const inflightListRequests = new Map() const inflightTeamRequests = new Map>() +let inflightStatusRequest: Promise | null = null +let statusRequestGeneration = 0 function getSelectedWorkspaceId(status: LinearConnectionStatus): LinearWorkspaceSelection | null { return status.selectedWorkspaceId ?? status.activeWorkspaceId ?? null @@ -94,6 +96,16 @@ type LinearIssueReadArgs = type LinearFetchOptions = { force?: boolean } +function beginStatusOperation(): number { + statusRequestGeneration += 1 + inflightStatusRequest = null + return statusRequestGeneration +} + +function isCurrentStatusOperation(generation: number): boolean { + return generation === statusRequestGeneration +} + export type LinearSlice = { linearStatus: LinearConnectionStatus linearStatusChecked: boolean @@ -101,7 +113,7 @@ export type LinearSlice = { linearSearchCache: Record> linearTeamCache: Record> - checkLinearConnection: () => Promise + checkLinearConnection: (force?: boolean) => Promise connectLinear: ( apiKey: string ) => Promise<{ ok: true; viewer: LinearViewer } | { ok: false; error: string }> @@ -139,36 +151,59 @@ export const createLinearSlice: StateCreator = (s linearSearchCache: {}, linearTeamCache: {}, - checkLinearConnection: async () => { - try { - const status = (await linearStatus(get().settings)) as LinearConnectionStatus - const prev = get().linearStatus - if ( - prev.connected !== status.connected || - prev.viewer?.email !== status.viewer?.email || - getSelectedWorkspaceId(prev) !== getSelectedWorkspaceId(status) || - (prev.workspaces?.length ?? 0) !== (status.workspaces?.length ?? 0) - ) { - set({ linearStatus: status, linearStatusChecked: true }) - } else if (!get().linearStatusChecked) { - set({ linearStatusChecked: true }) - } - } catch { - if (get().linearStatus.connected) { - set({ linearStatus: { connected: false, viewer: null }, linearStatusChecked: true }) - } else if (!get().linearStatusChecked) { - set({ linearStatusChecked: true }) - } + checkLinearConnection: async (force = false) => { + if (inflightStatusRequest && !force) { + return inflightStatusRequest } + + const requestGeneration = beginStatusOperation() + inflightStatusRequest = linearStatus(get().settings) + .then((status) => { + if (!isCurrentStatusOperation(requestGeneration)) { + return + } + const typedStatus = status as LinearConnectionStatus + const prev = get().linearStatus + if ( + prev.connected !== typedStatus.connected || + prev.viewer?.email !== typedStatus.viewer?.email || + getSelectedWorkspaceId(prev) !== getSelectedWorkspaceId(typedStatus) || + (prev.workspaces?.length ?? 0) !== (typedStatus.workspaces?.length ?? 0) + ) { + set({ linearStatus: typedStatus, linearStatusChecked: true }) + } else if (!get().linearStatusChecked) { + set({ linearStatusChecked: true }) + } + }) + .catch(() => { + if (!isCurrentStatusOperation(requestGeneration)) { + return + } + if (get().linearStatus.connected) { + set({ linearStatus: { connected: false, viewer: null }, linearStatusChecked: true }) + } else if (!get().linearStatusChecked) { + set({ linearStatusChecked: true }) + } + }) + .finally(() => { + if (isCurrentStatusOperation(requestGeneration)) { + inflightStatusRequest = null + } + }) + + return inflightStatusRequest }, testLinearConnection: async (workspaceId) => { + const requestGeneration = beginStatusOperation() try { const result = (await linearTestConnection(get().settings, workspaceId)) as | { ok: true; viewer: LinearViewer } | { ok: false; error: string } const status = await linearStatus(get().settings) - set({ linearStatus: status, linearStatusChecked: true }) + if (isCurrentStatusOperation(requestGeneration)) { + set({ linearStatus: status, linearStatusChecked: true }) + } return result } catch (error) { const message = error instanceof Error ? error.message : 'Test failed' @@ -177,16 +212,17 @@ export const createLinearSlice: StateCreator = (s }, connectLinear: async (apiKey: string) => { + const requestGeneration = beginStatusOperation() try { const result = await linearConnect(get().settings, apiKey) - if (result.ok) { + if (result.ok && isCurrentStatusOperation(requestGeneration)) { set({ linearStatus: { connected: true, viewer: result.viewer as LinearViewer } }) - void get().checkLinearConnection() + void get().checkLinearConnection(true) } return result as { ok: true; viewer: LinearViewer } | { ok: false; error: string } } catch (error) { @@ -196,7 +232,11 @@ export const createLinearSlice: StateCreator = (s }, selectLinearWorkspace: async (workspaceId) => { + const requestGeneration = beginStatusOperation() const status = await linearSelectWorkspace(get().settings, workspaceId) + if (!isCurrentStatusOperation(requestGeneration)) { + return + } inflightIssueRequests.clear() inflightSearchRequests.clear() inflightListRequests.clear() @@ -212,7 +252,11 @@ export const createLinearSlice: StateCreator = (s }, disconnectLinear: async () => { + const requestGeneration = beginStatusOperation() await linearDisconnect(get().settings) + if (!isCurrentStatusOperation(requestGeneration)) { + return + } inflightIssueRequests.clear() inflightSearchRequests.clear() inflightListRequests.clear() @@ -227,13 +271,17 @@ export const createLinearSlice: StateCreator = (s }, disconnectLinearWorkspace: async (workspaceId) => { + const requestGeneration = beginStatusOperation() await linearDisconnectWorkspace(get().settings, workspaceId) + const status = await linearStatus(get().settings) + if (!isCurrentStatusOperation(requestGeneration)) { + return + } inflightIssueRequests.clear() inflightSearchRequests.clear() inflightListRequests.clear() inflightTeamRequests.clear() clearLinearMetadataCache() - const status = await linearStatus(get().settings) set({ linearStatus: status, linearIssueCache: {}, diff --git a/src/renderer/src/store/slices/preflight.test.ts b/src/renderer/src/store/slices/preflight.test.ts new file mode 100644 index 000000000..5864df83f --- /dev/null +++ b/src/renderer/src/store/slices/preflight.test.ts @@ -0,0 +1,108 @@ +import { describe, expect, it, vi } from 'vitest' +import { create } from 'zustand' +import type { PreflightStatus } from '../../../../preload/api-types' +import type { AppState } from '../types' +import { createPreflightSlice } from './preflight' + +const preflightCheck = vi.fn() + +globalThis.window = { + api: { + preflight: { + check: preflightCheck, + detectAgents: vi.fn().mockResolvedValue([]), + refreshAgents: vi.fn().mockResolvedValue({ + agents: [], + addedPathSegments: [], + shellHydrationOk: false, + pathSource: 'sync_seed_only', + pathFailureReason: 'spawn_error' + }), + detectRemoteAgents: vi.fn().mockResolvedValue([]) + } + } as unknown as Window['api'] +} as Window & typeof globalThis + +function createTestStore() { + return create()( + (...a) => + ({ + ...createPreflightSlice(...a) + }) as AppState + ) +} + +function makeStatus(glabInstalled: boolean): PreflightStatus { + return { + git: { installed: true }, + gh: { installed: true, authenticated: true }, + glab: { installed: glabInstalled, authenticated: glabInstalled } + } +} + +function deferred() { + let resolve!: (value: T) => void + let reject!: (error: unknown) => void + const promise = new Promise((res, rej) => { + resolve = res + reject = rej + }) + return { promise, resolve, reject } +} + +describe('createPreflightSlice', () => { + it('dedupes concurrent non-forced checks', async () => { + preflightCheck.mockReset() + const pending = deferred() + preflightCheck.mockReturnValueOnce(pending.promise) + const store = createTestStore() + + const first = store.getState().refreshPreflightStatus() + const second = store.getState().refreshPreflightStatus() + + expect(preflightCheck).toHaveBeenCalledTimes(1) + pending.resolve(makeStatus(true)) + await Promise.all([first, second]) + + expect(store.getState().preflightStatus?.glab?.installed).toBe(true) + expect(store.getState().preflightStatusChecked).toBe(true) + expect(store.getState().preflightStatusLoading).toBe(false) + }) + + it('lets forced checks bypass non-forced dedupe and win stale races', async () => { + preflightCheck.mockReset() + const stale = deferred() + const fresh = deferred() + preflightCheck.mockReturnValueOnce(stale.promise).mockReturnValueOnce(fresh.promise) + const store = createTestStore() + + const normal = store.getState().refreshPreflightStatus() + const forced = store.getState().refreshPreflightStatus({ force: true }) + + expect(preflightCheck).toHaveBeenNthCalledWith(1, undefined) + expect(preflightCheck).toHaveBeenNthCalledWith(2, { force: true }) + + fresh.resolve(makeStatus(true)) + await forced + stale.resolve(makeStatus(false)) + await normal + + expect(store.getState().preflightStatus?.glab?.installed).toBe(true) + }) + + it('dedupes lazy checks onto an in-flight forced refresh', async () => { + preflightCheck.mockReset() + const fresh = deferred() + preflightCheck.mockReturnValueOnce(fresh.promise) + const store = createTestStore() + + const forced = store.getState().refreshPreflightStatus({ force: true }) + const lazy = store.getState().refreshPreflightStatus() + + expect(preflightCheck).toHaveBeenCalledTimes(1) + fresh.resolve(makeStatus(true)) + await Promise.all([forced, lazy]) + + expect(store.getState().preflightStatus?.glab?.installed).toBe(true) + }) +}) diff --git a/src/renderer/src/store/slices/preflight.ts b/src/renderer/src/store/slices/preflight.ts new file mode 100644 index 000000000..20946aa94 --- /dev/null +++ b/src/renderer/src/store/slices/preflight.ts @@ -0,0 +1,80 @@ +import type { StateCreator } from 'zustand' +import type { PreflightStatus } from '../../../../preload/api-types' +import type { AppState } from '../types' + +export type PreflightSlice = { + preflightStatus: PreflightStatus | null + preflightStatusChecked: boolean + preflightStatusLoading: boolean + preflightStatusError: string | null + + refreshPreflightStatus: (options?: { force?: boolean }) => Promise +} + +let nonForcedPreflightRequest: Promise | null = null +let forcedPreflightRequest: Promise | null = null +let latestPreflightRequestId = 0 + +function getErrorMessage(error: unknown): string { + return error instanceof Error ? error.message : 'Failed to check integrations.' +} + +export const createPreflightSlice: StateCreator = (set) => ({ + preflightStatus: null, + preflightStatusChecked: false, + preflightStatusLoading: false, + preflightStatusError: null, + + refreshPreflightStatus: async (options) => { + const force = options?.force === true + if (!force && forcedPreflightRequest) { + return forcedPreflightRequest + } + if (!force && nonForcedPreflightRequest) { + return nonForcedPreflightRequest + } + + const requestId = ++latestPreflightRequestId + set({ preflightStatusLoading: true, preflightStatusError: null }) + + const request = window.api.preflight + .check(force ? { force: true } : undefined) + .then((status) => { + if (requestId !== latestPreflightRequestId) { + return + } + set({ + preflightStatus: status, + preflightStatusChecked: true, + preflightStatusLoading: false, + preflightStatusError: null + }) + }) + .catch((error) => { + if (requestId !== latestPreflightRequestId) { + return + } + set({ + preflightStatusChecked: true, + preflightStatusLoading: false, + preflightStatusError: getErrorMessage(error) + }) + }) + .finally(() => { + if (!force && nonForcedPreflightRequest === request) { + nonForcedPreflightRequest = null + } + if (force && forcedPreflightRequest === request) { + forcedPreflightRequest = null + } + }) + + if (!force) { + nonForcedPreflightRequest = request + } else { + forcedPreflightRequest = request + } + + return request + } +}) diff --git a/src/renderer/src/store/slices/store-session-cascades.test.ts b/src/renderer/src/store/slices/store-session-cascades.test.ts index b080c2295..97efa2acf 100644 --- a/src/renderer/src/store/slices/store-session-cascades.test.ts +++ b/src/renderer/src/store/slices/store-session-cascades.test.ts @@ -116,6 +116,7 @@ import { createSettingsSlice } from './settings' import { createGitHubSlice } from './github' import { createHostedReviewSlice } from './hosted-review' import { createLinearSlice } from './linear' +import { createPreflightSlice } from './preflight' import { createEditorSlice } from './editor' import { createStatsSlice } from './stats' import { createMemorySlice } from './memory' @@ -145,6 +146,7 @@ function createTestStore() { ...createGitHubSlice(...a), ...createHostedReviewSlice(...a), ...createLinearSlice(...a), + ...createPreflightSlice(...a), ...createEditorSlice(...a), ...createStatsSlice(...a), ...createMemorySlice(...a), diff --git a/src/renderer/src/store/slices/store-test-helpers.ts b/src/renderer/src/store/slices/store-test-helpers.ts index e154a2522..2a12b2aa2 100644 --- a/src/renderer/src/store/slices/store-test-helpers.ts +++ b/src/renderer/src/store/slices/store-test-helpers.ts @@ -18,6 +18,7 @@ import { createSettingsSlice } from './settings' import { createGitHubSlice } from './github' import { createHostedReviewSlice } from './hosted-review' import { createLinearSlice } from './linear' +import { createPreflightSlice } from './preflight' import { createEditorSlice } from './editor' import { createStatsSlice } from './stats' import { createMemorySlice } from './memory' @@ -55,6 +56,7 @@ export function createTestStore() { ...createGitHubSlice(...a), ...createHostedReviewSlice(...a), ...createLinearSlice(...a), + ...createPreflightSlice(...a), ...createEditorSlice(...a), ...createStatsSlice(...a), ...createMemorySlice(...a), diff --git a/src/renderer/src/store/slices/tabs.test.ts b/src/renderer/src/store/slices/tabs.test.ts index 8f19e2469..795ce8857 100644 --- a/src/renderer/src/store/slices/tabs.test.ts +++ b/src/renderer/src/store/slices/tabs.test.ts @@ -110,6 +110,7 @@ import { createSettingsSlice } from './settings' import { createGitHubSlice } from './github' import { createHostedReviewSlice } from './hosted-review' import { createLinearSlice } from './linear' +import { createPreflightSlice } from './preflight' import { createEditorSlice } from './editor' import { createStatsSlice } from './stats' import { createMemorySlice } from './memory' @@ -141,6 +142,7 @@ function createTestStore() { ...createGitHubSlice(...a), ...createHostedReviewSlice(...a), ...createLinearSlice(...a), + ...createPreflightSlice(...a), ...createEditorSlice(...a), ...createStatsSlice(...a), ...createMemorySlice(...a), diff --git a/src/renderer/src/store/types.ts b/src/renderer/src/store/types.ts index bed69db25..ca93a8ab9 100644 --- a/src/renderer/src/store/types.ts +++ b/src/renderer/src/store/types.ts @@ -8,6 +8,7 @@ import type { SettingsSlice } from './slices/settings' import type { GitHubSlice } from './slices/github' import type { HostedReviewSlice } from './slices/hosted-review' import type { LinearSlice } from './slices/linear' +import type { PreflightSlice } from './slices/preflight' import type { EditorSlice } from './slices/editor' import type { StatsSlice } from './slices/stats' import type { MemorySlice } from './slices/memory' @@ -35,6 +36,7 @@ export type AppState = RepoSlice & GitHubSlice & HostedReviewSlice & LinearSlice & + PreflightSlice & EditorSlice & StatsSlice & MemorySlice & diff --git a/src/shared/task-providers.test.ts b/src/shared/task-providers.test.ts index 6ef00ac85..791ea2948 100644 --- a/src/shared/task-providers.test.ts +++ b/src/shared/task-providers.test.ts @@ -1,5 +1,9 @@ import { describe, expect, it } from 'vitest' -import { normalizeVisibleTaskProviders, resolveVisibleTaskProvider } from './task-providers' +import { + filterAvailableTaskProviders, + normalizeVisibleTaskProviders, + resolveVisibleTaskProvider +} from './task-providers' describe('task providers', () => { it('normalizes provider lists while preserving supported order', () => { @@ -16,4 +20,22 @@ describe('task providers', () => { it('resolves hidden preferred providers to the first visible provider', () => { expect(resolveVisibleTaskProvider('github', ['linear'])).toBe('linear') }) + + it('filters runtime-unavailable providers without changing preference normalization', () => { + expect( + filterAvailableTaskProviders(['github', 'gitlab', 'linear'], { + gitlabInstalled: false, + linearConnected: true + }) + ).toEqual(['github', 'linear']) + }) + + it('falls back to GitHub when every preferred provider is unavailable', () => { + expect( + filterAvailableTaskProviders(['gitlab', 'linear'], { + gitlabInstalled: false, + linearConnected: false + }) + ).toEqual(['github']) + }) }) diff --git a/src/shared/task-providers.ts b/src/shared/task-providers.ts index a94484ad6..65b84969e 100644 --- a/src/shared/task-providers.ts +++ b/src/shared/task-providers.ts @@ -24,6 +24,28 @@ export function normalizeVisibleTaskProviders(value: unknown): TaskProvider[] { return normalized.length > 0 ? normalized : [...TASK_PROVIDERS] } +export type TaskProviderAvailability = { + gitlabInstalled: boolean + linearConnected: boolean +} + +export function filterAvailableTaskProviders( + visibleProviders: readonly TaskProvider[], + availability: TaskProviderAvailability +): TaskProvider[] { + const available = visibleProviders.filter((provider) => { + if (provider === 'github') { + return true + } + if (provider === 'gitlab') { + return availability.gitlabInstalled + } + return availability.linearConnected + }) + + return available.length > 0 ? available : ['github'] +} + export function resolveVisibleTaskProvider( preferred: TaskProvider | null | undefined, visibleProviders: readonly TaskProvider[]