fix(ui): separate navigation pins from selection and show menu icons (#6883)

* fix(ui): separate navigation pins from selection and show menu icons

* fix(ui): simplify More menus and fold conversation views on resize

* test(ui): fix navigation CI regressions and refresh catalogs

* refactor(ui): render sidebar panel icons directly

* fix(ui): keep Chat visible and stop persisting navigation pins

* refactor(ui): drop unreachable pin validation from in-memory stores
This commit is contained in:
imccyu authored and GitHub committed 2026-10-22 20:05:33 +08:00
1 parent 675164957a
commit 39d2903841
50 files changed
+454 -385

No files matched your search

@@ -3,9 +3,9 @@ import { cp, mkdir, mkdtemp, readFile, readdir, rm, writeFile } from 'node:fs/pr
import { tmpdir } from 'node:os'
import { dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'
import { promisify } from 'node:util'
import { zstdDecompress } from 'node:zlib'
import { zstdDecompressSync } from 'node:zlib'
import { resolveExampleLaunch } from '@deepseek-ai/dsh-loader-smoke'
import { scanZstdFrames } from '@deepseek-ai/dsh-session-persistence-jsonl/src/zstd.js'
import { execa } from 'execa'
import { describe, expect, it, onTestFinished } from 'vitest'
import { workspaceDependencyPaths, type PrimaryRuntimeManifest } from '@deepseek-ai/dsh-tool-workspace-dependencies'
@@ -15,7 +15,6 @@ const launch = resolveExampleLaunch({
srcBin: fileURLToPath(new URL('../../../src/bin.ts', import.meta.url)),
mode: 'lib',
})
const decompress = promisify(zstdDecompress)
/** Frame one text or tool response from the local Messages endpoint. */
function messagesResponse(content: Record<string, unknown>, stopReason: 'end_turn' | 'max_tokens' | 'tool_use'): string {
@@ -187,11 +186,30 @@ describe('Python SDK dsh profile keyless smoke', () => {
expect(exit.exitCode, `signal=${String(exit.signal)}; stderr=${stderr}`).toBe(0)
const sessionsRoot = join(root, '.dsh', 'sessions')
const files = await readdir(sessionsRoot, { recursive: true })
const log = files.find(file => file.endsWith('.jsonl.zstd'))
// Session logs are JSONL across one zstd frame per batch, header frame
// first. Decompress frames explicitly: a single call over concatenated
// frames returned only the first frame on Node 24.19 and returns every
// frame on 24.22, so neither behavior may back the assertion.
const logs: { compressed: Buffer; header: Record<string, unknown> }[] = []
for (const file of files.filter(file => file.endsWith('.jsonl.zstd'))) {
const compressed = await readFile(join(sessionsRoot, file))
expect(compressed.subarray(0, 4).toString('hex')).toBe('28b52ffd')
const first = scanZstdFrames(compressed).frames[0]
if (first === undefined) continue
const headerLine = zstdDecompressSync(compressed.subarray(first.start, first.end)).toString().trim().split('\n')[0]
if (headerLine === undefined) continue
logs.push({ compressed, header: JSON.parse(headerLine) as Record<string, unknown> })
}
const log = logs.find(entry => entry.header.type === 'session' && entry.header.id === 'main')
expect(log).toBeDefined()
const compressed = await readFile(join(sessionsRoot, log!))
expect(compressed.subarray(0, 4).toString('hex')).toBe('28b52ffd')
expect(JSON.parse((await decompress(compressed)).toString())).toMatchObject({ type: 'session', id: 'main' })
const { frames, tornStart } = scanZstdFrames(log!.compressed)
expect(tornStart).toBeUndefined()
const records = frames
.flatMap(({ start, end }) => zstdDecompressSync(log!.compressed.subarray(start, end)).toString().trim().split('\n'))
.map(line => JSON.parse(line) as Record<string, unknown>)
expect(records[0]).toMatchObject({ type: 'session', id: 'main' })
expect(records.length).toBeGreaterThan(1)
expect(records.some(record => record.type === 'turn/end')).toBe(true)
} finally {
// No-op after exit; reject: false settles on every outcome, so cleanup never races teardown.
child.kill('SIGKILL')
+21 -25
View File
@@ -25,37 +25,33 @@ describe('default navigation entry customization', () => {
try { await browser?.close() } finally { await scaffold?.close() }
})
it('unpins a sidebar panel across reloads while its panel stays reachable from More', async () => {
it('keeps navigation outside More and offers only browsing and creation after reload', async () => {
onTestFailed(() => saveFailureShot(page, 'entry-customization'))
const nav = page.getByRole('navigation', { name: 'Global panels' })
const plugins = nav.getByRole('button', { name: 'Plugins', exact: true })
const tasks = nav.getByRole('button', { name: 'Automation tasks', exact: true })
const more = nav.getByRole('button', { name: 'More', exact: true })
await plugins.waitFor()
await tasks.waitFor()
await more.waitFor()
const menu = page.getByRole('menu')
await more.click()
expect(await menu.getByRole('menuitem', { name: 'Plugins', exact: true }).count()).toBe(0)
await menu.getByRole('menuitem', { name: 'Unpin from left sidebar: Automation tasks', exact: true }).click()
await tasks.waitFor({ state: 'detached' })
await menu.getByRole('menuitem', { name: 'Automation tasks', exact: true }).click()
await tasks.click()
await page.getByRole('heading', { name: 'Automation tasks', exact: true }).waitFor()
// The selected panel keeps its row until another panel is selected.
await tasks.waitFor()
expect(await tasks.getAttribute('aria-current')).toBe('page')
await plugins.click()
await tasks.waitFor({ state: 'detached' })
expect(await plugins.getAttribute('aria-current')).toBe('page')
await page.reload({ waitUntil: 'load' })
await plugins.waitFor()
await more.waitFor()
expect(await tasks.count()).toBe(0)
await more.click()
await menu.getByRole('menuitem', { name: 'Pin to left sidebar: Automation tasks', exact: true }).click()
await tasks.waitFor()
for (const reload of [false, true]) {
if (reload) {
await page.reload({ waitUntil: 'load' })
await tasks.click()
await page.getByRole('heading', { name: 'Automation tasks', exact: true }).waitFor()
}
await tasks.waitFor()
await more.click()
const menu = page.getByRole('menu')
await menu.getByRole('menuitem', { name: 'Browse other plugins', exact: true }).waitFor()
expect(await menu.getByRole('menuitem').allTextContents()).toEqual(['Browse other plugins', 'Create your plugin'])
expect(await menu.getByRole('separator').count()).toBe(0)
expect(await tasks.getAttribute('aria-current')).toBe('page')
await menu.getByRole('menuitem', { name: 'Browse other plugins', exact: true }).click()
await menu.waitFor({ state: 'detached' })
expect(await nav.getByRole('button', { name: 'Plugins', exact: true }).getAttribute('aria-current')).toBe('page')
await tasks.click()
await page.getByRole('heading', { name: 'Automation tasks', exact: true }).waitFor()
}
expect(tripwire.pageErrors).toEqual([])
expect(tripwire.warnings).toEqual([])
})
@@ -77,6 +77,7 @@ it('renders durable record images in the Trajectory details panel from the share
// The virtual ledger needs a measurable viewport; jsdom reports zero
// heights, so pin one and neutralize the imperative tail scroll.
vi.spyOn(HTMLElement.prototype, 'offsetHeight', 'get').mockReturnValue(600)
vi.spyOn(HTMLElement.prototype, 'clientWidth', 'get').mockReturnValue(1000)
Object.defineProperty(HTMLElement.prototype, 'scrollTo', {
configurable: true,
value: () => {},
+2 -2
View File
@@ -3,8 +3,8 @@
# After editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write docs/subsystems/slots.md
/web-client-slots:
en: 38962bc8b7d03c3a
zh: 99c48ac27abe8816
en: 353a4e6b1bbc973b
zh: b46f8520b8c2240a
/web-client-slots/declaration-and-lifecycle:
en: f2d2c0d3b8f14ec7
zh: 441ed13ff3f81d2a
+1 -1
View File
@@ -10,7 +10,7 @@ This page documents slot ownership, component inputs, extension APIs, and the sh
The plugin catalog declares `plugins.add.actions`, a root-scoped list for `MenuItemButton` rows after installation in the Add plugin menu. Its owner supplies `onDismiss()` to close the menu before starting an action. The slot follows the owning Plugins page declaration lifetime.
The root-scoped lists `sidebar.panellist.actions`, `conversation.session.header.views.actions`, and `sidebar.right.tab.guide.actions` supply the rows of the More menus after the panel rows, the View tabs, and the right-sidebar guide cards. A region draws its More button and menu only while its list has registrations, and passes the customizable entries with their pin state, `onOpen`, `onPinnedChange`, and `onDismiss`. Each region persists its own unpinned entry ids; [`ui-entry-customization`](../../packages/client/ui-entry-customization/README.md) registers the shipped rows.
The root-scoped lists `sidebar.panellist.actions`, `conversation.session.header.views.actions`, and `sidebar.right.tab.guide.actions` supply the rows of the More menus after the panel rows, the View tabs, and the right-sidebar guide cards. A region draws its More button and menu only while its list has registrations, and passes the customizable entries with their pin state, `onOpen`, `onPinnedChange`, and `onDismiss`. Panel and View entries also carry optional `active` state for the current selection, independently of pin state. Each region keeps its own unpinned entry ids in memory; [`ui-entry-customization`](../../packages/client/ui-entry-customization/README.md) registers the shipped rows.
## Declaration and lifecycle
+1 -1
View File
@@ -10,7 +10,7 @@ Slots 是 Web Client 的类型化 React 组合系统。[`dsh-client-ui-slots`](.
插件目录声明 `plugins.add.actions`,它是 root 作用域的 list,在添加插件菜单的安装项之后提供 `MenuItemButton` 行。owner 提供 `onDismiss()`,用于开始动作前关闭菜单。该 slot 遵循所属插件页面声明的生命周期。
root 作用域的 list `sidebar.panellist.actions`、`conversation.session.header.views.actions` 和 `sidebar.right.tab.guide.actions` 提供面板行、View 标签和右侧栏引导卡片之后“更多”菜单的菜单行。区域只在其 list 有注册项时画“更多”按钮与菜单,并传入可自定义的入口及其 Pin 状态、`onOpen`、`onPinnedChange` 与 `onDismiss`。各区域自行持久化已收起的入口 id;[`ui-entry-customization`](../../packages/client/ui-entry-customization/README.zh.md) 注册随产品提供的菜单行。
root 作用域的 list `sidebar.panellist.actions`、`conversation.session.header.views.actions` 和 `sidebar.right.tab.guide.actions` 提供面板行、View 标签和右侧栏引导卡片之后“更多”菜单的菜单行。区域只在其 list 有注册项时画“更多”按钮与菜单,并传入可自定义的入口及其 Pin 状态、`onOpen`、`onPinnedChange` 与 `onDismiss`。面板和 View 入口还通过可选的 `active` 状态标识当前选中项,该状态独立于 Pin 状态。各区域在内存中维护已收起的入口 id;[`ui-entry-customization`](../../packages/client/ui-entry-customization/README.zh.md) 注册随产品提供的菜单行。
## 声明与生命周期
@@ -21,8 +21,8 @@
en: 9912b286384c41d7
zh: 3e994887c1948677
/deepseek-ai-dsh-client-ui-conversation/shell-and-standard-props:
en: a7a0b235e1c5f2b0
zh: ca24e6bf77ad7e1b
en: 626f5095ffbd8018
zh: 672a7762384f4caf
/deepseek-ai-dsh-client-ui-conversation/temporary-composer-entries:
en: bb1244310f5c0382
zh: 824b84077432b559
+1 -1
View File
@@ -72,7 +72,7 @@ The context-occupancy button shows a ring and percentage below the input card, a
With Coding Tools off, the shell hides only Trajectory; other plugin-contributed Views remain available. The View tab bar is hidden when fewer than two Views are available. Turning Coding Tools off while Trajectory is active returns to Chat; stored View preferences and recorded Session data remain intact. Turning it on makes Trajectory available again. View owners receive the available roster so their navigation actions can follow the same visibility.
While the root-scoped `conversation.session.header.views.actions` list has registrations, a **More conversation views** button with a tooltip follows the View tabs in the tab row. It opens a menu whose rows are that list's registrations. Each receives `entries` (available Views in registration order with `id`, `label`, and `pinned`; Chat is never listed), `onOpen(id)`, which selects the View in this Session, `onPinnedChange(id, pinned)`, and `onDismiss()`, which closes the menu. The header keeps one root list of unpinned View ids in browser `localStorage` under `dsh.slot.manual-order.conversation.view.v1`, stored as `{ "unpinned": string[] }` and shared by every Session; the tab bar omits unpinned Views, and Chat always shows. The tab bar's visibility counts available Views, not pinned ones, so the bar and More stay while every other View is unpinned. Unpinning the active View leaves its content selected and keeps its tab, with `aria-selected`, until another View is selected. Ids of Views that are not registered stay stored; stored content other than a string array shows every tab.
While the root-scoped `conversation.session.header.views.actions` list has registrations, a **More conversation views** button with a tooltip follows the View tabs in the tab row. It opens a menu whose rows are that list's registrations. Each receives `entries` (Views outside the header in registration order with `id`, `label`, `pinned`, and `active`; Chat is never listed), `onOpen(id)`, which selects the View in this Session, `onPinnedChange(id, pinned)`, and `onDismiss()`, which closes the menu. The header keeps an in-memory list of unpinned View ids shared by every Session; the tab bar omits unpinned Views and places registered Chat first so it always remains outside More. Plugin reload resets pin choices, and legacy localStorage values are ignored. The tab bar's visibility counts available Views, not pinned ones, so the bar and More stay while every other View is unpinned. Unpinning the active View hides its tab immediately without changing the selected content. More lists only Views outside the header and identifies the current View through `active`. The header reads its available row width, reserves 48px for More and padding, and estimates 120px per tab. Remaining Views keep registration order in More. A single ResizeObserver watches the tab row so window resizing and sidebar expansion update the split; active selection does not change the order. The observer disconnects when the row is removed. If ResizeObserver is unavailable, the initial width calculation still applies without subsequent observation. The More button highlights and names the active View in its tooltip when that View is folded. Individual tabs are not measured. At least one preferred tab remains outside, and long labels truncate with a full-name tooltip. Unregistered View ids remain in the in-memory pin list until plugin reload.
The composer registers the File command action and owns its label, availability, and native file-dialog callback. Menu availability and invocation both consult the mounted composer's current attachment-intake policy. Unmounting or locking the composer disables that action; disposing the plugin removes its registration. The callback binding stays inside the input module.
+1 -1
View File
@@ -72,7 +72,7 @@ Queue 预览启用空白折叠和文档边缘 trim,保留 emoji,并使用 CS
关闭代码工作工具时,外壳仅隐藏 Trajectory;其他插件贡献的 View 仍然可用。可用 View 少于两个时隐藏 View 标签栏。在 Trajectory 激活时关闭代码工作工具会返回 Chat;已保存的 View 偏好和 Session 记录保持不变。开启后,Trajectory 恢复可用。View 所有者接收可用列表,使导航操作跟随相同的可见性。
root 作用域的 `conversation.session.header.views.actions` list 有注册项时,标签行中的 View 标签之后显示带提示框的**更多会话视图**按钮。它打开的菜单以该 list 的注册项为菜单行。每个注册项接收 `entries`(按注册顺序的可用 View,含 `id`、`label` 与 `pinned`;不含 Chat)、在本 Session 选择 View 的 `onOpen(id)`、`onPinnedChange(id, pinned)`,以及关闭菜单的 `onDismiss()`。头部在浏览器 `localStorage` 的 `dsh.slot.manual-order.conversation.view.v1` 下保存一份 root 级的已收起 View id 列表,格式为 `{ "unpinned": string[] }`,所有 Session 共用;标签栏不显示已收起的 View,Chat 始终显示。标签栏是否显示按可用 View 计算,不按已固定的 View 计算,因此其他 View 全部收起时,标签栏和“更多”仍然显示。收起当前激活的 View 不会切走其内容,其标签及 `aria-selected` 保留到选中其他 View 为止。未注册 View 的 id 保留在列表中;保存内容不是字符串数组时,所有标签都显示。
root 作用域的 `conversation.session.header.views.actions` list 有注册项时,标签行中的 View 标签之后显示带提示框的**更多会话视图**按钮。它打开的菜单以该 list 的注册项为菜单行。每个注册项接收 `entries`(按注册顺序的头部之外的 View,含 `id`、`label`、`pinned` 与 `active`;不含 Chat)、在本 Session 选择 View 的 `onOpen(id)`、`onPinnedChange(id, pinned)`,以及关闭菜单的 `onDismiss()`。头部在内存中保存所有 Session 共用的已收起 View id 列表;标签栏不显示已收起的 View,并将已注册的 Chat 放在首位,确保它始终留在更多菜单外。插件重新加载时 Pin 选择重置,不读取旧 localStorage 值。标签栏是否显示按可用 View 计算,不按已固定的 View 计算,因此其他 View 全部收起时,标签栏和“更多”仍然显示。收起当前激活的 View 会立即隐藏其标签,但不会切走其内容。更多菜单仅包含头部之外的 View,通过 `active` 标识当前 View。头部读取当前行可用宽度,为更多按钮与内边距预留 48px,每个标签估算 120px,其余 View 按注册顺序进入更多。单个 ResizeObserver 观察标签行,使窗口缩放和侧栏展开能够更新分组;当前选中项变化不改变顺序。标签行移除时断开观察。如果环境没有 ResizeObserver,仍按初始宽度计算,但不继续观察。当前 View 收起时,更多按钮高亮并在提示框中显示其名称。不逐项测量标签。至少保留一个优先展示的外部标签,长标题截断并通过提示框提供全名。未注册 View 的 id 保留在内存 Pin 列表中,直到插件重新加载。
输入框注册「文件」命令动作,负责其标题、可用性和原生文件选择器回调。菜单可用性与实际调用都读取已挂载输入框当前的附件接收策略。输入框卸载或锁定后该动作不可用,插件 dispose(资源释放)时移除注册。回调绑定留在输入模块内部。
@@ -430,13 +430,15 @@ export interface ConversationViewActionEntry {
readonly id: string
/** Resolved View label. */
readonly label: string
/** False while the tab is unpinned from the header. */
/** False while the View is manually unpinned. A pinned View still enters More when the header's width budget cannot hold it. */
readonly pinned: boolean
/** Whether this View is selected in this Session, independently of its pin state. */
readonly active?: boolean
}
/** Owner share of the View tab row's More menu rows. */
export interface ConversationViewActionsOwnerProps {
/** Available Views in registration order; Chat is never listed. */
/** Views outside the header in registration order. */
readonly entries: readonly ConversationViewActionEntry[]
/**
* Select a View in this Session without changing its pin state.
@@ -444,9 +446,9 @@ export interface ConversationViewActionsOwnerProps {
*/
readonly onOpen: (id: string) => void
/**
* Show (true) or unpin (false) a View tab in every Session.
* Pin or unpin a View in every Session.
* @param id - Listed View id.
* @param pinned - Whether the tab shows in the header.
* @param pinned - Whether the View is manually pinned; the width budget can still fold it into More.
*/
readonly onPinnedChange: (id: string, pinned: boolean) => void
/** Close the More menu. */
@@ -458,15 +460,15 @@ export interface ConversationSessionHeaderInjected {
/** Package-owned View roster source bound only for the Conversation header. */
readonly hooks: {
readonly conversationViews: ObservableSnapshot<readonly ViewTab[]>
/** Ids of unpinned View tabs, persisted across reloads and shared by every Session. */
/** Ids of unpinned View tabs held in memory and shared by every Session until plugin reload. */
readonly entryPins: ObservableSnapshot<readonly string[]>
/** Whether `conversation.session.header.views.actions` has registrations. */
readonly entryActions: ObservableSnapshot<boolean>
}
/**
* Show or unpin one View tab in every Session; Chat is ignored.
* Pin or unpin one View tab in every Session; Chat is ignored.
* @param id - View id.
* @param pinned - Whether the tab shows in the header.
* @param pinned - Whether the View is manually pinned; the width budget can still fold it into More.
*/
setPinned: (id: string, pinned: boolean) => void
/** Select a Session through the Session Controller. */
@@ -79,6 +79,7 @@ export const zh = {
'hero.chooseWorkspace': '选择工作区',
'session.hierarchy': '会话层级',
'session.moreViews': '更多会话视图',
'session.moreViewsCurrent': '更多会话视图 · {name}',
'todo.title': '任务',
'todo.progress.done': '{done} 已完成',
'todo.progress.active': '{active} 进行中',
@@ -460,6 +461,7 @@ export const en = {
'hero.chooseWorkspace': 'Choose workspace',
'session.hierarchy': 'Session hierarchy',
'session.moreViews': 'More conversation views',
'session.moreViewsCurrent': 'More conversation views · {name}',
'todo.title': 'To-dos',
'todo.progress.done': '{done} completed',
'todo.progress.active': '{active} in progress',
@@ -196,6 +196,9 @@ button.crumb:hover {
z-index: 1;
display: flex;
gap: 16px 20px;
width: 100%;
min-width: 0;
box-sizing: border-box;
align-items: flex-start;
flex-wrap: wrap;
margin-top: 10px;
@@ -260,6 +263,10 @@ button.crumb:hover {
color: var(--dsw-alias-label-secondary);
}
.viewsMoreActive { color: var(--dsw-alias-state-business-primary); }
.tabLabel { display: block; max-width: 88px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.viewsMore[aria-expanded='true'] {
background: var(--dsw-alias-interactive-bg-hover);
}
@@ -1,6 +1,6 @@
/** Strict per-session header/body content inserted into the resident conversation layout. */
import { useState } from 'react'
import { useLayoutEffect, useMemo, useRef, useState } from 'react'
import clsx from 'clsx'
import { Button, IconEllipsisOutlineRegular, Menu, Tooltip } from '@deepseek-ai/dsh-client-ui-primitives'
import type { SessionListState, SessionSummary } from '@deepseek-ai/dsh-api-session-controller/client'
@@ -22,17 +22,17 @@ export type ConversationSessionHeaderProps = ConversationSessionHeaderSlotProps
type ViewsMoreMenuProps =
Omit<ConversationViewActionsOwnerProps, 'onDismiss'>
& Pick<ConversationSessionHeaderSlotProps, 'renderSlot'>
& { readonly label: string }
& { readonly label: string; readonly current: boolean }
/** The View tab row's More launcher; its menu rows come from `conversation.session.header.views.actions`. */
function ViewsMoreMenu({ label, entries, onOpen, onPinnedChange, renderSlot }: ViewsMoreMenuProps) {
function ViewsMoreMenu({ label, current, entries, onOpen, onPinnedChange, renderSlot }: ViewsMoreMenuProps) {
const [open, setOpen] = useState(false)
const onDismiss = (): void => { setOpen(false) }
return (
<Menu open={open} onClose={onDismiss} portal autoFocus className={css.viewsMoreAnchor} listClassName={css.viewsMoreMenu}
anchor={(
<Tooltip label={label} portal>
<Button size="sm" className={css.viewsMore} aria-label={label} aria-haspopup="menu" aria-expanded={open}
<Button size="sm" className={clsx(css.viewsMore, current && css.viewsMoreActive)} aria-label={label} aria-haspopup="menu" aria-expanded={open}
onClick={() => { setOpen(value => !value) }}>
<IconEllipsisOutlineRegular size={13} />
</Button>
@@ -93,6 +93,31 @@ export function ConversationSessionHeader({
const unpinned = useEntryPins(ids => ids)
const hasEntryActions = useEntryActions(registered => registered)
const showTabs = !hideChrome && tabs.length > 1
const tabRow = useRef<HTMLDivElement>(null)
const [visibleViewLimit, setVisibleViewLimit] = useState(1)
// Observe only the available row width; individual tabs are not measured.
useLayoutEffect(() => {
const row = tabRow.current
if (row === null || !hasEntryActions) return
const updateLimit = (): void => {
setVisibleViewLimit(Math.max(1, Math.floor((row.clientWidth - 48) / 120)))
}
updateLimit()
if (typeof ResizeObserver === 'undefined') return
const observer = new ResizeObserver(updateLimit)
observer.observe(row)
return () => { observer.disconnect() }
}, [showTabs, hasEntryActions])
const { visibleTabs, moreTabs } = useMemo(() => {
const preferred = [
...tabs.filter(tab => tab.id === FIXED_VIEW_ID),
...tabs.filter(tab => tab.id !== FIXED_VIEW_ID && !unpinned.includes(tab.id)),
]
const visibleTabs = hasEntryActions ? preferred.slice(0, visibleViewLimit) : preferred
const visibleIds = new Set(visibleTabs.map(tab => tab.id))
return { visibleTabs, moreTabs: tabs.filter(tab => !visibleIds.has(tab.id)) }
}, [tabs, unpinned, hasEntryActions, visibleViewLimit])
const currentInMore = moreTabs.some(tab => tab.id === active?.id)
return (
<>
<div className={css.titleRow}>
@@ -169,10 +194,9 @@ export function ConversationSessionHeader({
{showTabs && (
// data-conversation-tabs: marks the tab strip, which the window-chrome
// geometry and the browser coverage lane anchor on.
<div className={css.tabs}>
<div ref={tabRow} className={css.tabs}>
<div className={css.viewTabs} role="tablist" data-conversation-tabs="">
{tabs.filter(viewTab => viewTab.id === FIXED_VIEW_ID || viewTab.id === active?.id
|| !unpinned.includes(viewTab.id)).map(viewTab => (
{visibleTabs.map(viewTab => (
<button
key={viewTab.id}
type="button"
@@ -181,15 +205,18 @@ export function ConversationSessionHeader({
className={clsx(css.tab, viewTab.id === active?.id && css.tabActive)}
onClick={() => { selectView(viewTab.id) }}
>
{viewTab.label}
<Tooltip label={viewTab.label} portal><span className={css.tabLabel}>{viewTab.label}</span></Tooltip>
</button>
))}
</div>
{hasEntryActions && (
<ViewsMoreMenu
label={t('session.moreViews')}
entries={tabs.filter(viewTab => viewTab.id !== FIXED_VIEW_ID)
.map(viewTab => ({ id: viewTab.id, label: viewTab.label, pinned: !unpinned.includes(viewTab.id) }))}
label={currentInMore && active !== undefined ? t('session.moreViewsCurrent', { name: active.label }) : t('session.moreViews')}
current={currentInMore}
entries={moreTabs.map(viewTab => ({
id: viewTab.id, label: viewTab.label,
pinned: !unpinned.includes(viewTab.id), active: viewTab.id === active?.id,
}))}
onOpen={selectView}
onPinnedChange={setPinned}
renderSlot={renderSlot}
@@ -1,11 +1,9 @@
/** Persisted pin choices for the Conversation View tabs. */
/** In-memory pin choices for the Conversation View tabs. */
import { createSnapshotStore, type ObservableSnapshot } from '@deepseek-ai/dsh-client-store'
/** View whose tab always shows and never enters the customization list. */
export const FIXED_VIEW_ID = 'chat'
/** Root-level key shared with the slot layer's future per-list preferences; Sessions share one list. */
const STORAGE_KEY = 'dsh.slot.manual-order.conversation.view.v1'
const NO_VIEWS: readonly string[] = []
/** Unpinned View ids and their only mutation. */
@@ -13,30 +11,24 @@ export interface ViewPins {
/** Unpinned View ids; ids of Views that are not registered stay listed. */
readonly unpinned: ObservableSnapshot<readonly string[]>
/**
* Show or hide one View tab; the fixed View is ignored.
* Pin or unpin one View tab; the fixed View is ignored. A pinned tab still
* enters More when the header's width budget cannot hold it.
* @param id - View id.
* @param pinned - Whether the tab shows in the Conversation header.
* @param pinned - Whether the tab is manually pinned.
*/
readonly setPinned: (id: string, pinned: boolean) => void
}
/** Stored content is unchecked; anything but a string array leaves every View pinned. */
function unpinnedViews(value: unknown): readonly string[] {
if (typeof value !== 'object' || value === null || !('unpinned' in value)) return NO_VIEWS
const { unpinned } = value
return Array.isArray(unpinned) && unpinned.every((id): id is string => typeof id === 'string') ? unpinned : NO_VIEWS
}
/**
* Create the persisted View pin list; the snapshot keeps its reference until the list changes.
* @returns Validated unpinned View ids and the pin mutation.
* Create the in-memory View pin list; the snapshot keeps its reference until the list changes.
* @returns Unpinned View ids and the pin mutation.
*/
export function createViewPins(): ViewPins {
const store = createSnapshotStore<unknown>({ unpinned: NO_VIEWS }, { persist: { name: STORAGE_KEY } })
const store = createSnapshotStore<{ readonly unpinned: readonly string[] }>({ unpinned: NO_VIEWS })
return {
unpinned: { getSnapshot: () => unpinnedViews(store.getSnapshot()), subscribe: listener => store.subscribe(listener) },
unpinned: { getSnapshot: () => store.getSnapshot().unpinned, subscribe: listener => store.subscribe(listener) },
setPinned: (id, pinned) => {
const current = unpinnedViews(store.getSnapshot())
const current = store.getSnapshot().unpinned
if (id === FIXED_VIEW_ID || pinned !== current.includes(id)) return
store.set({ unpinned: pinned ? current.filter(entry => entry !== id) : [...current, id] })
},
@@ -44,7 +44,7 @@ function entry(
}
describe('target-neutral Conversation apply wiring', () => {
it('hands every Session header the same persisted View pins', async () => {
it('hands every Session header the same in-memory View pins without restoring saved choices', async () => {
localStorage.setItem('dsh.slot.manual-order.conversation.view.v1', JSON.stringify({ unpinned: ['trajectory'] }))
try {
const b = await bench()
@@ -56,10 +56,10 @@ describe('target-neutral Conversation apply wiring', () => {
const inject = header.inject as (sessionId: SessionId) => PinHooks
const injected = inject(SID)
const first = injected.hooks.entryPins
expect(first.getSnapshot()).toEqual(['trajectory'])
expect(first.getSnapshot()).toEqual([])
expect(inject('session-2' as SessionId).hooks.entryPins).toBe(first)
injected.setPinned('timeline', false)
expect(first.getSnapshot()).toEqual(['trajectory', 'timeline'])
expect(first.getSnapshot()).toEqual(['timeline'])
const actions = injected.hooks.entryActions
const changed = vi.fn()
actions.subscribe(changed)
@@ -85,6 +85,7 @@ function fireResize(el: Element): void {
afterEach(() => {
cleanup()
vi.restoreAllMocks()
vi.unstubAllGlobals()
resizeObservers.length = 0
})
@@ -144,6 +145,8 @@ function mount(
unpinnedViews?: readonly string[]
/** Whether the View actions list has registrations. */
entryActions?: boolean
/** Isolate header capability checks from the composer's required layout observers. */
headerOnly?: boolean
} = {},
) {
const sessionId = 'sessionId' in options ? options.sessionId : SID
@@ -340,6 +343,7 @@ function mount(
const renderFactorySlot = ((_name: string, input: ConversationContentInputProps, factoryOptions?: {
slots?: Record<string, (props: never) => ReactNode>
}) => {
if (options.headerOnly) return null
const common: ConversationViewsProps = {
sessionId: SID,
SessionProvider,
@@ -681,7 +685,30 @@ describe('ConversationRoot resident composer', () => {
expect(b.view.getByRole('tablist').hasAttribute('data-conversation-tabs')).toBe(true)
})
it('keeps the selected View tab while it is unpinned and hides it once another View is selected', () => {
it('keeps Chat outside at the narrowest width even when it registers after other Views', () => {
vi.spyOn(HTMLElement.prototype, 'clientWidth', 'get').mockReturnValue(180)
const viewTabs = ['trajectory', 'timeline', 'chat', 'notes'].map(id => ({ id, label: id }))
const b = mount(sessionSnapshotOf(), undefined, undefined, { viewTabs, entryActions: true })
expect(within(b.view.getByRole('tablist')).getAllByRole('tab').map(tab => tab.textContent)).toEqual(['chat'])
fireEvent.click(b.view.getByRole('button', { name: zh['session.moreViews'] }))
expect(b.actionsOwner().entries.map(entry => entry.id)).toEqual(['trajectory', 'timeline', 'notes'])
act(() => { b.actionsOwner().onOpen('trajectory') })
expect(b.view.getByRole('tab', { name: 'chat' })).toBeTruthy()
expect(b.view.queryByRole('tab', { name: 'trajectory' })).toBeNull()
expect(b.actionsOwner().entries.find(entry => entry.id === 'trajectory')?.active).toBe(true)
})
it('calculates the initial tab budget when ResizeObserver is unavailable', () => {
vi.stubGlobal('ResizeObserver', undefined)
vi.spyOn(HTMLElement.prototype, 'clientWidth', 'get').mockReturnValue(300)
const viewTabs = ['chat', 'trajectory', 'timeline'].map(id => ({ id, label: id }))
const b = mount(sessionSnapshotOf(), undefined, undefined, { viewTabs, entryActions: true, headerOnly: true })
expect(within(b.view.getByRole('tablist')).getAllByRole('tab').map(tab => tab.textContent)).toEqual(['chat', 'trajectory'])
fireEvent.click(b.view.getByRole('button', { name: zh['session.moreViews'] }))
expect(b.actionsOwner().entries.map(entry => entry.id)).toEqual(['timeline'])
})
it('keeps an unpinned View selected without exposing its tab', () => {
const viewTabs: ViewTab[] = [
{ id: 'chat', label: 'Chat' },
{ id: 'trajectory', label: 'Trajectory' },
@@ -691,7 +718,7 @@ describe('ConversationRoot resident composer', () => {
act(() => { b.store.actions.setView('trajectory') })
const tabs = (): HTMLElement[] => within(b.view.getByRole('tablist')).getAllByRole('tab')
expect(tabs().map(tab => [tab.textContent, tab.getAttribute('aria-selected')])).toEqual([
['Chat', 'false'], ['Trajectory', 'true'], ['Timeline', 'false'],
['Chat', 'false'], ['Timeline', 'false'],
])
expect(b.view.getByTestId('view-trajectory')).toBeTruthy()
act(() => { b.store.actions.setView('chat') })
@@ -708,6 +735,75 @@ describe('ConversationRoot resident composer', () => {
expect(b.view.queryByRole('menu')).toBeNull()
})
it('reuses the width budget on View roster and selection changes without a window resize listener', () => {
let width = 480
const readWidth = vi.spyOn(HTMLElement.prototype, 'clientWidth', 'get').mockImplementation(() => width)
const viewTabs: ViewTab[] = [
{ id: 'chat', label: 'Chat' },
{ id: 'trajectory', label: 'Trajectory' },
{ id: 'timeline', label: 'Timeline' },
{ id: 'inspector', label: 'Inspector' },
]
const b = mount(sessionSnapshotOf(), undefined, undefined, { viewTabs, entryActions: true })
const external = () => within(b.view.getByRole('tablist')).getAllByRole('tab').map(tab => tab.textContent)
expect(external()).toEqual(['Chat', 'Trajectory', 'Timeline'])
fireEvent.click(b.view.getByRole('button', { name: zh['session.moreViews'] }))
expect(b.actionsOwner().entries.map(entry => entry.id)).toEqual(['inspector'])
act(() => { b.actionsOwner().onOpen('inspector') })
expect(external()).toEqual(['Chat', 'Trajectory', 'Timeline'])
expect(b.actionsOwner().entries.find(entry => entry.id === 'inspector')?.active).toBe(true)
expect(b.view.getByRole('button', { name: '更多会话视图 · Inspector' })).toBeTruthy()
const reads = readWidth.mock.calls.length
width = 1000
fireEvent(window, new Event('resize'))
act(() => { b.store.actions.setView('timeline') })
expect(readWidth).toHaveBeenCalledTimes(reads)
expect(external()).toEqual(['Chat', 'Trajectory', 'Timeline'])
viewTabs.splice(1, 1)
act(() => { b.store.actions.setView('chat') })
expect(external()).toEqual(['Chat', 'Timeline', 'Inspector'])
expect(readWidth).toHaveBeenCalledTimes(reads)
expect(b.actionsOwner().entries).toEqual([])
expect(b.setPinned).not.toHaveBeenCalled()
})
it('folds and restores Views on container resize without changing selection, and disconnects on unmount', () => {
let width = 1000
vi.spyOn(HTMLElement.prototype, 'clientWidth', 'get').mockImplementation(() => width)
const viewTabs = ['chat', 'trajectory', 'timeline', 'inspector', 'notes'].map(id => ({ id, label: id }))
const b = mount(sessionSnapshotOf(), undefined, undefined, { viewTabs, entryActions: true })
const row = b.view.getByRole('tablist').parentElement!
const external = () => within(b.view.getByRole('tablist')).getAllByRole('tab').map(tab => tab.textContent)
expect(external()).toEqual(viewTabs.map(tab => tab.label))
act(() => { b.store.actions.setView('notes') })
width = 300
act(() => { fireResize(row) })
expect(external()).toEqual(['chat', 'trajectory'])
fireEvent.click(b.view.getByRole('button', { name: '更多会话视图 · notes' }))
expect(b.actionsOwner().entries.map(entry => entry.id)).toEqual(['timeline', 'inspector', 'notes'])
expect(b.actionsOwner().entries.find(entry => entry.id === 'notes')?.active).toBe(true)
width = 1000
act(() => { fireResize(row) })
expect(external()).toEqual(viewTabs.map(tab => tab.label))
expect(b.view.getByRole('tab', { name: 'notes' }).getAttribute('aria-selected')).toBe('true')
expect(b.actionsOwner().entries).toEqual([])
expect(b.setPinned).not.toHaveBeenCalled()
const observers = resizeObservers.filter(observer => observer.targets.includes(row))
expect(observers).toHaveLength(1)
b.view.unmount()
expect(observers[0]!.targets).toEqual([])
})
it('keeps all five Views outside when the sampled width budget is sufficient', () => {
vi.spyOn(HTMLElement.prototype, 'clientWidth', 'get').mockReturnValue(1000)
const viewTabs = ['chat', 'trajectory', 'timeline', 'inspector', 'notes'].map(id => ({ id, label: id }))
const b = mount(sessionSnapshotOf(), undefined, undefined, { viewTabs, entryActions: true })
expect(within(b.view.getByRole('tablist')).getAllByRole('tab').map(tab => tab.textContent))
.toEqual(viewTabs.map(tab => tab.label))
fireEvent.click(b.view.getByRole('button', { name: zh['session.moreViews'] }))
expect(b.actionsOwner().entries).toEqual([])
})
it('omits More while the View actions list has no registrations', () => {
const b = mount(sessionSnapshotOf())
expect(b.view.getByRole('tablist')).toBeTruthy()
@@ -726,11 +822,13 @@ describe('ConversationRoot resident composer', () => {
fireEvent.click(more)
expect(more.getAttribute('aria-expanded')).toBe('true')
expect(within(b.view.getByRole('menu')).getByRole('menuitem', { name: 'Header view action' })).toBeTruthy()
expect(b.actionsOwner().entries).toEqual([{ id: 'trajectory', label: 'Trajectory', pinned: false }])
expect(b.actionsOwner().entries).toEqual([{ id: 'trajectory', label: 'Trajectory', pinned: false, active: false }])
b.actionsOwner().onPinnedChange('trajectory', true)
expect(b.setPinned).toHaveBeenCalledExactlyOnceWith('trajectory', true)
act(() => { b.actionsOwner().onOpen('trajectory') })
expect(b.view.getByTestId('view-trajectory')).toBeTruthy()
expect(b.view.queryByRole('tab', { name: 'Trajectory' })).toBeNull()
expect(b.actionsOwner().entries).toEqual([{ id: 'trajectory', label: 'Trajectory', pinned: false, active: true }])
fireEvent.click(b.view.getByRole('menuitem', { name: 'Header view action' }))
expect(b.view.queryByRole('menu')).toBeNull()
expect(more.getAttribute('aria-expanded')).toBe('false')
@@ -1,5 +1,5 @@
// @vitest-environment jsdom
/** Conversation View pins persist one root list for every Session and ignore Chat. */
/** Conversation View pins share one in-memory root list for every Session and ignore Chat. */
import { afterEach, describe, expect, it, vi } from 'vitest'
import { createViewPins } from '../src/client/view-pins.ts'
@@ -7,15 +7,15 @@ const KEY = 'dsh.slot.manual-order.conversation.view.v1'
afterEach(() => { localStorage.clear() })
describe('Conversation View pins', () => {
it('persists unpinned View ids and restores them in a new instance', () => {
it('keeps unpinned View ids in memory without restoring them in a new instance', () => {
const pins = createViewPins()
const initial = pins.unpinned.getSnapshot()
expect(initial).toEqual([])
expect(pins.unpinned.getSnapshot()).toBe(initial)
pins.setPinned('trajectory', false)
pins.setPinned('timeline', false)
expect(JSON.parse(localStorage.getItem(KEY) ?? 'null')).toEqual({ unpinned: ['trajectory', 'timeline'] })
expect(createViewPins().unpinned.getSnapshot()).toEqual(['trajectory', 'timeline'])
expect(localStorage.getItem(KEY)).toBeNull()
expect(createViewPins().unpinned.getSnapshot()).toEqual([])
pins.setPinned('trajectory', true)
expect(pins.unpinned.getSnapshot()).toEqual(['timeline'])
})
@@ -34,20 +34,14 @@ describe('Conversation View pins', () => {
})
it('keeps ids of Views that are not registered', () => {
localStorage.setItem(KEY, JSON.stringify({ unpinned: ['removed-view'] }))
const pins = createViewPins()
pins.setPinned('removed-view', false)
pins.setPinned('timeline', false)
expect(pins.unpinned.getSnapshot()).toEqual(['removed-view', 'timeline'])
})
it.each([
['a non-array list', { unpinned: { timeline: true } }],
['non-string ids', { unpinned: [null] }],
['a missing list', { pinned: [] }],
['null', null],
['an array root', ['timeline']],
])('reads %s as no unpinned Views with a stable snapshot', (_name, stored) => {
localStorage.setItem(KEY, JSON.stringify(stored))
it('ignores a legacy stored list instead of restoring it', () => {
localStorage.setItem(KEY, JSON.stringify({ unpinned: ['trajectory'] }))
const pins = createViewPins()
const snapshot = pins.unpinned.getSnapshot()
expect(snapshot).toEqual([])
@@ -3,23 +3,23 @@
# After editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/client/ui-entry-customization/README.md
/:
en: 200adf0dd8c9757c
zh: 3d10d49a71289702
en: 57416280d387ea2b
zh: f9b97e610116bf59
/deepseek-ai-dsh-client-ui-entry-customization:
en: ab35194f4a9dafce
zh: 7e9e57fde518c3d7
/deepseek-ai-dsh-client-ui-entry-customization/summary:
en: d53e7d71da9fd4d6
zh: a32b635ec1e572f3
en: 589199344fe3dd70
zh: e5e3003c5c4d9479
/deepseek-ai-dsh-client-ui-entry-customization/table-of-contents:
en: 76368b6b1da79c80
zh: 52384aee991ac542
/deepseek-ai-dsh-client-ui-entry-customization/use-this-package:
en: 7cc90968634e3f7b
zh: 54f58e17b14f7404
en: e5ebba75f83c699c
zh: ff0e1229d7b771e0
/deepseek-ai-dsh-client-ui-entry-customization/understand-the-implementation:
en: 3dcb1f29d03d5afe
zh: 5ee39637636cd062
en: eb2cd6781ce5cc49
zh: 1ba64cf481e2889a
/deepseek-ai-dsh-client-ui-entry-customization/model-experience:
en: b891f311c09d536f
zh: 68c0f41fbfed0d73
@@ -27,8 +27,8 @@
en: dc50ebf94183efa2
zh: 617803c558367430
/deepseek-ai-dsh-client-ui-entry-customization/known-limitations-and-deferred-work:
en: 2e582c76ffe9d663
zh: 7e5e49ad3d6f0f83
en: 5b4914a3ef05fa9c
zh: 9cbe8a17a88ee4d6
/deepseek-ai-dsh-client-ui-entry-customization/known-limitations-and-deferred-work/dev-note:
en: e67f633dc6fe7773
zh: 3bb0c20c93f16f98
@@ -1,5 +1,5 @@
---
description: "Fill the More menus of three navigation regions with pin controls, plugin browsing, and a Creator draft."
description: "Offer plugin browsing and a Creator draft in three navigation regions."
kind: "package-reference"
---
@@ -11,7 +11,7 @@ Desktop product events use the optional [product analytics service](../product-a
## Summary
Fill the More menus of the left sidebar, Conversation View tabs, and right-sidebar guide with each region's customizable entries, pin buttons, plugin browsing, and a reviewable Creator draft. The Web app loads it by default. Each region draws its own More button and stores its own pin choices; this package stores nothing.
Browse plugins and prepare a reviewable Creator draft from the More menus of the left sidebar, Conversation View tabs, and right-sidebar guide. The Web app loads this package by default. Each region draws its own More button; this package stores nothing.
## Table of Contents
@@ -28,11 +28,11 @@ Fill the More menus of the left sidebar, Conversation View tabs, and right-sideb
The `@deepseek-ai/dsh-web-app` bundle loads this package by default; it needs no Plugins switch or profile selection. A region shows its More button only while its actions slot has a registration, so unloading this package removes all three buttons.
Open **More** beneath the left navigation entries, **More conversation views** after the Conversation View tabs, or **More tools** after the right-sidebar guide cards. Each menu lists the region's customizable entries in registration order; Plugins, Chat, and Workspace files always show and are not listed. Selecting an entry's name closes the menu and opens the entry without changing its pin state. The separate pin button pins or unpins the region's launcher and keeps the menu open; a filled pin marks a pinned entry. Unpinned entries stay reachable through the menu, and pin choices persist across reloads. Long entry names wrap, except in the More tools menu, where they truncate to one line.
Open **More** beneath the left navigation entries, **More conversation views** after the Conversation View tabs, or **More tools** after the right-sidebar guide cards. Left-sidebar and guide menus offer only **Browse other plugins** and **Create your plugin**. The Conversation menu also lists folded Views before those actions and marks the active View. None of these menus provide pin controls or sorting.
**Browse other plugins** opens the Plugins main list without opening an installation dialog. **Create your plugin** opens a new or reusable blank Session with the Creator preset and adds a short localized request for a plugin in that region to its draft: an empty draft takes the request as its text, and an existing draft keeps its text, references, and attachments with the request appended as a new paragraph. It never sends a message. Review and send the draft yourself. Both actions remain available when a region has no customizable entries; an empty list adds no separator.
**Browse other plugins** opens the Plugins main list without opening an installation dialog. **Create your plugin** opens a new or reusable blank Session with the Creator preset and adds a short localized request for a plugin in that region to its draft: an empty draft takes the request as its text, and an existing draft keeps its text, references, and attachments with the request appended as a new paragraph. It never sends a message. Review and send the draft yourself. Both actions remain available regardless of the region’s entries; a separator appears above browsing only when folded Views precede it.
On Desktop, **Browse other plugins** and **Create your plugin** report `more_menu_item_click` before they act, with `action=browse_plugins` or `action=create_plugin` and the menu's `region`: `sidebar` for **More**, `conversation` for **More conversation views**, and `rightbar` for **More tools**. Opening an entry and changing a pin report nothing, and the menus report no exposure events. Without the analytics service, both actions behave the same and report nothing.
On Desktop, **Browse other plugins** and **Create your plugin** report `more_menu_item_click` before they act, with `action=browse_plugins` or `action=create_plugin` and the menu's `region`: `sidebar` for **More**, `conversation` for **More conversation views**, and `rightbar` for **More tools**. The menus report no exposure events. Without the analytics service, both actions behave the same and report nothing.
-----
@@ -42,7 +42,7 @@ On Desktop, **Browse other plugins** and **Create your plugin** report `more_men
<details>
<summary>Implementation internals — click to expand</summary>
[`apply`](src/client/index.ts) registers [`EntryMenuContent`](src/client/EntryMenuContent.tsx) under id `entry-customization` into `sidebar.panellist.actions`, `conversation.session.header.views.actions`, and `sidebar.right.tab.guide.actions` through `ctx.slots.inject`, so each registration follows its region's declaration lifetime. The regions render these slots inside their own `Menu` and supply the entries, their pin state, `onOpen`, `onPinnedChange`, and `onDismiss` as owner props. The rows call `onDismiss` before opening an entry or running a footer action; pin changes leave the menu open.
[`apply`](src/client/index.ts) registers [`EntryMenuContent`](src/client/EntryMenuContent.tsx) under id `entry-customization` into `sidebar.panellist.actions`, `conversation.session.header.views.actions`, and `sidebar.right.tab.guide.actions` through `ctx.slots.inject`, so each registration follows its region's declaration lifetime. The regions render these slots inside their own `Menu`; the rows call `onDismiss` before browsing or creating. Only the Conversation menu consumes its owner’s folded entry list and navigation callback; pin callbacks remain unused.
Each registration's `inject` supplies its region and two callbacks. Each callback first calls `ctx.get('productAnalytics')?.track('more_menu_item_click', { region, action })`. Browsing then calls `pluginNavigation.openList()`. Creation then calls `uiWorkspace.startSession(undefined, { agentPreset: 'cordis', prompt, whenDraftExists: 'append' })` with the region's localized prompt; it does not go through `ui-agent-preset`, so the preset chip plays no introduction. The [`dsh-web-app` bundle patch](../../bundle/web-app/cordis.patch.yml) mounts the browser plugin.
@@ -63,12 +63,12 @@ None; the package neither assembles nor sends model requests.
<a id="known-limitations-and-deferred-work"></a>
- If this package is disabled or fails to load after entries were unpinned, those launchers stay hidden and no More menu can restore them. Re-enable the package or clear the region's `dsh.slot.manual-order.*.v1` localStorage key.
- Pin choices live in browser localStorage, which is isolated by protocol, host, and port: changing the Web port or switching between Web and Desktop does not carry them over, and they do not sync across devices.
- More menus provide no visibility controls. Region pin callbacks remain available to other registrations and change only in-memory choices; plugin reload restores defaults.
- Legacy pin values in localStorage are neither read nor written.
- **Create your plugin** does not check whether the Creator preset exists. Without it, Session creation fails, the Workspace shows its creation-failure notice, and no draft is prepared. Draft preparation also requires a resolvable Workspace; without one, the existing flow reports that a Workspace is required.
- Entries keep registration order; the menus offer no reordering, and entries cannot move between regions.
- Conversation owns the estimated tab split; these menus provide no sorting.
- Conversation controls follow the existing nonblank-Session header and Coding Tools eligibility. Right-sidebar controls require a selected Session and appear on its guide page.
- Only active registered launchers appear; the menus are not an inventory of disabled or uninstalled plugins.
- The menus do not list plugins; use the browsing action to open the Plugins page.
<a id="dev-note"></a>
### Dev Note
@@ -1,5 +1,5 @@
---
description: "为三个导航区域的“更多”菜单提供 Pin 控件、插件浏览与创造模式草稿。"
description: "为三个导航区域的更多菜单提供插件浏览与创造模式草稿。"
kind: "package-reference"
---
@@ -11,7 +11,7 @@ kind: "package-reference"
## 概述
为左侧栏、会话 View 标签和右侧栏起始页的“更多”菜单提供内容:各区域可自定义的入口、Pin 按钮、插件浏览与可审阅的创造模式草稿。Web 应用默认加载本包。各区域自己画“更多”按钮并保存自己的 Pin 状态;本包不保存状态。
为左侧栏、会话 View 标签和右侧栏起始页的“更多”菜单提供插件浏览与可审阅的创造模式草稿。Web 应用默认加载本包。各区域自己画“更多”按钮;本包不保存状态。
## 目录
@@ -28,11 +28,11 @@ kind: "package-reference"
`@deepseek-ai/dsh-web-app` 组合包默认加载本包,无需在插件页开启或由 profile 选择。区域只在其 actions slot 有注册项时显示“更多”按钮,因此卸载本包会移除三个按钮。
打开左侧导航入口下方的**更多**、会话 View 标签之后的**更多会话视图**,或右侧栏起始页卡片之后的**更多工具**。每个菜单按注册顺序列出该区域可自定义的入口;插件管理、Chat 和工作区文件始终显示,不列入菜单。点击入口名称会关闭菜单并打开该入口,不改变 Pin 状态。独立的 Pin 按钮在区域中固定或收起该入口,并保持菜单打开;实心 Pin 表示已固定。已收起的入口仍可从菜单打开,Pin 状态在刷新后保留。过长的入口名称会换行,更多工具菜单中则截断为单行。
打开左侧导航入口下方的**更多**、会话 View 标签之后的**更多会话视图**,或右侧栏起始页卡片之后的**更多工具**。左侧栏和起始页菜单仅提供**浏览更多插件**与**创建你的插件**。会话菜单在这两个操作之前列出收起的 View,并标记当前 View。所有菜单都不提供 Pin 或排序操作。
**浏览更多插件**进入插件页主列表,不打开安装弹窗。**创建你的插件**用 Creator 预设打开新 Session 或可复用的空白 Session,并把对应区域的简短本地化插件创建请求加入草稿:空草稿直接以该请求为文字,已有草稿保留其文字、引用和附件,请求另起一段追加在后面。它绝不发送消息。请自行审阅并发送草稿。当前区域没有可自定义入口时,两项操作仍然可用;空列表不添加分隔线。
**浏览更多插件**进入插件页主列表,不打开安装弹窗。**创建你的插件**用 Creator 预设打开新 Session 或可复用的空白 Session,并把对应区域的简短本地化插件创建请求加入草稿:空草稿直接以该请求为文字,已有草稿保留其文字、引用和附件,请求另起一段追加在后面。它绝不发送消息。请自行审阅并发送草稿。两项操作始终可用;仅当前面有收起的 View 时,在浏览操作之前显示分隔线。
在桌面端,**浏览更多插件**与**创建你的插件**在执行前上报 `more_menu_item_click`,`action` 为 `browse_plugins` 或 `create_plugin`,`region` 为菜单所在区域:**更多**为 `sidebar`,**更多会话视图**为 `conversation`,**更多工具**为 `rightbar`。打开入口和更改 Pin 状态不上报,菜单也不上报曝光事件。没有埋点服务时,两项操作行为不变且不上报。
在桌面端,**浏览更多插件**与**创建你的插件**在执行前上报 `more_menu_item_click`,`action` 为 `browse_plugins` 或 `create_plugin`,`region` 为菜单所在区域:**更多**为 `sidebar`,**更多会话视图**为 `conversation`,**更多工具**为 `rightbar`。菜单不上报曝光事件。没有埋点服务时,两项操作行为不变且不上报。
-----
@@ -42,7 +42,7 @@ kind: "package-reference"
<details>
<summary>实现细节——点击展开</summary>
[`apply`](src/client/index.ts) 通过 `ctx.slots.inject` 以 id `entry-customization` 把 [`EntryMenuContent`](src/client/EntryMenuContent.tsx) 注册到 `sidebar.panellist.actions`、`conversation.session.header.views.actions` 和 `sidebar.right.tab.guide.actions`,每个注册项跟随所在区域声明的生命周期。各区域在自己的 `Menu` 中渲染这些 slot,并以 owner props 提供入口、Pin 状态、`onOpen`、`onPinnedChange` 与 `onDismiss`。菜单行先调用 `onDismiss`,再打开入口或执行底部操作;Pin 变化不关闭菜单。
[`apply`](src/client/index.ts) 通过 `ctx.slots.inject` 以 id `entry-customization` 把 [`EntryMenuContent`](src/client/EntryMenuContent.tsx) 注册到 `sidebar.panellist.actions`、`conversation.session.header.views.actions` 和 `sidebar.right.tab.guide.actions`,每个注册项跟随所在区域声明的生命周期。各区域在自己的 `Menu` 中渲染这些 slot;菜单行先调用 `onDismiss`,再执行浏览或创建操作。仅会话菜单消费 owner 提供的收起入口列表及导航回调;Pin 回调不被消费。
每个注册项的 `inject` 提供所属区域与两个回调。每个回调先调用 `ctx.get('productAnalytics')?.track('more_menu_item_click', { region, action })`。浏览随后调用 `pluginNavigation.openList()`。创建随后按区域取本地化提示语,调用 `uiWorkspace.startSession(undefined, { agentPreset: 'cordis', prompt, whenDraftExists: 'append' })`;该调用不经过 `ui-agent-preset`,预设标签不播放介绍动画。[`dsh-web-app` bundle patch](../../bundle/web-app/cordis.patch.yml) 挂载浏览器插件。
@@ -63,12 +63,12 @@ kind: "package-reference"
<a id="known-limitations-and-deferred-work"></a>
- 用户收起入口后,若本包被停用或加载失败,被收起的入口保持隐藏,且没有“更多”菜单可以恢复。重新启用本包或清除对应区域的 `dsh.slot.manual-order.*.v1` localStorage 键即可恢复。
- Pin 状态保存在浏览器 localStorage,按协议、主机和端口隔离:更换 Web 端口或在 Web 与 Desktop 之间切换时不共享,也不跨设备同步。
- 更多菜单不提供入口显隐管理。其他注册项仍可调用区域 Pin 回调,仅修改内存中的选择;插件重新加载后恢复默认值。
- 不读取或写入 localStorage 中的旧 Pin 值。
- **创建你的插件**不检查 Creator 预设是否存在。预设缺失时 Session 创建失败,Workspace 显示创建失败提示,不准备草稿。草稿准备还要求可确定目标 Workspace;没有目标时,现有流程提示需要 Workspace。
- 入口保持注册顺序;菜单不提供排序,入口也不能跨区域移动。
- 会话区域负责按估算值分组;菜单不提供排序。
- 会话控件遵循现有非空 Session 头部与代码工作工具的可用性规则。右侧栏控件要求已选择 Session,并显示在其起始页。
- 仅显示已激活、已注册的入口;菜单不是已停用或未安装插件的清单。
- 菜单不列出插件清单;使用浏览操作进入插件页。
<a id="dev-note"></a>
### 开发备注
@@ -1,12 +1,3 @@
.entryRow { position: relative; }
.entryName { display: flex; align-items: center; gap: 8px; padding-right: 28px; }
.entryName { display: flex; align-items: center; gap: 8px; }
.entryTitle { min-width: 0; overflow-wrap: anywhere; white-space: normal; }
.singleLine .entryTitle { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.pinAnchor { position: absolute; right: 6px; top: 0; bottom: 0; display: flex; align-items: center; }
.pinButton { width: 22px; height: 22px; padding: 0; color: var(--dsw-alias-label-tertiary); }
@media (hover: hover) {
.pinButton:hover { color: var(--dsw-alias-label-primary); }
.pinAnchor[data-pinned='false'] { opacity: 0; }
.entryRow:hover .pinAnchor, .entryRow:focus-within .pinAnchor { opacity: 1; }
}
.activeMark { flex: none; margin-left: auto; }
@@ -1,17 +1,17 @@
/** Rows of a navigation region's More menu: customizable entries, then plugin discovery and creation. */
/** Folded Conversation Views and plugin discovery and creation actions. */
import {
IconPluginPinwheelOutlineRegular, IconPlusOutlineRegular, MenuItemButton,
IconCheckOutlineRegular, IconPluginPinwheelOutlineRegular, IconPlusOutlineRegular, MenuItemButton,
} from '@deepseek-ai/dsh-client-ui-primitives'
import type { InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
import type {} from '@deepseek-ai/dsh-client-ui-sidebar/client'
import { EntryPickerRow } from './EntryPickerRow.tsx'
import css from './EntryMenu.module.css'
/** Navigation region whose More menu hosts one registration. */
export type EntryRegion = 'sidebar' | 'conversation' | 'rightbar'
/** Region identity and footer actions injected into each registration. */
export interface EntryMenuInjected {
/** Region of this registration; selects pin copy and the Creator prompt. */
/** Region of this registration; selects analytics attributes and the Creator prompt. */
readonly region: EntryRegion
/** Open the Plugins main list. */
readonly browsePlugins: () => void
@@ -24,22 +24,23 @@ type EntryMenuContentProps = PropsRuntime<'sidebar.panellist.actions'>
& PropsLocale<'entryCustomization'> & InjectFace<EntryMenuInjected>
/**
* Render the region's entries with pin actions, then the discovery and Creator rows.
* @param props - Owner entries and callbacks, injected region actions, and localized copy.
* Render folded Conversation Views followed by discovery and Creator actions, without pin controls.
* @param props - Owner dismissal callback, injected actions, and localized copy.
* @returns Menu rows for the region's More menu.
*/
export function EntryMenuContent({
entries, onOpen, onPinnedChange, onDismiss, region, browsePlugins, createPlugin, t,
entries, onOpen, region, onDismiss, browsePlugins, createPlugin, t,
}: EntryMenuContentProps) {
const moreEntries = region === 'conversation' ? entries : []
return <>
{entries.map((entry) => {
const pinLabel = t(`${entry.pinned ? 'unpin' : 'pin'}.${region}`)
return <EntryPickerRow key={entry.id} label={entry.label} pinned={entry.pinned} pinLabel={pinLabel}
pinAccessibleLabel={t('pinLabel', { name: entry.label, action: pinLabel })} singleLine={region === 'rightbar'}
onOpen={() => { onDismiss(); onOpen(entry.id) }}
onPin={() => { onPinnedChange(entry.id, !entry.pinned) }} />
})}
<MenuItemButton icon={<IconPluginPinwheelOutlineRegular size={16} />} separatorBefore={entries.length > 0}
{moreEntries.map(entry => <MenuItemButton key={entry.id} current={entry.active === true}
onSelect={() => { onDismiss(); onOpen(entry.id) }}>
<span className={css.entryName}>
<span className={css.entryTitle}>{entry.label}</span>
{entry.active === true && <IconCheckOutlineRegular size={16} className={css.activeMark} />}
</span>
</MenuItemButton>)}
<MenuItemButton separatorBefore={moreEntries.length > 0} icon={<IconPluginPinwheelOutlineRegular size={16} />}
onSelect={() => { onDismiss(); browsePlugins() }}>
{t('install')}
</MenuItemButton>
@@ -1,42 +0,0 @@
/** A launcher row separates navigation from its independently focusable pin action. */
import clsx from 'clsx'
import {
Button, IconPinFillRegular, IconPinOutlineRegular, MenuItemButton, Tooltip,
} from '@deepseek-ai/dsh-client-ui-primitives'
import css from './EntryMenu.module.css'
interface EntryPickerRowProps {
readonly label: string
readonly pinned: boolean
readonly pinLabel: string
readonly pinAccessibleLabel: string
/** Truncate the label to one line instead of wrapping it. */
readonly singleLine: boolean
readonly onOpen: () => void
readonly onPin: () => void
}
/**
* Render two sibling controls so pinning never opens the entry.
* @param props - Entry label, pin state, localized action names and independent callbacks.
* @returns A shared menu row with a trailing pin button.
*/
export function EntryPickerRow({
label, pinned, pinLabel, pinAccessibleLabel, singleLine, onOpen, onPin,
}: EntryPickerRowProps) {
return <div className={clsx(css.entryRow, singleLine && css.singleLine)}>
<MenuItemButton onSelect={onOpen}>
<span className={css.entryName}>
<span className={css.entryTitle}>{label}</span>
</span>
</MenuItemButton>
<span className={css.pinAnchor} data-pinned={pinned}>
<Tooltip label={pinLabel} side="bottom" align="end" delayMs={500} portal>
<Button size="sm" className={css.pinButton} role="menuitem" aria-label={pinAccessibleLabel}
onClick={(event) => { event.stopPropagation(); onPin() }}>
{pinned ? <IconPinFillRegular size={14} /> : <IconPinOutlineRegular size={14} />}
</Button>
</Tooltip>
</span>
</div>
}
@@ -13,7 +13,7 @@ import { en, zh } from './locales.ts'
declare module '@deepseek-ai/dsh-client-ui-slots' {
interface LocaleNamespaceMap {
/** More menu rows: pin actions, footer actions, and Creator draft prompts. */
/** More menu rows: navigation actions, and Creator draft prompts. */
entryCustomization: keyof typeof en
}
}
@@ -1,12 +1,5 @@
/** Copy for the More menu rows: pin actions, footer actions, and Creator draft prompts per region. */
/** Copy for the More menu rows: navigation actions, and Creator draft prompts per region. */
export const en = {
'pin.sidebar': 'Pin to left sidebar',
'unpin.sidebar': 'Unpin from left sidebar',
'pin.conversation': 'Pin to conversation tabs',
'unpin.conversation': 'Unpin from conversation tabs',
'pin.rightbar': 'Pin to right sidebar',
'unpin.rightbar': 'Unpin from right sidebar',
pinLabel: '{action}: {name}',
install: 'Browse other plugins',
create: 'Create your plugin',
'createPrompt.sidebar': 'Create a DSH plugin with a left sidebar entry.',
@@ -16,13 +9,6 @@ export const en = {
/** Chinese counterpart of the entry-customization dictionary. */
export const zh: Record<keyof typeof en, string> = {
'pin.sidebar': '固定到左侧栏',
'unpin.sidebar': '从左侧栏取消固定',
'pin.conversation': '固定到会话标签栏',
'unpin.conversation': '从会话标签栏取消固定',
'pin.rightbar': '固定到右侧栏',
'unpin.rightbar': '从右侧栏取消固定',
pinLabel: '{action}:{name}',
install: '浏览更多插件',
create: '创建你的插件',
'createPrompt.sidebar': '创建一个带左侧栏入口的 DSH 插件。',
@@ -1,8 +1,8 @@
// @vitest-environment jsdom
/** The plugin fills each region's More menu with entry rows and the discovery and Creator actions. */
/** The plugin fills each region's More menu with the discovery and Creator actions. */
import type { Context } from '@deepseek-ai/cordis'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { act, cleanup, fireEvent, render, within } from '@testing-library/react'
import { cleanup, fireEvent, render, within } from '@testing-library/react'
import type { ComponentProps } from 'react'
import { useState } from 'react'
import { makeTranslate, SlotTestRuntime } from '@deepseek-ai/dsh-client-test-runtime'
@@ -30,10 +30,9 @@ function content(overrides: Partial<ContentProps> = {}) {
onDismiss: vi.fn<ContentProps['onDismiss']>(), browsePlugins: vi.fn<ContentProps['browsePlugins']>(),
createPlugin: vi.fn<ContentProps['createPlugin']>(),
}
// Framework hooks stay unused stubs; this isolated row set reads only its owner, inject, and locale shares.
const props = {
entries: [{ id: 'notes', label: 'Notes', pinned: true }, { id: 'tasks', label: 'Tasks', pinned: false }],
region: 'sidebar', t: makeTranslate(en), renderFactorySlot: unused,
region: 'sidebar', t: makeTranslate(en),
usePanelInfo: unused, useSessions: unused, useSessionStatus: unused, useSessionRetainInfo: unused,
useResource: unused, useWorkspaces: unused,
...callbacks,
@@ -44,53 +43,45 @@ function content(overrides: Partial<ContentProps> = {}) {
}
describe('More menu rows', () => {
it('separates opening from pinning and reports the inverted pin state', () => {
const b = content()
expect(b.view.getAllByRole('menuitem').map(row => row.getAttribute('aria-label') ?? row.textContent)).toEqual([
'Notes', 'Unpin from left sidebar: Notes', 'Tasks', 'Pin to left sidebar: Tasks', 'Browse other plugins', 'Create your plugin',
])
expect(b.view.getAllByRole('separator')).toHaveLength(1)
fireEvent.click(b.view.getByRole('menuitem', { name: 'Unpin from left sidebar: Notes' }))
fireEvent.click(b.view.getByRole('menuitem', { name: 'Pin to left sidebar: Tasks' }))
expect(b.onPinnedChange).toHaveBeenNthCalledWith(1, 'notes', false)
expect(b.onPinnedChange).toHaveBeenNthCalledWith(2, 'tasks', true)
expect(b.onDismiss).not.toHaveBeenCalled()
expect(b.onOpen).not.toHaveBeenCalled()
fireEvent.click(b.view.getByRole('menuitem', { name: 'Tasks' }))
expect(b.onDismiss).toHaveBeenCalledOnce()
expect(b.onOpen).toHaveBeenCalledExactlyOnceWith('tasks')
expect(b.onDismiss.mock.invocationCallOrder[0]).toBeLessThan(b.onOpen.mock.invocationCallOrder[0]!)
})
it.each([
['conversation', 'Pin to conversation tabs: Tasks'],
['rightbar', 'Pin to right sidebar: Tasks'],
] as const)('names the %s pin destination', (region, name) => {
const { view } = content({ region })
expect(view.getByRole('menuitem', { name })).toBeTruthy()
})
it('keeps only the discovery and Creator actions, without a separator, for an empty roster', () => {
const b = content({ entries: [], region: 'rightbar' })
it.each(['sidebar', 'rightbar'] as const)('shows only discovery and creation in %s regardless of entries', (region) => {
const b = content({ region, entries: [
{ id: 'notes', label: 'Notes', pinned: true, active: true },
{ id: 'tasks', label: 'Tasks', pinned: false },
] })
expect(b.view.getAllByRole('menuitem').map(row => row.textContent)).toEqual(['Browse other plugins', 'Create your plugin'])
expect(b.view.queryByRole('separator')).toBeNull()
fireEvent.click(b.view.getByRole('menuitem', { name: 'Browse other plugins' }))
expect(b.browsePlugins).toHaveBeenCalledExactlyOnceWith()
expect(b.onDismiss.mock.invocationCallOrder[0]).toBeLessThan(b.browsePlugins.mock.invocationCallOrder[0]!)
fireEvent.click(b.view.getByRole('menuitem', { name: 'Create your plugin' }))
expect(b.createPlugin).toHaveBeenCalledExactlyOnceWith()
expect(b.onDismiss).toHaveBeenCalledTimes(2)
expect(b.onDismiss.mock.invocationCallOrder[1]).toBeLessThan(b.createPlugin.mock.invocationCallOrder[0]!)
expect(b.onOpen).not.toHaveBeenCalled()
expect(b.onPinnedChange).not.toHaveBeenCalled()
})
it('shows the pin hint after the shared tooltip delay outside the menu card', () => {
const { view } = content()
const unpin = view.getByRole('menuitem', { name: 'Unpin from left sidebar: Notes' })
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] })
fireEvent.mouseEnter(unpin)
act(() => { vi.advanceTimersByTime(499) })
expect(view.queryByRole('tooltip', { hidden: true })).toBeNull()
act(() => { vi.advanceTimersByTime(1) })
expect(view.getByRole('tooltip', { hidden: true }).closest('[role="menu"]')).toBeNull()
fireEvent.mouseLeave(unpin)
it('opens folded Conversation Views and marks the active View without pin controls', () => {
const b = content({ region: 'conversation', entries: [
{ id: 'notes', label: 'Notes', pinned: true, active: true },
{ id: 'tasks', label: 'Tasks', pinned: true, active: false },
] })
expect(b.view.getAllByRole('menuitem').map(row => row.textContent))
.toEqual(['Notes', 'Tasks', 'Browse other plugins', 'Create your plugin'])
const currentRow = b.view.getByRole('menuitem', { name: 'Notes' })
expect(currentRow.getAttribute('aria-current')).toBe('page')
expect(currentRow.querySelector('svg')?.previousElementSibling?.textContent).toBe('Notes')
expect(b.view.getByRole('menuitem', { name: 'Tasks' }).hasAttribute('aria-current')).toBe(false)
expect(b.view.getAllByRole('separator')).toHaveLength(1)
fireEvent.click(b.view.getByRole('menuitem', { name: 'Tasks' }))
expect(b.onOpen).toHaveBeenCalledExactlyOnceWith('tasks')
expect(b.onDismiss.mock.invocationCallOrder[0]).toBeLessThan(b.onOpen.mock.invocationCallOrder[0]!)
expect(b.onPinnedChange).not.toHaveBeenCalled()
})
it('keeps both actions for an empty roster', () => {
const b = content({ entries: [] })
expect(b.view.getAllByRole('menuitem').map(row => row.textContent)).toEqual(['Browse other plugins', 'Create your plugin'])
expect(b.view.queryByRole('separator')).toBeNull()
})
})
@@ -101,7 +92,7 @@ function Frame({ renderSlot }: PropsRenderSlots<Seats>) {
return <>
{SEATS.map(seat => <div key={seat} role="menu" aria-label={seat}>
{renderSlot(seat, {
entries,
entries: seat === 'conversation.session.header.views.actions' ? [] : entries,
onOpen: (id) => { setOpened(`${seat}:${id}`) },
onPinnedChange: (_id, next) => { setPinned(next) },
onDismiss: () => {},
@@ -142,15 +133,10 @@ describe('More menu registrations', () => {
const b = await bench()
for (const seat of SEATS) {
expect(b.runtime.slots.entries(seat).map(entry => entry.options.id)).toEqual(['entry-customization'])
expect(within(b.view.getByRole('menu', { name: seat })).getByRole('menuitem', { name: 'Notes' })).toBeTruthy()
expect(within(b.view.getByRole('menu', { name: seat })).getAllByRole('menuitem').map(row => row.textContent))
.toEqual(['Browse other plugins', 'Create your plugin'])
}
const sidebar = within(b.view.getByRole('menu', { name: 'sidebar.panellist.actions' }))
fireEvent.click(sidebar.getByRole('menuitem', { name: 'Unpin from left sidebar: Notes' }))
expect(within(b.view.getByRole('menu', { name: 'sidebar.right.tab.guide.actions' }))
.getByRole('menuitem', { name: 'Pin to right sidebar: Notes' })).toBeTruthy()
fireEvent.click(within(b.view.getByRole('menu', { name: 'conversation.session.header.views.actions' }))
.getByRole('menuitem', { name: 'Notes' }))
expect(b.view.getByTestId('opened').textContent).toBe('conversation.session.header.views.actions:notes')
fireEvent.click(sidebar.getByRole('menuitem', { name: 'Browse other plugins' }))
expect(b.openList).toHaveBeenCalledExactlyOnceWith()
await b.plugin.dispose()
@@ -192,15 +178,6 @@ describe('More menu registrations', () => {
expect(b.track).toHaveBeenCalledTimes(2)
})
it('reports neither entry opening nor pin changes', async () => {
const b = await bench()
const menu = within(b.view.getByRole('menu', { name: 'sidebar.panellist.actions' }))
fireEvent.click(menu.getByRole('menuitem', { name: 'Unpin from left sidebar: Notes' }))
fireEvent.click(menu.getByRole('menuitem', { name: 'Notes' }))
expect(b.view.getByTestId('opened').textContent).toBe('sidebar.panellist.actions:notes')
expect(b.track).not.toHaveBeenCalled()
})
it('keeps both footer actions working without the analytics service', async () => {
const b = await bench('en', { analytics: false })
const menu = within(b.view.getByRole('menu', { name: 'sidebar.right.tab.guide.actions' }))
@@ -1,20 +0,0 @@
/** Pin controls use Session-action ink without changing the navigation control's dimensions. */
import { readFileSync } from 'node:fs'
import { describe, expect, it } from 'vitest'
const css = readFileSync(new URL('../src/client/EntryMenu.module.css', import.meta.url), 'utf8')
describe('entry pin styling', () => {
it('uses tertiary ink for both pin states and primary ink on pointer hover', () => {
expect(css).toMatch(/\.pinButton\s*\{[^}]*color:\s*var\(--dsw-alias-label-tertiary\)/)
expect(css).toMatch(/\.pinButton:hover\s*\{[^}]*color:\s*var\(--dsw-alias-label-primary\)/)
expect(css).not.toMatch(/\[data-pinned=['"]true['"]\]\s+\.pinButton\s*\{[^}]*color:/)
})
it('centers the pin without creating a transformed tooltip container', () => {
const pinAnchor = css.match(/\.pinAnchor\s*\{([^}]*)\}/)?.[1]
expect(pinAnchor).toBeDefined()
expect(pinAnchor).toContain('align-items: center')
expect(pinAnchor).not.toContain('transform:')
})
})
@@ -18,8 +18,8 @@
en: 7bae72c97d0449f5
zh: 4589ecc2b216edc5
/deepseek-ai-dsh-client-ui-primitives/use-this-package/component-catalog:
en: 75315591e4aa1202
zh: 2b1920468ccb715d
en: c804a44a782227cb
zh: 14b53da9bf298c5f
/deepseek-ai-dsh-client-ui-primitives/use-this-package/controls-and-icons:
en: 09c61f61c560f8a2
zh: 895ac73add1199b8
+1 -1
View File
@@ -46,7 +46,7 @@ Check this table before writing a control in a feature package. A plugin cannot
| `Checkbox` | Labeled native checkbox with controlled state, keyboard interaction, and disabled styling; the caller supplies localized `label` text. |
| `Input` | Single-line text entry for search boxes and inline forms. Its ref targets the native input for focus and is cleared on unmount. |
| `InlineEditor` | Autofocused multi-line inline editor for short drafts such as queued messages and goals. It grows with its text and its width up to six lines, then scrolls; Enter saves, Shift+Enter breaks the line, Enter during IME composition does nothing, and Escape cancels. The caller supplies the localized `label` and rebinds the scrollbar on an elevated surface. |
| `Menu`, `MenuItemButton` | Dropdown of `items` data rows, separators, and group labels, with nested submenus; `children` adds component rows, each a `MenuItemButton` (`separatorBefore` starts a new group), in the same list. Every row shares the styling, the keyboard walk, and the focus return; closing stays the owner's state change for both kinds. While open, ↑/↓ (with Home and End) walk the list, Tab settles the focused row, and Escape or Shift+Tab close back to the anchor; selecting a row also returns the keyboard to the anchor unless the owner moved it itself. Only a keyboard on the anchor or inside the list is intercepted, and `autoFocus` decides solely whether opening focuses the first row. |
| `Menu`, `MenuItemButton` | Dropdown of `items` data rows, separators, and group labels, with nested submenus; `children` adds component rows, each a `MenuItemButton` (`separatorBefore` starts a new group), in the same list. `MenuItemButton.current` exposes the current page or view through `aria-current` without changing selection or appearance. Every row shares the styling, the keyboard walk, and the focus return; closing stays the owner's state change for both kinds. While open, ↑/↓ (with Home and End) walk the list, Tab settles the focused row, and Escape or Shift+Tab close back to the anchor; selecting a row also returns the keyboard to the anchor unless the owner moved it itself. Only a keyboard on the anchor or inside the list is intercepted, and `autoFocus` decides solely whether opening focuses the first row. |
| `MenuGroup`, `observeStickyMenuGroups` | Localized, accessible groups for custom menus and listboxes, with shared sticky-heading styling and native intersection/size observation; the caller owns the observer's lifetime. |
| `Pill` | Selectable capsule button for view switchers and filters; takes `active` and `onClick`. |
| `SegmentedTabs` | Controlled equal-width tabs with a sliding indicator and Left/Right, Home, and End navigation. The caller supplies labels, tab/panel ids, and panel content. |
+1 -1
View File
@@ -46,7 +46,7 @@ kind: "package-library"
| `Checkbox` | 带标签的原生复选框,支持受控状态、键盘交互和禁用样式;调用方提供本地化的 `label` 文本。 |
| `Input` | 单行文本输入,用于搜索框与行内表单。ref 指向原生输入框,供焦点控制使用,并在卸载时清空。 |
| `InlineEditor` | 自动聚焦的多行行内编辑器,用于排队消息、goal 等短草稿。它随文本和宽度增高,最多六行,之后滚动;Enter 保存,Shift+Enter 换行,输入法组合中的 Enter 不生效,Escape 取消。调用方提供本地化 `label`,并在 elevated 表面上重绑滚动条。 |
| `Menu`, `MenuItemButton` | 由 `items` 数据行、分隔线与分组标题构成的下拉菜单,支持嵌套子菜单;`children` 在同一列表中加入组件行,每行一个 `MenuItemButton`(`separatorBefore` 开启新分组)。所有行共享样式、键盘走位与焦点归还;两类行的关闭都是 owner 状态的改变。打开期间 `↑`/`↓`(以及 Home、End)在列表中走位,Tab 选定聚焦行,Escape 或 Shift+Tab 关闭并把焦点还给锚点;选定一行同样把键盘还给锚点——除非拥有者自己移动了焦点。只拦截位于锚点或列表内的键盘,`autoFocus` 仅决定打开时是否聚焦首行。 |
| `Menu`, `MenuItemButton` | 由 `items` 数据行、分隔线与分组标题构成的下拉菜单,支持嵌套子菜单;`children` 在同一列表中加入组件行,每行一个 `MenuItemButton`(`separatorBefore` 开启新分组)。`MenuItemButton.current` 通过 `aria-current` 标识当前页面或视图,不改变选中状态或外观。所有行共享样式、键盘走位与焦点归还;两类行的关闭都是 owner 状态的改变。打开期间 `↑`/`↓`(以及 Home、End)在列表中走位,Tab 选定聚焦行,Escape 或 Shift+Tab 关闭并把焦点还给锚点;选定一行同样把键盘还给锚点——除非拥有者自己移动了焦点。只拦截位于锚点或列表内的键盘,`autoFocus` 仅决定打开时是否聚焦首行。 |
| `MenuGroup`、`observeStickyMenuGroups` | 为自定义菜单与列表框提供本地化、可访问的分组,共用吸顶标题样式与原生交叉/尺寸观察;调用方负责观察器的生命周期。 |
| `Pill` | 可选中的胶囊按钮,用于视图切换与筛选器;接受 `active` 与 `onClick`。 |
| `SegmentedTabs` | 受控的等宽分段标签,支持滑动指示条及左/右方向键、Home、End 导航。调用方提供文案、标签与面板 id,以及面板内容。 |
+5 -1
View File
@@ -51,6 +51,8 @@ export interface MenuItemButtonProps {
shortcut?: MenuItem['shortcut']
/** Leading icon (figma .Menu_cell gap 8). */
icon?: ReactNode
/** Whether this row identifies the currently displayed page or view. */
current?: boolean
/** Whether the row cannot be activated. */
disabled?: boolean
/** Destructive row: error-colored text/icon and danger hover fill. */
@@ -75,6 +77,7 @@ export interface MenuItemButtonProps {
* @param props.children - visible row label.
* @param props.shortcut - effective key labels and accessible combination.
* @param props.icon - optional leading icon.
* @param props.current - whether the row identifies the currently displayed page or view.
* @param props.disabled - whether the row cannot be activated.
* @param props.danger - whether to use the destructive row colors.
* @param props.separatorBefore - whether this row starts a new group (hairline above it).
@@ -82,7 +85,7 @@ export interface MenuItemButtonProps {
* @returns one menu-item row.
*/
export function MenuItemButton({
children, shortcut, icon, disabled = false, danger = false, separatorBefore = false, onSelect,
children, shortcut, icon, current, disabled = false, danger = false, separatorBefore = false, onSelect,
}: MenuItemButtonProps) {
return (
<div className={css.itemWrap}>
@@ -93,6 +96,7 @@ export function MenuItemButton({
className={clsx(css.item, danger && css.danger)}
disabled={disabled}
aria-keyshortcuts={shortcut?.aria}
aria-current={current === true ? 'page' : undefined}
onClick={onSelect}
>
{icon !== undefined && <span className={css.itemIcon}>{icon}</span>}
@@ -80,6 +80,21 @@ describe('Input', () => {
})
})
describe('MenuItemButton', () => {
it('exposes the current page without preventing activation and clears it when selection moves', () => {
const onSelect = vi.fn()
const { rerender } = render(<MenuItemButton current onSelect={onSelect}>Notes</MenuItemButton>)
const row = screen.getByRole('menuitem', { name: 'Notes' })
expect(row.getAttribute('aria-current')).toBe('page')
fireEvent.click(row)
expect(onSelect).toHaveBeenCalledOnce()
rerender(<MenuItemButton current={false} onSelect={onSelect}>Notes</MenuItemButton>)
expect(row.hasAttribute('aria-current')).toBe(false)
rerender(<MenuItemButton onSelect={onSelect}>Notes</MenuItemButton>)
expect(row.hasAttribute('aria-current')).toBe(false)
})
})
describe('Menu', () => {
const items = [
{ id: 'a', label: 'Alpha' },
@@ -36,8 +36,8 @@
en: 717b86e05b44ebdd
zh: 0f4f694d0ea1d7f8
/deepseek-ai-dsh-client-ui-sidebar-right/the-guide:
en: dfb9e0911e4278f1
zh: e395e10f61ad040d
en: 0a77be5c16ef83c6
zh: 532545fef09c3e66
/deepseek-ai-dsh-client-ui-sidebar-right/copy:
en: 4315a1c03ca5fef3
zh: c09a09f1d9b99107
+1 -1
View File
@@ -114,7 +114,7 @@ Close and refresh commands resolve the focused pane and tab occurrence. Page bod
Each `guide` entry has an `id` unique within its provider. The guide renders `sidebar.right.tab.guide.entry` by the active definition id, forwarding the entry id, resolved title and optional description, plus the framework `useTabInfo` hook. A provider can register a custom card under its definition id; otherwise the standard icon/title button renders. The sidebar owns width and placement, while custom components own their markup and interaction. Guide cards use the shared R20 radius, including hover fills and provider-owned split buttons. Entry identity remains stable across sorting and unrelated registrations. Unboxed shortcut text aligns at the right. The terminal shell menu sits beside its entry title.
While the root-scoped `sidebar.right.tab.guide.actions` list has registrations, a **More tools** card follows the guide cards. Its menu end-aligns to the card, is at least as wide as the card, and grows with its rows up to 480px or the viewport width minus 24px. The menu rows are that list's registrations. Each receives `entries` (cards in registry order with `id`, `label`, and `pinned`; kind `files` is never listed), `onOpen(id)`, which opens the card's tab type in the guide's place, `onPinnedChange(id, pinned)`, and `onDismiss()`, which closes the menu. The guide keeps one root list of unpinned card ids, each `kind:entry id`, in browser `localStorage` under `dsh.slot.manual-order.sidebar.right.tab.guide.list.v1`, stored as `{ "unpinned": string[] }`; the key reserves the name of a future guide list slot. The kind identifies a card instead of the provider id, so a pin choice survives an extension taking over that kind with the same entry id. Cards of kind `files` always show. Unpinning hides only the card; it closes no tab and changes neither tab-type registration nor the default page. Description density follows the visible card count. Ids of cards that are not registered stay stored; stored content other than a string array shows every card.
While the root-scoped `sidebar.right.tab.guide.actions` list has registrations, a **More tools** card follows the guide cards. Its menu end-aligns to the card, is at least as wide as the card, and grows with its rows up to 480px or the viewport width minus 24px. The menu rows are that list's registrations. Each receives `entries` (cards in registry order with `id`, `label`, and `pinned`; kind `files` is never listed), `onOpen(id)`, which opens the card's tab type in the guide's place, `onPinnedChange(id, pinned)`, and `onDismiss()`, which closes the menu. The guide keeps an in-memory list of unpinned card ids, each `kind:entry id`. Plugin reload resets pin choices; legacy localStorage values are ignored. The default More menu only offers plugin browsing and creation; pin callbacks remain available to other registrations. The kind identifies a card instead of the provider id, so a pin choice survives an extension taking over that kind with the same entry id. Cards of kind `files` always show. Unpinning hides only the card; it closes no tab and changes neither tab-type registration nor the default page. Description density follows the visible card count. Unregistered card ids remain in memory until plugin reload.
A tab type can declare `multiple: true` for independent instances such as terminals. Each open receives a distinct content address, so placement and docking preserve those instances; ordinary pages retain per-pane uniqueness.
@@ -114,7 +114,7 @@ Tab 域按(Session,Tab id)保留品牌化 occurrence id、导航、中止
每个 `guide` 入口具有 provider 内唯一的 `id`。开始页按当前生效的定义 id 渲染 `sidebar.right.tab.guide.entry`,传入入口 id、解析后的标题、可选描述,以及框架提供的 `useTabInfo` hook。provider 可按自己的定义 id 注册自定义卡片;未注册时使用默认图标与标题按钮。侧栏负责宽度和排列,自定义组件负责内部结构和交互。引导卡片统一使用 R20,包含悬停底色与提供方自有的分体按钮。排序或其他注册变化不会改变入口身份。无背景的快捷键文字右对齐。终端 Shell 菜单位于入口标题旁。
root 作用域的 `sidebar.right.tab.guide.actions` list 有注册项时,引导卡片之后显示**更多工具**卡片。它的菜单与卡片末端对齐,不窄于卡片,并随菜单行增宽,最大为 480px 与视口宽度减 24px 中的较小值。菜单行为该 list 的注册项。每个注册项接收 `entries`(按注册表顺序的卡片,含 `id`、`label` 与 `pinned`;不含 kind 为 `files` 的卡片)、在引导页位置打开卡片标签类型的 `onOpen(id)`、`onPinnedChange(id, pinned)`,以及关闭菜单的 `onDismiss()`。引导页在浏览器 `localStorage` 的 `dsh.slot.manual-order.sidebar.right.tab.guide.list.v1` 下保存一份 root 级的已收起卡片 id 列表,每个 id 为 `kind:入口 id`,格式为 `{ "unpinned": string[] }`;该键名为以后的引导入口 list slot 预留。卡片由 kind 而不是提供方 id 标识,因此扩展接管某个 kind 且保持入口 id 时,Pin 选择仍然有效。kind 为 `files` 的卡片始终显示。收起只隐藏卡片,不关闭标签,也不改变标签类型注册或默认页。描述的显示密度按可见卡片数计算。未注册卡片的 id 保留在列表中;保存内容不是字符串数组时,所有卡片都显示。
root 作用域的 `sidebar.right.tab.guide.actions` list 有注册项时,引导卡片之后显示**更多工具**卡片。它的菜单与卡片末端对齐,不窄于卡片,并随菜单行增宽,最大为 480px 与视口宽度减 24px 中的较小值。菜单行为该 list 的注册项。每个注册项接收 `entries`(按注册表顺序的卡片,含 `id`、`label` 与 `pinned`;不含 kind 为 `files` 的卡片)、在引导页位置打开卡片标签类型的 `onOpen(id)`、`onPinnedChange(id, pinned)`,以及关闭菜单的 `onDismiss()`。引导页在内存中保存已收起卡片 id 列表,每个 id 为 `kind:入口 id`。插件重新加载时 Pin 选择重置,不读取旧 localStorage 值。默认更多菜单只提供插件浏览和创建;其他注册项仍可使用 Pin 回调。卡片由 kind 而不是提供方 id 标识,因此扩展接管某个 kind 且保持入口 id 时,Pin 选择仍然有效。kind 为 `files` 的卡片始终显示。收起只隐藏卡片,不关闭标签,也不改变标签类型注册或默认页。描述的显示密度按可见卡片数计算。未注册卡片的 id 保留在内存中,直到插件重新加载。
终端等需要独立实例的 tab 类型可声明 `multiple: true`。每次打开都会获得独立内容地址,因此移动和停靠会保留这些实例;普通页面仍在每个格内去重。
@@ -19,8 +19,7 @@
* open.
*/
import { useState } from 'react'
import clsx from 'clsx'
import { IconAppsAddOutlineRegular, Menu, ShortcutKeys } from '@deepseek-ai/dsh-client-ui-primitives'
import { Menu, ShortcutKeys } from '@deepseek-ai/dsh-client-ui-primitives'
import type { ShortcutCatalogEntry } from '@deepseek-ai/dsh-client-shortcuts/client'
import type { ReactNode } from 'react'
import type { ObservableSnapshot } from '@deepseek-ai/dsh-client-store'
@@ -30,6 +29,7 @@ import type {
import type { SidebarRightGuideActionsOwnerProps } from '../../contract/slots.ts'
import type { SidebarRightGuideBox } from '../../tab-registry.ts'
import { CompassGlyph, CubeGlyph } from './GuideTitle.tsx'
import { GuideMoreIcon } from './GuideMoreIcon.tsx'
import { FIXED_GUIDE_KIND, guideEntryId } from './guide-pins.ts'
import css from './GuideBody.module.css'
@@ -39,7 +39,7 @@ export interface GuideInjected {
readonly hooks: {
readonly shortcuts: ObservableSnapshot<readonly ShortcutCatalogEntry[]>
readonly guideEntries: ObservableSnapshot<readonly SidebarRightGuideBox[]>
/** Unpinned `kind:id` card ids, persisted across reloads. */
/** Unpinned `kind:id` card ids held in memory until plugin reload. */
readonly entryPins: ObservableSnapshot<readonly string[]>
/** Whether `sidebar.right.tab.guide.actions` has registrations. */
readonly entryActions: ObservableSnapshot<boolean>
@@ -106,7 +106,7 @@ function GuideMoreMenu({ label, entries, onOpen, onPinnedChange, renderSlot }: G
anchor={(
<button type="button" className={css.entry} aria-label={label} aria-haspopup="menu" aria-expanded={open}
onClick={() => { setOpen(value => !value) }}>
<span className={clsx(css.entryIcon, css.placeholderInk)}><IconAppsAddOutlineRegular size={22} compact /></span>
<span className={css.entryIcon}><GuideMoreIcon /></span>
<span className={css.entryText}><span className={css.entryTitle}>{label}</span></span>
</button>
)}>
@@ -0,0 +1,15 @@
/** Designer-supplied artwork used only by the right-sidebar More tools card. */
/**
* Render the More tools glyph at its authored size and ink.
* @returns Decorative 22px More tools artwork.
*/
export function GuideMoreIcon() {
return <svg width="22" height="22" viewBox="0 0 22 22" fill="none" xmlns="http://www.w3.org/2000/svg"
stroke="#A6B2C7" strokeWidth="1.38181" aria-hidden="true" focusable="false">
<path d="M5.45873 5.04102H7.90157C8.57615 5.04102 9.123 5.58787 9.123 6.26244V8.70528C9.123 9.37986 8.57615 9.92671 7.90157 9.92671H5.45873C4.78415 9.92671 4.2373 9.37986 4.2373 8.70528V6.26244C4.2373 5.58787 4.78415 5.04102 5.45873 5.04102Z" />
<path d="M5.45873 12.9805H7.90157C8.57615 12.9805 9.123 13.5273 9.123 14.2019V16.6447C9.123 17.3193 8.57615 17.8662 7.90157 17.8662H5.45873C4.78415 17.8662 4.2373 17.3193 4.2373 16.6447V14.2019C4.2373 13.5273 4.78415 12.9805 5.45873 12.9805Z" />
<path d="M13.3992 12.9805H15.842C16.5166 12.9805 17.0634 13.5273 17.0634 14.2019V16.6447C17.0634 17.3193 16.5166 17.8662 15.842 17.8662H13.3992C12.7246 17.8662 12.1777 17.3193 12.1777 16.6447V14.2019C12.1777 13.5273 12.7246 12.9805 13.3992 12.9805Z" />
<path d="M15.0692 3.86523V6.91879M15.0692 6.91879V9.97235M15.0692 6.91879H12.0156M15.0692 6.91879H18.1227" />
</svg>
}
@@ -1,12 +1,10 @@
/** Persisted pin choices for the guide's tool cards. */
/** In-memory pin choices for the guide's tool cards. */
import { createSnapshotStore, type ObservableSnapshot } from '@deepseek-ai/dsh-client-store'
import type { SidebarRightGuideBox } from '../../tab-registry.ts'
/** Tab kind whose card always shows and never enters the customization list. */
export const FIXED_GUIDE_KIND = 'files'
/** Root-level key reserved for the future guide list slot's per-list preferences. */
const STORAGE_KEY = 'dsh.slot.manual-order.sidebar.right.tab.guide.list.v1'
const NO_CARDS: readonly string[] = []
/**
@@ -23,30 +21,23 @@ export interface GuidePins {
/** Unpinned `kind:id` card ids; ids of cards that are not registered stay listed. */
readonly unpinned: ObservableSnapshot<readonly string[]>
/**
* Show or hide one guide card; cards of the fixed kind are ignored.
* Pin or unpin one guide card; cards of the fixed kind are ignored.
* @param id - `kind:id` card id.
* @param pinned - Whether the card shows on the guide.
* @param pinned - Whether the card is manually pinned.
*/
readonly setPinned: (id: string, pinned: boolean) => void
}
/** Stored content is unchecked; anything but a string array leaves every card pinned. */
function unpinnedCards(value: unknown): readonly string[] {
if (typeof value !== 'object' || value === null || !('unpinned' in value)) return NO_CARDS
const { unpinned } = value
return Array.isArray(unpinned) && unpinned.every((id): id is string => typeof id === 'string') ? unpinned : NO_CARDS
}
/**
* Create the persisted guide pin list; the snapshot keeps its reference until the list changes.
* @returns Validated unpinned card ids and the pin mutation.
* Create the in-memory guide pin list; the snapshot keeps its reference until the list changes.
* @returns Unpinned card ids and the pin mutation.
*/
export function createGuidePins(): GuidePins {
const store = createSnapshotStore<unknown>({ unpinned: NO_CARDS }, { persist: { name: STORAGE_KEY } })
const store = createSnapshotStore<{ readonly unpinned: readonly string[] }>({ unpinned: NO_CARDS })
return {
unpinned: { getSnapshot: () => unpinnedCards(store.getSnapshot()), subscribe: listener => store.subscribe(listener) },
unpinned: { getSnapshot: () => store.getSnapshot().unpinned, subscribe: listener => store.subscribe(listener) },
setPinned: (id, pinned) => {
const current = unpinnedCards(store.getSnapshot())
const current = store.getSnapshot().unpinned
if (id.startsWith(`${FIXED_GUIDE_KIND}:`) || pinned !== current.includes(id)) return
store.set({ unpinned: pinned ? current.filter(entry => entry !== id) : [...current, id] })
},
@@ -119,6 +119,12 @@ describe('GuideBody', () => {
const guide = b.view.container.querySelector('[data-sidebar-right-guide]')
expect(guide?.lastElementChild?.contains(more)).toBe(true)
expect(more.getAttribute('aria-haspopup')).toBe('menu')
const glyph = more.querySelector('svg')
expect(glyph?.getAttribute('width')).toBe('22')
expect(glyph?.getAttribute('height')).toBe('22')
expect(glyph?.getAttribute('stroke')).toBe('#A6B2C7')
expect(glyph?.getAttribute('aria-hidden')).toBe('true')
expect(glyph?.querySelectorAll('path')).toHaveLength(4)
fireEvent.click(more)
expect(more.getAttribute('aria-expanded')).toBe('true')
expect(b.actionsOwner().entries).toEqual([
@@ -1,5 +1,5 @@
// @vitest-environment jsdom
/** Guide card pins persist per `kind:id` and ignore every Files card. */
/** Guide card pins stay in memory per `kind:id` and ignore every Files card. */
import { afterEach, describe, expect, it, vi } from 'vitest'
import { createGuidePins, guideEntryId } from '../src/client/tabs/guide/guide-pins.ts'
@@ -11,15 +11,15 @@ describe('guide card pins', () => {
expect(guideEntryId({ kind: 'terminal', id: 'shell' })).toBe('terminal:shell')
})
it('persists unpinned card ids and restores them in a new instance', () => {
it('keeps unpinned card ids in memory without restoring them in a new instance', () => {
const pins = createGuidePins()
const initial = pins.unpinned.getSnapshot()
expect(initial).toEqual([])
expect(pins.unpinned.getSnapshot()).toBe(initial)
pins.setPinned('terminal:shell', false)
pins.setPinned('browser:page', false)
expect(JSON.parse(localStorage.getItem(KEY) ?? 'null')).toEqual({ unpinned: ['terminal:shell', 'browser:page'] })
expect(createGuidePins().unpinned.getSnapshot()).toEqual(['terminal:shell', 'browser:page'])
expect(localStorage.getItem(KEY)).toBeNull()
expect(createGuidePins().unpinned.getSnapshot()).toEqual([])
pins.setPinned('terminal:shell', true)
expect(pins.unpinned.getSnapshot()).toEqual(['browser:page'])
})
@@ -38,20 +38,14 @@ describe('guide card pins', () => {
})
it('keeps ids of cards that are not registered', () => {
localStorage.setItem(KEY, JSON.stringify({ unpinned: ['removed:card'] }))
const pins = createGuidePins()
pins.setPinned('removed:card', false)
pins.setPinned('terminal:shell', false)
expect(pins.unpinned.getSnapshot()).toEqual(['removed:card', 'terminal:shell'])
})
it.each([
['a non-array list', { unpinned: 'terminal:shell' }],
['non-string ids', { unpinned: [false] }],
['a missing list', {}],
['null', null],
['a number', 7],
])('reads %s as no unpinned cards with a stable snapshot', (_name, stored) => {
localStorage.setItem(KEY, JSON.stringify(stored))
it('ignores a legacy stored list instead of restoring it', () => {
localStorage.setItem(KEY, JSON.stringify({ unpinned: ['terminal:shell'] }))
const pins = createGuidePins()
const snapshot = pins.unpinned.getSnapshot()
expect(snapshot).toEqual([])
+6 -6
View File
@@ -21,8 +21,8 @@
en: 098f85b9287d3147
zh: b921c128ac37d2cd
/deepseek-ai-dsh-client-ui-sidebar/use-this-package/global-panel-entries:
en: b109498d88367117
zh: 6d407f265bacdb0d
en: e7f316023445511b
zh: 6271c8c011533877
/deepseek-ai-dsh-client-ui-sidebar/use-this-package/collapse-behavior:
en: 20e744360b4be2cc
zh: a90be7e8526bbb21
@@ -33,11 +33,11 @@
en: 9e7b31acee759f2b
zh: 3a6beb63b23d26ae
/deepseek-ai-dsh-client-ui-sidebar/use-this-package/click-analytics:
en: bb3f4a199ed49039
zh: cc29413fd31fe2e7
en: 8855ad013285a27e
zh: f19b3e75c38c5b0a
/deepseek-ai-dsh-client-ui-sidebar/understand-the-implementation:
en: bc51c446d8b8e098
zh: c0c7bbfb50541d33
en: 39b5e3ca8ce8c5c2
zh: 1d652b7554772dad
/deepseek-ai-dsh-client-ui-sidebar/understand-the-implementation/slot-discipline:
en: 6eefc5c45abe8a8f
zh: 20b651e48a0ea903
+3 -3
View File
@@ -37,7 +37,7 @@ The expanded brand row renders `sidebar.brand.mark` and `sidebar.brand.name` as
Plugins add an icon component to the root-scoped `sidebar.panellist` list with an `id`, optional `order`, and a string or locale-aware `label`. The same id addresses the component registered in the layout's root-scoped `main` keyed slot; selecting a missing main entry throws without changing the current selection. The label supplies plain visible text, the accessible name, and the collapsed tooltip. Expanded navigation rows have a 2px gap. Each row reads its own selected state through `usePanelInfo`; moving DOM focus to search or a directory picker does not change the displayed panel or its selected row. With no panel registrations, neither the list nor spacing for it is rendered. The shipped composition registers no example panel.
While the root-scoped `sidebar.panellist.actions` list has registrations, a **More** button styled as a panel row follows the rows inside the navigation landmark; the collapsed rail shows its label as a tooltip. It opens a menu whose rows are that list's registrations. Each receives `entries` (customizable panels in registration order with `id`, `label`, and `pinned`; Plugins is never listed), `onOpen(id)`, which selects the panel, `onPinnedChange(id, pinned)`, and `onDismiss()`, which closes the menu. The sidebar keeps one root list of unpinned panel ids in browser `localStorage` under `dsh.slot.manual-order.sidebar.panellist.v1`, stored as `{ "unpinned": string[] }` and shared by every Session; the rows omit unpinned panels except the selected one, which keeps its row and `aria-current` until another panel is selected, and Plugins always shows. Unpinning hides only the row; the main panel, its registration, and the current selection stay unchanged. Ids of panels that are not registered stay stored, so a panel that registers again stays unpinned; stored content other than a string array shows every row.
While the root-scoped `sidebar.panellist.actions` list has registrations, a **More** button styled as a panel row follows the rows inside the navigation landmark; the collapsed rail shows its label as a tooltip. It opens a menu whose rows are that list's registrations. Each receives `entries` (customizable panels in registration order with `id`, `label`, `pinned`, and `active`; Plugins is never listed), `onOpen(id)`, which selects the panel, `onPinnedChange(id, pinned)`, and `onDismiss()`, which closes the menu. The sidebar keeps one in-memory list of unpinned panel ids shared by every Session; Plugins always shows. The default More menu only offers plugin browsing and creation, without panel rows or pin controls. Pin callbacks remain available to other registrations, but choices reset when the plugin reloads and legacy localStorage values are ignored. Unpinning hides only the row; the main panel, its registration, and the current selection stay unchanged. Unregistered panel ids remain in the in-memory list until the plugin reloads.
### Collapse behavior
@@ -64,7 +64,7 @@ On Desktop, the official navigation controls report `sidebar_menu_click` with `m
- The expanded brand and the New Session button report `menu_name=new_session` with `position=sidebar`. While the Windows caption hosts the collapsed New Session button, that button reports `position=titlebar`; the macOS New Session control beside the traffic lights also reports `position=titlebar`.
- The **Plugins** and **Automation tasks** rows report `menu_name=plugin` and `menu_name=cron` with `position=sidebar`. Other panel rows report nothing.
- Each **More** button click reports `menu_name=more` with `position=sidebar`, whether it opens or closes the menu.
- A More menu row that opens Plugins or Automation tasks through `onOpen(id)` reports `menu_name=plugin` or `menu_name=cron` with `position=sidebar_more`. The report precedes the registration check, so a request for a panel that has unregistered still counts although nothing is selected.
- A third-party More menu row that calls `onOpen(id)` for Plugins or Automation tasks reports `menu_name=plugin` or `menu_name=cron` with `position=sidebar_more`. The shipped More menu only browses and creates plugins, so it never calls `onOpen`. The report precedes the registration check, so a request for a panel that has unregistered still counts although nothing is selected.
-----
@@ -74,7 +74,7 @@ On Desktop, the official navigation controls report `sidebar_menu_click` with `m
<details>
<summary>Implementation internals — click to expand</summary>
The shell is pure composition: `SidebarRootComponentProps` composes the layout owner share, the global `useSessions` and `useWorkspaces` hooks, the declared brand, the `sidebar.workspaces` and `sidebar.settings` child slots, and injected navigation callbacks. Panel entries and their optional titles use the same composition path. Panel metadata is derived from list registrations and locale changes; selection belongs to the layout store. The injected `trackEntryClick(attributes)` forwards to `ctx.get('productAnalytics')?.track('sidebar_menu_click', attributes)`, and `selectPanel` only selects, so each control supplies its own entry and position.
The shell is pure composition: `SidebarRootComponentProps` composes the layout owner share, the global `useSessions` and `useWorkspaces` hooks, the declared brand, the `sidebar.workspaces` and `sidebar.settings` child slots, and injected navigation callbacks. The sidebar owns `sidebar.panellist` and renders each panel's registered icon directly. Panel metadata is derived from list registrations and locale changes; selection belongs to the layout store. The injected `trackEntryClick(attributes)` forwards to `ctx.get('productAnalytics')?.track('sidebar_menu_click', attributes)`, and `selectPanel` only selects, so each control supplies its own entry and position.
### Slot discipline
+3 -3
View File
@@ -37,7 +37,7 @@ dsh Web 客户端的侧边栏让用户识别当前构建、启动新会话、将
插件在 root 作用域的 `sidebar.panellist` list 中注册图标组件,提供 `id`、可选 `order`,以及字符串或 locale-aware 的 `label`。同一个 id 寻址布局中 root 作用域 `main` keyed slot 的组件;选择不存在的主面板条目会抛错,并保留当前选中态。标签提供普通可见文字、无障碍名称和折叠提示。展开态导航行的间距为 2px。每一行通过 `usePanelInfo` 读取自己的选中态;DOM 焦点移到搜索框或目录选择器时,显示的面板及其列表项选中态不变。没有面板注册项时,列表及其间距均不渲染。产品随附的组合不注册示例面板。
root 作用域的 `sidebar.panellist.actions` list 有注册项时,导航区域的面板行之后显示面板行样式的**更多**按钮;收起的窄栏以提示框显示其名称。它打开的菜单以该 list 的注册项为菜单行。每个注册项接收 `entries`(按注册顺序的可自定义面板,含 `id`、`label` 与 `pinned`;不含插件管理)、选中面板的 `onOpen(id)`、`onPinnedChange(id, pinned)`,以及关闭菜单的 `onDismiss()`。侧边栏在浏览器 `localStorage` 的 `dsh.slot.manual-order.sidebar.panellist.v1` 下保存一份 root 级的已收起面板 id 列表,格式为 `{ "unpinned": string[] }`,所有会话共用;面板行不显示已收起的面板,但当前选中的面板保留其行和 `aria-current`,直到选中其他面板;插件管理入口始终显示。收起只隐藏导航行,主面板、其注册和当前选中态不变。未注册面板的 id 保留在列表中,因此重新注册的面板保持收起;保存内容不是字符串数组时,所有行都显示。
root 作用域的 `sidebar.panellist.actions` list 有注册项时,导航区域的面板行之后显示面板行样式的**更多**按钮;收起的窄栏以提示框显示其名称。它打开的菜单以该 list 的注册项为菜单行。每个注册项接收 `entries`(按注册顺序的可自定义面板,含 `id`、`label`、`pinned` 与 `active`;不含插件管理)、选中面板的 `onOpen(id)`、`onPinnedChange(id, pinned)`,以及关闭菜单的 `onDismiss()`。侧边栏在内存中保存所有会话共用的已收起面板 id 列表;插件管理入口始终显示。默认更多菜单只提供插件浏览和创建,不列出面板或 Pin 控件。其他注册项仍可调用 Pin 回调,但插件重新加载时选择重置,不读取旧 localStorage 值。收起只隐藏导航行,主面板、其注册和当前选中态不变。未注册面板的 id 保留在内存列表中,直到插件重新加载。
### 折叠行为
@@ -64,7 +64,7 @@ root 作用域的 `sidebar.panellist.actions` list 有注册项时,导航区
- 展开态品牌与新建会话按钮上报 `menu_name=new_session`、`position=sidebar`。Windows 顶栏承载收起态新建会话按钮时,该按钮上报 `position=titlebar`;macOS 红绿灯旁的新建会话控件同样上报 `position=titlebar`。
- **插件**与**自动化任务**面板行分别上报 `menu_name=plugin` 与 `menu_name=cron`,`position=sidebar`。其他面板行不上报。
- 每次点击**更多**按钮都上报 `menu_name=more`、`position=sidebar`,无论打开还是关闭菜单。
- 更多菜单中的行通过 `onOpen(id)` 打开插件或自动化任务时,上报 `menu_name=plugin` 或 `menu_name=cron`,`position=sidebar_more`。上报先于注册检查,因此所请求的面板已注销时仍会计数,但不会选中任何面板。
- 第三方注册的更多菜单行通过 `onOpen(id)` 打开插件或自动化任务时,上报 `menu_name=plugin` 或 `menu_name=cron`,`position=sidebar_more`。随产品提供的更多菜单只浏览和创建插件,不会调用 `onOpen`。上报先于注册检查,因此所请求的面板已注销时仍会计数,但不会选中任何面板。
-----
@@ -74,7 +74,7 @@ root 作用域的 `sidebar.panellist.actions` list 有注册项时,导航区
<details>
<summary>实现细节——点击展开</summary>
外壳是纯组合:`SidebarRootComponentProps` 组合布局 owner share、全局 `useSessions` 与 `useWorkspaces` 钩子、已声明的品牌、`sidebar.workspaces` 与 `sidebar.settings` 子 slot,以及注入的导航回调。面板入口及其可选标题使用相同的组合方式。面板元数据由列表注册和 locale 变化派生;选中态属于布局存储。注入的 `trackEntryClick(attributes)` 转发到 `ctx.get('productAnalytics')?.track('sidebar_menu_click', attributes)`;`selectPanel` 只负责选中,因此由各控件自行提供入口与位置。
外壳是纯组合:`SidebarRootComponentProps` 组合布局 owner share、全局 `useSessions` 与 `useWorkspaces` 钩子、已声明的品牌、`sidebar.workspaces` 与 `sidebar.settings` 子 slot,以及注入的导航回调。侧边栏声明 `sidebar.panellist`,并直接渲染各面板注册的图标。面板元数据由列表注册和 locale 变化派生;选中态属于布局存储。注入的 `trackEntryClick(attributes)` 转发到 `ctx.get('productAnalytics')?.track('sidebar_menu_click', attributes)`;`selectPanel` 只负责选中,因此由各控件自行提供入口与位置。
### slot 纪律
@@ -332,7 +332,7 @@ export function SidebarRoot({
{panels.length > 0 && (
<nav className={css.panelList} aria-label={t('panels.label')}>
{panels.filter(({ id }) => id === FIXED_PANEL_ID || id === activePanelId || !unpinned.includes(id)).map(({ id, label }) => (
{panels.filter(({ id }) => id === FIXED_PANEL_ID || !unpinned.includes(id)).map(({ id, label }) => (
<PanelRow
key={id}
id={id}
@@ -349,7 +349,7 @@ export function SidebarRoot({
wide={wide}
label={t('panels.more')}
entries={panels.filter(({ id }) => id !== FIXED_PANEL_ID)
.map(({ id, label }) => ({ id, label, pinned: !unpinned.includes(id) }))}
.map(({ id, label }) => ({ id, label, pinned: !unpinned.includes(id), active: id === activePanelId }))}
onOpen={(id) => {
const entry = officialEntry(id)
if (entry !== undefined) trackEntryClick({ menu_name: entry, position: 'sidebar_more' })
@@ -95,6 +95,8 @@ export interface SidebarPanelActionEntry {
readonly label: string
/** False while the launcher is unpinned from the sidebar. */
readonly pinned: boolean
/** Whether this panel is selected, independently of its pin state. */
readonly active?: boolean
}
/** Owner share of the panel list's More menu rows. */
@@ -169,7 +171,7 @@ export type SidebarRootInjected = {
hooks: {
panels: ObservableSnapshot<readonly SidebarPanelMetadata[]>
shortcuts: ObservableSnapshot<readonly ShortcutCatalogEntry[]>
/** Ids of unpinned panel launchers, persisted across reloads. */
/** Ids of unpinned panel launchers held in memory until plugin reload. */
entryPins: ObservableSnapshot<readonly string[]>
/** Whether `sidebar.panellist.actions` has registrations. */
entryActions: ObservableSnapshot<boolean>
@@ -1,11 +1,9 @@
/** Persisted pin choices for the global panel launchers. */
/** In-memory pin choices for the global panel launchers. */
import { createSnapshotStore, type ObservableSnapshot } from '@deepseek-ai/dsh-client-store'
/** Panel whose launcher always shows and never enters the customization list. */
export const FIXED_PANEL_ID = 'plugins'
/** Root-level key shared with the slot layer's future per-list preferences. */
const STORAGE_KEY = 'dsh.slot.manual-order.sidebar.panellist.v1'
const NO_PANELS: readonly string[] = []
/** Unpinned panel ids and their only mutation. */
@@ -13,30 +11,23 @@ export interface PanelPins {
/** Unpinned panel ids; ids of panels that are not registered stay listed. */
readonly unpinned: ObservableSnapshot<readonly string[]>
/**
* Show or hide one panel launcher; the fixed panel is ignored.
* Pin or unpin one panel launcher; the fixed panel is ignored.
* @param id - Panel id.
* @param pinned - Whether the launcher shows in the sidebar.
* @param pinned - Whether the launcher is manually pinned.
*/
readonly setPinned: (id: string, pinned: boolean) => void
}
/** Stored content is unchecked; anything but a string array leaves every panel pinned. */
function unpinnedPanels(value: unknown): readonly string[] {
if (typeof value !== 'object' || value === null || !('unpinned' in value)) return NO_PANELS
const { unpinned } = value
return Array.isArray(unpinned) && unpinned.every((id): id is string => typeof id === 'string') ? unpinned : NO_PANELS
}
/**
* Create the persisted panel pin list; the snapshot keeps its reference until the list changes.
* @returns Validated unpinned panel ids and the pin mutation.
* Create the in-memory panel pin list; the snapshot keeps its reference until the list changes.
* @returns Unpinned panel ids and the pin mutation.
*/
export function createPanelPins(): PanelPins {
const store = createSnapshotStore<unknown>({ unpinned: NO_PANELS }, { persist: { name: STORAGE_KEY } })
const store = createSnapshotStore<{ readonly unpinned: readonly string[] }>({ unpinned: NO_PANELS })
return {
unpinned: { getSnapshot: () => unpinnedPanels(store.getSnapshot()), subscribe: listener => store.subscribe(listener) },
unpinned: { getSnapshot: () => store.getSnapshot().unpinned, subscribe: listener => store.subscribe(listener) },
setPinned: (id, pinned) => {
const current = unpinnedPanels(store.getSnapshot())
const current = store.getSnapshot().unpinned
if (id === FIXED_PANEL_ID || pinned !== current.includes(id)) return
store.set({ unpinned: pinned ? current.filter(entry => entry !== id) : [...current, id] })
},
@@ -125,13 +125,23 @@ describe('ui-sidebar apply', () => {
const fiber = b.ctx.plugin({ inject: [...inject], apply })
await fiber.await()
expect(b.slots.entries('sidebar')).toHaveLength(0)
const disposeRoot = b.slots.register({
expect(b.slots.spec('sidebar.panellist')).toBeUndefined()
const declareSidebar = () => b.slots.register({
name: 'root',
children: { sidebar: { kind: 'single', scope: 'root' } },
}, SidebarFrame)
const disposeRoot = declareSidebar()
expect(b.slots.entries('sidebar')).toHaveLength(1)
expect(b.slots.spec('sidebar.panellist')).toEqual({ kind: 'list', scope: 'root' })
disposeRoot()
expect(b.slots.entries('sidebar')).toEqual([])
expect(b.slots.spec('sidebar.panellist')).toBeUndefined()
const disposeReplacement = declareSidebar()
expect(b.slots.entries('sidebar')).toHaveLength(1)
expect(b.slots.spec('sidebar.panellist')).toEqual({ kind: 'list', scope: 'root' })
await fiber.dispose()
expect(b.slots.spec('sidebar.panellist')).toBeUndefined()
disposeReplacement()
})
it('projects plugin-owned panel entries and removes them with their owner', async () => {
@@ -91,7 +91,8 @@ function ProbeActions({ entries, onOpen, onPinnedChange, onDismiss }: PropsRunti
<>
{entries.map(entry => (
<span key={entry.id}>
<button type="button" role="menuitem" onClick={() => { onOpen(entry.id) }}>Open {entry.label}</button>
<button type="button" role="menuitem" aria-current={entry.active ? 'page' : undefined}
onClick={() => { onOpen(entry.id) }}>Open {entry.label}</button>
<button type="button" role="menuitem" onClick={() => { onPinnedChange(entry.id, !entry.pinned) }}>
{entry.pinned ? 'Unpin' : 'Pin'} {entry.label}
</button>
@@ -323,21 +324,28 @@ describe('sidebar global panels', () => {
expect(view.getByRole('heading', { name: 'Alpha content' })).toBeTruthy()
act(() => { locale.setLocale('zh') })
expect(view.queryByRole('navigation')).toBeNull()
await runtime.mount({ inject: [...inject], apply })
const restored = await view.findByRole('button', { name: '甲面板' })
expect(restored.getAttribute('aria-current')).toBe('page')
const icon = within(restored).getByTestId(`${ALPHA}-icon`)
expect(icon.getAttribute('data-active')).toBe('true')
expect(icon.querySelector('svg')?.getAttribute('width')).toBe('16')
expect(runtime.slots.entries('sidebar.panellist').map(entry => entry.options.id)).toEqual([BETA, ALPHA])
})
})
describe('sidebar launcher pins', () => {
const PINS_KEY = 'dsh.slot.manual-order.sidebar.panellist.v1'
it('hides stored unpinned panels, keeps Plugins, and keeps the list while panels are registered', async () => {
it('ignores stored unpinned panels and shows all registered launchers', async () => {
localStorage.setItem(PINS_KEY, JSON.stringify({ unpinned: [ALPHA, BETA, PLUGINS, 'removed-plugin'] }))
const { runtime, locale, view } = await bench()
await mountPanels(runtime, locale)
const navigation = await view.findByRole('navigation', { name: 'Global panels' })
expect(within(navigation).queryAllByRole('button')).toEqual([])
expect(within(navigation).getAllByRole('button').map(row => row.textContent)).toEqual(['Beta panel', 'Alpha panel'])
await mountPanel(runtime, { id: PLUGINS, heading: 'Plugins content', label: 'Plugins', order: 0 })
await waitFor(() => {
expect(within(navigation).getAllByRole('button').map(row => row.textContent)).toEqual(['Plugins'])
expect(within(navigation).getAllByRole('button').map(row => row.textContent)).toEqual(['Plugins', 'Beta panel', 'Alpha panel'])
})
expect(runtime.slots.entries('main').map(entry => entry.options.key)).toEqual(['conversation', ALPHA, BETA, PLUGINS])
})
@@ -377,16 +385,25 @@ describe('sidebar launcher pins', () => {
.toEqual(['Open Beta panel', 'Unpin Beta panel', 'Open Alpha panel', 'Unpin Alpha panel', 'Dismiss'])
fireEvent.click(menu.getByRole('menuitem', { name: 'Unpin Alpha panel' }))
expect(JSON.parse(localStorage.getItem(PINS_KEY) ?? 'null')).toEqual({ unpinned: [ALPHA] })
expect(localStorage.getItem(PINS_KEY)).toBeNull()
await waitFor(() => { expect(within(navigation).queryByRole('button', { name: 'Alpha panel' })).toBeNull() })
expect(menu.getByRole('menuitem', { name: 'Pin Alpha panel' })).toBeTruthy()
fireEvent.click(menu.getByRole('menuitem', { name: 'Open Alpha panel' }))
expect(layout.selectPanel).toHaveBeenLastCalledWith(ALPHA)
expect(view.getByRole('heading', { name: 'Alpha content' })).toBeTruthy()
// The open panel keeps its unpinned row as the current page until another panel is selected.
await waitFor(() => { expect(within(navigation).queryByRole('button', { name: 'Alpha panel' })).toBeNull() })
fireEvent.click(menu.getByRole('menuitem', { name: 'Pin Alpha panel' }))
const alphaRow = await within(navigation).findByRole('button', { name: 'Alpha panel' })
expect(alphaRow.getAttribute('aria-current')).toBe('page')
fireEvent.click(menu.getByRole('menuitem', { name: 'Unpin Alpha panel' }))
await waitFor(() => { expect(within(navigation).queryByRole('button', { name: 'Alpha panel' })).toBeNull() })
expect(view.getByRole('heading', { name: 'Alpha content' })).toBeTruthy()
expect(menu.getByRole('menuitem', { name: 'Open Alpha panel' }).getAttribute('aria-current')).toBe('page')
act(() => { layout.selectPanel(BETA) })
await waitFor(() => {
expect(menu.getByRole('menuitem', { name: 'Open Alpha panel' }).hasAttribute('aria-current')).toBe(false)
expect(menu.getByRole('menuitem', { name: 'Open Beta panel' }).getAttribute('aria-current')).toBe('page')
})
await waitFor(() => { expect(within(navigation).queryByRole('button', { name: 'Alpha panel' })).toBeNull() })
fireEvent.click(menu.getByRole('menuitem', { name: 'Pin Alpha panel' }))
@@ -1,5 +1,5 @@
// @vitest-environment jsdom
/** Sidebar launcher pins persist per panel id and ignore the fixed Plugins panel. */
/** Sidebar launcher pins stay in memory per panel id and ignore the fixed Plugins panel. */
import { afterEach, describe, expect, it, vi } from 'vitest'
import { createPanelPins } from '../src/client/panel-pins.ts'
@@ -7,7 +7,7 @@ const KEY = 'dsh.slot.manual-order.sidebar.panellist.v1'
afterEach(() => { localStorage.clear(); vi.restoreAllMocks() })
describe('sidebar panel pins', () => {
it('persists unpinned panel ids and restores them in a new instance', () => {
it('keeps unpinned panel ids in memory without restoring them in a new instance', () => {
const pins = createPanelPins()
const listener = vi.fn()
pins.unpinned.subscribe(listener)
@@ -17,12 +17,12 @@ describe('sidebar panel pins', () => {
pins.setPinned('notes', false)
pins.setPinned('tasks', false)
expect(pins.unpinned.getSnapshot()).toEqual(['notes', 'tasks'])
expect(JSON.parse(localStorage.getItem(KEY) ?? 'null')).toEqual({ unpinned: ['notes', 'tasks'] })
expect(localStorage.getItem(KEY)).toBeNull()
expect(listener).toHaveBeenCalledTimes(2)
expect(createPanelPins().unpinned.getSnapshot()).toEqual(['notes', 'tasks'])
expect(createPanelPins().unpinned.getSnapshot()).toEqual([])
pins.setPinned('notes', true)
expect(pins.unpinned.getSnapshot()).toEqual(['tasks'])
expect(JSON.parse(localStorage.getItem(KEY) ?? 'null')).toEqual({ unpinned: ['tasks'] })
expect(localStorage.getItem(KEY)).toBeNull()
})
it('ignores Plugins and repeated choices without republishing', () => {
@@ -39,20 +39,14 @@ describe('sidebar panel pins', () => {
})
it('keeps ids of panels that are not registered', () => {
localStorage.setItem(KEY, JSON.stringify({ unpinned: ['removed-plugin'] }))
const pins = createPanelPins()
pins.setPinned('removed-plugin', false)
pins.setPinned('notes', false)
expect(pins.unpinned.getSnapshot()).toEqual(['removed-plugin', 'notes'])
})
it.each([
['a non-array list', { unpinned: 'notes' }],
['non-string ids', { unpinned: ['notes', 1] }],
['a missing list', { order: ['notes'] }],
['null', null],
['a string', 'notes'],
])('reads %s as no unpinned panels with a stable snapshot', (_name, stored) => {
localStorage.setItem(KEY, JSON.stringify(stored))
it('ignores a legacy stored list instead of restoring it', () => {
localStorage.setItem(KEY, JSON.stringify({ unpinned: ['notes'] }))
const pins = createPanelPins()
const snapshot = pins.unpinned.getSnapshot()
expect(snapshot).toEqual([])
@@ -1859,7 +1859,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
},
],
ownerProps: [
'/** Owner share of the View tab row\'s More menu rows. */\nexport interface ConversationViewActionsOwnerProps {\n /** Available Views in registration order; Chat is never listed. */\n readonly entries: readonly ConversationViewActionEntry[]\n /**\n * Select a View in this Session without changing its pin state.\n * @param id - Listed View id.\n */\n readonly onOpen: (id: string) => void\n /**\n * Show (true) or unpin (false) a View tab in every Session.\n * @param id - Listed View id.\n * @param pinned - Whether the tab shows in the header.\n */\n readonly onPinnedChange: (id: string, pinned: boolean) => void\n /** Close the More menu. */\n readonly onDismiss: () => void\n}',
'/** Owner share of the View tab row\'s More menu rows. */\nexport interface ConversationViewActionsOwnerProps {\n /** Views outside the header in registration order. */\n readonly entries: readonly ConversationViewActionEntry[]\n /**\n * Select a View in this Session without changing its pin state.\n * @param id - Listed View id.\n */\n readonly onOpen: (id: string) => void\n /**\n * Pin or unpin a View in every Session.\n * @param id - Listed View id.\n * @param pinned - Whether the View is manually pinned; the width budget can still fold it into More.\n */\n readonly onPinnedChange: (id: string, pinned: boolean) => void\n /** Close the More menu. */\n readonly onDismiss: () => void\n}',
],
ownerPropsReferences: [
'ConversationViewActionEntry',