mirror of
https://github.com/stablyai/orca.git
synced 2026-09-27 08:02:35 +00:00
Refine onboarding GitHub task setup (#3984)
This commit is contained in:
@@ -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 ? (
|
||||
<div className="grid grid-cols-1 gap-3">
|
||||
<div className="grid grid-cols-1 gap-3 md:grid-cols-2">
|
||||
<LinearRow compact />
|
||||
<GitHubRow compact />
|
||||
</div>
|
||||
@@ -130,37 +127,50 @@ export function FeatureWallBody(props: {
|
||||
) : isAgentsUsage ? (
|
||||
<UsageAccountsCard onAccountStateChange={onUsageAccountStateChange} />
|
||||
) : isAgentsOrchestration ? (
|
||||
<OrchestrationSetupCard compact terminalHeightPx={240} skill={orchestrationSkill} />
|
||||
<OrchestrationSetupCard
|
||||
compact
|
||||
terminalHeightPx={setupTerminalHeightPx}
|
||||
skill={orchestrationSkill}
|
||||
/>
|
||||
) : isWorkbenchBrowser ? (
|
||||
<BrowserUseSkillSetupCard compact terminalHeightPx={240} skill={browserUseSkill} />
|
||||
<BrowserUseSkillSetupCard
|
||||
compact
|
||||
terminalHeightPx={setupTerminalHeightPx}
|
||||
skill={browserUseSkill}
|
||||
/>
|
||||
) : isReviewPrView ? (
|
||||
<GitHubRow compact />
|
||||
) : isReviewShip ? (
|
||||
<AiCommitPrSettingsCard />
|
||||
) : 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 ? (
|
||||
<WorkspacesAnimatedVisual reducedMotion={prefersReducedMotion} />
|
||||
) : 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 = (
|
||||
<div
|
||||
className={cn(
|
||||
'flex w-full items-start justify-center',
|
||||
visualStageHeight,
|
||||
settingBesideVisual ? 'self-center' : null
|
||||
)}
|
||||
>
|
||||
<div className={cn('flex w-full items-start justify-center', visualStageHeight)}>
|
||||
<div
|
||||
className={cn(
|
||||
'max-w-full',
|
||||
animatedVisualWidth,
|
||||
isAgentsOrchestration && !isOnboardingOrchestrationBesideVisual ? 'translate-x-6' : null
|
||||
isAgentsOrchestration && !isOnboardingOrchestration ? 'translate-x-6' : null
|
||||
)}
|
||||
>
|
||||
{animatedVisual}
|
||||
@@ -210,30 +224,15 @@ export function FeatureWallBody(props: {
|
||||
</div>
|
||||
)
|
||||
const previewVisualNode = shouldLabelOnboardingTourZones ? (
|
||||
<TourZone label="Preview" className="items-center">
|
||||
<TourZone label="Demo" className="items-center">
|
||||
{animatedVisualNode}
|
||||
</TourZone>
|
||||
) : (
|
||||
animatedVisualNode
|
||||
)
|
||||
|
||||
if (isTasks) {
|
||||
return (
|
||||
<div className="flex h-full min-h-0 flex-col px-8 pb-6 pt-1">
|
||||
<div className="@container flex min-h-0 flex-1 items-start">
|
||||
<div className="grid w-full items-start gap-5 @[820px]:grid-cols-[minmax(420px,560px)_minmax(280px,320px)]">
|
||||
<div className="flex h-[260px] w-full items-start justify-center">
|
||||
<div className="w-[520px] max-w-full">{animatedVisual}</div>
|
||||
</div>
|
||||
<div className="grid w-full gap-3">{settingContent}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-5 px-8 pb-9 pt-1">
|
||||
<div className="flex min-h-full flex-col gap-4 px-8 pb-0 pt-1">
|
||||
<div
|
||||
className={cn(
|
||||
'grid grid-cols-1 items-start gap-7',
|
||||
@@ -251,82 +250,7 @@ export function FeatureWallBody(props: {
|
||||
) : null}
|
||||
|
||||
{hasAnimatedVisual ? (
|
||||
settingContent && settingBesideVisual ? (
|
||||
<div className="@container w-full">
|
||||
<div
|
||||
className={cn(
|
||||
'grid w-full items-start',
|
||||
isOnboardingOrchestrationBesideVisual ||
|
||||
isOnboardingStatuses ||
|
||||
isOnboardingWorkbenchBrowser ||
|
||||
(isCompactBesideVisual && isReviewSettingBesideVisual)
|
||||
? 'gap-4'
|
||||
: 'gap-5',
|
||||
'justify-center',
|
||||
isAgentsUsage
|
||||
? isOnboardingUsage
|
||||
? '@[780px]:grid-cols-[minmax(360px,400px)_minmax(340px,360px)] @[780px]:items-center'
|
||||
: '@[860px]:grid-cols-[minmax(400px,440px)_minmax(360px,400px)] @[860px]:items-center'
|
||||
: isOnboardingStatuses
|
||||
? '@[760px]:grid-cols-[minmax(320px,360px)_minmax(400px,420px)] @[760px]:items-center'
|
||||
: isWorkbenchBrowser
|
||||
? isOnboardingWorkbenchBrowser
|
||||
? '@[800px]:grid-cols-[minmax(320px,340px)_minmax(440px,460px)] @[800px]:items-center'
|
||||
: '@[880px]:grid-cols-[minmax(360px,400px)_minmax(440px,480px)] @[880px]:items-center'
|
||||
: isOnboardingOrchestrationBesideVisual
|
||||
? '@[800px]:grid-cols-[minmax(340px,360px)_minmax(420px,440px)] @[800px]:items-center'
|
||||
: isReviewSettingBesideVisual
|
||||
? isCompactBesideVisual
|
||||
? '@[800px]:grid-cols-[minmax(300px,320px)_minmax(460px,480px)] @[800px]:items-center'
|
||||
: cn(
|
||||
'@[840px]:grid-cols-[minmax(380px,420px)_minmax(440px,480px)]',
|
||||
isReviewShip ? '@[840px]:items-start' : '@[840px]:items-center'
|
||||
)
|
||||
: isCompactBesideVisual
|
||||
? '@[700px]:grid-cols-[minmax(300px,340px)_minmax(320px,340px)] @[700px]:items-center'
|
||||
: isAgentsOrchestration
|
||||
? '@[860px]:grid-cols-[minmax(340px,380px)_auto] @[860px]:items-center'
|
||||
: '@[760px]:grid-cols-[auto_minmax(320px,420px)] @[760px]:items-center'
|
||||
)}
|
||||
>
|
||||
<TourZone
|
||||
label={shouldLabelOnboardingTourZones ? 'Setup' : null}
|
||||
className={cn(
|
||||
'w-full',
|
||||
isReviewShip ? 'translate-y-0.5 self-start' : 'self-center',
|
||||
isAgentsUsage
|
||||
? isOnboardingUsage
|
||||
? 'max-w-[400px]'
|
||||
: 'max-w-[440px]'
|
||||
: isAgentsStatuses
|
||||
? isOnboardingStatuses
|
||||
? 'max-w-[360px]'
|
||||
: 'max-w-[520px]'
|
||||
: isWorkbenchBrowser
|
||||
? isOnboardingWorkbenchBrowser
|
||||
? 'max-w-[340px]'
|
||||
: 'max-w-[400px]'
|
||||
: isOnboardingOrchestrationBesideVisual
|
||||
? 'max-w-[360px]'
|
||||
: isCompactBesideVisual
|
||||
? isReviewSettingBesideVisual
|
||||
? 'max-w-[320px]'
|
||||
: 'max-w-[340px]'
|
||||
: isAgentsOrchestration
|
||||
? 'max-w-[400px]'
|
||||
: isReviewSettingBesideVisual
|
||||
? 'max-w-[420px]'
|
||||
: 'max-w-[420px]'
|
||||
)}
|
||||
>
|
||||
{settingContent}
|
||||
</TourZone>
|
||||
{previewVisualNode}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
animatedVisualNode
|
||||
)
|
||||
previewVisualNode
|
||||
) : (
|
||||
<aside className="flex flex-col gap-5">
|
||||
{selected.relatedTileIds.length > 0 ? (
|
||||
@@ -335,8 +259,25 @@ export function FeatureWallBody(props: {
|
||||
</aside>
|
||||
)}
|
||||
</div>
|
||||
{settingContent && !settingBesideVisual ? (
|
||||
<div className={cn('mx-auto w-full', settingWidth)}>{settingContent}</div>
|
||||
{settingContent && shouldStickSetupToBottom ? (
|
||||
<div className="sticky bottom-0 z-10 -mx-8 mt-auto border-t border-border bg-card/95 px-8 py-3 backdrop-blur supports-[backdrop-filter]:bg-card/85">
|
||||
<TourZone
|
||||
label="Setup"
|
||||
className={cn(
|
||||
'scrollbar-sleek mx-auto max-h-[220px] w-full overflow-y-auto',
|
||||
settingWidth
|
||||
)}
|
||||
>
|
||||
{settingContent}
|
||||
</TourZone>
|
||||
</div>
|
||||
) : settingContent ? (
|
||||
<TourZone
|
||||
label={shouldLabelOnboardingTourZones ? 'Setup' : null}
|
||||
className={cn('mx-auto w-full', settingWidth)}
|
||||
>
|
||||
{settingContent}
|
||||
</TourZone>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -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 (
|
||||
<div className="space-y-6">
|
||||
@@ -195,15 +193,14 @@ export function IntegrationsStep(): React.JSX.Element {
|
||||
|
||||
<div className="space-y-3">
|
||||
<GitHubRow />
|
||||
<LinearRow />
|
||||
<div className="mt-4 flex items-center justify-between rounded-xl border border-border bg-muted/10 px-5 py-3.5">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-[14px] font-medium text-foreground/70">Jira</span>
|
||||
<span className="text-[13px] text-muted-foreground">
|
||||
Issues, sprints, and assignees.
|
||||
<div className="mt-4 rounded-xl border border-border bg-muted/10 px-5 py-4">
|
||||
<div className="flex flex-col gap-1 sm:flex-row sm:items-center sm:justify-between">
|
||||
<span className="text-[14px] font-medium text-foreground/70">More task sources</span>
|
||||
<span className="text-[13px] leading-relaxed text-muted-foreground">
|
||||
Linear, GitLab, Bitbucket, Azure DevOps, Gitea, and Jira live in Settings >
|
||||
Integrations.
|
||||
</span>
|
||||
</div>
|
||||
<IntegrationStatusPill tone="neutral">Coming soon</IntegrationStatusPill>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -64,6 +64,60 @@ describe('OnboardingFlow', () => {
|
||||
expect(html).not.toContain('>Skip</button>')
|
||||
})
|
||||
|
||||
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(
|
||||
<OnboardingFlow
|
||||
onboarding={{
|
||||
...getDefaultOnboardingState(),
|
||||
lastCompletedStep: 4
|
||||
}}
|
||||
onOnboardingChange={vi.fn()}
|
||||
/>
|
||||
)
|
||||
|
||||
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(
|
||||
<OnboardingFlow
|
||||
onboarding={{
|
||||
...getDefaultOnboardingState(),
|
||||
lastCompletedStep: 4
|
||||
}}
|
||||
onOnboardingChange={vi.fn()}
|
||||
/>
|
||||
)
|
||||
|
||||
expect(html).toContain('Set up GitHub tasks')
|
||||
expect(html).toContain('Install the GitHub CLI to:')
|
||||
expect(html).toContain('GitHub')
|
||||
expect(html).not.toContain(
|
||||
'<h3 class="text-[15px] font-semibold leading-tight text-foreground">Linear</h3>'
|
||||
)
|
||||
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(
|
||||
<OnboardingFlow onboarding={getDefaultOnboardingState()} onOnboardingChange={vi.fn()} />
|
||||
|
||||
@@ -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({
|
||||
) : (
|
||||
<div className="mt-10 flex items-center gap-2 transition-[margin-top] duration-[760ms] ease-[cubic-bezier(0.22,1,0.36,1)] motion-reduce:transition-none">
|
||||
<TooltipProvider delayDuration={0} skipDelayDuration={0}>
|
||||
{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 (
|
||||
<Tooltip key={step.id}>
|
||||
<TooltipTrigger asChild>
|
||||
@@ -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)}
|
||||
/>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" sideOffset={8} style={{ zIndex: 110 }}>
|
||||
@@ -245,7 +245,7 @@ export default function OnboardingFlow({
|
||||
})}
|
||||
</TooltipProvider>
|
||||
<span className="ml-3 text-xs font-medium text-muted-foreground">
|
||||
{stepIndex + 1} of {STEPS.length}
|
||||
{flow.visibleStepIndex + 1} of {flow.visibleSteps.length}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -57,6 +57,33 @@ type TaskSourcesGithubStatus = TaskSourcesSnapshotProps['github_status']
|
||||
type TaskSourcesLinearStatus = TaskSourcesSnapshotProps['linear_status']
|
||||
type TaskSourcesExitAction = TaskSourcesSnapshotProps['exit_action']
|
||||
|
||||
function shouldSkipIntegrationsStep(
|
||||
status: ReturnType<typeof useAppStore.getState>['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<TuiAgent | null>(
|
||||
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,
|
||||
|
||||
Reference in New Issue
Block a user