Procházet zdrojové kódy

fix(sidebar): address review feedback on aria names, touch hits, and fullscreen close

- Split toggle aria-labels from their tooltips: the sidebar expand/collapse
  buttons announce "Open/Collapse right sidebar" so the two sidebars stay
  distinguishable, and the wrap toggle keeps an invariant "Line wrap" name
  beside aria-pressed while the tooltip keeps the action wording.
- A hidden tab close also takes no pointer events, so a touch press on the
  chip's trailing edge activates the tab instead of closing it; e2e closes
  hover the chip first, the way a reader would.
- Closing the last docked tab in fullscreen hands the mode back to push, so
  the next expand does not give the whole window to the start page.
- The drop scrim mixes from bg-base, not bg-layer-2, which dark mode binds
  to a lighter bluish step than the column beneath it.
- Document the menuitem probe in TabMenuExtras and the strip fade's known
  gap; match the open-in-app split-button comment to the 28px control row.
Yif před 1 měsícem
rodič
revize
c76e371874

+ 2 - 2
.agents/notes/implemented/feature/2026-09-08-sidebar-last-tab-close-rules.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-09-08-sidebar-last-tab-close-rules.md
-2026-09-08-sidebar-last-tab-close-rules.md: 336394851445557537a9183c4a2172edadb5aedb
-2026-09-08-sidebar-last-tab-close-rules.zh.md: 9231246fff16a7bfc5cd7cf045e9907124a9ff97
+2026-09-08-sidebar-last-tab-close-rules.md: 4fb0e54af1075dd595d18db4d74779a7cfd2656b
+2026-09-08-sidebar-last-tab-close-rules.zh.md: 87cadf0f15eecb0cadc2fb2dacc5695771fd66f7

+ 2 - 2
.agents/notes/implemented/feature/2026-09-08-sidebar-last-tab-close-rules.md

@@ -10,9 +10,9 @@ The settle planner guarantees the docked surface is never empty: closing the las
 
 ## Decision
 
-The docked surface's last tab carries one rule, decided in the Sidebar store's `closeTab` and mirrored to the kit through a new `canCloseTab(tabId)` control-policy prop (joining `canSplit` and `canAddTab`): the guide standing as the only docked tab is unclosable — no chip close control, no menu close item, and a programmatic close records nothing; any other lone tab closes together with the column in one history entry, so the settle planner prepares the current default page for the next expansion. `soleDockedTab(state, tabId)` in [stores.ts](../../../../packages/client/ui-sidebar-right/src/client/stores.ts) names the condition; floating panels take no part in it. Per the packages rule "enforce a decision in the operation that makes it", the store's `closeTab` is the enforcement and `canCloseTab` only mirrors it into the chrome. This rule supersedes the close-protection part of [the default-page decision](2026-09-08-sidebar-default-pages.md); its selection rule remains active.
+The docked surface's last tab carries one rule, decided in the Sidebar store's `closeTab` and mirrored to the kit through a new `canCloseTab(tabId)` control-policy prop (joining `canSplit` and `canAddTab`): the guide standing as the only docked tab is unclosable — no chip close control, no menu close item, and a programmatic close records nothing; any other lone tab closes together with the column in one history entry, resets fullscreen to push mode, and lets the settle planner prepare the current default page for the next expansion. `soleDockedTab(state, tabId)` in [stores.ts](../../../../packages/client/ui-sidebar-right/src/client/stores.ts) names the condition; floating panels take no part in it. Per the packages rule "enforce a decision in the operation that makes it", the store's `closeTab` is the enforcement and `canCloseTab` only mirrors it into the chrome. This rule supersedes the close-protection part of [the default-page decision](2026-09-08-sidebar-default-pages.md); its selection rule remains active.
 
-Two kit-side presentation rules complete it in [TabPanel.tsx](../../../../packages/client/ui-dockkit/src/components/TabPanel.tsx) and [TabMenu.tsx](../../../../packages/client/ui-dockkit/src/components/TabMenu.tsx): a pane's lone chip whose close is withheld draws quiet — no capsule, no hover fill — since there is nothing to select against and nothing to do to it; and a menu that would hold no item at all dismisses itself before the first paint, so a secondary press on such a chip shows nothing rather than an empty box.
+Two kit-side presentation rules complete it in [TabPanel.tsx](../../../../packages/client/ui-dockkit/src/components/TabPanel.tsx) and [TabMenu.tsx](../../../../packages/client/ui-dockkit/src/components/TabMenu.tsx): a pane's lone chip whose close is withheld draws quiet — no capsule, no hover fill — since there is nothing to select against and nothing to do to it; and a menu that would hold no item at all produces no visible popup, so a secondary press on such a chip shows nothing rather than an empty box.
 
 ## Alternatives considered
 

