fix(desktop): restore flat glass surfaces without text bleed

This commit is contained in:
emozilla
2026-09-11 14:30:32 -04:00
committed by brooklyn!
parent 8226c2f6a2
commit 4e865d42d1
12 changed files with 947 additions and 69 deletions
+7 -3
View File
@@ -220,9 +220,13 @@ Holding Cmd (Ctrl off macOS) reveals small slot numbers over the target strip's
status dots after 400ms, without changing tab widths. Hints follow the same
binding and hovered/focused-zone resolver as the number shortcuts.
Sticky user messages mask scrolling content with the opaque chat surface,
including the gap above them. Use `data-glass-opaque` so Glass cannot clear the
mask; no gradient or backdrop blur.
Tab close buttons fade the label with a content mask, not a painted gradient.
The tab reads its surface token directly so glass tint is painted only once.
Sticky user messages clip covered scrolling content, including the gap above
them. Their wrappers stay unpainted; only the rounded user bubble owns a fill.
Clipping follows the pinned prompt and its live height without changing layout,
so glass and message-bubble transparency do not reveal scrolling text.
## Feedback & empty/error/loading states
+350
View File
@@ -0,0 +1,350 @@
import * as fs from 'node:fs/promises'
import * as os from 'node:os'
import * as path from 'node:path'
import { expect, test } from '@playwright/test'
import { createServer, type ViteDevServer } from 'vite'
const desktop = path.resolve(import.meta.dirname, '..')
let server: ViteDevServer
let scratch: string
let url: string
test.beforeAll(async () => {
scratch = await fs.mkdtemp(path.join(os.tmpdir(), 'hermes-glass-'))
// Match the existing component-browser fixtures without sharing Vite's cache.
Object.assign(globalThis, { __dirname: desktop })
server = await createServer({
root: desktop,
configFile: path.join(desktop, 'vite.config.ts'),
configLoader: 'runner',
cacheDir: path.join(scratch, 'node_modules/.vite'),
server: { host: '127.0.0.1', port: 0, strictPort: false },
optimizeDeps: { entries: ['scripts/fixtures/glass-surfaces.html'] }
})
await server.listen()
url = `${server.resolvedUrls!.local[0]}scripts/fixtures/glass-surfaces.html`
})
test.afterAll(async () => {
await server?.close()
if (scratch) {
await fs.rm(scratch, { recursive: true, force: true })
}
})
test('glass tabs fade the label beneath the close button without repainting the field', async ({ page }) => {
await page.goto(url)
const tab = page.getByTestId('active-tab')
const close = tab.getByRole('button', { name: 'Close', exact: true })
await expect(tab).toBeVisible()
for (const id of ['active-tab', 'idle-tab', 'fixed-tab']) {
expect(await page.getByTestId(id).evaluate(el => getComputedStyle(el).backgroundColor)).toBe('rgba(0, 0, 0, 0)')
}
const bounds = await tab.boundingBox()
await tab.hover()
await expect(close).toBeVisible()
expect(await close.evaluate(el => getComputedStyle(el).backgroundColor)).toBe('rgba(0, 0, 0, 0)')
const mask = await tab.getByText('A long session title', { exact: false }).evaluate(el => {
const masks = []
for (let node: Element | null = el; node && !node.hasAttribute('data-testid'); node = node.parentElement) {
masks.push(getComputedStyle(node).maskImage)
}
return masks.find(value => value !== 'none')
})
expect(mask).toContain('linear-gradient')
expect(await tab.boundingBox()).toEqual(bounds)
// Paint a continuous marker through the real label slot to test the mask's
// pixels, not just the existence of a gradient. The close glyph is hidden so
// its paint cannot be mistaken for label bleed-through.
const sample = await tab.evaluate(el => {
const content = el.querySelector<HTMLElement>('.pane-tab-content')!
const button = el.querySelector<HTMLButtonElement>('button[aria-label="Close"]')!
content.style.background = '#ff0000'
button.style.visibility = 'hidden'
const bounds = content.getBoundingClientRect()
const closeBounds = button.getBoundingClientRect()
return {
y: Math.floor(bounds.top + 2),
solid: Math.floor(bounds.left + 2),
fade: Math.floor(closeBounds.left - 7),
covered: Math.floor(closeBounds.left + 3)
}
})
await page.evaluate(() => new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve))))
const pixels = await page.screenshot({ omitBackground: true, path: test.info().outputPath('masked-tab.png') })
const alpha = await page.evaluate(
async ({ image, points }) => {
const img = new Image()
img.src = image
await img.decode()
const canvas = document.createElement('canvas')
canvas.width = img.width
canvas.height = img.height
const context = canvas.getContext('2d')!
context.drawImage(img, 0, 0)
return [points.solid, points.fade, points.covered].map(x => context.getImageData(x, points.y, 1, 1).data[3])
},
{ image: `data:image/png;base64,${pixels.toString('base64')}`, points: sample }
)
expect(alpha[0]).toBeGreaterThan(alpha[1])
expect(alpha[1]).toBeGreaterThan(alpha[2])
expect(alpha[2]).toBeLessThan(128)
await tab.evaluate(el => {
el.querySelector<HTMLElement>('.pane-tab-content')!.style.removeProperty('background')
el.querySelector<HTMLButtonElement>('button[aria-label="Close"]')!.style.removeProperty('visibility')
})
// The active underline remains on the tab itself, without a second stroke
// on the close button increasing its opacity.
expect(await tab.evaluate(el => getComputedStyle(el).boxShadow)).toContain('inset')
await close.click()
await expect(page.locator('body')).toHaveAttribute('data-closed', 'true')
await expect(page.locator('body')).not.toHaveAttribute('data-activated', 'true')
for (const id of ['idle-tab', 'short-tab', 'selected-tab']) {
const sampleTab = page.getByTestId(id)
const before = await sampleTab.boundingBox()
await sampleTab.hover()
const content = sampleTab.locator('.pane-tab-content')
expect(await content.evaluate(el => getComputedStyle(el).maskImage)).toContain('linear-gradient')
expect(await sampleTab.boundingBox()).toEqual(before)
expect(
await sampleTab
.getByRole('button', { name: 'Close', exact: true })
.evaluate(el => getComputedStyle(el).backgroundColor)
).toBe('rgba(0, 0, 0, 0)')
}
// Glass scope and tint do not affect the fade. Solid mode still honors each
// tab's surface token instead of becoming unconditionally transparent.
await page.evaluate(() => {
document.documentElement.setAttribute('data-hermes-glass-scope', 'sidebar')
document.documentElement.style.setProperty('--translucency-glass-keep', '85%')
})
expect(await tab.evaluate(el => getComputedStyle(el).backgroundColor)).toBe('rgba(0, 0, 0, 0)')
await page.evaluate(() => document.documentElement.removeAttribute('data-hermes-glass'))
expect(await tab.evaluate(el => getComputedStyle(el).backgroundColor)).not.toBe('rgba(0, 0, 0, 0)')
await tab.hover()
expect(await tab.locator('.pane-tab-content').evaluate(el => getComputedStyle(el).maskImage)).toContain(
'linear-gradient'
)
})
test('sticky prompts clip scrolling replies without an opaque backing', async ({ page }) => {
await page.goto(url)
const transcript = page.getByTestId('first-transcript')
const viewport = transcript.locator('[data-slot="aui_thread-viewport"]')
const prompt = transcript.locator('[data-slot="aui_user-message-root"]').first()
await expect(prompt).toBeAttached()
await viewport.evaluate(el => {
el.scrollTop = 0
})
await expect.poll(() => viewport.evaluate(el => el.scrollTop)).toBe(0)
expect(await prompt.evaluate(el => getComputedStyle(el).backgroundColor)).toBe('rgba(0, 0, 0, 0)')
await page.evaluate(() => document.documentElement.style.setProperty('--user-bubble-keep', '0%'))
await viewport.evaluate(el => {
el.scrollTop = 300
})
await expect.poll(() => viewport.evaluate(el => el.scrollTop)).toBe(300)
const points = await prompt.evaluate(el => {
const rect = el.getBoundingClientRect()
const viewport = el.closest('[data-slot="aui_thread-viewport"]')!.getBoundingClientRect()
return {
x: Math.floor(rect.left + rect.width / 2),
gap: Math.floor(viewport.top + 1),
hidden: Math.floor(rect.top + 8),
visible: Math.ceil(rect.bottom + 5)
}
})
await page.evaluate(() => new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve))))
const image = await page.screenshot({ omitBackground: true, path: test.info().outputPath('sticky-mask.png') })
const colors = await page.evaluate(
async ({ image, points }) => {
const bitmap = new Image()
bitmap.src = image
await bitmap.decode()
const canvas = document.createElement('canvas')
canvas.width = bitmap.width
canvas.height = bitmap.height
const context = canvas.getContext('2d')!
context.drawImage(bitmap, 0, 0)
return [points.gap, points.hidden, points.visible].map(y =>
Array.from(context.getImageData(points.x, y, 1, 1).data)
)
},
{ image: `data:image/png;base64,${image.toString('base64')}`, points }
)
expect(colors[0][3]).toBeLessThan(128)
expect(colors[1][3]).toBeLessThan(128)
expect(colors[2]).toEqual([255, 0, 0, 255])
const clipEdge = () =>
transcript
.locator('[data-slot="aui_assistant-message-root"]')
.first()
.evaluate(el => {
const rect = el.getBoundingClientRect()
const clip = Number.parseFloat(getComputedStyle(el).getPropertyValue('--sticky-prompt-clip'))
const prompt = el
.closest('[data-slot="aui_message-group"]')!
.querySelector('[data-slot="aui_user-message-root"]')!
return Math.abs(rect.top + clip - prompt.getBoundingClientRect().bottom)
})
await expect.poll(clipEdge).toBeLessThan(1)
const clips = await page.evaluate(async () => {
const transcript = document.querySelector('[data-testid="first-transcript"]')!
const reply = transcript.querySelector<HTMLElement>('[data-slot="aui_assistant-message-root"]')!
const read = () => getComputedStyle(reply).clipPath
const before = read()
document.querySelector<HTMLButtonElement>('[data-testid="first-transcript-append"]')!.click()
const samples = [read()]
for (let i = 0; i < 3; i++) {
await new Promise(requestAnimationFrame)
samples.push(read())
}
return { before, samples }
})
expect(clips.before).not.toBe('none')
expect(clips.samples.every(value => value !== 'none')).toBe(true)
// Expanding a pinned prompt must move the clip without needing a scroll.
await prompt.getByRole('button').click()
await expect.poll(() => prompt.getByRole('button').evaluate(el => el.getBoundingClientRect().height)).toBe(140)
await expect.poll(clipEdge).toBeLessThan(1)
// A larger secondary-window titlebar offset moves both the sticky and clip.
await viewport.evaluate(el => el.style.setProperty('--sticky-human-top', '55px'))
await viewport.evaluate(el => {
el.scrollTop = 301
})
await expect.poll(clipEdge).toBeLessThan(1)
// Scroll back through attachments and out of the pinned state. Nothing may
// remain clipped, and the other pane's independently pinned reply is intact.
await viewport.evaluate(el => el.style.removeProperty('--sticky-human-top'))
await page.evaluate(() => new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve))))
await viewport.evaluate(el => {
el.scrollTop = 0
})
await page.evaluate(() => new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve))))
await expect.poll(() => transcript.locator('[data-sticky-prompt-clip]').count()).toBe(0)
await expect(transcript.getByTestId('attachment').first()).toBeVisible()
const other = page.getByTestId('second-transcript')
expect(await other.locator('[data-slot="aui_thread-viewport"]').evaluate(el => el.scrollTop)).toBeGreaterThan(0)
expect(await other.locator('[data-sticky-prompt-clip]').count()).toBeGreaterThan(0)
// Enter another turn via a large jump (including virtualized history), then
// return: stale clip styles must leave the previous turn.
await viewport.evaluate(el => {
el.scrollTop = 1400
})
await expect.poll(() => transcript.locator('[data-sticky-prompt-clip]').count()).toBeGreaterThan(0)
await viewport.evaluate(el => {
el.scrollTop = 0
})
await expect.poll(() => transcript.locator('[data-sticky-prompt-clip]').count()).toBe(0)
// A standalone reply can still occupy the gap below a secondary titlebar
// when the following prompt pins. It must be clipped across group boundaries.
await page.goto(`${url}?preceding`)
const gapViewport = page.getByTestId('first-transcript').locator('[data-slot="aui_thread-viewport"]')
await gapViewport.locator('[data-slot="aui_user-message-root"]').first().waitFor({ state: 'attached' })
await gapViewport.evaluate(el => {
el.scrollTop = 0
})
await page.evaluate(() => new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve))))
const gapScroll = await gapViewport.evaluate(el => {
el.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true }))
el.style.setProperty('--sticky-human-top', '55px')
const group = el.querySelector('[data-slot="aui_user-message-root"]')!.closest('[data-slot="aui_message-group"]')!
const target = el.scrollTop + group.getBoundingClientRect().top - el.getBoundingClientRect().top - 45
el.scrollTop = target
return el.scrollTop
})
await expect.poll(() => gapViewport.evaluate(el => el.scrollTop)).toBe(gapScroll)
await page.evaluate(() => new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve))))
const gapClear = () =>
gapViewport.evaluate(el => {
const previous = el.querySelector('[data-slot="aui_assistant-message-root"]')!
const bounds = el.getBoundingClientRect()
const x = bounds.left + bounds.width / 2
const hit = document.elementFromPoint(x, bounds.top + 34)
return { hidden: !previous.contains(hit), clip: getComputedStyle(previous).clipPath }
})
await expect.poll(async () => (await gapClear()).hidden).toBe(true)
expect((await gapClear()).clip).not.toBe('none')
// Exercise the production message/edit components as well as the colored
// clipping markers. Editing replaces the prompt with a display:contents root.
await page.goto(`${url}?real`)
const realViewport = page.getByTestId('first-transcript').locator('[data-slot="aui_thread-viewport"]')
await realViewport.locator('[data-slot="aui_assistant-message-root"]').first().waitFor()
await realViewport
.locator('[data-slot="aui_assistant-message-root"]')
.first()
.evaluate(el => {
el.style.height = '900px'
})
await realViewport.evaluate(el => {
el.scrollTop = 300
})
const realPrompt = realViewport.locator('[data-slot="aui_user-message-root"]').first()
await realPrompt.getByRole('button', { name: 'Edit message', exact: true }).click()
const editor = realViewport.getByRole('textbox', { name: 'Edit message', exact: true })
await expect(editor).toBeVisible()
await editor.fill('Expanded editable prompt\n'.repeat(8))
await realViewport.evaluate(el => {
el.scrollTop = 400
})
await expect
.poll(() =>
realViewport
.locator('[data-slot="aui_assistant-message-root"]')
.first()
.evaluate(el => {
const prompt = el
.closest('[data-slot="aui_message-group"]')!
.querySelector('[data-slot="aui_user-message-root"]')!
return Math.abs(
el.getBoundingClientRect().top +
Number.parseFloat(getComputedStyle(el).getPropertyValue('--sticky-prompt-clip')) -
prompt.getBoundingClientRect().bottom
)
})
)
.toBeLessThan(1)
})
@@ -0,0 +1,3 @@
<!doctype html>
<html lang="en"><head><meta charset="UTF-8"><title>Glass surface regression fixture</title></head>
<body><div id="root"></div><script type="module" src="./glass-surfaces.tsx"></script></body></html>
@@ -0,0 +1,160 @@
import '@/styles.css'
import {
AssistantRuntimeProvider,
MessagePrimitive,
type ThreadMessage,
useExternalStoreRuntime
} from '@assistant-ui/react'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { useState } from 'react'
import { flushSync } from 'react-dom'
import { createRoot } from 'react-dom/client'
import { MemoryRouter } from 'react-router'
import { Thread } from '@/components/assistant-ui/thread'
import { ThreadMessageList } from '@/components/assistant-ui/thread/list'
import { StickyHumanMessageContainer, USER_BUBBLE_BASE_CLASS } from '@/components/assistant-ui/thread/user-message'
import { PaneTab, PaneTabLabel, PaneTabStrip } from '@/components/ui/pane-tab'
import { RootTooltipProvider } from '@/components/ui/tooltip'
import { I18nProvider } from '@/i18n'
// Real primitives and stylesheet; only the sample labels and glass inputs are fixtures.
const root = document.documentElement
root.classList.add('dark')
root.setAttribute('data-hermes-glass', '')
root.setAttribute('data-hermes-glass-scope', 'window')
root.style.setProperty('--translucency-glass-keep', '40%')
const messages = Array.from({ length: 6 }, (_, turn) => [
{
id: `user-${turn}`,
role: 'user',
attachments: [],
createdAt: new Date(0),
content: [{ type: 'text', text: `Prompt ${turn}` }],
metadata: { custom: {} }
},
{
id: `assistant-${turn}`,
role: 'assistant',
createdAt: new Date(0),
content: [{ type: 'text', text: `Reply ${turn}` }],
status: { type: 'complete', reason: 'stop' },
metadata: { custom: {}, unstable_state: null, unstable_annotations: [], unstable_data: [], steps: [] }
}
]).flat() as ThreadMessage[]
function User() {
const [expanded, setExpanded] = useState(false)
return (
<StickyHumanMessageContainer
attachments={
<div data-testid="attachment" style={{ height: 40 }}>
Attachment
</div>
}
>
<button
className={USER_BUBBLE_BASE_CLASS}
onClick={() => setExpanded(value => !value)}
style={{ height: expanded ? 140 : 48 }}
>
A user prompt — click to change its height
</button>
</StickyHumanMessageContainer>
)
}
function Assistant() {
return (
<MessagePrimitive.Root data-slot="aui_assistant-message-root">
<div data-testid="reply-marker" style={{ height: 900, background: '#ff0000' }}>
Scrolling reply
</div>
</MessagePrimitive.Root>
)
}
const components = { UserMessage: User, AssistantMessage: Assistant }
function Transcript({ id }: { id: string }) {
const [currentMessages, setMessages] = useState(() =>
new URLSearchParams(location.search).has('preceding')
? ([{ ...messages[1], id: 'standalone' }, ...messages] as ThreadMessage[])
: messages
)
const runtime = useExternalStoreRuntime<ThreadMessage>({
messages: currentMessages,
isRunning: false,
onNew: async () => {},
onEdit: async () => {}
})
const realMessages = new URLSearchParams(location.search).has('real')
return (
<AssistantRuntimeProvider runtime={runtime}>
<div>
<button
data-testid={`${id}-append`}
onClick={() =>
flushSync(() =>
setMessages(previous => [
...previous,
{ ...messages[1], id: `appended-${previous.length}` } as ThreadMessage
])
)
}
>
Append reply
</button>
<div data-testid={id} style={{ height: 430, width: 480 }}>
{realMessages ? <Thread sessionKey={id} /> : <ThreadMessageList components={components} sessionKey={id} />}
</div>
</div>
</AssistantRuntimeProvider>
)
}
createRoot(document.getElementById('root')!).render(
<QueryClientProvider client={new QueryClient()}>
<I18nProvider>
<RootTooltipProvider>
<MemoryRouter>
<div style={{ padding: 40 }}>
<PaneTabStrip>
<PaneTab
active
data-testid="active-tab"
onClose={() => document.body.setAttribute('data-closed', 'true')}
>
<PaneTabLabel as="button" onClick={() => document.body.setAttribute('data-activated', 'true')}>
A long session title that reaches underneath the close button
</PaneTabLabel>
</PaneTab>
<PaneTab data-testid="idle-tab" dirty onClose={() => {}}>
<PaneTabLabel>Another long session title with unsaved changes</PaneTabLabel>
</PaneTab>
<PaneTab data-testid="fixed-tab">
<PaneTabLabel>Sessions</PaneTabLabel>
</PaneTab>
<PaneTab data-testid="short-tab" onClose={() => {}}>
<PaneTabLabel>X</PaneTabLabel>
</PaneTab>
<PaneTab data-testid="selected-tab" onClose={() => {}} selected>
<PaneTabLabel>Selected</PaneTabLabel>
</PaneTab>
</PaneTabStrip>
<div style={{ display: 'flex', gap: 30, marginTop: 40 }}>
<Transcript id="first-transcript" />
<Transcript id="second-transcript" />
</div>
</div>
</MemoryRouter>
</RootTooltipProvider>
</I18nProvider>
</QueryClientProvider>
)
@@ -47,6 +47,7 @@ import { MessageRenderBoundary } from '../message-render-boundary'
import { resolveShowEarlierAction, shouldAutoShowEarlier, useTranscriptWindow } from './transcript-window'
import { useMessagesBelow } from './use-messages-below'
import { useStickyPromptClip } from './use-sticky-prompt-clip'
type ThreadMessageComponents = ComponentProps<typeof ThreadPrimitive.MessageByIndex>['components']
@@ -1145,6 +1146,7 @@ const ThreadMessageListInner: FC<ThreadMessageListProps> = ({
)
useMessagesBelow({ contentRef, scrollRef, isAtBottom, paneVisible, rows, sessionKey })
useStickyPromptClip({ contentRef, scrollRef, paneVisible, rows })
return (
<div
@@ -0,0 +1,147 @@
import { act, cleanup, renderHook } from '@testing-library/react'
import { afterEach, expect, it, vi } from 'vitest'
import { useStickyPromptClip } from './use-sticky-prompt-clip'
const rect = (top: number, height: number) => ({ top, bottom: top + height, height }) as DOMRect
afterEach(() => {
cleanup()
vi.restoreAllMocks()
vi.unstubAllGlobals()
})
it('clips only visible covered siblings, follows resize, and releases styles and observers on hide', () => {
const viewport = window.document.createElement('div')
const content = window.document.createElement('div')
viewport.append(content)
vi.spyOn(viewport, 'getBoundingClientRect').mockReturnValue(rect(0, 500))
const makeGroup = () => {
const group = window.document.createElement('div')
group.dataset.slot = 'aui_message-group'
const editor = window.document.createElement('div')
editor.style.display = 'contents'
const prompt = window.document.createElement('div')
prompt.dataset.slot = 'aui_user-message-root'
prompt.style.top = '5px'
const attachments = window.document.createElement('div')
const reply = window.document.createElement('div')
group.append(editor, reply)
editor.append(prompt, attachments)
content.append(group)
return {
group,
prompt,
attachments,
reply,
promptRect: vi.spyOn(prompt, 'getBoundingClientRect').mockReturnValue(rect(5, 60)),
attachmentRect: vi.spyOn(attachments, 'getBoundingClientRect').mockReturnValue(rect(-50, 40)),
replyRect: vi.spyOn(reply, 'getBoundingClientRect').mockReturnValue(rect(-10, 900))
}
}
const visible = makeGroup()
const skipped = makeGroup()
vi.spyOn(visible.group, 'getBoundingClientRect').mockReturnValue(rect(0, 900))
vi.spyOn(skipped.group, 'getBoundingClientRect').mockReturnValue(rect(900, 900))
let intersection: IntersectionObserverCallback
let resize: ResizeObserverCallback
let frame: FrameRequestCallback | undefined
const disconnect = vi.fn()
const observe = vi.fn()
vi.stubGlobal(
'IntersectionObserver',
class {
constructor(callback: IntersectionObserverCallback) {
intersection = callback
}
observe = observe
disconnect = disconnect
}
)
vi.stubGlobal(
'ResizeObserver',
class {
constructor(callback: ResizeObserverCallback) {
resize = callback
}
observe() {}
unobserve() {}
disconnect = disconnect
}
)
vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => {
frame = callback
return 1
})
vi.stubGlobal('cancelAnimationFrame', () => {
frame = undefined
})
const flush = () =>
act(() => {
const run = frame
frame = undefined
run?.(0)
})
const options = {
scrollRef: { current: viewport },
contentRef: { current: content },
paneVisible: true,
rows: 'initial'
}
const { rerender } = renderHook(props => useStickyPromptClip(props), { initialProps: options })
expect(observe).toHaveBeenCalledWith(skipped.group)
act(() =>
intersection(
[
{
target: visible.group,
isIntersecting: true,
intersectionRatio: 1,
boundingClientRect: rect(0, 900),
intersectionRect: rect(0, 500),
rootBounds: rect(0, 500),
time: 0
}
],
{} as IntersectionObserver
)
)
flush()
expect(visible.reply.style.getPropertyValue('--sticky-prompt-clip')).toBe('75px')
expect(visible.attachments.style.getPropertyValue('--sticky-prompt-clip')).toBe('40px')
expect(visible.prompt.hasAttribute('data-sticky-prompt-clip')).toBe(false)
expect(skipped.promptRect).not.toHaveBeenCalled()
expect(skipped.replyRect).not.toHaveBeenCalled()
// A new message or backfill changes rows while the same prompt is pinned.
// Its existing mask must survive the commit, before any observer/rAF delivery.
rerender({ ...options, rows: 'appended' })
expect(visible.reply.style.getPropertyValue('--sticky-prompt-clip')).toBe('75px')
expect(disconnect).not.toHaveBeenCalled()
visible.promptRect.mockReturnValue(rect(5, 120))
act(() => resize([], {} as ResizeObserver))
flush()
expect(visible.reply.style.getPropertyValue('--sticky-prompt-clip')).toBe('135px')
visible.promptRect.mockReturnValue(rect(80, 120))
act(() => viewport.dispatchEvent(new Event('scroll')))
flush()
expect(visible.reply.hasAttribute('data-sticky-prompt-clip')).toBe(false)
expect(visible.attachments.hasAttribute('data-sticky-prompt-clip')).toBe(false)
visible.promptRect.mockReturnValue(rect(5, 120))
act(() => viewport.dispatchEvent(new Event('scroll')))
flush()
expect(visible.reply.hasAttribute('data-sticky-prompt-clip')).toBe(true)
rerender({ ...options, paneVisible: false })
expect(visible.reply.style.getPropertyValue('--sticky-prompt-clip')).toBe('')
expect(disconnect).toHaveBeenCalledTimes(2)
})
@@ -0,0 +1,221 @@
import { type ReactNode, type RefObject, useLayoutEffect, useRef } from 'react'
const GROUP = '[data-slot="aui_message-group"]'
const PROMPT = '[data-slot="aui_user-message-root"]'
const CLIP = '--sticky-prompt-clip'
interface StickyPromptClipOptions {
contentRef: RefObject<HTMLElement | null>
scrollRef: RefObject<HTMLElement | null>
paneVisible: boolean
rows: ReactNode
}
/** Clip covered content instead of painting a solid rectangle over the glass. */
export function useStickyPromptClip({ contentRef, scrollRef, paneVisible, rows }: StickyPromptClipOptions) {
const controller = useRef<ReturnType<typeof observeStickyPromptClip> | null>(null)
useLayoutEffect(() => {
const viewport = scrollRef.current
const content = contentRef.current
const previous = controller.current
if (!paneVisible || previous?.viewport !== viewport || previous?.content !== content) {
previous?.dispose()
controller.current = null
}
if (paneVisible && viewport && content) {
controller.current ??= observeStickyPromptClip(viewport, content)
// Reconcile before paint without tearing down surviving clips when a
// stream appends a message or the history window changes its rows.
controller.current.reconcile()
}
}, [contentRef, scrollRef, paneVisible, rows])
useLayoutEffect(
() => () => {
controller.current?.dispose()
controller.current = null
},
[]
)
}
function observeStickyPromptClip(viewport: HTMLElement, content: HTMLElement) {
const observed = new Set<HTMLElement>()
const visible = new Set<HTMLElement>()
const clipped = new Set<HTMLElement>()
let frame = 0
const measure = () => {
frame = 0
const viewportTop = viewport.getBoundingClientRect().top
const next = new Map<HTMLElement, number>()
let activePrompt: HTMLElement | null = null
let exclusionBottom = viewportTop
for (const group of visible) {
// Read only intersecting groups: measuring a skipped turn's descendants
// would defeat content-visibility and wake the entire transcript.
const prompt = group.querySelector<HTMLElement>(PROMPT)
if (!prompt) {
continue
}
const promptRect = prompt.getBoundingClientRect()
const stickyTop = Number.parseFloat(getComputedStyle(prompt).top) || 0
if (promptRect.top > viewportTop + stickyTop + 1 || promptRect.bottom <= viewportTop) {
continue
}
// During the handoff, the later prompt supersedes the one being pushed
// out. Its exclusion region also covers preceding turns in the top gap.
if (!activePrompt || activePrompt.compareDocumentPosition(prompt) & Node.DOCUMENT_POSITION_FOLLOWING) {
activePrompt = prompt
exclusionBottom = promptRect.bottom
}
}
if (activePrompt) {
// Follow only the ancestor path to the active prompt. Other groups can
// be clipped whole, including old prompts and standalone assistant rows.
const collect = (element: HTMLElement) => {
if (element === activePrompt) {
return
}
if (element.contains(activePrompt)) {
for (const child of element.children) {
if (child instanceof HTMLElement) {
collect(child)
}
}
return
}
const rect = element.getBoundingClientRect()
if (rect.height > 0 && rect.top < exclusionBottom) {
next.set(element, Math.min(rect.height, exclusionBottom - rect.top))
}
}
for (const group of visible) {
// Keep the IO target itself unmasked; clipping it would change its
// intersection and oscillate between hiding and revealing the group.
for (const child of group.children) {
if (child instanceof HTMLElement) {
collect(child)
}
}
}
}
// All geometry reads precede writes. Clipping changes no layout, scroll
// position, or React state; only covered siblings get a style update.
for (const element of clipped) {
if (!next.has(element)) {
element.style.removeProperty(CLIP)
element.removeAttribute('data-sticky-prompt-clip')
}
}
clipped.clear()
for (const [element, inset] of next) {
const value = `${inset}px`
if (element.style.getPropertyValue(CLIP) !== value) {
element.style.setProperty(CLIP, value)
element.setAttribute('data-sticky-prompt-clip', '')
}
clipped.add(element)
}
}
const schedule = () => {
if (!frame) {
frame = requestAnimationFrame(measure)
}
}
const sizes = new ResizeObserver(schedule)
const intersections = new IntersectionObserver(
entries => {
for (const entry of entries) {
const group = entry.target as HTMLElement
if (entry.isIntersecting) {
visible.add(group)
sizes.observe(group)
} else {
visible.delete(group)
sizes.unobserve(group)
}
}
schedule()
},
{ root: viewport }
)
const reconcile = () => {
const groups = new Set(content.querySelectorAll<HTMLElement>(GROUP))
const viewportRect = viewport.getBoundingClientRect()
for (const group of observed) {
if (!groups.has(group)) {
intersections.unobserve(group)
sizes.unobserve(group)
observed.delete(group)
visible.delete(group)
}
}
for (const group of groups) {
if (!observed.has(group)) {
intersections.observe(group)
observed.add(group)
}
// A row commit can rehome a turn before IO delivers. Measure only outer
// boxes here; never force layout inside content-visibility's skipped rows.
const rect = group.getBoundingClientRect()
if (rect.bottom > viewportRect.top && rect.top < viewportRect.bottom) {
visible.add(group)
sizes.observe(group)
} else {
visible.delete(group)
sizes.unobserve(group)
}
}
cancelAnimationFrame(frame)
measure()
}
sizes.observe(viewport)
sizes.observe(content)
viewport.addEventListener('scroll', schedule, { passive: true })
const dispose = () => {
intersections.disconnect()
sizes.disconnect()
viewport.removeEventListener('scroll', schedule)
cancelAnimationFrame(frame)
for (const element of clipped) {
element.style.removeProperty(CLIP)
element.removeAttribute('data-sticky-prompt-clip')
}
}
return { viewport, content, reconcile, dispose }
}
@@ -41,8 +41,7 @@ export function StickyHumanMessageContainer({
// while attachments below it scroll away.
<>
<div
className="group/user-message sticky z-40 -mx-4 flex w-[calc(100%+2rem)] min-w-0 max-w-none flex-col items-stretch gap-0 self-end overflow-visible bg-(--ui-chat-surface-background) px-4 pb-(--conversation-turn-gap) pt-1"
data-glass-opaque=""
className="group/user-message sticky z-40 -mx-4 flex w-[calc(100%+2rem)] min-w-0 max-w-none flex-col items-stretch gap-0 self-end overflow-visible px-4 pb-(--conversation-turn-gap) pt-1"
data-message-id={messageId}
data-role="user"
data-slot="aui_user-message-root"
@@ -5,9 +5,9 @@ import { PaneTab, PaneTabLabel } from './pane-tab'
afterEach(cleanup)
/** The tab shell's own classes (the label's grandparent), split for set diffs. */
/** The tab shell's own classes, independent of its label's internal layout. */
const classesOf = (label: string): string[] =>
screen.getByText(label).parentElement!.parentElement!.className.split(/\s+/).filter(Boolean)
screen.getByText(label).closest('[data-slot="pane-tab"]')!.className.split(/\s+/).filter(Boolean)
describe('PaneTab close gestures', () => {
it('middle-click closes — pointer events only, no auxclick', () => {
+22 -42
View File
@@ -12,32 +12,23 @@ import { cn } from '@/lib/utils'
export const PANE_TAB_STRIP_LINE_LEFT = 'shadow-[inset_1px_0_0_var(--ui-stroke-tertiary)]'
export const PANE_TAB_STRIP_LINE_RIGHT = 'shadow-[inset_-1px_0_0_var(--ui-stroke-tertiary)]'
// `--tab-face` is the tab's EFFECTIVE surface color — what actually sits under
// the label after every wash lands. The hover close-button gradient fades into
// it, so the fade is seamless on any theme. Idle hover repaints it below with
// the same color-mix the darkening wash applies.
//
// `--tab-bg` is the base every wash mixes onto. Under Glass the surface tokens
// are transparent and the <body> field is what shows through a tab, so the
// base prefers `--glass-field` (styles.css sets it only under glass, on
// `data-glass-field` declarers) and falls back to the tab's own surface token.
// Surface tokens become transparent under glass; the body owns the tint.
// The close-button fade masks the label, so it needs no second surface fill.
const TAB =
'group/tab relative flex shrink-0 items-center border-transparent bg-(--tab-bg) text-[0.6875rem] font-medium [-webkit-app-region:no-drag] [--tab-face:var(--tab-bg)] [--tab-bg:var(--glass-field,var(--tab-surface))]'
'group/tab relative flex shrink-0 items-center border-transparent bg-(--tab-bg) text-[0.6875rem] font-medium [-webkit-app-region:no-drag]'
// Full height: with the strip's rule removed there is no last-pixel row to
// leave uncovered, so tabs fill the bar and no sliver of gutter shows through.
const TAB_HORIZONTAL = 'h-full min-w-0 max-w-48 not-first:border-l not-first:border-l-(--ui-stroke-quaternary)'
// A closeable tab's floor: 8px label inset + the ~19px opaque ✕ chip, so the
// shortest labels (FILES, REVIEW) clear the chip and pass under nothing but the
// gradient. A floor, not padding — a tab already wider than it pays nothing.
// A closeable tab's floor keeps short labels left of the close button.
// A floor, not padding — a tab already wider than it pays nothing.
const TAB_CLOSEABLE = 'min-w-13'
const TAB_VERTICAL =
'w-full max-h-48 justify-center not-first:border-t not-first:border-t-(--ui-stroke-quaternary) [writing-mode:vertical-rl]'
const TAB_ACTIVE =
'h-full text-foreground [--tab-surface:var(--pane-tab-active-bg,var(--ui-editor-surface-background))]'
const TAB_ACTIVE = 'h-full text-foreground [--tab-bg:var(--pane-tab-active-bg,var(--ui-editor-surface-background))]'
// Horizontal only: the active tab is the sole seam on the strip — a
// theme-primary underline drawn as an inset shadow in its own last pixel row,
@@ -47,23 +38,21 @@ const TAB_ACTIVE_UNDERLINE = 'shadow-[inset_0_-2px_0_var(--pane-tab-active-accen
// Inactive = gutter, defaulting to the shared chrome surface so a strip that
// sets no vars still matches the sidebar/titlebar instead of falling through to
// the raw (unmixed) card seed. Hover DARKENS: surfaces this close in value need
// a darkening wash to register at all. `--tab-face` tracks the wash — the same
// mix flattened onto `--tab-bg` — so the close gradient matches what shows.
// a darkening wash to register at all.
const TAB_IDLE =
'text-(--ui-text-tertiary) [--tab-surface:var(--pane-tab-strip-bg,var(--ui-sidebar-surface-background))] hover:shadow-[inset_0_0_0_100vmax_color-mix(in_srgb,#000_var(--ui-tab-hover-darken),transparent)] hover:[--tab-face:color-mix(in_srgb,#000_var(--ui-tab-hover-darken),var(--tab-bg))] hover:text-(--ui-text-secondary)'
'text-(--ui-text-tertiary) [--tab-bg:var(--pane-tab-strip-bg,var(--ui-sidebar-surface-background))] hover:shadow-[inset_0_0_0_100vmax_color-mix(in_srgb,#000_var(--ui-tab-hover-darken),transparent)] hover:text-(--ui-text-secondary)'
// A tab riding a multi-tab selection: an accent wash over whatever surface the
// tab sits on. A background-image gradient (not a shadow) so it stacks cleanly
// over `--tab-bg` without fighting the active underline / hover shadows.
// `--tab-face` gets the same wash flattened in, keeping the close fade honest.
const TAB_SELECTED =
'[background-image:linear-gradient(color-mix(in_srgb,var(--ui-accent)_14%,transparent),color-mix(in_srgb,var(--ui-accent)_14%,transparent))] [--tab-face:color-mix(in_srgb,var(--ui-accent)_14%,var(--tab-bg))] text-foreground'
'[background-image:linear-gradient(color-mix(in_srgb,var(--ui-accent)_14%,transparent),color-mix(in_srgb,var(--ui-accent)_14%,transparent))] text-foreground'
interface PaneTabProps extends React.ComponentProps<'div'> {
active?: boolean
dirty?: boolean
/** Close verb. Horizontal tabs reveal a hover ✕ on the right (a `--tab-face`
* gradient fades it over the label); middle-click and ⌘-click always work,
/** Close verb. Horizontal tabs reveal a hover ✕ over the label's masked
* right edge; middle-click and ⌘-click always work,
* and stay the only gestures on vertical rails (no room for a chip ✕).
* There is no way to take the ✕ off a tab that HAS this verb: the chip and
* the pointer gestures are one affordance, so a closeable tab always says
@@ -123,8 +112,8 @@ export const PaneTab = React.forwardRef<HTMLDivElement, PaneTabProps>(function P
)}
data-active={active}
data-closeable={(onClose && !vertical) || undefined}
data-glass-field=""
data-selected={selected || undefined}
data-slot="pane-tab"
data-vertical={vertical || undefined}
onClickCapture={event => {
// Sites whose tab activates on the label's own onClick (the preview
@@ -164,12 +153,16 @@ export const PaneTab = React.forwardRef<HTMLDivElement, PaneTabProps>(function P
ref={ref}
{...props}
>
{children}
<div
className={cn('pane-tab-content flex h-full min-w-0 max-w-full flex-1 items-center', vertical && 'contents')}
>
{children}
</div>
{dirty && (
<span
aria-hidden
className={cn(
'pointer-events-none absolute grid size-4 place-items-center',
'pointer-events-none absolute grid size-4 place-items-center group-hover/tab:group-data-[closeable]/tab:opacity-0',
vertical ? 'bottom-1.5 left-1/2 -translate-x-1/2' : 'right-1.5 top-1/2 -translate-y-1/2'
)}
>
@@ -177,24 +170,12 @@ export const PaneTab = React.forwardRef<HTMLDivElement, PaneTabProps>(function P
</span>
)}
{onClose && !vertical && (
// Hover ✕, painted OVER the label's right edge as an overlay (no
// layout shift, tab width never jumps on hover). The runway is a tiny
// transparent→`--tab-face` gradient, so the button melts into the
// tab's effective surface instead of hard-clipping the text under it.
// Short labels are kept legible by TAB_CLOSEABLE, not by padding.
// Rendered after the dirty dot: on hover the ✕ takes the dot's spot,
// VS Code-style.
// Mask the content beneath the close button instead of painting over it.
// Geometry stays fixed; the same fade works on solid and glass surfaces.
<span className="pointer-events-none absolute inset-y-0 right-0 flex items-stretch opacity-0 transition-opacity group-hover/tab:pointer-events-auto group-hover/tab:opacity-100">
{/* Both pieces re-draw the active underline: they paint over the
tab's own last-pixel row, so without it the ✕ would bite a
notch out of the accent line on the active tab. */}
<span
aria-hidden
className="w-4 bg-linear-to-r from-transparent to-(--tab-face) group-data-[active=true]/tab:shadow-[inset_0_-2px_0_var(--pane-tab-active-accent,var(--theme-primary))]"
/>
<button
aria-label={translateNow('common.close')}
className="grid cursor-pointer place-items-center bg-(--tab-face) pr-1.5 pl-0.5 text-(--ui-text-tertiary) outline-none hover:text-foreground group-data-[active=true]/tab:shadow-[inset_0_-2px_0_var(--pane-tab-active-accent,var(--theme-primary))]"
className="grid w-(--pane-tab-close-width) cursor-pointer place-items-center bg-transparent text-(--ui-text-tertiary) outline-none hover:text-foreground"
onClick={event => {
event.preventDefault()
event.stopPropagation()
@@ -227,8 +208,7 @@ interface PaneTabLabelProps extends React.ComponentProps<'button'> {
/** Truncating label inside a `PaneTab`. `className` merges into the text span
* (e.g. `normal-case tracking-normal` for filenames). On a closeable tab the
* text clips instead of ellipsizing, so it runs under the hover ✕ fade rather
* than stopping short of it with dots. */
* text clips instead of ellipsizing, so the hover mask can fade its right edge. */
export const PaneTabLabel = React.forwardRef<HTMLElement, PaneTabLabelProps>(function PaneTabLabel(
{ as = 'span', className, children, ...props },
ref
+18 -20
View File
@@ -622,16 +622,6 @@
background: color-mix(in srgb, var(--ui-bg-chrome) var(--translucency-glass-keep, 100%), transparent);
}
/* A surface that FADES INTO the field (a pane tab's hover ✕ runway) declares
`data-glass-field` and reads `--glass-field` for its endpoint. Under glass
the surface tokens are transparent, so a fade aimed at one goes
transparent→transparent and the runway vanishes; the field is the <body>
mix above, so that is what a fade must land on. Inert with glass off:
the caller's own fallback (its surface token) applies. */
:root[data-hermes-glass] [data-glass-field] {
--glass-field: color-mix(in srgb, var(--ui-bg-chrome) var(--translucency-glass-keep, 100%), transparent);
}
/* Sidebar scope (the Finder shape): glass rail, opaque content column.
<body> stays the one painter but splits at the rail's visual edge — the
store publishes it as --glass-rail-edge (live-tracked through collapse
@@ -1524,6 +1514,20 @@ body.guest-pointer-lock :is(webview, iframe) {
width: 100%;
}
/* Remove label pixels under the hover close button; translucent paint would
stack the glass tint and still let the label bleed through. */
[data-slot='pane-tab'][data-closeable] {
--pane-tab-close-width: 1.5rem;
}
[data-slot='pane-tab'][data-closeable]:hover > .pane-tab-content {
mask-image: linear-gradient(
to right,
#000 calc(100% - var(--pane-tab-close-width) - 1rem),
transparent calc(100% - var(--pane-tab-close-width))
);
}
[data-slot='aui_assistant-message-content'] .aui-md,
[data-slot='aui_assistant-message-content'] .aui-md :where(p, li, blockquote, table, pre) {
font-size: inherit;
@@ -1602,16 +1606,10 @@ body.guest-pointer-lock :is(webview, iframe) {
top: var(--sticky-human-offset);
}
/* Cover the gap above the floating bubble with the thread's solid surface. */
[data-slot='aui_user-message-root']::before {
content: '';
position: absolute;
z-index: -1;
inset-inline: 0;
top: calc(-1 * var(--sticky-human-offset));
height: calc(var(--sticky-human-offset) + 1rem);
pointer-events: none;
background: var(--ui-chat-surface-background);
/* The sticky prompt owns no field paint. Clip only the scrolling siblings
behind it, including the top gap, so bubble transparency still works. */
[data-sticky-prompt-clip] {
clip-path: inset(var(--sticky-prompt-clip) 0 0);
}
[data-slot='aui_user-message-root'],
+14
View File
@@ -9,6 +9,7 @@ import { configure } from '@testing-library/react'
// Storage when the global resolves to nothing, before any test module reads it.
if (typeof (globalThis as any).localStorage === 'undefined') {
const store = new Map<string, string>()
const storage: Storage = {
get length() {
return store.size
@@ -19,6 +20,7 @@ if (typeof (globalThis as any).localStorage === 'undefined') {
removeItem: (k: string) => void store.delete(String(k)),
clear: () => store.clear(),
}
for (const target of [globalThis, (globalThis as any).window].filter(Boolean)) {
Object.defineProperty(target, 'localStorage', {
value: storage,
@@ -28,6 +30,18 @@ if (typeof (globalThis as any).localStorage === 'undefined') {
}
}
// jsdom has no layout or intersection delivery. Tests of observer behavior
// supply their own callbacks; ordinary component tests only need the lifecycle.
globalThis.IntersectionObserver = class {
readonly root = null
readonly rootMargin = '0px'
readonly thresholds = [0]
observe() {}
unobserve() {}
disconnect() {}
takeRecords(): IntersectionObserverEntry[] { return [] }
} as typeof IntersectionObserver
// React 19 + Testing Library 16: opt into the act environment so render(),
// fireEvent(), and findBy* queries automatically flush state updates without
// spurious "not wrapped in act(...)" warnings.