fix(client): address sidebar alignment review feedback

This commit is contained in:
Yif committed 2026-10-26 12:57:11 +08:00
1 parent 88894be0db
commit 8668cf310a
33 files changed
+661 -277

No files matched your search

@@ -0,0 +1,20 @@
{
"wide": {
"height": 44,
"width": 260,
"footerCenterOffset": 0,
"leftOffset": -2,
"rightOffset": 2,
"paintedLeftOffset": -2,
"paintedRightOffset": 2
},
"rail": {
"height": 36,
"width": 36,
"footerCenterOffset": 0,
"leftOffset": -0.5,
"rightOffset": -0.5,
"paintedLeftOffset": -0.5,
"paintedRightOffset": -0.5
}
}
@@ -0,0 +1,20 @@
{
"wide": {
"height": 32,
"width": 264,
"footerCenterOffset": 0,
"leftOffset": -4,
"rightOffset": 4,
"paintedLeftOffset": -1,
"paintedRightOffset": 1
},
"rail": {
"height": 36,
"width": 36,
"footerCenterOffset": 0,
"leftOffset": -0.5,
"rightOffset": -0.5,
"paintedLeftOffset": -0.5,
"paintedRightOffset": -0.5
}
}
@@ -0,0 +1,7 @@
# Sidebar alignment
- search title is clipped: true
- unarchive hit target width: 24px
- clipped search title label gap: 6px
- clipped search title clearance from unarchive hit target: 2px
- clipped search title edge hit-tests to title: true
+467
View File
@@ -0,0 +1,467 @@
/** Browser geometry for sidebar controls, text clearance, and drag previews. */
import { readFile } from 'node:fs/promises'
import { fileURLToPath } from 'node:url'
import type { Browser, Locator, Page } from 'playwright'
import { chromium } from 'playwright'
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, onTestFailed, vi } from 'vitest'
import type { AccountView } from '@deepseek-ai/dsh-deepseek-account/types'
import type {} from '@deepseek-ai/dsh-deepseek-account'
import type {} from '@deepseek-ai/dsh-api-session-controller'
import {
compareOrRefreshGolden, launchWebScaffold, seedSession, watchConsole,
webSnapshotMode, 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))
const GEOMETRY_EXPECTED = fileURLToPath(new URL('./expected/sidebar-alignment/geometry.expected.md', import.meta.url))
const MODE = webSnapshotMode()
const ACTION_TARGET_SIZE = 24
const ACTION_LAYOUT_SIZE = 16
const ACTION_CENTER_SPACING = 29
const ACTION_TRAILING_INSET = 7
const SEARCH_HEIGHT = 32
const SEARCH_TRANSITION_MS = 180
const HEADER_LABEL_OFFSET = 7
const HEADER_ACTION_RIGHT_OFFSETS = { search: -61, view: -29, add: 3 }
/**
* Measure painted glyphs and pointer targets independently of the row's layout cells.
* @param row - visible, hovered Workspace or Session row.
* @returns row bounds, action geometry, and browser hit-test results.
*/
async function measureRowActions(row: Locator) {
return row.evaluate((element) => {
const rowRect = element.getBoundingClientRect()
const title = element.querySelector<HTMLElement>('[class*="title"]')
if (title === null) throw new Error('row title not in the DOM')
const buttons = [...element.querySelectorAll<HTMLButtonElement>('button')]
return {
height: rowRect.height,
overflows: element.scrollWidth > element.clientWidth,
titleRight: title.getBoundingClientRect().right,
actions: buttons.map((button) => {
const rect = button.getBoundingClientRect()
const glyph = button.querySelector('svg')
if (glyph === null) throw new Error('row action glyph not in the DOM')
const glyphRect = glyph.getBoundingClientRect()
const style = getComputedStyle(button)
const center = { x: rect.x + rect.width / 2, y: rect.y + rect.height / 2 }
// Side samples cover the outer target; corner samples stay inside the rounded border.
const points = [
{ x: center.x - 11.5, y: center.y },
{ x: center.x + 11.5, y: center.y },
{ x: center.x, y: center.y - 11.5 },
{ x: center.x, y: center.y + 11.5 },
{ x: center.x - 9.5, y: center.y - 9.5 },
{ x: center.x + 9.5, y: center.y - 9.5 },
{ x: center.x - 9.5, y: center.y + 9.5 },
{ x: center.x + 9.5, y: center.y + 9.5 },
]
const hits = (x: number, y: number): boolean => {
const target = document.elementFromPoint(x, y)
return target !== null && button.contains(target)
}
return {
label: button.getAttribute('aria-label'),
width: rect.width,
height: rect.height,
layoutWidth: rect.width + Number.parseFloat(style.marginLeft) + Number.parseFloat(style.marginRight),
layoutHeight: rect.height + Number.parseFloat(style.marginTop) + Number.parseFloat(style.marginBottom),
margins: [style.marginTop, style.marginRight, style.marginBottom, style.marginLeft],
pseudoContent: getComputedStyle(button, '::after').content,
center,
rowCenterOffset: center.y - (rowRect.y + rowRect.height / 2),
trailingInset: rowRect.right - rect.right,
glyphWidth: glyphRect.width,
glyphHeight: glyphRect.height,
glyphCenterOffset: {
x: glyphRect.x + glyphRect.width / 2 - center.x,
y: glyphRect.y + glyphRect.height / 2 - center.y,
},
insideRow: center.x - 12 >= rowRect.left && center.x + 12 <= rowRect.right
&& center.y - 12 >= rowRect.top && center.y + 12 <= rowRect.bottom,
points,
edgeHits: points.map(({ x, y }) => hits(x, y)),
// Clear Chromium's boundary rounding while still rejecting an oversized 28px target.
outsideHits: [
hits(center.x - 13.5, center.y), hits(center.x + 13.5, center.y),
hits(center.x, center.y - 13.5), hits(center.x, center.y + 13.5),
],
}
}),
}
})
}
describe('web e2e: sidebar search alignment and row action targets', () => {
const workspaceTitle = 'Sidebar geometry workspace'
const sessionTitle = 'Sidebar geometry session'
const archivedTitle = 'Archived geometry session with a long title that fills its sidebar row'
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, workspaceTitle)
const id = await seedSession(scaffold, await readFile(SEED, 'utf8'), 'sidebar-action-geometry')
await workspace.attachSession(id)
await scaffold.ctx.sessionController.rename({ sessionId: id, title: sessionTitle })
const archivedId = await seedSession(scaffold, await readFile(SEED, 'utf8'), 'sidebar-search-geometry')
await workspace.attachSession(archivedId)
await scaffold.ctx.sessionController.rename({ sessionId: archivedId, title: archivedTitle })
await scaffold.ctx.workspaceRegistry.archiveSession(archivedId)
browser = await chromium.launch()
})
beforeEach(async () => {
page = await newEnglishPage(browser, 800)
tripwire = watchConsole(page)
await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
const workspace = page.locator('[data-row-key^="workspace:"]').filter({ hasText: workspaceTitle })
await workspace.waitFor({ timeout: 30_000 })
await expect.poll(async () => {
if (await workspace.getAttribute('aria-expanded') !== 'true') {
await workspace.getByText(workspaceTitle, { exact: true }).click()
}
return workspace.getAttribute('aria-expanded')
}, { timeout: 10_000 }).toBe('true')
await page.locator('[data-row-key="session:sidebar-action-geometry"]').waitFor()
})
afterEach(async () => {
await page?.close()
})
afterAll(async () => {
try { await browser?.close() } finally { await scaffold?.close() }
})
it('aligns the 32px expanded search with both edges of New session', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-sidebar-search-alignment'))
const searchButton = page.getByRole('button', { name: 'Search sessions', exact: true })
const input = page.getByPlaceholder('Search session names', { exact: true })
const search = input.locator('..')
const newSession = page.locator('button[class*="newSession"]')
const measure = async () => {
const searchBox = await search.boundingBox()
const newSessionBox = await newSession.boundingBox()
if (searchBox === null || newSessionBox === null) throw new Error('sidebar controls have no layout box')
return {
height: searchBox.height,
leftOffset: searchBox.x - newSessionBox.x,
rightOffset: searchBox.x + searchBox.width - newSessionBox.x - newSessionBox.width,
}
}
for (const dark of [false, true]) {
await page.evaluate(dark => document.body.toggleAttribute('data-ds-dark-theme', dark), dark)
await expect.poll(async () => {
const reference = await newSession.boundingBox()
if (reference === null) throw new Error('New session has no layout box')
const controls = [searchButton, page.getByRole('button', { name: 'View options', exact: true }), page.getByRole('button', { name: 'Add workspace', exact: true })]
const boxes = await Promise.all(controls.map(control => control.boundingBox()))
return boxes.map(box => box === null ? null : box.x + box.width - reference.x - reference.width)
}).toEqual(Object.values(HEADER_ACTION_RIGHT_OFFSETS))
const labelBox = await page.locator('[class*="sectionHeader"] [class*="sectionLabel"]').boundingBox()
const referenceBox = await newSession.boundingBox()
if (labelBox === null || referenceBox === null) throw new Error('Sidebar heading has no layout box')
expect(labelBox.x - referenceBox.x).toBe(HEADER_LABEL_OFFSET)
await searchButton.click()
await expect.poll(() => searchButton.getAttribute('aria-expanded')).toBe('true')
await expect.poll(measure, { timeout: 10_000 }).toEqual({ height: SEARCH_HEIGHT, leftOffset: 0, rightOffset: 0 })
expect(await input.evaluate(element => element === document.activeElement)).toBe(true)
await input.press('Escape')
await expect.poll(() => searchButton.getAttribute('aria-expanded')).toBe('false')
}
expect(tripwire.pageErrors).toEqual([])
expect(tripwire.warnings).toEqual([])
})
it('interpolates header spacing with the search expansion and collapse', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-sidebar-search-transition'))
const header = page.locator('[class*="sectionHeader"]').filter({ has: page.getByRole('button', { name: 'Search sessions', exact: true }) })
for (const expand of [true, false]) {
const samples = await header.evaluate(async (element, { expand, duration }) => {
const button = element.querySelector<HTMLButtonElement>('button[aria-label="Search sessions"]')
const input = element.querySelector<HTMLInputElement>('input')
if (button === null || input === null) throw new Error('Search controls are missing')
// Pause transitions at their first frame, before scheduler delays can consume their duration.
const pause = (event: Event): void => {
if (event.target instanceof Element) {
for (const animation of event.target.getAnimations()) animation.pause()
}
}
element.addEventListener('transitionrun', pause, true)
try {
await new Promise<void>((resolve) => {
element.addEventListener('transitionrun', () => { resolve() }, { once: true })
if (expand) button.click()
else input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }))
})
const animations = element.getAnimations({ subtree: true })
const headerTransitions = element.getAnimations().map(animation => ({
property: animation instanceof CSSTransition ? animation.transitionProperty : '',
duration: animation.effect?.getTiming().duration,
}))
const frames = [0, 0.25, 0.5, 0.75, 1].map((progress) => {
for (const animation of animations) { animation.pause(); animation.currentTime = duration * progress }
const style = getComputedStyle(element)
return {
marginRight: Number.parseFloat(style.marginRight),
paddingLeft: Number.parseFloat(style.paddingLeft),
paddingRight: Number.parseFloat(style.paddingRight),
gap: Number.parseFloat(style.columnGap),
width: element.getBoundingClientRect().width,
}
})
for (const animation of animations) animation.finish()
return { headerTransitions, frames }
} finally {
element.removeEventListener('transitionrun', pause, true)
}
}, { expand, duration: SEARCH_TRANSITION_MS })
for (const property of ['margin-right', 'padding-left', 'padding-right', 'column-gap']) {
expect(samples.headerTransitions).toContainEqual({ property, duration: SEARCH_TRANSITION_MS })
}
const first = samples.frames[0]!
const last = samples.frames.at(-1)!
expect(first).toMatchObject(expand
? { marginRight: -4, paddingLeft: 12, paddingRight: 1, gap: 4 }
: { marginRight: 0, paddingLeft: 4, paddingRight: 4, gap: 0 })
expect(last).toMatchObject(expand
? { marginRight: 0, paddingLeft: 4, paddingRight: 4, gap: 0 }
: { marginRight: -4, paddingLeft: 12, paddingRight: 1, gap: 4 })
for (const frame of samples.frames.slice(1, -1)) {
for (const key of ['marginRight', 'paddingLeft', 'paddingRight', 'gap', 'width'] as const) {
expect(frame[key]).toBeGreaterThan(Math.min(first[key], last[key]))
expect(frame[key]).toBeLessThan(Math.max(first[key], last[key]))
}
const progress = (frame.marginRight - first.marginRight) / (last.marginRight - first.marginRight)
for (const key of ['paddingLeft', 'paddingRight', 'gap'] as const) {
expect((frame[key] - first[key]) / (last[key] - first[key])).toBeCloseTo(progress, 2)
}
}
}
})
it('keeps a clipped search title clear of the unarchive target', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-sidebar-search-title-clearance'))
await page.getByRole('button', { name: 'View options', exact: true }).click()
await page.getByRole('menuitem', { name: 'All conversations (show archived)', exact: true }).click()
await expect.poll(() => page.locator('[data-row-key="session:sidebar-search-geometry"]').textContent()).toContain(archivedTitle)
await page.getByRole('button', { name: 'Search sessions', exact: true }).click()
await page.getByPlaceholder('Search session names', { exact: true }).fill('Archived geometry')
const row = page.getByRole('treeitem').filter({ hasText: archivedTitle })
await row.hover()
await row.getByRole('button', { name: 'Unarchive session', exact: true }).waitFor()
const geometry = await row.evaluate((element) => {
const title = element.querySelector<HTMLElement>('[class*="searchResultTitle"]')
const button = element.querySelector('button')
if (title === null || button === null) throw new Error('Search result title or action is missing')
const titleRect = title.getBoundingClientRect()
const buttonRect = button.getBoundingClientRect()
const titleEdge = document.elementFromPoint(titleRect.right - 0.5, titleRect.top + titleRect.height / 2)
return {
clipped: title.scrollWidth > title.clientWidth,
labelGap: Number.parseFloat(getComputedStyle(title).marginRight),
targetWidth: buttonRect.width,
clearance: buttonRect.left - titleRect.right,
titleEdgeHitsTitle: titleEdge !== null && title.contains(titleEdge),
overflows: element.scrollWidth > element.clientWidth,
}
})
expect(geometry).toEqual({
clipped: true, labelGap: 6, targetWidth: ACTION_TARGET_SIZE, clearance: 2, titleEdgeHitsTitle: true, overflows: false,
})
await compareOrRefreshGolden(GEOMETRY_EXPECTED, [
'# Sidebar alignment', '',
`- search title is clipped: ${String(geometry.clipped)}`,
`- unarchive hit target width: ${geometry.targetWidth}px`,
`- clipped search title label gap: ${geometry.labelGap}px`,
`- clipped search title clearance from unarchive hit target: ${geometry.clearance}px`,
`- clipped search title edge hit-tests to title: ${String(geometry.titleEdgeHitsTitle)}`,
].join('\n'), MODE)
expect(tripwire.pageErrors).toEqual([])
})
it.each(['session', 'workspace'] as const)('retains %s row spacing in the native drag preview', async (kind) => {
onTestFailed(() => saveFailureShot(page, `web-e2e-sidebar-${kind}-drag-preview`))
const row = kind === 'session'
? page.locator('[data-row-key="session:sidebar-action-geometry"]')
: page.locator('[data-row-key^="workspace:"]').filter({ hasText: workspaceTitle })
const box = await row.boundingBox()
if (box === null) throw new Error('Drag source has no layout box')
await page.mouse.move(box.x + 40, box.y + box.height / 2)
await page.mouse.down()
try {
await page.mouse.move(box.x + 45, box.y + box.height + 10, { steps: 8 })
const preview = page.locator('body > [inert][aria-hidden="true"] > [role="treeitem"]')
await preview.waitFor({ state: 'attached' })
const spacing = (target: Locator) => target.evaluate((element) => {
const style = getComputedStyle(element)
const slot = [...element.querySelectorAll('[class*="slot"]')].find(candidate => candidate.getBoundingClientRect().width > 0)
if (slot === undefined) throw new Error('Row leading slot is missing')
return {
paddingStart: style.paddingInlineStart, paddingEnd: style.paddingInlineEnd,
borderStart: style.borderInlineStartWidth, borderEnd: style.borderInlineEndWidth,
gap: style.columnGap,
width: element.getBoundingClientRect().width,
height: element.getBoundingClientRect().height,
slotOffset: slot.getBoundingClientRect().left - element.getBoundingClientRect().left,
}
})
expect(await spacing(preview)).toEqual(await spacing(row))
} finally {
await page.keyboard.press('Escape')
await page.mouse.up()
}
await page.locator('body > [inert][aria-hidden="true"] > [role="treeitem"]').waitFor({ state: 'detached' })
expect(tripwire.pageErrors).toEqual([])
})
it.each([false, true])('keeps account footer geometry in wide and rail layouts (signed in: %s)', async (signedIn) => {
onTestFailed(() => saveFailureShot(page, `web-e2e-sidebar-account-${String(signedIn)}`))
const account = scaffold.ctx.deepseekAccount
const state: AccountView = {
status: 'credential-stored', attempt: null,
links: { usageUrl: 'https://platform.deepseek.com/usage', topUpUrl: 'https://platform.deepseek.com/top_up' },
}
// Only Platform identity is synthetic; the account provider's RPC and client renderer remain composed.
const mocks = signedIn ? [
vi.spyOn(account, 'getState').mockResolvedValue(state),
vi.spyOn(account, 'getProfile').mockResolvedValue({
status: 'ready', value: { id: null, name: 'Sidebar account', contact: null, avatarUrl: null },
}),
vi.spyOn(account, 'getBalance').mockResolvedValue({ status: 'ready', value: [], bonusWallets: [] }),
vi.spyOn(account, 'watch').mockImplementation(async function* (signal) {
yield state
if (signal.aborted) return
await new Promise<void>((resolve) => { signal.addEventListener('abort', () => { resolve() }, { once: true }) })
}),
] : []
try {
if (signedIn) await page.reload({ waitUntil: 'load' })
const accountButton = page.getByRole('button', { name: 'Account menu', exact: true })
await expect.poll(() => accountButton.getAttribute('data-signed-out')).toBe(String(!signedIn))
const measure = () => accountButton.evaluate((element) => {
const button = element.getBoundingClientRect()
const style = getComputedStyle(element)
const newSession = document.querySelector('button[class*="newSession"]')
if (newSession === null) throw new Error('New session reference is missing')
const reference = newSession.getBoundingClientRect()
const footer = element.closest('[class*="footArea"]')?.getBoundingClientRect()
if (footer === undefined) throw new Error('Account footer is missing')
return {
height: button.height, width: button.width,
footerCenterOffset: (button.left + button.right - footer.left - footer.right) / 2,
leftOffset: button.left - reference.left, rightOffset: button.right - reference.right,
paintedLeftOffset: button.left + Number.parseFloat(style.borderLeftWidth) - reference.left,
paintedRightOffset: button.right - Number.parseFloat(style.borderRightWidth) - reference.right,
}
})
const wide = await measure()
expect(wide.height).toBe(signedIn ? 44 : 32)
expect(wide.leftOffset).toBe(signedIn ? -2 : -4)
expect(wide.rightOffset).toBe(signedIn ? 2 : 4)
expect(wide.paintedLeftOffset).toBe(signedIn ? -2 : -1)
expect(wide.paintedRightOffset).toBe(signedIn ? 2 : 1)
await page.getByRole('button', { name: 'Collapse sidebar', exact: true }).click()
await expect.poll(() => accountButton.getAttribute('data-collapsed')).toBe('true')
await expect.poll(async () => {
const rect = await accountButton.boundingBox()
return rect === null ? null : { width: rect.width, height: rect.height }
}).toEqual({ width: 36, height: 36 })
await page.locator('button[class*="newSession"]').evaluate(async (element) => {
await Promise.all(element.getAnimations().map(animation => animation.finished))
})
const rail = await measure()
expect(rail.footerCenterOffset).toBe(0)
await compareOrRefreshGolden(
fileURLToPath(new URL(`./expected/sidebar-alignment/account-${signedIn ? 'signed-in' : 'signed-out'}.expected.md`, import.meta.url)),
JSON.stringify({ wide, rail }, null, 2), MODE,
)
expect(tripwire.pageErrors).toEqual([])
} finally {
await page.close()
mocks.forEach((mock) => { mock.mockRestore() })
}
})
it.each(['session', 'workspace'] as const)('keeps %s action glyphs in 16px layout space with 24px buttons', async (kind) => {
onTestFailed(() => saveFailureShot(page, `web-e2e-sidebar-${kind}-action-targets`))
const row = kind === 'session'
? page.locator('[data-row-key="session:sidebar-action-geometry"]')
: page.locator('[data-row-key^="workspace:"]').filter({ hasText: workspaceTitle })
const restingBox = await row.boundingBox()
await row.hover()
const labels = kind === 'session'
? [`Session actions for ${sessionTitle}`, 'Archive session', 'Pin session'] as const
: [`Workspace actions for ${workspaceTitle}`, `New session in ${workspaceTitle}`] as const
await row.getByRole('button', { name: labels[0], exact: true }).waitFor()
const metrics = await measureRowActions(row)
expect(await row.boundingBox()).toEqual(restingBox)
expect(metrics.height).toBe(kind === 'session' ? 32 : 34)
expect(metrics.overflows).toBe(false)
expect(metrics.actions.map(action => action.label)).toEqual(labels)
for (const [index, action] of metrics.actions.entries()) {
expect(action.width).toBe(ACTION_TARGET_SIZE)
expect(action.height).toBe(ACTION_TARGET_SIZE)
expect(action.layoutWidth).toBe(ACTION_LAYOUT_SIZE)
expect(action.layoutHeight).toBe(ACTION_LAYOUT_SIZE)
expect(action.margins).toEqual(['-4px', '-4px', '-4px', '-4px'])
expect(action.pseudoContent).toBe('none')
expect(action.rowCenterOffset).toBe(0)
expect(action.glyphWidth).toBe(index === 0 ? 16 : 14)
expect(action.glyphHeight).toBe(index === 0 ? 16 : 14)
expect(action.glyphCenterOffset).toEqual({ x: 0, y: 0 })
expect(action.insideRow).toBe(true)
expect(action.edgeHits).toEqual(Array<boolean>(8).fill(true))
expect(action.outsideHits).toEqual([false, false, false, false])
const previous = metrics.actions[index - 1]
if (previous !== undefined) {
expect(action.center.x - previous.center.x).toBe(ACTION_CENTER_SPACING)
expect(previous.center.x + 12).toBeLessThan(action.center.x - 12)
} else {
expect(metrics.titleRight).toBeLessThanOrEqual(action.center.x - 12)
}
}
expect(metrics.actions.at(-1)?.trailingInset).toBe(ACTION_TRAILING_INSET)
if (kind === 'session') expect(await row.getAttribute('aria-selected')).toBe('false')
const selectedRows = page.locator('[role="tree"][aria-label="Sessions"] [aria-selected="true"]')
const selectedKeys = await selectedRows.evaluateAll(rows => rows.map(row => row.getAttribute('data-row-key')))
const expanded = await row.getAttribute('aria-expanded')
const menuButton = row.getByRole('button', { name: labels[0], exact: true })
const menu = page.getByRole('menu')
const menuAction = kind === 'session' ? 'Pin session' : 'Rename'
const first = metrics.actions[0]
if (first === undefined) throw new Error('row has no menu trigger')
for (const point of first.points.slice(0, 4)) {
// Edge clicks exercise the enlarged target rather than the glyph's center.
await page.mouse.click(point.x, point.y)
await menu.getByRole('menuitem', { name: menuAction, exact: true }).waitFor()
expect(await selectedRows.evaluateAll(rows => rows.map(row => row.getAttribute('data-row-key')))).toEqual(selectedKeys)
expect(await row.getAttribute('aria-expanded')).toBe(expanded)
await page.keyboard.press('Escape')
await menu.waitFor({ state: 'detached' })
await menuButton.hover()
}
const tooltipAnchor = row.getByRole('button', { name: labels[1], exact: true })
const tooltip = page.getByRole('tooltip').filter({ hasText: kind === 'session' ? 'Archive' : 'New session' })
await tooltipAnchor.hover()
await tooltip.waitFor({ timeout: 10_000 })
const anchorBox = await tooltipAnchor.boundingBox()
const tooltipBox = await tooltip.boundingBox()
if (anchorBox === null || tooltipBox === null) throw new Error('hover tooltip or its anchor has no layout box')
expect(tooltipBox.y).toBe(anchorBox.y + anchorBox.height + 8)
expect(tooltipBox.x + tooltipBox.width).toBe(anchorBox.x + anchorBox.width)
await page.mouse.move(1000, 400)
await tooltip.waitFor({ state: 'detached' })
expect(await tooltipAnchor.isVisible()).toBe(false)
expect(await page.getByRole('tooltip').count()).toBe(0)
expect(tripwire.pageErrors).toEqual([])
expect(tripwire.warnings).toEqual([])
})
})
+14 -240
View File
@@ -6,9 +6,9 @@
import { readFile } from 'node:fs/promises'
import { fileURLToPath } from 'node:url'
import { join } from 'node:path'
import type { Browser, Locator, Page } from 'playwright'
import type { Browser, Page } from 'playwright'
import { chromium } from 'playwright'
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, onTestFailed } from 'vitest'
import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
import {
assertFixtureInventory, compareOrRefreshGolden, launchWebScaffold, seedSession, watchConsole,
webSnapshotMode, type WebScaffold,
@@ -94,8 +94,8 @@ function measureList(page: Page): Promise<ListMetrics> {
const pseudoWidth = getComputedStyle(list, '::-webkit-scrollbar').width
const barWidth = pseudoWidth === 'auto' ? 15 : Number.parseFloat(pseudoWidth)
const listRect = list.getBoundingClientRect()
const sidebarEdge = list.parentElement?.getBoundingClientRect().right
if (sidebarEdge === undefined) throw new Error('sidebar session list has no layout parent')
const listParentRight = list.parentElement?.getBoundingClientRect().right
if (listParentRight === undefined) throw new Error('sidebar session list has no layout parent')
return {
gutter: style.scrollbarGutter,
width: pseudoWidth,
@@ -107,9 +107,9 @@ function measureList(page: Page): Promise<ListMetrics> {
hoverToken: resolve('--dsh-scrollbar-thumb-hover'),
overflows: list.scrollHeight > list.clientHeight,
band: listRect.width - list.clientWidth,
scrollbarEdgeOffset: sidebarEdge - listRect.right,
rowEdgeInset: sidebarEdge - row.getBoundingClientRect().right,
rowPaintInset: sidebarEdge - row.getBoundingClientRect().right + Number.parseFloat(getComputedStyle(row).borderRightWidth),
scrollbarEdgeOffset: listParentRight - listRect.right,
rowEdgeInset: listParentRight - row.getBoundingClientRect().right,
rowPaintInset: listParentRight - row.getBoundingClientRect().right + Number.parseFloat(getComputedStyle(row).borderRightWidth),
clientRight: listRect.left + list.clientWidth,
borderRight: listRect.right,
timeRight: time.getBoundingClientRect().right,
@@ -137,12 +137,12 @@ function measureRowInset(page: Page): Promise<Pick<ListMetrics, 'overflows' | 'r
if (list === null) throw new Error('sidebar session list not in the DOM')
const row = list.querySelector<HTMLElement>('[role="treeitem"]')
if (row === null) throw new Error('no row in the sidebar list')
const sidebarEdge = list.parentElement?.getBoundingClientRect().right
if (sidebarEdge === undefined) throw new Error('sidebar session list has no layout parent')
const listParentRight = list.parentElement?.getBoundingClientRect().right
if (listParentRight === undefined) throw new Error('sidebar session list has no layout parent')
return {
overflows: list.scrollHeight > list.clientHeight,
rowEdgeInset: sidebarEdge - row.getBoundingClientRect().right,
rowPaintInset: sidebarEdge - row.getBoundingClientRect().right + Number.parseFloat(getComputedStyle(row).borderRightWidth),
rowEdgeInset: listParentRight - row.getBoundingClientRect().right,
rowPaintInset: listParentRight - row.getBoundingClientRect().right + Number.parseFloat(getComputedStyle(row).borderRightWidth),
}
})
}
@@ -195,9 +195,9 @@ function renderGeometry(light: PaletteMetrics, dark: PaletteMetrics): string {
`- --dsh-scrollbar-thumb-hover, pointer over the list: ${metrics.hoverToken}`,
`- list overflows: ${String(metrics.overflows)}`,
`- reserved band: ${String(metrics.band)}px`,
`- scrollbar inset from the sidebar edge: ${String(metrics.scrollbarEdgeOffset)}px`,
`- row hitbox inset from the sidebar edge: ${String(metrics.rowEdgeInset)}px`,
`- row background inset from the sidebar edge: ${String(metrics.rowPaintInset)}px`,
`- scrollbar inset from the list parent edge: ${String(metrics.scrollbarEdgeOffset)}px`,
`- row hitbox inset from the list parent edge: ${String(metrics.rowEdgeInset)}px`,
`- row background inset from the list parent edge: ${String(metrics.rowPaintInset)}px`,
`- relative time covered by the bar: ${String(metrics.timeCoveredBy)}px`,
`- relative time ends inside the content area: ${String(metrics.timeRight <= metrics.clientRight)}`,
`- content area ends before the border box: ${String(metrics.clientRight < metrics.borderRight)}`,
@@ -432,229 +432,3 @@ describe('web e2e: sidebar session list scrollbar (reserved gutter / themed thum
expect(tripwire.pageErrors).toEqual([])
})
})
/**
* Measure painted glyphs and pointer targets independently of the row's layout cells.
* @param row - visible, hovered Workspace or Session row.
* @returns row bounds, action geometry, and browser hit-test results.
*/
async function measureRowActions(row: Locator) {
return row.evaluate((element) => {
const rowRect = element.getBoundingClientRect()
const title = element.querySelector<HTMLElement>('[class*="title"]')
if (title === null) throw new Error('row title not in the DOM')
const buttons = [...element.querySelectorAll<HTMLButtonElement>('button')]
return {
height: rowRect.height,
overflows: element.scrollWidth > element.clientWidth,
titleRight: title.getBoundingClientRect().right,
actions: buttons.map((button) => {
const rect = button.getBoundingClientRect()
const glyph = button.querySelector('svg')
if (glyph === null) throw new Error('row action glyph not in the DOM')
const glyphRect = glyph.getBoundingClientRect()
const style = getComputedStyle(button)
const center = { x: rect.x + rect.width / 2, y: rect.y + rect.height / 2 }
// Side samples cover the outer target; corner samples stay inside the rounded border.
const points = [
{ x: center.x - 11.5, y: center.y },
{ x: center.x + 11.5, y: center.y },
{ x: center.x, y: center.y - 11.5 },
{ x: center.x, y: center.y + 11.5 },
{ x: center.x - 9.5, y: center.y - 9.5 },
{ x: center.x + 9.5, y: center.y - 9.5 },
{ x: center.x - 9.5, y: center.y + 9.5 },
{ x: center.x + 9.5, y: center.y + 9.5 },
]
const hits = (x: number, y: number): boolean => {
const target = document.elementFromPoint(x, y)
return target !== null && button.contains(target)
}
return {
label: button.getAttribute('aria-label'),
width: rect.width,
height: rect.height,
layoutWidth: rect.width + Number.parseFloat(style.marginLeft) + Number.parseFloat(style.marginRight),
layoutHeight: rect.height + Number.parseFloat(style.marginTop) + Number.parseFloat(style.marginBottom),
margins: [style.marginTop, style.marginRight, style.marginBottom, style.marginLeft],
pseudoContent: getComputedStyle(button, '::after').content,
center,
rowCenterOffset: center.y - (rowRect.y + rowRect.height / 2),
trailingInset: rowRect.right - rect.right,
glyphWidth: glyphRect.width,
glyphHeight: glyphRect.height,
glyphCenterOffset: {
x: glyphRect.x + glyphRect.width / 2 - center.x,
y: glyphRect.y + glyphRect.height / 2 - center.y,
},
insideRow: center.x - 12 >= rowRect.left && center.x + 12 <= rowRect.right
&& center.y - 12 >= rowRect.top && center.y + 12 <= rowRect.bottom,
points,
edgeHits: points.map(({ x, y }) => hits(x, y)),
// Clear Chromium's boundary rounding while still rejecting an oversized 28px target.
outsideHits: [
hits(center.x - 13.5, center.y), hits(center.x + 13.5, center.y),
hits(center.x, center.y - 13.5), hits(center.x, center.y + 13.5),
],
}
}),
}
})
}
describe('web e2e: sidebar search alignment and row action targets', () => {
const workspaceTitle = 'Sidebar geometry workspace'
const sessionTitle = 'Sidebar geometry session'
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, workspaceTitle)
const id = await seedSession(scaffold, await readFile(SEED, 'utf8'), 'sidebar-action-geometry')
await workspace.attachSession(id)
await scaffold.ctx.sessionController.rename({ sessionId: id, title: sessionTitle })
browser = await chromium.launch()
})
beforeEach(async () => {
page = await newEnglishPage(browser, 800)
tripwire = watchConsole(page)
await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
const workspace = page.locator('[data-row-key^="workspace:"]').filter({ hasText: workspaceTitle })
await workspace.waitFor({ timeout: 30_000 })
await expect.poll(async () => {
if (await workspace.getAttribute('aria-expanded') !== 'true') {
await workspace.getByText(workspaceTitle, { exact: true }).click()
}
return workspace.getAttribute('aria-expanded')
}, { timeout: 10_000 }).toBe('true')
await page.locator('[data-row-key="session:sidebar-action-geometry"]').waitFor()
})
afterEach(async () => {
await page?.close()
})
afterAll(async () => {
try { await browser?.close() } finally { await scaffold?.close() }
})
it('aligns the 32px expanded search with both edges of New session', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-sidebar-search-alignment'))
const searchButton = page.getByRole('button', { name: 'Search sessions', exact: true })
const input = page.getByPlaceholder('Search session names', { exact: true })
const search = input.locator('..')
const newSession = page.locator('button[class*="newSession"]')
const measure = async () => {
const searchBox = await search.boundingBox()
const newSessionBox = await newSession.boundingBox()
if (searchBox === null || newSessionBox === null) throw new Error('sidebar controls have no layout box')
return {
height: searchBox.height,
leftOffset: searchBox.x - newSessionBox.x,
rightOffset: searchBox.x + searchBox.width - newSessionBox.x - newSessionBox.width,
}
}
for (const dark of [false, true]) {
await page.evaluate(dark => document.body.toggleAttribute('data-ds-dark-theme', dark), dark)
await expect.poll(async () => {
const reference = await newSession.boundingBox()
if (reference === null) throw new Error('New session has no layout box')
const controls = [searchButton, page.getByRole('button', { name: 'View options', exact: true }), page.getByRole('button', { name: 'Add workspace', exact: true })]
const boxes = await Promise.all(controls.map(control => control.boundingBox()))
return boxes.map(box => box === null ? null : box.x + box.width - reference.x - reference.width)
}).toEqual([-61, -29, 3])
const labelBox = await page.locator('[class*="sectionHeader"] [class*="sectionLabel"]').boundingBox()
const referenceBox = await newSession.boundingBox()
if (labelBox === null || referenceBox === null) throw new Error('Sidebar heading has no layout box')
expect(labelBox.x - referenceBox.x).toBe(7)
await searchButton.click()
await expect.poll(() => searchButton.getAttribute('aria-expanded')).toBe('true')
await expect.poll(measure, { timeout: 10_000 }).toEqual({ height: 32, leftOffset: 0, rightOffset: 0 })
expect(await input.evaluate(element => element === document.activeElement)).toBe(true)
await input.press('Escape')
await expect.poll(() => searchButton.getAttribute('aria-expanded')).toBe('false')
}
expect(tripwire.pageErrors).toEqual([])
expect(tripwire.warnings).toEqual([])
})
it.each(['session', 'workspace'] as const)('keeps %s action glyphs in 16px layout space with 24px buttons', async (kind) => {
onTestFailed(() => saveFailureShot(page, `web-e2e-sidebar-${kind}-action-targets`))
const row = kind === 'session'
? page.locator('[data-row-key="session:sidebar-action-geometry"]')
: page.locator('[data-row-key^="workspace:"]').filter({ hasText: workspaceTitle })
const restingBox = await row.boundingBox()
await row.hover()
const labels = kind === 'session'
? [`Session actions for ${sessionTitle}`, 'Archive session', 'Pin session'] as const
: [`Workspace actions for ${workspaceTitle}`, `New session in ${workspaceTitle}`] as const
await row.getByRole('button', { name: labels[0], exact: true }).waitFor()
const metrics = await measureRowActions(row)
expect(await row.boundingBox()).toEqual(restingBox)
expect(metrics.height).toBe(kind === 'session' ? 32 : 34)
expect(metrics.overflows).toBe(false)
expect(metrics.actions.map(action => action.label)).toEqual(labels)
for (const [index, action] of metrics.actions.entries()) {
expect(action.width).toBe(24)
expect(action.height).toBe(24)
expect(action.layoutWidth).toBe(16)
expect(action.layoutHeight).toBe(16)
expect(action.margins).toEqual(['-4px', '-4px', '-4px', '-4px'])
expect(action.pseudoContent).toBe('none')
expect(action.rowCenterOffset).toBe(0)
expect(action.glyphWidth).toBe(index === 0 ? 16 : 14)
expect(action.glyphHeight).toBe(index === 0 ? 16 : 14)
expect(action.glyphCenterOffset).toEqual({ x: 0, y: 0 })
expect(action.insideRow).toBe(true)
expect(action.edgeHits).toEqual(Array<boolean>(8).fill(true))
expect(action.outsideHits).toEqual([false, false, false, false])
const previous = metrics.actions[index - 1]
if (previous !== undefined) {
expect(action.center.x - previous.center.x).toBe(29)
expect(previous.center.x + 12).toBeLessThan(action.center.x - 12)
} else {
expect(metrics.titleRight).toBeLessThanOrEqual(action.center.x - 12)
}
}
expect(metrics.actions.at(-1)?.trailingInset).toBe(7)
if (kind === 'session') expect(await row.getAttribute('aria-selected')).toBe('false')
const selectedRows = page.locator('[role="tree"][aria-label="Sessions"] [aria-selected="true"]')
const selectedKeys = await selectedRows.evaluateAll(rows => rows.map(row => row.getAttribute('data-row-key')))
const expanded = await row.getAttribute('aria-expanded')
const menuButton = row.getByRole('button', { name: labels[0], exact: true })
const menu = page.getByRole('menu')
const menuAction = kind === 'session' ? 'Pin session' : 'Rename'
const first = metrics.actions[0]
if (first === undefined) throw new Error('row has no menu trigger')
for (const point of first.points.slice(0, 4)) {
// Edge clicks exercise the enlarged target rather than the glyph's center.
await page.mouse.click(point.x, point.y)
await menu.getByRole('menuitem', { name: menuAction, exact: true }).waitFor()
expect(await selectedRows.evaluateAll(rows => rows.map(row => row.getAttribute('data-row-key')))).toEqual(selectedKeys)
expect(await row.getAttribute('aria-expanded')).toBe(expanded)
await page.keyboard.press('Escape')
await menu.waitFor({ state: 'detached' })
await menuButton.hover()
}
const tooltipAnchor = row.getByRole('button', { name: labels[1], exact: true })
const tooltip = page.getByRole('tooltip').filter({ hasText: kind === 'session' ? 'Archive' : 'New session' })
await tooltipAnchor.hover()
await tooltip.waitFor({ timeout: 10_000 })
const anchorBox = await tooltipAnchor.boundingBox()
const tooltipBox = await tooltip.boundingBox()
if (anchorBox === null || tooltipBox === null) throw new Error('hover tooltip or its anchor has no layout box')
expect(tooltipBox.y).toBe(anchorBox.y + anchorBox.height + 8)
expect(tooltipBox.x + tooltipBox.width).toBe(anchorBox.x + anchorBox.width)
await pointAt(page, 'away')
await tooltip.waitFor({ state: 'detached' })
expect(await tooltipAnchor.isVisible()).toBe(false)
expect(await page.getByRole('tooltip').count()).toBe(0)
expect(tripwire.pageErrors).toEqual([])
expect(tripwire.warnings).toEqual([])
})
})
@@ -231,6 +231,41 @@ describe('web e2e: macOS window drag coverage', () => {
return { page, tripwire }
}
it('keeps the macOS sidebar toggle stationary as its expanded content starts fading', async () => {
const { page, tripwire } = await desktopPage()
try {
const open = page.getByRole('button', { name: 'Open sidebar', exact: true })
if (await open.isVisible()) await open.click()
const toggle = page.getByRole('button', { name: 'Collapse sidebar', exact: true })
await toggle.waitFor()
const positions = await toggle.evaluate(async (button) => {
if (!(button instanceof HTMLButtonElement)) throw new Error('Sidebar toggle is not a button')
const root = button.closest('[class*="topStrip"]')!.parentElement!
const position = () => ({
left: button.getBoundingClientRect().left - root.getBoundingClientRect().left,
transform: getComputedStyle(button).transform,
})
const before = position()
// Observe the class change before the 150ms fade can settle under host contention.
const fading = await new Promise<ReturnType<typeof position>>((resolve) => {
const observer = new MutationObserver(() => {
if (![...root.classList].some(name => name.includes('fading'))) return
observer.disconnect()
resolve(position())
})
observer.observe(root, { attributes: true, attributeFilter: ['class'] })
button.click()
})
return { before, fading }
})
expect(positions.before.transform).toBe('matrix(1, 0, 0, 1, 4, 0)')
expect(positions.fading).toEqual(positions.before)
expect(tripwire.pageErrors).toEqual([])
} finally {
await page.close()
}
})
it.each(['darwin', 'win32'] as const)('%s keeps compact Plugins controls below native chrome in both sidebar states', async (platform) => {
const { page, tripwire } = await desktopPage(platform)
try {
+1
View File
@@ -154,6 +154,7 @@
"tests/stats-paged-history.e2e.ts",
"tests/stats-truncated-tooltip.e2e.ts",
"tests/sidebar-scrollbar.e2e.ts",
"tests/sidebar-alignment.e2e.ts",
"tests/sidebar-title-hover-scroll.e2e.ts",
"tests/rail-search-expand.e2e.ts",
"tests/conversation-column-overflow.e2e.ts",
@@ -15,8 +15,8 @@
en: c26f81e8adf0128b
zh: e11ab371f51f66bc
/deepseek-ai-dsh-client-ui-settings-account/use-this-package:
en: 287911020aedb764
zh: 5a8c89af230b9e1f
en: 2e63a8b01d800894
zh: 42db1f28e2ddb2a3
/deepseek-ai-dsh-client-ui-settings-account/use-this-package/desktop-onboarding:
en: 7ae4e68f9491b068
zh: f7478269bf8d28b3
@@ -30,7 +30,7 @@ A new account-model sign-in-required event displays “Model unavailable. Please
Desktop and plain Web clients both mount the sidebar account launcher, Account settings, the sign-in step of model onboarding, and the account-state subscription. Desktop main opens the authorization page in the system browser. A plain Web client, identified by the absent Desktop preload bridge, sends `login_source` web with its own origin as the callback origin, preopens a tab during the sign-in click, navigates it to the authorization URL with the same `theme` parameter once its attempt receives one, and closes it on failure, expiry, or cancellation; a blocked popup leaves the copy-link route. Embedded Platform pages, Desktop onboarding, and the native device reader stay Desktop-only, so Web Platform links open new tabs.
The sidebar and Account settings display circular profile images with diameters of 24px and 32px respectively. Both observe one browser image load and show the account icon while no URL is available, loading is pending, or loading failed. A rendered image failure updates the shared result, so both surfaces return to the icon and remain eligible for retry; reports from a superseded picture attempt are ignored. Each Settings entry, or return from a Settings or quota-notice top-up page, retries a failed image once even when its URL is unchanged; pending and successful loads are reused, and success updates both surfaces. A changed picture URL starts a new load; account-state replacement, sign-out, and plugin unload discard the previous picture and its callbacks. The collapsed sidebar centers the avatar in a 36 × 36 px button. Before sign-in the sidebar launcher shows a compact More row instead, leading with the ellipsis glyph and aligning its content and inset hover fill with navigation rows. The expanded signed-in launcher retains its 24px avatar, 6px padding, and 8px label gap; the Settings shell preserves its outer bounds independently of the compact row alignment.
The sidebar and Account settings display circular profile images with diameters of 24px and 32px respectively. Both observe one browser image load and show the account icon while no URL is available, loading is pending, or loading failed. A rendered image failure updates the shared result, so both surfaces return to the icon and remain eligible for retry; reports from a superseded picture attempt are ignored. Each Settings entry, or return from a Settings or quota-notice top-up page, retries a failed image once even when its URL is unchanged; pending and successful loads are reused, and success updates both surfaces. A changed picture URL starts a new load; account-state replacement, sign-out, and plugin unload discard the previous picture and its callbacks. The collapsed sidebar centers the avatar in a 36 × 36 px button. Before sign-in the sidebar launcher shows a compact More row instead, leading with the ellipsis glyph and aligning its content and inset hover fill with navigation rows. Expanded buttons use border-box sizing: the signed-in row is 44px high including its 6px padding, and the signed-out More row is 32px high. The signed-in launcher retains its 24px avatar and 8px label gap and owns a 2px inline margin on its root; this inset does not move the Settings shell's adjacent status indicator horizontally.
The section registers through settings.section and uses the account Remote namespace. The stream survives carrier reconnects through the shared Remote supervisor. The page renders feature-owned English and Chinese copy and keeps API keys separate from account state.
@@ -30,7 +30,7 @@ kind: "package-reference"
Desktop 与普通 Web 客户端都挂载侧边栏账号菜单、账号设置、模型引导中的登录步骤,并订阅账号状态。Desktop 主进程在系统浏览器中打开授权页面。普通 Web 客户端(以缺少 Desktop preload 桥接识别)以 `login_source` web 发起登录,并以自身 origin 作为回调 origin;它在点击登录时预先打开一个标签页,本次尝试获得授权 URL 后带上相同的 `theme` 参数导航该标签页,并在失败、过期或取消时关闭它;弹窗被拦截时仍可使用复制链接。内嵌 Platform 页面、Desktop 引导和原生设备信息读取器仅在 Desktop 中提供,因此 Web 中的 Platform 链接在新标签页打开。
侧边栏和账号设置以正圆图片显示账号头像,直径分别为 24px 和 32px。两处共享一次浏览器图片加载,URL 缺失、加载中或加载失败时使用账号图标兜底。渲染图片失败时更新共享结果,两处一起恢复为图标,并保留重试机会;已被替换的图片加载尝试上报的失败会被忽略。每次进入设置,或从设置及余额不足提示打开的充值页面返回时,都会重试一次失败的图片,即使 URL 未变化;加载中和已成功的图片会复用,成功后两处一起展示。头像 URL 变化时开始新加载;账号状态替换、退出登录和插件卸载会丢弃旧头像及其回调。折叠侧边栏将头像居中放置在 36 × 36 px 的按钮内。登录前侧边栏入口改为紧凑的「更多」行,以三点图标开头,内容和内缩的悬停底色与导航行对齐。展开态已登录入口保留 24px 头像、6px 内边距及 8px 标签间距;设置外壳单独保留其外边界,不受紧凑行对齐影响。
侧边栏和账号设置以正圆图片显示账号头像,直径分别为 24px 和 32px。两处共享一次浏览器图片加载,URL 缺失、加载中或加载失败时使用账号图标兜底。渲染图片失败时更新共享结果,两处一起恢复为图标,并保留重试机会;已被替换的图片加载尝试上报的失败会被忽略。每次进入设置,或从设置及余额不足提示打开的充值页面返回时,都会重试一次失败的图片,即使 URL 未变化;加载中和已成功的图片会复用,成功后两处一起展示。头像 URL 变化时开始新加载;账号状态替换、退出登录和插件卸载会丢弃旧头像及其回调。折叠侧边栏将头像居中放置在 36 × 36 px 的按钮内。登录前侧边栏入口改为紧凑的「更多」行,以三点图标开头,内容和内缩的悬停底色与导航行对齐。展开态按钮采用 border-box 尺寸:已登录行含 6px 内边距共高 44px,未登录的「更多」行高 32px。已登录入口保留 24px 头像及 8px 标签间距,并由自身根节点承担左右各 2px 外边距;该内缩不会水平移动设置外壳中相邻的状态指示器。
该页面通过 settings.section 注册,并使用 account Remote 命名空间。共享 Remote 管理器负责连接恢复后的流重连。页面使用功能自有的中英文文案,将 API Key 与账号状态分开。
@@ -1,4 +1,5 @@
.root { flex: 1; min-width: 0; }
.root[data-inset='true'] { margin-inline: 2px; }
.anchor { width: 100%; }
.trigger { user-select: none; display: flex; align-items: center; width: 100%; height: 44px; gap: 8px; padding: 6px calc(var(--dsh-sidebar-row-padding) - var(--dsh-sidebar-paint-inset)); border: 0; border-inline: var(--dsh-sidebar-paint-inset) solid transparent; box-sizing: border-box; border-radius: var(--dsw-radius-md); background: transparent; background-clip: padding-box; color: var(--dsw-alias-label-primary); font: inherit; font-size: 14px; cursor: pointer; }
.trigger[data-signed-out='false']:not([data-collapsed='true']) { padding: 6px; border-inline: 0; background-clip: border-box; }
@@ -54,7 +54,7 @@ export function AccountMenu({
}
// The plugin's start publishes `loginFailed` before it rejects, so the dialog owns the report.
const beginSignIn = (): void => { setOpen(false); void start().catch(() => undefined) }
return <div ref={anchor} className={css.root}>
return <div ref={anchor} className={css.root} data-inset={signedIn && wide}>
{signInNotice > 0 && <Toast key={signInNotice} text={t('modelSignInRequired')} onDone={() => { setSignInNotice(0) }} />}
{expiryNotice && <Toast text={t('sessionExpired')} onDone={() => { setExpiryNotice(false) }} />}
{signedIn && !account.desktopOnboardingVisible && !account.onboarding && account.notices && <AccountNoticeStack
@@ -0,0 +1,18 @@
/** Account-owned sidebar spacing and total control heights. */
import { readFileSync } from 'node:fs'
import { expect, it } from 'vitest'
const css = readFileSync(new URL('../src/client/AccountMenu.module.css', import.meta.url), 'utf8')
it('insets only the account root without resizing the settings carrier', () => {
expect(css).toMatch(/\.root\[data-inset='true'\]\s*\{\s*margin-inline: 2px;\s*\}/)
expect(css).toMatch(/\.root\s*\{[^}]*flex: 1;[^}]*min-width: 0;/)
})
it('includes padding and borders in the 44px account and 32px More rows', () => {
const trigger = css.match(/\.trigger\s*\{([^}]+)\}/)?.[1]
expect(trigger).toContain('box-sizing: border-box;')
expect(trigger).toContain('height: 44px;')
expect(css).toMatch(/\.trigger\[data-signed-out='true'\]:not\(\[data-collapsed='true'\]\)\s*\{[^}]*height: 32px;/)
expect(css).toMatch(/\.trigger\[data-collapsed='true'\]\s*\{[^}]*width: 36px;[^}]*height: 36px;/)
})
@@ -201,6 +201,30 @@ it.each([en, zh])('offers settings, contact and sign-in from the signed-out acco
expect(operations.contactUs).toHaveBeenCalledOnce()
})
it.each([true, false])('keeps the account inset local through sign-in changes with wide=%s', async (wide) => {
const { AccountMenu } = await import('../src/client/AccountMenu.tsx')
let operations = operationsOf({ status: 'signed-out', attempt: null })
const props: AccountMenuProps = {
...({} as GlobalStandardProps), ...operations, wide, settingsOpen: false,
useAccount: selector => selector(operations.hooks.account.getSnapshot()),
useTheme: selector => selector(operations.hooks.theme.getSnapshot()),
openSettings: vi.fn(), openOnboarding: vi.fn(), t: key => en[key as AccountKey],
}
const view = render(<AccountMenu {...props} />)
const root = view.container.firstElementChild
const trigger = screen.getByRole('button', { name: en.menu })
trigger.focus()
expect(root?.getAttribute('data-inset')).toBe('false')
operations = operationsOf({ status: 'credential-stored', attempt: null })
view.rerender(<AccountMenu {...props} />)
expect(root?.getAttribute('data-inset')).toBe(String(wide))
expect(screen.getByRole('button', { name: en.menu })).toBe(trigger)
expect(document.activeElement).toBe(trigger)
operations = operationsOf({ status: 'signed-out', attempt: null })
view.rerender(<AccountMenu {...props} />)
expect(root?.getAttribute('data-inset')).toBe('false')
})
it('reports a failed start in the login dialog, not as a sidebar alert', async () => {
const operations = mount({ status: 'signed-out', attempt: null })
cleanup()
@@ -27,8 +27,8 @@
en: ba55fe92bf49dc5a
zh: 9a0e1345496a0ae6
/deepseek-ai-dsh-client-ui-settings-general/understand-the-implementation:
en: d712fe4de4bb14f8
zh: 265397283ed702f6
en: 9c8c35ae0e672b14
zh: 503c8e9d24a93d88
/deepseek-ai-dsh-client-ui-settings-general/understand-the-implementation/ledger-projections:
en: 3e3c2c72f87e8b23
zh: 12b10ba2d37c9077
@@ -66,7 +66,7 @@ Access: the settings shell and the `settings.open` shortcut are registered only
<a id="understand-the-implementation"></a>
## Understand the implementation
The shell declares settings.launcher for an account-owned sidebar menu and retains the Settings button as its fallback. The launcher receives settingsOpen, whose false-to-true edge marks one Settings entry, so a registrant acts once per entry instead of on every re-render inside one open. Closing the dialog returns focus to the active launcher. In the expanded sidebar, the fallback uses the navigation rows' compact icon-label alignment and inset hover fill; a signed-in account launcher instead keeps a root wrapper inset 2px on each side to preserve its outer bounds. The collapsed rail retains its centered 36 × 36 px control.
The shell declares settings.launcher for an account-owned sidebar menu and retains the Settings button as its fallback. The launcher receives settingsOpen, whose false-to-true edge marks one Settings entry, so a registrant acts once per entry instead of on every re-render inside one open. Closing the dialog returns focus to the active launcher. In the expanded sidebar, the fallback uses the navigation rows' compact icon-label alignment and inset hover fill. Contributed launchers own their insets; the shell keeps its row full-width and its status indicator's trailing edge independent of account state. The collapsed rail retains its centered 36 × 36 px control.
<details>
<summary>Implementation internals — click to expand</summary>
@@ -66,7 +66,7 @@ Web 与桌面端的通用设置底部显示当前发布版本,使用构建注
<a id="understand-the-implementation"></a>
## 理解实现
外壳声明 settings.launcher,供账号功能提供侧边栏菜单,并以设置按钮作为回退。入口接收 settingsOpen,其 false→true 边沿代表一次进入设置页,因此注册方在每次进入时执行一次,而不是在同一次打开中的每次重渲染执行。关闭对话框后,焦点返回当前入口。侧栏展开时,回退按钮采用导航行的紧凑图文对齐及内缩悬停底色;已登录账号入口则由根包装层左右各内缩 2px,保留其外边界。折叠轨道保留居中的 36 × 36 px 控件。
外壳声明 settings.launcher,供账号功能提供侧边栏菜单,并以设置按钮作为回退。入口接收 settingsOpen,其 false→true 边沿代表一次进入设置页,因此注册方在每次进入时执行一次,而不是在同一次打开中的每次重渲染执行。关闭对话框后,焦点返回当前入口。侧栏展开时,回退按钮采用导航行的紧凑图文对齐及内缩悬停底色。贡献入口自行拥有内缩;外壳保持行宽占满可用空间,状态指示器的尾侧边缘不随账号状态移动。折叠轨道保留居中的 36 × 36 px 控件。
<details>
<summary>实现细节——点击展开</summary>
@@ -8,11 +8,6 @@
margin: 4px 0;
}
.triggerRow:not(.railRow):has(button[data-signed-out='false']) {
width: calc(100% - 4px);
margin-inline: 2px;
}
.triggerRow.railRow {
width: 36px;
margin: 8px 0 10px;
@@ -0,0 +1,11 @@
/** Settings carrier geometry is independent of contributed launcher markup. */
import { readFileSync } from 'node:fs'
import { expect, it } from 'vitest'
const css = readFileSync(new URL('../src/client/SettingsRoot.module.css', import.meta.url), 'utf8')
it('keeps the indicator carrier full-width without inspecting account state', () => {
expect(css).toMatch(/\.triggerRow\s*\{[^}]*width: 100%;[^}]*margin: 4px 0;/)
expect(css).not.toContain('data-signed-out')
expect(css).toMatch(/\.triggerRow\.railRow\s*\{[^}]*width: 36px;/)
})
+2 -2
View File
@@ -36,8 +36,8 @@
en: 8855ad013285a27e
zh: f19b3e75c38c5b0a
/deepseek-ai-dsh-client-ui-sidebar/understand-the-implementation:
en: f56d373d62c574fc
zh: 7d085555f9a22e05
en: 8cc657bd9b66c93f
zh: 47ad4bd9e906ef8e
/deepseek-ai-dsh-client-ui-sidebar/understand-the-implementation/slot-discipline:
en: 6eefc5c45abe8a8f
zh: 20b651e48a0ea903
+3 -1
View File
@@ -76,7 +76,9 @@ On Desktop, the official navigation controls report `sidebar_menu_click` with `m
The shell is pure composition: `SidebarRootComponentProps` composes the layout owner share, the global `useSessions` and `useWorkspaces` hooks, the declared brand, the `sidebar.workspaces` and `sidebar.settings` child slots, and injected navigation callbacks. The sidebar owns `sidebar.panellist` and renders each panel's registered icon directly. Panel metadata is derived from list registrations and locale changes; selection belongs to the layout store. The injected `trackEntryClick(attributes)` forwards to `ctx.get('productAnalytics')?.track('sidebar_menu_click', attributes)`, and `selectPanel` only selects, so each control supplies its own entry and position.
Expanded navigation uses 8px outer gutters, hover and selected fills inset 3px on each side, and a 16px leading cell with a 6px label gap. New Session centers its content with 4px side margins and a 10px bottom margin. The logo row has no bottom margin; its content starts 16px from the column's left edge, with 5px between mark and name. Only the macOS non-fullscreen top toggle shifts 4px right while expanded and not fading. Collapsed geometry stays independent.
Expanded navigation uses 8px outer gutters, hover and selected fills inset 3px on each side, and a 16px leading cell with a 6px label gap. New Session centers its content with 4px side margins and a 10px bottom margin. The logo row has no bottom margin; its content starts 16px from the column's left edge, with 5px between mark and name. Only the macOS non-fullscreen top toggle shifts 4px right while expanded, retaining that offset throughout its fade-out. Collapsed geometry stays independent.
The sidebar root supplies inherited CSS properties to `sidebar.workspaces` and `sidebar.settings`: `--dsh-sidebar-inline-padding: 8px`, `--dsh-sidebar-row-padding: 12px`, `--dsh-sidebar-label-gap: 6px`, and `--dsh-sidebar-paint-inset: 3px`. Occupants consume these values for expanded row alignment; standalone compositions must provide them on an ancestor. The paint inset reduces the fill without moving content: rows subtract it from their inline padding and use it as a transparent border.
### Slot discipline
+3 -1
View File
@@ -76,7 +76,9 @@ root 作用域的 `sidebar.panellist.actions` list 有注册项时,导航区
外壳是纯组合:`SidebarRootComponentProps` 组合布局 owner share、全局 `useSessions` 与 `useWorkspaces` 钩子、已声明的品牌、`sidebar.workspaces` 与 `sidebar.settings` 子 slot,以及注入的导航回调。侧边栏声明 `sidebar.panellist`,并直接渲染各面板注册的图标。面板元数据由列表注册和 locale 变化派生;选中态属于布局存储。注入的 `trackEntryClick(attributes)` 转发到 `ctx.get('productAnalytics')?.track('sidebar_menu_click', attributes)`;`selectPanel` 只负责选中,因此由各控件自行提供入口与位置。
展开态导航的左右外距为 8px,悬停与选中底色在两侧各内缩 3px,前置单元宽 16px,与标签间隔 6px。新会话内容居中,左右外边距为 4px,底部外边距为 10px。logo 行无底部外边距;内容从侧栏左边缘向内 16px 开始,标记与名称间隔 5px。仅 macOS 非全屏、侧栏展开且未淡出时,顶部开关向右偏移 4px。折叠态尺寸独立保留。
展开态导航的左右外距为 8px,悬停与选中底色在两侧各内缩 3px,前置单元宽 16px,与标签间隔 6px。新会话内容居中,左右外边距为 4px,底部外边距为 10px。logo 行无底部外边距;内容从侧栏左边缘向内 16px 开始,标记与名称间隔 5px。仅 macOS 非全屏、侧栏展开时,顶部开关向右偏移 4px,并在整个淡出期间保持该偏移。折叠态尺寸独立保留。
侧边栏根节点向 `sidebar.workspaces` 与 `sidebar.settings` 提供可继承的 CSS 属性:`--dsh-sidebar-inline-padding: 8px`、`--dsh-sidebar-row-padding: 12px`、`--dsh-sidebar-label-gap: 6px` 和 `--dsh-sidebar-paint-inset: 3px`。占用者使用这些值对齐展开态行;独立组合必须在祖先节点提供这些值。底色内缩不移动内容:行从左右内边距中减去该内缩值,并以等宽透明边框承载内缩。
### slot 纪律
@@ -79,7 +79,6 @@
:global([data-windows-titlebar]) .root:not(.collapsed) .newSession {
margin-top: 6px;
margin-left: calc(var(--dsh-sidebar-paint-inset) + 1px);
}
:global([data-windows-titlebar]) .collapsed .panelList,
@@ -195,7 +194,7 @@
padding: 0 12px 2px;
}
:global(html[data-platform='darwin']:not([data-fullscreen])) .root:not(.collapsed):not(.fading) > .topStrip > .toggle {
:global(html[data-platform='darwin']:not([data-fullscreen])) .root:not(.collapsed) > .topStrip > .toggle {
transform: translateX(4px);
}
@@ -212,7 +211,8 @@
margin-top: -12px;
}
/* Logo row (figma pad (4,8,4,8)): expanded keeps the panel toggle at the
/* Logo row: 8px vertical and leading padding, with no trailing padding or
bottom margin. Expanded keeps the panel toggle at the
right edge; the rail gives every control the same base left anchor before
the shared entry translation is applied. macOS hiddenInset titlebar: the row
is marked `data-window-drag` in markup (SidebarRoot), so ui-web base.css's
@@ -534,8 +534,8 @@
margin-bottom: 8px;
}
/* Panel entries share the New Session bar's 12px radius and 2px inset, the
session rows' hover/selected surface, and the primary ink. */
/* Panel entries use a 3px paint inset, the New Session bar's 12px radius,
the session rows' hover/selected fill, and the primary ink. */
.panelRow {
display: flex;
align-items: center;
@@ -53,7 +53,9 @@ describe('SidebarRoot.module.css', () => {
expect(declarations('.logoRow')?.get('margin-bottom')).toBe('0')
expect(declarations('.newSession')?.get('margin')).toBe('0 calc(var(--dsh-sidebar-paint-inset) + 1px) 10px')
expect(declarations('.newSessionContent')?.get('justify-content')).toBe('center')
expect(declarations(":global(html[data-platform='darwin']:not([data-fullscreen])) .root:not(.collapsed):not(.fading) > .topStrip > .toggle")?.get('transform')).toBe('translateX(4px)')
expect(declarations(":global(html[data-platform='darwin']:not([data-fullscreen])) .root:not(.collapsed) > .topStrip > .toggle")?.get('transform')).toBe('translateX(4px)')
expect(css).not.toContain(':not(.fading)')
expect(declarations(':global([data-windows-titlebar]) .root:not(.collapsed) .newSession')?.has('margin-left')).toBe(false)
expect(declarations('.topStrip')?.get('height')).toBe('52px')
expect(declarations('.root.collapsed')?.get('padding')).toBe('18px 10px 6px')
})
@@ -24,8 +24,8 @@
en: a94394a5a9f70ed8
zh: 62ce596a95395ad8
/deepseek-ai-dsh-client-ui-workspace/use-this-package/search:
en: 29facc4eae028dae
zh: 754ebac4cc294e65
en: a706353ea1ad9b56
zh: 0e7738b0ec518892
/deepseek-ai-dsh-client-ui-workspace/use-this-package/managing-sessions:
en: 353ed9b66fab6486
zh: e38a4a37627a9426
+1 -1
View File
@@ -49,7 +49,7 @@ On devices without hover, Workspace rows keep their menu and New Session control
### Search
Collapsed search is one header action beside the view and add actions: activating it expands the field across the header. A non-blank query replaces either browsing mode with one flat result list — case-insensitive title, Workspace, and Session id substring matches appear immediately, while a 250 ms debounced Host request adds ranked current-conversation content matches and snippets. Each new query aborts the preceding request; a failed content search leaves metadata matches visible without an additional warning. The list is capped at 20. Choosing an unarchived result clears and collapses search, opens the Session, and scrolls its row into view in the configured browsing mode; grouped browsing also expands its Workspace and the full Session list when required. Archived results offer Unarchive; attempting to open one explains that restriction without clearing the query or navigating.
Collapsed search is one header action beside the view and add actions: activating it expands the field across the header. Header spacing and field expansion share a 180 ms transition, disabled for reduced motion. Search titles leave space before the Unarchive click target even when truncated. A non-blank query replaces either browsing mode with one flat result list — case-insensitive title, Workspace, and Session id substring matches appear immediately, while a 250 ms debounced Host request adds ranked current-conversation content matches and snippets. Each new query aborts the preceding request; a failed content search leaves metadata matches visible without an additional warning. The list is capped at 20. Choosing an unarchived result clears and collapses search, opens the Session, and scrolls its row into view in the configured browsing mode; grouped browsing also expands its Workspace and the full Session list when required. Archived results offer Unarchive; attempting to open one explains that restriction without clearing the query or navigating.
### Managing sessions
+1 -1
View File
@@ -49,7 +49,7 @@ kind: "package-reference"
### 搜索
折叠搜索是视图和添加操作旁的一枚区头按钮:激活后输入框会扩展并占据区头。非空白查询会以单一扁平结果列表替代任一浏览模式——不区分大小写的标题、Workspace 和 Session id 子串匹配项会立即显示,经 250 ms 防抖的 Host 请求则会加入经过排序的当前对话内容匹配项及其摘要片段。每次新查询都会中止前一个请求;内容搜索失败时,元数据匹配项仍会显示,不另给警告。列表最多显示 20 条结果。选择未归档结果会清空并收起搜索、打开 Session,并在当前浏览模式中将其行滚动到可见区域;分组浏览还会按需展开所属 Workspace 和完整 Session 列表。已归档结果提供取消归档操作;尝试打开时会说明限制,不清空查询,也不导航。
折叠搜索是视图和添加操作旁的一枚区头按钮:激活后输入框会扩展并占据区头。区头间距与输入框展开采用同步的 180 ms 过渡,减少动态效果模式下禁用。搜索标题即使被截断,也会在取消归档的点击区域前留出间距。非空白查询会以单一扁平结果列表替代任一浏览模式——不区分大小写的标题、Workspace 和 Session id 子串匹配项会立即显示,经 250 ms 防抖的 Host 请求则会加入经过排序的当前对话内容匹配项及其摘要片段。每次新查询都会中止前一个请求;内容搜索失败时,元数据匹配项仍会显示,不另给警告。列表最多显示 20 条结果。选择未归档结果会清空并收起搜索、打开 Session,并在当前浏览模式中将其行滚动到可见区域;分组浏览还会按需展开所属 Workspace 和完整 Session 列表。已归档结果提供取消归档操作;尝试打开时会说明限制,不清空查询,也不导航。
### 管理会话
@@ -66,7 +66,7 @@
.searchResultTitle {
flex: 0 1 auto;
min-width: 0;
margin-left: var(--dsh-sidebar-label-gap);
margin: 0 var(--dsh-sidebar-label-gap);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
@@ -659,7 +659,6 @@ export function SessionNodeItem({
rowRef.current?.scrollIntoView({ block: 'nearest' })
onReveal()
}, [onReveal])
// Figma session cell: pad 8, status slot 16, then a 4px title gap.
const ownRow = (
<div
ref={rowRef}
@@ -70,6 +70,10 @@
.root:not(.rail) .sectionHeader {
margin-top: 2px;
margin-right: -4px;
transition:
margin-right 180ms var(--ds-ease-in-out),
padding-inline 180ms var(--ds-ease-in-out),
gap 180ms var(--ds-ease-in-out);
}
.root:not(.rail) .sectionHeader:not(:has(.searchExpanded)) {
@@ -637,6 +641,7 @@
animation: none;
}
.root:not(.rail) .sectionHeader,
.search,
.sectionLabel,
.searchSlot,
@@ -110,6 +110,7 @@ describe('WorkspaceBrowser.module.css list', () => {
expect(rowDeclarations('.slot')?.get('height')).toBe('20px')
expect(rowDeclarations('.sessionRow .title')?.get('margin')).toBe('0 6px 0 var(--dsh-sidebar-label-gap)')
expect(rowDeclarations('.searchResultRow')?.get('min-height')).toBe('48px')
expect(rowDeclarations('.searchResultTitle')?.get('margin')).toBe('0 var(--dsh-sidebar-label-gap)')
expect(rowDeclarations('.sessionRow.selected')?.get('background-color'))
.toBe('var(--dsw-alias-interactive-bg-hover)')
})
@@ -146,7 +147,6 @@ describe('WorkspaceBrowser.module.css list', () => {
expect(declarations('.searchExpanded')?.get('margin-inline')).toBe('0')
expect(declarations('.searchExpanded .searchButton')?.get('height')).toBe('32px')
expect(declarations('.sectionHeader:has(.searchExpanded) .sectionLabelHidden')?.get('margin-right')).toBe('0')
expect(declarations('.root:not(.rail) .sectionHeader')?.get('position')).toBeUndefined()
expect(declarations('.list')?.get('overflow-y')).toBe('auto')
})
@@ -13,9 +13,9 @@
- --dsh-scrollbar-thumb-hover, pointer over the list: rgb(212, 212, 212)
- list overflows: true
- reserved band: 5px
- scrollbar inset from the sidebar edge: 2px
- row hitbox inset from the sidebar edge: 8px
- row background inset from the sidebar edge: 11px
- scrollbar inset from the list parent edge: 2px
- row hitbox inset from the list parent edge: 8px
- row background inset from the list parent edge: 11px
- relative time covered by the bar: 0px
- relative time ends inside the content area: true
- content area ends before the border box: true
@@ -33,9 +33,9 @@
- --dsh-scrollbar-thumb-hover, pointer over the list: rgb(101, 103, 107)
- list overflows: true
- reserved band: 5px
- scrollbar inset from the sidebar edge: 2px
- row hitbox inset from the sidebar edge: 8px
- row background inset from the sidebar edge: 11px
- scrollbar inset from the list parent edge: 2px
- row hitbox inset from the list parent edge: 8px
- row background inset from the list parent edge: 11px
- relative time covered by the bar: 0px
- relative time ends inside the content area: true
- content area ends before the border box: true
+1
View File
@@ -143,6 +143,7 @@
"apps/web/tests/stats-paged-history.e2e.ts",
"apps/web/tests/stats-truncated-tooltip.e2e.ts",
"apps/web/tests/sidebar-scrollbar.e2e.ts",
"apps/web/tests/sidebar-alignment.e2e.ts",
"apps/web/tests/sidebar-title-hover-scroll.e2e.ts",
"apps/web/tests/rail-search-expand.e2e.ts",
"apps/web/tests/conversation-column-overflow.e2e.ts",