diff --git a/apps/desktop/DESIGN.md b/apps/desktop/DESIGN.md index 845b36de62..9b7e4097a4 100644 --- a/apps/desktop/DESIGN.md +++ b/apps/desktop/DESIGN.md @@ -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 diff --git a/apps/desktop/e2e/glass-surfaces.spec.ts b/apps/desktop/e2e/glass-surfaces.spec.ts new file mode 100644 index 0000000000..e346e3f8bc --- /dev/null +++ b/apps/desktop/e2e/glass-surfaces.spec.ts @@ -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('.pane-tab-content')! + const button = el.querySelector('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('.pane-tab-content')!.style.removeProperty('background') + el.querySelector('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('[data-slot="aui_assistant-message-root"]')! + const read = () => getComputedStyle(reply).clipPath + const before = read() + document.querySelector('[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) +}) diff --git a/apps/desktop/scripts/fixtures/glass-surfaces.html b/apps/desktop/scripts/fixtures/glass-surfaces.html new file mode 100644 index 0000000000..89654c1915 --- /dev/null +++ b/apps/desktop/scripts/fixtures/glass-surfaces.html @@ -0,0 +1,3 @@ + +Glass surface regression fixture +
diff --git a/apps/desktop/scripts/fixtures/glass-surfaces.tsx b/apps/desktop/scripts/fixtures/glass-surfaces.tsx new file mode 100644 index 0000000000..9ca707af52 --- /dev/null +++ b/apps/desktop/scripts/fixtures/glass-surfaces.tsx @@ -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 ( + + Attachment + + } + > + + + ) +} + +function Assistant() { + return ( + +
+ Scrolling reply +
+
+ ) +} + +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({ + messages: currentMessages, + isRunning: false, + onNew: async () => {}, + onEdit: async () => {} + }) + + const realMessages = new URLSearchParams(location.search).has('real') + + return ( + +
+ +
+ {realMessages ? : } +
+
+
+ ) +} + +createRoot(document.getElementById('root')!).render( + + + + +
+ + document.body.setAttribute('data-closed', 'true')} + > + document.body.setAttribute('data-activated', 'true')}> + A long session title that reaches underneath the close button + + + {}}> + Another long session title with unsaved changes + + + Sessions + + {}}> + X + + {}} selected> + Selected + + +
+ + +
+
+
+
+
+
+) diff --git a/apps/desktop/src/components/assistant-ui/thread/list.tsx b/apps/desktop/src/components/assistant-ui/thread/list.tsx index 299c92b190..ec593f8af3 100644 --- a/apps/desktop/src/components/assistant-ui/thread/list.tsx +++ b/apps/desktop/src/components/assistant-ui/thread/list.tsx @@ -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['components'] @@ -1145,6 +1146,7 @@ const ThreadMessageListInner: FC = ({ ) useMessagesBelow({ contentRef, scrollRef, isAtBottom, paneVisible, rows, sessionKey }) + useStickyPromptClip({ contentRef, scrollRef, paneVisible, rows }) return (
({ 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) +}) diff --git a/apps/desktop/src/components/assistant-ui/thread/use-sticky-prompt-clip.ts b/apps/desktop/src/components/assistant-ui/thread/use-sticky-prompt-clip.ts new file mode 100644 index 0000000000..fdfb8a6301 --- /dev/null +++ b/apps/desktop/src/components/assistant-ui/thread/use-sticky-prompt-clip.ts @@ -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 + scrollRef: RefObject + 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 | 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() + const visible = new Set() + const clipped = new Set() + let frame = 0 + + const measure = () => { + frame = 0 + const viewportTop = viewport.getBoundingClientRect().top + const next = new Map() + 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(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(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 } +} diff --git a/apps/desktop/src/components/assistant-ui/thread/user-message.tsx b/apps/desktop/src/components/assistant-ui/thread/user-message.tsx index 319a4c8736..2ca36ec8f6 100644 --- a/apps/desktop/src/components/assistant-ui/thread/user-message.tsx +++ b/apps/desktop/src/components/assistant-ui/thread/user-message.tsx @@ -41,8 +41,7 @@ export function StickyHumanMessageContainer({ // while attachments below it scroll away. <>
- 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', () => { diff --git a/apps/desktop/src/components/ui/pane-tab.tsx b/apps/desktop/src/components/ui/pane-tab.tsx index ebaf755f48..b23816e5ec 100644 --- a/apps/desktop/src/components/ui/pane-tab.tsx +++ b/apps/desktop/src/components/ui/pane-tab.tsx @@ -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 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(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(function P ref={ref} {...props} > - {children} +
+ {children} +
{dirty && ( @@ -177,24 +170,12 @@ export const PaneTab = React.forwardRef(function P )} {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. - {/* 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. */} -