fix(desktop): restore flat glass surfaces without text bleed
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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
@@ -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'],
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user