diff --git a/src/renderer/src/components/feature-wall/FeatureWallTourPanel.tsx b/src/renderer/src/components/feature-wall/FeatureWallTourPanel.tsx index 0a6bc6b7967..de7971a758a 100644 --- a/src/renderer/src/components/feature-wall/FeatureWallTourPanel.tsx +++ b/src/renderer/src/components/feature-wall/FeatureWallTourPanel.tsx @@ -53,6 +53,7 @@ export function FeatureWallTourPanel(props: { updateSettings: (updates: Partial) => void footerText: string | null continueButton: ReactNode + leadingFooterContent?: ReactNode }): JSX.Element { const panel = (
{panel} -
{props.continueButton}
+
+ {props.leadingFooterContent ?? } + {props.continueButton} +
) } diff --git a/src/renderer/src/components/feature-wall/FeatureWallTourSurface.tsx b/src/renderer/src/components/feature-wall/FeatureWallTourSurface.tsx index 55275f8d4cf..3d0b91da83a 100644 --- a/src/renderer/src/components/feature-wall/FeatureWallTourSurface.tsx +++ b/src/renderer/src/components/feature-wall/FeatureWallTourSurface.tsx @@ -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} /> ) } diff --git a/src/renderer/src/components/onboarding/OnboardingFlow.test.tsx b/src/renderer/src/components/onboarding/OnboardingFlow.test.tsx new file mode 100644 index 00000000000..f8dc0bf3a0e --- /dev/null +++ b/src/renderer/src/components/onboarding/OnboardingFlow.test.tsx @@ -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( + + ) + + 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') + }) +}) diff --git a/src/renderer/src/components/onboarding/OnboardingFlow.tsx b/src/renderer/src/components/onboarding/OnboardingFlow.tsx index 964d8ca4d57..8ce35e7bce8 100644 --- a/src/renderer/src/components/onboarding/OnboardingFlow.tsx +++ b/src/renderer/src/components/onboarding/OnboardingFlow.tsx @@ -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({ {isInlineTourRunning ? (

- {copy.title} + {stepTooltipLabels.tour}

) : null} @@ -280,6 +280,7 @@ export default function OnboardingFlow({ busyLabel={busyLabel} onStartTour={flow.startTour} onCompleteTour={flow.completeTour} + onExitTour={flow.exitTour} onTourDepthSummaryChange={flow.recordTourDepthSummary} /> )} diff --git a/src/renderer/src/components/onboarding/OnboardingTourStep.tsx b/src/renderer/src/components/onboarding/OnboardingTourStep.tsx index 1e73c3d2b7f..c98bca9db6d 100644 --- a/src/renderer/src/components/onboarding/OnboardingTourStep.tsx +++ b/src/renderer/src/components/onboarding/OnboardingTourStep.tsx @@ -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 + 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={ + + } /> ) } return (
-
-
-

- Interested in Orca's advanced features? -

-

- Take a short workflow tour before choosing your first project. -

-
- -
- - +
+
+

Learn how Orca can help you…

+
    + {TOUR_LEARNING_POINTS.map((point) => ( +
  • + + + + {point} +
  • + ))} +
+
+ + ~ 60 seconds +
+
-

+

This tour can be seen anytime under Help > Explore Orca.

diff --git a/src/renderer/src/components/onboarding/use-onboarding-flow.ts b/src/renderer/src/components/onboarding/use-onboarding-flow.ts index 869769e7d93..0306d16e460 100644 --- a/src/renderer/src/components/onboarding/use-onboarding-flow.ts +++ b/src/renderer/src/components/onboarding/use-onboarding-flow.ts @@ -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 => { + (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,