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 <mohammed.elabidi123@gmail.com>

* test(chat): verify painted backdrop surfaces and isolate fixture state

* Update backdrop imports after renderer module relocation

---------

Co-authored-by: medox <mohammed.elabidi123@gmail.com>
This commit is contained in:
Brennan Benson
2026-11-10 15:24:54 -08:00
committed by GitHub
co-authored by medox
parent efc62d1ae5
commit cc1d2e2514
8 changed files with 177 additions and 31 deletions
@@ -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
@@ -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 (
<div
data-native-chat-root="true"
className={`${NATIVE_CHAT_APPEARANCE_ROOT_CLASS} relative h-full w-full`}
style={nativeChatAppearanceStyle({ theme: 'dark' })}
>
<div data-paint-sample={sample} className="absolute left-4 top-4 size-8" />
</div>
)
}
function TerminalPane({
covered,
sample
}: {
covered: boolean
sample: string
}): React.JSX.Element {
const [container, setContainer] = useState<HTMLDivElement | null>(null)
const [terminal] = useState(() => new Terminal())
useEffect(() => () => terminal.dispose(), [terminal])
return (
<div ref={setContainer} className="pane relative h-32 w-40">
<div className="xterm-container absolute inset-0">
<div data-terminal-ink className="absolute inset-0" />
</div>
{!covered && <div data-paint-sample={sample} className="absolute left-4 top-4 size-8" />}
{covered && container
? createPortal(
<NativeChatPaneCover pane={{ container, terminal }}>
<ChatCanvas sample={sample} />
</NativeChatPaneCover>,
container
)
: null}
</div>
)
}
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 (
<>
<button onClick={() => setCovered(!covered)}>Toggle split chat</button>
<div data-test-backdrop className="flex gap-4">
<div data-paint-sample="bare" className="size-8 shrink-0" />
<div data-terminal-chat-pane="" style={{ background: terminalBackground }}>
<TerminalPane covered sample="single" />
</div>
<div
data-terminal-chat-pane={covered ? '' : undefined}
className="has-multiple-panes flex"
style={{ background: terminalBackground }}
>
<TerminalPane covered={covered} sample="split" />
<TerminalPane covered={false} sample="neighbor" />
</div>
<div data-structured-agent-session-overlay-tab-id="fixture" className="h-32 w-40">
<ChatCanvas sample="structured" />
</div>
</div>
</>
)
}
export const material = defineFixture({
width: 800,
render: () => <Backdrops />
})
@@ -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
);
}
@@ -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
<RetainedPaneHost
groupId={groupId}
isVisible={isActive}
// Why not over a theme image: the chat is translucent there, so a dim would bleed the image in.
dimmed={hasSplitGroups && !isFocusedGroup && !themeBackgroundActive}
// Why not over a backdrop: dimming the translucent chat also dims its text.
dimmed={hasSplitGroups && !isFocusedGroup && !backdropActive}
data-structured-agent-session-overlay-tab-id={tab.id}
onFocusOwningGroup={onFocusOwningGroup}
>
@@ -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 `<webview>`.
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({
<div
ref={setBodyRef}
data-tab-group-body-id={groupId}
// Why: the pane overlays paint terminals and chats above this body; only they show the theme image.
// Why: the pane overlays paint terminals and chats above this body; only they show the backdrop.
data-translucent-tab-body={
activeTab?.contentType === 'terminal' ||
(activeTab && isStructuredTab(activeTab) && themeBackgroundActive)
(activeTab && isStructuredTab(activeTab) && backdropActive)
? ''
: undefined
}
@@ -56,6 +56,7 @@ export function TerminalPaneSurface({
agentSessionContinuation,
agentSessionFork,
beginPaneDragFromHeader,
chatPane,
closeTerminalLinkActions,
contextMenu,
contextMenuCanContinueInNewSession,
@@ -147,6 +148,7 @@ export function TerminalPaneSurface({
className="absolute inset-0 min-h-0 min-w-0"
data-terminal-tab-id={tabId}
data-terminal-chat-view={effectiveChatViewMode && activePaneIsChatLeaf ? 'true' : undefined}
data-terminal-chat-pane={effectiveChatViewMode && chatPane ? '' : undefined}
data-terminal-layout-leaf-ids={expectedLayoutLeafIdsAttr}
data-pane-title-surface={titleUsesLightSurface ? 'light' : 'dark'}
style={terminalContainerStyle}
@@ -231,9 +233,7 @@ export function TerminalPaneSurface({
)
: undefined
}
onOpenNewTerminal={() => {
openTerminalBesideTab(tabId)
}}
onOpenNewTerminal={() => void openTerminalBesideTab(tabId)}
onClosePane={() => handleRequestClosePane(activePane.id)}
/>,
activePane.container,
@@ -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}
@@ -0,0 +1,43 @@
import { expect, test, type Page } from '@playwright/test'
async function pixels(page: Page, sample: string): Promise<Buffer> {
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)
})