@@ -195,15 +193,14 @@ export function IntegrationsStep(): React.JSX.Element {
-
-
-
-
Jira
-
- Issues, sprints, and assignees.
+
+
+ More task sources
+
+ Linear, GitLab, Bitbucket, Azure DevOps, Gitea, and Jira live in Settings >
+ Integrations.
-
Coming soon
diff --git a/src/renderer/src/components/onboarding/OnboardingFlow.test.tsx b/src/renderer/src/components/onboarding/OnboardingFlow.test.tsx
index 106524714c4..d31f66a9e50 100644
--- a/src/renderer/src/components/onboarding/OnboardingFlow.test.tsx
+++ b/src/renderer/src/components/onboarding/OnboardingFlow.test.tsx
@@ -64,6 +64,60 @@ describe('OnboardingFlow', () => {
expect(html).not.toContain('>Skip')
})
+ it('skips GitHub task setup when the GitHub CLI is already detected', () => {
+ useAppStore.setState({
+ preflightStatus: {
+ git: { installed: true },
+ gh: { installed: true, authenticated: false }
+ },
+ preflightStatusChecked: true
+ })
+
+ const html = renderToStaticMarkup(
+
+ )
+
+ expect(html).toContain('Explore Orca')
+ expect(html).not.toContain('Set up GitHub tasks')
+ expect(html).not.toContain('Connect your task sources')
+ })
+
+ it('shows only GitHub on the task setup page when the GitHub CLI is missing', () => {
+ useAppStore.setState({
+ preflightStatus: {
+ git: { installed: true },
+ gh: { installed: false, authenticated: false }
+ },
+ preflightStatusChecked: true
+ })
+
+ const html = renderToStaticMarkup(
+
+ )
+
+ expect(html).toContain('Set up GitHub tasks')
+ expect(html).toContain('Install the GitHub CLI to:')
+ expect(html).toContain('GitHub')
+ expect(html).not.toContain(
+ '
Linear
'
+ )
+ expect(html).toContain(
+ 'Linear, GitLab, Bitbucket, Azure DevOps, Gitea, and Jira live in Settings'
+ )
+ })
+
it('renders onboarding inside a centered modal shell', () => {
const html = renderToStaticMarkup(
diff --git a/src/renderer/src/components/onboarding/OnboardingFlow.tsx b/src/renderer/src/components/onboarding/OnboardingFlow.tsx
index 2db11c6d20f..06c168bc3cd 100644
--- a/src/renderer/src/components/onboarding/OnboardingFlow.tsx
+++ b/src/renderer/src/components/onboarding/OnboardingFlow.tsx
@@ -11,7 +11,7 @@ import { AgentFeatureSetupStep } from './AgentFeatureSetupStep'
import { IntegrationsStep } from './IntegrationsStep'
import { RepoStep } from './RepoStep'
import { OnboardingTourStep } from './OnboardingTourStep'
-import { STEPS, useOnboardingFlow } from './use-onboarding-flow'
+import { useOnboardingFlow } from './use-onboarding-flow'
import { OnboardingSkipConfirmationDialog } from './OnboardingSkipConfirmationDialog'
import { OnboardingFooter } from './OnboardingFooter'
import { shouldRequestOnboardingSkipConfirmation } from './onboarding-dismiss-target'
@@ -36,8 +36,8 @@ const stepCopy = {
subtitle: 'Turn on advanced Orca capabilities for agents.'
},
integrations: {
- title: 'Connect your task sources',
- subtitle: 'Connect GitHub or Linear to:'
+ title: 'Set up GitHub tasks',
+ subtitle: 'Install the GitHub CLI to:'
},
tour: {
title: 'Explore Orca',
@@ -214,9 +214,9 @@ export default function OnboardingFlow({
) : (
- {STEPS.map((step, idx) => {
- const isActive = idx === stepIndex
- const isDone = idx < stepIndex
+ {flow.visibleSteps.map(({ step, index: realStepIndex }, visibleIdx) => {
+ const isActive = realStepIndex === stepIndex
+ const isDone = realStepIndex < stepIndex
return (
@@ -232,9 +232,9 @@ export default function OnboardingFlow({
? 'w-6 bg-muted-foreground/70 hover:bg-foreground/80'
: 'w-6 bg-muted-foreground/25 hover:bg-muted-foreground/45'
)}
- aria-label={`Go to onboarding step ${step.stepNumber}: ${stepCopy[step.id].title}`}
+ aria-label={`Go to onboarding step ${visibleIdx + 1}: ${stepCopy[step.id].title}`}
aria-current={isActive ? 'step' : undefined}
- onClick={() => flow.jumpToStep(idx)}
+ onClick={() => flow.jumpToStep(realStepIndex)}
/>
@@ -245,7 +245,7 @@ export default function OnboardingFlow({
})}
- {stepIndex + 1} of {STEPS.length}
+ {flow.visibleStepIndex + 1} of {flow.visibleSteps.length}
)}
diff --git a/src/renderer/src/components/onboarding/use-onboarding-flow.ts b/src/renderer/src/components/onboarding/use-onboarding-flow.ts
index aeede39579b..312ef738812 100644
--- a/src/renderer/src/components/onboarding/use-onboarding-flow.ts
+++ b/src/renderer/src/components/onboarding/use-onboarding-flow.ts
@@ -57,6 +57,33 @@ type TaskSourcesGithubStatus = TaskSourcesSnapshotProps['github_status']
type TaskSourcesLinearStatus = TaskSourcesSnapshotProps['linear_status']
type TaskSourcesExitAction = TaskSourcesSnapshotProps['exit_action']
+function shouldSkipIntegrationsStep(
+ status: ReturnType
['preflightStatus']
+): boolean {
+ return status?.gh.installed === true
+}
+
+function isSkippedStepIndex(index: number, skipIntegrations: boolean): boolean {
+ return skipIntegrations && STEPS[index]?.id === 'integrations'
+}
+
+function resolveStepIndex(
+ index: number,
+ skipIntegrations: boolean,
+ direction: 'forward' | 'backward'
+): number {
+ const lastIndex = STEPS.length - 1
+ let nextIndex = Math.min(Math.max(index, 0), lastIndex)
+ while (isSkippedStepIndex(nextIndex, skipIntegrations)) {
+ const candidate = nextIndex + (direction === 'forward' ? 1 : -1)
+ if (candidate < 0 || candidate > lastIndex) {
+ return direction === 'forward' ? lastIndex : 0
+ }
+ nextIndex = candidate
+ }
+ return nextIndex
+}
+
function defaultProjectGroupNameForPath(path: string): string {
return (
path
@@ -112,14 +139,24 @@ export function useOnboardingFlow(
const openSettingsPage = useAppStore((s) => s.openSettingsPage)
const openSettingsTarget = useAppStore((s) => s.openSettingsTarget)
const preflightStatus = useAppStore((s) => s.preflightStatus)
+ const preflightStatusChecked = useAppStore((s) => s.preflightStatusChecked)
const preflightStatusLoading = useAppStore((s) => s.preflightStatusLoading)
+ const refreshPreflightStatus = useAppStore((s) => s.refreshPreflightStatus)
const linearStatus = useAppStore((s) => s.linearStatus)
const linearStatusChecked = useAppStore((s) => s.linearStatusChecked)
// Why: App hydrates repos before mounting onboarding. Reading the store
// synchronously lets the final step render its already-added state without a flash.
const repos = useAppStore((s) => s.repos)
+ // Why: renderToStaticMarkup uses Zustand's initial server snapshot. The
+ // synchronous read keeps tests and the first client render aligned.
+ const effectivePreflightStatus = preflightStatus ?? useAppStore.getState().preflightStatus
- const initialStep = Math.min(Math.max(onboarding.lastCompletedStep, 0), STEPS.length - 1)
+ const skipIntegrations = shouldSkipIntegrationsStep(effectivePreflightStatus)
+ const initialStep = resolveStepIndex(
+ Math.min(Math.max(onboarding.lastCompletedStep, 0), STEPS.length - 1),
+ skipIntegrations,
+ 'forward'
+ )
const [stepIndex, setStepIndex] = useState(initialStep)
const [selectedAgent, setSelectedAgent] = useState(
settings?.defaultTuiAgent && settings.defaultTuiAgent !== 'blank'
@@ -233,6 +270,17 @@ export function useOnboardingFlow(
const detectedSet = useMemo(() => new Set(detectedAgentIds ?? []), [detectedAgentIds])
const currentStep = STEPS[stepIndex]
+ const visibleSteps = useMemo(
+ () =>
+ STEPS.map((step, index) => ({ step, index })).filter(
+ ({ index }) => !isSkippedStepIndex(index, skipIntegrations)
+ ),
+ [skipIntegrations]
+ )
+ const visibleStepIndex = Math.max(
+ 0,
+ visibleSteps.findIndex(({ index }) => index === stepIndex)
+ )
const hasExistingProject = repos.length > 0
// Why: pin start time once so onboarding_completed reports a real funnel duration.
@@ -273,6 +321,43 @@ export function useOnboardingFlow(
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
+ useEffect(() => {
+ void refreshPreflightStatus()
+ }, [refreshPreflightStatus])
+
+ const getNextStepIndex = useCallback(
+ (idx: number): number => resolveStepIndex(idx + 1, skipIntegrations, 'forward'),
+ [skipIntegrations]
+ )
+
+ const getPreviousStepIndex = useCallback(
+ (idx: number): number => resolveStepIndex(idx - 1, skipIntegrations, 'backward'),
+ [skipIntegrations]
+ )
+
+ useEffect(() => {
+ if (currentStep.id !== 'integrations' || !preflightStatusChecked || !skipIntegrations) {
+ return
+ }
+ const nextIndex = getNextStepIndex(stepIndex)
+ setStepIndex(nextIndex)
+ // Why: users with gh already on PATH don't need this setup page, but
+ // persistence must still resume them at the tour instead of bouncing back.
+ void persistStep(currentStep.stepNumber).then(onOnboardingChange, (err) => {
+ toast.error('Could not save progress', {
+ description: err instanceof Error ? err.message : String(err)
+ })
+ })
+ }, [
+ currentStep.id,
+ currentStep.stepNumber,
+ getNextStepIndex,
+ onOnboardingChange,
+ preflightStatusChecked,
+ skipIntegrations,
+ stepIndex
+ ])
+
// Why: ref guard prevents StrictMode's double-invoke from emitting
// `onboarding_started` twice on mount.
const startedTrackedRef = useRef(false)
@@ -520,7 +605,7 @@ export function useOnboardingFlow(
return
}
if (currentStep.id === 'agentSetup' && featureSetupTerminalCommand) {
- setStepIndex((idx) => Math.min(idx + 1, STEPS.length - 1))
+ setStepIndex(getNextStepIndex)
return
}
nextInFlightRef.current = true
@@ -535,7 +620,7 @@ export function useOnboardingFlow(
}
if (result.ok) {
trackCurrentStepCompleted(advancedVia)
- setStepIndex((idx) => Math.min(idx + 1, STEPS.length - 1))
+ setStepIndex(getNextStepIndex)
}
} finally {
nextInFlightRef.current = false
@@ -546,6 +631,7 @@ export function useOnboardingFlow(
currentStep.id,
featureSetupSelection,
featureSetupTerminalCommand,
+ getNextStepIndex,
persistCurrentStep,
trackCurrentStepCompleted
]
@@ -1138,7 +1224,7 @@ export function useOnboardingFlow(
})
setFeatureSetupTerminalCommand(null)
setFeatureSetupTerminalSelection(null)
- setStepIndex((idx) => Math.min(idx + 1, STEPS.length - 1))
+ setStepIndex(getNextStepIndex)
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
setError(message)
@@ -1150,6 +1236,7 @@ export function useOnboardingFlow(
currentStep.id,
currentStep.stepNumber,
currentStep.valueKind,
+ getNextStepIndex,
onOnboardingChange
])
@@ -1190,8 +1277,8 @@ export function useOnboardingFlow(
return
}
setTourStarted(false)
- setStepIndex((idx) => Math.max(idx - 1, 0))
- }, [nestedScan, trackNestedBackAndClear])
+ setStepIndex(getPreviousStepIndex)
+ }, [getPreviousStepIndex, nestedScan, trackNestedBackAndClear])
// Why: returns the user to the "Take the tour" intro without leaving the
// tour step. Don't emit the tour outcome here — re-entry must still let
@@ -1207,15 +1294,19 @@ export function useOnboardingFlow(
trackNestedBackAndClear()
}
setTourStarted(false)
- setStepIndex(Math.min(Math.max(idx, 0), STEPS.length - 1))
+ setStepIndex(
+ resolveStepIndex(idx, skipIntegrations, idx < stepIndex ? 'backward' : 'forward')
+ )
},
- [nestedScan, stepIndex, trackNestedBackAndClear]
+ [nestedScan, skipIntegrations, stepIndex, trackNestedBackAndClear]
)
return {
settings,
updateSettings,
stepIndex,
+ visibleSteps,
+ visibleStepIndex,
currentStep,
selectedAgent,
setSelectedAgent: setSelectedAgentInteractive,