Merge remote-tracking branch 'origin/master' into codex/office-package-master

This commit is contained in:
07akioni committed 2026-09-21 13:46:31 +08:00
commit 4e5aec6a74
7 files changed
+368 -120

No files matched your search

+32 -1
View File
@@ -110,6 +110,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,
@@ -119,7 +130,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
@@ -157,6 +168,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(chromeFallbackFill())
} 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[] = []
+52 -3
View File
@@ -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) => {
@@ -591,6 +598,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')
@@ -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)
})
@@ -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;
}
}
@@ -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<HTMLSpanElement | null>): { 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<HTMLDivElement>(null)
const titleRef = useRef<HTMLSpanElement>(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
@@ -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', () => {
@@ -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(
<SessionNodeItem node={node} currentId={undefined} now={0} onOpen={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)
// 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(
<SessionNodeItem node={node} currentId={undefined} now={0} onOpen={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()} 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 })
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(
<SessionNodeItem node={node} currentId={undefined} now={0} onOpen={vi.fn()} t={t} />,
)
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(
<SessionNodeItem node={node} currentId={undefined} now={0} onOpen={vi.fn()} t={t} />,
)
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', () => {