mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 08:02:28 +00:00
Left-align onboarding tour intro and add Continue action (#2774)
Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
@@ -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'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'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 > 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,
|
||||
|
||||
Reference in New Issue
Block a user