From 5b559def7c8288cf6060f7a23cd6c4bedf5d6f74 Mon Sep 17 00:00:00 2001 From: Yif <877193178@qq.com> Date: Sun, 25 Oct 2026 14:35:30 +0800 Subject: [PATCH] feat(ui): customize sidebar plugin pins and More menu --- docs/subsystems/slots.i18n.yaml | 4 +- docs/subsystems/slots.md | 2 +- docs/subsystems/slots.zh.md | 2 +- .../ui-entry-customization/README.i18n.yaml | 12 +-- .../client/ui-entry-customization/README.md | 10 +-- .../ui-entry-customization/README.zh.md | 10 +-- .../src/client/EntryMenu.module.css | 29 +++++++ .../src/client/EntryMenuContent.tsx | 38 +++++++--- .../src/client/locales.ts | 16 ++++ .../tests/menu.client.spec.tsx | 42 +++++++++-- packages/client/ui-sidebar/README.i18n.yaml | 8 +- packages/client/ui-sidebar/README.md | 4 +- packages/client/ui-sidebar/README.zh.md | 4 +- .../ui-sidebar/src/client/PanelIcon.tsx | 12 +++ .../src/client/SidebarRoot.module.css | 68 +++++++++++++++-- .../ui-sidebar/src/client/SidebarRoot.tsx | 75 ++++++++++++------- .../ui-sidebar/src/client/contract/slots.ts | 13 +++- .../client/ui-sidebar/src/client/index.ts | 36 +++++---- .../client/ui-sidebar/src/client/locales.ts | 16 ++++ .../tests/panel-list.client.spec.tsx | 11 ++- .../tests/pointer-scrollbars.client.spec.tsx | 1 + .../tests/sidebar-root.client.spec.tsx | 7 ++ .../src/client/slot-catalog.ts | 18 ++--- 23 files changed, 332 insertions(+), 106 deletions(-) create mode 100644 packages/client/ui-sidebar/src/client/PanelIcon.tsx diff --git a/docs/subsystems/slots.i18n.yaml b/docs/subsystems/slots.i18n.yaml index e5b5c959fc..3397485d88 100644 --- a/docs/subsystems/slots.i18n.yaml +++ b/docs/subsystems/slots.i18n.yaml @@ -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: 9df94e585e2e4809 - zh: ecc4f05a2cc54b31 + en: 15c49af3ed8b57ff + zh: e7f9189de6f4334c /web-client-slots/declaration-and-lifecycle: en: f2d2c0d3b8f14ec7 zh: 441ed13ff3f81d2a diff --git a/docs/subsystems/slots.md b/docs/subsystems/slots.md index 87f36a0951..1e551c2e51 100644 --- a/docs/subsystems/slots.md +++ b/docs/subsystems/slots.md @@ -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`. 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. +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. The `sidebar.panel.icon` root-scoped factory owns the `sidebar.panellist` list and renders its registered icon by `id`, `size`, and `active` for both sidebar rows and More entries. Each region keeps its own unpinned entry ids in memory; [`ui-entry-customization`](../../packages/client/ui-entry-customization/README.md) registers the shipped rows. [ui-workspace](../../packages/client/ui-workspace/README.md#understand-the-implementation) documents Session-row extension positions, visibility, and keyboard behavior. diff --git a/docs/subsystems/slots.zh.md b/docs/subsystems/slots.zh.md index 8b70a28100..8260a34075 100644 --- a/docs/subsystems/slots.zh.md +++ b/docs/subsystems/slots.zh.md @@ -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`。面板和 View 入口还通过可选的 `active` 状态标识当前选中项,该状态独立于 Pin 状态。各区域在内存中维护已收起的入口 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 状态。root 作用域的 `sidebar.panel.icon` factory 拥有 `sidebar.panellist` list,按 `id`、`size` 与 `active` 为侧栏行和更多菜单入口渲染已注册图标。各区域在内存中维护已收起的入口 id;[`ui-entry-customization`](../../packages/client/ui-entry-customization/README.zh.md) 注册随产品提供的菜单行。 [ui-workspace](../../packages/client/ui-workspace/README.zh.md#understand-the-implementation) 记录 Session 行的扩展位置、显示条件和键盘行为。 diff --git a/packages/client/ui-entry-customization/README.i18n.yaml b/packages/client/ui-entry-customization/README.i18n.yaml index acd7ff8b16..54af01f476 100644 --- a/packages/client/ui-entry-customization/README.i18n.yaml +++ b/packages/client/ui-entry-customization/README.i18n.yaml @@ -15,11 +15,11 @@ en: 76368b6b1da79c80 zh: 52384aee991ac542 /deepseek-ai-dsh-client-ui-entry-customization/use-this-package: - en: 795f14554e3ff1b0 - zh: ed6d8702c9cfa4e7 + en: 38c78ee0aa7349e6 + zh: 911a68fd90e71ae4 /deepseek-ai-dsh-client-ui-entry-customization/understand-the-implementation: - en: 65738fbe4d77e47a - zh: 201acc369942cab4 + en: cbde7d9e17be4db1 + zh: c768e2bdb98727ba /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: 5b4914a3ef05fa9c - zh: 9cbe8a17a88ee4d6 + en: 2ef90af5f52401b7 + zh: d5c85d3fa7aeaf15 /deepseek-ai-dsh-client-ui-entry-customization/known-limitations-and-deferred-work/dev-note: en: e67f633dc6fe7773 zh: 3bb0c20c93f16f98 diff --git a/packages/client/ui-entry-customization/README.md b/packages/client/ui-entry-customization/README.md index 148fd6862d..25f6da87b9 100644 --- a/packages/client/ui-entry-customization/README.md +++ b/packages/client/ui-entry-customization/README.md @@ -28,13 +28,13 @@ Browse plugins and prepare a reviewable Creator draft from the More menus of the 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. Left-sidebar and guide menus offer only **Browse other plugins** (when available) and **Create your plugin**. The Conversation menu also lists folded Views before those actions and marks the active View with a check on the right. None of these menus provide pin controls or sorting. +Open **More** beneath the left navigation entries, **More conversation views** after the Conversation View tabs, or **More tools** after the right-sidebar guide cards. All three menus offer **Browse other plugins** (when available) and **Create your plugin**. The left-sidebar menu lists only unpinned sidebar entries, each with its registered panel icon, before those actions: opening an entry does not pin it, while its separate pin icon returns it to the sidebar and dismisses the menu. Plugins stays fixed in the sidebar. Sidebar and menu entries retain the same registration order; pinning does not reorder them. The current sidebar entry shows a right-aligned check that is replaced in place by the pin action on row hover or keyboard focus. Hovering the pin changes only its color and keeps the whole row highlighted; non-hover devices show the pin action directly. Its tooltip says “Pin to left sidebar”. The Conversation menu also lists folded Views before those actions and marks the active View with a check on the right. Guide and Conversation menus provide no pin controls; none of the menus provide 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. Creation remains available regardless of the region’s entries; a separator appears only between folded Views and the actions, before browsing when available or before creation otherwise. +**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. Creation remains available regardless of the region’s entries; a separator appears only between unpinned sidebar entries or folded Views and the actions, before browsing when available or before creation otherwise. 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. -The browse-plugins row appears only while the `pluginNavigation` service is present; the plugin starts without it. Its absence removes only browsing, not More buttons, folded Views, or creation. +The browse-plugins row appears only while the `pluginNavigation` service is present; the plugin starts without it. Its absence removes only browsing, not More buttons, unpinned sidebar entries, folded Views, or creation. ----- @@ -44,7 +44,7 @@ The browse-plugins row appears only while the `pluginNavigation` service is pres
Implementation internals — click to expand -[`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. +[`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. The sidebar menu consumes its owner’s unpinned entries and navigation and pin callbacks; the Conversation menu consumes its owner’s folded entry list and navigation callback. 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 `ctx.get('pluginNavigation')?.openList()`; ui-plugin-manager provides that service only while the Plugins page has access, so this package reads it optionally and never lists it in `inject`. 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. @@ -65,7 +65,7 @@ None; the package neither assembles nor sends model requests. -- More menus provide no visibility controls. Region pin callbacks remain available to other registrations and change only in-memory choices; plugin reload restores defaults. +- Only the sidebar More menu provides pin controls. Pin choices remain in memory in the region owner; 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. - Conversation owns the estimated tab split; these menus provide no sorting. diff --git a/packages/client/ui-entry-customization/README.zh.md b/packages/client/ui-entry-customization/README.zh.md index 4bb7cf5379..83b2c22abe 100644 --- a/packages/client/ui-entry-customization/README.zh.md +++ b/packages/client/ui-entry-customization/README.zh.md @@ -28,13 +28,13 @@ kind: "package-reference" `@deepseek-ai/dsh-web-app` 组合包默认加载本包,无需在插件页开启或由 profile 选择。区域只在其 actions slot 有注册项时显示“更多”按钮,因此卸载本包会移除三个按钮。 -打开左侧导航入口下方的**更多**、会话 View 标签之后的**更多会话视图**,或右侧栏起始页卡片之后的**更多工具**。左侧栏和起始页菜单仅提供**浏览更多插件**(可用时)与**创建你的插件**。会话菜单在这些操作之前列出收起的 View,并在当前 View 右侧显示勾选标记。所有菜单都不提供 Pin 或排序操作。 +打开左侧导航入口下方的**更多**、会话 View 标签之后的**更多会话视图**,或右侧栏起始页卡片之后的**更多工具**。三个菜单均提供**浏览更多插件**(可用时)与**创建你的插件**。左侧栏菜单在这些操作之前仅列出未固定的侧栏入口,每项带有其注册的面板图标:打开入口不会将其固定,独立的固定图标则将入口放回侧栏并关闭菜单。插件管理入口始终固定在侧栏中。侧栏和菜单入口保持相同的注册排序,固定操作不改变顺序。当前侧栏入口在最右侧显示勾号,悬停菜单行或键盘聚焦时由固定操作原位替换。悬停固定图标时只改变图标颜色,整行高亮保持;不支持悬停的设备直接显示固定操作。其提示为“固定到左侧栏”。会话菜单在这些操作之前列出收起的 View,并在当前 View 右侧显示勾选标记。起始页和会话菜单不提供 Pin 控件;所有菜单都不提供排序操作。 -**浏览更多插件**进入插件页主列表,不打开安装弹窗。**创建你的插件**用 Creator 预设打开新 Session 或可复用的空白 Session,并把对应区域的简短本地化插件创建请求加入草稿:空草稿直接以该请求为文字,已有草稿保留其文字、引用和附件,请求另起一段追加在后面。它绝不发送消息。请自行审阅并发送草稿。无论区域是否有入口,创建操作始终可用;分隔线仅显示在收起的 View 与操作之间,浏览可用时位于浏览之前,否则位于创建之前。 +**浏览更多插件**进入插件页主列表,不打开安装弹窗。**创建你的插件**用 Creator 预设打开新 Session 或可复用的空白 Session,并把对应区域的简短本地化插件创建请求加入草稿:空草稿直接以该请求为文字,已有草稿保留其文字、引用和附件,请求另起一段追加在后面。它绝不发送消息。请自行审阅并发送草稿。无论区域是否有入口,创建操作始终可用;分隔线仅显示在未固定的侧栏入口或收起的 View 与操作之间,浏览可用时位于浏览之前,否则位于创建之前。 在桌面端,**浏览更多插件**与**创建你的插件**在执行前上报 `more_menu_item_click`,`action` 为 `browse_plugins` 或 `create_plugin`,`region` 为菜单所在区域:**更多**为 `sidebar`,**更多会话视图**为 `conversation`,**更多工具**为 `rightbar`。菜单不上报曝光事件。没有埋点服务时,两项操作行为不变且不上报。 -「浏览插件」一行只在 `pluginNavigation` 服务存在时出现;没有该服务时插件照常启动。该服务缺失时仅移除浏览操作,不影响“更多”按钮、收起的 View 或创建操作。 +「浏览插件」一行只在 `pluginNavigation` 服务存在时出现;没有该服务时插件照常启动。该服务缺失时仅移除浏览操作,不影响“更多”按钮、未固定的侧栏入口、收起的 View 或创建操作。 ----- @@ -44,7 +44,7 @@ kind: "package-reference"
实现细节——点击展开 -[`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 回调不被消费。 +[`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 回调;会话菜单消费 owner 提供的收起入口列表及导航回调。 每个注册项的 `inject` 提供所属区域与两个回调。每个回调先调用 `ctx.get('productAnalytics')?.track('more_menu_item_click', { region, action })`。浏览随后调用 `ctx.get('pluginNavigation')?.openList()`;ui-plugin-manager 只在插件页有权限时提供该服务,因此本包按可选方式读取,不把它写进 `inject`。创建随后按区域取本地化提示语,调用 `uiWorkspace.startSession(undefined, { agentPreset: 'cordis', prompt, whenDraftExists: 'append' })`;该调用不经过 `ui-agent-preset`,预设标签不播放介绍动画。[`dsh-web-app` bundle patch](../../bundle/web-app/cordis.patch.yml) 挂载浏览器插件。 @@ -65,7 +65,7 @@ kind: "package-reference" -- 更多菜单不提供入口显隐管理。其他注册项仍可调用区域 Pin 回调,仅修改内存中的选择;插件重新加载后恢复默认值。 +- 只有侧栏更多菜单提供 Pin 控件。固定选择仅保存在所属区域的内存中;插件重新加载后恢复默认值。 - 不读取或写入 localStorage 中的旧 Pin 值。 - **创建你的插件**不检查 Creator 预设是否存在。预设缺失时 Session 创建失败,Workspace 显示创建失败提示,不准备草稿。草稿准备还要求可确定目标 Workspace;没有目标时,现有流程提示需要 Workspace。 - 会话区域负责按估算值分组;菜单不提供排序。 diff --git a/packages/client/ui-entry-customization/src/client/EntryMenu.module.css b/packages/client/ui-entry-customization/src/client/EntryMenu.module.css index d7d1c3a298..2bbc9568bd 100644 --- a/packages/client/ui-entry-customization/src/client/EntryMenu.module.css +++ b/packages/client/ui-entry-customization/src/client/EntryMenu.module.css @@ -1,3 +1,32 @@ .entryName { display: flex; align-items: center; gap: 8px; } .entryTitle { min-width: 0; overflow-wrap: anywhere; white-space: normal; } .activeMark { flex: none; margin-left: auto; } +.entryIcon { display: inline-flex; align-items: center; justify-content: center; width: 16px; height: 16px; flex: none; } +.sidebarEntry { position: relative; } +.sidebarEntry .entryName { padding-right: 28px; } +.sidebarEntry .activeMark { + position: absolute; + right: 9px; + top: 50%; + transform: translateY(-50%); + opacity: 0; + pointer-events: none; +} +.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); } + +.sidebarEntry .pinButton:active:not(:disabled) { background: transparent; } + +@media (hover: hover) { + .sidebarEntry:hover > div:first-child > button { background: var(--dsw-alias-interactive-bg-hover); } + .sidebarEntry .pinButton:hover:not(:disabled) { + color: var(--dsw-alias-label-primary); + background: transparent; + } + .pinAnchor { opacity: 0; } + .sidebarEntry .activeMark { opacity: 1; } + .sidebarEntry:hover .pinAnchor, + :global(html[data-input-modality='keyboard']) .sidebarEntry:focus-within .pinAnchor { opacity: 1; } + .sidebarEntry:hover .activeMark, + :global(html[data-input-modality='keyboard']) .sidebarEntry:focus-within .activeMark { opacity: 0; } +} diff --git a/packages/client/ui-entry-customization/src/client/EntryMenuContent.tsx b/packages/client/ui-entry-customization/src/client/EntryMenuContent.tsx index 5ab4fa91cf..38ef68c50b 100644 --- a/packages/client/ui-entry-customization/src/client/EntryMenuContent.tsx +++ b/packages/client/ui-entry-customization/src/client/EntryMenuContent.tsx @@ -1,8 +1,8 @@ /** Folded Conversation Views, plugin discovery while reachable, and creation. */ import { - IconCheckOutlineRegular, IconPluginPinwheelOutlineRegular, IconPlusOutlineRegular, MenuItemButton, + Button, IconCheckOutlineRegular, IconPinOutlineRegular, IconPluginPinwheelOutlineRegular, IconPlusOutlineRegular, MenuItemButton, Tooltip, } from '@deepseek-ai/dsh-client-ui-primitives' -import type { HostObservable, InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' +import type { HostObservable, InjectFace, PropsLocale, PropsRenderFactories, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' import type {} from '@deepseek-ai/dsh-client-ui-sidebar/client' import css from './EntryMenu.module.css' @@ -25,26 +25,40 @@ export interface EntryMenuInjected { // The three region owners declare the same fields; the sidebar's slot names them. type EntryMenuContentProps = PropsRuntime<'sidebar.panellist.actions'> - & PropsLocale<'entryCustomization'> & InjectFace + & PropsRenderFactories & PropsLocale<'entryCustomization'> & InjectFace /** - * Render folded Conversation Views followed by discovery and Creator actions, without pin controls. + * Render unpinned sidebar entries or folded Conversation Views, followed by discovery and Creator actions. * @param props - Owner dismissal callback, injected actions, and localized copy. * @returns Menu rows for the region's More menu. */ export function EntryMenuContent({ - entries, onOpen, region, onDismiss, usePluginNavigation, browsePlugins, createPlugin, t, + entries, onOpen, onPinnedChange, region, onDismiss, usePluginNavigation, browsePlugins, createPlugin, t, renderFactorySlot, }: EntryMenuContentProps) { - const moreEntries = region === 'conversation' ? entries : [] + const moreEntries = region === 'sidebar' ? entries.filter(entry => !entry.pinned) : region === 'conversation' ? entries : [] const browsable = usePluginNavigation(present => present) return <> - {moreEntries.map(entry =>
+ )} + + {entry.label} + {entry.active === true && } + + + {region === 'sidebar' && + + + + } +
)} {browsable && 0} icon={} onSelect={() => { onDismiss(); browsePlugins() }}> {t('install')} diff --git a/packages/client/ui-entry-customization/src/client/locales.ts b/packages/client/ui-entry-customization/src/client/locales.ts index c0a0e2cccc..c9191fb438 100644 --- a/packages/client/ui-entry-customization/src/client/locales.ts +++ b/packages/client/ui-entry-customization/src/client/locales.ts @@ -1,5 +1,7 @@ /** Copy for the More menu rows: navigation actions, and Creator draft prompts per region. */ export const en = { + 'pinTooltip': 'Pin to left sidebar', + 'pin': 'Pin {name} to sidebar', install: 'Browse other plugins', create: 'Create your plugin', 'createPrompt.sidebar': 'Create a DSH plugin with a left sidebar entry.', @@ -9,6 +11,8 @@ export const en = { /** Chinese counterpart of the entry-customization dictionary. */ export const zh: Record = { + 'pinTooltip': '固定到左侧栏', + 'pin': '将{name}固定到侧边栏', install: '浏览更多插件', create: '创建你的插件', 'createPrompt.sidebar': '创建一个带左侧栏入口的 DSH 插件。', @@ -18,6 +22,8 @@ export const zh: Record = { /** Japanese copy, checked against the English keys. */ export const ja = { + 'pinTooltip': '左サイドバーに固定', + 'pin': '{name}をサイドバーに固定', 'install': '他のプラグインを探す', 'create': 'プラグインを作成', 'createPrompt.sidebar': '左サイドバーにエントリを持つ DSH プラグインを作成します。', @@ -27,6 +33,8 @@ export const ja = { /** Spanish dictionary, checked against the English keys. */ export const es = { + 'pinTooltip': 'Fijar a la barra lateral izquierda', + 'pin': 'Fijar {name} en la barra lateral', install: 'Explorar otros complementos', create: 'Crea tu complemento', 'createPrompt.sidebar': 'Cree un complemento DSH con una entrada en la barra lateral izquierda.', @@ -36,6 +44,8 @@ export const es = { /** French dictionary, checked against the English keys. */ export const fr = { + 'pinTooltip': 'Épingler sur la barre latérale gauche', + 'pin': 'Épingler {name} à la barre latérale', install: "Parcourir d'autres plugins", create: 'Créez votre plugin', 'createPrompt.sidebar': 'Créez un plugin DSH avec une entrée dans la barre latérale gauche.', @@ -45,6 +55,8 @@ export const fr = { /** German dictionary, checked against the English keys. */ export const de = { + 'pinTooltip': 'An die linke Seitenleiste anpinnen', + 'pin': '{name} an der Seitenleiste anheften', install: 'Durchsuchen Sie andere Plugins', create: 'Erstellen Sie Ihr Plugin', 'createPrompt.sidebar': 'Erstellen Sie ein DSH-Plugin mit einem Eintrag in der linken Seitenleiste.', @@ -54,6 +66,8 @@ export const de = { /** Russian dictionary, checked against the English keys. */ export const ru = { + 'pinTooltip': 'Закрепить на левой боковой панели', + 'pin': 'Закрепить {name} на боковой панели', install: 'Просмотрите другие плагины', create: 'Создайте свой плагин', 'createPrompt.sidebar': 'Создайте плагин DSH с записью на левой боковой панели.', @@ -63,6 +77,8 @@ export const ru = { /** Portuguese dictionary, checked against the English keys. */ export const pt = { + 'pinTooltip': 'Fixar na barra lateral esquerda', + 'pin': 'Fixar {name} na barra lateral', install: 'Procure outros plug-ins', create: 'Crie seu plug-in', 'createPrompt.sidebar': 'Crie um plug-in DSH com uma entrada na barra lateral esquerda.', diff --git a/packages/client/ui-entry-customization/tests/menu.client.spec.tsx b/packages/client/ui-entry-customization/tests/menu.client.spec.tsx index ff77c49ca7..334352d679 100644 --- a/packages/client/ui-entry-customization/tests/menu.client.spec.tsx +++ b/packages/client/ui-entry-customization/tests/menu.client.spec.tsx @@ -2,11 +2,12 @@ /** 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 { act, cleanup, fireEvent, render, waitFor, within } from '@testing-library/react' import type { ComponentProps } from 'react' import { useState } from 'react' import { makeTranslate, SlotTestRuntime } from '@deepseek-ai/dsh-client-test-runtime' import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client' +import { Menu } from '@deepseek-ai/dsh-client-ui-primitives' import type { PropsRenderSlots } from '@deepseek-ai/dsh-client-ui-slots' import type { TrackProductEvent } from '@deepseek-ai/dsh-client-product-analytics/client' import { EntryMenuContent } from '../src/client/EntryMenuContent.tsx' @@ -35,16 +36,16 @@ function content(overrides: Partial = {}) { region: 'sidebar', t: makeTranslate(en), usePluginNavigation: (select: (present: boolean) => T): T => select(true), usePanelInfo: unused, useSessions: unused, useSessionStatus: unused, useSessionRetainInfo: unused, - useResource: unused, useWorkspaces: unused, + useResource: unused, useWorkspaces: unused, renderFactorySlot: () => null, ...callbacks, ...overrides, } as ContentProps const view = render(
) - return { view, ...callbacks } + return { view, props, ...callbacks } } describe('More menu rows', () => { - it.each(['sidebar', 'rightbar'] as const)('shows only discovery and creation in %s regardless of entries', (region) => { + it.each(['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 }, @@ -61,6 +62,37 @@ describe('More menu rows', () => { expect(b.onPinnedChange).not.toHaveBeenCalled() }) + it('lists only unpinned sidebar entries and pins without navigating', () => { + const b = content() + expect(b.view.queryByRole('menuitem', { name: 'Notes' })).toBeNull() + fireEvent.click(b.view.getByRole('menuitem', { name: 'Tasks' })) + expect(b.onOpen).toHaveBeenCalledExactlyOnceWith('tasks') + expect(b.onPinnedChange).not.toHaveBeenCalled() + b.onOpen.mockClear() + fireEvent.click(b.view.getByRole('menuitem', { name: 'Pin Tasks to sidebar' })) + expect(b.onPinnedChange).toHaveBeenCalledExactlyOnceWith('tasks', true) + expect(b.onOpen).not.toHaveBeenCalled() + }) + + it('returns focus to More after keyboard pinning without opening the entry', async () => { + const b = content() + b.view.unmount() + function PinMenu() { + const [open, setOpen] = useState(true) + return { setOpen(false) }} autoFocus + anchor={}> + { setOpen(false) }} /> + + } + const view = render() + const pin = view.getByRole('menuitem', { name: 'Pin Tasks to sidebar' }) + act(() => { pin.focus() }) + fireEvent.keyDown(pin, { key: 'Tab' }) + await waitFor(() => { expect(document.activeElement).toBe(view.getByRole('button', { name: 'More' })) }) + expect(b.onPinnedChange).toHaveBeenCalledExactlyOnceWith('tasks', true) + expect(b.onOpen).not.toHaveBeenCalled() + }) + it.each([true, false])('opens folded Views and marks the active View with plugin navigation present=%s', (browsable) => { const b = content({ region: 'conversation', usePluginNavigation: select => select(browsable), @@ -90,7 +122,7 @@ describe('More menu rows', () => { }) it.each(['sidebar', 'rightbar'] as const)('keeps only creation in %s without plugin navigation', (region) => { - const b = content({ region, usePluginNavigation: select => select(false) }) + const b = content({ region, entries: [], usePluginNavigation: select => select(false) }) expect(b.view.getAllByRole('menuitem').map(row => row.textContent)).toEqual(['Create your plugin']) expect(b.view.queryByRole('separator')).toBeNull() fireEvent.click(b.view.getByRole('menuitem', { name: 'Create your plugin' })) diff --git a/packages/client/ui-sidebar/README.i18n.yaml b/packages/client/ui-sidebar/README.i18n.yaml index 50458ab3da..1346cf4acb 100644 --- a/packages/client/ui-sidebar/README.i18n.yaml +++ b/packages/client/ui-sidebar/README.i18n.yaml @@ -21,8 +21,8 @@ en: 6bc49e22f0f73f07 zh: 10883997ccb7d145 /deepseek-ai-dsh-client-ui-sidebar/use-this-package/global-panel-entries: - en: e7f316023445511b - zh: 6271c8c011533877 + en: 20099567e47e1252 + zh: 1418d3d3f36a2207 /deepseek-ai-dsh-client-ui-sidebar/use-this-package/collapse-behavior: en: 10381cbac96069a3 zh: c5849741a0be2f00 @@ -33,8 +33,8 @@ en: 9e7b31acee759f2b zh: 3a6beb63b23d26ae /deepseek-ai-dsh-client-ui-sidebar/use-this-package/click-analytics: - en: 8855ad013285a27e - zh: f19b3e75c38c5b0a + en: 3e84ffdf6c7c1731 + zh: 3f7b19cac6d6674f /deepseek-ai-dsh-client-ui-sidebar/understand-the-implementation: en: 8cc657bd9b66c93f zh: 47ad4bd9e906ef8e diff --git a/packages/client/ui-sidebar/README.md b/packages/client/ui-sidebar/README.md index 8783f2622c..7ccd3e0db1 100644 --- a/packages/client/ui-sidebar/README.md +++ b/packages/client/ui-sidebar/README.md @@ -41,7 +41,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`, `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. +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. Expanded rows other than Plugins show an unpin action on hover or keyboard focus; devices without hover show it continuously. Its resting color matches the Session pin marker, and its tooltip says “Unpin from left sidebar”. Both sidebar and More entries follow the same registration order rather than pin timing. The default More menu lists only unpinned panels before plugin browsing and creation; opening a panel does not pin it, while its separate pin icon returns the row to the sidebar and dismisses the menu. 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 @@ -68,7 +68,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 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. +- A 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 calls `onOpen` for unpinned panels, including Automation tasks; pin and unpin actions report no navigation click. The report precedes the registration check, so a request for a panel that has unregistered still counts although nothing is selected. ----- diff --git a/packages/client/ui-sidebar/README.zh.md b/packages/client/ui-sidebar/README.zh.md index 15acbfc859..ecbf9697a4 100644 --- a/packages/client/ui-sidebar/README.zh.md +++ b/packages/client/ui-sidebar/README.zh.md @@ -41,7 +41,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` 与 `active`;不含插件管理)、选中面板的 `onOpen(id)`、`onPinnedChange(id, pinned)`,以及关闭菜单的 `onDismiss()`。侧边栏在内存中保存所有会话共用的已收起面板 id 列表;插件管理入口始终显示。默认更多菜单只提供插件浏览和创建,不列出面板或 Pin 控件。其他注册项仍可调用 Pin 回调,但插件重新加载时选择重置,不读取旧 localStorage 值。收起只隐藏导航行,主面板、其注册和当前选中态不变。未注册面板的 id 保留在内存列表中,直到插件重新加载。 +root 作用域的 `sidebar.panellist.actions` list 有注册项时,导航区域的面板行之后显示面板行样式的**更多**按钮;收起的窄栏以提示框显示其名称。它打开的菜单以该 list 的注册项为菜单行。每个注册项接收 `entries`(按注册顺序的可自定义面板,含 `id`、`label`、`pinned` 与 `active`;不含插件管理)、选中面板的 `onOpen(id)`、`onPinnedChange(id, pinned)`,以及关闭菜单的 `onDismiss()`。侧边栏在内存中保存所有会话共用的已收起面板 id 列表;插件管理入口始终显示。展开态中,除插件管理外的面板行在悬停或键盘聚焦时显示取消固定操作;不支持悬停的设备始终显示该操作。图标默认颜色与会话置顶标记一致,提示为“从左侧栏取消固定”。侧栏和更多菜单入口遵循相同的注册排序,不按固定时间重排。默认更多菜单在插件浏览和创建之前仅列出未固定的面板;打开面板不会将其固定,独立的固定图标则将该行放回侧边栏并关闭菜单。插件重新加载时选择重置,不读取旧 localStorage 值。收起只隐藏导航行,主面板、其注册和当前选中态不变。未注册面板的 id 保留在内存列表中,直到插件重新加载。 ### 折叠行为 @@ -68,7 +68,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`。上报先于注册检查,因此所请求的面板已注销时仍会计数,但不会选中任何面板。 +- 更多菜单行通过 `onOpen(id)` 打开插件或自动化任务时,上报 `menu_name=plugin` 或 `menu_name=cron`,`position=sidebar_more`。随产品提供的更多菜单通过 `onOpen` 打开未固定的面板,包括自动化任务;固定和取消固定操作不上报导航点击。上报先于注册检查,因此所请求的面板已注销时仍会计数,但不会选中任何面板。 ----- diff --git a/packages/client/ui-sidebar/src/client/PanelIcon.tsx b/packages/client/ui-sidebar/src/client/PanelIcon.tsx new file mode 100644 index 0000000000..ba3ed5fc04 --- /dev/null +++ b/packages/client/ui-sidebar/src/client/PanelIcon.tsx @@ -0,0 +1,12 @@ +/** Shared rendering of plugin-owned panel icons in navigation rows and menus. */ +import type { FactoryComponentPropsOf } from '@deepseek-ai/dsh-client-ui-slots' +import type {} from './contract/slots.ts' + +/** + * Render the icon registered for one panel at the caller's size and selection state. + * @param props - Panel identity, icon presentation, and the authorized child renderer. + * @returns The matching panel icon. + */ +export function PanelIcon({ id, size, active, renderSlot }: FactoryComponentPropsOf<'sidebar.panel.icon'>) { + return renderSlot('sidebar.panellist', { size, active }, { only: id }) +} diff --git a/packages/client/ui-sidebar/src/client/SidebarRoot.module.css b/packages/client/ui-sidebar/src/client/SidebarRoot.module.css index 387b6f804f..caeeb1f938 100644 --- a/packages/client/ui-sidebar/src/client/SidebarRoot.module.css +++ b/packages/client/ui-sidebar/src/client/SidebarRoot.module.css @@ -530,6 +530,65 @@ } } +/* The entry wrapper paints the row fill, so it carries the shared paint inset + instead of its inner panel row. */ +.panelEntry { + position: relative; + display: flex; + border-inline: var(--dsh-sidebar-paint-inset) solid transparent; + border-radius: var(--dsw-radius-md); + background-clip: padding-box; +} + +.panelEntryWide > *:first-child { + flex: 1; + min-width: 0; +} + +.panelEntryWide .panelRow { + width: 100%; + margin: 0; + border-inline: 0; + padding-right: 38px; + background: transparent; +} + +.panelEntryActive, +.panelEntry:has(:focus-visible) { + background-color: var(--dsw-alias-interactive-bg-hover); +} + +.panelUnpin { + position: absolute; + right: 0; + top: 4px; + display: inline-flex; + align-items: center; + justify-content: center; + width: 28px; + height: 28px; + padding: 0; + border: 0; + border-radius: var(--dsw-radius-sm); + background: transparent; + color: var(--dsw-alias-label-caption); + cursor: pointer; +} + +.panelUnpin:focus-visible { + outline: var(--dsw-focus-ring-width) solid var(--dsw-focus-ring-color, var(--dsw-alias-state-business-primary)); + outline-offset: -2px; +} + +@media (hover: hover) { + .panelUnpin { opacity: 0; } + .panelEntry:hover { background-color: var(--dsw-alias-interactive-bg-hover); } + .panelEntry .panelRow:hover { background: transparent; } + .panelUnpin:hover { color: var(--dsw-alias-label-primary); } + .panelEntry:hover .panelUnpin, + .panelEntry:has(:focus-visible) .panelUnpin { opacity: 1; } +} + .panelList { flex: none; display: flex; @@ -566,11 +625,6 @@ } } -.panelRow.panelActive { - background-color: var(--dsw-alias-interactive-bg-hover); - color: var(--dsw-alias-label-primary); -} - .panelRow:focus-visible { outline: var(--dsw-focus-ring-width) solid var(--dsw-focus-ring-color, var(--dsw-alias-state-business-primary)); outline-offset: -2px; @@ -610,6 +664,10 @@ margin-bottom: 12px; } +.collapsed .panelEntry { + border-inline: 0; +} + .collapsed .panelRow { width: 36px; height: 36px; diff --git a/packages/client/ui-sidebar/src/client/SidebarRoot.tsx b/packages/client/ui-sidebar/src/client/SidebarRoot.tsx index f03ecc32aa..417306d7d5 100644 --- a/packages/client/ui-sidebar/src/client/SidebarRoot.tsx +++ b/packages/client/ui-sidebar/src/client/SidebarRoot.tsx @@ -20,10 +20,10 @@ import { useEffect, useLayoutEffect, useRef, useState } from 'react' import clsx from 'clsx' import { FishLogo, IconAppsAddOutlineRegular, IconNewChatOutlineMedium, IconNewChatOutlineRegular, - IconPanelLeftOutlineRegular, focusWithoutRing, isBehindModal, isDarwinDesktop, Menu, ShortcutKeys, Toast, Tooltip, useModalLayer, - writeClipboard, + IconPanelLeftOutlineRegular, IconPinFillRegular, focusWithoutRing, isBehindModal, isDarwinDesktop, Menu, ShortcutKeys, Toast, + Tooltip, useModalLayer, writeClipboard, } from '@deepseek-ai/dsh-client-ui-primitives' -import type { InjectFace, PropsRenderSlots, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' +import type { InjectFace, PropsRenderFactories, PropsRenderSlots, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' import type { SidebarPanelActionsOwnerProps, SidebarPanelMetadata, SidebarRootComponentProps, SidebarRootInjected, SidebarSectionOwnerProps, } from './contract/slots.ts' @@ -55,8 +55,9 @@ type PanelRowProps = Pick & Pick & Pick, 'usePanelInfo'> - & Pick, 'selectPanel' | 'trackEntryClick'> - & PropsRenderSlots<'sidebar.panellist'> + & Pick, 'selectPanel' | 'trackEntryClick' | 'setPinned'> + & { readonly unpinLabel: string; readonly unpinTooltip: string; readonly canUnpin: boolean } + & PropsRenderFactories /** Analytics category of an official panel; other panels are never reported. */ function officialEntry(id: string): 'plugin' | 'cron' | undefined { @@ -65,31 +66,44 @@ function officialEntry(id: string): 'plugin' | 'cron' | undefined { } /** Each panel row subscribes only to its own selection state. */ -function PanelRow({ id, label, wide, usePanelInfo, selectPanel, trackEntryClick, renderSlot }: PanelRowProps) { +function PanelRow({ + id, label, wide, unpinLabel, unpinTooltip, canUnpin, usePanelInfo, selectPanel, setPinned, trackEntryClick, renderFactorySlot, +}: PanelRowProps) { const active = usePanelInfo(info => info.activePanelId === id) return ( - - - + {wide && ( + + {label} + + )} + + + {wide && canUnpin && + + } + ) } @@ -145,6 +159,7 @@ export function SidebarRoot({ usePanelInfo, t, renderSlot, + renderFactorySlot, }: SidebarRootComponentProps) { const panels = usePanels(snapshot => snapshot) const unpinned = useEntryPins(ids => ids) @@ -384,10 +399,14 @@ export function SidebarRoot({ id={id} label={label} wide={wide} + unpinLabel={t('panels.unpin', { name: label })} + unpinTooltip={t('panels.unpinTooltip')} + canUnpin={hasEntryActions && id !== FIXED_PANEL_ID} + setPinned={setPinned} usePanelInfo={usePanelInfo} selectPanel={selectPanel} trackEntryClick={trackEntryClick} - renderSlot={renderSlot} + renderFactorySlot={renderFactorySlot} /> ))} {hasEntryActions && ( diff --git a/packages/client/ui-sidebar/src/client/contract/slots.ts b/packages/client/ui-sidebar/src/client/contract/slots.ts index 7b6662b6c7..18222c0a21 100644 --- a/packages/client/ui-sidebar/src/client/contract/slots.ts +++ b/packages/client/ui-sidebar/src/client/contract/slots.ts @@ -7,7 +7,7 @@ * `sidebar.settings` registrant's (ui-settings), followed by optional footer * actions in `sidebar.footer.action`. */ -import type { InjectFace, PropsLocale, PropsRenderSlots, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' +import type { InjectFace, PropsLocale, PropsRenderFactories, PropsRenderSlots, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' import type { ObservableSnapshot } from '@deepseek-ai/dsh-client-store' import type { WorkspaceId } from '@deepseek-ai/dsh-api-workspace-controller/client' import type { ShortcutCatalogEntry } from '@deepseek-ai/dsh-client-shortcuts/client' @@ -15,6 +15,14 @@ import type { MainPanelId } from '@deepseek-ai/dsh-client-ui-layout/client' import type { ProductEventMap } from '@deepseek-ai/dsh-client-product-analytics/client' declare module '@deepseek-ai/dsh-client-ui-slots' { + interface SlotFactoryMap { + /** Reuse a registered panel icon in navigation rows and menus. */ + 'sidebar.panel.icon': { + scope: 'root' + props: SidebarPanelIconOwnerProps & { readonly id: string } + children: { 'sidebar.panellist': { kind: 'list'; scope: 'root' } } + } + } interface SlotMap { /** Non-interactive notification inside the collapsed sidebar expand button. */ 'sidebar.toggle.badge': { kind: 'single'; scope: 'root'; owner: Record } @@ -189,10 +197,9 @@ export type SidebarRootComponentProps = | 'sidebar.brand.mark' | 'sidebar.brand.name' | 'sidebar.toggle.badge' - | 'sidebar.panellist' | 'sidebar.panellist.actions' | 'sidebar.workspaces' | 'sidebar.settings' | 'sidebar.footer.action' > - & InjectFace & PropsLocale<'sidebar'> + & PropsRenderFactories & InjectFace & PropsLocale<'sidebar'> diff --git a/packages/client/ui-sidebar/src/client/index.ts b/packages/client/ui-sidebar/src/client/index.ts index f45c249e8f..e25375e4dd 100644 --- a/packages/client/ui-sidebar/src/client/index.ts +++ b/packages/client/ui-sidebar/src/client/index.ts @@ -14,6 +14,7 @@ import type { SidebarPanelMetadata, SidebarRootInjected } from './contract/slots import { createPanelPins } from './panel-pins.ts' import { HeaderLeadingControls } from './HeaderLeadingControls.tsx' import { SidebarRoot } from './SidebarRoot.tsx' +import { PanelIcon } from './PanelIcon.tsx' import { en, ja, es, fr, de, ru, pt, zh, type SidebarKey } from './locales.ts' export type { @@ -78,21 +79,26 @@ export function apply(ctx: ClientContext): void { trackEntryClick: (attributes) => { ctx.get('productAnalytics')?.track('sidebar_menu_click', attributes) }, hooks: { panels, shortcuts: ctx.shortcuts.catalog, entryPins: panelPins.unpinned, entryActions }, }) - ctx.slots.inject('sidebar', () => ctx.slots.register({ - name: 'sidebar', - locale: NS, - children: { - 'sidebar.brand.mark': { kind: 'single', scope: 'root' }, - 'sidebar.brand.name': { kind: 'single', scope: 'root' }, - 'sidebar.toggle.badge': { kind: 'single', scope: 'root' }, - 'sidebar.panellist': { kind: 'list', scope: 'root' }, - 'sidebar.panellist.actions': { kind: 'list', scope: 'root' }, - 'sidebar.workspaces': { kind: 'single', scope: 'root' }, - 'sidebar.settings': { kind: 'single', scope: 'root' }, - 'sidebar.footer.action': { kind: 'list', scope: 'root' }, - }, - inject: injectProps, - }, SidebarRoot)) + ctx.slots.inject('sidebar', function* () { + yield ctx.slots.registerFactory({ + name: 'sidebar.panel.icon', scope: 'root', + children: { 'sidebar.panellist': { kind: 'list', scope: 'root' } }, + }, PanelIcon) + yield ctx.slots.register({ + name: 'sidebar', + locale: NS, + children: { + 'sidebar.brand.mark': { kind: 'single', scope: 'root' }, + 'sidebar.brand.name': { kind: 'single', scope: 'root' }, + 'sidebar.toggle.badge': { kind: 'single', scope: 'root' }, + 'sidebar.panellist.actions': { kind: 'list', scope: 'root' }, + 'sidebar.workspaces': { kind: 'single', scope: 'root' }, + 'sidebar.settings': { kind: 'single', scope: 'root' }, + 'sidebar.footer.action': { kind: 'list', scope: 'root' }, + }, + inject: injectProps, + }, SidebarRoot) + }) // macOS desktop hides the collapsed sidebar entirely, so the open/New // Session controls move into the frame's window-chrome seat beside the // traffic lights; the occupant reuses the shell's injected actions, and diff --git a/packages/client/ui-sidebar/src/client/locales.ts b/packages/client/ui-sidebar/src/client/locales.ts index 7dd30be7ac..e6ee027f8e 100644 --- a/packages/client/ui-sidebar/src/client/locales.ts +++ b/packages/client/ui-sidebar/src/client/locales.ts @@ -2,6 +2,8 @@ /** Simplified Chinese dictionary (the key-set source of truth). */ export const zh = { + 'panels.unpinTooltip': '从左侧栏取消固定', + 'panels.unpin': '从侧边栏取消固定{name}', 'session.new': '新会话', 'session.new.label': '新建会话', 'toggle.open': '打开侧边栏', @@ -17,6 +19,8 @@ export type SidebarKey = keyof typeof zh /** English dictionary, checked complete against the zh key set. */ export const en = { + 'panels.unpinTooltip': 'Unpin from left sidebar', + 'panels.unpin': 'Unpin {name} from sidebar', 'session.new': 'New Session', 'session.new.label': 'New session', 'toggle.open': 'Open sidebar', @@ -29,6 +33,8 @@ export const en = { /** Japanese copy, checked against the English keys. */ export const ja = { + 'panels.unpinTooltip': '左サイドバーから固定解除', + 'panels.unpin': '{name}をサイドバーから固定解除', 'session.new': '新規セッション', 'session.new.label': '新規セッション', 'toggle.open': 'サイドバーを開く', @@ -41,6 +47,8 @@ export const ja = { /** Spanish dictionary, checked against the English keys. */ export const es = { + 'panels.unpinTooltip': 'Desanclar de la barra lateral izquierda', + 'panels.unpin': 'Desfijar {name} de la barra lateral', 'session.new': 'Nueva sesión', 'session.new.label': 'Nueva sesión', 'toggle.open': 'Abrir barra lateral', @@ -53,6 +61,8 @@ export const es = { /** French dictionary, checked against the English keys. */ export const fr = { + 'panels.unpinTooltip': "Retirer l'épingle de la barre latérale gauche", + 'panels.unpin': 'Désépingler {name} de la barre latérale', 'session.new': 'Nouvelle séance', 'session.new.label': 'Nouvelle séance', 'toggle.open': 'Ouvrir la barre latérale', @@ -65,6 +75,8 @@ export const fr = { /** German dictionary, checked against the English keys. */ export const de = { + 'panels.unpinTooltip': 'Von der linken Seitenleiste lösen', + 'panels.unpin': '{name} von der Seitenleiste lösen', 'session.new': 'Neue Sitzung', 'session.new.label': 'Neue Sitzung', 'toggle.open': 'Seitenleiste öffnen', @@ -77,6 +89,8 @@ export const de = { /** Russian dictionary, checked against the English keys. */ export const ru = { + 'panels.unpinTooltip': 'Открепить от левой боковой панели', + 'panels.unpin': 'Открепить {name} от боковой панели', 'session.new': 'Новая сессия', 'session.new.label': 'Новая сессия', 'toggle.open': 'Открыть боковую панель', @@ -89,6 +103,8 @@ export const ru = { /** Portuguese dictionary, checked against the English keys. */ export const pt = { + 'panels.unpinTooltip': 'Soltar da barra lateral esquerda', + 'panels.unpin': 'Desafixar {name} da barra lateral', 'session.new': 'Nova sessão', 'session.new.label': 'Nova sessão', 'toggle.open': 'Abrir barra lateral', diff --git a/packages/client/ui-sidebar/tests/panel-list.client.spec.tsx b/packages/client/ui-sidebar/tests/panel-list.client.spec.tsx index 6d3045ca09..69e6764c05 100644 --- a/packages/client/ui-sidebar/tests/panel-list.client.spec.tsx +++ b/packages/client/ui-sidebar/tests/panel-list.client.spec.tsx @@ -364,7 +364,11 @@ describe('sidebar launcher pins', () => { expect(view.queryByRole('navigation')).toBeNull() await mountPanels(runtime, locale) const navigation = await view.findByRole('navigation', { name: 'Global panels' }) - expect(within(navigation).getAllByRole('button').map(row => row.getAttribute('aria-label'))).toEqual(['Beta panel', 'Alpha panel', 'More']) + expect(within(navigation).getAllByRole('button').map(row => row.getAttribute('aria-label'))) + .toEqual(['Beta panel', 'Unpin Beta panel from sidebar', 'Alpha panel', 'Unpin Alpha panel from sidebar', 'More']) + fireEvent.click(within(navigation).getByRole('button', { name: 'Unpin Alpha panel from sidebar' })) + expect(within(navigation).queryByRole('button', { name: 'Alpha panel' })).toBeNull() + expect(document.activeElement).toBe(within(navigation).getByRole('button', { name: 'More' })) await probe.dispose() await waitFor(() => { expect(within(navigation).queryByRole('button', { name: 'More' })).toBeNull() }) }) @@ -395,6 +399,11 @@ describe('sidebar launcher pins', () => { 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 Beta panel' })) + fireEvent.click(menu.getByRole('menuitem', { name: 'Pin Beta panel' })) + expect(within(navigation).getAllByRole('button').map(row => row.getAttribute('aria-label')) + .filter(label => !label?.startsWith('Unpin '))) + .toEqual(['Plugins', 'Beta panel', 'Alpha panel', 'More']) 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() diff --git a/packages/client/ui-sidebar/tests/pointer-scrollbars.client.spec.tsx b/packages/client/ui-sidebar/tests/pointer-scrollbars.client.spec.tsx index 6e15b94baa..7b06396d72 100644 --- a/packages/client/ui-sidebar/tests/pointer-scrollbars.client.spec.tsx +++ b/packages/client/ui-sidebar/tests/pointer-scrollbars.client.spec.tsx @@ -50,6 +50,7 @@ function mountColumn(): { column: HTMLElement; quiet: () => boolean } { useContextMenu={contextMenuHook()} useResource={useResource} useWorkspaces={neverHook} {...allowAllAccess} startSession={vi.fn()} toggleSidebar={vi.fn()} trackEntryClick={vi.fn()} t={t} + renderFactorySlot={() => null} renderSlot={((_key: string, owner: SidebarSectionOwnerProps) =>
) as SidebarRootComponentProps['renderSlot']} />, diff --git a/packages/client/ui-sidebar/tests/sidebar-root.client.spec.tsx b/packages/client/ui-sidebar/tests/sidebar-root.client.spec.tsx index ee09e12ce5..7bd2cf8837 100644 --- a/packages/client/ui-sidebar/tests/sidebar-root.client.spec.tsx +++ b/packages/client/ui-sidebar/tests/sidebar-root.client.spec.tsx @@ -77,6 +77,7 @@ function mountShell({ collapsed = false, width = 300, drawer = false, shortcuts useContextMenu={contextMenuHook()} useResource={useResource} useWorkspaces={neverHook} {...allowAllAccess} startSession={startSession} toggleSidebar={toggleSidebar} trackEntryClick={trackEntryClick} t={t} + renderFactorySlot={() => null} renderSlot={(( key: string, owner: SidebarFooterActionOwnerProps | SidebarSectionOwnerProps | SidebarSettingsOwnerProps, @@ -190,6 +191,7 @@ describe('SidebarRoot shell', () => { {...allowAllAccess} useRemoteAccess={useRemoteAccess} startSession={startSession} toggleSidebar={toggleSidebar} trackEntryClick={vi.fn()} t={t} + renderFactorySlot={() => null} renderSlot={((key: string) => { if (key === 'sidebar.brand.mark') return M if (key === 'sidebar.brand.name') return Brand @@ -221,6 +223,7 @@ describe('SidebarRoot shell', () => { useContextMenu={contextMenuHook()} useResource={useResource} useWorkspaces={neverHook} {...allowAllAccess} startSession={vi.fn()} toggleSidebar={vi.fn()} trackEntryClick={vi.fn()} t={t} + renderFactorySlot={() => null} renderSlot={((_key: string, _owner: unknown, options?: { fallback?: ReactNode }) => options?.fallback ?? null) as SidebarRootComponentProps['renderSlot']} />) @@ -245,6 +248,7 @@ describe('SidebarRoot shell', () => { useContextMenu={contextMenuHook()} useResource={useResource} useWorkspaces={neverHook} {...allowAllAccess} startSession={vi.fn()} toggleSidebar={vi.fn()} trackEntryClick={vi.fn()} t={t} + renderFactorySlot={() => null} renderSlot={((_key: string, _owner: unknown, options?: { fallback?: ReactNode }) => options?.fallback ?? null) as SidebarRootComponentProps['renderSlot']} />) @@ -273,6 +277,7 @@ describe('SidebarRoot shell', () => { useContextMenu={contextMenuHook()} useResource={useResource} useWorkspaces={neverHook} {...allowAllAccess} startSession={startSession} toggleSidebar={vi.fn()} trackEntryClick={trackEntryClick} t={t} + renderFactorySlot={() => null} renderSlot={((_key: string, _owner: unknown, options?: { fallback?: ReactNode }) => options?.fallback ?? null) as SidebarRootComponentProps['renderSlot']} />) @@ -324,6 +329,7 @@ describe('SidebarRoot shell', () => { useContextMenu={contextMenuHook()} useResource={useResource} useWorkspaces={neverHook} {...allowAllAccess} startSession={vi.fn()} toggleSidebar={vi.fn()} trackEntryClick={vi.fn()} t={t} + renderFactorySlot={() => null} renderSlot={((_key: string, _owner: unknown, options?: { fallback?: ReactNode }) => options?.fallback ?? null) as SidebarRootComponentProps['renderSlot']} />) @@ -388,6 +394,7 @@ describe('SidebarRoot shell', () => { useContextMenu={contextMenuHook()} useResource={useResource} useWorkspaces={neverHook} {...allowAllAccess} startSession={vi.fn()} toggleSidebar={vi.fn()} trackEntryClick={vi.fn()} t={t} + renderFactorySlot={() => null} renderSlot={((key: string) => key === 'sidebar.toggle.badge' ? : null) as SidebarRootComponentProps['renderSlot']} diff --git a/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts b/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts index 8169aa709e..1bdaaaa615 100644 --- a/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts +++ b/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts @@ -4020,7 +4020,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'shadows-shipped-ui', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'sidebar.brand.mark\', () => ctx.slots.register(\n { name: \'sidebar.brand.mark\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-sidebar/src/client/contract/slots.ts:26', + source: 'packages/client/ui-sidebar/src/client/contract/slots.ts:34', }, { key: 'sidebar.brand.name', @@ -4055,7 +4055,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'shadows-shipped-ui', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'sidebar.brand.name\', () => ctx.slots.register(\n { name: \'sidebar.brand.name\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-sidebar/src/client/contract/slots.ts:31', + source: 'packages/client/ui-sidebar/src/client/contract/slots.ts:39', }, { key: 'sidebar.chat.conversation', @@ -4149,7 +4149,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'none', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'sidebar.footer.action\', () => ctx.slots.register(\n { name: \'sidebar.footer.action\', id: \'my-entry\', order: 100, label: \'My entry\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-sidebar/src/client/contract/slots.ts:56', + source: 'packages/client/ui-sidebar/src/client/contract/slots.ts:64', }, { key: 'sidebar.panellist', @@ -4196,14 +4196,14 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ keyDomain: '', hookContext: '', slotInject: '', - declaredBy: 'an entry in \'sidebar\' (client-ui-sidebar), so it exists while that entry is mounted', + declaredBy: 'factory \'sidebar.panel.icon\' (client-ui-sidebar), so it exists while that definition is registered', occupants: [ 'client-ui-plugin-manager PluginsPanelIcon', 'client-ui-schedule TaskManagerIcon', ], replaceRisk: 'none', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'sidebar.panellist\', () => ctx.slots.register(\n { name: \'sidebar.panellist\', id: \'my-entry\', order: 100, label: \'My entry\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-sidebar/src/client/contract/slots.ts:36', + source: 'packages/client/ui-sidebar/src/client/contract/slots.ts:44', }, { key: 'sidebar.panellist.actions', @@ -4258,7 +4258,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'none', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'sidebar.panellist.actions\', () => ctx.slots.register(\n { name: \'sidebar.panellist.actions\', id: \'my-entry\', order: 100, label: \'My entry\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-sidebar/src/client/contract/slots.ts:38', + source: 'packages/client/ui-sidebar/src/client/contract/slots.ts:46', }, { key: 'sidebar.right.pane.tab', @@ -5131,7 +5131,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'shadows-shipped-ui', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'sidebar.settings\', () => ctx.slots.register(\n { name: \'sidebar.settings\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-sidebar/src/client/contract/slots.ts:51', + source: 'packages/client/ui-sidebar/src/client/contract/slots.ts:59', }, { key: 'sidebar.toggle.badge', @@ -5163,7 +5163,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'shadows-shipped-ui', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'sidebar.toggle.badge\', () => ctx.slots.register(\n { name: \'sidebar.toggle.badge\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-sidebar/src/client/contract/slots.ts:20', + source: 'packages/client/ui-sidebar/src/client/contract/slots.ts:28', }, { key: 'sidebar.workspaces', @@ -5197,7 +5197,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'shadows-shipped-ui', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'sidebar.workspaces\', () => ctx.slots.register(\n { name: \'sidebar.workspaces\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-sidebar/src/client/contract/slots.ts:45', + source: 'packages/client/ui-sidebar/src/client/contract/slots.ts:53', }, { key: 'sidebar.workspaces.directoryFlow',