Left-align onboarding tour intro and add Continue action (#2774)

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Brennan Benson
2026-05-24 22:29:26 -07:00
committed by GitHub
co-authored by Orca
parent 3ef4a42305
commit 4bacdc534d
6 changed files with 195 additions and 97 deletions
@@ -53,6 +53,7 @@ export function FeatureWallTourPanel(props: {
updateSettings: (updates: Partial<GlobalSettings>) => void
footerText: string | null
continueButton: ReactNode
leadingFooterContent?: ReactNode
}): JSX.Element {
const panel = (
<div
@@ -164,7 +165,10 @@ export function FeatureWallTourPanel(props: {
return (
<div className={cn('grid min-h-0 grid-rows-[minmax(0,1fr)_auto] gap-3', props.className)}>
{panel}
<div className="flex justify-end">{props.continueButton}</div>
<div className="flex items-center justify-between gap-3">
{props.leadingFooterContent ?? <span />}
{props.continueButton}
</div>
</div>
)
}
@@ -1,5 +1,6 @@
/* eslint-disable max-lines -- Why: orchestrator for the inline tour surface; splitting it here would scatter related state across helpers without making the file easier to read. */
import { useCallback, useEffect, useId, useMemo, useRef, useState } from 'react'
import type { JSX, KeyboardEvent } from 'react'
import type { JSX, KeyboardEvent, ReactNode } from 'react'
import {
DEFAULT_FEATURE_WALL_WORKFLOW_ID,
FEATURE_WALL_WORKFLOWS,
@@ -41,6 +42,7 @@ type FeatureWallTourSurfaceProps = {
enableKeyboardShortcut?: boolean
compactRail?: boolean
detachedFooter?: boolean
leadingFooterContent?: ReactNode
onTourDepthSummaryChange?: (summary: FeatureWallTourDepthSummary) => void
}
@@ -55,6 +57,7 @@ export function FeatureWallTourSurface({
enableKeyboardShortcut = true,
compactRail = false,
detachedFooter = false,
leadingFooterContent,
onTourDepthSummaryChange
}: FeatureWallTourSurfaceProps): JSX.Element | null {
const settings = useAppStore((s) => s.settings)
@@ -412,6 +415,7 @@ export function FeatureWallTourSurface({
updateSettings={updateSettings}
footerText={footerText}
continueButton={continueButton}
leadingFooterContent={leadingFooterContent}
/>
)
}
@@ -0,0 +1,45 @@
import { renderToStaticMarkup } from 'react-dom/server'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { getDefaultOnboardingState, getDefaultSettings } from '../../../../shared/constants'
import { useAppStore } from '@/store'
import OnboardingFlow from './OnboardingFlow'
describe('OnboardingFlow', () => {
beforeEach(() => {
useAppStore.setState(useAppStore.getInitialState(), true)
useAppStore.setState({
repos: [],
settings: getDefaultSettings('/tmp')
})
vi.stubGlobal('navigator', { userAgent: 'Macintosh' })
})
afterEach(() => {
useAppStore.setState(useAppStore.getInitialState(), true)
vi.unstubAllGlobals()
})
it('renders the tour intro in the standard left-aligned onboarding shell', () => {
const html = renderToStaticMarkup(
<OnboardingFlow
onboarding={{
...getDefaultOnboardingState(),
lastCompletedStep: 5
}}
onOnboardingChange={vi.fn()}
/>
)
expect(html).toContain('Interested in Orca&#x27;s advanced features?')
expect(html).toContain('Take a short tour before getting started.')
expect(html).toContain('Learn how Orca can help you')
expect(html).toContain('Hand off a feature to an orchestrator agent.')
expect(html).toContain('Grab an element from your running app and send it to an agent.')
expect(html).not.toContain('Write and preview Markdown.')
expect(html).toContain('items-start')
expect(html).toContain('text-left')
expect(html).toContain('Continue')
expect(html).toContain('Skip to project setup')
expect(html).not.toContain('Skip the tour')
})
})
@@ -38,8 +38,8 @@ const stepCopy = {
subtitle: 'Connect GitHub or Linear to:'
},
tour: {
title: 'Explore Orca',
subtitle: ''
title: "Interested in Orca's advanced features?",
subtitle: 'Take a short tour before getting started.'
},
repo: {
title: 'Point Orca at some code',
@@ -81,9 +81,9 @@ export default function OnboardingFlow({
const tourStarted = flow.tourStarted
const isInlineTourRunning = isTourStep && tourStarted
const shouldShowFooter = !isInlineTourRunning
const shouldShowSkipToProjectSetup = currentStep.id !== 'repo' && currentStep.id !== 'tour'
const shouldShowStepHeading = !isTourStep
const footerPrimaryLabel = isTourStep ? 'Skip the tour' : primaryActionLabel
const shouldShowSkipToProjectSetup = currentStep.id !== 'repo'
const shouldShowStepHeading = !isInlineTourRunning
const footerPrimaryLabel = primaryActionLabel
const {
next: flowNext,
openFolder: flowOpenFolder,
@@ -217,7 +217,7 @@ export default function OnboardingFlow({
</span>
{isInlineTourRunning ? (
<h1 className="ml-5 text-[34px] font-semibold leading-[1.15] tracking-tight text-foreground">
{copy.title}
{stepTooltipLabels.tour}
</h1>
) : null}
</div>
@@ -280,6 +280,7 @@ export default function OnboardingFlow({
busyLabel={busyLabel}
onStartTour={flow.startTour}
onCompleteTour={flow.completeTour}
onExitTour={flow.exitTour}
onTourDepthSummaryChange={flow.recordTourDepthSummary}
/>
)}
@@ -1,17 +1,25 @@
import type { JSX } from 'react'
import { flushSync } from 'react-dom'
import { ArrowRight } from 'lucide-react'
import { ArrowRight, Check } from 'lucide-react'
import { Button } from '@/components/ui/button'
import type { FeatureWallTourDepthSummary } from '../../../../shared/feature-wall-tour-depth'
import { FeatureTourPreview } from '../feature-wall/FeatureTourPreview'
import { FeatureWallTourSurface } from '../feature-wall/FeatureWallTourSurface'
import { usePrefersReducedMotion } from '../feature-wall/feature-wall-modal-helpers'
const TOUR_LEARNING_POINTS: readonly string[] = [
'Work on several branches at once.',
'Hand off a feature to an orchestrator agent.',
'Start work straight from a GitHub or Linear ticket.',
'Grab an element from your running app and send it to an agent.'
]
type OnboardingTourStepProps = {
tourStarted: boolean
busyLabel: string | null
onStartTour: () => void
onCompleteTour: (markSuccessfulExit?: () => void) => boolean | void | Promise<boolean | void>
onExitTour: () => void
onTourDepthSummaryChange: (summary: FeatureWallTourDepthSummary) => void
}
@@ -28,6 +36,7 @@ export function OnboardingTourStep({
busyLabel,
onStartTour,
onCompleteTour,
onExitTour,
onTourDepthSummaryChange
}: OnboardingTourStepProps): JSX.Element {
const prefersReducedMotion = usePrefersReducedMotion()
@@ -76,37 +85,52 @@ export function OnboardingTourStep({
onTourDepthSummaryChange={onTourDepthSummaryChange}
className="h-full max-h-[790px] min-h-0"
panelClassName="rounded-xl border border-border bg-card"
leadingFooterContent={
<button
type="button"
className="rounded-md px-3 py-2 text-sm text-muted-foreground hover:text-foreground disabled:cursor-not-allowed disabled:opacity-60 disabled:hover:text-muted-foreground"
disabled={Boolean(busyLabel)}
onClick={onExitTour}
>
Exit tour
</button>
}
/>
)
}
return (
<div className="flex h-full min-h-[430px] flex-col">
<div className="mx-auto flex w-full max-w-[560px] flex-col items-center gap-5 pt-16 text-center">
<div className="space-y-2">
<h2 className="text-2xl font-semibold tracking-tight text-foreground">
Interested in Orca&apos;s advanced features?
</h2>
<p className="text-sm leading-relaxed text-muted-foreground">
Take a short workflow tour before choosing your first project.
</p>
</div>
<div className="flex w-full flex-col items-center gap-3">
<FeatureTourPreview className="w-full max-w-[360px]" />
<Button
variant="default"
onClick={handleStartTour}
disabled={Boolean(busyLabel)}
className="w-full max-w-[360px] justify-center gap-2"
>
Take the tour
<ArrowRight className="size-4" />
</Button>
<div className="grid w-full grid-cols-1 items-start gap-10 md:grid-cols-[1fr_minmax(0,340px)]">
<div className="flex flex-col gap-4">
<p className="text-sm font-medium text-foreground">Learn how Orca can help you</p>
<ul className="flex flex-col gap-2.5">
{TOUR_LEARNING_POINTS.map((point) => (
<li key={point} className="flex items-start gap-3">
<span className="mt-0.5 flex size-4 shrink-0 items-center justify-center rounded-full bg-foreground/[0.06] text-foreground">
<Check className="size-2.5" strokeWidth={3} />
</span>
<span className="text-sm leading-snug text-foreground">{point}</span>
</li>
))}
</ul>
<div className="mt-2 flex items-center gap-3">
<Button
variant="default"
onClick={handleStartTour}
disabled={Boolean(busyLabel)}
className="gap-2"
>
Take the tour
<ArrowRight className="size-4" />
</Button>
<span className="text-xs text-muted-foreground">~ 60 seconds</span>
</div>
</div>
<FeatureTourPreview className="w-full" />
</div>
<p className="mx-auto mt-auto max-w-[560px] text-center text-xs leading-relaxed text-muted-foreground">
<p className="mt-auto max-w-[560px] text-left text-xs leading-relaxed text-muted-foreground">
This tour can be seen anytime under Help &gt; Explore Orca.
</p>
</div>
@@ -693,27 +693,35 @@ export function useOnboardingFlow(
if (currentStep.id === 'agent' && selectedAgent) {
await updateSettings({ defaultTuiAgent: selectedAgent })
}
try {
const nextState = await persistStep(repoStep.stepNumber - 1)
onOnboardingChange(nextState)
// Why: users can skip optional preferences, but onboarding remains open
// because Orca needs a project before the app has a useful first state.
track('onboarding_step_skipped', {
step: currentStep.stepNumber,
value_kind: currentStep.valueKind,
duration_ms: durationMs,
advanced_via: 'button'
})
if (currentStep.id === 'integrations') {
trackTaskSourcesSnapshot('skip_to_project_setup', durationMs, 'button')
const stepId = currentStep.id
const stepNumber = currentStep.stepNumber
const valueKind = currentStep.valueKind
setStepIndex(repoStepIndex)
setTourStarted(false)
// Why: progress persist is bookkeeping — advance the UI immediately and
// run the IPC + telemetry in the background.
void persistStep(repoStep.stepNumber - 1).then(
(nextState) => {
onOnboardingChange(nextState)
// Why: users can skip optional preferences, but onboarding remains
// open because Orca needs a project before the app has a useful
// first state.
track('onboarding_step_skipped', {
step: stepNumber,
value_kind: valueKind,
duration_ms: durationMs,
advanced_via: 'button'
})
if (stepId === 'integrations') {
trackTaskSourcesSnapshot('skip_to_project_setup', durationMs, 'button')
}
},
(err) => {
toast.error('Could not save progress', {
description: err instanceof Error ? err.message : String(err)
})
}
setStepIndex(repoStepIndex)
setTourStarted(false)
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
setError(message)
toast.error('Could not skip to Add Project', { description: message })
}
)
}, [
busyLabel,
consumeStepDurationMs,
@@ -737,7 +745,7 @@ export function useOnboardingFlow(
}, [busyLabel])
const completeTour = useCallback(
async (markSuccessfulExit?: () => void): Promise<boolean> => {
(markSuccessfulExit?: () => void): boolean => {
if (busyLabel || currentStep.id !== 'tour') {
return false
}
@@ -747,43 +755,45 @@ export function useOnboardingFlow(
if (!repoStep) {
return false
}
const stepNumber = currentStep.stepNumber
const valueKind = currentStep.valueKind
const durationMs = consumeStepDurationMs()
setBusyLabel('Saving…')
try {
const nextState = await persistStep(repoStep.stepNumber - 1)
onOnboardingChange(nextState)
track('onboarding_step_completed', {
step: currentStep.stepNumber,
value_kind: currentStep.valueKind,
duration_ms: durationMs,
advanced_via: 'button'
})
emitTourOutcome('completed_inline', 'button')
markSuccessfulExit?.()
setTourStarted(false)
setStepIndex(repoStepIndex)
return true
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
setError(message)
toast.error('Could not continue to project setup', { description: message })
return false
} finally {
setBusyLabel(null)
}
markSuccessfulExit?.()
setTourStarted(false)
setStepIndex(repoStepIndex)
// Why: persist is pure progress bookkeeping — advance the UI immediately
// and don't show the user a "Saving…" spinner for invisible work.
void persistStep(repoStep.stepNumber - 1).then(
(nextState) => {
onOnboardingChange(nextState)
track('onboarding_step_completed', {
step: stepNumber,
value_kind: valueKind,
duration_ms: durationMs,
advanced_via: 'button'
})
emitTourOutcome('completed_inline', 'button')
},
(err) => {
toast.error('Could not save tour progress', {
description: err instanceof Error ? err.message : String(err)
})
}
)
return true
},
[
busyLabel,
consumeStepDurationMs,
emitTourOutcome,
currentStep.id,
currentStep.stepNumber,
currentStep.valueKind,
emitTourOutcome,
onOnboardingChange
]
)
const skipTourToRepo = useCallback(async () => {
const skipTourToRepo = useCallback(() => {
if (busyLabel || currentStep.id !== 'tour') {
return
}
@@ -793,27 +803,28 @@ export function useOnboardingFlow(
if (!repoStep) {
return
}
const stepNumber = currentStep.stepNumber
const valueKind = currentStep.valueKind
const durationMs = consumeStepDurationMs()
setBusyLabel('Saving…')
try {
const nextState = await persistStep(repoStep.stepNumber - 1)
onOnboardingChange(nextState)
track('onboarding_step_skipped', {
step: currentStep.stepNumber,
value_kind: currentStep.valueKind,
duration_ms: durationMs,
advanced_via: 'button'
})
emitTourOutcome('skipped_intro', 'button')
setTourStarted(false)
setStepIndex(repoStepIndex)
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
setError(message)
toast.error('Could not continue to project setup', { description: message })
} finally {
setBusyLabel(null)
}
setTourStarted(false)
setStepIndex(repoStepIndex)
void persistStep(repoStep.stepNumber - 1).then(
(nextState) => {
onOnboardingChange(nextState)
track('onboarding_step_skipped', {
step: stepNumber,
value_kind: valueKind,
duration_ms: durationMs,
advanced_via: 'button'
})
emitTourOutcome('skipped_intro', 'button')
},
(err) => {
toast.error('Could not save tour progress', {
description: err instanceof Error ? err.message : String(err)
})
}
)
}, [
busyLabel,
consumeStepDurationMs,
@@ -894,6 +905,14 @@ export function useOnboardingFlow(
setStepIndex((idx) => Math.max(idx - 1, 0))
}, [])
// 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
// `completed_inline` win per the telemetry contract; the existing skip /
// complete / unmount paths handle the eventual emission.
const exitTour = useCallback(() => {
setTourStarted(false)
}, [])
const jumpToStep = useCallback((idx: number) => {
setTourStarted(false)
setStepIndex(Math.min(Math.max(idx, 0), STEPS.length - 1))
@@ -931,6 +950,7 @@ export function useOnboardingFlow(
startTour,
completeTour,
skipTourToRepo,
exitTour,
recordTourDepthSummary,
back,
jumpToStep,