diff --git a/.agents/skills/dsh-client-ui-ux/SKILL.md b/.agents/skills/dsh-client-ui-ux/SKILL.md index 6d293a5f86..0b8b72de99 100644 --- a/.agents/skills/dsh-client-ui-ux/SKILL.md +++ b/.agents/skills/dsh-client-ui-ux/SKILL.md @@ -67,6 +67,10 @@ Use [dsh-responsive-ui](../dsh-responsive-ui/SKILL.md) for responsive layouts, m - Review every changed region for gaps: nothing sits flush against its neighbour without an intentional gap, and adjacent icons and elements keep the region's established spacing. - Prefer symmetry for parallel elements; an unexplained one-off gap or offset usually signals a missed shared value. +- **For complex pages and containers, establish shared horizontal and vertical alignment axes before adjusting local spacing.** Consider hover, selected, and expanded/collapsed states, as well as the overall relationship with adjacent panels and other page elements. Choose from the following references according to layout similarity: + - **Sidebar:** align corresponding text starts and leading icons across vertically stacked rows. Align hover and selected row backgrounds with the buttons above the list to maintain visual balance. + - **Plugins page:** keep content left-aligned along a shared edge. List-row hover backgrounds may extend beyond that edge while the content stays in place. + - **Conversation and right-sidebar headers:** align the first header rows across both panels and match their heights. Apply the same rule to the second rows, and align corresponding underlines and dividers on shared horizontal baselines. ## Design review escalation diff --git a/apps/web/tests/expected/sidebar-alignment/account-signed-in.expected.md b/apps/web/tests/expected/sidebar-alignment/account-signed-in.expected.md new file mode 100644 index 0000000000..81589bea33 --- /dev/null +++ b/apps/web/tests/expected/sidebar-alignment/account-signed-in.expected.md @@ -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 + } +} diff --git a/apps/web/tests/expected/sidebar-alignment/account-signed-out.expected.md b/apps/web/tests/expected/sidebar-alignment/account-signed-out.expected.md new file mode 100644 index 0000000000..3bacdca875 --- /dev/null +++ b/apps/web/tests/expected/sidebar-alignment/account-signed-out.expected.md @@ -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 + } +} diff --git a/apps/web/tests/expected/sidebar-alignment/geometry.expected.md b/apps/web/tests/expected/sidebar-alignment/geometry.expected.md new file mode 100644 index 0000000000..5282e0ea90 --- /dev/null +++ b/apps/web/tests/expected/sidebar-alignment/geometry.expected.md @@ -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 diff --git a/apps/web/tests/sidebar-alignment.e2e.ts b/apps/web/tests/sidebar-alignment.e2e.ts new file mode 100644 index 0000000000..6ea3f3dedb --- /dev/null +++ b/apps/web/tests/sidebar-alignment.e2e.ts @@ -0,0 +1,561 @@ +/** 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('[class*="title"]') + if (title === null) throw new Error('row title not in the DOM') + const buttons = [...element.querySelectorAll('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 + + 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('button[aria-label="Search sessions"]') + const input = element.querySelector('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((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.each([ + { usedSearch: false, entry: 'toggle' }, + { usedSearch: true, entry: 'toggle' }, + { usedSearch: false, entry: 'search' }, + { usedSearch: true, entry: 'search' }, + { usedSearch: true, entry: 'toggle-with-query' }, + ] as const)('lands the header at its final rail-to-wide geometry ($entry, prior search: $usedSearch)', async ({ usedSearch, entry }) => { + onTestFailed(() => saveFailureShot(page, `web-e2e-sidebar-rail-${entry}-${String(usedSearch)}`)) + if (usedSearch) { + await page.getByRole('button', { name: 'Search sessions', exact: true }).click() + const input = page.getByPlaceholder('Search session names', { exact: true }) + if (entry === 'toggle-with-query') await input.fill(sessionTitle) + else await input.press('Escape') + } + await page.getByRole('button', { name: 'Collapse sidebar', exact: true }).click() + await expect.poll(() => page.getByRole('button', { name: 'Account menu', exact: true }).getAttribute('data-collapsed')).toBe('true') + const add = page.getByRole('button', { name: 'Add workspace', exact: true }) + const samples = await add.evaluate(async (button, entry) => { + const header = button.closest('[class*="sectionHeader"]') + const root = header?.parentElement + if (header === null || root === null || root === undefined) throw new Error('Workspace header is missing') + const railClass = root.className + const measure = () => { + const origin = root.getBoundingClientRect() + const bounds = header.getBoundingClientRect() + const addBounds = button.getBoundingClientRect() + const style = getComputedStyle(header) + return { + left: bounds.left - origin.left, right: bounds.right - origin.left, + top: bounds.top - origin.top, height: bounds.height, + // Search hides this cluster with its own animation; only compare the visible add control. + add: entry === 'toggle' + ? { left: addBounds.left - origin.left, right: addBounds.right - origin.left } + : null, + marginRight: Number.parseFloat(style.marginRight), + paddingLeft: Number.parseFloat(style.paddingLeft), paddingRight: Number.parseFloat(style.paddingRight), + gap: Number.parseFloat(style.columnGap), + transitionDuration: style.transitionDuration, + searchExpanded: header.querySelector('button[aria-label="Search sessions"]')?.getAttribute('aria-expanded'), + } + } + const first = await new Promise>((resolve) => { + // Observe the first rendered wide frame after the rail gesture's React updates. + const observer = new MutationObserver(() => { + if (root.className === railClass) return + observer.disconnect() + requestAnimationFrame(() => { resolve(measure()) }) + }) + observer.observe(root, { attributes: true, attributeFilter: ['class'] }) + const trigger = entry === 'search' + ? root.querySelector('button[aria-label="Search sessions"]') + : document.querySelector('button[aria-label="Open sidebar"]') + if (trigger === null) { observer.disconnect(); throw new Error('Rail expansion trigger is missing') } + trigger.click() + }) + const headerTransitions = header.getAnimations() + .filter(animation => animation instanceof CSSTransition) + .map(animation => animation.transitionProperty) + await Promise.all(root.getAnimations({ subtree: true }) + .filter(animation => animation.effect?.getTiming().iterations !== Infinity) + .map(animation => animation.finished.then(() => undefined, () => undefined))) + return { + first, last: measure(), headerTransitions, + sameHeader: root.querySelector('[class*="sectionHeader"]') === header, + sameAdd: root.querySelector('button[aria-label="Add workspace"]') === button, + } + }, entry) + expect(samples.headerTransitions).toEqual([]) + expect(samples.first.transitionDuration).toBe('0s') + expect(samples.first).toEqual(samples.last) + expect(samples.first).toMatchObject(entry !== 'toggle' + ? { marginRight: 0, paddingLeft: 4, paddingRight: 4, gap: 0, searchExpanded: 'true' } + : { marginRight: -4, paddingLeft: 12, paddingRight: 1, gap: 4, searchExpanded: 'false' }) + expect(samples.sameHeader).toBe(true) + expect(samples.sameAdd).toBe(true) + if (entry === 'search') { + await expect.poll(() => page.getByPlaceholder('Search session names', { exact: true }) + .evaluate(element => element === document.activeElement)).toBe(true) + } + expect(tripwire.pageErrors).toEqual([]) + }) + + 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('[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 }) + await row.evaluate((element) => { + element.parentElement!.style.setProperty('--dsh-workspace-indent', '24px') + element.parentElement!.style.setProperty('--unrelated-preview-variable', '17px') + }) + 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)) + expect(await preview.evaluate(element => [...element.parentElement!.style].filter(name => name.startsWith('--')).sort())) + .toEqual(['--dsh-sidebar-label-gap', '--dsh-sidebar-paint-inset', '--dsh-sidebar-row-padding']) + const titleOffset = (target: Locator) => target.evaluate((element) => { + const title = element.querySelector('[class*="title"]') + if (title === null) throw new Error('Row title is missing') + return title.getBoundingClientRect().left - element.getBoundingClientRect().left + }) + expect(await titleOffset(preview)).toBe(await titleOffset(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((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(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([]) + }) +}) diff --git a/apps/web/tests/sidebar-scrollbar.e2e.ts b/apps/web/tests/sidebar-scrollbar.e2e.ts index f15a893682..afcedbf0ca 100644 --- a/apps/web/tests/sidebar-scrollbar.e2e.ts +++ b/apps/web/tests/sidebar-scrollbar.e2e.ts @@ -37,6 +37,7 @@ interface ListMetrics { band: number scrollbarEdgeOffset: number rowEdgeInset: number + rowPaintInset: number clientRight: number borderRight: number timeRight: number @@ -93,8 +94,8 @@ function measureList(page: Page): Promise { 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, @@ -106,8 +107,9 @@ function measureList(page: Page): Promise { 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, + 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, @@ -129,17 +131,18 @@ function measureList(page: Page): Promise { * @param page - the page under test. * @returns the list overflow state and first row's trailing inset. */ -function measureRowInset(page: Page): Promise> { +function measureRowInset(page: Page): Promise> { return page.evaluate(() => { const list = document.querySelector('[role="tree"][aria-label="Sessions"]') if (list === null) throw new Error('sidebar session list not in the DOM') const row = list.querySelector('[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, + rowEdgeInset: listParentRight - row.getBoundingClientRect().right, + rowPaintInset: listParentRight - row.getBoundingClientRect().right + Number.parseFloat(getComputedStyle(row).borderRightWidth), } }) } @@ -192,8 +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 background inset from the sidebar edge: ${String(metrics.rowEdgeInset)}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)}`, @@ -321,7 +325,8 @@ describe('web e2e: sidebar session list scrollbar (reserved gutter / themed thum // Pin presence, not width, because the width is platform-dependent. expect(metrics.band).toBeGreaterThan(0) expect(metrics.scrollbarEdgeOffset).toBe(2) - expect(metrics.rowEdgeInset).toBe(12) + expect(metrics.rowEdgeInset).toBe(8) + expect(metrics.rowPaintInset).toBe(11) // Measure against the bar because overlay scrollbars do not move the client edge. expect(metrics.timeCoveredBy).toBe(0) expect(metrics.timeRight).toBeLessThanOrEqual(metrics.clientRight) @@ -359,12 +364,12 @@ describe('web e2e: sidebar session list scrollbar (reserved gutter / themed thum it('keeps the row background inset when overflow disappears', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-sidebar-scrollbar-stable-inset')) - expect(await measureRowInset(page)).toEqual({ overflows: true, rowEdgeInset: 12 }) + expect(await measureRowInset(page)).toEqual({ overflows: true, rowEdgeInset: 8, rowPaintInset: 11 }) const bucket = page.getByText('Ungrouped', { exact: true }).locator('..').locator('..') await bucket.click() try { await expect.poll(async () => (await measureRowInset(page)).overflows, { timeout: 10_000 }).toBe(false) - expect(await measureRowInset(page)).toEqual({ overflows: false, rowEdgeInset: 12 }) + expect(await measureRowInset(page)).toEqual({ overflows: false, rowEdgeInset: 8, rowPaintInset: 11 }) } finally { await expandSeededSessions(page) } diff --git a/apps/web/tests/window-drag-coverage.e2e.ts b/apps/web/tests/window-drag-coverage.e2e.ts index cdbb727736..87c468a901 100644 --- a/apps/web/tests/window-drag-coverage.e2e.ts +++ b/apps/web/tests/window-drag-coverage.e2e.ts @@ -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>((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 { diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json index f882829d98..e9df1e11f1 100644 --- a/apps/web/tsconfig.json +++ b/apps/web/tsconfig.json @@ -157,6 +157,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", diff --git a/packages/client/ui-primitives/README.i18n.yaml b/packages/client/ui-primitives/README.i18n.yaml index e3b1049c7d..e6a2cb577d 100644 --- a/packages/client/ui-primitives/README.i18n.yaml +++ b/packages/client/ui-primitives/README.i18n.yaml @@ -18,8 +18,8 @@ en: 10be7f75a1576575 zh: abf2905d89b0da48 /deepseek-ai-dsh-client-ui-primitives/use-this-package/component-catalog: - en: 3c79351ff2210ccb - zh: 4cbe07ded89bbaa1 + en: 289fe5c01b026e81 + zh: 5680066a8443fba9 /deepseek-ai-dsh-client-ui-primitives/use-this-package/controls-and-icons: en: b3744373b4b2c1fb zh: 2dbde8ee0bfa5f28 diff --git a/packages/client/ui-primitives/README.md b/packages/client/ui-primitives/README.md index a1285641e2..d95d802886 100644 --- a/packages/client/ui-primitives/README.md +++ b/packages/client/ui-primitives/README.md @@ -59,7 +59,7 @@ Check this table before writing a control in a feature package. A plugin cannot | `ConnectionIndicator` | Inline connection-recovery control across outage, retry, and recovered states. | | `DisclosureRow` | 24px compact disclosure that lays title and content side by side. The header supplies tertiary at rest and secondary on hover; text and icons inherit it regardless of expandability or open state unless the caller supplies a feature-specific override. Semantic status colors remain explicit. Collapsed rows preview a down chevron on hover, and expanded rows keep an up chevron visible. `headerAccessory` places an independent action outside the disclosure target and shimmer; with an accessory, the title row shrinks to its content width. Memoized with shallow prop comparison; keep callbacks and React-node props stable when their content is unchanged. | | `AnimatedRows` | Keyed list movement with 200 ms glides and 100 ms entry/exit fades. Starts after local pointer or keyboard input, honors reduced motion, and settles loading or view replacement. Mark rows with `data-row-key`, supply matching `rowKeys`, and position the parent for the inert exit overlay; `role` and `label` belong to the caller. | -| `useRowDragPreview` | Native drag preview using an inert offscreen row copy. Supply a class for the surface background and, for a child drag handle, pass the row to `start`; `clear` and unmount remove the copy. | +| `useRowDragPreview` | Native drag preview using an inert offscreen row copy. The optional `inheritedProperties` list names the CSS custom properties whose computed values the offscreen wrapper copies; omitting it copies none. Keep the list reference stable, because replacing it clears an active preview. Unlisted custom properties follow normal inheritance at the preview's location. Supply a class for the surface background and, for a child drag handle, pass the row to `start`; `clear` and unmount remove the copy. | | `TextShimmer` | One left-to-right highlight across a row’s title, separators, summary, and suffix, with a 300ms initial delay, a one-second sweep and a 500ms rest. The mask spans the content width, capped by the visible row and tilts 15° from vertical, with a flat peak and soft edges. The moving decoration stays clipped to the row without enlarging its scrollable area. Base text inherits the caller’s color, including hover changes; the theme supplies the translucent highlight. Place icons and chevrons outside `TextShimmer` so only text and separators receive the highlight. A string child uses the same retained text interface; nested `TextShimmer` instances share the outer animation. Composite children render twice while active and must have no effects or element ids. Mark separators with `data-shimmer-decoration` so their fill receives the highlight. `DisclosureRow` forwards `contentClassName` for the text area and `contentLayoutClassName` for its inner layout. The inert decorative copy is excluded from interaction, selection, and accessibility; text updates retain the animation, and reduced-motion mode keeps the static base. | | `Modal` | Centered dialog over a page mask. A nested dialog can intercept keys with `onKeyDownCapture` before document Escape handlers. The tint and dialog fade in while backdrop blur stays fully applied, honoring reduced motion. Set `backdropBlur={false}` when the caller already blurs the source page. | | `RiskConfirmation` | Sensitive action gated behind an explicit checkbox. | diff --git a/packages/client/ui-primitives/README.zh.md b/packages/client/ui-primitives/README.zh.md index c5b9360995..115e1defe5 100644 --- a/packages/client/ui-primitives/README.zh.md +++ b/packages/client/ui-primitives/README.zh.md @@ -59,7 +59,7 @@ kind: "package-library" | `ConnectionIndicator` | 行内连接恢复控件,覆盖断线、重试与已恢复三种状态。 | | `DisclosureRow` | 24px 紧凑折叠行,标题与内容左右排列。标题行默认使用 tertiary,悬停时使用 secondary;文字与图标继承该颜色,不受能否展开及开合状态影响,调用方可提供功能专属覆盖。语义状态色保持显式指定。收起时悬停预览向下箭头,展开后持续显示向上箭头。`headerAccessory` 将独立操作放在折叠触发区域和流光之外;有此操作时,标题行收窄到内容宽度。使用浅层 prop 比较进行 memo;内容未变时,保持回调与 React 节点 prop 的引用稳定。 | | `AnimatedRows` | 带键列表的位移动画:移动为 200 ms,进出淡变为 100 ms。列表内发生指针或键盘输入后才启用,遵循减少动态效果设置,加载或替换视图时直接完成。为行标记 `data-row-key` 并传入对应的 `rowKeys`,为父容器设置定位以承载不可交互的退出层;`role` 和 `label` 由调用方提供。 | -| `useRowDragPreview` | 使用不可交互的离屏行副本生成原生拖拽预览。传入设置表面底色的类名;拖拽来自子级手柄时,将行元素传给 `start`;`clear` 和卸载会移除副本。 | +| `useRowDragPreview` | 使用不可交互的离屏行副本生成原生拖拽预览。可选的 `inheritedProperties` 列表指定离屏包装层需要复制其计算值的 CSS 自定义属性;省略时不复制。列表引用需保持稳定,因为替换列表会清除活动预览。未列出的自定义属性按预览所在位置的正常继承规则生效。传入设置表面底色的类名;拖拽来自子级手柄时,将行元素传给 `start`;`clear` 和卸载会移除副本。 | | `TextShimmer` | 同一道高光从左到右扫过一行的标题、分隔符、摘要与后缀:首次等待 300ms,扫动一秒,再静止 500ms。遮罩覆盖内容宽度,并限制在可见行宽内,相对垂直方向倾斜 15°,峰值区域平坦,两侧柔和渐隐。移动的装饰层始终裁剪在行内,不会扩大可滚动区域。底色继承调用方的颜色,包括悬停变化;主题提供半透明高光。图标和箭头放在 `TextShimmer` 外,只有文字和分隔符参与扫光。字符串子节点沿用稳定的文字接口;嵌套的 `TextShimmer` 共用外层动画。活动期间组合子节点渲染两次,因此不得带副作用或元素 id。分隔符标记 `data-shimmer-decoration`,让填色参与扫光。`DisclosureRow` 将 `contentClassName` 传给文字区域,将 `contentLayoutClassName` 传给内部布局。惰性装饰副本不参与交互、选区和无障碍访问;文字更新保留动画,减少动态效果模式保留静态底色。 | | `Modal` | 页面遮罩之上的居中对话框。嵌套对话框可通过 `onKeyDownCapture` 在文档级 Escape 处理器之前拦截按键。 色层与弹窗淡入,背景模糊始终完整生效,并遵循减少动态效果偏好。调用方已模糊源页面时设置 `backdropBlur={false}`。 | | `RiskConfirmation` | 以显式复选框把关的敏感操作确认。 | diff --git a/packages/client/ui-primitives/src/useRowDragPreview.ts b/packages/client/ui-primitives/src/useRowDragPreview.ts index f60e4bdc93..9a1e6fce84 100644 --- a/packages/client/ui-primitives/src/useRowDragPreview.ts +++ b/packages/client/ui-primitives/src/useRowDragPreview.ts @@ -7,15 +7,18 @@ import css from './RowDragPreview.module.css' /** * Own a row's inert drag preview until drag end, replacement, or unmount. * The offscreen copy keeps WebKit snapshots separate from overlapping panels. + * The wrapper copies only explicitly requested computed custom properties. * Its cloned slot DOM remains in document.body for that lifetime; query live rows * within their list to exclude duplicate text and element IDs. * @param className - preview background matching the row's surface; WebKit fills * transparent snapshot pixels white, including rounded corners. + * @param inheritedProperties - custom-property names whose computed values the preview needs. + * Omit to copy none. Keep the list reference stable; replacing it clears the active preview. * @returns preview callbacks; cleanup reports whether this row started a drag, * even when its preview failed or the row became non-draggable before drag end. * Start accepts the source row when a child handle owns the drag event. */ -export function useRowDragPreview(className: string): { +export function useRowDragPreview(className: string, inheritedProperties?: readonly `--${string}`[]): { start: (event: DragEvent, row?: HTMLElement) => void clear: () => boolean } { @@ -48,6 +51,9 @@ export function useRowDragPreview(className: string): { image.className = clsx(css.preview, className) image.inert = true image.setAttribute('aria-hidden', 'true') + inheritedProperties?.forEach((property) => { + image.style.setProperty(property, style.getPropertyValue(property)) + }) Object.assign(image.style, { left: `${String(-rect.width)}px`, width: `${String(rect.width)}px`, height: `${String(rect.height)}px`, font: style.font, @@ -64,7 +70,7 @@ export function useRowDragPreview(className: string): { } }, } - }, [className]) + }, [className, inheritedProperties]) useEffect(() => () => { preview.clear() }, [preview]) return preview } diff --git a/packages/client/ui-primitives/tests/row-drag-preview.client.spec.tsx b/packages/client/ui-primitives/tests/row-drag-preview.client.spec.tsx index 4e2c19e0e6..0b92db385a 100644 --- a/packages/client/ui-primitives/tests/row-drag-preview.client.spec.tsx +++ b/packages/client/ui-primitives/tests/row-drag-preview.client.spec.tsx @@ -6,8 +6,8 @@ import { useRowDragPreview } from '../src/useRowDragPreview.ts' afterEach(() => { cleanup(); vi.restoreAllMocks() }) -function Row({ handle }: { handle: boolean }) { - const preview = useRowDragPreview('surface') +function Row({ handle, properties }: { handle: boolean; properties?: readonly `--${string}`[] | undefined }) { + const preview = useRowDragPreview('surface', properties) return
} -function fixture(handle: boolean) { - const view = render() +function fixture(handle: boolean, properties?: readonly `--${string}`[]) { + const view = render() const button = screen.getByRole('button') const row = button.parentElement as HTMLElement vi.spyOn(row, 'getBoundingClientRect').mockReturnValue(new DOMRect(5, 10, 200, 40)) @@ -65,6 +65,47 @@ describe('row drag preview', () => { expect(third.isConnected).toBe(false) }) + it('copies only requested custom properties without enumerating computed styles', () => { + const { row, start } = fixture(false, ['--row-inset', '--row-label-gap']) + const original = globalThis.getComputedStyle + const computed = document.createElement('div').style + computed.cssText = original(row).cssText + computed.setProperty('--row-inset', '3px') + computed.setProperty('--row-label-gap', '6px') + computed.setProperty('--unrelated-theme-color', 'red') + const enumerate = vi.spyOn(computed, 'item') + vi.spyOn(globalThis, 'getComputedStyle').mockImplementation((element, pseudo) => + element === row ? computed : original(element, pseudo)) + const image = start() + expect(image.style.getPropertyValue('--row-inset')).toBe('3px') + expect(image.style.getPropertyValue('--row-label-gap')).toBe('6px') + expect(image.style.getPropertyValue('--unrelated-theme-color')).toBe('') + expect(enumerate).not.toHaveBeenCalled() + expect(row.style.getPropertyValue('--row-inset')).toBe('') + expect(row.style.getPropertyValue('--row-label-gap')).toBe('') + }) + + it('does not copy custom properties to the wrapper when no list is supplied', () => { + const { row, start } = fixture(false) + row.style.setProperty('--unrelated-theme-color', 'red') + expect(start().style.getPropertyValue('--unrelated-theme-color')).toBe('') + }) + + it.each([false, true])('keeps a preview through rerenders with a stable property list (configured: %s)', (configured) => { + const properties = configured ? ['--row-inset'] as const : undefined + const { start, rerender } = fixture(false, properties) + const image = start() + rerender() + expect(image.isConnected).toBe(true) + rerender() + expect(image.isConnected).toBe(false) + }) + + it('leaves a requested but undefined custom property unset', () => { + const { start } = fixture(false, ['--missing']) + expect(start().style.getPropertyValue('--missing')).toBe('') + }) + it('removes a rejected custom preview while allowing native drag cleanup', () => { const { source, dataTransfer, start } = fixture(true) dataTransfer.setDragImage.mockImplementation(() => { throw new DOMException('Unavailable') }) diff --git a/packages/client/ui-settings-account/README.i18n.yaml b/packages/client/ui-settings-account/README.i18n.yaml index f6f6403a3f..ebcb4c7149 100644 --- a/packages/client/ui-settings-account/README.i18n.yaml +++ b/packages/client/ui-settings-account/README.i18n.yaml @@ -15,8 +15,8 @@ en: c26f81e8adf0128b zh: e11ab371f51f66bc /deepseek-ai-dsh-client-ui-settings-account/use-this-package: - en: 45aa7b44d5424d9b - zh: 20c7d344c4e9bce0 + en: 2e63a8b01d800894 + zh: 42db1f28e2ddb2a3 /deepseek-ai-dsh-client-ui-settings-account/use-this-package/desktop-onboarding: en: 7ae4e68f9491b068 zh: f7478269bf8d28b3 diff --git a/packages/client/ui-settings-account/README.md b/packages/client/ui-settings-account/README.md index 6388b08d0b..f16fe754b3 100644 --- a/packages/client/ui-settings-account/README.md +++ b/packages/client/ui-settings-account/README.md @@ -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 More row instead, leading with the ellipsis glyph. +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. diff --git a/packages/client/ui-settings-account/README.zh.md b/packages/client/ui-settings-account/README.zh.md index 8200c6e468..6599e08a22 100644 --- a/packages/client/ui-settings-account/README.zh.md +++ b/packages/client/ui-settings-account/README.zh.md @@ -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 和 32px。两处共享一次浏览器图片加载,URL 缺失、加载中或加载失败时使用账号图标兜底。渲染图片失败时更新共享结果,两处一起恢复为图标,并保留重试机会;已被替换的图片加载尝试上报的失败会被忽略。每次进入设置,或从设置及余额不足提示打开的充值页面返回时,都会重试一次失败的图片,即使 URL 未变化;加载中和已成功的图片会复用,成功后两处一起展示。头像 URL 变化时开始新加载;账号状态替换、退出登录和插件卸载会丢弃旧头像及其回调。折叠侧边栏将头像居中放置在 36 × 36 px 的按钮内。登录前侧边栏入口改为紧凑的「更多」行,以三点图标开头,内容和内缩的悬停底色与导航行对齐。展开态按钮采用 border-box 尺寸:已登录行含 6px 内边距共高 44px,未登录的「更多」行高 32px。已登录入口保留 24px 头像及 8px 标签间距,并由自身根节点承担左右各 2px 外边距;该内缩不会水平移动设置外壳中相邻的状态指示器。 该页面通过 settings.section 注册,并使用 account Remote 命名空间。共享 Remote 管理器负责连接恢复后的流重连。页面使用功能自有的中英文文案,将 API Key 与账号状态分开。 diff --git a/packages/client/ui-settings-account/src/client/AccountMenu.module.css b/packages/client/ui-settings-account/src/client/AccountMenu.module.css index a873b4dd52..a18e586f6a 100644 --- a/packages/client/ui-settings-account/src/client/AccountMenu.module.css +++ b/packages/client/ui-settings-account/src/client/AccountMenu.module.css @@ -1,15 +1,17 @@ .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; border: 0; border-radius: var(--dsw-radius-md); background: transparent; color: var(--dsw-alias-label-primary); font: inherit; font-size: 14px; cursor: pointer; } -.trigger[data-collapsed='true'] { box-sizing: border-box; width: 36px; height: 36px; justify-content: center; gap: 0; padding: 0; } +.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; } +.trigger[data-collapsed='true'] { box-sizing: border-box; width: 36px; height: 36px; justify-content: center; gap: 0; padding: 0; border-inline: 0; background-clip: border-box; } @media (hover: hover) { - .trigger:hover { background: var(--dsw-alias-interactive-bg-hover); } + .trigger:hover { background-color: var(--dsw-alias-interactive-bg-hover); } } .avatar { display: flex; align-items: center; justify-content: center; flex: none; width: 24px; height: 24px; border-radius: 50%; corner-shape: round; background: var(--dsw-alias-bg-skeleton); color: var(--dsw-alias-label-tertiary); } .label { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } /* Signed-out launcher: a 32px More row carrying the 14px ellipsis. */ -.trigger[data-signed-out='true']:not([data-collapsed='true']) { height: 32px; padding: 6px 2px 6px 6px; line-height: 20px; } +.trigger[data-signed-out='true']:not([data-collapsed='true']) { height: 32px; padding: 6px calc(var(--dsh-sidebar-row-padding) - var(--dsh-sidebar-paint-inset)); line-height: 20px; } /* Signed-out menu card and rows; listClassName reaches the portaled card. */ .signedOutMenu[role='menu'] { diff --git a/packages/client/ui-settings-account/src/client/AccountMenu.tsx b/packages/client/ui-settings-account/src/client/AccountMenu.tsx index f2274e3058..0b24f8cacd 100644 --- a/packages/client/ui-settings-account/src/client/AccountMenu.tsx +++ b/packages/client/ui-settings-account/src/client/AccountMenu.tsx @@ -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
+ return
{signInNotice > 0 && { setSignInNotice(0) }} />} {expiryNotice && { setExpiryNotice(false) }} />} {signedIn && !account.desktopOnboardingVisible && !account.onboarding && account.notices && { + 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;/) +}) diff --git a/packages/client/ui-settings-account/tests/account.client.spec.tsx b/packages/client/ui-settings-account/tests/account.client.spec.tsx index e6c72c0989..0ff3cb139f 100644 --- a/packages/client/ui-settings-account/tests/account.client.spec.tsx +++ b/packages/client/ui-settings-account/tests/account.client.spec.tsx @@ -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() + 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() + 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() + 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() diff --git a/packages/client/ui-settings-general/README.i18n.yaml b/packages/client/ui-settings-general/README.i18n.yaml index 377d9797cd..3be104f993 100644 --- a/packages/client/ui-settings-general/README.i18n.yaml +++ b/packages/client/ui-settings-general/README.i18n.yaml @@ -27,8 +27,8 @@ en: ba55fe92bf49dc5a zh: 9a0e1345496a0ae6 /deepseek-ai-dsh-client-ui-settings-general/understand-the-implementation: - en: 1531b45780d0689f - zh: 844548208c08a9c9 + en: 9c8c35ae0e672b14 + zh: 503c8e9d24a93d88 /deepseek-ai-dsh-client-ui-settings-general/understand-the-implementation/ledger-projections: en: 3e3c2c72f87e8b23 zh: 12b10ba2d37c9077 diff --git a/packages/client/ui-settings-general/README.md b/packages/client/ui-settings-general/README.md index 0b14da3bcb..3a7c6ffdee 100644 --- a/packages/client/ui-settings-general/README.md +++ b/packages/client/ui-settings-general/README.md @@ -66,7 +66,7 @@ Access: the settings shell and the `settings.open` shortcut are registered only ## 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. +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.
Implementation internals — click to expand diff --git a/packages/client/ui-settings-general/README.zh.md b/packages/client/ui-settings-general/README.zh.md index d1e276b141..bc53c128df 100644 --- a/packages/client/ui-settings-general/README.zh.md +++ b/packages/client/ui-settings-general/README.zh.md @@ -66,7 +66,7 @@ Web 与桌面端的通用设置底部显示当前发布版本,使用构建注 ## 理解实现 -外壳声明 settings.launcher,供账号功能提供侧边栏菜单,并以设置按钮作为回退。入口接收 settingsOpen,其 false→true 边沿代表一次进入设置页,因此注册方在每次进入时执行一次,而不是在同一次打开中的每次重渲染执行。关闭对话框后,焦点返回当前入口。 +外壳声明 settings.launcher,供账号功能提供侧边栏菜单,并以设置按钮作为回退。入口接收 settingsOpen,其 false→true 边沿代表一次进入设置页,因此注册方在每次进入时执行一次,而不是在同一次打开中的每次重渲染执行。关闭对话框后,焦点返回当前入口。侧栏展开时,回退按钮采用导航行的紧凑图文对齐及内缩悬停底色。贡献入口自行拥有内缩;外壳保持行宽占满可用空间,状态指示器的尾侧边缘不随账号状态移动。折叠轨道保留居中的 36 × 36 px 控件。
实现细节——点击展开 diff --git a/packages/client/ui-settings-general/src/client/SettingsRoot.module.css b/packages/client/ui-settings-general/src/client/SettingsRoot.module.css index 0b15fa8252..87b137c082 100644 --- a/packages/client/ui-settings-general/src/client/SettingsRoot.module.css +++ b/packages/client/ui-settings-general/src/client/SettingsRoot.module.css @@ -4,8 +4,8 @@ display: flex; align-items: center; gap: 8px; - width: calc(100% + 4px); - margin: 4px -2px; + width: 100%; + margin: 4px 0; } .triggerRow.railRow { @@ -18,15 +18,17 @@ min-width: 0; display: flex; align-items: center; - gap: 8px; + gap: var(--dsh-sidebar-label-gap); width: auto; height: 42px; margin: 0; - padding: 0 10px 0 8px; + padding: 0 calc(var(--dsh-sidebar-row-padding) - var(--dsh-sidebar-paint-inset)); box-sizing: border-box; border: none; border-radius: var(--dsw-radius-md); background: transparent; + border-inline: var(--dsh-sidebar-paint-inset) solid transparent; + background-clip: padding-box; cursor: pointer; overflow: hidden; color: var(--dsw-alias-label-primary); @@ -37,7 +39,7 @@ @media (hover: hover) { .trigger:hover { - background: var(--dsw-alias-interactive-bg-hover); + background-color: var(--dsw-alias-interactive-bg-hover); } } @@ -51,6 +53,8 @@ justify-content: center; gap: 0; padding: 0; + border-inline: 0; + background-clip: border-box; } .triggerLabel { @@ -173,7 +177,7 @@ @media (hover: hover) { .navCell:hover { - background: var(--dsw-alias-interactive-bg-hover); + background-color: var(--dsw-alias-interactive-bg-hover); } } @@ -232,7 +236,7 @@ @media (hover: hover) { .close:hover { - background: var(--dsw-alias-interactive-bg-hover); + background-color: var(--dsw-alias-interactive-bg-hover); } } diff --git a/packages/client/ui-settings-general/tests/settings-root-styles.client.spec.ts b/packages/client/ui-settings-general/tests/settings-root-styles.client.spec.ts new file mode 100644 index 0000000000..b070f1627e --- /dev/null +++ b/packages/client/ui-settings-general/tests/settings-root-styles.client.spec.ts @@ -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;/) +}) diff --git a/packages/client/ui-sidebar/README.i18n.yaml b/packages/client/ui-sidebar/README.i18n.yaml index 9f24479203..7ba295bb7e 100644 --- a/packages/client/ui-sidebar/README.i18n.yaml +++ b/packages/client/ui-sidebar/README.i18n.yaml @@ -36,8 +36,8 @@ en: 8855ad013285a27e zh: f19b3e75c38c5b0a /deepseek-ai-dsh-client-ui-sidebar/understand-the-implementation: - en: 39b5e3ca8ce8c5c2 - zh: 1d652b7554772dad + en: 8cc657bd9b66c93f + zh: 47ad4bd9e906ef8e /deepseek-ai-dsh-client-ui-sidebar/understand-the-implementation/slot-discipline: en: 6eefc5c45abe8a8f zh: 20b651e48a0ea903 diff --git a/packages/client/ui-sidebar/README.md b/packages/client/ui-sidebar/README.md index 5a21b27987..a307aeffc0 100644 --- a/packages/client/ui-sidebar/README.md +++ b/packages/client/ui-sidebar/README.md @@ -76,6 +76,10 @@ 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, 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 Declaration-aware `slots.inject()` lets a replacing package activate before or after the sidebar. The foot is the `sidebar.settings` seat: the sidebar renders only the bottom-pinned layout slot and shares its column state (`wide`). The `/client` exports are the plugin body (`apply`/`inject`) plus the contract types only; SidebarRoot, the row components, and the tree derivation remain package-internal behind the slot registration. diff --git a/packages/client/ui-sidebar/README.zh.md b/packages/client/ui-sidebar/README.zh.md index 08eb1f1648..07207b894b 100644 --- a/packages/client/ui-sidebar/README.zh.md +++ b/packages/client/ui-sidebar/README.zh.md @@ -76,6 +76,10 @@ 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,并在整个淡出期间保持该偏移。折叠态尺寸独立保留。 + +侧边栏根节点向 `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 纪律 声明感知的 `slots.inject()` 让替换包无论先于还是后于侧边栏激活都能生效。页脚承载 `sidebar.settings` 席位:侧边栏只渲染固定在底部的布局 slot,并共享其栏状态(`wide`)。`/client` 导出接口只包含插件主体(`apply`/`inject`)及约定类型;SidebarRoot、行组件与树派生仍由 slot 注册封装在包内。 diff --git a/packages/client/ui-sidebar/src/client/SidebarRoot.module.css b/packages/client/ui-sidebar/src/client/SidebarRoot.module.css index 536718114c..2560e37509 100644 --- a/packages/client/ui-sidebar/src/client/SidebarRoot.module.css +++ b/packages/client/ui-sidebar/src/client/SidebarRoot.module.css @@ -1,4 +1,4 @@ -/* Sidebar column (figma 133:7629): vertical stack, padding 12/6, sidebar +/* Sidebar column: vertical stack, padding 8/6, sidebar fill + 1px right border painted by the layout column. Collapse is a slide + crossfade, not a morph: the content holds its frozen expanded layout (inline width set by the component) and fades in place (.fading) @@ -7,7 +7,10 @@ mid-slide. */ .root { - --dsh-sidebar-inline-padding: 12px; + --dsh-sidebar-inline-padding: 8px; + --dsh-sidebar-row-padding: 12px; + --dsh-sidebar-label-gap: 6px; + --dsh-sidebar-paint-inset: 3px; display: flex; flex-direction: column; height: 100%; @@ -55,7 +58,7 @@ } :global([data-windows-titlebar]) .root:not(.collapsed) .brand { - padding-left: 4px; + padding-left: 8px; } :global([data-windows-titlebar]) .brandIdentity { @@ -75,8 +78,7 @@ } :global([data-windows-titlebar]) .root:not(.collapsed) .newSession { - margin-top: 8px; - margin-left: 0; + margin-top: 6px; } :global([data-windows-titlebar]) .collapsed .panelList, @@ -192,6 +194,10 @@ padding: 0 12px 2px; } +:global(html[data-platform='darwin']:not([data-fullscreen])) .root:not(.collapsed) > .topStrip > .toggle { + transform: translateX(4px); +} + /* Window fullscreen hides the traffic lights (html[data-fullscreen], desktop preload): the toggle takes their spot at the strip's left edge. */ :global([data-fullscreen]) .topStrip { @@ -205,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 @@ -224,8 +231,8 @@ justify-content: flex-end; gap: 8px; height: 60px; - padding: 8px 0 8px 4px; - margin-bottom: 4px; + padding: 8px 0 8px 8px; + margin-bottom: 0; box-sizing: border-box; overflow: hidden; } @@ -268,7 +275,7 @@ .brandIdentity { display: inline-flex; align-items: center; - gap: 8px; + gap: 5px; height: 24px; min-width: 0; } @@ -341,7 +348,7 @@ @media (hover: hover) { .iconButton:hover { - background: var(--dsw-alias-interactive-bg-hover); + background-color: var(--dsw-alias-interactive-bg-hover); } } @@ -416,7 +423,7 @@ gap: 0; height: 38px; padding: 8px 12px; - margin: 0 2px 12px; + margin: 0 calc(var(--dsh-sidebar-paint-inset) + 1px) 10px; box-sizing: border-box; border: 0.5px solid var(--dsw-alias-border-l3); border-radius: var(--dsw-radius-md); @@ -471,7 +478,7 @@ @media (hover: hover) { .collapsed .newSession:hover { - background: var(--dsw-alias-interactive-bg-hover); + background-color: var(--dsw-alias-interactive-bg-hover); } } @@ -527,19 +534,21 @@ 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; - gap: 8px; - margin: 0 2px; + gap: var(--dsh-sidebar-label-gap); + margin: 0; min-height: 36px; - padding: 7px 8px; + padding: 7px calc(var(--dsh-sidebar-row-padding) - var(--dsh-sidebar-paint-inset)); box-sizing: border-box; border: none; + border-inline: var(--dsh-sidebar-paint-inset) solid transparent; border-radius: var(--dsw-radius-md); background: transparent; + background-clip: padding-box; color: var(--dsw-alias-label-primary); font: inherit; line-height: 22px; @@ -549,12 +558,12 @@ @media (hover: hover) { .panelRow:hover { - background: var(--dsw-alias-interactive-bg-hover); + background-color: var(--dsw-alias-interactive-bg-hover); } } .panelRow.panelActive { - background: var(--dsw-alias-interactive-bg-hover); + background-color: var(--dsw-alias-interactive-bg-hover); color: var(--dsw-alias-label-primary); } @@ -570,7 +579,7 @@ } .panelRow[aria-expanded='true'] { - background: var(--dsw-alias-interactive-bg-hover); + background-color: var(--dsw-alias-interactive-bg-hover); } .moreMenu { @@ -602,6 +611,8 @@ height: 36px; justify-content: center; margin: 0; + border-inline: 0; + background-clip: border-box; padding: 0; color: var(--dsw-alias-label-primary); } diff --git a/packages/client/ui-sidebar/tests/sidebar-styles.client.spec.ts b/packages/client/ui-sidebar/tests/sidebar-styles.client.spec.ts index 4fdaf74269..67efc3c945 100644 --- a/packages/client/ui-sidebar/tests/sidebar-styles.client.spec.ts +++ b/packages/client/ui-sidebar/tests/sidebar-styles.client.spec.ts @@ -33,7 +33,10 @@ describe('SidebarRoot.module.css', () => { it('shares and cancels the wide shell trailing padding structurally', () => { const root = declarations('.root') - expect(root?.get('--dsh-sidebar-inline-padding')).toBe('12px') + expect(root?.get('--dsh-sidebar-inline-padding')).toBe('8px') + expect(root?.get('--dsh-sidebar-row-padding')).toBe('12px') + expect(root?.get('--dsh-sidebar-label-gap')).toBe('6px') + expect(root?.get('--dsh-sidebar-paint-inset')).toBe('3px') expect(root?.get('padding')).toBe('6px var(--dsh-sidebar-inline-padding)') expect(declarations('.regionArea')?.get('margin-left')).toBe('-4px') expect(declarations('.regionArea')?.get('padding-left')).toBe('4px') @@ -45,6 +48,18 @@ describe('SidebarRoot.module.css', () => { expect(declarations('.collapsed .regionArea')?.get('margin-right')).toBe('0') }) + it('keeps the reviewed expanded spacing separate from rail and caption controls', () => { + expect(declarations('.logoRow')?.get('padding')).toBe('8px 0 8px 8px') + 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) > .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') + }) + it('moves the four upper controls while the settings seat only fades', () => { const animation = 'rail-in 150ms var(--ds-ease-in-out) backwards' for (const selector of [ @@ -69,7 +84,8 @@ describe('SidebarRoot.module.css', () => { expect(declarations('.collapsed .newSession')?.get('width')).toBe('36px') }) - it('keeps the slotted brand row at the full artwork height', () => { + it('keeps full-size brand artwork with a compact mark-to-name gap', () => { + expect(declarations('.brandIdentity')?.get('gap')).toBe('5px') expect(declarations('.brandIdentity')?.get('height')).toBe('24px') expect(declarations('.brandName')?.get('height')).toBe('24px') expect(declarations('.brandName')?.get('line-height')).toBe('24px') diff --git a/packages/client/ui-workspace/README.i18n.yaml b/packages/client/ui-workspace/README.i18n.yaml index 5f161360f4..2f63a27b97 100644 --- a/packages/client/ui-workspace/README.i18n.yaml +++ b/packages/client/ui-workspace/README.i18n.yaml @@ -24,8 +24,8 @@ en: a94394a5a9f70ed8 zh: 62ce596a95395ad8 /deepseek-ai-dsh-client-ui-workspace/use-this-package/search: - en: 29facc4eae028dae - zh: 754ebac4cc294e65 + en: 60d1486f5309a338 + zh: c16261d4dd6200de /deepseek-ai-dsh-client-ui-workspace/use-this-package/managing-sessions: en: 353ed9b66fab6486 zh: e38a4a37627a9426 @@ -36,8 +36,8 @@ en: 2168316f604893a5 zh: fe11347009339a9a /deepseek-ai-dsh-client-ui-workspace/understand-the-implementation: - en: 96a37e74555a36b7 - zh: d03941523509a5cf + en: 298c0358edd2d7ef + zh: 4d4c4a6be3e70434 /deepseek-ai-dsh-client-ui-workspace/understand-the-implementation/the-directory-flow-hole: en: 868904dbfb5713f8 zh: 225906bfef7398bf @@ -51,8 +51,8 @@ en: ad4a333892f2fe92 zh: 3a670fd401f4d7a7 /deepseek-ai-dsh-client-ui-workspace/understand-the-implementation/view-state: - en: b62b5a344b962c45 - zh: 6c773bece95c3621 + en: d6a49581b099985d + zh: 7bd58bf5566213de /deepseek-ai-dsh-client-ui-workspace/understand-the-implementation/hover-cards: en: 8bcb7bd90d7c5773 zh: 17663be926a099d2 diff --git a/packages/client/ui-workspace/README.md b/packages/client/ui-workspace/README.md index eea0b6fcee..ce78cd13d6 100644 --- a/packages/client/ui-workspace/README.md +++ b/packages/client/ui-workspace/README.md @@ -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. Opening or closing search in the expanded sidebar animates header spacing and field expansion together over 180 ms, disabled for reduced motion. Expanding the sidebar from its rail places the header at its final geometry while preserving the content fade-in. Rail search prepares the open field before expanding the sidebar, so the first wide render already contains the expanded field. 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 @@ -99,7 +99,7 @@ The browser entry declares three root-scoped `list` child seats on each Session The leading seat occupies the status cell only while the row's primary status is idle and leaves archived rows blank. The trailing seat accepts non-interactive icons in ascending `order`, after the time label and before the pin marker. Blank rows omit the trailing seat; an empty seat adds no spacing. The hover seat mounts only while the row's hover card is open. -On hover-capable devices, hovering a Session row, giving keyboard focus to the row or its actions, or opening its menu replaces the time, trailing icons, and pin marker with action buttons. A pointer click does not keep the buttons visible after the pointer leaves. +On hover-capable devices, hovering a Session row, giving keyboard focus to the row or its actions, or opening its menu replaces the time, trailing icons, and pin marker with action buttons. A pointer click does not keep the buttons visible after the pointer leaves. Built-in Session and Workspace row action buttons use 24px click targets with compensated margins that retain their compact layout footprint and icon positions. Hoverless devices keep the static details and the More menu trigger visible, including while the menu is open; pin and archive remain available in that menu. @@ -188,13 +188,15 @@ A dynamically loaded browser half follows the same component contract; which mod ### View state +Workspace and Session rows share a 16px leading cell and 6px label gap, with 8px trailing padding. Action centers are 29px apart, leaving 5px between their 24px targets. The built-in More action uses a 16px glyph; other built-in row actions use 14px glyphs. The time label has a -1px end margin, and the Workspace heading label has a -1px left offset. The expanded sidebar toolbar gives its collapsed search, view, and add controls a separate 1px trailing inset; the heading label and expanded search retain their own alignment. Session insertion markers compensate for the transparent side borders without moving row content. + Once the Workspace baseline is ready, browser-persisted expansion and Session-order records retain only current Workspace ids plus Ungrouped and the flat-list account. `WorkspaceView.sessionIds` supplies real-Workspace membership, not Session display order. View actions receive complete account orders, never filtered rows. A new member without a Session summary waits for that summary, while a saved position survives a temporarily missing summary. Archive visibility is applied only when deriving rows. Pin and drag writes save complete orders; ordinary derivation does not write them. The selected blank Session remains an explicit position write, including during Workspace reconnection, when other saved members are retained until the baseline establishes membership. Ordering remains mounted while the sidebar is a rail or search replaces its body. Last updated derives from current summaries without reading saved positions; equal timestamps use Session ids as a stable tie-break. The sidebar hides durable summaries with `origin: 'subagent'`. A visible ordinary row shows the shared ongoing loader from running direct children in its loaded parent catalog, never from summary lineage. Child activity uses the latest UI status, falling back to the Session summary until that status is known. Row motion belongs to [AnimatedRows](../ui-primitives/src/AnimatedRows.tsx). It measures keyed rows before and after React commits that change their membership or order, and uses native movement and opacity animations. Removed rows fade as inert copies outside the scrolling list; they do not delay React unmounting or extend its scroll range. Initial loading, drag commits, overflow expansion, and view-option changes settle immediately. The animator has no layout observers or polling and does not measure content-only updates or scrolling. -Native Workspace and Session drags use an inert offscreen copy for their preview, with the row's dimensions and indentation over the themed sidebar fill. [useRowDragPreview](../ui-primitives/src/useRowDragPreview.ts) removes the copy on drag end, replacement, or row unmount. The preview does not change the source row's background or drag-and-drop handlers. If the browser rejects a custom drag image, the copy is removed and native dragging continues. +Native Workspace and Session drags use an inert offscreen copy for their preview, with the row's dimensions and indentation over the themed sidebar fill. The rows explicitly request `--dsh-sidebar-row-padding`, `--dsh-sidebar-label-gap`, and `--dsh-sidebar-paint-inset`; the preview copies their computed values, while the computed leading padding preserves Workspace tree indentation. [useRowDragPreview](../ui-primitives/src/useRowDragPreview.ts) removes the copy on drag end, replacement, or row unmount. The preview does not change the source row's background or drag-and-drop handlers. If the browser rejects a custom drag image, the copy is removed and native dragging continues. ### Hover cards diff --git a/packages/client/ui-workspace/README.zh.md b/packages/client/ui-workspace/README.zh.md index a0f91b30a0..f1966a60f5 100644 --- a/packages/client/ui-workspace/README.zh.md +++ b/packages/client/ui-workspace/README.zh.md @@ -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 列表。已归档结果提供取消归档操作;尝试打开时会说明限制,不清空查询,也不导航。 ### 管理会话 @@ -99,7 +99,7 @@ Workspace 和 Session 的启动基线均就绪后,空安装环境调用 `works 前置 slot 仅在该行主状态为 idle 时占用状态单元,归档行留空。尾部 slot 按 `order` 升序放置非交互图标,位置在时间标签之后、置顶标记之前。空白行不渲染尾部 slot;空 slot 不增加间距。悬浮 slot 仅在该行的悬浮卡片打开时挂载。 -在支持悬停的设备上,鼠标移入 Session 行、通过键盘聚焦行或其操作按钮、打开行菜单,都会用操作按钮替代时间、尾部图标和置顶标记。鼠标点击不会让按钮在指针移出后仍然显示。 +在支持悬停的设备上,鼠标移入 Session 行、通过键盘聚焦行或其操作按钮、打开行菜单,都会用操作按钮替代时间、尾部图标和置顶标记。鼠标点击不会让按钮在指针移出后仍然显示。内置 Session 和 Workspace 行操作按钮使用 24px 点击热区,通过补偿外边距保留紧凑的布局占位与图标位置。 不支持悬停的设备始终保留静态信息和更多菜单入口,菜单打开时也不隐藏;置顶与归档仍可通过该菜单操作。 @@ -188,13 +188,15 @@ export function apply(ctx: Context): void { ### 视图状态 +Workspace 与 Session 行共用 16px 前置单元和 6px 标签间距,行尾内边距为 8px。操作中心相距 29px,24px 热区之间保留 5px 间隔。内置更多操作的图标为 16px,其他内置行操作图标为 14px。时间标签的尾部外边距为 -1px,工作区区头标签的左偏移为 -1px。展开侧栏的工具栏为收起的搜索、视图和添加控件单独保留 1px 尾部内边距;区头标签与展开搜索框保留各自的对齐方式。Session 插入标记补偿两侧透明边框,不移动行内容。 + Workspace 基线就绪后,浏览器持久化的展开状态和 Session 顺序记录只保留当前 Workspace id、Ungrouped 和单列表记账。`WorkspaceView.sessionIds` 提供真实 Workspace 的成员关系,而不提供 Session 显示顺序。视图操作接收完整记账顺序,而不是筛选后的行。尚无 Session 摘要的新成员会等待摘要,已保存的位置则在摘要暂时缺失时保留。归档显隐仅在派生行时应用。置顶和拖拽写入完整顺序,普通派生不执行写入。当前选中的空白 Session 仍是一次显式位置写入;Workspace 重连时同样如此,此时保留其他已保存成员,直到基线确定成员关系。侧边栏收成窄栏或搜索替代列表主体时,排序仍保持挂载。最近更新从当前摘要派生,不读取已保存位置;时间相同时按 Session id 稳定排序。 侧边栏隐藏持久化摘要中带有 `origin: 'subagent'` 的行。可见普通行的共享 ongoing loading 来自其已加载 parent 目录中正在运行的直接 child,绝不来自摘要谱系。Child 活动状态使用最新 UI status,尚无该状态时使用 Session 摘要。 行动画由 [AnimatedRows](../ui-primitives/src/AnimatedRows.tsx) 负责。它仅在 React 提交改变行成员或顺序时读取更新前后的位置,并使用浏览器原生位移与透明度动画。被移除的行以不可交互的副本在滚动列表外淡出,不会延迟 React 卸载,也不会扩大列表的滚动范围。初始加载、拖拽提交、展开其余会话和视图选项变化直接完成。动画组件不使用布局观察器或轮询,也不会因仅内容更新或滚动而测量位置。 -Workspace 和 Session 的原生拖拽预览使用不可交互的离屏副本,在主题侧栏底色上保留行尺寸和缩进。[useRowDragPreview](../ui-primitives/src/useRowDragPreview.ts) 在拖拽结束、替换预览或行卸载时移除副本。预览不改变原行的背景或拖放事件处理。如果浏览器拒绝自定义拖拽图片,则移除副本并继续原生拖动。 +Workspace 和 Session 的原生拖拽预览使用不可交互的离屏副本,在主题侧栏底色上保留行尺寸和缩进。行组件显式请求 `--dsh-sidebar-row-padding`、`--dsh-sidebar-label-gap` 和 `--dsh-sidebar-paint-inset`;预览复制这些变量的计算值,计算后的起始侧内边距则保留 Workspace 树的层级缩进。[useRowDragPreview](../ui-primitives/src/useRowDragPreview.ts) 在拖拽结束、替换预览或行卸载时移除副本。预览不改变原行的背景或拖放事件处理。如果浏览器拒绝自定义拖拽图片,则移除副本并继续原生拖动。 ### 悬浮卡片 diff --git a/packages/client/ui-workspace/src/client/rows/Rows.module.css b/packages/client/ui-workspace/src/client/rows/Rows.module.css index 6436998842..4b1cbc1a03 100644 --- a/packages/client/ui-workspace/src/client/rows/Rows.module.css +++ b/packages/client/ui-workspace/src/client/rows/Rows.module.css @@ -2,10 +2,13 @@ .sessionRow { display: flex; align-items: center; - gap: 6px; + gap: var(--dsh-sidebar-label-gap); border-radius: var(--dsw-radius-md); + border-inline: var(--dsh-sidebar-paint-inset) solid transparent; + background-clip: padding-box; + box-sizing: border-box; padding: 0 8px; - padding-inline-start: calc(8px + var(--dsh-workspace-indent, 0px)); + padding-inline-start: calc(var(--dsh-sidebar-row-padding) - var(--dsh-sidebar-paint-inset) + var(--dsh-workspace-indent, 0px)); cursor: pointer; -webkit-user-select: none; user-select: none; @@ -14,12 +17,12 @@ @media (hover: hover) { .projectRow:hover { - background: var(--dsw-alias-interactive-bg-hover); + background-color: var(--dsw-alias-interactive-bg-hover); } } .sessionRow.selected { - background: var(--dsw-alias-interactive-bg-hover); + background-color: var(--dsw-alias-interactive-bg-hover); } .dragPreview { @@ -35,8 +38,10 @@ box-sizing: border-box; border: none; border-radius: var(--dsw-radius-lg); - padding: 4px 8px; + padding: 4px calc(var(--dsh-sidebar-row-padding) - var(--dsh-sidebar-paint-inset)); + border-inline: var(--dsh-sidebar-paint-inset) solid transparent; background: transparent; + background-clip: padding-box; cursor: pointer; text-align: left; color: var(--dsw-alias-label-primary); @@ -44,12 +49,12 @@ @media (hover: hover) { .searchResultRow:hover { - background: var(--dsw-alias-interactive-bg-hover); + background-color: var(--dsw-alias-interactive-bg-hover); } } .searchResultRow.selected { - background: var(--dsw-alias-interactive-bg-hover); + background-color: var(--dsw-alias-interactive-bg-hover); } .searchResultHeading { @@ -61,7 +66,7 @@ .searchResultTitle { flex: 0 1 auto; min-width: 0; - margin-left: 4px; + margin: 0 var(--dsh-sidebar-label-gap); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; @@ -74,7 +79,7 @@ align-items: center; gap: 6px; min-width: 0; - margin-left: 20px; + margin-left: calc(16px + var(--dsh-sidebar-label-gap)); } .searchResultWorkspace, @@ -114,14 +119,14 @@ height: 20px; } -/* Session cell (figma): pad 8, a 16px status slot, then a 4px title gap. */ +/* Session titles share the workspace icon slot and compact label gap. */ .sessionRow { height: 32px; gap: 0; } .sessionRow .title { - margin: 0 6px 0 4px; + margin: 0 6px 0 var(--dsh-sidebar-label-gap); } .slot { @@ -235,6 +240,7 @@ font-size: 10px; line-height: 16px; color: var(--dsw-alias-label-tertiary); + margin-inline-end: -1px; } .pinIndicator { @@ -263,12 +269,12 @@ flex: none; } -/* Trailing action buttons surface on hover with bare 16px glyphs. */ +/* The trailing strip keeps a shared gap between its fixed-size control boxes. */ .rowActions { flex: none; display: none; align-items: center; - gap: 15px; + gap: 13px; } @media (hover: hover) { @@ -310,7 +316,7 @@ unguarded rule would strand that row without its ellipsis. */ @media (hover: hover) { .sessionRow:hover { - background: var(--dsw-alias-interactive-bg-hover); + background-color: var(--dsw-alias-interactive-bg-hover); } .sessionRow:hover .rowActions, @@ -347,7 +353,7 @@ hover fill while its dropdown is up). */ .projectRow.menuOpen, .sessionRow.menuOpen { - background: var(--dsw-alias-interactive-bg-hover); + background-color: var(--dsw-alias-interactive-bg-hover); } /* Session drag insert marker: a leading chevron and 2px rule between rows, @@ -362,8 +368,8 @@ content: ''; position: absolute; z-index: 1; - left: 0; - right: 4px; + left: calc(-1 * var(--dsh-sidebar-paint-inset)); + right: calc(4px - var(--dsh-sidebar-paint-inset)); height: 12px; background: linear-gradient( @@ -434,13 +440,15 @@ margin: 0 -4px; } +/* Compensated margins keep the 24px target on the compact icon's center. */ .iconButton { flex: none; display: inline-flex; align-items: center; justify-content: center; - width: 16px; - height: 16px; + width: 24px; + height: 24px; + margin: -4px; border: none; border-radius: var(--dsw-radius-xs); padding: 0; @@ -449,6 +457,11 @@ color: var(--dsw-alias-label-tertiary); } +/* Keep the focus ring on its original compact outline. */ +.iconButton:focus-visible { + outline-offset: -2px; +} + @media (hover: hover) { .iconButton:hover { color: var(--dsw-alias-label-primary); diff --git a/packages/client/ui-workspace/src/client/rows/Rows.tsx b/packages/client/ui-workspace/src/client/rows/Rows.tsx index d34f1b4a59..116cab1800 100644 --- a/packages/client/ui-workspace/src/client/rows/Rows.tsx +++ b/packages/client/ui-workspace/src/client/rows/Rows.tsx @@ -28,6 +28,13 @@ import type { MenuOpenState, WorkspaceBrowserProps } from '../contract/slots.ts' import type { GroupNode, SearchResultNode, SessionNode } from '../tree.ts' import css from './Rows.module.css' +/** Sidebar-owned layout values needed by row copies mounted outside the sidebar. */ +const DRAG_PREVIEW_PROPERTIES = [ + '--dsh-sidebar-row-padding', + '--dsh-sidebar-label-gap', + '--dsh-sidebar-paint-inset', +] as const + /** The standard locale seat, prop-passed from the browser root. */ type RowTranslate = WorkspaceBrowserProps['t'] @@ -279,7 +286,7 @@ function ProjectRow({ const label = row.workspaceId === undefined ? t('group.ungrouped') : row.label const active = containsCurrentDescendant || (group.expanded && group.containsCurrent) const [menuOpen, setMenuOpen] = useState(false) - const dragPreview = useRowDragPreview(css.dragPreview as string) + const dragPreview = useRowDragPreview(css.dragPreview as string, DRAG_PREVIEW_PROPERTIES) const workspaceMenuItems = [ ...access.rename ? [{ id: 'rename', label: t('rename'), icon: }] : [], ...access.delete ? [{ id: 'delete', label: t('delete.workspace'), icon: , danger: true }] : [], @@ -349,7 +356,7 @@ function ProjectRow({ aria-label={t('actions.newSession.aria', { name: label })} onClick={(e) => { e.stopPropagation(); onCreate() }} > - + )} @@ -643,7 +650,7 @@ export function SessionNodeItem({ const reorderable = useSessionAccess(node.id, 'workspace/insertSessionBefore') const draggable = drag !== undefined && reorderable && !row.blank && !row.archived const [menuOpen, setMenuOpen] = useState(false) - const dragPreview = useRowDragPreview(css.dragPreview as string) + const dragPreview = useRowDragPreview(css.dragPreview as string, DRAG_PREVIEW_PROPERTIES) // The menu's open state, bound into the row entries' `useMenuOpenState` hook. const menuOpenState = useMemo((): MenuOpenState => [menuOpen, setMenuOpen], [menuOpen]) const rowRef = useRef(null) @@ -659,7 +666,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 = (
{ + setSearchSpacingMotionEnabled(wide) + setSearchExpanded(expanded) + } const [revealSessionId, setRevealSessionId] = useState(undefined) const normalizedQuery = sanitizeSearchQuery(query).trim() const [remoteSearch, setRemoteSearch] = useState({ @@ -1111,7 +1129,7 @@ export function WorkspaceBrowser({ } setRevealSessionId(sessionId) setQuery('') - setSearchExpanded(false) + changeSearchExpanded(false) open(sessionId) } const acknowledgeSessionReveal = (sessionId: SessionId): void => { @@ -1123,7 +1141,6 @@ export function WorkspaceBrowser({ // Rail search = expand + land in the search box: the flag arms before the // expand request; once the shell flips wide the input mounts and takes focus. - const [searchOnExpand, setSearchOnExpand] = useState(false) useEffect(() => { if (wide && searchOnExpand) { const timer = window.setTimeout(() => { @@ -1136,11 +1153,8 @@ export function WorkspaceBrowser({ useEffect(() => { if (shortcutState.searchRequest === 0) return closeAddWorkspace() - setSearchExpanded(true) - if (!wide) { - setSearchOnExpand(true) - expandSidebar() - } else searchInput.current?.focus({ preventScroll: true }) + if (!wide) expandSidebar() + else searchInput.current?.focus({ preventScroll: true }) }, [shortcutState.searchRequest]) useEffect(() => { @@ -1159,7 +1173,7 @@ export function WorkspaceBrowser({ if (!(event.target instanceof Node) || searchRoot.current?.contains(event.target) === true) return searchInput.current?.blur() if (normalizedQuery !== '') return - setSearchExpanded(false) + changeSearchExpanded(false) } document.addEventListener('click', onClick) return () => { document.removeEventListener('click', onClick) } @@ -1281,7 +1295,7 @@ export function WorkspaceBrowser({ return (
-
+
{wide && ( {groupBy === 'flat' ? t('section.sessions') : t('section.workspaces')} @@ -1294,7 +1308,7 @@ export function WorkspaceBrowser({ className={clsx(css.search, searchExpanded && css.searchExpanded)} onClick={() => { closeAddWorkspace() - setSearchExpanded(true) + changeSearchExpanded(true) searchInput.current?.focus() }} > @@ -1324,7 +1338,7 @@ export function WorkspaceBrowser({ onKeyDown={(e) => { if (e.key !== 'Escape') return setQuery('') - setSearchExpanded(false) + changeSearchExpanded(false) }} /> {searchExpanded && ( @@ -1335,7 +1349,7 @@ export function WorkspaceBrowser({ onClick={(e) => { e.stopPropagation() setQuery('') - setSearchExpanded(false) + changeSearchExpanded(false) }} > diff --git a/packages/client/ui-workspace/tests/browser-styles.client.spec.ts b/packages/client/ui-workspace/tests/browser-styles.client.spec.ts index e94b2152a3..1a30b879ef 100644 --- a/packages/client/ui-workspace/tests/browser-styles.client.spec.ts +++ b/packages/client/ui-workspace/tests/browser-styles.client.spec.ts @@ -83,7 +83,8 @@ describe('WorkspaceBrowser.module.css list', () => { expect(listTopMarker?.get('top')).toBe('-8px') expect(listTopMarker?.get('left')).toBe('0') expect(workspaceMarker?.get('left')).toBe('0') - expect(sessionMarker?.get('left')).toBe('0') + expect(sessionMarker?.get('left')).toBe('calc(-1 * var(--dsh-sidebar-paint-inset))') + expect(sessionMarker?.get('right')).toBe('calc(4px - var(--dsh-sidebar-paint-inset))') for (const marker of [listTopMarker, workspaceMarker, sessionMarker]) { expect(marker?.get('height')).toBe('12px') expect(marker?.get('background')).not.toContain('radial-gradient') @@ -99,7 +100,7 @@ describe('WorkspaceBrowser.module.css list', () => { it('keeps the compact fade, overflow control, search field, and row heights', () => { expect(declarations('.fade')?.get('height')).toBe('24px') expect(declarations('.sessionOverflowButton')?.get('height')).toBe('28px') - expect(declarations('.searchExpanded')?.get('height')).toBe('30px') + expect(declarations('.searchExpanded')?.get('height')).toBe('32px') expect(rowDeclarations('.projectRow')?.get('height')).toBe('34px') expect(rowDeclarations('.sessionRow')?.get('height')).toBe('32px') // One leading status cell for every session row, grouped or flat: the cell @@ -107,12 +108,57 @@ describe('WorkspaceBrowser.module.css list', () => { // own box, so the title's one shared margin below needs no per-view override. expect(rowDeclarations('.slot')?.get('width')).toBe('16px') expect(rowDeclarations('.slot')?.get('height')).toBe('20px') - expect(rowDeclarations('.sessionRow .title')?.get('margin')).toBe('0 6px 0 4px') + 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('.sessionRow.selected')?.get('background')) + 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)') }) + it('shares action spacing and inset backgrounds without moving the title column', () => { + for (const selector of ['.projectRow', '.sessionRow']) { + expect(rowDeclarations(selector)?.get('padding')).toBe('0 8px') + expect(rowDeclarations(selector)?.get('background-clip')).toBe('padding-box') + expect(rowDeclarations(selector)?.get('border-inline')).toBe('var(--dsh-sidebar-paint-inset) solid transparent') + } + expect(rowDeclarations('.rowActions')?.get('gap')).toBe('13px') + expect(rowDeclarations('.iconButton')?.get('width')).toBe('24px') + expect(rowDeclarations('.time')?.get('margin-inline-end')).toBe('-1px') + expect(declarations('.sectionLabel')?.get('left')).toBe('-1px') + }) + + it('extends native row action targets while retaining the compact layout footprint', () => { + const button = rowDeclarations('.iconButton') + expect(button?.get('width')).toBe('24px') + expect(button?.get('height')).toBe('24px') + expect(button?.get('margin')).toBe('-4px') + expect(button?.get('background')).toBe('transparent') + expect(rowDeclarations('.iconButton::after')).toBeUndefined() + expect(rowDeclarations('.iconButton:focus-visible')?.get('outline-offset')).toBe('-2px') + }) + + it('enables header spacing transitions only for search gestures in wide layout', () => { + expect(declarations('.root:not(.rail) .sectionHeader')?.get('transition')).toBeUndefined() + const selector = '.root:not(.rail) .sectionHeader.searchSpacingMotion' + const [ordinary = '', reduced = ''] = css.split('@media (prefers-reduced-motion: reduce)') + expect(declarationsFrom(ordinary, selector)?.get('transition')) + .toBe('margin-right 180ms var(--ds-ease-in-out), padding-inline 180ms var(--ds-ease-in-out), gap 180ms var(--ds-ease-in-out)') + expect(declarationsFrom(reduced, selector)?.get('transition')).toBe('none') + }) + + it('aligns the expanded search field without changing list scroll ownership', () => { + const header = declarations('.root:not(.rail) .sectionHeader:has(.searchExpanded)') + expect(header?.get('margin-right')).toBe('0') + expect(header?.get('padding-inline')).toBe('calc(var(--dsh-sidebar-paint-inset) + 1px)') + expect(header?.get('gap')).toBe('0') + expect(declarations('.root:not(.rail) .sectionHeader:not(:has(.searchExpanded))')?.get('padding-right')).toBe('1px') + expect(declarations('.searchExpanded')?.get('width')).toBe('100%') + 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('.list')?.get('overflow-y')).toBe('auto') + }) + 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 diff --git a/packages/client/ui-workspace/tests/rows.client.spec.tsx b/packages/client/ui-workspace/tests/rows.client.spec.tsx index 073a26ae41..10a9289311 100644 --- a/packages/client/ui-workspace/tests/rows.client.spec.tsx +++ b/packages/client/ui-workspace/tests/rows.client.spec.tsx @@ -349,6 +349,7 @@ describe('workspace browser rows', () => { expect(screen.getByRole('treeitem').getAttribute('aria-expanded')).toBe('true') expect(screen.getByRole('button', { name: '在“Project”中新建会话' }).getAttribute('aria-keyshortcuts')).toBe('Control+N') + expect(screen.getByRole('button', { name: '在“Project”中新建会话' }).querySelector('svg')?.getAttribute('width')).toBe('14') fireEvent.click(screen.getByRole('button', { name: '在“Project”中新建会话' })) expect(onCreate).toHaveBeenCalledOnce() expect(onToggle).not.toHaveBeenCalled() diff --git a/packages/client/ui-workspace/tests/workspace-browser.client.spec.tsx b/packages/client/ui-workspace/tests/workspace-browser.client.spec.tsx index cefad07057..014f53ba81 100644 --- a/packages/client/ui-workspace/tests/workspace-browser.client.spec.tsx +++ b/packages/client/ui-workspace/tests/workspace-browser.client.spec.tsx @@ -1,5 +1,7 @@ // @vitest-environment jsdom import type { GlobalStandardProps } from '@deepseek-ai/dsh-client-ui-slots' +import { Profiler } from 'react' +import { createSnapshotStore } from '@deepseek-ai/dsh-client-store' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { act, cleanup, createEvent, fireEvent, render, screen, waitFor, within } from '@testing-library/react' import { allowAllAccess, bindSnapshotSelector, makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' @@ -1861,6 +1863,94 @@ describe('WorkspaceBrowser', () => { } }) + it.each(['clear', 'escape', 'outside'] as const)('animates %s search dismissal without carrying spacing motion through the rail', (dismissal) => { + vi.useFakeTimers() + try { + const b = mount() + const add = screen.getByRole('button', { name: '添加工作区' }) + const header = add.closest('[class*="sectionHeader"]')! + // jsdom observes the motion opt-in; the browser suite measures the resulting transitions. + const motionEnabled = () => header.className.includes('searchSpacingMotion') + expect(motionEnabled()).toBe(false) + fireEvent.click(screen.getByRole('button', { name: '搜索会话' })) + expect(motionEnabled()).toBe(true) + rerender(b, { wide: false }) + expect(motionEnabled()).toBe(false) + rerender(b, { wide: true }) + expect(motionEnabled()).toBe(false) + const input = screen.getByPlaceholderText('搜索会话名称') + if (dismissal === 'clear') { + fireEvent.change(input, { target: { value: 'query' } }) + fireEvent.click(screen.getByRole('button', { name: '清除搜索' })) + } else if (dismissal === 'escape') { + fireEvent.change(input, { target: { value: 'query' } }) + fireEvent.keyDown(input, { key: 'Escape' }) + } else fireEvent.click(document.body) + expect(motionEnabled()).toBe(true) + expect(input.value).toBe('') + expect(screen.getByRole('button', { name: '搜索会话' }).getAttribute('aria-expanded')).toBe('false') + expect(screen.getByRole('button', { name: '添加工作区' })).toBe(add) + expect(add.closest('[class*="sectionHeader"]')).toBe(header) + rerender(b, { wide: false }) + fireEvent.click(screen.getByRole('button', { name: '搜索会话' })) + rerender(b, { wide: true }) + expect(motionEnabled()).toBe(false) + act(() => { vi.advanceTimersByTime(300) }) + expect(document.activeElement).toBe(screen.getByPlaceholderText('搜索会话名称')) + expect(screen.getByRole('button', { name: '添加工作区' })).toBe(add) + } finally { + vi.useRealTimers() + } + }) + + it('preserves the add button and its open directory flow across rail layout changes', () => { + const b = mount({ wide: false }) + const add = screen.getByRole('button', { name: '添加工作区' }) + fireEvent.click(add) + const flow = screen.getByTestId('directory-flow') + for (const wide of [true, false, true]) { + rerender(b, { wide }) + expect(screen.getByRole('button', { name: '添加工作区' })).toBe(add) + expect(screen.getByTestId('directory-flow')).toBe(flow) + } + }) + + it.each([false, true])('commits rail search intent before synchronously expanding the shell (prior search: %s)', (usedSearch) => { + const layout = createSnapshotStore({ wide: usedSearch }) + const useLayout = bindSnapshotSelector(layout) + const b = mount({ wide: false, expandSidebar: () => { layout.set({ wide: true }) } }) + const committedSearchStates: boolean[] = [] + function Shell() { + return state.wide)} /> + } + b.view.rerender( { + // The shell's external-store update can commit before state queued by a passive effect. + if (screen.queryByPlaceholderText('搜索会话名称') === null) return + committedSearchStates.push(screen.getByRole('button', { name: '搜索会话' }).getAttribute('aria-expanded') === 'true') + }}>) + if (usedSearch) { + fireEvent.click(screen.getByRole('button', { name: '搜索会话' })) + fireEvent.keyDown(screen.getByPlaceholderText('搜索会话名称'), { key: 'Escape' }) + act(() => { layout.set({ wide: false }) }) + } + committedSearchStates.length = 0 + fireEvent.click(screen.getByRole('button', { name: '搜索会话' })) + expect(committedSearchStates.length).toBeGreaterThan(0) + expect(committedSearchStates.every(Boolean)).toBe(true) + expect(screen.getByRole('button', { name: '搜索会话' }).getAttribute('aria-expanded')).toBe('true') + }) + + it('treats a new shortcut source with no request as idle', () => { + const b = mount() + fireEvent.click(screen.getByRole('button', { name: '搜索会话' })) + fireEvent.keyDown(screen.getByPlaceholderText('搜索会话名称'), { key: 'Escape' }) + const controls = createWorkspaceShortcutControls() + rerender(b, { useWorkspaceShortcuts: bindSnapshotSelector(controls.state), requestSearch: controls.search }) + expect(screen.getByRole('button', { name: '搜索会话' }).getAttribute('aria-expanded')).toBe('false') + act(() => { controls.search() }) + expect(screen.getByRole('button', { name: '搜索会话' }).getAttribute('aria-expanded')).toBe('true') + }) + it('rail state renders icon controls that request expansion', () => { vi.useFakeTimers() try { @@ -1874,6 +1964,7 @@ describe('WorkspaceBrowser', () => { // The wide flip mounts the input and focuses it after the slide. rerender(b, { wide: true }) const input = screen.getByPlaceholderText('搜索会话名称') + expect(document.activeElement).not.toBe(input) act(() => { vi.advanceTimersByTime(300) }) expect(document.activeElement).toBe(input) // Wide search button is decorative (tabIndex -1, no expand call). diff --git a/snapshots/web/sidebar-scrollbar/geometry.expected.md b/snapshots/web/sidebar-scrollbar/geometry.expected.md index fa9397bdc2..26020d30a1 100644 --- a/snapshots/web/sidebar-scrollbar/geometry.expected.md +++ b/snapshots/web/sidebar-scrollbar/geometry.expected.md @@ -13,8 +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 background inset from the sidebar edge: 12px +- 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 @@ -32,8 +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 background inset from the sidebar edge: 12px +- 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 diff --git a/tsconfig.host.json b/tsconfig.host.json index 11bdd272a4..6d2f07f78a 100644 --- a/tsconfig.host.json +++ b/tsconfig.host.json @@ -146,6 +146,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",