Merge pull request #7556 from deepseek-harness/fix/mobile-sidebar-title

fix(web): 稳定手机端右侧栏切换时的会话标题布局
This commit is contained in:
imccyu
2026-10-29 23:54:15 +08:00
committed by GitHub
7 changed files with 136 additions and 37 deletions
+72 -4
View File
@@ -7,7 +7,7 @@
// draws the panel is a recorded, reversible choice, so this file asserts against
// the frame's track as much as against the panel itself. The way back in while
// collapsed is not in the column at all: it is one button in the conversation
// header, and it leaves when the panel opens.
// header, visible only while the panel is collapsed.
//
// Ordering is the product's own: the hero comes before any session, so the
// empty right edge is asserted first and the session-bound cases follow in a
@@ -114,7 +114,7 @@ async function holdPanelSlide(panel: Locator) {
})
}
/** The expand button in the conversation header, present only while collapsed. */
/** The conversation header's expand control, visible only while collapsed. */
function expandOf(page: Page): Locator {
return page.locator('[data-sidebar-right-expand]')
}
@@ -379,8 +379,7 @@ describe('web e2e: shipped right Sidebar', () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-sidebar-right-pageup'))
const frame = page.locator('[class*="frame"]').first()
const composer = page.locator('textarea, [contenteditable="true"]').first()
// The expand button exists only while the panel is collapsed, when its
// docked hosts sit translated past the frame's right edge.
// Collapsed docked hosts sit translated past the frame's right edge.
await expandOf(page).waitFor({ timeout: 15_000 })
await composer.click()
await composer.pressSequentially('caret')
@@ -644,6 +643,74 @@ 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) => {
const caseName = `mobile-title-${viewportWidth}`
const mobilePage = await browser.newPage({
viewport: { width: viewportWidth, height: 844 },
locale: 'en-US', timezoneId: 'Asia/Shanghai',
userAgent: 'Mozilla/5.0 (iPhone; CPU iPhone OS 18_6 like Mac OS X) AppleWebKit/605.1.15 Version/18.6 Mobile/15E148 Safari/604.1',
isMobile: true, hasTouch: true, reducedMotion: 'no-preference',
storageState: await page.context().storageState(),
})
const mobileTripwire = watchConsole(mobilePage)
try {
await mobilePage.goto(page.url(), { waitUntil: 'load' })
const column = await resetSidebar(mobilePage)
const panel = column.locator('[data-sidebar-right-panel]')
const title = mobilePage.locator('[class*="titleCluster"]').first()
const utilities = mobilePage.locator('[class*="headerUtilities"]').first()
await expect.poll(() => panel.getAttribute('data-sidebar-right-panel')).toBe('fullscreen')
await column.locator('[data-sidebar-right-toggle]').tap()
await panel.evaluate(async (node) => {
await Promise.allSettled(node.getAnimations({ subtree: true }).map(animation => animation.finished))
})
await expandOf(mobilePage).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(mobilePage, `${caseName}-collapsed`)
try {
for (const opening of [true, false]) {
const held = await holdPanelSlide(panel)
try {
await (opening ? expandOf(mobilePage) : column.locator('[data-sidebar-right-toggle]')).tap()
await expect.poll(() => held.evaluate(state => state.animation?.playState)).toBe('paused')
const progress = await held.evaluate(state => state.animation?.effect?.getComputedTiming().progress)
expect(progress).toBeGreaterThan(0)
expect(progress).toBeLessThan(1)
expect(await title.boundingBox()).toEqual(titleBefore)
expect(await utilities.boundingBox()).toEqual(utilitiesBefore)
expect(await titleNode.evaluate(node => node.isConnected)).toBe(true)
expect(await expandOf(mobilePage).isDisabled()).toBe(opening)
expect(await mobilePage.getByRole('button', { name: 'Open right sidebar', exact: true }).count()).toBe(opening ? 0 : 1)
await shot(mobilePage, `${caseName}-${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()
}
expect(mobileTripwire.pageErrors).toEqual([])
expect(mobileTripwire.warnings).toEqual([])
} catch (error) {
await saveFailureShot(mobilePage, `web-e2e-sidebar-right-${caseName}`)
throw error
} finally {
await mobilePage.close()
}
})
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')
@@ -668,6 +735,7 @@ describe('web e2e: shipped right Sidebar', () => {
await expect.poll(async () => await panel.getAttribute('data-sidebar-right-panel')).toBe('fullscreen')
await expect.poll(async () => await width(panel)).toBe(767)
await expect.poll(() => frame.getAttribute('data-rightbar-fullscreen')).toBe('true')
expect(await expandOf(page).count()).toBe(0)
expect(await frame.locator('[data-side="rightbar"]').count()).toBe(0)
await column.locator('[data-sidebar-right-mode="push"]').click()
await expect.poll(async () => await column.locator('[data-sidebar-right-open]').count()).toBe(0)
@@ -21,8 +21,8 @@
en: 5024c4e5fe5dfd2e
zh: 535a7f585e1bcd97
/deepseek-ai-dsh-client-ui-sidebar-right/the-expand-button:
en: 2614d3f179290606
zh: 88c08ee260ba57b7
en: ca7a1966ad9cef31
zh: b9a7a967a14f067e
/deepseek-ai-dsh-client-ui-sidebar-right/state:
en: 98597ce087c48075
zh: c5466bf7955ea7b0
+1 -1
View File
@@ -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). On mobile devices recognized by the shared `isMobileDevice()` helper, the expanded panel leaves this button mounted, hidden, and disabled, retaining its header space so the title and utilities do not shift during the overlay slide. On desktop devices, the button renders nothing while the panel is shown and the corner seat collapses, regardless of window width or manual fullscreen mode. 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.
@@ -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)。在公共 `isMobileDevice()` 函数识别的移动设备上,面板展开时按钮保持挂载、隐藏并禁用,保留 header 占位,使标题与工具组在覆盖层滑动时不发生位移。在桌面设备上,面板显示时按钮不渲染,角落席位随之收起,不受窗口宽度或手动全屏模式影响。于是折叠的 Sidebar 不花会话区任何代价:没有轨条、没有宽度,转录的滚动条留在列的边缘。没有会话就没有按钮也没有面板。
面板取会话区的底色与正文字号,而不是自成一层浮起的表面:它是页面的一列,不是压在页面上的卡片。
@@ -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);
@@ -1,14 +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
* slot runtime allows because both seats are session-scoped.
* the utilities row. On mobile devices, the disabled button stays mounted
* and hidden while the panel is shown, preserving the title's width during
* the overlay slide. Desktop devices collapse the seat while the panel is shown.
* It shares the panel's per-session store because both seats are session-scoped.
*
* The glyph is the left sidebar's collapse icon mirrored: the same affordance,
* on the other edge.
@@ -16,7 +17,8 @@
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 { Button, IconPanelLeftOutlineRegular, Tooltip } from '@deepseek-ai/dsh-client-ui-primitives'
import clsx from 'clsx'
import { Button, IconPanelLeftOutlineRegular, isMobileDevice, 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'
import type { createSidebarRightStore } from '../stores.ts'
@@ -29,18 +31,19 @@ export type ExpandButtonProps =
& PropsLocale<'sidebarRight'>
& InjectFace<{ hooks: { shortcuts: HostObservable<readonly ShortcutCatalogEntry[]> } }>
/** The expand control while the panel is collapsed; nothing while it is shown. */
/** Opens a collapsed panel; when expanded, hides and disables on mobile devices and renders nothing on desktop devices. */
export function ExpandButton({ sessionId, useStore, actions, t, useShortcuts }: 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
if (expanded && !isMobileDevice()) return null
return (
<Tooltip label={t('chrome.expand')} shortcutKeys={shortcut?.keys} side="bottom" delayMs={500}>
<Tooltip label={t('chrome.expand')} shortcutKeys={shortcut?.keys} side="bottom" delayMs={500} disabled={expanded}>
<Button
size="sm"
className={css.button}
className={clsx(css.button, expanded && css.expanded)}
disabled={expanded}
aria-label={t('chrome.expandAria')}
aria-keyshortcuts={shortcut?.aria}
data-sidebar-right-expand
@@ -1,13 +1,13 @@
// @vitest-environment jsdom
import { contextMenuHook } from '@deepseek-ai/dsh-client-test-runtime'
/**
* The way back into a hidden panel: the header's corner button exists exactly
* while the panel is collapsed, asks for it to expand, and renders nothing
* while the panel is shown.
* Mobile devices preserve the header control's identity; desktops release
* its header space while open.
*/
import { describe, expect, it } from 'vitest'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { act, cleanup, fireEvent, render } from '@testing-library/react'
import { useSyncExternalStore } from 'react'
import * as uiPrimitives from '@deepseek-ai/dsh-client-ui-primitives'
import type { GlobalStandardProps, SessionStandardProps } from '@deepseek-ai/dsh-client-ui-slots'
import type { ShortcutCatalogEntry } from '@deepseek-ai/dsh-client-shortcuts/client'
import type { SessionId } from '@deepseek-ai/dsh-session/types'
@@ -17,6 +17,10 @@ import type { ExpandButtonProps } from '../src/client/shell/ExpandButton.tsx'
import { createSidebarRightStore } from '../src/client/stores.ts'
const SESSION = 's-test' as SessionId
afterEach(() => {
cleanup()
vi.restoreAllMocks()
})
const unused = (): never => { throw new Error('This isolated component does not consume framework hooks') }
const standard: GlobalStandardProps & SessionStandardProps = {
sessionId: SESSION, useSession: unused, useProjection: unused, useConversation: unused,
@@ -37,7 +41,8 @@ function hookOf<T>(inst: { subscribe: (fn: () => void) => () => void; getSnapsho
}
/** Mount the button over its real store and an empty shortcut catalog. */
function mountButton(shortcuts: readonly ShortcutCatalogEntry[] = []) {
function mountButton(shortcuts: readonly ShortcutCatalogEntry[] = [], mobile = false) {
vi.spyOn(uiPrimitives, 'isMobileDevice').mockReturnValue(mobile)
const instance = createSidebarRightStore(() => ({ kind: 'guide', title: 'Start' })).create()
const props: ExpandButtonProps = {
...standard,
@@ -49,39 +54,58 @@ function mountButton(shortcuts: readonly ShortcutCatalogEntry[] = []) {
t: (key: string) => key,
}
const view = render(<ExpandButton {...props} />)
const control = (): HTMLElement | null => view.container.querySelector('[data-sidebar-right-expand]')
const control = (): HTMLButtonElement | null => view.container.querySelector('[data-sidebar-right-expand]')
return { instance, view, control }
}
describe('ExpandButton', () => {
it('advertises the configured expand binding', async () => {
const { control, view } = mountButton([{ id: 'sidebar.right.toggle' as never, label: 'Toggle', aliases: [],
binding: null, keys: ['Ctrl', 'B'], aria: 'Control+B', modified: true, conflicts: [], issue: null }])
const { instance, control, view } = mountButton([{ id: 'sidebar.right.toggle' as never, label: 'Toggle', aliases: [],
binding: null, keys: ['Ctrl', 'B'], aria: 'Control+B', modified: true, conflicts: [], issue: null }], true)
expect(control()?.getAttribute('aria-keyshortcuts')).toBe('Control+B')
fireEvent.pointerEnter(control()!)
const tooltip = await view.findByRole('tooltip')
expect(tooltip.textContent).toContain('chrome.expand')
expect(Array.from(tooltip.querySelectorAll('kbd'), key => key.textContent)).toEqual(['Ctrl', 'B'])
cleanup()
act(() => { instance.actions.setExpanded(SESSION, true) })
expect(view.queryByRole('tooltip')).toBeNull()
})
it('offers the way in while the session has no surface yet, and asks the panel to expand', () => {
const { instance, view, control } = mountButton()
const { instance, control } = mountButton()
const button = control()
if (button === null) throw new Error('expected the expand control')
expect(button.getAttribute('aria-label')).toBe('chrome.expandAria')
fireEvent.click(button)
expect(instance.getSnapshot().bySession[SESSION]?.layout.expanded).toBe(true)
// Shown: the seat is empty, so the header lays out without it.
expect(view.container.childElementCount).toBe(0)
cleanup()
})
it('comes back when the panel collapses again', () => {
const { instance, control } = mountButton()
act(() => { instance.actions.setExpanded(SESSION, true) })
expect(control()).toBeNull()
act(() => { instance.actions.setExpanded(SESSION, false) })
expect(control()).not.toBeNull()
cleanup()
expect(control()?.disabled).toBe(false)
})
it.each(['push', 'fullscreen'] as const)('retains the disabled control through mobile %s panel opening and closing', (mode) => {
const { instance, control } = mountButton([], true)
const button = control()
if (button === null) throw new Error('expected the expand control')
act(() => {
instance.actions.setMode(SESSION, mode)
instance.actions.setExpanded(SESSION, true)
})
expect(control()).toBe(button)
expect(button.disabled).toBe(true)
act(() => { instance.actions.setExpanded(SESSION, false) })
expect(control()).toBe(button)
expect(button.disabled).toBe(false)
})
it('releases header space for a manually fullscreen panel on desktop', () => {
const { instance, control } = mountButton()
act(() => {
instance.actions.setMode(SESSION, 'fullscreen')
instance.actions.setExpanded(SESSION, true)
})
expect(instance.getSnapshot().bySession[SESSION]?.layout.mode).toBe('fullscreen')
expect(control()).toBeNull()
act(() => { instance.actions.setExpanded(SESSION, false) })
expect(control()?.disabled).toBe(false)
})
})