feat(workspace): restore the hover title reveal with an 8px jitter floor

Hovering a session row again scrolls a clipped title to its far edge
(the #4423 reveal), but overflow of at most 8px stays put so barely
clipped titles do not jitter. The hover mask fade yields back to plain
clipping — at the scrolled extent the fade would cover the revealed
tail. Row action buttons tighten from gap 12 to 10.
This commit is contained in:
Yif authored and imccyu committed 2026-09-19 12:04:07 +08:00
1 parent 1d221f9723
commit 34d78d843d
5 files changed
+212 -12

No files matched your search

@@ -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<typeof watchConsole>
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)
})
@@ -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;
}
}
@@ -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<HTMLDivElement>(null)
const titleRef = useRef<HTMLSpanElement>(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({
</span>
)}
<span
ref={titleRef}
className={css.title}
onDoubleClick={row.blank
? undefined
@@ -108,7 +108,11 @@ describe('WorkspaceBrowser.module.css list', () => {
.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')
})
@@ -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(
<SessionNodeItem node={node} currentId={undefined} now={0} onOpen={vi.fn()}
onRename={vi.fn()} onFork={vi.fn()} onArchive={vi.fn()} onUnarchive={vi.fn()} onPin={vi.fn()} t={t} />,
)
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(
<SessionNodeItem node={node} currentId={undefined} now={0} onOpen={vi.fn()}
onRename={vi.fn()} onFork={vi.fn()} onArchive={vi.fn()} onUnarchive={vi.fn()} onPin={vi.fn()} t={t} />,
)
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 = {