![]()
({
- state: {
- activeModal: 'project-added',
- modalData: {} as Record
,
- closeModal: vi.fn(),
- repos: [] as Repo[],
- worktreesByRepo: {} as Record,
- fetchRepos: vi.fn(),
- fetchWorktrees: vi.fn(),
- setHideDefaultBranchWorkspace: vi.fn()
- },
- activateAndRevealWorktree: vi.fn(),
- finishProjectAddWithDefaultCheckout: vi.fn()
-}))
-
-vi.mock('react', async (importOriginal) => {
- const actual = await importOriginal()
- return {
- ...actual,
- useEffect: (effect: () => void | (() => void)) => {
- effect()
- }
- }
-})
-
-vi.mock('@/store', () => ({
- useAppStore: Object.assign(
- (selector: (state: typeof mocks.state) => unknown) => selector(mocks.state),
- {
- getState: () => mocks.state
- }
- )
-}))
-
-vi.mock('@/lib/worktree-activation', () => ({
- activateAndRevealWorktree: mocks.activateAndRevealWorktree
-}))
-
-vi.mock('./project-added-default-checkout', () => ({
- finishProjectAddWithDefaultCheckout: mocks.finishProjectAddWithDefaultCheckout
-}))
-
-function makeRepo(overrides: Partial = {}): Repo {
- return {
- id: 'repo-1',
- path: '/repo',
- displayName: 'orca',
- badgeColor: '#999999',
- addedAt: 1,
- ...overrides
- }
-}
-
-async function flushPromises(): Promise {
- await Promise.resolve()
- await Promise.resolve()
-}
-
-describe('ProjectAddedDialog', () => {
- beforeEach(() => {
- vi.clearAllMocks()
- mocks.state.activeModal = 'project-added'
- mocks.state.modalData = { repoId: 'repo-1' }
- mocks.state.repos = [makeRepo()]
- mocks.state.worktreesByRepo = {}
- mocks.state.fetchRepos.mockResolvedValue(undefined)
- mocks.state.fetchWorktrees.mockResolvedValue(true)
- })
-
- it('fetches worktrees and finishes by opening the default checkout for Git repos', async () => {
- const { default: ProjectAddedDialog } = await import('./ProjectAddedDialog')
-
- const markup = renderToStaticMarkup()
- await flushPromises()
-
- expect(markup).toBe('')
- expect(mocks.state.fetchWorktrees).toHaveBeenCalledWith('repo-1')
- expect(mocks.finishProjectAddWithDefaultCheckout).toHaveBeenCalledWith({
- repoId: 'repo-1',
- source: 'project_added_compat',
- closeModal: mocks.state.closeModal,
- setHideDefaultBranchWorkspace: mocks.state.setHideDefaultBranchWorkspace
- })
- })
-
- it('does not block the compatibility handoff during StrictMode effect replay', async () => {
- const cleanupFns: (() => void)[] = []
- vi.doMock('react', async (importOriginal) => {
- const actual = await importOriginal()
- return {
- ...actual,
- useEffect: (effect: () => void | (() => void)) => {
- const cleanup = effect()
- if (cleanup) {
- cleanupFns.push(cleanup)
- }
- }
- }
- })
- vi.resetModules()
- const { default: ProjectAddedDialog } = await import('./ProjectAddedDialog')
-
- renderToStaticMarkup()
- cleanupFns.pop()?.()
- renderToStaticMarkup()
- await flushPromises()
-
- expect(mocks.state.fetchWorktrees).toHaveBeenCalledTimes(2)
- expect(mocks.finishProjectAddWithDefaultCheckout).toHaveBeenCalledTimes(1)
- expect(mocks.finishProjectAddWithDefaultCheckout).toHaveBeenCalledWith({
- repoId: 'repo-1',
- source: 'project_added_compat',
- closeModal: mocks.state.closeModal,
- setHideDefaultBranchWorkspace: mocks.state.setHideDefaultBranchWorkspace
- })
- })
-
- it('accepts older onboarding modal data that uses projectId', async () => {
- mocks.state.modalData = { projectId: 'repo-1' }
- const { default: ProjectAddedDialog } = await import('./ProjectAddedDialog')
-
- renderToStaticMarkup()
- await flushPromises()
-
- expect(mocks.state.fetchWorktrees).toHaveBeenCalledWith('repo-1')
- expect(mocks.finishProjectAddWithDefaultCheckout).toHaveBeenCalledWith(
- expect.objectContaining({ repoId: 'repo-1' })
- )
- })
-
- it('activates the synthetic folder workspace for folder repos', async () => {
- mocks.state.repos = [makeRepo({ kind: 'folder' })]
- mocks.state.worktreesByRepo = {
- 'repo-1': [{ id: 'repo-1::folder' }]
- }
- const { default: ProjectAddedDialog } = await import('./ProjectAddedDialog')
-
- const markup = renderToStaticMarkup()
- await flushPromises()
-
- expect(markup).toBe('')
- expect(mocks.state.fetchWorktrees).toHaveBeenCalledWith('repo-1')
- expect(mocks.activateAndRevealWorktree).toHaveBeenCalledWith('repo-1::folder', {
- sidebarRevealBehavior: 'auto'
- })
- expect(mocks.state.closeModal).toHaveBeenCalledTimes(1)
- expect(mocks.finishProjectAddWithDefaultCheckout).not.toHaveBeenCalled()
- })
-
- it('closes malformed modal data without blocking the app invisibly', async () => {
- mocks.state.modalData = {}
- const { default: ProjectAddedDialog } = await import('./ProjectAddedDialog')
-
- const markup = renderToStaticMarkup()
- await flushPromises()
-
- expect(markup).toBe('')
- expect(mocks.state.closeModal).toHaveBeenCalledTimes(1)
- expect(mocks.state.fetchWorktrees).not.toHaveBeenCalled()
- })
-
- it('refreshes repos before closing a stale repo id', async () => {
- mocks.state.repos = []
- mocks.state.fetchRepos.mockImplementation(async () => {
- mocks.state.repos = []
- })
- const { default: ProjectAddedDialog } = await import('./ProjectAddedDialog')
-
- renderToStaticMarkup()
- await flushPromises()
-
- expect(mocks.state.fetchRepos).toHaveBeenCalledTimes(1)
- expect(mocks.state.closeModal).toHaveBeenCalledTimes(1)
- })
-
- it('waits for repo hydration and does not close when fetchRepos supplies the repo', async () => {
- mocks.state.repos = []
- mocks.state.fetchRepos.mockImplementation(async () => {
- mocks.state.repos = [makeRepo()]
- })
- const { default: ProjectAddedDialog } = await import('./ProjectAddedDialog')
-
- renderToStaticMarkup()
- await flushPromises()
-
- expect(mocks.state.fetchRepos).toHaveBeenCalledTimes(1)
- expect(mocks.state.closeModal).not.toHaveBeenCalled()
- expect(mocks.finishProjectAddWithDefaultCheckout).not.toHaveBeenCalled()
- })
-})
diff --git a/src/renderer/src/components/sidebar/ProjectAddedDialog.tsx b/src/renderer/src/components/sidebar/ProjectAddedDialog.tsx
deleted file mode 100644
index 6532a39cae8e..000000000000
--- a/src/renderer/src/components/sidebar/ProjectAddedDialog.tsx
+++ /dev/null
@@ -1,123 +0,0 @@
-import { useEffect, useRef } from 'react'
-import { useAppStore } from '@/store'
-import { activateAndRevealWorktree } from '@/lib/worktree-activation'
-import { isFolderRepo } from '../../../../shared/repo-kind'
-import { finishProjectAddWithDefaultCheckout } from './project-added-default-checkout'
-
-type ProjectAddedModalData = {
- repoId?: string
- projectId?: string
-}
-
-export default function ProjectAddedDialog(): null {
- const activeModal = useAppStore((s) => s.activeModal)
- const modalData = useAppStore((s) => s.modalData as ProjectAddedModalData)
- const closeModal = useAppStore((s) => s.closeModal)
- const repos = useAppStore((s) => s.repos)
- const fetchRepos = useAppStore((s) => s.fetchRepos)
- const fetchWorktrees = useAppStore((s) => s.fetchWorktrees)
- const setHideDefaultBranchWorkspace = useAppStore((s) => s.setHideDefaultBranchWorkspace)
- const handoffRunRef = useRef(0)
- const pendingRepoHydrationRef = useRef(null)
-
- // Why: older onboarding builds wrote `projectId`; accepting both prevents a
- // stale project-added modal from blocking follow-up contextual tours.
- const repoId =
- typeof modalData?.repoId === 'string'
- ? modalData.repoId
- : typeof modalData?.projectId === 'string'
- ? modalData.projectId
- : ''
- const repo = repos.find((candidate) => candidate.id === repoId) ?? null
-
- useEffect(() => {
- if (activeModal !== 'project-added') {
- handoffRunRef.current++
- pendingRepoHydrationRef.current = null
- return
- }
- if (!repoId) {
- closeModal()
- return
- }
- if (!repo) {
- if (pendingRepoHydrationRef.current === repoId) {
- return
- }
- pendingRepoHydrationRef.current = repoId
- let cancelled = false
- void (async () => {
- await fetchRepos()
- if (cancelled) {
- return
- }
- const hydratedRepo = useAppStore
- .getState()
- .repos.find((candidate) => candidate.id === repoId)
- if (!hydratedRepo) {
- closeModal()
- }
- pendingRepoHydrationRef.current = null
- })()
- return () => {
- cancelled = true
- pendingRepoHydrationRef.current = null
- }
- }
- pendingRepoHydrationRef.current = null
- const runId = ++handoffRunRef.current
-
- let cancelled = false
- if (isFolderRepo(repo)) {
- void (async () => {
- try {
- await fetchWorktrees(repoId)
- } catch {
- // Why: folder compatibility exists to clear stale modal state; close
- // even if the best-effort synthetic workspace refresh fails.
- }
- if (cancelled) {
- return
- }
- const folderWorktree = useAppStore.getState().worktreesByRepo[repoId]?.[0]
- if (folderWorktree) {
- activateAndRevealWorktree(folderWorktree.id, { sidebarRevealBehavior: 'auto' })
- }
- closeModal()
- })()
- return () => {
- cancelled = true
- }
- }
-
- void (async () => {
- try {
- await fetchWorktrees(repoId)
- } catch {
- // Why: this is a compatibility handoff; fall back to whatever worktree
- // state is already loaded rather than leaving a stale modal active.
- }
- if (!cancelled && handoffRunRef.current === runId) {
- await finishProjectAddWithDefaultCheckout({
- repoId,
- source: 'project_added_compat',
- closeModal,
- setHideDefaultBranchWorkspace
- })
- }
- })()
- return () => {
- cancelled = true
- }
- }, [
- activeModal,
- closeModal,
- fetchRepos,
- fetchWorktrees,
- repo,
- repoId,
- setHideDefaultBranchWorkspace
- ])
-
- return null
-}
diff --git a/src/renderer/src/components/sidebar/hovered-workspace-delete.ts b/src/renderer/src/components/sidebar/hovered-workspace-delete.ts
index f1ecdc30d8b1..ae92c8b70afe 100644
--- a/src/renderer/src/components/sidebar/hovered-workspace-delete.ts
+++ b/src/renderer/src/components/sidebar/hovered-workspace-delete.ts
@@ -1,4 +1,6 @@
import { useAppStore } from '@/store'
+import { useDialogRegistry } from '@/store/dialog-registry'
+import { selectDialogOnScreen } from '@/store/dialog-registry-state'
import { getAllWorktreesFromState } from '@/store/selectors'
import type { AppState } from '@/store/types'
import { isEditableTarget } from '@/lib/editable-target'
@@ -59,7 +61,11 @@ export function resolveHoveredWorkspaceDeleteTarget(
state: HoveredWorkspaceDeleteState,
doc: HoveredWorkspaceDocument = document
): HoveredWorkspaceDeleteTarget | null {
- if (state.activeModal !== 'none' || (doc.activeElement && isEditableTarget(doc.activeElement))) {
+ if (
+ state.activeModal !== 'none' ||
+ selectDialogOnScreen(useDialogRegistry.getState()) ||
+ (doc.activeElement && isEditableTarget(doc.activeElement))
+ ) {
return null
}
const hovered = getHoveredWorkspaceIdentity(doc)
diff --git a/src/renderer/src/components/sidebar/index.tsx b/src/renderer/src/components/sidebar/index.tsx
index 057c494c9179..bcc9cdfbf730 100644
--- a/src/renderer/src/components/sidebar/index.tsx
+++ b/src/renderer/src/components/sidebar/index.tsx
@@ -18,6 +18,7 @@ import { useWorkspaceRevealBodyRedirect } from './use-workspace-reveal-body-redi
import { resolveLeftSidebarStyleVariables } from '@/lib/left-sidebar-appearance'
import { useSystemPrefersDark } from '@/components/terminal-pane/use-system-prefers-dark'
import { lazyWithRetry } from '@/lib/lazy-with-retry'
+import { RecoverableRenderErrorBoundary } from '../error-boundaries/RecoverableRenderErrorBoundary'
import { LocalGitToolchainScanBanner } from './LocalGitToolchainScanBanner'
// Why lazy: the Agents list pulls the whole activity pipeline (virtualizer, markdown
@@ -254,13 +255,20 @@ function Sidebar({
{/* Dialogs render outside sidebar to avoid clipping. Lazy-load them only
for the modal that needs their flow-specific hooks and UI. */}
-
- {activeModal === 'edit-meta' ? : null}
- {activeModal === 'confirm-remove-folder' ? : null}
- {activeModal === 'worktree-visibility' ? : null}
- {activeModal === 'confirm-orca-yaml-hooks' ? : null}
- {activeModal === 'forget-ssh-workspace' ? : null}
-
+
+
+ {activeModal === 'edit-meta' ? : null}
+ {activeModal === 'confirm-remove-folder' ? : null}
+ {activeModal === 'worktree-visibility' ? : null}
+ {activeModal === 'confirm-orca-yaml-hooks' ? : null}
+ {activeModal === 'forget-ssh-workspace' ? : null}
+
+
{sidebarOpen ? (
({
setGroupBy: vi.fn(),
pendingRevealSidebarRow: null,
revealSidebarRow: vi.fn(),
- revealWorktreeInSidebar: vi.fn(),
- setContextualToursBlockingSurfaceVisible: vi.fn()
+ revealWorktreeInSidebar: 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 fc5fefce1505..7b612ec8605c 100644
--- a/src/renderer/src/components/status-bar/NativeChatResumeStatusSegment.test.tsx
+++ b/src/renderer/src/components/status-bar/NativeChatResumeStatusSegment.test.tsx
@@ -92,14 +92,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 this entry is the one summary a failed resume leaves.
@@ -122,7 +122,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()
@@ -163,9 +163,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/UsagePercentageDisplayChangeNotice.test.tsx b/src/renderer/src/components/status-bar/UsagePercentageDisplayChangeNotice.test.tsx
index a562d2768e47..64d7bfdead6c 100644
--- a/src/renderer/src/components/status-bar/UsagePercentageDisplayChangeNotice.test.tsx
+++ b/src/renderer/src/components/status-bar/UsagePercentageDisplayChangeNotice.test.tsx
@@ -151,7 +151,7 @@ describe('UsagePercentageDisplayChangeNotice', () => {
})
it('does not open while another modal is open', () => {
- storeState.activeModal = 'feature-tips'
+ storeState.activeModal = 'add-repo'
act(() => {
root.render(
diff --git a/src/renderer/src/components/status-bar/UsagePercentageDisplayChangeNotice.tsx b/src/renderer/src/components/status-bar/UsagePercentageDisplayChangeNotice.tsx
index 437afc314be4..8a01adeb17aa 100644
--- a/src/renderer/src/components/status-bar/UsagePercentageDisplayChangeNotice.tsx
+++ b/src/renderer/src/components/status-bar/UsagePercentageDisplayChangeNotice.tsx
@@ -4,6 +4,8 @@ import { BarChart3, X } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { translate } from '@/i18n/i18n'
import { useAppStore } from '@/store'
+import { useDialogRegistry } from '@/store/dialog-registry'
+import { selectDialogOnScreen } from '@/store/dialog-registry-state'
import { shouldShowUsagePercentageDisplayChangeNotice } from '../../../../shared/usage-percentage-display-change-notice'
import { USAGE_PERCENTAGE_DISPLAY_SETTING_ID } from '../settings/appearance-usage-percentage-search'
@@ -62,6 +64,7 @@ export function UsagePercentageDisplayChangeNotice({
const dismiss = useAppStore((s) => s.dismissUsagePercentageDisplayChangeNotice)
const statusBarVisible = useAppStore((s) => s.statusBarVisible)
const activeModal = useAppStore((s) => s.activeModal)
+ const dialogOnScreen = useDialogRegistry(selectDialogOnScreen)
const [delayElapsed, setDelayElapsed] = useState(false)
const anchorRef = useRef(null)
const [anchorPosition, setAnchorPosition] = useState(null)
@@ -71,7 +74,8 @@ export function UsagePercentageDisplayChangeNotice({
usagePercentageDisplayChangeNoticeDismissed: dismissed,
statusBarVisible,
hasVisibleUsageMeters,
- activeModal
+ activeModal,
+ dialogOnScreen
})
useEffect(() => {
diff --git a/src/renderer/src/components/ui/command.tsx b/src/renderer/src/components/ui/command.tsx
index 97237799c1f4..88238ae53b70 100644
--- a/src/renderer/src/components/ui/command.tsx
+++ b/src/renderer/src/components/ui/command.tsx
@@ -9,6 +9,7 @@ import * as DialogPrimitive from 'radix-ui/dialog'
import { cn } from '@/lib/utils'
import { handleImeOverlayEscape } from '@/lib/ime-overlay-escape'
import { useImeTextFieldProps } from '@/lib/ime-text-field'
+import { DialogEntryContent } from '@/lib/dialog-registry-entry'
const commandSurfaceVariants = cva('', {
variants: {
@@ -87,20 +88,22 @@ function CommandDialog({
onOpenAutoFocus={onOpenAutoFocus}
onCloseAutoFocus={onCloseAutoFocus}
>
- {title}
-
- {description}
-
-
- {children}
-
+
+ {title}
+
+ {description}
+
+
+ {children}
+
+
diff --git a/src/renderer/src/components/ui/dialog.tsx b/src/renderer/src/components/ui/dialog.tsx
index 5a74593bda1d..4d651588df91 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 { DialogEntryContent } from '@/lib/dialog-registry-entry'
function Dialog({ ...props }: React.ComponentProps) {
return
@@ -48,10 +49,12 @@ function DialogContent({
className,
children,
overlayClassName,
+ dialogKind,
showCloseButton = true,
...props
}: React.ComponentProps & {
overlayClassName?: string
+ dialogKind?: string
showCloseButton?: boolean
}) {
return (
@@ -74,18 +77,20 @@ function DialogContent({
{...props}
onEscapeKeyDown={(event) => handleImeOverlayEscape(event, props.onEscapeKeyDown)}
>
- {children}
- {showCloseButton && (
-
-
-
- {translate('auto.components.ui.dialog.f26c4baeda', 'Close')}
-
-
- )}
+
+ {children}
+ {showCloseButton && (
+
+
+
+ {translate('auto.components.ui.dialog.f26c4baeda', 'Close')}
+
+
+ )}
+
)
diff --git a/src/renderer/src/components/use-task-page-global-effects.dialogs.test.tsx b/src/renderer/src/components/use-task-page-global-effects.dialogs.test.tsx
new file mode 100644
index 000000000000..76c00987c463
--- /dev/null
+++ b/src/renderer/src/components/use-task-page-global-effects.dialogs.test.tsx
@@ -0,0 +1,97 @@
+// @vitest-environment happy-dom
+
+import { act } from 'react'
+import { createRoot, type Root } from 'react-dom/client'
+import { afterEach, beforeEach, expect, it, vi } from 'vitest'
+import { useAppStore } from '@/store'
+import { resetDialogRegistryForTests } from '@/store/dialog-registry-test-state'
+import { AppOpenFeatureTip } from './feature-tips/AppOpenFeatureTip'
+import { useTaskPageGlobalEffects } from './use-task-page-global-effects'
+
+vi.mock('@/lib/telemetry', () => ({ track: vi.fn() }))
+vi.mock('./feature-tips/FeatureTipsModal', async () => {
+ const ui = await import('./ui/dialog')
+ return {
+ FeatureTipDialogs: ({ open, onClose }: { open: boolean; onClose: () => void }) => (
+ !next && onClose()}>
+
+ Tip
+
+
+
+ )
+ }
+})
+
+globalThis.IS_REACT_ACT_ENVIRONMENT = true
+let root: Root
+let container: HTMLDivElement
+const closeTaskPage = vi.fn()
+
+/** The Tasks page's Escape handling, as the page wires it. */
+function TasksPage(): null {
+ const activeModal = useAppStore((s) => s.activeModal)
+ const noop = (): void => {}
+ const model = {
+ closeTaskPage,
+ activeModal,
+ preflightStatusChecked: true,
+ preflightStatusCurrent: true,
+ linearStatusReady: true,
+ jiraStatusReady: true,
+ checkLinearConnection: noop,
+ checkJiraConnection: noop,
+ refreshPreflightStatus: noop
+ }
+ // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the page model is large; these are the only fields the Escape and preflight effects read when nothing else is open.
+ useTaskPageGlobalEffects(model as unknown as Parameters[0])
+ return null
+}
+
+async function flush(): Promise {
+ for (let i = 0; i < 40; i += 1) {
+ await act(async () => {
+ await new Promise((resolve) => setTimeout(resolve, 10))
+ })
+ }
+}
+
+beforeEach(() => {
+ resetDialogRegistryForTests({ startupSettled: true })
+ useAppStore.setState(useAppStore.getInitialState(), true)
+ useAppStore.setState({ markFeatureTipsSeen: vi.fn() })
+ closeTaskPage.mockReset()
+ container = document.createElement('div')
+ document.body.append(container)
+ root = createRoot(container)
+})
+
+afterEach(() => {
+ act(() => root.unmount())
+ container.remove()
+})
+
+it('Escape over the launch tip on the Tasks page goes to the tip, not the page', async () => {
+ await act(async () =>
+ root.render(
+ <>
+
+
+ >
+ )
+ )
+ await flush()
+ expect(document.querySelector('[data-testid="feature-tip"]')).not.toBeNull()
+ // The tip is not in the modal slot; the page still knows a dialog is up.
+ expect(useAppStore.getState().activeModal).toBe('none')
+ const button = [...document.querySelectorAll('button')].find((b) => b.textContent === 'Got it')!
+ button.focus()
+ act(() => {
+ button.dispatchEvent(
+ new KeyboardEvent('keydown', { key: 'Escape', bubbles: true, cancelable: true })
+ )
+ })
+ await flush()
+ expect(closeTaskPage).not.toHaveBeenCalled()
+ expect(document.querySelector('[data-testid="feature-tip"]')).toBeNull()
+})
diff --git a/src/renderer/src/components/use-task-page-global-effects.ts b/src/renderer/src/components/use-task-page-global-effects.ts
index 395f92e7842e..08398c703868 100644
--- a/src/renderer/src/components/use-task-page-global-effects.ts
+++ b/src/renderer/src/components/use-task-page-global-effects.ts
@@ -1,5 +1,7 @@
import type { TaskPageJiraIssueCreationModel } from './use-task-page-jira-issue-creation'
import { useEffect } from 'react'
+import { useDialogRegistry } from '@/store/dialog-registry'
+import { selectDialogOnScreen } from '@/store/dialog-registry-state'
export function useTaskPageGlobalEffects(model: TaskPageJiraIssueCreationModel) {
const {
closeTaskPage,
@@ -27,6 +29,8 @@ export function useTaskPageGlobalEffects(model: TaskPageJiraIssueCreationModel)
newJiraIssueOpen
} = model
const githubTasksBusy = tasksLoading || tasksRefreshing || tasksFiltering
+ // Dialogs outside the modal slot too, such as the tip the app shows at launch.
+ const dialogOnScreen = useDialogRegistry(selectDialogOnScreen)
useEffect(() => {
// Why: when a modal is open, let it own Esc dismissal.
if (
@@ -36,7 +40,8 @@ export function useTaskPageGlobalEffects(model: TaskPageJiraIssueCreationModel)
newIssueOpen ||
newLinearIssueOpen ||
newJiraIssueOpen ||
- activeModal !== 'none'
+ activeModal !== 'none' ||
+ dialogOnScreen
) {
return
}
@@ -82,6 +87,7 @@ export function useTaskPageGlobalEffects(model: TaskPageJiraIssueCreationModel)
}, [
activeModal,
closeTaskPage,
+ dialogOnScreen,
dialogWorkItem,
newIssueOpen,
newLinearIssueOpen,
diff --git a/src/renderer/src/components/use-task-page-search-actions.ts b/src/renderer/src/components/use-task-page-search-actions.ts
index f9a07140003d..963761b4948d 100644
--- a/src/renderer/src/components/use-task-page-search-actions.ts
+++ b/src/renderer/src/components/use-task-page-search-actions.ts
@@ -12,6 +12,8 @@ import { translate } from '@/i18n/i18n'
import type { GitHubTaskKind } from '@/components/task-page-localized-options'
import { getTaskPresetQuery } from '../../../shared/task-preset-query'
import { shouldSuppressEnterSubmit } from '@/lib/new-workspace-enter-guard'
+import { useDialogRegistry } from '@/store/dialog-registry'
+import { selectDialogOnScreen } from '@/store/dialog-registry-state'
export function useTaskPageSearchActions(model: TaskPageGitHubQuietRefreshModel) {
const {
setTaskResumeState,
@@ -180,6 +182,7 @@ export function useTaskPageSearchActions(model: TaskPageGitHubQuietRefreshModel)
},
[handleApplyTaskSearch]
)
+ const dialogOnScreen = useDialogRegistry(selectDialogOnScreen)
useEffect(() => {
if (
taskSource !== 'github' ||
@@ -189,7 +192,8 @@ export function useTaskPageSearchActions(model: TaskPageGitHubQuietRefreshModel)
newLinearProjectOpen ||
newLinearIssueOpen ||
newJiraIssueOpen ||
- activeModal !== 'none'
+ activeModal !== 'none' ||
+ dialogOnScreen
) {
return
}
@@ -227,6 +231,7 @@ export function useTaskPageSearchActions(model: TaskPageGitHubQuietRefreshModel)
})
}, [
activeModal,
+ dialogOnScreen,
dialogWorkItem,
githubMode,
newIssueOpen,
diff --git a/src/renderer/src/lazy-modal-mount-state.test.ts b/src/renderer/src/lazy-modal-mount-state.test.ts
index ba3faeab212e..1f95f0caea53 100644
--- a/src/renderer/src/lazy-modal-mount-state.test.ts
+++ b/src/renderer/src/lazy-modal-mount-state.test.ts
@@ -8,7 +8,7 @@ import {
describe('isLazyModalId', () => {
it('recognizes only lazily retained root modal ids', () => {
expect(isLazyModalId('quick-open')).toBe(true)
- expect(isLazyModalId('feature-tips')).toBe(true)
+ expect(isLazyModalId('feature-wall')).toBe(true)
expect(isLazyModalId('new-workspace-composer')).toBe(false)
expect(isLazyModalId('delete-worktree')).toBe(false)
expect(isLazyModalId('none')).toBe(false)
diff --git a/src/renderer/src/lazy-modal-mount-state.ts b/src/renderer/src/lazy-modal-mount-state.ts
index a94b036b6874..2d9d01b521a6 100644
--- a/src/renderer/src/lazy-modal-mount-state.ts
+++ b/src/renderer/src/lazy-modal-mount-state.ts
@@ -3,8 +3,7 @@ const LAZY_MODAL_IDS = [
'worktree-palette',
'workspace-cleanup',
'setup-guide',
- 'feature-wall',
- 'feature-tips'
+ 'feature-wall'
] as const
export type LazyModalId = (typeof LAZY_MODAL_IDS)[number]
diff --git a/src/renderer/src/lib/dialog-registry-entry.test.tsx b/src/renderer/src/lib/dialog-registry-entry.test.tsx
new file mode 100644
index 000000000000..8c036f7b2129
--- /dev/null
+++ b/src/renderer/src/lib/dialog-registry-entry.test.tsx
@@ -0,0 +1,128 @@
+// @vitest-environment happy-dom
+import { act, StrictMode, Suspense } from 'react'
+import { createRoot, type Root } from 'react-dom/client'
+import { afterEach, beforeEach, expect, it, vi } from 'vitest'
+import { Dialog, DialogContent, DialogTitle } from '@/components/ui/dialog'
+import { CommandDialog } from '@/components/ui/command'
+import { useDialogRegistry } from '@/store/dialog-registry'
+import { resetDialogRegistryForTests } from '@/store/dialog-registry-test-state'
+import { selectDialogOnScreen } from '@/store/dialog-registry-state'
+import { DialogEntryContent, DialogEntryScope } from './dialog-registry-entry'
+
+globalThis.IS_REACT_ACT_ENVIRONMENT = true
+let root: Root
+let container: HTMLDivElement
+beforeEach(() => {
+ resetDialogRegistryForTests({ startupSettled: true })
+ container = document.createElement('div')
+ document.body.append(container)
+ root = createRoot(container)
+})
+afterEach(async () => {
+ await act(async () => root.unmount())
+ container.remove()
+ vi.restoreAllMocks()
+})
+const onScreen = (): boolean => selectDialogOnScreen(useDialogRegistry.getState())
+
+it('an open root with absent or suspended content counts nothing', async () => {
+ const pending = new Promise(() => {})
+ const Loading = (): never => {
+ throw pending
+ }
+ await act(async () =>
+ root.render(
+
+ )
+ )
+ expect(onScreen()).toBe(false)
+ expect(useDialogRegistry.getState().dialogEntries).toEqual([])
+})
+
+it('ordinary content counts through the closing animation, ending only at content unmount', async () => {
+ const render = (open: boolean): React.JSX.Element => (
+
+ )
+ await act(async () => root.render(render(true)))
+ expect(onScreen()).toBe(true)
+ const token = useDialogRegistry.getState().dialogEntries[0]?.token
+ await act(async () => root.render(render(false)))
+ const content = document.querySelector('[data-slot="dialog-content"]')
+ expect(content?.getAttribute('data-state')).toBe('closed')
+ expect(onScreen()).toBe(true)
+ expect(useDialogRegistry.getState().dialogEntries[0]?.token).toBe(token)
+ const event = new Event('animationend', { bubbles: true })
+ Object.defineProperty(event, 'animationName', { value: 'fade-out' })
+ await act(async () => {
+ content?.dispatchEvent(event)
+ })
+ expect(onScreen()).toBe(false)
+})
+
+it('uncontrolled dialog and command content register without mirroring their root state', async () => {
+ await act(async () =>
+ root.render(
+ <>
+
+
+ >
+ )
+ )
+ expect(useDialogRegistry.getState().dialogEntries).toHaveLength(2)
+})
+
+it('a nested dialog does not reuse the automatic parent token', async () => {
+ useDialogRegistry.getState().enqueueAutomaticDialog('tip', 'feature-tip')
+ await act(async () =>
+ root.render(
+
+
+
+ )
+ )
+ const entries = useDialogRegistry.getState().dialogEntries
+ expect(entries).toHaveLength(2)
+ expect(entries.find((entry) => entry.token === 'tip')?.origin).toBe('automatic')
+ expect(entries.find((entry) => entry.token !== 'tip')?.origin).toBe('user')
+})
+
+it('custom onboarding content counts only while mounted, including StrictMode replay', async () => {
+ await act(async () =>
+ root.render(
+
+
+
+ )
+ )
+ expect(useDialogRegistry.getState().dialogEntries).toEqual([
+ expect.objectContaining({ kind: 'onboarding', phase: 'visible' })
+ ])
+ await act(async () => root.render(null))
+ expect(onScreen()).toBe(false)
+})
diff --git a/src/renderer/src/lib/dialog-registry-entry.tsx b/src/renderer/src/lib/dialog-registry-entry.tsx
new file mode 100644
index 000000000000..d0343dee293d
--- /dev/null
+++ b/src/renderer/src/lib/dialog-registry-entry.tsx
@@ -0,0 +1,75 @@
+import { createContext, useContext, useId, useLayoutEffect, useRef } from 'react'
+import { useDialogRegistry } from '@/store/dialog-registry'
+import {
+ selectDialogPhase,
+ type AutomaticDialogKind,
+ type DialogOrigin,
+ type DialogPhase
+} from '@/store/dialog-registry-state'
+
+const DialogTokenContext = createContext(null)
+
+export function DialogEntryScope({
+ token,
+ children
+}: {
+ token: string
+ children: React.ReactNode
+}): React.JSX.Element {
+ return {children}
+}
+
+// React's effect replay keeps the same token; real disposal settles after the commit finishes.
+export function useDialogDisposal(token: string, dispose: (token: string) => void): void {
+ const current = useRef<{ token: string } | null>(null)
+ useLayoutEffect(() => {
+ const lifetime = { token }
+ current.current = lifetime
+ return () => {
+ queueMicrotask(() => {
+ if (current.current === lifetime || current.current?.token !== token) {
+ dispose(token)
+ }
+ })
+ }
+ }, [dispose, token])
+}
+
+// Lives inside portaled content, through its exit animation. Nested dialogs get their own token.
+export function DialogEntryContent({
+ kind = 'dialog',
+ origin = 'user',
+ children
+}: {
+ kind?: string
+ origin?: Exclude
+ children?: React.ReactNode
+}): React.JSX.Element {
+ const scopedToken = useContext(DialogTokenContext)
+ const ownToken = `dialog:${useId()}`
+ const token = scopedToken ?? ownToken
+ useLayoutEffect(() => {
+ useDialogRegistry.getState().dialogContentMounted(token, kind, origin)
+ }, [kind, origin, token])
+ useDialogDisposal(token, useDialogRegistry.getState().dialogContentUnmounted)
+ return {children}
+}
+
+export function useAutomaticDialogEntry(
+ token: string,
+ kind: AutomaticDialogKind,
+ request: 'automatic' | 'user' | null
+): DialogPhase | null {
+ useLayoutEffect(() => {
+ const registry = useDialogRegistry.getState()
+ if (request === 'automatic') {
+ registry.enqueueAutomaticDialog(token, kind)
+ } else if (request === 'user') {
+ registry.openDialog({ token, kind, origin: 'user' })
+ } else {
+ registry.closeDialog(token)
+ }
+ }, [kind, request, token])
+ useDialogDisposal(token, useDialogRegistry.getState().closeDialog)
+ return useDialogRegistry((s) => selectDialogPhase(s, token))
+}
diff --git a/src/renderer/src/lib/react-error-boundary-reporting.ts b/src/renderer/src/lib/react-error-boundary-reporting.ts
index f7aa47095ec1..5666daffe01d 100644
--- a/src/renderer/src/lib/react-error-boundary-reporting.ts
+++ b/src/renderer/src/lib/react-error-boundary-reporting.ts
@@ -21,7 +21,8 @@ type BuildReportArgsInput = {
const reportedRendererErrorKeys: string[] = []
const reportedRendererErrorKeySet = new Set()
const MAX_REPORTED_RENDERER_ERROR_KEYS = 50
-let pendingReactErrorBoundaryReport: CrashReportRecord | null = null
+// Every report not yet taken: two boundaries failing before the dialog listens are two reports.
+let pendingReactErrorBoundaryReports: CrashReportRecord[] = []
export const REACT_ERROR_BOUNDARY_REPORT_AVAILABLE_EVENT =
'orca:react-error-boundary-report-available'
@@ -113,14 +114,14 @@ function getRendererErrorKey(args: ReactErrorBoundaryReportArgs): string {
})
}
-export function takePendingReactErrorBoundaryReport(): CrashReportRecord | null {
- const report = pendingReactErrorBoundaryReport
- pendingReactErrorBoundaryReport = null
- return report
+export function takePendingReactErrorBoundaryReports(): CrashReportRecord[] {
+ const reports = pendingReactErrorBoundaryReports
+ pendingReactErrorBoundaryReports = []
+ return reports
}
function notifyReactErrorBoundaryReportAvailable(report: CrashReportRecord): void {
- pendingReactErrorBoundaryReport = report
+ pendingReactErrorBoundaryReports.push(report)
window.dispatchEvent(new CustomEvent(REACT_ERROR_BOUNDARY_REPORT_AVAILABLE_EVENT))
}
@@ -150,5 +151,5 @@ export async function reportReactErrorBoundaryCrash(
export function clearReactErrorBoundaryReportingForTest(): void {
reportedRendererErrorKeys.length = 0
reportedRendererErrorKeySet.clear()
- pendingReactErrorBoundaryReport = null
+ pendingReactErrorBoundaryReports = []
}
diff --git a/src/renderer/src/lib/workspace-activation-terminal-focus.test.ts b/src/renderer/src/lib/workspace-activation-terminal-focus.test.ts
index e87fc615042e..4b2127117e33 100644
--- a/src/renderer/src/lib/workspace-activation-terminal-focus.test.ts
+++ b/src/renderer/src/lib/workspace-activation-terminal-focus.test.ts
@@ -1,3 +1,5 @@
+import { useDialogRegistry } from '@/store/dialog-registry'
+import { resetDialogRegistryForTests } from '@/store/dialog-registry-test-state'
// @vitest-environment happy-dom
import type { AppState } from '@/store/types'
import { act, createElement } from 'react'
@@ -41,6 +43,7 @@ function flushFrame(): void {
}
beforeEach(() => {
+ resetDialogRegistryForTests({ startupSettled: true })
vi.clearAllMocks()
vi.useFakeTimers()
frame = null
@@ -126,8 +129,7 @@ describe('workspace activation focus', () => {
{ activeWorkspaceExecutionHostId: 'ssh:second-host' },
{ activeView: 'settings' },
{ activeTabType: 'browser' },
- { activeTabId: 'tab-2' },
- { activeModal: 'worktree-palette' }
+ { activeTabId: 'tab-2' }
] as const)('cancels when selection changes to %j during restoration', (change) => {
queueWorkspaceActivationTerminalFocus('wt-1', { primaryTabId: 'tab-1' })
flushFrame()
@@ -233,3 +235,30 @@ describe('workspace activation focus', () => {
expect(mocks.subscribe).not.toHaveBeenCalled()
})
})
+
+it('a closing palette keeps its selected terminal focus request until content leaves', () => {
+ const registry = useDialogRegistry.getState()
+ registry.dialogContentMounted('palette')
+ registry.closeDialog('palette')
+ queueWorkspaceActivationTerminalFocus('wt-1', { primaryTabId: 'tab-1' })
+ flushFrame()
+ expect(mocks.focus).not.toHaveBeenCalled()
+ expect(unsubscribe).not.toHaveBeenCalled()
+ registry.dialogContentUnmounted('palette')
+ mocks.focus.mockReturnValue(true)
+ notifyMount()
+ flushFrame()
+ expect(mocks.focus).toHaveBeenCalledWith('tab-1', null, 'wt-1')
+})
+
+it('a live dialog prevents terminal focus while preserving the request', () => {
+ useDialogRegistry.getState().dialogContentMounted('tip')
+ queueWorkspaceActivationTerminalFocus('wt-1', { primaryTabId: 'tab-1' })
+ flushFrame()
+ expect(mocks.focus).not.toHaveBeenCalled()
+ document.dispatchEvent(new Event('pointerdown'))
+ useDialogRegistry.getState().dialogContentUnmounted('tip')
+ notifyMount()
+ flushFrame()
+ expect(mocks.focus).not.toHaveBeenCalled()
+})
diff --git a/src/renderer/src/lib/workspace-activation-terminal-focus.ts b/src/renderer/src/lib/workspace-activation-terminal-focus.ts
index 7b36867461bf..761bb93183ef 100644
--- a/src/renderer/src/lib/workspace-activation-terminal-focus.ts
+++ b/src/renderer/src/lib/workspace-activation-terminal-focus.ts
@@ -1,5 +1,7 @@
import { shouldPreserveEditableFocus } from '@/components/terminal-pane/pane-helpers'
import { useAppStore } from '@/store'
+import { useDialogRegistry } from '@/store/dialog-registry'
+import { selectDialogOnScreen } from '@/store/dialog-registry-state'
import { focusRuntimeTerminalSurface } from '@/runtime/sync-runtime-graph'
import type { ActivateAndRevealResult } from '@/lib/worktree-activation'
@@ -51,12 +53,15 @@ export function queueWorkspaceActivationTerminalFocus(
state.activeWorkspaceExecutionHostId !== executionHostId ||
state.activeView !== 'terminal' ||
state.activeTabType !== 'terminal' ||
- (tabId !== null && state.activeTabId !== tabId) ||
- state.activeModal !== 'none'
+ (tabId !== null && state.activeTabId !== tabId)
) {
cancel()
return
}
+ // The selecting palette can still be finishing its exit animation; keep this action pending.
+ if (state.activeModal !== 'none' || selectDialogOnScreen(useDialogRegistry.getState())) {
+ return
+ }
tabId ??= state.activeTabId
if (tabId && focusRuntimeTerminalSurface(tabId, null, worktreeId)) {
cancel()
diff --git a/src/renderer/src/startup/startup-deferred-catalog-refresh.test.ts b/src/renderer/src/startup/startup-deferred-catalog-refresh.test.ts
new file mode 100644
index 000000000000..55fb81b72f7a
--- /dev/null
+++ b/src/renderer/src/startup/startup-deferred-catalog-refresh.test.ts
@@ -0,0 +1,88 @@
+// @vitest-environment happy-dom
+import { beforeEach, expect, it, vi } from 'vitest'
+import { selectStartupActions } from '../app-shell/startup-actions-selector'
+import { useAppStore } from '../store'
+import { refreshDeferredStartupCatalog } from './startup-deferred-catalog-refresh'
+
+function createCatalogActions() {
+ return {
+ ...selectStartupActions(useAppStore.getState()),
+ awaitOwnerWorktreeVisibilityDefaultsHydration: vi.fn(async () => {}),
+ fetchReposForAllHosts: vi.fn(async () => {}),
+ fetchProjectGroupsForAllHosts: vi.fn(async () => {}),
+ fetchFolderWorkspacesForAllHosts: vi.fn(async () => {}),
+ fetchAllWorktrees: vi.fn(async () => {}),
+ pruneLastVisitedTimestamps: vi.fn(),
+ fetchWorktreeLineage: vi.fn(async () => {})
+ }
+}
+
+beforeEach(() => useAppStore.setState({ startupWorktreeRefreshCompleted: false }))
+
+it('waits for owner visibility before refreshing git and folder catalogs, then releases the refresh gate', async () => {
+ const defaults = Promise.withResolvers()
+ const actions = createCatalogActions()
+ actions.awaitOwnerWorktreeVisibilityDefaultsHydration.mockReturnValue(defaults.promise)
+ const refresh = refreshDeferredStartupCatalog(actions, () => false)
+ expect(actions.fetchReposForAllHosts).not.toHaveBeenCalled()
+ expect(actions.fetchFolderWorkspacesForAllHosts).not.toHaveBeenCalled()
+ expect(useAppStore.getState().startupWorktreeRefreshCompleted).toBe(false)
+ defaults.resolve()
+ await refresh
+ expect(actions.fetchReposForAllHosts).toHaveBeenCalledBefore(
+ actions.fetchProjectGroupsForAllHosts
+ )
+ expect(actions.fetchProjectGroupsForAllHosts).toHaveBeenCalledBefore(
+ actions.fetchFolderWorkspacesForAllHosts
+ )
+ expect(actions.fetchFolderWorkspacesForAllHosts).toHaveBeenCalledBefore(actions.fetchAllWorktrees)
+ expect(actions.fetchAllWorktrees).toHaveBeenCalledBefore(actions.pruneLastVisitedTimestamps)
+ expect(actions.pruneLastVisitedTimestamps).toHaveBeenCalledBefore(actions.fetchWorktreeLineage)
+ expect(useAppStore.getState().startupWorktreeRefreshCompleted).toBe(true)
+})
+
+it.each(['visibility', 'worktrees'] as const)(
+ 'cancellation during %s leaves refresh completion to the next startup owner',
+ async (phase) => {
+ const pending = Promise.withResolvers()
+ const actions = createCatalogActions()
+ let cancelled = false
+ if (phase === 'visibility') {
+ actions.awaitOwnerWorktreeVisibilityDefaultsHydration.mockReturnValue(pending.promise)
+ } else {
+ actions.fetchAllWorktrees.mockImplementation(() => {
+ cancelled = true
+ return pending.promise
+ })
+ }
+ const refresh = refreshDeferredStartupCatalog(actions, () => cancelled)
+ cancelled = phase === 'visibility'
+ pending.resolve()
+ await refresh
+ expect(actions.fetchReposForAllHosts).toHaveBeenCalledTimes(1)
+ expect(actions.fetchAllWorktrees).toHaveBeenCalledTimes(phase === 'visibility' ? 0 : 1)
+ expect(useAppStore.getState().startupWorktreeRefreshCompleted).toBe(false)
+ }
+)
+
+it.each(['catalog', 'worktrees'] as const)(
+ 'a failed %s refresh reports the failure and releases the gate',
+ async (phase) => {
+ const actions = createCatalogActions()
+ const failure = new Error('host unavailable')
+ const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
+ if (phase === 'catalog') {
+ actions.fetchReposForAllHosts.mockRejectedValue(failure)
+ } else {
+ actions.fetchAllWorktrees.mockRejectedValue(failure)
+ }
+ try {
+ await refreshDeferredStartupCatalog(actions, () => false)
+ expect(actions.fetchAllWorktrees).toHaveBeenCalledTimes(1)
+ expect(warn).toHaveBeenCalledWith(expect.any(String), failure)
+ expect(useAppStore.getState().startupWorktreeRefreshCompleted).toBe(true)
+ } finally {
+ warn.mockRestore()
+ }
+ }
+)
diff --git a/src/renderer/src/startup/startup-deferred-catalog-refresh.ts b/src/renderer/src/startup/startup-deferred-catalog-refresh.ts
new file mode 100644
index 000000000000..9ec149e4251e
--- /dev/null
+++ b/src/renderer/src/startup/startup-deferred-catalog-refresh.ts
@@ -0,0 +1,43 @@
+import type { StartupActions } from '../app-shell/startup-actions-selector'
+import { useAppStore } from '../store'
+import { timeRendererStartupStep } from './startup-diagnostics'
+
+export async function refreshDeferredStartupCatalog(
+ actions: StartupActions,
+ isCancelled: () => boolean
+): Promise {
+ try {
+ try {
+ // Why: remote rows must not render under a fallback visibility while their owner default is still loading.
+ await timeRendererStartupStep('owner-visibility-defaults', () =>
+ actions.awaitOwnerWorktreeVisibilityDefaultsHydration()
+ )
+ await timeRendererStartupStep('remote-catalog-refresh', async () => {
+ await actions.fetchReposForAllHosts()
+ await actions.fetchProjectGroupsForAllHosts()
+ await actions.fetchFolderWorkspacesForAllHosts()
+ })
+ } catch (err) {
+ console.warn('Remote startup catalog refresh failed:', err)
+ }
+ if (!isCancelled()) {
+ try {
+ await timeRendererStartupStep('remote-worktree-refresh', async () => {
+ // Why: the full scan is not required for session recovery, so keep it off the startup-critical path.
+ await actions.fetchAllWorktrees()
+ // Why: the startup prune only saw session-referenced repos; use the deferred scan's
+ // authoritative results to drop deleted-worktree visit timestamps that would
+ // otherwise accumulate unbounded (disconnected SSH stays non-authoritative and is kept).
+ actions.pruneLastVisitedTimestamps()
+ await actions.fetchWorktreeLineage()
+ })
+ } catch (err) {
+ console.warn('Deferred startup worktree refresh failed:', err)
+ }
+ }
+ } finally {
+ if (!isCancelled()) {
+ useAppStore.setState({ startupWorktreeRefreshCompleted: true })
+ }
+ }
+}
diff --git a/src/renderer/src/startup/startup-discovery-read.ts b/src/renderer/src/startup/startup-discovery-read.ts
new file mode 100644
index 000000000000..bd0828380607
--- /dev/null
+++ b/src/renderer/src/startup/startup-discovery-read.ts
@@ -0,0 +1,15 @@
+import { withTimeout } from '../../../shared/promise-timeout-fallback'
+
+// Local preload IPC has no deadline; match the remote-runtime read budget to abandon this launch check.
+export const STARTUP_DISCOVERY_READ_TIMEOUT_MS = 15_000
+
+export function readStartupDiscovery(pending: Promise): Promise {
+ return withTimeout(
+ pending.catch((error) => {
+ console.error('Startup discovery read failed:', error)
+ return null
+ }),
+ STARTUP_DISCOVERY_READ_TIMEOUT_MS,
+ null
+ )
+}
diff --git a/src/renderer/src/startup/startup-runtime-session-hosts.ts b/src/renderer/src/startup/startup-runtime-session-hosts.ts
new file mode 100644
index 000000000000..dc071166e050
--- /dev/null
+++ b/src/renderer/src/startup/startup-runtime-session-hosts.ts
@@ -0,0 +1,12 @@
+import { toRuntimeExecutionHostId, type ExecutionHostId } from '../../../shared/execution-host'
+
+export async function listRuntimeSessionHostIdsForStartup(): Promise {
+ try {
+ return (await window.api.runtimeEnvironments.list()).map((environment) =>
+ toRuntimeExecutionHostId(environment.id)
+ )
+ } catch (err) {
+ console.warn('Failed to list runtime session hosts for startup:', err)
+ return []
+ }
+}
diff --git a/src/renderer/src/store/dialog-registry-state.test.ts b/src/renderer/src/store/dialog-registry-state.test.ts
new file mode 100644
index 000000000000..6a6381e8db6d
--- /dev/null
+++ b/src/renderer/src/store/dialog-registry-state.test.ts
@@ -0,0 +1,314 @@
+import { describe, expect, it } from 'vitest'
+import {
+ AUTOMATIC_DIALOG_ORDER,
+ closeDialogEntry,
+ dialogContentMounted,
+ dialogContentUnmounted,
+ endDialogEntry,
+ enqueueAutomaticDialog,
+ INITIAL_DIALOG_REGISTRY,
+ openDialogEntry,
+ selectDialogPhase,
+ selectTourBlocked,
+ selectTourParentToken,
+ selectDialogOnScreen,
+ selectTourInterrupted,
+ settleStartupSource,
+ type AutomaticDialogKind,
+ type DialogRegistry
+} from './dialog-registry-state'
+
+const settled: DialogRegistry = {
+ ...INITIAL_DIALOG_REGISTRY,
+ startupSources: { 'crash-report': 'none', 'feature-tip': 'none', 'native-chat-resume': 'none' }
+}
+
+function phase(registry: DialogRegistry, token: string): string | null {
+ return selectDialogPhase(registry, token)
+}
+
+/** What the dialog's content does: mounts once admitted, unmounts once closed. */
+function show(registry: DialogRegistry, token: string): DialogRegistry {
+ return dialogContentMounted(registry, token)
+}
+
+function dismiss(registry: DialogRegistry, token: string): DialogRegistry {
+ return dialogContentUnmounted(closeDialogEntry(registry, token), token)
+}
+
+describe('self-opening dialogs take turns', () => {
+ const pairs = AUTOMATIC_DIALOG_ORDER.flatMap((first, i) =>
+ AUTOMATIC_DIALOG_ORDER.slice(i + 1).map((second) => [first, second] as const)
+ )
+
+ it.each(pairs)('%s goes before %s, whichever was queued first', (first, second) => {
+ let registry = enqueueAutomaticDialog(settled, 'later', second)
+ // The later kind took the turn at once: nothing else was waiting.
+ expect(phase(registry, 'later')).toBe('opening')
+ registry = dismiss(show(registry, 'later'), 'later')
+
+ // Both queued while a dialog is up: the earlier kind is admitted next, not the earlier arrival.
+ registry = dialogContentMounted(registry, 'user')
+ registry = enqueueAutomaticDialog(registry, 'b', second)
+ registry = enqueueAutomaticDialog(registry, 'a', first)
+ expect(phase(registry, 'b')).toBe('queued')
+ registry = dialogContentUnmounted(registry, 'user')
+ expect(phase(registry, 'a')).toBe('opening')
+ expect(phase(registry, 'b')).toBe('queued')
+ registry = dismiss(show(registry, 'a'), 'a')
+ expect(phase(registry, 'b')).toBe('opening')
+ })
+
+ it.each(pairs)('%s goes before %s even when the later check answers first', (first, second) => {
+ let registry: DialogRegistry = {
+ ...INITIAL_DIALOG_REGISTRY,
+ startupSources: { ...settled.startupSources, [first]: 'pending' }
+ }
+ registry = settleStartupSource(registry, second, 'ready', 'b')
+ expect(phase(registry, 'b')).toBe('queued')
+ // The earlier check answers with its own dialog, which goes first.
+ registry = settleStartupSource(registry, first, 'ready', 'a')
+ expect(phase(registry, 'a')).toBe('opening')
+ expect(phase(registry, 'b')).toBe('queued')
+ })
+
+ it.each(['none', 'unavailable'] as const)(
+ 'an earlier check answering %s lets the later kind show',
+ (answer) => {
+ let registry = settleStartupSource(INITIAL_DIALOG_REGISTRY, 'native-chat-resume', 'ready')
+ registry = enqueueAutomaticDialog(registry, 'resume', 'native-chat-resume')
+ registry = settleStartupSource(registry, 'crash-report', answer)
+ expect(phase(registry, 'resume')).toBe('queued')
+ registry = settleStartupSource(registry, 'feature-tip', answer)
+ expect(phase(registry, 'resume')).toBe('opening')
+ }
+ )
+
+ it('a crash report never waits for a startup check', () => {
+ const registry = enqueueAutomaticDialog(INITIAL_DIALOG_REGISTRY, 'crash', 'crash-report')
+ expect(phase(registry, 'crash')).toBe('opening')
+ })
+
+ it('a check answers once; its item is queued in the same step', () => {
+ let registry = settleStartupSource(INITIAL_DIALOG_REGISTRY, 'crash-report', 'ready', 'crash')
+ expect(registry.startupSources['crash-report']).toBe('ready')
+ expect(phase(registry, 'crash')).toBe('opening')
+ registry = settleStartupSource(registry, 'crash-report', 'unavailable')
+ expect(registry.startupSources['crash-report']).toBe('ready')
+ })
+
+ it('queues each item of a kind in arrival order; the same token is one item', () => {
+ let registry = dialogContentMounted(settled, 'user')
+ registry = enqueueAutomaticDialog(registry, 'crash:1', 'crash-report')
+ registry = enqueueAutomaticDialog(registry, 'crash:2', 'crash-report')
+ registry = enqueueAutomaticDialog(registry, 'crash:1', 'crash-report')
+ expect(registry.dialogEntries.filter((entry) => entry.kind === 'crash-report')).toHaveLength(2)
+ registry = dialogContentUnmounted(registry, 'user')
+ expect(phase(registry, 'crash:1')).toBe('opening')
+ registry = dismiss(show(registry, 'crash:1'), 'crash:1')
+ // The second report is its own dialog; the first never replaced it.
+ expect(phase(registry, 'crash:2')).toBe('opening')
+ })
+})
+
+describe('dialogs opened over a self-opening one', () => {
+ it.each(AUTOMATIC_DIALOG_ORDER)(
+ 'a user dialog stacks over a shown %s, which stays; the next waits for both',
+ (kind: AutomaticDialogKind) => {
+ let registry = enqueueAutomaticDialog(settled, 'shown', kind)
+ registry = show(registry, 'shown')
+ registry = enqueueAutomaticDialog(registry, 'next', 'native-chat-resume')
+ registry = show(dialogContentMounted(registry, 'user'), 'user')
+ expect(phase(registry, 'shown')).toBe('visible')
+ expect(phase(registry, 'user')).toBe('visible')
+ registry = dismiss(registry, 'user')
+ expect(phase(registry, 'shown')).toBe('visible')
+ expect(phase(registry, 'next')).toBe('queued')
+ registry = dismiss(registry, 'shown')
+ expect(phase(registry, 'next')).toBe('opening')
+ }
+ )
+
+ it('a response dialog opens at once, even before every check has answered', () => {
+ let registry = show(
+ enqueueAutomaticDialog(INITIAL_DIALOG_REGISTRY, 'crash', 'crash-report'),
+ 'crash'
+ )
+ registry = openDialogEntry(registry, {
+ token: 'ssh',
+ kind: 'ssh-credential',
+ origin: 'response'
+ })
+ expect(phase(registry, 'ssh')).toBe('opening')
+ expect(phase(registry, 'crash')).toBe('visible')
+ })
+
+ it.each(['user', 'response'] as const)(
+ 'a %s dialog opened while an admitted one has not painted yet sends it back to its place',
+ (origin) => {
+ let registry = enqueueAutomaticDialog(settled, 'crash', 'crash-report')
+ const tokens = registry.dialogEntries.map((entry) => entry.token)
+ expect(phase(registry, 'crash')).toBe('opening')
+ registry = dialogContentMounted(registry, 'top', 'dialog', origin)
+ // Its code was still loading: it waits under nothing, and the dialog opened now stays on top.
+ expect(registry.dialogEntries.find((entry) => entry.token === 'crash')).toEqual(
+ expect.objectContaining({ phase: 'queued' })
+ )
+ expect(
+ registry.dialogEntries
+ .filter((entry) => tokens.includes(entry.token))
+ .map((entry) => entry.token)
+ ).toEqual(tokens)
+ registry = dismiss(show(registry, 'top'), 'top')
+ expect(phase(registry, 'crash')).toBe('opening')
+ }
+ )
+
+ it('an earlier kind queued before the admitted one painted goes first', () => {
+ let registry = enqueueAutomaticDialog(settled, 'resume', 'native-chat-resume')
+ expect(phase(registry, 'resume')).toBe('opening')
+ registry = enqueueAutomaticDialog(registry, 'tip', 'feature-tip')
+ expect(phase(registry, 'tip')).toBe('opening')
+ expect(phase(registry, 'resume')).toBe('queued')
+ // Once painted it keeps its turn.
+ registry = enqueueAutomaticDialog(show(registry, 'tip'), 'crash', 'crash-report')
+ expect(phase(registry, 'tip')).toBe('visible')
+ expect(phase(registry, 'crash')).toBe('queued')
+ })
+
+ it('the next is admitted only once the previous one finished closing', () => {
+ let registry = show(enqueueAutomaticDialog(settled, 'crash', 'crash-report'), 'crash')
+ registry = enqueueAutomaticDialog(registry, 'tip', 'feature-tip')
+ registry = closeDialogEntry(registry, 'crash')
+ // Its exit animation is still on screen.
+ expect(phase(registry, 'crash')).toBe('closing')
+ expect(phase(registry, 'tip')).toBe('queued')
+ registry = dialogContentUnmounted(registry, 'crash')
+ expect(phase(registry, 'crash')).toBeNull()
+ expect(phase(registry, 'tip')).toBe('opening')
+ })
+
+ it('a dialog reopened while closing is back on screen, not a second entry', () => {
+ let registry = show(
+ openDialogEntry(settled, { token: 'd', kind: 'dialog', origin: 'user' }),
+ 'd'
+ )
+ registry = closeDialogEntry(registry, 'd')
+ registry = openDialogEntry(registry, { token: 'd', kind: 'dialog', origin: 'user' })
+ expect(registry.dialogEntries).toEqual([expect.objectContaining({ phase: 'visible' })])
+ })
+
+ it('a failed surface ends only its own entry', () => {
+ let registry = dialogContentMounted(settled, 'a')
+ registry = dialogContentMounted(registry, 'b')
+ registry = enqueueAutomaticDialog(registry, 'crash', 'crash-report')
+ registry = endDialogEntry(registry, 'a')
+ expect(phase(registry, 'a')).toBeNull()
+ expect(phase(registry, 'b')).toBe('visible')
+ expect(phase(registry, 'crash')).toBe('queued')
+ })
+
+ it('withdrawing a queued one gives its place to the next', () => {
+ let registry = dialogContentMounted(settled, 'user')
+ registry = enqueueAutomaticDialog(registry, 'crash', 'crash-report')
+ registry = enqueueAutomaticDialog(registry, 'tip', 'feature-tip')
+ registry = closeDialogEntry(registry, 'crash')
+ registry = dialogContentUnmounted(registry, 'user')
+ expect(phase(registry, 'crash')).toBeNull()
+ expect(phase(registry, 'tip')).toBe('opening')
+ })
+
+ it('the user opening a queued one opens it at once, in place', () => {
+ let registry = show(enqueueAutomaticDialog(settled, 'crash', 'crash-report'), 'crash')
+ registry = enqueueAutomaticDialog(registry, 'resume', 'native-chat-resume')
+ registry = openDialogEntry(registry, {
+ token: 'resume',
+ kind: 'native-chat-resume',
+ origin: 'user'
+ })
+ expect(registry.dialogEntries.filter((entry) => entry.token === 'resume')).toEqual([
+ expect.objectContaining({ origin: 'user', phase: 'opening' })
+ ])
+ // A later launch request for it is the same entry.
+ registry = enqueueAutomaticDialog(registry, 'resume', 'native-chat-resume')
+ expect(registry.dialogEntries.filter((entry) => entry.token === 'resume')).toHaveLength(1)
+ })
+})
+
+describe('tours', () => {
+ it('one started by the app goes after every startup check and self-opening dialog', () => {
+ expect(selectTourBlocked(INITIAL_DIALOG_REGISTRY, false)).toBe(true)
+ expect(selectTourBlocked(settled, false)).toBe(false)
+ const queued = enqueueAutomaticDialog(dialogContentMounted(settled, 'u'), 'tip', 'feature-tip')
+ expect(selectTourBlocked(dialogContentUnmounted(queued, 'u'), false)).toBe(true)
+ })
+
+ it('one the user asked for yields only to what is on screen', () => {
+ expect(selectTourBlocked(INITIAL_DIALOG_REGISTRY, true)).toBe(false)
+ const user = dialogContentMounted(settled, 'u')
+ expect(selectTourBlocked(user, true)).toBe(true)
+ expect(selectTourInterrupted(user)).toBe(true)
+ })
+
+ it('runs inside the modal it is written for, but not inside a dialog opened from it', () => {
+ let registry = dialogContentMounted(settled, 'composer', 'new-workspace-composer')
+ const parent = selectTourParentToken(registry, ['new-workspace-composer'])
+ expect(parent).toBe('composer')
+ expect(selectTourBlocked(registry, true, parent)).toBe(false)
+ expect(selectTourBlocked(registry, true)).toBe(true)
+ registry = dialogContentMounted(registry, 'nested')
+ expect(selectTourInterrupted(registry, parent)).toBe(true)
+ })
+
+ it('a running tour holds self-opening dialogs back until it ends', () => {
+ let registry = dialogContentMounted(settled, 'tour:browser', 'tour', 'tour')
+ registry = enqueueAutomaticDialog(registry, 'crash', 'crash-report')
+ expect(phase(registry, 'crash')).toBe('queued')
+ // The tour itself never counts as something a tour must give way to.
+ expect(selectTourInterrupted(registry)).toBe(false)
+ registry = dialogContentUnmounted(registry, 'tour:browser')
+ expect(phase(registry, 'crash')).toBe('opening')
+ })
+})
+
+it('user opening intent and an empty host count nothing; committed content alone blocks admission', () => {
+ let registry = openDialogEntry(settled, { token: 'user', kind: 'dialog', origin: 'user' })
+ expect(selectDialogOnScreen(registry)).toBe(false)
+ registry = enqueueAutomaticDialog(registry, 'crash', 'crash-report')
+ expect(phase(registry, 'crash')).toBe('opening')
+ registry = dialogContentMounted(registry, 'user')
+ expect(selectDialogOnScreen(registry)).toBe(true)
+ expect(phase(registry, 'crash')).toBe('queued')
+ registry = dialogContentUnmounted(registry, 'user')
+ expect(phase(registry, 'crash')).toBe('opening')
+})
+
+it('a stale content commit cannot acknowledge an automatic dialog returned to its queue', () => {
+ let registry = enqueueAutomaticDialog(settled, 'crash', 'crash-report')
+ registry = dialogContentMounted(registry, 'user')
+ registry = dialogContentMounted(registry, 'crash')
+ expect(phase(registry, 'crash')).toBe('queued')
+})
+
+it('composer allowance comes only from its mounted content, and closes with it', () => {
+ let registry = openDialogEntry(settled, {
+ token: 'composer',
+ kind: 'new-workspace-composer',
+ origin: 'user'
+ })
+ expect(selectTourParentToken(registry, ['new-workspace-composer'])).toBeNull()
+ registry = dialogContentMounted(registry, 'composer')
+ expect(selectTourParentToken(registry, ['new-workspace-composer'])).toBe('composer')
+ registry = closeDialogEntry(registry, 'composer')
+ expect(selectTourParentToken(registry, ['new-workspace-composer'])).toBeNull()
+ expect(selectTourBlocked(registry, true)).toBe(true)
+})
+
+it('onboarding content blocks automatic prompts and forced tours, without a separate visibility flag', () => {
+ let registry = dialogContentMounted(settled, 'onboarding', 'onboarding')
+ registry = enqueueAutomaticDialog(registry, 'crash', 'crash-report')
+ expect(phase(registry, 'crash')).toBe('queued')
+ expect(selectTourBlocked(registry, true)).toBe(true)
+ registry = dialogContentUnmounted(registry, 'onboarding')
+ expect(phase(registry, 'crash')).toBe('opening')
+})
diff --git a/src/renderer/src/store/dialog-registry-state.ts b/src/renderer/src/store/dialog-registry-state.ts
new file mode 100644
index 000000000000..cd22e016a7b9
--- /dev/null
+++ b/src/renderer/src/store/dialog-registry-state.ts
@@ -0,0 +1,179 @@
+export type DialogOrigin = 'automatic' | 'user' | 'response' | 'tour'
+export type DialogPhase = 'queued' | 'opening' | 'visible' | 'closing'
+export const AUTOMATIC_DIALOG_ORDER = ['crash-report', 'feature-tip', 'native-chat-resume'] as const
+export type AutomaticDialogKind = (typeof AUTOMATIC_DIALOG_ORDER)[number]
+export type StartupSourceAnswer = 'pending' | 'ready' | 'none' | 'unavailable'
+export type DialogEntry = Readonly<{
+ token: string
+ kind: string
+ origin: DialogOrigin
+ phase: DialogPhase
+}>
+export type DialogRegistry = Readonly<{
+ dialogEntries: readonly DialogEntry[]
+ startupSources: Readonly>
+}>
+
+export const INITIAL_DIALOG_REGISTRY: DialogRegistry = {
+ dialogEntries: [],
+ startupSources: {
+ 'crash-report': 'pending',
+ 'feature-tip': 'pending',
+ 'native-chat-resume': 'pending'
+ }
+}
+
+function onScreen(entry: DialogEntry): boolean {
+ return entry.phase === 'visible' || entry.phase === 'closing'
+}
+
+// Uncommitted requests retain FIFO position; only their permission to render is derived.
+function nextAutomaticDialog(registry: DialogRegistry): DialogEntry | undefined {
+ if (registry.dialogEntries.some(onScreen)) {
+ return undefined
+ }
+ for (const kind of AUTOMATIC_DIALOG_ORDER) {
+ const next = registry.dialogEntries.find(
+ (entry) => entry.origin === 'automatic' && entry.kind === kind
+ )
+ if (next || registry.startupSources[kind] === 'pending') {
+ return next
+ }
+ }
+ return undefined
+}
+
+function putEntry(registry: DialogRegistry, entry: DialogEntry): DialogRegistry {
+ const existing = registry.dialogEntries.find((item) => item.token === entry.token)
+ return {
+ ...registry,
+ dialogEntries: existing
+ ? registry.dialogEntries.map((item) => (item === existing ? entry : item))
+ : [...registry.dialogEntries, entry]
+ }
+}
+
+export function enqueueAutomaticDialog(
+ registry: DialogRegistry,
+ token: string,
+ kind: AutomaticDialogKind
+): DialogRegistry {
+ return registry.dialogEntries.some((entry) => entry.token === token)
+ ? registry
+ : putEntry(registry, { token, kind, origin: 'automatic', phase: 'queued' })
+}
+
+// User-requested automatic content opens immediately, reusing its queued or shown entry.
+export function openDialogEntry(
+ registry: DialogRegistry,
+ opened: { token: string; kind: string; origin: Exclude }
+): DialogRegistry {
+ const existing = registry.dialogEntries.find((entry) => entry.token === opened.token)
+ return putEntry(registry, {
+ ...opened,
+ phase: existing && onScreen(existing) ? 'visible' : 'opening'
+ })
+}
+
+export function endDialogEntry(registry: DialogRegistry, token: string): DialogRegistry {
+ return {
+ ...registry,
+ dialogEntries: registry.dialogEntries.filter((entry) => entry.token !== token)
+ }
+}
+
+export function closeDialogEntry(registry: DialogRegistry, token: string): DialogRegistry {
+ const entry = registry.dialogEntries.find((item) => item.token === token)
+ return entry?.phase === 'visible'
+ ? putEntry(registry, { ...entry, phase: 'closing' })
+ : entry?.phase === 'closing'
+ ? registry
+ : endDialogEntry(registry, token)
+}
+
+// Ordinary dialogs exist only for their mounted content, including the exit animation.
+export function dialogContentMounted(
+ registry: DialogRegistry,
+ token: string,
+ kind = 'dialog',
+ origin: Exclude = 'user'
+): DialogRegistry {
+ const entry = registry.dialogEntries.find((item) => item.token === token)
+ if (selectDialogPhase(registry, token) === 'queued' || (entry && onScreen(entry))) {
+ return registry
+ }
+ return putEntry(registry, { token, kind, origin, ...entry, phase: 'visible' })
+}
+
+export function dialogContentUnmounted(registry: DialogRegistry, token: string): DialogRegistry {
+ const entry = registry.dialogEntries.find((item) => item.token === token)
+ return entry?.origin === 'automatic' && entry.phase === 'visible'
+ ? putEntry(registry, { ...entry, phase: 'queued' })
+ : endDialogEntry(registry, token)
+}
+
+// The answer and its item enter together, leaving no gap for a later startup kind.
+export function settleStartupSource(
+ registry: DialogRegistry,
+ kind: AutomaticDialogKind,
+ answer: Exclude,
+ itemToken?: string
+): DialogRegistry {
+ const next = itemToken ? enqueueAutomaticDialog(registry, itemToken, kind) : registry
+ return next.startupSources[kind] !== 'pending'
+ ? next
+ : { ...next, startupSources: { ...next.startupSources, [kind]: answer } }
+}
+
+export function selectDialogOnScreen(registry: DialogRegistry): boolean {
+ return registry.dialogEntries.some((entry) => onScreen(entry) && entry.origin !== 'tour')
+}
+
+export function selectDialogPhase(registry: DialogRegistry, token: string): DialogPhase | null {
+ const entry = registry.dialogEntries.find((item) => item.token === token)
+ return entry?.phase === 'queued' && nextAutomaticDialog(registry) === entry
+ ? 'opening'
+ : (entry?.phase ?? null)
+}
+
+export function selectAdmittedDialog(
+ registry: DialogRegistry,
+ kind: AutomaticDialogKind
+): DialogEntry | undefined {
+ return registry.dialogEntries.find(
+ (entry) => entry.kind === kind && selectDialogPhase(registry, entry.token) !== 'queued'
+ )
+}
+
+export function selectTourParentToken(
+ registry: DialogRegistry,
+ allowedModals: readonly string[] = []
+): string | null {
+ return (
+ registry.dialogEntries.find(
+ (entry) => entry.phase === 'visible' && allowedModals.includes(entry.kind)
+ )?.token ?? null
+ )
+}
+
+export function selectTourInterrupted(
+ registry: DialogRegistry,
+ parentToken?: string | null
+): boolean {
+ return registry.dialogEntries.some(
+ (entry) => onScreen(entry) && entry.origin !== 'tour' && entry.token !== parentToken
+ )
+}
+
+export function selectTourBlocked(
+ registry: DialogRegistry,
+ forced: boolean,
+ parentToken?: string | null
+): boolean {
+ return (
+ selectTourInterrupted(registry, parentToken) ||
+ (!forced &&
+ (registry.dialogEntries.some((entry) => entry.origin === 'automatic') ||
+ Object.values(registry.startupSources).includes('pending')))
+ )
+}
diff --git a/src/renderer/src/store/dialog-registry-test-state.ts b/src/renderer/src/store/dialog-registry-test-state.ts
new file mode 100644
index 000000000000..d7266cbe225c
--- /dev/null
+++ b/src/renderer/src/store/dialog-registry-test-state.ts
@@ -0,0 +1,18 @@
+import { useDialogRegistry } from './dialog-registry'
+import { INITIAL_DIALOG_REGISTRY } from './dialog-registry-state'
+
+/** @internal - tests start from an empty registry; `startupSettled` models a running app. */
+export function resetDialogRegistryForTests(options: { startupSettled?: boolean } = {}): void {
+ useDialogRegistry.setState({
+ ...INITIAL_DIALOG_REGISTRY,
+ ...(options.startupSettled
+ ? {
+ startupSources: {
+ 'crash-report': 'none',
+ 'feature-tip': 'none',
+ 'native-chat-resume': 'none'
+ }
+ }
+ : {})
+ })
+}
diff --git a/src/renderer/src/store/dialog-registry.ts b/src/renderer/src/store/dialog-registry.ts
new file mode 100644
index 000000000000..f1c5e65c042e
--- /dev/null
+++ b/src/renderer/src/store/dialog-registry.ts
@@ -0,0 +1,38 @@
+import { create } from 'zustand'
+import {
+ closeDialogEntry,
+ dialogContentMounted,
+ dialogContentUnmounted,
+ endDialogEntry,
+ enqueueAutomaticDialog,
+ INITIAL_DIALOG_REGISTRY,
+ openDialogEntry,
+ settleStartupSource,
+ type DialogRegistry
+} from './dialog-registry-state'
+
+type DialogRegistryActions = {
+ openDialog: (...args: DropFirst>) => void
+ enqueueAutomaticDialog: (...args: DropFirst>) => void
+ closeDialog: (token: string) => void
+ endDialog: (token: string) => void
+ dialogContentMounted: (...args: DropFirst>) => void
+ dialogContentUnmounted: (token: string) => void
+ settleStartupSource: (...args: DropFirst>) => void
+}
+
+type DropFirst = T extends [unknown, ...infer Rest] ? Rest : never
+
+export type DialogRegistryStore = DialogRegistry & DialogRegistryActions
+
+// Window-scoped presentation state stays separate from persisted app data.
+export const useDialogRegistry = create()((set) => ({
+ ...INITIAL_DIALOG_REGISTRY,
+ openDialog: (...args) => set((s) => openDialogEntry(s, ...args)),
+ enqueueAutomaticDialog: (...args) => set((s) => enqueueAutomaticDialog(s, ...args)),
+ closeDialog: (token) => set((s) => closeDialogEntry(s, token)),
+ endDialog: (token) => set((s) => endDialogEntry(s, token)),
+ dialogContentMounted: (...args) => set((s) => dialogContentMounted(s, ...args)),
+ dialogContentUnmounted: (token) => set((s) => dialogContentUnmounted(s, token)),
+ settleStartupSource: (...args) => set((s) => settleStartupSource(s, ...args))
+}))
diff --git a/src/renderer/src/store/slices/ui-contextual-tours.test.ts b/src/renderer/src/store/slices/ui-contextual-tours.test.ts
index 3db02f6e587f..06ea67a083dc 100644
--- a/src/renderer/src/store/slices/ui-contextual-tours.test.ts
+++ b/src/renderer/src/store/slices/ui-contextual-tours.test.ts
@@ -2,6 +2,9 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { PersistedUIState } from '../../../../shared/persisted-ui-state-types'
import type { ContextualTourId } from '../../../../shared/contextual-tours'
import { createUIStore, makePersistedUI } from './ui-slice-test-harness'
+import { useDialogRegistry } from '../dialog-registry'
+import { selectDialogPhase } from '../dialog-registry-state'
+import { resetDialogRegistryForTests } from '../dialog-registry-test-state'
const mocks = vi.hoisted(() => ({
sendNotesToActiveAgentSession: vi.fn(),
@@ -225,13 +228,60 @@ describe('createUISlice contextual tours', () => {
stubContextualTourTargets(['[data-contextual-tour-target="tasks-source-filters"]'])
store.getState().hydratePersistedUI(makeAutoTourEligibleUI())
- store.getState().setContextualToursBlockingSurfaceVisible(true)
+ useDialogRegistry.getState().dialogContentMounted('confirmation')
store.getState().requestContextualTour('tasks', 'tasks_open')
expect(store.getState().activeContextualTourId).toBeNull()
expect(store.getState().contextualTourShownThisSession).toBe(false)
})
+ it('runs the composer tour inside the composer, but not over a dialog opened from it', () => {
+ const store = createUIStore()
+ stubContextualTourTargets(['[data-contextual-tour-target="workspace-creation-project"]'])
+ store.getState().hydratePersistedUI(makeAutoTourEligibleUI())
+ store.getState().openModal('new-workspace-composer')
+ useDialogRegistry.getState().dialogContentMounted('composer', 'new-workspace-composer')
+ useDialogRegistry.getState().dialogContentMounted('nested')
+ const request = (): void =>
+ store
+ .getState()
+ .requestContextualTour('workspace-creation', 'workspace_creation_modal', false, {
+ force: true
+ })
+
+ request()
+ expect(store.getState().activeContextualTourId).toBeNull()
+ useDialogRegistry.getState().dialogContentUnmounted('nested')
+ request()
+ expect(store.getState().activeContextualTourId).toBe('workspace-creation')
+ })
+
+ it('goes after every startup check and dialog the app opens by itself', () => {
+ const store = createUIStore()
+ resetDialogRegistryForTests()
+ stubContextualTourTargets(['[data-contextual-tour-target="tasks-source-filters"]'])
+ store.getState().hydratePersistedUI(makeAutoTourEligibleUI())
+ const registry = useDialogRegistry.getState
+
+ registry().settleStartupSource('crash-report', 'none')
+ registry().settleStartupSource('native-chat-resume', 'none')
+ registry().settleStartupSource('feature-tip', 'ready')
+ registry().enqueueAutomaticDialog('tip', 'feature-tip')
+ store.getState().requestContextualTour('tasks', 'tasks_open')
+ expect(store.getState().activeContextualTourId).toBeNull()
+
+ registry().closeDialog('tip')
+ store.getState().requestContextualTour('tasks', 'tasks_open')
+ expect(store.getState().activeContextualTourId).toBe('tasks')
+ // The rendered overlay owns occupancy; an unrendered tour request counts nothing.
+ registry().dialogContentMounted('tour:tasks', 'tour', 'tour')
+ registry().enqueueAutomaticDialog('crash', 'crash-report')
+ expect(selectDialogPhase(registry(), 'crash')).toBe('queued')
+ store.getState().dismissContextualTour('tasks')
+ registry().dialogContentUnmounted('tour:tasks')
+ expect(selectDialogPhase(registry(), 'crash')).toBe('opening')
+ })
+
it('does not auto-start tours for profiles that are not eligible', () => {
const store = createUIStore()
stubContextualTourTargets(['[data-contextual-tour-target="tasks-source-filters"]'])
@@ -297,6 +347,7 @@ describe('createUISlice contextual tours', () => {
store.getState().hydratePersistedUI(makeAutoTourEligibleUI())
store.getState().openModal('new-workspace-composer')
+ useDialogRegistry.getState().dialogContentMounted('composer', 'new-workspace-composer')
store.getState().requestContextualTour('tasks', 'tasks_open')
expect(store.getState().activeContextualTourId).toBeNull()
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..6322c8bf3d2e 100644
--- a/src/renderer/src/store/slices/ui-slice-test-harness.ts
+++ b/src/renderer/src/store/slices/ui-slice-test-harness.ts
@@ -5,8 +5,11 @@ import type { AppState } from '../types'
import { createUISlice } from './ui'
import { createWorktreeNavHistorySlice } from './worktree-nav-history'
import { createSettingsSearchState } from './settings-search-state'
+import { resetDialogRegistryForTests } from '../dialog-registry-test-state'
export function createUIStore(): StoreApi {
+ // Why: the registry is per window, not per store; these cases model a running app past startup.
+ resetDialogRegistryForTests({ startupSettled: true })
// 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/ui-slice-contract-contextual.ts b/src/renderer/src/store/slices/ui/ui-slice-contract-contextual.ts
index d8ea7fe00046..5b1541510a37 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
@@ -31,7 +31,6 @@ export type UISliceContextual = {
clearAppearanceAccordionDeepLink: () => void
activeModal:
| 'none'
- | 'create-worktree'
| 'edit-meta'
| 'delete-worktree'
| 'preserved-branch-review'
@@ -43,11 +42,9 @@ export type UISliceContextual = {
| 'quick-open'
| 'worktree-palette'
| 'workspace-cleanup'
- | 'project-added'
| 'worktree-visibility'
| 'setup-guide'
| 'feature-wall'
- | 'feature-tips'
| 'new-workspace-composer'
| 'confirm-orca-yaml-hooks'
modalData: Record
@@ -59,6 +56,7 @@ export type UISliceContextual = {
recordFeatureInteraction: (id: FeatureInteractionId) => Promise
contextualToursSeenIds: ContextualTourId[]
contextualToursAutoEligible: boolean | null
+ contextualToursAwaitingOnboarding: boolean
activeContextualTourId: ContextualTourId | null
activeContextualTourStepIndex: number
activeContextualTourSource: string | null
@@ -67,12 +65,9 @@ export type UISliceContextual = {
contextualTourNavigationInteractionSnapshot: Partial>
activeContextualTourSuppressed: boolean
contextualTourShownThisSession: boolean
- contextualToursOnboardingVisible: boolean
- contextualToursBlockingSurfaceVisible: boolean
lastCompletedContextualTourId: ContextualTourId | null
setContextualToursAutoEligible: (eligible: boolean) => void
- setContextualToursOnboardingVisible: (visible: boolean) => void
- setContextualToursBlockingSurfaceVisible: (visible: boolean) => void
+ setContextualToursAwaitingOnboarding: (awaiting: boolean) => void
requestContextualTour: (
id: ContextualTourId,
source: string,
diff --git a/src/renderer/src/store/slices/ui/ui-slice-modal-actions.ts b/src/renderer/src/store/slices/ui/ui-slice-modal-actions.ts
index 278aefd58030..44776a21117a 100644
--- a/src/renderer/src/store/slices/ui/ui-slice-modal-actions.ts
+++ b/src/renderer/src/store/slices/ui/ui-slice-modal-actions.ts
@@ -6,7 +6,7 @@ export function createUiModalActions(set: UISliceSet, get: UISliceGet): Partial<
activeModal: 'none',
modalData: {},
openModal: (modal, data = {}) => {
- if (modal === 'add-repo' || modal === 'create-worktree') {
+ if (modal === 'add-repo') {
get().recordFeatureInteraction?.('workspace-creation')
}
const evicted = get().modalData
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..3a4c7007dcb6 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,11 +7,14 @@ import {
getPreviousVisibleContextualTourStepIndex
} from '../../../components/contextual-tours/contextual-tour-gate'
import { hasFeatureInteraction } from '../../../../../shared/feature-interactions'
+import { useDialogRegistry } from '../../dialog-registry'
+import { selectTourBlocked, selectTourParentToken } from '../../dialog-registry-state'
export function createUiTourActions(set: UISliceSet, get: UISliceGet): Partial {
return {
contextualToursSeenIds: [],
contextualToursAutoEligible: null,
+ contextualToursAwaitingOnboarding: false,
activeContextualTourId: null,
activeContextualTourStepIndex: 0,
activeContextualTourSource: null,
@@ -20,8 +23,6 @@ export function createUiTourActions(set: UISliceSet, get: UISliceGet): Partial
set((s) => {
@@ -33,17 +34,11 @@ export function createUiTourActions(set: UISliceSet, get: UISliceGet): Partial
+ setContextualToursAwaitingOnboarding: (awaiting) =>
set((s) =>
- s.contextualToursOnboardingVisible === visible
+ s.contextualToursAwaitingOnboarding === awaiting
? s
- : { contextualToursOnboardingVisible: visible }
- ),
- setContextualToursBlockingSurfaceVisible: (visible) =>
- set((s) =>
- s.contextualToursBlockingSurfaceVisible === visible
- ? s
- : { contextualToursBlockingSurfaceVisible: visible }
+ : { contextualToursAwaitingOnboarding: awaiting }
),
requestContextualTour: (id, source, wasFeaturePreviouslyInteracted, options) =>
set((s) => {
@@ -52,12 +47,16 @@ export function createUiTourActions(set: UISliceSet, get: UISliceGet): Partial {
usagePercentageDisplayChangeNoticeDismissed: false,
statusBarVisible: true,
hasVisibleUsageMeters: true,
- activeModal: 'none'
+ activeModal: 'none',
+ dialogOnScreen: false
})
).toBe(true)
@@ -71,7 +72,8 @@ describe('shouldShowUsagePercentageDisplayChangeNotice', () => {
usagePercentageDisplayChangeNoticeDismissed: false,
statusBarVisible: true,
hasVisibleUsageMeters: true,
- activeModal: 'none'
+ activeModal: 'none',
+ dialogOnScreen: false
})
).toBe(false)
@@ -81,7 +83,8 @@ describe('shouldShowUsagePercentageDisplayChangeNotice', () => {
usagePercentageDisplayChangeNoticeDismissed: true,
statusBarVisible: true,
hasVisibleUsageMeters: true,
- activeModal: 'none'
+ activeModal: 'none',
+ dialogOnScreen: false
})
).toBe(false)
@@ -91,7 +94,8 @@ describe('shouldShowUsagePercentageDisplayChangeNotice', () => {
usagePercentageDisplayChangeNoticeDismissed: false,
statusBarVisible: false,
hasVisibleUsageMeters: true,
- activeModal: 'none'
+ activeModal: 'none',
+ dialogOnScreen: false
})
).toBe(false)
@@ -101,7 +105,8 @@ describe('shouldShowUsagePercentageDisplayChangeNotice', () => {
usagePercentageDisplayChangeNoticeDismissed: false,
statusBarVisible: true,
hasVisibleUsageMeters: false,
- activeModal: 'none'
+ activeModal: 'none',
+ dialogOnScreen: false
})
).toBe(false)
@@ -111,7 +116,20 @@ describe('shouldShowUsagePercentageDisplayChangeNotice', () => {
usagePercentageDisplayChangeNoticeDismissed: false,
statusBarVisible: true,
hasVisibleUsageMeters: true,
- activeModal: 'feature-tips'
+ activeModal: 'add-repo',
+ dialogOnScreen: false
+ })
+ ).toBe(false)
+
+ // A dialog outside the modal slot, such as the tip the app shows at launch, holds it too.
+ expect(
+ shouldShowUsagePercentageDisplayChangeNotice({
+ persistedUIReady: true,
+ usagePercentageDisplayChangeNoticeDismissed: false,
+ statusBarVisible: true,
+ hasVisibleUsageMeters: true,
+ activeModal: 'none',
+ dialogOnScreen: true
})
).toBe(false)
})
diff --git a/src/shared/usage-percentage-display-change-notice.ts b/src/shared/usage-percentage-display-change-notice.ts
index caeaa6ccfb67..007fbf293964 100644
--- a/src/shared/usage-percentage-display-change-notice.ts
+++ b/src/shared/usage-percentage-display-change-notice.ts
@@ -34,12 +34,15 @@ export function shouldShowUsagePercentageDisplayChangeNotice(args: {
statusBarVisible: boolean
hasVisibleUsageMeters: boolean
activeModal: string
+ /** Any dialog up, including the ones the app opens by itself outside the modal slot. */
+ dialogOnScreen: boolean
}): boolean {
return (
args.persistedUIReady &&
!args.usagePercentageDisplayChangeNoticeDismissed &&
args.statusBarVisible &&
args.hasVisibleUsageMeters &&
- args.activeModal === 'none'
+ args.activeModal === 'none' &&
+ !args.dialogOnScreen
)
}
diff --git a/tests/e2e/add-project-default-checkout.spec.ts b/tests/e2e/add-project-default-checkout.spec.ts
index 6a678c0bf9ab..4aa5bcdd7c76 100644
--- a/tests/e2e/add-project-default-checkout.spec.ts
+++ b/tests/e2e/add-project-default-checkout.spec.ts
@@ -120,8 +120,7 @@ test.describe('Add project default checkout', () => {
return {
activeCheckoutIsDefault: state.activeWorktreeId === defaultCheckout?.id,
defaultCheckoutLooksCloned:
- normalizedDefaultCheckoutPath?.endsWith(`/clones/${cloneName}`) ?? false,
- oldSetupModalOpen: state.activeModal === 'project-added'
+ normalizedDefaultCheckoutPath?.endsWith(`/clones/${cloneName}`) ?? false
}
}, path.basename(fixture.sourcePath)),
{
@@ -131,8 +130,7 @@ test.describe('Add project default checkout', () => {
)
.toEqual({
activeCheckoutIsDefault: true,
- defaultCheckoutLooksCloned: true,
- oldSetupModalOpen: false
+ defaultCheckoutLooksCloned: true
})
})