Files
deepseek-harness/apps/web/tests/sidebar-title-hover-scroll.e2e.ts
T
Turtle 2099 65648082a3 feat(web): reveal a clipped sidebar session title on hover (#4423)
* feat(web): reveal a clipped sidebar session title on hover

A session title wider than its sidebar row is clipped with an ellipsis, so a
long parent title hides the `(1)` that distinguishes its fork. Hovering the row
now scrolls the title to its far edge and returns it to the start when the
pointer leaves. The title stays an `overflow: hidden` scrollport, so nothing
reflows and no scrollbar appears; `scroll-behavior: smooth` supplies the motion
(reduced motion keeps the jump), and the hovered row clips instead of
ellipsizing because a scrolled box still paints the ellipsis over the revealed
characters.

Covered by the row component spec, the stylesheet contract spec, and a new
keyless Chromium scenario that hovers a seeded clipped row in the assembled
application and asserts the reached scroll extent.

* fix(web): return the revealed session title in one step

Review follow-up on the hover reveal:

- Return the title to its start with `scrollTo({ behavior: 'instant' })` instead
  of a smooth scroll. A smooth return carried the resting ellipsis and the
  narrowing cell over the travelling text for its whole duration.
- Scope `scroll-behavior: smooth` to `.sessionRow .title` so Workspace rows are
  untouched, and fold the reduced-motion reset into the file's existing block.
- Guard the hover-only `text-overflow: clip` with `@media (hover: hover)`: a
  touch tap latches `:hover` after the title has already returned, which would
  otherwise strand that row without its ellipsis.
- Pin the contract in the browser scenario: computed `scroll-behavior` is
  `smooth` by default and `auto` under reduced motion, and the return lands
  inside one step. Add the unit case for the explicit instant return.
2026-09-17 17:19:29 +08:00

87 lines
4.3 KiB
TypeScript

// 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)
})