From d72f85a0adf4010f22f48d50c32784d513b7ae32 Mon Sep 17 00:00:00 2001 From: Yif <877193178@qq.com> Date: Sun, 20 Sep 2026 20:41:48 +0800 Subject: [PATCH 1/3] fix(desktop): paint an opaque base while minimized to cover the vibrancy reattach gap --- apps/desktop/src/main.ts | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index 7531a7cfa9..75fe3ab47c 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -154,6 +154,26 @@ function createWindow(preload: string, show = false, primary = false): BrowserWi // Reloads and navigations re-register the preload listener; resend the // current state so a fullscreen reload does not fall back to windowed CSS. window.webContents.on('did-finish-load', sendFullscreen) + // Deminiaturize reattaches the NSVisualEffectView material late + // (electron/electron#25368), so a transparent window shows the desktop + // through the sidebar until then. Paint an opaque base while minimized or + // hidden so the deminiaturize animation and the reattachment gap show a + // solid fill; restoring flips back, and the null -> 'sidebar' transition + // forces the material to reattach. + const applyBackdrop = (): void => { + if (window.isDestroyed()) return + if (window.isMinimized() || !window.isVisible()) { + window.setVibrancy(null) + window.setBackgroundColor(nativeTheme.shouldUseDarkColors ? '#1b1b1c' : '#f9fafb') + } else { + window.setVibrancy('sidebar') + window.setBackgroundColor('#00000000') + } + } + window.on('minimize', applyBackdrop) + window.on('hide', applyBackdrop) + window.on('restore', applyBackdrop) + window.on('show', applyBackdrop) } window.webContents.on('context-menu', (_event, { isEditable, selectionText, editFlags }) => { const items: MenuItemConstructorOptions[] = [] From 44fe86eaf8cd5be39d0af4b168b420027faf0df0 Mon Sep 17 00:00:00 2001 From: Yif <877193178@qq.com> Date: Mon, 21 Sep 2026 13:02:45 +0800 Subject: [PATCH 2/3] test(desktop): cover the minimize backdrop swap and extract the chrome fallback fill --- apps/desktop/src/main.ts | 15 ++++++- apps/desktop/tests/main-startup.spec.ts | 55 +++++++++++++++++++++++-- 2 files changed, 65 insertions(+), 5 deletions(-) diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index 75fe3ab47c..9b2d263471 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -108,6 +108,17 @@ function developmentHostInspectPort(enabled: boolean): number | undefined { return port } +/** + * Opaque chrome fallback matching the built-in sidebar palette (the resolved + * `--dsw-static-neutral-bluish-900` / `-50` tokens). An approximation for + * custom themes: Windows swaps in the renderer's measured palette over the + * windowsAppearance IPC, and macOS shows it only while minimized or hidden. + * @returns the sidebar fill hex for the active system color scheme. + */ +function chromeFallbackFill(): string { + return nativeTheme.shouldUseDarkColors ? '#1b1b1c' : '#f9fafb' +} + function createWindow(preload: string, show = false, primary = false): BrowserWindow { const window = new BrowserWindow({ width: 1280, @@ -117,7 +128,7 @@ function createWindow(preload: string, show = false, primary = false): BrowserWi show, ...(process.platform === 'win32' && primary ? { titleBarStyle: 'hidden' as const, - titleBarOverlay: { height: WINDOWS_TITLEBAR_HEIGHT, color: nativeTheme.shouldUseDarkColors ? '#1b1b1c' : '#f9fafb', + titleBarOverlay: { height: WINDOWS_TITLEBAR_HEIGHT, color: chromeFallbackFill(), symbolColor: nativeTheme.shouldUseDarkColors ? '#f9fafb' : '#0f1115' }, } : {}), // hiddenInset places traffic lights inside the sidebar; sidebar vibrancy @@ -164,7 +175,7 @@ function createWindow(preload: string, show = false, primary = false): BrowserWi if (window.isDestroyed()) return if (window.isMinimized() || !window.isVisible()) { window.setVibrancy(null) - window.setBackgroundColor(nativeTheme.shouldUseDarkColors ? '#1b1b1c' : '#f9fafb') + window.setBackgroundColor(chromeFallbackFill()) } else { window.setVibrancy('sidebar') window.setBackgroundColor('#00000000') diff --git a/apps/desktop/tests/main-startup.spec.ts b/apps/desktop/tests/main-startup.spec.ts index 8547bc28fe..13d646546f 100644 --- a/apps/desktop/tests/main-startup.spec.ts +++ b/apps/desktop/tests/main-startup.spec.ts @@ -72,13 +72,18 @@ const harness = await vi.hoisted(async () => { readonly restore = vi.fn() readonly setSize = vi.fn() readonly setTitleBarOverlay = vi.fn() + readonly setVibrancy = vi.fn() + readonly setBackgroundColor = vi.fn() constructor(readonly options: { show: boolean; modal?: boolean }) { super(); if (windowFailure !== undefined) throw windowFailure; windows.push(this) } isDestroyed() { return this.destroyed } fullscreen = false isFullScreen() { return this.fullscreen } - isMinimized() { return false } + minimized = false + isMinimized() { return this.minimized } + visible = true + isVisible() { return this.visible } isFocused() { return true } async loadURL(url: string) { this.urls.push(url) @@ -136,9 +141,10 @@ const harness = await vi.hoisted(async () => { } }), }) + const nativeTheme = { themeSource: 'system', shouldUseDarkColors: false } return { failWindow(error: Error) { windowFailure = error }, - windows, hosts, handlers, app, FakeWindow, FakeHost, powerMonitor, + windows, hosts, handlers, app, FakeWindow, FakeHost, powerMonitor, nativeTheme, menu, popup, socketHeaders: vi.fn(), updateCheck, updateDownload, updateInstall, ipcOn: vi.fn<(channel: string, listener: (event: { sender: unknown; senderFrame: unknown }, ...args: unknown[]) => void) => void>(), get updateState() { return updateState }, @@ -179,6 +185,7 @@ const harness = await vi.hoisted(async () => { updateCheck.mockReset().mockImplementation(async () => updateState) updateDownload.mockReset().mockImplementation(async () => updateState) updateInstall.mockReset().mockImplementation(async () => updateState) + nativeTheme.themeSource = 'system'; nativeTheme.shouldUseDarkColors = false preparing = deferred(); prepared = deferred(); hostStarted = deferred() navigated = deferred(); dialogShown = deferred(); quitCompleted = deferred() policyBlocked = deferred() @@ -201,7 +208,7 @@ vi.mock('electron', () => ({ BrowserWindow: harness.FakeWindow, dialog: harness.dialog, shell: { openExternal: harness.openExternal }, - nativeTheme: { themeSource: 'system' }, + nativeTheme: harness.nativeTheme, ipcMain: { on: harness.ipcOn, handle: (channel: string, handler: InvokeHandler) => { @@ -589,6 +596,48 @@ describe('desktop main startup', () => { expect(window.webContents.send.mock.calls.filter(([channel]) => channel === DESKTOP_IPC.windowFullscreen)).toHaveLength(0) }) + it('covers the macOS vibrancy reattach gap with an opaque base while minimized or hidden', async () => { + vi.spyOn(process, 'platform', 'get').mockReturnValue('darwin') + await import('../src/main.ts') + await harness.preparing.promise + const window = harness.windows[0]! + // Minimizing drops the vibrancy material and paints the theme's opaque fill. + window.minimized = true + window.emit('minimize') + expect(window.setVibrancy).toHaveBeenLastCalledWith(null) + expect(window.setBackgroundColor).toHaveBeenLastCalledWith('#f9fafb') + // Restoring re-requests the material and returns to the transparent base. + window.minimized = false + window.emit('restore') + expect(window.setVibrancy).toHaveBeenLastCalledWith('sidebar') + expect(window.setBackgroundColor).toHaveBeenLastCalledWith('#00000000') + // Hiding under the dark palette picks the dark opaque fill. + harness.nativeTheme.shouldUseDarkColors = true + window.visible = false + window.emit('hide') + expect(window.setVibrancy).toHaveBeenLastCalledWith(null) + expect(window.setBackgroundColor).toHaveBeenLastCalledWith('#1b1b1c') + window.visible = true + window.emit('show') + expect(window.setBackgroundColor).toHaveBeenLastCalledWith('#00000000') + // A destroyed window ends the backdrop updates. + const applied = window.setVibrancy.mock.calls.length + window.destroyed = true + window.emit('minimize') + expect(window.setVibrancy.mock.calls).toHaveLength(applied) + }) + + it.each(['win32', 'linux'] as const)('registers no backdrop swap on %s', async (platform) => { + vi.spyOn(process, 'platform', 'get').mockReturnValue(platform) + await import('../src/main.ts') + await harness.preparing.promise + const window = harness.windows[0]! + window.minimized = true + window.emit('minimize') + expect(window.setVibrancy).not.toHaveBeenCalled() + expect(window.setBackgroundColor).not.toHaveBeenCalled() + }) + it('follows the Windows primary document language and palette without trusting other frames', async () => { vi.spyOn(process, 'platform', 'get').mockReturnValue('win32') await import('../src/main.ts') From b4886c7e5bf3a2a12577fa6eac6725b54bca6c6d Mon Sep 17 00:00:00 2001 From: Yif <877193178@qq.com> Date: Mon, 21 Sep 2026 13:02:48 +0800 Subject: [PATCH 3/3] feat(workspace): marquee hovered session titles with fades on both cut edges --- .../tests/sidebar-title-hover-scroll.e2e.ts | 67 ++++--- .../src/client/rows/Rows.module.css | 35 ++-- .../ui-workspace/src/client/rows/Rows.tsx | 99 +++++++--- .../tests/browser-styles.client.spec.ts | 17 +- .../ui-workspace/tests/rows.client.spec.tsx | 182 ++++++++++++------ 5 files changed, 284 insertions(+), 116 deletions(-) diff --git a/apps/web/tests/sidebar-title-hover-scroll.e2e.ts b/apps/web/tests/sidebar-title-hover-scroll.e2e.ts index 2a0fe8caf4..5f797f19e2 100644 --- a/apps/web/tests/sidebar-title-hover-scroll.e2e.ts +++ b/apps/web/tests/sidebar-title-hover-scroll.e2e.ts @@ -1,9 +1,10 @@ -// Web e2e scenario: hovering a sidebar session row reveals a title wider than -// its cell. The jsdom lane pins only the scroll position the handler writes; -// the assembled browser is where the title really clips, really reaches its own -// hovered extent (the trailing relative-time cell yields to the row menu while -// the pointer rests on the row, so the hovered cell is wider than the resting -// one), and really returns to its start when the pointer leaves. +// Web e2e scenario: hovering a sidebar session row marquees a title wider than +// its cell. The jsdom lane pins only the positions the handler writes; the +// assembled browser is where the title really clips, really crawls toward its +// own hovered extent (the trailing relative-time cell yields to the row menu +// while the pointer rests on the row, so the hovered cell is wider than the +// resting one), really fades its moved left edge, and really returns to its +// start when the pointer leaves. // // Zero model calls: seeding one renamed session and hovering its row touches no // provider. @@ -16,10 +17,10 @@ import { launchWebScaffold, seedSession, watchConsole, type WebScaffold } from ' import { newEnglishPage, saveFailureShot } from './support.ts' const SEED = fileURLToPath(new URL('../../../snapshots/web/seeded-history/session.v3.jsonl', import.meta.url)) -/** Wider than the sidebar cell, under the 80-byte title limit, with a far-edge suffix the reveal must reach. */ +/** Wider than the sidebar cell, under the 80-byte title limit, with a far-edge suffix the marquee must reach. */ const TITLE = 'Forked session clipped before its suffix (1)' -describe('web e2e: hovering a clipped session title scrolls it to its far edge', () => { +describe('web e2e: hovering a clipped session title marquees it to its far edge', () => { let scaffold: WebScaffold let browser: Browser let page: Page @@ -43,7 +44,7 @@ describe('web e2e: hovering a clipped session title scrolls it to its far edge', await scaffold?.close() }) - it('reveals the clipped title under the pointer and restores its start after', async () => { + it('crawls the clipped title under the pointer and restores its start after', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-sidebar-title-hover-scroll')) const row = page.getByRole('treeitem').filter({ has: page.getByText(TITLE, { exact: true }) }) await row.waitFor({ timeout: 20_000 }) @@ -53,34 +54,56 @@ describe('web e2e: hovering a clipped session title scrolls it to its far edge', expect(await title.evaluate(el => el.scrollWidth - el.clientWidth)).toBeGreaterThan(0) expect(await title.evaluate(el => getComputedStyle(el).textOverflow)).toBe('ellipsis') expect(await title.evaluate(el => el.scrollLeft)).toBe(0) - expect(await title.evaluate(el => getComputedStyle(el).scrollBehavior)).toBe('smooth') await row.hover() - // Poll against the live extent: the reveal must reach the far edge of the - // hovered cell (the wider one — the relative-time label yields to the row - // menu), whatever width the handler itself read. + // The marquee crawls instead of jumping: the title is caught mid-travel, + // strictly between its start and its far edge, with both cut edges + // publishing their fade-mask hooks. + await expect.poll( + async () => title.evaluate(el => el.scrollLeft > 0), + { timeout: 5_000 }, + ).toBe(true) + expect(await title.evaluate(el => el.scrollLeft < el.scrollWidth - el.clientWidth - 1)).toBe(true) + expect(await title.evaluate(el => el.hasAttribute('data-scrolled'))).toBe(true) + expect(await title.evaluate(el => el.hasAttribute('data-clipped'))).toBe(true) + expect(await title.evaluate(el => getComputedStyle(el).maskImage)).toContain('linear-gradient') + // The crawl still reaches the far edge of the hovered cell (the wider + // one — the relative-time label yields to the row menu) and rests there, + // lifting the right fade so the final character reads at full strength. + // The fade lifts on the frame that lands exactly on the far edge, one or + // two frames after scrollLeft first rounds within a pixel of it. await expect.poll( async () => title.evaluate(el => el.scrollLeft >= el.scrollWidth - el.clientWidth - 1), - { timeout: 10_000 }, + { timeout: 20_000 }, ).toBe(true) - // Scrolled to its end, the title must not paint the ellipsis over the - // characters the reveal reached. + await expect.poll( + async () => title.evaluate(el => el.hasAttribute('data-clipped')), + { timeout: 5_000 }, + ).toBe(false) + // At its extent, the title must not paint the ellipsis over the + // characters the marquee reached. expect(await title.evaluate(el => getComputedStyle(el).textOverflow)).toBe('clip') - // Leaving returns in one step: a smooth return would still be travelling - // inside this window, and it would carry the resting ellipsis back with it. + // Leaving returns in one step: a crawl back would still be travelling + // inside this window, and it would carry the resting ellipsis with it. await page.mouse.move(0, 0) await expect.poll( async () => title.evaluate(el => el.scrollLeft), { timeout: 150, interval: 20 }, ).toBe(0) + expect(await title.evaluate(el => el.hasAttribute('data-scrolled'))).toBe(false) expect(await title.evaluate(el => getComputedStyle(el).textOverflow)).toBe('ellipsis') - // The reduce override reaches the real stylesheet, so the reveal jumps - // instead of gliding. + // Reduced motion reaches the handler's matchMedia probe: the reveal jumps + // to the far edge instead of crawling. await page.emulateMedia({ reducedMotion: 'reduce' }) - expect(await title.evaluate(el => getComputedStyle(el).scrollBehavior)).toBe('auto') + await row.hover() + await expect.poll( + async () => title.evaluate(el => el.scrollLeft >= el.scrollWidth - el.clientWidth - 1), + { timeout: 500, interval: 20 }, + ).toBe(true) + await page.mouse.move(0, 0) await page.emulateMedia({ reducedMotion: null }) expect(tripwire.pageErrors).toEqual([]) - }, 60_000) + }, 90_000) }) diff --git a/packages/client/ui-workspace/src/client/rows/Rows.module.css b/packages/client/ui-workspace/src/client/rows/Rows.module.css index 5a643c235a..26a6af0825 100644 --- a/packages/client/ui-workspace/src/client/rows/Rows.module.css +++ b/packages/client/ui-workspace/src/client/rows/Rows.module.css @@ -182,13 +182,31 @@ outline: none; } -/* Hovering a session row reveals a title wider than its cell: Rows.tsx scrolls - it to its end while the pointer rests on the row — smoothly, through the - scroll-behavior here — and returns it to the start in one step when the - pointer leaves. */ +/* Hovering a session row marquees a title wider than its cell: Rows.tsx crawls + it at a constant speed to its far edge while the pointer rests on the row, + holds it there, and returns it to the start in one step when the pointer + leaves. The motion is scripted frame by frame (reduced motion jumps), so no + scroll-behavior applies here. */ .sessionRow .title { flex: 1; - scroll-behavior: smooth; +} + +/* While the marquee travels, both clipping edges fade in over 12px instead of + hard-cutting a character: the left once the title has moved off its start + (data-scrolled), the right while text still remains beyond the cell + (data-clipped) — at the far edge the right fade lifts so the final character + reads at full strength. The mask rides the title span itself, so the status + slot and every other leading cell keep their full color. */ +.sessionRow .title[data-scrolled] { + mask-image: linear-gradient(to right, transparent, #000 12px); +} + +.sessionRow .title[data-clipped] { + mask-image: linear-gradient(to left, transparent, #000 12px); +} + +.sessionRow .title[data-scrolled][data-clipped] { + mask-image: linear-gradient(to right, transparent, #000 12px, #000 calc(100% - 12px), transparent); } .meta { @@ -287,7 +305,7 @@ } /* The unclipped hover state drops the ellipsis, which would otherwise cover - the characters the scroll reveal reached. Guarded because a touch tap + the characters the marquee reached. Guarded because a touch tap latches `:hover` on the row after the title has already returned: an unguarded rule would strand that row without its ellipsis. */ @media (hover: hover) { @@ -418,9 +436,4 @@ animation: none; transition: none; } - - /* The title reveal jumps to the far edge instead of gliding there. */ - .sessionRow .title { - scroll-behavior: auto; - } } diff --git a/packages/client/ui-workspace/src/client/rows/Rows.tsx b/packages/client/ui-workspace/src/client/rows/Rows.tsx index ee35919e1f..a3cfa707de 100644 --- a/packages/client/ui-workspace/src/client/rows/Rows.tsx +++ b/packages/client/ui-workspace/src/client/rows/Rows.tsx @@ -2,7 +2,7 @@ * Workspace browser tree row components (figma Cell set 14:3080): pure presentational — * all data and callbacks arrive via props. Hover swaps (folder->chevron, * time->ellipsis, action buttons) are CSS-only, and a session row's clipped - * title is scrolled programmatically while the row is hovered. Workspace row + * title marquees programmatically while the row is hovered. Workspace row * menus are visual-only except Rename/Delete. A Session row's "..." menu and * its hover buttons are the `sidebar.workspaces.session.menu.item` and * `sidebar.workspaces.session.row.action` lists, rendered through the @@ -11,6 +11,7 @@ * session and workspace hover cards are suppressed while a menu is open. */ import { useEffect, useMemo, useRef, useState } from 'react' +import type { RefObject } from 'react' import clsx from 'clsx' import type { PropsRenderSlots } from '@deepseek-ai/dsh-client-ui-slots' import { @@ -37,31 +38,84 @@ function displayTitle(node: SessionNode, t: RowTranslate): string { accidental jitter, so the title stays put. */ const MIN_TITLE_REVEAL_PX = 8 +/* Marquee travel speed: slow enough to read the text as it passes. */ +const TITLE_MARQUEE_PX_PER_MS = 0.03 + /** - * Reveal a title wider than its one-line cell while its row is hovered: the - * title clips its own text, so the far edge (a fork's incremented title, for - * example) is reachable by scrolling the element to its end. Overflow of at - * most {@link MIN_TITLE_REVEAL_PX} stays put — a barely-clipped title moving a - * few pixels reads as jitter, not a reveal. Leaving returns the title to the - * start in one step, because the resting ellipsis and the narrowed cell would - * otherwise meet the text while it travelled back. The stylesheet decides - * whether either move glides or jumps. + * Place the title's scroll position and publish the stylesheet's fade-mask + * hooks: `data-scrolled` while the title has left its start (left fade) and + * `data-clipped` while text remains beyond the right edge (right fade). * @param title - the row's clipping title element. - * @param revealed - whether the pointer is on the row. + * @param left - scroll offset in CSS pixels. + * @param range - the title's maximum scroll offset in CSS pixels. */ -function revealClippedTitle(title: HTMLSpanElement | null, revealed: boolean): void { - /* v8 ignore next -- defensive: the title span renders unconditionally. */ - if (title === null) return - if (revealed) { - const range = title.scrollWidth - title.clientWidth - if (range <= MIN_TITLE_REVEAL_PX) return - title.scrollLeft = range - return - } +function placeTitle(title: HTMLSpanElement, left: number, range: number): void { // jsdom implements no scrollTo; the lane's direct assignment is instant there - // anyway, so both paths land on the same resting position. + // anyway, so both paths land on the same position. + if (typeof title.scrollTo === 'function') title.scrollTo({ left, behavior: 'instant' }) + else title.scrollLeft = left + if (left > 0) title.dataset.scrolled = '' + else delete title.dataset.scrolled + if (left < range) title.dataset.clipped = '' + else delete title.dataset.clipped +} + +/** + * Return the title to its resting state: scrolled to the start with both fade + * masks off, so the resting ellipsis renders at full strength. + * @param title - the row's clipping title element. + */ +function restTitle(title: HTMLSpanElement): void { if (typeof title.scrollTo === 'function') title.scrollTo({ left: 0, behavior: 'instant' }) else title.scrollLeft = 0 + delete title.dataset.scrolled + delete title.dataset.clipped +} + +/** + * Marquee a title wider than its one-line cell while its row is hovered: the + * title clips its own text, so entering crawls it at a constant speed until the + * far edge (a fork's incremented title, for example) is in view, then rests + * there under the pointer. Overflow of at most {@link MIN_TITLE_REVEAL_PX} + * stays put — a barely-clipped title moving a few pixels reads as jitter, not a + * reveal. Leaving returns the title to the start in one step, because the + * resting ellipsis and the narrowed cell would otherwise meet the text while it + * travelled back. Reduced motion jumps to the far edge instead of crawling. + * @param title - ref to the row's clipping title element. + * @returns stable pointer enter/leave handlers for the row. + */ +function useTitleMarquee(title: RefObject): { enter: () => void; leave: () => void } { + const frame = useRef(0) + useEffect(() => () => { cancelAnimationFrame(frame.current) }, []) + return useMemo(() => ({ + enter: (): void => { + /* v8 ignore next -- defensive: the title span renders unconditionally. */ + if (title.current === null) return + const element = title.current + const range = element.scrollWidth - element.clientWidth + if (range <= MIN_TITLE_REVEAL_PX) return + if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) { + placeTitle(element, range, range) + return + } + cancelAnimationFrame(frame.current) + let previous: number | undefined + let position = 0 + const step = (now: DOMHighResTimeStamp): void => { + position += previous === undefined ? 0 : (now - previous) * TITLE_MARQUEE_PX_PER_MS + previous = now + placeTitle(element, Math.min(position, range), range) + if (position < range) frame.current = requestAnimationFrame(step) + } + frame.current = requestAnimationFrame(step) + }, + leave: (): void => { + cancelAnimationFrame(frame.current) + /* v8 ignore next -- defensive: the title span renders unconditionally. */ + if (title.current === null) return + restTitle(title.current) + }, + }), [title]) } /** Localized compact relative time ("刚刚"/"5分钟" in zh, "now"/"5min" in en). */ @@ -504,6 +558,7 @@ export function SessionNodeItem({ const menuOpenState = useMemo((): MenuOpenState => [menuOpen, setMenuOpen], [menuOpen]) const rowRef = useRef(null) const titleRef = useRef(null) + const marquee = useTitleMarquee(titleRef) useEffect(() => { if (onReveal === undefined) return rowRef.current?.scrollIntoView({ block: 'nearest' }) @@ -524,8 +579,8 @@ export function SessionNodeItem({ aria-selected={selected} aria-description={row.archived ? t('toast.archivedNotOpenable') : undefined} onClick={() => { onOpen(node.id) }} - onPointerEnter={() => { revealClippedTitle(titleRef.current, true) }} - onPointerLeave={() => { revealClippedTitle(titleRef.current, false) }} + onPointerEnter={marquee.enter} + onPointerLeave={marquee.leave} draggable={draggable} onDragStart={!draggable ? undefined diff --git a/packages/client/ui-workspace/tests/browser-styles.client.spec.ts b/packages/client/ui-workspace/tests/browser-styles.client.spec.ts index 38d4ee36a3..bf4191366b 100644 --- a/packages/client/ui-workspace/tests/browser-styles.client.spec.ts +++ b/packages/client/ui-workspace/tests/browser-styles.client.spec.ts @@ -108,13 +108,20 @@ describe('WorkspaceBrowser.module.css list', () => { .toBe('var(--dsw-alias-interactive-bg-hover)') }) - it('reveals a clipped session title by scrolling it on row hover', () => { - // Smooth versus reduced motion is pinned as a computed style in - // apps/web/tests/sidebar-title-hover-scroll.e2e.ts: this helper merges - // same-selector rules across media queries, so the reduce override would - // mask the smooth declaration here. + it('marquees a clipped session title on row hover', () => { + // The crawl itself is scripted in Rows.tsx frame by frame, so the title + // declares no scroll-behavior; the stylesheet keeps the hovered cell + // unclipped and fades whichever edges cut text mid-travel, on the title + // span itself so the status slot beside it keeps its full color. expect(rowDeclarations('.sessionRow .title')?.get('flex')).toBe('1') + expect(rowDeclarations('.sessionRow .title')?.get('scroll-behavior')).toBeUndefined() expect(rowDeclarations('.sessionRow:hover .title')?.get('text-overflow')).toBe('clip') + expect(rowDeclarations('.sessionRow .title[data-scrolled]')?.get('mask-image')) + .toBe('linear-gradient(to right, transparent, #000 12px)') + expect(rowDeclarations('.sessionRow .title[data-clipped]')?.get('mask-image')) + .toBe('linear-gradient(to left, transparent, #000 12px)') + expect(rowDeclarations('.sessionRow .title[data-scrolled][data-clipped]')?.get('mask-image')) + .toBe('linear-gradient(to right, transparent, #000 12px, #000 calc(100% - 12px), transparent)') }) it('pins both rail controls to the shared left anchor during the column slide', () => { diff --git a/packages/client/ui-workspace/tests/rows.client.spec.tsx b/packages/client/ui-workspace/tests/rows.client.spec.tsx index f41199824f..41e1b6e037 100644 --- a/packages/client/ui-workspace/tests/rows.client.spec.tsx +++ b/packages/client/ui-workspace/tests/rows.client.spec.tsx @@ -208,66 +208,136 @@ describe('workspace browser rows', () => { expect(onOpen).toHaveBeenCalledWith(node.id) }) - it('reveals a clipped session title by scrolling it while the row is hovered', () => { - const node: SessionNode = { - id: sid('clipped'), title: 'A Session Title Long Enough To Be Clipped (1)', blank: false, - running: false, runningSubagentCount: 0, completed: false, hasActiveSchedule: false, updatedAt: 0, - pinned: false, archived: false, + it('marquees a clipped session title at a constant speed while the row is hovered', () => { + vi.useFakeTimers() + // jsdom implements no matchMedia; the stub answers the reduced-motion probe. + vi.stubGlobal('matchMedia', vi.fn(() => ({ matches: false }))) + try { + const node: SessionNode = { + id: sid('clipped'), title: 'A Session Title Long Enough To Be Clipped (1)', blank: false, + running: false, runningSubagentCount: 0, completed: false, hasActiveSchedule: false, updatedAt: 0, + pinned: false, archived: false, + } + render( + , + ) + const row = screen.getByRole('treeitem') + const title = screen.getByText(node.title) + // jsdom lays out nothing: the title's clipped geometry is stated outright. + const geometry = (scrollWidth: number, clientWidth: number): void => { + Object.defineProperty(title, 'scrollWidth', { value: scrollWidth, configurable: true }) + Object.defineProperty(title, 'clientWidth', { value: clientWidth, configurable: true }) + } + + geometry(320, 180) + fireEvent.pointerEnter(row) + // The first frame only records the clock; travel begins on the second. + vi.advanceTimersByTime(16) + expect(title.scrollLeft).toBe(0) + expect(title.hasAttribute('data-scrolled')).toBe(false) + expect(title.hasAttribute('data-clipped')).toBe(true) + // Constant 30px/s: 1600ms of frames crawl 48px, and the moved title + // publishes both fade-mask hooks — off its start, short of its far edge. + vi.advanceTimersByTime(1600) + expect(title.scrollLeft).toBeCloseTo(48, 5) + expect(title.hasAttribute('data-scrolled')).toBe(true) + expect(title.hasAttribute('data-clipped')).toBe(true) + // The crawl clamps at the far edge and rests there under the pointer; + // the right fade lifts so the final character reads at full strength. + vi.advanceTimersByTime(10_000) + expect(title.scrollLeft).toBe(140) + expect(title.hasAttribute('data-clipped')).toBe(false) + fireEvent.pointerLeave(row) + expect(title.scrollLeft).toBe(0) + expect(title.hasAttribute('data-scrolled')).toBe(false) + expect(title.hasAttribute('data-clipped')).toBe(false) + + // A title that fits has no scroll range: hovering leaves it at its start. + geometry(180, 180) + fireEvent.pointerEnter(row) + vi.advanceTimersByTime(1000) + expect(title.scrollLeft).toBe(0) + fireEvent.pointerLeave(row) + + // Overflow at the 8px jitter threshold also stays put. + geometry(188, 180) + fireEvent.pointerEnter(row) + vi.advanceTimersByTime(1000) + expect(title.scrollLeft).toBe(0) + fireEvent.pointerLeave(row) + + // One pixel past the threshold crawls to its 9px extent. + geometry(189, 180) + fireEvent.pointerEnter(row) + vi.advanceTimersByTime(1000) + expect(title.scrollLeft).toBe(9) + } finally { + vi.useRealTimers() + vi.unstubAllGlobals() } - render( - , - ) - const row = screen.getByRole('treeitem') - const title = screen.getByText(node.title) - // jsdom lays out nothing: the title's clipped geometry is stated outright. - const geometry = (scrollWidth: number, clientWidth: number): void => { - Object.defineProperty(title, 'scrollWidth', { value: scrollWidth, configurable: true }) - Object.defineProperty(title, 'clientWidth', { value: clientWidth, configurable: true }) - } - - geometry(320, 180) - fireEvent.pointerEnter(row) - expect(title.scrollLeft).toBe(140) - fireEvent.pointerLeave(row) - expect(title.scrollLeft).toBe(0) - - // A title that fits has no scroll range: hovering leaves it at its start. - geometry(180, 180) - fireEvent.pointerEnter(row) - expect(title.scrollLeft).toBe(0) - fireEvent.pointerLeave(row) - - // Overflow at the 8px jitter threshold also stays put. - geometry(188, 180) - fireEvent.pointerEnter(row) - expect(title.scrollLeft).toBe(0) - fireEvent.pointerLeave(row) - - // One pixel past the threshold reveals. - geometry(189, 180) - fireEvent.pointerEnter(row) - expect(title.scrollLeft).toBe(9) }) - it('returns a revealed title to its start in one step', () => { - const node: SessionNode = { - id: sid('instant-return'), title: 'A Session Title Long Enough To Be Clipped (1)', blank: false, - running: false, runningSubagentCount: 0, completed: false, hasActiveSchedule: false, updatedAt: 0, - pinned: false, archived: false, - } - render( - , - ) - const row = screen.getByRole('treeitem') - const title = screen.getByText(node.title) - const scrollTo = vi.fn() - Object.defineProperty(title, 'scrollTo', { value: scrollTo, configurable: true }) + it('jumps a clipped title to its far edge under reduced motion', () => { + vi.stubGlobal('matchMedia', vi.fn((query: string) => ({ matches: query === '(prefers-reduced-motion: reduce)' }))) + try { + const node: SessionNode = { + id: sid('reduced-motion'), title: 'A Session Title Long Enough To Be Clipped (1)', blank: false, + running: false, runningSubagentCount: 0, completed: false, hasActiveSchedule: false, updatedAt: 0, + pinned: false, archived: false, + } + render( + , + ) + const row = screen.getByRole('treeitem') + const title = screen.getByText(node.title) + Object.defineProperty(title, 'scrollWidth', { value: 320, configurable: true }) + Object.defineProperty(title, 'clientWidth', { value: 180, configurable: true }) - fireEvent.pointerEnter(row) - fireEvent.pointerLeave(row) - // Browsers receive the explicit instant behavior that the stylesheet's - // smooth scroll-behavior would otherwise override. - expect(scrollTo).toHaveBeenCalledWith({ left: 0, behavior: 'instant' }) + fireEvent.pointerEnter(row) + expect(title.scrollLeft).toBe(140) + expect(title.hasAttribute('data-scrolled')).toBe(true) + expect(title.hasAttribute('data-clipped')).toBe(false) + fireEvent.pointerLeave(row) + expect(title.scrollLeft).toBe(0) + expect(title.hasAttribute('data-scrolled')).toBe(false) + } finally { + vi.unstubAllGlobals() + } + }) + + it('returns a marqueed title to its start in one step and cancels the crawl', () => { + vi.useFakeTimers() + vi.stubGlobal('matchMedia', vi.fn(() => ({ matches: false }))) + try { + const node: SessionNode = { + id: sid('instant-return'), title: 'A Session Title Long Enough To Be Clipped (1)', blank: false, + running: false, runningSubagentCount: 0, completed: false, hasActiveSchedule: false, updatedAt: 0, + pinned: false, archived: false, + } + render( + , + ) + const row = screen.getByRole('treeitem') + const title = screen.getByText(node.title) + Object.defineProperty(title, 'scrollWidth', { value: 320, configurable: true }) + Object.defineProperty(title, 'clientWidth', { value: 180, configurable: true }) + const scrollTo = vi.fn() + Object.defineProperty(title, 'scrollTo', { value: scrollTo, configurable: true }) + + fireEvent.pointerEnter(row) + vi.advanceTimersByTime(160) + fireEvent.pointerLeave(row) + // Browsers receive the explicit instant behavior so mid-crawl positions + // never ease; the return is one step. + expect(scrollTo).toHaveBeenLastCalledWith({ left: 0, behavior: 'instant' }) + // Leaving cancels the crawl rather than letting it finish under the mask. + const settled = scrollTo.mock.calls.length + vi.advanceTimersByTime(1000) + expect(scrollTo.mock.calls.length).toBe(settled) + } finally { + vi.useRealTimers() + vi.unstubAllGlobals() + } }) it('keeps the active-Schedule marker between the title and time in grouped and flat rows', () => {