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
This commit is contained in:
Jinjing 2026-05-17 19:27:44 -07:00 committed by GitHub
parent 8057cbdf78
commit d88b62f115
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
17 changed files with 622 additions and 136 deletions

1
.gitignore vendored
View File

@ -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/*

View File

@ -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<TaskSource>(
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.')

View File

@ -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<ReturnType<typeof parseGitHubIssueOrPRLink>>
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<RowEntry[]>(() => {
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 }) => (
<TabsTrigger
key={id}
value={id}

View File

@ -121,10 +121,12 @@ function giteaStatusFromPreflight(status: GiteaPreflightStatus | undefined): Git
export function IntegrationsPane(): React.JSX.Element {
const linearStatus = useAppStore((s) => 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<void> => {
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 (

View File

@ -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 ~200600ms off perceived page-load latency.

View File

@ -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<AppState>()((...a) => ({
...createGitHubSlice(...a),
...createHostedReviewSlice(...a),
...createLinearSlice(...a),
...createPreflightSlice(...a),
...createEditorSlice(...a),
...createStatsSlice(...a),
...createMemorySlice(...a),

View File

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

View File

@ -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<LinearConnectionStatus>()
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<LinearConnectionStatus>()
const freshConnectCheck = deferred<LinearConnectionStatus>()
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<LinearConnectionStatus>()
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()
})
})

View File

@ -63,6 +63,8 @@ type InflightLinearListRequest = {
const inflightSearchRequests = new Map<string, InflightLinearListRequest>()
const inflightListRequests = new Map<string, InflightLinearListRequest>()
const inflightTeamRequests = new Map<string, Promise<LinearTeam[]>>()
let inflightStatusRequest: Promise<void> | 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<string, CacheEntry<LinearIssue[]>>
linearTeamCache: Record<string, CacheEntry<LinearTeam[]>>
checkLinearConnection: () => Promise<void>
checkLinearConnection: (force?: boolean) => Promise<void>
connectLinear: (
apiKey: string
) => Promise<{ ok: true; viewer: LinearViewer } | { ok: false; error: string }>
@ -139,36 +151,59 @@ export const createLinearSlice: StateCreator<AppState, [], [], LinearSlice> = (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<AppState, [], [], LinearSlice> = (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<AppState, [], [], LinearSlice> = (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<AppState, [], [], LinearSlice> = (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<AppState, [], [], LinearSlice> = (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: {},

View File

@ -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<AppState>()(
(...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<T>() {
let resolve!: (value: T) => void
let reject!: (error: unknown) => void
const promise = new Promise<T>((res, rej) => {
resolve = res
reject = rej
})
return { promise, resolve, reject }
}
describe('createPreflightSlice', () => {
it('dedupes concurrent non-forced checks', async () => {
preflightCheck.mockReset()
const pending = deferred<PreflightStatus>()
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<PreflightStatus>()
const fresh = deferred<PreflightStatus>()
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<PreflightStatus>()
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)
})
})

View File

@ -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<void>
}
let nonForcedPreflightRequest: Promise<void> | null = null
let forcedPreflightRequest: Promise<void> | null = null
let latestPreflightRequestId = 0
function getErrorMessage(error: unknown): string {
return error instanceof Error ? error.message : 'Failed to check integrations.'
}
export const createPreflightSlice: StateCreator<AppState, [], [], PreflightSlice> = (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
}
})

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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[]