diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 1ed1194a66bb..126cdff114c0 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -42,7 +42,10 @@ function App(): React.JSX.Element { useAppShellServices() // Why before the startup chain: its effect runs first, and no startup step can skip the load. useEffect(startNativeChatDraftLoad, []) - useAppStartupHydration(onboardingGate.applyStartupOnboardingState) + useAppStartupHydration( + onboardingGate.applyStartupOnboardingState, + onboardingGate.applyStartupTipCheckInputs + ) useAppSessionPersistence() useRuntimeGraphSync() usePersistedUIWriter() diff --git a/src/renderer/src/app-shell/AppRootSurfaces.tsx b/src/renderer/src/app-shell/AppRootSurfaces.tsx index 59e6c67ee5c4..9f1d24d59335 100644 --- a/src/renderer/src/app-shell/AppRootSurfaces.tsx +++ b/src/renderer/src/app-shell/AppRootSurfaces.tsx @@ -18,6 +18,7 @@ import { shouldRenderPetOverlay } from '../components/pet/pet-overlay-visibility import { useAppStore } from '../store' import type { UpdateStatus } from '../../../shared/update-status-types' import { useLazyModalMounts } from './use-lazy-modal-mounts' +import { AppOpenFeatureTip } from '../components/feature-tips/AppOpenFeatureTip' import { selectAppRootSurfacePetEnabled, selectAppRootSurfaceTelemetryOptedIn, @@ -26,6 +27,7 @@ import { import type { FloatingWorkspacePanelState } from './use-floating-workspace-panel' import type { OnboardingGate } from './use-onboarding-and-feature-tips' +const OnboardingFlow = lazy(() => import('../components/onboarding/OnboardingFlow')) const QuickOpen = lazy(() => import('../components/QuickOpen')) const WorktreeJumpPalette = lazy(() => import('../components/WorktreeJumpPalette')) const WorkspaceCleanupDialog = lazy( @@ -36,13 +38,11 @@ const StatusBar = lazy(() => ) const SetupGuideModal = lazy(() => import('../components/setup-guide/SetupGuideModal')) const FeatureWallModal = lazy(() => import('../components/feature-wall/FeatureWallModal')) -const FeatureTipsModal = lazy(() => import('../components/feature-tips/FeatureTipsModal')) const AddRepoDialog = lazy(() => import('../components/sidebar/AddRepoDialog')) const NonGitFolderDialog = lazy(() => import('../components/sidebar/NonGitFolderDialog')) const AddProjectFromFolderDialog = lazy( () => import('../components/sidebar/AddProjectFromFolderDialog') ) -const ProjectAddedDialog = lazy(() => import('../components/sidebar/ProjectAddedDialog')) const DeleteWorktreeDialog = lazy(() => import('../components/sidebar/DeleteWorktreeDialog')) const PreservedBranchBatchReviewModal = lazy( () => import('../components/sidebar/PreservedBranchBatchReviewModal') @@ -85,8 +85,6 @@ const FloatingTerminalPanel = lazy(() => ) // Why: lazy so the WebP asset + overlay module aren't fetched unless the experimental flag is on. const PetOverlay = lazy(() => import('../components/pet/PetOverlay')) -// Why: lazy so onboarding's step modules + assets aren't fetched for users past first-launch. -const OnboardingFlow = lazy(() => import('../components/onboarding/OnboardingFlow')) type BoundaryProps = { boundaryId: string @@ -146,7 +144,6 @@ export function AppRootSurfaces(props: { const updateStatus = useAppStore((s) => s.updateStatus) const activeContextualTourId = useAppStore((s) => s.activeContextualTourId) const hasSshCredentialRequest = useAppStore((s) => s.sshCredentialQueue.length > 0) - const shouldMountSetupGuideTelemetryObserver = persistedUIReady const shouldMountUpdateCard = shouldMountUpdateCardForStatus(updateStatus) const shouldMountDictationController = voiceEnabled || dictationState !== 'idle' @@ -215,11 +212,6 @@ export function AppRootSurfaces(props: { ) : null} - {activeModal === 'project-added' ? ( - - - - ) : null} {/* Why: root overlays can render Radix s; keep inside the shared provider so lazy surfaces mount from any entry point. */} @@ -256,11 +248,6 @@ export function AppRootSurfaces(props: { ) : null} - {mountedLazyModalIds.has('feature-tips') ? ( - - - - ) : null} {shouldMountSetupGuideTelemetryObserver ? ( @@ -365,6 +352,9 @@ export function AppRootSurfaces(props: { ) : null} + {onboardingGate.appOpenTipId ? ( + + ) : null} {shouldMountDictationController ? ( diff --git a/src/renderer/src/app-shell/onboarding-contextual-tours.test.tsx b/src/renderer/src/app-shell/onboarding-contextual-tours.test.tsx new file mode 100644 index 000000000000..2d98060607f7 --- /dev/null +++ b/src/renderer/src/app-shell/onboarding-contextual-tours.test.tsx @@ -0,0 +1,175 @@ +// @vitest-environment happy-dom + +import { act, lazy, Suspense, type LazyExoticComponent } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, expect, it, vi } from 'vitest' +import { getDefaultSettings } from '../../../shared/constants' +import { getDefaultOnboardingState } from '../../../shared/onboarding-defaults' +import { useAppStore } from '@/store' +import { useDialogRegistry } from '@/store/dialog-registry' +import { resetDialogRegistryForTests } from '@/store/dialog-registry-test-state' +import { ContextualTourOverlay } from '../components/contextual-tours/ContextualTourOverlay' +import { useContextualTour } from '../components/contextual-tours/use-contextual-tour' +import OnboardingFlow from '../components/onboarding/OnboardingFlow' +import { showOnboardingFromRenderer } from '../components/onboarding/show-onboarding-event' +import { useOnboardingAndFeatureTips, type OnboardingGate } from './use-onboarding-and-feature-tips' + +vi.mock('@/lib/feature-education-telemetry', () => ({ + trackContextualTourShown: vi.fn(), + trackContextualTourOutcome: vi.fn() +})) +vi.mock('@/lib/telemetry', () => ({ track: vi.fn() })) + +globalThis.IS_REACT_ACT_ENVIRONMENT = true +let root: Root +let container: HTMLDivElement +let target: HTMLButtonElement +let gate: OnboardingGate | null +let chunk: ReturnType> +let LazyOnboarding: LazyExoticComponent + +function App(): React.JSX.Element { + const onboarding = useOnboardingAndFeatureTips() + gate = onboarding + useContextualTour('automations', true, 'automations_open', { recordFeatureInteraction: false }) + return ( + <> + {onboarding.onboarding && onboarding.shouldRender ? ( + + + + ) : null} + + + ) +} + +async function settle(): Promise { + for (let i = 0; i < 4; i += 1) { + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 30)) + }) + } +} + +beforeEach(() => { + gate = null + chunk = Promise.withResolvers() + LazyOnboarding = lazy(() => chunk.promise) + useAppStore.setState(useAppStore.getInitialState(), true) + useAppStore.setState({ + settings: getDefaultSettings(''), + persistedUIReady: true, + contextualToursAutoEligible: true, + refreshDetectedAgents: vi.fn(async () => []), + refreshPreflightStatus: vi.fn(async () => {}) + }) + resetDialogRegistryForTests({ startupSettled: true }) + Object.assign(window, { + api: { + ui: { set: vi.fn(async () => undefined) }, + onboarding: { update: vi.fn(async () => getDefaultOnboardingState()) }, + cli: { getInstallStatus: vi.fn(async () => ({ supported: false })) } + } + }) + target = document.createElement('button') + target.dataset.contextualTourTarget = 'automations-create' + target.getBoundingClientRect = () => new DOMRect(100, 100, 120, 40) + document.body.append(target) + container = document.createElement('div') + document.body.append(container) + root = createRoot(container) +}) + +afterEach(async () => { + await act(async () => root.unmount()) + target.remove() + container.remove() +}) + +it('does not consume a restored-page tour before first-run onboarding content commits', async () => { + await act(async () => root.render()) + act(() => gate?.applyStartupOnboardingState(getDefaultOnboardingState())) + await settle() + + expect(useAppStore.getState().contextualToursAutoEligible).toBe(true) + expect(Object.values(useDialogRegistry.getState().startupSources)).not.toContain('pending') + expect({ + onboardingMounted: container.querySelector('[data-onboarding-modal]') !== null, + contentKinds: useDialogRegistry.getState().dialogEntries.map((entry) => entry.kind), + activeTour: useAppStore.getState().activeContextualTourId, + shown: useAppStore.getState().contextualTourShownThisSession, + seen: useAppStore.getState().contextualToursSeenIds + }).toEqual({ + onboardingMounted: false, + contentKinds: [], + activeTour: null, + shown: false, + seen: [] + }) + act(() => + useAppStore.getState().requestContextualTour('automations', 'automations_open', false, { + force: true + }) + ) + expect(useAppStore.getState().activeContextualTourId).toBeNull() + + await act(async () => chunk.resolve({ default: OnboardingFlow })) + await settle() + expect(container.querySelector('[data-onboarding-modal]')).not.toBeNull() + expect(useDialogRegistry.getState().dialogEntries.map((entry) => entry.kind)).toEqual([ + 'onboarding' + ]) + expect(useAppStore.getState().contextualToursSeenIds).toEqual([]) + + act(() => + gate?.setOnboarding({ ...getDefaultOnboardingState(), closedAt: 1, outcome: 'completed' }) + ) + await settle() + expect(useAppStore.getState().activeContextualTourId).toBe('automations') + expect(useAppStore.getState().contextualToursSeenIds).toEqual(['automations']) +}) + +it('waits for unknown onboarding even when the profile already belongs to the automatic-tour cohort', async () => { + await act(async () => root.render()) + await settle() + + expect(useDialogRegistry.getState().dialogEntries).toEqual([]) + expect(useAppStore.getState().activeContextualTourId).toBeNull() + expect(useAppStore.getState().contextualToursSeenIds).toEqual([]) + + act(() => + gate?.applyStartupOnboardingState({ + ...getDefaultOnboardingState(), + closedAt: 1, + outcome: 'completed' + }) + ) + await settle() + expect(useAppStore.getState().activeContextualTourId).toBe('automations') + expect(useAppStore.getState().contextualToursSeenIds).toEqual(['automations']) +}) + +it('cancels a shown tour when onboarding is reopened before its content mounts', async () => { + await act(async () => root.render()) + act(() => + gate?.applyStartupOnboardingState({ + ...getDefaultOnboardingState(), + closedAt: 1, + outcome: 'completed' + }) + ) + await settle() + expect(useAppStore.getState().activeContextualTourId).toBe('automations') + + await act(async () => showOnboardingFromRenderer()) + await settle() + + expect(container.querySelector('[data-onboarding-modal]')).toBeNull() + expect(useDialogRegistry.getState().dialogEntries).toEqual([]) + expect(useAppStore.getState().activeContextualTourId).toBeNull() + expect(useAppStore.getState().contextualToursSeenIds).toEqual(['automations']) +}) diff --git a/src/renderer/src/app-shell/self-opening-dialogs.test.tsx b/src/renderer/src/app-shell/self-opening-dialogs.test.tsx new file mode 100644 index 000000000000..9f36c62b8d34 --- /dev/null +++ b/src/renderer/src/app-shell/self-opening-dialogs.test.tsx @@ -0,0 +1,859 @@ +import { STARTUP_DISCOVERY_READ_TIMEOUT_MS } from '../startup/startup-discovery-read' +// @vitest-environment happy-dom + +import { act, StrictMode, useEffect, useLayoutEffect, useState } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, expect, it, vi } from 'vitest' +import { useAppStore } from '@/store' +import { useDialogRegistry } from '@/store/dialog-registry' +import { selectDialogPhase } from '@/store/dialog-registry-state' +import { resetDialogRegistryForTests } from '@/store/dialog-registry-test-state' +import { getDefaultSettings } from '../../../shared/constants' +import type { CrashReportRecord } from '../../../shared/crash-reporting' +import { NativeChatResumeOnRestartModal } from '../components/NativeChatResumeOnRestartModal' +import { CrashReportDialog } from '../components/crash-report/CrashReportDialog' +import { AppOpenFeatureTip } from '../components/feature-tips/AppOpenFeatureTip' +import { APP_OPEN_FEATURE_TIP_TOKEN } from '../components/feature-tips/feature-tip-startup-gate' +import { SshPassphraseDialog } from '../components/settings/SshPassphraseDialog' +import { RecoverableRenderErrorBoundary } from '../components/error-boundaries/RecoverableRenderErrorBoundary' +import { TooltipProvider } from '../components/ui/tooltip' +import { Dialog, DialogContent, DialogTitle } from '../components/ui/dialog' +import type { ResumeCandidate } from '../components/native-chat-resume-on-restart-grouping' +import { + _resetNativeChatRestartOffer, + getNativeChatRestartOffer, + useNativeChatRestartOffer +} from '../components/native-chat-resume-on-restart-store' +import { + _resetNativeChatResumeOnRestartDialog, + requestNativeChatResumeOnRestartDialog +} from '../components/native-chat-resume-on-restart-dialog' +import { resetLocalStructuredChatsForTests } from '@/runtime/local-structured-chats' + +const rpc = vi.hoisted(() => vi.fn()) +vi.mock('@/runtime/structured-agent-session-client', () => ({ + callStructuredAgentSession: rpc, + subscribeStructuredAgentSessionStatus: () => new Promise(() => {}) +})) +vi.mock('@/lib/activate-ai-vault-structured-session', () => ({ + activateAiVaultStructuredSession: vi.fn(async () => true) +})) +vi.mock('sonner', () => ({ toast: Object.assign(vi.fn(), { error: vi.fn() }) })) +vi.mock('@/lib/telemetry', () => ({ track: vi.fn() })) +const surface = vi.hoisted(() => ({ loaded: Promise.resolve(), suspended: false, error: false })) +// The real surfaces have their own tests; here they are a real Dialog, so their entries behave. +vi.mock('../components/crash-report/CrashReportDialogSurface', async () => { + const ui = await import('../components/ui/dialog') + return { + CrashReportDialogSurface: ({ + open, + report, + onOpenChange + }: { + open: boolean + report: CrashReportRecord | null + onOpenChange: (open: boolean) => void + }) => { + if (surface.error) { + throw new Error('surface failed') + } + if (surface.suspended) { + // Its lazy chunk is still loading: admitted, nothing on screen yet. + throw surface.loaded + } + return ( + + + Crash report +