diff --git a/apps/web/tests/sidebar-title-hover-scroll.e2e.ts b/apps/web/tests/sidebar-title-hover-scroll.e2e.ts new file mode 100644 index 0000000000..2a0fe8caf4 --- /dev/null +++ b/apps/web/tests/sidebar-title-hover-scroll.e2e.ts @@ -0,0 +1,86 @@ +// 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. +// +// Zero model calls: seeding one renamed session and hovering its row touches no +// provider. +import { readFile } from 'node:fs/promises' +import { fileURLToPath } from 'node:url' +import { chromium } from 'playwright' +import type { Browser, Page } from 'playwright' +import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest' +import { launchWebScaffold, seedSession, watchConsole, type WebScaffold } from './scaffold.ts' +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. */ +const TITLE = 'Forked session clipped before its suffix (1)' + +describe('web e2e: hovering a clipped session title scrolls it to its far edge', () => { + let scaffold: WebScaffold + let browser: Browser + let page: Page + let tripwire: ReturnType + + beforeAll(async () => { + scaffold = await launchWebScaffold({}) + const workspace = await scaffold.ctx.workspaceRegistry.create(scaffold.workspaceCwd) + const id = await seedSession(scaffold, await readFile(SEED, 'utf8'), 'sidebar-title-hover-scroll') + await workspace.attachSession(id) + await scaffold.ctx.sessionController.rename({ sessionId: id, title: TITLE }) + browser = await chromium.launch() + page = await newEnglishPage(browser) + tripwire = watchConsole(page) + await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' }) + await page.waitForSelector('[class*="frame"]', { timeout: 30_000 }) + }, 120_000) + + afterAll(async () => { + await browser?.close() + await scaffold?.close() + }) + + it('reveals 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 }) + const title = row.getByText(TITLE, { exact: true }) + + // At rest the row clips its title with an ellipsis and holds its start. + 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. + await expect.poll( + async () => title.evaluate(el => el.scrollLeft >= el.scrollWidth - el.clientWidth - 1), + { timeout: 10_000 }, + ).toBe(true) + // Scrolled to its end, the title must not paint the ellipsis over the + // characters the reveal 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. + 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 => getComputedStyle(el).textOverflow)).toBe('ellipsis') + + // The reduce override reaches the real stylesheet, so the reveal jumps + // instead of gliding. + await page.emulateMedia({ reducedMotion: 'reduce' }) + expect(await title.evaluate(el => getComputedStyle(el).scrollBehavior)).toBe('auto') + await page.emulateMedia({ reducedMotion: null }) + expect(tripwire.pageErrors).toEqual([]) + }, 60_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 6a83e55d72..6401881a3b 100644 --- a/packages/client/ui-workspace/src/client/rows/Rows.module.css +++ b/packages/client/ui-workspace/src/client/rows/Rows.module.css @@ -184,8 +184,13 @@ 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. */ .sessionRow .title { flex: 1; + scroll-behavior: smooth; } .meta { @@ -248,12 +253,13 @@ } /* Trailing action buttons surface on hover only (figma 27:4668 / 27:4656): - bare 16px glyphs, gap 12, tertiary grey. */ + bare 16px glyphs, tertiary grey; the figma gap 12 reads too airy beside + the 16px glyphs, so the buttons sit at 10. */ .rowActions { flex: none; display: none; align-items: center; - gap: 12px; + gap: 10px; } .projectRow:hover .rowActions, @@ -282,15 +288,15 @@ display: none; } -/* Hover truncation fades the title's own alpha out instead of clipping to - "…": an overlay gradient would need the sidebar's fill, which macOS - vibrancy makes translucent, while a mask works over any backdrop. Short - titles never reach the faded band, so only overflow is affected. */ -.sessionRow:hover .title, -.sessionRow.menuOpen .title { - text-overflow: clip; - -webkit-mask-image: linear-gradient(to right, #000 calc(100% - 24px), transparent); - mask-image: linear-gradient(to right, #000 calc(100% - 24px), transparent); +/* The unclipped hover state drops the ellipsis, which would otherwise cover + the characters the scroll reveal 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) { + .sessionRow:hover .title, + .sessionRow.menuOpen .title { + text-overflow: clip; + } } /* An open row menu pins the hover affordances (figma: the row keeps its @@ -414,4 +420,9 @@ 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 e291547031..87d7875b67 100644 --- a/packages/client/ui-workspace/src/client/rows/Rows.tsx +++ b/packages/client/ui-workspace/src/client/rows/Rows.tsx @@ -28,6 +28,37 @@ function displayTitle(node: SessionNode, t: RowTranslate): string { return node.blank ? t('session.new') : node.title } +/* Overflow this small hides no meaningful tail; scrolling for it reads as an + accidental jitter, so the title stays put. */ +const MIN_TITLE_REVEAL_PX = 8 + +/** + * 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. + * @param title - the row's clipping title element. + * @param revealed - whether the pointer is on the row. + */ +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 + } + // jsdom implements no scrollTo; the lane's direct assignment is instant there + // anyway, so both paths land on the same resting position. + if (typeof title.scrollTo === 'function') title.scrollTo({ left: 0, behavior: 'instant' }) + else title.scrollLeft = 0 +} + /** Localized compact relative time ("刚刚"/"5分钟" in zh, "now"/"5min" in en). */ function timeLabel(updatedAt: number, now: number, t: RowTranslate): string { const { unit, n } = relativeTime(updatedAt, now) @@ -472,6 +503,7 @@ export function SessionNodeItem({ const draggable = drag !== undefined && !row.blank && !row.archived const [menuOpen, setMenuOpen] = useState(false) const rowRef = useRef(null) + const titleRef = useRef(null) useEffect(() => { if (onReveal === undefined) return rowRef.current?.scrollIntoView({ block: 'nearest' }) @@ -509,6 +541,8 @@ export function SessionNodeItem({ role="treeitem" aria-selected={selected} onClick={() => { onOpen(node.id) }} + onPointerEnter={() => { revealClippedTitle(titleRef.current, true) }} + onPointerLeave={() => { revealClippedTitle(titleRef.current, false) }} draggable={draggable} onDragStart={!draggable ? undefined @@ -545,6 +579,7 @@ export function SessionNodeItem({ )} { .toBe('var(--dsw-alias-interactive-bg-hover)') }) - it('fades a hovered title out through the mask instead of the resting ellipsis', () => { + 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. expect(rowDeclarations('.sessionRow .title')?.get('flex')).toBe('1') expect(rowDeclarations('.sessionRow:hover .title')?.get('text-overflow')).toBe('clip') }) diff --git a/packages/client/ui-workspace/tests/rows.client.spec.tsx b/packages/client/ui-workspace/tests/rows.client.spec.tsx index a0bf66845d..680a9dcd74 100644 --- a/packages/client/ui-workspace/tests/rows.client.spec.tsx +++ b/packages/client/ui-workspace/tests/rows.client.spec.tsx @@ -172,6 +172,70 @@ 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, + } + 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 }) + + 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' }) + }) + it('keeps the active-Schedule marker between the title and time in grouped and flat rows', () => { const onOpen = vi.fn() const node: SessionNode = {