Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
9f51800
fix(dialogs): dialogs that open by themselves take turns
brennanb2025 Oct 5, 2026
492d71f
fix(dialogs): count every open dialog, mark shown from rendered content
brennanb2025 Oct 5, 2026
bbbb4e9
test(dialogs): use a real user modal id in the prompt-turn tests
brennanb2025 Oct 5, 2026
6317bc7
fix(dialogs): a prompt on screen steps aside without unmounting; turn…
brennanb2025 Oct 5, 2026
b767ef5
fix(dialogs): the tip waits for a free modal slot; loading dialogs co…
brennanb2025 Oct 5, 2026
395c77d
fix(dialogs): a user's modal counts from the slot; prompts take no tr…
brennanb2025 Oct 6, 2026
9a272c5
Merge remote-tracking branch 'origin/main' into brennanb2025/rsc-laun…
brennanb2025 Oct 6, 2026
d2562d3
Merge remote-tracking branch 'origin/main' into brennanb2025/rsc-laun…
brennanb2025 Oct 6, 2026
5573ead
fix(dialogs): a shown automatic dialog stays on screen; dialogs opene…
brennanb2025 Oct 6, 2026
7978884
test(dialogs): a shown feature tip evicted by a user modal ends its turn
brennanb2025 Oct 6, 2026
f10a8c5
Merge remote-tracking branch 'origin/main' into brennanb2025/rsc-laun…
brennanb2025 Oct 6, 2026
5ebdbf3
Merge remote-tracking branch 'origin/main' into brennanb2025/rsc-laun…
brennanb2025 Oct 6, 2026
bdc45b7
Merge commit '83cf7cf5e28692a01efbb628c1794b1fc5714839' into brennanb…
brennanb2025 Oct 6, 2026
7755bba
fix(native-chat): keep the resume store under its line cap after the …
brennanb2025 Oct 6, 2026
8ea07d1
chore: restore main's lockfile (a local pnpm run added the @pnpm/exe …
brennanb2025 Oct 6, 2026
628ac4c
Merge remote-tracking branch 'origin/main' into brennanb2025/rsc-laun…
brennanb2025 Oct 7, 2026
9af03ab
refactor(dialogs): one dialog registry decides when self-opening dial…
brennanb2025 Oct 7, 2026
5c616cc
test(dialogs): pin that a launch offer never repeats a user-opened on…
brennanb2025 Oct 7, 2026
b224a28
fix(dialogs): answer the tip check as soon as onboarding is read, and…
brennanb2025 Oct 7, 2026
374c5cb
fix(dialogs): admission holds only once a self-opening dialog has pai…
brennanb2025 Oct 7, 2026
ad96c0d
fix(dialogs): code that waits for a modal also sees dialogs outside t…
brennanb2025 Oct 7, 2026
387dd22
refactor(feature-tips): drop the modal-slot tip path nothing opens an…
brennanb2025 Oct 7, 2026
86c3fb2
refactor(crash-report): Help over a report already on screen leaves i…
brennanb2025 Oct 7, 2026
40d259e
Merge remote-tracking branch 'origin/main' into brennanb2025/rsc-laun…
brennanb2025 Oct 7, 2026
0a9881c
refactor(dialogs): count committed content and remove host reservations
brennanb2025 Oct 7, 2026
2d86ff3
fix(tours): wait for first-run onboarding before starting tours
brennanb2025 Oct 7, 2026
d786f59
Simplify startup dialog ownership and remove dead modal paths
brennanb2025 Oct 7, 2026
f2dc4c0
Check exhaustive feature tip against narrowed value
brennanb2025 Oct 7, 2026
d0b6b07
Merge commit '00984ccb25040e5d4d7ff5d385668a64520312f6' into brennanb…
brennanb2025 Oct 7, 2026
45319de
Split startup catalog refresh and resume activity ownership
brennanb2025 Oct 7, 2026
2de8103
Observe startup tip test state after commit
brennanb2025 Oct 7, 2026
e0f4bd1
Merge current main and preserve launch resume decision timing
brennanb2025 Oct 8, 2026
0d8f16d
Merge origin/main 216ef2985a8 into brennanb2025/rsc-launch-prompt-tur…
brennanb2025 Oct 8, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion src/renderer/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
20 changes: 5 additions & 15 deletions src/renderer/src/app-shell/AppRootSurfaces.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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(
Expand All @@ -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')
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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'
Expand Down Expand Up @@ -215,11 +212,6 @@ export function AppRootSurfaces(props: {
<AddProjectFromFolderDialog />
</ModalBoundary>
) : null}
{activeModal === 'project-added' ? (
<ModalBoundary boundaryId="modal.project-added" resetKey>
<ProjectAddedDialog />
</ModalBoundary>
) : null}
</Suspense>
{/* Why: root overlays can render Radix <Tooltip>s; keep inside the shared provider so lazy surfaces mount from any entry point. */}
<Suspense fallback={null}>
Expand Down Expand Up @@ -256,11 +248,6 @@ export function AppRootSurfaces(props: {
<FeatureWallModal />
</ModalBoundary>
) : null}
{mountedLazyModalIds.has('feature-tips') ? (
<ModalBoundary boundaryId="modal.feature-tips" resetKey={activeModal === 'feature-tips'}>
<FeatureTipsModal />
</ModalBoundary>
) : null}
</Suspense>
{shouldMountSetupGuideTelemetryObserver ? (
<Suspense fallback={null}>
Expand Down Expand Up @@ -365,6 +352,9 @@ export function AppRootSurfaces(props: {
</RecoverableRenderErrorBoundary>
</Suspense>
) : null}
{onboardingGate.appOpenTipId ? (
<AppOpenFeatureTip tipId={onboardingGate.appOpenTipId} />
) : null}
{shouldMountDictationController ? (
<Suspense fallback={null}>
<OverlayBoundary boundaryId="overlay.dictation" resetKey={activeView}>
Expand Down
175 changes: 175 additions & 0 deletions src/renderer/src/app-shell/onboarding-contextual-tours.test.tsx
Original file line number Diff line number Diff line change
@@ -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<typeof Promise.withResolvers<{ default: typeof OnboardingFlow }>>
let LazyOnboarding: LazyExoticComponent<typeof OnboardingFlow>

function App(): React.JSX.Element {
const onboarding = useOnboardingAndFeatureTips()
gate = onboarding
useContextualTour('automations', true, 'automations_open', { recordFeatureInteraction: false })
return (
<>
{onboarding.onboarding && onboarding.shouldRender ? (
<Suspense fallback={null}>
<LazyOnboarding
onboarding={onboarding.onboarding}
onOnboardingChange={onboarding.setOnboarding}
/>
</Suspense>
) : null}
<ContextualTourOverlay />
</>
)
}

async function settle(): Promise<void> {
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(<App />))
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(<App />))
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(<App />))
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'])
})
Loading
Loading