Merge pull request #6697 from deepseek-harness/worktree/sync-dsh-0.2.1-entry-customization-to-master

release(dsh): sync 0.2.1 entry customization back to master
This commit is contained in:
imccyu authored and GitHub committed 2026-10-20 16:18:44 +08:00
commit 8f47dd7e16
220 files changed
+3353 -275

No files matched your search

@@ -9,14 +9,14 @@
en: 6aa396e12264ee48
zh: 56849a18acaf830a
/agent-note-initial-draft-content-and-structured-reference-restoration/decision:
en: 9cf2afd9d5e663e9
zh: ad59b1bec6ec4666
en: 1a370f23ee32b6a4
zh: fb969ec929a6c7c9
/agent-note-initial-draft-content-and-structured-reference-restoration/decision/data-model:
en: 7f33895364a9f5cc
zh: f1c79ea15f5c151e
/agent-note-initial-draft-content-and-structured-reference-restoration/decision/classes-and-methods:
en: ca81423a57f898a0
zh: 98962cc1c6bce113
en: e57394545d9d93ff
zh: 5ab142e661750b82
/agent-note-initial-draft-content-and-structured-reference-restoration/decision/data-flow:
en: dd57e639e9328f1d
zh: 5e16cb2e3a73597c
@@ -12,7 +12,7 @@ New-task entry points need to place unsent content in the composer. A draft cont
Keep the existing `SessionInputShell`, `DraftEditorRuntime`, Conversation store, and Session reference lifetime. Plain text and structured drafts enter through the input model. Initial import finishes before `InputHub.shellFor()` returns, without React effects restoring text. Existing input objects are not restored again. React mounts the same editor and binds the existing persistence callback.
The second parameter of `startSession(workspaceId?, options?)` contains `prompt` and `clearPreviousDraft`. `prompt` accepts only a string; structured drafts remain internal to restoration and Workspace transfer. Omitting options preserves the existing flow. Without clearing, existing content is preserved and only an empty draft adopts the prompt. Explicit clearing replaces the target Session's text document, without deleting attachments or changing other Sessions. Prefilling does not send, execute commands, or change `blank`.
The second parameter of `startSession(workspaceId?, options?)` contains `prompt`, `whenDraftExists`, and `clearPreviousDraft`. `prompt` accepts only a string; structured drafts remain internal to restoration and Workspace transfer. Omitting options preserves the existing flow. An empty draft adopts the prompt. When the target already has text or attachments, `whenDraftExists` decides: `keep` (the default) preserves it, `replace` replaces the target Session's text document with the prompt or clears it without one, and `append` adds the prompt as new paragraphs after the existing text and references. Neither deletes attachments or changes other Sessions. `clearPreviousDraft: true` acts as `replace` only when `whenDraftExists` is omitted. Prefilling does not send, execute commands, or change `blank`.
### Data model
@@ -41,7 +41,7 @@ The existing `dsh.conversation.<sessionId>` key remains. Readers accept both leg
There is no new draft service, storage key, general task framework, or Session creation entry point. SlotRenderer, Host Session logs, command execution, and file-discovery RPCs are unchanged. `InputActions` remains the shared input-action entry; components receive no separate content-initialization callback.
The call parameters are `startSession(workspaceId?: WorkspaceId, options?: StartSessionOptions)`, `requestDraftInitialization(binding: SessionBinding, options: DraftInitializationOptions)`, `SessionInputShell.requestDraftInitialization(options)`, and the internal `SessionInputShell.setDraft(input: string | DraftSnapshot)`. `StartSessionOptions` aliases `DraftInitializationOptions`, whose fields are `prompt?: string` and `clearPreviousDraft?: boolean`. A shallow copy captures these values before asynchronous creation, and the result addresses only the retained target binding. Initialization distinguishes applied, preserved, and submission-blocked outcomes. Refusal does not clear first; superseded navigation does not write a draft.
The call parameters are `startSession(workspaceId?: WorkspaceId, options?: StartSessionOptions)`, `requestDraftInitialization(binding: SessionBinding, options: DraftInitializationOptions)`, `SessionInputShell.requestDraftInitialization(options)`, and the internal `SessionInputShell.setDraft(input: string | DraftSnapshot)`. `StartSessionOptions` extends `DraftInitializationOptions`, whose fields are `prompt?: string`, `whenDraftExists?: 'replace' | 'append' | 'keep'`, and `clearPreviousDraft?: boolean`. A shallow copy captures these values before asynchronous creation, and the result addresses only the retained target binding. Initialization distinguishes applied, preserved, and submission-blocked outcomes. Refusal does not clear first; superseded navigation does not write a draft.
### Data flow
@@ -12,7 +12,7 @@ Status: implemented
保留现有 `SessionInputShell`、`DraftEditorRuntime`、Conversation store 和 Session 引用生命周期。普通文字与结构化草稿统一经过输入模型导入;初始导入在 `InputHub.shellFor()` 返回对象前完成,不由 React effect 恢复文字。已存在的输入对象不重新恢复。React 只挂载同一编辑器,并绑定现有持久化回调。
`startSession(workspaceId?, options?)` 的第二参数保留 `prompt` 与 `clearPreviousDraft`。`prompt` 仅接受字符串;结构化草稿只用于内部恢复和工作区携带。无选项时维持原流程。不清空时保留已有内容,空稿才采用 prompt;显式清空作用于目标 Session 的文字文档,不删除附件,也不修改其他 Session。预填不自动发送、执行命令或改变 `blank`。
`startSession(workspaceId?, options?)` 的第二参数包含 `prompt`、`whenDraftExists` 与 `clearPreviousDraft`。`prompt` 仅接受字符串;结构化草稿只用于内部恢复和工作区携带。无选项时维持原流程。空稿直接采用 prompt。目标已有文字或附件时由 `whenDraftExists` 决定:`keep`(默认)保留原稿,`replace` 用 prompt 替换目标 Session 的文字文档、无 prompt 时清空,`append` 在原有文字与引用之后另起段落追加 prompt。三者都不删除附件,也不修改其他 Session。只有未传 `whenDraftExists` 时,`clearPreviousDraft: true` 才等同 `replace`。预填不自动发送、执行命令或改变 `blank`。
### 数据模型
@@ -41,7 +41,7 @@ Status: implemented
不新增草稿服务、存储 key、通用任务框架或另一套 Session 创建入口;不改 SlotRenderer、Host Session 日志、命令执行和文件发现 RPC。`InputActions` 保留统一输入操作入口,组件不单独注入另一条内容初始化回调。
调用参数依次为 `startSession(workspaceId?: WorkspaceId, options?: StartSessionOptions)`、`requestDraftInitialization(binding: SessionBinding, options: DraftInitializationOptions)`、`SessionInputShell.requestDraftInitialization(options)`,以及内部的 `SessionInputShell.setDraft(input: string | DraftSnapshot)`。`StartSessionOptions` 直接引用 `DraftInitializationOptions`,字段为 `prompt?: string` 和 `clearPreviousDraft?: boolean`;进入异步创建前浅拷贝这些值,结果只作用于最终保留的 binding。初始化结果区分已应用、保留原稿和提交期间拒绝;拒绝不先清空,导航被后续操作取消时不写稿。
调用参数依次为 `startSession(workspaceId?: WorkspaceId, options?: StartSessionOptions)`、`requestDraftInitialization(binding: SessionBinding, options: DraftInitializationOptions)`、`SessionInputShell.requestDraftInitialization(options)`,以及内部的 `SessionInputShell.setDraft(input: string | DraftSnapshot)`。`StartSessionOptions` 扩展 `DraftInitializationOptions`,字段为 `prompt?: string`、`whenDraftExists?: 'replace' | 'append' | 'keep'` 和 `clearPreviousDraft?: boolean`;进入异步创建前浅拷贝这些值,结果只作用于最终保留的 binding。初始化结果区分已应用、保留原稿和提交期间拒绝;拒绝不先清空,导航被后续操作取消时不写稿。
### 数据流
@@ -0,0 +1,37 @@
# Bilingual-pair consistency record for 2026-10-14-plugin-entry-customization.md (docs/i18n/README.md): per heading
# section, a hash of its English and Chinese blocks outside code blocks and generated regions.
# After editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-10-14-plugin-entry-customization.md
/agent-note-plugin-entry-customization-region-owned-pins-and-more-menus:
en: 2fdf5b95d5603955
zh: bfdc61e7db5f5f5b
/agent-note-plugin-entry-customization-region-owned-pins-and-more-menus/problem:
en: 896876f139383cba
zh: 42a4aae7e5885780
/agent-note-plugin-entry-customization-region-owned-pins-and-more-menus/decision:
en: a6cfc29cb9caa83c
zh: 21bbeb6a7ab20018
/agent-note-plugin-entry-customization-region-owned-pins-and-more-menus/decision/user-visible-behavior:
en: f1456281aa937626
zh: 6fef46b8d72ddd2b
/agent-note-plugin-entry-customization-region-owned-pins-and-more-menus/decision/structure-and-ownership:
en: d6a2816ca55b1c38
zh: 53217016f1595755
/agent-note-plugin-entry-customization-region-owned-pins-and-more-menus/decision/package-changes-relative-to-release-dsh-0-2-1:
en: 18ed81fee1ca7d41
zh: fc298457e54b6488
/agent-note-plugin-entry-customization-region-owned-pins-and-more-menus/decision/actions-slots-and-menu-content:
en: c87e2fdd911714b5
zh: 074426fdc7abafa9
/agent-note-plugin-entry-customization-region-owned-pins-and-more-menus/decision/the-three-regions:
en: 770c6150e84742ba
zh: 1d619388c8f38646
/agent-note-plugin-entry-customization-region-owned-pins-and-more-menus/alternatives-considered:
en: 79355028387c4094
zh: 5b518166e4803ebe
/agent-note-plugin-entry-customization-region-owned-pins-and-more-menus/verification:
en: 9c6a9ec155bbbfaa
zh: 8bd6e7afc8c43d21
/agent-note-plugin-entry-customization-region-owned-pins-and-more-menus/consequences:
en: a2459a3316f3ee60
zh: 713b51329995fe46
@@ -0,0 +1,136 @@
# Agent Note: Plugin entry customization: region-owned pins and More menus
Status: implemented
English | [中文](2026-10-14-plugin-entry-customization.zh.md)
## Problem
The `release/dsh-0.2.1` Web app shows plugin entries in three regions: the left sidebar's panel buttons (`sidebar.panellist`), the Conversation header's View tabs (`conversation.view`), and the right sidebar guide's tool cards (guide entries from the right-sidebar tab registry). Each region shows every entry in registration order, so users cannot hide entries they rarely use, and every installed plugin makes the regions more crowded. Users also cannot browse more plugins or start creating a plugin from these positions.
The feature ships in `release/dsh-0.2.1` and is later merged back to master with the release, so it changes as little as possible relative to the release: no changes to the slot framework (`ui-slots`, `ui-renderer`), no new Cordis service, `ui-layout` unchanged, and client plugin packages share only types ([Client package export rules](../../../../packages/client/AGENTS.md)). Persisted data must be readable without migration by a later slot-level store for manual order and pin state.
## Decision
Each region keeps and persists its own list of unpinned entries and filters its own entry buttons with it. Each region draws its own More button, opens a menu with the shared `Menu` component, and renders its actions slot inside that menu. The default-loaded plugin package `ui-entry-customization` registers the same menu-content component into the three actions slots: the entry list with pin buttons, Browse other plugins, and Create your plugin. This follows the Plugins page's Add plugin menu, where the page draws the button and menu and `ui-agent-preset` adds Create plugin through `plugins.add.actions`. Entries cannot be reordered.
### User-visible behavior
- The left sidebar, the Conversation header, and the right sidebar guide each have a More button. Its menu lists the region's customizable entries. Selecting an entry name opens the entry; its pin button shows or hides the entry in the region; the bottom of the menu has Browse other plugins (opens the Plugins main list) and Create your plugin (opens a new or reused blank Session with the Creator preset and fills in the region's prompt without sending it).
- Entries appear in registration order, as in the release; they cannot be reordered.
- Default entries always show and never appear in the menu: Plugins in the left sidebar, Chat in the Conversation header, and Files in the right sidebar.
- Pin state survives page reloads and is shared by every Session.
- Hiding the selected entry does not navigate away from its content; the left-sidebar row or View tab of the selected entry stays visible, as the current item, until another entry is selected.
- The left-sidebar `<nav>` and the Conversation header tab bar keep their release visibility conditions: `<nav>` shows while any panel is registered, and the tab bar shows while more than one View is registered. Both conditions count registered entries, not entries left visible by pin state. With only Chat registered the tab bar stays hidden; with other Views registered, the tab bar and its More button stay visible even when every other View is hidden, so users can pin them again.
- While an actions slot has no registrations, for example when `ui-entry-customization` is not loaded, its region shows no More button.
### Structure and ownership
```text
ui-sidebar / ui-conversation / ui-sidebar-right (each region)
apply: the region's unpinned list (createSnapshotStore + persist) and setPinned
whether the actions slot has registrations (entriesOfSlot + subscribe)
root component: draws only fixed or pinned entries
while the actions slot has registrations, draws the region's More button and Menu:
<Menu anchor={region's More button} …>
renderSlot('<region>.actions', { entries, onOpen, onPinnedChange, onDismiss })
</Menu>
ui-entry-customization (default-loaded)
registers one menu-content component into each actions slot (id 'entry-customization')
content: non-fixed entries with pin buttons, Browse other plugins, Create your plugin; keeps no state
```
Dependency direction: the three regions do not depend on the plugin package and only add uses of `client-store` and `ui-primitives` they already depend on. `ui-entry-customization` depends on the three regions (slot types) and on `ui-plugin-manager` and `ui-workspace` (service types), and calls `ctx.pluginNavigation` and `ctx.uiWorkspace` at runtime. No package gains a new runtime value import from another package.
### Package changes relative to release/dsh-0.2.1
| Package | Change |
|---|---|
| `ui-sidebar` | Adds `sidebar.panellist.actions`; apply adds the unpinned list, `setPinned`, and the actions-slot registration state; `SidebarRoot` filters entries and draws the More button and menu at the end of `<nav>`; expanded panel rows use a 2px gap instead of 4px |
| `ui-conversation` | Adds `conversation.session.header.views.actions`; apply adds the same three parts; the Session header filters View tabs and draws the More button and menu inside the tab bar after the tablist; View tabs use a 32px gap instead of 36px, with 20px before More |
| `ui-sidebar-right` | Adds `sidebar.right.tab.guide.actions`; apply adds the same three parts; the guide filters cards and draws the More card and menu after the cards; the guide registration declares the `sidebarRight` locale namespace |
| `ui-entry-customization` (new package) | Registers the menu-content component into the three actions slots; owns the `entryCustomization` locale namespace; keeps no state |
| `ui-plugin-manager` | Adds `openList()` to `pluginNavigation`: selects the Installed tab of the Plugins main list without opening a dialog or changing the current Session |
| `ui-primitives` | Adds `minWidthFromAnchor` to `Menu` so the right-sidebar menu is never narrower than its card; adds `IconAppsAddOutlineRegular` / `IconAppsAddOutlineMedium` (`compact` for card size), shared by the left-sidebar and right-sidebar buttons |
| `@deepseek-ai/dsh-web-app` | Loads `ui-entry-customization` by default |
### Actions slots and menu content
The three actions slots are root-scoped list slots declared by their regions and rendered inside the region's More menu. Other plugins may register menu items into them; items follow their registration `order`. Each region declares the owner props in its own contract (`SidebarPanelActionsOwnerProps`, `ConversationViewActionsOwnerProps`, `SidebarRightGuideActionsOwnerProps`) with the same fields; the left sidebar's type is:
```ts
interface SidebarPanelActionsOwnerProps {
/** The region's customizable entries in registration order; fixed entries are excluded. */
readonly entries: readonly { readonly id: string; readonly label: string; readonly pinned: boolean }[]
/** Open an entry without changing its pin state. */
readonly onOpen: (id: string) => void
/** Show (true) or hide (false) an entry. */
readonly onPinnedChange: (id: string, pinned: boolean) => void
/** Close the More menu. */
readonly onDismiss: () => void
}
```
The `ui-entry-customization` menu-content component renders those fields: one row per entry (`EntryPickerRow`, whose name and pin button are separately focusable). Selecting the name calls `onDismiss` and then `onOpen`; the pin button calls `onPinnedChange`. Browse other plugins and Create your plugin follow and call `onDismiss` before acting. Each registration receives its region (used only to choose pin copy and the Creator prompt) and the two footer actions through its registration `inject`. Browse other plugins calls `ctx.pluginNavigation.openList()`; Create your plugin calls `ctx.uiWorkspace.startSession(undefined, { agentPreset: 'cordis', prompt, whenDraftExists: 'append' })` with the region's localized prompt. The call does not go through `ui-agent-preset`, so the preset chip plays no introduction animation. When no Creator preset exists, Session creation fails, Workspace shows its creation-failure notice, and no draft is created.
The region decides its More button's styling, accessible name ("More", "More conversation views", and "More tools", from the regions' `panels.more`, `session.moreViews`, and `tab.guide.more` copy), Tooltip, and the menu's placement and width. Every menu uses the `ui-primitives` `Menu` component.
### The three regions
| | Left sidebar (`ui-sidebar`) | Conversation header (`ui-conversation`) | Right sidebar guide (`ui-sidebar-right`) |
|---|---|---|---|
| Entry source | Existing `panels` (from `sidebar.panellist`) | Existing View list (from `conversation.view`; Trajectory is removed while developer tools are off) | Existing tab registry `guide()` |
| Entry id | Panel id | View id | `kind:entryId` |
| Fixed | `plugins` | `chat` | Entries of kind `files` |
| localStorage key | `dsh.slot.manual-order.sidebar.panellist.v1` | `dsh.slot.manual-order.conversation.view.v1` | `dsh.slot.manual-order.sidebar.right.tab.guide.list.v1` |
| Actions slot | `sidebar.panellist.actions` | `conversation.session.header.views.actions` | `sidebar.right.tab.guide.actions` |
| More button | Panel-button styling; shows its label when expanded and a Tooltip when collapsed | Small header button with a Tooltip | Card styling; the menu is at least as wide as the card and aligned to its end |
| `onOpen` | `selectPanel(id)` | The Session's `selectView(id)` | Finds the entry by id, then `openTab(kind, { replaceTab: true })` |
Each region defines its fixed entry in a module constant. The unpinned list is created in apply with client-store's `createSnapshotStore(…, { persist })`, is independent of Sessions, and stores `{ "unpinned": string[] }`. It reaches the region's root component through the inject `hooks.entryPins`; `setPinned` is an inject callback and ignores the fixed entry. Reads accept only an array of strings and treat any other value as an empty list, so corrupted localStorage content cannot break rendering. Ids that are no longer registered stay in `unpinned`, so an entry registered again stays hidden. Whether the actions slot has registrations reaches the root component through `hooks.entryActions`.
Every localStorage key has the form `dsh.slot.manual-order.<list slot key>.v1`, named after the list slot that holds the entries rather than after the region package: the left sidebar and the Conversation header use the existing `sidebar.panellist` and `conversation.view`. The right-sidebar guide entries come from the tab registry and have no list slot in the release, so their key uses the name reserved for one, `sidebar.right.tab.guide.list`. A later slot-level store for the manual order and pin state of a list slot reads these keys and ids directly; saved `unpinned` values need no migration, and fields added later default to empty lists when absent from saved values.
Right-sidebar ids use `kind` instead of `providerId`: when an extension takes over a kind, `providerId` changes while `kind` stays the same, so persisted pin state remains valid after the takeover.
## Alternatives considered
**Keep pin state in a Cordis service (provided by `ui-layout` or the plugin).** A region must read pin state while drawing its own buttons, and a slot only passes data from the region to the plugin, so plugin-held state needs a new public service. A `ui-layout` service would hold state unrelated to layout; a plugin-provided service would require each region to handle the service appearing and disappearing. Region-held state needs only a few lines.
**One pin service per region.** Adds three public services with different signatures, while pin state is used only by its region and that region's menu.
**Add `useSlotEntries` / `useManualOrderedListSlot` to the slot framework.** Requires changing `ui-slots` and `ui-renderer`, makes `ui-renderer` hold user preferences and read and write localStorage, and requires the right sidebar to mirror its guide entries into an internal list slot. Without reordering, each region's pin logic is a few lines, which does not justify changing the framework in a release.
**Drag reordering in the left sidebar.** Only the left sidebar would use it, so the three regions would behave differently; this version does not support reordering.
**Make the popover a Component Factory with the More button as a local slot.** The callers are the three regions and the implementation is the plugin. Declaring the Factory in the plugin package creates a dependency cycle, because the plugin's dependencies `ui-plugin-manager` and `ui-workspace` depend on the regions. Declaring it in `ui-layout` cannot name the plugin's locale namespace, so the declaration would be split across two packages and completed through declaration merging.
**Let the plugin draw the whole More control (button and menu) from region data.** The plugin would copy the three regions' button styles, because CSS Modules cannot be reused across packages, and would need layout state from each region. Button styling carries region-specific behavior and belongs to the region.
**Let the plugin render the region entries.** Left-sidebar icons and right-sidebar cards depend on their regions' child slots, and the plugin cannot reach the region's `renderSlot`; React nodes also cannot be passed as props.
**Stop applying pin state while the plugin is not loaded.** The only benefit is that entries hidden before the plugin was disabled or failed to load would still show. That case requires explicitly disabling a default-loaded plugin; the limitation is accepted and documented in the README.
**Show the tab bar based on entries left visible by pin state.** With other Views registered, hiding all of them would remove the tab bar together with its More button, and users could not pin them again.
**Publish `agentPresetNavigation`, or have `ui-agent-preset` react to `startSession` calls that carry `agentPreset` and play the introduction animation.** The first adds a service only to forward to `startSession`; the second changes the `ui-workspace` and `ui-agent-preset` interfaces, and the introduction animation is not essential to this feature.
**Name localStorage keys after region packages.** A later slot-level store keyed by list slot would have to migrate saved data.
## Verification
- Unit tests for the three regions cover: entry buttons show only fixed or pinned entries; `setPinned` ignores the fixed entry; `setPinned` writes and persists to the region's key; corrupted localStorage content reads as an empty list; ids that are no longer registered are kept; More shows while the actions slot has registrations and passes `entries` without fixed entries and the callbacks, and is absent without registrations; the Conversation header tab bar stays hidden with only Chat and stays visible after every other View is hidden; right-sidebar ids are `kind:entryId`.
- Unit tests for the menu-content component cover: opening an entry, toggling a pin, an empty list showing only the footer actions, the arguments of Browse other plugins and Create your plugin, and removal of the three registrations when the plugin is released.
- The Web e2e `entry-customization.e2e.ts` exercises the left sidebar through the real Web composition: More shows by default, hiding a panel removes its button, the panel still opens from the menu, it stays hidden after a reload, and pinning it again restores the button. Recorded-session Web goldens cover the Conversation header and right-sidebar More buttons; `changed-files-turn.e2e.ts` and `document-preview.e2e.ts` assert that the compact header has no tab bar with only Chat.
- Every changed source file has 100% per-file coverage.
## Consequences
- If a user hides entries and the plugin is later explicitly disabled or fails to load, the hidden entries stay hidden with no menu to restore them; re-enabling the plugin or clearing the region's localStorage key restores them.
- localStorage is isolated by protocol, host, and port, so pin state is not shared after changing the Web port or between Web and Desktop.
- client-store's persist does not validate restored values, so each region validates on read; the validation and the owner props type exist once per region.
- The actions slots sit inside the More menus, so other plugins can add menu items but cannot place arbitrary controls beside the entries.
- The right-sidebar key reserves `sidebar.right.tab.guide.list`; a later list slot for guide entries must use that name for saved pin state to carry over.
- When an extension takes over a kind and uses different guide entry ids, existing pin state does not match the new entries.
- With the plugin loaded by default, Web goldens that record the left navigation, the Conversation header tab bar, or the right sidebar guide include a More button.
@@ -0,0 +1,136 @@
# Agent Note: 插件入口自定义:区域自管的 Pin 与“更多”菜单
Status: implemented
[English](2026-10-14-plugin-entry-customization.md) | 中文
## 问题
`release/dsh-0.2.1` 的 Web 应用在三个区域显示插件入口:左侧栏的面板按钮(`sidebar.panellist`)、会话头部的 View 标签(`conversation.view`)和右侧栏起始页的工具卡片(右侧栏 tab 注册表的 guide 入口)。三处都按注册顺序显示全部入口,用户不能收起不常用的入口,插件越多三处越挤;用户也不能在这些位置直接浏览更多插件或开始创建插件。
该功能随 `release/dsh-0.2.1` 发布,之后随 release 合回 master,因此相对 release 的改动要尽量少:不修改 slot 框架(`ui-slots`、`ui-renderer`),不新增 Cordis 服务,`ui-layout` 保持不变,client 插件包之间只共享类型([Client 包导出规则](../../../../packages/client/AGENTS.md))。持久化的数据要能被以后在 slot 层统一保存手动顺序与 Pin 状态的实现直接沿用。
## 决策
三个区域各自保存本区域被收起入口的列表并持久化,用它过滤自己的入口按钮;各自画自己的“更多”按钮,用共用的 `Menu` 组件弹出菜单,菜单里渲染本区域的 actions slot。默认加载的插件包 `ui-entry-customization` 往三个 actions slot 注册同一个菜单内容组件:入口列表与 Pin 按钮、“浏览更多插件”和“创建你的插件”。做法与插件页的“添加插件”菜单一致:页面画按钮和菜单,`ui-agent-preset` 通过 `plugins.add.actions` 往菜单里加“创建插件”。不提供排序。
### 用户可见行为
- 左侧栏、会话头部、右侧栏起始页各有一个“更多”按钮,打开后的菜单列出该区域可自定义的入口。点入口名称打开它;点 Pin 让它在区域里显示或收起;底部有“浏览更多插件”(打开插件页主列表)和“创建你的插件”(用 Creator 预设新开或复用空白会话,填入该区域对应的提示语,不发送)。
- 入口按注册顺序显示,与 release 一致;不提供排序。
- 默认绑定的入口始终显示,不进入菜单:左侧栏的插件管理、会话头部的 Chat、右侧栏的文件。
- Pin 状态在刷新页面后保留,所有会话共用。
- 收起当前选中的入口不会切走当前内容;左侧栏行或 View 标签在选中其他入口之前保持显示,并标为当前项。
- 左侧栏 `<nav>` 与会话头部标签条的显示条件与 release 一致:`<nav>` 在有已注册面板时显示,标签条在注册的 View 多于一个时显示。条件按已注册的入口计算,不按 Pin 后可见的入口计算,因此只有 Chat 时不显示标签条;有其他 View 时即使全部收起,标签条和其中的“更多”仍然显示,用户可以重新固定。
- actions slot 没有注册项时(例如 `ui-entry-customization` 未加载),区域不显示“更多”。
### 结构与职责
```text
ui-sidebar / ui-conversation / ui-sidebar-right (each region)
apply: the region's unpinned list (createSnapshotStore + persist) and setPinned
whether the actions slot has registrations (entriesOfSlot + subscribe)
root component: draws only fixed or pinned entries
while the actions slot has registrations, draws the region's More button and Menu:
<Menu anchor={region's More button} …>
renderSlot('<region>.actions', { entries, onOpen, onPinnedChange, onDismiss })
</Menu>
ui-entry-customization (default-loaded)
registers one menu-content component into each actions slot (id 'entry-customization')
content: non-fixed entries with pin buttons, Browse other plugins, Create your plugin; keeps no state
```
依赖方向:三个区域不依赖插件包,只增加 `client-store` 与 `ui-primitives` 的既有用法;`ui-entry-customization` 依赖三个区域(slot 类型)、`ui-plugin-manager` 与 `ui-workspace`(服务类型),运行时通过 `ctx.pluginNavigation` 与 `ctx.uiWorkspace` 调用。各包之间不新增运行时值导入。
### 各包改动(相对 release/dsh-0.2.1)
| 包 | 改动 |
|---|---|
| `ui-sidebar` | 新增 `sidebar.panellist.actions`;apply 中新增 unpinned 列表、`setPinned` 和 actions slot 注册状态;`SidebarRoot` 过滤入口,并在 `<nav>` 末尾画“更多”按钮与菜单;展开态面板行间距由 4px 改为 2px |
| `ui-conversation` | 新增 `conversation.session.header.views.actions`;apply 中新增同样三项;会话头部过滤 View 标签,并在标签条内、tablist 之后画“更多”按钮与菜单;View 标签间距由 36px 改为 32px,与“更多”间隔 20px |
| `ui-sidebar-right` | 新增 `sidebar.right.tab.guide.actions`;apply 中新增同样三项;起始页过滤卡片,并在卡片之后画“更多”卡片与菜单;guide 注册声明 `sidebarRight` 文案命名空间 |
| `ui-entry-customization`(新包) | 往三个 actions slot 注册菜单内容组件;`entryCustomization` 文案命名空间;不保存状态 |
| `ui-plugin-manager` | `pluginNavigation` 增加 `openList()`:切到插件页主列表的“已安装”标签,不打开对话框,不改变当前会话 |
| `ui-primitives` | `Menu` 增加 `minWidthFromAnchor`,让右侧栏菜单不窄于卡片;新增 `IconAppsAddOutlineRegular` / `IconAppsAddOutlineMedium`(`compact` 用于卡片尺寸),供左侧栏和右侧栏的按钮共用 |
| `@deepseek-ai/dsh-web-app` | 默认加载 `ui-entry-customization` |
### actions slot 与菜单内容
三个 actions slot 都是 root 级 list slot,由各区域声明,渲染在本区域的“更多”菜单里,其他插件也可以往里注册菜单项,排列顺序按注册项的 `order`。owner props 由各区域在自己的 contract 中声明(`SidebarPanelActionsOwnerProps`、`ConversationViewActionsOwnerProps`、`SidebarRightGuideActionsOwnerProps`),三处字段相同,以左侧栏为例:
```ts
interface SidebarPanelActionsOwnerProps {
/** The region's customizable entries in registration order; fixed entries are excluded. */
readonly entries: readonly { readonly id: string; readonly label: string; readonly pinned: boolean }[]
/** Open an entry without changing its pin state. */
readonly onOpen: (id: string) => void
/** Show (true) or hide (false) an entry. */
readonly onPinnedChange: (id: string, pinned: boolean) => void
/** Close the More menu. */
readonly onDismiss: () => void
}
```
`ui-entry-customization` 的菜单内容组件按上述字段渲染:每个入口一行(`EntryPickerRow`,名称与 Pin 按钮各自可聚焦),点名称先 `onDismiss` 再 `onOpen`,点 Pin 调 `onPinnedChange`;其后是“浏览更多插件”和“创建你的插件”,先 `onDismiss` 再执行。每个注册项通过 registration `inject` 得到所属区域(只用于选择 Pin 提示与创建提示语)和两个底部操作:“浏览更多插件”调用 `ctx.pluginNavigation.openList()`;“创建你的插件”调用 `ctx.uiWorkspace.startSession(undefined, { agentPreset: 'cordis', prompt, whenDraftExists: 'append' })`,`prompt` 按区域取本地化文案。该调用不经过 `ui-agent-preset`,预设标签不播放介绍动画;Creator 预设不存在时会话创建失败,Workspace 显示创建失败提示,不产生草稿。
“更多”按钮的样式、无障碍名称(“更多”“更多会话视图”“更多工具”,分别来自各区域的 `panels.more`、`session.moreViews`、`tab.guide.more` 文案)、Tooltip、菜单的弹出方向与宽度都由区域决定;菜单外框统一使用 `ui-primitives` 的 `Menu`。
### 三个区域
| | 左侧栏(`ui-sidebar`) | 会话头部(`ui-conversation`) | 右侧栏起始页(`ui-sidebar-right`) |
|---|---|---|---|
| 入口来源 | 现有的 `panels`(来自 `sidebar.panellist`) | 现有的 View 列表(来自 `conversation.view`,未开启开发者工具时去掉 Trajectory) | 现有的 tab 注册表 `guide()` |
| 入口 id | 面板 id | View id | `kind:入口id` |
| fixed | `plugins` | `chat` | kind 为 `files` 的入口 |
| localStorage 键 | `dsh.slot.manual-order.sidebar.panellist.v1` | `dsh.slot.manual-order.conversation.view.v1` | `dsh.slot.manual-order.sidebar.right.tab.guide.list.v1` |
| actions slot | `sidebar.panellist.actions` | `conversation.session.header.views.actions` | `sidebar.right.tab.guide.actions` |
| “更多”按钮 | 与面板按钮同样式;展开时显示文字,收起时显示 Tooltip | 头部样式的小按钮,带 Tooltip | 卡片样式,菜单不窄于卡片并右对齐 |
| `onOpen` | `selectPanel(id)` | 本会话的 `selectView(id)` | 按 id 找到入口后 `openTab(kind, { replaceTab: true })` |
每个区域用一个模块常量定义 fixed。unpinned 列表在 apply 中用 client-store 的 `createSnapshotStore(…, { persist })` 创建,与会话无关,值为 `{ "unpinned": string[] }`,经 inject 的 `hooks.entryPins` 交给区域根组件,`setPinned` 作为 inject 回调,对 fixed 入口不生效。读取时只接受字符串数组,其他值按空列表处理,避免损坏的 localStorage 内容影响渲染。`unpinned` 中已不存在的 id 保留,同一入口重新注册后保持收起。actions slot 是否有注册项经 `hooks.entryActions` 交给根组件。
localStorage 键统一为 `dsh.slot.manual-order.<list slot key>.v1`,按入口所在的 list slot 命名,不按区域包命名:左侧栏与会话头部使用现有的 `sidebar.panellist`、`conversation.view`;右侧栏的 guide 入口来自 tab 注册表,release 中还没有对应的 list slot,键名使用为它预留的 `sidebar.right.tab.guide.list`。以后在 slot 层统一保存同一 list slot 的手动顺序与 Pin 状态时,直接沿用这些键和 id,已保存的 `unpinned` 不需要迁移;新增的字段在旧值中缺失时按空列表处理。
右侧栏 id 使用 `kind` 而不是 `providerId`:扩展接管某个 kind 时 `providerId` 会改变,`kind` 不变,持久化的 Pin 状态在接管后仍然有效。
## 考虑过的替代方案
**由一个 Cordis 服务保存 Pin 状态(在 `ui-layout` 或插件中提供)。** 区域画自己的按钮时要读到 Pin 状态,而 slot 只能把数据从区域传给插件;由插件保存就必须新增公开服务。放在 `ui-layout` 会让布局服务承担与布局无关的状态;由插件提供时,区域还要处理服务时有时无。区域自己保存只需几行。
**三个区域各提供一个 Pin 服务。** 新增三个形态不同的公开服务,而 Pin 状态只被所在区域和它的菜单使用。
**在 slot 框架中提供 `useSlotEntries` / `useManualOrderedListSlot`。** 需要修改 `ui-slots` 与 `ui-renderer`,让 `ui-renderer` 持有用户偏好并读写 localStorage,右侧栏还要在内部同步成 list slot;不提供排序时,各区域的 Pin 逻辑只有几行,不值得为 release 修改框架。
**左侧栏拖动排序。** 只有左侧栏使用,三个区域的交互不一致;本版本不提供排序。
**把弹出框做成 Component Factory,“更多”按钮作为局部 slot。** 调用方是三个区域,实现方是插件:Factory 声明放在插件包会形成依赖环(插件依赖的 `ui-plugin-manager`、`ui-workspace` 依赖区域),放在 `ui-layout` 又引用不到插件的文案命名空间,只能把声明拆在两个包里用声明合并补齐。
**插件画整个“更多”(按钮与菜单),区域只提供数据。** 插件要复制三个区域的按钮样式(CSS Module 不能跨包复用),并从区域取得布局状态;按钮样式带有区域的业务特点,应留在各自的区域。
**由插件渲染区域入口。** 左侧栏图标和右侧栏卡片依赖各自的子 slot,插件拿不到区域的 `renderSlot`;ReactNode 也不能作为 props 传递。
**插件未加载时停止应用 Pin 状态。** 唯一收益是插件在用户收起入口后被禁用或加载失败时,入口仍能显示;该情形需要显式禁用默认加载的插件,接受这一限制并在 README 中说明。
**按 Pin 后可见的入口数决定标签条是否显示。** 有其他 View 时全部收起后,标签条和其中的“更多”一起消失,用户无法重新固定。
**公开 `agentPresetNavigation`,或让 `ui-agent-preset` 响应带 `agentPreset` 的 `startSession` 并播放介绍动画。** 前者只为转调 `startSession` 新增服务;后者要修改 `ui-workspace` 与 `ui-agent-preset` 的接口,介绍动画不是本功能的必要部分。
**按区域包命名 localStorage 键。** 以后在 slot 层按 list slot 统一保存时需要迁移已保存的数据。
## 验证
- 三个区域的单元测试覆盖:入口按钮只显示 fixed 或未收起的入口;对 fixed 入口调用 `setPinned` 不生效;`setPinned` 写入并持久化到对应键;localStorage 内容损坏时按空列表处理;已不存在的 id 被保留;actions slot 有注册项时显示“更多”并传入不含 fixed 入口的 `entries` 与回调,没有注册项时不显示;会话头部标签条在只有 Chat 时不显示,有其他 View 时全部收起后仍然显示;右侧栏 id 为 `kind:入口id`。
- 菜单内容组件的单元测试覆盖:入口打开、Pin 切换、空列表时只有底部操作、浏览更多插件与创建插件的调用参数,以及三个注册项在插件释放后移除。
- Web e2e `entry-customization.e2e.ts` 通过真实 Web 组合验证左侧栏:默认显示“更多”,收起后面板按钮消失,仍可从菜单打开,刷新后保持收起,重新固定后恢复。会话头部与右侧栏的“更多”由录制会话的 Web golden 覆盖;`changed-files-turn.e2e.ts` 与 `document-preview.e2e.ts` 断言只有 Chat 时紧凑头部没有标签条。
- 改动的源文件逐文件覆盖率 100%。
## 后果
- 用户收起入口后,若插件被显式禁用或加载失败,被收起的入口保持隐藏且没有菜单可以恢复;恢复方式是重新启用插件或清除对应 localStorage 键。
- localStorage 按协议、主机和端口隔离,更换 Web 端口或在 Web 与 Desktop 之间切换时 Pin 状态不共享。
- client-store 的 persist 读回时不校验格式,三个区域各自在读取处校验;校验逻辑与 owner props 类型在三处各有一份。
- actions slot 位于“更多”菜单内,其他插件只能往里加菜单项,不能在入口旁放置任意控件。
- 右侧栏的键名预留了 `sidebar.right.tab.guide.list`;以后为 guide 入口新增 list slot 时必须使用这个名称,已保存的 Pin 状态才能沿用。
- 扩展接管某个 kind 且使用不同的 guide 入口 id 时,原有 Pin 状态无法对应到新入口。
- 插件默认加载后,录有左侧导航、会话头部标签条或右侧栏起始页的 Web golden 都会出现“更多”按钮。
@@ -0,0 +1,25 @@
# Bilingual-pair consistency record for 2026-10-20-new-session-blank-reuse-by-preset.md (docs/i18n/README.md): per heading
# section, a hash of its English and Chinese blocks outside code blocks and generated regions.
# After editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-10-20-new-session-blank-reuse-by-preset.md
/agent-note-new-session-reuses-a-blank-session-matching-a-requested-preset:
en: ff1fe8bacfb05cee
zh: b03fa775407f8aa2
/agent-note-new-session-reuses-a-blank-session-matching-a-requested-preset/problem:
en: 5e6529a1ff3c1af5
zh: 7d08e27fd9e4ccac
/agent-note-new-session-reuses-a-blank-session-matching-a-requested-preset/decision:
en: a06d2e45382320f0
zh: 3eb09fc03c69eced
/agent-note-new-session-reuses-a-blank-session-matching-a-requested-preset/alternatives-considered:
en: 895f9dedbb6d7abf
zh: bb882bd3cc962ed3
/agent-note-new-session-reuses-a-blank-session-matching-a-requested-preset/verification:
en: bcfd62fdd75b53c4
zh: 040ca441c67030c2
/agent-note-new-session-reuses-a-blank-session-matching-a-requested-preset/consequences:
en: 88d1152b67ab392d
zh: 8c95b9fa7c1b2a04
/agent-note-new-session-reuses-a-blank-session-matching-a-requested-preset/deferred-work:
en: a7a8e49adae7b4fc
zh: a835f0e39b981fc6
@@ -0,0 +1,51 @@
# Agent Note: New Session reuses a blank Session, matching a requested preset
Status: implemented
English | [中文](2026-10-20-new-session-blank-reuse-by-preset.zh.md)
## Problem
In 0.2.0-rc.2, New Session (the sidebar button, the Workspace "+" button, and the shortcut, all calling `uiWorkspace.startSession` without options) reused the Workspace's first blank Session. PR #5775, first released in `dsh-v0.2.1-alpha.2`, changed it to create a fresh Session and Agent on every call, so that New Session composes the current preset definition after the user changes one instead of keeping a reused blank's earlier composition ([upgrade guide](../../../../docs/upgrade-guide/v0.2.1-alpha.1/new-session-generations/guide.md)).
A blank Session that is not current is hidden from the Session tree, and New Session is the only way back to it. After that change, a user who types a draft into a blank Session, switches to another Session, and clicks New Session lands in another empty Session; the original draft stays in localStorage under the old Session id, but no control returns to it. 0.2.1-rc.2 therefore loses New Session's unsent drafts.
The Creator entries (`startSession(…, { agentPreset: 'cordis', prompt, clearPreviousDraft: false })`) also reuse the first blank Session and switch it to Creator: an existing ordinary draft stays under Creator mode and the Creator prompt is not filled in, and New Session can likewise take over a Creator blank.
## Decision
Only the Client (`ui-workspace`) changes; the Host is unchanged:
- `startSession` without options takes the reuse path again and follows the same rule as `openWorkspace`, `connectWorkspace`, and `startSession` with options.
- `createInWorkspace` names the first unstarted, unarchived blank Session that is still a Workspace member. A request that names a preset skips a blank whose listed preset (`projectionValues.agentPreset`) is a different one, while a blank with no listed preset remains eligible; a request without a preset may name any blank, which the Host switches to its default preset. The Host creates a Session when no blank is eligible.
As a result, New Session returns to the original blank Session with its unsent draft, and preset flows such as the Creator entries do not take over a blank that lists another preset, so an ordinary draft is not moved under Creator. No persisted state is added, and the Client does not read the preset roster.
## Alternatives considered
**Revert the PR #5775 change completely, reusing the first blank for every request.** Drafts come back, but preset flows such as the Creator entries take over the blank holding the user's ordinary draft, switch it to Creator, keep the draft there, and cannot fill in their prompt.
**Keep creating a fresh Session and move the abandoned blank's draft into it.** This needs rules for which blank to move from when several exist, how to treat attachments, and whether to archive the old Session, which is more change and state than reusing blanks.
**Let a request without a preset reuse only a blank of the Host default preset.** The Client does not know the default preset and would have to request the `agentPresets.list()` roster.
**Record a persisted source-to-blank-Session map in the Client.** It duplicates the preset each blank already records and must handle stale entries and per-origin isolation.
**Clear the existing draft in place when the preset changes (`clearPreviousDraft: true`).** It deletes a draft the user was writing in the other mode.
**Also change the Host to always call `presets.select` when reusing a loaded blank.** It fully keeps #5775's goal but is outside this Client-only fix; see Deferred work.
## Verification
- `ui-workspace` unit tests: New Session without options returns to the existing blank; a request without a preset reuses any blank, a request with a preset reuses only a blank listing the same preset, and a new Session is created when none matches. `navigation.ts` has 100% per-file coverage.
- Regression localization: the same unit probe on the PR #5775 commit that made this change and on its parent, plus a browser reproduction on builds of both, confirmed that the draft loss comes from that change.
## Consequences
- New Session no longer guarantees the current preset definition: a reused blank whose Agent is still loaded in the Host with the same preset id keeps its creation-time composition; a blank resumed after a Host restart composes the current definition.
- New Session may reuse a blank left by a preset flow such as the Creator entries; the Host switches it back to the default preset and its draft stays.
- The [new-session-blank-reuse](../../../../docs/upgrade-guide/v0.2.1-rc.2/new-session-blank-reuse/guide.md) upgrade guide records the change relative to 0.2.1-rc.2.
## Deferred work
- Host `reuseLive` (`packages/api/session-controller/src/agent.ts`) returns the loaded Agent unchanged when the preset id matches and does not call `presets.select`. `select` decides whether a definition was registered again by comparing `Generation` objects: when they are the same it does nothing; when they differ it recomposes under the same Session id with the current definition and records `agent-preset/selected`; on a started Session it throws `agent-preset/locked`, and the caller creates a new Session instead. Removing the `target === current` early return and always calling `select` keeps drafts while meeting #5775's goal that New Session composes the current definition; it needs a Host test, and updates to the `reuseLive` JSDoc and to the upgrade guide's statement that the current preset definition is no longer guaranteed.
@@ -0,0 +1,51 @@
# Agent Note: 新会话复用空白会话,指定预设时按预设匹配
Status: implemented
[English](2026-10-20-new-session-blank-reuse-by-preset.md) | 中文
## 问题
0.2.0-rc.2 中,新会话(左侧栏按钮、工作区“+”和快捷键,均调用不带选项的 `uiWorkspace.startSession`)复用工作区内第一个空白会话。PR #5775(首次发布于 `dsh-v0.2.1-alpha.2`)改为每次新建 Session 与 Agent,目的是在用户修改预设定义后,新会话按当前定义装配,而不是沿用复用空白会话的旧装配([升级指南](../../../../docs/upgrade-guide/v0.2.1-alpha.1/new-session-generations/guide.zh.md))。
空白会话不是当前会话时不在 Session 树中显示,返回它的唯一入口是新会话。改为每次新建后,用户在空白会话中写下草稿、切到其他会话再点新会话,会进入另一个空的 Session;原草稿仍按会话 id 保存在 localStorage,但没有界面入口可以回到它。0.2.1-rc.2 因此丢失新会话的未发送草稿。
此外,创造模式入口(`startSession(…, { agentPreset: 'cordis', prompt, clearPreviousDraft: false })`)复用第一个空白会话并把它切换到 Creator:已有普通草稿时,草稿留在 Creator 模式下,Creator 提示语不会填入;反之新会话也可能接管 Creator 的空白会话。
## 决策
只修改前端(`ui-workspace`),Host 不变:
- 不带选项的 `startSession` 重新走复用分支,与 `openWorkspace`、`connectWorkspace` 和带选项的 `startSession` 使用同一规则。
- `createInWorkspace` 在未开始、未归档、仍属于该工作区的空白会话中选第一个。请求指明预设时,跳过列出的预设(`projectionValues.agentPreset`)为其他值的空白会话,未列出预设的空白会话仍可用;请求未指明预设时任何空白会话都可用,由 Host 切换到默认预设。没有可用的空白会话时由 Host 新建。
结果:新会话回到原来的空白会话,未发送的草稿保留;创造模式等预设流程不会接管列出其他预设的空白会话,用户的普通草稿不会被切到 Creator 下。不新增持久化状态,前端也不读取预设名单。
## 考虑过的替代方案
**完全回退 PR #5775 的这项改动,所有请求都复用第一个空白会话。** 草稿可以回来,但创造模式等预设流程会接管用户普通草稿所在的空白会话,把它切到 Creator,草稿留在 Creator 下,提示语也填不进去。
**保持每次新建,把被丢下的空白会话中的草稿迁移到新会话。** 需要定义多个空白会话时迁移哪一个、附件如何处理、旧会话是否归档,改动和状态都比按预设复用多。
**未指明预设的请求只复用 Host 默认预设的空白会话。** 前端不知道默认预设,需要额外请求 `agentPresets.list()` 名单。
**在前端记录“来源 → 空白会话 id”的映射并持久化。** 与空白会话自身记录的预设重复,需要处理映射过期、按浏览器 origin 隔离等问题。
**预设变化时在原会话上清空草稿(`clearPreviousDraft: true`)。** 会删除用户在另一种模式下写到一半的草稿。
**同时修改 Host,让复用内存中的空白会话时总是调用 `presets.select`。** 能完整保留 #5775 的目的,但不在本次只修前端的范围内,见“推迟工作”。
## 验证
- `ui-workspace` 单元测试:不带选项的新会话回到已有空白会话;未指明预设时复用任何空白会话,指明预设时只复用列出相同预设的空白会话,没有时新建。`navigation.ts` 逐文件覆盖率 100%。
- 回归定位:在 PR #5775 中做出这项改动的提交及其父提交上运行同一单元探针,以及在两侧构建上用浏览器复现,确认草稿丢失由该改动引入。
## 后果
- 新会话不再保证使用当前预设定义:复用的空白会话若其 Agent 仍在 Host 内存中且预设 id 相同,沿用创建时的装配;Host 重启后恢复的空白会话按当前定义装配。
- 新会话可能复用创造模式等预设流程留下的空白会话,Host 把它切回默认预设,其中的草稿保留。
- 升级指南 [new-session-blank-reuse](../../../../docs/upgrade-guide/v0.2.1-rc.2/new-session-blank-reuse/guide.zh.md) 记录相对 0.2.1-rc.2 的行为变化。
## 推迟工作
- Host `reuseLive`(`packages/api/session-controller/src/agent.ts`)在预设 id 相同时直接返回内存中的 Agent,不调用 `presets.select`。`select` 用定义的 `Generation` 对象是否相同判断定义是否重新注册过:相同则不做任何事;不同则在同一会话 id 上按当前定义重新装配并记录 `agent-preset/selected`;会话已开始时抛出 `agent-preset/locked`,调用方改为新建。去掉 `target === current` 的提前返回、改为总是调用 `select`,可以在保留草稿的同时满足 #5775“新会话按当前定义装配”的目的;需要配套 Host 测试,并更新 `reuseLive` 的 JSDoc 和上述升级指南中“不再保证当前预设定义”的说明。
+51 -28
View File
@@ -357,7 +357,7 @@ describe('web e2e: agent-preset selection', () => {
await writeComposerDraft(page, composer, '')
}, 90_000)
it('preserves the current mode while New Session uses the reset Standard default', async () => {
it('preserves the current mode until New Session reuses its blank under the reset Standard default', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-agent-preset-disabled'))
await expect.poll(() => livePreset(scaffold), { timeout: 15_000 }).toBe('standard')
@@ -381,17 +381,23 @@ describe('web e2e: agent-preset selection', () => {
await page.keyboard.press('Escape')
await expect.poll(async () => (await scaffold.ctx.agentPresets.remoteExportList()).presets.find(preset => preset.isDefault)?.id).toBe('standard')
// The current Session is still the Workspace's only blank, so New Session names it for
// reuse and the Host runs it under the reset default instead of its kept Minimal mode.
const currentSessionId = await page.evaluate(() =>
(JSON.parse(localStorage.getItem('dsh.sessions.current') ?? '{}') as { sessionId?: string }).sessionId)
expect(currentSessionId).toBeDefined()
const creation = page.waitForResponse('**/api/session/create')
await page.getByRole('button', { name: 'New session', exact: true }).last().click()
const created = await creation
expect(created.request().postDataJSON()).not.toHaveProperty('payload.args.request.sessionId')
expect(created.request().postDataJSON()).toHaveProperty('payload.args.request.sessionId', currentSessionId)
expect(created.request().postDataJSON()).not.toHaveProperty('payload.args.request.agentPreset')
const createdBody = await created.json() as { result: { value: { sessionId: SessionId; agentPreset: string } } }
expect(createdBody).toMatchObject({ result: { ok: true, value: { agentPreset: 'standard' } } })
const freshSessionId = createdBody.result.value.sessionId
const freshConversation = page.locator(`[data-conversation-session="${freshSessionId}"]`)
await freshConversation.waitFor()
expect(createdBody).toMatchObject({ result: { ok: true, value: { sessionId: currentSessionId, agentPreset: 'standard' } } })
const newSessionId = createdBody.result.value.sessionId
const newConversation = page.locator(`[data-conversation-session="${newSessionId}"]`)
await newConversation.waitFor()
await page.reload()
await freshConversation.waitFor()
await newConversation.waitFor()
await page.getByRole('button', { name: 'Standard mode', exact: true }).waitFor()
await openSettings(page, 'en')
@@ -405,7 +411,7 @@ describe('web e2e: agent-preset selection', () => {
await reopened.getByRole('button', { name: 'New task default: Standard mode' }).waitFor({ timeout: 10_000 })
await reopened.getByRole('button', { name: 'Set as new task default: Minimal mode' }).waitFor({ timeout: 10_000 })
await reopened.getByRole('button', { name: 'Close' }).last().click()
await expect.poll(() => livePreset(scaffold, freshSessionId), { timeout: 15_000 }).toBe('standard')
await expect.poll(() => livePreset(scaffold, newSessionId), { timeout: 15_000 }).toBe('standard')
await page.getByRole('button', { name: 'Standard mode' }).waitFor({ timeout: 10_000 })
})
@@ -445,40 +451,53 @@ it.each([false, true])('starts Creator from Plugins with Coding Tools=%s without
onTestFailed(() => saveFailureShot(page, 'web-e2e-plugin-creator'))
await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
await connectFreshWorkspace(page, scaffold.workspaceCwd)
const creator = scaffold.ctx.agents.list()[0]
if (creator === undefined) throw new Error('Connecting the workspace did not create a Session')
expect(scaffold.ctx.agentPresets.composedPreset(creator.ctx)).toBe('standard')
const standard = scaffold.ctx.agents.list()[0]
if (standard === undefined) throw new Error('Connecting the workspace did not create a Session')
expect(scaffold.ctx.agentPresets.composedPreset(standard.ctx)).toBe('standard')
await page.getByRole('button', { name: 'Standard mode', exact: true }).waitFor()
const composer = page.locator('[data-composer-input][contenteditable="true"]').last()
const composerOf = (sessionId: SessionId) =>
page.locator(`[data-conversation-session="${sessionId}"] [data-composer-input][contenteditable="true"]`)
const draft = 'Keep this unsent plugin idea.'
await writeComposerDraft(page, composer, draft)
await writeComposerDraft(page, composerOf(standard.id), draft)
await page.getByRole('navigation', { name: 'Global panels' }).getByRole('button', { name: 'Plugins', exact: true }).click()
await page.getByRole('button', { name: 'Choose how to add a plugin', exact: true }).click()
// The Creator entry reuses the draft's blank Session and names its preset in the creation request.
await Promise.all([
// The draft's blank lists Standard, so the Creator entry names no blank and the Host creates a separate Creator Session.
const [creation] = await Promise.all([
page.waitForResponse('**/api/session/create'),
page.getByRole('menuitem', { name: /^Let the agent create a plugin/ }).click(),
])
expect(creation.request().postDataJSON()).toHaveProperty('payload.args.request.agentPreset', 'cordis')
expect(creation.request().postDataJSON()).not.toHaveProperty('payload.args.request.sessionId')
const creationBody = await creation.json() as { result: { ok: boolean; value: { sessionId: SessionId; agentPreset: string } } }
expect(creationBody).toMatchObject({ result: { ok: true, value: { agentPreset: 'cordis' } } })
const creator = scaffold.ctx.agents.get(creationBody.result.value.sessionId)
if (creator === undefined) throw new Error('The Creator entry did not publish its Session')
expect(creator.id).not.toBe(standard.id)
const picker = page.getByTitle('Choose the agent preset for your new task', { exact: true }).filter({ hasText: 'Creator mode' })
await picker.waitFor()
await composer.filter({ hasText: draft }).waitFor()
expect(await composer.innerText()).toBe(draft)
const creatorComposer = composerOf(creator.id)
await creatorComposer.waitFor()
expect((await creatorComposer.innerText()).trim()).toBe('')
expect(await picker.getAttribute('aria-haspopup')).toBe('menu')
expect(await page.getByRole('dialog', { name: 'Add plugin', exact: true }).count()).toBe(0)
expect(creator.session.header.agentPreset).toBe('cordis')
expect(scaffold.ctx.sessionProjections.checkpoint(creator.session).agentPreset?.val).toBe('cordis')
expect(scaffold.ctx.agentPresets.composedPreset(creator.ctx)).toBe('cordis')
expect(scaffold.ctx.tools.schemas(creator).map(tool => tool.name))
.toEqual(expect.arrayContaining(['cordis_inspect_list', 'cordis_inspect_query', 'plugin_manager']))
// The chip may also select Creator once the list updates; the registry records the switch only once.
expect(creator.session.snapshotEvents().filter(event => event.type === 'agent-preset/selected'
&& event.data.agentPreset === 'cordis')).toHaveLength(1)
// The Session starts under Creator; a chip selection after the list updates records no switch.
expect(creator.session.snapshotEvents().filter(event => event.type === 'agent-preset/selected')).toEqual([])
expect(creator.session.snapshotEvents().some(event => event.type === 'user/message' || event.type === 'turn/start')).toBe(false)
// The draft's blank keeps its preset and starts no turn.
expect(scaffold.ctx.sessionProjections.checkpoint(standard.session).agentPreset?.val).toBe('standard')
expect(scaffold.ctx.agentPresets.composedPreset(standard.ctx)).toBe('standard')
expect(standard.session.snapshotEvents().some(event => event.type === 'agent-preset/selected'
|| event.type === 'user/message' || event.type === 'turn/start')).toBe(false)
expect(scaffold.ctx.settings.describe().find(row => row.ns === 'ui-settings')?.value).toMatchObject({ enabled: developerTools })
expect(scaffold.ctx.agentPresets.defaultId).toBe('standard')
// Close a fixture turn on the real Creator Session without requesting a model.
await writeComposerDraft(page, composer, '')
creator.session.append('turn/start', { turn: 1 })
creator.session.append('user/message', createUserMessage({
content: [{ type: 'text', text: 'Creator fixture completed without a model call.' }], source: { kind: 'user' },
@@ -486,14 +505,18 @@ it.each([false, true])('starts Creator from Plugins with Coding Tools=%s without
creator.session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
await scaffold.ctx.sessions.flush(creator.session)
await page.getByText('Creator fixture completed without a model call.', { exact: true }).waitFor()
// New Session returns to the Standard blank and its unsent draft.
const newSession = page.getByRole('button', { name: 'New session', exact: true }).filter({ hasText: 'New Session' })
await Promise.all([page.waitForResponse('**/api/session/create'), newSession.click()])
expect(scaffold.ctx.agents.list().find(agent => agent.id !== creator.id)).toBeDefined()
const next = scaffold.ctx.agents.list().find(agent => agent.id !== creator.id)!
expect(next.id).not.toBe(creator.id)
expect(next.session.header.agentPreset).toBe('standard')
expect(scaffold.ctx.sessionProjections.checkpoint(next.session).agentPreset?.val).toBe('standard')
expect(scaffold.ctx.agentPresets.composedPreset(next.ctx)).toBe('standard')
const [reopening] = await Promise.all([page.waitForResponse('**/api/session/create'), newSession.click()])
expect(reopening.request().postDataJSON()).toHaveProperty('payload.args.request.sessionId', standard.id)
expect(await reopening.json()).toMatchObject({ result: { ok: true, value: { sessionId: standard.id, agentPreset: 'standard' } } })
await composerOf(standard.id).filter({ hasText: draft }).waitFor()
expect(await composerOf(standard.id).innerText()).toBe(draft)
const reopened = scaffold.ctx.agents.get(standard.id)
if (reopened === undefined) throw new Error('New Session did not keep the Standard blank live')
expect(reopened.session.header.agentPreset).toBe('standard')
expect(scaffold.ctx.sessionProjections.checkpoint(reopened.session).agentPreset?.val).toBe('standard')
expect(scaffold.ctx.agentPresets.composedPreset(reopened.ctx)).toBe('standard')
expect(scaffold.ctx.sessionProjections.checkpoint(creator.session).agentPreset?.val).toBe('cordis')
await page.getByRole('button', { name: 'Standard mode', exact: true }).waitFor()
expect(scaffold.ctx.settings.describe().find(row => row.ns === 'ui-settings')?.value).toMatchObject({ enabled: developerTools })
+62
View File
@@ -0,0 +1,62 @@
/** Default-loaded entry controls activate through the real Web profile and Loader. */
import { fileURLToPath } from 'node:url'
import { chromium, type Browser, type Page } from 'playwright'
import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
import { launchWebScaffold, watchConsole, type WebScaffold, type WebConsoleTripwire } from './scaffold.ts'
import { newEnglishPage, saveFailureShot } from './support.ts'
describe('default navigation entry customization', () => {
let scaffold: WebScaffold | undefined
let browser: Browser | undefined
let page: Page
let tripwire: WebConsoleTripwire
beforeAll(async () => {
scaffold = await launchWebScaffold({
extraOverlayPath: fileURLToPath(new URL('./pin-browse-picker.overlay.yml', import.meta.url)),
})
browser = await chromium.launch()
page = await newEnglishPage(browser)
tripwire = watchConsole(page)
await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
})
afterAll(async () => {
try { await browser?.close() } finally { await scaffold?.close() }
})
it('unpins a sidebar panel across reloads while its panel stays reachable from More', 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 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()
expect(tripwire.pageErrors).toEqual([])
expect(tripwire.warnings).toEqual([])
})
})
@@ -5,6 +5,7 @@
- tablist:
- tab "Chat" [selected]
- tab "Trajectory"
- button "More conversation views"
- text: "Assemble the link gallery: write the report and styles, inspect the sources, and summarize. {{clock}}"
- button "Copy"
- status: Completed
@@ -5,6 +5,7 @@
- tablist:
- tab "Chat" [selected]
- tab "Trajectory"
- button "More conversation views"
- navigation "Turn navigation":
- button "Jump to turn 1"
- button "Jump to turn 2"
@@ -16,6 +16,7 @@
- tablist:
- tab "Chat" [selected]
- tab "Trajectory"
- button "More conversation views"
- button "GitHub event received {{clock}}":
- text: GitHub event received
- time: {{clock}}
@@ -16,6 +16,7 @@
- tablist:
- tab "Chat" [selected]
- tab "Trajectory"
- button "More conversation views"
- button "GitHub event received {{clock}}":
- text: GitHub event received
- time: {{clock}}
@@ -6,6 +6,7 @@
- tablist:
- tab "Chat" [selected]
- tab "Trajectory"
- button "More conversation views"
- group "Command input": /goal
- 'button "goal No goal is currently set. Usage: /goal [<objective>|clear|edit <objective>|pause|resume]"'
- textbox "Message or run a task, / commands, @ files or sessions"
@@ -5,6 +5,7 @@
- tablist:
- tab "Chat" [selected]
- tab "Trajectory"
- button "More conversation views"
- text: Render adjacent CJK strong emphasis. {{clock}}
- button "Copy"
- status: Completed
@@ -5,6 +5,7 @@
- tablist:
- tab "Chat" [selected]
- tab "Trajectory"
- button "More conversation views"
- text: Show the Markdown image policy. {{clock}}
- button "Copy"
- status: Completed
@@ -4,6 +4,7 @@
- tablist:
- tab "Chat" [selected]
- tab "Trajectory"
- button "More conversation views"
- text: Show the local preview URL. {{clock}}
- button "Copy"
- status: Completed
@@ -5,6 +5,7 @@
- tablist:
- tab "Chat" [selected]
- tab "Trajectory"
- button "More conversation views"
- text: Render this mathematical proof. {{clock}}
- button "Copy"
- status: Completed
@@ -5,6 +5,7 @@
- tablist:
- tab "Chat" [selected]
- tab "Trajectory"
- button "More conversation views"
- text: Research notes what changed? Referenced session · Research notes {{clock}}
- button "Copy"
- status: Completed
@@ -7,3 +7,4 @@
- button "New terminal Run commands in the Session workspace"
- button "Choose shell"
- button "Browser Browse web pages"
- button "More tools"
@@ -6,6 +6,7 @@
- tablist:
- tab "Chat" [selected]
- tab "Trajectory"
- button "More conversation views"
- button "/user-invoke-demo"
- button "meeting notes-this-is-a-very-long-filename-for-testing-user-message-file-references-and-preview-layout.md"
- text: and confirm the fixture wiring {{clock}}
@@ -6,6 +6,7 @@
- tablist:
- tab "Chat" [selected]
- tab "Trajectory"
- button "More conversation views"
- button "/user-invoke-demo"
- button "meeting notes-this-is-a-very-long-filename-for-testing-user-message-file-references-and-preview-layout.md"
- text: and confirm the fixture wiring {{clock}}
@@ -5,6 +5,7 @@
- tablist:
- tab "Chat" [selected]
- tab "Trajectory"
- button "More conversation views"
- navigation "Turn navigation":
- button "Jump to turn 1"
- button "Jump to turn 2"
@@ -6,6 +6,7 @@
- tablist:
- tab "Chat" [selected]
- tab "Trajectory"
- button "More conversation views"
- text: Use the ask_user_question tool to ask me exactly one question with id "checkpoint", question "Ready to continue?", header "Checkpoint", and options labeled "Yes" and "No". After I answer, reply with one short sentence acknowledging my answer and stop. {{clock}}
- button "Copy"
- button "Think The user wants me to ask them a checkpoint question first, then continue with whatever they interject. Let me do exactly that."
@@ -6,6 +6,7 @@
- tablist:
- tab "Chat" [selected]
- tab "Trajectory"
- button "More conversation views"
- text: Use the ask_user_question tool to ask me exactly one question with id "checkpoint", question "Ready to continue?", header "Checkpoint", and options labeled "Yes" and "No". After I answer, reply with one short sentence acknowledging my answer and stop. {{clock}}
- button "Copy"
- status: Completed
@@ -6,6 +6,7 @@
- tablist:
- tab "Chat" [selected]
- tab "Trajectory"
- button "More conversation views"
- text: Use the ask_user_question tool to ask me exactly one question with id "checkpoint", question "Ready to continue?", header "Checkpoint", and options labeled "Yes" and "No". After I answer, reply with one short sentence acknowledging my answer and stop. {{clock}}
- button "Copy"
- status: Completed
+4 -3
View File
@@ -163,11 +163,12 @@ describe.skipIf(mode === 'record')('web e2e: workspace shortcuts', () => {
const newSessionBody = await newSessionResponse.json() as { result: { ok: boolean; value: { sessionId: string } } }
expect(newSessionBody.result.ok).toBe(true)
const newSessionId = newSessionBody.result.value.sessionId
expect(previousSessionIds).not.toContain(newSessionId)
// The added Workspace's only member is still blank, so New Session returns to it.
expect(previousSessionIds).toEqual([newSessionId])
expect(newSessionResponse.request().postDataJSON()).toHaveProperty('payload.args.request.sessionId', newSessionId)
await page.locator(`[data-conversation-session="${newSessionId}"]`).waitFor()
await expect.poll(() => page.getByRole('treeitem', { selected: true }).textContent()).toContain('New Session')
await expect.poll(() => workspace.sessionIds).toHaveLength(previousSessionIds.length + 1)
expect(workspace.sessionIds).toEqual(expect.arrayContaining([...previousSessionIds, newSessionId]))
expect(workspace.sessionIds).toEqual(previousSessionIds)
await bind('Open locally')
await page.keyboard.press('Meta+Shift+Comma')
@@ -6,6 +6,7 @@
- tablist:
- tab "Chat" [selected]
- tab "Trajectory"
- button "More conversation views"
- text: Stream one TypeScript fence for the highlighting snapshot. {{clock}}
- button "Copy"
- text: ts
+13 -6
View File
@@ -986,13 +986,14 @@ describe('web e2e: workspace management (create / rename / grouping / hover affo
const createdBody = await createdResponse.json() as { result: { ok: boolean; value: { sessionId: string } } }
expect(createdBody.result.ok).toBe(true)
const createdSessionId = createdBody.result.value.sessionId
expect(previousParentSessionIds).not.toContain(createdSessionId)
// The parent Workspace's only member is the blank adopting it created, so New Session returns to it.
expect(previousParentSessionIds).toEqual([createdSessionId])
expect(createdResponse.request().postDataJSON()).toHaveProperty('payload.args.request.sessionId', createdSessionId)
await page.locator(`[data-conversation-session="${createdSessionId}"]`).waitFor()
await expect.poll(() => section.locator('[aria-selected="true"]').evaluate(row =>
row.closest('[class*="groupSection"]')?.querySelector('[role="treeitem"]')?.textContent,
), { timeout: 10_000 }).toBe('folder-group')
await expect.poll(() => parentWorkspace.sessionIds).toHaveLength(previousParentSessionIds.length + 1)
expect(parentWorkspace.sessionIds).toEqual(expect.arrayContaining([...previousParentSessionIds, createdSessionId]))
expect([...parentWorkspace.sessionIds]).toEqual(previousParentSessionIds)
expect([...childWorkspace.sessionIds]).toEqual(childSessionIds)
expect(tripwire.pageErrors).toEqual([])
})
@@ -1006,7 +1007,7 @@ describe('web e2e: workspace management (create / rename / grouping / hover affo
})
describe('web e2e: New Session after an outdated blank cache', () => {
it('opens a fresh conversation instead of reusing the recorded conversation', async () => {
it('returns to the startup blank instead of reusing the recorded conversation', async () => {
const scaffold = await launchWebScaffold({})
let browser: Browser | undefined
try {
@@ -1034,6 +1035,8 @@ describe('web e2e: New Session after an outdated blank cache', () => {
// candidate; the Host declines the started Session and creates a blank one.
await expect.poll(() => selected.innerText()).toBe('New Session')
await expect.poll(() => workspace.sessionIds.length).toBe(2)
const startupBlank = workspace.sessionIds.find(member => member !== id)
expect(startupBlank).toBeDefined()
// The declined Session's projections are reloaded, so its stale blank row reappears.
await page.getByRole('treeitem', { name: /^Use the read tool twice/ }).click()
await page.getByRole('tab', { name: 'Chat', exact: true }).waitFor()
@@ -1041,11 +1044,15 @@ describe('web e2e: New Session after an outdated blank cache', () => {
const previous = (await selected.innerText()).split('\n')[0]!
const before = await captureStableAria(page, '[role="tree"]', scaffold.workspaceCwd)
const creation = page.waitForResponse('**/api/session/create')
await page.getByRole('button', { name: 'New session', exact: true }).last().click()
const created = await creation
// New Session names the blank startup created, not the recorded conversation, and the Host reuses it.
expect(created.request().postDataJSON()).toHaveProperty('payload.args.request.sessionId', startupBlank)
expect(await created.json()).toMatchObject({ result: { ok: true, value: { sessionId: startupBlank } } })
await expect.poll(() => selected.innerText()).toBe('New Session')
await page.getByRole('tab', { name: 'Chat', exact: true }).waitFor({ state: 'hidden' })
// New Session creates a fresh Session; the blank startup created stays a member.
await expect.poll(() => workspace.sessionIds.length).toBe(3)
expect(workspace.sessionIds).toHaveLength(2)
expect(workspace.sessionIds).toContain(id)
await page.getByRole('treeitem').filter({ hasText: previous }).waitFor()
const after = await captureStableAria(page, '[role="tree"]', scaffold.workspaceCwd)
+1
View File
@@ -83,6 +83,7 @@
"tests/plugin-config.e2e.ts",
"tests/input-plugin-extensions.e2e.ts",
"tests/plugin-manager.e2e.ts",
"tests/entry-customization.e2e.ts",
"tests/cua-optin.e2e.ts",
"tests/cua-permissions.e2e.ts",
"tests/schedule-header.e2e.ts",
+1
View File
@@ -4720,6 +4720,7 @@ These load from a `cordis.yml` entry with no `config:` block; they declare no co
| `@deepseek-ai/dsh-client-ui-deliverables` | `connection` · `sessionQuery` · `sessionController` · `workspaceFiles` · `fs` · `sandboxPolicy` · `workspaceChanges` | [`packages/client/ui-deliverables/src/index.ts`](../packages/client/ui-deliverables/src/index.ts) |
| `@deepseek-ai/dsh-client-ui-directory-picker-browse` | — | [`packages/client/ui-directory-picker-browse/src/index.ts`](../packages/client/ui-directory-picker-browse/src/index.ts) |
| `@deepseek-ai/dsh-client-ui-directory-picker-native` | — | [`packages/client/ui-directory-picker-native/src/index.ts`](../packages/client/ui-directory-picker-native/src/index.ts) |
| `@deepseek-ai/dsh-client-ui-entry-customization` | — | [`packages/client/ui-entry-customization/src/index.ts`](../packages/client/ui-entry-customization/src/index.ts) |
| `@deepseek-ai/dsh-client-ui-goal` | — | [`packages/client/ui-goal/src/index.ts`](../packages/client/ui-goal/src/index.ts) |
| `@deepseek-ai/dsh-client-ui-input-trigger` | — | [`packages/client/ui-input-trigger/src/index.ts`](../packages/client/ui-input-trigger/src/index.ts) |
| `@deepseek-ai/dsh-client-ui-jobs` | — | [`packages/client/ui-jobs/src/index.ts`](../packages/client/ui-jobs/src/index.ts) |
+1
View File
@@ -4722,6 +4722,7 @@ export interface Config {
| `@deepseek-ai/dsh-client-ui-deliverables` | `connection` · `sessionQuery` · `sessionController` · `workspaceFiles` · `fs` · `sandboxPolicy` · `workspaceChanges` | [`packages/client/ui-deliverables/src/index.ts`](../packages/client/ui-deliverables/src/index.ts) |
| `@deepseek-ai/dsh-client-ui-directory-picker-browse` | — | [`packages/client/ui-directory-picker-browse/src/index.ts`](../packages/client/ui-directory-picker-browse/src/index.ts) |
| `@deepseek-ai/dsh-client-ui-directory-picker-native` | — | [`packages/client/ui-directory-picker-native/src/index.ts`](../packages/client/ui-directory-picker-native/src/index.ts) |
| `@deepseek-ai/dsh-client-ui-entry-customization` | — | [`packages/client/ui-entry-customization/src/index.ts`](../packages/client/ui-entry-customization/src/index.ts) |
| `@deepseek-ai/dsh-client-ui-goal` | — | [`packages/client/ui-goal/src/index.ts`](../packages/client/ui-goal/src/index.ts) |
| `@deepseek-ai/dsh-client-ui-input-trigger` | — | [`packages/client/ui-input-trigger/src/index.ts`](../packages/client/ui-input-trigger/src/index.ts) |
| `@deepseek-ai/dsh-client-ui-jobs` | — | [`packages/client/ui-jobs/src/index.ts`](../packages/client/ui-jobs/src/index.ts) |
+2
View File
@@ -168,6 +168,7 @@ flowchart TD
pkg_client_ui_directory_picker_browse["client-ui-directory-picker-browse"]
pkg_client_ui_directory_picker_native["client-ui-directory-picker-native"]
pkg_client_ui_dockkit["client-ui-dockkit"]
pkg_client_ui_entry_customization["client-ui-entry-customization"]
pkg_client_ui_goal["client-ui-goal"]
pkg_client_ui_input_trigger["client-ui-input-trigger"]
pkg_client_ui_jobs["client-ui-jobs"]
@@ -1495,6 +1496,7 @@ flowchart TD
| [`client-ui-directory-picker-browse`](../packages/client/ui-directory-picker-browse) | `client` | — |
| [`client-ui-directory-picker-native`](../packages/client/ui-directory-picker-native) | `client` | — |
| [`client-ui-dockkit`](../packages/client/ui-dockkit) | `client` | — |
| [`client-ui-entry-customization`](../packages/client/ui-entry-customization) | `client` | — |
| [`client-ui-goal`](../packages/client/ui-goal) | `client` | — |
| [`client-ui-input-trigger`](../packages/client/ui-input-trigger) | `client` | — |
| [`client-ui-jobs`](../packages/client/ui-jobs) | `client` | — |
+2
View File
@@ -170,6 +170,7 @@ flowchart TD
pkg_client_ui_directory_picker_browse["client-ui-directory-picker-browse"]
pkg_client_ui_directory_picker_native["client-ui-directory-picker-native"]
pkg_client_ui_dockkit["client-ui-dockkit"]
pkg_client_ui_entry_customization["client-ui-entry-customization"]
pkg_client_ui_goal["client-ui-goal"]
pkg_client_ui_input_trigger["client-ui-input-trigger"]
pkg_client_ui_jobs["client-ui-jobs"]
@@ -1497,6 +1498,7 @@ flowchart TD
| [`client-ui-directory-picker-browse`](../packages/client/ui-directory-picker-browse) | `client` | — |
| [`client-ui-directory-picker-native`](../packages/client/ui-directory-picker-native) | `client` | — |
| [`client-ui-dockkit`](../packages/client/ui-dockkit) | `client` | — |
| [`client-ui-entry-customization`](../packages/client/ui-entry-customization) | `client` | — |
| [`client-ui-goal`](../packages/client/ui-goal) | `client` | — |
| [`client-ui-input-trigger`](../packages/client/ui-input-trigger) | `client` | — |
| [`client-ui-jobs`](../packages/client/ui-jobs) | `client` | — |
+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: 5f160332e4980284
zh: b1ae1da451a6209e
en: dd504c406c346f45
zh: 5dadae8b00aafc5b
/web-client-slots/declaration-and-lifecycle:
en: f2d2c0d3b8f14ec7
zh: 441ed13ff3f81d2a
+3
View File
@@ -10,6 +10,8 @@ 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.
[ui-workspace](../../packages/client/ui-workspace/README.md#understand-the-implementation) documents Session-row extension positions, visibility, and keyboard behavior.
## Declaration and lifecycle
@@ -127,6 +129,7 @@ root [AppFrame]
│ ├─ conversation.session.header.lineage
│ ├─ conversation.session.header.actions
│ ├─ conversation.session.header.utilities
│ ├─ conversation.session.header.views.actions
│ └─ conversation.session.header.corner
└─ calls Factory: conversation.content
+3
View File
@@ -10,6 +10,8 @@ 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) 注册随产品提供的菜单行。
[ui-workspace](../../packages/client/ui-workspace/README.zh.md#understand-the-implementation) 记录 Session 行的扩展位置、显示条件和键盘行为。
## 声明与生命周期
@@ -129,6 +131,7 @@ root [AppFrame]
│ ├─ conversation.session.header.lineage
│ ├─ conversation.session.header.actions
│ ├─ conversation.session.header.utilities
│ ├─ conversation.session.header.views.actions
│ └─ conversation.session.header.corner
└─ calls Factory: conversation.content
@@ -0,0 +1,16 @@
# Bilingual-pair consistency record for guide.md (docs/i18n/README.md): per heading
# section, a hash of its English and Chinese blocks outside code blocks and generated regions.
# After editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write docs/upgrade-guide/v0.2.1-rc.2/new-session-blank-reuse/guide.md
/:
en: 22fc9673ecfb83a7
zh: 8f73437381945374
/new-session-returns-to-its-blank-session:
en: a169c9999df57111
zh: 64f09ffd5ea03a84
/new-session-returns-to-its-blank-session/change:
en: 92a5464174d2614a
zh: dd25d30ce416ad39
/new-session-returns-to-its-blank-session/migration:
en: 8e4c268720257098
zh: a0ade706d7e3c9a2
@@ -0,0 +1,22 @@
---
kind: upgrade-guide
description: "New Session reuses a blank Session in the Workspace again instead of creating a fresh Agent on every call."
---
# New Session returns to its blank Session
English | [中文](guide.zh.md)
## Change
In 0.2.1-rc.2, New Session in Web and Desktop, and `uiWorkspace.startSession(workspaceId)` without options, created a fresh Session and Agent on every call ([earlier guide](../../v0.2.1-alpha.1/new-session-generations/guide.md)). The blank Session it left behind is hidden from the Session tree, so its unsent draft could no longer be reached.
New Session now reuses the first unstarted, unarchived blank Session in the Workspace, which the Host switches to its default preset, and creates a Session only when none exists. `startSession` with options, `openWorkspace`, and `connectWorkspace` follow the same rule; a request that names an Agent preset skips blanks whose listed preset is a different one, so preset flows such as the Creator entries do not take over another preset's draft.
A reused blank whose Agent is still loaded keeps the preset composition it was created with. New Session no longer guarantees the current preset definition.
## Migration
1. After changing a preset definition, start the conversation in a Session whose Agent was composed after the change, for example by restarting the Host before using New Session; a blank resumed after a restart composes the current definition.
2. Client integrations that must not take over a blank listing another preset pass `{ agentPreset }` to `startSession`, `openWorkspace`, or `connectWorkspace`.
3. To confirm, type a draft after New Session, open another Session, and use New Session again: the same blank opens with the draft.
@@ -0,0 +1,22 @@
---
kind: upgrade-guide
description: "新会话重新复用工作区内的空白 Session,不再每次都创建新的 Agent。"
---
# 新会话回到其空白 Session
[English](guide.md) | 中文
## 变更
在 0.2.1-rc.2 中,Web 和 Desktop 的新会话,以及不带选项的 `uiWorkspace.startSession(workspaceId)`,每次调用都创建新的 Session 和 Agent([此前的指南](../../v0.2.1-alpha.1/new-session-generations/guide.zh.md))。被留下的空白 Session 不在 Session 树中显示,因此其中未发送的草稿无法再回到。
现在新会话复用工作区内第一个未开始、未归档的空白 Session,由 Host 切换到其默认 preset,没有空白 Session 时才创建。带选项的 `startSession`、`openWorkspace` 和 `connectWorkspace` 采用相同规则;指明 Agent preset 的请求会跳过列出其他 preset 的空白会话,因此创造模式入口等 preset 流程不会接管其他 preset 的草稿。
被复用的空白会话若其 Agent 仍在加载状态,会保留创建时的 preset 装配。新会话不再保证使用当前的 preset 定义。
## 迁移
1. 更改 preset 定义后,在 Agent 于更改之后装配的 Session 中开始对话,例如先重启 Host 再使用新会话;重启后恢复的空白会话会按当前定义装配。
2. 不能接管列出其他 preset 的空白会话的客户端集成,向 `startSession`、`openWorkspace` 或 `connectWorkspace` 传入 `{ agentPreset }`。
3. 确认方式:新会话中输入草稿,打开另一个 Session,再使用新会话,应打开同一个空白会话并显示该草稿。
@@ -43,7 +43,7 @@ export interface SessionSummary {
/**
* New Session presentation and reuse eligibility, derived from the Host
* summary, `sessionListMetadata`, and client acceptance/running observations.
* Workspace reconnection may reuse a blank in the same workspace. Filtering
* New Session and Workspace reconnection may reuse a blank in the same workspace. Filtering
* stays with the consumer: the store carries every row, while the Workspace
* browser shows only the selected blank entry.
*/
+5
View File
@@ -426,6 +426,11 @@
- id: ui-agent-preset
name: '@deepseek-ai/dsh-client-ui-agent-preset'
# Region-local More menus: pin or unpin plugin launchers and start a
# plugin draft.
- id: entry-customization
name: '@deepseek-ai/dsh-client-ui-entry-customization'
- id: ui-settings-session-log
name: '@deepseek-ai/dsh-client-ui-settings-session-log'
+1
View File
@@ -84,6 +84,7 @@
"@deepseek-ai/dsh-client-ui-deliverables": "workspace:*",
"@deepseek-ai/dsh-client-ui-directory-picker-browse": "workspace:*",
"@deepseek-ai/dsh-client-ui-directory-picker-native": "workspace:*",
"@deepseek-ai/dsh-client-ui-entry-customization": "workspace:*",
"@deepseek-ai/dsh-client-ui-goal": "workspace:*",
"@deepseek-ai/dsh-client-ui-input-trigger": "workspace:*",
"@deepseek-ai/dsh-client-ui-jobs": "workspace:*",
+2 -2
View File
@@ -15,8 +15,8 @@
en: 1eb35b015a6c423f
zh: a8a3dceee2c9e943
/client-web-gui-browser-half/packages:
en: 4d88ec16156593e5
zh: 745b3aab722ca17d
en: 3cae9938f3a11040
zh: 8ed2edf0ffe6eed0
/client-web-gui-browser-half/related-documentation:
en: d1d506e68fab7333
zh: 1a90fe6f3f8f24a9
+1
View File
@@ -83,6 +83,7 @@ The kernel packages boot and serve the page; the UI feature packages present it.
| [`ui-settings-subagent/`](ui-settings-subagent/README.md) | Provides the Subagent settings page on the Plugins page | — |
| [`ui-settings-web-search/`](ui-settings-web-search/README.md) | Provides the web-search settings page on the Plugins page | — |
| [`ui-plugin-manager/`](ui-plugin-manager/README.md) | Contributes the sidebar Plugins panel: install, enable, disable, retry, and compose installed packages | — |
| [`ui-entry-customization/`](ui-entry-customization/README.md) | Adds region-local More menus that pin or unpin plugin launchers and start a plugin draft | — |
| [`ui-plugin-market/`](ui-plugin-market/README.md) | Contributes the Discover tab to the Plugins page: browse market indexes and install their bundles | — |
| [`ui-settings-plugin-inventory/`](ui-settings-plugin-inventory/README.md) | Contributes the read-only Host Loader inventory tab to Plugins settings | — |
| [`ui-deliverables/`](ui-deliverables/README.md) | Produces the changed-files card with its comparison tabs, delivery cards, and clickable final-response file references | — |
+1
View File
@@ -83,6 +83,7 @@ kind: "package-group"
| [`ui-settings-subagent/`](ui-settings-subagent/README.zh.md) | 在插件页提供子智能体设置页 | — |
| [`ui-settings-web-search/`](ui-settings-web-search/README.zh.md) | 在插件页提供网页搜索设置页 | — |
| [`ui-plugin-manager/`](ui-plugin-manager/README.zh.md) | 贡献侧栏的“插件”面板:安装、启用、停用、重试与组合已安装的包 | — |
| [`ui-entry-customization/`](ui-entry-customization/README.zh.md) | 添加分区域的“更多”菜单,固定或收起插件入口,并开始创建插件的草稿 | — |
| [`ui-plugin-market/`](ui-plugin-market/README.zh.md) | 为“插件”页面贡献“发现”标签页:浏览市场索引并安装其中的组合包 | — |
| [`ui-settings-plugin-inventory/`](ui-settings-plugin-inventory/README.zh.md) | 向「插件」设置贡献只读的 Host Loader 清单标签页 | — |
| [`ui-deliverables/`](ui-deliverables/README.zh.md) | 生成改动文件卡片及其对比 tab、交付文件卡片与可点击的最终响应文件引用 | — |
@@ -18,8 +18,8 @@
en: e015215c32d78b52
zh: e8a8029903de7515
/deepseek-ai-dsh-client-ui-agent-preset/understand-the-implementation:
en: a90bf7539dbce636
zh: d77c1fecac2c6a4b
en: d7a38ad9e5497789
zh: 5f5595ddd90fa6bd
/deepseek-ai-dsh-client-ui-agent-preset/further-exploration:
en: bbae8d79844bc481
zh: 4302107fd1f460e6
+1 -1
View File
@@ -45,7 +45,7 @@ Entering Creator selects `cordis` for the receiving blank Session without changi
`agentPresets/list` supplies the roster and marks the current default, and `agentPresets/read` one declaration's YAML for the viewer; default changes write the `agent-preset-registry` settings namespace. The picker, blank-session synchronization and read-only session label use recorded preset identities. Connection resets and settings updates refresh the roster.
`CreatePluginMenuItem` contributes to `plugins.add.actions` using the shared roster store. Its selection calls the owner's `onDismiss()` before the same `startCreatorDraft` callback exposed to Settings as `creatorDraft`. That callback stages `cordis` through `AgentPresetSeatController` and opens the task through `uiWorkspace.startSession` with explicit draft preservation and `agentPreset: 'cordis'`, so the Host composes the reused blank or new Session under Creator; without a target Workspace, the staged choice applies when a Session binding becomes available. The choice is consumed once applied. Both Coding Tools states use the same picker and selection store, and `AgentPresetSeat` renders refusals through the shared `Toast` component.
`CreatePluginMenuItem` contributes to `plugins.add.actions` using the shared roster store. Its selection calls the owner's `onDismiss()` before the same `startCreatorDraft` callback exposed to Settings as `creatorDraft`. That callback stages `cordis` through `AgentPresetSeatController`, except when the main blank Session lists a different preset (navigation then opens another blank and leaves that one unchanged), and opens the task through `uiWorkspace.startSession` with explicit draft preservation and `agentPreset: 'cordis'`, so the Host composes the reused blank or new Session under Creator; without a target Workspace, the staged choice applies when a Session binding becomes available. The choice is consumed once applied. Both Coding Tools states use the same picker and selection store, and `AgentPresetSeat` renders refusals through the shared `Toast` component.
</details>
+1 -1
View File
@@ -45,7 +45,7 @@ kind: "package-reference"
`agentPresets/list` 提供列表并标记当前默认值,`agentPresets/read` 为查看器提供一条声明的 YAML;默认值的修改写入 `agent-preset-registry` settings 命名空间。选择器、空白会话同步和只读会话标签使用记录的 preset 标识。连接重置和设置更新会刷新列表。
`CreatePluginMenuItem` 使用共享的预设列表 store,向 `plugins.add.actions` 贡献菜单项。选择时先调用页面传入的 `onDismiss()`,再调用与设置页 `creatorDraft` 相同的 `startCreatorDraft` 回调。该回调通过 `AgentPresetSeatController` 暂存 `cordis`,再通过显式保留草稿并传入 `agentPreset: 'cordis'` 的 `uiWorkspace.startSession` 调用打开任务,由 Host 以创造模式组合被复用的空白 Session 或新 Session;没有目标工作区时,暂存选择在 Session 绑定可用时应用。暂存选择应用后即清除。代码工作工具开启或关闭时均使用同一个选择器和选择 store,`AgentPresetSeat` 通过共享的 `Toast` 组件显示拒绝原因。
`CreatePluginMenuItem` 使用共享的预设列表 store,向 `plugins.add.actions` 贡献菜单项。选择时先调用页面传入的 `onDismiss()`,再调用与设置页 `creatorDraft` 相同的 `startCreatorDraft` 回调。该回调通过 `AgentPresetSeatController` 暂存 `cordis`(主区空白 Session 列出其他 preset 时不暂存,因为导航会打开另一个空白 Session,原空白 Session 保持不变),再通过显式保留草稿并传入 `agentPreset: 'cordis'` 的 `uiWorkspace.startSession` 调用打开任务,由 Host 以创造模式组合被复用的空白 Session 或新 Session;没有目标工作区时,暂存选择在 Session 绑定可用时应用。暂存选择应用后即清除。代码工作工具开启或关闭时均使用同一个选择器和选择 store,`AgentPresetSeat` 通过共享的 `Toast` 组件显示拒绝原因。
</details>
@@ -116,12 +116,13 @@ export function apply(ctx: ClientContext): void {
void unboundSeat.apply()
for (const seat of seats.values) void seat.apply()
}), 'ui-agent-preset: Developer tools gate')
const mainBlank = () => Object.values(ctx.sessions.list.getSnapshot().byId)
.find((session) => {
/* v8 ignore next -- retained source counts omit zero-valued entries. */
return session.blank && (session.retainedBy.mainView ?? 0) > 0
})
const mainBlankSeat = (): AgentPresetSeatController | undefined => {
const summary = Object.values(ctx.sessions.list.getSnapshot().byId)
.find((session) => {
/* v8 ignore next -- retained source counts omit zero-valued entries. */
return session.blank && (session.retainedBy.mainView ?? 0) > 0
})
const summary = mainBlank()
const binding = summary === undefined ? undefined : ctx.sessions.binding(summary.id)
return binding === undefined ? undefined : seatFor(binding)
}
@@ -199,8 +200,9 @@ export function apply(ctx: ClientContext): void {
const startCreatorDraft = () => {
// The Host composes the reused draft or new Session; the stage only drives the chip's display and introduction.
const seat = mainBlankSeat() ?? unboundSeat
seat.stage('cordis', true)
// Navigation opens another blank when the main blank lists a different preset, so that blank is not staged.
const listed = mainBlank()?.projectionValues?.agentPreset
if (typeof listed !== 'string' || listed === 'cordis') (mainBlankSeat() ?? unboundSeat).stage('cordis', true)
scope.uiWorkspace.startSession(undefined, { clearPreviousDraft: false, agentPreset: 'cordis' })
}
@@ -1079,7 +1079,7 @@ describe('ui-agent-preset apply', () => {
conversation()
})
it('lets the new-session flow compose the creator preset on a blank main Session', async () => {
it('stages Creator only on a main blank that does not list another preset', async () => {
const { ctx, slots, calls } = await bench()
declareRoot(slots)
const conversation = declareConversation(slots)
@@ -1103,10 +1103,19 @@ describe('ui-agent-preset apply', () => {
section.startCreatorDraft?.()
expect(uiWorkspace.starts).toEqual([{ workspaceId: undefined, options: { clearPreviousDraft: false, agentPreset: 'cordis' } }])
// The Host composes the reused Session; the chip only follows its projection.
// Navigation opens another blank for Creator, so the Standard main blank is neither staged nor switched.
expect(seat.hooks.agentPresetSeat.getSnapshot().current).toBe('standard')
sessions.notify()
await Promise.resolve()
expect(seat.hooks.agentPresetSeat.getSnapshot().current).toBe('standard')
expect(calls).not.toContain('select:cordis')
// A blank whose listed preset is already Creator is staged and follows its projection.
state.byId.s1 = { id: 's1', blank: true, projectionValues: { agentPreset: 'cordis' } }
sessions.notify()
section.startCreatorDraft?.()
await vi.waitFor(() => { expect(seat.hooks.agentPresetSeat.getSnapshot().current).toBe('cordis') })
expect(seat.hooks.agentPresetSeat.getSnapshot().introduce).toBe(true)
expect(calls).not.toContain('select:cordis')
conversation()
})
@@ -21,8 +21,8 @@
en: 9912b286384c41d7
zh: 3e994887c1948677
/deepseek-ai-dsh-client-ui-conversation/shell-and-standard-props:
en: c4d4aaef46daab72
zh: 0dac50689d806ecf
en: 566ac2a2289609a8
zh: 068e895845d911a4
/deepseek-ai-dsh-client-ui-conversation/temporary-composer-entries:
en: bb1244310f5c0382
zh: 824b84077432b559
@@ -72,6 +72,8 @@ 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.
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.
`SessionInputShell` owns one Lexical editor per Session through its private [DraftEditorRuntime](src/client/input/editor/runtime.ts), while retaining submission, attachment selection, and recovery decisions. [DraftEditor](src/client/input/editor/DraftEditor.tsx) renders the borrowed editor; InputBar retains its Hooks and refs and installs DOM behavior through [view-binding](src/client/input/editor/view-binding.ts). Editor-facing types live in [draft-editor.ts](src/client/contract/draft-editor.ts), with shared input and submission types in [input.ts](src/client/contract/input.ts). Background editor updates retain the draft selection without changing the document selection or reclaiming focus from another control; explicit Composer focus restores its selection. This separation does not support simultaneous editable roots for one Session; [the two-stage isolation proposal](../../../.agents/notes/proposed/architecture/2026-09-14-composer-model-and-draft-editor.md) defines the remaining work.
@@ -72,6 +72,8 @@ 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 保留在列表中;保存内容不是字符串数组时,所有标签都显示。
输入框注册「文件」命令动作,负责其标题、可用性和原生文件选择器回调。菜单可用性与实际调用都读取已挂载输入框当前的附件接收策略。输入框卸载或锁定后该动作不可用,插件 dispose(资源释放)时移除注册。回调绑定留在输入模块内部。
`SessionInputShell` 通过私有 [DraftEditorRuntime](src/client/input/editor/runtime.ts) 为每个 Session 持有一个 Lexical editor,同时保留提交、附件选择和恢复决策。[DraftEditor](src/client/input/editor/DraftEditor.tsx) 呈现借用的 editor;InputBar 保留钩子与 refs,并通过 [view-binding](src/client/input/editor/view-binding.ts) 安装 DOM 行为。编辑器类型位于 [draft-editor.ts](src/client/contract/draft-editor.ts),共享输入和提交类型位于 [input.ts](src/client/contract/input.ts)。编辑器后台更新会保留草稿选区,不修改文档选区或从其他控件夺回焦点;显式聚焦 Composer 时恢复其选区。这一拆分不支持同一 Session 同时挂载多个可编辑 root;[两阶段隔离提案](../../../.agents/notes/proposed/architecture/2026-09-14-composer-model-and-draft-editor.zh.md) 定义剩余工作。
@@ -45,6 +45,7 @@ import { InputBar } from './skeleton/InputBar.tsx'
import { todoDockEntry } from './skeleton/TodoPanel.tsx'
import { installStopShortcut } from './stop-shortcut.ts'
import { TRAJECTORY_VIEW_ID, resolveActiveView } from './view-selection.ts'
import { createViewPins } from './view-pins.ts'
import { en, NS, zh, type ConversationKey } from './locales.ts'
import { CONVERSATION_SETTINGS_NAMESPACE, type ConversationSettings } from '../submission-settings.ts'
@@ -225,6 +226,11 @@ export function apply(ctx: Context, config: Config = Config({})): void {
activateView(sessionId, readConversationViewPreference(sessionId))
}
const conversationViews = createSnapshotStore<readonly ViewTab[]>(viewTabs())
const viewPins = createViewPins()
const viewActions = {
getSnapshot: () => slots.entriesOfSlot('conversation.session.header.views.actions').length > 0,
subscribe: (listener: () => void) => slots.subscribe('conversation.session.header.views.actions', listener),
}
const bindings = new Set<SessionBinding>()
const trackedBindings = new WeakSet<SessionBinding>()
const trackBinding = (binding: SessionBinding): void => {
@@ -433,10 +439,12 @@ export function apply(ctx: Context, config: Config = Config({})): void {
'conversation.session.header.actions': { kind: 'list', scope: 'session' },
'conversation.session.header.utilities': { kind: 'list', scope: 'session' },
'conversation.session.header.corner': { kind: 'single', scope: 'session' },
'conversation.session.header.views.actions': { kind: 'list', scope: 'root' },
},
store: conversationStore,
inject: (sessionId: SessionId, actions: BoundActions<typeof conversationStore>): ConversationSessionHeaderInjected => ({
hooks: { conversationViews },
hooks: { conversationViews, entryPins: viewPins.unpinned, entryActions: viewActions },
setPinned: viewPins.setPinned,
open: (id) => { workspaceNavigation.openSession(id) },
selectView: (view) => {
activateView(sessionId, view)
@@ -222,7 +222,7 @@ export interface SessionInputResolver {
/**
* Prepare the exact retained Session before navigation publishes it.
* @param binding - target Session generation, already retained by the caller.
* @param options - content to adopt and explicit permission to replace existing text.
* @param options - content to adopt and the handling of an existing draft.
* @returns applied, preserved, or blocked by a pending submission or disposal.
* @throws when binding is not the currently retained Session generation.
*/
@@ -232,7 +232,15 @@ export interface SessionInputResolver {
/** Initial text for a target Session; attachments remain under their existing owner. */
export interface DraftInitializationOptions {
readonly prompt?: string
/** `true` acts as `whenDraftExists: 'replace'`; ignored when `whenDraftExists` is set. */
readonly clearPreviousDraft?: boolean
/**
* Handling of a target that already has text or attachments: `replace` swaps the text and
* inline references for the prompt (or clears them without one), `append` adds the prompt
* as new paragraphs after the existing text, and `keep` leaves the draft unchanged. Omitted,
* it is `replace` when `clearPreviousDraft` is true and `keep` otherwise.
*/
readonly whenDraftExists?: 'replace' | 'append' | 'keep'
}
/** An initialization request never waits for a React mount or a remote catalog. */
@@ -254,6 +254,8 @@ declare module '@deepseek-ai/dsh-client-ui-slots' {
scope: 'session'
owner: ConversationHeaderCornerOwnerProps
}
/** Rows of the View tab row's More menu; the header shows More only while this list has registrations. */
'conversation.session.header.views.actions': { kind: 'list'; scope: 'root'; owner: ConversationViewActionsOwnerProps }
/** Registered Conversation target Views, rendered one at a time. */
'conversation.view': { kind: 'list'; scope: 'session'; owner: ConvViewOwnerProps }
/** Selector-routed replacements for the current Session's resident composer. */
@@ -422,10 +424,51 @@ export interface ConversationSessionInjected {
openView: (view: string, focus: string) => void
}
/** One customizable View tab listed in the More menu. */
export interface ConversationViewActionEntry {
/** View id. */
readonly id: string
/** Resolved View label. */
readonly label: string
/** False while the tab is unpinned from the header. */
readonly pinned: boolean
}
/** Owner share of the View tab row's More menu rows. */
export interface ConversationViewActionsOwnerProps {
/** Available Views in registration order; Chat is never listed. */
readonly entries: readonly ConversationViewActionEntry[]
/**
* Select a View in this Session without changing its pin state.
* @param id - Listed View id.
*/
readonly onOpen: (id: string) => void
/**
* Show (true) or unpin (false) a View tab in every Session.
* @param id - Listed View id.
* @param pinned - Whether the tab shows in the header.
*/
readonly onPinnedChange: (id: string, pinned: boolean) => void
/** Close the More menu. */
readonly onDismiss: () => void
}
/** Business callbacks injected into the strict Session header. */
export interface ConversationSessionHeaderInjected {
/** Package-owned View roster source bound only for the Conversation header. */
readonly hooks: { readonly conversationViews: ObservableSnapshot<readonly ViewTab[]> }
readonly hooks: {
readonly conversationViews: ObservableSnapshot<readonly ViewTab[]>
/** Ids of unpinned View tabs, persisted across reloads and shared by every Session. */
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.
* @param id - View id.
* @param pinned - Whether the tab shows in the header.
*/
setPinned: (id: string, pinned: boolean) => void
/** Select a Session through the Session Controller. */
open: (sessionId: SessionId) => void
/** Select and activate one registered Conversation View. */
@@ -579,6 +622,7 @@ export type ConversationSessionHeaderSlotProps =
| 'conversation.session.header.actions'
| 'conversation.session.header.utilities'
| 'conversation.session.header.corner'
| 'conversation.session.header.views.actions'
>
& PropsStore<ConversationStore>
& InjectFace<ConversationSessionHeaderInjected>
@@ -87,7 +87,8 @@ export type {
ConversationHeaderActionOwnerProps, ConversationHeaderCornerOwnerProps, ConversationHeaderLineageOwnerProps,
ConversationContentInputProps, ConversationContentProps,
ConversationInjected, ConversationSessionHeaderInjected, ConversationSessionHeaderSlotProps,
ConversationSessionInjected, ConversationSessionSlotProps, ConversationSlotProps, ConversationViewsProps,
ConversationSessionInjected, ConversationSessionSlotProps, ConversationSlotProps,
ConversationViewActionEntry, ConversationViewActionsOwnerProps, ConversationViewsProps,
ConversationWidthControlsInputProps, ConversationWidthControlsProps,
ConversationStore, ConvViewOwnerProps, ConvViewProps, EmptyWorkspaceOwnerProps,
HeroAgentPresetOwnerProps, HeroBrandMarkOwnerProps, InputControlOwnerProps, InputZone,
@@ -39,6 +39,21 @@ interface DraftEditorRuntimeDeps {
*/
const REFERENCE_PLACEHOLDER_RE = /[\uE100-\uE11D\uFFFC]/gu
/**
* Append one paragraph per line of `text` to the editor root and move the caret
* to the end. Must run inside an editor update.
* @param text - sanitized plain text.
*/
function $appendParagraphs(text: string): void {
const root = $getRoot()
for (const line of text.split('\n')) {
const paragraph = $createParagraphNode()
if (line !== '') paragraph.append($createTextNode(line))
root.append(paragraph)
}
root.selectEnd()
}
/** Undo merge window for contiguous typing, in ms (the old machine's mergeWindowMs). */
const HISTORY_MERGE_DELAY_MS = 1000
@@ -173,17 +188,28 @@ export class DraftEditorRuntime {
const clean = text.replace(REFERENCE_PLACEHOLDER_RE, '')
if (clean === this.projection.clipboardText && this.projection.occurrences.length === 0) return
this.editor.update(() => {
const root = $getRoot()
root.clear()
for (const line of clean.split('\n')) {
const paragraph = $createParagraphNode()
if (line !== '') paragraph.append($createTextNode(line))
root.append(paragraph)
}
root.selectEnd()
$getRoot().clear()
$appendParagraphs(clean)
}, { discrete: true, tag: HISTORY_MERGE_TAG })
}
/**
* Add plain text after the current draft as new paragraphs, keeping its text
* and references; an empty draft takes the text as its whole content.
* Placeholder-sanitized; the caret lands at the end; merged into history
* like {@link setDraft}.
* @param text - plain text to add.
*/
appendDraft(text: string): void {
const clean = text.replace(REFERENCE_PLACEHOLDER_RE, '')
if (clean === '') return
if (this.projection.clipboardText === '' && this.projection.occurrences.length === 0) {
this.setDraft(clean)
return
}
this.editor.update(() => { $appendParagraphs(clean) }, { discrete: true, tag: HISTORY_MERGE_TAG })
}
/**
* Insert pasted plain text over the current editor selection
* (placeholder-sanitized). The paste event's own default is suppressed by
@@ -284,17 +284,24 @@ export class SessionInputShell implements SessionInput {
/**
* Apply one new-task request to this already-initialized input model.
* @param options - replacement content and explicit permission to clear existing text.
* @param options - content to adopt and the handling of an existing draft.
* @returns applied when the requested text is adopted, even if unchanged;
* preserved when the current draft is kept; blocked after disposal or during a pending submission.
*/
requestDraftInitialization(options: DraftInitializationOptions): DraftInitializationResult {
if (this.draftInitializationBlocked()) return 'blocked'
if (options.prompt === undefined && options.clearPreviousDraft !== true) return 'preserved'
if (options.clearPreviousDraft !== true && (this.snapshot.draft !== '' || this.attachmentIds.length > 0)) {
return 'preserved'
const whenDraftExists = options.whenDraftExists ?? (options.clearPreviousDraft === true ? 'replace' : 'keep')
if (whenDraftExists === 'replace') {
this.setDraft(options.prompt ?? '')
return 'applied'
}
this.setDraft(options.prompt ?? '')
if (options.prompt === undefined) return 'preserved'
if (this.snapshot.draft === '' && this.attachmentIds.length === 0) {
this.setDraft(options.prompt)
return 'applied'
}
if (whenDraftExists === 'keep') return 'preserved'
this.draftEditor.appendDraft(options.prompt)
return 'applied'
}
@@ -78,6 +78,7 @@ export const zh = {
'hero.preview': '预览版',
'hero.chooseWorkspace': '选择工作区',
'session.hierarchy': '会话层级',
'session.moreViews': '更多会话视图',
'todo.title': '任务',
'todo.progress.done': '{done} 已完成',
'todo.progress.active': '{active} 进行中',
@@ -458,6 +459,7 @@ export const en = {
'hero.preview': 'Preview',
'hero.chooseWorkspace': 'Choose workspace',
'session.hierarchy': 'Session hierarchy',
'session.moreViews': 'More conversation views',
'todo.title': 'To-dos',
'todo.progress.done': '{done} completed',
'todo.progress.active': '{active} in progress',
@@ -10,8 +10,9 @@
/* 76px with its rule: the height of the Sidebar's tab strip and header row
(ui-dockkit `.tabStrip` 38px + the pane header's 38px), so the two rules
meet at the column edge. The rows add up to it — 10px top inset, the 30px
title row, the tab strip's 10px margin, a 16px tab line, and its 9px bottom
inset — so no row below may grow past its figure. */
title row, the tab strip's 10px margin, and a 25px view row. When the views
and the More launcher do not fit on one row, the row wraps and the header
grows past 76px. */
.header {
display: grid;
grid-template-columns: auto minmax(0, 1fr);
@@ -188,23 +189,32 @@ button.crumb:hover {
display: none;
}
/* figma Tab_Group 34:11441: 35px strip, gap 36, pad-left 8, tabs bottom-aligned.
Positioned above the header's own paint, so the active bar covers the rule. */
/* The tab row sits above the header paint so the active bar covers its rule. */
.tabs {
grid-column: 1 / -1;
position: relative;
z-index: 1;
display: flex;
gap: 36px;
gap: 16px 20px;
align-items: flex-start;
flex-wrap: wrap;
margin-top: 10px;
padding-left: 8px;
}
/* figma .Tab 34:11442: 13/16 text (figma wt510, rendered 500). The 2px bar
reaches 1px past the tab's box to end flush with the header's bottom edge,
sitting over the rule. */
.viewTabs {
display: flex;
flex-wrap: wrap;
gap: 32px;
min-width: 0;
min-height: 25px;
}
/* The 16px text line retains 9px below it. The active bar reaches 1px past
the tab box to sit over the header rule. */
.tab {
position: relative;
min-height: 25px;
padding: 0 0 9px;
border: none;
background: transparent;
@@ -237,6 +247,28 @@ button.crumb:hover {
background: var(--dsw-alias-state-business-primary);
}
/* The More launcher centers its 22px box on the tab's 16px text line. */
.viewsMoreAnchor {
flex: none;
}
.viewsMore {
width: 22px;
height: 22px;
margin-top: -3px;
padding: 0;
color: var(--dsw-alias-label-secondary);
}
.viewsMore[aria-expanded='true'] {
background: var(--dsw-alias-interactive-bg-hover);
}
.viewsMoreMenu {
width: 288px;
max-width: calc(100vw - 24px);
}
.viewArea {
display: flex;
flex: 1;
@@ -1,12 +1,15 @@
/** Strict per-session header/body content inserted into the resident conversation layout. */
import { 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'
import type { SessionId } from '@deepseek-ai/dsh-session/types'
import type {
ConversationSessionHeaderSlotProps, ConversationSessionSlotProps,
ConversationSessionHeaderSlotProps, ConversationSessionSlotProps, ConversationViewActionsOwnerProps,
} from '../contract/slots.ts'
import { resolveActiveView } from '../view-selection.ts'
import { FIXED_VIEW_ID } from '../view-pins.ts'
import { DefaultConversationViews } from './DefaultConversationViews.tsx'
import css from './ConversationRoot.module.css'
@@ -16,6 +19,30 @@ export type ConversationSessionProps = ConversationSessionSlotProps
/** Full props composed from the strict session header contract. */
export type ConversationSessionHeaderProps = ConversationSessionHeaderSlotProps
type ViewsMoreMenuProps =
Omit<ConversationViewActionsOwnerProps, 'onDismiss'>
& Pick<ConversationSessionHeaderSlotProps, 'renderSlot'>
& { readonly label: string }
/** 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) {
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}
onClick={() => { setOpen(value => !value) }}>
<IconEllipsisOutlineRegular size={13} />
</Button>
</Tooltip>
)}>
{renderSlot('conversation.session.header.views.actions', { entries, onOpen, onPinnedChange, onDismiss })}
</Menu>
)
}
interface Breadcrumb {
readonly id: SessionId
readonly displayTitle: string
@@ -57,12 +84,14 @@ function equalBreadcrumbs(left: readonly Breadcrumb[], right: readonly Breadcrum
*/
export function ConversationSessionHeader({
sessionId, hideChrome, useSessions, useConversationViews, useStore,
renderSlot, open, selectView, t,
renderSlot, open, selectView, setPinned, t, useEntryPins, useEntryActions,
}: ConversationSessionHeaderProps) {
const tabs = useConversationViews(value => value)
const selectedId = useStore(s => s.view)
const active = resolveActiveView(tabs, selectedId)
const ancestry = useSessions(s => deriveAncestry(s, sessionId), equalBreadcrumbs)
const unpinned = useEntryPins(ids => ids)
const hasEntryActions = useEntryActions(registered => registered)
const showTabs = !hideChrome && tabs.length > 1
return (
<>
@@ -140,19 +169,32 @@ 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} role="tablist" data-conversation-tabs="">
{tabs.map(viewTab => (
<button
key={viewTab.id}
type="button"
role="tab"
aria-selected={viewTab.id === active?.id}
className={clsx(css.tab, viewTab.id === active?.id && css.tabActive)}
onClick={() => { selectView(viewTab.id) }}
>
{viewTab.label}
</button>
))}
<div 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 => (
<button
key={viewTab.id}
type="button"
role="tab"
aria-selected={viewTab.id === active?.id}
className={clsx(css.tab, viewTab.id === active?.id && css.tabActive)}
onClick={() => { selectView(viewTab.id) }}
>
{viewTab.label}
</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) }))}
onOpen={selectView}
onPinnedChange={setPinned}
renderSlot={renderSlot}
/>
)}
</div>
)}
</>
@@ -0,0 +1,44 @@
/** Persisted 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. */
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.
* @param id - View id.
* @param pinned - Whether the tab shows in the Conversation header.
*/
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.
*/
export function createViewPins(): ViewPins {
const store = createSnapshotStore<unknown>({ unpinned: NO_VIEWS }, { persist: { name: STORAGE_KEY } })
return {
unpinned: { getSnapshot: () => unpinnedViews(store.getSnapshot()), subscribe: listener => store.subscribe(listener) },
setPinned: (id, pinned) => {
const current = unpinnedViews(store.getSnapshot())
if (id === FIXED_VIEW_ID || pinned !== current.includes(id)) return
store.set({ unpinned: pinned ? current.filter(entry => entry !== id) : [...current, id] })
},
}
}
@@ -44,6 +44,35 @@ function entry(
}
describe('target-neutral Conversation apply wiring', () => {
it('hands every Session header the same persisted View pins', async () => {
localStorage.setItem('dsh.slot.manual-order.conversation.view.v1', JSON.stringify({ unpinned: ['trajectory'] }))
try {
const b = await bench()
const header = b.runtime.slots.entries('conversation.session.header')[0]!
type PinHooks = {
hooks: { entryPins: ObservableSnapshot<readonly string[]>; entryActions: ObservableSnapshot<boolean> }
setPinned: (id: string, pinned: boolean) => void
}
const inject = header.inject as (sessionId: SessionId) => PinHooks
const injected = inject(SID)
const first = injected.hooks.entryPins
expect(first.getSnapshot()).toEqual(['trajectory'])
expect(inject('session-2' as SessionId).hooks.entryPins).toBe(first)
injected.setPinned('timeline', false)
expect(first.getSnapshot()).toEqual(['trajectory', 'timeline'])
const actions = injected.hooks.entryActions
const changed = vi.fn()
actions.subscribe(changed)
expect(actions.getSnapshot()).toBe(false)
b.runtime.slots.register({ name: 'conversation.session.header.views.actions', id: 'probe' }, (() => null) as never)
expect(actions.getSnapshot()).toBe(true)
await vi.waitFor(() => { expect(changed).toHaveBeenCalled() })
await b.runtime.dispose()
} finally {
localStorage.clear()
}
})
it('hides only the Trajectory View while developer tools are disabled and restores it when enabled', async () => {
const b = await bench()
const header = b.runtime.slots.entries('conversation.session.header')[0]!
@@ -474,6 +474,31 @@ describe('draft documents in the input shell', () => {
expect(shell.snapshot.attachmentIds).toEqual([attachment])
})
it('lets whenDraftExists keep, append to, or replace an existing draft ahead of clearPreviousDraft', () => {
const shell = makeShell()
const old = documentOf('existing ', session)
shell.setDraft(old)
expect(shell.requestDraftInitialization({ prompt: 'ignored', clearPreviousDraft: true, whenDraftExists: 'keep' })).toBe('preserved')
expect(shell.draftSnapshot).toEqual(old)
expect(shell.requestDraftInitialization({ whenDraftExists: 'append' })).toBe('preserved')
expect(shell.requestDraftInitialization({ prompt: '\uFFFC', whenDraftExists: 'append' })).toBe('applied')
expect(shell.draftSnapshot).toEqual(old)
expect(shell.requestDraftInitialization({ prompt: 'next\nline', whenDraftExists: 'append' })).toBe('applied')
expect(shell.draftSnapshot).toEqual({ text: `${old.text}\nnext\nline`, references: old.references })
expect(chips(shell)).toHaveLength(1)
expect(shell.requestDraftInitialization({ prompt: 'fresh', whenDraftExists: 'replace' })).toBe('applied')
expect(shell.draftSnapshot).toEqual({ text: 'fresh', references: [] })
})
it('appends to an attachment-only draft as its whole text', () => {
const shell = makeShell()
const attachment = 'draft-image' as DraftAttachmentId
shell.addAttachments([attachment])
expect(shell.requestDraftInitialization({ prompt: 'describe it', whenDraftExists: 'append' })).toBe('applied')
expect(shell.snapshot.draft).toBe('describe it')
expect(shell.snapshot.attachmentIds).toEqual([attachment])
})
it('persists clearing so a persistence rebind and a new shell do not restore references', () => {
const shell = makeShell()
shell.setDraft(documentOf(file, ' ', session))
@@ -0,0 +1,19 @@
/** Conversation navigation spacing preserves the right-pane header alignment. */
import { readFileSync } from 'node:fs'
import { describe, expect, it } from 'vitest'
const css = readFileSync(new URL('../src/client/skeleton/ConversationRoot.module.css', import.meta.url), 'utf8')
describe('conversation header geometry', () => {
it('keeps the single-line header at the shared 76px height', () => {
expect(css.match(/\.header\s*\{([^}]*)\}/)?.[1]).toContain('min-height: 76px')
expect(css.match(/\.viewTabs\s*\{([^}]*)\}/)?.[1]).toContain('min-height: 25px')
expect(css.match(/\.tab\s*\{([^}]*)\}/)?.[1]).toContain('min-height: 25px')
expect(css.match(/\.tab\s*\{([^}]*)\}/)?.[1]).toContain('padding: 0 0 9px')
})
it('uses 32px between view labels without changing the action gap', () => {
expect(css.match(/\.viewTabs\s*\{([^}]*)\}/)?.[1]).toContain('gap: 32px')
expect(css.match(/\.tabs\s*\{([^}]*)\}/)?.[1]).toContain('gap: 16px 20px')
})
})
@@ -33,7 +33,7 @@ import type { InputBarProps } from '../src/client/skeleton/InputBar.tsx'
import type {
ComposerBarOwnerProps, ConversationContentInputProps, ConversationContentProps,
ConversationHeaderLineageOwnerProps, ConversationSessionHeaderSlotProps, ConversationSessionSlotProps, ConversationSlotProps,
ConversationViewsProps,
ConversationViewActionsOwnerProps, ConversationViewsProps,
} from '../src/client/contract/slots.ts'
import type { ViewTab } from '../src/client/contract/views.ts'
@@ -140,6 +140,10 @@ function mount(
composerBlock?: { reason: string }
/** Mutable view ledger used by registration-order regressions. */
viewTabs?: ViewTab[]
/** Persisted ids of unpinned View tabs. */
unpinnedViews?: readonly string[]
/** Whether the View actions list has registrations. */
entryActions?: boolean
} = {},
) {
const sessionId = 'sessionId' in options ? options.sessionId : SID
@@ -193,12 +197,18 @@ function mount(
/** Owner share handed to the two composer tool-row seats, per render. */
const seatOwners: { key: string; owner: unknown }[] = []
let pickerOwner: unknown
let actionsOwner: ConversationViewActionsOwnerProps | undefined
const setPinned = vi.fn<(id: string, pinned: boolean) => void>()
const renderSlot = ((key: string, owner: object, opts?: { only?: string; fallback?: ReactNode }) => {
slotCalls.push(key)
if (key === 'conversation.input.model' || key === 'conversation.input.plan') {
seatOwners.push({ key, owner })
}
if (key === 'conversation.hero.workspace') { pickerOwner = owner; return null }
if (key === 'conversation.session.header.views.actions') {
actionsOwner = owner as ConversationViewActionsOwnerProps
return <button type="button" role="menuitem" onClick={actionsOwner.onDismiss}>Header view action</button>
}
if (key === 'conversation.session.header.lineage') {
lineageOwners.push(owner as ConversationHeaderLineageOwnerProps)
return opts?.fallback ?? null
@@ -215,6 +225,9 @@ function mount(
useSession={useSession}
useConversation={useConversation}
useConversationViews={useConversationViews}
useEntryPins={selector => selector(options.unpinnedViews ?? [])}
useEntryActions={selector => selector(options.entryActions ?? false)}
setPinned={setPinned}
useChat={useChat}
useTrajectory={useTrajectory}
useSessions={props.useSessions}
@@ -381,6 +394,11 @@ function mount(
return {
view, store, wiring, sink, retargetWorkspace, session, conversation, slotCalls, lineageOwners, seatOwners, open,
pickerOwner: () => pickerOwner,
setPinned,
actionsOwner: () => {
if (actionsOwner === undefined) throw new Error('View actions not rendered')
return actionsOwner
},
rerender: () => { view.rerender(<ConversationMainPanel {...props} />) },
}
}
@@ -663,6 +681,61 @@ 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', () => {
const viewTabs: ViewTab[] = [
{ id: 'chat', label: 'Chat' },
{ id: 'trajectory', label: 'Trajectory' },
{ id: 'timeline', label: 'Timeline' },
]
const b = mount(sessionSnapshotOf(), undefined, undefined, { viewTabs, unpinnedViews: ['chat', 'trajectory', 'removed-view'] })
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'],
])
expect(b.view.getByTestId('view-trajectory')).toBeTruthy()
act(() => { b.store.actions.setView('chat') })
expect(tabs().map(tab => tab.textContent)).toEqual(['Chat', 'Timeline'])
})
it('keeps the tab row and More after the tabs while every other View is unpinned', () => {
const b = mount(sessionSnapshotOf(), undefined, undefined, { unpinnedViews: ['trajectory'], entryActions: true })
const tablist = b.view.getByRole('tablist')
expect(within(tablist).getAllByRole('tab').map(tab => tab.textContent)).toEqual(['Chat'])
const more = b.view.getByRole('button', { name: zh['session.moreViews'] })
expect(tablist.nextElementSibling?.contains(more)).toBe(true)
expect(more.getAttribute('aria-haspopup')).toBe('menu')
expect(b.view.queryByRole('menu')).toBeNull()
})
it('omits More while the View actions list has no registrations', () => {
const b = mount(sessionSnapshotOf())
expect(b.view.getByRole('tablist')).toBeTruthy()
expect(b.view.queryByRole('button', { name: zh['session.moreViews'] })).toBeNull()
})
it('omits the tab row and More while Chat is the only registered View', () => {
const b = mount(sessionSnapshotOf(), undefined, undefined, { viewTabs: [{ id: 'chat', label: 'Chat' }], entryActions: true })
expect(b.view.queryByRole('tablist')).toBeNull()
expect(b.view.queryByRole('button', { name: zh['session.moreViews'] })).toBeNull()
})
it('hands the More menu the non-Chat Views with their pin state and this Session\'s selection', () => {
const b = mount(sessionSnapshotOf(), undefined, undefined, { unpinnedViews: ['trajectory'], entryActions: true })
const more = b.view.getByRole('button', { name: zh['session.moreViews'] })
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 }])
b.actionsOwner().onPinnedChange('trajectory', true)
expect(b.setPinned).toHaveBeenCalledExactlyOnceWith('trajectory', true)
act(() => { b.actionsOwner().onOpen('trajectory') })
expect(b.view.getByTestId('view-trajectory')).toBeTruthy()
fireEvent.click(b.view.getByRole('menuitem', { name: 'Header view action' }))
expect(b.view.queryByRole('menu')).toBeNull()
expect(more.getAttribute('aria-expanded')).toBe('false')
})
it('rolls the pending workspace label back when switching fails', async () => {
const selectWorkspace = vi.fn(async () => { throw new Error('connect failed') })
const b = mount(
@@ -0,0 +1,56 @@
// @vitest-environment jsdom
/** Conversation View pins persist one root list for every Session and ignore Chat. */
import { afterEach, describe, expect, it, vi } from 'vitest'
import { createViewPins } from '../src/client/view-pins.ts'
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', () => {
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'])
pins.setPinned('trajectory', true)
expect(pins.unpinned.getSnapshot()).toEqual(['timeline'])
})
it('ignores Chat and repeated choices without republishing', () => {
const pins = createViewPins()
pins.setPinned('timeline', false)
const snapshot = pins.unpinned.getSnapshot()
const listener = vi.fn()
pins.unpinned.subscribe(listener)
pins.setPinned('chat', false)
pins.setPinned('timeline', false)
pins.setPinned('trajectory', true)
expect(pins.unpinned.getSnapshot()).toBe(snapshot)
expect(listener).not.toHaveBeenCalled()
})
it('keeps ids of Views that are not registered', () => {
localStorage.setItem(KEY, JSON.stringify({ unpinned: ['removed-view'] }))
const pins = createViewPins()
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))
const pins = createViewPins()
const snapshot = pins.unpinned.getSnapshot()
expect(snapshot).toEqual([])
expect(pins.unpinned.getSnapshot()).toBe(snapshot)
})
})
@@ -0,0 +1,34 @@
# Bilingual-pair consistency record for README.md (docs/i18n/README.md): per heading
# section, a hash of its English and Chinese blocks outside code blocks and generated regions.
# 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
/deepseek-ai-dsh-client-ui-entry-customization:
en: 942676b8e2aef2b6
zh: 9e107b4c007856ad
/deepseek-ai-dsh-client-ui-entry-customization/summary:
en: d53e7d71da9fd4d6
zh: a32b635ec1e572f3
/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: 9edf91832478f10d
zh: 2baeefa27d2c01fe
/deepseek-ai-dsh-client-ui-entry-customization/understand-the-implementation:
en: 12a9c39c4e309946
zh: 59027889d3d7a49a
/deepseek-ai-dsh-client-ui-entry-customization/model-experience:
en: b891f311c09d536f
zh: 68c0f41fbfed0d73
/deepseek-ai-dsh-client-ui-entry-customization/model-experience/kv-cache-effect:
en: dc50ebf94183efa2
zh: 617803c558367430
/deepseek-ai-dsh-client-ui-entry-customization/known-limitations-and-deferred-work:
en: 2e582c76ffe9d663
zh: 7e5e49ad3d6f0f83
/deepseek-ai-dsh-client-ui-entry-customization/known-limitations-and-deferred-work/dev-note:
en: e67f633dc6fe7773
zh: 3bb0c20c93f16f98
@@ -0,0 +1,77 @@
---
description: "Fill the More menus of three navigation regions with pin controls, plugin browsing, and a Creator draft."
kind: "package-reference"
---
# @deepseek-ai/dsh-client-ui-entry-customization
English | [中文](README.zh.md)
## 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.
## Table of Contents
- [Use this package](#use-this-package)
- [Understand the implementation](#understand-the-implementation)
- [Model Experience](#model-experience)
- [Known Limitations and Deferred Work](#known-limitations-and-deferred-work)
- [Dev Note](#dev-note)
-----
<a id="use-this-package"></a>
## Use this package
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.
**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.
-----
<a id="understand-the-implementation"></a>
## Understand the implementation
<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.
Each registration's `inject` supplies its region and two callbacks. Browsing calls `pluginNavigation.openList()`. Creation 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.
</details>
-----
<a id="model-experience"></a>
## Model Experience
None, as the package manages browser launchers and prepares an unsent, reviewable draft; only user submission sends its text to the model.
#### KV Cache effect
None; the package neither assembles nor sends model requests.
## Known Limitations and Deferred Work
<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.
- **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 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.
<a id="dev-note"></a>
### Dev Note
<details>
<summary>Working context for maintainers — click to expand</summary>
None.
</details>
@@ -0,0 +1,77 @@
---
description: "为三个导航区域的“更多”菜单提供 Pin 控件、插件浏览与创造模式草稿。"
kind: "package-reference"
---
# @deepseek-ai/dsh-client-ui-entry-customization
[English](README.md) | 中文
## 概述
为左侧栏、会话 View 标签和右侧栏起始页的“更多”菜单提供内容:各区域可自定义的入口、Pin 按钮、插件浏览与可审阅的创造模式草稿。Web 应用默认加载本包。各区域自己画“更多”按钮并保存自己的 Pin 状态;本包不保存状态。
## 目录
- [使用本包](#use-this-package)
- [理解实现](#understand-the-implementation)
- [模型体验](#model-experience)
- [已知限制与延期工作](#known-limitations-and-deferred-work)
- [开发备注](#dev-note)
-----
<a id="use-this-package"></a>
## 使用本包
`@deepseek-ai/dsh-web-app` 组合包默认加载本包,无需在插件页开启或由 profile 选择。区域只在其 actions slot 有注册项时显示“更多”按钮,因此卸载本包会移除三个按钮。
打开左侧导航入口下方的**更多**、会话 View 标签之后的**更多会话视图**,或右侧栏起始页卡片之后的**更多工具**。每个菜单按注册顺序列出该区域可自定义的入口;插件管理、Chat 和工作区文件始终显示,不列入菜单。点击入口名称会关闭菜单并打开该入口,不改变 Pin 状态。独立的 Pin 按钮在区域中固定或收起该入口,并保持菜单打开;实心 Pin 表示已固定。已收起的入口仍可从菜单打开,Pin 状态在刷新后保留。过长的入口名称会换行,更多工具菜单中则截断为单行。
**浏览更多插件**进入插件页主列表,不打开安装弹窗。**创建你的插件**用 Creator 预设打开新 Session 或可复用的空白 Session,并把对应区域的简短本地化插件创建请求加入草稿:空草稿直接以该请求为文字,已有草稿保留其文字、引用和附件,请求另起一段追加在后面。它绝不发送消息。请自行审阅并发送草稿。当前区域没有可自定义入口时,两项操作仍然可用;空列表不添加分隔线。
-----
<a id="understand-the-implementation"></a>
## 理解实现
<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 变化不关闭菜单。
每个注册项的 `inject` 提供所属区域与两个回调。浏览调用 `pluginNavigation.openList()`。创建按区域取本地化提示语,调用 `uiWorkspace.startSession(undefined, { agentPreset: 'cordis', prompt, whenDraftExists: 'append' })`;该调用不经过 `ui-agent-preset`,预设标签不播放介绍动画。[`dsh-web-app` bundle patch](../../bundle/web-app/cordis.patch.yml) 挂载浏览器插件。
</details>
-----
<a id="model-experience"></a>
## 模型体验
无。本包管理浏览器入口并准备未发送、可审阅的草稿;仅用户提交才会将其文字发送给模型。
#### KV Cache 影响
无;本包既不组装也不发送模型请求。
## 已知限制与延期工作
<a id="known-limitations-and-deferred-work"></a>
- 用户收起入口后,若本包被停用或加载失败,被收起的入口保持隐藏,且没有“更多”菜单可以恢复。重新启用本包或清除对应区域的 `dsh.slot.manual-order.*.v1` localStorage 键即可恢复。
- Pin 状态保存在浏览器 localStorage,按协议、主机和端口隔离:更换 Web 端口或在 Web 与 Desktop 之间切换时不共享,也不跨设备同步。
- **创建你的插件**不检查 Creator 预设是否存在。预设缺失时 Session 创建失败,Workspace 显示创建失败提示,不准备草稿。草稿准备还要求可确定目标 Workspace;没有目标时,现有流程提示需要 Workspace。
- 入口保持注册顺序;菜单不提供排序,入口也不能跨区域移动。
- 会话控件遵循现有非空 Session 头部与代码工作工具的可用性规则。右侧栏控件要求已选择 Session,并显示在其起始页。
- 仅显示已激活、已注册的入口;菜单不是已停用或未安装插件的清单。
<a id="dev-note"></a>
### 开发备注
<details>
<summary>维护者的工作上下文——点击展开</summary>
无。
</details>
@@ -0,0 +1,6 @@
{
"meta": {
"title": "Interface customization",
"description": "Customize plugin entries in the left sidebar, conversation views, and right sidebar, and quickly create new plugins."
}
}
@@ -0,0 +1,6 @@
{
"meta": {
"title": "界面自定义",
"description": "自定义左侧栏、会话视图和右侧栏显示的插件入口,并快捷创建新插件。"
}
}
@@ -0,0 +1,52 @@
{
"name": "@deepseek-ai/dsh-client-ui-entry-customization",
"version": "0.2.1-rc.2",
"description": "Customize plugin entries in the left sidebar, conversation views, and right sidebar, and quickly create new plugins.",
"publishConfig": { "access": "public" },
"repository": {
"type": "git",
"url": "git+https://github.com/deepseek-ai/deepseek-harness.git",
"directory": "packages/client/ui-entry-customization"
},
"type": "module",
"main": "lib/index.js",
"types": "lib/types/index.d.ts",
"exports": {
".": { "types": "./lib/types/index.d.ts", "default": "./lib/index.js" },
"./client": { "types": "./lib/types/client/index.d.ts", "default": "./lib/client.js" },
"./src/*": "./src/*",
"./package.json": "./package.json",
"./locale/*.json": "./locale/*.json"
},
"files": ["locale/*.json", "lib/index.js", "lib/client.js", "lib/types/**/*.d.ts"],
"license": "MIT",
"peerDependencies": { "@deepseek-ai/cordis": "workspace:~" },
"devDependencies": {
"@deepseek-ai/cordis": "workspace:~",
"@deepseek-ai/dsh-client-locale": "workspace:*",
"@deepseek-ai/dsh-client-ui-sidebar": "workspace:*",
"@deepseek-ai/dsh-client-ui-conversation": "workspace:*",
"@deepseek-ai/dsh-client-ui-sidebar-right": "workspace:*",
"@deepseek-ai/dsh-client-ui-plugin-manager": "workspace:*",
"@deepseek-ai/dsh-client-ui-workspace": "workspace:*",
"@deepseek-ai/dsh-client-ui-primitives": "workspace:*",
"@deepseek-ai/dsh-client-ui-renderer": "workspace:*",
"@deepseek-ai/dsh-client-ui-slots": "workspace:*",
"@deepseek-ai/dsh-client-test-runtime": "workspace:*",
"clsx": "^2.1.1",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"@testing-library/react": "^16.3.3"
},
"dsh": {
"client": {
"platform": "web",
"inject": [
"@deepseek-ai/dsh-client-locale",
"@deepseek-ai/dsh-client-ui-plugin-manager",
"@deepseek-ai/dsh-client-ui-workspace"
]
}
},
"scripts": { "bundle": "tsdown", "watch": "tsdown --watch" }
}
@@ -0,0 +1,12 @@
.entryRow { position: relative; }
.entryName { display: flex; align-items: center; gap: 8px; padding-right: 28px; }
.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; }
}
@@ -0,0 +1,50 @@
/** Rows of a navigation region's More menu: customizable entries, then plugin discovery and creation. */
import {
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'
/** 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. */
readonly region: EntryRegion
/** Open the Plugins main list. */
readonly browsePlugins: () => void
/** Start a Creator draft with this region's prompt without sending it. */
readonly createPlugin: () => void
}
// The three region owners declare the same fields; the sidebar's slot names them.
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.
* @returns Menu rows for the region's More menu.
*/
export function EntryMenuContent({
entries, onOpen, onPinnedChange, onDismiss, region, browsePlugins, createPlugin, t,
}: EntryMenuContentProps) {
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}
onSelect={() => { onDismiss(); browsePlugins() }}>
{t('install')}
</MenuItemButton>
<MenuItemButton icon={<IconPlusOutlineRegular size={16} />} onSelect={() => { onDismiss(); createPlugin() }}>
{t('create')}
</MenuItemButton>
</>
}
@@ -0,0 +1,42 @@
/** 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>
}
@@ -0,0 +1,51 @@
/** Rows for the More menus of the left sidebar, Conversation View tabs, and right-sidebar guide. */
import type { Context } from '@deepseek-ai/cordis'
import type {} from '@deepseek-ai/dsh-client-locale/client'
import type {} from '@deepseek-ai/dsh-client-ui-renderer/client'
import type {} from '@deepseek-ai/dsh-client-ui-sidebar/client'
import type {} from '@deepseek-ai/dsh-client-ui-conversation/client'
import type {} from '@deepseek-ai/dsh-client-ui-sidebar-right/client'
import type {} from '@deepseek-ai/dsh-client-ui-plugin-manager/client'
import type {} from '@deepseek-ai/dsh-client-ui-workspace/client'
import { EntryMenuContent, type EntryMenuInjected, type EntryRegion } from './EntryMenuContent.tsx'
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. */
entryCustomization: keyof typeof en
}
}
/** Services used by the menu rows' footer actions. */
export const inject = ['slots', 'locale', 'pluginNavigation', 'uiWorkspace']
/**
* Register the same menu rows into each region's More menu.
* @param ctx - Browser context with slot, locale, plugin navigation, and Workspace services.
*/
export function apply(ctx: Context): void {
ctx.effect(() => ctx.locale.register('entryCustomization', { en, zh }))
const t = ctx.locale.bind('entryCustomization')
const injected = (region: EntryRegion): EntryMenuInjected => ({
region,
browsePlugins: () => { ctx.pluginNavigation.openList() },
createPlugin: () => {
ctx.uiWorkspace.startSession(undefined, {
agentPreset: 'cordis', prompt: t(`createPrompt.${region}`), whenDraftExists: 'append',
})
},
})
ctx.slots.inject('sidebar.panellist.actions', () => ctx.slots.register({
name: 'sidebar.panellist.actions', id: 'entry-customization', locale: 'entryCustomization',
inject: () => injected('sidebar'),
}, EntryMenuContent))
ctx.slots.inject('conversation.session.header.views.actions', () => ctx.slots.register({
name: 'conversation.session.header.views.actions', id: 'entry-customization', locale: 'entryCustomization',
inject: () => injected('conversation'),
}, EntryMenuContent))
ctx.slots.inject('sidebar.right.tab.guide.actions', () => ctx.slots.register({
name: 'sidebar.right.tab.guide.actions', id: 'entry-customization', locale: 'entryCustomization',
inject: () => injected('rightbar'),
}, EntryMenuContent))
}
@@ -0,0 +1,31 @@
/** Copy for the More menu rows: pin actions, footer 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.',
'createPrompt.conversation': 'Create a DSH conversation view plugin.',
'createPrompt.rightbar': 'Create a DSH plugin with a right sidebar entry.',
} as const
/** 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 插件。',
'createPrompt.conversation': '创建一个 DSH 会话视图插件。',
'createPrompt.rightbar': '创建一个带右侧栏入口的 DSH 插件。',
}
@@ -0,0 +1,5 @@
/** CSS Module declarations for browser components. */
declare module '*.module.css' {
const classes: Readonly<Record<string, string>>
export default classes
}
@@ -0,0 +1,2 @@
/** Host companion for the browser entry-customization plugin. */
export function apply(): void {}
@@ -0,0 +1,17 @@
/** The browser plugin retains a loadable, inert Host companion. */
import { Context } from '@deepseek-ai/cordis'
import { expect, it } from 'vitest'
import * as companion from '../src/index.ts'
it('loads and releases the Host companion through Cordis', async () => {
const ctx = new Context()
try {
const fiber = ctx.plugin(companion)
await fiber
expect(fiber.uid).not.toBeNull()
await fiber.dispose()
expect(fiber.uid).toBeNull()
} finally {
await ctx.fiber.dispose()
}
})
@@ -0,0 +1,176 @@
// @vitest-environment jsdom
/** The plugin fills each region's More menu with entry rows and 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 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 type { PropsRenderSlots } from '@deepseek-ai/dsh-client-ui-slots'
import { EntryMenuContent } from '../src/client/EntryMenuContent.tsx'
import { en } from '../src/client/locales.ts'
import { apply, inject } from '../src/client/index.ts'
type Seats = 'sidebar.panellist.actions' | 'conversation.session.header.views.actions' | 'sidebar.right.tab.guide.actions'
const SEATS = ['sidebar.panellist.actions', 'conversation.session.header.views.actions', 'sidebar.right.tab.guide.actions'] as const
const runtimes = new Set<SlotTestRuntime>()
afterEach(async () => {
try { for (const runtime of runtimes) await runtime.dispose() }
finally { runtimes.clear(); cleanup(); vi.useRealTimers() }
})
type ContentProps = ComponentProps<typeof EntryMenuContent>
const unused = (): never => { throw new Error('The menu rows read no framework hooks') }
function content(overrides: Partial<ContentProps> = {}) {
const callbacks = {
onOpen: vi.fn<ContentProps['onOpen']>(), onPinnedChange: vi.fn<ContentProps['onPinnedChange']>(),
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,
usePanelInfo: unused, useSessions: unused, useSessionStatus: unused, useSessionRetainInfo: unused,
useResource: unused, useWorkspaces: unused,
...callbacks,
...overrides,
} as ContentProps
const view = render(<div role="menu"><EntryMenuContent {...props} /></div>)
return { view, ...callbacks }
}
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' })
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()
fireEvent.click(b.view.getByRole('menuitem', { name: 'Create your plugin' }))
expect(b.createPlugin).toHaveBeenCalledExactlyOnceWith()
expect(b.onDismiss).toHaveBeenCalledTimes(2)
})
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)
})
})
function Frame({ renderSlot }: PropsRenderSlots<Seats>) {
const [pinned, setPinned] = useState(true)
const [opened, setOpened] = useState('')
const entries = [{ id: 'notes', label: 'Notes', pinned }]
return <>
{SEATS.map(seat => <div key={seat} role="menu" aria-label={seat}>
{renderSlot(seat, {
entries,
onOpen: (id) => { setOpened(`${seat}:${id}`) },
onPinnedChange: (_id, next) => { setPinned(next) },
onDismiss: () => {},
})}
</div>)}
<output data-testid="opened">{opened}</output>
</>
}
async function bench(language: 'en' | 'zh' = 'en') {
const runtime = await SlotTestRuntime.create()
runtimes.add(runtime)
const openList = vi.fn(), startSession = vi.fn()
await runtime.mount({
inject: ['slots'],
apply(ctx: Context) {
const locale = new LocaleRuntime(ctx)
locale.setLocale(language)
ctx.provide('locale', locale)
ctx.slots.installLocale(locale)
ctx.provide('pluginNavigation', { openList } as never)
ctx.provide('uiWorkspace', { startSession } as never)
},
})
await runtime.root.declare({
'sidebar.panellist.actions': { kind: 'list', scope: 'root' },
'conversation.session.header.views.actions': { kind: 'list', scope: 'root' },
'sidebar.right.tab.guide.actions': { kind: 'list', scope: 'root' },
}, Frame)
const plugin = await runtime.mount({ inject: [...inject], apply })
const view = runtime.renderRoot()
return { runtime, openList, startSession, plugin, view }
}
describe('More menu registrations', () => {
it('registers one row set per region and removes all of them when the plugin unloads', async () => {
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()
}
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()
for (const seat of SEATS) {
expect(b.runtime.slots.entries(seat)).toEqual([])
expect(within(b.view.getByRole('menu', { name: seat })).queryByRole('menuitem')).toBeNull()
}
})
it.each([
['en', 'sidebar.panellist.actions', 'Create a DSH plugin with a left sidebar entry.'],
['en', 'conversation.session.header.views.actions', 'Create a DSH conversation view plugin.'],
['en', 'sidebar.right.tab.guide.actions', 'Create a DSH plugin with a right sidebar entry.'],
['zh', 'sidebar.panellist.actions', '创建一个带左侧栏入口的 DSH 插件。'],
['zh', 'conversation.session.header.views.actions', '创建一个 DSH 会话视图插件。'],
['zh', 'sidebar.right.tab.guide.actions', '创建一个带右侧栏入口的 DSH 插件。'],
] as const)('starts an unsent %s Creator draft from %s', async (language, seat, prompt) => {
const b = await bench(language)
fireEvent.click(within(b.view.getByRole('menu', { name: seat }))
.getByRole('menuitem', { name: language === 'zh' ? '创建你的插件' : 'Create your plugin' }))
expect(b.startSession).toHaveBeenCalledExactlyOnceWith(undefined, {
agentPreset: 'cordis', prompt, whenDraftExists: 'append',
})
})
})
@@ -0,0 +1,20 @@
/** 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:')
})
})
@@ -0,0 +1,17 @@
{
"extends": "../../../tsconfig.base.client.json",
"compilerOptions": { "rootDir": "src", "outDir": "lib/types" },
"include": ["src"],
"references": [
{ "path": "../../../vendor/cordis" },
{ "path": "../../client/locale/tsconfig.client.json" },
{ "path": "../../client/ui-sidebar" },
{ "path": "../../client/ui-conversation/tsconfig.client.json" },
{ "path": "../../client/ui-sidebar-right" },
{ "path": "../../client/ui-plugin-manager/tsconfig.client.json" },
{ "path": "../../client/ui-workspace" },
{ "path": "../../client/ui-primitives" },
{ "path": "../../client/ui-renderer" },
{ "path": "../../client/ui-slots" }
]
}
@@ -0,0 +1,3 @@
import { clientBundle } from '../../client/tsdown.client.ts'
export default clientBundle(import.meta.dirname, ['lib/types/index.js'])
@@ -42,8 +42,8 @@
en: 203168fc87e939bd
zh: 72d0ccd2c54f3f44
/deepseek-ai-dsh-client-ui-plugin-manager/understand-the-implementation/registration:
en: 3d70281f082988a5
zh: 17c6f595e3b57260
en: de110cb51ae1e984
zh: 8703ded36ae7a1f1
/deepseek-ai-dsh-client-ui-plugin-manager/understand-the-implementation/the-store:
en: 21a0bbe8eb92229c
zh: 096ff63db61992bb
@@ -128,6 +128,8 @@ The browser plugin registers the `plugins` sidebar entry and its `main` panel th
The add control reuses `Button`, `Menu`, and `MenuItemButton`. Both installation actions call the same `openInstall` callback and use `InstallDialog` and `PluginManagerController`. The root-scoped list slot `plugins.add.actions` accepts contributed `MenuItemButton` rows after the install action; each contribution receives an `onDismiss()` callback to close the menu before starting its action.
Other Client plugins can inject `pluginNavigation` and call `openList()` to select the Plugins main list, including when a detail page is already open. This browsing action preserves the current Session and opens no installation dialog. Installation remains an explicit action on the Plugins page.
### The store
`PluginManagerController` owns the bundle views, busy keys, notices, install progress and the uninstall confirmation. Each read asks the inventory whether the Host manages a profile, then joins `listBundles` with `listPlugins` into one view per bundle, whose rows carry the live entry's enablement and fiber phase. It coalesces overlapping reads, refreshes after operations, on `plugin-manager/changed`, and on reconnect, and ignores late results after disposal. Install output is grouped by job id. The install dialog moves `idle → checking → starting → running → done | failed`, with `cancelling` and `applying` as the Host reports them and `unconfirmed` when an installation or cancellation response is lost; confirmed `applying` progress never regresses. Recovery uses `waitForInstall`, settling to `unknown` if no active request remains; checking and every active phase use ongoing, while the final screens use done or error. The check runs under an `AbortController` that going back or closing aborts, and its settlement is dropped; a run is stopped only through `pluginManager.cancelInstall`. Closing hides a run without discarding its state. A cancellation that overtakes installation is retried when progress or output acknowledges the request. A change the Host could not apply, a restart it waits for, and an override by a higher layer become toasts that retire on their own.
@@ -128,6 +128,8 @@ Host 入口通过生成的 Remote 接口暴露 `pluginRegistryProbe.fastest()`
添加控件复用 `Button`、`Menu` 和 `MenuItemButton`。两个安装入口调用同一个 `openInstall` 回调,使用 `InstallDialog` 与 `PluginManagerController`。root 作用域的 list slot `plugins.add.actions` 接受贡献的 `MenuItemButton` 行,放在安装项之后;每个贡献者接收 `onDismiss()` 回调,在开始动作前关闭菜单。
其他 Client 插件可注入 `pluginNavigation` 并调用 `openList()`,选中插件主列表,已打开详情页时也如此。此浏览操作保留当前 Session,不打开安装对话框。安装仍需在插件页显式发起。
### store
`PluginManagerController` 拥有组合包视图、忙碌键、提示、安装进度和卸载确认。每次读取先问清单 Host 是否管理着 profile,再把 `listBundles` 与 `listPlugins` 合成每个组合包一份视图,其行携带存活条目的启停状态与 fiber 阶段。它合并重叠读取,在操作后、收到 `plugin-manager/changed` 时以及重连后刷新,并在销毁后忽略晚到结果。安装输出按 job id 分组。安装对话框沿 `idle → checking → starting → running → done | failed` 推进,`cancelling` 与 `applying` 按 Host 的报告呈现,安装或取消响应丢失时进入 `unconfirmed`;已确认的 `applying` 阶段不会倒退。通过 `waitForInstall` 恢复结果,无活动请求时结束为 `unknown`;checking 与所有活动阶段使用 ongoing,最终页面使用 done 或 error。检查在一个 `AbortController` 下运行,返回编辑或关闭会中止它并丢弃其结果;运行只能通过 `pluginManager.cancelInstall` 停止。关闭会隐藏任务而保留其状态。如果取消请求先于安装到达,在进度或输出确认该请求后会再次请求取消。Host 无法应用的变更、要等重启的变更、被更高层覆盖的变更,都是会自行消失的 toast。
@@ -40,6 +40,8 @@ declare module '@deepseek-ai/cordis' {
* @param packageName - npm package name of the bundle.
*/
openBundle(packageName: string): void
/** Open the Plugins main list without opening a dialog or changing the current Session. */
openList(): void
}
}
}
@@ -136,6 +138,11 @@ export function apply(ctx: ClientContext): void {
if (ctx.layout.panelInfo.getSnapshot().activePanelId !== PANEL_ID) instance.actions.setView({ kind: 'list' })
})
const disposeNavigation = ctx.reflect.provide('pluginNavigation', {
openList: () => {
ctx.layout.selectPanel(PANEL_ID)
instance.actions.setTab('installed')
instance.actions.setView({ kind: 'list' })
},
openBundle: (packageName: string) => {
ctx.layout.selectPanel(PANEL_ID)
instance.actions.setTab('installed')
@@ -15,6 +15,7 @@ import { PluginManagerPage } from '../src/client/PluginManagerPage.tsx'
import { PluginRefreshToast, type PluginRefreshToastFace } from '../src/client/PluginRefreshToast.tsx'
import { PluginsPanelIcon } from '../src/client/PluginsPanelIcon.tsx'
import type { PluginManagerFace } from '../src/client/manager-store.ts'
import type { createNavigationStore } from '../src/client/navigation-store.ts'
usePinnedBrowserLanguages('zh-CN')
beforeEach(() => { localStorage.clear() })
@@ -89,6 +90,32 @@ describe('ui-plugin-manager browser plugin', () => {
expect(navigation.getSnapshot()).toEqual({ tab: 'installed', view: { kind: 'package', name: 'dsh-navigation-test' } })
})
it('opens the Plugins list from another panel and resets bundle details without opening installation', async () => {
const b = await bench()
declare(b.slots)
await b.ctx.plugin({ inject: [...inject], apply }).await()
const entry = b.slots.entries('main')[0]!
assert(entry.store && 'create' in entry.store)
const navigation = entry.store.create() as ReturnType<ReturnType<typeof createNavigationStore>['create']>
const injected: object = entry.inject!()
const face = injected as PluginManagerFace
expect(face.hooks.pluginManager.getSnapshot().install.open).toBe(false)
b.ctx.pluginNavigation.openList()
expect(b.panelInfo.getSnapshot().activePanelId).toBe(PANEL_ID)
expect(navigation.getSnapshot()).toEqual({ tab: 'installed', view: { kind: 'list' } })
expect(face.hooks.pluginManager.getSnapshot().install.open).toBe(false)
b.ctx.pluginNavigation.openBundle('dsh-navigation-test')
expect(navigation.getSnapshot()).toEqual({ tab: 'installed', view: { kind: 'package', name: 'dsh-navigation-test' } })
navigation.actions.setTab('fixture-discover')
b.ctx.pluginNavigation.openList()
expect(b.panelInfo.getSnapshot().activePanelId).toBe(PANEL_ID)
expect(navigation.getSnapshot()).toEqual({ tab: 'installed', view: { kind: 'list' } })
expect(face.hooks.pluginManager.getSnapshot().install.open).toBe(false)
expect(b.list).not.toHaveBeenCalled()
})
it('shares refresh failures with the overlay after navigation and releases both registrations across reloads', async () => {
const b = await bench()
const fiber = b.ctx.plugin({ inject: [...inject], apply })
@@ -18,11 +18,11 @@
en: 7bae72c97d0449f5
zh: 4589ecc2b216edc5
/deepseek-ai-dsh-client-ui-primitives/use-this-package/component-catalog:
en: adaaabb712de1a3d
zh: 45d6286e39880274
en: cb874a0ca53a6747
zh: 31179193dd1d6e12
/deepseek-ai-dsh-client-ui-primitives/use-this-package/controls-and-icons:
en: 28ac8f0b1edbcf81
zh: 5c4c324bf84a282f
en: 09c61f61c560f8a2
zh: 895ac73add1199b8
/deepseek-ai-dsh-client-ui-primitives/use-this-package/rendering-agent-output:
en: 3a31ad8de6dd0af8
zh: 6b22881a929c33df
+2 -2
View File
@@ -70,7 +70,7 @@ Check this table before writing a control in a feature package. A plugin cannot
| `UserTextRun`, `ReferenceChip` | Uninterpreted inline text and business-resolved reference labels or activation buttons. |
| `CommandText` | Selectable command or argument text; only horizontal overflow adds a named keyboard focus target. |
| `TerminalBlock`, `ReadBlock`, `DiffBlock`, `SearchBlock`, `WebBlock` | The agent-output card matching each tool-result intent. |
| `icons/*`, `FishLogo`, `BrandWordmark`, `ReferenceIconRegular`/`ReferenceIconMedium`, `LinkIconRegular`/`LinkIconMedium` | Glyphs and brand marks. The thinking glyph keeps an inset orbit within its 16px view box. Use `LinkIconMedium` for 14px clickable-link categories and known-site marks. |
| `icons/*`, `FishLogo`, `BrandWordmark`, `ReferenceIconRegular`/`ReferenceIconMedium`, `LinkIconRegular`/`LinkIconMedium` | Glyphs and brand marks. The thinking glyph keeps an inset orbit within its 16px view box. `IconAppsAddOutlineRegular`/`Medium`, the More launcher glyph, take `compact` to draw their tiles closer together at card size. Use `LinkIconMedium` for 14px clickable-link categories and known-site marks. |
| `PermissionIconReadOnlyRegular`/`Medium`, `PermissionIconWorkspaceWriteRegular`/`Medium`, `PermissionIconFullAccessRegular`/`Medium`, `PermissionIconAutoReviewRegular`/`Medium` | Permission-mode glyphs for read-only, workspace-write, full-access, and auto-review choices. |
| `PluginArtworkTerminal`/`Loop`/`Subagent`/`Search`/`Default` | Fixed-palette 36×36 plugin artwork; `Terminal` supplies the light-blue prompt in plugin cards and sidebar guide entries. `Default` marks plugins without artwork of their own. Def ids are per-instance, so the same artwork repeats safely on one page. |
| `GuideArtworkBrowser`/`Files` | Fixed-palette 36×36 browser and folder artwork for sidebar guide entries. |
@@ -90,7 +90,7 @@ Writing your own component in your own package is fine when the need is genuinel
The catalog above lists what each export is for; this section covers the behavior that props alone do not show. Product icon names omit artboard sizes and end in `Regular` for the supplied one-pixel artwork or `Medium` for the same geometry at a 1.3px stroke; the `size` prop controls rendered dimensions. Named components keep the chosen weight visible at import sites; the library owns stroke geometry instead of consumer CSS overrides. Each product, reference, link, and permission glyph intentionally keeps both weight exports even when the current product uses only one, so callers can choose emphasis without adding another API later; fill-only pairs render identically. `IconWarningOutlineRegular`/`Medium` use a circle; `IconWarningTriangleOutlineRegular`/`Medium` use a rounded triangle. `FishLogo` and `BrandWordmark` fill brand slots. `FileTypeIcon` renders the traditional 28px spreadsheet, folder, HTML, image, Markdown, generic, PDF, PPT, video, and Word glyphs, and uses the imported square technology artwork for the established 48 code and configuration categories. The import replaces artwork only: archive-only categories do not extend `CodeFileType`. `classifyFileType` applies exact filename, prefix, suffix, optional project-context, and extension rules in that order; React names win over TypeScript/JavaScript, Angular suffixes win over their base extension, and a Dart file becomes Flutter only when the supplied project files contain a `pubspec.yaml` with `flutter:`. Markdown and SVG remain traditional Markdown and image files. Spreadsheet mappings include CSV, TSV, Excel workbooks and templates, OpenDocument spreadsheets, and Numbers; KEY maps to slides, while RTF/ODT/Pages map to documents. `fileExtension` exposes the same basename and final-dot parsing for adjacent metadata labels. Traditional glyphs use a solid category-colored sheet with a white mark and translucent white corner; the generic code glyph uses angle brackets and a slash at the supplied 1.35px stroke; the generic file uses a grey sheet and darker grey corner. Callers may override the sheet color through `--dsh-file-type-icon-color`. The full-color technology artwork is the deliberate exception and retains its embedded palette. All glyphs are decorative and carry no label. `LinkIconMedium` is the leading glyph for clickable artifact links — globe, folder, code, image, document, or plain paper, and for a `url` link the mark of a well-known site named by its `href` — the developer sites a transcript usually cites (GitHub, GitLab, npm, PyPI, Stack Overflow, MDN, Wikipedia, Hacker News, YouTube, X, Bilibili, Zhihu, Juejin, CSDN) and mainstream search, video, social, shopping, and reference sites (Google, Baidu, DuckDuckGo, TikTok, Netflix, Spotify, Facebook, Instagram, Reddit, Telegram, WhatsApp, WeChat, QQ, Weibo, Taobao, AliExpress, eBay, Quora, V2EX, Apple) — while `classifyLinkPath` folds the shared file types into that existing six-category vocabulary. `ConnectionIndicator` renders a warning-colored disconnected action whose permanent retry glyph marks the retry action beside the owner-supplied outage label, the shared ongoing loader in the same warning color as its label whose one-to-three dots advance every 500ms independently of retry timing, or a success-colored recovered status. Clicking either warning state requests an immediate reconnect; no hover interaction changes the copy. The pill fades in on appearance, fades out for 150ms before unmounting, and sizes to its current label. Its owner supplies visibility, the recovery hold, localized labels, and the immediate-reconnect callback; the primitive uses no native title tooltip. `useAnchoredPosition` and `useAnchoredMaxHeight` keep floating panels and bottom-anchored overlays clamped to the viewport and following their anchor; anchored overlays and portalled `Menu` add 20px to the frame's published top clearance, or keep 20px in native fullscreen, while preserving their own minimum margins in ordinary browsers. `HoverCard` keeps its portaled preview reachable across the anchor gap and can expose a copy button through the `copyText` prop. Its `preview` variant uses the anchor or `widthAnchorRef` element’s width minus 48px, centers the panel with 24px side insets, and places it above or below the row within the viewport. The panel keeps the frame’s top clearance, caps its height at 420px, and follows content and anchor resizing; Escape or pointer and keyboard activation of the anchor dismisses it. The whole preview fades in and out over 100ms; a closing preview stops receiving pointer input and unmounts after the fade. Re-entering the anchor during the fade restores it. Reduced-motion preferences suppress the transition. Copy labels are required only when copying is enabled. `HoverCard` and nested action tooltips display independently; use `Tooltip.portal` to layer the small bubble above the card. A visible nested `Tooltip` suppresses only its enclosing tooltip, not the hoveLine truncated
`Tooltip` reads the anchor on hover or keyboard focus and fits its bubble from `ResizeObserver` border-box sizes. The bubble stays hidden until its first fit, slides inside the horizontal viewport margin, and flips above or below only when the opposite side fits. Label-size and viewport changes reuse the anchor coordinates; fitting does not synchronously measure the bubble or trigger a React render. Opt-in `openOnClick` also lets an informational button pin the same bubble for reading and associates it with the anchor description. Another click, Escape, Tab, or an outside pointerdown closes it; ordinary action tooltips still dismiss on click.
`Tooltip` reads the anchor on hover or keyboard focus and fits its bubble from `ResizeObserver` border-box sizes. It stays hidden until its first fit, prefers the requested side, then tries the opposite and perpendicular sides, sliding along the anchor without covering it when a side fits. Horizontal placements keep a 10px gap; vertical placements use `gap` (8px by default). Width and height stay within a 12px viewport margin; text wraps, and content taller than the viewport scrolls when pinned. If no side fits, a stable viewport-bounded fallback can overlap the anchor. Viewport resize refreshes the anchor coordinates; label-size changes reuse them. Fitting does not synchronously measure the bubble or trigger a React render. Opt-in `openOnClick` also lets an informational button pin the same bubble for reading and associates it with the anchor description. Another click, Escape, Tab, or an outside pointerdown closes it; ordinary action tooltips still dismiss on click.
<a id="rendering-agent-output"></a>
+2 -2
View File
@@ -70,7 +70,7 @@ kind: "package-library"
| `UserTextRun`、`ReferenceChip` | 不做解析的行内文本,以及业务已解析的引用标签或激活按钮。 |
| `CommandText` | 可选中的命令或参数文本;仅横向溢出时添加具名的键盘聚焦目标。 |
| `TerminalBlock`、`ReadBlock`、`DiffBlock`、`SearchBlock`、`WebBlock` | 与各类工具结果意图对应的 agent 输出卡片。 |
| `icons/*`、`FishLogo`、`BrandWordmark`、`ReferenceIconRegular`/`ReferenceIconMedium`、`LinkIconRegular`/`LinkIconMedium` | 字形与品牌标识。思考图标的轨道在 16px 视口内保留内边距。`LinkIconMedium` 用于 14px 的可点击链接分类及已知站点标记。 |
| `icons/*`、`FishLogo`、`BrandWordmark`、`ReferenceIconRegular`/`ReferenceIconMedium`、`LinkIconRegular`/`LinkIconMedium` | 字形与品牌标识。思考图标的轨道在 16px 视口内保留内边距。“更多”入口使用的 `IconAppsAddOutlineRegular`/`Medium` 接受 `compact`,在卡片尺寸下让方块更靠近。`LinkIconMedium` 用于 14px 的可点击链接分类及已知站点标记。 |
| `PermissionIconReadOnlyRegular`/`Medium`、`PermissionIconWorkspaceWriteRegular`/`Medium`、`PermissionIconFullAccessRegular`/`Medium`、`PermissionIconAutoReviewRegular`/`Medium` | 只读、工作区写入、完全访问与自动审查选项使用的权限模式图形。 |
| `PluginArtworkTerminal`/`Loop`/`Subagent`/`Search`/`Default` | 固定配色的 36×36 插件插画;`Terminal` 为插件卡片和侧边栏开始页入口提供浅蓝色提示符。`Default` 用于没有自有插画的插件。def id 按实例生成,同一插画可在一页中安全重复。 |
| `GuideArtworkBrowser`/`Files` | 固定配色的 36×36 浏览器与文件夹插画,用于侧栏引导入口。 |
@@ -90,7 +90,7 @@ kind: "package-library"
上面的目录说明每个导出的用途;本节讲 props 本身看不出来的行为。产品图标名称不含画板尺寸,以 `Regular` 表示原始 1px 图形,以 `Medium` 表示同一几何的 1.3px 描边;`size` prop 控制渲染尺寸。具名组件让所选线重在导入处可见;描边几何由组件库拥有,而不是由消费方 CSS 覆盖。每个产品、引用、链接与权限图形都会有意保留两种线重导出,即使当前产品只使用其中一种,也让调用方无需再次扩展 API 就能选择强调程度;仅填充的成对图形外观相同。`IconWarningOutlineRegular`/`Medium` 使用圆形;`IconWarningTriangleOutlineRegular`/`Medium` 使用圆角三角形。`FishLogo` 与 `BrandWordmark` 填充品牌 slot。`FileTypeIcon` 渲染传统的 28px spreadsheet、folder、HTML、image、Markdown、generic、PDF、PPT、video 与 Word 图形,并为现有 48 个代码和配置类别使用导入的方形技术图形。该导入只替换图形:资源包中额外的类别不会扩展 `CodeFileType`。`classifyFileType` 按完整文件名、前缀、后缀、可选项目上下文、扩展名的顺序匹配;React 文件名优先于 TypeScript/JavaScript,Angular 后缀优先于基础扩展名,只有传入的项目文件包含带 `flutter:` 的 `pubspec.yaml` 时 Dart 文件才使用 Flutter。Markdown 与 SVG 仍分别使用传统 Markdown 与图片图形。表格映射包括 CSV、TSV、Excel 工作簿与模板、OpenDocument 表格和 Numbers;KEY 映射为幻灯片,RTF/ODT/Pages 映射为文档。`fileExtension` 为相邻元数据 label 暴露同一套 basename 与最终点号解析。传统图形使用实色分类底板、白色标记和半透明白色折角;通用代码图形使用尖括号与斜线,保留原图 1.35px 描边;通用文件使用灰色底板与较深灰色折角。调用方可通过 `--dsh-file-type-icon-color` 覆盖底板颜色。全彩技术图形是明确例外,会保留其内嵌调色板。所有图形都是装饰性的,不自带 label。`LinkIconMedium` 是可点击产物链接的前置图形——地球、文件夹、代码、图片、文档或纸张,`url` 链接的 `href` 指向已知站点时则改用该站点自己的标记——转写内容常引用的开发者站点(GitHub、GitLab、npm、PyPI、Stack Overflow、MDN、Wikipedia、Hacker News、YouTube、X、Bilibili、知乎、掘金、CSDN),以及主流搜索、视频、社交、购物与参考资料站点(Google、百度、DuckDuckGo、TikTok、Netflix、Spotify、Facebook、Instagram、Reddit、Telegram、WhatsApp、微信、QQ、微博、淘宝、速卖通、eBay、Quora、V2EX、Apple)——`classifyLinkPath` 把共享文件类型折叠进原有六类词汇。`ConnectionIndicator` 可渲染警告色的断联操作(常驻重试图形指明重试动作,断联文案由持有方提供)、与文案使用相同警告色的共享 ongoing loading 加一至三个点以独立于 retry 时序的 500ms 节奏推进的连接中状态,或成功色的恢复状态。点击任一警告状态都会请求立即重连;没有任何悬停交互会改变文案。药丸出现时淡入、卸载前淡出 150ms,宽度随当前 label 自适应。它的持有方提供可见性、恢复驻留时间、本地化 label 与立即重连回调;该原语不使用原生 title tooltip。`useAnchoredPosition` 与 `useAnchoredMaxHeight` 让浮动面板与底部锚定浮层始终钳制在视口内并跟随锚点;锚定浮层与 portal 模式的 `Menu` 在框架发布的顶部占用量上增加 20px,原生全屏时保留 20px,普通浏览器中沿用自身的最小边距。`HoverCard` 通过指针离开宽限期让采用 portal 的预览在跨过锚点间隙时仍可触及,并可通过 `copyText` prop 提供复制按钮。其 `preview` 变体使用 anchor 或 `widthAnchorRef` 元素的宽度减去 48px,左右各内缩 24px,并在视口内放置于行的上方或下方。浮层避开框架顶部保留区,高度最多 420px,会跟随内容及 anchor 尺寸变化;Escape 或通过鼠标和键盘激活锚点可将其关闭。整个浮层的淡入和淡出各持续 100ms;关闭中的浮层停止接收指针输入,淡出后卸载。在淡出期间移回锚点可恢复显示。减少动态效果偏好会禁用过渡动画。只有启用复制时才必须提供复制标签。`HoverCard` 与嵌套的操作提示独立显示;使用 `Tooltip.portal` 将小气泡置于卡片上层。可见的嵌套 `Tooltip` 只隐藏外层 tooltip,不隐藏悬停卡片。`Toast` 使用调用方的 `holdMs` 同时控制淡出延迟与停留加淡出的总时长。`holdMs` 未变时,父组件重渲染不会重启该生命周期;完成时调用最新回调,完全淡出的操作不能接收输入。新的组件 key 会重新开始横幅周期。`rankByName` 是 `/` 菜单命令源与 skill(技能)源共享的候选排序器:查询必须是名字的不区分大小写的Line truncated
`Tooltip` 在悬停或键盘聚焦时读取锚点位置,再根据 `ResizeObserver` 提供的边框盒尺寸调整气泡。首次定位前气泡保持隐藏;横向移入视口留白,仅在另一侧容得下时上下翻转。标签尺寸与视口变化复用锚点坐标;定位不会同步测量气泡,也不会触发 React 渲染。 信息按钮可显式启用 `openOnClick`,点击后保持同一个气泡供阅读,并将内容关联为锚点的无障碍描述;再次点击、Escape、Tab 或外部 pointerdown 会关闭。普通操作按钮的 tooltip 仍在点击时关闭。
`Tooltip` 在悬停或键盘聚焦时读取锚点位置,再根据 `ResizeObserver` 提供的边框盒尺寸调整气泡。首次定位前保持隐藏,优先使用指定方向,再尝试相反方向和垂直于该方向的两侧;只要某一侧容得下,就沿锚点平移而不遮挡锚点。左右放置保留 10px 间距,上下放置使用 `gap`(默认 8px)。宽高限制在视口内并保留 12px 边距;文本自动换行,超出视口高度的内容在固定气泡后可滚动。若所有方向都放不下,则使用稳定的视口内回退位置,可能遮挡锚点。视口尺寸变化会重新读取锚点坐标,标签尺寸变化则复用坐标;定位不会同步测量气泡,也不会触发 React 渲染。 信息按钮可显式启用 `openOnClick`,点击后保持同一个气泡供阅读,并将内容关联为锚点的无障碍描述;再次点击、Escape、Tab 或外部 pointerdown 会关闭。普通操作按钮的 tooltip 仍在点击时关闭。
<a id="rendering-agent-output"></a>
+8 -2
View File
@@ -149,6 +149,7 @@ const MEASURE_STYLE: CSSProperties = { visibility: 'hidden', left: 0, top: 0 }
* the trigger (render-prop anchors, effect-positioned proxies — measuring the
* wrapper there races the host's layout effects). Called on open, each animation
* frame, and scroll/resize; return null to skip placement for that frame.
* @param props.minWidthFromAnchor - in portal mode, keep the menu at least as wide as its anchor, bounded by viewport margins.
* @param props.footer - rows pinned below the scrolling items area, separated
* by a hairline; they stay visible while the items above scroll.
* @param props.children - component rows rendered after `items` in the same
@@ -165,7 +166,7 @@ const MEASURE_STYLE: CSSProperties = { visibility: 'hidden', left: 0, top: 0 }
* document.body outside the owner's DOM subtree.
* @returns anchor wrapper with the conditional list.
*/
export function Menu({ open, anchor, items = [], children, selectedId, selectedIds, onSelect, onClose, align = 'start', side = 'bottom', portal = false, closeOnPointerLeave = false, dense = false, compact = false, autoFocus = false, selection = 'check', getAnchorRect, footer, className, listClassName }: {
export function Menu({ open, anchor, items = [], children, selectedId, selectedIds, onSelect, onClose, align = 'start', side = 'bottom', portal = false, closeOnPointerLeave = false, dense = false, compact = false, autoFocus = false, selection = 'check', getAnchorRect, minWidthFromAnchor = false, footer, className, listClassName }: {
open: boolean
autoFocus?: boolean
anchor: ReactNode
@@ -184,6 +185,7 @@ export function Menu({ open, anchor, items = [], children, selectedId, selectedI
compact?: boolean
selection?: 'check' | 'fill'
getAnchorRect?: () => DOMRect | null
minWidthFromAnchor?: boolean
className?: string | undefined
listClassName?: string | undefined
}) {
@@ -256,6 +258,10 @@ export function Menu({ open, anchor, items = [], children, selectedId, selectedI
const vw = window.innerWidth
const vh = window.innerHeight
const listEl = listRef.current
if (listEl !== null) {
const minimum = minWidthFromAnchor ? `${Math.min(r.width, Math.max(0, vw - MARGIN * 2))}px` : ''
if (listEl.style.minWidth !== minimum) listEl.style.minWidth = minimum
}
const lw = listEl?.offsetWidth ?? 0
const lh = listEl?.offsetHeight ?? 0
@@ -294,7 +300,7 @@ export function Menu({ open, anchor, items = [], children, selectedId, selectedI
window.removeEventListener('scroll', place, true)
window.removeEventListener('resize', place)
}
}, [open, portal, align, side, getAnchorRect])
}, [open, portal, align, side, getAnchorRect, minWidthFromAnchor])
// Opening remembers where the keyboard was, so closing can hand it back to
// that control — an anchor wrapping several (a split button) cannot be asked
@@ -1,6 +1,6 @@
.bubble {
display: inline-flex;
align-items: center;
align-items: safe center;
gap: 8px;
position: fixed;
z-index: 100;
@@ -8,7 +8,10 @@
viewport edge, so anchors near the right edge would wrap early;
max-content sizes by the label alone, capped at half the viewport. */
width: max-content;
max-width: 50vw;
box-sizing: border-box;
max-width: min(50vw, calc(100vw - 24px));
max-height: calc(100vh - 24px);
overflow: auto;
padding: 3px 7px;
border-radius: var(--dsw-radius-sm);
background: var(--dsw-alias-tooltip-bg);
@@ -30,7 +33,8 @@
.bubble[data-has-shortcut] { padding-inline-end: 5px; }
.bubble[data-has-shortcut]:not(:has(.label)) { padding: 3px; }
.bubble[data-side='right'] {
.bubble[data-side='right'],
.bubble[data-side='left'] {
transform: translateY(-50%);
}
+28 -37
View File
@@ -4,14 +4,14 @@ import { cloneElement, createContext, useCallback, useContext, useEffect, useId,
import type { FocusEventHandler, MouseEventHandler, MutableRefObject, ReactElement, Ref } from 'react'
import { createPortal } from 'react-dom'
import { ShortcutKeys } from './ShortcutKeys.tsx'
import { placeTooltip, type TooltipSide } from './tooltip-placement.ts'
import { useDismissOnOutsidePointer } from './useDismissOnOutsidePointer.ts'
import css from './Tooltip.module.css'
// Tooltips take the wide answer — any key returns to the keyboard. Focus rings read the
// narrower `data-input-modality` attribute the same module publishes.
import { pointerModality } from './input-modality.ts'
/** Bubble placement relative to the anchor. */
export type TooltipSide = 'right' | 'bottom' | 'top'
export type { TooltipSide } from './tooltip-placement.ts'
/**
* Suppression channel for nested tooltips: a visible descendant tooltip
@@ -36,28 +36,29 @@ type TooltipLabel = string | (() => string)
* Attach a hover/focus tooltip to an anchor element.
* @param props.label - bubble text, or a resolver evaluated only while visible; an empty string shows only shortcut keys.
* @param props.shortcutKeys - effective key labels rendered as platform-formatted keycaps after optional text.
* @param props.side - placement relative to the anchor (default 'right').
* @param props.side - preferred placement (default 'right'); fitting tries the opposite then perpendicular sides.
* @param props.align - horizontal anchor-edge alignment for 'bottom'/'top' bubbles: 'end' pins
* the bubble's right edge to the anchor's (for anchors beside other hover surfaces the centered
* bubble would overlap); default 'center'. Ignored for side 'right'.
* bubble would overlap); default 'center'. Horizontal placements ignore it.
* @param props.portal - render the bubble under document.body, so an ancestor's clipping or its
* stacking context (which confines the bubble's z-index to that context) cannot hide it.
* @param props.delayMs - hover delay in milliseconds (default 0).
* @param props.focusDelayMs - keyboard focus delay in milliseconds (default 0); blur, click,
* mouse leave, disabling, and unmount cancel a pending show.
* @param props.gap - anchor-to-bubble distance in pixels for 'bottom'/'top' bubbles (default 8);
* ignored for side 'right'.
* horizontal placements use 10px, including after a flip.
* @param props.disabled - suppress the bubble while true; the anchor renders identically so
* toggling never remounts it (which would cut its CSS transitions).
* @param props.maxWidth - bubble width cap in pixels, for labels long enough that the default
* half-viewport cap would render a slab wider than the surface the anchor sits on.
* half-viewport cap would render a slab wider than the surface the anchor sits on; always bounded by viewport margins.
* @param props.openOnClick - clicking also pins the bubble for reading; another click, Escape,
* Tab, or an outside pointerdown dismisses it. Defaults to false for ordinary action tooltips.
* @param props.children - a single anchor element; its own ref (callback or object) is forwarded alongside the tooltip's.
* @returns the cloned anchor plus a fixed-position bubble, optionally portaled to the body.
* The bubble stays hidden until ResizeObserver supplies its size for viewport fitting; clicking the
* anchor dismisses the bubble unless openOnClick is enabled, and focus arriving after a pointer
* interaction (a closing menu refocusing its trigger) never raises it.
* interaction (a closing menu refocusing its trigger) never raises it. When no side fits, the
* viewport-bounded fallback may overlap the anchor; oversized content is scrollable when pinned.
*/
export function Tooltip({ label, shortcutKeys, side = 'right', align = 'center', delayMs = 0, focusDelayMs = 0, gap = 8, disabled = false, portal = false, maxWidth, openOnClick = false, children }: { label: TooltipLabel; shortcutKeys?: readonly string[] | undefined; side?: TooltipSide; align?: 'center' | 'end'; delayMs?: number; focusDelayMs?: number; gap?: number; disabled?: boolean; portal?: boolean; maxWidth?: number; openOnClick?: boolean; children: ReactElement<AnchorProps> }) {
const id = useId()
@@ -71,18 +72,11 @@ export function Tooltip({ label, shortcutKeys, side = 'right', align = 'center',
if (typeof childRef === 'function') childRef(el)
else if (childRef != null) (childRef as MutableRefObject<HTMLElement | null>).current = el
}, [childRef])
// The anchor's edges rather than final coordinates: a vertical flip has to
// re-derive the bubble's own top from the opposite edge.
const [pos, setPos] = useState<{ x: number; top: number; bottom: number } | null>(null)
const [pos, setPos] = useState<DOMRect | null>(null)
const bubble = useRef<HTMLSpanElement | null>(null)
const resolvedLabel = pos === null
? null
: typeof label === 'function' ? label() : label
const y = pos === null
? 0
: side === 'right'
? pos.top + (pos.bottom - pos.top) / 2
: side === 'top' ? pos.top - gap : pos.bottom + gap
const showTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
// Hover and focus are independent triggers: the bubble hides only after
// BOTH clear (hovering away from a focused anchor must not drop it).
@@ -102,33 +96,35 @@ export function Tooltip({ label, shortcutKeys, side = 'right', align = 'center',
useEffect(() => {
const el = bubble.current
if (pos === null || !visible || suppressed || el === null) return
const edgeMargin = 12
let size: ResizeObserverSize | undefined
let placement = side
let anchorRect = pos
const fit = () => {
if (size === undefined) return
const { inlineSize: width, blockSize: height } = size
const offset = side === 'right' ? 0 : align === 'end' ? width : width / 2
const left = Math.max(edgeMargin, Math.min(pos.x - offset, window.innerWidth - edgeMargin - width))
const fitsBelow = pos.bottom + gap + height <= window.innerHeight - edgeMargin
const fitsAbove = pos.top - gap - height >= edgeMargin
if (placement === 'bottom' && !fitsBelow && fitsAbove) placement = 'top'
else if (placement === 'top' && !fitsAbove && fitsBelow) placement = 'bottom'
el.style.left = `${left + offset}px`
el.style.top = `${placement === 'right' ? (pos.top + pos.bottom) / 2
: placement === 'top' ? pos.top - gap : pos.bottom + gap}px`
el.dataset.side = placement
const placement = placeTooltip({
anchor: anchorRect, width: size.inlineSize, height: size.blockSize,
viewportWidth: window.innerWidth, viewportHeight: window.innerHeight, side, align, gap,
})
const horizontal = placement.side === 'right' || placement.side === 'left'
const offsetX = horizontal ? 0 : align === 'end' ? placement.width : placement.width / 2
const offsetY = horizontal ? placement.height / 2 : placement.side === 'top' ? placement.height : 0
el.style.left = `${placement.left + offsetX}px`
el.style.top = `${placement.top + offsetY}px`
el.dataset.side = placement.side
el.style.visibility = 'visible'
}
const onResize = () => {
if (anchor.current !== null) anchorRect = anchor.current.getBoundingClientRect()
fit()
}
const observer = new ResizeObserver((entries) => {
size = entries[0]?.borderBoxSize[0]
fit()
})
observer.observe(el, { box: 'border-box' })
window.addEventListener('resize', fit)
window.addEventListener('resize', onResize)
return () => {
observer.disconnect()
window.removeEventListener('resize', fit)
window.removeEventListener('resize', onResize)
}
}, [align, gap, pos, side, suppressed, visible])
useEffect(() => {
@@ -158,12 +154,7 @@ export function Tooltip({ label, shortcutKeys, side = 'right', align = 'center',
const el = anchor.current
/* v8 ignore next -- the ref is attached by event time: events fire on the cloned anchor. */
if (el === null) return
const r = el.getBoundingClientRect()
setPos({
x: side === 'right' ? r.right + 10 : align === 'end' ? r.right : r.left + r.width / 2,
top: r.top,
bottom: r.bottom,
})
setPos(el.getBoundingClientRect())
announce(true)
}
const showAfterDelay = (delay: number) => {
@@ -213,7 +204,7 @@ export function Tooltip({ label, shortcutKeys, side = 'right', align = 'center',
data-pinned={pinned || undefined}
data-align={align}
data-has-shortcut={shortcutKeys?.length ? true : undefined}
style={{ left: pos.x, top: y, visibility: 'hidden', ...maxWidth === undefined ? {} : { maxWidth } }}
style={{ left: pos.left, top: pos.top, visibility: 'hidden', ...maxWidth === undefined ? {} : { maxWidth: `min(${maxWidth}px, calc(100vw - 24px))` } }}
role="tooltip"
aria-label={shortcutKeys?.length ? [resolvedLabel, shortcutKeys.join(' ')].filter(Boolean).join(' ') : undefined}
>
Loaded 100 of 220 files, more files were not shown because too many files have changed in this diff. Show more