feat(client): polish sidebar and file preview interactions

Seed sidebar default pages lazily on expansion, scope page-tab dedupe
to the target pane, let a sole tab split its own pane with a factory
backfill, close menus when focus enters a sandboxed iframe, and pin the
code preview copy banner while unifying its background with the pane.
This commit is contained in:
Yif committed 2026-09-09 17:27:37 +08:00
1 parent ba5ce5700c
commit 3b4122ac16
32 files changed
+420 -162

No files matched your search

@@ -0,0 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# 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-09-sidebar-and-preview-interaction-polish.md
2026-09-09-sidebar-and-preview-interaction-polish.md: 63205125fc5833f643217d38fabc9cb897ab1f9c
2026-09-09-sidebar-and-preview-interaction-polish.zh.md: bcfcbeb61cae71b45539393df20a9a6bfc00e3f0
@@ -0,0 +1,35 @@
# Agent Note: Sidebar and preview interaction polish
Status: implemented
English | [中文](2026-09-09-sidebar-and-preview-interaction-polish.zh.md)
## Problem
Five small interaction defects around the right Sidebar and the document preview. Every session's surface was born with a seeded default page, so a collapsed column the user never opened already held a page, and the first open into a fresh surface showed the seed beside the content it opened. Page tabs deduplicated surface-wide: opening a page whose tab sat in the other pane pulled focus across panes instead of opening it where asked. Dragging a pane's sole tab onto its own edge did nothing, though the user plainly asked for a split. A dropdown over the HTML preview did not dismiss on a click inside the sandboxed iframe, because that pointerdown never reaches the parent document. And the code preview's copy banner and card background scrolled away under horizontal scrolling, while the code kept the chat card's gray fill instead of sitting on the pane's own background.
## Decision
**Default pages seed lazily.** `createSurface` in [stores.ts](../../../../packages/client/ui-sidebar-right/src/client/stores.ts) mints a collapsed, empty surface; the store's `advance` passes the seed factory to `planSettle` only when the intent leaves the column expanded. The expansion that would first show an empty layout is what seeds the then-current default page, and closing the last closable tab collapses the column and leaves the layout empty until the next expansion. The [last-tab close rule](2026-09-08-sidebar-last-tab-close-rules.md) and [default-page selection](2026-09-08-sidebar-default-pages.md) stay as decided; only the seeding moment moved.
**Page uniqueness is pane-scoped.** The guide-only merge generalized to every page kind (`pageKind`/`panePage`): opening a page focuses an existing tab only inside the pane the open targets, and a page dragged, dropped, or docked into a pane already showing that kind's page merges into the pane's own. Resource tabs keep the kit's surface-wide reveal.
**A sole tab splits its own pane when a factory backfills it.** `planDropTab` in [planner.ts](../../../../packages/client/ui-dockkit/src/engine/planner.ts) takes an optional `TabFactory`; with one, the previously refused self-edge release splits, the factory's tab backfills the vacated pane before the move so the dragged tab ends focused. Without a factory the release still changes nothing.
**Menus close on focus entering an iframe.** [Menu.tsx](../../../../packages/client/ui-primitives/src/Menu.tsx) adds a window `blur` listener gated on `document.activeElement instanceof HTMLIFrameElement` — the focus move is the only signal a pointerdown inside a cross-origin iframe leaves, and the gate keeps app or tab switches from closing the list.
**The code preview pins its banner and drops the card fill.** With wrap off, [CodeBody.module.css](../../../../packages/client/ui-sidebar-documentpreview/src/client/code/CodeBody.module.css) sizes the renderer `max-content` so the sticky banner has the full scroll width to ride, and pins the banner `sticky; left: 0; width: 100cqw` against the document scroller (`container-type: inline-size` on the preview body). The shared CodeBlock's fill is routed through a new `--dsl-code-block-background` variable (default unchanged, so chat keeps its gray card) and the shared banner carries an inert `data-code-block-banner` hook; the preview sets the variable to `transparent` so code sits on the pane's own background.
## Alternatives considered
**Keep seeding at surface creation.** A collapsed column held a page nobody asked for, and the seed took slot 0 of every fresh surface ahead of the first real open.
**Keep surface-wide page dedupe.** Focus jumped to the other pane on an explicit "open here", the exact complaint that started the change.
**A bare window-blur close for menus.** Closes the list on every app or tab switch; the `activeElement` gate scopes the close to the one case the document cannot see.
**An inner code scroller for the horizontal axis.** Restoring `overflow-x: auto` on the `pre` keeps the banner still, but puts the horizontal scrollbar at the bottom of the whole block — unreachable in a long file — and both axes deliberately live in the document owner's scroller.
## Consequences
`planDropTab`'s factory parameter is new kit API any embedder may pass; `planSettle` already accepted an absent factory, which now also names the Sidebar's collapsed-state behavior. The `--dsl-code-block-background` variable and `data-code-block-banner` attribute are the code block's owner-styling seam; no shared stylesheet rule targets the attribute. Kit planner specs cover the backfilled self-split and its focus order; Sidebar store, service, and seat specs cover lazy seeding, pane-scoped page merges, and the empty collapsed layout; a Menu spec covers the gated blur close. The `ui-sidebar-right`, `ui-dockkit`, and `ui-sidebar-documentpreview` READMEs restate the rules.
@@ -0,0 +1,35 @@
# Agent Note: 侧边栏与文件预览交互打磨
Status: implemented
[English](2026-09-09-sidebar-and-preview-interaction-polish.md) | 中文
## Problem
右侧边栏与文档预览周边的五个小交互缺陷。每个会话的停靠面出生时就播种默认页,于是用户从未打开过的折叠列里已经躺着一个页面,向全新停靠面的第一次打开会让种子页出现在所开内容旁边。页 tab 在整个停靠面范围去重:打开一个页时,若它的 tab 在另一格里,焦点会被拽到那一格,而不是在被要求的格里打开。把一个格的唯一 tab 拖到本格边缘什么都不发生,尽管用户明明要的是分栏。HTML 预览上方的下拉菜单在点击沙箱 iframe 内部时不消失,因为那次 pointerdown 根本到不了父文档。代码预览的复制条和卡片背景会被横向滚动甩开,而且代码保留了会话卡片的灰色填充,而不是坐在分栏自身的背景上。
## Decision
**默认页惰性播种。**[stores.ts](../../../../packages/client/ui-sidebar-right/src/client/stores.ts) 的 `createSurface` 铸造一个折叠且为空的停靠面;store 的 `advance` 只在意图让列保持展开时才把种子工厂传给 `planSettle`。首次会展示空布局的那次展开播种彼时的默认页;关闭最后一个可关 tab 时收起整列,布局保持为空直到下次展开。[最后一个 tab 的关闭规则](2026-09-08-sidebar-last-tab-close-rules.zh.md)与[默认页选择](2026-09-08-sidebar-default-pages.zh.md)维持原决定;改变的只是播种时机。
**页唯一性以格为界。**只对引导页的合并规则推广到每种页 kind(`pageKind`/`panePage`):打开一个页只在这次打开的目标格内聚焦既有 tab;把页拖入、放入或收回到已展示该 kind 页的格会并入该格自己的 tab。资源 tab 保留套件的全停靠面聚焦。
**有工厂回填时,唯一 tab 可对本格分栏。**[planner.ts](../../../../packages/client/ui-dockkit/src/engine/planner.ts) 的 `planDropTab` 接受可选 `TabFactory`:带工厂时,先前被拒绝的本格边缘释放会分栏,工厂的 tab 先于移动回填腾出的格,因此被拖的 tab 最终保持聚焦。不带工厂时该释放仍不改变任何东西。
**焦点进入 iframe 时关闭菜单。**[Menu.tsx](../../../../packages/client/ui-primitives/src/Menu.tsx) 增加 window `blur` 监听,以 `document.activeElement instanceof HTMLIFrameElement` 为门:焦点移动是跨源 iframe 内 pointerdown 留下的唯一信号,这道门也让应用或标签页切换不会误关列表。
**代码预览钉住复制条并去掉卡片填充。**关闭折行时,[CodeBody.module.css](../../../../packages/client/ui-sidebar-documentpreview/src/client/code/CodeBody.module.css) 把渲染器设为 `max-content`,让吸附的复制条拥有完整滚动宽度可骑行,并以 `sticky; left: 0; width: 100cqw` 把它钉在文档滚动区上(预览正文设 `container-type: inline-size`)。共享 CodeBlock 的填充改经新变量 `--dsl-code-block-background`(默认值不变,会话保持灰色卡片),共享复制条带上惰性的 `data-code-block-banner` 钩子;预览把变量设为 `transparent`,代码于是坐在分栏自身的背景上。
## Alternatives considered
**保留出生即播种。**折叠列里躺着没人要的页面,且种子占据每个新停靠面的 0 号位,排在第一次真实打开之前。
**保留全停靠面的页去重。**显式"在这里打开"时焦点跳到另一格——正是引发这次改动的抱怨。
**菜单用裸的 window blur 关闭。**每次应用或标签页切换都会关掉列表;`activeElement` 门把关闭收窄到父文档看不见的那一种情形。
**代码横轴用内层滚动。**在 `pre` 上恢复 `overflow-x: auto` 能让复制条不动,但横向滚动条会落在整个代码块底部——长文件里够不着——而且两个轴本就有意放在文档 owner 的滚动区里。
## Consequences
`planDropTab` 的工厂参数是任何嵌入方都可传的新套件 API;`planSettle` 本就接受缺省工厂,如今它同时命名了侧边栏的折叠态行为。`--dsl-code-block-background` 变量与 `data-code-block-banner` 属性是代码块的 owner 定制接缝;共享样式表没有任何规则指向该属性。套件 planner 规格覆盖带回填的本格分栏及其聚焦顺序;侧边栏 store、service 与 seat 规格覆盖惰性播种、格内页合并与折叠后的空布局;一条 Menu 规格覆盖带门的 blur 关闭。`ui-sidebar-right`、`ui-dockkit` 与 `ui-sidebar-documentpreview` 的 README 重述了这些规则。
+2 -2
View File
@@ -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 packages/client/ui-dockkit/README.md
README.md: 396253d771196380d04da13cfc0843584b37fbc2
README.zh.md: ec2d5077ef847b3fa50d7e78113b978ec4fa7507
README.md: 41100530b77f9776c768010b53ecbc6ad67b5902
README.zh.md: 237ce5f0aedf1124e4dc3a214bf10529dea1703f
+1 -1
View File
@@ -34,7 +34,7 @@ A docking layout kit: a split tree of tabbed panes with invertible operations, a
- `applyOp(state, op)` returns the next state **and the operations that undo it**. Inverses are captured when an operation runs, because by undo time the pre-operation state is gone.
- Every operation carries the ids it creates, so `replay(initial, ops)` reproduces the same tree. The engine reads no clock and no random source.
- `Sequencer` keeps a linear history with one entry per intent: the operations one gesture or command produced step back and forward together, a run of consecutive focus-only entries steps as one, and a new entry after stepping back drops the forward branch.
- `planSettle` is the opt-in rule that keeps every docked pane populated after an intent: panes an intent emptied are merged away, and an emptied root pane is reseeded through the embedder's factory. An embedder that wants empty panes simply does not call it.
- `planSettle` is the opt-in rule that keeps every docked pane populated after an intent: panes an intent emptied are merged away, and an emptied root pane is reseeded through the embedder's factory — withholding the factory keeps the merge and leaves the root pane empty. An embedder that wants empty panes simply does not call it, or calls it without a factory. `planDropTab` takes the same factory: with one, a sole tab released on its own pane's edge splits and the factory's tab backfills the pane it vacates (the dragged tab stays focused); without one that release changes nothing.
- `DockController` is the intent layer and an observable source (`subscribe` + `getSnapshot`, whose reference only changes when the layout does).
**The components** render a layout snapshot and report settled intents — one per gesture, never a drag frame. A drag previews in local state while the gesture's own facts stay in its closure; on release the net result leaves through one `DockIntents` call — a strip release reports the caret slot as drawn, the dragged chip counted, and `planPlaceTab` turns that into the reorder or the move. That is what lets an embedder record exactly one history entry per gesture. The strip follows the WAI-ARIA tabs pattern with manual activation: the selected chip is in the tab order; Left and Right (wrapping), Home, and End move focus between chips without selecting; Enter or Space selects the focused chip through the same intent as a click. A chip is a capsule carrying one control, its close; the context menu (a secondary press on the chip) carries the same close plus the embedder's items — a menu that would hold no item at all never shows — and renders in a portal positioned against the chip because the chip box clips its overflow on purpose (see below). After the chips sits the add control, which asks the embedder (`DockIntents.addTab`) to seat its seeded tab; the embedder's `canAddTab(paneId)` decides per pane whether the control is drawn at all. Copying a tab has no kit control — it is the embedder's API — and floating is the drag released clear of the surface.
+1 -1
View File
@@ -34,7 +34,7 @@ kind: "package-reference"
- `applyOp(state, op)` 返回下一状态**以及撤销它的操作**。逆操作在操作执行时捕获,因为到撤销时操作前的状态已经不存在了。
- 每个操作都携带它创建的 id,因此 `replay(initial, ops)` 能复现同一棵树。引擎不读时钟,也不读随机源。
- `Sequencer` 维护一条线性历史,每个意图一条记录:一次手势或命令产生的操作一起后退、一起前进,连续的纯焦点记录作为一步,后退后的新记录会丢弃前进分支。
- `planSettle` 是可选加入的规则,保证意图之后每个停靠格都有内容:被意图清空的格会被并掉,被清空的根格通过嵌入方的工厂重新播种。想要空格的嵌入方只需不调用它。
- `planSettle` 是可选加入的规则,保证意图之后每个停靠格都有内容:被意图清空的格会被并掉,被清空的根格通过嵌入方的工厂重新播种——不传工厂则只并格、让根格保持为空。想要空格的嵌入方只需不调用它,或不带工厂调用。`planDropTab` 接受同一工厂:带工厂时,唯一 tab 放到本格边缘会分栏,工厂的 tab 回填它腾出的格(被拖的 tab 保持聚焦);不带工厂时这种释放不改变任何东西。
- `DockController` 是意图层,也是一个可观察源(`subscribe` + `getSnapshot`,其引用只在布局变化时才变)。
**组件**渲染布局快照并上报已落定的意图——每次手势一条,绝不上报拖动帧。拖动过程中在本地状态里预览,手势自身的事实留在它的闭包里;松手时净结果通过一次 `DockIntents` 调用离开——在标签条上松手上报的是按绘制顺序数出的插入槽位(被拖的 chip 也计入),由 `planPlaceTab` 换算成重排或移动。正是这一点让嵌入方能为每次手势记录恰好一条历史。标签条遵循 WAI-ARIA tabs 模式的手动激活:选中的 chip 在 Tab 键序里;左右方向键(循环)、Home、End 只在 chip 之间移动焦点而不选中;Enter 或空格选中当前聚焦的 chip,走与点击相同的意图。chip 是一个胶囊,携带唯一的控件——它的关闭按钮;上下文菜单(在 chip 上的次键按下)携带同样的关闭项加上嵌入方的条目——一个连一项都没有的菜单绝不展示——并渲染在按 chip 定位的 portal 里,因为 chip 盒会故意裁掉溢出(见下文)。chip 之后是添加控件,它请嵌入方(`DockIntents.addTab`)安放其种子 tab;嵌入方的 `canAddTab(paneId)` 按格决定是否绘制该控件。复制 tab 没有套件控件——那是嵌入方的 API——而浮出就是把拖动松手在停靠区之外。
@@ -255,13 +255,16 @@ export function planPlaceTab(
/**
* Resolve a tab release on a pane body: the centre moves the tab in, an edge
* splits the pane and seats the tab in the new half. A pane's only tab released
* on that pane changes nothing in either zone: the split would empty the pane
* and seat the tab beside where it already was.
* on that pane's centre changes nothing; released on its edge it splits, and
* the factory's tab backfills the pane the drag would otherwise empty — without
* a factory that release also changes nothing, since the split would empty the
* pane and seat the tab beside where it already was.
* @param state - current layout.
* @param mint - id source for a pane an edge release creates.
* @param tabId - the dragged tab.
* @param targetPaneId - pane under the pointer.
* @param zone - dock region the pointer released in.
* @param makeTab - builds the tab that backfills a pane its only tab splits away from.
* @returns the operations, or none when the release changes nothing.
*/
export function planDropTab(
@@ -270,6 +273,7 @@ export function planDropTab(
tabId: TabId,
targetPaneId: PaneId,
zone: DockZone,
makeTab?: TabFactory,
): readonly LayoutOp[] {
const source = findTabPane(state, tabId)
const target = getPane(state, targetPaneId)
@@ -281,20 +285,25 @@ export function planDropTab(
return [tabInto(source, tabId, targetPaneId, target.tabs.length)]
}
if (source.id === targetPaneId && source.tabs.length === 1) return NOTHING
const vacates = source.id === targetPaneId && source.tabs.length === 1
if (vacates && makeTab === undefined) return NOTHING
if (!canSplit(state)) return NOTHING
const newPaneId = mint('pane')
return [
{
type: 'split',
paneId: targetPaneId,
axis: split.axis,
direction: split.direction,
newPaneId,
newSplitId: mint('split'),
},
tabInto(source, tabId, newPaneId, 0),
]
const ops: LayoutOp[] = [{
type: 'split',
paneId: targetPaneId,
axis: split.axis,
direction: split.direction,
newPaneId,
newSplitId: mint('split'),
}]
// The backfill seats before the move so the moved tab ends focused, as any
// other drop leaves it.
if (vacates && makeTab !== undefined) {
ops.push({ type: 'openTab', paneId: targetPaneId, tab: makeTab(mint('tab')), index: source.tabs.length })
}
ops.push(tabInto(source, tabId, newPaneId, 0))
return ops
}
/**
@@ -133,7 +133,7 @@ describe('planPlaceTab', () => {
})
describe('planDropTab on the tab\'s own pane', () => {
it('plans nothing for a pane\'s only tab released on any of its own edges', () => {
it('plans nothing for a pane\'s only tab released on any of its own edges without a factory', () => {
const { state, minter } = seededState()
const mint = minter.next
const tabId = getPane(state, state.rootId).tabs[0]
@@ -143,12 +143,33 @@ describe('planDropTab on the tab\'s own pane', () => {
}
})
it('splits the pane when it keeps another tab', () => {
it('splits on a sole tab\'s own edge when a factory backfills the pane it vacates', () => {
const { state, minter } = seededState()
const mint = minter.next
const paneId = getPane(state, state.rootId).id
const tabId = getPane(state, paneId).tabs[0]
if (tabId === undefined) throw new Error('fixture: seeded tab missing')
const ops = planDropTab(state, mint, tabId, paneId, 'right', seedTab)
expect(ops.map(op => op.type)).toEqual(['split', 'openTab', 'moveTab'])
const split = applyAll(state, ops)
const [home, destination] = dockPaneIds(split)
if (home === undefined || destination === undefined) throw new Error('expected two panes')
expect(getPane(split, home).tabs).toHaveLength(1)
expect(getPane(split, home).tabs[0]).not.toBe(tabId)
expect(getPane(split, destination).tabs).toEqual([tabId])
// The backfill seats first, so the moved tab ends focused.
expect(split.activePaneId).toBe(destination)
})
it('splits without a backfill when the pane keeps another tab, factory or not', () => {
const { state, minter } = seededState()
const mint = minter.next
const opened = planOpenContent(state, mint, { contentId: 'dsh-resource://file/session/s/a.txt', title: 'a.txt', kind: 'file' })
const two = applyAll(state, opened.ops)
const ops = planDropTab(two, mint, opened.tabId, getPane(two, two.rootId).id, 'right')
const target = getPane(two, two.rootId).id
expect(planDropTab(two, mint, opened.tabId, target, 'right')
.map(op => op.type)).toEqual(['split', 'moveTab'])
const ops = planDropTab(two, mint, opened.tabId, target, 'right', seedTab)
expect(ops.map(op => op.type)).toEqual(['split', 'moveTab'])
const split = applyAll(two, ops)
expect(dockPaneIds(split)).toHaveLength(2)
+12 -1
View File
@@ -54,7 +54,9 @@ const MEASURE_STYLE: CSSProperties = { visibility: 'hidden', left: 0, top: 0 }
* @param props.selectedId - row shown as selected.
* @param props.selectedIds - rows shown as selected when a menu contains independent option groups.
* @param props.onSelect - row click callback (not called for disabled rows or submenu parents that only open children).
* @param props.onClose - invoked on outside click or Escape.
* @param props.onClose - invoked on outside click, Escape, or a window blur
* that moved focus into an iframe (the only signal a pointerdown inside a
* cross-origin iframe leaves).
* @param props.align - list alignment against the anchor (default 'start').
* @param props.side - open below (`bottom`, default) or above (`top`) the anchor.
* @param props.portal - render the list into document.body, fixed-positioned
@@ -174,11 +176,20 @@ export function Menu({ open, anchor, items, selectedId, selectedIds, onSelect, o
const onKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose()
}
// A pointerdown inside a cross-origin iframe (a sandboxed HTML preview)
// never reaches this document; the focus move it causes blurs the window
// instead. Only that case closes: an app or tab switch leaves the
// document's focus where it was, so activeElement is not an iframe.
const onWindowBlur = () => {
if (document.activeElement instanceof HTMLIFrameElement) onClose()
}
document.addEventListener('pointerdown', onPointerDown)
document.addEventListener('keydown', onKeyDown)
window.addEventListener('blur', onWindowBlur)
return () => {
document.removeEventListener('pointerdown', onPointerDown)
document.removeEventListener('keydown', onKeyDown)
window.removeEventListener('blur', onWindowBlur)
}
}, [open, onClose])
@@ -8,11 +8,12 @@
with the markdown 0.875 ladder without shrinking every other xs-13 use. */
--dsl-code-block-banner-font: 11px/18px var(--dsw-font-family);
--dsl-code-block-content-font: var(--dsw-font-markdown-code-block);
--dsl-code-block-background: var(--dsw-alias-markdown-code-block);
position: relative;
margin: 16px 0;
color: var(--dsw-alias-label-primary);
background: var(--dsw-alias-markdown-code-block);
background: var(--dsl-code-block-background);
border-radius: var(--dsl-code-block-border-radius);
}
@@ -75,7 +76,7 @@
overflow-x: auto;
white-space: pre-wrap;
word-break: break-all;
background: var(--dsw-alias-markdown-code-block);
background: var(--dsl-code-block-background);
/* Bottom radii live on <pre>: overflow:hidden on .block would kill the
sticky banner, and this opaque fill otherwise squares off the wrapper. */
border-bottom-left-radius: var(--dsl-code-block-border-radius);
@@ -84,7 +85,7 @@
/* Shiki inlines its theme background var; route it to the repo token. */
.block :where(pre.shiki) {
background: var(--dsw-alias-markdown-code-block) !important;
background: var(--dsl-code-block-background) !important;
}
.block :where(pre) code {
@@ -178,7 +178,8 @@ export function CodeBlock({ code, lang, streaming, className, lineNumbers = fals
'--dsl-code-block-line-number-width': `${Math.max(2, String(sourceLines.length).length)}ch`,
} as CSSProperties}>
<div className={css.bannerWrap}>
<div className={css.banner}>
{/* The attribute carries no style here; owner stylesheets use it to pin the banner (sidebar code preview, horizontal scroll). */}
<div className={css.banner} data-code-block-banner>
<div className={css.infostring}>{lang ?? ''}</div>
<div className={css.action}>
<button type="button" className={css.copyButton} onClick={onCopy}>
@@ -97,6 +97,24 @@ describe('Menu', () => {
expect(onClose).not.toHaveBeenCalled()
})
it('window blur closes only when focus moved into an iframe', () => {
const onClose = vi.fn()
render(
<Menu open anchor={<span>trigger</span>} items={items} onSelect={() => {}} onClose={onClose} />)
// An app or tab switch blurs the window without focusing an iframe: stays open.
fireEvent.blur(window)
expect(onClose).not.toHaveBeenCalled()
// A pointerdown inside a cross-origin iframe never reaches this document;
// the focus move it causes is the one signal left, and it closes.
const iframe = document.createElement('iframe')
document.body.appendChild(iframe)
iframe.focus()
expect(document.activeElement).toBe(iframe)
fireEvent.blur(window)
expect(onClose).toHaveBeenCalledTimes(1)
iframe.remove()
})
it('selected item shows the trailing check; align=end, side=top, and className apply', () => {
const { container } = render(
<Menu
@@ -1,7 +1,7 @@
<div class="_markdown_404681">
<div class="_block_9aea57 md-code-block">
<div class="_bannerWrap_9aea57">
<div class="_banner_9aea57">
<div class="_banner_9aea57" data-code-block-banner="true">
<div class="_infostring_9aea57">
#text "ts"
<div class="_action_9aea57">
@@ -25,7 +25,7 @@
#text " 42"
<div class="_block_9aea57 md-code-block">
<div class="_bannerWrap_9aea57">
<div class="_banner_9aea57">
<div class="_banner_9aea57" data-code-block-banner="true">
<div class="_infostring_9aea57">
<div class="_action_9aea57">
<button class="_copyButton_9aea57" type="button">
@@ -35,7 +35,7 @@
#text "no language"
<div class="_block_9aea57 md-code-block">
<div class="_bannerWrap_9aea57">
<div class="_banner_9aea57">
<div class="_banner_9aea57" data-code-block-banner="true">
<div class="_infostring_9aea57">
#text "unknown-lang"
<div class="_action_9aea57">
@@ -46,7 +46,7 @@
#text "plain fallback"
<div class="_block_9aea57 md-code-block">
<div class="_bannerWrap_9aea57">
<div class="_banner_9aea57">
<div class="_banner_9aea57" data-code-block-banner="true">
<div class="_infostring_9aea57">
#text "ts"
<div class="_action_9aea57">
@@ -68,7 +68,7 @@
<code>
<div class="_block_9aea57 md-code-block">
<div class="_bannerWrap_9aea57">
<div class="_banner_9aea57">
<div class="_banner_9aea57" data-code-block-banner="true">
<div class="_infostring_9aea57">
<div class="_action_9aea57">
<button class="_copyButton_9aea57" type="button">
@@ -1,7 +1,7 @@
<div class="_markdown_404681">
<div class="_block_9aea57 md-code-block">
<div class="_bannerWrap_9aea57">
<div class="_banner_9aea57">
<div class="_banner_9aea57" data-code-block-banner="true">
<div class="_infostring_9aea57">
#text "ts"
<div class="_action_9aea57">
@@ -24,7 +24,7 @@
#text " 42"
<div class="_block_9aea57 md-code-block">
<div class="_bannerWrap_9aea57">
<div class="_banner_9aea57">
<div class="_banner_9aea57" data-code-block-banner="true">
<div class="_infostring_9aea57">
<div class="_action_9aea57">
<button class="_copyButton_9aea57" type="button">
@@ -34,7 +34,7 @@
#text "no language"
<div class="_block_9aea57 md-code-block">
<div class="_bannerWrap_9aea57">
<div class="_banner_9aea57">
<div class="_banner_9aea57" data-code-block-banner="true">
<div class="_infostring_9aea57">
#text "unknown-lang"
<div class="_action_9aea57">
@@ -45,7 +45,7 @@
#text "plain fallback"
<div class="_block_9aea57 md-code-block">
<div class="_bannerWrap_9aea57">
<div class="_banner_9aea57">
<div class="_banner_9aea57" data-code-block-banner="true">
<div class="_infostring_9aea57">
#text "ts"
<div class="_action_9aea57">
@@ -66,7 +66,7 @@
<code>
<div class="_block_9aea57 md-code-block">
<div class="_bannerWrap_9aea57">
<div class="_banner_9aea57">
<div class="_banner_9aea57" data-code-block-banner="true">
<div class="_infostring_9aea57">
<div class="_action_9aea57">
<button class="_copyButton_9aea57" type="button">
@@ -1,7 +1,7 @@
<div class="_markdown_404681">
<div class="_block_9aea57 md-code-block">
<div class="_bannerWrap_9aea57">
<div class="_banner_9aea57">
<div class="_banner_9aea57" data-code-block-banner="true">
<div class="_infostring_9aea57">
<div class="_action_9aea57">
<button class="_copyButton_9aea57" type="button">
@@ -11,7 +11,7 @@
#text "kept blank line follows\n"
<div class="_block_9aea57 md-code-block">
<div class="_bannerWrap_9aea57">
<div class="_banner_9aea57">
<div class="_banner_9aea57" data-code-block-banner="true">
<div class="_infostring_9aea57">
#text "ts"
<div class="_action_9aea57">
@@ -1,7 +1,7 @@
<div class="_markdown_404681">
<div class="_block_9aea57 md-code-block">
<div class="_bannerWrap_9aea57">
<div class="_banner_9aea57">
<div class="_banner_9aea57" data-code-block-banner="true">
<div class="_infostring_9aea57">
<div class="_action_9aea57">
<button class="_copyButton_9aea57" type="button">
@@ -11,7 +11,7 @@
#text "kept blank line follows\n"
<div class="_block_9aea57 md-code-block">
<div class="_bannerWrap_9aea57">
<div class="_banner_9aea57">
<div class="_banner_9aea57" data-code-block-banner="true">
<div class="_infostring_9aea57">
#text "ts"
<div class="_action_9aea57">
@@ -34,7 +34,7 @@
#text "item with nested blocks"
<div class="_block_9aea57 md-code-block">
<div class="_bannerWrap_9aea57">
<div class="_banner_9aea57">
<div class="_banner_9aea57" data-code-block-banner="true">
<div class="_infostring_9aea57">
<div class="_action_9aea57">
<button class="_copyButton_9aea57" type="button">
@@ -34,7 +34,7 @@
#text "item with nested blocks"
<div class="_block_9aea57 md-code-block">
<div class="_bannerWrap_9aea57">
<div class="_banner_9aea57">
<div class="_banner_9aea57" data-code-block-banner="true">
<div class="_infostring_9aea57">
<div class="_action_9aea57">
<button class="_copyButton_9aea57" type="button">
@@ -19,7 +19,7 @@
#text "(\\frac{1}{5})"
<div class="_block_9aea57 md-code-block">
<div class="_bannerWrap_9aea57">
<div class="_banner_9aea57">
<div class="_banner_9aea57" data-code-block-banner="true">
<div class="_infostring_9aea57">
#text "math"
<div class="_action_9aea57">
@@ -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 packages/client/ui-sidebar-documentpreview/README.md
README.md: 5b258c154258cee170b757c8a8a9599b61998bb4
README.zh.md: 1c18763770df7c73b5b41fff3334c045e5204d3b
README.md: 9f664664a5d00add4d2f360c1c6b6af2e885bf72
README.zh.md: 2cdad6964ae26e8e10597a405b0925cf70d7a278
@@ -51,7 +51,7 @@ HTML runs in a Blob iframe with exactly `sandbox="allow-scripts"`, without `allo
Shared copy comes from `sidebarDocumentPreview`; each builtin renderer owns its localized labels.
Initial reads, additional pages, and HTML/PDF preparation share a loading indicator that respects reduced-motion preferences. Loaded pages stay visible while another page loads. PDF pages form one vertical, width-fitted sequence and render lazily near the viewport. Code previews show source line numbers by default without including them in copied text; plain text uses the same font size and line height as code.
Initial reads, additional pages, and HTML/PDF preparation share a loading indicator that respects reduced-motion preferences. Loaded pages stay visible while another page loads. PDF pages form one vertical, width-fitted sequence and render lazily near the viewport. Code previews show source line numbers by default without including them in copied text; plain text uses the same font size and line height as code. Code sits on the pane's own background rather than the chat card's fill, and with wrap off both axes scroll in the shared document scroller while the copy banner stays pinned to the visible width.
<a id="navigation"></a>
## Navigation
@@ -51,7 +51,7 @@ HTML 在 Blob iframe 中运行,沙箱属性严格为 `sandbox="allow-scripts"`
共享文案来自 `sidebarDocumentPreview`;各内置渲染器拥有自己的本地化标签。
首次读取、追加页及 HTML/PDF 准备共用加载指示器,并遵循减少动态效果偏好。下一页加载期间保留已显示的内容。PDF 页面组成一个纵向、适配宽度的连续序列,并在接近视口时惰性渲染。代码预览默认显示源码行号,但复制文本不包含行号;纯文本与代码使用相同字号和行高。
首次读取、追加页及 HTML/PDF 准备共用加载指示器,并遵循减少动态效果偏好。下一页加载期间保留已显示的内容。PDF 页面组成一个纵向、适配宽度的连续序列,并在接近视口时惰性渲染。代码预览默认显示源码行号,但复制文本不包含行号;纯文本与代码使用相同字号和行高。代码直接坐在分栏自身的背景上,而不是会话卡片的填充色;关闭折行时两个轴都在共享文档滚动区滚动,复制条钉在可视宽度内不随横向滚动偏移。
<a id="navigation"></a>
## 导航
@@ -116,6 +116,12 @@
padding: 0;
}
/* The code renderer's banner sizes itself to this scroller's visible width
(100cqw in CodeBody.module.css); the container makes that width queryable. */
.body:has([data-code-preview]) {
container-type: inline-size;
}
/* One page of lines; pages abut so the file reads as one. */
.page {
margin: 0;
@@ -2,12 +2,14 @@
min-height: 100%;
min-width: 0;
white-space: normal;
background: var(--dsw-alias-markdown-code-block);
}
.renderer .code {
--dsl-code-block-border-radius: 0px;
--dsl-code-block-line-white-space: pre;
/* In a sidebar pane the code sits on the pane's own background; the chat
card's gray fill stays a chat-only default. */
--dsl-code-block-background: transparent;
/* Source lines resolve their offset against the document owner's scroller. */
position: static;
margin: 0;
@@ -24,6 +26,25 @@
overflow-wrap: normal;
}
/* Unwrapped long lines widen the document owner's scroller past its viewport;
sizing the card to that full scroll width gives the sticky banner room to
ride horizontal scrolling. Wrap mode keeps the default block width:
max-content would defeat soft wrapping. */
.renderer[data-wrap='false'] {
width: max-content;
min-width: 100%;
}
/* The banner sticks to the scrollport's left edge at the owner's visible
width (cqw resolves against the owner's .body container), so the copy
control stays in view while the card scrolls horizontally. */
.renderer[data-wrap='false'] .code [data-code-block-banner] {
position: sticky;
left: 0;
box-sizing: border-box;
width: 100cqw;
}
.renderer[data-wrap='true'] .code {
--dsl-code-block-line-white-space: pre-wrap;
}
@@ -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 packages/client/ui-sidebar-right/README.md
README.md: 128f0e41a1b1a90f39f4db36f1a8ca8dfa3089f7
README.zh.md: 66f2e8d6a07635d7e94ee50395c274951911e517
README.md: 484667e6286e672b0541b0345e2ba02c21215aed
README.zh.md: 521406868e3fe62240a838cfc2ea41b2c78e3134
+3 -3
View File
@@ -63,7 +63,7 @@ One `SurfaceState` per session id — the layout, its recorded sequence, and how
Carrying the mint counter in the surface is what makes a recorded sequence replayable: operations embed the ids they create, so replaying from the same initial state reproduces the same tree. Every action records one history entry, however many operations it needed. Expanding, collapsing, and switching presentation are recorded too.
After every action the kit's settle planner keeps the surface populated: a docked pane whose last tab was moved out or floated is merged away, and an emptied root pane receives the default page. Explicit closing follows [the default-page and close rules](#the-guide). There is always at least one tab, and never an empty pane — so there is no separate "close pane" gesture.
After every action the kit's settle planner keeps the expanded surface populated: a docked pane whose last tab was moved out or floated is merged away, and an emptied root pane receives the default page. A collapsed surface holds no such backfill — a session starts collapsed and empty, and a close that collapses the column leaves it empty — the expansion that would first show an empty layout is what seeds the default page. Explicit closing follows [the default-page and close rules](#the-guide). While expanded there is always at least one tab, and never an empty pane — so there is no separate "close pane" gesture.
The docked surface's last tab carries one more rule, decided in the store's `closeTab` and mirrored to the kit through `canCloseTab`: the guide standing as the only docked tab draws no close control and no menu close item — its chip sits quiet, and with no extension item contributed a secondary press opens no menu — and a programmatic close of it records nothing; any other tab standing alone closes together with the column in one entry — the settle planner reseeds the current default page for the next expansion. Floating panels take no part in the rule: they render whether or not the column is expanded, and their tabs close freely.
@@ -84,7 +84,7 @@ Two more seats extend what is already there: `sidebar.right.tab.guide` (chain) r
<a id="ctxsidebarright"></a>
## `ctx.sidebarRight`
`openResource(address, options?)` and `openTab(kind, options?)` are the navigation controller, and every way into the column calls one of them: the conversation's file links and a tool row's line reference (`openResource(fileAddress, { params: { line } })`), the strip's add control and a guide entry box (`openTab`), a file tree's rows (`tab.actions.openResource`). A resource address is a `dsh-resource://<type>/…` URI; without `options.kind` the registry claims it (globs and `canOpen`, best band wins), with it that kind's type in force opens it. A page is named by kind; the tab is recorded under an address this package composes and nobody else spells (`contract/seed.ts`). Both run the same steps as one history entry: a tab already showing the same (kind, contentId) is focused unless `revealIfOpened: false`; otherwise a new tab lands in `options.replaceTab`'s pane and slot (closing that tab), else `options.paneId`, else the active docked pane; the panel expands, because content the user cannot see is not opened. Then the Tab domain records the navigation — `params` reach the body as `navigation.params`, with `revision` stepped — outside the layout history. `params` is typed by what is opened: a viewer for a resource type merges its entry into `SidebarRightResourceParamsMap` (the text preview declares `{ line?: number }`); a page type that takes parameters merges into `SidebarRightTabParamsMap` under its kind; values are JSON-shaped by convention, unchecked at run time. An address outside `dsh-resource://`, one no type claims, or a kind nothing registered throws: that is a wiring mistake, not a user error.
`openResource(address, options?)` and `openTab(kind, options?)` are the navigation controller, and every way into the column calls one of them: the conversation's file links and a tool row's line reference (`openResource(fileAddress, { params: { line } })`), the strip's add control and a guide entry box (`openTab`), a file tree's rows (`tab.actions.openResource`). A resource address is a `dsh-resource://<type>/…` URI; without `options.kind` the registry claims it (globs and `canOpen`, best band wins), with it that kind's type in force opens it. A page is named by kind; the tab is recorded under an address this package composes and nobody else spells (`contract/seed.ts`). Both run the same steps as one history entry: a tab already showing the same (kind, contentId) is focused unless `revealIfOpened: false` — a resource wherever it sits, a page only within the pane the open targets (the same page in another pane never draws the open away); otherwise a new tab lands in `options.replaceTab`'s pane and slot (closing that tab), else `options.paneId`, else the active docked pane; the panel expands, because content the user cannot see is not opened. Then the Tab domain records the navigation — `params` reach the body as `navigation.params`, with `revision` stepped — outside the layout history. `params` is typed by what is opened: a viewer for a resource type merges its entry into `SidebarRightResourceParamsMap` (the text preview declares `{ line?: number }`); a page type that takes parameters merges into `SidebarRightTabParamsMap` under its kind; values are JSON-shaped by convention, unchecked at run time. An address outside `dsh-resource://`, one no type claims, or a kind nothing registered throws: that is a wiring mistake, not a user error.
`close(tabId)` closes a tab; `active()` reads the active tab. `isExpanded()` and `toggleExpanded()` read and drive the column's expansion; the presentation switch is the panel's own control and not part of this face. Layout operations, for callers that arrange the column programmatically, each recorded like the gesture it stands in for: `focus(tabId)` focuses a tab and its pane; `split(paneId?)` splits a docked pane (the active one by default) under the same pane budget and room rule as the strip's control and returns the new pane's id, or `undefined` — recording nothing — when it cannot; `float(tabId, rect?)` takes a docked tab out into a panel; `dock(paneId)` returns a floating panel to the active docked pane. A tab or pane that does not exist, or already is where the call would put it, is left alone. The face exposes operations only: no layout snapshot, no operation log, no lookup by address. `_undo()` / `_redo()` step the mounted surface's history; they are `@internal` — the sequence has no user-facing control, and these exist for tests. Commands need a mounted session surface; with none, they throw rather than write into a surface nobody draws.
@@ -98,7 +98,7 @@ The Tab domain retains navigation, an abort signal, and bound actions per (Sessi
Default pages depend on the number of registered guide entries, not the number of tab types or open tabs. Exactly one entry opens its page directly (Files in the shipped composition); zero or multiple entries open the guide. Explicitly adding a guide still opens the guide, even with one entry. The sole docked guide is the only tab that cannot close; closing any other sole tab also collapses the column. The chip, context menu, and `close` API apply the same rule.
The guide tab is one entry capsule (a glyph and a title, nothing else) per `guide` entry the registered types contributed, in `order`, centred in the body; the guide has no words of its own. Picking a capsule calls `tab.actions.openTab(entry.kind, { replaceTab: true })`, so the guide gives way to the page it opened. A pane holds at most one guide tab. The strip's add control is drawn only while its pane holds none and opens one there with `openTab('guide', { paneId, revealIfOpened: false })`, so a guide in another pane does not capture the click; opening the guide into a pane that already has one focuses it instead; a guide dragged, dropped, or docked into such a pane merges into it — the arriving guide closes and the pane's own is focused; `duplicateTab` on the guide records nothing. A split or an emptied root pane uses the same default-page rule, one tab per new pane. A plain `openTab('guide')` keeps the tree-wide reveal every open has. The product allows two horizontal panes, initially equal, with divider ratios limited to 20%–80%. Insufficient width blocks a new split; with two panes already present, a body drop moves the tab between panes instead of creating a third. At the two-pane limit, split controls are hidden; closing back to one pane restores them.
The guide tab is one entry capsule (a glyph and a title, nothing else) per `guide` entry the registered types contributed, in `order`, centred in the body; the guide has no words of its own. Picking a capsule calls `tab.actions.openTab(entry.kind, { replaceTab: true })`, so the guide gives way to the page it opened. A pane holds at most one guide tab. The strip's add control is drawn only while its pane holds none and opens one there with `openTab('guide', { paneId, revealIfOpened: false })`, so a guide in another pane does not capture the click; opening the guide into a pane that already has one focuses it instead; a guide dragged, dropped, or docked into such a pane merges into it — the arriving guide closes and the pane's own is focused; `duplicateTab` on the guide records nothing. A split, an emptied root pane, and the pane a sole tab vacates by dropping on its own edge use the same default-page rule, one tab per new pane; the self-edge drop leaves the dragged tab focused. A plain `openTab('guide')` keeps the tree-wide reveal every open has. The product allows two horizontal panes, initially equal, with divider ratios limited to 20%–80%. Insufficient width blocks a new split; with two panes already present, a body drop moves the tab between panes instead of creating a third. At the two-pane limit, split controls are hidden; closing back to one pane restores them.
<a id="copy"></a>
## Copy
@@ -63,7 +63,7 @@ kind: "package-reference"
把铸造计数器带在停靠面里,是记录的序列可回放的原因:操作内嵌它们创建的 id,因此从同一初始状态回放能复现同一棵树。每个动作记录一条历史,无论它需要多少操作。展开、折叠与切换形态也都被记录。
每个动作之后,套件的 settle planner 保证停靠面有内容:最后一个 tab 被搬走或浮出的停靠格会被并掉;根格被清空时填入默认页。显式关闭遵循[默认页与关闭规则](#the-guide)。永远至少有一个 tab,永远没有空格——因此没有单独的「关闭格」手势。
每个动作之后,套件的 settle planner 保证展开的停靠面有内容:最后一个 tab 被搬走或浮出的停靠格会被并掉;根格被清空时填入默认页。折叠的停靠面没有这种回填——会话以折叠且为空的停靠面开始,收起整列的关闭也让它保持为空——首次会展示空布局的那次展开才播种默认页。显式关闭遵循[默认页与关闭规则](#the-guide)。展开期间永远至少有一个 tab,永远没有空格——因此没有单独的「关闭格」手势。
停靠面的最后一个 tab 还多带一条规则,由 store 的 `closeTab` 决定并经 `canCloseTab` 镜像给套件:作为唯一停靠 tab 的引导页不画关闭控件也不画菜单里的关闭项——它的 chip 呈安静样式,在没有扩展条目时次键按下也不弹出菜单——对它的编程式关闭什么都不记录;任何其它 tab 独自留下时,点击关闭会连同整列一起收起,记为一条历史——settle planner 为下次展开重新播种当前默认页。浮动面板不参与这条规则:它们无论列是否展开都会渲染,其 tab 照常关闭。
@@ -84,7 +84,7 @@ tab 类型分两阶段注册,随包发布的引导类型走的正是别的包
<a id="ctxsidebarright"></a>
## `ctx.sidebarRight`
`openResource(address, options?)` 与 `openTab(kind, options?)` 是导航控制器,进入该列的每条路都调用其中之一:会话区的文件链接与工具行的行号引用(`openResource(fileAddress, { params: { line } })`),tab 条的添加控件与引导入口框(`openTab`),文件树的行(`tab.actions.openResource`)。资源地址是 `dsh-resource://<type>/…` URI;不带 `options.kind` 时由注册表认领(glob 与 `canOpen`,最高档胜出),带它时由该 kind 生效的类型打开。页按 kind 命名;tab 记录在本包拼出、别处无人书写的地址下(`contract/seed.ts`)。两者以同一组步骤作为一条历史运行:已展示同一 (kind, contentId) 的 tab 被聚焦,除非 `revealIfOpened: false`;否则新 tab 落到 `options.replaceTab` 所在的格与位置(并关掉那个 tab),再退而落到 `options.paneId`,再退而落到活跃停靠格;面板展开,因为用户看不到的内容不算打开。随后 Tab 域记录这次导航——`params` 以 `navigation.params` 抵达正文,`revision` 递增——不进布局历史。`params` 按所开之物定型:某资源类型的查看器把自己那项并入 `SidebarRightResourceParamsMap`(文本预览声明 `{ line?: number }`);接受参数的页类型按其 kind 并入 `SidebarRightTabParamsMap`;值约定为 JSON 形状,运行时不校验。`dsh-resource://` 之外的地址、无人认领的地址、或未注册的 kind 都会 throw:那是接线错误,不是用户错误。
`openResource(address, options?)` 与 `openTab(kind, options?)` 是导航控制器,进入该列的每条路都调用其中之一:会话区的文件链接与工具行的行号引用(`openResource(fileAddress, { params: { line } })`),tab 条的添加控件与引导入口框(`openTab`),文件树的行(`tab.actions.openResource`)。资源地址是 `dsh-resource://<type>/…` URI;不带 `options.kind` 时由注册表认领(glob 与 `canOpen`,最高档胜出),带它时由该 kind 生效的类型打开。页按 kind 命名;tab 记录在本包拼出、别处无人书写的地址下(`contract/seed.ts`)。两者以同一组步骤作为一条历史运行:已展示同一 (kind, contentId) 的 tab 被聚焦,除非 `revealIfOpened: false`——资源不论在哪个格都算,页只在这次打开的目标格内算(另一格里的同一页不会把这次打开吸走);否则新 tab 落到 `options.replaceTab` 所在的格与位置(并关掉那个 tab),再退而落到 `options.paneId`,再退而落到活跃停靠格;面板展开,因为用户看不到的内容不算打开。随后 Tab 域记录这次导航——`params` 以 `navigation.params` 抵达正文,`revision` 递增——不进布局历史。`params` 按所开之物定型:某资源类型的查看器把自己那项并入 `SidebarRightResourceParamsMap`(文本预览声明 `{ line?: number }`);接受参数的页类型按其 kind 并入 `SidebarRightTabParamsMap`;值约定为 JSON 形状,运行时不校验。`dsh-resource://` 之外的地址、无人认领的地址、或未注册的 kind 都会 throw:那是接线错误,不是用户错误。
`close(tabId)` 关闭一个 tab;`active()` 读取活动 tab。`isExpanded()` 与 `toggleExpanded()` 读取并驱动该列的展开;形态切换是面板自己的控件,不属于这个接口。布局操作供以编程方式安排该列的调用方使用,每个都像它替代的手势一样被记录:`focus(tabId)` 聚焦一个 tab 及其格;`split(paneId?)` 在与 tab 条控件相同的格预算与空间规则下分栏一个停靠格(默认活跃格),返回新格的 id,做不到时返回 `undefined`——且不记录任何东西;`float(tabId, rect?)` 把停靠 tab 浮出为浮窗;`dock(paneId)` 把浮窗放回活跃停靠格。不存在的 tab 或格、或已处于调用目标状态的,都原样不动。该接口只暴露操作:没有布局快照、没有操作日志、没有按地址查找。`_undo()` / `_redo()` 步进已挂载停靠面的历史;它们是 `@internal`——序列没有面向用户的控件,这两个只为测试存在。命令需要一个已挂载的会话停靠面;没有时它们 throw,而不是写进一个没人绘制的面里。
@@ -98,7 +98,7 @@ Tab域按(Session,Tab id)保留导航、中止信号与绑定动作;私
默认页取决于已注册的引导入口数,不取决于 tab 类型数或已打开的 tab 数。恰好一个入口时直接打开对应页面(随包组合中为 Files);没有入口或有多个入口时打开引导页。即使只有一个入口,显式添加引导页仍会打开引导页。只有作为唯一停靠 tab 的引导页不可关闭;关闭其它任何唯一 tab 时会同时收起整列。chip、上下文菜单与 `close` API 使用同一规则。
引导 tab 是各已注册类型贡献的每个 `guide` 条目一个入口胶囊(一个图标加一个标题,别无其他),按 `order` 排列并在正文中居中;引导页自己没有文字。选中一个胶囊会调用 `tab.actions.openTab(entry.kind, { replaceTab: true })`,于是引导页让位给它打开的页。一个格最多持有一个引导 tab。tab 条的添加控件只在该格没有引导 tab 时绘制,并以 `openTab('guide', { paneId, revealIfOpened: false })` 在该格打开一个,这样别的格里的引导页不会截走这次点击;把引导页开进已有引导页的格则改为聚焦它;把引导页拖入、放入或收回到这样的格会合并进去——来者关闭,该格自己的被聚焦;对引导页 `duplicateTab` 不记录任何东西。分栏或被清空的根格使用相同的默认页规则,每个新格一个 tab。普通的 `openTab('guide')` 保留每次打开都有的整树聚焦。产品最多保留左右两格,默认均分,分隔条限定20%~80%。宽度不足以容纳两格时不允许新分栏;已有两格时,正文拖放用于跨格移动,不再创建第三格。达到两格上限时隐藏分栏控件;关闭回单格后恢复。
引导 tab 是各已注册类型贡献的每个 `guide` 条目一个入口胶囊(一个图标加一个标题,别无其他),按 `order` 排列并在正文中居中;引导页自己没有文字。选中一个胶囊会调用 `tab.actions.openTab(entry.kind, { replaceTab: true })`,于是引导页让位给它打开的页。一个格最多持有一个引导 tab。tab 条的添加控件只在该格没有引导 tab 时绘制,并以 `openTab('guide', { paneId, revealIfOpened: false })` 在该格打开一个,这样别的格里的引导页不会截走这次点击;把引导页开进已有引导页的格则改为聚焦它;把引导页拖入、放入或收回到这样的格会合并进去——来者关闭,该格自己的被聚焦;对引导页 `duplicateTab` 不记录任何东西。分栏、被清空的根格、以及唯一 tab 拖到本格边缘分屏后腾出的格使用相同的默认页规则,每个新格一个 tab;这种本格边缘拖放让被拖的 tab 保持聚焦。普通的 `openTab('guide')` 保留每次打开都有的整树聚焦。产品最多保留左右两格,默认均分,分隔条限定20%~80%。宽度不足以容纳两格时不允许新分栏;已有两格时,正文拖放用于跨格移动,不再创建第三格。达到两格上限时隐藏分栏控件;关闭回单格后恢复。
<a id="copy"></a>
## 文案
@@ -10,26 +10,28 @@
* edit a layout in place, which is what keeps the kit testable without a store
* and keeps snapshot identity honest.
*
* The settle step is this product's rule, not the kit's: a docked pane never
* stays empty, and the last pane reseeds the default tab, so there is always at
* least one tab to look at.
* The settle step is this product's rule, not the kit's: an intent never leaves
* an expanded column with an empty pane — emptied side panes merge away, and an
* empty root pane seeds the default page. A collapsed column may stand empty;
* the seed waits for the expansion that would otherwise show nothing.
*
* A focus that changes nothing — a tab already active in its already-active
* pane, a pane already active — plans nothing and records nothing, whoever
* asks: the kit's chip click and `ctx.sidebarRight.focus` alike.
*
* So is the guide's uniqueness: a pane holds at most one guide tab. Opening the
* guide into a pane that has one focuses it, and a guide dragged, dropped, or
* docked into such a pane merges into it — the arriving guide closes and the
* pane's own is focused. The kit plans none of this; it is decided here before
* its planners run.
* So is page uniqueness: a pane holds at most one tab of each page kind (a tab
* whose content is the kind's own page address — the guide, the explorer).
* Opening a page into a pane that shows it focuses that tab, in that pane and
* nowhere else, and a page dragged, dropped, or docked into such a pane merges
* into the pane's own — the arriving tab closes and the pane's own is focused.
* The kit plans none of this; it is decided here before its planners run.
*/
import { defineStore, type EngineStoreHandle } from '@deepseek-ai/dsh-client-store'
import type {
DockMode, DockZone, FloatRect, History, LayoutOp, LayoutState, Mint, PaneId, SplitId, TabId, TabRecord,
} from '@deepseek-ai/dsh-client-ui-dockkit'
import {
activeDockPaneId, createInitialState, dockPaneIds, EMPTY_HISTORY, findPaneContentTab, findTabPane,
activeDockPaneId, createInitialState, dockPaneIds, EMPTY_HISTORY, findPaneContentTab, findTabPane, getPane,
planDropTab, planDuplicateTab, planFloatTab, planOpenContent, planPlaceTab, planResizeSplit, planSetExpanded,
planSetMode, planSettle, planSplitPane, planUnfloatPane, record, replay, stepBack, stepForward,
} from '@deepseek-ai/dsh-client-ui-dockkit'
@@ -103,27 +105,33 @@ function counting(from: number): { mint: Mint; used: () => number } {
}
/**
* The surface a session starts with: collapsed, one pane, one default tab.
* @param seed - the registered default page at mint time.
* The surface a session starts with: collapsed, one pane, no tabs. The default
* page is not seeded here — the settle rule seeds it when the column first
* expands still empty, so a collapsed column never holds a page nobody asked
* for, and an open into a fresh surface shows only what it opened.
* @returns the initial surface.
*/
export function createSurface(seed: () => SidebarRightSeed): SurfaceState {
export function createSurface(): SurfaceState {
const counter = counting(0)
return {
layout: createInitialState({ next: counter.mint }, id => seedRecord(id, seed)),
layout: createInitialState({ next: counter.mint }),
history: EMPTY_HISTORY,
minted: counter.used(),
}
}
/** The guide tab a pane holds, if any. */
function paneGuide(state: LayoutState, paneId: PaneId): TabId | undefined {
return findPaneContentTab(state, paneId, pageAddress(GUIDE_KIND), GUIDE_KIND)
/** The tab showing `kind`'s page in a pane, if any. */
function panePage(state: LayoutState, paneId: PaneId, kind: string): TabId | undefined {
return findPaneContentTab(state, paneId, pageAddress(kind), kind)
}
/** Whether a tab is the guide, which a pane holds at most once and which is therefore never copied. */
function isGuide(state: LayoutState, tabId: TabId): boolean {
return state.tabs[tabId]?.kind === GUIDE_KIND
/**
* The kind whose page a tab shows, or `undefined` for a resource tab. A pane
* holds at most one page of each kind, so a page tab is never copied.
*/
function pageKind(state: LayoutState, tabId: TabId): string | undefined {
const tab = state.tabs[tabId]
return tab !== undefined && tab.contentId === pageAddress(tab.kind) ? tab.kind : undefined
}
/**
@@ -151,8 +159,8 @@ function planFocusPane(state: LayoutState, paneId: PaneId): readonly LayoutOp[]
}
/**
* Plan a tab's arrival in a docked pane: a guide arriving where one already is
* merges into it, anything else plans as the kit does.
* Plan a tab's arrival in a docked pane: a page arriving where its kind's page
* already shows merges into it, anything else plans as the kit does.
* @param state - current layout.
* @param tabId - the arriving tab.
* @param toPaneId - the pane it arrives in.
@@ -160,8 +168,9 @@ function planFocusPane(state: LayoutState, paneId: PaneId): readonly LayoutOp[]
* @returns the operations.
*/
function arriving(state: LayoutState, tabId: TabId, toPaneId: PaneId, otherwise: () => readonly LayoutOp[]): readonly LayoutOp[] {
if (!isGuide(state, tabId)) return otherwise()
const existing = paneGuide(state, toPaneId)
const kind = pageKind(state, tabId)
if (kind === undefined) return otherwise()
const existing = panePage(state, toPaneId, kind)
if (existing === undefined || existing === tabId) return otherwise()
return [{ type: 'closeTab', tabId }, { type: 'focusTab', tabId: existing }]
}
@@ -180,9 +189,11 @@ function advance(surface: SurfaceState, plan: SurfacePlan, seed: () => SidebarRi
const planned = plan(surface.layout, counter.mint, makeTab)
if (planned.length === 0) return surface
// The settle planner reads the state the intent produces, so it is applied
// to a scratch copy first; the record then applies both parts once.
// to a scratch copy first; the record then applies both parts once. The
// seed factory is withheld while the intent leaves the column collapsed:
// an empty collapsed column stays empty until the expansion that shows it.
const after = replay(surface.layout, planned)
const settled = planSettle(after, counter.mint, makeTab)
const settled = planSettle(after, counter.mint, after.expanded ? makeTab : undefined)
const stepped = record(surface.history, surface.layout, [...planned, ...settled])
return { layout: stepped.state, history: stepped.history, minted: counter.used() }
}
@@ -196,11 +207,10 @@ function advance(surface: SurfaceState, plan: SurfacePlan, seed: () => SidebarRi
function seat(
state: SidebarRightState,
sessionId: string,
seed: () => SidebarRightSeed,
next: (surface: SurfaceState) => SurfaceState,
): Record<string, SurfaceState> {
const existing = state.bySession[sessionId]
const updated = next(existing ?? createSurface(seed))
const updated = next(existing ?? createSurface())
return updated === existing ? state.bySession : { ...state.bySession, [sessionId]: updated }
}
@@ -245,9 +255,9 @@ function stepped(surface: SurfaceState, step: HistoryStepper): SurfaceState {
/**
* Create the Sidebar store handle.
*
* The default page arrives as a thunk: a pane is seeded
* whenever one is created, which can be long after the store was built and in a
* language the user has since changed to.
* The default page arrives as a thunk: a pane is seeded when a split or an
* expansion of an empty column needs one, which can be long after the store was
* built and in a language the user has since changed to.
* @param seed - the registered default page, read at each mint.
* @returns the handle (spec, type, identity, and factory in one).
*/
@@ -258,23 +268,23 @@ export function createSidebarRightStore(
init: (): SidebarRightState => ({ bySession: {} }),
actions: {
// Materialize a session's surface without changing it, so the first read
// after a session switch sees the collapsed default rather than nothing.
open: (d, sessionId: string) => { d.bySession = seat(d, sessionId, seed, surface => surface) },
// after a session switch sees the collapsed empty column rather than nothing.
open: (d, sessionId: string) => { d.bySession = seat(d, sessionId, surface => surface) },
setExpanded: (d, sessionId: string, expanded: boolean) => {
d.bySession = seat(d, sessionId, seed, s => advance(s, state => planSetExpanded(state, expanded), seed))
d.bySession = seat(d, sessionId, s => advance(s, state => planSetExpanded(state, expanded), seed))
},
toggleExpanded: (d, sessionId: string) => {
d.bySession = seat(d, sessionId, seed, s => advance(s, state => planSetExpanded(state, !state.expanded), seed))
d.bySession = seat(d, sessionId, s => advance(s, state => planSetExpanded(state, !state.expanded), seed))
},
// Switching presentation is recorded like any other change, so stepping
// back through the sequence puts the surface back the way it was drawn.
setMode: (d, sessionId: string, mode: DockMode) => {
d.bySession = seat(d, sessionId, seed, s => advance(s, state => planSetMode(state, mode), seed))
d.bySession = seat(d, sessionId, s => advance(s, state => planSetMode(state, mode), seed))
},
// `settled` reports the pane the split created, synchronously, because
// actions return nothing; it is not called when nothing was split.
splitPane: (d, sessionId: string, paneId?: PaneId, settled?: (paneId: PaneId) => void) => {
d.bySession = seat(d, sessionId, seed, (s) => {
d.bySession = seat(d, sessionId, (s) => {
const next = advance(s, (state, mint, makeTab) => dockPaneIds(state).length >= 2
? []
: planSplitPane(state, mint, paneId, makeTab), seed)
@@ -292,7 +302,7 @@ export function createSidebarRightStore(
// closing the tab it replaces. `settled` reports the tab the planner
// landed on, synchronously, because actions return nothing.
openContent: (d, sessionId: string, intent, settled) => {
d.bySession = seat(d, sessionId, seed, s => advance(s, (state, mint) => {
d.bySession = seat(d, sessionId, s => advance(s, (state, mint) => {
const { kind, contentId, title, replaceTab: replace } = intent
const ops: LayoutOp[] = [...planSetExpanded(state, true)]
// A replaced tab lends its pane and slot; one that floats cannot (a
@@ -301,9 +311,12 @@ export function createSidebarRightStore(
const lent = replace !== undefined && replaced !== undefined && replaced.host === 'dock' ? replaced : undefined
const paneId = lent?.id ?? intent.paneId
const index = lent === undefined || replace === undefined ? undefined : lent.tabs.indexOf(replace)
// The guide is unique per pane: the pane it would land in may already
// hold one, which is then the tab this open settles on.
const held = kind === GUIDE_KIND ? paneGuide(state, paneId ?? activeDockPaneId(state)) : undefined
// A page is unique per pane, not per surface: the pane it would land
// in may already show it, which is then the tab this open settles on.
// The same page in another pane never draws the open away — the kit's
// cross-pane reveal is for resources only.
const page = contentId === pageAddress(kind)
const held = page ? panePage(state, paneId ?? activeDockPaneId(state), kind) : undefined
const planned = held !== undefined
? { ops: [{ type: 'focusTab' as const, tabId: held }], tabId: held }
: planOpenContent(state, mint, {
@@ -312,7 +325,9 @@ export function createSidebarRightStore(
title,
...paneId === undefined ? {} : { paneId },
...index === undefined ? {} : { index },
...intent.revealIfOpened === undefined ? {} : { revealIfOpened: intent.revealIfOpened },
...page
? { revealIfOpened: false }
: intent.revealIfOpened === undefined ? {} : { revealIfOpened: intent.revealIfOpened },
})
ops.push(...planned.ops)
if (replace !== undefined && replace !== planned.tabId) ops.push({ type: 'closeTab', tabId: replace })
@@ -320,20 +335,20 @@ export function createSidebarRightStore(
return ops
}, seed))
},
// The guide is never copied: the copy would sit beside it in the same pane.
// A page is never copied: the copy would sit beside it in the same pane.
duplicateTab: (d, sessionId: string, tabId: TabId) => {
d.bySession = seat(d, sessionId, seed, s =>
advance(s, (state, mint) => isGuide(state, tabId) ? [] : planDuplicateTab(state, mint, tabId).ops, seed))
d.bySession = seat(d, sessionId, s =>
advance(s, (state, mint) => pageKind(state, tabId) !== undefined ? [] : planDuplicateTab(state, mint, tabId).ops, seed))
},
// A tab already gone — closed twice by a racing callback and the user — is
// left alone rather than handed to the kit, which refuses an unknown tab.
// The docked surface's last tab follows the close rule, whoever asks: the
// guide stays (the kit hides its close routes through `canCloseTab`, and
// this plan refuses the programmatic path), and anything else closes
// together with the column — the settle planner reseeds the current
// default page for the next expansion.
// together with the column, which stays empty until an expansion seeds
// the then-current default page.
closeTab: (d, sessionId: string, tabId: TabId) => {
d.bySession = seat(d, sessionId, seed, s => advance(s, (state) => {
d.bySession = seat(d, sessionId, s => advance(s, (state) => {
if (!canCloseTab(s, tabId)) return []
if (!soleDockedTab(state, tabId)) return [{ type: 'closeTab', tabId }]
// The collapse also leaves fullscreen: the reopened column shows only
@@ -342,52 +357,54 @@ export function createSidebarRightStore(
}, seed))
},
focusTab: (d, sessionId: string, tabId: TabId) => {
d.bySession = seat(d, sessionId, seed, s => advance(s, state => planFocusTab(state, tabId), seed))
d.bySession = seat(d, sessionId, s => advance(s, state => planFocusTab(state, tabId), seed))
},
focusPane: (d, sessionId: string, paneId: PaneId) => {
d.bySession = seat(d, sessionId, seed, s => advance(s, state => planFocusPane(state, paneId), seed))
d.bySession = seat(d, sessionId, s => advance(s, state => planFocusPane(state, paneId), seed))
},
placeTab: (d, sessionId: string, tabId: TabId, toPaneId: PaneId, index: number) => {
d.bySession = seat(d, sessionId, seed, s =>
d.bySession = seat(d, sessionId, s =>
advance(s, state => arriving(state, tabId, toPaneId, () => planPlaceTab(state, tabId, toPaneId, index)), seed))
},
// Only a centre release lands in the target pane; an edge release makes a
// new pane, where nothing can already be.
// new pane, where nothing can already be — and when the release drags a
// pane's only tab to that pane's own edge, the default page backfills it.
dropTab: (d, sessionId: string, tabId: TabId, paneId: PaneId, zone: DockZone) => {
d.bySession = seat(d, sessionId, seed, s => advance(s, (state, mint) => {
d.bySession = seat(d, sessionId, s => advance(s, (state, mint, makeTab) => {
if (zone === 'top' || zone === 'bottom') return []
if (zone !== 'center' && dockPaneIds(state).length >= 2) return []
const plan = (): readonly LayoutOp[] => planDropTab(state, mint, tabId, paneId, zone)
const plan = (): readonly LayoutOp[] => planDropTab(state, mint, tabId, paneId, zone, makeTab)
return zone === 'center' ? arriving(state, tabId, paneId, plan) : plan()
}, seed))
},
floatTab: (d, sessionId: string, tabId: TabId, rect?: FloatRect) => {
d.bySession = seat(d, sessionId, seed, s =>
d.bySession = seat(d, sessionId, s =>
advance(s, (state, mint) => planFloatTab(state, mint, tabId, rect).ops, seed))
},
// A floating pane holds one tab; docking it back lands in the active
// docked pane, and only a guide is subject to the merge rule there.
// docked pane, and only a page is subject to the merge rule there.
unfloatPane: (d, sessionId: string, paneId: PaneId) => {
d.bySession = seat(d, sessionId, seed, s => advance(s, (state) => {
const guide = paneGuide(state, paneId)
d.bySession = seat(d, sessionId, s => advance(s, (state) => {
const floated = getPane(state, paneId).tabs[0]
const plan = (): readonly LayoutOp[] => planUnfloatPane(state, paneId)
return guide === undefined ? plan() : arriving(state, guide, activeDockPaneId(state), plan)
/* v8 ignore next -- a floating pane holds exactly one tab. */
return floated === undefined ? plan() : arriving(state, floated, activeDockPaneId(state), plan)
}, seed))
},
moveFloat: (d, sessionId: string, paneId: PaneId, x: number, y: number) => {
d.bySession = seat(d, sessionId, seed, s => advance(s, () => [{ type: 'moveFloat', paneId, x, y }], seed))
d.bySession = seat(d, sessionId, s => advance(s, () => [{ type: 'moveFloat', paneId, x, y }], seed))
},
resizeFloat: (d, sessionId: string, paneId: PaneId, rect: FloatRect) => {
d.bySession = seat(d, sessionId, seed, s => advance(s, () => [{ type: 'resizeFloat', paneId, rect }], seed))
d.bySession = seat(d, sessionId, s => advance(s, () => [{ type: 'resizeFloat', paneId, rect }], seed))
},
resizeSplit: (d, sessionId: string, splitId: SplitId, sizes: readonly number[]) => {
d.bySession = seat(d, sessionId, seed, s => advance(s, () => planResizeSplit(splitId, sizes, 0.2), seed))
d.bySession = seat(d, sessionId, s => advance(s, () => planResizeSplit(splitId, sizes, 0.2), seed))
},
undo: (d, sessionId: string) => {
d.bySession = seat(d, sessionId, seed, s => stepped(s, stepBack))
d.bySession = seat(d, sessionId, s => stepped(s, stepBack))
},
redo: (d, sessionId: string) => {
d.bySession = seat(d, sessionId, seed, s => stepped(s, stepForward))
d.bySession = seat(d, sessionId, s => stepped(s, stepForward))
},
},
})
@@ -156,7 +156,8 @@ describe('ui-sidebar-right apply', () => {
expect(seat('conversation.session.header.corner').store).toBe(handle)
const instance = handle.create(SESSION)
instance.actions.open(SESSION)
// A second tab beside the guide makes the guide closable.
// The first expansion seeds the guide; a second tab beside it makes it closable.
instance.actions.setExpanded(SESSION, true)
instance.actions.openContent(SESSION, { kind: 'text', contentId: 'dsh-resource://file/session/s/a.txt', title: 'a' }, () => {})
const guide = Object.values(instance.getSnapshot().bySession[SESSION]?.layout.tabs ?? {}).find(tab => tab.kind === 'guide')
if (guide === undefined) throw new Error('expected the seeded guide')
@@ -148,10 +148,12 @@ describe('RightbarSeat presentation', () => {
expect(h.view.container.querySelector(`[data-dockkit-tab-close="${initial.id}"]`)).not.toBeNull()
act(() => { h.controller.close(initial.id) })
expect(h.layout().expanded).toBe(false)
// The close leaves the layout empty; the next expansion reseeds.
expect(Object.keys(h.layout().tabs)).toHaveLength(0)
act(() => { h.controller.toggleExpanded() })
const reseeded = Object.values(h.layout().tabs)[0]!
expect(reseeded.kind).toBe('text')
expect(reseeded.id).not.toBe(initial.id)
act(() => { h.controller.toggleExpanded() })
fireEvent.click(element(h.view.container, '[data-dockkit-add-tab]'))
const guide = Object.values(h.layout().tabs).find(tab => tab.kind === 'guide')!
expect(h.view.container.querySelector('[data-dockkit-add-tab]')).toBeNull()
@@ -451,6 +453,8 @@ describe('slot-owned useTabInfo', () => {
it('updates guide replacements through the same hook and guide boxes through framework injection', async () => {
const h = await mountSeat()
// The first expansion seeds the guide the replacement renders over.
act(() => { h.controller.toggleExpanded() })
let captured: SidebarRightTabInfo | undefined
await act(async () => {
h.runtime.ctx.sidebarRightTabs.register({
@@ -511,6 +515,8 @@ describe('slot-owned useTabInfo', () => {
it('hides split controls at two panes and adds a guide only to a pane without one', async () => {
const h = await mountSeat()
// Expanding first seeds the left pane's guide; only the right pane will lack one.
act(() => { h.controller.toggleExpanded() })
h.open()
const splitButtons = () => h.view.container.querySelectorAll<HTMLButtonElement>('[data-dockkit-split-button]')
expect(splitButtons()).toHaveLength(1)
@@ -74,7 +74,9 @@ function harness() {
return found.id
}
const entries = (): number => instance.getSnapshot().bySession[SESSION]?.history.entries.length ?? 0
return { controller, adopt, tabs, instance, pin, publish, titles, tabOf, layout, entries, room }
// A surface starts empty; expanding seeds the default guide ('seed').
const expand = (): void => { instance.actions.setExpanded(SESSION, true) }
return { controller, adopt, tabs, instance, pin, publish, titles, tabOf, layout, entries, room, expand }
}
describe('SidebarRightController — opening', () => {
@@ -137,7 +139,8 @@ describe('SidebarRightController — opening', () => {
})
it('lands a new tab in the pane the caller names', () => {
const { controller, instance, publish, layout, tabOf } = harness()
const { controller, instance, publish, layout, tabOf, expand } = harness()
expand()
publish()
instance.actions.splitPane(SESSION)
publish()
@@ -158,8 +161,8 @@ describe('SidebarRightController — opening', () => {
const before = entries()
controller.openResource('dsh-resource://file/session/s-test/c.txt', { replaceTab: a })
const pane = findTabPane(layout(), tabOf('c.txt'))
// The seeded guide sits at 0; a took 1; c took a's slot.
expect(pane.tabs.indexOf(tabOf('c.txt'))).toBe(1)
// a took slot 0 of the lazily-seeded (still empty) surface; c took a's slot.
expect(pane.tabs.indexOf(tabOf('c.txt'))).toBe(0)
expect(layout().tabs[a]).toBeUndefined()
expect(entries()).toBe(before + 1)
publish()
@@ -183,8 +186,9 @@ describe('SidebarRightController — opening', () => {
})
it('refuses to copy the guide, and records nothing for the attempt', () => {
const { controller, instance, publish, layout, tabOf, entries } = harness()
const { controller, instance, publish, layout, tabOf, entries, expand } = harness()
instance.actions.open(SESSION)
expand()
publish()
controller.openResource('dsh-resource://file/session/s-test/a.txt')
publish()
@@ -198,8 +202,9 @@ describe('SidebarRightController — opening', () => {
})
it('reveals an open guide for a plain open, as for any address', () => {
const { controller, instance, publish, layout, tabOf } = harness()
const { controller, instance, publish, layout, tabOf, expand } = harness()
instance.actions.open(SESSION)
expand()
publish()
controller.openResource('dsh-resource://file/session/s-test/a.txt')
publish()
@@ -209,8 +214,9 @@ describe('SidebarRightController — opening', () => {
})
it('keeps the guide to one per pane: opening it into a pane that holds one settles on that one', () => {
const { controller, instance, publish, layout, tabOf } = harness()
const { controller, instance, publish, layout, tabOf, expand } = harness()
instance.actions.open(SESSION)
expand()
publish()
controller.openResource('dsh-resource://file/session/s-test/a.txt')
publish()
@@ -230,8 +236,9 @@ describe('SidebarRightController — opening', () => {
})
it('merges a guide placed, dropped, or docked into a pane that already holds one', () => {
const { instance, publish, layout } = harness()
const { instance, publish, layout, expand } = harness()
instance.actions.open(SESSION)
expand()
publish()
const guides = (): TabId[] => Object.values(layout().tabs).filter(tab => tab.contentId === 'sidebar://guide').map(tab => tab.id)
// Place: the split's guide dragged into the first pane's strip.
@@ -318,7 +325,7 @@ describe('SidebarRightController — the two opens', () => {
publish()
controller.openResource('dsh-resource://file/session/s-test/a.txt')
publish()
// The seeded guide is the page in force: opening it by kind reveals that tab.
// No pane holds the page yet: opening it by kind opens its tab.
controller.openTab('guide')
publish()
expect(Object.values(layout().tabs).filter(tab => tab.contentId === 'sidebar://guide')).toHaveLength(1)
@@ -329,8 +336,9 @@ describe('SidebarRightController — the two opens', () => {
})
it('replaceTab opens in the named tab\'s place and closes it, as one entry', () => {
const { controller, instance, publish, layout, tabOf, entries } = harness()
const { controller, instance, publish, layout, tabOf, entries, expand } = harness()
instance.actions.open(SESSION)
expand()
publish()
controller.openResource('dsh-resource://file/session/s-test/a.txt')
publish()
@@ -343,7 +351,8 @@ describe('SidebarRightController — the two opens', () => {
describe('SidebarRightController — layout operations', () => {
it('focuses an existing tab and its pane, and leaves a missing tab alone', () => {
const { controller, publish, layout, tabOf, entries } = harness()
const { controller, publish, layout, tabOf, entries, expand } = harness()
expand()
publish()
controller.openResource('dsh-resource://file/session/s-test/a.txt')
publish()
@@ -363,8 +372,9 @@ describe('SidebarRightController — layout operations', () => {
})
it('splits the active docked pane and names the new one, or nothing when the budget or the room rule says no', () => {
const { controller, instance, publish, layout, entries, room } = harness()
const { controller, instance, publish, layout, entries, room, expand } = harness()
instance.actions.open(SESSION)
expand()
publish()
const created = controller.split()
expect(created).toBeDefined()
@@ -434,8 +444,9 @@ describe('SidebarRightController — a tab\'s own actions', () => {
const B_TXT = 'dsh-resource://file/session/s-test/b.txt'
it('land in the session the tab is in through its own adopted store, after another session\'s seat took over', () => {
const { controller, adopt, instance, publish, layout } = harness()
const { controller, adopt, instance, publish, layout, expand } = harness()
const releaseOwn = adopt(SESSION, instance)
expand()
publish()
controller.openResource(A_TXT)
const own = Object.values(layout().tabs).find(tab => tab.title === 'a.txt')
@@ -448,6 +459,7 @@ describe('SidebarRightController — a tab\'s own actions', () => {
const other = createSidebarRightStore(() => ({ kind: 'guide', title: 'seed' })).create(OTHER)
const releaseOther = adopt(OTHER, other)
other.actions.open(OTHER)
other.actions.setExpanded(OTHER, true)
const otherSurface = other.getSnapshot().bySession[OTHER]
if (otherSurface === undefined) throw new Error('expected the other surface')
controller.bind({ sessionId: OTHER, actions: other.actions, surfaces: other.getSnapshot().bySession, canSplitPane: () => true })
@@ -497,7 +509,7 @@ describe('SidebarRightController — a tab\'s own actions', () => {
// session leaves this session's occurrences alone.
instance.actions.open(OTHER)
expect(pin).not.toHaveBeenCalled()
instance.actions.open(SESSION)
instance.actions.setExpanded(SESSION, true)
expect(pin).toHaveBeenCalledWith('sidebar://guide', expect.any(AbortSignal))
instance.actions.openContent(SESSION, { kind: 'text', contentId: A_TXT, title: 'a' }, () => {})
const surface = instance.getSnapshot().bySession[SESSION]
@@ -2,10 +2,11 @@
* The store shell over the docking kit: every action is one settled intent
* recorded as one history entry, or no entry when it changes nothing.
*
* The opens, the guide's uniqueness, and the merge rule are asserted through
* The opens, page uniqueness, and the merge rule are asserted through
* `ctx.sidebarRight` in service.client.spec.ts; here are the intents only the
* kit's gestures reach — floating-panel moves and resizes, divider drags — and
* the sequence's ends.
* the sequence's ends, plus the seed's timing: a surface starts empty, and the
* default page arrives with the expansion that would otherwise show nothing.
*/
import { describe, expect, it, vi } from 'vitest'
import type { LayoutState, PaneId, TabId } from '@deepseek-ai/dsh-client-ui-dockkit'
@@ -25,16 +26,19 @@ function harness() {
const layout = (): LayoutState => surface().layout
const entries = (): number => surface().history.entries.length
const guide = (): TabId => {
const seeded = Object.values(layout().tabs)[0]
const seeded = Object.values(layout().tabs).find(tab => tab.kind === 'guide')
if (seeded === undefined) throw new Error('expected the seeded guide')
return seeded.id
}
return { instance, actions: instance.actions, surface, layout, entries, guide }
// The surface starts empty; expanding seeds the default guide.
const expand = (): void => { instance.actions.setExpanded(SESSION, true) }
return { instance, actions: instance.actions, surface, layout, entries, guide, expand }
}
describe('createSidebarRightStore — the sequence', () => {
it.each(['left', 'right'] as const)('splits one pane at its %s edge and records the tab move as one reversible intent', (zone) => {
const { actions, layout, entries, guide } = harness()
const { actions, layout, entries, guide, expand } = harness()
expand()
actions.openContent(SESSION, {
kind: 'text', contentId: 'dsh-resource://file/session/s-test/a.txt', title: 'a',
}, () => {})
@@ -64,8 +68,40 @@ describe('createSidebarRightStore — the sequence', () => {
expect(layout()).toEqual(dropped)
})
it('splits on a sole tab\'s own edge drop, backfilling the vacated pane with the default page', () => {
const { actions, layout, entries, guide, expand } = harness()
expand()
const dragged = guide()
const home = layout().activePaneId
const recorded = entries()
// Released on its own centre, a sole tab still changes nothing.
actions.dropTab(SESSION, dragged, home, 'center')
expect(entries()).toBe(recorded)
actions.dropTab(SESSION, dragged, home, 'right')
const split = getSplit(layout(), layout().rootId)
const destination = findTabPane(layout(), dragged)
expect(split.children).toEqual([home, destination.id])
expect(destination.tabs).toEqual([dragged])
// The vacated pane holds a fresh default page rather than merging away.
const backfill = getPane(layout(), home).tabs
expect(backfill).toHaveLength(1)
expect(backfill[0]).not.toBe(dragged)
expect(layout().tabs[backfill[0]!]?.kind).toBe('guide')
// The dragged tab keeps focus, as any other drop leaves it.
expect(layout().activePaneId).toBe(destination.id)
expect(destination.activeTabId).toBe(dragged)
expect(entries()).toBe(recorded + 1)
actions.undo(SESSION)
expect(getPane(layout(), home).tabs).toEqual([dragged])
expect(Object.values(layout().tabs)).toHaveLength(1)
})
it('allows two docked panes and rejects further splits without recording', () => {
const { actions, layout, entries } = harness()
const { actions, layout, entries, expand } = harness()
expand()
actions.splitPane(SESSION)
expect(dockPaneIds(layout())).toHaveLength(2)
const before = entries()
@@ -77,7 +113,8 @@ describe('createSidebarRightStore — the sequence', () => {
})
it('rejects vertical drops and keeps split ratios within twenty to eighty percent', () => {
const { actions, layout, entries, guide } = harness()
const { actions, layout, entries, guide, expand } = harness()
expand()
actions.openContent(SESSION, { kind: 'text', contentId: 'file:a', title: 'a' }, () => {})
const before = entries()
actions.dropTab(SESSION, guide(), layout().activePaneId, 'top')
@@ -94,18 +131,25 @@ describe('createSidebarRightStore — the sequence', () => {
expect(entries()).toBe(splitCount)
})
it('materializes a session on open without recording, then records each change of expansion once', () => {
it('materializes a session empty on open without recording, then seeds the guide with the first expansion', () => {
const { actions, layout, entries } = harness()
expect(layout().expanded).toBe(false)
expect(Object.values(layout().tabs)).toHaveLength(0)
expect(entries()).toBe(0)
// The expansion and the seed it makes visible are one recorded entry.
actions.setExpanded(SESSION, true)
expect(layout().expanded).toBe(true)
expect(Object.values(layout().tabs)).toHaveLength(1)
expect(entries()).toBe(1)
// Already expanded: nothing to plan, nothing recorded, the surface kept by reference.
const before = layout()
actions.setExpanded(SESSION, true)
expect(layout()).toBe(before)
expect(entries()).toBe(1)
// Collapsing keeps the seeded guide; the next expansion has nothing to seed.
actions.setExpanded(SESSION, false)
actions.setExpanded(SESSION, true)
expect(Object.values(layout().tabs)).toHaveLength(1)
})
it('closes a tab once: a second close of a record already gone records nothing and throws nothing', () => {
@@ -148,16 +192,16 @@ describe('createSidebarRightStore — the last docked tab', () => {
expect(layout().tabs[guide()]).toBeDefined()
})
it('closes the last non-guide tab together with the column, reseeding the guide, as one reversible entry', () => {
const { actions, layout, entries } = harness()
it('closes the last non-guide tab together with the column, leaving it empty until the next expansion seeds', () => {
const { actions, layout, entries, expand, guide } = harness()
expand()
actions.openContent(SESSION, { kind: 'text', contentId: 'dsh-resource://file/session/s-test/a.txt', title: 'a' }, () => {})
const text = Object.values(layout().tabs).find(tab => tab.kind === 'text')
const seeded = Object.values(layout().tabs).find(tab => tab.kind !== 'text')
if (text === undefined || seeded === undefined) throw new Error('expected the text tab beside the guide')
if (text === undefined) throw new Error('expected the text tab beside the guide')
// Two tabs: closing the guide is an ordinary close, and the column stays open.
actions.closeTab(SESSION, seeded.id)
actions.closeTab(SESSION, guide())
expect(layout().expanded).toBe(true)
expect(layout().tabs[seeded.id]).toBeUndefined()
expect(Object.values(layout().tabs)).toEqual([text])
const before = layout()
const recorded = entries()
// Fullscreen at the moment of the close: the collapse must hand the mode
@@ -168,15 +212,21 @@ describe('createSidebarRightStore — the last docked tab', () => {
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)
// The collapsed column stands empty: no page was seeded behind the collapse.
expect(Object.values(layout().tabs)).toHaveLength(0)
expect(entries()).toBe(recorded + 2)
actions.undo(SESSION)
actions.undo(SESSION)
expect(layout()).toEqual(before)
// The next expansion seeds the then-current default page.
actions.redo(SESSION)
actions.redo(SESSION)
expand()
expect(layout().expanded).toBe(true)
expect(Object.values(layout().tabs)).toHaveLength(1)
expect(guide()).toBeDefined()
})
it('closes a floating tab without touching the column: floats do not count as the last docked tab', () => {
@@ -197,7 +247,8 @@ describe('createSidebarRightStore — the last docked tab', () => {
describe('createSidebarRightStore — floating panels and dividers', () => {
it('closes the sole tab in a floating pane while retaining the docked pane', () => {
const { actions, layout, entries, guide } = harness()
const { actions, layout, entries, guide, expand } = harness()
expand()
const floating = guide()
actions.floatTab(SESSION, floating, { x: 10, y: 20, width: 300, height: 200 })
expect(findTabPane(layout(), floating).host).toBe('float')
@@ -218,7 +269,8 @@ describe('createSidebarRightStore — floating panels and dividers', () => {
})
it('moves and resizes a floating panel, one entry each', () => {
const { actions, layout, entries, guide } = harness()
const { actions, layout, entries, guide, expand } = harness()
expand()
actions.floatTab(SESSION, guide(), { x: 10, y: 20, width: 300, height: 200 })
const [float] = layout().floats
if (float === undefined) throw new Error('expected a floating pane')
@@ -231,7 +283,8 @@ describe('createSidebarRightStore — floating panels and dividers', () => {
})
it('focuses a pane as one entry, which a click on a floating panel records', () => {
const { actions, layout, entries, guide } = harness()
const { actions, layout, entries, guide, expand } = harness()
expand()
actions.floatTab(SESSION, guide(), { x: 10, y: 20, width: 300, height: 200 })
const [float] = layout().floats
if (float === undefined) throw new Error('expected a floating pane')
@@ -246,7 +299,8 @@ describe('createSidebarRightStore — floating panels and dividers', () => {
})
it('records a divider drag as the split\'s new fractions', () => {
const { actions, layout, entries } = harness()
const { actions, layout, entries, expand } = harness()
expand()
actions.splitPane(SESSION)
const rootId = getSplit(layout(), layout().rootId).id
expect(getSplit(layout(), rootId).sizes).toEqual([0.5, 0.5])
@@ -257,7 +311,8 @@ describe('createSidebarRightStore — floating panels and dividers', () => {
})
it('reports the pane a split created, and only then', () => {
const { actions, layout } = harness()
const { actions, layout, expand } = harness()
expand()
const settled = vi.fn<(paneId: PaneId) => void>()
const before = dockPaneIds(layout())
actions.splitPane(SESSION, undefined, settled)
@@ -277,7 +332,8 @@ describe('createSidebarRightStore — floating panels and dividers', () => {
describe('createSidebarRightStore — focus', () => {
it('records a focus only when it changes which tab or pane is active, docked or floating', () => {
const { actions, layout, entries, guide } = harness()
const { actions, layout, entries, guide, expand } = harness()
expand()
actions.openContent(SESSION, { kind: 'text', contentId: 'dsh-resource://file/session/s-test/a.txt', title: 'a' }, () => {})
const text = Object.values(layout().tabs).find(tab => tab.kind === 'text')
if (text === undefined) throw new Error('expected the text tab')
@@ -321,9 +377,10 @@ describe('createSidebarRightStore — focus', () => {
})
})
describe('createSidebarRightStore — the guide\'s uniqueness', () => {
describe('createSidebarRightStore — page uniqueness', () => {
it('places and drops any other tab as the kit plans it, guides in the target pane or not', () => {
const { actions, layout } = harness()
const { actions, layout, expand } = harness()
expand()
actions.openContent(SESSION, { kind: 'text', contentId: 'dsh-resource://file/session/s-test/a.txt', title: 'a' }, () => {})
const text = Object.values(layout().tabs).find(tab => tab.kind === 'text')
if (text === undefined) throw new Error('expected the text tab')
@@ -341,7 +398,8 @@ describe('createSidebarRightStore — the guide\'s uniqueness', () => {
})
it('reorders the guide within its own pane as a plain place', () => {
const { actions, layout, guide } = harness()
const { actions, layout, guide, expand } = harness()
expand()
const pane = layout().activePaneId
actions.openContent(SESSION, { kind: 'text', contentId: 'dsh-resource://file/session/s-test/a.txt', title: 'a' }, () => {})
expect(getPane(layout(), pane).tabs[0]).toBe(guide())