mirror of
https://github.com/stablyai/orca.git
synced 2026-11-13 17:37:40 +00:00
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:
@@ -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
|
||||
);
|
||||
}
|
||||
|
||||
+4
-4
@@ -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)
|
||||
})
|
||||
Reference in New Issue
Block a user