Redesign Connect integrations (#4531)

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Brennan Benson
2026-06-11 12:47:31 -07:00
committed by Jinwoo-H
co-authored by Orca
parent 19ee3355ac
commit 3b10e2049d
40 changed files with 4909 additions and 1414 deletions
+140 -45
View File
@@ -41,6 +41,8 @@ import { toast } from 'sonner'
import { useAppStore } from '@/store'
import { useAllWorktrees, useRepoMap } from '@/store/selectors'
import { callRuntimeRpc, getActiveRuntimeTarget } from '@/runtime/runtime-rpc-client'
import { getLocalPreflightContext, localPreflightContextKey } from '@/lib/local-preflight-context'
import { getProviderRuntimeContextKey } from '@/lib/provider-runtime-context'
import { Button } from '@/components/ui/button'
import { ButtonGroup } from '@/components/ui/button-group'
import { Input } from '@/components/ui/input'
@@ -2467,8 +2469,10 @@ 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 linearStatusContextKey = useAppStore((s) => s.linearStatusContextKey)
const preflightStatus = useAppStore((s) => s.preflightStatus)
const preflightStatusChecked = useAppStore((s) => s.preflightStatusChecked)
const preflightStatusContextKey = useAppStore((s) => s.preflightStatusContextKey)
const selectLinearWorkspace = useAppStore((s) => s.selectLinearWorkspace)
const searchLinearIssues = useAppStore((s) => s.searchLinearIssues)
const listLinearIssues = useAppStore((s) => s.listLinearIssues)
@@ -2487,13 +2491,27 @@ export default function TaskPage(): React.JSX.Element {
const patchLinearIssue = useAppStore((s) => s.patchLinearIssue)
const checkLinearConnection = useAppStore((s) => s.checkLinearConnection)
const refreshPreflightStatus = useAppStore((s) => s.refreshPreflightStatus)
const expectedPreflightContextKey = useAppStore((s) =>
localPreflightContextKey(getLocalPreflightContext(s))
)
const jiraStatus = useAppStore((s) => s.jiraStatus)
const jiraStatusChecked = useAppStore((s) => s.jiraStatusChecked)
const jiraStatusContextKey = useAppStore((s) => s.jiraStatusContextKey)
const connectJira = useAppStore((s) => s.connectJira)
const selectJiraSite = useAppStore((s) => s.selectJiraSite)
const searchJiraIssues = useAppStore((s) => s.searchJiraIssues)
const listJiraIssues = useAppStore((s) => s.listJiraIssues)
const checkJiraConnection = useAppStore((s) => s.checkJiraConnection)
const providerRuntimeContextKey = getProviderRuntimeContextKey(settings)
const providerRuntimeContextKeyRef = useRef(providerRuntimeContextKey)
providerRuntimeContextKeyRef.current = providerRuntimeContextKey
const linearStatusCurrent = linearStatusContextKey === providerRuntimeContextKey
const jiraStatusCurrent = jiraStatusContextKey === providerRuntimeContextKey
const preflightStatusCurrent = preflightStatusContextKey === expectedPreflightContextKey
const linearStatusReady = linearStatusCurrent && linearStatusChecked
const jiraStatusReady = jiraStatusCurrent && jiraStatusChecked
const linearConnected = linearStatusCurrent && linearStatus.connected
const jiraConnected = jiraStatusCurrent && jiraStatus.connected
const submitShortcutLabel = getScreenSubmitShortcutLabel()
const eligibleRepos = useMemo(() => repos.filter((repo) => isGitRepoKind(repo)), [repos])
@@ -2588,15 +2606,16 @@ export default function TaskPage(): React.JSX.Element {
restoreAvailableDefaultTaskProvider(
preferredVisibleTaskProviders,
{
gitlabInstalled: preflightStatus?.glab?.installed === true,
linearConnected: linearStatus.connected === true
gitlabInstalled: preflightStatusCurrent && preflightStatus?.glab?.installed === true,
linearConnected: linearConnected === true
},
defaultTaskSource
),
[
defaultTaskSource,
linearStatus.connected,
linearConnected,
preferredVisibleTaskProviders,
preflightStatusCurrent,
preflightStatus?.glab?.installed
]
)
@@ -3412,7 +3431,7 @@ export default function TaskPage(): React.JSX.Element {
linearContextResumeAttemptedRef.current ||
!taskResumeApplied ||
taskSource !== 'linear' ||
!linearStatus.connected ||
!linearConnected ||
!context
) {
return
@@ -3489,7 +3508,7 @@ export default function TaskPage(): React.JSX.Element {
fetchLinearCustomView,
fetchLinearProject,
listLinearCustomViews,
linearStatus.connected,
linearConnected,
setTaskResumeState,
taskResumeApplied,
taskResumeState?.linearContext,
@@ -3506,7 +3525,7 @@ export default function TaskPage(): React.JSX.Element {
if (!taskResumeApplied) {
return
}
if (taskSource !== 'linear' || !linearStatus.connected) {
if (taskSource !== 'linear' || !linearConnected) {
setAvailableTeams([])
return
}
@@ -3533,7 +3552,7 @@ export default function TaskPage(): React.JSX.Element {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [
taskSource,
linearStatus.connected,
linearConnected,
selectedLinearWorkspaceId,
linearTeamRefreshNonce,
taskResumeApplied,
@@ -3548,7 +3567,7 @@ export default function TaskPage(): React.JSX.Element {
if (!taskResumeApplied) {
return
}
if (taskSource !== 'jira' || !jiraStatus.connected) {
if (taskSource !== 'jira' || !jiraConnected) {
setAvailableJiraProjects([])
setJiraProjectsLoading(false)
return
@@ -3575,7 +3594,7 @@ export default function TaskPage(): React.JSX.Element {
return () => {
cancelled = true
}
}, [settings, taskSource, jiraStatus.connected, selectedJiraSiteId, taskResumeApplied])
}, [settings, taskSource, jiraConnected, selectedJiraSiteId, taskResumeApplied])
// Why: stable key for `selectedRepos` so the GitLab fetch effect below
// doesn't re-run on every parent re-render just because the array
@@ -4331,7 +4350,7 @@ export default function TaskPage(): React.JSX.Element {
useEffect(() => {
let cancelled = false
if (!newLinearIssueTargetTeam) {
if (!newLinearIssueOpen || !linearConnected || !newLinearIssueTargetTeam) {
setNewLinearIssueProjects([])
setNewLinearIssueProjectsLoading(false)
return
@@ -4357,7 +4376,13 @@ export default function TaskPage(): React.JSX.Element {
// populate the composer after a team/workspace switch.
cancelled = true
}
}, [newLinearIssueTargetTeam, settings, selectedLinearWorkspaceId])
}, [
linearConnected,
newLinearIssueOpen,
newLinearIssueTargetTeam,
settings,
selectedLinearWorkspaceId
])
useEffect(() => {
// Why: the selected team can change indirectly when the available Linear
@@ -4377,17 +4402,17 @@ export default function TaskPage(): React.JSX.Element {
}, [newLinearIssueTargetTeam?.id, newLinearIssueTargetTeam?.workspaceId, selectedLinearProject])
const newLinearStates = useTeamStates(
newLinearIssueTargetTeam?.id || null,
linearConnected ? newLinearIssueTargetTeam?.id || null : null,
settings,
newLinearIssueTargetTeam?.workspaceId
)
const newLinearMembers = useTeamMembers(
newLinearIssueTargetTeam?.id || null,
linearConnected ? newLinearIssueTargetTeam?.id || null : null,
settings,
newLinearIssueTargetTeam?.workspaceId
)
const newLinearLabels = useTeamLabels(
newLinearIssueTargetTeam?.id || null,
linearConnected ? newLinearIssueTargetTeam?.id || null : null,
settings,
newLinearIssueTargetTeam?.workspaceId
)
@@ -4457,6 +4482,45 @@ export default function TaskPage(): React.JSX.Element {
const [jiraConnectState, setJiraConnectState] = useState<'idle' | 'connecting' | 'error'>('idle')
const [jiraConnectError, setJiraConnectError] = useState<string | null>(null)
const includeJiraSiteNameInProjectLabel = selectedJiraSiteId === 'all'
const previousProviderRuntimeContextKeyRef = useRef(providerRuntimeContextKey)
useEffect(() => {
if (previousProviderRuntimeContextKeyRef.current === providerRuntimeContextKey) {
return
}
previousProviderRuntimeContextKeyRef.current = providerRuntimeContextKey
if (newLinearIssueOpen) {
setNewLinearIssueOpen(false)
setNewLinearIssueTitle('')
setNewLinearIssueBody('')
setNewLinearIssueTeamId(null)
setNewLinearIssueStateId(null)
setNewLinearIssueAssigneeId(null)
setNewLinearIssuePriority(0)
setNewLinearIssueProjectId(null)
setNewLinearIssueLabelIds([])
setNewLinearIssueProjects([])
setNewLinearIssueProjectsLoading(false)
setNewLinearIssueSubmitting(false)
}
if (newJiraIssueOpen) {
setNewJiraIssueOpen(false)
setNewJiraIssueTitle('')
setNewJiraIssueBody('')
setNewJiraIssueProjectId(null)
setNewJiraIssueProjectComboboxOpen(false)
setNewJiraIssueProjectQuery('')
setNewJiraIssueProjectCommandValue('')
setNewJiraIssueTypeId(null)
setAvailableJiraIssueTypes([])
setJiraIssueTypesLoading(false)
setJiraCreateFields([])
setJiraCreateFieldsLoading(false)
setJiraCreateFieldsError(null)
setNewJiraIssueCustomFieldValues({})
setNewJiraIssueSubmitting(false)
}
}, [newJiraIssueOpen, newLinearIssueOpen, providerRuntimeContextKey])
const sortedAvailableJiraProjects = useMemo(
() =>
@@ -4572,7 +4636,7 @@ export default function TaskPage(): React.JSX.Element {
)
useEffect(() => {
if (!newJiraIssueOpen || !newJiraIssueTargetProject) {
if (!newJiraIssueOpen || !jiraConnected || !newJiraIssueTargetProject) {
setAvailableJiraIssueTypes([])
setJiraIssueTypesLoading(false)
return
@@ -4607,10 +4671,15 @@ export default function TaskPage(): React.JSX.Element {
return () => {
cancelled = true
}
}, [settings, newJiraIssueOpen, newJiraIssueTargetProject])
}, [settings, jiraConnected, newJiraIssueOpen, newJiraIssueTargetProject])
useEffect(() => {
if (!newJiraIssueOpen || !newJiraIssueTargetProject || !newJiraIssueTargetType) {
if (
!newJiraIssueOpen ||
!jiraConnected ||
!newJiraIssueTargetProject ||
!newJiraIssueTargetType
) {
setJiraCreateFields([])
setJiraCreateFieldsLoading(false)
setJiraCreateFieldsError(null)
@@ -4648,7 +4717,7 @@ export default function TaskPage(): React.JSX.Element {
// responses after the user switches either selector.
cancelled = true
}
}, [settings, newJiraIssueOpen, newJiraIssueTargetProject, newJiraIssueTargetType])
}, [settings, jiraConnected, newJiraIssueOpen, newJiraIssueTargetProject, newJiraIssueTargetType])
// Why: defense-in-depth safety net applied to the current page's items.
// The active tab scopes requests to issues or PRs, and this keeps stale
@@ -5558,6 +5627,7 @@ export default function TaskPage(): React.JSX.Element {
return
}
setNewLinearIssueSubmitting(true)
const submitProviderRuntimeContextKey = providerRuntimeContextKey
try {
const result = await linearCreateIssue(settings, {
teamId: newLinearIssueTargetTeam.id,
@@ -5570,6 +5640,9 @@ export default function TaskPage(): React.JSX.Element {
projectId: newLinearIssueProjectId || null,
labelIds: newLinearIssueLabelIds.length > 0 ? newLinearIssueLabelIds : undefined
})
if (submitProviderRuntimeContextKey !== providerRuntimeContextKeyRef.current) {
return
}
if (!result.ok) {
toast.error(
result.error ||
@@ -5605,13 +5678,18 @@ export default function TaskPage(): React.JSX.Element {
// sees exactly what was filed, mirroring the GitHub create-issue flow.
void linearGetIssue(settings, result.id, newLinearIssueTargetTeam.workspaceId)
.then((full) => {
if (submitProviderRuntimeContextKey !== providerRuntimeContextKeyRef.current) {
return
}
if (full) {
setSelectedLinearIssue(full, { allowOutsideList: true })
}
})
.catch(() => {})
} finally {
setNewLinearIssueSubmitting(false)
if (submitProviderRuntimeContextKey === providerRuntimeContextKeyRef.current) {
setNewLinearIssueSubmitting(false)
}
}
}, [
newLinearIssueBody,
@@ -5623,6 +5701,7 @@ export default function TaskPage(): React.JSX.Element {
newLinearIssueAssigneeId,
newLinearIssueProjectId,
newLinearIssueLabelIds,
providerRuntimeContextKey,
selectedLinearProject,
setSelectedLinearIssue,
settings
@@ -5641,6 +5720,7 @@ export default function TaskPage(): React.JSX.Element {
newJiraIssueCustomFieldValues
)
setNewJiraIssueSubmitting(true)
const submitProviderRuntimeContextKey = providerRuntimeContextKey
try {
const result = await jiraCreateIssue(settings, {
siteId: newJiraIssueTargetProject.siteId,
@@ -5650,6 +5730,9 @@ export default function TaskPage(): React.JSX.Element {
description: newJiraIssueBody || undefined,
customFields
})
if (submitProviderRuntimeContextKey !== providerRuntimeContextKeyRef.current) {
return
}
if (!result.ok) {
toast.error(
result.error ||
@@ -5678,6 +5761,9 @@ export default function TaskPage(): React.JSX.Element {
void jiraGetIssue(settings, result.key, newJiraIssueTargetProject.siteId)
.then((full) => {
if (submitProviderRuntimeContextKey !== providerRuntimeContextKeyRef.current) {
return
}
if (full) {
// Why: the list cache may still be fresh after create; insert the
// new row locally before selecting it so the inspector stays open.
@@ -5687,7 +5773,9 @@ export default function TaskPage(): React.JSX.Element {
})
.catch(() => {})
} finally {
setNewJiraIssueSubmitting(false)
if (submitProviderRuntimeContextKey === providerRuntimeContextKeyRef.current) {
setNewJiraIssueSubmitting(false)
}
}
}, [
hasMissingJiraCreateField,
@@ -5698,6 +5786,7 @@ export default function TaskPage(): React.JSX.Element {
newJiraIssueTargetProject,
newJiraIssueTargetType,
newJiraIssueTitle,
providerRuntimeContextKey,
settings,
setSelectedJiraIssue,
visibleJiraCreateFields
@@ -5759,21 +5848,27 @@ export default function TaskPage(): React.JSX.Element {
])
useEffect(() => {
if (!preflightStatusChecked) {
if (!preflightStatusCurrent || !preflightStatusChecked) {
void refreshPreflightStatus()
}
if (!linearStatusChecked) {
if (!linearStatusReady) {
void checkLinearConnection()
}
if (!jiraStatusChecked) {
if (!jiraStatusReady) {
void checkJiraConnection()
}
}, [
checkJiraConnection,
checkLinearConnection,
jiraStatusChecked,
linearStatusChecked,
expectedPreflightContextKey,
jiraStatusContextKey,
jiraStatusReady,
linearStatusContextKey,
linearStatusReady,
providerRuntimeContextKey,
preflightStatusContextKey,
preflightStatusChecked,
preflightStatusCurrent,
refreshPreflightStatus
])
@@ -5826,7 +5921,7 @@ export default function TaskPage(): React.JSX.Element {
if (linearMode !== 'issues') {
return
}
if (!linearStatus.connected) {
if (!linearConnected) {
return
}
@@ -5940,7 +6035,7 @@ export default function TaskPage(): React.JSX.Element {
}, [
taskSource,
linearMode,
linearStatus.connected,
linearConnected,
selectedLinearWorkspaceId,
appliedLinearSearch,
linearIssueLimit,
@@ -5963,7 +6058,7 @@ export default function TaskPage(): React.JSX.Element {
if (!taskResumeApplied || taskSource !== 'linear' || linearMode !== 'projects') {
return
}
if (!linearStatus.connected || selectedLinearProject) {
if (!linearConnected || selectedLinearProject) {
return
}
let cancelled = false
@@ -6000,7 +6095,7 @@ export default function TaskPage(): React.JSX.Element {
taskResumeApplied,
taskSource,
linearMode,
linearStatus.connected,
linearConnected,
selectedLinearWorkspaceId,
selectedLinearProject,
appliedLinearProjectSearch,
@@ -6088,7 +6183,7 @@ export default function TaskPage(): React.JSX.Element {
if (!taskResumeApplied || taskSource !== 'linear' || linearMode !== 'views') {
return
}
if (!linearStatus.connected || selectedLinearCustomView) {
if (!linearConnected || selectedLinearCustomView) {
return
}
let cancelled = false
@@ -6133,7 +6228,7 @@ export default function TaskPage(): React.JSX.Element {
taskResumeApplied,
taskSource,
linearMode,
linearStatus.connected,
linearConnected,
selectedLinearWorkspaceId,
selectedLinearCustomView,
linearRefreshNonce,
@@ -6201,7 +6296,7 @@ export default function TaskPage(): React.JSX.Element {
return
}
if (!linearStatus.connected) {
if (!linearConnected) {
clearSelectedLinearIssue()
return
}
@@ -6227,7 +6322,7 @@ export default function TaskPage(): React.JSX.Element {
}, [
clearSelectedLinearIssue,
filteredLinearIssues,
linearStatus.connected,
linearConnected,
selectedLinearIssueCanFloat,
selectedLinearIssueId,
taskResumeApplied,
@@ -6262,7 +6357,7 @@ export default function TaskPage(): React.JSX.Element {
if (taskSource !== 'jira') {
return
}
if (!jiraStatus.connected) {
if (!jiraConnected) {
return
}
@@ -6298,7 +6393,7 @@ export default function TaskPage(): React.JSX.Element {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [
taskSource,
jiraStatus.connected,
jiraConnected,
selectedJiraSiteId,
appliedJiraSearch,
activeJiraPreset,
@@ -6310,7 +6405,7 @@ export default function TaskPage(): React.JSX.Element {
if (!taskResumeApplied || taskSource !== 'jira') {
return
}
if (!jiraStatus.connected || displayedJiraIssues.length === 0) {
if (!jiraConnected || displayedJiraIssues.length === 0) {
if (selectedJiraIssueKey !== null) {
setSelectedJiraIssueKey(null)
}
@@ -6328,7 +6423,7 @@ export default function TaskPage(): React.JSX.Element {
}
}, [
displayedJiraIssues,
jiraStatus.connected,
jiraConnected,
selectedJiraIssueFallback,
selectedJiraIssueKey,
taskResumeApplied,
@@ -6579,7 +6674,7 @@ export default function TaskPage(): React.JSX.Element {
)
})}
</div>
{taskSource === 'linear' && linearStatus.connected ? (
{taskSource === 'linear' && linearConnected ? (
<div className="flex items-center gap-2">
<LinearScopeSelector
workspaces={linearWorkspaces}
@@ -6637,7 +6732,7 @@ export default function TaskPage(): React.JSX.Element {
</Tooltip>
</div>
) : null}
{taskSource === 'jira' && jiraStatus.connected ? (
{taskSource === 'jira' && jiraConnected ? (
<div className="flex items-center gap-2">
{jiraSites.length > 1 ? (
<Select
@@ -7026,7 +7121,7 @@ export default function TaskPage(): React.JSX.Element {
)
})()}
</div>
) : taskSource === 'linear' && linearStatus.connected ? (
) : taskSource === 'linear' && linearConnected ? (
<div
className="min-w-0 rounded-md rounded-b-none border border-border/50 bg-muted/50 p-3 shadow-sm"
data-contextual-tour-target="tasks-search-presets"
@@ -7254,7 +7349,7 @@ export default function TaskPage(): React.JSX.Element {
</div>
) : null}
</div>
) : taskSource === 'jira' && jiraStatus.connected ? (
) : taskSource === 'jira' && jiraConnected ? (
<div className="rounded-md rounded-b-none border border-border/50 bg-muted/50 p-3 shadow-sm">
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="flex flex-wrap gap-2">
@@ -8327,11 +8422,11 @@ export default function TaskPage(): React.JSX.Element {
</div>
</div>
) : taskSource === 'jira' ? (
!jiraStatusChecked ? (
!jiraStatusReady ? (
<div className="mt-4 flex items-center justify-center py-14">
<LoaderCircle className="size-5 animate-spin text-muted-foreground" />
</div>
) : !jiraStatus.connected ? (
) : !jiraConnected ? (
<div className="mt-4 flex flex-col items-center justify-center rounded-md border border-border/50 bg-muted/50 px-6 py-14 text-center shadow-sm">
<JiraIcon className="mb-4 size-8 text-muted-foreground/60" />
<p className="text-base font-medium text-foreground">
@@ -8624,11 +8719,11 @@ export default function TaskPage(): React.JSX.Element {
onOpenIssue={openRelatedLinearIssue}
onClose={closeTaskDetailPage}
/>
) : !linearStatusChecked ? (
) : !linearStatusReady ? (
<div className="mt-4 flex items-center justify-center py-14">
<LoaderCircle className="size-5 animate-spin text-muted-foreground" />
</div>
) : !linearStatus.connected ? (
) : !linearConnected ? (
<div className="mt-4 flex flex-col items-center justify-center rounded-md border border-border/50 bg-muted/50 px-6 py-14 text-center shadow-sm">
<LinearIcon className="mb-4 size-8 text-muted-foreground/60" />
<p className="text-base font-medium text-foreground">
@@ -0,0 +1,224 @@
import { renderToStaticMarkup } from 'react-dom/server'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { PreflightStatus } from '../../../../preload/api-types'
import { getProviderRuntimeContextKey } from '@/lib/provider-runtime-context'
import { ConnectIntegrationsList } from './ConnectIntegrationsList'
type StoreState = {
activeRepoId: string | null
activeWorktreeId: string | null
worktreesByRepo: Record<string, unknown[]>
repos: unknown[]
settings: { activeRuntimeEnvironmentId?: string | null }
preflightStatus: PreflightStatus | null
preflightStatusChecked: boolean
preflightStatusContextKey: string
preflightStatusError: string | null
preflightStatusLoading: boolean
refreshPreflightStatus: () => Promise<void>
linearStatus: { connected: boolean; workspaces?: unknown[] }
linearStatusChecked: boolean
linearStatusContextKey: string | null
checkLinearConnection: () => Promise<void>
testLinearConnection: () => Promise<{ ok: boolean; error?: string }>
disconnectLinear: () => Promise<void>
disconnectLinearWorkspace: () => Promise<void>
jiraStatus: { connected: boolean; sites?: unknown[] }
jiraStatusChecked: boolean
jiraStatusContextKey: string | null
checkJiraConnection: () => Promise<void>
testJiraConnection: () => Promise<{ ok: boolean; error?: string }>
disconnectJira: () => Promise<void>
}
const { storeState } = vi.hoisted(() => ({
storeState: { current: null as StoreState | null }
}))
vi.mock('@/store', () => ({
useAppStore: (selector: (state: StoreState) => unknown) => {
if (!storeState.current) {
throw new Error('Store state was not installed')
}
return selector(storeState.current)
}
}))
vi.mock('@/components/linear-api-key-dialog', () => ({
LinearApiKeyDialog: () => null
}))
vi.mock('@/components/jira-connect-dialog', () => ({
JiraConnectDialog: () => null
}))
function makePreflightStatus(overrides: Partial<PreflightStatus> = {}): PreflightStatus {
const status: PreflightStatus = {
git: { installed: true },
gh: { installed: true, authenticated: false },
glab: { installed: true, authenticated: false },
bitbucket: {
configured: false,
authenticated: false,
account: null
},
azureDevOps: {
configured: false,
authenticated: false,
account: null,
baseUrl: null,
tokenConfigured: false
},
gitea: {
configured: false,
authenticated: false,
account: null,
baseUrl: null,
tokenConfigured: false
}
}
return { ...status, ...overrides }
}
function installStore(preflightStatus: PreflightStatus): void {
const settings = { activeRuntimeEnvironmentId: null }
const providerContextKey = getProviderRuntimeContextKey(settings)
storeState.current = {
activeRepoId: null,
activeWorktreeId: null,
worktreesByRepo: {},
repos: [],
settings,
preflightStatus,
preflightStatusChecked: true,
preflightStatusContextKey: 'host',
preflightStatusError: null,
preflightStatusLoading: false,
refreshPreflightStatus: vi.fn(async () => {}),
linearStatus: { connected: false, workspaces: [] },
linearStatusChecked: true,
linearStatusContextKey: providerContextKey,
checkLinearConnection: vi.fn(async () => {}),
testLinearConnection: vi.fn(async () => ({ ok: true })),
disconnectLinear: vi.fn(async () => {}),
disconnectLinearWorkspace: vi.fn(async () => {}),
jiraStatus: { connected: false, sites: [] },
jiraStatusChecked: true,
jiraStatusContextKey: providerContextKey,
checkJiraConnection: vi.fn(async () => {}),
testJiraConnection: vi.fn(async () => ({ ok: true })),
disconnectJira: vi.fn(async () => {})
}
}
async function renderConnectIntegrationsList(): Promise<{
markup: string
}> {
return { markup: renderToStaticMarkup(<ConnectIntegrationsList />) }
}
describe('ConnectIntegrationsList', () => {
beforeEach(() => {
vi.stubGlobal('window', {
api: {
shell: {
openUrl: vi.fn()
}
}
})
})
afterEach(() => {
storeState.current = null
vi.unstubAllGlobals()
vi.restoreAllMocks()
})
it('renders the settings review cards in the review step without an inline auth terminal', async () => {
installStore(makePreflightStatus())
const { markup } = await renderConnectIntegrationsList()
for (const provider of ['GitHub', 'GitLab', 'Bitbucket', 'Azure DevOps', 'Gitea']) {
expect(markup).toContain(provider)
}
expect(markup).toContain('gh auth login')
expect(markup).toContain('glab auth login')
expect(markup).not.toContain('Run in terminal')
})
it('keeps the upcoming task step collapsed but openable, not inert', async () => {
installStore(makePreflightStatus())
const { markup } = await renderConnectIntegrationsList()
// Step 1 is not a prerequisite: the task step starts collapsed but offers
// an "Open" affordance instead of a disabled, dimmed row.
expect(markup).toContain('Open')
expect(markup).not.toContain('opacity-55')
expect(markup).not.toContain('Add Linear access')
})
it('collapses the task step to its summary when a tracker connects first', async () => {
installStore(makePreflightStatus())
if (!storeState.current) {
throw new Error('Store state was not installed')
}
storeState.current.linearStatus = { connected: true, workspaces: [] }
const { markup } = await renderConnectIntegrationsList()
expect(markup).toContain('connected for tasks')
expect(markup).not.toContain('Connect Jira')
})
it('auto-resolves the task step from a connected code host but keeps it open for trackers', async () => {
installStore(makePreflightStatus({ gh: { installed: true, authenticated: true } }))
const { markup } = await renderConnectIntegrationsList()
expect(markup).toContain('GitHub')
expect(markup).toContain('issues available as tasks')
expect(markup).toContain('add Linear or Jira if your team plans work there')
expect(markup).not.toContain('Use GitHub issues')
// The step is done but stays expanded so Linear/Jira remain discoverable
// for teams that plan work in a dedicated tracker.
expect(markup).toContain('Add Linear access')
expect(markup).toContain('Connect Jira')
})
it('offers GitHub and GitLab as task sources when review came from a non-task provider', async () => {
// Bitbucket satisfies review but cannot serve tasks, so step 2 must still
// offer the code hosts as connectable task sources alongside the trackers.
installStore(
makePreflightStatus({
bitbucket: { configured: true, authenticated: true, account: 'acme' }
})
)
const { markup } = await renderConnectIntegrationsList()
expect(markup).toContain('issues also work as tasks.')
expect(markup).toContain('gh auth login')
expect(markup).toContain('glab auth login')
expect(markup).toContain('Linear')
expect(markup).toContain('Jira')
})
it('lists the code host alongside a connected tracker in the task summary', async () => {
installStore(makePreflightStatus({ gh: { installed: true, authenticated: true } }))
if (!storeState.current) {
throw new Error('Store state was not installed')
}
storeState.current.linearStatus = { connected: true, workspaces: [] }
const { markup } = await renderConnectIntegrationsList()
expect(markup).toContain('Linear')
expect(markup).toContain('GitHub')
expect(markup).toContain('connected for tasks')
expect(markup).toContain(' and ')
// A connected tracker collapses the step to its summary.
expect(markup).not.toContain('Connect Jira')
})
})
@@ -0,0 +1,184 @@
import { Fragment, useState } from 'react'
import {
AzureDevOpsIntegrationCard,
BitbucketIntegrationCard,
GiteaIntegrationCard,
GitHubIntegrationCard,
GitLabIntegrationCard
} from '@/components/settings/source-control-integration-cards'
import {
JiraIntegrationCard,
LinearIntegrationCard
} from '@/components/settings/task-tracker-integration-cards'
import { useIntegrationProviderStatusRefresh } from '@/components/settings/use-integration-provider-status-refresh'
import { IntegrationProgress, IntegrationStep } from './connect-integration-step'
import {
deriveIntegrationFlowState,
useIntegrationConnectionStatus
} from './use-integration-connection-status'
import { translate } from '@/i18n/i18n'
// Bold provider names joined into a natural-language list ("Linear and
// GitHub", "Linear, Jira, and GitHub") for the task-step summary.
function TaskSourceNameList(props: { names: readonly string[] }): React.JSX.Element {
return (
<>
{props.names.map((name, index) => (
<Fragment key={name}>
{index > 0
? index === props.names.length - 1
? props.names.length > 2
? translate('auto.components.feature.wall.ConnectIntegrationsList.list_end', ', and ')
: translate('auto.components.feature.wall.ConnectIntegrationsList.list_pair', ' and ')
: translate('auto.components.feature.wall.ConnectIntegrationsList.list_mid', ', ')
: null}
<span className="font-semibold text-foreground">{name}</span>
</Fragment>
))}
</>
)
}
// Progressive two-step integration setup: first connect a code host for review
// status, then a task source. The order is a recommendation, not a gate — step
// 2 starts collapsed but opens on click so tracker-first users aren't blocked.
// Connecting step 1 collapses it to a summary and expands step 2, which stays
// open until a dedicated tracker connects so Linear/Jira remain discoverable.
// Done-state is driven by real provider connection status, never an
// optimistic click.
export function ConnectIntegrationsList(): React.JSX.Element {
useIntegrationProviderStatusRefresh()
const status = useIntegrationConnectionStatus()
// Lets the done review step reopen inline via "Change" without losing its
// connected state. Cleared once the user collapses it again.
const [reviewReopened, setReviewReopened] = useState(false)
// A code host doubles as a task source, so a connected GitHub/GitLab
// resolves step 2 on its own. The collapsed summary still invites a
// dedicated tracker, and "Change" reopens the step to connect one.
const flow = deriveIntegrationFlowState({
reviewConnected: status.reviewConnected,
trackerProviderName: status.trackerProviderName,
codeHostTaskProviderName: status.codeHostTaskProviderName,
trackerChecking: status.trackerChecking
})
const reviewDone = status.reviewConnected
const trackerDone = status.trackerProviderName !== null
const reviewExpanded = !reviewDone || reviewReopened
const reviewCanToggle = reviewDone
// User's explicit expand/collapse of step 2, snapshotted against the
// connection state so a provider connecting (or disconnecting) restores the
// default for the new state instead of keeping a stale manual choice.
const [taskToggle, setTaskToggle] = useState<{
expanded: boolean
whenTrackerDone: boolean
whenReviewDone: boolean
} | null>(null)
const taskToggleCurrent =
taskToggle !== null &&
taskToggle.whenTrackerDone === trackerDone &&
taskToggle.whenReviewDone === reviewDone
// Step 2 defaults collapsed while step 1 is still active (but opens on
// click — review is not a prerequisite for connecting a tracker), stays open
// even when the code host already resolved it so Linear/Jira remain
// discoverable, and collapses only once a dedicated tracker connects.
const taskExpanded = taskToggleCurrent ? taskToggle.expanded : reviewDone && !trackerDone
return (
<div className="space-y-2.5">
<div className="flex items-center justify-between gap-3">
<p className="text-[13px] leading-snug text-muted-foreground">
{translate(
'auto.components.feature.wall.ConnectIntegrationsList.3a1fcdddad',
'Two quick steps: connect where your code is reviewed, then where your team plans work.'
)}
</p>
<IntegrationProgress states={[flow.review, flow.task]} />
</div>
<IntegrationStep
index={0}
state={flow.review}
expanded={reviewExpanded}
title={translate(
'auto.components.feature.wall.ConnectIntegrationsList.review_step_title',
'See PR status while agents work'
)}
description={translate(
'auto.components.feature.wall.ConnectIntegrationsList.review_step_description',
'Connect a review provider so Orca can show PR or MR status, checks, and reviews.'
)}
summary={
<>
<span className="font-semibold text-foreground">{status.reviewProviderName}</span>{' '}
{translate(
'auto.components.feature.wall.ConnectIntegrationsList.5b3577a492',
'connected for review status'
)}
</>
}
onToggle={() => setReviewReopened((value) => !value)}
canToggle={reviewCanToggle}
>
<GitHubIntegrationCard />
<GitLabIntegrationCard />
<BitbucketIntegrationCard />
<AzureDevOpsIntegrationCard />
<GiteaIntegrationCard />
</IntegrationStep>
<IntegrationStep
index={1}
state={flow.task}
expanded={taskExpanded}
title={translate(
'auto.components.feature.wall.ConnectIntegrationsList.task_step_title',
'Start agents on your tasks without leaving Orca'
)}
description={translate(
'auto.components.feature.wall.ConnectIntegrationsList.33b650af52',
'Connect where your team tracks work. Orca starts workspaces with the issue title, link, and context already attached.'
)}
summary={
status.trackerProviderName ? (
<>
<TaskSourceNameList names={status.taskSourceNames} />{' '}
{translate(
'auto.components.feature.wall.ConnectIntegrationsList.3dddb2d565',
'connected for tasks'
)}
</>
) : (
<>
<span className="font-semibold text-foreground">
{status.codeHostTaskProviderName}
</span>{' '}
{translate(
'auto.components.feature.wall.ConnectIntegrationsList.code_host_tasks_summary',
'issues available as tasks · add Linear or Jira if your team plans work there'
)}
</>
)
}
onToggle={() =>
setTaskToggle({
expanded: !taskExpanded,
whenTrackerDone: trackerDone,
whenReviewDone: reviewDone
})
}
>
<LinearIntegrationCard />
<JiraIntegrationCard />
<p className="px-1 pt-1 text-[12px] leading-snug text-muted-foreground">
{translate(
'auto.components.feature.wall.ConnectIntegrationsList.code_host_tasks_caption',
"Your code host's issues also work as tasks."
)}
</p>
<GitHubIntegrationCard />
<GitLabIntegrationCard />
</IntegrationStep>
</div>
)
}
@@ -1,5 +1,5 @@
import { useCallback, useEffect, useMemo } from 'react'
import { ArrowUpRight, Check } from 'lucide-react'
import { Check } from 'lucide-react'
import type {
FeatureWallSetupStep,
FeatureWallSetupStepId
@@ -15,6 +15,7 @@ import {
TwoAgentsAction,
WorkspacesAction
} from './FeatureWallSetupWorkflowActions'
import { ConnectIntegrationsList } from './ConnectIntegrationsList'
import { BrowserAction } from './FeatureWallBrowserAction'
import {
SetupBrowserVisual,
@@ -22,12 +23,11 @@ import {
SetupTwoAgentsVisual,
SetupWorkspacesVisual
} from './FeatureWallSetupStepVisuals'
import { Button } from '@/components/ui/button'
import { GitHubRow, LinearRow } from '../onboarding/IntegrationsStep'
import { AgentStep } from '../onboarding/AgentStep'
import { NotificationStep } from '../onboarding/NotificationStep'
import { useAppStore } from '@/store'
import type { TuiAgent } from '../../../../shared/types'
import { getProviderRuntimeContextKey } from '@/lib/provider-runtime-context'
import { translate } from '@/i18n/i18n'
type FeatureWallSetupChecklistLayout = 'modal' | 'embedded'
@@ -234,31 +234,26 @@ function NotificationAction(): React.JSX.Element {
}
function TaskSourcesAction(): React.JSX.Element {
const closeModal = useAppStore((s) => s.closeModal)
const openTaskPage = useAppStore((s) => s.openTaskPage)
const refreshPreflightStatus = useAppStore((s) => s.refreshPreflightStatus)
const checkJiraConnection = useAppStore((s) => s.checkJiraConnection)
const checkLinearConnection = useAppStore((s) => s.checkLinearConnection)
const settings = useAppStore((s) => s.settings)
const providerRuntimeContextKey = getProviderRuntimeContextKey(settings)
useEffect(() => {
void refreshPreflightStatus()
void checkJiraConnection()
void checkLinearConnection()
}, [
refreshPreflightStatus,
checkJiraConnection,
checkLinearConnection,
providerRuntimeContextKey
])
return (
<div className="space-y-5">
<div className="grid gap-3 xl:grid-cols-2">
<GitHubRow compact />
<LinearRow compact />
</div>
<div className="flex items-center pt-2">
<Button
type="button"
size="sm"
className="w-fit gap-2"
onClick={() => {
closeModal()
openTaskPage()
}}
>
<ArrowUpRight className="size-3.5" />
{translate(
'auto.components.feature.wall.FeatureWallSetupChecklist.b1f1981c5e',
'See tasks'
)}
</Button>
</div>
<ConnectIntegrationsList />
</div>
)
}
@@ -0,0 +1,119 @@
import { Check } from 'lucide-react'
import { cn } from '@/lib/utils'
import type { IntegrationStepState } from './use-integration-connection-status'
import { translate } from '@/i18n/i18n'
export type { IntegrationStepState }
// One progressive step. The active step shows its instructional copy and
// provider rows; a done step collapses to a one-line summary with a "Change"
// affordance that reopens it inline; upcoming steps start collapsed but open
// on click so the step order never blocks anyone. `expanded` (body visibility)
// is tracked separately from `state` so a done step can reopen while still
// reading as connected.
export function IntegrationStep(props: {
index: number
state: IntegrationStepState
expanded: boolean
title: string
description: string
summary?: React.ReactNode
onToggle?: () => void
canToggle?: boolean
children?: React.ReactNode
}): React.JSX.Element {
const { state, expanded, onToggle } = props
const done = state === 'done'
const active = state === 'active'
// Upcoming steps are openable too — the order is a recommendation, not a
// prerequisite, so a Linear/Jira-first user can connect tasks right away.
const canToggle = !active && (props.canToggle ?? true)
return (
<div
className={cn(
'overflow-hidden rounded-xl border bg-card transition-colors',
active || (done && expanded) ? 'border-foreground/25 shadow-xs' : 'border-border'
)}
>
<button
type="button"
onClick={canToggle ? onToggle : undefined}
disabled={!canToggle}
aria-current={active ? 'step' : undefined}
aria-expanded={canToggle ? expanded : undefined}
className={cn(
'flex w-full items-center gap-3 px-4 py-3.5 text-left',
canToggle ? 'hover:bg-accent/50' : 'cursor-default'
)}
>
<span
className={cn(
'flex size-7 shrink-0 items-center justify-center rounded-full border text-[13px] font-semibold leading-none',
done
? 'border-status-success-border bg-status-success-background text-status-success'
: active
? 'border-foreground bg-foreground text-background'
: 'border-border text-muted-foreground'
)}
>
{done ? <Check className="size-3.5" /> : props.index + 1}
</span>
<span className="min-w-0 flex-1">
<span className="block text-[15px] font-semibold leading-tight text-foreground">
{props.title}
</span>
<span className="mt-0.5 block text-[13px] leading-snug text-muted-foreground">
{done ? props.summary : props.description}
</span>
</span>
{canToggle ? (
<span className="shrink-0 text-[12px] font-medium text-muted-foreground">
{done
? expanded
? translate(
'auto.components.feature.wall.connect.integration.step.5538eb6743',
'Done'
)
: translate(
'auto.components.feature.wall.connect.integration.step.0f47ff17c6',
'Change'
)
: expanded
? translate(
'auto.components.feature.wall.connect.integration.step.close_step',
'Close'
)
: translate(
'auto.components.feature.wall.connect.integration.step.open_step',
'Open'
)}
</span>
) : null}
</button>
{expanded ? (
<div className="space-y-2 border-t border-border bg-muted/30 p-3">{props.children}</div>
) : null}
</div>
)
}
// Two progress dots tracking step state; the active one stretches into a bar.
export function IntegrationProgress(props: {
states: readonly IntegrationStepState[]
}): React.JSX.Element {
return (
<div className="flex items-center gap-1.5 pt-2" aria-hidden>
{props.states.map((state, i) => (
<span
key={i}
className={cn(
'h-[7px] rounded-full transition-all',
state === 'active' ? 'w-[22px] bg-foreground' : 'w-[7px]',
state === 'done' ? 'bg-status-success' : state !== 'active' && 'bg-border'
)}
/>
))}
</div>
)
}
@@ -297,6 +297,12 @@ describe('getFeatureWallSetupProgress', () => {
expect(progress.stepDone['task-sources']).toBe(true)
})
it('does not mark task sources complete while provider checks are pending', () => {
const progress = getFeatureWallSetupProgress(makeInput({ hasConnectedTaskSource: false }))
expect(progress.stepDone['task-sources']).toBe(false)
})
it('does not mark agent capabilities complete from setup-start interactions alone', () => {
const progress = getFeatureWallSetupProgress(
makeInput({
@@ -1,13 +1,16 @@
import { useEffect } from 'react'
import type { FeatureWallWorkflow } from '../../../../shared/feature-wall-workflows'
import { useAppStore } from '@/store'
import { getLocalPreflightContext, localPreflightContextKey } from '@/lib/local-preflight-context'
import { getProviderRuntimeContextKey } from '@/lib/provider-runtime-context'
import { deriveIntegrationConnectionStatus } from './use-integration-connection-status'
export type FeatureWallTaskSourcePresentation = {
workflow: FeatureWallWorkflow
hasConnectedTaskSource: boolean
// True until the first preflight + Linear status check has resolved. Callers
// should treat unknown state as "don't show the disconnected setup affordance
// yet" so we don't flash inline integration rows for a connected user.
// True until the first provider status checks resolve. Callers should treat
// unknown state as "don't show the disconnected setup affordance yet" so we
// don't flash inline integration rows for a connected user.
isCheckingTaskSources: boolean
}
@@ -17,11 +20,26 @@ export function useFeatureWallTaskSourcePresentation(
): FeatureWallTaskSourcePresentation {
const preflightStatus = useAppStore((s) => s.preflightStatus)
const preflightStatusChecked = useAppStore((s) => s.preflightStatusChecked)
const preflightStatusContextKey = useAppStore((s) => s.preflightStatusContextKey)
const preflightStatusError = useAppStore((s) => s.preflightStatusError)
const preflightStatusLoading = useAppStore((s) => s.preflightStatusLoading)
const refreshPreflightStatus = useAppStore((s) => s.refreshPreflightStatus)
const linearStatus = useAppStore((s) => s.linearStatus)
const linearStatusChecked = useAppStore((s) => s.linearStatusChecked)
const linearStatusContextKey = useAppStore((s) => s.linearStatusContextKey)
const checkLinearConnection = useAppStore((s) => s.checkLinearConnection)
const jiraStatus = useAppStore((s) => s.jiraStatus)
const jiraStatusChecked = useAppStore((s) => s.jiraStatusChecked)
const jiraStatusContextKey = useAppStore((s) => s.jiraStatusContextKey)
const checkJiraConnection = useAppStore((s) => s.checkJiraConnection)
const settings = useAppStore((s) => s.settings)
const expectedPreflightContextKey = useAppStore((s) =>
localPreflightContextKey(getLocalPreflightContext(s))
)
const providerRuntimeContextKey = getProviderRuntimeContextKey(settings)
const linearStatusCurrent = linearStatusContextKey === providerRuntimeContextKey
const jiraStatusCurrent = jiraStatusContextKey === providerRuntimeContextKey
const preflightStatusCurrent = preflightStatusContextKey === expectedPreflightContextKey
useEffect(() => {
if (!isOpen) {
@@ -29,26 +47,52 @@ export function useFeatureWallTaskSourcePresentation(
}
// Why: the Tasks tour copy depends on whether a task source is already
// usable, so connected users should not see setup-oriented guidance.
if (!preflightStatusChecked) {
if (!preflightStatusCurrent || !preflightStatusChecked) {
void refreshPreflightStatus()
}
if (!linearStatusChecked) {
if (!linearStatusCurrent || !linearStatusChecked) {
void checkLinearConnection()
}
if (!jiraStatusCurrent || !jiraStatusChecked) {
void checkJiraConnection()
}
}, [
checkJiraConnection,
checkLinearConnection,
expectedPreflightContextKey,
isOpen,
jiraStatusCurrent,
jiraStatusChecked,
jiraStatusContextKey,
linearStatusCurrent,
linearStatusChecked,
linearStatusContextKey,
preflightStatusContextKey,
preflightStatusCurrent,
preflightStatusChecked,
providerRuntimeContextKey,
refreshPreflightStatus
])
const hasConnectedTaskSource =
(preflightStatus?.gh.installed === true && preflightStatus.gh.authenticated === true) ||
(preflightStatus?.glab?.installed === true && preflightStatus.glab.authenticated === true) ||
linearStatus.connected === true
const isCheckingTaskSources =
preflightStatusLoading || !preflightStatusChecked || !linearStatusChecked
const taskSourceStatus = deriveIntegrationConnectionStatus({
preflightStatus,
preflightStatusChecked,
preflightStatusContextKey,
preflightStatusError,
preflightStatusLoading,
expectedPreflightContextKey,
linearStatus,
linearStatusChecked,
linearStatusContextKey,
jiraStatus,
jiraStatusChecked,
jiraStatusContextKey,
providerRuntimeContextKey
})
return { workflow: selected, hasConnectedTaskSource, isCheckingTaskSources }
return {
workflow: selected,
hasConnectedTaskSource: taskSourceStatus.trackerConnected,
isCheckingTaskSources: taskSourceStatus.checking
}
}
@@ -0,0 +1,466 @@
import { describe, expect, it } from 'vitest'
import {
deriveIntegrationConnectionStatus,
deriveIntegrationFlowState,
deriveIntegrationStepStates
} from './use-integration-connection-status'
import { deriveCliProviderCardState } from '@/components/settings/source-control-integration-cards'
type StatusFacts = Parameters<typeof deriveIntegrationConnectionStatus>[0]
function statusFacts(overrides: Partial<StatusFacts> = {}): StatusFacts {
return {
preflightStatus: {
gh: { installed: false, authenticated: false },
glab: { installed: false, authenticated: false },
bitbucket: { configured: false, authenticated: false },
azureDevOps: {
configured: false,
authenticated: false,
tokenConfigured: false,
baseUrl: null
},
gitea: {
configured: false,
authenticated: false,
tokenConfigured: false,
baseUrl: null
}
},
preflightStatusChecked: true,
preflightStatusContextKey: 'host',
preflightStatusError: null,
preflightStatusLoading: false,
expectedPreflightContextKey: 'host',
linearStatus: { connected: false },
linearStatusChecked: true,
linearStatusContextKey: 'local#0',
jiraStatus: { connected: false },
jiraStatusChecked: true,
jiraStatusContextKey: 'local#0',
providerRuntimeContextKey: 'local#0',
...overrides
}
}
describe('deriveIntegrationStepStates', () => {
it('starts with review active and tasks upcoming when nothing is connected', () => {
expect(
deriveIntegrationStepStates({
reviewConnected: false,
trackerConnected: false,
codeHostTaskConnected: false
})
).toEqual({ review: 'active', task: 'upcoming', complete: false })
})
it('promotes tasks to active once a non-task review provider connects', () => {
expect(
deriveIntegrationStepStates({
reviewConnected: true,
trackerConnected: false,
codeHostTaskConnected: false
})
).toEqual({ review: 'done', task: 'active', complete: false })
})
it('completes both steps when a dedicated tracker connects', () => {
expect(
deriveIntegrationStepStates({
reviewConnected: true,
trackerConnected: true,
codeHostTaskConnected: false
})
).toEqual({ review: 'done', task: 'done', complete: true })
})
it('completes the task step from a connected code host without a tracker', () => {
// GitHub/GitLab issues double as a task source, so a connected code host
// resolves step 2 outright instead of asking for an extra acknowledgement.
expect(
deriveIntegrationStepStates({
reviewConnected: true,
trackerConnected: false,
codeHostTaskConnected: true
})
).toEqual({ review: 'done', task: 'done', complete: true })
})
it('ignores the code host for tasks until review is connected', () => {
// Step 2 is unreachable until step 1 is done, so the code host alone must
// not resolve tasks or complete the flow.
expect(
deriveIntegrationStepStates({
reviewConnected: false,
trackerConnected: false,
codeHostTaskConnected: true
})
).toEqual({ review: 'active', task: 'upcoming', complete: false })
})
it('marks tasks done if a tracker is already connected before a code host', () => {
// A pre-existing Linear/Jira connection is a real, truthful task source even
// if the user has not yet connected a code host for review.
expect(
deriveIntegrationStepStates({
reviewConnected: false,
trackerConnected: true,
codeHostTaskConnected: false
})
).toEqual({ review: 'active', task: 'done', complete: false })
})
})
describe('deriveIntegrationConnectionStatus', () => {
it('does not expose cached GitHub review readiness while preflight is unresolved', () => {
const cachedGitHub = {
gh: { installed: true, authenticated: true },
glab: { installed: false, authenticated: false }
}
const unresolvedFacts: Partial<StatusFacts>[] = [
{ preflightStatus: cachedGitHub, preflightStatusLoading: true },
{ preflightStatus: cachedGitHub, preflightStatusChecked: false },
{ preflightStatus: cachedGitHub, preflightStatusContextKey: 'wsl:Ubuntu' }
]
for (const overrides of unresolvedFacts) {
expect(deriveIntegrationConnectionStatus(statusFacts(overrides))).toMatchObject({
reviewConnected: false,
reviewProviderName: null,
reviewChecking: true
})
}
})
it('treats current preflight errors as settled without exposing cached auth', () => {
expect(
deriveIntegrationConnectionStatus(
statusFacts({
preflightStatus: {
gh: { installed: true, authenticated: true },
glab: { installed: false, authenticated: false }
},
preflightStatusError: 'failed to check gh'
})
)
).toMatchObject({
reviewConnected: false,
reviewProviderName: null,
reviewChecking: false,
checking: false
})
})
it('does not expose cached GitLab review readiness while preflight is stale', () => {
expect(
deriveIntegrationConnectionStatus(
statusFacts({
preflightStatus: {
gh: { installed: false, authenticated: false },
glab: { installed: true, authenticated: true }
},
preflightStatusContextKey: 'wsl:Debian'
})
)
).toMatchObject({
reviewConnected: false,
reviewProviderName: null,
reviewChecking: true
})
})
it('does not expose cached Linear or Jira tracker readiness while checks are stale', () => {
const staleTrackerFacts: Partial<StatusFacts>[] = [
{
linearStatus: { connected: true },
linearStatusContextKey: 'runtime:old#0'
},
{
jiraStatus: { connected: true },
jiraStatusChecked: false
}
]
for (const overrides of staleTrackerFacts) {
expect(deriveIntegrationConnectionStatus(statusFacts(overrides))).toMatchObject({
trackerProviderName: null,
trackerChecking: true
})
}
})
it('keeps a current connected tracker usable when the other tracker is stale', () => {
expect(
deriveIntegrationConnectionStatus(
statusFacts({
linearStatus: { connected: true },
jiraStatusContextKey: 'runtime:old#0'
})
)
).toMatchObject({
trackerConnected: true,
trackerProviderName: 'Linear',
trackerChecking: false,
checking: false
})
})
it('does not report task-source checking when a tracker is usable but preflight is stale', () => {
expect(
deriveIntegrationConnectionStatus(
statusFacts({
preflightStatus: {
gh: { installed: true, authenticated: true },
glab: { installed: false, authenticated: false }
},
preflightStatusContextKey: 'wsl:Ubuntu',
linearStatus: { connected: true }
})
)
).toMatchObject({
reviewConnected: false,
trackerConnected: true,
trackerProviderName: 'Linear',
checking: false
})
})
it('exposes provider readiness once the relevant checks are resolved and current', () => {
expect(
deriveIntegrationConnectionStatus(
statusFacts({
preflightStatus: {
gh: { installed: true, authenticated: true },
glab: { installed: false, authenticated: false }
},
linearStatus: { connected: true }
})
)
).toMatchObject({
reviewConnected: true,
reviewProviderName: 'GitHub',
codeHostTaskProviderName: 'GitHub',
trackerConnected: true,
trackerProviderName: 'Linear',
// Trackers lead, but the code host stays listed so task summaries do not
// under-report what is usable.
taskSourceNames: ['Linear', 'GitHub'],
checking: false
})
})
it('lists every connected task source with trackers before code hosts', () => {
expect(
deriveIntegrationConnectionStatus(
statusFacts({
preflightStatus: {
gh: { installed: true, authenticated: true },
glab: { installed: true, authenticated: true }
},
linearStatus: { connected: true },
jiraStatus: { connected: true }
})
)
).toMatchObject({
taskSourceNames: ['Linear', 'Jira', 'GitHub', 'GitLab']
})
expect(
deriveIntegrationConnectionStatus(
statusFacts({
preflightStatus: {
gh: { installed: true, authenticated: true },
glab: { installed: false, authenticated: false }
}
})
)
).toMatchObject({
trackerProviderName: null,
taskSourceNames: ['GitHub']
})
})
it('counts token-backed review providers as review-ready without treating them as task sources', () => {
const cases: {
name: 'Bitbucket' | 'Azure DevOps' | 'Gitea'
preflightStatus: StatusFacts['preflightStatus']
}[] = [
{
name: 'Bitbucket',
preflightStatus: {
gh: { installed: false, authenticated: false },
glab: { installed: false, authenticated: false },
bitbucket: { configured: true, authenticated: true }
}
},
{
name: 'Azure DevOps',
preflightStatus: {
gh: { installed: false, authenticated: false },
glab: { installed: false, authenticated: false },
azureDevOps: {
configured: true,
authenticated: true,
tokenConfigured: true,
baseUrl: 'https://dev.azure.com/acme'
}
}
},
{
name: 'Gitea',
preflightStatus: {
gh: { installed: false, authenticated: false },
glab: { installed: false, authenticated: false },
gitea: {
configured: true,
authenticated: true,
tokenConfigured: true,
baseUrl: 'https://gitea.example.test/api/v1'
}
}
}
]
for (const testCase of cases) {
expect(
deriveIntegrationConnectionStatus(
statusFacts({
preflightStatus: testCase.preflightStatus
})
)
).toMatchObject({
reviewConnected: true,
reviewProviderName: testCase.name,
codeHostTaskProviderName: null,
trackerConnected: false,
trackerProviderName: null,
checking: false
})
}
})
it('does not count failed token-backed review provider auth as review-ready', () => {
expect(
deriveIntegrationConnectionStatus(
statusFacts({
preflightStatus: {
gh: { installed: false, authenticated: false },
glab: { installed: false, authenticated: false },
azureDevOps: {
configured: true,
authenticated: false,
tokenConfigured: true,
baseUrl: 'https://dev.azure.com/acme'
},
gitea: {
configured: true,
authenticated: false,
tokenConfigured: true,
baseUrl: 'https://gitea.example.test/api/v1'
}
}
})
)
).toMatchObject({
reviewConnected: false,
reviewProviderName: null,
codeHostTaskProviderName: null
})
})
})
describe('deriveCliProviderCardState', () => {
it('does not show cached CLI auth as connected while preflight is stale or errored', () => {
const connectedCli = { installed: true, authenticated: true }
expect(
deriveCliProviderCardState({
cliStatus: connectedCli,
preflightStatusAvailable: true,
preflightStatusChecked: true,
preflightStatusCurrent: false,
preflightStatusError: null,
preflightStatusLoading: false
})
).toBe('checking')
expect(
deriveCliProviderCardState({
cliStatus: connectedCli,
preflightStatusAvailable: true,
preflightStatusChecked: true,
preflightStatusCurrent: true,
preflightStatusError: 'failed to check gh',
preflightStatusLoading: false
})
).toBe('unavailable')
})
})
describe('deriveIntegrationFlowState', () => {
it('does not complete progress from the code host while tracker facts are unresolved', () => {
const status = deriveIntegrationConnectionStatus(
statusFacts({
preflightStatus: {
gh: { installed: true, authenticated: true },
glab: { installed: false, authenticated: false }
},
linearStatus: { connected: true },
linearStatusContextKey: 'runtime:old#0'
})
)
expect(
deriveIntegrationFlowState({
reviewConnected: status.reviewConnected,
trackerProviderName: status.trackerProviderName,
codeHostTaskProviderName: status.codeHostTaskProviderName,
trackerChecking: status.trackerChecking
})
).toMatchObject({
review: 'done',
task: 'active',
complete: false
})
})
it('completes the flow from a connected code host once tracker checks settle', () => {
const status = deriveIntegrationConnectionStatus(
statusFacts({
preflightStatus: {
gh: { installed: true, authenticated: true },
glab: { installed: false, authenticated: false }
}
})
)
expect(
deriveIntegrationFlowState({
reviewConnected: status.reviewConnected,
trackerProviderName: status.trackerProviderName,
codeHostTaskProviderName: status.codeHostTaskProviderName,
trackerChecking: status.trackerChecking
})
).toMatchObject({
review: 'done',
task: 'done',
complete: true,
taskResolved: true
})
})
it('keeps tracker-before-code-host completion scoped to the task step only', () => {
expect(
deriveIntegrationFlowState({
reviewConnected: false,
trackerProviderName: 'Jira',
codeHostTaskProviderName: null,
trackerChecking: false
})
).toMatchObject({
review: 'active',
task: 'done',
complete: false
})
})
})
@@ -0,0 +1,255 @@
import { useAppStore } from '@/store'
import { getLocalPreflightContext, localPreflightContextKey } from '@/lib/local-preflight-context'
import { getProviderRuntimeContextKey } from '@/lib/provider-runtime-context'
export type IntegrationStepState = 'active' | 'done' | 'upcoming'
// Pure derivation of the two-step flow's step states from connection facts,
// extracted so the progressive logic is testable without the store or DOM.
// `codeHostTaskConnected` means a connected code host whose issues double as
// a task source (GitHub/GitLab), which resolves step 2 without a tracker.
export function deriveIntegrationStepStates(input: {
reviewConnected: boolean
trackerConnected: boolean
codeHostTaskConnected: boolean
}): { review: IntegrationStepState; task: IntegrationStepState; complete: boolean } {
const review: IntegrationStepState = input.reviewConnected ? 'done' : 'active'
// A dedicated tracker resolves tasks outright. The code host only counts
// once review is connected, since step 2 is unreachable before then.
const taskResolved =
input.trackerConnected || (input.reviewConnected && input.codeHostTaskConnected)
const task: IntegrationStepState = taskResolved
? 'done'
: input.reviewConnected
? 'active'
: 'upcoming'
return { review, task, complete: input.reviewConnected && taskResolved }
}
export function deriveIntegrationFlowState(input: {
reviewConnected: boolean
trackerProviderName: 'Linear' | 'Jira' | null
codeHostTaskProviderName: 'GitHub' | 'GitLab' | null
trackerChecking: boolean
}): {
review: IntegrationStepState
task: IntegrationStepState
complete: boolean
taskResolved: boolean
} {
const trackerConnected = input.trackerProviderName !== null
// The code host resolves the task step only after dedicated tracker facts
// have settled for this runtime, so the collapsed summary names the right
// completion reason instead of flashing the code-host fallback copy.
const codeHostTaskReady = input.codeHostTaskProviderName !== null && !input.trackerChecking
const stepStates = deriveIntegrationStepStates({
reviewConnected: input.reviewConnected,
trackerConnected,
codeHostTaskConnected: codeHostTaskReady
})
return {
...stepStates,
taskResolved: stepStates.task === 'done'
}
}
type CliStatus = {
installed?: boolean
authenticated?: boolean
}
type BitbucketStatus = {
configured?: boolean
authenticated?: boolean
}
type TokenReviewStatus = {
configured?: boolean
authenticated?: boolean
baseUrl?: string | null
tokenConfigured?: boolean
}
type ProviderStatusFacts = {
preflightStatus: {
gh?: CliStatus
glab?: CliStatus
bitbucket?: BitbucketStatus
azureDevOps?: TokenReviewStatus
gitea?: TokenReviewStatus
} | null
preflightStatusChecked: boolean
preflightStatusContextKey: string | null
preflightStatusError: string | null
preflightStatusLoading: boolean
expectedPreflightContextKey: string
linearStatus: { connected?: boolean }
linearStatusChecked: boolean
linearStatusContextKey: string | null
jiraStatus: { connected?: boolean }
jiraStatusChecked: boolean
jiraStatusContextKey: string | null
providerRuntimeContextKey: string
}
export type IntegrationConnectionStatus = {
// True once any review provider is connected/configured for this context.
reviewConnected: boolean
// Display name of the connected review provider, or null while none is.
reviewProviderName: 'GitHub' | 'GitLab' | 'Bitbucket' | 'Azure DevOps' | 'Gitea' | null
// GitHub/GitLab issues can double as tasks; token/env review providers do not.
codeHostTaskProviderName: 'GitHub' | 'GitLab' | null
// True once any task source is usable: a code host (its issues double as a
// task source) or a dedicated tracker (Linear/Jira).
trackerConnected: boolean
// Display name of the connected tracker, or null. Code hosts are surfaced
// via reviewProviderName, so this only names Linear/Jira.
trackerProviderName: 'Linear' | 'Jira' | null
// Every connected task source, trackers first, for "Linear and GitHub
// connected for tasks" summaries that don't under-report what's usable.
taskSourceNames: ('Linear' | 'Jira' | 'GitHub' | 'GitLab')[]
// True while the code-host check is unresolved, stale, loading, or errored.
reviewChecking: boolean
// True while either dedicated tracker check is unresolved or stale.
trackerChecking: boolean
// True until the underlying provider checks have resolved for this surface.
// Callers should treat unknown state as "not connected yet" rather than
// flashing summaries.
checking: boolean
}
function isBitbucketReviewConnected(status: BitbucketStatus | undefined): boolean {
return status?.configured === true && status.authenticated === true
}
function isAzureDevOpsReviewConfigured(status: TokenReviewStatus | undefined): boolean {
if (status?.configured !== true) {
return false
}
if (status.tokenConfigured === true && status.baseUrl && status.authenticated !== true) {
return false
}
return true
}
function isGiteaReviewConfigured(status: TokenReviewStatus | undefined): boolean {
if (status?.configured !== true) {
return false
}
if (status.tokenConfigured === true && status.authenticated !== true) {
return false
}
return true
}
export function deriveIntegrationConnectionStatus(
facts: ProviderStatusFacts
): IntegrationConnectionStatus {
const preflightCurrent = facts.preflightStatusContextKey === facts.expectedPreflightContextKey
const reviewChecking =
facts.preflightStatusLoading || !facts.preflightStatusChecked || !preflightCurrent
const reviewReadyForConnection = !reviewChecking && facts.preflightStatusError === null
const githubConnected =
reviewReadyForConnection &&
facts.preflightStatus?.gh?.installed === true &&
facts.preflightStatus.gh.authenticated === true
const gitlabConnected =
reviewReadyForConnection &&
facts.preflightStatus?.glab?.installed === true &&
facts.preflightStatus.glab.authenticated === true
const bitbucketConnected =
reviewReadyForConnection && isBitbucketReviewConnected(facts.preflightStatus?.bitbucket)
const azureDevOpsConnected =
reviewReadyForConnection && isAzureDevOpsReviewConfigured(facts.preflightStatus?.azureDevOps)
const giteaConnected =
reviewReadyForConnection && isGiteaReviewConfigured(facts.preflightStatus?.gitea)
const linearStatusCurrent = facts.linearStatusContextKey === facts.providerRuntimeContextKey
const jiraStatusCurrent = facts.jiraStatusContextKey === facts.providerRuntimeContextKey
const linearChecking = !linearStatusCurrent || !facts.linearStatusChecked
const jiraChecking = !jiraStatusCurrent || !facts.jiraStatusChecked
const linearConnected =
!linearChecking && linearStatusCurrent && facts.linearStatus.connected === true
const jiraConnected = !jiraChecking && jiraStatusCurrent && facts.jiraStatus.connected === true
const reviewProviderName = githubConnected
? 'GitHub'
: gitlabConnected
? 'GitLab'
: bitbucketConnected
? 'Bitbucket'
: azureDevOpsConnected
? 'Azure DevOps'
: giteaConnected
? 'Gitea'
: null
const codeHostTaskProviderName = githubConnected ? 'GitHub' : gitlabConnected ? 'GitLab' : null
const trackerProviderName = linearConnected ? 'Linear' : jiraConnected ? 'Jira' : null
const taskSourceNames: IntegrationConnectionStatus['taskSourceNames'] = [
...(linearConnected ? (['Linear'] as const) : []),
...(jiraConnected ? (['Jira'] as const) : []),
...(githubConnected ? (['GitHub'] as const) : []),
...(gitlabConnected ? (['GitLab'] as const) : [])
]
const hasUsableTaskSource = taskSourceNames.length > 0
// Why: one resolved task source is enough for parent setup readiness, but the
// local "use code host issues" acknowledgement waits until tracker checks
// settle so the banner uses the right completion reason.
const trackerChecking = trackerProviderName === null && (linearChecking || jiraChecking)
return {
reviewConnected:
githubConnected ||
gitlabConnected ||
bitbucketConnected ||
azureDevOpsConnected ||
giteaConnected,
reviewProviderName,
codeHostTaskProviderName,
trackerConnected: hasUsableTaskSource,
trackerProviderName,
taskSourceNames,
reviewChecking,
trackerChecking,
checking: !hasUsableTaskSource && (reviewChecking || trackerChecking)
}
}
// Derives the two-step progressive flow's done-state from real provider
// connection status, mirroring use-feature-wall-task-source-presentation so a
// code host counts as both a review source and a task source.
export function useIntegrationConnectionStatus(): IntegrationConnectionStatus {
const preflightStatus = useAppStore((s) => s.preflightStatus)
const preflightStatusChecked = useAppStore((s) => s.preflightStatusChecked)
const preflightStatusContextKey = useAppStore((s) => s.preflightStatusContextKey)
const preflightStatusError = useAppStore((s) => s.preflightStatusError)
const preflightStatusLoading = useAppStore((s) => s.preflightStatusLoading)
const linearStatus = useAppStore((s) => s.linearStatus)
const linearStatusChecked = useAppStore((s) => s.linearStatusChecked)
const linearStatusContextKey = useAppStore((s) => s.linearStatusContextKey)
const jiraStatus = useAppStore((s) => s.jiraStatus)
const jiraStatusChecked = useAppStore((s) => s.jiraStatusChecked)
const jiraStatusContextKey = useAppStore((s) => s.jiraStatusContextKey)
const settings = useAppStore((s) => s.settings)
const expectedPreflightContextKey = useAppStore((s) =>
localPreflightContextKey(getLocalPreflightContext(s))
)
const providerRuntimeContextKey = getProviderRuntimeContextKey(settings)
return deriveIntegrationConnectionStatus({
preflightStatus,
preflightStatusChecked,
preflightStatusContextKey,
preflightStatusError,
preflightStatusLoading,
expectedPreflightContextKey,
linearStatus,
linearStatusChecked,
linearStatusContextKey,
jiraStatus,
jiraStatusChecked,
jiraStatusContextKey,
providerRuntimeContextKey
})
}
@@ -0,0 +1,249 @@
import { useId, useState } from 'react'
import { LoaderCircle, Lock } from 'lucide-react'
import { useAppStore } from '@/store'
import { useMountedRef } from '@/hooks/useMountedRef'
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle
} from '@/components/ui/dialog'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { cn } from '@/lib/utils'
import { hasRemoteProviderRuntime } from '@/lib/provider-runtime-context'
import { translate } from '@/i18n/i18n'
type JiraConnectDialogProps = {
open: boolean
onOpenChange: (open: boolean) => void
onConnected?: () => void
overlayClassName?: string
contentClassName?: string
}
type ConnectState = 'idle' | 'connecting' | 'error'
// Why: mirrors the inline Jira connect dialog in TaskPage so the onboarding
// "Connect integrations" step can reuse the same site URL + email + API token
// flow without depending on TaskPage's local state.
export function JiraConnectDialog({
open,
onOpenChange,
onConnected,
overlayClassName,
contentClassName
}: JiraConnectDialogProps): React.JSX.Element {
const connectJira = useAppStore((s) => s.connectJira)
const settings = useAppStore((s) => s.settings)
const mountedRef = useMountedRef()
const siteUrlId = useId()
const emailId = useId()
const tokenId = useId()
const errorId = useId()
const [siteUrl, setSiteUrl] = useState('')
const [email, setEmail] = useState('')
const [apiToken, setApiToken] = useState('')
const [connectState, setConnectState] = useState<ConnectState>('idle')
const [connectError, setConnectError] = useState<string | null>(null)
const canSubmit =
Boolean(siteUrl.trim()) &&
Boolean(email.trim()) &&
Boolean(apiToken.trim()) &&
connectState !== 'connecting'
const credentialStorageCopy = hasRemoteProviderRuntime(settings)
? 'Your token is sent to the selected remote runtime and stored there with runtime-supported encryption.'
: 'Your token is stored locally and encrypted when local runtime storage supports it.'
const clearErrorOnEdit = (): void => {
if (connectState === 'error') {
setConnectState('idle')
setConnectError(null)
}
}
const handleOpenChange = (nextOpen: boolean): void => {
if (connectState !== 'connecting') {
onOpenChange(nextOpen)
}
}
const handleConnect = async (): Promise<void> => {
const trimmedSite = siteUrl.trim()
const trimmedEmail = email.trim()
const trimmedToken = apiToken.trim()
if (!trimmedSite || !trimmedEmail || !trimmedToken || connectState === 'connecting') {
return
}
setConnectState('connecting')
setConnectError(null)
try {
const result = await connectJira({
siteUrl: trimmedSite,
email: trimmedEmail,
apiToken: trimmedToken
})
if (!mountedRef.current) {
return
}
if (result.ok) {
setSiteUrl('')
setEmail('')
setApiToken('')
setConnectState('idle')
onOpenChange(false)
onConnected?.()
return
}
setConnectState('error')
setConnectError(result.error)
} catch (error) {
if (mountedRef.current) {
setConnectState('error')
setConnectError(error instanceof Error ? error.message : 'Connection failed')
}
}
}
return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent
overlayClassName={overlayClassName}
className={cn('sm:max-w-md', contentClassName)}
>
<DialogHeader className="gap-3">
<DialogTitle className="leading-tight">
{translate('auto.components.jira.connect.dialog.8388bdea2b', 'Connect Jira site')}
</DialogTitle>
<DialogDescription>
{translate(
'auto.components.jira.connect.dialog.d785c42b8b',
'Use a Jira Cloud site URL, Atlassian email, and API token to browse issues.'
)}
</DialogDescription>
</DialogHeader>
<form
className="flex flex-col gap-4"
onSubmit={(event) => {
event.preventDefault()
void handleConnect()
}}
>
<div className="flex flex-col gap-3">
<div className="space-y-2">
<Label htmlFor={siteUrlId} className="text-xs">
{translate('auto.components.jira.connect.dialog.e176f9d0c5', 'Jira Cloud site URL')}
</Label>
<Input
id={siteUrlId}
autoFocus
placeholder={translate(
'auto.components.jira.connect.dialog.70fcd360c4',
'https://example.atlassian.net'
)}
value={siteUrl}
onChange={(event) => {
setSiteUrl(event.target.value)
clearErrorOnEdit()
}}
disabled={connectState === 'connecting'}
/>
</div>
<div className="space-y-2">
<Label htmlFor={emailId} className="text-xs">
{translate('auto.components.jira.connect.dialog.2849ddb295', 'Atlassian email')}
</Label>
<Input
id={emailId}
type="email"
placeholder={translate(
'auto.components.jira.connect.dialog.e91b9a4073',
'you@example.com'
)}
value={email}
onChange={(event) => {
setEmail(event.target.value)
clearErrorOnEdit()
}}
disabled={connectState === 'connecting'}
/>
</div>
<div className="space-y-2">
<Label htmlFor={tokenId} className="text-xs">
{translate('auto.components.jira.connect.dialog.3d81bf3ab3', 'API token')}
</Label>
<Input
id={tokenId}
type="password"
placeholder={translate(
'auto.components.jira.connect.dialog.7b3967c12f',
'Atlassian API token'
)}
value={apiToken}
onChange={(event) => {
setApiToken(event.target.value)
clearErrorOnEdit()
}}
disabled={connectState === 'connecting'}
aria-invalid={connectState === 'error'}
aria-describedby={connectState === 'error' ? errorId : undefined}
/>
</div>
{connectState === 'error' && connectError ? (
<p id={errorId} className="text-xs text-destructive">
{connectError}
</p>
) : null}
<p className="text-xs text-muted-foreground">
{translate('auto.components.jira.connect.dialog.8090504a3e', 'Create a token in')}{' '}
<button
type="button"
className="text-primary underline-offset-2 hover:underline"
onClick={() =>
window.api.shell.openUrl(
'https://id.atlassian.com/manage-profile/security/api-tokens'
)
}
>
{translate(
'auto.components.jira.connect.dialog.fdd26d81cc',
'Atlassian account settings'
)}
</button>
.
</p>
<p className="flex items-center gap-1.5 text-[11px] text-muted-foreground/70">
<Lock className="size-3 shrink-0" />
{credentialStorageCopy}
</p>
</div>
<DialogFooter>
<Button
type="button"
variant="ghost"
onClick={() => onOpenChange(false)}
disabled={connectState === 'connecting'}
>
{translate('auto.components.jira.connect.dialog.79e7aaed39', 'Cancel')}
</Button>
<Button type="submit" disabled={!canSubmit}>
{connectState === 'connecting' ? (
<>
<LoaderCircle className="size-4 animate-spin" />
{translate('auto.components.jira.connect.dialog.4a2ab52781', 'Verifying…')}
</>
) : (
translate('auto.components.jira.connect.dialog.63ce735809', 'Connect')
)}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
)
}
@@ -41,6 +41,7 @@ import {
} from '@/lib/github-links'
import { lookupSmartGitHubSubmitItem } from '@/lib/smart-github-submit'
import { parseGitLabIssueOrMRLink } from '@/lib/gitlab-links'
import { getLocalPreflightContext, localPreflightContextKey } from '@/lib/local-preflight-context'
import { cn } from '@/lib/utils'
import { LinearIcon } from '@/components/icons/LinearIcon'
import { JiraIcon } from '@/components/icons/JiraIcon'
@@ -189,6 +190,8 @@ export default function SmartWorkspaceNameField({
listLinearIssues,
preflightStatus,
preflightStatusChecked,
preflightStatusContextKey,
expectedPreflightContextKey,
refreshPreflightStatus,
searchLinearIssues,
settings
@@ -203,6 +206,8 @@ export default function SmartWorkspaceNameField({
listLinearIssues: s.listLinearIssues,
preflightStatus: s.preflightStatus,
preflightStatusChecked: s.preflightStatusChecked,
preflightStatusContextKey: s.preflightStatusContextKey,
expectedPreflightContextKey: localPreflightContextKey(getLocalPreflightContext(s)),
refreshPreflightStatus: s.refreshPreflightStatus,
searchLinearIssues: s.searchLinearIssues,
settings: s.settings
@@ -239,13 +244,14 @@ export default function SmartWorkspaceNameField({
link: NonNullable<ReturnType<typeof parseGitHubIssueOrPRLink>>
matchingRepo: RepoOption | null
} | null>(null)
const preflightStatusCurrent = preflightStatusContextKey === expectedPreflightContextKey
const availableTaskProviders = useMemo(
() =>
filterAvailableTaskProviders(['github', 'gitlab', 'linear'], {
gitlabInstalled: preflightStatus?.glab?.installed === true,
gitlabInstalled: preflightStatusCurrent && preflightStatus?.glab?.installed === true,
linearConnected: linearStatus.connected === true
}),
[linearStatus.connected, preflightStatus?.glab?.installed]
[linearStatus.connected, preflightStatus?.glab?.installed, preflightStatusCurrent]
)
const gitlabAvailable = availableTaskProviders.includes('gitlab')
const linearAvailable = availableTaskProviders.includes('linear')
@@ -314,7 +320,7 @@ export default function SmartWorkspaceNameField({
if (disabled || textOnly) {
return
}
if (!preflightStatusChecked) {
if (!preflightStatusChecked || !preflightStatusCurrent) {
void refreshPreflightStatus()
}
if (!linearStatusChecked) {
@@ -325,6 +331,7 @@ export default function SmartWorkspaceNameField({
disabled,
linearStatusChecked,
preflightStatusChecked,
preflightStatusCurrent,
refreshPreflightStatus,
textOnly
])
@@ -1,891 +1,58 @@
/* eslint-disable max-lines -- Why: this pane co-locates source-host and
Linear integration cards so the preflight-check + status-badge +
install/auth-prompt scaffolding lives in one place rather than fanning
out across per-integration files that would each repeat the same
pattern. Splitting buys nothing while the surface stays this narrow. */
import { useEffect, useState } from 'react'
import {
Github,
Gitlab,
GitPullRequestArrow,
ExternalLink,
LoaderCircle,
Terminal,
Unlink,
CheckCircle2,
AlertCircle
} from 'lucide-react'
import { useAppStore } from '../../store'
import { Button } from '../ui/button'
import { useMountedRef } from '@/hooks/useMountedRef'
import { LinearApiKeyDialog } from '@/components/linear-api-key-dialog'
import {
getPreflightIntegrationStatuses,
type PreflightRefreshProvider
} from './integrations-pane-status'
import { JiraIntegrationCard } from './jira-integration-card'
AzureDevOpsIntegrationCard,
BitbucketIntegrationCard,
GiteaIntegrationCard,
GitHubIntegrationCard,
GitLabIntegrationCard
} from './source-control-integration-cards'
import { JiraIntegrationCard, LinearIntegrationCard } from './task-tracker-integration-cards'
import { useIntegrationProviderStatusRefresh } from './use-integration-provider-status-refresh'
import { translate } from '@/i18n/i18n'
export { getIntegrationsPaneSearchEntries } from './integrations-search'
function LinearIcon({ className }: { className?: string }): React.JSX.Element {
return (
<svg viewBox="0 0 24 24" aria-hidden className={className} fill="currentColor">
<path d="M2.886 4.18A11.982 11.982 0 0 1 11.99 0C18.624 0 24 5.376 24 12.009c0 3.64-1.62 6.903-4.18 9.105L2.887 4.18ZM1.817 5.626l16.556 16.556c-.524.33-1.075.62-1.65.866L.951 7.277c.247-.575.537-1.126.866-1.65ZM.322 9.163l14.515 14.515c-.71.172-1.443.282-2.195.322L0 11.358a12 12 0 0 1 .322-2.195Zm-.17 4.862 9.823 9.824a12.02 12.02 0 0 1-9.824-9.824Z" />
</svg>
)
}
export function IntegrationsPane(): React.JSX.Element {
const linearStatus = useAppStore((s) => s.linearStatus)
const preflightStatus = useAppStore((s) => s.preflightStatus)
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 ?? []
const mountedRef = useMountedRef()
const [refreshingPreflightProviders, setRefreshingPreflightProviders] = useState<
Set<PreflightRefreshProvider>
>(new Set())
const [linearDialogOpen, setLinearDialogOpen] = useState(false)
const [linearTestingWorkspaceId, setLinearTestingWorkspaceId] = useState<string | null>(null)
const [linearTestResultByWorkspace, setLinearTestResultByWorkspace] = useState<
Record<string, { state: 'ok' | 'error'; error?: string }>
>({})
useEffect(() => {
void checkLinearConnection()
void refreshPreflightStatus()
}, [checkLinearConnection, refreshPreflightStatus])
const {
ghStatus,
glabStatus,
bitbucketStatus,
bitbucketAccount,
azureDevOpsStatus,
azureDevOpsAccount,
azureDevOpsBaseUrl,
giteaStatus,
giteaAccount,
giteaBaseUrl
} = getPreflightIntegrationStatuses(preflightStatus, refreshingPreflightProviders)
const handleLinearDisconnect = async (workspaceId?: string): Promise<void> => {
await (workspaceId ? disconnectLinearWorkspace(workspaceId) : disconnectLinear())
if (!mountedRef.current) {
return
}
setLinearTestResultByWorkspace({})
}
// Why: explicit user-triggered verification. This is the *only* path in
// settings that decrypts the stored API key, so the macOS Keychain prompt
// (if the app signature has changed since the item was stored) only
// appears when the user clicks Test — not just for opening Settings.
const handleLinearTest = async (workspaceId: string): Promise<void> => {
setLinearTestingWorkspaceId(workspaceId)
setLinearTestResultByWorkspace((prev) => {
const next = { ...prev }
delete next[workspaceId]
return next
})
const result = await testLinearConnection(workspaceId)
if (!mountedRef.current) {
return
}
if (result.ok) {
setLinearTestResultByWorkspace((prev) => ({
...prev,
[workspaceId]: { state: 'ok' }
}))
} else {
setLinearTestResultByWorkspace((prev) => ({
...prev,
[workspaceId]: { state: 'error', error: result.error }
}))
}
setLinearTestingWorkspaceId(null)
}
const refreshPreflightProvider = (provider: PreflightRefreshProvider): void => {
setRefreshingPreflightProviders((prev) => new Set(prev).add(provider))
void refreshPreflightStatus({ force: true }).finally(() => {
if (!mountedRef.current) {
return
}
setRefreshingPreflightProviders((prev) => {
if (!prev.has(provider)) {
return prev
}
const next = new Set(prev)
next.delete(provider)
return next
})
})
}
const handleRefreshGlab = (): void => refreshPreflightProvider('glab')
const handleRefreshGh = (): void => refreshPreflightProvider('gh')
const handleRefreshBitbucket = (): void => refreshPreflightProvider('bitbucket')
const handleRefreshAzureDevOps = (): void => refreshPreflightProvider('azureDevOps')
const handleRefreshGitea = (): void => refreshPreflightProvider('gitea')
useIntegrationProviderStatusRefresh()
return (
<div className="space-y-3">
{/* GitHub */}
<div className="rounded-md border border-border/50 bg-muted/30 px-4 py-3">
<div className="flex items-center gap-3">
<Github className="size-5 shrink-0 text-muted-foreground" />
<div className="min-w-0 flex-1 space-y-0.5">
<p className="text-sm font-medium">
{translate('auto.components.settings.IntegrationsPane.70c5f74f36', 'GitHub')}
</p>
<p className="text-xs text-muted-foreground">
{translate(
'auto.components.settings.IntegrationsPane.de6a0d13ab',
'Pull requests, issues, and checks via the'
)}{' '}
<span className="font-mono text-[11px]">
{translate('auto.components.settings.IntegrationsPane.f36365ed45', 'gh')}
</span>{' '}
{translate('auto.components.settings.IntegrationsPane.ea160a9978', 'CLI.')}
</p>
</div>
{ghStatus === 'checking' ? (
<LoaderCircle className="size-4 shrink-0 animate-spin text-muted-foreground" />
) : ghStatus === 'connected' ? (
<span className="shrink-0 rounded-full border border-emerald-500/30 bg-emerald-500/10 px-2.5 py-1 text-[11px] font-medium text-emerald-700 dark:text-emerald-300">
{translate('auto.components.settings.IntegrationsPane.6432f6522e', 'Connected')}
</span>
) : (
<span className="shrink-0 rounded-full border border-amber-500/30 bg-amber-500/10 px-2.5 py-1 text-[11px] font-medium text-amber-700 dark:text-amber-300">
{ghStatus === 'not-installed'
? translate('auto.components.settings.IntegrationsPane.f7eb5f0b24', 'Not installed')
: translate(
'auto.components.settings.IntegrationsPane.15cf990798',
'Not authenticated'
)}
</span>
)}
</div>
{ghStatus !== 'checking' && ghStatus !== 'connected' && (
<div className="mt-3 rounded-md border border-border/30 bg-background/50 px-3 py-2.5 space-y-2">
{ghStatus === 'not-installed' ? (
<>
<p className="text-xs text-muted-foreground">
{translate(
'auto.components.settings.IntegrationsPane.c0c8575e05',
'Install the GitHub CLI to enable pull requests, issues, and checks.'
)}
</p>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={() => window.api.shell.openUrl('https://cli.github.com')}
>
<ExternalLink className="size-3.5 mr-1.5" />
{translate(
'auto.components.settings.IntegrationsPane.399cf46867',
'Install GitHub CLI'
)}
</Button>
<Button variant="ghost" size="sm" onClick={handleRefreshGh}>
{translate('auto.components.settings.IntegrationsPane.4831ba1083', 'Re-check')}
</Button>
</div>
</>
) : (
<>
<p className="text-xs text-muted-foreground">
{translate(
'auto.components.settings.IntegrationsPane.09285e9fe6',
'The GitHub CLI is installed but not authenticated. Run this command in a terminal:'
)}
</p>
<div className="flex items-center gap-2 rounded-md bg-muted/50 px-2.5 py-1.5 font-mono text-xs">
<Terminal className="size-3.5 shrink-0 text-muted-foreground" />
{translate(
'auto.components.settings.IntegrationsPane.51000487c4',
'gh auth login'
)}
</div>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={() =>
window.api.shell.openUrl('https://cli.github.com/manual/gh_auth_login')
}
>
<ExternalLink className="size-3.5 mr-1.5" />
{translate(
'auto.components.settings.IntegrationsPane.01f6c7582e',
'Learn more'
)}
</Button>
<Button variant="ghost" size="sm" onClick={handleRefreshGh}>
{translate('auto.components.settings.IntegrationsPane.4831ba1083', 'Re-check')}
</Button>
</div>
</>
<div className="space-y-5">
<section className="space-y-3">
<div className="space-y-1">
<h3 className="text-sm font-semibold text-foreground">
{translate('auto.components.settings.IntegrationsPane.298c65ecac', 'Review providers')}
</h3>
<p className="text-xs text-muted-foreground">
{translate(
'auto.components.settings.IntegrationsPane.1683acbac4',
'Connect the source hosts Orca can use for pull requests, merge requests, checks, and review status.'
)}
</div>
)}
</div>
{/* GitLab */}
<div className="rounded-md border border-border/50 bg-muted/30 px-4 py-3">
<div className="flex items-center gap-3">
<Gitlab className="size-5 shrink-0 text-muted-foreground" />
<div className="min-w-0 flex-1 space-y-0.5">
<p className="text-sm font-medium">
{translate('auto.components.settings.IntegrationsPane.513abfe47d', 'GitLab')}
</p>
<p className="text-xs text-muted-foreground">
{translate(
'auto.components.settings.IntegrationsPane.027440e1cb',
'Merge requests, issues, todos, and pipelines via the'
)}{' '}
<span className="font-mono text-[11px]">
{translate('auto.components.settings.IntegrationsPane.a3326f6f1b', 'glab')}
</span>{' '}
{translate('auto.components.settings.IntegrationsPane.ea160a9978', 'CLI.')}
</p>
</div>
{glabStatus === 'checking' ? (
<LoaderCircle className="size-4 shrink-0 animate-spin text-muted-foreground" />
) : glabStatus === 'connected' ? (
<span className="shrink-0 rounded-full border border-emerald-500/30 bg-emerald-500/10 px-2.5 py-1 text-[11px] font-medium text-emerald-700 dark:text-emerald-300">
{translate('auto.components.settings.IntegrationsPane.6432f6522e', 'Connected')}
</span>
) : (
<span className="shrink-0 rounded-full border border-amber-500/30 bg-amber-500/10 px-2.5 py-1 text-[11px] font-medium text-amber-700 dark:text-amber-300">
{glabStatus === 'not-installed'
? translate('auto.components.settings.IntegrationsPane.f7eb5f0b24', 'Not installed')
: translate(
'auto.components.settings.IntegrationsPane.15cf990798',
'Not authenticated'
)}
</span>
)}
</p>
</div>
<div className="space-y-3">
<GitHubIntegrationCard />
<GitLabIntegrationCard />
<BitbucketIntegrationCard />
<AzureDevOpsIntegrationCard />
<GiteaIntegrationCard />
</div>
</section>
{glabStatus !== 'checking' && glabStatus !== 'connected' && (
<div className="mt-3 rounded-md border border-border/30 bg-background/50 px-3 py-2.5 space-y-2">
{glabStatus === 'not-installed' ? (
<>
<p className="text-xs text-muted-foreground">
{translate(
'auto.components.settings.IntegrationsPane.35a3379372',
'Install the GitLab CLI to enable merge requests, issues, and pipelines.'
)}
</p>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={() =>
window.api.shell.openUrl('https://gitlab.com/gitlab-org/cli#installation')
}
>
<ExternalLink className="size-3.5 mr-1.5" />
{translate(
'auto.components.settings.IntegrationsPane.a83cac5726',
'Install GitLab CLI'
)}
</Button>
<Button variant="ghost" size="sm" onClick={handleRefreshGlab}>
{translate('auto.components.settings.IntegrationsPane.4831ba1083', 'Re-check')}
</Button>
</div>
</>
) : (
<>
<p className="text-xs text-muted-foreground">
{translate(
'auto.components.settings.IntegrationsPane.05e5245af7',
'The GitLab CLI is installed but not authenticated. Run this command in a terminal:'
)}
</p>
<div className="flex items-center gap-2 rounded-md bg-muted/50 px-2.5 py-1.5 font-mono text-xs">
<Terminal className="size-3.5 shrink-0 text-muted-foreground" />
{translate(
'auto.components.settings.IntegrationsPane.e74de656ce',
'glab auth login'
)}
</div>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={() =>
window.api.shell.openUrl(
'https://gitlab.com/gitlab-org/cli/-/blob/main/docs/source/auth/login.md'
)
}
>
<ExternalLink className="size-3.5 mr-1.5" />
{translate(
'auto.components.settings.IntegrationsPane.01f6c7582e',
'Learn more'
)}
</Button>
<Button variant="ghost" size="sm" onClick={handleRefreshGlab}>
{translate('auto.components.settings.IntegrationsPane.4831ba1083', 'Re-check')}
</Button>
</div>
</>
<section className="space-y-3">
<div className="space-y-1">
<h3 className="text-sm font-semibold text-foreground">
{translate('auto.components.settings.IntegrationsPane.70e885705b', 'Task providers')}
</h3>
<p className="text-xs text-muted-foreground">
{translate(
'auto.components.settings.IntegrationsPane.3ba07f933b',
'Connect issue trackers Orca can use to browse tasks and start workspaces with linked context.'
)}
</div>
)}
</div>
{/* Bitbucket */}
<div className="rounded-md border border-border/50 bg-muted/30 px-4 py-3">
<div className="flex items-center gap-3">
<GitPullRequestArrow className="size-5 shrink-0 text-muted-foreground" />
<div className="min-w-0 flex-1 space-y-0.5">
<p className="text-sm font-medium">
{translate('auto.components.settings.IntegrationsPane.8489c0aa49', 'Bitbucket')}
</p>
<p className="text-xs text-muted-foreground">
{bitbucketStatus === 'connected'
? bitbucketAccount
? translate(
'auto.components.settings.IntegrationsPane.277fc23929',
'{{value0}} · Pull requests and build statuses',
{ value0: bitbucketAccount }
)
: translate(
'auto.components.settings.IntegrationsPane.9707523939',
'Pull requests and build statuses'
)
: translate(
'auto.components.settings.IntegrationsPane.0879860c58',
'Pull requests and build statuses via Bitbucket Cloud API tokens.'
)}
</p>
</div>
{bitbucketStatus === 'checking' ? (
<LoaderCircle className="size-4 shrink-0 animate-spin text-muted-foreground" />
) : bitbucketStatus === 'connected' ? (
<span className="shrink-0 rounded-full border border-emerald-500/30 bg-emerald-500/10 px-2.5 py-1 text-[11px] font-medium text-emerald-700 dark:text-emerald-300">
{translate('auto.components.settings.IntegrationsPane.6432f6522e', 'Connected')}
</span>
) : (
<span className="shrink-0 rounded-full border border-amber-500/30 bg-amber-500/10 px-2.5 py-1 text-[11px] font-medium text-amber-700 dark:text-amber-300">
{bitbucketStatus === 'not-configured'
? translate(
'auto.components.settings.IntegrationsPane.f92fbf11aa',
'Not configured'
)
: translate('auto.components.settings.IntegrationsPane.45bf5e6e4b', 'Auth failed')}
</span>
)}
</p>
</div>
{bitbucketStatus !== 'checking' && bitbucketStatus !== 'connected' && (
<div className="mt-3 rounded-md border border-border/30 bg-background/50 px-3 py-2.5 space-y-2">
{bitbucketStatus === 'not-configured' ? (
<>
<p className="text-xs text-muted-foreground">
{translate('auto.components.settings.IntegrationsPane.4ee74d1470', 'Set')}
<span className="font-mono text-[11px]">
{translate(
'auto.components.settings.IntegrationsPane.b8a7efb3f6',
'ORCA_BITBUCKET_EMAIL'
)}
</span>{' '}
{translate('auto.components.settings.IntegrationsPane.a6c2816115', 'and')}{' '}
<span className="font-mono text-[11px]">
{translate(
'auto.components.settings.IntegrationsPane.44cde4aa01',
'ORCA_BITBUCKET_API_TOKEN'
)}
</span>
{translate('auto.components.settings.IntegrationsPane.ce3c58cd63', ', or set')}{' '}
<span className="font-mono text-[11px]">
{translate(
'auto.components.settings.IntegrationsPane.6e0ff3403e',
'ORCA_BITBUCKET_ACCESS_TOKEN'
)}
</span>
.
</p>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={() =>
window.api.shell.openUrl(
'https://support.atlassian.com/bitbucket-cloud/docs/using-api-tokens/'
)
}
>
<ExternalLink className="size-3.5 mr-1.5" />
{translate(
'auto.components.settings.IntegrationsPane.01f6c7582e',
'Learn more'
)}
</Button>
<Button variant="ghost" size="sm" onClick={handleRefreshBitbucket}>
{translate('auto.components.settings.IntegrationsPane.4831ba1083', 'Re-check')}
</Button>
</div>
</>
) : (
<>
<p className="text-xs text-muted-foreground">
{translate(
'auto.components.settings.IntegrationsPane.3c3cf05c63',
'Bitbucket credentials are configured but could not authenticate. Check the token and repository permissions, then restart Orca if environment variables changed.'
)}
</p>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={() =>
window.api.shell.openUrl(
'https://support.atlassian.com/bitbucket-cloud/docs/using-api-tokens/'
)
}
>
<ExternalLink className="size-3.5 mr-1.5" />
{translate(
'auto.components.settings.IntegrationsPane.01f6c7582e',
'Learn more'
)}
</Button>
<Button variant="ghost" size="sm" onClick={handleRefreshBitbucket}>
{translate('auto.components.settings.IntegrationsPane.4831ba1083', 'Re-check')}
</Button>
</div>
</>
)}
</div>
)}
</div>
{/* Azure DevOps */}
<div className="rounded-md border border-border/50 bg-muted/30 px-4 py-3">
<div className="flex items-center gap-3">
<GitPullRequestArrow className="size-5 shrink-0 text-muted-foreground" />
<div className="min-w-0 flex-1 space-y-0.5">
<p className="text-sm font-medium">
{translate('auto.components.settings.IntegrationsPane.5efce6953d', 'Azure DevOps')}
</p>
<p className="text-xs text-muted-foreground">
{azureDevOpsStatus === 'configured'
? azureDevOpsAccount
? translate(
'auto.components.settings.IntegrationsPane.277fc23929',
'{{value0}} · Pull requests and build statuses',
{ value0: azureDevOpsAccount }
)
: azureDevOpsBaseUrl
? translate(
'auto.components.settings.IntegrationsPane.277fc23929',
'{{value0}} · Pull requests and build statuses',
{ value0: azureDevOpsBaseUrl }
)
: translate(
'auto.components.settings.IntegrationsPane.e3d5a24979',
'Pull requests and build statuses for detected Azure Repos'
)
: translate(
'auto.components.settings.IntegrationsPane.6791d7af95',
'Pull requests and build statuses via Azure DevOps REST API tokens.'
)}
</p>
</div>
{azureDevOpsStatus === 'checking' ? (
<LoaderCircle className="size-4 shrink-0 animate-spin text-muted-foreground" />
) : azureDevOpsStatus === 'configured' ? (
<span className="shrink-0 rounded-full border border-emerald-500/30 bg-emerald-500/10 px-2.5 py-1 text-[11px] font-medium text-emerald-700 dark:text-emerald-300">
{azureDevOpsAccount
? translate('auto.components.settings.IntegrationsPane.6432f6522e', 'Connected')
: translate('auto.components.settings.IntegrationsPane.e7a961e1c5', 'Configured')}
</span>
) : (
<span className="shrink-0 rounded-full border border-amber-500/30 bg-amber-500/10 px-2.5 py-1 text-[11px] font-medium text-amber-700 dark:text-amber-300">
{azureDevOpsStatus === 'not-configured'
? translate(
'auto.components.settings.IntegrationsPane.f92fbf11aa',
'Not configured'
)
: translate('auto.components.settings.IntegrationsPane.45bf5e6e4b', 'Auth failed')}
</span>
)}
<div className="space-y-3">
<LinearIntegrationCard />
<JiraIntegrationCard />
</div>
{azureDevOpsStatus !== 'checking' && azureDevOpsStatus !== 'configured' && (
<div className="mt-3 rounded-md border border-border/30 bg-background/50 px-3 py-2.5 space-y-2">
{azureDevOpsStatus === 'not-configured' ? (
<>
<p className="text-xs text-muted-foreground">
{translate('auto.components.settings.IntegrationsPane.4ee74d1470', 'Set')}
<span className="font-mono text-[11px]">
{translate(
'auto.components.settings.IntegrationsPane.5ee6ef6405',
'ORCA_AZURE_DEVOPS_TOKEN'
)}
</span>
{translate('auto.components.settings.IntegrationsPane.ce3c58cd63', ', or set')}{' '}
<span className="font-mono text-[11px]">
{translate(
'auto.components.settings.IntegrationsPane.8f960935c1',
'ORCA_AZURE_DEVOPS_ACCESS_TOKEN'
)}
</span>
{translate('auto.components.settings.IntegrationsPane.67a9f26a80', '. Set')}{' '}
<span className="font-mono text-[11px]">
{translate(
'auto.components.settings.IntegrationsPane.ae6b7f5f40',
'ORCA_AZURE_DEVOPS_API_BASE_URL'
)}
</span>{' '}
{translate(
'auto.components.settings.IntegrationsPane.6f317f5132',
'only when Orca cannot derive the API base URL from the git remote.'
)}
</p>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={() =>
window.api.shell.openUrl(
'https://learn.microsoft.com/en-us/azure/devops/organizations/accounts/use-personal-access-tokens-to-authenticate'
)
}
>
<ExternalLink className="size-3.5 mr-1.5" />
{translate(
'auto.components.settings.IntegrationsPane.01f6c7582e',
'Learn more'
)}
</Button>
<Button variant="ghost" size="sm" onClick={handleRefreshAzureDevOps}>
{translate('auto.components.settings.IntegrationsPane.4831ba1083', 'Re-check')}
</Button>
</div>
</>
) : (
<>
<p className="text-xs text-muted-foreground">
{translate(
'auto.components.settings.IntegrationsPane.953b7bf6f7',
'Azure DevOps credentials are configured but could not authenticate. Check the token, API base URL, and repository permissions, then restart Orca if environment variables changed.'
)}
</p>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={() =>
window.api.shell.openUrl(
'https://learn.microsoft.com/en-us/rest/api/azure/devops/git/pull-requests/get-pull-requests'
)
}
>
<ExternalLink className="size-3.5 mr-1.5" />
{translate(
'auto.components.settings.IntegrationsPane.01f6c7582e',
'Learn more'
)}
</Button>
<Button variant="ghost" size="sm" onClick={handleRefreshAzureDevOps}>
{translate('auto.components.settings.IntegrationsPane.4831ba1083', 'Re-check')}
</Button>
</div>
</>
)}
</div>
)}
</div>
{/* Gitea */}
<div className="rounded-md border border-border/50 bg-muted/30 px-4 py-3">
<div className="flex items-center gap-3">
<GitPullRequestArrow className="size-5 shrink-0 text-muted-foreground" />
<div className="min-w-0 flex-1 space-y-0.5">
<p className="text-sm font-medium">
{translate('auto.components.settings.IntegrationsPane.4ab9b96925', 'Gitea')}
</p>
<p className="text-xs text-muted-foreground">
{giteaStatus === 'configured'
? giteaAccount
? translate(
'auto.components.settings.IntegrationsPane.1fac9b4910',
'{{value0}} · Pull requests and commit statuses',
{ value0: giteaAccount }
)
: giteaBaseUrl
? translate(
'auto.components.settings.IntegrationsPane.1fac9b4910',
'{{value0}} · Pull requests and commit statuses',
{ value0: giteaBaseUrl }
)
: translate(
'auto.components.settings.IntegrationsPane.6355fe585e',
'Pull requests and commit statuses for detected repositories'
)
: translate(
'auto.components.settings.IntegrationsPane.6bd148dcb5',
'Pull requests and commit statuses via the Gitea REST API.'
)}
</p>
</div>
{giteaStatus === 'checking' ? (
<LoaderCircle className="size-4 shrink-0 animate-spin text-muted-foreground" />
) : giteaStatus === 'configured' ? (
<span className="shrink-0 rounded-full border border-emerald-500/30 bg-emerald-500/10 px-2.5 py-1 text-[11px] font-medium text-emerald-700 dark:text-emerald-300">
{giteaAccount
? translate('auto.components.settings.IntegrationsPane.6432f6522e', 'Connected')
: translate('auto.components.settings.IntegrationsPane.e7a961e1c5', 'Configured')}
</span>
) : (
<span className="shrink-0 rounded-full border border-amber-500/30 bg-amber-500/10 px-2.5 py-1 text-[11px] font-medium text-amber-700 dark:text-amber-300">
{giteaStatus === 'not-configured'
? translate(
'auto.components.settings.IntegrationsPane.e1bd5364e6',
'Optional setup'
)
: translate('auto.components.settings.IntegrationsPane.45bf5e6e4b', 'Auth failed')}
</span>
)}
</div>
{giteaStatus !== 'checking' && giteaStatus !== 'configured' && (
<div className="mt-3 rounded-md border border-border/30 bg-background/50 px-3 py-2.5 space-y-2">
{giteaStatus === 'not-configured' ? (
<>
<p className="text-xs text-muted-foreground">
{translate(
'auto.components.settings.IntegrationsPane.d9467ab026',
'Public repositories are detected from their git remote. Set'
)}{' '}
<span className="font-mono text-[11px]">
{translate(
'auto.components.settings.IntegrationsPane.e678d89e8c',
'ORCA_GITEA_TOKEN'
)}
</span>{' '}
{translate(
'auto.components.settings.IntegrationsPane.2c0330ec3e',
'for private repositories, and set'
)}{' '}
<span className="font-mono text-[11px]">
{translate(
'auto.components.settings.IntegrationsPane.6193444689',
'ORCA_GITEA_API_BASE_URL'
)}
</span>{' '}
{translate(
'auto.components.settings.IntegrationsPane.5a1f86225a',
'only when Orca cannot derive the API URL from the remote.'
)}
</p>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={() =>
window.api.shell.openUrl('https://docs.gitea.com/next/development/api-usage')
}
>
<ExternalLink className="size-3.5 mr-1.5" />
{translate(
'auto.components.settings.IntegrationsPane.01f6c7582e',
'Learn more'
)}
</Button>
<Button variant="ghost" size="sm" onClick={handleRefreshGitea}>
{translate('auto.components.settings.IntegrationsPane.4831ba1083', 'Re-check')}
</Button>
</div>
</>
) : (
<>
<p className="text-xs text-muted-foreground">
{translate(
'auto.components.settings.IntegrationsPane.1a62c295c6',
'Gitea credentials are configured but could not authenticate. Check the token, API base URL, and repository permissions, then restart Orca if environment variables changed.'
)}
</p>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={() =>
window.api.shell.openUrl('https://docs.gitea.com/next/development/api-usage')
}
>
<ExternalLink className="size-3.5 mr-1.5" />
{translate(
'auto.components.settings.IntegrationsPane.01f6c7582e',
'Learn more'
)}
</Button>
<Button variant="ghost" size="sm" onClick={handleRefreshGitea}>
{translate('auto.components.settings.IntegrationsPane.4831ba1083', 'Re-check')}
</Button>
</div>
</>
)}
</div>
)}
</div>
{/* Linear */}
<div className="rounded-md border border-border/50 bg-muted/30 px-4 py-3">
<div className="flex items-center gap-3">
<LinearIcon className="size-5 shrink-0 text-muted-foreground" />
<div className="min-w-0 flex-1 space-y-0.5">
<p className="text-sm font-medium">
{translate('auto.components.settings.IntegrationsPane.264a9b6128', 'Linear')}
</p>
<p className="text-xs text-muted-foreground">
{linearStatus.connected
? translate(
'auto.components.settings.IntegrationsPane.98ded79cd7',
'{{value0}} workspace{{value1}} connected',
{
value0: linearWorkspaces.length,
value1: linearWorkspaces.length === 1 ? '' : 's'
}
)
: translate(
'auto.components.settings.IntegrationsPane.33ae9730a8',
'Add Linear access to browse and link issues.'
)}
</p>
{linearStatus.credentialError ? (
<p className="flex items-center gap-1 text-xs text-destructive">
<AlertCircle className="size-3.5 shrink-0" />
<span className="min-w-0">{linearStatus.credentialError}</span>
</p>
) : null}
</div>
{linearStatus.connected ? (
<div className="flex shrink-0 items-center gap-1.5">
<Button variant="outline" size="sm" onClick={() => setLinearDialogOpen(true)}>
{translate(
'auto.components.settings.IntegrationsPane.077844591a',
'Add workspace access'
)}
</Button>
<span className="rounded-full border border-emerald-500/30 bg-emerald-500/10 px-2.5 py-1 text-[11px] font-medium text-emerald-700 dark:text-emerald-300">
{translate('auto.components.settings.IntegrationsPane.6432f6522e', 'Connected')}
</span>
</div>
) : (
<button
className="shrink-0 rounded-full border border-border/50 bg-muted/40 px-2.5 py-1 text-[11px] font-medium text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
onClick={() => setLinearDialogOpen(true)}
>
{translate(
'auto.components.settings.IntegrationsPane.f5c5246514',
'Add Linear access'
)}
</button>
)}
</div>
{linearStatus.connected && (
<div className="mt-3 space-y-2">
{linearWorkspaces.map((workspace) => {
const testResult = linearTestResultByWorkspace[workspace.id]
const testing = linearTestingWorkspaceId === workspace.id
return (
<div
key={workspace.id}
className="flex items-center gap-3 rounded-md border border-border/50 bg-background/60 px-3 py-2"
>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium text-foreground">
{workspace.organizationName}
</p>
<p className="truncate text-xs text-muted-foreground">
{workspace.displayName}
{workspace.email ? ` · ${workspace.email}` : ''}
</p>
</div>
{testResult?.state === 'ok' ? (
<span className="flex shrink-0 items-center gap-1 text-xs text-emerald-600 dark:text-emerald-400">
<CheckCircle2 className="size-3.5" />
{translate(
'auto.components.settings.IntegrationsPane.fe4d378dc4',
'Verified'
)}
</span>
) : null}
{testResult?.state === 'error' ? (
<span className="flex min-w-0 max-w-[220px] shrink items-center gap-1 truncate text-xs text-destructive">
<AlertCircle className="size-3.5 shrink-0" />
<span className="truncate">{testResult.error}</span>
</span>
) : null}
<Button
variant="outline"
size="sm"
onClick={() => void handleLinearTest(workspace.id)}
disabled={testing}
>
{testing ? (
<>
<LoaderCircle className="size-3.5 mr-1.5 animate-spin" />
{translate(
'auto.components.settings.IntegrationsPane.e7b2dd46f9',
'Testing…'
)}
</>
) : (
translate('auto.components.settings.IntegrationsPane.95b9a87e7e', 'Test')
)}
</Button>
<button
onClick={() => void handleLinearDisconnect(workspace.id)}
aria-label={translate(
'auto.components.settings.IntegrationsPane.8e078e480c',
'Disconnect {{value0}}',
{ value0: workspace.organizationName }
)}
className="rounded-md p-1 text-muted-foreground/50 transition-colors hover:text-destructive"
>
<Unlink className="size-3.5" />
</button>
</div>
)
})}
<p className="text-[11px] text-muted-foreground/70">
{translate(
'auto.components.settings.IntegrationsPane.2122e15517',
'Each connected Linear workspace has one key stored by the active runtime. Full-access keys can cover all teams the key owner can access; restricted keys can be replaced any time.'
)}
</p>
</div>
)}
</div>
<JiraIntegrationCard />
<LinearApiKeyDialog
open={linearDialogOpen}
onOpenChange={setLinearDialogOpen}
connectLabel="Add Linear access"
onConnected={() => setLinearTestResultByWorkspace({})}
/>
</section>
</div>
)
}
@@ -0,0 +1,265 @@
import { ExternalLink, Github, Gitlab, Terminal } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { IntegrationCardDetails, IntegrationCardShell } from './integration-card-shell'
import { usePreflightCardStatuses } from './source-control-preflight-card-status'
import { translate } from '@/i18n/i18n'
export function GitHubIntegrationCard(): React.JSX.Element {
const { statuses, unavailable, refresh } = usePreflightCardStatuses('gh')
const status = unavailable ? 'unavailable' : statuses.ghStatus
const connected = status === 'connected'
return (
<IntegrationCardShell
icon={<Github className="size-5" />}
name="GitHub"
description={
<>
{translate(
'auto.components.settings.cli.source.control.integration.cards.b4d900e7f1',
'Pull requests, issues, and checks via the'
)}{' '}
<span className="font-mono text-[11px]">
{translate(
'auto.components.settings.cli.source.control.integration.cards.6b2cfb52b4',
'gh'
)}
</span>{' '}
{translate(
'auto.components.settings.cli.source.control.integration.cards.a47f71e357',
'CLI.'
)}
</>
}
checking={status === 'checking'}
statusTone={connected ? 'connected' : 'attention'}
statusLabel={
connected
? 'Connected'
: status === 'unavailable'
? 'Unavailable'
: status === 'not-installed'
? 'Not installed'
: 'Not authenticated'
}
>
{status !== 'checking' && !connected ? (
<IntegrationCardDetails>
{status === 'unavailable' ? (
<>
<p className="text-xs text-muted-foreground">
{translate(
'auto.components.settings.cli.source.control.integration.cards.6f30fc4216',
'GitHub CLI status is not available in this runtime yet.'
)}
</p>
<Button variant="ghost" size="sm" onClick={refresh}>
{translate(
'auto.components.settings.cli.source.control.integration.cards.d5b3be8ecd',
'Re-check'
)}
</Button>
</>
) : status === 'not-installed' ? (
<>
<p className="text-xs text-muted-foreground">
{translate(
'auto.components.settings.cli.source.control.integration.cards.23cb5a0dee',
'Install the GitHub CLI to enable pull requests, issues, and checks.'
)}
</p>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={() => window.api.shell.openUrl('https://cli.github.com')}
>
<ExternalLink className="size-3.5 mr-1.5" />
{translate(
'auto.components.settings.cli.source.control.integration.cards.7755c28af5',
'Install GitHub CLI'
)}
</Button>
<Button variant="ghost" size="sm" onClick={refresh}>
{translate(
'auto.components.settings.cli.source.control.integration.cards.d5b3be8ecd',
'Re-check'
)}
</Button>
</div>
</>
) : (
<>
<p className="text-xs text-muted-foreground">
{translate(
'auto.components.settings.cli.source.control.integration.cards.2e44dda68a',
'The GitHub CLI is installed but not authenticated. Run this command in a terminal:'
)}
</p>
<div className="flex items-center gap-2 rounded-md bg-muted/50 px-2.5 py-1.5 font-mono text-xs">
<Terminal className="size-3.5 shrink-0 text-muted-foreground" />
{translate(
'auto.components.settings.cli.source.control.integration.cards.8d90249d22',
'gh auth login'
)}
</div>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={() =>
window.api.shell.openUrl('https://cli.github.com/manual/gh_auth_login')
}
>
<ExternalLink className="size-3.5 mr-1.5" />
{translate(
'auto.components.settings.cli.source.control.integration.cards.8cbc39f862',
'Learn more'
)}
</Button>
<Button variant="ghost" size="sm" onClick={refresh}>
{translate(
'auto.components.settings.cli.source.control.integration.cards.d5b3be8ecd',
'Re-check'
)}
</Button>
</div>
</>
)}
</IntegrationCardDetails>
) : null}
</IntegrationCardShell>
)
}
export function GitLabIntegrationCard(): React.JSX.Element {
const { statuses, unavailable, refresh } = usePreflightCardStatuses('glab')
const status = unavailable ? 'unavailable' : statuses.glabStatus
const connected = status === 'connected'
return (
<IntegrationCardShell
icon={<Gitlab className="size-5" />}
name="GitLab"
description={
<>
{translate(
'auto.components.settings.cli.source.control.integration.cards.1f2b347bd3',
'Merge requests, issues, todos, and pipelines via the'
)}{' '}
<span className="font-mono text-[11px]">
{translate(
'auto.components.settings.cli.source.control.integration.cards.2a6b359e75',
'glab'
)}
</span>{' '}
{translate(
'auto.components.settings.cli.source.control.integration.cards.a47f71e357',
'CLI.'
)}
</>
}
checking={status === 'checking'}
statusTone={connected ? 'connected' : 'attention'}
statusLabel={
connected
? 'Connected'
: status === 'unavailable'
? 'Unavailable'
: status === 'not-installed'
? 'Not installed'
: 'Not authenticated'
}
>
{status !== 'checking' && !connected ? (
<IntegrationCardDetails>
{status === 'unavailable' ? (
<>
<p className="text-xs text-muted-foreground">
{translate(
'auto.components.settings.cli.source.control.integration.cards.faddeb763d',
'GitLab CLI status is not available in this runtime yet.'
)}
</p>
<Button variant="ghost" size="sm" onClick={refresh}>
{translate(
'auto.components.settings.cli.source.control.integration.cards.d5b3be8ecd',
'Re-check'
)}
</Button>
</>
) : status === 'not-installed' ? (
<>
<p className="text-xs text-muted-foreground">
{translate(
'auto.components.settings.cli.source.control.integration.cards.b56fd5676a',
'Install the GitLab CLI to enable merge requests, issues, and pipelines.'
)}
</p>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={() =>
window.api.shell.openUrl('https://gitlab.com/gitlab-org/cli#installation')
}
>
<ExternalLink className="size-3.5 mr-1.5" />
{translate(
'auto.components.settings.cli.source.control.integration.cards.54a640af7a',
'Install GitLab CLI'
)}
</Button>
<Button variant="ghost" size="sm" onClick={refresh}>
{translate(
'auto.components.settings.cli.source.control.integration.cards.d5b3be8ecd',
'Re-check'
)}
</Button>
</div>
</>
) : (
<>
<p className="text-xs text-muted-foreground">
{translate(
'auto.components.settings.cli.source.control.integration.cards.4be0616873',
'The GitLab CLI is installed but not authenticated. Run this command in a terminal:'
)}
</p>
<div className="flex items-center gap-2 rounded-md bg-muted/50 px-2.5 py-1.5 font-mono text-xs">
<Terminal className="size-3.5 shrink-0 text-muted-foreground" />
{translate(
'auto.components.settings.cli.source.control.integration.cards.707180d09c',
'glab auth login'
)}
</div>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={() =>
window.api.shell.openUrl(
'https://gitlab.com/gitlab-org/cli/-/blob/main/docs/source/auth/login.md'
)
}
>
<ExternalLink className="size-3.5 mr-1.5" />
{translate(
'auto.components.settings.cli.source.control.integration.cards.8cbc39f862',
'Learn more'
)}
</Button>
<Button variant="ghost" size="sm" onClick={refresh}>
{translate(
'auto.components.settings.cli.source.control.integration.cards.d5b3be8ecd',
'Re-check'
)}
</Button>
</div>
</>
)}
</IntegrationCardDetails>
) : null}
</IntegrationCardShell>
)
}
@@ -0,0 +1,68 @@
import { LoaderCircle } from 'lucide-react'
import { cn } from '@/lib/utils'
export type IntegrationCardStatusTone = 'connected' | 'attention' | 'neutral'
const STATUS_TONE_CLASSES: Record<IntegrationCardStatusTone, string> = {
connected: 'border-status-success-border bg-status-success-background text-status-success',
attention: 'border-amber-500/30 bg-amber-500/10 text-amber-700 dark:text-amber-300',
neutral: 'border-border bg-background text-muted-foreground'
}
export function IntegrationCardShell(props: {
icon: React.ReactNode
name: string
description: React.ReactNode
statusLabel: string
statusTone: IntegrationCardStatusTone
checking?: boolean
className?: string
actions?: React.ReactNode
children?: React.ReactNode
}): React.JSX.Element {
return (
<div
className={cn('rounded-md border border-border/50 bg-muted/30 px-4 py-3', props.className)}
>
<div className="flex items-center gap-3">
<span className="shrink-0 text-muted-foreground">{props.icon}</span>
<div className="min-w-0 flex-1 space-y-0.5">
<p className="text-sm font-medium">{props.name}</p>
<p className="text-xs text-muted-foreground">{props.description}</p>
</div>
{props.actions ? (
<div className="flex shrink-0 items-center gap-1.5">{props.actions}</div>
) : null}
{props.checking ? (
<LoaderCircle className="size-4 shrink-0 animate-spin text-muted-foreground" />
) : (
<span
className={cn(
'shrink-0 rounded-full border px-2.5 py-1 text-[11px] font-medium',
STATUS_TONE_CLASSES[props.statusTone]
)}
>
{props.statusLabel}
</span>
)}
</div>
{props.children}
</div>
)
}
export function IntegrationCardDetails(props: {
className?: string
children: React.ReactNode
}): React.JSX.Element {
return (
<div
className={cn(
'mt-3 space-y-2 rounded-md border border-border/30 bg-background/50 px-3 py-2.5',
props.className
)}
>
{props.children}
</div>
)
}
@@ -1,100 +1,47 @@
import { useEffect, useState } from 'react'
import { AlertCircle, CheckCircle2, ExternalLink, LoaderCircle, Unlink } from 'lucide-react'
import { useState } from 'react'
import { AlertCircle, CheckCircle2, LoaderCircle, Unlink } from 'lucide-react'
import { JiraConnectDialog } from '@/components/jira-connect-dialog'
import { JiraIcon } from '@/components/icons/JiraIcon'
import { Button } from '@/components/ui/button'
import { useMountedRef } from '@/hooks/useMountedRef'
import {
getProviderRuntimeContextKey,
hasRemoteProviderRuntime
} from '@/lib/provider-runtime-context'
import { useAppStore } from '@/store'
import type { JiraSite } from '../../../../shared/types'
import { Button } from '../ui/button'
import { Input } from '../ui/input'
import { IntegrationCardDetails, IntegrationCardShell } from './integration-card-shell'
import { translate } from '@/i18n/i18n'
type JiraFormMode =
| { kind: 'add' }
| {
kind: 'update'
site: JiraSite
}
type JiraTestResult = { state: 'ok' | 'error'; error?: string }
function getJiraSiteLabel(site: JiraSite): string {
return `${site.displayName} · ${site.email}`
}
type VerificationResult = { state: 'ok' | 'error'; error?: string }
export function JiraIntegrationCard(): React.JSX.Element {
const jiraStatus = useAppStore((s) => s.jiraStatus)
const jiraStatusChecked = useAppStore((s) => s.jiraStatusChecked)
const jiraStatusContextKey = useAppStore((s) => s.jiraStatusContextKey)
const checkJiraConnection = useAppStore((s) => s.checkJiraConnection)
const connectJira = useAppStore((s) => s.connectJira)
const disconnectJira = useAppStore((s) => s.disconnectJira)
const testJiraConnection = useAppStore((s) => s.testJiraConnection)
const settings = useAppStore((s) => s.settings)
const mountedRef = useMountedRef()
const [formMode, setFormMode] = useState<JiraFormMode | null>(null)
const [siteUrlDraft, setSiteUrlDraft] = useState('')
const [emailDraft, setEmailDraft] = useState('')
const [apiTokenDraft, setApiTokenDraft] = useState('')
const [connectState, setConnectState] = useState<'idle' | 'connecting' | 'error'>('idle')
const [connectError, setConnectError] = useState<string | null>(null)
const [dialogOpen, setDialogOpen] = useState(false)
const [testingSiteId, setTestingSiteId] = useState<string | null>(null)
const [testResultBySite, setTestResultBySite] = useState<Record<string, JiraTestResult>>({})
const [testResultBySite, setTestResultBySite] = useState<Record<string, VerificationResult>>({})
const jiraSites = jiraStatus.sites ?? []
const contextMatches = jiraStatusContextKey === getProviderRuntimeContextKey(settings)
const checking = !contextMatches || !jiraStatusChecked
const connected = contextMatches && jiraStatus.connected
const sites = jiraStatus.sites ?? []
const siteCount = sites.length || (connected ? 1 : 0)
const credentialCopy = hasRemoteProviderRuntime(settings)
? 'Connect a Jira Cloud site with your Atlassian email and an API token. Credentials are sent to the selected remote runtime and stored there with runtime-supported encryption.'
: 'Connect a Jira Cloud site with your Atlassian email and an API token. Credentials are stored locally and encrypted when local runtime storage supports it.'
useEffect(() => {
void checkJiraConnection()
}, [checkJiraConnection])
const openAddForm = (): void => {
setFormMode({ kind: 'add' })
setSiteUrlDraft('')
setEmailDraft('')
setApiTokenDraft('')
setConnectState('idle')
setConnectError(null)
}
const openUpdateForm = (site: JiraSite): void => {
setFormMode({ kind: 'update', site })
setSiteUrlDraft(site.siteUrl)
setEmailDraft(site.email)
setApiTokenDraft('')
setConnectState('idle')
setConnectError(null)
}
const closeForm = (): void => {
if (connectState === 'connecting') {
return
}
setFormMode(null)
setConnectError(null)
setConnectState('idle')
}
const handleConnect = async (): Promise<void> => {
const siteUrl = siteUrlDraft.trim()
const email = emailDraft.trim()
const apiToken = apiTokenDraft.trim()
if (!siteUrl || !email || !apiToken) {
return
}
setConnectState('connecting')
setConnectError(null)
const result = await connectJira({ siteUrl, email, apiToken })
if (!mountedRef.current) {
return
}
if (result.ok) {
setFormMode(null)
setSiteUrlDraft('')
setEmailDraft('')
setApiTokenDraft('')
setConnectState('idle')
const handleDisconnect = async (siteId?: string): Promise<void> => {
await disconnectJira(siteId)
if (mountedRef.current) {
setTestResultBySite({})
return
}
setConnectState('error')
setConnectError(result.error)
}
const handleTest = async (siteId: string): Promise<void> => {
@@ -115,167 +62,53 @@ export function JiraIntegrationCard(): React.JSX.Element {
setTestingSiteId(null)
}
const handleDisconnect = async (siteId: string): Promise<void> => {
await disconnectJira(siteId)
if (!mountedRef.current) {
return
}
setTestResultBySite((prev) => {
const next = { ...prev }
delete next[siteId]
return next
})
}
return (
<div className="rounded-md border border-border/50 bg-muted/30 px-4 py-3">
<div className="flex items-center gap-3">
<JiraIcon className="size-5 shrink-0 text-muted-foreground" />
<div className="min-w-0 flex-1 space-y-0.5">
<p className="text-sm font-medium">
{translate('auto.components.settings.jira.integration.card.09742875cd', 'Jira')}
</p>
<p className="text-xs text-muted-foreground">
{jiraStatus.connected
<IntegrationCardShell
icon={<JiraIcon className="size-5" />}
name="Jira"
description={
connected
? translate(
'auto.components.settings.task.tracker.integration.cards.9fa04a032e',
'{{value0}} site{{value1}} connected',
{ value0: siteCount, value1: siteCount === 1 ? '' : 's' }
)
: checking
? translate(
'auto.components.settings.task.tracker.integration.cards.a1093a06c7',
'Checking Jira access before showing setup actions.'
)
: translate(
'auto.components.settings.task.tracker.integration.cards.7ca5ffffdb',
'Browse, create, and start work from Jira Cloud issues.'
)
}
checking={checking}
statusTone={connected ? 'connected' : 'attention'}
statusLabel={connected ? 'Connected' : 'Not connected'}
actions={
!checking ? (
<Button
variant={connected ? 'outline' : 'default'}
size="sm"
onClick={() => setDialogOpen(true)}
>
{connected
? translate(
'auto.components.settings.jira.integration.card.74f3063026',
'{{value0}} site{{value1}} connected',
{ value0: jiraSites.length, value1: jiraSites.length === 1 ? '' : 's' }
'auto.components.settings.task.tracker.integration.cards.60996beda6',
'Add Jira site'
)
: translate(
'auto.components.settings.jira.integration.card.9a9f8d4910',
'Connect Jira Cloud to browse, create, and link issues.'
'auto.components.settings.task.tracker.integration.cards.e2ff968276',
'Connect Jira'
)}
</p>
</div>
{jiraStatus.connected ? (
<div className="flex shrink-0 items-center gap-1.5">
<Button variant="outline" size="sm" onClick={openAddForm}>
{translate('auto.components.settings.jira.integration.card.efaab83c5d', 'Add site')}
</Button>
<span className="rounded-full border border-emerald-500/30 bg-emerald-500/10 px-2.5 py-1 text-[11px] font-medium text-emerald-700 dark:text-emerald-300">
{translate('auto.components.settings.jira.integration.card.9bb34706ca', 'Connected')}
</span>
</div>
) : (
<button
className="shrink-0 rounded-full border border-border/50 bg-muted/40 px-2.5 py-1 text-[11px] font-medium text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
onClick={openAddForm}
>
{translate('auto.components.settings.jira.integration.card.a28f417220', 'Connect Jira')}
</button>
)}
</div>
{formMode ? (
<div className="mt-3 rounded-md border border-border/30 bg-background/50 px-3 py-2.5">
<div className="grid gap-2 md:grid-cols-[minmax(0,1.2fr)_minmax(0,1fr)]">
<Input
placeholder={translate(
'auto.components.settings.jira.integration.card.27dae4ab60',
'https://example.atlassian.net'
)}
value={siteUrlDraft}
onChange={(e) => {
setSiteUrlDraft(e.target.value)
setConnectError(null)
setConnectState('idle')
}}
disabled={connectState === 'connecting'}
/>
<Input
type="email"
placeholder={translate(
'auto.components.settings.jira.integration.card.09d310e42d',
'you@example.com'
)}
value={emailDraft}
onChange={(e) => {
setEmailDraft(e.target.value)
setConnectError(null)
setConnectState('idle')
}}
disabled={connectState === 'connecting'}
/>
<Input
className="md:col-span-2"
type="password"
placeholder={translate(
'auto.components.settings.jira.integration.card.1ab7f551f3',
'Atlassian API token'
)}
value={apiTokenDraft}
onChange={(e) => {
setApiTokenDraft(e.target.value)
setConnectError(null)
setConnectState('idle')
}}
disabled={connectState === 'connecting'}
/>
</div>
{connectState === 'error' && connectError ? (
<p className="mt-2 text-xs text-destructive">{connectError}</p>
) : null}
<div className="mt-2 flex flex-wrap items-center justify-between gap-2">
<button
className="inline-flex items-center gap-1.5 text-xs text-muted-foreground underline-offset-2 hover:text-foreground hover:underline"
onClick={() =>
window.api.shell.openUrl(
'https://id.atlassian.com/manage-profile/security/api-tokens'
)
}
>
<ExternalLink className="size-3.5" />
{translate(
'auto.components.settings.jira.integration.card.1666f8d562',
'Create an Atlassian API token'
)}
</button>
<div className="flex items-center gap-2">
<Button
variant="ghost"
size="sm"
onClick={closeForm}
disabled={connectState === 'connecting'}
>
{translate('auto.components.settings.jira.integration.card.5936977fcd', 'Cancel')}
</Button>
<Button
variant="outline"
size="sm"
onClick={() => void handleConnect()}
disabled={
!siteUrlDraft.trim() ||
!emailDraft.trim() ||
!apiTokenDraft.trim() ||
connectState === 'connecting'
}
>
{connectState === 'connecting' ? (
<>
<LoaderCircle className="size-3.5 mr-1.5 animate-spin" />
{translate(
'auto.components.settings.jira.integration.card.d914d7ab70',
'Verifying…'
)}
</>
) : formMode.kind === 'update' ? (
translate(
'auto.components.settings.jira.integration.card.33a8b261ee',
'Update credentials'
)
) : (
translate('auto.components.settings.jira.integration.card.2e8bb790fd', 'Connect')
)}
</Button>
</div>
</div>
</div>
) : null}
{jiraStatus.connected ? (
</Button>
) : null
}
>
{connected && sites.length > 0 ? (
<div className="mt-3 space-y-2">
{jiraSites.map((site) => {
{sites.map((site) => {
const testResult = testResultBySite[site.id]
const testing = testingSiteId === site.id
return (
@@ -284,16 +117,17 @@ export function JiraIntegrationCard(): React.JSX.Element {
className="flex items-center gap-3 rounded-md border border-border/50 bg-background/60 px-3 py-2"
>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium text-foreground">
{getJiraSiteLabel(site)}
<p className="truncate text-sm font-medium text-foreground">{site.displayName}</p>
<p className="truncate text-xs text-muted-foreground">
{site.siteUrl}
{site.email ? ` · ${site.email}` : ''}
</p>
<p className="truncate text-xs text-muted-foreground">{site.siteUrl}</p>
</div>
{testResult?.state === 'ok' ? (
<span className="flex shrink-0 items-center gap-1 text-xs text-emerald-600 dark:text-emerald-400">
<span className="flex shrink-0 items-center gap-1 text-xs text-status-success">
<CheckCircle2 className="size-3.5" />
{translate(
'auto.components.settings.jira.integration.card.ab350991b8',
'auto.components.settings.task.tracker.integration.cards.a2c0015fb8',
'Verified'
)}
</span>
@@ -314,23 +148,23 @@ export function JiraIntegrationCard(): React.JSX.Element {
<>
<LoaderCircle className="size-3.5 mr-1.5 animate-spin" />
{translate(
'auto.components.settings.jira.integration.card.cec06a0f79',
'Testing…'
'auto.components.settings.task.tracker.integration.cards.3e7c10d286',
'Testing...'
)}
</>
) : (
translate('auto.components.settings.jira.integration.card.255bfe98ec', 'Test')
translate(
'auto.components.settings.task.tracker.integration.cards.c24e56c532',
'Test'
)
)}
</Button>
<Button variant="outline" size="sm" onClick={() => openUpdateForm(site)}>
{translate('auto.components.settings.jira.integration.card.eaffa454e9', 'Update')}
</Button>
<button
onClick={() => void handleDisconnect(site.id)}
aria-label={translate(
'auto.components.settings.jira.integration.card.9046a20d4c',
'auto.components.settings.task.tracker.integration.cards.dd3529015d',
'Disconnect {{value0}}',
{ value0: getJiraSiteLabel(site) }
{ value0: site.displayName }
)}
className="rounded-md p-1 text-muted-foreground/50 transition-colors hover:text-destructive"
>
@@ -341,12 +175,53 @@ export function JiraIntegrationCard(): React.JSX.Element {
})}
<p className="text-[11px] text-muted-foreground/70">
{translate(
'auto.components.settings.jira.integration.card.8ff73fef62',
"Jira tokens are encrypted by the active runtime and stored locally. Re-entering the same site URL and email replaces that site's API token."
'auto.components.settings.task.tracker.integration.cards.8c20e76308',
'Each connected Jira site has one token stored by the active runtime.'
)}
</p>
</div>
) : connected ? (
<IntegrationCardDetails>
<p className="text-xs text-muted-foreground">
{translate(
'auto.components.settings.task.tracker.integration.cards.8b2408a8e5',
'Jira is connected for this runtime. Re-check if the connected site list looks stale.'
)}
</p>
<div className="flex items-center gap-2">
<Button variant="ghost" size="sm" onClick={() => void checkJiraConnection()}>
{translate(
'auto.components.settings.task.tracker.integration.cards.c90f2ef419',
'Re-check'
)}
</Button>
<Button variant="ghost" size="sm" onClick={() => void handleDisconnect()}>
{translate(
'auto.components.settings.task.tracker.integration.cards.disconnect_all',
'Disconnect'
)}
</Button>
</div>
</IntegrationCardDetails>
) : !checking ? (
<IntegrationCardDetails>
<p className="text-xs text-muted-foreground">{credentialCopy}</p>
<Button variant="ghost" size="sm" onClick={() => void checkJiraConnection()}>
{translate(
'auto.components.settings.task.tracker.integration.cards.c90f2ef419',
'Re-check'
)}
</Button>
</IntegrationCardDetails>
) : null}
</div>
<JiraConnectDialog
open={dialogOpen}
onOpenChange={setDialogOpen}
onConnected={() => setTestResultBySite({})}
overlayClassName="z-[110]"
contentClassName="z-[120]"
/>
</IntegrationCardShell>
)
}
@@ -0,0 +1,11 @@
export {
GitHubIntegrationCard,
GitLabIntegrationCard
} from './cli-source-control-integration-cards'
export { deriveCliProviderCardState } from './source-control-preflight-card-status'
export type { CliProviderCardState } from './source-control-preflight-card-status'
export {
AzureDevOpsIntegrationCard,
BitbucketIntegrationCard,
GiteaIntegrationCard
} from './token-source-control-integration-cards'
@@ -0,0 +1,95 @@
import { useState } from 'react'
import { useMountedRef } from '@/hooks/useMountedRef'
import { getLocalPreflightContext, localPreflightContextKey } from '@/lib/local-preflight-context'
import { useAppStore } from '@/store'
import {
getPreflightIntegrationStatuses,
type PreflightIntegrationStatuses,
type PreflightRefreshProvider
} from './integrations-pane-status'
type CliStatus = {
installed?: boolean
authenticated?: boolean
}
export type CliProviderCardState =
| 'checking'
| 'connected'
| 'not-installed'
| 'not-authenticated'
| 'unavailable'
export function deriveCliProviderCardState(input: {
cliStatus?: CliStatus
preflightStatusAvailable: boolean
preflightStatusChecked: boolean
preflightStatusCurrent: boolean
preflightStatusError: string | null
preflightStatusLoading: boolean
}): CliProviderCardState {
if (
input.preflightStatusLoading ||
!input.preflightStatusChecked ||
!input.preflightStatusCurrent
) {
return 'checking'
}
if (input.preflightStatusError !== null || !input.preflightStatusAvailable || !input.cliStatus) {
return 'unavailable'
}
if (!input.cliStatus.installed) {
return 'not-installed'
}
return input.cliStatus.authenticated ? 'connected' : 'not-authenticated'
}
export type PreflightCardStatuses = {
statuses: PreflightIntegrationStatuses
unavailable: boolean
refresh: () => void
}
export function usePreflightCardStatuses(
provider: PreflightRefreshProvider
): PreflightCardStatuses {
const preflightStatus = useAppStore((s) => s.preflightStatus)
const preflightStatusChecked = useAppStore((s) => s.preflightStatusChecked)
const preflightStatusContextKey = useAppStore((s) => s.preflightStatusContextKey)
const preflightStatusError = useAppStore((s) => s.preflightStatusError)
const preflightStatusLoading = useAppStore((s) => s.preflightStatusLoading)
const refreshPreflightStatus = useAppStore((s) => s.refreshPreflightStatus)
const expectedPreflightContextKey = useAppStore((s) =>
localPreflightContextKey(getLocalPreflightContext(s))
)
const mountedRef = useMountedRef()
const [refreshing, setRefreshing] = useState(false)
const refreshingProviders: ReadonlySet<PreflightRefreshProvider> = refreshing
? new Set<PreflightRefreshProvider>([provider])
: new Set<PreflightRefreshProvider>()
const preflightCurrent = preflightStatusContextKey === expectedPreflightContextKey
const unavailable =
!preflightStatusLoading &&
preflightStatusChecked &&
preflightCurrent &&
preflightStatusError !== null
const statusInput =
!preflightStatusLoading && preflightStatusChecked && preflightCurrent && !unavailable
? preflightStatus
: null
const refresh = (): void => {
setRefreshing(true)
void refreshPreflightStatus({ force: true }).finally(() => {
if (mountedRef.current) {
setRefreshing(false)
}
})
}
return {
statuses: getPreflightIntegrationStatuses(statusInput, refreshingProviders),
unavailable,
refresh
}
}
@@ -0,0 +1,212 @@
import { useState } from 'react'
import { AlertCircle, CheckCircle2, LoaderCircle, Unlink } from 'lucide-react'
import { LinearIcon } from '@/components/icons/LinearIcon'
import { LinearApiKeyDialog } from '@/components/linear-api-key-dialog'
import { Button } from '@/components/ui/button'
import { useMountedRef } from '@/hooks/useMountedRef'
import { getProviderRuntimeContextKey } from '@/lib/provider-runtime-context'
import { useAppStore } from '@/store'
import { IntegrationCardDetails, IntegrationCardShell } from './integration-card-shell'
import { translate } from '@/i18n/i18n'
type VerificationResult = { state: 'ok' | 'error'; error?: string }
export function LinearIntegrationCard(): React.JSX.Element {
const linearStatus = useAppStore((s) => s.linearStatus)
const linearStatusChecked = useAppStore((s) => s.linearStatusChecked)
const linearStatusContextKey = useAppStore((s) => s.linearStatusContextKey)
const disconnectLinear = useAppStore((s) => s.disconnectLinear)
const disconnectLinearWorkspace = useAppStore((s) => s.disconnectLinearWorkspace)
const checkLinearConnection = useAppStore((s) => s.checkLinearConnection)
const testLinearConnection = useAppStore((s) => s.testLinearConnection)
const settings = useAppStore((s) => s.settings)
const mountedRef = useMountedRef()
const [dialogOpen, setDialogOpen] = useState(false)
const [testingWorkspaceId, setTestingWorkspaceId] = useState<string | null>(null)
const [testResultByWorkspace, setTestResultByWorkspace] = useState<
Record<string, VerificationResult>
>({})
const contextMatches = linearStatusContextKey === getProviderRuntimeContextKey(settings)
const checking = !contextMatches || !linearStatusChecked
const connected = contextMatches && linearStatus.connected
const workspaces = linearStatus.workspaces ?? []
const handleDisconnect = async (workspaceId?: string): Promise<void> => {
await (workspaceId ? disconnectLinearWorkspace(workspaceId) : disconnectLinear())
if (mountedRef.current) {
setTestResultByWorkspace({})
}
}
// Why: explicit user-triggered verification. This is the only settings path
// that decrypts a stored Linear key, avoiding surprise keychain prompts.
const handleTest = async (workspaceId: string): Promise<void> => {
setTestingWorkspaceId(workspaceId)
setTestResultByWorkspace((prev) => {
const next = { ...prev }
delete next[workspaceId]
return next
})
const result = await testLinearConnection(workspaceId)
if (!mountedRef.current) {
return
}
setTestResultByWorkspace((prev) => ({
...prev,
[workspaceId]: result.ok ? { state: 'ok' } : { state: 'error', error: result.error }
}))
setTestingWorkspaceId(null)
}
return (
<IntegrationCardShell
icon={<LinearIcon className="size-5" />}
name="Linear"
description={
connected
? translate(
'auto.components.settings.task.tracker.integration.cards.e1f5e6424c',
'{{value0}} workspace{{value1}} connected',
{ value0: workspaces.length, value1: workspaces.length === 1 ? '' : 's' }
)
: checking
? translate(
'auto.components.settings.task.tracker.integration.cards.fe9231215b',
'Checking Linear access before showing setup actions.'
)
: translate(
'auto.components.settings.task.tracker.integration.cards.eae4a9f16b',
'Add Linear access to browse and link issues.'
)
}
checking={checking}
statusTone={connected ? 'connected' : 'attention'}
statusLabel={connected ? 'Connected' : 'Not connected'}
actions={
!checking ? (
<Button
variant={connected ? 'outline' : 'default'}
size="sm"
onClick={() => setDialogOpen(true)}
>
{connected
? translate(
'auto.components.settings.task.tracker.integration.cards.622c224082',
'Add workspace access'
)
: translate(
'auto.components.settings.task.tracker.integration.cards.1a12e33fe5',
'Add Linear access'
)}
</Button>
) : null
}
>
{connected ? (
<div className="mt-3 space-y-2">
{workspaces.map((workspace) => {
const testResult = testResultByWorkspace[workspace.id]
const testing = testingWorkspaceId === workspace.id
return (
<div
key={workspace.id}
className="flex items-center gap-3 rounded-md border border-border/50 bg-background/60 px-3 py-2"
>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium text-foreground">
{workspace.organizationName}
</p>
<p className="truncate text-xs text-muted-foreground">
{workspace.displayName}
{workspace.email ? ` · ${workspace.email}` : ''}
</p>
</div>
{testResult?.state === 'ok' ? (
<span className="flex shrink-0 items-center gap-1 text-xs text-status-success">
<CheckCircle2 className="size-3.5" />
{translate(
'auto.components.settings.task.tracker.integration.cards.a2c0015fb8',
'Verified'
)}
</span>
) : null}
{testResult?.state === 'error' ? (
<span className="flex min-w-0 max-w-[220px] shrink items-center gap-1 truncate text-xs text-destructive">
<AlertCircle className="size-3.5 shrink-0" />
<span className="truncate">{testResult.error}</span>
</span>
) : null}
<Button
variant="outline"
size="sm"
onClick={() => void handleTest(workspace.id)}
disabled={testing}
>
{testing ? (
<>
<LoaderCircle className="size-3.5 mr-1.5 animate-spin" />
{translate(
'auto.components.settings.task.tracker.integration.cards.3e7c10d286',
'Testing...'
)}
</>
) : (
translate(
'auto.components.settings.task.tracker.integration.cards.c24e56c532',
'Test'
)
)}
</Button>
<button
onClick={() => void handleDisconnect(workspace.id)}
aria-label={translate(
'auto.components.settings.task.tracker.integration.cards.dd3529015d',
'Disconnect {{value0}}',
{ value0: workspace.organizationName }
)}
className="rounded-md p-1 text-muted-foreground/50 transition-colors hover:text-destructive"
>
<Unlink className="size-3.5" />
</button>
</div>
)
})}
<p className="text-[11px] text-muted-foreground/70">
{translate(
'auto.components.settings.task.tracker.integration.cards.6224fe9d34',
'Each connected Linear workspace has one key stored by the active runtime. Full-access keys can cover all teams the key owner can access; restricted keys can be replaced any time.'
)}
</p>
</div>
) : !checking ? (
<IntegrationCardDetails>
<p className="text-xs text-muted-foreground">
{translate(
'auto.components.settings.task.tracker.integration.cards.cef18762a2',
'Add access with a Personal API key from your Linear settings. Full-access keys can see every team the key owner can reach.'
)}
</p>
<Button variant="ghost" size="sm" onClick={() => void checkLinearConnection(true)}>
{translate(
'auto.components.settings.task.tracker.integration.cards.c90f2ef419',
'Re-check'
)}
</Button>
</IntegrationCardDetails>
) : null}
<LinearApiKeyDialog
open={dialogOpen}
onOpenChange={setDialogOpen}
connectLabel="Add Linear access"
onConnected={() => setTestResultByWorkspace({})}
overlayClassName="z-[110]"
contentClassName="z-[120]"
/>
</IntegrationCardShell>
)
}
export { JiraIntegrationCard } from './jira-integration-card'
@@ -0,0 +1,365 @@
import { ExternalLink, GitPullRequestArrow } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { IntegrationCardDetails, IntegrationCardShell } from './integration-card-shell'
import { usePreflightCardStatuses } from './source-control-preflight-card-status'
import { translate } from '@/i18n/i18n'
export function BitbucketIntegrationCard(): React.JSX.Element {
const { statuses, unavailable, refresh } = usePreflightCardStatuses('bitbucket')
const status = unavailable ? 'unavailable' : statuses.bitbucketStatus
const connected = status === 'connected'
return (
<IntegrationCardShell
icon={<GitPullRequestArrow className="size-5" />}
name="Bitbucket"
description={
connected
? statuses.bitbucketAccount
? translate(
'auto.components.settings.token.source.control.integration.cards.ea204f5e03',
'{{value0}} · Pull requests and build statuses',
{ value0: statuses.bitbucketAccount }
)
: translate(
'auto.components.settings.token.source.control.integration.cards.0fa5629dad',
'Pull requests and build statuses'
)
: translate(
'auto.components.settings.token.source.control.integration.cards.a924e8dcd1',
'Pull requests and build statuses via Bitbucket Cloud API tokens.'
)
}
checking={status === 'checking'}
statusTone={connected ? 'connected' : 'attention'}
statusLabel={
connected
? 'Connected'
: status === 'unavailable'
? 'Unavailable'
: status === 'not-configured'
? 'Not configured'
: 'Auth failed'
}
>
{status !== 'checking' && !connected ? (
<IntegrationCardDetails>
<p className="text-xs text-muted-foreground">
{status === 'unavailable' ? (
translate(
'auto.components.settings.token.source.control.integration.cards.24ac1c69dc',
'Bitbucket status is not available in this runtime yet.'
)
) : status === 'not-configured' ? (
<>
{translate(
'auto.components.settings.token.source.control.integration.cards.7bbc9c64f0',
'Set'
)}{' '}
<span className="font-mono text-[11px]">
{translate(
'auto.components.settings.token.source.control.integration.cards.63a7f47392',
'ORCA_BITBUCKET_EMAIL'
)}
</span>{' '}
{translate(
'auto.components.settings.token.source.control.integration.cards.fc71a0e7aa',
'and'
)}{' '}
<span className="font-mono text-[11px]">
{translate(
'auto.components.settings.token.source.control.integration.cards.19416c874c',
'ORCA_BITBUCKET_API_TOKEN'
)}
</span>
{translate(
'auto.components.settings.token.source.control.integration.cards.087feb92f1',
', or set'
)}{' '}
<span className="font-mono text-[11px]">
{translate(
'auto.components.settings.token.source.control.integration.cards.e63fe8f627',
'ORCA_BITBUCKET_ACCESS_TOKEN'
)}
</span>
.
</>
) : (
translate(
'auto.components.settings.token.source.control.integration.cards.6154b02093',
'Bitbucket credentials are configured but could not authenticate. Check the token and repository permissions, then restart Orca if environment variables changed.'
)
)}
</p>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={() =>
window.api.shell.openUrl(
'https://support.atlassian.com/bitbucket-cloud/docs/using-api-tokens/'
)
}
>
<ExternalLink className="size-3.5 mr-1.5" />
{translate(
'auto.components.settings.token.source.control.integration.cards.1a9475dace',
'Learn more'
)}
</Button>
<Button variant="ghost" size="sm" onClick={refresh}>
{translate(
'auto.components.settings.token.source.control.integration.cards.793a06e899',
'Re-check'
)}
</Button>
</div>
</IntegrationCardDetails>
) : null}
</IntegrationCardShell>
)
}
export function AzureDevOpsIntegrationCard(): React.JSX.Element {
const { statuses, unavailable, refresh } = usePreflightCardStatuses('azureDevOps')
const status = unavailable ? 'unavailable' : statuses.azureDevOpsStatus
const configured = status === 'configured'
return (
<IntegrationCardShell
icon={<GitPullRequestArrow className="size-5" />}
name="Azure DevOps"
description={
configured
? statuses.azureDevOpsAccount
? translate(
'auto.components.settings.token.source.control.integration.cards.ea204f5e03',
'{{value0}} · Pull requests and build statuses',
{ value0: statuses.azureDevOpsAccount }
)
: statuses.azureDevOpsBaseUrl
? translate(
'auto.components.settings.token.source.control.integration.cards.ea204f5e03',
'{{value0}} · Pull requests and build statuses',
{ value0: statuses.azureDevOpsBaseUrl }
)
: translate(
'auto.components.settings.token.source.control.integration.cards.54636c65d4',
'Pull requests and build statuses for detected Azure Repos'
)
: translate(
'auto.components.settings.token.source.control.integration.cards.0eb50d5593',
'Pull requests and build statuses via Azure DevOps REST API tokens.'
)
}
checking={status === 'checking'}
statusTone={configured ? 'connected' : 'attention'}
statusLabel={
configured
? statuses.azureDevOpsAccount
? 'Connected'
: 'Configured'
: status === 'unavailable'
? 'Unavailable'
: status === 'not-configured'
? 'Not configured'
: 'Auth failed'
}
>
{status !== 'checking' && !configured ? (
<IntegrationCardDetails>
<p className="text-xs text-muted-foreground">
{status === 'unavailable' ? (
translate(
'auto.components.settings.token.source.control.integration.cards.f3f47dc7de',
'Azure DevOps status is not available in this runtime yet.'
)
) : status === 'not-configured' ? (
<>
{translate(
'auto.components.settings.token.source.control.integration.cards.7bbc9c64f0',
'Set'
)}{' '}
<span className="font-mono text-[11px]">
{translate(
'auto.components.settings.token.source.control.integration.cards.48842720d2',
'ORCA_AZURE_DEVOPS_TOKEN'
)}
</span>
{translate(
'auto.components.settings.token.source.control.integration.cards.087feb92f1',
', or set'
)}{' '}
<span className="font-mono text-[11px]">
{translate(
'auto.components.settings.token.source.control.integration.cards.fbfd237f5e',
'ORCA_AZURE_DEVOPS_ACCESS_TOKEN'
)}
</span>
{translate(
'auto.components.settings.token.source.control.integration.cards.b8a10b07c1',
'. Set'
)}{' '}
<span className="font-mono text-[11px]">
{translate(
'auto.components.settings.token.source.control.integration.cards.186a6689df',
'ORCA_AZURE_DEVOPS_API_BASE_URL'
)}
</span>{' '}
{translate(
'auto.components.settings.token.source.control.integration.cards.7bd345e3f6',
'only when Orca cannot derive the API base URL from the git remote.'
)}
</>
) : (
translate(
'auto.components.settings.token.source.control.integration.cards.40f678df73',
'Azure DevOps credentials are configured but could not authenticate. Check the token, API base URL, and repository permissions, then restart Orca if environment variables changed.'
)
)}
</p>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={() =>
window.api.shell.openUrl(
status === 'not-configured'
? 'https://learn.microsoft.com/en-us/azure/devops/organizations/accounts/use-personal-access-tokens-to-authenticate'
: 'https://learn.microsoft.com/en-us/rest/api/azure/devops/git/pull-requests/get-pull-requests'
)
}
>
<ExternalLink className="size-3.5 mr-1.5" />
{translate(
'auto.components.settings.token.source.control.integration.cards.1a9475dace',
'Learn more'
)}
</Button>
<Button variant="ghost" size="sm" onClick={refresh}>
{translate(
'auto.components.settings.token.source.control.integration.cards.793a06e899',
'Re-check'
)}
</Button>
</div>
</IntegrationCardDetails>
) : null}
</IntegrationCardShell>
)
}
export function GiteaIntegrationCard(): React.JSX.Element {
const { statuses, unavailable, refresh } = usePreflightCardStatuses('gitea')
const status = unavailable ? 'unavailable' : statuses.giteaStatus
const configured = status === 'configured'
return (
<IntegrationCardShell
icon={<GitPullRequestArrow className="size-5" />}
name="Gitea"
description={
configured
? statuses.giteaAccount
? translate(
'auto.components.settings.token.source.control.integration.cards.0b5242f8a2',
'{{value0}} · Pull requests and commit statuses',
{ value0: statuses.giteaAccount }
)
: statuses.giteaBaseUrl
? translate(
'auto.components.settings.token.source.control.integration.cards.0b5242f8a2',
'{{value0}} · Pull requests and commit statuses',
{ value0: statuses.giteaBaseUrl }
)
: translate(
'auto.components.settings.token.source.control.integration.cards.52f75876be',
'Pull requests and commit statuses for detected repositories'
)
: translate(
'auto.components.settings.token.source.control.integration.cards.05863d2599',
'Pull requests and commit statuses via the Gitea REST API.'
)
}
checking={status === 'checking'}
statusTone={configured ? 'connected' : 'attention'}
statusLabel={
configured
? statuses.giteaAccount
? 'Connected'
: 'Configured'
: status === 'unavailable'
? 'Unavailable'
: status === 'not-configured'
? 'Optional setup'
: 'Auth failed'
}
>
{status !== 'checking' && !configured ? (
<IntegrationCardDetails>
<p className="text-xs text-muted-foreground">
{status === 'unavailable' ? (
translate(
'auto.components.settings.token.source.control.integration.cards.0613928cb3',
'Gitea status is not available in this runtime yet.'
)
) : status === 'not-configured' ? (
<>
{translate(
'auto.components.settings.token.source.control.integration.cards.fcbe0469fd',
'Public repositories are detected from their git remote. Set'
)}{' '}
<span className="font-mono text-[11px]">
{translate(
'auto.components.settings.token.source.control.integration.cards.6d5c2a3005',
'ORCA_GITEA_TOKEN'
)}
</span>{' '}
{translate(
'auto.components.settings.token.source.control.integration.cards.6da9dfa5de',
'for private repositories, and set'
)}{' '}
<span className="font-mono text-[11px]">
{translate(
'auto.components.settings.token.source.control.integration.cards.709057ad91',
'ORCA_GITEA_API_BASE_URL'
)}
</span>{' '}
{translate(
'auto.components.settings.token.source.control.integration.cards.60708f23da',
'only when Orca cannot derive the API URL from the remote.'
)}
</>
) : (
translate(
'auto.components.settings.token.source.control.integration.cards.19fb419c12',
'Gitea credentials are configured but could not authenticate. Check the token, API base URL, and repository permissions, then restart Orca if environment variables changed.'
)
)}
</p>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={() =>
window.api.shell.openUrl('https://docs.gitea.com/next/development/api-usage')
}
>
<ExternalLink className="size-3.5 mr-1.5" />
{translate(
'auto.components.settings.token.source.control.integration.cards.1a9475dace',
'Learn more'
)}
</Button>
<Button variant="ghost" size="sm" onClick={refresh}>
{translate(
'auto.components.settings.token.source.control.integration.cards.793a06e899',
'Re-check'
)}
</Button>
</div>
</IntegrationCardDetails>
) : null}
</IntegrationCardShell>
)
}
@@ -0,0 +1,51 @@
import { useEffect } from 'react'
import { getLocalPreflightContext, localPreflightContextKey } from '@/lib/local-preflight-context'
import { getProviderRuntimeContextKey } from '@/lib/provider-runtime-context'
import { useAppStore } from '@/store'
export function useIntegrationProviderStatusRefresh(): void {
const settings = useAppStore((s) => s.settings)
const preflightStatusChecked = useAppStore((s) => s.preflightStatusChecked)
const preflightStatusContextKey = useAppStore((s) => s.preflightStatusContextKey)
const linearStatusChecked = useAppStore((s) => s.linearStatusChecked)
const linearStatusContextKey = useAppStore((s) => s.linearStatusContextKey)
const jiraStatusChecked = useAppStore((s) => s.jiraStatusChecked)
const jiraStatusContextKey = useAppStore((s) => s.jiraStatusContextKey)
const checkLinearConnection = useAppStore((s) => s.checkLinearConnection)
const checkJiraConnection = useAppStore((s) => s.checkJiraConnection)
const refreshPreflightStatus = useAppStore((s) => s.refreshPreflightStatus)
const expectedPreflightContextKey = useAppStore((s) =>
localPreflightContextKey(getLocalPreflightContext(s))
)
const providerRuntimeContextKey = getProviderRuntimeContextKey(settings)
const preflightStatusCurrent = preflightStatusContextKey === expectedPreflightContextKey
const linearStatusCurrent = linearStatusContextKey === providerRuntimeContextKey
const jiraStatusCurrent = jiraStatusContextKey === providerRuntimeContextKey
useEffect(() => {
if (!linearStatusCurrent || !linearStatusChecked) {
void checkLinearConnection()
}
if (!jiraStatusCurrent || !jiraStatusChecked) {
void checkJiraConnection()
}
if (!preflightStatusCurrent || !preflightStatusChecked) {
void refreshPreflightStatus()
}
}, [
checkJiraConnection,
checkLinearConnection,
jiraStatusChecked,
jiraStatusCurrent,
jiraStatusContextKey,
linearStatusChecked,
linearStatusCurrent,
linearStatusContextKey,
expectedPreflightContextKey,
preflightStatusChecked,
preflightStatusContextKey,
preflightStatusCurrent,
providerRuntimeContextKey,
refreshPreflightStatus
])
}
@@ -13,6 +13,7 @@ export type SetupGuideProgressReadinessInput = {
settingsLoaded: boolean
preflightStatusChecked: boolean
linearStatusChecked: boolean
jiraStatusChecked: boolean
browserUseSkillDiscoveryLoading: boolean
computerUseSkillDiscoveryLoading: boolean
orchestrationSkillDiscoveryLoading: boolean
@@ -82,6 +83,7 @@ export function getSetupGuideProgressReady(input: SetupGuideProgressReadinessInp
input.settingsLoaded &&
input.preflightStatusChecked &&
input.linearStatusChecked &&
input.jiraStatusChecked &&
!input.browserUseSkillDiscoveryLoading &&
!input.computerUseSkillDiscoveryLoading &&
!input.orchestrationSkillDiscoveryLoading &&
@@ -0,0 +1,34 @@
import {
INITIAL_SETUP_SCRIPT_PROBE_STATE,
type SetupScriptProbeState
} from './setup-guide-progress-readiness'
// Why: probe results are shared across every mounted setup-guide consumer so a
// single bounded probe can settle readiness for all of them at once.
const setupScriptProbeCacheListeners = new Set<() => void>()
let setupScriptProbeCache = INITIAL_SETUP_SCRIPT_PROBE_STATE
export function readSetupScriptProbeCache(): SetupScriptProbeState {
return setupScriptProbeCache
}
export function subscribeSetupScriptProbeCache(listener: () => void): () => void {
setupScriptProbeCacheListeners.add(listener)
return () => {
setupScriptProbeCacheListeners.delete(listener)
}
}
export function setSetupScriptProbeCache(next: SetupScriptProbeState): void {
if (
setupScriptProbeCache.signature === next.signature &&
setupScriptProbeCache.ready === next.ready &&
setupScriptProbeCache.hasSetupScript === next.hasSetupScript
) {
return
}
setupScriptProbeCache = next
for (const listener of setupScriptProbeCacheListeners) {
listener()
}
}
@@ -137,6 +137,7 @@ describe('getSetupGuideProgressReady', () => {
settingsLoaded: true,
preflightStatusChecked: true,
linearStatusChecked: true,
jiraStatusChecked: true,
browserUseSkillDiscoveryLoading: false,
computerUseSkillDiscoveryLoading: false,
orchestrationSkillDiscoveryLoading: false,
@@ -194,9 +195,10 @@ describe('getSetupGuideProgressReady', () => {
).toBe(false)
})
it('waits for preflight and Linear checks', () => {
it('waits for preflight, Linear, and Jira checks', () => {
expect(getSetupGuideProgressReady({ ...readyInput, preflightStatusChecked: false })).toBe(false)
expect(getSetupGuideProgressReady({ ...readyInput, linearStatusChecked: false })).toBe(false)
expect(getSetupGuideProgressReady({ ...readyInput, jiraStatusChecked: false })).toBe(false)
})
})
@@ -3,7 +3,9 @@ import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore
import { useAppStore } from '@/store'
import { isGitRepoKind } from '../../../../shared/repo-kind'
import { checkRuntimeHooks } from '@/runtime/runtime-hooks-client'
import { getLocalPreflightContext, localPreflightContextKey } from '@/lib/local-preflight-context'
import { hasEffectiveSetupCommand } from '@/lib/setup-script-status'
import { getProviderRuntimeContextKey } from '@/lib/provider-runtime-context'
import {
COMPUTER_USE_SKILL_NAME,
ORCA_CLI_SKILL_NAME,
@@ -17,46 +19,22 @@ import {
getFeatureWallSetupProgress,
type FeatureWallSetupProgress
} from '../feature-wall/feature-wall-setup-progress'
import { deriveIntegrationConnectionStatus } from '../feature-wall/use-integration-connection-status'
import { useSetupGuideBrowserMilestoneProgress } from './setup-guide-browser-milestone-progress'
import {
getComputerUsePermissionSetupState,
getCurrentSetupScriptProbeState,
getSetupGuideProgressReady,
getSetupScriptProbeSignature,
INITIAL_SETUP_SCRIPT_PROBE_STATE,
type SetupScriptProbeState
getSetupScriptProbeSignature
} from './setup-guide-progress-readiness'
import {
readSetupScriptProbeCache,
setSetupScriptProbeCache,
subscribeSetupScriptProbeCache
} from './setup-script-probe-cache'
const SETUP_SCRIPT_PROBE_SETTLE_TIMEOUT_MS = 15_000
const setupScriptProbeCacheListeners = new Set<() => void>()
let setupScriptProbeCache = INITIAL_SETUP_SCRIPT_PROBE_STATE
function readSetupScriptProbeCache(): SetupScriptProbeState {
return setupScriptProbeCache
}
function subscribeSetupScriptProbeCache(listener: () => void): () => void {
setupScriptProbeCacheListeners.add(listener)
return () => {
setupScriptProbeCacheListeners.delete(listener)
}
}
function setSetupScriptProbeCache(next: SetupScriptProbeState): void {
if (
setupScriptProbeCache.signature === next.signature &&
setupScriptProbeCache.ready === next.ready &&
setupScriptProbeCache.hasSetupScript === next.hasSetupScript
) {
return
}
setupScriptProbeCache = next
for (const listener of setupScriptProbeCacheListeners) {
listener()
}
}
export function useSetupGuideProgress(
shouldRefreshCoreState: boolean,
orchestrationSkillInstalled: boolean,
@@ -69,12 +47,23 @@ export function useSetupGuideProgress(
const terminalLayoutsByTabId = useAppStore((s) => s.terminalLayoutsByTabId)
const preflightStatus = useAppStore((s) => s.preflightStatus)
const preflightStatusChecked = useAppStore((s) => s.preflightStatusChecked)
const preflightStatusContextKey = useAppStore((s) => s.preflightStatusContextKey)
const preflightStatusError = useAppStore((s) => s.preflightStatusError)
const preflightStatusLoading = useAppStore((s) => s.preflightStatusLoading)
const refreshPreflightStatus = useAppStore((s) => s.refreshPreflightStatus)
const linearStatus = useAppStore((s) => s.linearStatus)
const linearStatusChecked = useAppStore((s) => s.linearStatusChecked)
const linearStatusContextKey = useAppStore((s) => s.linearStatusContextKey)
const checkLinearConnection = useAppStore((s) => s.checkLinearConnection)
const jiraStatus = useAppStore((s) => s.jiraStatus)
const jiraStatusChecked = useAppStore((s) => s.jiraStatusChecked)
const jiraStatusContextKey = useAppStore((s) => s.jiraStatusContextKey)
const checkJiraConnection = useAppStore((s) => s.checkJiraConnection)
const repos = useAppStore((s) => s.repos)
const activeRepoId = useAppStore((s) => s.activeRepoId)
const expectedPreflightContextKey = useAppStore((s) =>
localPreflightContextKey(getLocalPreflightContext(s))
)
const setupScriptProbe = useSyncExternalStore(
subscribeSetupScriptProbeCache,
readSetupScriptProbeCache,
@@ -101,21 +90,38 @@ export function useSetupGuideProgress(
enabled: shouldRefreshCoreState,
sourceKinds: GLOBAL_AGENT_SKILL_SOURCE_KINDS
})
const providerRuntimeContextKey = getProviderRuntimeContextKey(settings)
const linearStatusCurrent = linearStatusContextKey === providerRuntimeContextKey
const jiraStatusCurrent = jiraStatusContextKey === providerRuntimeContextKey
const preflightStatusCurrent = preflightStatusContextKey === expectedPreflightContextKey
useEffect(() => {
if (!shouldRefreshCoreState) {
return
}
if (!preflightStatusChecked) {
if (!preflightStatusCurrent || !preflightStatusChecked) {
void refreshPreflightStatus()
}
if (!linearStatusChecked) {
if (!linearStatusCurrent || !linearStatusChecked) {
void checkLinearConnection()
}
if (!jiraStatusCurrent || !jiraStatusChecked) {
void checkJiraConnection()
}
}, [
checkJiraConnection,
checkLinearConnection,
jiraStatusCurrent,
jiraStatusChecked,
jiraStatusContextKey,
linearStatusCurrent,
linearStatusChecked,
linearStatusContextKey,
expectedPreflightContextKey,
preflightStatusContextKey,
preflightStatusCurrent,
preflightStatusChecked,
providerRuntimeContextKey,
refreshPreflightStatus,
shouldRefreshCoreState
])
@@ -185,6 +191,7 @@ export function useSetupGuideProgress(
return
}
const permissionState = getComputerUsePermissionSetupState(status)
// oxlint-disable-next-line react-doctor/no-adjust-state-on-prop-change -- Why: async permission checks update setup progress after external OS state changes.
setComputerUsePermissionStatusChecked(true)
setComputerUsePermissionsReady(permissionState.ready)
setComputerUseUnavailable(permissionState.unavailable)
@@ -192,12 +199,18 @@ export function useSetupGuideProgress(
useEffect(() => {
if (!shouldRefreshCoreState || !computerUseSkillInstalled) {
// Why: unavailable setup-guide steps must clear stale permission state before
// readiness is derived for the visible checklist.
setComputerUsePermissionStatusChecked(false)
setComputerUsePermissionsReady(false)
setComputerUseUnavailable(false)
return
}
let stale = false
const refreshComputerUsePermissions = (): void => {
void readComputerUsePermissions(() => stale)
}
// oxlint-disable-next-line react-doctor/no-adjust-state-on-prop-change -- Why: refresh the setup checklist when the permission step becomes active.
refreshComputerUsePermissions()
const handleFocus = (): void => {
void refreshComputerUsePermissions()
@@ -218,10 +231,22 @@ export function useSetupGuideProgress(
}
}, [computerUseSkillInstalled, readComputerUsePermissions, shouldRefreshCoreState])
const hasConnectedTaskSource =
(preflightStatus?.gh.installed === true && preflightStatus.gh.authenticated === true) ||
(preflightStatus?.glab?.installed === true && preflightStatus.glab.authenticated === true) ||
linearStatus.connected === true
const taskSourceStatus = deriveIntegrationConnectionStatus({
preflightStatus,
preflightStatusChecked,
preflightStatusContextKey,
preflightStatusError,
preflightStatusLoading,
expectedPreflightContextKey,
linearStatus,
linearStatusChecked,
linearStatusContextKey,
jiraStatus,
jiraStatusChecked,
jiraStatusContextKey,
providerRuntimeContextKey
})
const hasConnectedTaskSource = taskSourceStatus.trackerConnected
const gitRepoCount = orderedGitRepos.length
const currentSetupScriptProbe = getCurrentSetupScriptProbeState(
setupScriptProbe,
@@ -236,8 +261,11 @@ export function useSetupGuideProgress(
const ready = getSetupGuideProgressReady({
refreshEnabled: shouldRefreshCoreState,
settingsLoaded: settings !== null,
preflightStatusChecked,
linearStatusChecked,
// Why: task-source readiness is a capability group. Once any provider is
// usable, unrelated stale provider checks should not hide setup progress.
preflightStatusChecked: !taskSourceStatus.checking,
linearStatusChecked: true,
jiraStatusChecked: true,
browserUseSkillDiscoveryLoading: detectedBrowserUseSkillLoading,
computerUseSkillDiscoveryLoading: computerUseSkillLoading,
orchestrationSkillDiscoveryLoading: detectedOrchestrationSkillLoading,
@@ -15,6 +15,7 @@ import {
} from '../../../../shared/task-providers'
import { useActivityUnreadCount } from '@/components/activity/useActivityUnreadCount'
import { useShortcutLabel } from '@/hooks/useShortcutLabel'
import { getLocalPreflightContext, localPreflightContextKey } from '@/lib/local-preflight-context'
import { useMobileSidebarOnboardingBadge } from './mobile-sidebar-onboarding-badge'
import {
ContextMenu,
@@ -108,7 +109,11 @@ const SidebarNav = React.memo(function SidebarNav() {
const defaultTaskSource = useAppStore((s) => s.settings?.defaultTaskSource ?? 'github')
const preflightStatus = useAppStore((s) => s.preflightStatus)
const preflightStatusChecked = useAppStore((s) => s.preflightStatusChecked)
const preflightStatusContextKey = useAppStore((s) => s.preflightStatusContextKey)
const refreshPreflightStatus = useAppStore((s) => s.refreshPreflightStatus)
const expectedPreflightContextKey = useAppStore((s) =>
localPreflightContextKey(getLocalPreflightContext(s))
)
const linearStatus = useAppStore((s) => s.linearStatus)
const linearStatusChecked = useAppStore((s) => s.linearStatusChecked)
const checkLinearConnection = useAppStore((s) => s.checkLinearConnection)
@@ -119,12 +124,13 @@ const SidebarNav = React.memo(function SidebarNav() {
() => normalizeVisibleTaskProviders(rawVisibleTaskProviders),
[rawVisibleTaskProviders]
)
const preflightStatusCurrent = preflightStatusContextKey === expectedPreflightContextKey
const visibleTaskProviders = React.useMemo(
() =>
restoreAvailableDefaultTaskProvider(
preferredVisibleTaskProviders,
{
gitlabInstalled: preflightStatus?.glab?.installed === true,
gitlabInstalled: preflightStatusCurrent && preflightStatus?.glab?.installed === true,
linearConnected: linearStatus.connected === true
},
defaultTaskSource
@@ -133,6 +139,7 @@ const SidebarNav = React.memo(function SidebarNav() {
defaultTaskSource,
linearStatus.connected,
preferredVisibleTaskProviders,
preflightStatusCurrent,
preflightStatus?.glab?.installed
]
)
@@ -142,13 +149,19 @@ const SidebarNav = React.memo(function SidebarNav() {
)
React.useEffect(() => {
if (!preflightStatusChecked) {
if (!preflightStatusChecked || !preflightStatusCurrent) {
void refreshPreflightStatus()
}
if (!linearStatusChecked) {
void checkLinearConnection()
}
}, [checkLinearConnection, linearStatusChecked, preflightStatusChecked, refreshPreflightStatus])
}, [
checkLinearConnection,
linearStatusChecked,
preflightStatusChecked,
preflightStatusCurrent,
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
+151 -1
View File
@@ -232,6 +232,12 @@
"ui": {
"66e3bd7ce6": "Sent to {{value0}}",
"53883b7bc3": "Couldn't send to {{value0}}"
},
"jira": {
"856083302c": "Jira connection was superseded by a newer request."
},
"linear": {
"37d36984d0": "Linear connection was superseded by a newer request."
}
}
},
@@ -4657,7 +4663,11 @@
"9707523939": "Pull requests and build statuses",
"a565377c38": "not-installed",
"15cf990798": "Not authenticated",
"f7eb5f0b24": "Not installed"
"f7eb5f0b24": "Not installed",
"3ba07f933b": "Connect issue trackers Orca can use to browse tasks and start workspaces with linked context.",
"70e885705b": "Task providers",
"1683acbac4": "Connect the source hosts Orca can use for pull requests, merge requests, checks, and review status.",
"298c65ecac": "Review providers"
},
"KagiSessionLinkForm": {
"92f0b4e472": "Clear",
@@ -7401,6 +7411,103 @@
"agent-generated-tab-title-copy": {
"19ad21615a": "Auto-generate tab titles",
"b036c7a409": "Derive short stable tab names from the first known agent prompt. Manual renames always win."
},
"cli": {
"source": {
"control": {
"integration": {
"cards": {
"d5b3be8ecd": "Re-check",
"8cbc39f862": "Learn more",
"707180d09c": "glab auth login",
"4be0616873": "The GitLab CLI is installed but not authenticated. Run this command in a terminal:",
"54a640af7a": "Install GitLab CLI",
"b56fd5676a": "Install the GitLab CLI to enable merge requests, issues, and pipelines.",
"faddeb763d": "GitLab CLI status is not available in this runtime yet.",
"a47f71e357": "CLI.",
"2a6b359e75": "glab",
"1f2b347bd3": "Merge requests, issues, todos, and pipelines via the",
"8d90249d22": "gh auth login",
"2e44dda68a": "The GitHub CLI is installed but not authenticated. Run this command in a terminal:",
"7755c28af5": "Install GitHub CLI",
"23cb5a0dee": "Install the GitHub CLI to enable pull requests, issues, and checks.",
"6f30fc4216": "GitHub CLI status is not available in this runtime yet.",
"6b2cfb52b4": "gh",
"b4d900e7f1": "Pull requests, issues, and checks via the"
}
}
}
}
},
"task": {
"tracker": {
"integration": {
"cards": {
"c90f2ef419": "Re-check",
"dd3529015d": "Disconnect {{value0}}",
"8b2408a8e5": "Jira is connected for this runtime. Re-check if the connected site list looks stale.",
"8c20e76308": "Each connected Jira site has one token stored by the active runtime.",
"c24e56c532": "Test",
"3e7c10d286": "Testing...",
"a2c0015fb8": "Verified",
"e2ff968276": "Connect Jira",
"60996beda6": "Add Jira site",
"7ca5ffffdb": "Browse, create, and start work from Jira Cloud issues.",
"a1093a06c7": "Checking Jira access before showing setup actions.",
"9fa04a032e": "{{value0}} site{{value1}} connected",
"cef18762a2": "Add access with a Personal API key from your Linear settings. Full-access keys can see every team the key owner can reach.",
"6224fe9d34": "Each connected Linear workspace has one key stored by the active runtime. Full-access keys can cover all teams the key owner can access; restricted keys can be replaced any time.",
"1a12e33fe5": "Add Linear access",
"622c224082": "Add workspace access",
"eae4a9f16b": "Add Linear access to browse and link issues.",
"fe9231215b": "Checking Linear access before showing setup actions.",
"e1f5e6424c": "{{value0}} workspace{{value1}} connected",
"disconnect_all": "Disconnect"
}
}
}
},
"token": {
"source": {
"control": {
"integration": {
"cards": {
"793a06e899": "Re-check",
"1a9475dace": "Learn more",
"19fb419c12": "Gitea credentials are configured but could not authenticate. Check the token, API base URL, and repository permissions, then restart Orca if environment variables changed.",
"60708f23da": "only when Orca cannot derive the API URL from the remote.",
"709057ad91": "ORCA_GITEA_API_BASE_URL",
"6da9dfa5de": "for private repositories, and set",
"6d5c2a3005": "ORCA_GITEA_TOKEN",
"fcbe0469fd": "Public repositories are detected from their git remote. Set",
"0613928cb3": "Gitea status is not available in this runtime yet.",
"05863d2599": "Pull requests and commit statuses via the Gitea REST API.",
"52f75876be": "Pull requests and commit statuses for detected repositories",
"0b5242f8a2": "{{value0}} · Pull requests and commit statuses",
"40f678df73": "Azure DevOps credentials are configured but could not authenticate. Check the token, API base URL, and repository permissions, then restart Orca if environment variables changed.",
"7bd345e3f6": "only when Orca cannot derive the API base URL from the git remote.",
"186a6689df": "ORCA_AZURE_DEVOPS_API_BASE_URL",
"b8a10b07c1": ". Set",
"fbfd237f5e": "ORCA_AZURE_DEVOPS_ACCESS_TOKEN",
"087feb92f1": ", or set",
"48842720d2": "ORCA_AZURE_DEVOPS_TOKEN",
"7bbc9c64f0": "Set",
"f3f47dc7de": "Azure DevOps status is not available in this runtime yet.",
"0eb50d5593": "Pull requests and build statuses via Azure DevOps REST API tokens.",
"54636c65d4": "Pull requests and build statuses for detected Azure Repos",
"ea204f5e03": "{{value0}} · Pull requests and build statuses",
"6154b02093": "Bitbucket credentials are configured but could not authenticate. Check the token and repository permissions, then restart Orca if environment variables changed.",
"e63fe8f627": "ORCA_BITBUCKET_ACCESS_TOKEN",
"19416c874c": "ORCA_BITBUCKET_API_TOKEN",
"fc71a0e7aa": "and",
"63a7f47392": "ORCA_BITBUCKET_EMAIL",
"24ac1c69dc": "Bitbucket status is not available in this runtime yet.",
"a924e8dcd1": "Pull requests and build statuses via Bitbucket Cloud API tokens.",
"0fa5629dad": "Pull requests and build statuses"
}
}
}
}
}
},
"right": {
@@ -8959,6 +9066,30 @@
"b7345c18db": "An unexpected error occurred.",
"5f97caf76b": "Installing…",
"c2df599513": "Install CLI & Skill"
},
"ConnectIntegrationsList": {
"3dddb2d565": "connected for tasks",
"33b650af52": "Connect where your team tracks work. Orca starts workspaces with the issue title, link, and context already attached.",
"5b3577a492": "connected for review status",
"3a1fcdddad": "Two quick steps: connect where your code is reviewed, then where your team plans work.",
"list_end": ", and ",
"list_pair": " and ",
"list_mid": ", ",
"code_host_tasks_summary": "issues available as tasks · add Linear or Jira if your team plans work there",
"code_host_tasks_caption": "Your code host's issues also work as tasks.",
"review_step_title": "See PR status while agents work",
"review_step_description": "Connect a review provider so Orca can show PR or MR status, checks, and reviews.",
"task_step_title": "Start agents on your tasks without leaving Orca"
},
"connect": {
"integration": {
"step": {
"0f47ff17c6": "Change",
"5538eb6743": "Done",
"open_step": "Open",
"close_step": "Close"
}
}
}
},
"tips": {
@@ -10163,6 +10294,25 @@
"8490e5d36a": "Confirm",
"56f5c60e0c": "Cancel"
}
},
"jira": {
"connect": {
"dialog": {
"63ce735809": "Connect",
"4a2ab52781": "Verifying…",
"79e7aaed39": "Cancel",
"fdd26d81cc": "Atlassian account settings",
"8090504a3e": "Create a token in",
"7b3967c12f": "Atlassian API token",
"3d81bf3ab3": "API token",
"e91b9a4073": "you@example.com",
"2849ddb295": "Atlassian email",
"70fcd360c4": "https://example.atlassian.net",
"e176f9d0c5": "Jira Cloud site URL",
"d785c42b8b": "Use a Jira Cloud site URL, Atlassian email, and API token to browse issues.",
"8388bdea2b": "Connect Jira site"
}
}
}
},
"i18n": {
+151 -1
View File
@@ -232,6 +232,12 @@
"ui": {
"66e3bd7ce6": "Enviado a {{value0}}",
"53883b7bc3": "No se pudo enviar a {{value0}}"
},
"jira": {
"856083302c": "Jira connection was superseded by a newer request."
},
"linear": {
"37d36984d0": "Linear connection was superseded by a newer request."
}
}
},
@@ -4544,7 +4550,11 @@
"9707523939": "Solicitudes de extracción y estados de compilación",
"a565377c38": "no instalado",
"15cf990798": "No autenticado",
"f7eb5f0b24": "No instalado"
"f7eb5f0b24": "No instalado",
"3ba07f933b": "Connect issue trackers Orca can use to browse tasks and start workspaces with linked context.",
"70e885705b": "Task providers",
"1683acbac4": "Connect the source hosts Orca can use for pull requests, merge requests, checks, and review status.",
"298c65ecac": "Review providers"
},
"KagiSessionLinkForm": {
"92f0b4e472": "Claro",
@@ -7274,6 +7284,103 @@
},
"YamlThemeImportButton": {
"label": "Import from YAML"
},
"cli": {
"source": {
"control": {
"integration": {
"cards": {
"d5b3be8ecd": "Re-check",
"8cbc39f862": "Learn more",
"707180d09c": "glab auth login",
"4be0616873": "The GitLab CLI is installed but not authenticated. Run this command in a terminal:",
"54a640af7a": "Install GitLab CLI",
"b56fd5676a": "Install the GitLab CLI to enable merge requests, issues, and pipelines.",
"faddeb763d": "GitLab CLI status is not available in this runtime yet.",
"a47f71e357": "CLI.",
"2a6b359e75": "glab",
"1f2b347bd3": "Merge requests, issues, todos, and pipelines via the",
"8d90249d22": "gh auth login",
"2e44dda68a": "The GitHub CLI is installed but not authenticated. Run this command in a terminal:",
"7755c28af5": "Install GitHub CLI",
"23cb5a0dee": "Install the GitHub CLI to enable pull requests, issues, and checks.",
"6f30fc4216": "GitHub CLI status is not available in this runtime yet.",
"6b2cfb52b4": "gh",
"b4d900e7f1": "Pull requests, issues, and checks via the"
}
}
}
}
},
"task": {
"tracker": {
"integration": {
"cards": {
"c90f2ef419": "Re-check",
"dd3529015d": "Disconnect {{value0}}",
"8b2408a8e5": "Jira is connected for this runtime. Re-check if the connected site list looks stale.",
"8c20e76308": "Each connected Jira site has one token stored by the active runtime.",
"c24e56c532": "Test",
"3e7c10d286": "Testing...",
"a2c0015fb8": "Verified",
"e2ff968276": "Connect Jira",
"60996beda6": "Add Jira site",
"7ca5ffffdb": "Browse, create, and start work from Jira Cloud issues.",
"a1093a06c7": "Checking Jira access before showing setup actions.",
"9fa04a032e": "{{value0}} site{{value1}} connected",
"cef18762a2": "Add access with a Personal API key from your Linear settings. Full-access keys can see every team the key owner can reach.",
"6224fe9d34": "Each connected Linear workspace has one key stored by the active runtime. Full-access keys can cover all teams the key owner can access; restricted keys can be replaced any time.",
"1a12e33fe5": "Add Linear access",
"622c224082": "Add workspace access",
"eae4a9f16b": "Add Linear access to browse and link issues.",
"fe9231215b": "Checking Linear access before showing setup actions.",
"e1f5e6424c": "{{value0}} workspace{{value1}} connected",
"disconnect_all": "Disconnect"
}
}
}
},
"token": {
"source": {
"control": {
"integration": {
"cards": {
"793a06e899": "Re-check",
"1a9475dace": "Learn more",
"19fb419c12": "Gitea credentials are configured but could not authenticate. Check the token, API base URL, and repository permissions, then restart Orca if environment variables changed.",
"60708f23da": "only when Orca cannot derive the API URL from the remote.",
"709057ad91": "ORCA_GITEA_API_BASE_URL",
"6da9dfa5de": "for private repositories, and set",
"6d5c2a3005": "ORCA_GITEA_TOKEN",
"fcbe0469fd": "Public repositories are detected from their git remote. Set",
"0613928cb3": "Gitea status is not available in this runtime yet.",
"05863d2599": "Pull requests and commit statuses via the Gitea REST API.",
"52f75876be": "Pull requests and commit statuses for detected repositories",
"0b5242f8a2": "{{value0}} · Pull requests and commit statuses",
"40f678df73": "Azure DevOps credentials are configured but could not authenticate. Check the token, API base URL, and repository permissions, then restart Orca if environment variables changed.",
"7bd345e3f6": "only when Orca cannot derive the API base URL from the git remote.",
"186a6689df": "ORCA_AZURE_DEVOPS_API_BASE_URL",
"b8a10b07c1": ". Set",
"fbfd237f5e": "ORCA_AZURE_DEVOPS_ACCESS_TOKEN",
"087feb92f1": ", or set",
"48842720d2": "ORCA_AZURE_DEVOPS_TOKEN",
"7bbc9c64f0": "Set",
"f3f47dc7de": "Azure DevOps status is not available in this runtime yet.",
"0eb50d5593": "Pull requests and build statuses via Azure DevOps REST API tokens.",
"54636c65d4": "Pull requests and build statuses for detected Azure Repos",
"ea204f5e03": "{{value0}} · Pull requests and build statuses",
"6154b02093": "Bitbucket credentials are configured but could not authenticate. Check the token and repository permissions, then restart Orca if environment variables changed.",
"e63fe8f627": "ORCA_BITBUCKET_ACCESS_TOKEN",
"19416c874c": "ORCA_BITBUCKET_API_TOKEN",
"fc71a0e7aa": "and",
"63a7f47392": "ORCA_BITBUCKET_EMAIL",
"24ac1c69dc": "Bitbucket status is not available in this runtime yet.",
"a924e8dcd1": "Pull requests and build statuses via Bitbucket Cloud API tokens.",
"0fa5629dad": "Pull requests and build statuses"
}
}
}
}
}
},
"right": {
@@ -8828,6 +8935,30 @@
"b7345c18db": "Se produjo un error inesperado.",
"5f97caf76b": "Instalando…",
"c2df599513": "Instalar CLI y habilidad"
},
"ConnectIntegrationsList": {
"3dddb2d565": "connected for tasks",
"33b650af52": "Connect where your team tracks work. Orca starts workspaces with the issue title, link, and context already attached.",
"5b3577a492": "connected for review status",
"3a1fcdddad": "Two quick steps: connect where your code is reviewed, then where your team plans work.",
"list_end": ", and ",
"list_pair": " and ",
"list_mid": ", ",
"code_host_tasks_summary": "issues available as tasks · add Linear or Jira if your team plans work there",
"code_host_tasks_caption": "Your code host's issues also work as tasks.",
"review_step_title": "See PR status while agents work",
"review_step_description": "Connect a review provider so Orca can show PR or MR status, checks, and reviews.",
"task_step_title": "Start agents on your tasks without leaving Orca"
},
"connect": {
"integration": {
"step": {
"0f47ff17c6": "Change",
"5538eb6743": "Done",
"open_step": "Open",
"close_step": "Close"
}
}
}
},
"tips": {
@@ -10032,6 +10163,25 @@
"8490e5d36a": "Confirmar",
"56f5c60e0c": "Cancelar"
}
},
"jira": {
"connect": {
"dialog": {
"63ce735809": "Connect",
"4a2ab52781": "Verifying…",
"79e7aaed39": "Cancel",
"fdd26d81cc": "Atlassian account settings",
"8090504a3e": "Create a token in",
"7b3967c12f": "Atlassian API token",
"3d81bf3ab3": "API token",
"e91b9a4073": "you@example.com",
"2849ddb295": "Atlassian email",
"70fcd360c4": "https://example.atlassian.net",
"e176f9d0c5": "Jira Cloud site URL",
"d785c42b8b": "Use a Jira Cloud site URL, Atlassian email, and API token to browse issues.",
"8388bdea2b": "Connect Jira site"
}
}
}
},
"i18n": {
+151 -1
View File
@@ -232,6 +232,12 @@
"ui": {
"66e3bd7ce6": "{{value0}} に送信されました",
"53883b7bc3": "{{value0}} に送信できませんでした"
},
"jira": {
"856083302c": "Jira connection was superseded by a newer request."
},
"linear": {
"37d36984d0": "Linear connection was superseded by a newer request."
}
}
},
@@ -4637,7 +4643,11 @@
"9707523939": "PR とビルドステータス",
"a565377c38": "未インストール",
"15cf990798": "認証されていません",
"f7eb5f0b24": "未インストール"
"f7eb5f0b24": "未インストール",
"3ba07f933b": "Connect issue trackers Orca can use to browse tasks and start workspaces with linked context.",
"70e885705b": "Task providers",
"1683acbac4": "Connect the source hosts Orca can use for pull requests, merge requests, checks, and review status.",
"298c65ecac": "Review providers"
},
"KagiSessionLinkForm": {
"92f0b4e472": "クリア",
@@ -7382,6 +7392,103 @@
}
}
}
},
"cli": {
"source": {
"control": {
"integration": {
"cards": {
"d5b3be8ecd": "Re-check",
"8cbc39f862": "Learn more",
"707180d09c": "glab auth login",
"4be0616873": "The GitLab CLI is installed but not authenticated. Run this command in a terminal:",
"54a640af7a": "Install GitLab CLI",
"b56fd5676a": "Install the GitLab CLI to enable merge requests, issues, and pipelines.",
"faddeb763d": "GitLab CLI status is not available in this runtime yet.",
"a47f71e357": "CLI.",
"2a6b359e75": "glab",
"1f2b347bd3": "Merge requests, issues, todos, and pipelines via the",
"8d90249d22": "gh auth login",
"2e44dda68a": "The GitHub CLI is installed but not authenticated. Run this command in a terminal:",
"7755c28af5": "Install GitHub CLI",
"23cb5a0dee": "Install the GitHub CLI to enable pull requests, issues, and checks.",
"6f30fc4216": "GitHub CLI status is not available in this runtime yet.",
"6b2cfb52b4": "gh",
"b4d900e7f1": "Pull requests, issues, and checks via the"
}
}
}
}
},
"task": {
"tracker": {
"integration": {
"cards": {
"c90f2ef419": "Re-check",
"dd3529015d": "Disconnect {{value0}}",
"8b2408a8e5": "Jira is connected for this runtime. Re-check if the connected site list looks stale.",
"8c20e76308": "Each connected Jira site has one token stored by the active runtime.",
"c24e56c532": "Test",
"3e7c10d286": "Testing...",
"a2c0015fb8": "Verified",
"e2ff968276": "Connect Jira",
"60996beda6": "Add Jira site",
"7ca5ffffdb": "Browse, create, and start work from Jira Cloud issues.",
"a1093a06c7": "Checking Jira access before showing setup actions.",
"9fa04a032e": "{{value0}} site{{value1}} connected",
"cef18762a2": "Add access with a Personal API key from your Linear settings. Full-access keys can see every team the key owner can reach.",
"6224fe9d34": "Each connected Linear workspace has one key stored by the active runtime. Full-access keys can cover all teams the key owner can access; restricted keys can be replaced any time.",
"1a12e33fe5": "Add Linear access",
"622c224082": "Add workspace access",
"eae4a9f16b": "Add Linear access to browse and link issues.",
"fe9231215b": "Checking Linear access before showing setup actions.",
"e1f5e6424c": "{{value0}} workspace{{value1}} connected",
"disconnect_all": "Disconnect"
}
}
}
},
"token": {
"source": {
"control": {
"integration": {
"cards": {
"793a06e899": "Re-check",
"1a9475dace": "Learn more",
"19fb419c12": "Gitea credentials are configured but could not authenticate. Check the token, API base URL, and repository permissions, then restart Orca if environment variables changed.",
"60708f23da": "only when Orca cannot derive the API URL from the remote.",
"709057ad91": "ORCA_GITEA_API_BASE_URL",
"6da9dfa5de": "for private repositories, and set",
"6d5c2a3005": "ORCA_GITEA_TOKEN",
"fcbe0469fd": "Public repositories are detected from their git remote. Set",
"0613928cb3": "Gitea status is not available in this runtime yet.",
"05863d2599": "Pull requests and commit statuses via the Gitea REST API.",
"52f75876be": "Pull requests and commit statuses for detected repositories",
"0b5242f8a2": "{{value0}} · Pull requests and commit statuses",
"40f678df73": "Azure DevOps credentials are configured but could not authenticate. Check the token, API base URL, and repository permissions, then restart Orca if environment variables changed.",
"7bd345e3f6": "only when Orca cannot derive the API base URL from the git remote.",
"186a6689df": "ORCA_AZURE_DEVOPS_API_BASE_URL",
"b8a10b07c1": ". Set",
"fbfd237f5e": "ORCA_AZURE_DEVOPS_ACCESS_TOKEN",
"087feb92f1": ", or set",
"48842720d2": "ORCA_AZURE_DEVOPS_TOKEN",
"7bbc9c64f0": "Set",
"f3f47dc7de": "Azure DevOps status is not available in this runtime yet.",
"0eb50d5593": "Pull requests and build statuses via Azure DevOps REST API tokens.",
"54636c65d4": "Pull requests and build statuses for detected Azure Repos",
"ea204f5e03": "{{value0}} · Pull requests and build statuses",
"6154b02093": "Bitbucket credentials are configured but could not authenticate. Check the token and repository permissions, then restart Orca if environment variables changed.",
"e63fe8f627": "ORCA_BITBUCKET_ACCESS_TOKEN",
"19416c874c": "ORCA_BITBUCKET_API_TOKEN",
"fc71a0e7aa": "and",
"63a7f47392": "ORCA_BITBUCKET_EMAIL",
"24ac1c69dc": "Bitbucket status is not available in this runtime yet.",
"a924e8dcd1": "Pull requests and build statuses via Bitbucket Cloud API tokens.",
"0fa5629dad": "Pull requests and build statuses"
}
}
}
}
}
},
"right": {
@@ -8940,6 +9047,30 @@
"b7345c18db": "予期しないエラーが発生しました。",
"5f97caf76b": "インストール中…",
"c2df599513": "CLI とスキルをインストール"
},
"ConnectIntegrationsList": {
"3dddb2d565": "connected for tasks",
"33b650af52": "Connect where your team tracks work. Orca starts workspaces with the issue title, link, and context already attached.",
"5b3577a492": "connected for review status",
"3a1fcdddad": "Two quick steps: connect where your code is reviewed, then where your team plans work.",
"list_end": ", and ",
"list_pair": " and ",
"list_mid": ", ",
"code_host_tasks_summary": "issues available as tasks · add Linear or Jira if your team plans work there",
"code_host_tasks_caption": "Your code host's issues also work as tasks.",
"review_step_title": "See PR status while agents work",
"review_step_description": "Connect a review provider so Orca can show PR or MR status, checks, and reviews.",
"task_step_title": "Start agents on your tasks without leaving Orca"
},
"connect": {
"integration": {
"step": {
"0f47ff17c6": "Change",
"5538eb6743": "Done",
"open_step": "Open",
"close_step": "Close"
}
}
}
},
"tips": {
@@ -10144,6 +10275,25 @@
"8490e5d36a": "確認する",
"56f5c60e0c": "キャンセル"
}
},
"jira": {
"connect": {
"dialog": {
"63ce735809": "Connect",
"4a2ab52781": "Verifying…",
"79e7aaed39": "Cancel",
"fdd26d81cc": "Atlassian account settings",
"8090504a3e": "Create a token in",
"7b3967c12f": "Atlassian API token",
"3d81bf3ab3": "API token",
"e91b9a4073": "you@example.com",
"2849ddb295": "Atlassian email",
"70fcd360c4": "https://example.atlassian.net",
"e176f9d0c5": "Jira Cloud site URL",
"d785c42b8b": "Use a Jira Cloud site URL, Atlassian email, and API token to browse issues.",
"8388bdea2b": "Connect Jira site"
}
}
}
},
"i18n": {
+151 -1
View File
@@ -232,6 +232,12 @@
"ui": {
"66e3bd7ce6": "{{value0}}(으)로 보냄",
"53883b7bc3": "{{value0}}(으)로 보낼 수 없습니다."
},
"jira": {
"856083302c": "Jira connection was superseded by a newer request."
},
"linear": {
"37d36984d0": "Linear connection was superseded by a newer request."
}
}
},
@@ -4601,7 +4607,11 @@
"9707523939": "PR 및 빌드 상태",
"a565377c38": "설치되지 않은",
"15cf990798": "인증되지 않음",
"f7eb5f0b24": "설치되지 않음"
"f7eb5f0b24": "설치되지 않음",
"3ba07f933b": "Connect issue trackers Orca can use to browse tasks and start workspaces with linked context.",
"70e885705b": "Task providers",
"1683acbac4": "Connect the source hosts Orca can use for pull requests, merge requests, checks, and review status.",
"298c65ecac": "Review providers"
},
"KagiSessionLinkForm": {
"92f0b4e472": "지우기",
@@ -7382,6 +7392,103 @@
},
"YamlThemeImportButton": {
"label": "Import from YAML"
},
"cli": {
"source": {
"control": {
"integration": {
"cards": {
"d5b3be8ecd": "Re-check",
"8cbc39f862": "Learn more",
"707180d09c": "glab auth login",
"4be0616873": "The GitLab CLI is installed but not authenticated. Run this command in a terminal:",
"54a640af7a": "Install GitLab CLI",
"b56fd5676a": "Install the GitLab CLI to enable merge requests, issues, and pipelines.",
"faddeb763d": "GitLab CLI status is not available in this runtime yet.",
"a47f71e357": "CLI.",
"2a6b359e75": "glab",
"1f2b347bd3": "Merge requests, issues, todos, and pipelines via the",
"8d90249d22": "gh auth login",
"2e44dda68a": "The GitHub CLI is installed but not authenticated. Run this command in a terminal:",
"7755c28af5": "Install GitHub CLI",
"23cb5a0dee": "Install the GitHub CLI to enable pull requests, issues, and checks.",
"6f30fc4216": "GitHub CLI status is not available in this runtime yet.",
"6b2cfb52b4": "gh",
"b4d900e7f1": "Pull requests, issues, and checks via the"
}
}
}
}
},
"task": {
"tracker": {
"integration": {
"cards": {
"c90f2ef419": "Re-check",
"dd3529015d": "Disconnect {{value0}}",
"8b2408a8e5": "Jira is connected for this runtime. Re-check if the connected site list looks stale.",
"8c20e76308": "Each connected Jira site has one token stored by the active runtime.",
"c24e56c532": "Test",
"3e7c10d286": "Testing...",
"a2c0015fb8": "Verified",
"e2ff968276": "Connect Jira",
"60996beda6": "Add Jira site",
"7ca5ffffdb": "Browse, create, and start work from Jira Cloud issues.",
"a1093a06c7": "Checking Jira access before showing setup actions.",
"9fa04a032e": "{{value0}} site{{value1}} connected",
"cef18762a2": "Add access with a Personal API key from your Linear settings. Full-access keys can see every team the key owner can reach.",
"6224fe9d34": "Each connected Linear workspace has one key stored by the active runtime. Full-access keys can cover all teams the key owner can access; restricted keys can be replaced any time.",
"1a12e33fe5": "Add Linear access",
"622c224082": "Add workspace access",
"eae4a9f16b": "Add Linear access to browse and link issues.",
"fe9231215b": "Checking Linear access before showing setup actions.",
"e1f5e6424c": "{{value0}} workspace{{value1}} connected",
"disconnect_all": "Disconnect"
}
}
}
},
"token": {
"source": {
"control": {
"integration": {
"cards": {
"793a06e899": "Re-check",
"1a9475dace": "Learn more",
"19fb419c12": "Gitea credentials are configured but could not authenticate. Check the token, API base URL, and repository permissions, then restart Orca if environment variables changed.",
"60708f23da": "only when Orca cannot derive the API URL from the remote.",
"709057ad91": "ORCA_GITEA_API_BASE_URL",
"6da9dfa5de": "for private repositories, and set",
"6d5c2a3005": "ORCA_GITEA_TOKEN",
"fcbe0469fd": "Public repositories are detected from their git remote. Set",
"0613928cb3": "Gitea status is not available in this runtime yet.",
"05863d2599": "Pull requests and commit statuses via the Gitea REST API.",
"52f75876be": "Pull requests and commit statuses for detected repositories",
"0b5242f8a2": "{{value0}} · Pull requests and commit statuses",
"40f678df73": "Azure DevOps credentials are configured but could not authenticate. Check the token, API base URL, and repository permissions, then restart Orca if environment variables changed.",
"7bd345e3f6": "only when Orca cannot derive the API base URL from the git remote.",
"186a6689df": "ORCA_AZURE_DEVOPS_API_BASE_URL",
"b8a10b07c1": ". Set",
"fbfd237f5e": "ORCA_AZURE_DEVOPS_ACCESS_TOKEN",
"087feb92f1": ", or set",
"48842720d2": "ORCA_AZURE_DEVOPS_TOKEN",
"7bbc9c64f0": "Set",
"f3f47dc7de": "Azure DevOps status is not available in this runtime yet.",
"0eb50d5593": "Pull requests and build statuses via Azure DevOps REST API tokens.",
"54636c65d4": "Pull requests and build statuses for detected Azure Repos",
"ea204f5e03": "{{value0}} · Pull requests and build statuses",
"6154b02093": "Bitbucket credentials are configured but could not authenticate. Check the token and repository permissions, then restart Orca if environment variables changed.",
"e63fe8f627": "ORCA_BITBUCKET_ACCESS_TOKEN",
"19416c874c": "ORCA_BITBUCKET_API_TOKEN",
"fc71a0e7aa": "and",
"63a7f47392": "ORCA_BITBUCKET_EMAIL",
"24ac1c69dc": "Bitbucket status is not available in this runtime yet.",
"a924e8dcd1": "Pull requests and build statuses via Bitbucket Cloud API tokens.",
"0fa5629dad": "Pull requests and build statuses"
}
}
}
}
}
},
"right": {
@@ -8940,6 +9047,30 @@
"b7345c18db": "예기치 않은 오류가 발생했습니다.",
"5f97caf76b": "설치 중…",
"c2df599513": "CLI 및 스킬 설치"
},
"ConnectIntegrationsList": {
"3dddb2d565": "connected for tasks",
"33b650af52": "Connect where your team tracks work. Orca starts workspaces with the issue title, link, and context already attached.",
"5b3577a492": "connected for review status",
"3a1fcdddad": "Two quick steps: connect where your code is reviewed, then where your team plans work.",
"list_end": ", and ",
"list_pair": " and ",
"list_mid": ", ",
"code_host_tasks_summary": "issues available as tasks · add Linear or Jira if your team plans work there",
"code_host_tasks_caption": "Your code host's issues also work as tasks.",
"review_step_title": "See PR status while agents work",
"review_step_description": "Connect a review provider so Orca can show PR or MR status, checks, and reviews.",
"task_step_title": "Start agents on your tasks without leaving Orca"
},
"connect": {
"integration": {
"step": {
"0f47ff17c6": "Change",
"5538eb6743": "Done",
"open_step": "Open",
"close_step": "Close"
}
}
}
},
"tips": {
@@ -10144,6 +10275,25 @@
"8490e5d36a": "확인",
"56f5c60e0c": "취소"
}
},
"jira": {
"connect": {
"dialog": {
"63ce735809": "Connect",
"4a2ab52781": "Verifying…",
"79e7aaed39": "Cancel",
"fdd26d81cc": "Atlassian account settings",
"8090504a3e": "Create a token in",
"7b3967c12f": "Atlassian API token",
"3d81bf3ab3": "API token",
"e91b9a4073": "you@example.com",
"2849ddb295": "Atlassian email",
"70fcd360c4": "https://example.atlassian.net",
"e176f9d0c5": "Jira Cloud site URL",
"d785c42b8b": "Use a Jira Cloud site URL, Atlassian email, and API token to browse issues.",
"8388bdea2b": "Connect Jira site"
}
}
}
},
"i18n": {
+151 -1
View File
@@ -232,6 +232,12 @@
"ui": {
"66e3bd7ce6": "发送至 {{value0}}",
"53883b7bc3": "无法发送至 {{value0}}"
},
"jira": {
"856083302c": "Jira connection was superseded by a newer request."
},
"linear": {
"37d36984d0": "Linear connection was superseded by a newer request."
}
}
},
@@ -4601,7 +4607,11 @@
"9707523939": "拉取请求和构建状态",
"a565377c38": "未安装",
"15cf990798": "未经过验证",
"f7eb5f0b24": "未安装"
"f7eb5f0b24": "未安装",
"3ba07f933b": "Connect issue trackers Orca can use to browse tasks and start workspaces with linked context.",
"70e885705b": "Task providers",
"1683acbac4": "Connect the source hosts Orca can use for pull requests, merge requests, checks, and review status.",
"298c65ecac": "Review providers"
},
"KagiSessionLinkForm": {
"92f0b4e472": "清除",
@@ -7382,6 +7392,103 @@
},
"YamlThemeImportButton": {
"label": "Import from YAML"
},
"cli": {
"source": {
"control": {
"integration": {
"cards": {
"d5b3be8ecd": "Re-check",
"8cbc39f862": "Learn more",
"707180d09c": "glab auth login",
"4be0616873": "The GitLab CLI is installed but not authenticated. Run this command in a terminal:",
"54a640af7a": "Install GitLab CLI",
"b56fd5676a": "Install the GitLab CLI to enable merge requests, issues, and pipelines.",
"faddeb763d": "GitLab CLI status is not available in this runtime yet.",
"a47f71e357": "CLI.",
"2a6b359e75": "glab",
"1f2b347bd3": "Merge requests, issues, todos, and pipelines via the",
"8d90249d22": "gh auth login",
"2e44dda68a": "The GitHub CLI is installed but not authenticated. Run this command in a terminal:",
"7755c28af5": "Install GitHub CLI",
"23cb5a0dee": "Install the GitHub CLI to enable pull requests, issues, and checks.",
"6f30fc4216": "GitHub CLI status is not available in this runtime yet.",
"6b2cfb52b4": "gh",
"b4d900e7f1": "Pull requests, issues, and checks via the"
}
}
}
}
},
"task": {
"tracker": {
"integration": {
"cards": {
"c90f2ef419": "Re-check",
"dd3529015d": "Disconnect {{value0}}",
"8b2408a8e5": "Jira is connected for this runtime. Re-check if the connected site list looks stale.",
"8c20e76308": "Each connected Jira site has one token stored by the active runtime.",
"c24e56c532": "Test",
"3e7c10d286": "Testing...",
"a2c0015fb8": "Verified",
"e2ff968276": "Connect Jira",
"60996beda6": "Add Jira site",
"7ca5ffffdb": "Browse, create, and start work from Jira Cloud issues.",
"a1093a06c7": "Checking Jira access before showing setup actions.",
"9fa04a032e": "{{value0}} site{{value1}} connected",
"cef18762a2": "Add access with a Personal API key from your Linear settings. Full-access keys can see every team the key owner can reach.",
"6224fe9d34": "Each connected Linear workspace has one key stored by the active runtime. Full-access keys can cover all teams the key owner can access; restricted keys can be replaced any time.",
"1a12e33fe5": "Add Linear access",
"622c224082": "Add workspace access",
"eae4a9f16b": "Add Linear access to browse and link issues.",
"fe9231215b": "Checking Linear access before showing setup actions.",
"e1f5e6424c": "{{value0}} workspace{{value1}} connected",
"disconnect_all": "Disconnect"
}
}
}
},
"token": {
"source": {
"control": {
"integration": {
"cards": {
"793a06e899": "Re-check",
"1a9475dace": "Learn more",
"19fb419c12": "Gitea credentials are configured but could not authenticate. Check the token, API base URL, and repository permissions, then restart Orca if environment variables changed.",
"60708f23da": "only when Orca cannot derive the API URL from the remote.",
"709057ad91": "ORCA_GITEA_API_BASE_URL",
"6da9dfa5de": "for private repositories, and set",
"6d5c2a3005": "ORCA_GITEA_TOKEN",
"fcbe0469fd": "Public repositories are detected from their git remote. Set",
"0613928cb3": "Gitea status is not available in this runtime yet.",
"05863d2599": "Pull requests and commit statuses via the Gitea REST API.",
"52f75876be": "Pull requests and commit statuses for detected repositories",
"0b5242f8a2": "{{value0}} · Pull requests and commit statuses",
"40f678df73": "Azure DevOps credentials are configured but could not authenticate. Check the token, API base URL, and repository permissions, then restart Orca if environment variables changed.",
"7bd345e3f6": "only when Orca cannot derive the API base URL from the git remote.",
"186a6689df": "ORCA_AZURE_DEVOPS_API_BASE_URL",
"b8a10b07c1": ". Set",
"fbfd237f5e": "ORCA_AZURE_DEVOPS_ACCESS_TOKEN",
"087feb92f1": ", or set",
"48842720d2": "ORCA_AZURE_DEVOPS_TOKEN",
"7bbc9c64f0": "Set",
"f3f47dc7de": "Azure DevOps status is not available in this runtime yet.",
"0eb50d5593": "Pull requests and build statuses via Azure DevOps REST API tokens.",
"54636c65d4": "Pull requests and build statuses for detected Azure Repos",
"ea204f5e03": "{{value0}} · Pull requests and build statuses",
"6154b02093": "Bitbucket credentials are configured but could not authenticate. Check the token and repository permissions, then restart Orca if environment variables changed.",
"e63fe8f627": "ORCA_BITBUCKET_ACCESS_TOKEN",
"19416c874c": "ORCA_BITBUCKET_API_TOKEN",
"fc71a0e7aa": "and",
"63a7f47392": "ORCA_BITBUCKET_EMAIL",
"24ac1c69dc": "Bitbucket status is not available in this runtime yet.",
"a924e8dcd1": "Pull requests and build statuses via Bitbucket Cloud API tokens.",
"0fa5629dad": "Pull requests and build statuses"
}
}
}
}
}
},
"right": {
@@ -8940,6 +9047,30 @@
"b7345c18db": "发生了意外错误。",
"5f97caf76b": "正在安装…",
"c2df599513": "安装 CLI 和技能"
},
"ConnectIntegrationsList": {
"3dddb2d565": "connected for tasks",
"33b650af52": "Connect where your team tracks work. Orca starts workspaces with the issue title, link, and context already attached.",
"5b3577a492": "connected for review status",
"3a1fcdddad": "Two quick steps: connect where your code is reviewed, then where your team plans work.",
"list_end": ", and ",
"list_pair": " and ",
"list_mid": ", ",
"code_host_tasks_summary": "issues available as tasks · add Linear or Jira if your team plans work there",
"code_host_tasks_caption": "Your code host's issues also work as tasks.",
"review_step_title": "See PR status while agents work",
"review_step_description": "Connect a review provider so Orca can show PR or MR status, checks, and reviews.",
"task_step_title": "Start agents on your tasks without leaving Orca"
},
"connect": {
"integration": {
"step": {
"0f47ff17c6": "Change",
"5538eb6743": "Done",
"open_step": "Open",
"close_step": "Close"
}
}
}
},
"tips": {
@@ -10144,6 +10275,25 @@
"8490e5d36a": "确认",
"56f5c60e0c": "取消"
}
},
"jira": {
"connect": {
"dialog": {
"63ce735809": "Connect",
"4a2ab52781": "Verifying…",
"79e7aaed39": "Cancel",
"fdd26d81cc": "Atlassian account settings",
"8090504a3e": "Create a token in",
"7b3967c12f": "Atlassian API token",
"3d81bf3ab3": "API token",
"e91b9a4073": "you@example.com",
"2849ddb295": "Atlassian email",
"70fcd360c4": "https://example.atlassian.net",
"e176f9d0c5": "Jira Cloud site URL",
"d785c42b8b": "Use a Jira Cloud site URL, Atlassian email, and API token to browse issues.",
"8388bdea2b": "Connect Jira site"
}
}
}
},
"i18n": {
@@ -88,6 +88,21 @@ describe('local preflight context', () => {
expect(localPreflightContextKey(getLocalPreflightContext(state))).toBe('host')
})
it('keys preflight by active runtime before local WSL or host context', () => {
const state = {
...makeState({
repoPath: String.raw`\\wsl.localhost\Ubuntu\home\alice\repo`
}),
settings: {
activeRuntimeEnvironmentId: 'runtime-1'
}
} as AppState
const context = getLocalPreflightContext(state)
expect(context?.runtimeContextKey).toMatch(/^runtime:runtime-1#\d+$/)
expect(localPreflightContextKey(context)).toBe(context?.runtimeContextKey)
})
it('uses the selected WSL distro for local agent checks when WSL is the default shell', () => {
const state = {
...makeState({ repoPath: 'C:\\Users\\alice\\repo' }),
@@ -1,7 +1,10 @@
import type { AppState } from '@/store/types'
import { parseWslUncPath } from '../../../shared/wsl-paths'
import { getProviderRuntimeContextKey } from './provider-runtime-context'
export type LocalPreflightContext = { wslDistro?: string | null; wslDefault?: boolean } | undefined
export type LocalPreflightContext =
| { wslDistro?: string | null; wslDefault?: boolean; runtimeContextKey?: string }
| undefined
const wslPreflightContextsByDistro = new Map<string, NonNullable<LocalPreflightContext>>()
const wslDefaultPreflightContext = Object.freeze({ wslDefault: true })
@@ -24,6 +27,9 @@ function getWslPreflightContext(wslDistro: string): NonNullable<LocalPreflightCo
}
export function getLocalPreflightContext(state: AppState): LocalPreflightContext {
if (state.settings?.activeRuntimeEnvironmentId?.trim()) {
return { runtimeContextKey: getProviderRuntimeContextKey(state.settings) }
}
const wslDistro = getLocalPreflightWslDistro(state)
return wslDistro ? getWslPreflightContext(wslDistro) : undefined
}
@@ -69,6 +75,9 @@ function getLocalPreflightWslDistro(state: AppState): string | null {
}
export function localPreflightContextKey(context: LocalPreflightContext): string {
if (context?.runtimeContextKey) {
return context.runtimeContextKey
}
if (context?.wslDistro) {
return `wsl:${context.wslDistro}`
}
@@ -0,0 +1,22 @@
import type { GlobalSettings } from '../../../shared/types'
export function getProviderRuntimeContextKey(
settings: Pick<GlobalSettings, 'activeRuntimeEnvironmentId'> | null | undefined
): string {
const environmentId = settings?.activeRuntimeEnvironmentId?.trim()
const baseKey = environmentId ? `runtime:${environmentId}` : 'local'
return `${baseKey}#${providerRuntimeSessionGeneration}`
}
export function hasRemoteProviderRuntime(
settings: Pick<GlobalSettings, 'activeRuntimeEnvironmentId'> | null | undefined
): boolean {
return Boolean(settings?.activeRuntimeEnvironmentId?.trim())
}
let providerRuntimeSessionGeneration = 0
export function bumpProviderRuntimeSessionGeneration(): number {
providerRuntimeSessionGeneration += 1
return providerRuntimeSessionGeneration
}
+123 -6
View File
@@ -1,24 +1,36 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { create } from 'zustand'
import type { AppState } from '../types'
import type { JiraIssue } from '../../../../shared/types'
import type { JiraConnectionStatus, JiraIssue, JiraViewer } from '../../../../shared/types'
import { credentialDecryptionMessage } from '../../../../shared/integration-credential-errors'
import { createJiraSlice } from './jira'
const jiraStatus = vi.fn()
const jiraConnect = vi.fn()
const jiraDisconnect = vi.fn()
const jiraGetIssue = vi.fn()
const jiraListIssues = vi.fn()
const jiraSearchIssues = vi.fn()
const jiraStatus = vi.fn()
const jiraSelectSite = vi.fn()
const jiraTestConnection = vi.fn()
vi.mock('@/runtime/runtime-jira-client', () => ({
jiraConnect: vi.fn(),
jiraDisconnect: vi.fn(),
jiraAddIssueComment: vi.fn(),
jiraConnect: (...args: unknown[]) => jiraConnect(...args),
jiraCreateIssue: vi.fn(),
jiraDisconnect: (...args: unknown[]) => jiraDisconnect(...args),
jiraGetIssue: (...args: unknown[]) => jiraGetIssue(...args),
jiraIssueComments: vi.fn(),
jiraListCreateFields: vi.fn(),
jiraListIssueTypes: vi.fn(),
jiraListIssues: (...args: unknown[]) => jiraListIssues(...args),
jiraListPriorities: vi.fn(),
jiraListProjects: vi.fn(),
jiraSearchIssues: (...args: unknown[]) => jiraSearchIssues(...args),
jiraSelectSite: vi.fn(),
jiraSelectSite: (...args: unknown[]) => jiraSelectSite(...args),
jiraStatus: (...args: unknown[]) => jiraStatus(...args),
jiraTestConnection: vi.fn()
jiraTestConnection: (...args: unknown[]) => jiraTestConnection(...args),
jiraUpdateIssue: vi.fn()
}))
function createTestStore() {
@@ -31,6 +43,18 @@ function createTestStore() {
)
}
function deferred<T>() {
let resolve!: (value: T) => void
const promise = new Promise<T>((res) => {
resolve = res
})
return { promise, resolve }
}
function status(email: string): JiraConnectionStatus {
return { connected: true, viewer: { email } as JiraViewer }
}
function issue(key: string): JiraIssue {
return {
id: key,
@@ -48,6 +72,99 @@ function issue(key: string): JiraIssue {
}
}
describe('createJiraSlice runtime context', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('ignores stale status responses after the active runtime changes', async () => {
const store = createTestStore()
const localStatus = deferred<JiraConnectionStatus>()
const remoteStatus = deferred<JiraConnectionStatus>()
jiraStatus.mockReturnValueOnce(localStatus.promise).mockReturnValueOnce(remoteStatus.promise)
const localRequest = store.getState().checkJiraConnection()
store.setState({ settings: { activeRuntimeEnvironmentId: 'runtime-1' } as never })
const remoteRequest = store.getState().checkJiraConnection()
remoteStatus.resolve(status('remote@example.com'))
await remoteRequest
expect(store.getState().jiraStatus.viewer?.email).toBe('remote@example.com')
expect(store.getState().jiraStatusContextKey).toBe('runtime:runtime-1#0')
localStatus.resolve(status('local@example.com'))
await localRequest
expect(store.getState().jiraStatus.viewer?.email).toBe('remote@example.com')
expect(store.getState().jiraStatusContextKey).toBe('runtime:runtime-1#0')
})
it('ignores stale issue cache writes after the active runtime changes', async () => {
const store = createTestStore()
const localIssue = deferred<JiraIssue | null>()
const remoteIssue = deferred<JiraIssue | null>()
jiraGetIssue.mockReturnValueOnce(localIssue.promise).mockReturnValueOnce(remoteIssue.promise)
const localRequest = store.getState().fetchJiraIssue('ORC-1')
store.setState({ settings: { activeRuntimeEnvironmentId: 'runtime-1' } as never })
const remoteRequest = store.getState().fetchJiraIssue('ORC-1')
remoteIssue.resolve({ ...issue('ORC-1'), title: 'Remote issue' })
await remoteRequest
expect(store.getState().jiraIssueCache['selected::ORC-1']?.data?.title).toBe('Remote issue')
localIssue.resolve({ ...issue('ORC-1'), title: 'Local issue' })
await localRequest
expect(store.getState().jiraIssueCache['selected::ORC-1']?.data?.title).toBe('Remote issue')
})
it('returns a failed Jira connect result when the active runtime changes before completion', async () => {
const store = createTestStore()
const connectResult = deferred<{ ok: true; viewer: JiraViewer }>()
jiraConnect.mockReturnValueOnce(connectResult.promise)
const request = store.getState().connectJira({
siteUrl: 'https://example.atlassian.net',
email: 'local@example.com',
apiToken: 'token'
})
store.setState({ settings: { activeRuntimeEnvironmentId: 'runtime-1' } as never })
connectResult.resolve({ ok: true, viewer: { email: 'local@example.com' } as JiraViewer })
await expect(request).resolves.toEqual({
ok: false,
error: 'Jira connection was superseded by a newer request.'
})
expect(store.getState().jiraStatus.connected).toBe(false)
expect(store.getState().jiraStatusContextKey).toBeNull()
})
it('does not run a stale test follow-up status check after the active runtime changes', async () => {
const store = createTestStore()
const testResult = deferred<{ ok: true; viewer: JiraViewer }>()
jiraTestConnection.mockReturnValueOnce(testResult.promise)
const request = store.getState().testJiraConnection()
store.setState({ settings: { activeRuntimeEnvironmentId: 'runtime-1' } as never })
testResult.resolve({ ok: true, viewer: { email: 'local@example.com' } as JiraViewer })
await request
expect(jiraStatus).not.toHaveBeenCalled()
})
it('does not clear or refresh stale disconnect results after the active runtime changes', async () => {
const store = createTestStore()
const disconnectResult = deferred<void>()
jiraDisconnect.mockReturnValueOnce(disconnectResult.promise)
const request = store.getState().disconnectJira()
store.setState({ settings: { activeRuntimeEnvironmentId: 'runtime-1' } as never })
disconnectResult.resolve()
await request
expect(jiraStatus).not.toHaveBeenCalled()
})
})
describe('createJiraSlice credential errors', () => {
beforeEach(() => {
vi.clearAllMocks()
+230 -48
View File
@@ -22,6 +22,8 @@ import {
jiraStatus,
jiraTestConnection
} from '@/runtime/runtime-jira-client'
import { getProviderRuntimeContextKey } from '@/lib/provider-runtime-context'
import { translate } from '@/i18n/i18n'
const CACHE_TTL = 60_000
const MAX_CACHE_ENTRIES = 500
@@ -51,9 +53,17 @@ function looksLikeAuthError(error: unknown): boolean {
return /authenticat|unauthorized|forbidden|401|403/i.test(msg)
}
const inflightIssueRequests = new Map<string, Promise<JiraIssue | null>>()
const inflightSearchRequests = new Map<string, Promise<JiraIssue[]>>()
const inflightListRequests = new Map<string, Promise<JiraIssue[]>>()
type InflightJiraReadRequest<T> = {
promise: Promise<T>
contextKey: string
mutationGeneration: number
}
const inflightIssueRequests = new Map<string, InflightJiraReadRequest<JiraIssue | null>>()
const inflightSearchRequests = new Map<string, InflightJiraReadRequest<JiraIssue[]>>()
const inflightListRequests = new Map<string, InflightJiraReadRequest<JiraIssue[]>>()
let jiraStatusReadGeneration = 0
let jiraMutationGeneration = 0
function getSelectedSiteId(status: JiraConnectionStatus): JiraSiteSelection | null {
return status.selectedSiteId ?? status.activeSiteId ?? null
@@ -65,9 +75,34 @@ function clearJiraInflight(): void {
inflightListRequests.clear()
}
function beginJiraMutation(): number {
jiraMutationGeneration += 1
return jiraMutationGeneration
}
function isCurrentJiraMutation(generation: number): boolean {
return generation === jiraMutationGeneration
}
function isCurrentJiraRuntimeContext(contextKey: string, settings: AppState['settings']): boolean {
return getProviderRuntimeContextKey(settings) === contextKey
}
function canWriteJiraReadResult(
contextKey: string,
mutationGeneration: number,
settings: AppState['settings']
): boolean {
return (
mutationGeneration === jiraMutationGeneration &&
isCurrentJiraRuntimeContext(contextKey, settings)
)
}
export type JiraSlice = {
jiraStatus: JiraConnectionStatus
jiraStatusChecked: boolean
jiraStatusContextKey: string | null
jiraIssueCache: Record<string, CacheEntry<JiraIssue>>
jiraSearchCache: Record<string, CacheEntry<JiraIssue[]>>
@@ -91,12 +126,26 @@ export type JiraSlice = {
export const createJiraSlice: StateCreator<AppState, [], [], JiraSlice> = (set, get) => ({
jiraStatus: { connected: false, viewer: null },
jiraStatusChecked: false,
jiraStatusContextKey: null,
jiraIssueCache: {},
jiraSearchCache: {},
checkJiraConnection: async () => {
const contextKey = getProviderRuntimeContextKey(get().settings)
const statusReadGeneration = (jiraStatusReadGeneration += 1)
const mutationGeneration = jiraMutationGeneration
if (get().jiraStatusContextKey !== contextKey) {
set({ jiraStatusChecked: false })
}
try {
const status = await jiraStatus(get().settings)
if (
mutationGeneration !== jiraMutationGeneration ||
statusReadGeneration !== jiraStatusReadGeneration ||
getProviderRuntimeContextKey(get().settings) !== contextKey
) {
return
}
const prev = get().jiraStatus
if (
prev.connected !== status.connected ||
@@ -105,25 +154,58 @@ export const createJiraSlice: StateCreator<AppState, [], [], JiraSlice> = (set,
getSelectedSiteId(prev) !== getSelectedSiteId(status) ||
(prev.sites?.length ?? 0) !== (status.sites?.length ?? 0)
) {
set({ jiraStatus: status, jiraStatusChecked: true })
set({ jiraStatus: status, jiraStatusChecked: true, jiraStatusContextKey: contextKey })
} else if (!get().jiraStatusChecked) {
set({ jiraStatusChecked: true })
set({ jiraStatusChecked: true, jiraStatusContextKey: contextKey })
} else if (get().jiraStatusContextKey !== contextKey) {
set({ jiraStatusContextKey: contextKey })
}
} catch {
if (
mutationGeneration !== jiraMutationGeneration ||
statusReadGeneration !== jiraStatusReadGeneration ||
getProviderRuntimeContextKey(get().settings) !== contextKey
) {
return
}
if (get().jiraStatus.connected) {
set({ jiraStatus: { connected: false, viewer: null }, jiraStatusChecked: true })
set({
jiraStatus: { connected: false, viewer: null },
jiraStatusChecked: true,
jiraStatusContextKey: contextKey
})
} else if (!get().jiraStatusChecked) {
set({ jiraStatusChecked: true })
set({ jiraStatusChecked: true, jiraStatusContextKey: contextKey })
} else if (get().jiraStatusContextKey !== contextKey) {
set({ jiraStatusContextKey: contextKey })
}
}
},
connectJira: async (args) => {
const requestGeneration = beginJiraMutation()
const contextKey = getProviderRuntimeContextKey(get().settings)
try {
const result = await jiraConnect(get().settings, args)
if (result.ok) {
set({ jiraStatus: { connected: true, viewer: result.viewer }, jiraStatusChecked: true })
if (
result.ok &&
isCurrentJiraMutation(requestGeneration) &&
isCurrentJiraRuntimeContext(contextKey, get().settings)
) {
set({
jiraStatus: { connected: true, viewer: result.viewer },
jiraStatusChecked: true,
jiraStatusContextKey: contextKey
})
void get().checkJiraConnection()
} else if (result.ok) {
return {
ok: false as const,
error: translate(
'auto.store.slices.jira.856083302c',
'Jira connection was superseded by a newer request.'
)
}
}
return result
} catch (error) {
@@ -133,10 +215,23 @@ export const createJiraSlice: StateCreator<AppState, [], [], JiraSlice> = (set,
},
testJiraConnection: async (siteId) => {
const requestGeneration = beginJiraMutation()
const contextKey = getProviderRuntimeContextKey(get().settings)
try {
const result = await jiraTestConnection(get().settings, siteId)
if (
!isCurrentJiraMutation(requestGeneration) ||
!isCurrentJiraRuntimeContext(contextKey, get().settings)
) {
return result
}
const status = await jiraStatus(get().settings)
set({ jiraStatus: status, jiraStatusChecked: true })
if (
isCurrentJiraMutation(requestGeneration) &&
isCurrentJiraRuntimeContext(contextKey, get().settings)
) {
set({ jiraStatus: status, jiraStatusChecked: true, jiraStatusContextKey: contextKey })
}
return result
} catch (error) {
const message = error instanceof Error ? error.message : 'Test failed'
@@ -145,65 +240,111 @@ export const createJiraSlice: StateCreator<AppState, [], [], JiraSlice> = (set,
},
selectJiraSite: async (siteId) => {
const requestGeneration = beginJiraMutation()
const contextKey = getProviderRuntimeContextKey(get().settings)
const status = await jiraSelectSite(get().settings, siteId)
if (
!isCurrentJiraMutation(requestGeneration) ||
getProviderRuntimeContextKey(get().settings) !== contextKey
) {
return
}
clearJiraInflight()
set({
jiraStatus: status,
jiraIssueCache: {},
jiraSearchCache: {},
jiraStatusChecked: true
jiraStatusChecked: true,
jiraStatusContextKey: contextKey
})
},
disconnectJira: async (siteId) => {
const requestGeneration = beginJiraMutation()
const contextKey = getProviderRuntimeContextKey(get().settings)
await jiraDisconnect(get().settings, siteId)
if (
!isCurrentJiraMutation(requestGeneration) ||
!isCurrentJiraRuntimeContext(contextKey, get().settings)
) {
return
}
clearJiraInflight()
const status = await jiraStatus(get().settings)
if (
!isCurrentJiraMutation(requestGeneration) ||
!isCurrentJiraRuntimeContext(contextKey, get().settings)
) {
return
}
set({
jiraStatus: status.connected ? status : { connected: false, viewer: null },
jiraIssueCache: {},
jiraSearchCache: {},
jiraStatusChecked: true
jiraStatusChecked: true,
jiraStatusContextKey: contextKey
})
},
fetchJiraIssue: async (key, siteId) => {
const contextKey = getProviderRuntimeContextKey(get().settings)
const issueCacheKey = `${siteId ?? 'selected'}::${key}`
const cached = get().jiraIssueCache[issueCacheKey] ?? get().jiraIssueCache[key]
if (isFresh(cached)) {
return cached.data
}
const inflight = inflightIssueRequests.get(issueCacheKey)
if (inflight) {
return inflight
if (
inflight &&
inflight.contextKey === contextKey &&
inflight.mutationGeneration === jiraMutationGeneration
) {
return inflight.promise
}
let entry: InflightJiraReadRequest<JiraIssue | null>
const requestMutationGeneration = jiraMutationGeneration
const promise = jiraGetIssue(get().settings, key, siteId)
.then((issue) => {
set((s) => ({
jiraIssueCache: evictStaleEntries({
...s.jiraIssueCache,
[issueCacheKey]: { data: issue, fetchedAt: Date.now() }
})
}))
if (
inflightIssueRequests.get(issueCacheKey) === entry &&
canWriteJiraReadResult(contextKey, requestMutationGeneration, get().settings)
) {
set((s) => ({
jiraIssueCache: evictStaleEntries({
...s.jiraIssueCache,
[issueCacheKey]: { data: issue, fetchedAt: Date.now() }
})
}))
}
return issue
})
.catch((error) => {
console.warn('[jira] fetchJiraIssue failed:', error)
if (isIntegrationCredentialDecryptionError(error)) {
if (
isIntegrationCredentialDecryptionError(error) &&
canWriteJiraReadResult(contextKey, requestMutationGeneration, get().settings)
) {
void get().checkJiraConnection()
} else if (looksLikeAuthError(error)) {
} else if (
looksLikeAuthError(error) &&
canWriteJiraReadResult(contextKey, requestMutationGeneration, get().settings)
) {
set({ jiraStatus: { connected: false, viewer: null } })
}
return null
})
.finally(() => {
inflightIssueRequests.delete(issueCacheKey)
if (inflightIssueRequests.get(issueCacheKey) === entry) {
inflightIssueRequests.delete(issueCacheKey)
}
})
inflightIssueRequests.set(issueCacheKey, promise)
entry = { promise, contextKey, mutationGeneration: requestMutationGeneration }
inflightIssueRequests.set(issueCacheKey, entry)
return promise
},
searchJiraIssues: async (jql, limit = 30) => {
const contextKey = getProviderRuntimeContextKey(get().settings)
const siteId = getSelectedSiteId(get().jiraStatus)
const cacheKey = `${siteId ?? 'default'}::${jql}::${limit}`
const cached = get().jiraSearchCache[cacheKey]
@@ -211,36 +352,57 @@ export const createJiraSlice: StateCreator<AppState, [], [], JiraSlice> = (set,
return cached.data ?? []
}
const inflight = inflightSearchRequests.get(cacheKey)
if (inflight) {
return inflight
if (
inflight &&
inflight.contextKey === contextKey &&
inflight.mutationGeneration === jiraMutationGeneration
) {
return inflight.promise
}
let entry: InflightJiraReadRequest<JiraIssue[]>
const requestMutationGeneration = jiraMutationGeneration
const promise = jiraSearchIssues(get().settings, jql, limit, siteId)
.then((issues) => {
set((s) => ({
jiraSearchCache: evictStaleEntries({
...s.jiraSearchCache,
[cacheKey]: { data: issues, fetchedAt: Date.now() }
})
}))
if (
inflightSearchRequests.get(cacheKey) === entry &&
canWriteJiraReadResult(contextKey, requestMutationGeneration, get().settings)
) {
set((s) => ({
jiraSearchCache: evictStaleEntries({
...s.jiraSearchCache,
[cacheKey]: { data: issues, fetchedAt: Date.now() }
})
}))
}
return issues
})
.catch((error) => {
console.warn('[jira] searchJiraIssues failed:', error)
if (isIntegrationCredentialDecryptionError(error)) {
if (
isIntegrationCredentialDecryptionError(error) &&
canWriteJiraReadResult(contextKey, requestMutationGeneration, get().settings)
) {
void get().checkJiraConnection()
} else if (looksLikeAuthError(error)) {
} else if (
looksLikeAuthError(error) &&
canWriteJiraReadResult(contextKey, requestMutationGeneration, get().settings)
) {
set({ jiraStatus: { connected: false, viewer: null } })
}
return []
})
.finally(() => {
inflightSearchRequests.delete(cacheKey)
if (inflightSearchRequests.get(cacheKey) === entry) {
inflightSearchRequests.delete(cacheKey)
}
})
inflightSearchRequests.set(cacheKey, promise)
entry = { promise, contextKey, mutationGeneration: requestMutationGeneration }
inflightSearchRequests.set(cacheKey, entry)
return promise
},
listJiraIssues: async (filter = 'assigned', limit = 30) => {
const contextKey = getProviderRuntimeContextKey(get().settings)
const siteId = getSelectedSiteId(get().jiraStatus)
const cacheKey = `${siteId ?? 'default'}::list::${filter}::${limit}`
const cached = get().jiraSearchCache[cacheKey]
@@ -248,32 +410,52 @@ export const createJiraSlice: StateCreator<AppState, [], [], JiraSlice> = (set,
return cached.data ?? []
}
const inflight = inflightListRequests.get(cacheKey)
if (inflight) {
return inflight
if (
inflight &&
inflight.contextKey === contextKey &&
inflight.mutationGeneration === jiraMutationGeneration
) {
return inflight.promise
}
let entry: InflightJiraReadRequest<JiraIssue[]>
const requestMutationGeneration = jiraMutationGeneration
const promise = jiraListIssues(get().settings, filter, limit, siteId)
.then((issues) => {
set((s) => ({
jiraSearchCache: evictStaleEntries({
...s.jiraSearchCache,
[cacheKey]: { data: issues, fetchedAt: Date.now() }
})
}))
if (
inflightListRequests.get(cacheKey) === entry &&
canWriteJiraReadResult(contextKey, requestMutationGeneration, get().settings)
) {
set((s) => ({
jiraSearchCache: evictStaleEntries({
...s.jiraSearchCache,
[cacheKey]: { data: issues, fetchedAt: Date.now() }
})
}))
}
return issues
})
.catch((error) => {
console.warn('[jira] listJiraIssues failed:', error)
if (isIntegrationCredentialDecryptionError(error)) {
if (
isIntegrationCredentialDecryptionError(error) &&
canWriteJiraReadResult(contextKey, requestMutationGeneration, get().settings)
) {
void get().checkJiraConnection()
} else if (looksLikeAuthError(error)) {
} else if (
looksLikeAuthError(error) &&
canWriteJiraReadResult(contextKey, requestMutationGeneration, get().settings)
) {
set({ jiraStatus: { connected: false, viewer: null } })
}
return []
})
.finally(() => {
inflightListRequests.delete(cacheKey)
if (inflightListRequests.get(cacheKey) === entry) {
inflightListRequests.delete(cacheKey)
}
})
inflightListRequests.set(cacheKey, promise)
entry = { promise, contextKey, mutationGeneration: requestMutationGeneration }
inflightListRequests.set(cacheKey, entry)
return promise
},
@@ -757,6 +757,61 @@ describe('createLinearSlice', () => {
expect(store.getState().linearStatus.viewer?.email).toBe('test@example.com')
})
it('ignores stale status responses after the active runtime changes', async () => {
const localStatus = deferred<LinearConnectionStatus>()
const remoteStatus = deferred<LinearConnectionStatus>()
const localViewer = {
displayName: 'Local User',
email: 'local@example.com',
organizationName: 'Local Org'
}
const remoteViewer = {
displayName: 'Remote User',
email: 'remote@example.com',
organizationName: 'Remote Org'
}
linearStatus.mockReturnValueOnce(localStatus.promise).mockReturnValueOnce(remoteStatus.promise)
const store = createTestStore()
const localRequest = store.getState().checkLinearConnection()
store.setState({ settings: { activeRuntimeEnvironmentId: 'runtime-1' } as never })
const remoteRequest = store.getState().checkLinearConnection()
remoteStatus.resolve({ connected: true, viewer: remoteViewer })
await remoteRequest
expect(store.getState().linearStatus.viewer?.email).toBe('remote@example.com')
expect(store.getState().linearStatusContextKey).toBe('runtime:runtime-1#0')
localStatus.resolve({ connected: true, viewer: localViewer })
await localRequest
expect(store.getState().linearStatus.viewer?.email).toBe('remote@example.com')
expect(store.getState().linearStatusContextKey).toBe('runtime:runtime-1#0')
})
it('ignores stale list cache writes after the active runtime changes', async () => {
const localList = deferred<LinearCollectionResult<LinearIssue>>()
const remoteList = deferred<LinearCollectionResult<LinearIssue>>()
linearListIssues.mockReturnValueOnce(localList.promise).mockReturnValueOnce(remoteList.promise)
const store = createTestStore()
store.setState({ linearStatus: { connected: true, viewer: null } })
const localRequest = store.getState().listLinearIssues('assigned', 20)
store.setState({ settings: { activeRuntimeEnvironmentId: 'runtime-1' } as never })
const remoteRequest = store.getState().listLinearIssues('assigned', 20)
remoteList.resolve({ items: [issue('LIN-REMOTE')] })
await remoteRequest
expect(
store.getState().getCachedLinearIssues({ kind: 'list', filter: 'assigned', limit: 20 })
).toMatchObject({ items: [{ id: 'LIN-REMOTE' }] })
localList.resolve({ items: [issue('LIN-LOCAL')] })
await localRequest
expect(
store.getState().getCachedLinearIssues({ kind: 'list', filter: 'assigned', limit: 20 })
).toMatchObject({ items: [{ id: 'LIN-REMOTE' }] })
})
it('ignores stale status checks after a successful connect', async () => {
const staleMountCheck = deferred<LinearConnectionStatus>()
const freshConnectCheck = deferred<LinearConnectionStatus>()
@@ -787,6 +842,29 @@ describe('createLinearSlice', () => {
expect(store.getState().linearStatus.viewer?.email).toBe('test@example.com')
})
it('ignores stale connect results after the active runtime changes', async () => {
const connectResult = deferred<{ ok: true; viewer: LinearViewer }>()
const viewer = {
displayName: 'Local User',
email: 'local@example.com',
organizationName: 'Local Org'
}
linearConnect.mockReturnValueOnce(connectResult.promise)
const store = createTestStore()
const connectPromise = store.getState().connectLinear('linear-key')
store.setState({ settings: { activeRuntimeEnvironmentId: 'runtime-1' } as never })
connectResult.resolve({ ok: true, viewer })
await expect(connectPromise).resolves.toEqual({
ok: false,
error: 'Linear connection was superseded by a newer request.'
})
expect(store.getState().linearStatus.connected).toBe(false)
expect(store.getState().linearStatusContextKey).toBeNull()
})
it('does not let a background status refresh cancel an in-flight connect', async () => {
const connectResult = deferred<{ ok: true; viewer: LinearViewer }>()
const backgroundStatus = deferred<LinearConnectionStatus>()
File diff suppressed because it is too large Load Diff
@@ -6,6 +6,17 @@ import type { AppState } from '../types'
import { createPreflightSlice } from './preflight'
const preflightCheck = vi.fn()
const callRuntimeRpc = vi.fn()
vi.mock('@/runtime/runtime-rpc-client', () => ({
callRuntimeRpc: (...args: unknown[]) => callRuntimeRpc(...args),
getActiveRuntimeTarget: (
settings?: { activeRuntimeEnvironmentId?: string | null } | null
): { kind: 'local' } | { kind: 'environment'; environmentId: string } => {
const environmentId = settings?.activeRuntimeEnvironmentId?.trim()
return environmentId ? { kind: 'environment', environmentId } : { kind: 'local' }
}
}))
globalThis.window = {
api: {
@@ -33,6 +44,11 @@ function createTestStore() {
)
}
function resetPreflightMocks(): void {
preflightCheck.mockReset()
callRuntimeRpc.mockReset()
}
function makeStatus(glabInstalled: boolean): PreflightStatus {
return {
git: { installed: true },
@@ -86,7 +102,7 @@ function deferred<T>() {
describe('createPreflightSlice', () => {
it('dedupes concurrent non-forced checks', async () => {
preflightCheck.mockReset()
resetPreflightMocks()
const pending = deferred<PreflightStatus>()
preflightCheck.mockReturnValueOnce(pending.promise)
const store = createTestStore()
@@ -104,7 +120,7 @@ describe('createPreflightSlice', () => {
})
it('lets forced checks bypass non-forced dedupe and win stale races', async () => {
preflightCheck.mockReset()
resetPreflightMocks()
const stale = deferred<PreflightStatus>()
const fresh = deferred<PreflightStatus>()
preflightCheck.mockReturnValueOnce(stale.promise).mockReturnValueOnce(fresh.promise)
@@ -125,7 +141,7 @@ describe('createPreflightSlice', () => {
})
it('dedupes lazy checks onto an in-flight forced refresh', async () => {
preflightCheck.mockReset()
resetPreflightMocks()
const fresh = deferred<PreflightStatus>()
preflightCheck.mockReturnValueOnce(fresh.promise)
const store = createTestStore()
@@ -141,7 +157,7 @@ describe('createPreflightSlice', () => {
})
it('checks integrations inside the active WSL worktree distro', async () => {
preflightCheck.mockReset()
resetPreflightMocks()
preflightCheck.mockResolvedValueOnce(makeStatus(true))
const store = createTestStore()
store.setState({
@@ -170,7 +186,7 @@ describe('createPreflightSlice', () => {
})
it('keeps preflight request dedupe scoped by WSL distro context', async () => {
preflightCheck.mockReset()
resetPreflightMocks()
const ubuntu = deferred<PreflightStatus>()
const debian = deferred<PreflightStatus>()
preflightCheck.mockReturnValueOnce(ubuntu.promise).mockReturnValueOnce(debian.promise)
@@ -196,8 +212,46 @@ describe('createPreflightSlice', () => {
expect(store.getState().preflightStatus?.glab?.installed).toBe(true)
})
it('checks integrations through the active runtime environment', async () => {
resetPreflightMocks()
const firstRuntime = deferred<PreflightStatus>()
const secondRuntime = deferred<PreflightStatus>()
callRuntimeRpc
.mockReturnValueOnce(firstRuntime.promise)
.mockReturnValueOnce(secondRuntime.promise)
const store = createTestStore()
store.setState({
settings: { activeRuntimeEnvironmentId: 'runtime-1' }
} as Partial<AppState>)
const first = store.getState().refreshPreflightStatus()
store.setState({
settings: { activeRuntimeEnvironmentId: 'runtime-2' }
} as Partial<AppState>)
const second = store.getState().refreshPreflightStatus()
expect(preflightCheck).not.toHaveBeenCalled()
expect(callRuntimeRpc).toHaveBeenNthCalledWith(
1,
{ kind: 'environment', environmentId: 'runtime-1' },
'preflight.check',
{}
)
expect(callRuntimeRpc).toHaveBeenNthCalledWith(
2,
{ kind: 'environment', environmentId: 'runtime-2' },
'preflight.check',
{}
)
firstRuntime.resolve(makeStatus(false))
secondRuntime.resolve(makeStatus(true))
await Promise.all([first, second])
expect(store.getState().preflightStatusContextKey).toBe('runtime:runtime-2#0')
expect(store.getState().preflightStatus?.glab?.installed).toBe(true)
})
it('clears checked status immediately when refreshing a different local context', async () => {
preflightCheck.mockReset()
resetPreflightMocks()
const host = deferred<PreflightStatus>()
const wsl = deferred<PreflightStatus>()
preflightCheck.mockReturnValueOnce(host.promise).mockReturnValueOnce(wsl.promise)
+13 -4
View File
@@ -1,6 +1,7 @@
import type { StateCreator } from 'zustand'
import type { PreflightStatus } from '../../../../preload/api-types'
import type { AppState } from '../types'
import { callRuntimeRpc, getActiveRuntimeTarget } from '@/runtime/runtime-rpc-client'
import {
getLocalPreflightContext,
localPreflightContextKey,
@@ -29,12 +30,15 @@ function buildPreflightArgs(
force: boolean,
context: LocalPreflightContext
): { force?: boolean; wslDistro?: string | null; wslDefault?: boolean } | undefined {
if (!force && !context) {
const wslDistro = context?.wslDistro
const wslDefault = context?.wslDefault === true
if (!force && !wslDistro && !wslDefault) {
return undefined
}
return {
...(force ? { force: true } : {}),
...context
...(wslDistro ? { wslDistro } : {}),
...(wslDefault ? { wslDefault: true } : {})
}
}
@@ -61,6 +65,8 @@ export const createPreflightSlice: StateCreator<AppState, [], [], PreflightSlice
const requestId = ++latestPreflightRequestId
const contextChanged = get().preflightStatusContextKey !== contextKey
const runtimeTarget = getActiveRuntimeTarget(get().settings)
const preflightArgs = buildPreflightArgs(force, context)
set({
preflightStatus: contextChanged ? null : get().preflightStatus,
preflightStatusChecked: contextChanged ? false : get().preflightStatusChecked,
@@ -68,8 +74,11 @@ export const createPreflightSlice: StateCreator<AppState, [], [], PreflightSlice
preflightStatusError: null
})
const request = window.api.preflight
.check(buildPreflightArgs(force, context))
const request = (
runtimeTarget.kind === 'environment'
? callRuntimeRpc<PreflightStatus>(runtimeTarget, 'preflight.check', force ? { force } : {})
: window.api.preflight.check(preflightArgs)
)
.then((status) => {
if (requestId !== latestPreflightRequestId) {
return
@@ -15,6 +15,7 @@ import { normalizeTaskProviderSettings } from '../../../../shared/task-providers
import { normalizeOpenInApplications } from '../../../../shared/open-in-applications'
import { createSettingsSearchState, type SettingsSearchState } from './settings-search-state'
import { normalizeDisabledTuiAgents } from '../../../../shared/tui-agent-selection'
import { bumpProviderRuntimeSessionGeneration } from '@/lib/provider-runtime-context'
import { normalizeUiLanguage } from '../../../../shared/ui-language'
import { translate } from '@/i18n/i18n'
@@ -129,6 +130,7 @@ export const createSettingsSlice: StateCreator<AppState, [], [], SettingsSlice>
const nextSettings = await window.api.settings.set({
activeRuntimeEnvironmentId: nextId
})
bumpProviderRuntimeSessionGeneration()
set((s) => ({
// Why: in the multi-host model this is a focus/default-host change,
// not a teardown boundary. Existing host-owned sessions stay alive.