From df1bc8038dcbd0dabdd216c0d7e015d35dd7f593 Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Sat, 30 May 2026 21:53:51 -0700 Subject: [PATCH] Refine onboarding GitHub task setup (#3984) --- .../feature-wall/FeatureWallBody.tsx | 255 +++++++----------- .../onboarding/IntegrationsStep.tsx | 23 +- .../onboarding/OnboardingFlow.test.tsx | 54 ++++ .../components/onboarding/OnboardingFlow.tsx | 18 +- .../onboarding/use-onboarding-flow.ts | 107 +++++++- 5 files changed, 270 insertions(+), 187 deletions(-) diff --git a/src/renderer/src/components/feature-wall/FeatureWallBody.tsx b/src/renderer/src/components/feature-wall/FeatureWallBody.tsx index db49e74ebe8..20ee9c5954c 100644 --- a/src/renderer/src/components/feature-wall/FeatureWallBody.tsx +++ b/src/renderer/src/components/feature-wall/FeatureWallBody.tsx @@ -60,7 +60,6 @@ export function FeatureWallBody(props: { const isAgentsUsage = isAgents && agentsActiveStep?.id === 'usage' const isAgentsStatuses = isAgents && agentsActiveStep?.id === 'statuses' const isAgentsOrchestration = isAgents && agentsActiveStep?.id === 'orchestration' - const isOnboardingAgentsStep = source === 'onboarding' && isAgents const isWorkbenchEditor = isWorkbench && workbenchActiveStep?.id === 'editor' const isWorkbenchBrowser = isWorkbench && workbenchActiveStep?.id === 'browser' const isReviewPrView = isReview && reviewActiveStep?.id === 'pr-view' @@ -69,13 +68,10 @@ export function FeatureWallBody(props: { const isOnboardingUsage = isAgentsUsage && source === 'onboarding' const isOnboardingStatuses = isAgentsStatuses && source === 'onboarding' const isOnboardingWorkbenchBrowser = isWorkbenchBrowser && source === 'onboarding' - const isReviewSettingBesideVisual = isReviewPrView || isReviewShip - const isOnboardingOrchestrationBesideVisual = isAgentsOrchestration && source === 'onboarding' - const orchestrationVisualWidthPx = isOnboardingOrchestrationBesideVisual ? 440 : 520 - const orchestrationVisualHeightPx = 392 - const isCompactBesideVisual = - source === 'onboarding' && - (isAgentsUsage || isReviewSettingBesideVisual || isAgentsOrchestration) + const isReviewSettingStep = isReviewPrView || isReviewShip + const isOnboardingOrchestration = isAgentsOrchestration && source === 'onboarding' + const orchestrationVisualWidthPx = isOnboardingOrchestration ? 440 : 520 + const orchestrationVisualHeightPx = isOnboardingOrchestration ? 240 : 392 const animatedVisualWidth = isWorkspaces ? 'w-[440px]' : isWorkbenchEditor @@ -95,33 +91,34 @@ export function FeatureWallBody(props: { : isAgentsStatuses ? 'w-[420px]' : isAgentsOrchestration - ? isOnboardingOrchestrationBesideVisual + ? isOnboardingOrchestration ? 'w-[440px]' : 'w-[520px]' : 'w-[520px]' - const settingWidth = isAgentsUsage - ? isOnboardingUsage - ? 'max-w-[400px]' - : 'max-w-[440px]' - : isAgentsStatuses - ? isOnboardingStatuses - ? 'max-w-[360px]' - : 'max-w-[520px]' - : isAgentsOrchestration - ? isOnboardingOrchestrationBesideVisual + const settingWidth = isTasks + ? 'max-w-[760px]' + : isAgentsUsage + ? isOnboardingUsage + ? 'max-w-[400px]' + : 'max-w-[440px]' + : isAgentsStatuses + ? isOnboardingStatuses ? 'max-w-[360px]' - : 'max-w-[400px]' - : isReviewSettingBesideVisual - ? isCompactBesideVisual - ? 'max-w-[320px]' - : 'max-w-[420px]' - : isWorkbenchBrowser - ? isOnboardingWorkbenchBrowser - ? 'max-w-[340px]' - : 'max-w-[400px]' - : 'max-w-[480px]' + : 'max-w-[520px]' + : isAgentsOrchestration + ? isOnboardingOrchestration + ? 'max-w-[360px]' + : 'max-w-[400px]' + : isReviewSettingStep + ? 'max-w-[420px]' + : isWorkbenchBrowser + ? isOnboardingWorkbenchBrowser + ? 'max-w-[340px]' + : 'max-w-[400px]' + : 'max-w-[480px]' + const setupTerminalHeightPx = source === 'onboarding' ? 140 : 240 const settingContent = isTasks ? ( -
+
@@ -130,37 +127,50 @@ export function FeatureWallBody(props: { ) : isAgentsUsage ? ( ) : isAgentsOrchestration ? ( - + ) : isWorkbenchBrowser ? ( - + ) : isReviewPrView ? ( ) : isReviewShip ? ( ) : null - const settingBesideVisual = - (isOnboardingAgentsStep && Boolean(settingContent)) || - isAgentsUsage || - isAgentsOrchestration || - isReviewSettingBesideVisual - const shouldLabelOnboardingTourZones = isOnboardingAgentsStep && settingBesideVisual + const shouldLabelOnboardingTourZones = + source === 'onboarding' && hasAnimatedVisual && Boolean(settingContent) + const shouldStickSetupToBottom = shouldLabelOnboardingTourZones // Why: several visuals expand/collapse internally; setup controls should sit // after a stable stage so they do not jump with the animation loop. - const visualStageHeight = isWorkbenchEditor - ? 'h-[390px]' - : isWorkbenchBrowser - ? 'h-[270px]' - : isWorkbench - ? 'h-[340px]' - : isReview - ? 'h-[416px]' - : isAgentsOrchestration - ? 'h-[392px]' - : isAgentsStatuses - ? 'h-[250px]' - : isAgentsUsage - ? 'h-[392px]' - : 'h-[330px]' + const visualStageHeight = isTasks + ? 'h-[288px]' + : isWorkbenchEditor + ? 'h-[390px]' + : isWorkbenchBrowser + ? 'h-[270px]' + : isWorkbench + ? 'h-[340px]' + : isReview + ? 'h-[416px]' + : isAgentsOrchestration + ? isOnboardingOrchestration + ? 'h-[240px]' + : 'h-[392px]' + : isAgentsStatuses + ? isOnboardingStatuses + ? 'h-[200px]' + : 'h-[250px]' + : isAgentsUsage + ? isOnboardingUsage + ? 'h-[320px]' + : 'h-[392px]' + : 'h-[330px]' const animatedVisual = isWorkspaces ? ( ) : isTasks ? ( @@ -187,22 +197,26 @@ export function FeatureWallBody(props: { reducedMotion={prefersReducedMotion} activeStepId={agentsActiveStep.id} widthPx={isAgentsUsage ? (isOnboardingUsage ? 360 : 400) : isAgentsStatuses ? 420 : undefined} - heightPx={isAgentsStatuses ? 250 : undefined} + heightPx={ + isAgentsUsage + ? isOnboardingUsage + ? 320 + : undefined + : isAgentsStatuses + ? isOnboardingStatuses + ? 200 + : 250 + : undefined + } /> ) : null const animatedVisualNode = ( -
+
{animatedVisual} @@ -210,30 +224,15 @@ export function FeatureWallBody(props: {
) const previewVisualNode = shouldLabelOnboardingTourZones ? ( - + {animatedVisualNode} ) : ( animatedVisualNode ) - if (isTasks) { - return ( -
-
-
-
-
{animatedVisual}
-
-
{settingContent}
-
-
-
- ) - } - return ( -
+
-
- - {settingContent} - - {previewVisualNode} -
-
- ) : ( - animatedVisualNode - ) + previewVisualNode ) : ( )}
- {settingContent && !settingBesideVisual ? ( -
{settingContent}
+ {settingContent && shouldStickSetupToBottom ? ( +
+ + {settingContent} + +
+ ) : settingContent ? ( + + {settingContent} + ) : null}
) diff --git a/src/renderer/src/components/onboarding/IntegrationsStep.tsx b/src/renderer/src/components/onboarding/IntegrationsStep.tsx index 4a975734511..2638131cfee 100644 --- a/src/renderer/src/components/onboarding/IntegrationsStep.tsx +++ b/src/renderer/src/components/onboarding/IntegrationsStep.tsx @@ -167,20 +167,18 @@ export function LinearRow(props: { compact?: boolean } = {}): React.JSX.Element } const CAPABILITIES = [ - 'Start a workspace from any issue, PR, or Linear ticket, prefilled with its title and context', - 'Browse your assigned tasks in the Tasks view without leaving Orca', - 'See issue state, PR review status, and CI checks on every worktree', + 'Start a workspace from any GitHub issue or pull request, prefilled with its title and context', + 'Browse GitHub issues and pull requests in the Tasks view without leaving Orca', + 'See issue state, review status, and CI checks on every worktree', 'Read, comment on, and merge pull requests without leaving Orca' ] as const export function IntegrationsStep(): React.JSX.Element { const refreshPreflightStatus = useAppStore((s) => s.refreshPreflightStatus) - const checkLinearConnection = useAppStore((s) => s.checkLinearConnection) useEffect(() => { void refreshPreflightStatus() - void checkLinearConnection() - }, [checkLinearConnection, refreshPreflightStatus]) + }, [refreshPreflightStatus]) return (
@@ -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,