From 4436420f47f590be5a1708e205f4c4143b3141d2 Mon Sep 17 00:00:00 2001 From: EricMoin Date: Thu, 29 Oct 2026 17:47:02 +0800 Subject: [PATCH 1/4] fix(web): stabilize mobile sidebar header layout --- apps/web/tests/sidebar-right.e2e.ts | 62 +++++++++++++++++++ .../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 +- .../ui-sidebar-right/src/client/index.ts | 20 ++++-- .../src/client/shell/ExpandButton.module.css | 4 ++ .../src/client/shell/ExpandButton.tsx | 26 +++++--- .../src/client/shell/SidebarRight.tsx | 7 ++- .../tests/apply.client.spec.ts | 41 ++++++++++++ .../tests/expand-button.client.spec.tsx | 53 +++++++++++----- 10 files changed, 185 insertions(+), 36 deletions(-) diff --git a/apps/web/tests/sidebar-right.e2e.ts b/apps/web/tests/sidebar-right.e2e.ts index 064adbb620..948c98cd64 100644 --- a/apps/web/tests/sidebar-right.e2e.ts +++ b/apps/web/tests/sidebar-right.e2e.ts @@ -644,6 +644,68 @@ describe('web e2e: shipped right Sidebar', () => { expect(tripwire.warnings).toEqual([]) }) + it.each([390, 767])('keeps the mobile conversation title still while the right sidebar slides at %ipx', async (viewportWidth) => { + onTestFailed(() => saveFailureShot(page, `web-e2e-sidebar-right-mobile-title-${viewportWidth}`)) + const viewport = page.viewportSize() + if (viewport === null) throw new Error('expected a fixed viewport') + const column = await resetSidebar(page) + const panel = column.locator('[data-sidebar-right-panel]') + const title = page.locator('[class*="titleCluster"]').first() + const utilities = page.locator('[class*="headerUtilities"]').first() + try { + await page.emulateMedia({ reducedMotion: 'no-preference' }) + await page.setViewportSize({ width: viewportWidth, height: viewport.height }) + await expect.poll(() => panel.getAttribute('data-sidebar-right-panel')).toBe('fullscreen') + await column.locator('[data-sidebar-right-toggle]').click() + await panel.evaluate(async (node) => { + await Promise.allSettled(node.getAnimations({ subtree: true }).map(animation => animation.finished)) + }) + await expandOf(page).waitFor() + const titleNode = await title.locator('[class*="crumbCurrent"]').elementHandle() + if (titleNode === null) throw new Error('conversation title is not rendered') + const titleBefore = await title.boundingBox() + const utilitiesBefore = await utilities.boundingBox() + expect(titleBefore?.width).toBeGreaterThan(0) + expect(utilitiesBefore?.width).toBeGreaterThan(0) + await shot(page, `mobile-title-${viewportWidth}-collapsed`) + try { + for (const opening of [true, false]) { + const held = await holdPanelSlide(panel) + try { + await (opening ? expandOf(page) : column.locator('[data-sidebar-right-toggle]')).click() + await expect.poll(() => held.evaluate(state => state.animation?.playState)).toBe('paused') + const sliding = await paneAt(column, 0).boundingBox() + if (sliding === null) throw new Error('sliding panel is not rendered') + expect(sliding.x).toBeGreaterThan(0) + expect(sliding.x).toBeLessThan(viewportWidth) + expect(await title.boundingBox()).toEqual(titleBefore) + expect(await utilities.boundingBox()).toEqual(utilitiesBefore) + expect(await titleNode.evaluate(node => node.isConnected)).toBe(true) + expect(await expandOf(page).isDisabled()).toBe(opening) + expect(await page.getByRole('button', { name: 'Open right sidebar', exact: true }).count()).toBe(opening ? 0 : 1) + await shot(page, `mobile-title-${viewportWidth}-${opening ? 'opening' : 'closing'}`) + await held.evaluate((state) => { (state.animation as Animation).finish() }) + expect(await title.boundingBox()).toEqual(titleBefore) + expect(await utilities.boundingBox()).toEqual(utilitiesBefore) + } finally { + await held.evaluate((state) => { + state.dispose() + if (state.animation?.playState === 'paused') state.animation.finish() + }) + await held.dispose() + } + } + } finally { + await titleNode.dispose() + } + } finally { + await page.setViewportSize(viewport) + await page.emulateMedia({ reducedMotion: null }) + } + expect(tripwire.pageErrors).toEqual([]) + expect(tripwire.warnings).toEqual([]) + }) + it('keeps a capacity-closed panel closed after widening and uses fullscreen on a narrow viewport', async () => { const viewport = page.viewportSize() if (viewport === null) throw new Error('expected a fixed viewport') diff --git a/packages/client/ui-sidebar-right/README.i18n.yaml b/packages/client/ui-sidebar-right/README.i18n.yaml index 17ac7a98fc..03cf7d7d69 100644 --- a/packages/client/ui-sidebar-right/README.i18n.yaml +++ b/packages/client/ui-sidebar-right/README.i18n.yaml @@ -21,8 +21,8 @@ en: 5024c4e5fe5dfd2e zh: 535a7f585e1bcd97 /deepseek-ai-dsh-client-ui-sidebar-right/the-expand-button: - en: 2614d3f179290606 - zh: 88c08ee260ba57b7 + en: 50c158b6639ae83a + zh: 9580a299e1ee1ca8 /deepseek-ai-dsh-client-ui-sidebar-right/state: en: 98597ce087c48075 zh: c5466bf7955ea7b0 diff --git a/packages/client/ui-sidebar-right/README.md b/packages/client/ui-sidebar-right/README.md index a5eae492e0..d88aff7b90 100644 --- a/packages/client/ui-sidebar-right/README.md +++ b/packages/client/ui-sidebar-right/README.md @@ -56,7 +56,7 @@ The panel has no header row. Its two controls — the presentation switch and th The shortcut reference includes right-sidebar toggle, split and panel fullscreen commands. The same effective binding appears on the corresponding controls. Split and fullscreen require focus within a visible dock pane; conversation focus leaves them unavailable. Split remains visible when unavailable; its focusable tooltip shows the current binding and explains the pane limit or width requirement. Files, browser, and terminal providers contribute their own commands. Guide buttons display effective shortcuts as unboxed text inline without duplicate tooltips. -While the panel is hidden, one button in the conversation header's corner seat (`conversation.session.header.corner`, past the utilities' right edge and level with the Session log control) is the way back in. Its glyph is the left sidebar's collapse icon mirrored. It uses the shared compact Button, matching the adjacent More action’s radius and hover fill. It shares the panel's store (the slot runtime allows one handle across two same-scope seats); while the panel is shown it renders nothing, and the corner seat collapses with it. A collapsed Sidebar therefore costs the conversation nothing: no rail, no width, and the transcript's scrollbar stays at the column's edge. Without a session there is no button and no panel. +While the panel is hidden, one button in the conversation header's corner seat (`conversation.session.header.corner`, past the utilities' right edge and level with the Session log control) is the way back in. Its glyph is the left sidebar's collapse icon mirrored. It uses the shared compact Button, matching the adjacent More action’s radius and hover fill. It shares the panel's store (the slot runtime allows one handle across two same-scope seats); while the panel is shown below 768px, the button stays mounted, hidden, and disabled, retaining its header space so the title and utilities do not shift during the overlay slide. On wider viewports the button renders nothing and the corner seat collapses. A collapsed Sidebar therefore costs the conversation nothing: no rail, no width, and the transcript's scrollbar stays at the column's edge. Without a session there is no button and no panel. The panel takes the conversation's ground colour and content font sizes rather than a raised layer of its own: it is a column of the page, not a card over it. diff --git a/packages/client/ui-sidebar-right/README.zh.md b/packages/client/ui-sidebar-right/README.zh.md index 7e5319e477..0c7f6e1a5f 100644 --- a/packages/client/ui-sidebar-right/README.zh.md +++ b/packages/client/ui-sidebar-right/README.zh.md @@ -56,7 +56,7 @@ kind: "package-reference" 快捷键速查包含右侧栏切换、分栏和面板全屏命令,对应控件显示同一份有效键位。分栏与全屏要求焦点位于可见的停靠分栏内;焦点在聊天区时不可用。不可分栏时控件仍可见,可聚焦的提示显示当前键位,并说明分栏数量上限或宽度要求。文件、浏览器和终端提供者分别注册自己的命令。开始页按钮内以无背景文字显示有效快捷键,不额外弹出重复提示。 -面板隐藏时,会话 header 角落席位里的一个按钮(`conversation.session.header.corner`,在工具组右缘之外,与 Session 日志控件齐平)是回去的路。它的图形是左侧 sidebar 折叠图标的镜像。它使用公共紧凑 Button,与相邻更多操作按钮共用圆角和 hover 底色。它与面板共用一个存储(slot 运行时允许两个同作用域席位共用一个 handle);面板显示时它什么也不渲染,角落席位随之收起。于是折叠的 Sidebar 不花会话区任何代价:没有轨条、没有宽度,转录的滚动条留在列的边缘。没有会话就没有按钮也没有面板。 +面板隐藏时,会话 header 角落席位里的一个按钮(`conversation.session.header.corner`,在工具组右缘之外,与 Session 日志控件齐平)是回去的路。它的图形是左侧 sidebar 折叠图标的镜像。它使用公共紧凑 Button,与相邻更多操作按钮共用圆角和 hover 底色。它与面板共用一个存储(slot 运行时允许两个同作用域席位共用一个 handle);视口宽度小于 768px 且面板显示时,按钮保持挂载、隐藏并禁用,保留在 header 中的占位,使标题和工具组在覆盖层滑动期间不发生位移。更宽的视口中,按钮不渲染,角落席位随之收起。于是折叠的 Sidebar 不花会话区任何代价:没有轨条、没有宽度,转录的滚动条留在列的边缘。没有会话就没有按钮也没有面板。 面板取会话区的底色与正文字号,而不是自成一层浮起的表面:它是页面的一列,不是压在页面上的卡片。 diff --git a/packages/client/ui-sidebar-right/src/client/index.ts b/packages/client/ui-sidebar-right/src/client/index.ts index 1f3d3781fe..851909f867 100644 --- a/packages/client/ui-sidebar-right/src/client/index.ts +++ b/packages/client/ui-sidebar-right/src/client/index.ts @@ -32,6 +32,7 @@ import type {} from '@deepseek-ai/dsh-client-resources/client' import type {} from '@deepseek-ai/dsh-client-ui-renderer/client' import type {} from '@deepseek-ai/dsh-client-ui-session/client' import type { ILayout } from '@deepseek-ai/dsh-client-ui-layout/client' +import type { HostObservable } from '@deepseek-ai/dsh-client-ui-slots' import type {} from '@deepseek-ai/dsh-client-ui-layout/client' import type {} from '@deepseek-ai/dsh-client-ui-conversation/client' import type { SessionId } from '@deepseek-ai/dsh-session/types' @@ -39,7 +40,7 @@ import type {} from './contract/slots.ts' import { GuideBody, type GuideInjected } from './tabs/guide/GuideBody.tsx' import { GuideTitle } from './tabs/guide/GuideTitle.tsx' import { ExpandButton } from './shell/ExpandButton.tsx' -import { RightbarSeat, type SidebarRightInjected } from './shell/SidebarRight.tsx' +import { AUTO_FULLSCREEN_WIDTH, RightbarSeat, type SidebarRightInjected } from './shell/SidebarRight.tsx' import { closeWithPaneFocus, openWithPaneFocus } from './shell/close-focus.ts' import { RightbarRoot, type RightbarRootInjected } from './shell/RightbarRoot.tsx' import { SidebarSessionViews } from './session-views.ts' @@ -262,15 +263,22 @@ export function apply(ctx: ClientContext): void { }), }, RightbarSeat) }) - // The expand button shares the panel's store: it only needs to know whether - // the panel is expanded, and to ask for it to be. The header's corner seat - // is its own place, past the utilities, so showing and hiding it moves - // nothing else in the row. + // The expand button retains its width under narrow-screen overlays; + // wide-screen panels collapse the header's corner seat while shown. + const query = typeof matchMedia === 'function' + ? matchMedia(`(width < ${AUTO_FULLSCREEN_WIDTH}px)`) : undefined + const narrowViewport: HostObservable = { + getSnapshot: () => query?.matches ?? false, + subscribe: (listener) => { + query?.addEventListener('change', listener) + return () => { query?.removeEventListener('change', listener) } + }, + } const disposeExpand = ctx.slots.inject('conversation.session.header.corner', () => ctx.slots.register({ name: 'conversation.session.header.corner', locale: NS, store, - inject: () => ({ hooks: { shortcuts: ctx.shortcuts.catalog } }), + inject: () => ({ hooks: { shortcuts: ctx.shortcuts.catalog, narrowViewport } }), }, ExpandButton)) // Stage two for the guide: it declares the chain child it hosts and reads // the registry's entry boxes, which an ordinary type has no reason to do. diff --git a/packages/client/ui-sidebar-right/src/client/shell/ExpandButton.module.css b/packages/client/ui-sidebar-right/src/client/shell/ExpandButton.module.css index 0ea624baeb..77f7f4af7f 100644 --- a/packages/client/ui-sidebar-right/src/client/shell/ExpandButton.module.css +++ b/packages/client/ui-sidebar-right/src/client/shell/ExpandButton.module.css @@ -11,6 +11,10 @@ height: 15px; } +.expanded { + visibility: hidden; +} + /* The left sidebar's panel icon, mirrored: the divider moves to the right side. */ .icon { transform: scaleX(-1); diff --git a/packages/client/ui-sidebar-right/src/client/shell/ExpandButton.tsx b/packages/client/ui-sidebar-right/src/client/shell/ExpandButton.tsx index fbac5b553e..a0d17e4de2 100644 --- a/packages/client/ui-sidebar-right/src/client/shell/ExpandButton.tsx +++ b/packages/client/ui-sidebar-right/src/client/shell/ExpandButton.tsx @@ -1,13 +1,15 @@ /** * The way into a hidden panel: one button in the conversation header's corner - * seat, shown only while the panel is collapsed. + * seat, visible only while the panel is collapsed. * * It lives in the conversation's own header rather than in the frame's right * column so that a collapsed Sidebar costs the conversation nothing — no rail, * no width, and the transcript's scrollbar stays at the column's edge. The * corner seat is its own, past the utilities' edge, so the button never joins - * the utilities row; while the panel is shown this renders nothing, and the - * seat collapses with it. It shares the panel's per-session store, which the + * the utilities row. On narrow viewports, the disabled button stays mounted + * and hidden while the panel is shown, preserving the title's width during + * the overlay slide. Wide viewports collapse the seat while the panel is shown. + * It shares the panel's per-session store, which the * slot runtime allows because both seats are session-scoped. * * The glyph is the left sidebar's collapse icon mirrored: the same affordance, @@ -16,6 +18,7 @@ import type { ShortcutCatalogEntry } from '@deepseek-ai/dsh-client-shortcuts/client' import type { InjectFace, HostObservable } from '@deepseek-ai/dsh-client-ui-slots' import type { ReactNode } from 'react' +import clsx from 'clsx' import { Button, IconPanelLeftOutlineRegular, Tooltip } from '@deepseek-ai/dsh-client-ui-primitives' import type { PropsLocale, PropsRuntime, PropsStore } from '@deepseek-ai/dsh-client-ui-slots' import type {} from '@deepseek-ai/dsh-client-ui-conversation/client' @@ -27,20 +30,25 @@ export type ExpandButtonProps = & PropsRuntime<'conversation.session.header.corner'> & PropsStore> & PropsLocale<'sidebarRight'> - & InjectFace<{ hooks: { shortcuts: HostObservable } }> + & InjectFace<{ hooks: { + shortcuts: HostObservable + narrowViewport: HostObservable + } }> -/** The expand control while the panel is collapsed; nothing while it is shown. */ -export function ExpandButton({ sessionId, useStore, actions, t, useShortcuts }: ExpandButtonProps): ReactNode { +/** The hidden expand control retains its header space only on narrow viewports. */ +export function ExpandButton({ sessionId, useStore, actions, t, useShortcuts, useNarrowViewport }: ExpandButtonProps): ReactNode { // A session with no surface yet is collapsed: the panel seat materializes the // surface on its own mount, and until then there is nothing expanded. const shortcut = useShortcuts(entries => entries.find(entry => entry.id === 'sidebar.right.toggle')) const expanded = useStore(state => state.bySession[sessionId]?.layout.expanded ?? false) - if (expanded) return null + const narrow = useNarrowViewport(value => value) + if (expanded && !narrow) return null return ( - +