From 788bae3674a55ac082b6b51a2acc0eb3ccea9598 Mon Sep 17 00:00:00 2001 From: Saafo <46736350+Saafo@users.noreply.github.com> Date: Tue, 20 Oct 2026 12:41:40 +0800 Subject: [PATCH 001/161] feat(client): adapt sidebar navigation to mobile drawers --- .../tests/expected/sidebar-drawer.expected.md | 22 + apps/web/tests/model-input-layout.ts | 6 + apps/web/tests/sidebar-drawer.e2e.ts | 489 ++++++++++++++++++ apps/web/tests/support.ts | 9 +- apps/web/tsconfig.json | 1 + packages/client/ui-layout/README.i18n.yaml | 8 +- packages/client/ui-layout/README.md | 8 +- packages/client/ui-layout/README.zh.md | 8 +- .../ui-layout/src/client/AppFrame.module.css | 97 +++- .../client/ui-layout/src/client/AppFrame.tsx | 89 +++- .../client/ui-layout/src/client/columns.ts | 9 +- packages/client/ui-layout/src/client/index.ts | 17 +- .../client/ui-layout/src/client/stores.ts | 6 +- .../ui-layout/tests/app-frame.client.spec.tsx | 147 +++++- .../ui-layout/tests/columns.client.spec.ts | 5 + .../tests/layout-store.client.spec.ts | 18 + .../client/ui-sidebar-right/README.i18n.yaml | 4 +- packages/client/ui-sidebar-right/README.md | 2 + packages/client/ui-sidebar-right/README.zh.md | 2 + .../src/client/shell/SidebarRight.module.css | 13 +- packages/client/ui-sidebar/README.i18n.yaml | 8 +- packages/client/ui-sidebar/README.md | 4 +- packages/client/ui-sidebar/README.zh.md | 4 +- .../client/HeaderLeadingControls.module.css | 6 +- .../src/client/SidebarRoot.module.css | 67 ++- .../ui-sidebar/src/client/SidebarRoot.tsx | 55 +- .../client/ui-sidebar/src/client/locales.ts | 2 + .../ui-sidebar/tests/apply.client.spec.tsx | 2 +- .../tests/panel-list.client.spec.tsx | 2 +- .../tests/pointer-scrollbars.client.spec.tsx | 1 + .../tests/sidebar-root.client.spec.tsx | 140 ++++- .../tests/sidebar-snapshot.client.spec.tsx | 14 +- packages/client/ui-workspace/README.i18n.yaml | 8 +- packages/client/ui-workspace/README.md | 5 + packages/client/ui-workspace/README.zh.md | 5 + .../src/client/rows/Rows.module.css | 36 +- .../ui-workspace/src/client/rows/Rows.tsx | 24 +- .../client/rows/WorkspaceBrowser.module.css | 45 +- .../src/client/rows/WorkspaceBrowser.tsx | 8 +- .../ui-workspace/tests/rows.client.spec.tsx | 30 ++ .../src/client/slot-catalog.ts | 18 +- .../client-runtime/README.i18n.yaml | 4 +- .../test-support/client-runtime/README.md | 2 +- .../test-support/client-runtime/README.zh.md | 2 +- .../src/assembly/test-client.ts | 7 +- .../tests/assembly-test-client.client.spec.ts | 11 +- tsconfig.host.json | 1 + 47 files changed, 1307 insertions(+), 164 deletions(-) create mode 100644 apps/web/tests/expected/sidebar-drawer.expected.md create mode 100644 apps/web/tests/sidebar-drawer.e2e.ts diff --git a/apps/web/tests/expected/sidebar-drawer.expected.md b/apps/web/tests/expected/sidebar-drawer.expected.md new file mode 100644 index 0000000000..cc8c6d2b7c --- /dev/null +++ b/apps/web/tests/expected/sidebar-drawer.expected.md @@ -0,0 +1,22 @@ +390px: closed sidebar has no rail; main width 390px +open: main geometry retained; background inert; no resize handle +close: Session navigation, Plugins navigation, mask and Escape +retained: sidebar DOM and opener focus +768px: 56px rail; main width 712px; drawer focus transfers to the rail toggle +breakpoint motion: collapsed rail hides immediately and returns compact without crossfades; expanded sidebar and explicit toggles retain their slides +767px: hidden drawer; main width 767px; no page overflow +global panels: browser top inset 48px; macOS keeps its existing clearance +tooltips: sidebar and workspace controls escape the drawer and fit the viewport +search input: 16px on touch; 13px with a fine pointer +simulated keyboard: homepage, drawer and Settings retain 844px layout height without following the 360px visual viewport or adding safe-area padding to Settings +keyboard close: layout height stays 844px; sidebar search retained; Settings close remains reachable +safe areas: drawer backing and mask cover 844px; content retains 56px top and 34px bottom insets +rotation: 844x390 then 390x844 restores shell height and sidebar breakpoint +folded landscape: 951px frame splits at 475.5px without a resize handle; unfolding restores 280px and search focus; portrait retains the drawer +fold transitions: sidebar controls follow the divider in both directions; reduced motion changes width without animation +drawer mask: opacity transitions from 0 to 1 on open and 1 to 0 on close +reduced motion: drawer and mask settle without transitions +touch selected Session: More remains visible; archive and pin stay in its menu; pin marker retained +touch Workspace: actions appear only while expanded, including after folding the focused row +fine pointer: folded Workspace actions remain available on hover +fine pointer: selected Session actions stay hidden until hover, including at 390px diff --git a/apps/web/tests/model-input-layout.ts b/apps/web/tests/model-input-layout.ts index e911bb3812..5cdacbfabd 100644 --- a/apps/web/tests/model-input-layout.ts +++ b/apps/web/tests/model-input-layout.ts @@ -27,5 +27,11 @@ export async function assertModelInputLayout(page: Page, editor: Locator): Promi } } finally { await page.setViewportSize(original) + // The frame reports its own width to the layout store from a rAF-throttled + // ResizeObserver, so the restored viewport is only in force once the shell + // has left drawer mode; a capture taken in between reads the narrow frame. + if (await page.locator('[data-sidebar-drawer]').count() > 0) { + await expect.poll(async () => page.locator('[data-sidebar-drawer]').count(), { timeout: 5_000 }).toBe(0) + } } } diff --git a/apps/web/tests/sidebar-drawer.e2e.ts b/apps/web/tests/sidebar-drawer.e2e.ts new file mode 100644 index 0000000000..e935fa0944 --- /dev/null +++ b/apps/web/tests/sidebar-drawer.e2e.ts @@ -0,0 +1,489 @@ +/** Narrow sidebar geometry, navigation, focus and retained content in the shipped profile. */ +import { readFile } from 'node:fs/promises' +import { fileURLToPath } from 'node:url' +import type { Locator } from 'playwright' +import { chromium } from 'playwright' +import { expect, it } from 'vitest' +import { compareOrRefreshGolden, launchWebScaffold, seedSession, watchConsole, webSnapshotMode } from './scaffold.ts' + +/** A returning rail uses its compact layout without wide-content or icon-entry animations. */ +async function assertCompactRail(sidebar: Locator): Promise { + expect(await sidebar.getByRole('button', { name: 'New session', exact: true }).count()).toBe(1) + expect(await sidebar.evaluate((el) => { + const root = el.firstElementChild! + return { + fits: root.getBoundingClientRect().width <= el.parentElement!.getBoundingClientRect().width, + animations: root.getAnimations({ subtree: true }) + .filter((animation): animation is CSSAnimation => + animation instanceof CSSAnimation && /wide-in|rail-in|rail-fade-in/.test(animation.animationName)) + .map(animation => animation.animationName), + } + })).toEqual({ fits: true, animations: [] }) +} + +it('hides a collapsed rail immediately when it becomes a drawer', async () => { + const scaffold = await launchWebScaffold({ + extraOverlayPath: fileURLToPath(new URL('./pin-browse-picker.overlay.yml', import.meta.url)), + }) + try { + const browser = await chromium.launch() + try { + const page = await browser.newPage({ viewport: { width: 768, height: 844 }, locale: 'en-US' }) + await page.goto(scaffold.authenticatedUrl) + await page.getByRole('button', { name: 'Open sidebar', exact: true }).waitFor() + const sidebar = page.locator('[data-slot="sidebar"]') + const sidebarColumn = sidebar.locator('..') + await expect.poll(async () => (await sidebarColumn.boundingBox())?.width).toBe(56) + await sidebarColumn.evaluate((el) => { + el.parentElement!.style.setProperty('--ds-transition-duration-slow', '100s') + }) + await page.setViewportSize({ width: 767, height: 844 }) + await page.locator('[data-sidebar-drawer]').waitFor() + const state = await sidebarColumn.evaluate(el => ({ + visibility: getComputedStyle(el).visibility, + transitions: el.getAnimations().filter(animation => animation instanceof CSSTransition) + .map(animation => animation.transitionProperty), + })) + expect(state).toEqual({ visibility: 'hidden', transitions: [] }) + await page.setViewportSize({ width: 768, height: 844 }) + await page.locator('[data-sidebar-drawer]').waitFor({ state: 'detached' }) + await assertCompactRail(sidebar) + await page.setViewportSize({ width: 767, height: 844 }) + await page.locator('[data-sidebar-drawer]').waitFor() + await page.getByRole('button', { name: 'Open sidebar', exact: true }).click() + const drawer = page.getByRole('dialog', { name: 'Navigation', exact: true }) + await drawer.waitFor() + expect(await sidebarColumn.evaluate(el => el.getAnimations().some(animation => + animation instanceof CSSTransition && animation.transitionProperty === 'transform'))).toBe(true) + await sidebarColumn.evaluate((el) => { + for (const animation of el.parentElement!.getAnimations({ subtree: true })) animation.finish() + }) + await drawer.getByRole('button', { name: 'Collapse sidebar', exact: true }).click() + expect(await sidebarColumn.evaluate(el => el.getAnimations().some(animation => + animation instanceof CSSTransition && animation.transitionProperty === 'transform'))).toBe(true) + await sidebarColumn.evaluate((el) => { + for (const animation of el.parentElement!.getAnimations({ subtree: true })) animation.finish() + }) + await page.setViewportSize({ width: 768, height: 844 }) + await page.locator('[data-sidebar-drawer]').waitFor({ state: 'detached' }) + await assertCompactRail(sidebar) + } finally { + await browser.close() + } + } finally { + await scaffold.close() + } +}) + +it('retains the closing slide when an expanded sidebar becomes a collapsed drawer', async () => { + const scaffold = await launchWebScaffold({ + extraOverlayPath: fileURLToPath(new URL('./pin-browse-picker.overlay.yml', import.meta.url)), + }) + try { + const browser = await chromium.launch() + try { + const page = await browser.newPage({ viewport: { width: 1024, height: 844 }, locale: 'en-US' }) + await page.goto(scaffold.authenticatedUrl) + await page.getByRole('button', { name: 'Collapse sidebar', exact: true }).waitFor() + const sidebarColumn = page.locator('[data-slot="sidebar"]').locator('..') + await expect.poll(async () => (await sidebarColumn.boundingBox())?.width).toBe(280) + await sidebarColumn.evaluate((el) => { + el.parentElement!.style.setProperty('--ds-transition-duration-slow', '100s') + }) + await page.setViewportSize({ width: 767, height: 844 }) + await page.locator('[data-sidebar-drawer][data-sidebar-collapsed]').waitFor() + expect(await sidebarColumn.evaluate(el => el.getAnimations().some(animation => + animation instanceof CSSTransition && animation.transitionProperty === 'transform'))).toBe(true) + } finally { + await browser.close() + } + } finally { + await scaffold.close() + } +}) + +/** Sample the CSS fade at its midpoint without depending on the host's frame rate. */ +async function assertMaskFade(frame: Locator, from: number, to: number): Promise { + const sample = await frame.locator('[data-sidebar-mask]').evaluate((el) => { + const fade = el.getAnimations().find(animation => animation instanceof CSSTransition && animation.transitionProperty === 'opacity') + if (fade === undefined || !(fade.effect instanceof KeyframeEffect)) throw new Error('Drawer mask opacity transition is missing') + fade.pause() + fade.currentTime = Number(fade.effect.getComputedTiming().duration) / 2 + return { + endpoints: fade.effect.getKeyframes().map(keyframe => Number(keyframe.opacity)), + opacity: Number(getComputedStyle(el).opacity), + } + }) + expect(sample.endpoints).toEqual([from, to]) + expect(sample.opacity).toBeGreaterThan(0) + expect(sample.opacity).toBeLessThan(1) + await frame.evaluate((el) => { + for (const animation of el.getAnimations({ subtree: true })) { + if (animation instanceof CSSTransition) animation.finish() + } + el.style.removeProperty('--ds-transition-duration-slow') + }) +} + +/** Sample both width transitions on the same clock to detect detached sidebar controls. */ +async function assertFoldResize(toggle: Locator, from: number, to: number): Promise { + await expect.poll(() => toggle.evaluate((button, target) => { + const frame = button.closest('[data-animating]') + return frame !== null && parseFloat(frame.style.gridTemplateColumns) === target && frame.getAnimations().some(animation => + animation instanceof CSSTransition && animation.transitionProperty === 'grid-template-columns') + }, to)).toBe(true) + const sample = await toggle.evaluate((button) => { + const frame = button.closest('[data-animating]')! + const transitions = frame.getAnimations({ subtree: true }).filter((animation): animation is CSSTransition => + animation instanceof CSSTransition && ['grid-template-columns', 'width'].includes(animation.transitionProperty)) + const grid = transitions.find(animation => animation.transitionProperty === 'grid-template-columns') + if (grid === undefined) throw new Error('Fold width transition is missing') + const at = (progress: number) => { + for (const transition of transitions) { + transition.pause() + transition.currentTime = Number(transition.effect!.getComputedTiming().duration) * progress + } + const width = parseFloat(getComputedStyle(frame).gridTemplateColumns) + return { width, gap: frame.getBoundingClientRect().left + width - button.getBoundingClientRect().right } + } + const start = at(0) + const middle = at(0.5) + const end = at(1) + for (const transition of transitions) transition.finish() + frame.style.removeProperty('--ds-transition-duration-slow') + return { endpoints: [start.width, end.width], start, middle } + }) + expect(sample.endpoints).toEqual([from, to]) + expect(sample.middle.width).toBeGreaterThan(Math.min(from, to)) + expect(sample.middle.width).toBeLessThan(Math.max(from, to)) + expect(sample.middle.gap).toBeCloseTo(sample.start.gap, 1) +} + +it('overlays the conversation and closes after navigation without losing its sidebar content', async () => { + const scaffold = await launchWebScaffold({ + extraOverlayPath: fileURLToPath(new URL('./pin-browse-picker.overlay.yml', import.meta.url)), + }) + try { + const workspace = await scaffold.ctx.workspaceRegistry.create(scaffold.workspaceCwd) + const seed = await readFile(new URL('../../../snapshots/web/seeded-history/session.v3.jsonl', import.meta.url), 'utf8') + const sessionId = await seedSession(scaffold, seed, 'session-c4421b66-252e-44c0-80ed-6e320abeba25') + await workspace.attachSession(sessionId) + await scaffold.ctx.sessionController.rename({ sessionId, title: 'Drawer navigation' }) + const browser = await chromium.launch() + try { + const page = await browser.newPage({ viewport: { width: 390, height: 844 }, hasTouch: true, locale: 'en-US' }) + const console = watchConsole(page) + await page.goto(scaffold.authenticatedUrl) + const opener = page.getByRole('button', { name: 'Open sidebar', exact: true }) + await opener.waitFor() + const frame = page.locator('[data-sidebar-drawer]') + await expect.poll(() => frame.getAttribute('data-sidebar-collapsed')).toBe('true') + const sidebar = page.locator('[data-slot="sidebar"]') + const original = await sidebar.elementHandle() + const main = page.locator('[data-slot="main"]').locator('..') + const closed = await main.boundingBox() + // A long test-owned duration keeps the transition available until its + // animation clock is sampled and finished explicitly. + await frame.evaluate((el) => { el.style.setProperty('--ds-transition-duration-slow', '100s') }) + await opener.tap() + const drawer = page.getByRole('dialog', { name: 'Navigation', exact: true }) + await drawer.waitFor() + await assertMaskFade(frame, 0, 1) + await expect.poll(() => main.boundingBox()).toEqual(closed) + expect(closed?.width).toBe(390) + expect(await main.evaluate(el => el.closest('[inert]') !== null)).toBe(true) + expect(await page.locator('[data-side="sidebar"]').count()).toBe(0) + const workspaceRow = drawer.locator('[data-row-key^="workspace:"]').first() + const workspaceButtons = workspaceRow.getByRole('button', { includeHidden: true }) + expect(await workspaceRow.getAttribute('aria-expanded')).toBe('true') + expect(await workspaceButtons.count()).toBe(2) + for (const button of await workspaceButtons.all()) await button.waitFor() + await workspaceRow.tap() + expect(await workspaceRow.getAttribute('aria-expanded')).toBe('false') + for (const button of await workspaceButtons.all()) expect(await button.isVisible()).toBe(false) + expect(await workspaceRow.evaluate(el => el === document.activeElement)).toBe(true) + await workspaceRow.tap() + for (const button of await workspaceButtons.all()) await button.waitFor() + await drawer.getByRole('button', { name: 'Search sessions', exact: true }).tap() + const search = drawer.getByPlaceholder('Search session names') + expect(await search.evaluate(el => getComputedStyle(el).fontSize)).toBe('16px') + await drawer.getByRole('button', { name: 'Clear search', exact: true }).tap() + await frame.evaluate((el) => { el.style.setProperty('--ds-transition-duration-slow', '100s') }) + await page.getByRole('treeitem').filter({ has: page.getByText('Drawer navigation', { exact: true }) }).tap() + await assertMaskFade(frame, 1, 0) + await drawer.waitFor({ state: 'hidden' }) + await page.getByRole('navigation', { name: 'Session hierarchy' }).getByText('Drawer navigation', { exact: true }).waitFor() + await opener.click() + const selectedRow = drawer.getByRole('treeitem', { selected: true }) + const more = selectedRow.getByRole('button', { name: 'Session actions for Drawer navigation', exact: true }) + await more.waitFor() + expect(await selectedRow.getByRole('button', { name: 'Archive session', exact: true }).isVisible()).toBe(false) + expect(await selectedRow.getByRole('button', { name: 'Pin session', exact: true }).isVisible()).toBe(false) + expect(await selectedRow.getByRole('button').count()).toBe(1) + await more.tap() + const menu = page.getByRole('menu') + await menu.getByRole('menuitem', { name: 'Archive session', exact: true }).waitFor() + await menu.getByRole('menuitem', { name: 'Pin session', exact: true }).tap() + await menu.waitFor({ state: 'hidden' }) + await selectedRow.getByLabel('Pinned', { exact: true }).waitFor() + await more.tap() + await menu.getByRole('menuitem', { name: 'Unpin session', exact: true }).tap() + await menu.waitFor({ state: 'hidden' }) + await expect.poll(() => selectedRow.getByLabel('Pinned', { exact: true }).count()).toBe(0) + await drawer.getByRole('button', { name: 'Plugins', exact: true }).tap() + await drawer.waitFor({ state: 'hidden' }) + await page.locator('[data-plugin-panel]').waitFor() + await opener.click() + await drawer.waitFor() + await page.touchscreen.tap(370, 420) + await drawer.waitFor({ state: 'hidden' }) + await opener.click() + await page.keyboard.press('Escape') + await drawer.waitFor({ state: 'hidden' }) + expect(await opener.evaluate(el => el === document.activeElement)).toBe(true) + expect(await sidebar.evaluate((el, previous) => el === previous, original)).toBe(true) + await original?.dispose() + + await page.setViewportSize({ width: 768, height: 844 }) + await expect.poll(() => frame.count()).toBe(0) + await expect.poll(async () => Math.round((await main.boundingBox())!.width)).toBe(712) + await page.setViewportSize({ width: 767, height: 844 }) + await expect.poll(() => frame.count()).toBe(1) + + await page.emulateMedia({ reducedMotion: 'reduce' }) + await opener.tap() + const mask = frame.locator('[data-sidebar-mask]') + expect(await mask.evaluate(el => getComputedStyle(el).opacity)).toBe('1') + expect(await mask.evaluate(el => el.getAnimations().length)).toBe(0) + await drawer.getByRole('button', { name: 'Collapse sidebar', exact: true }).tap() + expect(await mask.evaluate(el => getComputedStyle(el).opacity)).toBe('0') + await page.emulateMedia({ reducedMotion: 'no-preference' }) + await expect.poll(async () => Math.round((await main.boundingBox())!.width)).toBe(767) + expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBe(767) + await page.evaluate(() => { document.documentElement.dataset.platform = 'darwin' }) + await expect.poll(() => main.evaluate(el => getComputedStyle(el).paddingTop)).toBe('0px') + await page.evaluate(() => { delete document.documentElement.dataset.platform }) + await expect.poll(() => main.evaluate(el => getComputedStyle(el).paddingTop)).toBe('48px') + + await page.setViewportSize({ width: 390, height: 844 }) + await drawer.waitFor({ state: 'hidden' }) + await opener.tap() + await drawer.getByRole('button', { name: 'New session', exact: true }).last().tap() + await drawer.waitFor({ state: 'hidden' }) + const home = page.locator('[data-phase="hero"]') + await home.waitFor() + const editor = home.locator('[contenteditable="true"]') + await editor.tap() + const homeEditorBox = await editor.boundingBox() + // Chromium cannot raise a phone keyboard; simulate document panning + // before the visual viewport publishes the matching offset. + const restoreViewport = await page.evaluateHandle(() => { + const viewport = window.visualViewport! + const scrollY = Object.getOwnPropertyDescriptor(window, 'scrollY')! + Object.defineProperty(window, 'scrollY', { configurable: true, value: 80 }) + Object.defineProperties(viewport, { + height: { configurable: true, value: 360 }, + offsetTop: { configurable: true, value: 0 }, + }) + viewport.dispatchEvent(new Event('resize')) + viewport.dispatchEvent(new Event('scroll')) + window.dispatchEvent(new Event('scroll')) + return () => { + Object.defineProperty(window, 'scrollY', scrollY) + Reflect.deleteProperty(viewport, 'height') + Reflect.deleteProperty(viewport, 'offsetTop') + window.dispatchEvent(new Event('scroll')) + viewport.dispatchEvent(new Event('resize')) + } + }) + const root = page.locator('#root') + await expect.poll(() => root.boundingBox()).toEqual({ x: 0, y: 0, width: 390, height: 844 }) + expect(await editor.boundingBox()).toEqual(homeEditorBox) + await page.evaluate(() => { + Object.defineProperty(window.visualViewport!, 'offsetTop', { configurable: true, value: 80 }) + window.visualViewport!.dispatchEvent(new Event('scroll')) + }) + await expect.poll(() => root.boundingBox()).toEqual({ x: 0, y: 0, width: 390, height: 844 }) + await expect.poll(async () => (await main.boundingBox())?.height).toBe(844) + await opener.tap() + await expect.poll(async () => (await drawer.boundingBox())?.height).toBe(844) + const searchWithKeyboard = drawer.getByPlaceholder('Search session names') + await drawer.getByRole('button', { name: 'Search sessions', exact: true }).tap() + await searchWithKeyboard.tap() + await searchWithKeyboard.fill('retained search') + await drawer.getByRole('button', { name: 'Account menu', exact: true }).tap() + await page.getByRole('menuitem', { name: 'Settings', exact: true }).tap() + const settings = page.getByRole('dialog', { name: 'Settings', exact: true }) + await settings.waitFor() + await expect.poll(() => settings.boundingBox()).toEqual({ x: 0, y: 0, width: 390, height: 844 }) + expect(await settings.locator('..').evaluate(el => getComputedStyle(el).padding)).toBe('0px') + const closeSettings = settings.getByRole('button', { name: 'Close', exact: true }) + const closeBox = (await closeSettings.boundingBox())! + expect(closeBox.y).toBeGreaterThanOrEqual(0) + expect(closeBox.y + closeBox.height).toBeLessThanOrEqual(844) + await closeSettings.tap() + await settings.waitFor({ state: 'hidden' }) + await expect.poll(() => searchWithKeyboard.inputValue()).toBe('retained search') + await restoreViewport.evaluate((restore) => { restore() }) + await restoreViewport.dispose() + await expect.poll(() => root.boundingBox()).toEqual({ x: 0, y: 0, width: 390, height: 844 }) + await expect.poll(async () => (await drawer.boundingBox())?.height).toBe(844) + // Desktop Chromium supplies zero safe-area insets; exercise the shell's + // inset properties with the same geometry as a phone's protected edges. + await root.evaluate((el) => { + el.style.setProperty('--dsh-safe-area-top', '56px') + el.style.setProperty('--dsh-safe-area-bottom', '34px') + }) + await frame.evaluate((el) => { el.scrollLeft = 10 }) + expect(await frame.evaluate(el => el.scrollLeft)).toBe(0) + await expect.poll(() => mask.boundingBox()).toEqual({ x: 0, y: 0, width: 390, height: 844 }) + await expect.poll(() => drawer.evaluate((el) => { + const frame = el.closest('[data-sidebar-drawer]')! + let column = el + while (column.parentElement !== frame) column = column.parentElement! + const { y, height } = column.getBoundingClientRect() + return { y, height } + })).toEqual({ y: 0, height: 844 }) + await expect.poll(async () => { + const box = await drawer.boundingBox() + return { y: box?.y, height: box?.height } + }).toEqual({ y: 56, height: 754 }) + await root.evaluate((el) => { + el.style.removeProperty('--dsh-safe-area-top') + el.style.removeProperty('--dsh-safe-area-bottom') + }) + await expect.poll(async () => (await drawer.boundingBox())?.height).toBe(844) + await page.setViewportSize({ width: 844, height: 390 }) + await expect.poll(async () => (await root.boundingBox())?.height).toBe(390) + await expect.poll(() => frame.count()).toBe(0) + await page.setViewportSize({ width: 390, height: 844 }) + await expect.poll(async () => (await root.boundingBox())?.height).toBe(844) + await expect.poll(() => frame.count()).toBe(1) + + const posture = await page.context().newCDPSession(page) + try { + await page.setViewportSize({ width: 951, height: 669 }) + await expect.poll(() => frame.count()).toBe(0) + await posture.send('Emulation.setDevicePostureOverride', { posture: { type: 'folded' } }) + await opener.tap() + await expect.poll(() => main.boundingBox()).toEqual({ x: 475.5, y: 0, width: 475.5, height: 669 }) + expect(await page.locator('[data-side="sidebar"]').count()).toBe(0) + const foldSearch = page.getByPlaceholder('Search session names') + if (!(await foldSearch.isVisible())) await page.getByRole('button', { name: 'Search sessions', exact: true }).tap() + await foldSearch.fill('fold search') + const wideFrame = page.locator('[data-rightbar-collapsed]') + const collapseSidebar = page.getByRole('button', { name: 'Collapse sidebar', exact: true }) + await wideFrame.evaluate((el) => { el.style.setProperty('--ds-transition-duration-slow', '100s') }) + await posture.send('Emulation.clearDevicePostureOverride') + await assertFoldResize(collapseSidebar, 475.5, 280) + await expect.poll(() => main.boundingBox()).toEqual({ x: 280, y: 0, width: 671, height: 669 }) + expect(await page.locator('[data-side="sidebar"]').count()).toBe(1) + expect(await foldSearch.evaluate(el => el === document.activeElement)).toBe(true) + expect(await foldSearch.inputValue()).toBe('fold search') + await wideFrame.evaluate((el) => { el.style.setProperty('--ds-transition-duration-slow', '100s') }) + await posture.send('Emulation.setDevicePostureOverride', { posture: { type: 'folded' } }) + await assertFoldResize(collapseSidebar, 280, 475.5) + await expect.poll(async () => (await main.boundingBox())?.x).toBe(475.5) + expect(await foldSearch.evaluate(el => el === document.activeElement)).toBe(true) + await page.emulateMedia({ reducedMotion: 'reduce' }) + await posture.send('Emulation.clearDevicePostureOverride') + await expect.poll(async () => (await main.boundingBox())?.x).toBe(280) + expect(await wideFrame.evaluate(el => el.getAnimations({ subtree: true }).length)).toBe(0) + await posture.send('Emulation.setDevicePostureOverride', { posture: { type: 'folded' } }) + await expect.poll(async () => (await main.boundingBox())?.x).toBe(475.5) + await page.emulateMedia({ reducedMotion: 'no-preference' }) + await page.setViewportSize({ width: 669, height: 951 }) + await expect.poll(() => frame.getAttribute('data-sidebar-collapsed')).toBe('true') + await drawer.waitFor({ state: 'hidden' }) + await opener.tap() + await expect.poll(async () => (await drawer.boundingBox())?.width).toBe(280) + expect(await main.boundingBox()).toEqual({ x: 0, y: 0, width: 669, height: 951 }) + } finally { + await posture.detach() + } + + const pointerPage = await browser.newPage({ viewport: { width: 390, height: 844 }, locale: 'en-US' }) + try { + await pointerPage.goto(scaffold.authenticatedUrl) + await pointerPage.getByRole('button', { name: 'Open sidebar', exact: true }).click() + expect(await pointerPage.getByPlaceholder('Search session names').evaluate(el => getComputedStyle(el).fontSize)).toBe('13px') + const pointerWorkspace = pointerPage.locator('[data-row-key^="workspace:"]').first() + const pointerWorkspaceButtons = pointerWorkspace.getByRole('button', { includeHidden: true }) + await pointerWorkspace.click() + expect(await pointerWorkspace.getAttribute('aria-expanded')).toBe('false') + const collapseSidebar = pointerPage.getByRole('button', { name: 'Collapse sidebar', exact: true }) + await collapseSidebar.focus() + await collapseSidebar.hover() + for (const button of await pointerWorkspaceButtons.all()) expect(await button.isVisible()).toBe(false) + await pointerWorkspace.hover() + for (const button of await pointerWorkspaceButtons.all()) await button.waitFor() + await pointerWorkspace.click() + for (const label of ['Collapse sidebar', 'View options']) { + await pointerPage.getByRole('button', { name: label, exact: true }).hover() + const tooltip = pointerPage.getByRole('tooltip').filter({ hasText: label }) + await tooltip.waitFor() + expect(await tooltip.evaluate(el => el.closest('[data-slot="sidebar"]'))).toBeNull() + const box = (await tooltip.boundingBox())! + expect(box.x).toBeGreaterThanOrEqual(12) + expect(box.x + box.width).toBeLessThanOrEqual(378) + } + const pointerRow = pointerPage.getByRole('treeitem').filter({ has: pointerPage.getByText('Drawer navigation', { exact: true }) }) + await pointerRow.click() + await pointerPage.getByRole('dialog', { name: 'Navigation', exact: true }).waitFor({ state: 'hidden' }) + await pointerPage.getByRole('button', { name: 'Open sidebar', exact: true }).click() + await pointerPage.getByRole('button', { name: 'Collapse sidebar', exact: true }).hover() + await expect.poll(() => pointerRow.getByRole('button', { name: 'Session actions for Drawer navigation', exact: true }).isVisible()).toBe(false) + await pointerRow.hover() + for (const label of ['Session actions for Drawer navigation', 'Archive session', 'Pin session']) { + await pointerRow.getByRole('button', { name: label, exact: true }).waitFor() + } + await pointerPage.getByRole('button', { name: 'Collapse sidebar', exact: true }).click() + const pointerDrawer = pointerPage.getByRole('dialog', { name: 'Navigation', exact: true }) + await pointerDrawer.waitFor({ state: 'hidden' }) + await pointerPage.setViewportSize({ width: 767, height: 844 }) + await pointerPage.getByRole('button', { name: 'Open sidebar', exact: true }).press('Enter') + await pointerDrawer.waitFor() + expect(await pointerDrawer.evaluate(el => el.contains(document.activeElement))).toBe(true) + await pointerPage.setViewportSize({ width: 768, height: 844 }) + await pointerPage.locator('[data-sidebar-drawer]').waitFor({ state: 'detached' }) + const pointerSidebar = pointerPage.locator('[data-slot="sidebar"]') + await expect.poll(() => pointerSidebar.getByRole('button', { name: 'New session', exact: true }).count()).toBe(1) + const railToggle = pointerSidebar.getByRole('button', { name: 'Open sidebar', exact: true }) + expect(await railToggle.evaluate(el => el === document.activeElement)).toBe(true) + } finally { + await pointerPage.close() + } + expect(console.pageErrors).toEqual([]) + expect(console.warnings).toEqual([]) + const result = [ + '390px: closed sidebar has no rail; main width 390px', + 'open: main geometry retained; background inert; no resize handle', + 'close: Session navigation, Plugins navigation, mask and Escape', + 'retained: sidebar DOM and opener focus', + '768px: 56px rail; main width 712px; drawer focus transfers to the rail toggle', + 'breakpoint motion: collapsed rail hides immediately and returns compact without crossfades; expanded sidebar and explicit toggles retain their slides', + '767px: hidden drawer; main width 767px; no page overflow', + 'global panels: browser top inset 48px; macOS keeps its existing clearance', + 'tooltips: sidebar and workspace controls escape the drawer and fit the viewport', + 'search input: 16px on touch; 13px with a fine pointer', + 'simulated keyboard: homepage, drawer and Settings retain 844px layout height without following the 360px visual viewport or adding safe-area padding to Settings', + 'keyboard close: layout height stays 844px; sidebar search retained; Settings close remains reachable', + 'safe areas: drawer backing and mask cover 844px; content retains 56px top and 34px bottom insets', + 'rotation: 844x390 then 390x844 restores shell height and sidebar breakpoint', + 'folded landscape: 951px frame splits at 475.5px without a resize handle; unfolding restores 280px and search focus; portrait retains the drawer', + 'fold transitions: sidebar controls follow the divider in both directions; reduced motion changes width without animation', + 'drawer mask: opacity transitions from 0 to 1 on open and 1 to 0 on close', + 'reduced motion: drawer and mask settle without transitions', + 'touch selected Session: More remains visible; archive and pin stay in its menu; pin marker retained', + 'touch Workspace: actions appear only while expanded, including after folding the focused row', + 'fine pointer: folded Workspace actions remain available on hover', + 'fine pointer: selected Session actions stay hidden until hover, including at 390px', + ].join('\n') + await compareOrRefreshGolden(fileURLToPath(new URL('./expected/sidebar-drawer.expected.md', import.meta.url)), result, webSnapshotMode()) + } finally { + await browser.close() + } + } finally { + await scaffold.close() + } +}) diff --git a/apps/web/tests/support.ts b/apps/web/tests/support.ts index 33cfdeb374..42b5260536 100644 --- a/apps/web/tests/support.ts +++ b/apps/web/tests/support.ts @@ -334,6 +334,13 @@ export function conversationContextKey(kind: string, id: string): string { * @param locale - current UI language. */ export async function openSettings(page: Page, locale: 'en' | 'zh'): Promise { - await page.getByRole('button', { name: locale === 'zh' ? '账号菜单' : 'Account menu', exact: true }).click() + const trigger = page.getByRole('button', { + name: locale === 'zh' ? '账号菜单' : 'Account menu', exact: true, includeHidden: true, + }) + await trigger.waitFor({ state: 'attached' }) + if (!await trigger.isVisible()) { + await page.getByRole('button', { name: locale === 'zh' ? '打开侧边栏' : 'Open sidebar', exact: true }).click() + } + await trigger.click() await page.getByRole('menuitem', { name: locale === 'zh' ? '设置' : 'Settings', exact: true }).click() } diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json index db02a233ec..40221e6122 100644 --- a/apps/web/tsconfig.json +++ b/apps/web/tsconfig.json @@ -22,6 +22,7 @@ // this client-registered project must not also hold them (one program // cannot see both sides of the cordis Context merges). "exclude": [ + "tests/sidebar-drawer.e2e.ts", "tests/collapse-timing.e2e.ts", "tests/notice-wake-tail.e2e.ts", "tests/fold-blank-matrix.e2e.ts", diff --git a/packages/client/ui-layout/README.i18n.yaml b/packages/client/ui-layout/README.i18n.yaml index 4ad7481ef7..159ff8de14 100644 --- a/packages/client/ui-layout/README.i18n.yaml +++ b/packages/client/ui-layout/README.i18n.yaml @@ -15,8 +15,8 @@ en: d152484eb41ac6b4 zh: 09388d293f9be9cb /deepseek-ai-dsh-client-ui-layout/use-this-package: - en: cc6e9021ba3001bc - zh: 97444857762b4e8e + en: df0222b3e0ae1df8 + zh: 77d2968b75f166a2 /deepseek-ai-dsh-client-ui-layout/use-this-package/window-chrome-seat: en: 7eac53b38d0777fe zh: 7f9422dff671ba50 @@ -36,8 +36,8 @@ en: ca75c51c89c2c9b0 zh: de54f3467ef3b9be /deepseek-ai-dsh-client-ui-layout/known-limitations-and-deferred-work: - en: da92b97ac60d989f - zh: 5816b861728f1a04 + en: 834ae31817179718 + zh: d2cff416fd0f643a /deepseek-ai-dsh-client-ui-layout/known-limitations-and-deferred-work/dev-note: en: e67f633dc6fe7773 zh: 3bb0c20c93f16f98 diff --git a/packages/client/ui-layout/README.md b/packages/client/ui-layout/README.md index 43c61b89d1..8d9b8cd3e6 100644 --- a/packages/client/ui-layout/README.md +++ b/packages/client/ui-layout/README.md @@ -27,7 +27,11 @@ This package provides the Web GUI's three-column AppFrame, edge-column widths, a Desktop Mod+B toggles the left sidebar through the same layout action used by its controls. The shortcut command is unavailable behind modal dialogs and yields to terminal input. -The root slot composes the sidebar, main content, and right column. The sidebar spans 264–420px, defaults to 280px, and retains a 56px rail when collapsed; below 1024px it collapses automatically, and opening the right panel collapses a manually expanded sidebar. The right panel first opens at 45% of the viewport, then retains the user's pixel preference, capped at 70%. To protect 400px for the center, the frame first reduces the right panel to 300px, then reports insufficient room so its occupant closes it, and only then compresses the center further. Dragging has no transition delay; the right handle is absent while closed or fullscreen. +The root slot composes the sidebar, main content, and right column. The sidebar spans 264–420px, defaults to 280px, and retains a 56px rail when collapsed at widths of 768px and above. Below 768px, without a native Windows caption, it uses a modal drawer without reserving a column; the header retains the reopen controls, and navigation closes the drawer. Below 1024px it collapses automatically, and opening the right panel collapses a manually expanded sidebar. The right panel first opens at 45% of the viewport, then retains the user's pixel preference, capped at 70%. To protect 400px for the center, the frame first reduces the right panel to 300px, then reports insufficient room so its occupant closes it, and only then compresses the center further. Dragging has no transition delay; the right handle is absent while closed or fullscreen. + +When the browser reports a folded device in landscape and the frame is at least 768px wide, the expanded sidebar occupies half the frame, aligning its divider with the center fold. Its resize handle is hidden; unfolding restores the saved width. Fold changes at a stable viewport width use the existing track transition; reduced motion disables it. Browsers without the device-posture media feature keep the ordinary layout. + +The drawer mask fades with the panel’s transition duration and easing on both open and close; reduced motion disables both transitions. Entering drawer mode from an already collapsed sidebar hides the drawer immediately; an expanded sidebar keeps its closing slide. The drawer has no edge divider, and its frame does not scroll. The frame fills the [shell’s layout viewport](../web/README.md#viewport-sizing), retaining its height while the keyboard opens. Drawer backing and mask cover its full height, including the top and bottom safe areas and `shell.bottom`; frame and drawer content retain the safe-area insets once. Global panels occupy the root-scoped `main` keyed slot; `conversation` is the reserved key for the Conversation. `ctx.layout.selectPanel(id)` selects a registered panel, and `null` selects the Conversation without changing the current Session. The Plugins page contributes one of the shipped global panels. @@ -92,7 +96,7 @@ None; this package neither assembles nor sends a provider request. These limits define the current layout behavior. They are current package constraints, not a general window-manager comparison or a task backlog. - **Panel geometry is transient** — reload restores the sidebar default and the right panel hidden; each dragged width is one frame-wide preference, not a per-Session fact. -- **Extremely narrow windows** — after the right panel closes, the center may still fall below 400px; the left 56px rail remains. +- **Extremely narrow windows** — after the right panel closes, the center may still fall below 400px; browser drawers reserve no sidebar width below 768px. - **Track and panel travel on one shared curve only while animating** — during a discrete open/close the frame sets `data-animating` and its track transition and the occupant's slide read the same duration and easing variables; an occupant that used its own would detach the panel's edge from the conversation's while squeezing. Drags and instant presentation switches run transition-free, so the curve does not cover them. - **No scroll anchoring during squeeze reflow** — layout changes may move the reader's viewport. diff --git a/packages/client/ui-layout/README.zh.md b/packages/client/ui-layout/README.zh.md index 9b426dcdef..211d590846 100644 --- a/packages/client/ui-layout/README.zh.md +++ b/packages/client/ui-layout/README.zh.md @@ -27,7 +27,11 @@ kind: "package-reference" 桌面 Mod+B 通过与控件共用的布局操作切换左侧栏。模态弹窗打开时此快捷键不可用;终端输入优先。 -本插件在 root slot 中组合侧边栏、主内容和右栏。侧边栏宽度为 264~420px,默认为 280px,收起后保留 56px 控制栏;窗口宽度低于 1024px 时自动收起,打开右侧面板也会收起手动展开的侧边栏。右侧面板首次打开时使用视口宽度的 45%,之后保留用户的像素宽度偏好,上限为 70%。为给中栏保留 400px,框架先将右侧面板缩减至 300px,再报告空间不足,使占用方将其关闭,最后才进一步压缩中栏。拖动没有过渡延迟;右侧手柄在关闭或全屏时不显示。 +本插件在 root slot 中组合侧边栏、主内容和右栏。侧边栏宽度为 264~420px,默认为 280px,在宽度不低于 768px 时,收起后保留 56px 控制栏。低于 768px 且没有原生 Windows 顶栏时,采用模态抽屉,不占据列宽;标题区域保留重新打开的控件,导航后关闭抽屉。窗口宽度低于 1024px 时自动收起,打开右侧面板也会收起手动展开的侧边栏。右侧面板首次打开时使用视口宽度的 45%,之后保留用户的像素宽度偏好,上限为 70%。为给中栏保留 400px,框架先将右侧面板缩减至 300px,再报告空间不足,使占用方将其关闭,最后才进一步压缩中栏。拖动没有过渡延迟;右侧手柄在关闭或全屏时不显示。 + +当浏览器报告设备处于横屏半折叠状态,且框架宽度不低于 768px 时,展开的侧边栏占据一半宽度,分隔线与屏幕中央的折痕对齐。此时隐藏拖拽手柄;完全展开后恢复保存的宽度。视口宽度不变时,折叠状态切换复用现有的列宽过渡;减少动态效果时禁用该过渡。不支持 device-posture 媒体特性的浏览器继续使用常规布局。 + +抽屉遮罩使用与面板相同的时长和缓动,在打开与关闭时渐变透明度;减少动态效果时两者均不播放动画。从已收起的侧栏切换到抽屉布局时,抽屉立即隐藏;已展开的侧栏保留收起动画。抽屉边缘不绘制分隔线,外层框架不滚动。框架铺满[外壳的布局视口](../web/README.zh.md#viewport-sizing),在键盘抬起时保留原有高度。抽屉背景和遮罩覆盖完整高度,包括上下安全区与 `shell.bottom`;框架和抽屉的内容各保留一次安全区边距。 全局面板占据 root 作用域的 `main` keyed slot;`conversation` 是为会话界面保留的 key。`ctx.layout.selectPanel(id)` 选中已注册面板,`null` 则选中会话界面,但不改变当前会话。Plugins 页面是默认组合中的一个全局面板。 @@ -92,7 +96,7 @@ Windows Electron 的 `data-windows-titlebar` 标记在所有列上方预留顶 这些限制界定了当前布局行为。它们是当前包约束,不是通用窗口管理器对比或任务积压。 - **面板几何是瞬时状态**——重新加载会恢复侧栏默认值并隐藏右侧面板;拖动设置的宽度是整个框架共用的一份偏好,而非每个会话各自的属性。 -- **极窄窗口**——右侧面板关闭后,中栏仍可能小于 400px;左侧 56px 控制栏仍会保留。 +- **极窄窗口**——右侧面板关闭后,中栏仍可能小于 400px;低于 768px 时,浏览器抽屉不占据侧栏宽度。 - **轨道与面板仅在动画期间沿同一条曲线运动**——离散开合时框架设置 `data-animating`,其轨道过渡和占用方的滑入读取同一组时长与缓动变量;占用方若自用一套,挤压时面板边缘就会与会话界面的边缘脱开。拖拽和即时呈现切换不带过渡,因此该曲线不覆盖它们。 - **挤压重排期间无滚动锚定**——布局变化可能移动读者的视口。 diff --git a/packages/client/ui-layout/src/client/AppFrame.module.css b/packages/client/ui-layout/src/client/AppFrame.module.css index 5b71ebecfd..bfe09fc483 100644 --- a/packages/client/ui-layout/src/client/AppFrame.module.css +++ b/packages/client/ui-layout/src/client/AppFrame.module.css @@ -3,11 +3,13 @@ display: grid; grid-template-rows: minmax(0, 1fr) auto; height: 100%; + box-sizing: border-box; + padding-block: var(--dsh-safe-area-top, 0px) var(--dsh-safe-area-bottom, 0px); overflow: hidden; background: var(--dsw-alias-bg-base); } -/* Only a discrete collapse/expand toggle eases the tracks (the TSX holds +/* Only discrete collapse/expand and fold changes ease the tracks (the TSX holds data-animating across the toggle on the deepsuite sider curve). Steady-state tracks move only with the viewport, and those updates must land instantly: an eased track chases the live window edge and the centre column visibly @@ -56,6 +58,8 @@ } .sidebarCol { + grid-column: 1; + grid-row: 1; min-width: 0; overflow: hidden; background: var(--dsw-specific-sidebar-fill); @@ -63,12 +67,22 @@ } .centerCol { + grid-column: 2; + grid-row: 1; min-width: 0; display: flex; flex-direction: column; overflow: hidden; } +.mainPanel { + display: flex; + flex-direction: column; + flex: 1; + min-width: 0; + min-height: 0; +} + /* macOS desktop: the frame goes transparent so window vibrancy reaches the sidebar column, which keeps only a translucent tint; the centre paints the base background itself to stay opaque. The sidebar/centre separator moves @@ -265,11 +279,92 @@ * where it is. The occupant draws its own left border. */ .rightbarCol { + grid-column: 3; + grid-row: 1; position: relative; min-width: 0; overflow: visible; } +.frame[data-sidebar-drawer] { + --dsh-frame-leading-clearance: 84px; + overflow: clip; +} + +.frame[data-sidebar-drawer] .mainPanel[data-global-panel] { + padding-top: max(0px, calc(48px - var(--dsh-frame-top-clearance, 0px))); +} + +.frame[data-sidebar-drawer] .sidebarCol { + position: absolute; + grid-area: auto; + inset: 0 auto 0 0; + z-index: 31; + box-sizing: border-box; + border-right: none; + padding-block: var(--dsh-safe-area-top, 0px) var(--dsh-safe-area-bottom, 0px); + transform: translateX(0); + visibility: visible; + transition: transform var(--ds-transition-duration-slow) var(--ds-ease-in-out); +} + +.frame[data-sidebar-drawer][data-sidebar-collapsed] .sidebarCol { + transform: translateX(-100%); + visibility: hidden; + transition: + transform var(--ds-transition-duration-slow) var(--ds-ease-in-out), + visibility 0s linear var(--ds-transition-duration-slow); +} + +.frame[data-sidebar-drawer][data-sidebar-instant] .sidebarCol { + transition: none; +} + +.drawerMask { + position: absolute; + inset: var(--dsh-frame-chrome-top, 0px) 0 0; + z-index: 30; + background: var(--dsw-alias-bg-mask-1); + backdrop-filter: var(--dsw-mask-blur); + opacity: 0; + pointer-events: none; + transition: opacity var(--ds-transition-duration-slow) var(--ds-ease-in-out); +} + +.frame[data-sidebar-drawer]:not([data-sidebar-collapsed]) .drawerMask { + opacity: 1; + pointer-events: auto; +} + +.frame[data-sidebar-drawer] .leadingSeat { + top: calc(11px + var(--dsh-safe-area-top, 0px)); + left: 12px; +} + +:global([data-platform='darwin']) .frame[data-sidebar-drawer] { + --dsh-frame-leading-clearance: 160px; +} + +:global([data-platform='darwin']) .frame[data-sidebar-drawer] .leadingSeat { + left: 88px; +} + +:global([data-platform='darwin'][data-fullscreen]) .frame[data-sidebar-drawer] { + --dsh-frame-leading-clearance: 84px; +} + +:global([data-platform='darwin'][data-fullscreen]) .frame[data-sidebar-drawer] .leadingSeat { + left: 12px; +} + +@media (prefers-reduced-motion: reduce) { + .frame[data-sidebar-drawer] .sidebarCol, + .frame[data-sidebar-drawer][data-sidebar-collapsed] .sidebarCol, + .drawerMask { + transition: none; + } +} + .bottomRow { grid-column: 1 / -1; min-width: 0; diff --git a/packages/client/ui-layout/src/client/AppFrame.tsx b/packages/client/ui-layout/src/client/AppFrame.tsx index 5614947447..5d0e310b59 100644 --- a/packages/client/ui-layout/src/client/AppFrame.tsx +++ b/packages/client/ui-layout/src/client/AppFrame.tsx @@ -19,7 +19,7 @@ import type { ReactNode } from 'react' import type { PropsLocale, PropsRenderSlots, PropsRuntime, PropsStore, } from '@deepseek-ai/dsh-client-ui-slots' -import { CENTER_MIN, clampWidth, computeColumns, RIGHTBAR_DEFAULT_RATIO, RIGHTBAR_MAX_RATIO, RIGHTBAR_MIN, SIDEBAR_AUTO_COLLAPSE, SIDEBAR_COLLAPSED, SIDEBAR_DEFAULT } from './columns.ts' +import { CENTER_MIN, clampWidth, computeColumns, RIGHTBAR_DEFAULT_RATIO, RIGHTBAR_MAX_RATIO, RIGHTBAR_MIN, SIDEBAR_AUTO_COLLAPSE, SIDEBAR_COLLAPSED, SIDEBAR_DEFAULT, SIDEBAR_DRAWER_BREAKPOINT } from './columns.ts' import { DocumentTitle } from './DocumentTitle.tsx' import type { createLayoutStore } from './stores.ts' import css from './AppFrame.module.css' @@ -32,14 +32,16 @@ export type AppFrameProps = & PropsLocale<'common'> /** Center column grid item (session-body building block). */ -function CenterColumn(props: { children?: ReactNode }) { - return
{props.children}
+function CenterColumn(props: { children?: ReactNode; inert: boolean }) { + return
{props.children}
} /** Subscribe to the main key without subscribing the column frame to each panel id. */ function MainPanel({ usePanelInfo, renderSlot }: Pick, 'usePanelInfo'> & PropsRenderSlots<'main'>) { const panelId = usePanelInfo(info => info.activePanelId) - return renderSlot('main', {}, { entryKey: panelId ?? 'conversation' }) + return
+ {renderSlot('main', {}, { entryKey: panelId ?? 'conversation' })} +
} /** @@ -47,8 +49,8 @@ function MainPanel({ usePanelInfo, renderSlot }: Pick, 'use * occupant's panel is positioned against the column's right edge, which never * moves, so it can hang over the centre when there is no track. */ -function RightbarColumn(props: { children?: ReactNode }) { - return
{props.children}
+function RightbarColumn(props: { children?: ReactNode; inert: boolean }) { + return
{props.children}
} /** @@ -129,6 +131,15 @@ export function AppFrame({ const layoutInfo = useStore(state => state.layoutInfo) const frameRef = useRef(null) const viewport = layoutInfo.viewportWidth + const [foldedLandscape, setFoldedLandscape] = useState(false) + + useLayoutEffect(() => { + const query = window.matchMedia('(device-posture: folded) and (orientation: landscape)') + const update = () => { setFoldedLandscape(query.matches) } + update() + query.addEventListener('change', update) + return () => { query.removeEventListener('change', update) } + }, []) // Track the frame's own box (not the window): rAF-throttled ResizeObserver. useLayoutEffect(() => { @@ -158,7 +169,16 @@ export function AppFrame({ }, [actions]) const narrow = viewport < SIDEBAR_AUTO_COLLAPSE + const windowsTitlebar = document.documentElement.hasAttribute('data-windows-titlebar') + const drawer = viewport < SIDEBAR_DRAWER_BREAKPOINT && !windowsTitlebar + const centeredSidebar = foldedLandscape && viewport >= SIDEBAR_DRAWER_BREAKPOINT const sidebarCollapsed = narrow ? !layoutInfo.narrowExpanded : layoutInfo.sidebar === 0 + const drawerOpen = drawer && !sidebarCollapsed + // Entering drawer mode from a collapsed rail hides the backing immediately. + const [sidebarTransition, setSidebarTransition] = useState({ drawer, collapsed: sidebarCollapsed, instant: true }) + if (sidebarTransition.drawer !== drawer || sidebarTransition.collapsed !== sidebarCollapsed) { + setSidebarTransition({ drawer, collapsed: sidebarCollapsed, instant: drawer && sidebarCollapsed && sidebarTransition.collapsed }) + } const sidebarPreference = sidebarCollapsed ? 0 : layoutInfo.sidebar === 0 ? SIDEBAR_DEFAULT : layoutInfo.sidebar @@ -166,12 +186,15 @@ export function AppFrame({ // Desktop reopen controls occupy the frame's shell.leading seat (macOS) or // the Windows caption row; neither platform keeps an icon rail. const darwin = document.documentElement.dataset.platform === 'darwin' - const collapsedWidth = darwin - || document.documentElement.hasAttribute('data-windows-titlebar') ? 0 : SIDEBAR_COLLAPSED + const collapsedWidth = drawer || darwin || windowsTitlebar ? 0 : SIDEBAR_COLLAPSED // Opening on a narrow frame collapses the left sidebar. Eligibility must // include that space before the occupant's first shown report arrives. - const normal = computeColumns(viewport, !layoutInfo.rightbarShown && narrow ? 0 : sidebarPreference, rightbarPreference, collapsedWidth) - const cols = computeColumns(viewport, sidebarPreference, layoutInfo.rightbarTrack ? rightbarPreference : 0, collapsedWidth) + const normal = computeColumns( + viewport, !layoutInfo.rightbarShown && narrow ? 0 : sidebarPreference, rightbarPreference, collapsedWidth, centeredSidebar, + ) + const cols = computeColumns( + viewport, drawer ? 0 : sidebarPreference, layoutInfo.rightbarTrack ? rightbarPreference : 0, collapsedWidth, centeredSidebar, + ) const colsRef = useRef(cols) colsRef.current = cols const rightbarWidth = useRef(normal.rightbar) @@ -185,8 +208,8 @@ export function AppFrame({ // Track-level transitions pause for the whole gesture: eased tracks would // detach the column edge from the pointer (AppFrame.module.css). const [dragging, setDragging] = useState(false) - // Track easing is scoped to a discrete open/close toggle: data-animating - // goes up when the collapse state or the rightbar track flips and comes down + // Track easing is scoped to discrete open/close and fold changes: data-animating + // goes up when the sidebar presentation or the rightbar track flips and comes down // at transition end (timeout as the reduced-motion/covered-frame fallback). // Steady-state viewport updates stay instant (AppFrame.module.css), and so // does a toggle arriving together with a viewport change — that is the @@ -194,15 +217,21 @@ export function AppFrame({ // chase the live window edge. The counter restarts the settle window when a // re-toggle interrupts a running transition. const [animating, setAnimating] = useState(0) - const trackToggle = `${sidebarCollapsed}:${layoutInfo.rightbarTrack}` + const trackToggle = `${sidebarCollapsed}:${layoutInfo.rightbarTrack}:${centeredSidebar}` const previousToggle = useRef(trackToggle) const previousViewport = useRef(viewport) + // Arm the transition before child layout effects measure the new column widths. + const animateTracks = previousViewport.current === viewport && (animating > 0 || previousToggle.current !== trackToggle) useLayoutEffect(() => { const viewportChanged = previousViewport.current !== viewport previousViewport.current = viewport + if (viewportChanged) { + previousToggle.current = trackToggle + setAnimating(0) + return + } if (previousToggle.current === trackToggle) return previousToggle.current = trackToggle - if (viewportChanged) return setAnimating(token => token + 1) }, [trackToggle, viewport]) useEffect(() => { @@ -239,10 +268,14 @@ export function AppFrame({ // whole and was corrected two frames later — visible jitter. cols keeps only // the discrete decisions (track present, collapse state) and the drag base. const rightbarMax = cols.rightbar === 0 ? 0 : clampWidth(rightbarPreference, RIGHTBAR_MIN, viewport * RIGHTBAR_MAX_RATIO) + const sidebarWidth = drawer + ? Math.min(layoutInfo.sidebar || SIDEBAR_DEFAULT, Math.max(0, viewport - 48)) + : cols.sidebar const sidebar = useMemo(() => renderSlot('sidebar', { collapsed: sidebarCollapsed, - width: cols.sidebar, - }), [renderSlot, sidebarCollapsed, cols.sidebar]) + width: sidebarWidth, + drawer, + }), [renderSlot, sidebarCollapsed, sidebarWidth, drawer]) const main = useMemo(() => ( ), [usePanelInfo, renderSlot]) @@ -253,52 +286,58 @@ export function AppFrame({ // row (ui-sidebar). AppFrame.module.css publishes the matching // --dsh-frame-leading-clearance under the same collapsed condition. const leading = useMemo(() => renderSlot('shell.leading', {}), [renderSlot]) - const leadingMounted = darwin && sidebarCollapsed + const leadingMounted = drawer || darwin && sidebarCollapsed return (
0 || undefined} + data-animating={animateTracks || undefined} > -
+ {drawer &&