+ 2 - 2
.agents/notes/implemented/feature/2026-09-08-sidebar-last-tab-close-rules.zh.md

@@ -10,9 +10,9 @@ settle planner 保证停靠面永不为空:关掉最后一个 tab 会重新播
 
 ## 决定
 
-停靠面的最后一个 tab 带一条规则,由 Sidebar store 的 `closeTab` 决定,并经新的控制策略 prop `canCloseTab(tabId)`(与 `canSplit`、`canAddTab` 并列)镜像给套件:作为唯一停靠 tab 的引导页不可关闭——chip 上没有关闭控件,菜单里没有关闭项,编程式关闭什么都不记录;任何其它 tab 独自留下时,关闭会连同整列一起收起,记为一条历史,于是 settle planner 为下次展开准备当前默认页。[stores.ts](../../../../packages/client/ui-sidebar-right/src/client/stores.ts) 里的 `soleDockedTab(state, tabId)` 命名这个条件;浮动面板不参与。按照 packages 规则「在做出决定的操作里执行它」,store 的 `closeTab` 是执行点,`canCloseTab` 只是把它镜像到界面。本规则取代[默认页决策](2026-09-08-sidebar-default-pages.zh.md)中的关闭保护部分;其默认页选择规则仍然有效。
+停靠面的最后一个 tab 带一条规则,由 Sidebar store 的 `closeTab` 决定,并经新的控制策略 prop `canCloseTab(tabId)`(与 `canSplit`、`canAddTab` 并列)镜像给套件:作为唯一停靠 tab 的引导页不可关闭——chip 上没有关闭控件,菜单里没有关闭项,编程式关闭什么都不记录;任何其它 tab 独自留下时,关闭会连同整列一起收起、把全屏重置为挤压模式并记为一条历史,于是 settle planner 为下次展开准备当前默认页。[stores.ts](../../../../packages/client/ui-sidebar-right/src/client/stores.ts) 里的 `soleDockedTab(state, tabId)` 命名这个条件;浮动面板不参与。按照 packages 规则「在做出决定的操作里执行它」,store 的 `closeTab` 是执行点,`canCloseTab` 只是把它镜像到界面。本规则取代[默认页决策](2026-09-08-sidebar-default-pages.zh.md)中的关闭保护部分;其默认页选择规则仍然有效。
 
-两条套件侧的呈现规则在 [TabPanel.tsx](../../../../packages/client/ui-dockkit/src/components/TabPanel.tsx) 与 [TabMenu.tsx](../../../../packages/client/ui-dockkit/src/components/TabMenu.tsx) 里补全它:某格仅剩的一个 chip 在关闭被收起时画成安静样式——没有胶囊底色,没有悬停填充——因为既没有别的 tab 可供选择,也没有任何可对它做的事;一个连一项都没有的菜单在首次绘制前自行消失,于是对这样的 chip 次键按下什么都不显示,而不是画一个空框。
+两条套件侧的呈现规则在 [TabPanel.tsx](../../../../packages/client/ui-dockkit/src/components/TabPanel.tsx) 与 [TabMenu.tsx](../../../../packages/client/ui-dockkit/src/components/TabMenu.tsx) 里补全它:某格仅剩的一个 chip 在关闭被收起时画成安静样式——没有胶囊底色,没有悬停填充——因为既没有别的 tab 可供选择,也没有任何可对它做的事;一个连一项都没有的菜单不会产生可见弹层,于是对这样的 chip 次键按下什么都不显示,而不是画一个空框。
 
 ## 考虑过的替代方案
 

+ 1 - 4
apps/web/tests/chat-scroll-contract.e2e.ts

