Merge remote-tracking branch 'origin/master' into codex/office-package-master
This commit is contained in:
commit
4e5aec6a74
7 files changed
+368
-120
No files matched your search
@@ -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[] = []
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
Reference in new issue
Block a user