From cc1d2e251489236fb702ef4ddd17d53dc7692467 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 10 Nov 2026 15:24:54 -0800 Subject: [PATCH] fix(chat): apply window material to Chat UI backgrounds (#40535) * fix(chat): apply window material to chat surfaces Use the existing backdrop path for chat canvases and split groups, keeping neighboring terminal backgrounds and readable chat surfaces intact. Co-authored-by: medox * test(chat): verify painted backdrop surfaces and isolate fixture state * Update backdrop imports after renderer module relocation --------- Co-authored-by: medox --- .../src/app-shell/use-document-appearance.ts | 6 ++ .../appearance/NativeChatBackdrop.fixture.tsx | 96 +++++++++++++++++++ .../appearance/native-chat-appearance.css | 35 +++---- ...StructuredAgentSessionPaneOverlayLayer.tsx | 8 +- .../components/tab-group/TabGroupPanel.tsx | 6 +- .../controller/TerminalPaneSurface.tsx | 6 +- .../mounting/TerminalPaneOverlayLayer.tsx | 8 +- .../native-chat-backdrop.spec.ts | 43 +++++++++ 8 files changed, 177 insertions(+), 31 deletions(-) create mode 100644 src/renderer/src/components/native-chat/appearance/NativeChatBackdrop.fixture.tsx create mode 100644 tests/component-fixtures/native-chat-backdrop.spec.ts diff --git a/src/renderer/src/app-shell/use-document-appearance.ts b/src/renderer/src/app-shell/use-document-appearance.ts index a8b0ff240cf..44ed5a220e7 100644 --- a/src/renderer/src/app-shell/use-document-appearance.ts +++ b/src/renderer/src/app-shell/use-document-appearance.ts @@ -99,6 +99,12 @@ export function useDocumentAppearance(): void { usePluginFontRegistration() const reduceEffects = useReduceVisualEffects(useAppStore((s) => s.settings?.reduceVisualEffects)) const surfaceOpacity = useAppStore((s) => s.settings?.terminalBackgroundOpacity ?? 1) + useEffect(() => { + document.documentElement.style.setProperty( + '--orca-window-surface-opacity', + String(surfaceOpacity) + ) + }, [surfaceOpacity]) // Why re-parse: the theme list keeps the registration as received, and a newer host may send // a background this build cannot paint; it must drop to no image, never reach CSS. const registeredBackground = pluginTheme?.background diff --git a/src/renderer/src/components/native-chat/appearance/NativeChatBackdrop.fixture.tsx b/src/renderer/src/components/native-chat/appearance/NativeChatBackdrop.fixture.tsx new file mode 100644 index 00000000000..7f56629b408 --- /dev/null +++ b/src/renderer/src/components/native-chat/appearance/NativeChatBackdrop.fixture.tsx @@ -0,0 +1,96 @@ +import { useEffect, useState } from 'react' +import { createPortal } from 'react-dom' +import { Terminal } from '@xterm/xterm' +import { defineFixture } from '../../../component-fixtures/component-fixture' +import { NativeChatPaneCover } from '../../terminal-pane/chat/NativeChatPaneCover' +import { + NATIVE_CHAT_APPEARANCE_ROOT_CLASS, + nativeChatAppearanceStyle +} from './native-chat-appearance-style' + +function ChatCanvas({ sample }: { sample: string }): React.JSX.Element { + return ( +
+
+
+ ) +} + +function TerminalPane({ + covered, + sample +}: { + covered: boolean + sample: string +}): React.JSX.Element { + const [container, setContainer] = useState(null) + const [terminal] = useState(() => new Terminal()) + useEffect(() => () => terminal.dispose(), [terminal]) + return ( +
+
+
+
+ {!covered &&
} + {covered && container + ? createPortal( + + + , + container + ) + : null} +
+ ) +} + +function Backdrops(): React.JSX.Element { + const [covered, setCovered] = useState(false) + useEffect(() => { + const root = document.documentElement + const previousMaterial = root.getAttribute('data-window-material') + const previousOpacity = root.style.getPropertyValue('--orca-window-surface-opacity') + root.dataset.windowMaterial = 'blur' + root.style.setProperty('--orca-window-surface-opacity', '0.3') + return () => { + if (previousMaterial === null) { + root.removeAttribute('data-window-material') + } else { + root.setAttribute('data-window-material', previousMaterial) + } + root.style.setProperty('--orca-window-surface-opacity', previousOpacity) + } + }, []) + const terminalBackground = 'color-mix(in srgb, var(--background) 30%, transparent)' + return ( + <> + +
+
+
+ +
+
+ + +
+
+ +
+
+ + ) +} + +export const material = defineFixture({ + width: 800, + render: () => +}) diff --git a/src/renderer/src/components/native-chat/appearance/native-chat-appearance.css b/src/renderer/src/components/native-chat/appearance/native-chat-appearance.css index ba550b8559c..28e22d1d2de 100644 --- a/src/renderer/src/components/native-chat/appearance/native-chat-appearance.css +++ b/src/renderer/src/components/native-chat/appearance/native-chat-appearance.css @@ -39,35 +39,31 @@ initial-value: 0px; } -/* Over a theme image (main.css) a chat that fills its pane paints the canvas once and the layers - under it go clear. The canvas follows Background Opacity, floored at 0.8 so body text stays near - 4.5:1 even over white or black pixels. A chat split beside terminals stays opaque, since the pane - manager paints the terminal color under every pane of the split. */ -:root[data-theme-background] [data-terminal-chat-view]:not(.has-multiple-panes) { +/* Chat keeps a readable canvas over either backdrop. Mixed splits retain their shared terminal + paint beneath it so neighboring terminals keep their background. */ +:root:is([data-theme-background], [data-window-material]) + [data-terminal-chat-pane]:not(.has-multiple-panes) { /* Beats the pane manager's inline split paint (pane-divider.ts applyRootBackground). */ background: transparent !important; } -:root[data-theme-background] - [data-terminal-chat-view]:not(.has-multiple-panes) +:root:is([data-theme-background], [data-window-material]) + [data-terminal-chat-pane] .native-chat-pane-shell { background-color: transparent; } /* The opaque cover used to hide the terminal and its banners (layers below its z-10); overlays above it stay. Opacity, not visibility, so the covered terminal can still take focus handoffs. */ -:root[data-theme-background] - [data-terminal-chat-view]:not(.has-multiple-panes) - .pane +:root:is([data-theme-background], [data-window-material]) + [data-terminal-chat-pane] + .pane:has(> .native-chat-pane-shell) > :is(.xterm-container, .session-restored-banner, .pane-top-banner) { opacity: 0; } -:root[data-theme-background] - :is( - [data-terminal-chat-view]:not(.has-multiple-panes), - [data-structured-agent-session-overlay-tab-id] - ) +:root:is([data-theme-background], [data-window-material]) + :is([data-terminal-chat-pane], [data-structured-agent-session-overlay-tab-id]) [data-native-chat-root] { --chat-pane: transparent; /* Text-bearing surfaces keep an opaque base, as in the light scheme. */ @@ -90,7 +86,14 @@ ); background-color: color-mix( in srgb, - var(--chat-canvas) calc(max(var(--orca-theme-background-surface-opacity), 0.8) * 100%), + var(--chat-canvas) + calc( + max( + var(--orca-theme-background-surface-opacity, var(--orca-window-surface-opacity, 1)), + 0.8 + ) * + 100% + ), transparent ); } diff --git a/src/renderer/src/components/native-chat/pane/routing/StructuredAgentSessionPaneOverlayLayer.tsx b/src/renderer/src/components/native-chat/pane/routing/StructuredAgentSessionPaneOverlayLayer.tsx index 45369165f09..8072d12b811 100644 --- a/src/renderer/src/components/native-chat/pane/routing/StructuredAgentSessionPaneOverlayLayer.tsx +++ b/src/renderer/src/components/native-chat/pane/routing/StructuredAgentSessionPaneOverlayLayer.tsx @@ -2,7 +2,7 @@ import { memo, useCallback, useMemo } from 'react' import { useShallow } from 'zustand/react/shallow' import type { Tab, TabGroup } from '../../../../../../shared/tab-types' import { useAppStore } from '@/store' -import { useThemeBackgroundStore } from '@/store/appearance/theme-background' +import { useWindowBackdropActive } from '@/app-shell/appearance/window-backdrop' import { structuredAgentSessionOwnerForTab, structuredAgentSessionTargetForHost @@ -37,7 +37,7 @@ const StructuredAgentSessionOverlaySlot = memo(function StructuredAgentSessionOv // Resolve from the tab’s recorded host because workspace ids can collide across hosts. const owner = useAppStore((state) => structuredAgentSessionOwnerForTab(state, tab)) const target = useMemo(() => structuredAgentSessionTargetForHost(owner), [owner]) - const themeBackgroundActive = useThemeBackgroundStore((state) => state.active) + const backdropActive = useWindowBackdropActive() if (!target) { return null } @@ -45,8 +45,8 @@ const StructuredAgentSessionOverlaySlot = memo(function StructuredAgentSessionOv diff --git a/src/renderer/src/components/tab-group/TabGroupPanel.tsx b/src/renderer/src/components/tab-group/TabGroupPanel.tsx index 0e42786c307..98e7d402b01 100644 --- a/src/renderer/src/components/tab-group/TabGroupPanel.tsx +++ b/src/renderer/src/components/tab-group/TabGroupPanel.tsx @@ -23,7 +23,6 @@ import { useEditorGroupFileDropOwner } from '../editor/use-editor-group-file-dro import { cn } from '@/lib/class-names' import { UNFOCUSED_SPLIT_GROUP_OPACITY } from './split-group-dim' import { useWindowBackdropActive } from '@/app-shell/appearance/window-backdrop' -import { useThemeBackgroundStore } from '@/store/appearance/theme-background' import { EditorRevealFlash } from './EditorRevealFlash' import { SplitPaneLanding } from './SplitPaneLanding' import { isSplitLandingGroup } from '@/store/slices/tabs/split-landing-groups' @@ -127,7 +126,6 @@ export default function TabGroupPanel({ ) const attachEditorFileDropOwner = useEditorGroupFileDropOwner({ worktreeId, groupId }) const backdropActive = useWindowBackdropActive() - const themeBackgroundActive = useThemeBackgroundStore((state) => state.active) // Why: per-group anchor-name lets the worktree-level overlay position panes via CSS anchor positioning, so moving a tab between groups re-targets the anchor instead of remounting xterm (loses alt-screen TUI state) or reloading ``. const bodyAnchorName = tabGroupBodyAnchorName(groupId) // Why: memoize so a fresh style object each render doesn't break downstream memoization keyed on referential equality. @@ -337,10 +335,10 @@ export default function TabGroupPanel({
{ - openTerminalBesideTab(tabId) - }} + onOpenNewTerminal={() => void openTerminalBesideTab(tabId)} onClosePane={() => handleRequestClosePane(activePane.id)} />, activePane.container, diff --git a/src/renderer/src/components/terminal-pane/mounting/TerminalPaneOverlayLayer.tsx b/src/renderer/src/components/terminal-pane/mounting/TerminalPaneOverlayLayer.tsx index 3a551672315..39b58931847 100644 --- a/src/renderer/src/components/terminal-pane/mounting/TerminalPaneOverlayLayer.tsx +++ b/src/renderer/src/components/terminal-pane/mounting/TerminalPaneOverlayLayer.tsx @@ -3,7 +3,7 @@ import { useShallow } from 'zustand/react/shallow' import type { Tab, TabGroup } from '../../../../../shared/tab-types' import type { TerminalTab } from '../../../../../shared/workspace-layout/terminal-tab-types' import { useAppStore } from '../../../store/index' -import { useThemeBackgroundStore } from '@/store/appearance/theme-background' +import { useWindowBackdropActive } from '@/app-shell/appearance/window-backdrop' import { findActivityTerminalPortal, type ActivityTerminalPortalTarget @@ -76,7 +76,7 @@ const TerminalPaneOverlayLayer = memo(function TerminalPaneOverlayLayer({ [worktreeId] ) - const themeBackgroundActive = useThemeBackgroundStore((state) => state.active) + const backdropActive = useWindowBackdropActive() const focusOwningGroup = useCallback( (groupId: string) => focusGroup(worktreeId, groupId), [focusGroup, worktreeId] @@ -173,8 +173,8 @@ const TerminalPaneOverlayLayer = memo(function TerminalPaneOverlayLayer({ isVisible={isVisible} isActive={isActive} hiddenPrewarm={assignment?.isActiveInGroup === true ? hiddenTerminalPrewarm : null} - // Why not over a theme image: a chat there is translucent, so a dim would bleed the image in. - dimmed={groups.length > 1 && isVisible && !isActive && !themeBackgroundActive} + // Why not over a backdrop: group dimming also fades terminal and chat text. + dimmed={groups.length > 1 && isVisible && !isActive && !backdropActive} activityTerminalPortal={activityTerminalPortal} onFocusOwningGroup={focusOwningGroup} consumeSuppressedPtyExit={consumeSuppressedPtyExit} diff --git a/tests/component-fixtures/native-chat-backdrop.spec.ts b/tests/component-fixtures/native-chat-backdrop.spec.ts new file mode 100644 index 00000000000..b7f60f98a1c --- /dev/null +++ b/tests/component-fixtures/native-chat-backdrop.spec.ts @@ -0,0 +1,43 @@ +import { expect, test, type Page } from '@playwright/test' + +async function pixels(page: Page, sample: string): Promise { + return page.locator(`[data-paint-sample="${sample}"]`).screenshot() +} + +test('material reaches chat without changing the neighboring terminal or exposing covered content', async ({ + page +}) => { + await page.goto('/fixtures.html?id=native-chat/appearance/NativeChatBackdrop:material&theme=dark') + await expect(page.locator('html')).toHaveAttribute('data-fixture-state', 'ready') + const backdrop = page.locator('[data-test-backdrop]') + await backdrop.evaluate((element) => (element.style.background = 'white')) + const neighbor = await pixels(page, 'neighbor') + await page.getByRole('button', { name: 'Toggle split chat' }).click() + expect(await pixels(page, 'neighbor')).toEqual(neighbor) + + const samples = ['single', 'split', 'structured'] + const lightBare = await pixels(page, 'bare') + const light = await Promise.all(samples.map((sample) => pixels(page, sample))) + for (const painted of light) { + expect(painted.equals(lightBare)).toBe(false) + } + await backdrop.evaluate((element) => (element.style.background = 'black')) + const darkBare = await pixels(page, 'bare') + for (const [index, sample] of samples.entries()) { + const painted = await pixels(page, sample) + expect(painted.equals(light[index])).toBe(false) + expect(painted.equals(darkBare)).toBe(false) + } + + const covered = await pixels(page, 'split') + const neighborDark = await pixels(page, 'neighbor') + await page.locator('[data-terminal-ink]').evaluateAll((elements) => { + for (const element of elements) { + if (element instanceof HTMLElement) { + element.style.background = 'red' + } + } + }) + expect(await pixels(page, 'split')).toEqual(covered) + expect((await pixels(page, 'neighbor')).equals(neighborDark)).toBe(false) +})