@@ -776,10 +776,7 @@ describe('web e2e: long Chat scroll contract', () => {
       await world.page.setViewportSize({ width: 700, height: 900 })
       // The narrow breakpoint auto-collapses the sidebar. Re-open it because
       // this scenario switches sessions while pinning the narrow Chat scroll owner.
-      await world.page
-        .getByRole('button', { name: 'Open sidebar', exact: true })
-        .and(world.page.locator(':not([data-sidebar-right-expand])'))
-        .click()
+      await world.page.getByRole('button', { name: 'Open sidebar', exact: true }).click()
       await world.page.getByRole('tab', { name: 'Chat', exact: true }).click()
       await nextPaint(world.page)
       await expectSameFlowTop(world.page, sessionAnchor, RESPONSIVE_REFLOW_TOLERANCE)

+ 1 - 1
apps/web/tests/navigation-panes.e2e.ts

@@ -279,7 +279,7 @@ describe('web e2e: navigation & panes over a rich seeded session', () => {
     const header = exportButton.locator('xpath=ancestor::header[1]')
     // The right Sidebar's expand button holds the header's corner; the export
     // control sits immediately to its left.
-    const sidebarButton = page.getByRole('button', { name: 'Open sidebar' })
+    const sidebarButton = page.getByRole('button', { name: 'Open right sidebar' })
     const [buttonBox, sidebarBox, headerBox] = await Promise.all([
       exportButton.boundingBox(), sidebarButton.boundingBox(), header.boundingBox(),
     ])

+ 11 - 5
apps/web/tests/sidebar-right.e2e.ts

@@ -407,6 +407,7 @@ describe('web e2e: shipped right Sidebar', () => {
       const guideTab = column.locator('[data-dockkit-tab]').filter({ hasText: 'Start' })
       expect(await guideTab.locator('[data-dockkit-tab-close]').count()).toBe(1)
       // Back to the seeded shape the cases below start from.
+      await guideTab.hover()
       await guideTab.locator('[data-dockkit-tab-close]').click()
       await expect.poll(async () => await tabTitles(column)).toEqual(['Files'])
       await expect.poll(async () => await addTab.count()).toBe(1)
@@ -751,7 +752,7 @@ describe('web e2e: shipped right Sidebar', () => {
       await expect.poll(async () => await panes.count()).toBe(2)
 
       const splitFiles = panes.nth(1).locator('[data-dockkit-tab]').filter({ hasText: 'Files' })
-      expect(await splitFiles.locator('[data-dockkit-tab-close]').count()).toBe(0)
+      expect(await splitFiles.locator('[data-dockkit-tab-close]').count()).toBe(1)
       await dragTo(page, splitFiles, await pointIn(panes.first(), 0.5, 0.5))
       await expect.poll(async () => await tabTitles(panes.nth(1))).toEqual([SAMPLE_NAME])
 
@@ -927,17 +928,20 @@ describe('web e2e: shipped right Sidebar', () => {
 
       // Closing a pane's last tab drops the pane: there is no separate
       // "close pane" gesture, and none is needed.
+      await panes.nth(1).locator('[data-dockkit-tab]').first().hover()
       await panes.nth(1).locator('[data-dockkit-tab-close]').first().click()
       await expect.poll(async () => await panes.count()).toBe(1)
       await expect.poll(async () => await tabTitles(column)).toEqual(['Files', SAMPLE_NAME])
 
       // Leave a guide as the sole docked tab.
-      await column.locator('[data-dockkit-tab]').filter({ hasText: 'Files' })
-        .locator('[data-dockkit-tab-close]').click()
+      const files = column.locator('[data-dockkit-tab]').filter({ hasText: 'Files' })
+      await files.hover()
+      await files.locator('[data-dockkit-tab-close]').click()
       await column.locator('[data-dockkit-add-tab]').click()
       await expect.poll(async () => await tabTitles(column)).toEqual([SAMPLE_NAME, 'Start'])
-      await column.locator('[data-dockkit-tab]').filter({ hasText: SAMPLE_NAME })
-        .locator('[data-dockkit-tab-close]').click()
+      const sample = column.locator('[data-dockkit-tab]').filter({ hasText: SAMPLE_NAME })
+      await sample.hover()
+      await sample.locator('[data-dockkit-tab-close]').click()
       await expect.poll(async () => await tabTitles(column)).toEqual(['Start'])
 
       // The guide standing as the docked surface's only tab draws no close
@@ -954,8 +958,10 @@ describe('web e2e: shipped right Sidebar', () => {
       // settle rule reseeds the current default, so reopening shows Files.
       await page.getByRole('button', { name: `Open ${SAMPLE_NAME}` }).click()
       await expect.poll(async () => await tabTitles(column)).toEqual(['Start', SAMPLE_NAME])
+      await column.locator('[data-dockkit-tab]').first().hover()
       await column.locator('[data-dockkit-tab-close]').first().click()
       await expect.poll(async () => await tabTitles(column)).toEqual([SAMPLE_NAME])
+      await column.locator('[data-dockkit-tab]').first().hover()
       await column.locator('[data-dockkit-tab-close]').first().click()
       await expect.poll(async () => await column.locator('[data-sidebar-right-open]').count()).toBe(0)
       await expandOf(page).click()

+ 18 - 14
packages/client/ui-dockkit/src/components/FloatLayer.tsx

@@ -18,7 +18,7 @@ import type { PointerEvent as ReactPointerEvent, ReactNode } from 'react'
 import clsx from 'clsx'
 import { IconCloseOutline16, IconPanelLeftOutline16, Tooltip } from '@deepseek-ai/dsh-client-ui-primitives'
 import type { DockIntents, DockLabels, TabRenderer } from '../contract/adapter.ts'
-import type { FloatRect, LayoutState, PaneId } from '../contract/types.ts'
+import type { FloatRect, LayoutState, PaneId, TabId } from '../contract/types.ts'
 import { FLOAT_MIN_SIZE } from '../engine/constraints.ts'
 import { movedRect, resizedRect } from '../engine/geometry.ts'
 import { floatRect, getPane, getTab, onlyTabId } from '../engine/tree.ts'
@@ -32,6 +32,8 @@ export interface FloatLayerProps {
   readonly intents: DockIntents
   readonly labels: DockLabels
   readonly renderTab: TabRenderer
+  /** Whether a floating tab offers its close control; defaults to true. Called per tab on every render. */
+  readonly canCloseTab?: (tabId: TabId) => boolean
   /** The panel header's title content; omit to show the record's `title` text (see `DockSurfaceProps`). */
   readonly renderTabTitle?: TabRenderer
 }
@@ -64,7 +66,7 @@ function raised(state: LayoutState, paneId: PaneId): boolean {
 }
 
 /** Every floating panel, in z order. */
-export function FloatLayer({ state, intents, labels, renderTab, renderTabTitle }: FloatLayerProps): ReactNode {
+export function FloatLayer({ state, intents, labels, renderTab, renderTabTitle, canCloseTab }: FloatLayerProps): ReactNode {
   const [preview, setPreview] = useState<{ paneId: PaneId; rect: FloatRect } | undefined>(undefined)
   const begin = useGesture(() => { setPreview(undefined) })
 
@@ -136,18 +138,20 @@ export function FloatLayer({ state, intents, labels, renderTab, renderTabTitle }
                   <IconPanelLeftOutline16 className={css.dockGlyph} />
                 </button>
               </Tooltip>
-              <Tooltip label={labels.closeFloat} side="bottom" delayMs={500}>
-                <button
-                  type="button"
-                  className={css.iconButton}
-                  aria-label={labels.closeFloat}
-                  data-dockkit-float-close={paneId}
-                  onPointerDown={(event) => { event.stopPropagation() }}
-                  onClick={() => { intents.closeTab(tab.id) }}
-                >
-                  <IconCloseOutline16 />
-                </button>
-              </Tooltip>
+              {(canCloseTab?.(tab.id) ?? true) && (
+                <Tooltip label={labels.closeFloat} side="bottom" delayMs={500}>
+                  <button
+                    type="button"
+                    className={css.iconButton}
+                    aria-label={labels.closeFloat}
+                    data-dockkit-float-close={paneId}
+                    onPointerDown={(event) => { event.stopPropagation() }}
+                    onClick={() => { intents.closeTab(tab.id) }}
+                  >
+                    <IconCloseOutline16 />
+                  </button>
+                </Tooltip>
+              )}
             </header>
             <div className={css.floatBody}>{renderTab(tab)}</div>
             <div

+ 9 - 15
packages/client/ui-dockkit/src/components/TabMenu.tsx

@@ -2,10 +2,10 @@
  * The per-tab context menu, opened by a secondary press on the chip. It carries
  * the close gesture and whatever the embedder appends; the copy and float
  * gestures have no menu item — copying is an embedder API, floating is a drag
- * released clear of the surface. A menu that would hold no item at all
- * dismisses itself before the first paint, so a secondary press on a chip with
- * nothing to offer shows nothing. Presentational — it renders what its props
- * supply and dismisses itself on outside presses.
+ * released clear of the surface. A menu that would hold no item at all renders
+ * no popup, so a secondary press on a chip with nothing to offer shows nothing.
+ * Presentational — it renders what its props supply and dismisses itself on
+ * outside presses.
  *
  * It renders in a portal, positioned against the control that opened it. The tab
  * strip clips its overflow on purpose (so it never becomes a scroll container
@@ -14,7 +14,7 @@
  * portal's synthetic events through the strip, which is why the press guards
  * below remain necessary.
  */
-import { useEffect, useLayoutEffect, useRef, useState } from 'react'
+import { Children, useEffect, useLayoutEffect, useRef, useState } from 'react'
 import type { CSSProperties, ReactNode } from 'react'
 import { createPortal } from 'react-dom'
 import type { DockLabels } from '../contract/adapter.ts'
@@ -53,19 +53,12 @@ function placeMenu(anchor: HTMLElement, menu: HTMLElement): CSSProperties {
 export function TabMenu({ labels, anchor, onClose, onDismiss, extras }: TabMenuProps): ReactNode {
   const self = useRef<HTMLDivElement | null>(null)
   const [position, setPosition] = useState<CSSProperties | undefined>(undefined)
+  const hasItems = onClose !== undefined || Children.toArray(extras).some(item => item !== '')
 
   useLayoutEffect(() => {
-    /* v8 ignore next -- the ref is attached by effect time: the menu renders unconditionally. */
     if (self.current === null) return
-    // A menu with nothing to offer never shows: with the close withheld and no
-    // embedder item rendered, dismiss before the first paint instead of
-    // drawing an empty box.
-    if (self.current.querySelector('[role="menuitem"]') === null) {
-      onDismiss()
-      return
-    }
     setPosition(placeMenu(anchor, self.current))
-  }, [anchor, onDismiss])
+  }, [anchor, hasItems])
 
   useEffect(() => {
     const menu = self.current
@@ -81,8 +74,9 @@ export function TabMenu({ labels, anchor, onClose, onDismiss, extras }: TabMenuP
     // so the menu must be gone before that handler runs.
     window.addEventListener('pointerdown', onPointerDown, true)
     return () => { window.removeEventListener('pointerdown', onPointerDown, true) }
-  }, [onDismiss])
+  }, [onDismiss, hasItems])
 
+  if (!hasItems) return null
   return createPortal(
     <div
       className={css.menu}

+ 6 - 0
packages/client/ui-dockkit/src/components/TabPanel.tsx

@@ -142,6 +142,12 @@ function hiddenSides(box: HTMLElement): 'start' | 'end' | 'start end' | undefine
  * commit that can change the chips, on scroll, and on resize. Written to the
  * DOM directly rather than through state because a reading never changes
  * what renders, only how the stylesheet fades it.
+ *
+ * Known gap: a content-width change that alters neither `tabs` nor the box's
+ * outer size — a live `renderTabTitle` growing a chip, or a drop-caret slot
+ * mounting mid-drag — keeps the fade at its last reading until the next
+ * scroll or resize. The fade is orientation chrome, so a stale edge fades a
+ * few frames late rather than hiding anything.
  */
 function useStripScrollFades(box: RefObject<HTMLDivElement | null>, tabs: readonly TabId[]): void {
   useLayoutEffect(() => {

+ 8 - 1
packages/client/ui-dockkit/src/components/dockkit.module.css

@@ -328,6 +328,9 @@
   border-radius: 20px;
   cursor: pointer;
   opacity: 0;
+  /* Hidden means untouchable too: without this a touch press on the chip's
+     trailing 20px would close the tab instead of activating it. */
+  pointer-events: none;
 }
 
 /* Shown while the chip is hovered or holds focus, and always on the active
@@ -336,6 +339,7 @@
 .tab:focus-within .tabClose,
 .tabActive .tabClose {
   opacity: 1;
+  pointer-events: auto;
 }
 
 /* The chip's height and corner, so it reads as one more capsule in the row. */
@@ -506,7 +510,10 @@
   position: absolute;
   inset: 0;
   pointer-events: none;
-  background: color-mix(in srgb, var(--dsw-alias-bg-layer-2) 72%, transparent);
+  /* The ground the panes sit on, not a raised layer: in dark mode `bg-layer-2`
+     is a lighter bluish step than the column's `bg-base`, which tinted the
+     whole scrim away from the content beneath it. */
+  background: color-mix(in srgb, var(--dsw-alias-bg-base) 72%, transparent);
   backdrop-filter: blur(6px);
   animation: dockScrimIn 140ms ease-out;
 }

+ 3 - 1
packages/client/ui-dockkit/src/contract/adapter.ts

@@ -47,7 +47,9 @@ export type TabRenderer = (tab: TabRecord) => ReactNode
  *
  * The kit's own item is the close gesture; anything that means something about
  * the tab's content comes from here. An item that acts MUST call `dismiss`,
- * because the menu closes on its own items only.
+ * because the menu closes on its own items only. Every rendered item MUST
+ * carry `role="menuitem"`: the kit probes for that role to dismiss a menu
+ * that would paint empty, so items without it count as an empty menu.
  * @param tab - the tab whose menu is open.
  * @param dismiss - close the menu without acting.
  * @returns extra actions with ARIA menuitem, menuitemcheckbox, or menuitemradio roles, or nothing.

+ 2 - 2
packages/client/ui-open-in-app/src/client/OpenInAppAction.module.css

@@ -1,5 +1,5 @@
-/* The split button sits beside the session-log capsule at the same compact
-   scale (28px tall, pill radius, hairline l4 border, 11px primary-color label). */
+/* The split button matches the header's 28px control row (pill radius,
+   hairline l4 border, 11px primary-color label) beside the round icon buttons. */
 
 .split {
   display: inline-flex;

+ 3 - 1
packages/client/ui-sidebar-documentpreview/src/client/TextPreview.tsx

@@ -238,12 +238,14 @@ export function TextPreview({
           dense
         />
         {selected.wrap === true && (
+          // The tooltip names the action while the stable aria name and
+          // `aria-pressed` expose the control and its current state.
           <Tooltip label={t(state.wrap ? 'wrap.disable' : 'wrap.enable')} side="bottom" delayMs={500}>
             <button
               type="button"
               className={css.tool}
               aria-pressed={state.wrap}
-              aria-label={t(state.wrap ? 'wrap.disable' : 'wrap.enable')}
+              aria-label={t('wrap.aria')}
               data-textpreview-tool="wrap"
               onClick={() => { actions.toggledWrap(tab.id) }}
             >

+ 2 - 0
packages/client/ui-sidebar-documentpreview/src/client/locales.ts

@@ -15,6 +15,7 @@ export const zh = {
   reload: '重新读取文件',
   'wrap.enable': '自动换行',
   'wrap.disable': '取消换行',
+  'wrap.aria': '自动换行',
   openWith: '打开方式',
   'viewer.text': '纯文本',
   resourceUnavailable: '文件资源服务不可用。',
@@ -39,6 +40,7 @@ export const en = {
   reload: 'Read the file again',
   'wrap.enable': 'Turn on line wrap',
   'wrap.disable': 'Turn off line wrap',
+  'wrap.aria': 'Line wrap',
   openWith: 'Open with',
   'viewer.text': 'Plain text',
   resourceUnavailable: 'The file resource service is unavailable.',

+ 6 - 2
packages/client/ui-sidebar-right/src/client/locales.ts

@@ -9,16 +9,18 @@
 /** Simplified Chinese dictionary and key-set source of truth. */
 export const zh = {
   'chrome.expand': '打开侧边栏',
+  'chrome.expandAria': '打开右侧边栏',
   'chrome.collapse': '收起侧边栏',
+  'chrome.collapseAria': '收起右侧边栏',
   'chrome.toFullscreen': '全屏',
   'chrome.exitFullscreen': '退出全屏',
   'dock.emptyPane': '空面板',
   'dock.splitPane': '分栏',
   'dock.splitPaneDisabled': '已达两格上限',
-  'dock.splitPaneNarrow': '栏宽不足,拖宽侧栏后再分栏',
+  'dock.splitPaneNarrow': '栏宽不足,拖宽侧边栏后再分栏',
   'dock.closeTab': '关闭',
   'dock.addTab': '新标签页',
-  'dock.dockFloat': '收回到侧栏',
+  'dock.dockFloat': '收回到侧边栏',
   'dock.closeFloat': '关闭',
   'dock.drop.center': '移到这里',
   'dock.drop.left': '左分栏',
@@ -35,7 +37,9 @@ export type SidebarRightKey = keyof typeof zh
 /** English dictionary, checked against the Chinese key set. */
 export const en = {
   'chrome.expand': 'Open sidebar',
+  'chrome.expandAria': 'Open right sidebar',
   'chrome.collapse': 'Collapse sidebar',
+  'chrome.collapseAria': 'Collapse right sidebar',
   'chrome.toFullscreen': 'Fullscreen',
   'chrome.exitFullscreen': 'Exit fullscreen',
   'dock.emptyPane': 'Empty pane',

+ 1 - 1
packages/client/ui-sidebar-right/src/client/shell/ExpandButton.tsx

@@ -37,7 +37,7 @@ export function ExpandButton({ sessionId, useStore, actions, t }: ExpandButtonPr
       <button
         type="button"
         className={css.button}
-        aria-label={t('chrome.expand')}
+        aria-label={t('chrome.expandAria')}
         data-sidebar-right-expand
         onClick={() => { actions.setExpanded(sessionId, true) }}
       >

+ 1 - 1
packages/client/ui-sidebar-right/src/client/shell/SidebarRight.tsx

@@ -272,7 +272,7 @@ function PanelChrome({ sessionId, fullscreen, autoFullscreen, actions, t }: Pick
         <button
           type="button"
           className={css.iconButton}
-          aria-label={t('chrome.collapse')}
+          aria-label={t('chrome.collapseAria')}
           data-sidebar-right-toggle
           onClick={() => { actions.toggleExpanded(sessionId) }}
         >

+ 3 - 1
packages/client/ui-sidebar-right/src/client/stores.ts

@@ -336,7 +336,9 @@ export function createSidebarRightStore(
         d.bySession = seat(d, sessionId, seed, s => advance(s, (state) => {
           if (!canCloseTab(s, tabId)) return []
           if (!soleDockedTab(state, tabId)) return [{ type: 'closeTab', tabId }]
-          return [{ type: 'closeTab', tabId }, ...planSetExpanded(state, false)]
+          // The collapse also leaves fullscreen: the reopened column shows only
+          // the reseeded default page, which never earns the whole window.
+          return [{ type: 'closeTab', tabId }, ...planSetMode(state, 'push'), ...planSetExpanded(state, false)]
         }, seed))
       },
       focusTab: (d, sessionId: string, tabId: TabId) => {

+ 1 - 1
packages/client/ui-sidebar-right/tests/expand-button.client.spec.tsx

@@ -45,7 +45,7 @@ describe('ExpandButton', () => {
     const { instance, view, control } = mountButton()
     const button = control()
     if (button === null) throw new Error('expected the expand control')
-    expect(button.getAttribute('aria-label')).toBe('chrome.expand')
+    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.

+ 6 - 1
packages/client/ui-sidebar-right/tests/stores.client.spec.ts

@@ -160,16 +160,21 @@ describe('createSidebarRightStore — the last docked tab', () => {
     expect(layout().tabs[seeded.id]).toBeUndefined()
     const before = layout()
     const recorded = entries()
+    // Fullscreen at the moment of the close: the collapse must hand the mode
+    // back too, or the next expand gives the whole window to the default page.
+    actions.setMode(SESSION, 'fullscreen')
 
     actions.closeTab(SESSION, text.id)
 
     expect(layout().expanded).toBe(false)
+    expect(layout().mode).toBe('push')
     expect(layout().tabs[text.id]).toBeUndefined()
     // The settle rule reseeded the emptied root pane, so reopening shows the guide.
     const reseeded = Object.values(layout().tabs)
     expect(reseeded).toHaveLength(1)
-    expect(entries()).toBe(recorded + 1)
+    expect(entries()).toBe(recorded + 2)
 
+    actions.undo(SESSION)
     actions.undo(SESSION)
     expect(layout()).toEqual(before)
   })