From 9f518007ff020c64f602e1ee057eb20216a4cecd Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 5 Oct 2026 13:21:14 -0700 Subject: [PATCH 01/23] fix(dialogs): dialogs that open by themselves take turns After a restart several dialogs could open by themselves at once and stack: the resume-interrupted-chats modal, a feature tip, a contextual tour, the crash report, and the SSH passphrase prompt. Nothing scheduled them. One arbiter in the UI store now decides which automatic prompt is on screen: - automatic prompts (resume offer, crash report, feature tip) request a turn, shown one at a time by priority; the one on screen keeps it until it closes, and an owner that unmounts releases it - at launch they wait (up to 2 s) for this machine's resume read, so the resume offer goes first - user dialogs and dialogs answering something in flight (SSH credentials, confirmations, pinned-tab close, WebAuthn, workspace fallback, onboarding) are never delayed: they register as visible, and a prompt on screen steps aside until they close - a feature tip is marked seen, and a launch crash report acknowledged, only once actually shown - tours read the arbiter instead of one shared boolean that any dialog's close cleared --- .../use-onboarding-and-feature-tips.ts | 42 ++- .../NativeChatResumeOnRestartModal.test.tsx | 12 +- .../NativeChatResumeOnRestartModal.tsx | 42 ++- .../WorktreeBaseFallbackDialog.test.tsx | 2 +- .../components/WorktreeBaseFallbackDialog.tsx | 9 +- .../automatic-prompt-turns.test.tsx | 323 ++++++++++++++++++ .../use-automatic-prompt-turn.ts | 53 +++ .../browser-webauthn-account-dialog.test.tsx | 2 +- .../browser-webauthn-account-dialog.tsx | 10 +- .../components/confirmation-dialog.test.tsx | 2 +- .../src/components/confirmation-dialog.tsx | 16 +- .../ContextualTourOverlay.remeasure.test.tsx | 4 +- .../ContextualTourOverlay.tsx | 3 +- .../ContextualTourOverlay.visibility.test.tsx | 2 +- .../contextual-tours/use-contextual-tour.ts | 9 +- .../crash-report/CrashReportDialog.tsx | 151 +++++--- .../feature-tips/FeatureTipsModal.tsx | 5 +- .../link-routing-preference-dialog.tsx | 10 +- .../native-chat-resume-on-restart-dialog.ts | 41 ++- .../native-chat-resume-on-restart-store.ts | 12 +- .../components/onboarding/OnboardingFlow.tsx | 3 + .../settings/SshPassphraseDialog.tsx | 4 + .../navigation/use-reveal-requests.test.tsx | 2 +- .../NativeChatResumeStatusSegment.test.tsx | 10 +- .../NativeChatResumeStatusSegment.tsx | 4 +- .../terminal-pane/PinnedTabCloseDialog.tsx | 4 + .../TerminalSessionStateSaveFailureDialog.tsx | 2 + .../store/slices/ui-contextual-tours.test.ts | 39 ++- .../src/store/slices/ui-slice-test-harness.ts | 13 +- src/renderer/src/store/slices/ui.ts | 3 + .../slices/ui/automatic-prompt-turns.test.ts | 197 +++++++++++ .../store/slices/ui/automatic-prompt-turns.ts | 105 ++++++ .../slices/ui/ui-slice-contract-contextual.ts | 2 - .../ui/ui-slice-contract-prompt-turns.ts | 13 + .../src/store/slices/ui/ui-slice-contract.ts | 2 + .../slices/ui/ui-slice-prompt-turn-actions.ts | 85 +++++ .../store/slices/ui/ui-slice-tour-actions.ts | 10 +- 37 files changed, 1096 insertions(+), 152 deletions(-) create mode 100644 src/renderer/src/components/automatic-prompts/automatic-prompt-turns.test.tsx create mode 100644 src/renderer/src/components/automatic-prompts/use-automatic-prompt-turn.ts create mode 100644 src/renderer/src/store/slices/ui/automatic-prompt-turns.test.ts create mode 100644 src/renderer/src/store/slices/ui/automatic-prompt-turns.ts create mode 100644 src/renderer/src/store/slices/ui/ui-slice-contract-prompt-turns.ts create mode 100644 src/renderer/src/store/slices/ui/ui-slice-prompt-turn-actions.ts diff --git a/src/renderer/src/app-shell/use-onboarding-and-feature-tips.ts b/src/renderer/src/app-shell/use-onboarding-and-feature-tips.ts index a170bcce16f7..f358aa64866e 100644 --- a/src/renderer/src/app-shell/use-onboarding-and-feature-tips.ts +++ b/src/renderer/src/app-shell/use-onboarding-and-feature-tips.ts @@ -12,7 +12,11 @@ import { } from '../components/feature-tips/feature-tip-telemetry' import { useAppStore } from '../store' import { isWebClientLocation } from '../lib/web-client-location' +import { useAutomaticPromptTurn } from '../components/automatic-prompts/use-automatic-prompt-turn' +import { AUTOMATIC_PROMPT_MODAL_KEY } from '../store/slices/ui/automatic-prompt-turns' +import { MODAL_DISMISSED_KEY } from '../store/slices/modal-slot-dismissal' import type { OnboardingState } from '../../../shared/onboarding-state-types' +import type { FeatureTipId } from '../../../shared/feature-tips' export type OnboardingGate = ReturnType @@ -25,6 +29,10 @@ export function useOnboardingAndFeatureTips() { const [onboardingLoaded, setOnboardingLoaded] = useState(false) const [featureTipCliInstalled, setFeatureTipCliInstalled] = useState(null) const promptedThisSessionRef = useRef(false) + // The app-open tip, chosen and waiting for its turn among dialogs that open by themselves. + const [pendingTipId, setPendingTipId] = useState(null) + const shownTipIdRef = useRef(null) + const tipTurn = useAutomaticPromptTurn('feature-tip', pendingTipId !== null) const suppressedByOnboardingThisSessionRef = useRef(false) const activeModal = useAppStore((s) => s.activeModal) @@ -115,17 +123,7 @@ export function useOnboardingAndFeatureTips() { } promptedThisSessionRef.current = true - if (featureTipsDecision.tipId === 'orca-cli') { - trackOrcaCliFeatureTipShown('app_open') - } else if (featureTipsDecision.tipId === 'cmd-j-palette') { - trackCmdJPaletteFeatureTipShown('app_open') - } - // Why: mark seen on show so a quit/crash before dismiss doesn't reappear it next launch. - actions.markFeatureTipsSeen([featureTipsDecision.tipId]) - actions.openModal('feature-tips', { - source: 'app_open', - tipId: featureTipsDecision.tipId - }) + setPendingTipId(featureTipsDecision.tipId) }, [ activeModal, actions, @@ -137,6 +135,28 @@ export function useOnboardingAndFeatureTips() { settings ]) + useEffect(() => { + if (!tipTurn || pendingTipId === null || shownTipIdRef.current === pendingTipId) { + return + } + shownTipIdRef.current = pendingTipId + if (pendingTipId === 'orca-cli') { + trackOrcaCliFeatureTipShown('app_open') + } else if (pendingTipId === 'cmd-j-palette') { + trackCmdJPaletteFeatureTipShown('app_open') + } + // Why: mark seen on show so a quit/crash before dismiss doesn't reappear it next launch. + actions.markFeatureTipsSeen([pendingTipId]) + actions.openModal('feature-tips', { + source: 'app_open', + tipId: pendingTipId, + [AUTOMATIC_PROMPT_MODAL_KEY]: 'feature-tip', + // Closing it, or a modal the user opens replacing it, ends its turn. It was shown, so it is + // not raised again. + [MODAL_DISMISSED_KEY]: () => setPendingTipId(null) + }) + }, [actions, pendingTipId, tipTurn]) + return { applyStartupOnboardingState, onboarding, diff --git a/src/renderer/src/components/NativeChatResumeOnRestartModal.test.tsx b/src/renderer/src/components/NativeChatResumeOnRestartModal.test.tsx index 1a139b18d26b..176f477ec975 100644 --- a/src/renderer/src/components/NativeChatResumeOnRestartModal.test.tsx +++ b/src/renderer/src/components/NativeChatResumeOnRestartModal.test.tsx @@ -308,7 +308,7 @@ it('closes on Resume and shows the resume in the status bar until the host answe expect(document.body.textContent).not.toContain('Resuming') expect(document.querySelector('[role="dialog"]')).toBeNull() // Nothing is left to show, so the reopen request is retired rather than left to latch. - expect(getNativeChatResumeOnRestartDialogRequest()).toBe(false) + expect(getNativeChatResumeOnRestartDialogRequest()).toBeNull() expect(toast).toHaveBeenCalledWith('Resumed 2 chats and asked them to continue') }) @@ -364,7 +364,7 @@ it('starts each opening from the default ticks, not the ones left at the last cl await act(async () => button('Close').click()) expect(document.querySelector('[role="dialog"]')).toBeNull() - await act(async () => requestNativeChatResumeOnRestartDialog()) + await act(async () => requestNativeChatResumeOnRestartDialog('user')) expect(checkbox(1).getAttribute('data-state')).toBe('checked') expect(button('Resume 2 chats').disabled).toBe(false) }) @@ -654,7 +654,7 @@ it('lists a chat the resume could not carry on when the dialog reopens, with wha await act(async () => button('Resume 2 chats').click()) // Resume hands off to the status bar; its failure entry (or the toast's Show) reopens the list. expect(document.querySelector('[role="dialog"]')).toBeNull() - await act(async () => requestNativeChatResumeOnRestartDialog()) + await act(async () => requestNativeChatResumeOnRestartDialog('user')) const dialog = document.querySelector('[role="dialog"]') expect(dialog).not.toBeNull() // Unchanged chrome: the title, the preference box, and the two footer actions. @@ -707,7 +707,7 @@ it.each(['footer', 'row'] as const)( await mount() // Old failures never raise the launch dialog by themselves; the status entry does. expect(document.querySelector('[role="dialog"]')).toBeNull() - await act(async () => requestNativeChatResumeOnRestartDialog()) + await act(async () => requestNativeChatResumeOnRestartDialog('user')) expect(document.querySelector('[role="dialog"]')?.textContent).toContain( 'Close it, then retry.' ) @@ -792,7 +792,7 @@ it('keeps a failure the host marks unretryable out of Resume, even after a tick' let failed: unknown[] = [failure('b')] rpc.mockImplementation(async () => ({ sessions: [], failed })) await mount() - await act(async () => requestNativeChatResumeOnRestartDialog()) + await act(async () => requestNativeChatResumeOnRestartDialog('user')) // An older host sends no flag, and the row stays selectable as it always was. expect(checkbox(0).hasAttribute('disabled')).toBe(false) await act(async () => checkbox(0).click()) @@ -820,7 +820,7 @@ it('dismisses one failed chat by name, and every record through Dismiss all', as } ) await mount() - await act(async () => requestNativeChatResumeOnRestartDialog()) + await act(async () => requestNativeChatResumeOnRestartDialog('user')) expect(document.querySelectorAll('[role="checkbox"]')).toHaveLength(3) await act(async () => button('Dismiss "Prompt a" in workspace').click()) expect(rpc.mock.calls.at(-1)?.slice(1)).toEqual([ diff --git a/src/renderer/src/components/NativeChatResumeOnRestartModal.tsx b/src/renderer/src/components/NativeChatResumeOnRestartModal.tsx index 4d7e7c8bfb7f..ae2defa31ebd 100644 --- a/src/renderer/src/components/NativeChatResumeOnRestartModal.tsx +++ b/src/renderer/src/components/NativeChatResumeOnRestartModal.tsx @@ -1,4 +1,4 @@ -import { useCallback, useMemo, useState, useSyncExternalStore } from 'react' +import { useCallback, useEffect, useMemo, useState, useSyncExternalStore } from 'react' import { useNativeChatRestartOfferEnabled } from './native-chat-restart-offer-gate' import { RotateCcw } from 'lucide-react' import { Button } from './ui/button' @@ -23,9 +23,14 @@ import { import type { ResumeCandidate, ResumeFailure } from './native-chat-resume-on-restart-grouping' import { consumeNativeChatResumeOnRestartDialogRequest, + getNativeChatResumeLaunchDecided, getNativeChatResumeOnRestartDialogRequest, subscribeNativeChatResumeOnRestartDialog } from './native-chat-resume-on-restart-dialog' +import { + useAutomaticPromptTurn, + usePromptBlockingDialog +} from './automatic-prompts/use-automatic-prompt-turn' import { continueNativeChatRestartOffer, dismissNativeChatRestartOffer, @@ -74,11 +79,34 @@ export function NativeChatResumeOnRestartModal(): React.JSX.Element | null { ) // Open is an external one-shot request, never mirrored into local state: the launch load and the // status-bar entry both raise it, and a copy here would go stale against whichever raised it last. - const open = useSyncExternalStore( + const request = useSyncExternalStore( subscribeNativeChatResumeOnRestartDialog, getNativeChatResumeOnRestartDialogRequest, getNativeChatResumeOnRestartDialogRequest ) + const launchDecided = useSyncExternalStore( + subscribeNativeChatResumeOnRestartDialog, + getNativeChatResumeLaunchDecided, + getNativeChatResumeLaunchDecided + ) + // Only a dialog that can render asks for a turn, so a hidden one never holds others back. + const renderable = offerEnabled && rows.length > 0 + // Raised by the launch, it takes its turn among the dialogs that open by themselves; opened by the + // user, it shows at once and the others wait for it. + const launchTurn = useAutomaticPromptTurn( + 'native-chat-resume', + request === 'launch' && renderable + ) + usePromptBlockingDialog('native-chat-resume', request === 'user' && renderable) + const open = request === 'user' || launchTurn + const settleLaunchPromptDiscovery = useAppStore((store) => store.settleLaunchPromptDiscovery) + useEffect(() => { + // After the turn request above (effects run in order), so a fast crash report or tip cannot + // take the first turn in between. + if (launchDecided) { + settleLaunchPromptDiscovery() + } + }, [launchDecided, settleLaunchPromptDiscovery]) const updateSettings = useAppStore((store) => store.updateSettings) const [dontAskAgain, setDontAskAgain] = useState(false) // The store's: the resume outlives this dialog, which can close or reopen mid-run. @@ -90,10 +118,12 @@ export function NativeChatResumeOnRestartModal(): React.JSX.Element | null { const [overrides, setOverrides] = useState>(() => new Map()) // Each opening starts from the rows' defaults. This component never unmounts, so an untick made // before a close would otherwise greet a reopen, e.g. as "Resume 0 chats" over what a run left. - const [openedWith, setOpenedWith] = useState(open) - if (openedWith !== open) { - setOpenedWith(open) - if (open) { + // Keyed on the request, not visibility: stepping aside for another dialog keeps the user's ticks. + const requested = request !== null + const [openedWith, setOpenedWith] = useState(requested) + if (openedWith !== requested) { + setOpenedWith(requested) + if (requested) { setOverrides(new Map()) } } diff --git a/src/renderer/src/components/WorktreeBaseFallbackDialog.test.tsx b/src/renderer/src/components/WorktreeBaseFallbackDialog.test.tsx index 2016fa4072a9..1e98f967fd7a 100644 --- a/src/renderer/src/components/WorktreeBaseFallbackDialog.test.tsx +++ b/src/renderer/src/components/WorktreeBaseFallbackDialog.test.tsx @@ -17,7 +17,7 @@ let root: Root | null = null async function renderDialog(activeModal: AppState['activeModal'] = 'none'): Promise { useAppStore.setState({ activeModal, - setContextualToursBlockingSurfaceVisible: vi.fn() + setPromptBlockingDialogVisible: vi.fn() }) const container = document.createElement('div') document.body.appendChild(container) diff --git a/src/renderer/src/components/WorktreeBaseFallbackDialog.tsx b/src/renderer/src/components/WorktreeBaseFallbackDialog.tsx index e64e59045119..3f1a53c3771e 100644 --- a/src/renderer/src/components/WorktreeBaseFallbackDialog.tsx +++ b/src/renderer/src/components/WorktreeBaseFallbackDialog.tsx @@ -11,6 +11,7 @@ import { } from '@/components/ui/dialog' import { translate } from '@/i18n/i18n' import { useAppStore } from '@/store' +import { usePromptBlockingDialog } from '@/components/automatic-prompts/use-automatic-prompt-turn' import { dismissWorktreeBaseFallbackNotice, getWorktreeBaseFallbackNotice, @@ -24,9 +25,6 @@ export default function WorktreeBaseFallbackDialog(): React.JSX.Element { getWorktreeBaseFallbackNotice ) const activeModal = useAppStore((state) => state.activeModal) - const setContextualToursBlockingSurfaceVisible = useAppStore( - (state) => state.setContextualToursBlockingSurfaceVisible - ) const lastNoticeRef = useRef(notice) const displayedNotice = notice ?? lastNoticeRef.current const open = notice !== null && activeModal === 'none' @@ -37,10 +35,7 @@ export default function WorktreeBaseFallbackDialog(): React.JSX.Element { } }, [notice]) - useEffect(() => { - setContextualToursBlockingSurfaceVisible(open) - return () => setContextualToursBlockingSurfaceVisible(false) - }, [open, setContextualToursBlockingSurfaceVisible]) + usePromptBlockingDialog('worktree-base-fallback', open) return ( 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() })) +// The real surface is covered by its own tests; here only whether it is on screen matters. +vi.mock('../crash-report/CrashReportDialogSurface', () => ({ + CrashReportDialogSurface: ({ + report, + onOpenChange + }: { + report: CrashReportRecord | null + onOpenChange: (open: boolean) => void + }) => ( +
+ {report?.status} + +
+ ) +})) + +globalThis.IS_REACT_ACT_ENVIRONMENT = true +let root: Root +let container: HTMLDivElement + +const offered: ResumeCandidate[] = [ + { + sessionId: 'a', + workspaceId: 'workspace', + agent: 'codex', + trigger: 'update', + latestPrompt: 'Prompt a', + recordedAt: 1_800_000_000_000, + executionHostId: 'local', + workspaceKind: 'git-worktree' + } +] + +const pendingCrash: CrashReportRecord = { + id: 'crash-1', + createdAt: '2026-10-05T00:00:00.000Z', + status: 'pending', + source: 'renderer', + processType: 'renderer', + reason: 'crashed', + exitCode: 5, + appVersion: '1.0.0', + platform: 'darwin', + osRelease: 'test', + arch: 'arm64', + electronVersion: '1', + chromeVersion: '1', + details: {} +} + +const crashReports = { + getLatestPending: vi.fn(async (): Promise => null), + getLatestReport: vi.fn(async (): Promise => null), + dismiss: vi.fn(async () => undefined) +} + +async function flush(): Promise { + for (let i = 0; i < 5; i += 1) { + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 0)) + }) + } +} + +async function mount(node: React.ReactNode): Promise { + await act(async () => root.render({node})) + await flush() +} + +function resumeOnScreen(): boolean { + return document.body.textContent?.includes('Resume interrupted chats?') === true +} + +function crashOnScreen(): boolean { + return document.querySelector('[data-testid="crash-report"]') !== null +} + +function sshOnScreen(): boolean { + return document.body.textContent?.includes('SSH Key Passphrase') === true +} + +function closeResume(): void { + act(() => { + document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true })) + }) +} + +beforeEach(() => { + rpc.mockReset() + crashReports.getLatestPending.mockReset().mockResolvedValue(null) + crashReports.getLatestReport.mockReset().mockResolvedValue(null) + crashReports.dismiss.mockReset().mockResolvedValue(undefined) + _resetNativeChatRestartOffer() + useAppStore.setState(useAppStore.getInitialState(), true) + useAppStore.setState({ + settings: { ...getDefaultSettings(''), experimentalStructuredNativeChat: true }, + // Far enough out that only the resume read can end the launch wait in these cases. + launchPromptDiscoveryDeadline: Date.now() + 60_000 + }) + Object.assign(window, { + api: { + crashReports, + ui: { onOpenCrashReport: () => () => {}, set: vi.fn(async () => undefined) }, + ssh: { submitCredential: vi.fn(async () => undefined) }, + cli: { getInstallStatus: vi.fn(async () => ({ supported: false })) }, + gh: { viewer: vi.fn(async () => null) } + } + }) + container = document.createElement('div') + document.body.append(container) + root = createRoot(container) +}) + +afterEach(() => { + act(() => root.unmount()) + container.remove() + useAppStore.getState().settleLaunchPromptDiscovery() + useAppStore.setState(useAppStore.getInitialState(), true) + _resetNativeChatRestartOffer() +}) + +it('a fast crash report waits for a slow local resume read, which goes first', async () => { + const read = Promise.withResolvers() + rpc.mockImplementation(async () => read.promise) + crashReports.getLatestPending.mockResolvedValue(pendingCrash) + + await mount( + <> + + + + ) + expect(crashOnScreen()).toBe(false) + // Acknowledged only when shown. + expect(crashReports.dismiss).not.toHaveBeenCalled() + + await act(async () => read.resolve({ sessions: offered })) + await flush() + expect(resumeOnScreen()).toBe(true) + expect(crashOnScreen()).toBe(false) + + closeResume() + await flush() + expect(resumeOnScreen()).toBe(false) + expect(crashOnScreen()).toBe(true) + expect(crashReports.dismiss).toHaveBeenCalledWith({ reportId: 'crash-1' }) +}) + +it('shows the crash report at once when the resume read finds nothing', async () => { + rpc.mockResolvedValue({ sessions: [] }) + crashReports.getLatestPending.mockResolvedValue(pendingCrash) + + await mount( + <> + + + + ) + expect(resumeOnScreen()).toBe(false) + expect(crashOnScreen()).toBe(true) +}) + +it('a failed acknowledgment does not hold the crash report back', async () => { + useAppStore.getState().settleLaunchPromptDiscovery() + crashReports.getLatestPending.mockResolvedValue(pendingCrash) + crashReports.dismiss.mockRejectedValue(new Error('disk full')) + const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {}) + + await mount() + expect(crashOnScreen()).toBe(true) + // Still pending, so the dialog dismisses it on close instead. + expect(document.querySelector('[data-testid="crash-report"]')?.textContent).toContain('pending') + consoleError.mockRestore() +}) + +it('an SSH credential prompt shows over a visible resume offer without waiting', async () => { + rpc.mockResolvedValue({ sessions: offered }) + await mount( + <> + + + + ) + expect(resumeOnScreen()).toBe(true) + + await act(async () => + useAppStore.getState().enqueueSshCredentialRequest({ + requestId: 'r1', + targetId: 'host', + kind: 'passphrase', + detail: '~/.ssh/id_ed25519' + }) + ) + await flush() + expect(sshOnScreen()).toBe(true) + expect(resumeOnScreen()).toBe(false) + + await act(async () => useAppStore.getState().removeSshCredentialRequest('r1')) + await flush() + expect(sshOnScreen()).toBe(false) + expect(resumeOnScreen()).toBe(true) +}) + +it('an SSH credential prompt never waits for the launch read', async () => { + rpc.mockImplementation(() => new Promise(() => {})) + await mount( + <> + + + + ) + expect(useAppStore.getState().launchPromptDiscoveryPending).toBe(true) + await act(async () => + useAppStore.getState().enqueueSshCredentialRequest({ + requestId: 'r1', + targetId: 'host', + kind: 'password', + detail: 'me@host' + }) + ) + await flush() + expect(document.body.textContent).toContain('SSH Password') +}) + +it('a modal the user opens hides the resume offer, which comes back after', async () => { + rpc.mockResolvedValue({ sessions: offered }) + await mount() + expect(resumeOnScreen()).toBe(true) + + await act(async () => useAppStore.getState().openModal('settings')) + expect(resumeOnScreen()).toBe(false) + await act(async () => useAppStore.getState().closeModal()) + expect(resumeOnScreen()).toBe(true) +}) + +function Toggle({ children }: { children: React.ReactNode }): React.JSX.Element | null { + const [shown, setShown] = useState(true) + useEffect(() => { + const hide = (): void => setShown(false) + window.addEventListener('test-unmount', hide) + return () => window.removeEventListener('test-unmount', hide) + }, []) + return shown ? <>{children} : null +} + +it('an owner that unmounts mid-turn releases it', async () => { + useAppStore.getState().settleLaunchPromptDiscovery() + crashReports.getLatestPending.mockResolvedValue(pendingCrash) + await mount( + + + + ) + expect(useAppStore.getState().automaticPromptShownId).toBe('crash-report') + + await act(async () => window.dispatchEvent(new Event('test-unmount'))) + expect(useAppStore.getState().automaticPromptRequests).toEqual([]) + expect(useAppStore.getState().automaticPromptShownId).toBeNull() +}) + +function FeatureTipHarness(): null { + const gate = useOnboardingAndFeatureTips() + const { applyStartupOnboardingState } = gate + useEffect(() => { + applyStartupOnboardingState({ ...getDefaultOnboardingState(), closedAt: 1 }) + }, [applyStartupOnboardingState]) + return null +} + +it('marks a feature tip seen only when it is actually shown', async () => { + useAppStore.setState({ persistedUIReady: true, featureTipsSeenIds: [] }) + useAppStore.getState().settleLaunchPromptDiscovery() + // The resume offer holds the turn. + useAppStore.getState().requestAutomaticPrompt('native-chat-resume') + useAppStore.getState().markAutomaticPromptShown('native-chat-resume') + + await mount() + expect(useAppStore.getState().automaticPromptRequests.map((r) => r.id)).toContain('feature-tip') + expect(useAppStore.getState().featureTipsSeenIds).toEqual([]) + expect(useAppStore.getState().activeModal).toBe('none') + + await act(async () => useAppStore.getState().releaseAutomaticPrompt('native-chat-resume')) + await flush() + const { activeModal, modalData, featureTipsSeenIds } = useAppStore.getState() + expect(activeModal).toBe('feature-tips') + expect(modalData[AUTOMATIC_PROMPT_MODAL_KEY]).toBe('feature-tip') + expect(featureTipsSeenIds).toEqual([modalData.tipId]) + + // Closing the tip ends its turn. + await act(async () => useAppStore.getState().closeModal()) + await flush() + expect(useAppStore.getState().automaticPromptRequests).toEqual([]) +}) diff --git a/src/renderer/src/components/automatic-prompts/use-automatic-prompt-turn.ts b/src/renderer/src/components/automatic-prompts/use-automatic-prompt-turn.ts new file mode 100644 index 000000000000..7e520d57eb47 --- /dev/null +++ b/src/renderer/src/components/automatic-prompts/use-automatic-prompt-turn.ts @@ -0,0 +1,53 @@ +import { useEffect, useId, useLayoutEffect } from 'react' +import { useAppStore } from '@/store' +import { + selectVisibleAutomaticPromptId, + type AutomaticPromptId +} from '@/store/slices/ui/automatic-prompt-turns' + +/** + * Asks for a turn while `wanted` and returns whether this prompt is the one to show now. + * + * `wanted` must be false whenever the owner could not render the prompt, so a request never holds a + * turn nobody sees. Turning it off, closing, and unmounting all release the turn. + */ +export function useAutomaticPromptTurn(id: AutomaticPromptId, wanted: boolean): boolean { + const requestAutomaticPrompt = useAppStore((s) => s.requestAutomaticPrompt) + const releaseAutomaticPrompt = useAppStore((s) => s.releaseAutomaticPrompt) + const markAutomaticPromptShown = useAppStore((s) => s.markAutomaticPromptShown) + const visible = useAppStore((s) => wanted && selectVisibleAutomaticPromptId(s) === id) + + useEffect(() => { + if (!wanted) { + return + } + requestAutomaticPrompt(id) + return () => releaseAutomaticPrompt(id) + }, [id, wanted, requestAutomaticPrompt, releaseAutomaticPrompt]) + + useEffect(() => { + if (visible) { + markAutomaticPromptShown(id) + } + }, [id, visible, markAutomaticPromptShown]) + + return visible +} + +/** + * Registers a dialog the user opened, or one answering something in flight, while it is visible. + * It is never delayed; automatic prompts wait behind it, and one already showing steps aside. + */ +export function usePromptBlockingDialog(name: string, visible: boolean): void { + const setPromptBlockingDialogVisible = useAppStore((s) => s.setPromptBlockingDialogVisible) + // Per instance, so two copies of one dialog never clear each other's entry. + const id = `${name}:${useId()}` + // Layout effect: a prompt already showing steps aside before this dialog's first paint. + useLayoutEffect(() => { + if (!visible) { + return + } + setPromptBlockingDialogVisible(id, true) + return () => setPromptBlockingDialogVisible(id, false) + }, [id, visible, setPromptBlockingDialogVisible]) +} diff --git a/src/renderer/src/components/browser-webauthn-account-dialog.test.tsx b/src/renderer/src/components/browser-webauthn-account-dialog.test.tsx index 69120a437779..c4226a9adc79 100644 --- a/src/renderer/src/components/browser-webauthn-account-dialog.test.tsx +++ b/src/renderer/src/components/browser-webauthn-account-dialog.test.tsx @@ -14,7 +14,7 @@ const { respondMock, setBlockingSurfaceMock } = vi.hoisted(() => ({ vi.mock('@/i18n/i18n', () => ({ translate: (_key: string, fallback: string) => fallback })) vi.mock('@/store', () => ({ useAppStore: (selector: (state: unknown) => unknown) => - selector({ setContextualToursBlockingSurfaceVisible: setBlockingSurfaceMock }) + selector({ setPromptBlockingDialogVisible: setBlockingSurfaceMock }) })) import { BrowserWebAuthnAccountDialog } from './browser-webauthn-account-dialog' diff --git a/src/renderer/src/components/browser-webauthn-account-dialog.tsx b/src/renderer/src/components/browser-webauthn-account-dialog.tsx index cb7bc9ff2c37..b6eac6f27fe4 100644 --- a/src/renderer/src/components/browser-webauthn-account-dialog.tsx +++ b/src/renderer/src/components/browser-webauthn-account-dialog.tsx @@ -11,7 +11,7 @@ import { DialogTitle } from '@/components/ui/dialog' import { translate } from '@/i18n/i18n' -import { useAppStore } from '@/store' +import { usePromptBlockingDialog } from '@/components/automatic-prompts/use-automatic-prompt-turn' import type { BrowserWebAuthnAccount, BrowserWebAuthnAccountRequest @@ -34,9 +34,6 @@ export function BrowserWebAuthnAccountDialog(): React.JSX.Element { const [respondingRequestId, setRespondingRequestId] = useState(null) const requestsRef = useRef(requests) const firstAccountRef = useRef(null) - const setContextualToursBlockingSurfaceVisible = useAppStore( - (state) => state.setContextualToursBlockingSurfaceVisible - ) const activeRequest = requests[0] ?? null const lastRequestRef = useRef(activeRequest) const displayedRequest = activeRequest ?? lastRequestRef.current @@ -74,10 +71,7 @@ export function BrowserWebAuthnAccountDialog(): React.JSX.Element { } }, [removeRequest]) - useEffect(() => { - setContextualToursBlockingSurfaceVisible(activeRequest !== null) - return () => setContextualToursBlockingSurfaceVisible(false) - }, [activeRequest, setContextualToursBlockingSurfaceVisible]) + usePromptBlockingDialog('browser-webauthn-account', activeRequest !== null) useEffect(() => { if (!activeRequest) { diff --git a/src/renderer/src/components/confirmation-dialog.test.tsx b/src/renderer/src/components/confirmation-dialog.test.tsx index 5097a5d7786b..e4bc5916869c 100644 --- a/src/renderer/src/components/confirmation-dialog.test.tsx +++ b/src/renderer/src/components/confirmation-dialog.test.tsx @@ -7,7 +7,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest' vi.mock('@/store', () => ({ useAppStore: (selector: (state: Record) => unknown) => - selector({ setContextualToursBlockingSurfaceVisible: vi.fn() }) + selector({ setPromptBlockingDialogVisible: vi.fn() }) })) import { ConfirmationDialogProvider } from './confirmation-dialog' diff --git a/src/renderer/src/components/confirmation-dialog.tsx b/src/renderer/src/components/confirmation-dialog.tsx index 615a2400bc65..7069d8ef4bff 100644 --- a/src/renderer/src/components/confirmation-dialog.tsx +++ b/src/renderer/src/components/confirmation-dialog.tsx @@ -1,4 +1,4 @@ -import React, { useCallback, useEffect, useRef, useState } from 'react' +import React, { useCallback, useRef, useState } from 'react' import { Button } from '@/components/ui/button' import { Checkbox } from '@/components/ui/checkbox' @@ -16,7 +16,7 @@ import { type ConfirmationDialogContextValue, type ConfirmationDialogOptions } from '@/components/confirmation-dialog-context' -import { useAppStore } from '@/store' +import { usePromptBlockingDialog } from '@/components/automatic-prompts/use-automatic-prompt-turn' import { translate } from '@/i18n/i18n' import { cn } from '@/lib/utils' @@ -37,9 +37,6 @@ export function ConfirmationDialogProvider({ const [dontAskAgain, setDontAskAgain] = useState(false) const activeRequest = queue[0] ?? null const activeRequestRef = useRef(activeRequest) - const setContextualToursBlockingSurfaceVisible = useAppStore( - (s) => s.setContextualToursBlockingSurfaceVisible - ) const lastDisplayedRequestRef = useRef(activeRequest) activeRequestRef.current = activeRequest if (activeRequest) { @@ -49,12 +46,9 @@ export function ConfirmationDialogProvider({ const displayedRequest = activeRequest ?? lastDisplayedRequestRef.current const Icon = displayedRequest?.options.icon - useEffect(() => { - // Why: this provider's dialog is not represented by activeModal. Block - // contextual tours so they cannot appear behind confirmation prompts. - setContextualToursBlockingSurfaceVisible(activeRequest !== null) - return () => setContextualToursBlockingSurfaceVisible(false) - }, [activeRequest, setContextualToursBlockingSurfaceVisible]) + // Why: this provider's dialog is not represented by activeModal, so it registers itself; tours and + // automatic prompts then cannot appear behind or over it. + usePromptBlockingDialog('confirmation', activeRequest !== null) const confirm = useCallback((options) => { return new Promise((resolve) => { diff --git a/src/renderer/src/components/contextual-tours/ContextualTourOverlay.remeasure.test.tsx b/src/renderer/src/components/contextual-tours/ContextualTourOverlay.remeasure.test.tsx index 31a9b55f7679..217a86ebf205 100644 --- a/src/renderer/src/components/contextual-tours/ContextualTourOverlay.remeasure.test.tsx +++ b/src/renderer/src/components/contextual-tours/ContextualTourOverlay.remeasure.test.tsx @@ -89,7 +89,7 @@ describe('ContextualTourOverlay re-measure triggers', () => { activeContextualTourStepIndex: 1, activeModal: 'none', contextualToursOnboardingVisible: false, - contextualToursBlockingSurfaceVisible: false, + promptBlockingDialogIds: [], activeContextualTourSuppressed: false }) await mountOverlay() @@ -115,7 +115,7 @@ describe('ContextualTourOverlay re-measure triggers', () => { activeContextualTourStepIndex: 1, activeModal: 'none', contextualToursOnboardingVisible: false, - contextualToursBlockingSurfaceVisible: false, + promptBlockingDialogIds: [], activeContextualTourSuppressed: false }) await mountOverlay() diff --git a/src/renderer/src/components/contextual-tours/ContextualTourOverlay.tsx b/src/renderer/src/components/contextual-tours/ContextualTourOverlay.tsx index 119ca73d451e..d9744d4f2053 100644 --- a/src/renderer/src/components/contextual-tours/ContextualTourOverlay.tsx +++ b/src/renderer/src/components/contextual-tours/ContextualTourOverlay.tsx @@ -1,5 +1,6 @@ import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, type JSX } from 'react' import { useAppStore } from '@/store' +import { selectPromptSurfaceVisible } from '@/store/slices/ui/automatic-prompt-turns' import { getContextualTour, type ContextualTourId, @@ -39,7 +40,7 @@ export function ContextualTourOverlay(): JSX.Element | null { ) const activeModal = useAppStore((s) => s.activeModal) const onboardingVisible = useAppStore((s) => s.contextualToursOnboardingVisible) - const blockingSurfaceVisible = useAppStore((s) => s.contextualToursBlockingSurfaceVisible) + const blockingSurfaceVisible = useAppStore(selectPromptSurfaceVisible) const activeTourSuppressed = useAppStore((s) => s.activeContextualTourSuppressed) const keybindings = useAppStore((s) => s.keybindings) const activeTabId = useAppStore((s) => s.activeTabId) diff --git a/src/renderer/src/components/contextual-tours/ContextualTourOverlay.visibility.test.tsx b/src/renderer/src/components/contextual-tours/ContextualTourOverlay.visibility.test.tsx index f8b6fe0094d1..79c8bc27ce39 100644 --- a/src/renderer/src/components/contextual-tours/ContextualTourOverlay.visibility.test.tsx +++ b/src/renderer/src/components/contextual-tours/ContextualTourOverlay.visibility.test.tsx @@ -79,7 +79,7 @@ describe('ContextualTourOverlay full-pass interval visibility gate', () => { activeContextualTourStepIndex: 1, activeModal: 'none', contextualToursOnboardingVisible: false, - contextualToursBlockingSurfaceVisible: false, + promptBlockingDialogIds: [], activeContextualTourSuppressed: false }) await act(async () => { diff --git a/src/renderer/src/components/contextual-tours/use-contextual-tour.ts b/src/renderer/src/components/contextual-tours/use-contextual-tour.ts index a19c1623999e..dc6fcd235328 100644 --- a/src/renderer/src/components/contextual-tours/use-contextual-tour.ts +++ b/src/renderer/src/components/contextual-tours/use-contextual-tour.ts @@ -5,6 +5,7 @@ import { type FeatureInteractionState } from '../../../../shared/feature-interactions' import { useAppStore } from '@/store' +import { selectTourBlockedByPrompts } from '@/store/slices/ui/automatic-prompt-turns' const TOUR_SOURCES = { 'workspace-board': 'workspace_board_visible', @@ -76,9 +77,7 @@ export function useContextualTour( const contextualToursAutoEligible = useAppStore((s) => s.contextualToursAutoEligible) const contextualTourShownThisSession = useAppStore((s) => s.contextualTourShownThisSession) const contextualToursOnboardingVisible = useAppStore((s) => s.contextualToursOnboardingVisible) - const contextualToursBlockingSurfaceVisible = useAppStore( - (s) => s.contextualToursBlockingSurfaceVisible - ) + const blockedByPrompts = useAppStore((s) => selectTourBlockedByPrompts(s, false)) const enabledInteractionSnapshotRef = useRef<{ id: ContextualTourId source: string @@ -168,7 +167,7 @@ export function useContextualTour( !persistedUIReady || contextualToursAutoEligible !== true || contextualToursOnboardingVisible || - contextualToursBlockingSurfaceVisible || + blockedByPrompts || activeContextualTourId !== null || contextualTourShownThisSession || contextualToursSeenIds.includes(id) @@ -249,7 +248,7 @@ export function useContextualTour( } }, [ activeContextualTourId, - contextualToursBlockingSurfaceVisible, + blockedByPrompts, activeModal, contextualToursAutoEligible, contextualTourShownThisSession, diff --git a/src/renderer/src/components/crash-report/CrashReportDialog.tsx b/src/renderer/src/components/crash-report/CrashReportDialog.tsx index fe2ba9b90241..d6c164e2ed62 100644 --- a/src/renderer/src/components/crash-report/CrashReportDialog.tsx +++ b/src/renderer/src/components/crash-report/CrashReportDialog.tsx @@ -5,6 +5,10 @@ import { REACT_ERROR_BOUNDARY_REPORT_AVAILABLE_EVENT, takePendingReactErrorBoundaryReport } from '@/lib/react-error-boundary-reporting' +import { + useAutomaticPromptTurn, + usePromptBlockingDialog +} from '@/components/automatic-prompts/use-automatic-prompt-turn' import type { CrashReportRecord } from '../../../../shared/crash-reporting' const CrashReportDialogSurface = lazy(() => @@ -13,81 +17,108 @@ const CrashReportDialogSurface = lazy(() => })) ) +/** A report the app raises by itself, waiting for its turn among the other automatic prompts. */ +type AutomaticCrashReport = { + report: CrashReportRecord + /** The launch prompt is one-shot: acknowledged once actually shown, never before. */ + acknowledgeOnShow: boolean +} + export function CrashReportDialog(): React.JSX.Element | null { const promptedThisLaunch = useRef(false) + const acknowledgedIds = useRef(new Set()) const mountedRef = useMountedRef() - const [open, setOpen] = useState(false) - const [report, setReport] = useState(null) + // Help > Report Crash: the user asked, so it opens at once. + const [userOpen, setUserOpen] = useState(false) + const [userReport, setUserReport] = useState(null) const [loading, setLoading] = useState(false) + const [automatic, setAutomatic] = useState(null) + const automaticVisible = useAutomaticPromptTurn('crash-report', automatic !== null && !userOpen) + usePromptBlockingDialog('crash-report', userOpen) - const openCrashReport = useCallback((nextReport: CrashReportRecord): void => { - setReport(nextReport) - setOpen(true) + const raiseCrashReport = useCallback((report: CrashReportRecord, acknowledgeOnShow = false) => { + setAutomatic({ report, acknowledgeOnShow }) }, []) - const loadCrashReport = useCallback( - async (promptIfPresent: boolean): Promise => { - setLoading(true) - try { - const nextReport = promptIfPresent - ? await window.api.crashReports.getLatestPending() - : await window.api.crashReports.getLatestReport() - let displayedReport = nextReport - if (nextReport?.status === 'pending' && promptIfPresent) { - try { - // Why: startup crash prompts are one-shot. The lazy dialog keeps the - // report data locally if the user sends immediately, while Help > - // Report Crash can still reopen dismissed unsent reports. - await window.api.crashReports.dismiss({ reportId: nextReport.id }) - displayedReport = { ...nextReport, status: 'dismissed' as const } - } catch (error) { - console.error('Failed to dismiss crash report after startup prompt:', error) - } - } - if (!mountedRef.current) { - return - } - setReport(displayedReport) - if (nextReport && promptIfPresent) { - setOpen(true) - } - } catch (error) { - console.error('Failed to load crash report:', error) - } finally { - if (mountedRef.current) { - setLoading(false) - } + const loadUserCrashReport = useCallback(async (): Promise => { + setLoading(true) + try { + const nextReport = await window.api.crashReports.getLatestReport() + if (mountedRef.current) { + setUserReport(nextReport) } - }, - [mountedRef] - ) + } catch (error) { + console.error('Failed to load crash report:', error) + } finally { + if (mountedRef.current) { + setLoading(false) + } + } + }, [mountedRef]) useEffect(() => { if (promptedThisLaunch.current) { return } promptedThisLaunch.current = true - void loadCrashReport(true) - }, [loadCrashReport]) + void window.api.crashReports + .getLatestPending() + .then((pending) => { + if (pending && mountedRef.current) { + raiseCrashReport(pending, pending.status === 'pending') + } + }) + .catch((error) => console.error('Failed to load crash report:', error)) + }, [mountedRef, raiseCrashReport]) + + useEffect(() => { + if (!automaticVisible || !automatic?.acknowledgeOnShow) { + return + } + const reportId = automatic.report.id + if (acknowledgedIds.current.has(reportId)) { + return + } + acknowledgedIds.current.add(reportId) + // Why: startup crash prompts are one-shot. Acknowledged only once on screen, and never awaited: + // a failed write must not delay the prompt, and the dialog dismisses a still-pending report on + // close. Help > Report Crash can still reopen dismissed unsent reports. + void window.api.crashReports + .dismiss({ reportId }) + .then(() => { + if (mountedRef.current) { + setAutomatic((current) => + current?.report.id === reportId + ? { ...current, report: { ...current.report, status: 'dismissed' as const } } + : current + ) + } + }) + .catch((error) => { + console.error('Failed to dismiss crash report after startup prompt:', error) + }) + }, [automatic, automaticVisible, mountedRef]) useEffect(() => { return window.api.ui.onOpenCrashReport(() => { - setReport(null) - setOpen(true) - void loadCrashReport(false) + // The user is now looking at crash reports; a queued one would only repeat what they see. + setAutomatic(null) + setUserReport(null) + setUserOpen(true) + void loadUserCrashReport() }) - }, [loadCrashReport]) + }, [loadUserCrashReport]) useEffect(() => { const pendingReport = takePendingReactErrorBoundaryReport() if (pendingReport) { - openCrashReport(pendingReport) + raiseCrashReport(pendingReport) } const onReactErrorBoundaryReport = (): void => { const nextReport = takePendingReactErrorBoundaryReport() if (nextReport) { - openCrashReport(nextReport) + raiseCrashReport(nextReport) } } @@ -98,8 +129,13 @@ export function CrashReportDialog(): React.JSX.Element | null { onReactErrorBoundaryReport ) } - }, [openCrashReport]) + }, [raiseCrashReport]) + + const changeAutomaticReport = useCallback((report: CrashReportRecord | null) => { + setAutomatic((current) => (current && report ? { ...current, report } : current)) + }, []) + const open = userOpen || automaticVisible if (!open) { return null } @@ -108,10 +144,19 @@ export function CrashReportDialog(): React.JSX.Element | null { { + if (nextOpen) { + return + } + if (userOpen) { + setUserOpen(false) + } else { + setAutomatic(null) + } + }} + onReportChange={userOpen ? setUserReport : changeAutomaticReport} /> ) diff --git a/src/renderer/src/components/feature-tips/FeatureTipsModal.tsx b/src/renderer/src/components/feature-tips/FeatureTipsModal.tsx index 631c121cc154..15299ad7dcf6 100644 --- a/src/renderer/src/components/feature-tips/FeatureTipsModal.tsx +++ b/src/renderer/src/components/feature-tips/FeatureTipsModal.tsx @@ -7,6 +7,7 @@ import { notifyOrchestrationSetupStateChanged } from '@/lib/orchestration-setup-state' import { useAppStore } from '@/store' +import { selectAutomaticPromptSlotSuspended } from '@/store/slices/ui/automatic-prompt-turns' import { CliSetupTipDialog } from './CliSetupTipDialog' import { CmdJPaletteTipDialog } from './CmdJPaletteTipDialog' import { installCliFromFeatureTip } from './feature-tip-cli-install-action' @@ -41,7 +42,9 @@ export default function FeatureTipsModal(): JSX.Element | null { const setupRequestIdRef = useRef(0) const [primaryBusy, setPrimaryBusy] = useState(false) const [skillTerminalOpen, setSkillTerminalOpen] = useState(false) - const isOpen = activeModal === 'feature-tips' + // Raised by the app, it steps aside while a dialog the user opened is up, then comes back. + const suspended = useAppStore(selectAutomaticPromptSlotSuspended) + const isOpen = activeModal === 'feature-tips' && !suspended const currentTip = getFeatureTipForModal({ cliInstalled: true, modalData, diff --git a/src/renderer/src/components/link-routing-preference-dialog.tsx b/src/renderer/src/components/link-routing-preference-dialog.tsx index cdf07d629625..5af6d80df906 100644 --- a/src/renderer/src/components/link-routing-preference-dialog.tsx +++ b/src/renderer/src/components/link-routing-preference-dialog.tsx @@ -12,7 +12,7 @@ import { } from '@/components/ui/dialog' import { Badge } from '@/components/ui/badge' import { ShortcutKeyCombo } from '@/components/ShortcutKeyCombo' -import { useAppStore } from '@/store' +import { usePromptBlockingDialog } from '@/components/automatic-prompts/use-automatic-prompt-turn' import { translate } from '@/i18n/i18n' type LinkRoutingPreferenceDialogOptions = { @@ -56,9 +56,6 @@ export function LinkRoutingPreferenceDialogProvider({ const [queue, setQueue] = useState([]) const activeRequest = queue[0] ?? null const activeRequestRef = useRef(activeRequest) - const setContextualToursBlockingSurfaceVisible = useAppStore( - (s) => s.setContextualToursBlockingSurfaceVisible - ) const lastDisplayedRequestRef = useRef(activeRequest) activeRequestRef.current = activeRequest if (activeRequest) { @@ -71,10 +68,7 @@ export function LinkRoutingPreferenceDialogProvider({ const isMac = navigator.userAgent.includes('Mac') const systemBrowserShortcutKeys = isMac ? ['⇧', '⌘'] : ['Shift', 'Ctrl'] - useEffect(() => { - setContextualToursBlockingSurfaceVisible(activeRequest !== null) - return () => setContextualToursBlockingSurfaceVisible(false) - }, [activeRequest, setContextualToursBlockingSurfaceVisible]) + usePromptBlockingDialog('link-routing-preference', activeRequest !== null) const requestPreference = useCallback((options = {}) => { return new Promise((resolve) => { diff --git a/src/renderer/src/components/native-chat-resume-on-restart-dialog.ts b/src/renderer/src/components/native-chat-resume-on-restart-dialog.ts index 4b139d1c2a34..3be5f6bf9097 100644 --- a/src/renderer/src/components/native-chat-resume-on-restart-dialog.ts +++ b/src/renderer/src/components/native-chat-resume-on-restart-dialog.ts @@ -1,4 +1,8 @@ -let pendingOpen = false +/** Who asked: the launch read raises it by itself and takes a turn; the user opens it at once. */ +export type NativeChatResumeDialogOrigin = 'launch' | 'user' + +let pendingOpen: NativeChatResumeDialogOrigin | null = null +let launchDecided = false const listeners = new Set<() => void>() function notify(): void { @@ -9,26 +13,51 @@ function notify(): void { // Why: the launch load and the status-bar entry both open this dialog, and either can fire before // it subscribes. Keeping the request as an external snapshot prevents mount ordering from losing it. -export function requestNativeChatResumeOnRestartDialog(): void { - pendingOpen = true +export function requestNativeChatResumeOnRestartDialog(origin: NativeChatResumeDialogOrigin): void { + // A user's request is never demoted to a scheduled one. + const next = pendingOpen === 'user' ? 'user' : origin + if (pendingOpen === next) { + return + } + pendingOpen = next notify() } export function consumeNativeChatResumeOnRestartDialogRequest(): void { - if (!pendingOpen) { + if (pendingOpen === null) { return } - pendingOpen = false + pendingOpen = null notify() } -export function getNativeChatResumeOnRestartDialogRequest(): boolean { +export function getNativeChatResumeOnRestartDialogRequest(): NativeChatResumeDialogOrigin | null { return pendingOpen } +/** This launch's read has asked, resumed by itself, or found nothing; other launch prompts need + * not wait for it any longer. */ +export function markNativeChatResumeLaunchDecided(): void { + if (launchDecided) { + return + } + launchDecided = true + notify() +} + +export function getNativeChatResumeLaunchDecided(): boolean { + return launchDecided +} + export function subscribeNativeChatResumeOnRestartDialog(listener: () => void): () => void { listeners.add(listener) return () => { listeners.delete(listener) } } + +/** @internal - tests need a clean module between cases. */ +export function _resetNativeChatResumeOnRestartDialog(): void { + pendingOpen = null + launchDecided = false +} diff --git a/src/renderer/src/components/native-chat-resume-on-restart-store.ts b/src/renderer/src/components/native-chat-resume-on-restart-store.ts index 452aa78cc816..e908e237c4eb 100644 --- a/src/renderer/src/components/native-chat-resume-on-restart-store.ts +++ b/src/renderer/src/components/native-chat-resume-on-restart-store.ts @@ -18,7 +18,9 @@ import { type ResumeFailure } from './native-chat-resume-on-restart-grouping' import { + _resetNativeChatResumeOnRestartDialog, consumeNativeChatResumeOnRestartDialogRequest, + markNativeChatResumeLaunchDecided, requestNativeChatResumeOnRestartDialog } from './native-chat-resume-on-restart-dialog' @@ -260,7 +262,7 @@ export async function refreshNativeChatRestartOffer(): Promise< /** What the failure toast can do. The dialog request is external state the toast may raise after * the dialog that started the action has closed. */ const failureToastActions = { - show: () => requestNativeChatResumeOnRestartDialog(), + show: () => requestNativeChatResumeOnRestartDialog('user'), dismiss: (sessionIds: readonly string[]) => { void dismissNativeChatRestartOffer([...sessionIds]) } @@ -378,9 +380,11 @@ async function loadLaunchOffer(): Promise { return } if (!autoResume) { - requestNativeChatResumeOnRestartDialog() + requestNativeChatResumeOnRestartDialog('launch') return } + // Nothing will ask, so other launch prompts need not wait for the resume to settle. + markNativeChatResumeLaunchDecided() await continueNativeChatRestartOffer(undefined, allResumeSessionIds(offered)) } @@ -394,7 +398,8 @@ export function useNativeChatRestartOffer(enabled: boolean): NativeChatRestartOf useEffect(() => { if (enabled) { // Fetched after mount, never awaited by startup: the workspace is usable first. - launch ??= loadLaunchOffer() + // Decided either way, so other launch prompts stop waiting on this read. + launch ??= loadLaunchOffer().finally(markNativeChatResumeLaunchDecided) } }, [enabled]) return useSyncExternalStore(subscribe, getNativeChatRestartOffer, getNativeChatRestartOffer) @@ -415,4 +420,5 @@ export function _resetNativeChatRestartOffer(): void { actionsSettled = 0 launch = undefined listeners.clear() + _resetNativeChatResumeOnRestartDialog() } diff --git a/src/renderer/src/components/onboarding/OnboardingFlow.tsx b/src/renderer/src/components/onboarding/OnboardingFlow.tsx index c57e25560149..93ad3d88bc58 100644 --- a/src/renderer/src/components/onboarding/OnboardingFlow.tsx +++ b/src/renderer/src/components/onboarding/OnboardingFlow.tsx @@ -15,6 +15,7 @@ import { OnboardingFooter } from './OnboardingFooter' import { shouldRequestOnboardingSkipConfirmation } from './onboarding-dismiss-target' import logo from '../../../../../resources/logo.svg' import { translate } from '@/i18n/i18n' +import { usePromptBlockingDialog } from '@/components/automatic-prompts/use-automatic-prompt-turn' const stepCopy = { agent: { @@ -127,6 +128,8 @@ export default function OnboardingFlow({ onOnboardingChange }: OnboardingFlowProps): React.JSX.Element { const flow = useOnboardingFlow(onboarding, onOnboardingChange) + // Mounted only while shown; a crash report from a first-run crash waits until setup is done. + usePromptBlockingDialog('onboarding', true) const continueShortcutModifierLabel = getScreenSubmitModifierLabel() const { currentStep, stepIndex, busyLabel } = flow const copy = stepCopy[currentStep.id] diff --git a/src/renderer/src/components/settings/SshPassphraseDialog.tsx b/src/renderer/src/components/settings/SshPassphraseDialog.tsx index 922e795fe28b..314a91ae06fd 100644 --- a/src/renderer/src/components/settings/SshPassphraseDialog.tsx +++ b/src/renderer/src/components/settings/SshPassphraseDialog.tsx @@ -12,6 +12,7 @@ import { } from '@/components/ui/dialog' import { useAppStore } from '@/store' import { translate } from '@/i18n/i18n' +import { usePromptBlockingDialog } from '@/components/automatic-prompts/use-automatic-prompt-turn' export function SshPassphraseDialog(): React.JSX.Element | null { const request = useAppStore((s) => s.sshCredentialQueue[0] ?? null) @@ -23,6 +24,9 @@ export function SshPassphraseDialog(): React.JSX.Element | null { const focusFrameRef = useRef(null) const open = request !== null + // Why: a connection is waiting on this with a deadline in main, so it is never queued behind an + // automatic prompt; one that is showing steps aside until this closes. + usePromptBlockingDialog('ssh-credential', open) const requestId = request?.requestId diff --git a/src/renderer/src/components/sidebar/worktree-list/navigation/use-reveal-requests.test.tsx b/src/renderer/src/components/sidebar/worktree-list/navigation/use-reveal-requests.test.tsx index da4d68e15345..9143057cbc79 100644 --- a/src/renderer/src/components/sidebar/worktree-list/navigation/use-reveal-requests.test.tsx +++ b/src/renderer/src/components/sidebar/worktree-list/navigation/use-reveal-requests.test.tsx @@ -18,7 +18,7 @@ const state = vi.hoisted(() => ({ pendingRevealSidebarRow: null, revealSidebarRow: vi.fn(), revealWorktreeInSidebar: vi.fn(), - setContextualToursBlockingSurfaceVisible: vi.fn() + setPromptBlockingDialogVisible: vi.fn() })) vi.mock('@/store', () => ({ useAppStore: (selector: (value: typeof state) => unknown) => selector(state) diff --git a/src/renderer/src/components/status-bar/NativeChatResumeStatusSegment.test.tsx b/src/renderer/src/components/status-bar/NativeChatResumeStatusSegment.test.tsx index 024623935026..c3196574665d 100644 --- a/src/renderer/src/components/status-bar/NativeChatResumeStatusSegment.test.tsx +++ b/src/renderer/src/components/status-bar/NativeChatResumeStatusSegment.test.tsx @@ -93,14 +93,14 @@ describe('NativeChatResumeStatusSegment', () => { expect(screen.getByRole('button', { name: '2 chats available to resume' })).toBeTruthy() expect(screen.getByText('2 chats to resume')).toBeTruthy() - expect(getNativeChatResumeOnRestartDialogRequest()).toBe(false) + expect(getNativeChatResumeOnRestartDialogRequest()).toBeNull() await act(async () => screen.getByRole('button').click()) // The launch read, then a second one taken before the dialog is allowed to reopen. expect(rpc.mock.calls.map((call) => call[1])).toEqual([ 'agentSession.restartResumable', 'agentSession.restartResumable' ]) - expect(getNativeChatResumeOnRestartDialogRequest()).toBe(true) + expect(getNativeChatResumeOnRestartDialogRequest()).toBe('user') }) // The offer is spent once acted on, so without this entry a failed resume would leave the bar @@ -123,7 +123,7 @@ describe('NativeChatResumeStatusSegment', () => { rpc.mockResolvedValue({ sessions: [], failed: [failed] }) await act(async () => entry.click()) - expect(getNativeChatResumeOnRestartDialogRequest()).toBe(true) + expect(getNativeChatResumeOnRestartDialogRequest()).toBe('user') // With the offer gone, only the failure entry is left — and it stays. expect(screen.queryByText('1 chat to resume')).toBeNull() expect(screen.getByText('1 chat failed to resume')).toBeTruthy() @@ -164,9 +164,9 @@ describe('NativeChatResumeStatusSegment', () => { rpc.mockResolvedValueOnce({ sessions: candidates }).mockResolvedValue({ sessions: [] }) await mount() - expect(getNativeChatResumeOnRestartDialogRequest()).toBe(false) + expect(getNativeChatResumeOnRestartDialogRequest()).toBeNull() await act(async () => screen.getByRole('button').click()) - expect(getNativeChatResumeOnRestartDialogRequest()).toBe(false) + expect(getNativeChatResumeOnRestartDialogRequest()).toBeNull() expect(screen.queryByRole('button')).toBeNull() }) diff --git a/src/renderer/src/components/status-bar/NativeChatResumeStatusSegment.tsx b/src/renderer/src/components/status-bar/NativeChatResumeStatusSegment.tsx index 24e9b225824e..15182b63a208 100644 --- a/src/renderer/src/components/status-bar/NativeChatResumeStatusSegment.tsx +++ b/src/renderer/src/components/status-bar/NativeChatResumeStatusSegment.tsx @@ -21,12 +21,12 @@ import { async function reopenOffer(): Promise { // Mid-resume the host's answer is already on its way; a re-read racing it could undo it. if (getNativeChatRestartResuming().length > 0) { - requestNativeChatResumeOnRestartDialog() + requestNativeChatResumeOnRestartDialog('user') return } const { candidates, failed } = await refreshNativeChatRestartOffer() if (candidates.length > 0 || failed.length > 0) { - requestNativeChatResumeOnRestartDialog() + requestNativeChatResumeOnRestartDialog('user') } } diff --git a/src/renderer/src/components/terminal-pane/PinnedTabCloseDialog.tsx b/src/renderer/src/components/terminal-pane/PinnedTabCloseDialog.tsx index 9416ba0d30fe..1962378d25f4 100644 --- a/src/renderer/src/components/terminal-pane/PinnedTabCloseDialog.tsx +++ b/src/renderer/src/components/terminal-pane/PinnedTabCloseDialog.tsx @@ -11,6 +11,7 @@ import { Button } from '@/components/ui/button' import { Checkbox } from '@/components/ui/checkbox' import { Label } from '@/components/ui/label' import { useAppStore } from '@/store' +import { usePromptBlockingDialog } from '@/components/automatic-prompts/use-automatic-prompt-turn' import { translate } from '@/i18n/i18n' /** Confirmation prompt shown when a pinned tab is about to be closed. Driven by @@ -25,6 +26,9 @@ export default function PinnedTabCloseDialog(): React.JSX.Element { const [dontAskAgain, setDontAskAgain] = useState(false) const [previousRequest, setPreviousRequest] = useState(request) + // A background PTY exit, a phone, or the CLI can raise this; it answers a close in flight. + usePromptBlockingDialog('pinned-tab-close', request !== null) + const tabLabel = request?.tabLabel.trim() // Why: a new store request is a new confirmation, so reset its checkbox before diff --git a/src/renderer/src/components/terminal-pane/TerminalSessionStateSaveFailureDialog.tsx b/src/renderer/src/components/terminal-pane/TerminalSessionStateSaveFailureDialog.tsx index 889fa69d7efb..a30d7809c756 100644 --- a/src/renderer/src/components/terminal-pane/TerminalSessionStateSaveFailureDialog.tsx +++ b/src/renderer/src/components/terminal-pane/TerminalSessionStateSaveFailureDialog.tsx @@ -9,6 +9,7 @@ import { DialogTitle } from '@/components/ui/dialog' import { translate } from '@/i18n/i18n' +import { usePromptBlockingDialog } from '@/components/automatic-prompts/use-automatic-prompt-turn' import { isTerminalSessionStorageCapacityFailure } from '../../../../shared/terminal-session-state-save-failure' export function TerminalSessionStateSaveFailureDialog({ @@ -22,6 +23,7 @@ export function TerminalSessionStateSaveFailureDialog({ onDismiss: () => void onOpenSpaceAnalyzer: () => void }): React.JSX.Element { + usePromptBlockingDialog('terminal-session-state-save-failure', open) const capacityFailure = isTerminalSessionStorageCapacityFailure(failureMessage) return ( ({ sendNotesToActiveAgentSession: vi.fn(), @@ -225,13 +226,49 @@ describe('createUISlice contextual tours', () => { stubContextualTourTargets(['[data-contextual-tour-target="tasks-source-filters"]']) store.getState().hydratePersistedUI(makeAutoTourEligibleUI()) - store.getState().setContextualToursBlockingSurfaceVisible(true) + store.getState().setPromptBlockingDialogVisible('confirmation', true) store.getState().requestContextualTour('tasks', 'tasks_open') expect(store.getState().activeContextualTourId).toBeNull() expect(store.getState().contextualTourShownThisSession).toBe(false) }) + it('waits for the resume offer at launch, then starts once it closes', () => { + const store = createUIStore({ launchPromptDiscoveryPending: true }) + stubContextualTourTargets(['[data-contextual-tour-target="tasks-source-filters"]']) + store.getState().hydratePersistedUI(makeAutoTourEligibleUI()) + + store.getState().requestContextualTour('tasks', 'tasks_open') + expect(store.getState().activeContextualTourId).toBeNull() + + store.getState().requestAutomaticPrompt('native-chat-resume') + store.getState().settleLaunchPromptDiscovery() + store.getState().markAutomaticPromptShown('native-chat-resume') + store.getState().requestContextualTour('tasks', 'tasks_open') + expect(store.getState().activeContextualTourId).toBeNull() + + store.getState().releaseAutomaticPrompt('native-chat-resume') + store.getState().requestContextualTour('tasks', 'tasks_open') + expect(store.getState().activeContextualTourId).toBe('tasks') + }) + + it('does not start behind a waiting feature tip, and the tip waits for a running tour', () => { + const store = createUIStore() + stubContextualTourTargets(['[data-contextual-tour-target="tasks-source-filters"]']) + store.getState().hydratePersistedUI(makeAutoTourEligibleUI()) + + store.getState().requestAutomaticPrompt('feature-tip') + store.getState().requestContextualTour('tasks', 'tasks_open') + expect(store.getState().activeContextualTourId).toBeNull() + + store.getState().releaseAutomaticPrompt('feature-tip') + store.getState().requestContextualTour('tasks', 'tasks_open') + expect(store.getState().activeContextualTourId).toBe('tasks') + + store.getState().requestAutomaticPrompt('feature-tip') + expect(selectVisibleAutomaticPromptId(store.getState())).toBeNull() + }) + it('does not auto-start tours for profiles that are not eligible', () => { const store = createUIStore() stubContextualTourTargets(['[data-contextual-tour-target="tasks-source-filters"]']) diff --git a/src/renderer/src/store/slices/ui-slice-test-harness.ts b/src/renderer/src/store/slices/ui-slice-test-harness.ts index b8bbcf24b855..deec2fd3a31d 100644 --- a/src/renderer/src/store/slices/ui-slice-test-harness.ts +++ b/src/renderer/src/store/slices/ui-slice-test-harness.ts @@ -6,7 +6,18 @@ import { createUISlice } from './ui' import { createWorktreeNavHistorySlice } from './worktree-nav-history' import { createSettingsSearchState } from './settings-search-state' -export function createUIStore(): StoreApi { +export function createUIStore( + options: { launchPromptDiscoveryPending?: boolean } = {} +): StoreApi { + const store = buildUIStore() + // Why: most cases model a running app, past the launch wait for this machine's resume offer. + if (options.launchPromptDiscoveryPending !== true) { + store.getState().settleLaunchPromptDiscovery() + } + return store +} + +function buildUIStore(): StoreApi { // Only the UI slice, repo/worktree ids, and right sidebar width fallback are // needed for these tests. The worktree-nav-history slice is also included // because page opens record view visits. diff --git a/src/renderer/src/store/slices/ui.ts b/src/renderer/src/store/slices/ui.ts index c62d1d64d24d..4b445719de06 100644 --- a/src/renderer/src/store/slices/ui.ts +++ b/src/renderer/src/store/slices/ui.ts @@ -8,6 +8,7 @@ import { createUiSettingsActions } from './ui/ui-slice-settings-actions' import { createUiModalActions } from './ui/ui-slice-modal-actions' import { createUiFeatureActions } from './ui/ui-slice-feature-actions' import { createUiTourActions } from './ui/ui-slice-tour-actions' +import { createUiPromptTurnActions } from './ui/ui-slice-prompt-turn-actions' import { createUiTrustActions } from './ui/ui-slice-trust-actions' import { createUiPreferenceActions } from './ui/ui-slice-preference-actions' import { createUiSurfaceActions } from './ui/ui-slice-surface-actions' @@ -26,6 +27,7 @@ export type { } from './ui/ui-slice-contract' export const createUISlice: StateCreator = (set, get) => + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: each group returns its part of UISlice and together they supply every key; the UI slice tests build the whole slice. ({ ...createUiAgentActions(set, get), ...createUiTaskActions(set, get), @@ -34,6 +36,7 @@ export const createUISlice: StateCreator = (set, get) ...createUiModalActions(set, get), ...createUiFeatureActions(set, get), ...createUiTourActions(set, get), + ...createUiPromptTurnActions(set, get), ...createUiTrustActions(set, get), ...createUiPreferenceActions(set, get), ...createUiSurfaceActions(set, get), diff --git a/src/renderer/src/store/slices/ui/automatic-prompt-turns.test.ts b/src/renderer/src/store/slices/ui/automatic-prompt-turns.test.ts new file mode 100644 index 000000000000..50ed3da0a01c --- /dev/null +++ b/src/renderer/src/store/slices/ui/automatic-prompt-turns.test.ts @@ -0,0 +1,197 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { StoreApi } from 'zustand' +import type { AppState } from '../../types' +import { createUIStore } from '../ui-slice-test-harness' +import { + AUTOMATIC_PROMPT_MODAL_KEY, + LAUNCH_PROMPT_DISCOVERY_BOUND_MS, + selectAutomaticPromptSlotSuspended, + selectPromptSurfaceVisible, + selectTourBlockedByPrompts, + selectVisibleAutomaticPromptId +} from './automatic-prompt-turns' + +function visible(store: StoreApi): string | null { + return selectVisibleAutomaticPromptId(store.getState()) +} + +/** What an owner does once its prompt is on screen. */ +function show(store: StoreApi): string | null { + const id = selectVisibleAutomaticPromptId(store.getState()) + if (id) { + store.getState().markAutomaticPromptShown(id) + } + return id +} + +afterEach(() => { + vi.useRealTimers() +}) + +describe('automatic prompt turns', () => { + it('shows the resume offer before a feature tip, and the tip once it closes', () => { + const store = createUIStore() + store.getState().requestAutomaticPrompt('feature-tip') + store.getState().requestAutomaticPrompt('native-chat-resume') + + expect(show(store)).toBe('native-chat-resume') + store.getState().releaseAutomaticPrompt('native-chat-resume') + expect(show(store)).toBe('feature-tip') + }) + + it('shows a crash report before a feature tip when both are waiting', () => { + const store = createUIStore() + store.getState().requestAutomaticPrompt('feature-tip') + store.getState().requestAutomaticPrompt('crash-report') + + expect(visible(store)).toBe('crash-report') + }) + + it('keeps the turn with the prompt on screen when one that goes first arrives later', () => { + const store = createUIStore() + store.getState().requestAutomaticPrompt('feature-tip') + expect(show(store)).toBe('feature-tip') + + store.getState().requestAutomaticPrompt('crash-report') + store.getState().requestAutomaticPrompt('native-chat-resume') + expect(visible(store)).toBe('feature-tip') + + store.getState().releaseAutomaticPrompt('feature-tip') + expect(show(store)).toBe('native-chat-resume') + store.getState().releaseAutomaticPrompt('native-chat-resume') + expect(show(store)).toBe('crash-report') + }) + + it('a cancelled request gives its place to the next one', () => { + const store = createUIStore() + store.getState().requestAutomaticPrompt('crash-report') + store.getState().requestAutomaticPrompt('feature-tip') + + store.getState().releaseAutomaticPrompt('crash-report') + expect(visible(store)).toBe('feature-tip') + store.getState().releaseAutomaticPrompt('feature-tip') + expect(visible(store)).toBeNull() + expect(store.getState().automaticPromptShownId).toBeNull() + }) + + it('never delays a user modal; a prompt on screen steps aside and comes back after it', () => { + const store = createUIStore() + store.getState().requestAutomaticPrompt('native-chat-resume') + expect(show(store)).toBe('native-chat-resume') + + store.getState().openModal('settings') + expect(visible(store)).toBeNull() + // The prompt keeps its turn while hidden, so another waiting prompt cannot take it. + store.getState().requestAutomaticPrompt('crash-report') + store.getState().closeModal() + expect(visible(store)).toBe('native-chat-resume') + }) + + it('does not count its own modal-slot entry as a user modal', () => { + const store = createUIStore() + store.getState().requestAutomaticPrompt('feature-tip') + expect(show(store)).toBe('feature-tip') + store.getState().openModal('feature-tips', { [AUTOMATIC_PROMPT_MODAL_KEY]: 'feature-tip' }) + + expect(visible(store)).toBe('feature-tip') + expect(selectAutomaticPromptSlotSuspended(store.getState())).toBe(false) + + store.getState().setPromptBlockingDialogVisible('ssh-credential:1', true) + expect(selectAutomaticPromptSlotSuspended(store.getState())).toBe(true) + store.getState().setPromptBlockingDialogVisible('ssh-credential:1', false) + expect(selectAutomaticPromptSlotSuspended(store.getState())).toBe(false) + }) + + it('an SSH credential prompt shows over a visible resume offer, which comes back after', () => { + const store = createUIStore() + store.getState().requestAutomaticPrompt('native-chat-resume') + expect(show(store)).toBe('native-chat-resume') + + store.getState().setPromptBlockingDialogVisible('ssh-credential:1', true) + expect(visible(store)).toBeNull() + store.getState().setPromptBlockingDialogVisible('ssh-credential:1', false) + expect(visible(store)).toBe('native-chat-resume') + }) + + it('one dialog closing does not clear another that is still open', () => { + const store = createUIStore() + store.getState().requestAutomaticPrompt('feature-tip') + store.getState().setPromptBlockingDialogVisible('confirmation:1', true) + store.getState().setPromptBlockingDialogVisible('ssh-credential:2', true) + + store.getState().setPromptBlockingDialogVisible('confirmation:1', false) + expect(visible(store)).toBeNull() + expect(selectPromptSurfaceVisible(store.getState())).toBe(true) + store.getState().setPromptBlockingDialogVisible('ssh-credential:2', false) + expect(visible(store)).toBe('feature-tip') + }) + + describe('launch discovery', () => { + it('holds every automatic prompt until the resume read decides', () => { + const store = createUIStore({ launchPromptDiscoveryPending: true }) + store.getState().requestAutomaticPrompt('crash-report') + expect(visible(store)).toBeNull() + + // A slow local read lands after the fast crash report and still goes first. + store.getState().requestAutomaticPrompt('native-chat-resume') + store.getState().settleLaunchPromptDiscovery() + expect(show(store)).toBe('native-chat-resume') + }) + + it('stops waiting at the bound when the resume read never decides', () => { + vi.useFakeTimers() + const store = createUIStore({ launchPromptDiscoveryPending: true }) + store.getState().requestAutomaticPrompt('crash-report') + + vi.advanceTimersByTime(LAUNCH_PROMPT_DISCOVERY_BOUND_MS - 1) + expect(visible(store)).toBeNull() + vi.advanceTimersByTime(1) + expect(visible(store)).toBe('crash-report') + }) + + it('measures the bound from boot, not from the request', () => { + vi.useFakeTimers() + const store = createUIStore({ launchPromptDiscoveryPending: true }) + vi.advanceTimersByTime(LAUNCH_PROMPT_DISCOVERY_BOUND_MS + 500) + store.getState().requestAutomaticPrompt('feature-tip') + vi.advanceTimersByTime(0) + expect(visible(store)).toBe('feature-tip') + }) + + it('never waits for discovery while a user dialog is what is up', () => { + const store = createUIStore({ launchPromptDiscoveryPending: true }) + store.getState().setPromptBlockingDialogVisible('ssh-credential:1', true) + // Nothing about the SSH prompt is gated; only automatic prompts read discovery. + expect(store.getState().promptBlockingDialogIds).toEqual(['ssh-credential:1']) + expect(selectPromptSurfaceVisible(store.getState())).toBe(true) + }) + }) + + describe('contextual tours', () => { + it('an automatic tour waits for a queued prompt and for launch discovery', () => { + const pending = createUIStore({ launchPromptDiscoveryPending: true }) + expect(selectTourBlockedByPrompts(pending.getState(), false)).toBe(true) + + const store = createUIStore() + expect(selectTourBlockedByPrompts(store.getState(), false)).toBe(false) + store.getState().requestAutomaticPrompt('native-chat-resume') + store.getState().openModal('settings') + // Hidden behind the user's modal, the resume offer still goes before an automatic tour. + expect(selectTourBlockedByPrompts(store.getState(), false)).toBe(true) + // A tour the user asked for yields only to what is on screen. + expect(selectTourBlockedByPrompts(store.getState(), true)).toBe(false) + }) + + it('a running tour holds the turn; a prompt asked for meanwhile opens after it', () => { + const store = createUIStore() + store.setState({ activeContextualTourId: 'tasks' }) + store.getState().requestAutomaticPrompt('feature-tip') + expect(visible(store)).toBeNull() + // A queued prompt does not cancel the running tour. + expect(selectPromptSurfaceVisible(store.getState())).toBe(false) + + store.setState({ activeContextualTourId: null }) + expect(visible(store)).toBe('feature-tip') + }) + }) +}) diff --git a/src/renderer/src/store/slices/ui/automatic-prompt-turns.ts b/src/renderer/src/store/slices/ui/automatic-prompt-turns.ts new file mode 100644 index 000000000000..b42f4ceb80f7 --- /dev/null +++ b/src/renderer/src/store/slices/ui/automatic-prompt-turns.ts @@ -0,0 +1,105 @@ +/** + * Turn-taking for dialogs the app opens by itself (resume offer, crash report, feature tip, tours). + * + * Exactly one automatic prompt is visible at a time and the next opens after it closes. Dialogs the + * user opened, or that answer something in flight (an SSH credential, a confirmation), are never + * scheduled: they only register as visible, and while any is up every automatic prompt waits. One + * that was already showing is hidden and keeps its turn until they close. + */ + +/** Lower goes first. The resume offer leads because it describes the restart the user just saw. */ +export const AUTOMATIC_PROMPT_PRIORITY = { + 'native-chat-resume': 0, + 'crash-report': 1, + 'feature-tip': 2 +} as const + +export type AutomaticPromptId = keyof typeof AUTOMATIC_PROMPT_PRIORITY + +/** Marks a modal-slot entry as an automatic prompt's own, so the slot alone never reads as a user + * modal that the prompt has to wait for. */ +export const AUTOMATIC_PROMPT_MODAL_KEY = 'automaticPromptId' + +/** How long automatic prompts wait at launch for this machine's resume offer to be read. Local only; + * a remote host is never awaited. */ +export const LAUNCH_PROMPT_DISCOVERY_BOUND_MS = 2000 + +export type AutomaticPromptRequest = Readonly<{ id: AutomaticPromptId; seq: number }> + +export type PromptTurnState = { + automaticPromptRequests: readonly AutomaticPromptRequest[] + /** The prompt that has actually been displayed. It keeps the turn until released, so a request + * with a higher priority that arrives later waits rather than replacing it. */ + automaticPromptShownId: AutomaticPromptId | null + promptBlockingDialogIds: readonly string[] + launchPromptDiscoveryPending: boolean + /** When the launch wait ends regardless (epoch ms), fixed at boot. */ + launchPromptDiscoveryDeadline: number +} + +type PromptTurnInputs = PromptTurnState & { + activeModal: string + modalData: Record + activeContextualTourId: string | null +} + +function modalSlotHeldByUser(state: PromptTurnInputs): boolean { + return state.activeModal !== 'none' && state.modalData[AUTOMATIC_PROMPT_MODAL_KEY] === undefined +} + +/** A user dialog or a response dialog is up; automatic prompts wait behind it. */ +export function selectUserDialogVisible(state: PromptTurnInputs): boolean { + return state.promptBlockingDialogIds.length > 0 || modalSlotHeldByUser(state) +} + +function compareRequests(a: AutomaticPromptRequest, b: AutomaticPromptRequest): number { + return AUTOMATIC_PROMPT_PRIORITY[a.id] - AUTOMATIC_PROMPT_PRIORITY[b.id] || a.seq - b.seq +} + +export function selectVisibleAutomaticPromptId(state: PromptTurnInputs): AutomaticPromptId | null { + if (selectUserDialogVisible(state)) { + return null + } + const requests = state.automaticPromptRequests + const shown = state.automaticPromptShownId + if (shown !== null && requests.some((request) => request.id === shown)) { + return shown + } + // A running tour holds the turn; it never queues, so it starts only when nothing else is waiting. + if (state.launchPromptDiscoveryPending || state.activeContextualTourId !== null) { + return null + } + let first: AutomaticPromptRequest | null = null + for (const request of requests) { + if (first === null || compareRequests(request, first) < 0) { + first = request + } + } + return first?.id ?? null +} + +/** The modal slot holds an automatic prompt that is not its turn; that modal stays hidden. */ +export function selectAutomaticPromptSlotSuspended(state: PromptTurnInputs): boolean { + const owner = state.modalData[AUTOMATIC_PROMPT_MODAL_KEY] + return ( + state.activeModal !== 'none' && + owner !== undefined && + owner !== selectVisibleAutomaticPromptId(state) + ) +} + +/** Whatever a running tour must give way to: something modal is on screen. */ +export function selectPromptSurfaceVisible(state: PromptTurnInputs): boolean { + return state.promptBlockingDialogIds.length > 0 || selectVisibleAutomaticPromptId(state) !== null +} + +/** + * Whether a tour may start. A tour the user asked for (forced) only yields to what is on screen; an + * automatic one also waits for queued prompts and the launch read, since those go first. + */ +export function selectTourBlockedByPrompts(state: PromptTurnInputs, forced: boolean): boolean { + if (selectPromptSurfaceVisible(state)) { + return true + } + return !forced && (state.launchPromptDiscoveryPending || state.automaticPromptRequests.length > 0) +} diff --git a/src/renderer/src/store/slices/ui/ui-slice-contract-contextual.ts b/src/renderer/src/store/slices/ui/ui-slice-contract-contextual.ts index 6ec88e1e4f36..8253dd796691 100644 --- a/src/renderer/src/store/slices/ui/ui-slice-contract-contextual.ts +++ b/src/renderer/src/store/slices/ui/ui-slice-contract-contextual.ts @@ -68,11 +68,9 @@ export type UISliceContextual = { activeContextualTourSuppressed: boolean contextualTourShownThisSession: boolean contextualToursOnboardingVisible: boolean - contextualToursBlockingSurfaceVisible: boolean lastCompletedContextualTourId: ContextualTourId | null setContextualToursAutoEligible: (eligible: boolean) => void setContextualToursOnboardingVisible: (visible: boolean) => void - setContextualToursBlockingSurfaceVisible: (visible: boolean) => void requestContextualTour: ( id: ContextualTourId, source: string, diff --git a/src/renderer/src/store/slices/ui/ui-slice-contract-prompt-turns.ts b/src/renderer/src/store/slices/ui/ui-slice-contract-prompt-turns.ts new file mode 100644 index 000000000000..34ebe4a05df9 --- /dev/null +++ b/src/renderer/src/store/slices/ui/ui-slice-contract-prompt-turns.ts @@ -0,0 +1,13 @@ +import type { AutomaticPromptId, PromptTurnState } from './automatic-prompt-turns' + +export type UISlicePromptTurns = PromptTurnState & { + /** Asks for a turn. Idempotent per id; the request keeps its place until released. */ + requestAutomaticPrompt: (id: AutomaticPromptId) => void + /** Withdraws a request, or ends the turn of the prompt that was showing. */ + releaseAutomaticPrompt: (id: AutomaticPromptId) => void + /** Called once the prompt is on screen: from here it keeps the turn until released. */ + markAutomaticPromptShown: (id: AutomaticPromptId) => void + setPromptBlockingDialogVisible: (id: string, visible: boolean) => void + /** This machine's resume offer has been read and decided; launch prompts may now take turns. */ + settleLaunchPromptDiscovery: () => void +} diff --git a/src/renderer/src/store/slices/ui/ui-slice-contract.ts b/src/renderer/src/store/slices/ui/ui-slice-contract.ts index e330d069cc03..22195b335a63 100644 --- a/src/renderer/src/store/slices/ui/ui-slice-contract.ts +++ b/src/renderer/src/store/slices/ui/ui-slice-contract.ts @@ -2,6 +2,7 @@ import type { StateCreator } from 'zustand' import type { AppState } from '../../types' import type { UISliceCore } from './ui-slice-contract-core' import type { UISliceContextual } from './ui-slice-contract-contextual' +import type { UISlicePromptTurns } from './ui-slice-contract-prompt-turns' import type { UISlicePersistence, UISlicePreferences, @@ -27,6 +28,7 @@ export type { export type UISlice = UISliceCore & UISliceContextual & + UISlicePromptTurns & UISlicePreferences & UISliceSurfaces & UISlicePersistence diff --git a/src/renderer/src/store/slices/ui/ui-slice-prompt-turn-actions.ts b/src/renderer/src/store/slices/ui/ui-slice-prompt-turn-actions.ts new file mode 100644 index 000000000000..821d9b1f3d90 --- /dev/null +++ b/src/renderer/src/store/slices/ui/ui-slice-prompt-turn-actions.ts @@ -0,0 +1,85 @@ +import type { UISlice, UISliceGet, UISliceSet } from './ui-slice-contract' +import { LAUNCH_PROMPT_DISCOVERY_BOUND_MS } from './automatic-prompt-turns' + +export function createUiPromptTurnActions(set: UISliceSet, get: UISliceGet): Partial { + let discoveryTimer: ReturnType | null = null + let nextSeq = 0 + + const settleDiscovery = (): void => { + if (discoveryTimer !== null) { + clearTimeout(discoveryTimer) + discoveryTimer = null + } + if (get().launchPromptDiscoveryPending) { + set({ launchPromptDiscoveryPending: false }) + } + } + + // Armed lazily so a store nobody asks of runs no timer. Re-reads the deadline when it fires, since + // the deadline is state and may have moved. + const armDiscoveryTimer = (): void => { + if (!get().launchPromptDiscoveryPending || discoveryTimer !== null) { + return + } + discoveryTimer = setTimeout( + () => { + discoveryTimer = null + if (Date.now() >= get().launchPromptDiscoveryDeadline) { + settleDiscovery() + } else { + armDiscoveryTimer() + } + }, + Math.max(0, get().launchPromptDiscoveryDeadline - Date.now()) + ) + } + + return { + automaticPromptRequests: [], + automaticPromptShownId: null, + promptBlockingDialogIds: [], + launchPromptDiscoveryPending: true, + // Measured from store creation, i.e. renderer boot, so no prompt waits past it whenever it asks. + launchPromptDiscoveryDeadline: Date.now() + LAUNCH_PROMPT_DISCOVERY_BOUND_MS, + requestAutomaticPrompt: (id) => { + if (get().automaticPromptRequests.some((request) => request.id === id)) { + return + } + nextSeq += 1 + set({ automaticPromptRequests: [...get().automaticPromptRequests, { id, seq: nextSeq }] }) + armDiscoveryTimer() + }, + releaseAutomaticPrompt: (id) => { + const state = get() + const requests = state.automaticPromptRequests.filter((request) => request.id !== id) + if (requests.length === state.automaticPromptRequests.length) { + return + } + set({ + automaticPromptRequests: requests, + automaticPromptShownId: + state.automaticPromptShownId === id ? null : state.automaticPromptShownId + }) + }, + markAutomaticPromptShown: (id) => { + const state = get() + if ( + state.automaticPromptShownId === id || + !state.automaticPromptRequests.some((request) => request.id === id) + ) { + return + } + set({ automaticPromptShownId: id }) + }, + setPromptBlockingDialogVisible: (id, visible) => { + const ids = get().promptBlockingDialogIds + if (ids.includes(id) === visible) { + return + } + set({ + promptBlockingDialogIds: visible ? [...ids, id] : ids.filter((entry) => entry !== id) + }) + }, + settleLaunchPromptDiscovery: settleDiscovery + } +} diff --git a/src/renderer/src/store/slices/ui/ui-slice-tour-actions.ts b/src/renderer/src/store/slices/ui/ui-slice-tour-actions.ts index 9f26a4092603..75d1e5c905f6 100644 --- a/src/renderer/src/store/slices/ui/ui-slice-tour-actions.ts +++ b/src/renderer/src/store/slices/ui/ui-slice-tour-actions.ts @@ -7,6 +7,7 @@ import { getPreviousVisibleContextualTourStepIndex } from '../../../components/contextual-tours/contextual-tour-gate' import { hasFeatureInteraction } from '../../../../../shared/feature-interactions' +import { selectTourBlockedByPrompts } from './automatic-prompt-turns' export function createUiTourActions(set: UISliceSet, get: UISliceGet): Partial { return { @@ -21,7 +22,6 @@ export function createUiTourActions(set: UISliceSet, get: UISliceGet): Partial set((s) => { @@ -39,12 +39,6 @@ export function createUiTourActions(set: UISliceSet, get: UISliceGet): Partial - set((s) => - s.contextualToursBlockingSurfaceVisible === visible - ? s - : { contextualToursBlockingSurfaceVisible: visible } - ), requestContextualTour: (id, source, wasFeaturePreviouslyInteracted, options) => set((s) => { const tour = getContextualTour(id) @@ -57,7 +51,7 @@ export function createUiTourActions(set: UISliceSet, get: UISliceGet): Partial Date: Mon, 5 Oct 2026 13:38:22 -0700 Subject: [PATCH 02/23] fix(dialogs): count every open dialog, mark shown from rendered content Review follow-ups to the dialog turn-taking: - the shared dialog primitive counts every open dialog, so a dialog that opens by itself also waits for dialogs outside the app's modal slot (unsaved changes, skill and remote-server updates, notebook setup, skill install); a prompt's own dialog is scoped out of the count - a feature tip is marked seen, and a crash report acknowledged and holding its turn, only once its dialog content has rendered; a tip replaced by the user before it ever showed keeps its turn and opens again afterwards - a tip whose dialog fails to render, or whose owner goes away, gives up its turn, closing only its own modal-slot entry - crash reports raised by themselves queue by identity, each taking its own turn; a second never replaces the first - the launch wait's 2 s bound runs from when this machine's resume read starts, a machine that holds no chats ends it at once, and a boot backstop ends it if neither happens; expiry only changes order, so a resume offer read later is still shown after whatever went first --- .../src/app-shell/AppRootSurfaces.tsx | 18 +- .../use-onboarding-and-feature-tips.ts | 41 +-- .../NativeChatResumeOnRestartModal.tsx | 235 ++++++++-------- .../automatic-prompt-turns.test.tsx | 260 ++++++++++++++++-- .../use-automatic-prompt-turn.ts | 33 ++- .../crash-report/CrashReportDialog.tsx | 100 ++++--- .../crash-report/CrashReportDialogSurface.tsx | 11 +- .../feature-tips/FeatureTipsModal.tsx | 12 +- .../feature-tips/use-app-open-feature-tip.ts | 90 ++++++ .../native-chat-resume-launch-discovery.ts | 53 ++++ src/renderer/src/components/ui/dialog.tsx | 2 + src/renderer/src/lib/dialog-presence.ts | 53 ++++ .../slices/ui/automatic-prompt-turns.test.ts | 31 ++- .../store/slices/ui/automatic-prompt-turns.ts | 11 +- .../ui/ui-slice-contract-prompt-turns.ts | 5 +- .../slices/ui/ui-slice-prompt-turn-actions.ts | 50 ++-- 16 files changed, 737 insertions(+), 268 deletions(-) create mode 100644 src/renderer/src/components/feature-tips/use-app-open-feature-tip.ts create mode 100644 src/renderer/src/components/native-chat-resume-launch-discovery.ts create mode 100644 src/renderer/src/lib/dialog-presence.ts diff --git a/src/renderer/src/app-shell/AppRootSurfaces.tsx b/src/renderer/src/app-shell/AppRootSurfaces.tsx index 59e6c67ee5c4..537feb214c06 100644 --- a/src/renderer/src/app-shell/AppRootSurfaces.tsx +++ b/src/renderer/src/app-shell/AppRootSurfaces.tsx @@ -18,6 +18,8 @@ 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 { AutomaticPromptDialogScope } from '@/lib/dialog-presence' +import { FailedFeatureTip } from '../components/feature-tips/use-app-open-feature-tip' import { selectAppRootSurfacePetEnabled, selectAppRootSurfaceTelemetryOptedIn, @@ -93,6 +95,7 @@ type BoundaryProps = { resetKey?: string | number | boolean | null title?: string description?: string + fallback?: () => React.ReactNode children: React.ReactNode } @@ -104,6 +107,10 @@ function ModalBoundary({ children, ...props }: BoundaryProps): React.JSX.Element ) } +function renderFailedFeatureTip(): React.ReactNode { + return +} + function OverlayBoundary({ children, ...props }: BoundaryProps): React.JSX.Element { return ( @@ -257,8 +264,15 @@ export function AppRootSurfaces(props: { ) : null} {mountedLazyModalIds.has('feature-tips') ? ( - - + + {/* Opens by itself, so its own dialogs never count as another dialog it waits for. */} + + + ) : null} diff --git a/src/renderer/src/app-shell/use-onboarding-and-feature-tips.ts b/src/renderer/src/app-shell/use-onboarding-and-feature-tips.ts index f358aa64866e..3df64d3ca799 100644 --- a/src/renderer/src/app-shell/use-onboarding-and-feature-tips.ts +++ b/src/renderer/src/app-shell/use-onboarding-and-feature-tips.ts @@ -6,17 +6,10 @@ import { getFeatureTipsAppOpenDecision, isCliFeatureTipCompleted } from '../components/feature-tips/feature-tip-startup-gate' -import { - trackCmdJPaletteFeatureTipShown, - trackOrcaCliFeatureTipShown -} from '../components/feature-tips/feature-tip-telemetry' import { useAppStore } from '../store' import { isWebClientLocation } from '../lib/web-client-location' -import { useAutomaticPromptTurn } from '../components/automatic-prompts/use-automatic-prompt-turn' -import { AUTOMATIC_PROMPT_MODAL_KEY } from '../store/slices/ui/automatic-prompt-turns' -import { MODAL_DISMISSED_KEY } from '../store/slices/modal-slot-dismissal' +import { useAppOpenFeatureTip } from '../components/feature-tips/use-app-open-feature-tip' import type { OnboardingState } from '../../../shared/onboarding-state-types' -import type { FeatureTipId } from '../../../shared/feature-tips' export type OnboardingGate = ReturnType @@ -29,10 +22,7 @@ export function useOnboardingAndFeatureTips() { const [onboardingLoaded, setOnboardingLoaded] = useState(false) const [featureTipCliInstalled, setFeatureTipCliInstalled] = useState(null) const promptedThisSessionRef = useRef(false) - // The app-open tip, chosen and waiting for its turn among dialogs that open by themselves. - const [pendingTipId, setPendingTipId] = useState(null) - const shownTipIdRef = useRef(null) - const tipTurn = useAutomaticPromptTurn('feature-tip', pendingTipId !== null) + const queueAppOpenFeatureTip = useAppOpenFeatureTip() const suppressedByOnboardingThisSessionRef = useRef(false) const activeModal = useAppStore((s) => s.activeModal) @@ -43,8 +33,6 @@ export function useOnboardingAndFeatureTips() { const contextualToursAutoEligible = useAppStore((s) => s.contextualToursAutoEligible) const actions = useAppStore( useShallow((s) => ({ - openModal: s.openModal, - markFeatureTipsSeen: s.markFeatureTipsSeen, setContextualToursAutoEligible: s.setContextualToursAutoEligible, setContextualToursOnboardingVisible: s.setContextualToursOnboardingVisible })) @@ -123,7 +111,7 @@ export function useOnboardingAndFeatureTips() { } promptedThisSessionRef.current = true - setPendingTipId(featureTipsDecision.tipId) + queueAppOpenFeatureTip(featureTipsDecision.tipId) }, [ activeModal, actions, @@ -132,31 +120,10 @@ export function useOnboardingAndFeatureTips() { featureTipsSeenIds, onboarding, persistedUIReady, + queueAppOpenFeatureTip, settings ]) - useEffect(() => { - if (!tipTurn || pendingTipId === null || shownTipIdRef.current === pendingTipId) { - return - } - shownTipIdRef.current = pendingTipId - if (pendingTipId === 'orca-cli') { - trackOrcaCliFeatureTipShown('app_open') - } else if (pendingTipId === 'cmd-j-palette') { - trackCmdJPaletteFeatureTipShown('app_open') - } - // Why: mark seen on show so a quit/crash before dismiss doesn't reappear it next launch. - actions.markFeatureTipsSeen([pendingTipId]) - actions.openModal('feature-tips', { - source: 'app_open', - tipId: pendingTipId, - [AUTOMATIC_PROMPT_MODAL_KEY]: 'feature-tip', - // Closing it, or a modal the user opens replacing it, ends its turn. It was shown, so it is - // not raised again. - [MODAL_DISMISSED_KEY]: () => setPendingTipId(null) - }) - }, [actions, pendingTipId, tipTurn]) - return { applyStartupOnboardingState, onboarding, diff --git a/src/renderer/src/components/NativeChatResumeOnRestartModal.tsx b/src/renderer/src/components/NativeChatResumeOnRestartModal.tsx index ae2defa31ebd..08bb351690ff 100644 --- a/src/renderer/src/components/NativeChatResumeOnRestartModal.tsx +++ b/src/renderer/src/components/NativeChatResumeOnRestartModal.tsx @@ -23,7 +23,6 @@ import { import type { ResumeCandidate, ResumeFailure } from './native-chat-resume-on-restart-grouping' import { consumeNativeChatResumeOnRestartDialogRequest, - getNativeChatResumeLaunchDecided, getNativeChatResumeOnRestartDialogRequest, subscribeNativeChatResumeOnRestartDialog } from './native-chat-resume-on-restart-dialog' @@ -31,6 +30,8 @@ import { useAutomaticPromptTurn, usePromptBlockingDialog } from './automatic-prompts/use-automatic-prompt-turn' +import { useNativeChatResumeLaunchDiscovery } from './native-chat-resume-launch-discovery' +import { AutomaticPromptDialogScope } from '@/lib/dialog-presence' import { continueNativeChatRestartOffer, dismissNativeChatRestartOffer, @@ -84,29 +85,23 @@ export function NativeChatResumeOnRestartModal(): React.JSX.Element | null { getNativeChatResumeOnRestartDialogRequest, getNativeChatResumeOnRestartDialogRequest ) - const launchDecided = useSyncExternalStore( - subscribeNativeChatResumeOnRestartDialog, - getNativeChatResumeLaunchDecided, - getNativeChatResumeLaunchDecided - ) // Only a dialog that can render asks for a turn, so a hidden one never holds others back. const renderable = offerEnabled && rows.length > 0 // Raised by the launch, it takes its turn among the dialogs that open by themselves; opened by the // user, it shows at once and the others wait for it. - const launchTurn = useAutomaticPromptTurn( + const [launchTurn, markLaunchShown] = useAutomaticPromptTurn( 'native-chat-resume', request === 'launch' && renderable ) usePromptBlockingDialog('native-chat-resume', request === 'user' && renderable) + // After the turn request above (effects run in order), so nothing takes the first turn between. + useNativeChatResumeLaunchDiscovery(offerEnabled) const open = request === 'user' || launchTurn - const settleLaunchPromptDiscovery = useAppStore((store) => store.settleLaunchPromptDiscovery) useEffect(() => { - // After the turn request above (effects run in order), so a fast crash report or tip cannot - // take the first turn in between. - if (launchDecided) { - settleLaunchPromptDiscovery() + if (launchTurn && renderable) { + markLaunchShown() } - }, [launchDecided, settleLaunchPromptDiscovery]) + }, [launchTurn, markLaunchShown, renderable]) const updateSettings = useAppStore((store) => store.updateSettings) const [dontAskAgain, setDontAskAgain] = useState(false) // The store's: the resume outlives this dialog, which can close or reopen mid-run. @@ -208,115 +203,121 @@ export function NativeChatResumeOnRestartModal(): React.JSX.Element | null { const interruptedByUpdate = rows.some((row) => row.trigger === 'update') return ( - { - if (!next) { - snooze() - } - }} - > - {/* Height is capped, never the data: the list scrolls inside the dialog so the header and - the primary action stay put however many chats were interrupted. */} - - - - {/* Plain wrapper owns the icon spacing; DialogTitle owns its own. */} - - - {translate( - 'auto.components.NativeChatResumeOnRestartModal.title', - 'Resume interrupted chats?' - )} - - - - {interruptedByUpdate - ? translate( - 'auto.components.NativeChatResumeOnRestartModal.updateBody', - 'These chats were working when Orca installed an update. Resuming restores each one where it stopped, with its full context, and asks the agent to check what it was doing before carrying on. Your own prompt is not re-sent.' - ) - : translate( - 'auto.components.NativeChatResumeOnRestartModal.body', - 'These chats were working when Orca closed. Resuming restores each one where it stopped, with its full context, and asks the agent to check what it was doing before carrying on. Your own prompt is not re-sent.' + // Its own dialog never counts as another one it waits for. + + { + if (!next) { + snooze() + } + }} + > + {/* Height is capped, never the data: the list scrolls inside the dialog so the header and + the primary action stay put however many chats were interrupted. */} + + + + {/* Plain wrapper owns the icon spacing; DialogTitle owns its own. */} + + + {translate( + 'auto.components.NativeChatResumeOnRestartModal.title', + 'Resume interrupted chats?' )} - - + + + + {interruptedByUpdate + ? translate( + 'auto.components.NativeChatResumeOnRestartModal.updateBody', + 'These chats were working when Orca installed an update. Resuming restores each one where it stopped, with its full context, and asks the agent to check what it was doing before carrying on. Your own prompt is not re-sent.' + ) + : translate( + 'auto.components.NativeChatResumeOnRestartModal.body', + 'These chats were working when Orca closed. Resuming restores each one where it stopped, with its full context, and asks the agent to check what it was doing before carrying on. Your own prompt is not re-sent.' + )} + + -
- failureBySession.get(sessionId)} - onFailureAction={(action, sessionId) => void actOnFailure(action, sessionId)} - /> -
+
+ failureBySession.get(sessionId)} + onFailureAction={(action, sessionId) => void actOnFailure(action, sessionId)} + /> +
-
-
+ + + + +
+ ) } diff --git a/src/renderer/src/components/automatic-prompts/automatic-prompt-turns.test.tsx b/src/renderer/src/components/automatic-prompts/automatic-prompt-turns.test.tsx index 281a4ee504c5..8a60a59f1102 100644 --- a/src/renderer/src/components/automatic-prompts/automatic-prompt-turns.test.tsx +++ b/src/renderer/src/components/automatic-prompts/automatic-prompt-turns.test.tsx @@ -15,6 +15,11 @@ import type { ResumeCandidate } from '../native-chat-resume-on-restart-grouping' import { _resetNativeChatRestartOffer } from '../native-chat-resume-on-restart-store' import { useOnboardingAndFeatureTips } from '../../app-shell/use-onboarding-and-feature-tips' import { AUTOMATIC_PROMPT_MODAL_KEY } from '@/store/slices/ui/automatic-prompt-turns' +import { resetLocalStructuredChatsForTests } from '@/runtime/local-structured-chats' +import { AutomaticPromptDialogScope } from '@/lib/dialog-presence' +import { Dialog, DialogContent, DialogTitle } from '../ui/dialog' +import FeatureTipsModal from '../feature-tips/FeatureTipsModal' +import { FailedFeatureTip } from '../feature-tips/use-app-open-feature-tip' const rpc = vi.hoisted(() => vi.fn()) vi.mock('@/runtime/structured-agent-session-client', () => ({ @@ -26,22 +31,46 @@ vi.mock('@/lib/activate-ai-vault-structured-session', () => ({ })) 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 })) // The real surface is covered by its own tests; here only whether it is on screen matters. -vi.mock('../crash-report/CrashReportDialogSurface', () => ({ - CrashReportDialogSurface: ({ - report, - onOpenChange - }: { - report: CrashReportRecord | null - onOpenChange: (open: boolean) => void - }) => ( -
- {report?.status} - -
- ) +vi.mock('../crash-report/CrashReportDialogSurface', async () => { + const { useEffect } = await import('react') + return { + CrashReportDialogSurface: ({ + open, + report, + onOpenChange, + onShown + }: { + open: boolean + report: CrashReportRecord | null + onOpenChange: (open: boolean) => void + onShown?: () => void + }) => { + useEffect(() => { + if (open) { + onShown?.() + } + }, [open, onShown]) + if (surface.suspended) { + // A lazy chunk still loading: granted the turn, nothing on screen yet. + throw surface.loaded + } + return ( +
+ {report?.status} + +
+ ) + } + } +}) +const boundaryReports = vi.hoisted((): CrashReportRecord[] => []) +vi.mock('@/lib/react-error-boundary-reporting', () => ({ + REACT_ERROR_BOUNDARY_REPORT_AVAILABLE_EVENT: 'test-boundary-report', + takePendingReactErrorBoundaryReport: () => boundaryReports.shift() ?? null })) globalThis.IS_REACT_ACT_ENVIRONMENT = true @@ -109,6 +138,20 @@ function sshOnScreen(): boolean { return document.body.textContent?.includes('SSH Key Passphrase') === true } +function screenButton(label: string): HTMLButtonElement { + const found = [...document.querySelectorAll('button')].find( + (button) => button.textContent?.trim() === label + ) + if (!found) { + throw new Error(`Missing button: ${label}`) + } + return found +} + +function shownCrashReportId(): string | null { + return document.querySelector('[data-testid="crash-report"]')?.getAttribute('data-report') ?? null +} + function closeResume(): void { act(() => { document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true })) @@ -121,6 +164,9 @@ beforeEach(() => { crashReports.getLatestReport.mockReset().mockResolvedValue(null) crashReports.dismiss.mockReset().mockResolvedValue(undefined) _resetNativeChatRestartOffer() + resetLocalStructuredChatsForTests() + surface.suspended = false + boundaryReports.length = 0 useAppStore.setState(useAppStore.getInitialState(), true) useAppStore.setState({ settings: { ...getDefaultSettings(''), experimentalStructuredNativeChat: true }, @@ -133,7 +179,11 @@ beforeEach(() => { ui: { onOpenCrashReport: () => () => {}, set: vi.fn(async () => undefined) }, ssh: { submitCredential: vi.fn(async () => undefined) }, cli: { getInstallStatus: vi.fn(async () => ({ supported: false })) }, - gh: { viewer: vi.fn(async () => null) } + gh: { viewer: vi.fn(async () => null) }, + app: { + holdsStructuredAgentSessions: vi.fn(async () => false), + onStructuredAgentSessionsHeldChanged: () => () => {} + } } }) container = document.createElement('div') @@ -289,35 +339,197 @@ it('an owner that unmounts mid-turn releases it', async () => { }) function FeatureTipHarness(): null { - const gate = useOnboardingAndFeatureTips() - const { applyStartupOnboardingState } = gate + const { applyStartupOnboardingState } = useOnboardingAndFeatureTips() useEffect(() => { applyStartupOnboardingState({ ...getDefaultOnboardingState(), closedAt: 1 }) }, [applyStartupOnboardingState]) return null } -it('marks a feature tip seen only when it is actually shown', async () => { - useAppStore.setState({ persistedUIReady: true, featureTipsSeenIds: [] }) +/** Leaves one tip to show, the keyboard palette one, whose dialog needs nothing else. */ +function seedOneFeatureTip(): void { + useAppStore.setState({ + persistedUIReady: true, + featureTipsSeenIds: ['voice-dictation', 'agent-session-search'] + }) useAppStore.getState().settleLaunchPromptDiscovery() +} + +it('marks a feature tip seen only once its dialog is on screen', async () => { + seedOneFeatureTip() // The resume offer holds the turn. useAppStore.getState().requestAutomaticPrompt('native-chat-resume') useAppStore.getState().markAutomaticPromptShown('native-chat-resume') - await mount() + await mount( + <> + + + + + + ) expect(useAppStore.getState().automaticPromptRequests.map((r) => r.id)).toContain('feature-tip') - expect(useAppStore.getState().featureTipsSeenIds).toEqual([]) expect(useAppStore.getState().activeModal).toBe('none') + expect(useAppStore.getState().featureTipsSeenIds).not.toContain('cmd-j-palette') await act(async () => useAppStore.getState().releaseAutomaticPrompt('native-chat-resume')) await flush() - const { activeModal, modalData, featureTipsSeenIds } = useAppStore.getState() + const { activeModal, modalData, featureTipsSeenIds, automaticPromptShownId } = + useAppStore.getState() expect(activeModal).toBe('feature-tips') expect(modalData[AUTOMATIC_PROMPT_MODAL_KEY]).toBe('feature-tip') - expect(featureTipsSeenIds).toEqual([modalData.tipId]) + expect(document.querySelector('[role="dialog"]')).not.toBeNull() + expect(featureTipsSeenIds).toContain('cmd-j-palette') + expect(automaticPromptShownId).toBe('feature-tip') // Closing the tip ends its turn. await act(async () => useAppStore.getState().closeModal()) await flush() expect(useAppStore.getState().automaticPromptRequests).toEqual([]) }) + +it('a tip replaced by the user before it was ever on screen keeps its turn and opens later', async () => { + seedOneFeatureTip() + // Only the owner: the tip's dialog never renders here, as when its lazy chunk is slow. + await mount() + expect(useAppStore.getState().activeModal).toBe('feature-tips') + + await act(async () => useAppStore.getState().openModal('settings')) + expect(useAppStore.getState().featureTipsSeenIds).not.toContain('cmd-j-palette') + expect(useAppStore.getState().automaticPromptRequests.map((r) => r.id)).toEqual(['feature-tip']) + + await act(async () => useAppStore.getState().closeModal()) + await flush() + expect(useAppStore.getState().activeModal).toBe('feature-tips') +}) + +it('a tip whose dialog fails to render gives up its turn', async () => { + seedOneFeatureTip() + await mount() + expect(useAppStore.getState().activeModal).toBe('feature-tips') + + await mount( + <> + + + + ) + expect(useAppStore.getState().activeModal).toBe('none') + expect(useAppStore.getState().automaticPromptRequests).toEqual([]) +}) + +it('a resume offer read after the wait ended is shown after what went first, not skipped', async () => { + const read = Promise.withResolvers() + rpc.mockImplementation(async () => read.promise) + crashReports.getLatestPending.mockResolvedValue(pendingCrash) + await mount( + <> + + + + ) + expect(crashOnScreen()).toBe(false) + + // The bound from the read's start runs out before the read answers. + await act(async () => useAppStore.getState().settleLaunchPromptDiscovery()) + await flush() + expect(crashOnScreen()).toBe(true) + + await act(async () => read.resolve({ sessions: offered })) + await flush() + expect(resumeOnScreen()).toBe(false) + expect(crashOnScreen()).toBe(true) + + await act(async () => screenButton('Close crash report').click()) + await flush() + expect(crashOnScreen()).toBe(false) + expect(resumeOnScreen()).toBe(true) +}) + +it('a crash report whose dialog has not rendered yet is neither acknowledged nor holding its turn', async () => { + useAppStore.getState().settleLaunchPromptDiscovery() + surface.suspended = true + surface.loaded = new Promise(() => {}) + crashReports.getLatestPending.mockResolvedValue(pendingCrash) + await mount() + + expect(useAppStore.getState().automaticPromptRequests.map((r) => r.id)).toEqual(['crash-report']) + expect(useAppStore.getState().automaticPromptShownId).toBeNull() + expect(crashReports.dismiss).not.toHaveBeenCalled() +}) + +it('each crash report takes its own turn; a second never replaces the first', async () => { + useAppStore.getState().settleLaunchPromptDiscovery() + await mount() + boundaryReports.push({ ...pendingCrash, id: 'boundary-1' }, { ...pendingCrash, id: 'boundary-2' }) + await act(async () => { + window.dispatchEvent(new Event('test-boundary-report')) + window.dispatchEvent(new Event('test-boundary-report')) + }) + await flush() + expect(shownCrashReportId()).toBe('boundary-1') + + await act(async () => screenButton('Close crash report').click()) + await flush() + expect(shownCrashReportId()).toBe('boundary-2') + await act(async () => screenButton('Close crash report').click()) + await flush() + expect(crashOnScreen()).toBe(false) + expect(useAppStore.getState().automaticPromptRequests).toEqual([]) +}) + +it('any other dialog on screen holds a visible resume offer back until it closes', async () => { + rpc.mockResolvedValue({ sessions: offered }) + await mount() + expect(resumeOnScreen()).toBe(true) + + await mount( + <> + + + + Unsaved changes + + + + ) + expect(resumeOnScreen()).toBe(false) + + await mount() + expect(resumeOnScreen()).toBe(true) +}) + +it('a machine with no chats ends the launch wait by itself', async () => { + useAppStore.setState({ + settings: { ...getDefaultSettings(''), experimentalStructuredNativeChat: false } + }) + await mount() + expect(rpc).not.toHaveBeenCalled() + expect(useAppStore.getState().launchPromptDiscoveryPending).toBe(false) +}) + +it('the tip owner going away closes its own tip, never a modal the user opened instead', async () => { + seedOneFeatureTip() + await mount( + + + + ) + expect(useAppStore.getState().activeModal).toBe('feature-tips') + await act(async () => window.dispatchEvent(new Event('test-unmount'))) + expect(useAppStore.getState().activeModal).toBe('none') + expect(useAppStore.getState().automaticPromptRequests).toEqual([]) + + act(() => root.unmount()) + root = createRoot(container) + useAppStore.setState({ featureTipsSeenIds: ['voice-dictation', 'agent-session-search'] }) + await mount( + + + + ) + await act(async () => useAppStore.getState().openModal('settings')) + await act(async () => window.dispatchEvent(new Event('test-unmount'))) + expect(useAppStore.getState().activeModal).toBe('settings') +}) diff --git a/src/renderer/src/components/automatic-prompts/use-automatic-prompt-turn.ts b/src/renderer/src/components/automatic-prompts/use-automatic-prompt-turn.ts index 7e520d57eb47..97b4b769cda5 100644 --- a/src/renderer/src/components/automatic-prompts/use-automatic-prompt-turn.ts +++ b/src/renderer/src/components/automatic-prompts/use-automatic-prompt-turn.ts @@ -1,4 +1,5 @@ -import { useEffect, useId, useLayoutEffect } from 'react' +import { useCallback, useEffect, useId, useLayoutEffect } from 'react' +import { useOtherDialogOpen } from '@/lib/dialog-presence' import { useAppStore } from '@/store' import { selectVisibleAutomaticPromptId, @@ -6,16 +7,27 @@ import { } from '@/store/slices/ui/automatic-prompt-turns' /** - * Asks for a turn while `wanted` and returns whether this prompt is the one to show now. + * Asks for a turn while `wanted` and returns whether this prompt is the one to show now, plus the + * call that records it as on screen. * * `wanted` must be false whenever the owner could not render the prompt, so a request never holds a - * turn nobody sees. Turning it off, closing, and unmounting all release the turn. + * turn nobody sees. Turning it off, closing, and unmounting all release the turn; a new `key` (the + * next item an owner queues) releases and asks again, so each item takes its own turn. + * + * Call `markShown` from the committed dialog content, never on being granted: a lazy surface may + * not have rendered yet, and only a prompt on screen keeps its turn against later arrivals. */ -export function useAutomaticPromptTurn(id: AutomaticPromptId, wanted: boolean): boolean { +export function useAutomaticPromptTurn( + id: AutomaticPromptId, + wanted: boolean, + key?: string +): [visible: boolean, markShown: () => void] { const requestAutomaticPrompt = useAppStore((s) => s.requestAutomaticPrompt) const releaseAutomaticPrompt = useAppStore((s) => s.releaseAutomaticPrompt) const markAutomaticPromptShown = useAppStore((s) => s.markAutomaticPromptShown) - const visible = useAppStore((s) => wanted && selectVisibleAutomaticPromptId(s) === id) + const selected = useAppStore((s) => wanted && selectVisibleAutomaticPromptId(s) === id) + // Any other dialog on screen, including ones the store does not track, holds this one back. + const otherDialogOpen = useOtherDialogOpen() useEffect(() => { if (!wanted) { @@ -23,15 +35,10 @@ export function useAutomaticPromptTurn(id: AutomaticPromptId, wanted: boolean): } requestAutomaticPrompt(id) return () => releaseAutomaticPrompt(id) - }, [id, wanted, requestAutomaticPrompt, releaseAutomaticPrompt]) - - useEffect(() => { - if (visible) { - markAutomaticPromptShown(id) - } - }, [id, visible, markAutomaticPromptShown]) + }, [id, key, wanted, requestAutomaticPrompt, releaseAutomaticPrompt]) - return visible + const markShown = useCallback(() => markAutomaticPromptShown(id), [id, markAutomaticPromptShown]) + return [selected && !otherDialogOpen, markShown] } /** diff --git a/src/renderer/src/components/crash-report/CrashReportDialog.tsx b/src/renderer/src/components/crash-report/CrashReportDialog.tsx index d6c164e2ed62..fd68ae027f76 100644 --- a/src/renderer/src/components/crash-report/CrashReportDialog.tsx +++ b/src/renderer/src/components/crash-report/CrashReportDialog.tsx @@ -9,6 +9,7 @@ import { useAutomaticPromptTurn, usePromptBlockingDialog } from '@/components/automatic-prompts/use-automatic-prompt-turn' +import { AutomaticPromptDialogScope } from '@/lib/dialog-presence' import type { CrashReportRecord } from '../../../../shared/crash-reporting' const CrashReportDialogSurface = lazy(() => @@ -32,12 +33,22 @@ export function CrashReportDialog(): React.JSX.Element | null { const [userOpen, setUserOpen] = useState(false) const [userReport, setUserReport] = useState(null) const [loading, setLoading] = useState(false) - const [automatic, setAutomatic] = useState(null) - const automaticVisible = useAutomaticPromptTurn('crash-report', automatic !== null && !userOpen) + // Each report the app raises by itself waits its own turn; a later one never replaces it. + const [queue, setQueue] = useState([]) + const automatic = queue[0] ?? null + const [automaticVisible, markAutomaticShown] = useAutomaticPromptTurn( + 'crash-report', + automatic !== null && !userOpen, + automatic?.report.id + ) usePromptBlockingDialog('crash-report', userOpen) const raiseCrashReport = useCallback((report: CrashReportRecord, acknowledgeOnShow = false) => { - setAutomatic({ report, acknowledgeOnShow }) + setQueue((current) => + current.some((entry) => entry.report.id === report.id) + ? current + : [...current, { report, acknowledgeOnShow }] + ) }, []) const loadUserCrashReport = useCallback(async (): Promise => { @@ -71,38 +82,39 @@ export function CrashReportDialog(): React.JSX.Element | null { .catch((error) => console.error('Failed to load crash report:', error)) }, [mountedRef, raiseCrashReport]) - useEffect(() => { - if (!automaticVisible || !automatic?.acknowledgeOnShow) { - return + const changeAutomaticReport = useCallback((report: CrashReportRecord | null) => { + if (report) { + setQueue((current) => + current.map((entry) => (entry.report.id === report.id ? { ...entry, report } : entry)) + ) } - const reportId = automatic.report.id - if (acknowledgedIds.current.has(reportId)) { + }, []) + + // From the committed dialog content: the lazy surface may load well after the turn is granted. + const onAutomaticShown = useCallback((): void => { + markAutomaticShown() + if (!automatic?.acknowledgeOnShow || acknowledgedIds.current.has(automatic.report.id)) { return } - acknowledgedIds.current.add(reportId) - // Why: startup crash prompts are one-shot. Acknowledged only once on screen, and never awaited: - // a failed write must not delay the prompt, and the dialog dismisses a still-pending report on - // close. Help > Report Crash can still reopen dismissed unsent reports. + const { report } = automatic + acknowledgedIds.current.add(report.id) + // Why: startup crash prompts are one-shot. Never awaited: a failed write must not hold the + // prompt back, and the dialog dismisses a still-pending report on close. Help > Report Crash + // can still reopen dismissed unsent reports. void window.api.crashReports - .dismiss({ reportId }) + .dismiss({ reportId: report.id }) .then(() => { if (mountedRef.current) { - setAutomatic((current) => - current?.report.id === reportId - ? { ...current, report: { ...current.report, status: 'dismissed' as const } } - : current - ) + changeAutomaticReport({ ...report, status: 'dismissed' as const }) } }) .catch((error) => { console.error('Failed to dismiss crash report after startup prompt:', error) }) - }, [automatic, automaticVisible, mountedRef]) + }, [automatic, changeAutomaticReport, markAutomaticShown, mountedRef]) useEffect(() => { return window.api.ui.onOpenCrashReport(() => { - // The user is now looking at crash reports; a queued one would only repeat what they see. - setAutomatic(null) setUserReport(null) setUserOpen(true) void loadUserCrashReport() @@ -131,33 +143,35 @@ export function CrashReportDialog(): React.JSX.Element | null { } }, [raiseCrashReport]) - const changeAutomaticReport = useCallback((report: CrashReportRecord | null) => { - setAutomatic((current) => (current && report ? { ...current, report } : current)) - }, []) - const open = userOpen || automaticVisible if (!open) { return null } return ( - - { - if (nextOpen) { - return - } - if (userOpen) { - setUserOpen(false) - } else { - setAutomatic(null) - } - }} - onReportChange={userOpen ? setUserReport : changeAutomaticReport} - /> - + // Its own dialog never counts as another one it waits for. + + + { + if (nextOpen) { + return + } + if (userOpen) { + setUserOpen(false) + } else { + setQueue((current) => current.slice(1)) + } + }} + onReportChange={userOpen ? setUserReport : changeAutomaticReport} + onShown={userOpen ? undefined : onAutomaticShown} + /> + + ) } diff --git a/src/renderer/src/components/crash-report/CrashReportDialogSurface.tsx b/src/renderer/src/components/crash-report/CrashReportDialogSurface.tsx index 05ad8fff24e5..88e5fe5b64f0 100644 --- a/src/renderer/src/components/crash-report/CrashReportDialogSurface.tsx +++ b/src/renderer/src/components/crash-report/CrashReportDialogSurface.tsx @@ -73,6 +73,8 @@ type CrashReportDialogSurfaceProps = { loading: boolean onOpenChange: (open: boolean) => void onReportChange: (report: CrashReportRecord | null) => void + /** Runs once this content is on screen, which a lazy load can delay past being asked to open. */ + onShown?: () => void } export function CrashReportDialogSurface({ @@ -80,7 +82,8 @@ export function CrashReportDialogSurface({ report, loading, onOpenChange, - onReportChange + onReportChange, + onShown }: CrashReportDialogSurfaceProps): React.JSX.Element { const mountedRef = useMountedRef() const [notes, setNotes] = useState('') @@ -122,6 +125,12 @@ export function CrashReportDialogSurface({ }) }, [mountedRef]) + useEffect(() => { + if (open) { + onShown?.() + } + }, [onShown, open]) + useEffect(() => { if (!open) { clearViewer() diff --git a/src/renderer/src/components/feature-tips/FeatureTipsModal.tsx b/src/renderer/src/components/feature-tips/FeatureTipsModal.tsx index 15299ad7dcf6..2ef7fee1bf93 100644 --- a/src/renderer/src/components/feature-tips/FeatureTipsModal.tsx +++ b/src/renderer/src/components/feature-tips/FeatureTipsModal.tsx @@ -7,7 +7,12 @@ import { notifyOrchestrationSetupStateChanged } from '@/lib/orchestration-setup-state' import { useAppStore } from '@/store' -import { selectAutomaticPromptSlotSuspended } from '@/store/slices/ui/automatic-prompt-turns' +import { + AUTOMATIC_PROMPT_MODAL_KEY, + selectAutomaticPromptSlotSuspended +} from '@/store/slices/ui/automatic-prompt-turns' +import { useOtherDialogOpen } from '@/lib/dialog-presence' +import { useAppOpenFeatureTipShown } from './use-app-open-feature-tip' import { CliSetupTipDialog } from './CliSetupTipDialog' import { CmdJPaletteTipDialog } from './CmdJPaletteTipDialog' import { installCliFromFeatureTip } from './feature-tip-cli-install-action' @@ -43,8 +48,10 @@ export default function FeatureTipsModal(): JSX.Element | null { const [primaryBusy, setPrimaryBusy] = useState(false) const [skillTerminalOpen, setSkillTerminalOpen] = useState(false) // Raised by the app, it steps aside while a dialog the user opened is up, then comes back. + const automaticTip = modalData[AUTOMATIC_PROMPT_MODAL_KEY] === 'feature-tip' const suspended = useAppStore(selectAutomaticPromptSlotSuspended) - const isOpen = activeModal === 'feature-tips' && !suspended + const otherDialogOpen = useOtherDialogOpen() + const isOpen = activeModal === 'feature-tips' && !(automaticTip && (suspended || otherDialogOpen)) const currentTip = getFeatureTipForModal({ cliInstalled: true, modalData, @@ -53,6 +60,7 @@ export default function FeatureTipsModal(): JSX.Element | null { settings, webClient: isWebClientLocation() }) + useAppOpenFeatureTipShown({ visible: isOpen, tipId: currentTip?.id, automatic: automaticTip }) const sessionSearchSetup = useSessionSearchTipSetup({ dialogOpen: isOpen && currentTip?.id === 'agent-session-search' }) diff --git a/src/renderer/src/components/feature-tips/use-app-open-feature-tip.ts b/src/renderer/src/components/feature-tips/use-app-open-feature-tip.ts new file mode 100644 index 000000000000..be17effb8de1 --- /dev/null +++ b/src/renderer/src/components/feature-tips/use-app-open-feature-tip.ts @@ -0,0 +1,90 @@ +import { useCallback, useEffect, useRef, useState } from 'react' +import { useAppStore } from '@/store' +import { useAutomaticPromptTurn } from '@/components/automatic-prompts/use-automatic-prompt-turn' +import { AUTOMATIC_PROMPT_MODAL_KEY } from '@/store/slices/ui/automatic-prompt-turns' +import { MODAL_DISMISSED_KEY } from '@/store/slices/modal-slot-dismissal' +import type { FeatureTipId } from '../../../../shared/feature-tips' +import { + trackCmdJPaletteFeatureTipShown, + trackOrcaCliFeatureTipShown +} from './feature-tip-telemetry' + +function closeOwnTip(): void { + const state = useAppStore.getState() + // Only the tip's own entry; a modal the user opened in its place stays. + if (state.modalData[AUTOMATIC_PROMPT_MODAL_KEY] === 'feature-tip') { + state.closeModal() + } +} + +/** + * The app-open feature tip, waiting for its turn among the dialogs that open by themselves. + * Returns the call that queues a tip. It is marked seen by the dialog once on screen, not here. + */ +export function useAppOpenFeatureTip(): (tipId: FeatureTipId) => void { + const [pendingTipId, setPendingTipId] = useState(null) + const openedTipIdRef = useRef(null) + const [tipTurn] = useAutomaticPromptTurn('feature-tip', pendingTipId !== null) + const openModal = useAppStore((s) => s.openModal) + + useEffect(() => { + if (!tipTurn || pendingTipId === null || openedTipIdRef.current === pendingTipId) { + return + } + openedTipIdRef.current = pendingTipId + const tipId = pendingTipId + openModal('feature-tips', { + source: 'app_open', + tipId, + [AUTOMATIC_PROMPT_MODAL_KEY]: 'feature-tip', + [MODAL_DISMISSED_KEY]: () => { + const { activeModal, featureTipsSeenIds } = useAppStore.getState() + // Replaced by a modal the user opened before it was ever on screen: it keeps its turn and + // opens again afterwards. Closed, or already seen, it is done. + if (activeModal !== 'none' && !featureTipsSeenIds.includes(tipId)) { + openedTipIdRef.current = null + return + } + setPendingTipId(null) + } + }) + }, [openModal, pendingTipId, tipTurn]) + + // The owner going away ends its tip's turn with it. + useEffect(() => closeOwnTip, []) + + return useCallback((tipId: FeatureTipId) => setPendingTipId(tipId), []) +} + +/** Called by the tip dialog once its content is on screen. */ +export function useAppOpenFeatureTipShown(args: { + visible: boolean + tipId: FeatureTipId | undefined + automatic: boolean +}): void { + const { visible, tipId, automatic } = args + const markFeatureTipsSeen = useAppStore((s) => s.markFeatureTipsSeen) + const markAutomaticPromptShown = useAppStore((s) => s.markAutomaticPromptShown) + const shownTipIdRef = useRef(null) + useEffect(() => { + if (!visible || !automatic || !tipId || shownTipIdRef.current === tipId) { + return + } + shownTipIdRef.current = tipId + if (tipId === 'orca-cli') { + trackOrcaCliFeatureTipShown('app_open') + } else if (tipId === 'cmd-j-palette') { + trackCmdJPaletteFeatureTipShown('app_open') + } + // Why: mark seen on show so a quit/crash before dismiss doesn't reappear it next launch. + markFeatureTipsSeen([tipId]) + markAutomaticPromptShown('feature-tip') + }, [automatic, markAutomaticPromptShown, markFeatureTipsSeen, tipId, visible]) +} + +/** Rendered in place of a tip whose dialog failed: ends its turn rather than holding every later + * prompt back. The boundary still reports the error. */ +export function FailedFeatureTip(): null { + useEffect(closeOwnTip, []) + return null +} diff --git a/src/renderer/src/components/native-chat-resume-launch-discovery.ts b/src/renderer/src/components/native-chat-resume-launch-discovery.ts new file mode 100644 index 000000000000..7177817a4abe --- /dev/null +++ b/src/renderer/src/components/native-chat-resume-launch-discovery.ts @@ -0,0 +1,53 @@ +import { useEffect, useSyncExternalStore } from 'react' +import { readLocalStructuredAgentSessionsHeld } from '@/runtime/local-structured-chats' +import { useAppStore } from '../store' +import { + getNativeChatResumeLaunchDecided, + markNativeChatResumeLaunchDecided, + subscribeNativeChatResumeOnRestartDialog +} from './native-chat-resume-on-restart-dialog' + +/** + * Tells the dialogs that open by themselves when this machine's resume read has started and when it + * has decided, so the resume offer goes first. A machine that holds no chats reads nothing; that is + * decided as soon as it is known, so the wait never depends on some other prompt asking for a turn. + */ +export function useNativeChatResumeLaunchDiscovery(offerEnabled: boolean): void { + const launchDecided = useSyncExternalStore( + subscribeNativeChatResumeOnRestartDialog, + getNativeChatResumeLaunchDecided, + getNativeChatResumeLaunchDecided + ) + const settingsLoaded = useAppStore((store) => store.settings !== null) + const beginLaunchPromptDiscovery = useAppStore((store) => store.beginLaunchPromptDiscovery) + const settleLaunchPromptDiscovery = useAppStore((store) => store.settleLaunchPromptDiscovery) + + useEffect(() => { + // The read starts with the offer once enabled; the wait's bound runs from here. + if (offerEnabled) { + beginLaunchPromptDiscovery() + } + }, [offerEnabled, beginLaunchPromptDiscovery]) + + useEffect(() => { + if (launchDecided) { + settleLaunchPromptDiscovery() + } + }, [launchDecided, settleLaunchPromptDiscovery]) + + useEffect(() => { + if (offerEnabled || !settingsLoaded || launchDecided) { + return + } + let cancelled = false + // A runtime that holds a chat turns the offer on instead, and its read decides. + void readLocalStructuredAgentSessionsHeld().then((holds) => { + if (!cancelled && !holds) { + markNativeChatResumeLaunchDecided() + } + }) + return () => { + cancelled = true + } + }, [launchDecided, offerEnabled, settingsLoaded]) +} diff --git a/src/renderer/src/components/ui/dialog.tsx b/src/renderer/src/components/ui/dialog.tsx index f996d91b1b6a..72a12b75d338 100644 --- a/src/renderer/src/components/ui/dialog.tsx +++ b/src/renderer/src/components/ui/dialog.tsx @@ -8,6 +8,7 @@ import { cn } from '@/lib/utils' import { handleImeOverlayEscape } from '@/lib/ime-overlay-escape' import { Button } from '@/components/ui/button' import { translate } from '@/i18n/i18n' +import { DialogPresenceMarker } from '@/lib/dialog-presence' function Dialog({ ...props }: React.ComponentProps) { return @@ -74,6 +75,7 @@ function DialogContent({ {...props} onEscapeKeyDown={(event) => handleImeOverlayEscape(event, props.onEscapeKeyDown)} > + {children} {showCloseButton && ( () +const listeners = new Set<() => void>() + +function emit(): void { + for (const listener of listeners) { + listener() + } +} + +function subscribe(listener: () => void): () => void { + listeners.add(listener) + return () => { + listeners.delete(listener) + } +} + +function getOpenDialogCount(): number { + return openDialogs.size +} + +/** Set around a dialog that opens by itself, so its own dialog never counts as another one. */ +export const AutomaticPromptDialogScope = createContext(false) + +/** Rendered inside the primitive's content, which mounts only while the dialog is open. */ +export function DialogPresenceMarker(): null { + const ownedByAutomaticPrompt = useContext(AutomaticPromptDialogScope) + // Layout effect: a prompt already showing steps aside before this dialog's first paint. + useLayoutEffect(() => { + if (ownedByAutomaticPrompt) { + return + } + const entry = Symbol('dialog') + openDialogs.add(entry) + emit() + return () => { + openDialogs.delete(entry) + emit() + } + }, [ownedByAutomaticPrompt]) + return null +} + +export function useOtherDialogOpen(): boolean { + return useSyncExternalStore(subscribe, getOpenDialogCount, getOpenDialogCount) > 0 +} diff --git a/src/renderer/src/store/slices/ui/automatic-prompt-turns.test.ts b/src/renderer/src/store/slices/ui/automatic-prompt-turns.test.ts index 50ed3da0a01c..2be663e772c0 100644 --- a/src/renderer/src/store/slices/ui/automatic-prompt-turns.test.ts +++ b/src/renderer/src/store/slices/ui/automatic-prompt-turns.test.ts @@ -4,6 +4,7 @@ import type { AppState } from '../../types' import { createUIStore } from '../ui-slice-test-harness' import { AUTOMATIC_PROMPT_MODAL_KEY, + LAUNCH_PROMPT_DISCOVERY_BACKSTOP_MS, LAUNCH_PROMPT_DISCOVERY_BOUND_MS, selectAutomaticPromptSlotSuspended, selectPromptSurfaceVisible, @@ -138,10 +139,13 @@ describe('automatic prompt turns', () => { expect(show(store)).toBe('native-chat-resume') }) - it('stops waiting at the bound when the resume read never decides', () => { + it('stops waiting a fixed time after the resume read starts', () => { vi.useFakeTimers() const store = createUIStore({ launchPromptDiscoveryPending: true }) store.getState().requestAutomaticPrompt('crash-report') + // A loaded machine can start the read well after boot; the bound runs from the read. + vi.advanceTimersByTime(5_000) + store.getState().beginLaunchPromptDiscovery() vi.advanceTimersByTime(LAUNCH_PROMPT_DISCOVERY_BOUND_MS - 1) expect(visible(store)).toBeNull() @@ -149,13 +153,28 @@ describe('automatic prompt turns', () => { expect(visible(store)).toBe('crash-report') }) - it('measures the bound from boot, not from the request', () => { + it('ends by itself when no prompt ever asks, so tours can start', () => { vi.useFakeTimers() const store = createUIStore({ launchPromptDiscoveryPending: true }) - vi.advanceTimersByTime(LAUNCH_PROMPT_DISCOVERY_BOUND_MS + 500) - store.getState().requestAutomaticPrompt('feature-tip') - vi.advanceTimersByTime(0) - expect(visible(store)).toBe('feature-tip') + expect(selectTourBlockedByPrompts(store.getState(), false)).toBe(true) + + vi.advanceTimersByTime(LAUNCH_PROMPT_DISCOVERY_BACKSTOP_MS) + expect(store.getState().launchPromptDiscoveryPending).toBe(false) + expect(selectTourBlockedByPrompts(store.getState(), false)).toBe(false) + }) + + it('a resume offer read after the bound still takes its turn, after what went first', () => { + vi.useFakeTimers() + const store = createUIStore({ launchPromptDiscoveryPending: true }) + store.getState().beginLaunchPromptDiscovery() + store.getState().requestAutomaticPrompt('crash-report') + vi.advanceTimersByTime(LAUNCH_PROMPT_DISCOVERY_BOUND_MS) + expect(show(store)).toBe('crash-report') + + store.getState().requestAutomaticPrompt('native-chat-resume') + expect(visible(store)).toBe('crash-report') + store.getState().releaseAutomaticPrompt('crash-report') + expect(visible(store)).toBe('native-chat-resume') }) it('never waits for discovery while a user dialog is what is up', () => { diff --git a/src/renderer/src/store/slices/ui/automatic-prompt-turns.ts b/src/renderer/src/store/slices/ui/automatic-prompt-turns.ts index b42f4ceb80f7..02382584defc 100644 --- a/src/renderer/src/store/slices/ui/automatic-prompt-turns.ts +++ b/src/renderer/src/store/slices/ui/automatic-prompt-turns.ts @@ -20,10 +20,14 @@ export type AutomaticPromptId = keyof typeof AUTOMATIC_PROMPT_PRIORITY * modal that the prompt has to wait for. */ export const AUTOMATIC_PROMPT_MODAL_KEY = 'automaticPromptId' -/** How long automatic prompts wait at launch for this machine's resume offer to be read. Local only; - * a remote host is never awaited. */ +/** How long automatic prompts wait at launch for this machine's resume read, measured from when the + * read starts. Local only; a remote host is never awaited. Expiry only changes the order: a resume + * offer read later still takes its turn after whatever went first. */ export const LAUNCH_PROMPT_DISCOVERY_BOUND_MS = 2000 +/** The wait's backstop when the read never starts and nothing says none is needed (no bridge). */ +export const LAUNCH_PROMPT_DISCOVERY_BACKSTOP_MS = 10_000 + export type AutomaticPromptRequest = Readonly<{ id: AutomaticPromptId; seq: number }> export type PromptTurnState = { @@ -33,7 +37,8 @@ export type PromptTurnState = { automaticPromptShownId: AutomaticPromptId | null promptBlockingDialogIds: readonly string[] launchPromptDiscoveryPending: boolean - /** When the launch wait ends regardless (epoch ms), fixed at boot. */ + /** When the launch wait ends regardless (epoch ms): the backstop from boot, then the bound from + * the start of the resume read. */ launchPromptDiscoveryDeadline: number } diff --git a/src/renderer/src/store/slices/ui/ui-slice-contract-prompt-turns.ts b/src/renderer/src/store/slices/ui/ui-slice-contract-prompt-turns.ts index 34ebe4a05df9..f87825ff3922 100644 --- a/src/renderer/src/store/slices/ui/ui-slice-contract-prompt-turns.ts +++ b/src/renderer/src/store/slices/ui/ui-slice-contract-prompt-turns.ts @@ -8,6 +8,9 @@ export type UISlicePromptTurns = PromptTurnState & { /** Called once the prompt is on screen: from here it keeps the turn until released. */ markAutomaticPromptShown: (id: AutomaticPromptId) => void setPromptBlockingDialogVisible: (id: string, visible: boolean) => void - /** This machine's resume offer has been read and decided; launch prompts may now take turns. */ + /** This machine's resume read has started; the launch wait now ends a fixed time after this. */ + beginLaunchPromptDiscovery: () => void + /** This machine's resume offer has been read and decided, or none needs reading; launch prompts + * may now take turns. */ settleLaunchPromptDiscovery: () => void } diff --git a/src/renderer/src/store/slices/ui/ui-slice-prompt-turn-actions.ts b/src/renderer/src/store/slices/ui/ui-slice-prompt-turn-actions.ts index 821d9b1f3d90..5b6661e67a49 100644 --- a/src/renderer/src/store/slices/ui/ui-slice-prompt-turn-actions.ts +++ b/src/renderer/src/store/slices/ui/ui-slice-prompt-turn-actions.ts @@ -1,9 +1,13 @@ import type { UISlice, UISliceGet, UISliceSet } from './ui-slice-contract' -import { LAUNCH_PROMPT_DISCOVERY_BOUND_MS } from './automatic-prompt-turns' +import { + LAUNCH_PROMPT_DISCOVERY_BACKSTOP_MS, + LAUNCH_PROMPT_DISCOVERY_BOUND_MS +} from './automatic-prompt-turns' export function createUiPromptTurnActions(set: UISliceSet, get: UISliceGet): Partial { let discoveryTimer: ReturnType | null = null let nextSeq = 0 + let discoveryBegun = false const settleDiscovery = (): void => { if (discoveryTimer !== null) { @@ -15,39 +19,35 @@ export function createUiPromptTurnActions(set: UISliceSet, get: UISliceGet): Par } } - // Armed lazily so a store nobody asks of runs no timer. Re-reads the deadline when it fires, since - // the deadline is state and may have moved. - const armDiscoveryTimer = (): void => { - if (!get().launchPromptDiscoveryPending || discoveryTimer !== null) { + // Re-reads the deadline when it fires, since the deadline is state and may have moved. + const onDiscoveryTimer = (): void => { + discoveryTimer = null + const { launchPromptDiscoveryPending, launchPromptDiscoveryDeadline } = get() + if (!launchPromptDiscoveryPending) { return } - discoveryTimer = setTimeout( - () => { - discoveryTimer = null - if (Date.now() >= get().launchPromptDiscoveryDeadline) { - settleDiscovery() - } else { - armDiscoveryTimer() - } - }, - Math.max(0, get().launchPromptDiscoveryDeadline - Date.now()) - ) + if (Date.now() >= launchPromptDiscoveryDeadline) { + settleDiscovery() + return + } + discoveryTimer = setTimeout(onDiscoveryTimer, launchPromptDiscoveryDeadline - Date.now()) } + // Armed at boot, never by a request: the wait must end even when no prompt ever asks for a turn, + // or tours would never start. + discoveryTimer = setTimeout(onDiscoveryTimer, LAUNCH_PROMPT_DISCOVERY_BACKSTOP_MS) return { automaticPromptRequests: [], automaticPromptShownId: null, promptBlockingDialogIds: [], launchPromptDiscoveryPending: true, - // Measured from store creation, i.e. renderer boot, so no prompt waits past it whenever it asks. - launchPromptDiscoveryDeadline: Date.now() + LAUNCH_PROMPT_DISCOVERY_BOUND_MS, + launchPromptDiscoveryDeadline: Date.now() + LAUNCH_PROMPT_DISCOVERY_BACKSTOP_MS, requestAutomaticPrompt: (id) => { if (get().automaticPromptRequests.some((request) => request.id === id)) { return } nextSeq += 1 set({ automaticPromptRequests: [...get().automaticPromptRequests, { id, seq: nextSeq }] }) - armDiscoveryTimer() }, releaseAutomaticPrompt: (id) => { const state = get() @@ -80,6 +80,18 @@ export function createUiPromptTurnActions(set: UISliceSet, get: UISliceGet): Par promptBlockingDialogIds: visible ? [...ids, id] : ids.filter((entry) => entry !== id) }) }, + beginLaunchPromptDiscovery: () => { + if (!get().launchPromptDiscoveryPending || discoveryBegun) { + return + } + discoveryBegun = true + const deadline = Date.now() + LAUNCH_PROMPT_DISCOVERY_BOUND_MS + set({ launchPromptDiscoveryDeadline: deadline }) + if (discoveryTimer !== null) { + clearTimeout(discoveryTimer) + } + discoveryTimer = setTimeout(onDiscoveryTimer, LAUNCH_PROMPT_DISCOVERY_BOUND_MS) + }, settleLaunchPromptDiscovery: settleDiscovery } } From bbbb4e9792123a392c3aa31e03cc056b333b851a Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 5 Oct 2026 14:08:08 -0700 Subject: [PATCH 03/23] test(dialogs): use a real user modal id in the prompt-turn tests --- .../automatic-prompts/automatic-prompt-turns.test.tsx | 8 ++++---- .../src/store/slices/ui/automatic-prompt-turns.test.ts | 4 ++-- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/renderer/src/components/automatic-prompts/automatic-prompt-turns.test.tsx b/src/renderer/src/components/automatic-prompts/automatic-prompt-turns.test.tsx index 8a60a59f1102..f60e1eb67167 100644 --- a/src/renderer/src/components/automatic-prompts/automatic-prompt-turns.test.tsx +++ b/src/renderer/src/components/automatic-prompts/automatic-prompt-turns.test.tsx @@ -307,7 +307,7 @@ it('a modal the user opens hides the resume offer, which comes back after', asyn await mount() expect(resumeOnScreen()).toBe(true) - await act(async () => useAppStore.getState().openModal('settings')) + await act(async () => useAppStore.getState().openModal('add-repo')) expect(resumeOnScreen()).toBe(false) await act(async () => useAppStore.getState().closeModal()) expect(resumeOnScreen()).toBe(true) @@ -395,7 +395,7 @@ it('a tip replaced by the user before it was ever on screen keeps its turn and o await mount() expect(useAppStore.getState().activeModal).toBe('feature-tips') - await act(async () => useAppStore.getState().openModal('settings')) + await act(async () => useAppStore.getState().openModal('add-repo')) expect(useAppStore.getState().featureTipsSeenIds).not.toContain('cmd-j-palette') expect(useAppStore.getState().automaticPromptRequests.map((r) => r.id)).toEqual(['feature-tip']) @@ -529,7 +529,7 @@ it('the tip owner going away closes its own tip, never a modal the user opened i ) - await act(async () => useAppStore.getState().openModal('settings')) + await act(async () => useAppStore.getState().openModal('add-repo')) await act(async () => window.dispatchEvent(new Event('test-unmount'))) - expect(useAppStore.getState().activeModal).toBe('settings') + expect(useAppStore.getState().activeModal).toBe('add-repo') }) diff --git a/src/renderer/src/store/slices/ui/automatic-prompt-turns.test.ts b/src/renderer/src/store/slices/ui/automatic-prompt-turns.test.ts index 2be663e772c0..867adc1a353c 100644 --- a/src/renderer/src/store/slices/ui/automatic-prompt-turns.test.ts +++ b/src/renderer/src/store/slices/ui/automatic-prompt-turns.test.ts @@ -80,7 +80,7 @@ describe('automatic prompt turns', () => { store.getState().requestAutomaticPrompt('native-chat-resume') expect(show(store)).toBe('native-chat-resume') - store.getState().openModal('settings') + store.getState().openModal('add-repo') expect(visible(store)).toBeNull() // The prompt keeps its turn while hidden, so another waiting prompt cannot take it. store.getState().requestAutomaticPrompt('crash-report') @@ -194,7 +194,7 @@ describe('automatic prompt turns', () => { const store = createUIStore() expect(selectTourBlockedByPrompts(store.getState(), false)).toBe(false) store.getState().requestAutomaticPrompt('native-chat-resume') - store.getState().openModal('settings') + store.getState().openModal('add-repo') // Hidden behind the user's modal, the resume offer still goes before an automatic tour. expect(selectTourBlockedByPrompts(store.getState(), false)).toBe(true) // A tour the user asked for yields only to what is on screen. From 6317bc7d4a4be33455353f9826029ac6b6364f69 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 5 Oct 2026 14:29:19 -0700 Subject: [PATCH 04/23] fix(dialogs): a prompt on screen steps aside without unmounting; turns belong to the queued item - A shown automatic prompt stays mounted under a dialog opened over it; its dialog scope hides it (data-stepped-aside on the shared dialog primitive), so typed crash notes, a send in flight and the CLI tip's setup terminal survive. Only the automatic instance is scoped; dialogs a prompt opens inside itself are part of it. - Being shown is a property of the queued request (id + key), so an owner's next item never inherits it and its content's mark is never lost to effect order. - User dialogs are read from what is rendered (presence mirrored into the store), not the modal slot, so a modal that failed to render holds nothing back, including its own crash report. - CommandDialog counts as a dialog; a queued prompt that cannot show does not block a tour inside the dialog holding the screen; the app-level tip owner no longer re-renders on every dialog. - Help > Report Crash drops that report from the automatic queue; error-boundary reports not yet shown coalesce to the newest. --- .../src/app-shell/AppRootSurfaces.tsx | 6 +- .../NativeChatResumeOnRestartModal.tsx | 6 +- .../automatic-prompt-turns.test.tsx | 379 ++++++++++++++++-- .../use-automatic-prompt-turn.ts | 43 +- .../CrashReportDialog.prompt-turns.test.tsx | 208 ++++++++++ .../crash-report/CrashReportDialog.tsx | 55 ++- .../FeatureTipsModal.prompt-turns.test.tsx | 125 ++++++ .../feature-tips/FeatureTipsModal.tsx | 19 +- src/renderer/src/components/ui/command.tsx | 2 + src/renderer/src/components/ui/dialog.tsx | 12 +- src/renderer/src/lib/dialog-presence.ts | 53 --- src/renderer/src/lib/dialog-presence.tsx | 106 +++++ .../slices/ui/automatic-prompt-turns.test.ts | 101 ++++- .../store/slices/ui/automatic-prompt-turns.ts | 64 ++- .../ui/ui-slice-contract-prompt-turns.ts | 13 +- .../slices/ui/ui-slice-prompt-turn-actions.ts | 58 ++- 16 files changed, 1059 insertions(+), 191 deletions(-) create mode 100644 src/renderer/src/components/crash-report/CrashReportDialog.prompt-turns.test.tsx create mode 100644 src/renderer/src/components/feature-tips/FeatureTipsModal.prompt-turns.test.tsx delete mode 100644 src/renderer/src/lib/dialog-presence.ts create mode 100644 src/renderer/src/lib/dialog-presence.tsx diff --git a/src/renderer/src/app-shell/AppRootSurfaces.tsx b/src/renderer/src/app-shell/AppRootSurfaces.tsx index 537feb214c06..8a8f9a7dbf8b 100644 --- a/src/renderer/src/app-shell/AppRootSurfaces.tsx +++ b/src/renderer/src/app-shell/AppRootSurfaces.tsx @@ -18,7 +18,6 @@ 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 { AutomaticPromptDialogScope } from '@/lib/dialog-presence' import { FailedFeatureTip } from '../components/feature-tips/use-app-open-feature-tip' import { selectAppRootSurfacePetEnabled, @@ -269,10 +268,7 @@ export function AppRootSurfaces(props: { resetKey={activeModal === 'feature-tips'} fallback={renderFailedFeatureTip} > - {/* Opens by itself, so its own dialogs never count as another dialog it waits for. */} - - - + ) : null} diff --git a/src/renderer/src/components/NativeChatResumeOnRestartModal.tsx b/src/renderer/src/components/NativeChatResumeOnRestartModal.tsx index 08bb351690ff..b52f7c57e6a0 100644 --- a/src/renderer/src/components/NativeChatResumeOnRestartModal.tsx +++ b/src/renderer/src/components/NativeChatResumeOnRestartModal.tsx @@ -203,8 +203,8 @@ export function NativeChatResumeOnRestartModal(): React.JSX.Element | null { const interruptedByUpdate = rows.some((row) => row.trigger === 'update') return ( - // Its own dialog never counts as another one it waits for. - + // Raised by the launch, it steps aside under another dialog; opened by the user, it counts as one. + { @@ -318,6 +318,6 @@ export function NativeChatResumeOnRestartModal(): React.JSX.Element | null { - + ) } diff --git a/src/renderer/src/components/automatic-prompts/automatic-prompt-turns.test.tsx b/src/renderer/src/components/automatic-prompts/automatic-prompt-turns.test.tsx index f60e1eb67167..e16afb48c072 100644 --- a/src/renderer/src/components/automatic-prompts/automatic-prompt-turns.test.tsx +++ b/src/renderer/src/components/automatic-prompts/automatic-prompt-turns.test.tsx @@ -13,13 +13,17 @@ import { SshPassphraseDialog } from '../settings/SshPassphraseDialog' import { TooltipProvider } from '../ui/tooltip' import type { ResumeCandidate } from '../native-chat-resume-on-restart-grouping' import { _resetNativeChatRestartOffer } from '../native-chat-resume-on-restart-store' +import { requestNativeChatResumeOnRestartDialog } from '../native-chat-resume-on-restart-dialog' import { useOnboardingAndFeatureTips } from '../../app-shell/use-onboarding-and-feature-tips' import { AUTOMATIC_PROMPT_MODAL_KEY } from '@/store/slices/ui/automatic-prompt-turns' import { resetLocalStructuredChatsForTests } from '@/runtime/local-structured-chats' import { AutomaticPromptDialogScope } from '@/lib/dialog-presence' import { Dialog, DialogContent, DialogTitle } from '../ui/dialog' +import { CommandDialog } from '../ui/command' import FeatureTipsModal from '../feature-tips/FeatureTipsModal' -import { FailedFeatureTip } from '../feature-tips/use-app-open-feature-tip' +import { FailedFeatureTip, useAppOpenFeatureTip } from '../feature-tips/use-app-open-feature-tip' +import { RecoverableRenderErrorBoundary } from '../error-boundaries/RecoverableRenderErrorBoundary' +import { useAutomaticPromptTurn, usePromptBlockingDialog } from './use-automatic-prompt-turn' const rpc = vi.hoisted(() => vi.fn()) vi.mock('@/runtime/structured-agent-session-client', () => ({ @@ -31,10 +35,11 @@ vi.mock('@/lib/activate-ai-vault-structured-session', () => ({ })) 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 })) +const surface = vi.hoisted(() => ({ loaded: Promise.resolve(), suspended: false, mounts: 0 })) // The real surface is covered by its own tests; here only whether it is on screen matters. vi.mock('../crash-report/CrashReportDialogSurface', async () => { const { useEffect } = await import('react') + const { DialogPresenceMarker, useAutomaticPromptScope } = await import('@/lib/dialog-presence') return { CrashReportDialogSurface: ({ open, @@ -47,6 +52,10 @@ vi.mock('../crash-report/CrashReportDialogSurface', async () => { onOpenChange: (open: boolean) => void onShown?: () => void }) => { + const steppedAside = useAutomaticPromptScope()?.steppedAside === true + useEffect(() => { + surface.mounts += 1 + }, []) useEffect(() => { if (open) { onShown?.() @@ -57,7 +66,14 @@ vi.mock('../crash-report/CrashReportDialogSurface', async () => { throw surface.loaded } return ( -
+
+ {/* As the real surface's DialogContent does. */} + {report?.status}