diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 7ba62c1b49a..e5a0a1d6d62 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -1,4 +1,5 @@ import { useCallback, useEffect } from 'react' +import { createPortal } from 'react-dom' import { Toaster } from '@/components/ui/sonner' import { TooltipProvider } from '@/components/ui/tooltip' import { ConfirmationDialogProvider } from './components/confirmation-dialog' @@ -108,7 +109,10 @@ function App(): React.JSX.Element { - + {createPortal( + , + document.body + )} diff --git a/src/renderer/src/app-shell/AppRootSurfaces.tsx b/src/renderer/src/app-shell/AppRootSurfaces.tsx index dfc19a7df02..2f6d99b88e3 100644 --- a/src/renderer/src/app-shell/AppRootSurfaces.tsx +++ b/src/renderer/src/app-shell/AppRootSurfaces.tsx @@ -3,6 +3,7 @@ import { OnboardingLanguageLoading } from '../components/onboarding/OnboardingLa import { useLanguageReadiness } from '../i18n/language-readiness' import { NotificationCardStack } from '../components/NotificationCardStack' import { Suspense } from 'react' +import { createPortal } from 'react-dom' import { lazyWithRetry as lazy } from '@/lib/lazy-with-retry' import { translate } from '@/i18n/i18n' import { RecoverableRenderErrorBoundary } from '../components/error-boundaries/RecoverableRenderErrorBoundary' @@ -347,28 +348,32 @@ export function AppRootSurfaces(props: { > - {onboardingGate.onboarding && onboardingGate.shouldRender ? ( - - - {languageReady ? ( - - ) : ( - - )} - - - ) : null} + {onboardingGate.onboarding && onboardingGate.shouldRender + ? createPortal( + + + {languageReady ? ( + + ) : ( + + )} + + , + document.body + ) + : null} {shouldMountDictationController ? ( diff --git a/src/renderer/src/assets/main.css b/src/renderer/src/assets/main.css index 8ff08f814a7..67352d82a80 100644 --- a/src/renderer/src/assets/main.css +++ b/src/renderer/src/assets/main.css @@ -865,6 +865,23 @@ body:has(> [data-slot='dialog-overlay'], > [data-slot='sheet-overlay']) overflow: hidden; } +/* Blur source regions independently so status-bar animation cannot re-filter the workspace. */ +body:has(> [data-slot='dialog-overlay'], > [data-slot='sheet-overlay'], > [data-onboarding-overlay]) + .app-layout + > :not(.window-controls) { + filter: blur(2px); +} + +body + > :is( + [data-slot='dialog-content'], + [data-slot='command-dialog-content'], + [data-slot='sheet-content'], + [data-onboarding-overlay] + ):has(~ [data-slot='dialog-overlay'], ~ [data-slot='sheet-overlay']) { + filter: blur(2px); +} + /* Why: native windows have a stable content box and need no wake-time viewport reflow. */ html.native-shell, html.native-shell body, diff --git a/src/renderer/src/components/native-chat/use-native-chat-context-menu.tsx b/src/renderer/src/components/native-chat/use-native-chat-context-menu.tsx index f89f5988613..8e595f3a6a6 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-context-menu.tsx +++ b/src/renderer/src/components/native-chat/use-native-chat-context-menu.tsx @@ -30,7 +30,6 @@ import { isEditableTarget } from '@/lib/editable-target' import { findEditablePrimarySelectionPasteTarget } from '@/lib/primary-selection-paste' import { NativeChatCopyOrcaSessionIdMenuItem } from './NativeChatCopyOrcaSessionIdMenuItem' import { NativeChatSelectionQuote } from './NativeChatSelectionQuote' -import { StructuredChatPaneMenuItems } from './StructuredChatPaneMenuItems' import { NativeChatPaneMenuItems } from './NativeChatPaneMenuItems' import { NativeChatStructuredPaneMenuItems } from './NativeChatStructuredPaneMenuItems' import type { NativeChatComposerHandle } from './native-chat-composer-types' diff --git a/src/renderer/src/components/onboarding/OnboardingFlow.tsx b/src/renderer/src/components/onboarding/OnboardingFlow.tsx index 4a0a60f1128..4ed63c3086b 100644 --- a/src/renderer/src/components/onboarding/OnboardingFlow.tsx +++ b/src/renderer/src/components/onboarding/OnboardingFlow.tsx @@ -197,7 +197,7 @@ export default function OnboardingFlow({ return (
{ if (!shouldRequestOnboardingSkipConfirmation(event)) { diff --git a/src/renderer/src/components/status-bar/StatusBarProviderSegment.tsx b/src/renderer/src/components/status-bar/StatusBarProviderSegment.tsx index 09e084ff78f..3896de3e85a 100644 --- a/src/renderer/src/components/status-bar/StatusBarProviderSegment.tsx +++ b/src/renderer/src/components/status-bar/StatusBarProviderSegment.tsx @@ -263,20 +263,12 @@ export function ProviderSegment({ : [] ) - if (!p || p.status === 'idle') { + // Why static: unfocused windows never poll, so a pending chip's pulse would redraw the compositor indefinitely. + if (!p || p.status === 'idle' || (p.status === 'fetching' && !tightest)) { return ( - ··· - - ) - } - - if (p.status === 'fetching' && !tightest) { - return ( - - - ··· + ··· ) } diff --git a/src/renderer/src/components/ui/command.tsx b/src/renderer/src/components/ui/command.tsx index 660d845cda0..980472c78be 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 { DialogOverlay } from '@/components/ui/dialog' const commandVariants = cva('flex h-full w-full flex-col overflow-hidden rounded-md', { variants: { @@ -49,6 +50,7 @@ function CommandDialog({ onCloseAutoFocus, contentClassName, overlayClassName, + surface = 'default', commandProps, ...props }: React.ComponentProps & { @@ -59,6 +61,8 @@ function CommandDialog({ onCloseAutoFocus?: (e: Event) => void contentClassName?: string overlayClassName?: string + /** `palette` is the larger full-search surface (worktree jump palette). */ + surface?: 'default' | 'palette' commandProps?: React.ComponentProps }) { const { className: commandClassName, ...commandRootProps } = commandProps ?? {} @@ -66,17 +70,14 @@ function CommandDialog({ return ( - + handleImeOverlayEscape(event)} diff --git a/src/renderer/src/components/ui/dialog.tsx b/src/renderer/src/components/ui/dialog.tsx index 39079fd1521..d4b394f95a6 100644 --- a/src/renderer/src/components/ui/dialog.tsx +++ b/src/renderer/src/components/ui/dialog.tsx @@ -35,9 +35,8 @@ function DialogOverlay({ return ( ( + <> +
+

Workspace content stays live behind the sheet.

+
+ + + + Sheet preview + + The sheet text stays sharp above the blurred workspace. + + + + + + ) +}) diff --git a/src/renderer/src/components/ui/sheet.tsx b/src/renderer/src/components/ui/sheet.tsx index 8005eaf2096..27a5e178154 100644 --- a/src/renderer/src/components/ui/sheet.tsx +++ b/src/renderer/src/components/ui/sheet.tsx @@ -29,9 +29,8 @@ function SheetOverlay({ return ( - +
+ +
{activeModal === 'delete-worktree' ? : null} {activeModal === 'preserved-branch-review' ? : null}
diff --git a/tests/e2e/modal-scene-blur.spec.ts b/tests/e2e/modal-scene-blur.spec.ts new file mode 100644 index 00000000000..7d8b0d846d2 --- /dev/null +++ b/tests/e2e/modal-scene-blur.spec.ts @@ -0,0 +1,135 @@ +import { test, expect } from './helpers/orca-app' +import { openSidebarProjectDialog } from './helpers/sidebar-project-dialog' + +test.use({ seedTestRepo: false, orcaAppExtraEnv: { ORCA_BACKGROUND_LAUNCH: '1' } }) + +for (const theme of ['dark', 'light'] as const) { + test(`modal surfaces blur the live scene and stay sharp (${theme})`, async ({ + electronApp, + orcaPage + }, testInfo) => { + const windows = await electronApp.evaluate(({ BrowserWindow }) => + BrowserWindow.getAllWindows().map((window) => ({ + visible: window.isVisible(), + focused: window.isFocused() + })) + ) + expect(windows.every((window) => !window.visible && !window.focused)).toBe(true) + await orcaPage.evaluate(async (theme) => { + await window.__store?.getState().updateSettings({ theme, uiLanguage: 'en' }) + }, theme) + + const scene = orcaPage.locator('.app-layout > :first-child') + await expect(scene).toHaveCSS('filter', 'none') + await openSidebarProjectDialog(orcaPage) + await expect(scene).toHaveCSS('filter', 'blur(2px)') + await expect(orcaPage.locator('.app-layout')).toHaveCSS('filter', 'none') + const dialog = orcaPage.getByRole('dialog', { name: /Add a project/i }) + await expect(dialog).toHaveCSS('filter', 'none') + await expect(dialog).toHaveCSS('backdrop-filter', 'none') + await expect(orcaPage.locator('[data-slot="dialog-overlay"]')).toHaveCSS( + 'backdrop-filter', + 'none' + ) + await orcaPage.screenshot({ path: testInfo.outputPath(`dialog-blur-${theme}.png`) }) + await orcaPage.keyboard.press('Escape') + await expect(scene).toHaveCSS('filter', 'none') + + await orcaPage.getByRole('button', { name: 'Search worktrees and browser tabs' }).click() + const palette = orcaPage.locator('[data-slot="command-dialog-content"]') + await expect(palette).toBeVisible() + await expect(scene).toHaveCSS('filter', 'blur(2px)') + await expect(palette).toHaveCSS('filter', 'none') + await expect(palette).toHaveCSS('backdrop-filter', 'none') + await orcaPage.keyboard.press('Escape') + await expect(scene).toHaveCSS('filter', 'none') + }) +} + +test('Space window dialogs blur their scene and release it on close', async ({ + electronApp, + orcaPage +}, testInfo) => { + const spaceWindow = electronApp.waitForEvent('window') + await orcaPage.evaluate(async () => { + await window.api.workspaceSpace.openWindow?.() + }) + const spacePage = await spaceWindow + await spacePage.waitForFunction(() => Boolean(window.__store)) + const scene = spacePage.locator('.app-layout > :first-child') + await expect(scene).toHaveCSS('filter', 'none') + await spacePage.evaluate(() => { + window.__store?.getState().openModal('preserved-branch-review', { + branches: [{ worktreeId: 'blur-preview', branchName: 'preview' }] + }) + }) + const dialog = spacePage.getByRole('dialog', { name: 'Review kept branches' }) + await expect(dialog).toBeVisible() + await expect(scene).toHaveCSS('filter', 'blur(2px)') + await expect(dialog).toHaveCSS('filter', 'none') + await expect(dialog).toHaveCSS('backdrop-filter', 'none') + const windows = await electronApp.evaluate(({ BrowserWindow }) => + BrowserWindow.getAllWindows().map((window) => ({ + visible: window.isVisible(), + focused: window.isFocused() + })) + ) + expect(windows.every((window) => !window.visible && !window.focused)).toBe(true) + await spacePage.screenshot({ path: testInfo.outputPath('space-dialog-blur.png') }) + await spacePage.keyboard.press('Escape') + await expect(scene).toHaveCSS('filter', 'none') +}) + +test.describe('onboarding scene blur', () => { + test.use({ dismissOnboarding: false }) + + test('onboarding stays sharp until a nested confirmation opens', async ({ orcaPage }) => { + const scene = orcaPage.locator('.app-layout > :first-child') + const onboarding = orcaPage.locator('[data-onboarding-overlay]') + await expect(onboarding).toBeVisible() + await expect(scene).toHaveCSS('filter', 'blur(2px)') + await expect(onboarding).toHaveCSS('filter', 'none') + await expect(onboarding).toHaveCSS('backdrop-filter', 'none') + await onboarding.getByRole('button').first().focus() + await orcaPage.keyboard.press('Escape') + const confirmation = orcaPage.getByRole('dialog', { name: /Skip onboarding\?/i }) + await expect(confirmation).toBeVisible() + await expect(confirmation).toHaveCSS('filter', 'none') + await expect(onboarding).toHaveCSS('filter', 'blur(2px)') + await confirmation.getByRole('button', { name: /No, keep going/i }).click() + await expect(onboarding).toHaveCSS('filter', 'none') + await expect(scene).toHaveCSS('filter', 'blur(2px)') + }) + + test('onboarding render recovery stays within the viewport', async ({ orcaPage }, testInfo) => { + await expect(orcaPage.locator('[data-onboarding-overlay]')).toBeVisible() + await orcaPage.evaluate(async () => { + const onboarding = await window.api.onboarding.get() + Object.defineProperty(onboarding, 'lastCompletedStep', { + get() { + throw new Error('Injected onboarding render failure') + } + }) + window.dispatchEvent(new CustomEvent('orca:onboarding-reopened', { detail: onboarding })) + }) + + const crashReport = orcaPage.getByRole('dialog').filter({ + has: orcaPage.getByRole('heading', { name: 'Orca hit a recoverable UI error' }) + }) + await crashReport.getByRole('button', { name: 'Close', exact: true }).click() + const recovery = orcaPage.getByRole('alert').filter({ hasText: 'Onboarding hit an error.' }) + const retry = recovery.getByRole('button', { name: 'Retry', exact: true }) + await expect(retry).toBeInViewport() + await orcaPage.screenshot({ path: testInfo.outputPath('onboarding-render-recovery.png') }) + + await orcaPage.evaluate(async () => { + window.dispatchEvent( + new CustomEvent('orca:onboarding-reopened', { detail: await window.api.onboarding.get() }) + ) + }) + await retry.click() + await expect(recovery).toHaveCount(0) + await expect(orcaPage.locator('[data-onboarding-overlay]')).toBeVisible() + await expect(orcaPage.locator('.app-layout > :first-child')).toHaveCSS('filter', 'blur(2px)') + }) +})