Merge remote-tracking branch 'origin/master' into xtr/session-store-write-lifecycle

# Conflicts:
#	packages/client/ui-workspace/README.i18n.yaml
#	packages/client/ui-workspace/README.md
#	packages/client/ui-workspace/README.zh.md
This commit is contained in:
_Kerman committed 2026-10-20 17:19:39 +08:00
commit deb8173dca
245 files changed
+4297 -424

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 和上述升级指南中“不再保证当前预设定义”的说明。
@@ -0,0 +1,19 @@
# Bilingual-pair consistency record for 2026-10-13-web-install-icons-and-launch-display.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/feature/2026-10-13-web-install-icons-and-launch-display.md
/agent-note-web-install-icons-and-launch-display:
en: 99458c7724588fd3
zh: 1af39a382e1a8b6a
/agent-note-web-install-icons-and-launch-display/problem:
en: 726b732fcaf49153
zh: b04aad2d0a65a42d
/agent-note-web-install-icons-and-launch-display/decision:
en: 65c6eb6a3ad5ab30
zh: 46ca48685f38269c
/agent-note-web-install-icons-and-launch-display/alternatives-considered:
en: 588d709bd7c8c95b
zh: 871b618b86247926
/agent-note-web-install-icons-and-launch-display/consequences:
en: 79695897b5237603
zh: b61304c9527e8dfc
@@ -0,0 +1,41 @@
# Agent Note: Web install icons and launch display
Status: implemented
English | [中文](2026-10-13-web-install-icons-and-launch-display.zh.md)
## Problem
A launcher masks the icon it puts on the home screen and chooses that icon itself. The install manifest advertised the whale favicon: an SVG mark drawn edge to edge on a transparent canvas, with no tile and no background of its own. Handed to a mask, the mark loses its fins and fills the tile at the wrong proportion. iOS reads no manifest icon at all and had no `apple-touch-icon` to fall back on, so an installed web app showed a generic tile. The manifest also requested `display: "fullscreen"`, which removes both system bars on Android and leaves a chat surface with no clock, battery, or navigation.
## Decision
`apps/web/public/` carries four install icons: a 192px and a 512px `any` raster, a 512px `maskable` raster, and a 180px `apple-touch-icon`. [`render-app-icons.ts`](../../../../apps/web/scripts/render-app-icons.ts) renders all four from the official application tile in `apps/desktop/resources/icon.svg`, and `pnpm --filter @deepseek-ai/dsh-web-frontend run render:app-icons` regenerates the committed files. Each variant preserves the whale's geometry, gradients, opacity, and filters. Rendering first produces a bitmap at no less than the 1024px source extent and four times the output edge, then reduces it with Lanczos3. This separate rasterization retains the original shading while smoothing the small icon's curves. Every variant crops the tile's desktop drop shadow. The maskable variant scales the whale about the tile centre by `MASKABLE_ARTWORK_SCALE` while the tile's gradient keeps its own extent, so the border a mask may crop is still the real tile rather than a flat colour meeting it at a seam.
[`pwa-manifest.e2e.ts`](../../../../apps/web/tests/pwa-manifest.e2e.ts) uses the browser image decoder to measure the committed maskable whale’s ink radius and rejects artwork outside the platform safe zone or smaller than the intended scale. The unscaled tile exceeds that zone and serves as the negative control.
The manifest requests `display: "standalone"`, names `#ffffff` as its `theme_color` and `background_color`, and lists the three rasters. `apps/web/index.html` adds the `apple-touch-icon` link, `mobile-web-app-capable`, and `apple-mobile-web-app-title`. It adds no `theme-color`: `ThemePresenter` owns the single such node and follows the resolved in-application theme, which a static media query cannot. The tab favicons are unchanged.
This supersedes the icon entry and the display mode in the [archived Web install manifest note](../../archived/feature/2026-08-06-web-install-manifest.md); its link to the manifest, product name, compact name, and root identity remain current.
## Alternatives considered
**Keep `favicon.svg` as the only icon.** It leaves iOS, which reads no manifest icon, with a generic tile, and the mark already reaches its own edges, so a launcher's mask crops it. `pwa-manifest.e2e.ts` keeps the unscaled tile’s overflow as its browser-pixel negative control.
**Commit the rasters without a generator.** The tile artwork moves with the desktop application's icon. A hand-exported set drifts silently and records neither the crop nor the safe-zone scale that makes it installable.
**Draw a separate browser mark.** The product has one approved tile. A second mark would need designer approval and would not match the installed desktop application beside it.
**Keep `display: "fullscreen"`.** Hiding both system bars suits an immersive surface, not a conversation the user reads beside the clock and returns to from the app switcher; `fullscreen` also offers no way back to the bars without a swipe.
**Ship a service worker for offline start.** Installation, launch, and reconnect work without one. Offline use is a separate capability with its own cache-invalidation contract, and no current requirement asks for it.
## Consequences
The repository carries four committed rasters, all rendered by one script from `apps/desktop/resources/icon.svg`. Changing the desktop tile means rerunning that script and reviewing the PNG diff. Unit tests inspect committed PNG dimensions without loading the native renderer. Browser tests execute the generator and decode its output and all four built PNGs, checking declared edges, opaque corners, and artwork extents against the source tile with a two-pixel rasterization tolerance. Maskable checks bound its artwork above and below, with the unscaled tile as the safe-zone negative control. Tests also compare fractional coverage along the Apple icon’s curves against direct small-size SVG rendering, which retains the source’s hard pixel edges.
The manifest's `theme_color` is one value and cannot follow the in-application theme, so a user whose theme differs from the system scheme sees that value until `ThemePresenter` writes the synchronized node shortly after boot. The presenter stays the only owner of `meta[name="theme-color"]`: a second static node wins document order and desynchronizes surrounding browser chrome from the rendered palette, which `settings-chrome.e2e.ts` pins.
iOS ignores `background_color` and masks the touch icon with its own rounded rectangle; the tile's own margins keep the whale clear of that mask. Android selects the maskable raster for the home screen and an `any` raster for the install surface. The install icons are not a service worker: an installed Web App still needs the Host reachable.
Real-device installation, launch display, and window mode remain a device check.
@@ -0,0 +1,41 @@
# Agent Note: Web 安装图标与启动显示
Status: implemented
[English](2026-10-13-web-install-icons-and-launch-display.md) | 中文
## Problem
启动器会自行选择放到桌面上的图标,并对它套用蒙层。安装 manifest 使用的却是鲸鱼 favicon:一个铺满整张画布、背景透明的 SVG 标记,既没有底板,也没有自己的背景。交给蒙层后,标记会丢掉尾鳍,并且以错误的比例填满图标。iOS 完全不读 manifest 图标,此前又没有 `apple-touch-icon` 可回退,因此安装后的 web app 只显示通用图标。manifest 还请求了 `display: "fullscreen"`,这在 Android 上会同时移除两条系统栏,让一个对话界面失去时钟、电量和导航。
## Decision
`apps/web/public/` 承载四个安装图标:192px 与 512px 的 `any` 位图、512px 的 `maskable` 位图,以及 180px 的 `apple-touch-icon`。[`render-app-icons.ts`](../../../../apps/web/scripts/render-app-icons.ts) 从 `apps/desktop/resources/icon.svg` 的官方应用底板渲染这四个文件,`pnpm --filter @deepseek-ai/dsh-web-frontend run render:app-icons` 重新生成已提交的文件。每个变体都保留鲸鱼的几何路径、渐变、透明度和滤镜。渲染先生成不小于源图 1024px、且边长至少为输出尺寸四倍的位图,再以 Lanczos3 下采样。分开的栅格化步骤在平滑小图标曲线的同时保留原有明暗细节。每个变体都会裁掉底板的桌面投影。maskable 变体围绕底板中心按 `MASKABLE_ARTWORK_SCALE` 缩放鲸鱼,而底板渐变保持自身范围,因此可能被蒙层裁掉的那圈边框仍是真实底板,而不是与其相接的纯色。
[`pwa-manifest.e2e.ts`](../../../../apps/web/tests/pwa-manifest.e2e.ts) 使用浏览器图像解码器测量已提交 maskable 图标的鲸鱼墨迹半径,拒绝越出平台安全区或小于预期比例的图案。未缩放图块超出该安全区,用作反向对照。
manifest 请求 `display: "standalone"`,把 `theme_color` 与 `background_color` 定为 `#ffffff`,并列出三个位图。`apps/web/index.html` 增加 `apple-touch-icon` 链接、`mobile-web-app-capable` 与 `apple-mobile-web-app-title`。它不添加 `theme-color`:该节点由 `ThemePresenter` 独占并跟随解析后的应用内主题,这是静态媒体查询做不到的。标签页 favicon 保持不变。
本决策取代[已归档的 Web 安装 manifest 记录](../../archived/feature/2026-08-06-web-install-manifest.md)中的图标条目与显示模式;其指向 manifest 的链接、产品名、简称与根路径身份仍然有效。
## Alternatives considered
**只保留 `favicon.svg` 作为图标。** 这会让不读 manifest 图标的 iOS 只显示通用图标,而该标记已经抵达自身边界,启动器的蒙层必然裁切它。`pwa-manifest.e2e.ts` 把未缩放图块的越界保留为浏览器像素反向对照。
**不使用生成器,直接提交位图。** 底板素材随桌面应用图标变化。手工导出的图标集会静默漂移,也无法记录使其可安装的裁剪方式与安全区缩放。
**另画一个浏览器专用标记。** 产品只有一个已批准的底板。第二个标记需要设计师批准,并且无法与旁边已安装的桌面应用保持一致。
**保留 `display: "fullscreen"`。** 隐藏两条系统栏适合沉浸式界面,而不适合用户对着时钟阅读、并从应用切换器返回的对话;`fullscreen` 也无法在不滑动的情况下恢复系统栏。
**为离线启动引入 service worker。** 安装、启动与重连都不需要它。离线使用是独立能力,有自己的缓存失效约定,当前没有需求要求它。
## Consequences
仓库携带四张已提交的栅格图,均由同一个脚本从 `apps/desktop/resources/icon.svg` 渲染。桌面图块变化时,需要重新运行该脚本并审查 PNG diff。单元测试直接检查已提交 PNG 的尺寸,不加载原生渲染器。浏览器测试执行生成器,并解码生成结果和全部四张构建后的 PNG,以两个像素的栅格化容差检查声明边长、不透明角落及相对于源图块的图案范围。maskable 检查同时限制图案范围的上界和下界,并以未缩放图块作为安全区反向对照。测试也将 Apple 图标曲线边缘的部分像素覆盖与直接小尺寸渲染的 SVG 对比;后者保留源图的硬像素边缘。
manifest 的 `theme_color` 是单个值,无法跟随应用内主题;因此当用户选择的主题与系统方案不同时,该值会一直生效,直到 `ThemePresenter` 在启动后不久写入同步节点。该 presenter 仍是 `meta[name="theme-color"]` 的唯一所有者:第二个静态节点会在文档顺序上胜出,使周边浏览器界面与已渲染的配色脱节,这一点由 `settings-chrome.e2e.ts` 固定。
iOS 忽略 `background_color`,并用自己的圆角矩形裁切触摸图标;底板自带的留白使鲸鱼避开该蒙层。Android 为桌面选择 maskable 位图,为安装界面选择 `any` 位图。安装图标不是 service worker:安装后的 Web App 仍要求 Host 可达。
真机安装、启动显示与窗口模式仍属设备检查。
@@ -0,0 +1,22 @@
# Bilingual-pair consistency record for 2026-09-29-weighted-approval-decision-check-run.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/process/2026-09-29-weighted-approval-decision-check-run.md
/agent-note-weighted-approval-decisions-are-completed-check-runs:
en: 1b120e96b0f5f340
zh: ff20c20fe0558964
/agent-note-weighted-approval-decisions-are-completed-check-runs/problem:
en: 6ba6f6a15a1562ec
zh: 1cbef3e736c258e6
/agent-note-weighted-approval-decisions-are-completed-check-runs/decision:
en: 7dcebfed221053cc
zh: 84346547d1b95ee3
/agent-note-weighted-approval-decisions-are-completed-check-runs/verification:
en: 3771d95af79c69bb
zh: d4053338d878511f
/agent-note-weighted-approval-decisions-are-completed-check-runs/alternatives-considered:
en: b8daea66b0a90d26
zh: fb7079c815b38281
/agent-note-weighted-approval-decisions-are-completed-check-runs/consequences:
en: 5708f2112ee1e647
zh: 02d29ce5b18db06d
@@ -0,0 +1,61 @@
# Agent Note: Weighted approval decisions are completed check runs
Status: implemented
English | [中文](2026-09-29-weighted-approval-decision-check-run.zh.md)
## Problem
The weighted approval policy must block merging until its conditions hold, distinguish an unmet condition from a failed evaluation, and let tools that wait for pull-request checks to finish terminate. A draft pull request, insufficient approval points, and an effective `CHANGES_REQUESTED` review from a write-capable reviewer are reversible review states rather than evaluation failures.
Commit statuses offer only `pending`, `success`, `failure`, and `error`. A `pending` commit status blocks merging, but `gh pr checks --watch` and other CI watchers treat it as running work and wait until someone approves. A `failure` commit status ends the wait but reports an unmet, reversible condition as a malfunction and conflates the policy outcome with publisher health.
## Decision
The [publisher](../../../../.github/review-ownership/check-approval.mjs) reports the decision as the `weighted approval decision` check run on the pull-request head, written with the job's GitHub Actions token. Branch rules require that check run with GitHub Actions as its expected source.
| Condition | Check run |
|---|---|
| First evaluation of a head | `in_progress` until the decision is written |
| Re-evaluating a head that published `success` | conclusion `action_required` until the decision is written |
| Draft, fewer than the required points, or an effective write-capable `CHANGES_REQUESTED` review | `completed`, conclusion `action_required` |
| Ready, threshold met, and no blocking review | `completed`, conclusion `success` |
| Setup, evaluation, or cancellation failure | `completed`, conclusion `failure` |
A check run that reached `completed` never returns to `in_progress` or `queued`: GitHub answers `200` to that update and leaves the run completed, so only the conclusion is mutable. The publisher therefore revokes a published success by writing the blocking conclusion before dependency setup, which covers the setup and history fetch that dominate a cold evaluation. The head keeps one GitHub Actions run with the required name: the publisher updates the head's existing run and creates one only when none exists. An incomplete list or a run without a valid ID fails the publication. The publish step records the conclusion it wrote in a step output, and the setup-failure step runs only when no conclusion was recorded: a published decision survives a later failure or cancellation, while a run cancelled before that write concludes `failure`. A run cancelled before the publish step records its conclusion, including a cancellation whose file-command outputs the runner drops, therefore concludes `failure`, which still blocks the merge. During a re-evaluation, a previous decision other than a published success keeps its conclusion and title until the new decision is written; a published success is replaced by `action_required` and the preparation title.
A blocking review dominates the point total, so the conclusion remains `action_required` even when counted approvals reach the threshold. GitHub accepts only `success`, `neutral`, and `skipped` for a required check, so `action_required` blocks merging while completing the check.
## Verification
[Approval policy tests](../../../../.github/review-ownership/check-approval.test.mjs) pin the threshold-reaching blocker case, the exact published check-run payloads, revoking a published success through its conclusion, updating an existing run instead of creating another, recording the conclusion that the setup-failure step reads, and invalid list responses. [Workflow tests](../../../../scripts/ci-workflow.spec.ts) pin the `checks: write` permission, the `revoke` and `failure` phase commands, the publish step identity, and the fallback that runs when a run fails or is cancelled before publishing.
The transition matrix and the fallback condition were measured on 2026-09-30 with throwaway tag runs in this repository, using the same GitHub Actions token as the publisher. A run created as `in_progress` and completed as `success` accepted `status: in_progress` and `status: queued` with `200` and stayed `completed`, while conclusion-only updates moved it to `action_required`, back to `success`, and to `failure`. Those runs measured the fallback condition as it stood on 2026-09-30 (`failure() || (cancelled() && steps.publish.outcome != 'success')`): the step ran for a failed publish and for a cancellation during the publish step, and did not run when the job was cancelled after the decision was published. The current condition also requires an empty `concluded` output, so a job cancelled after the publish step recorded its conclusion skips the fallback in either condition. A `timeout-minutes` cancellation was measured separately on 2026-10-20: a job that exceeded its one-minute timeout on a throwaway `push` branch still ran its `cancelled()` step, so the step executes on that path; whether a cancelled step's file-command outputs are exposed was not measured, and dropping them only makes that cancellation conclude `failure`.
The list response's `total_count` counts the `check_name`-filtered runs rather than every check on the head: on 2026-10-20 a head carrying 33 check runs reported `total_count` 0 for a name that matches none and 2 for an existing name, so an equality between `total_count` and the returned array length rejects a truncated page.
## Alternatives considered
**Keep the `pending` commit status.** Branch rules block correctly, but CI watchers cannot distinguish an approval wait from running checks and do not finish.
**Publish `failure` for unmet conditions.** This ends the wait, but it represents an unmet, reversible merge condition as a malfunction and conflates the policy outcome with publisher health.
**Revoke by returning the completed run to `in_progress`.** GitHub accepts that update without applying it, so the published success would stand through dependency setup and the history fetch.
**Publish nothing until success.** A required check that was never reported remains expected and is invisible to many watchers. Revoking a success already published on the same head still requires a non-success write, and each watcher's treatment of a missing required check is unspecified.
**Keep the `weighted approval` name.** When a check and a commit status share a required name, GitHub requires both to pass. Heads that carry a `pending` commit status from the earlier publisher would stay blocked until a new push.
**Create a new check run on every evaluation.** Several same-name check runs on one head make the result depend on how GitHub and each watcher select among them.
**Let approval points override a blocking review.** This makes the score the only success condition, but it permits success while a write-capable reviewer's effective decision still requests changes.
## Consequences
CI watchers finish while the decision waits on reviewers. `gh pr checks` groups `action_required` with failed checks and exits non-zero; consumers distinguish it by the check name, the `ACTION_REQUIRED` conclusion, or the output title, which carries the score or blocker. The `weighted approval publisher` Actions job and the `failure` conclusion carry operational failures.
Re-evaluating a head that already published `success` reports `action_required` until the new decision is written, so a watcher that polls inside that window sees a failed check.
The branch-rule switch leaves two gaps on heads the publisher has not evaluated since it. A check run's name does not identify its publisher. Any workflow in the pushed branch can name a job `weighted approval decision`: on 2026-10-20 a job with `if: false` on a throwaway branch produced a `github-actions` check run with that name and conclusion `skipped` on the pushed commit, and a required check accepts `skipped`. The publisher therefore revokes every passing conclusion it finds before dependency setup and adopts every same-named run when it publishes, which covers a run that exists at either write; a same-named passing run created after the publisher's last write on the head is outside its control. Requiring the check from a dedicated GitHub App is the only complete binding, and it replaces the job token with an app-installed identity.
A head without the decision check run reports the required check as expected until the next publisher event creates it; re-running a pre-switch run does not, because a re-run replays that run's earlier workflow file, which calls the removed `pending` phase against the new script. A re-run of a pre-switch run on 2026-10-20 executed the pre-switch step names and no check-run write. A pull-request edit triggers the publisher's `edited` event, so the rollout can create the run on a head that has none. A head that also carries the earlier `weighted approval` commit status keeps that pending status until the same event: the combined status resolves each context to its latest status, so the rollout posts a terminal `success` status for that context on every open head instead of waiting for its next push.
@@ -0,0 +1,61 @@
# Agent Note: 加权批准决策使用已完成的 check run
Status: implemented
[English](2026-09-29-weighted-approval-decision-check-run.md) | 中文
## 问题
加权批准策略必须在条件满足之前阻止合并,区分尚未满足的条件与失败的评估,并让等待 PR 检查结束的工具能够退出。draft PR、批准点数不足,以及具有写权限的评审人仍然生效的 `CHANGES_REQUESTED` 评审,都是可撤销的评审状态,而不是评估故障。
commit status 只提供 `pending`、`success`、`failure` 和 `error`。`pending` commit status 会阻止合并,但 `gh pr checks --watch` 和其他 CI 监视工具把它当作仍在运行的工作,会一直等到有人批准。`failure` commit status 能结束等待,但会把尚未满足且可撤销的条件报告为故障,并混淆策略结果与 publisher 的健康状态。
## 决策
[publisher](../../../../.github/review-ownership/check-approval.mjs) 使用该 job 的 GitHub Actions token,在 PR head 上以 `weighted approval decision` check run 报告决策。分支规则要求该 check run,并把 GitHub Actions 设为预期来源。
| 条件 | Check run |
|---|---|
| head 的首次评估 | 写入决策前为 `in_progress` |
| 重新评估已发布 `success` 的 head | 写入决策前 conclusion 为 `action_required` |
| draft、点数少于要求,或存在具有写权限的评审人仍然生效的 `CHANGES_REQUESTED` 评审 | `completed`,conclusion 为 `action_required` |
| 已 ready、达到阈值且没有阻塞性评审 | `completed`,conclusion 为 `success` |
| 安装、评估或取消导致的失败 | `completed`,conclusion 为 `failure` |
已经到达 `completed` 的 check run 不会再回到 `in_progress` 或 `queued`:GitHub 对该更新返回 `200`,但让 run 保持已完成,因此只有 conclusion 可变。publisher 因此在依赖安装前用阻塞性 conclusion 撤销已发布的成功,覆盖冷启动中占主要耗时的安装与历史拉取。同一 head 上只保留一个同名 GitHub Actions run:publisher 更新 head 上已有的 run,仅在不存在时创建一个。列表不完整或 run 没有有效 ID 时发布失败。publish 步骤把自己写入的 conclusion 记录到 step output,安装失败步骤只在该 output 未记录时运行:已发布的决策在之后的失败或取消中保留,而在该写入之前被取消的 run 以 `failure` 结束。在 publish 步骤记录 conclusion 之前被取消的 run——包括 runner 丢弃其 file-command 输出的取消——因此以 `failure` 结束,仍然阻止合并。重新评估期间,先前决策若不是已发布的 `success`,其 conclusion 与标题会一直显示到新决策写入为止;已发布的 `success` 会被替换为 `action_required` 与准备中的标题。
阻塞性评审的优先级高于点数总和,因此即使计入的批准已经达到阈值,conclusion 仍为 `action_required`。GitHub 只接受 `success`、`neutral` 和 `skipped` 作为必需检查的通过结果,因此 `action_required` 会阻止合并,同时让该检查处于完成状态。
## 验证
[批准策略测试](../../../../.github/review-ownership/check-approval.test.mjs)锁定已达到阈值但仍有 blocker 的场景、准确发布的 check run payload、通过 conclusion 撤销已发布的成功、更新已有 run 而不是另建一个、记录安装失败步骤所读的 conclusion,以及无效的列表响应。[工作流测试](../../../../scripts/ci-workflow.spec.ts)锁定 `checks: write` 权限、`revoke` 和 `failure` 阶段命令、publish 步骤的身份,以及运行失败或在发布前被取消时执行的兜底步骤。
转换矩阵与该兜底条件于 2026-09-30 在本仓库用一次性 tag 运行实测,使用的是与 publisher 相同的 GitHub Actions token。以 `in_progress` 创建、以 `success` 完成的 run,接受 `status: in_progress` 和 `status: queued` 的更新时都返回 `200` 并保持 `completed`;只改 conclusion 的更新把它依次改为 `action_required`、`success` 和 `failure`。这些运行测的是 2026-09-30 当时的兜底条件(`failure() || (cancelled() && steps.publish.outcome != 'success')`):兜底步骤在发布失败以及发布步骤执行期间被取消时运行,在决策发布之后才取消 job 时不运行。当前条件还要求 `concluded` output 为空,因此两种条件下,publish 步骤已记录结论后才取消的 job 都跳过兜底。`timeout-minutes` 取消路径于 2026-10-20 单独实测:一个在一次性 `push` 分支上超过一分钟超时的 job 仍然执行了 `cancelled()` 步骤,因此该步骤在这条路径上会执行;取消步骤的 file-command 输出是否会被暴露未实测,若被丢弃只会让该次取消以 `failure` 结束。
列表响应中的 `total_count` 统计的是经 `check_name` 过滤后的 run,而不是该 head 上的全部检查:2026-10-20 实测,一个带有 33 个 check run 的 head,对不匹配任何 run 的名称返回 `total_count` 0,对已存在的名称返回 2,因此用 `total_count` 与返回数组长度相等来拒绝被截断的分页是有效的。
## 考虑过的替代方案
**保留 `pending` commit status。** 分支规则能正确阻止合并,但 CI 监视工具无法区分等待批准与仍在运行的检查,因此不会结束。
**为尚未满足的条件发布 `failure`。** 该方案能结束等待,但会把尚未满足且可撤销的合并条件表示为故障,并混淆策略结果与 publisher 的健康状态。
**通过把已完成的 run 改回 `in_progress` 来撤销。** GitHub 接受该更新但不执行,因此已发布的成功会在依赖安装与历史拉取期间继续存在。
**在成功之前不发布任何结果。** 从未上报的必需检查保持 expected 状态,许多监视工具看不到它。撤销同一 head 上已发布的成功仍然需要写入非成功结果,而各监视工具如何对待缺失的必需检查没有规定。
**保留 `weighted approval` 名称。** 当 check 和 commit status 使用同一个必需名称时,GitHub 要求两者都通过。带有旧 publisher 所写 `pending` commit status 的 head 会一直被阻止,直到出现新的 push。
**每次评估都创建新的 check run。** 同一 head 上存在多个同名 check run 时,结果取决于 GitHub 和各监视工具如何在其中选择。
**允许批准点数覆盖阻塞性评审。** 该方案会让分数成为唯一的成功条件,但也允许在具有写权限的评审人仍然有效地要求修改时发布成功结果。
## 后果
决策等待评审人时,CI 监视工具会结束。`gh pr checks` 把 `action_required` 与失败的检查归为一组并以非零状态退出;消费方可以通过 check 名称、`ACTION_REQUIRED` conclusion 或携带分数或 blocker 的输出标题加以区分。`weighted approval publisher` Actions job 和 `failure` conclusion 承载运行故障信号。
重新评估已经发布 `success` 的 head 时,在新决策写入前会报告 `action_required`,因此在该窗口内轮询的监视工具会看到失败的检查。
分支规则切换后,对切换以来 publisher 尚未评估过的 head 存在两个缺口。check run 的名称并不标识发布者。被推送分支里的任何 workflow 都可以把一个 job 命名为 `weighted approval decision`:2026-10-20 在一次性分支上,带 `if: false` 的 job 在被推送的 commit 上产生了 `github-actions` 来源、该名称、conclusion 为 `skipped` 的 check run,而必需检查接受 `skipped`。因此 publisher 在依赖安装前撤销它找到的所有可通过的 conclusion,并在发布时接管所有同名 run,覆盖两次写入时已存在的 run;在 publisher 对该 head 最后一次写入之后新出现的同名可通过 run 不在其控制范围内。把该检查绑定到专用 GitHub App 是唯一完整的绑定方式,它会用 App 安装身份替换 job token。
没有该决策 check run 的 head 会把必需检查报告为 expected,直到下一次 publisher 事件创建它;重跑切换前的 run 不能完成迁移,因为重跑会回放该 run 当时的 workflow 文件,而它调用的 `pending` 阶段已被新脚本拒绝。2026-10-20 对切换前 run 的重跑执行的是切换前的步骤名,没有写入 check run。编辑 PR 会触发 publisher 的 `edited` 事件,因此 rollout 可以对没有该 run 的 head 制造这次事件。同时也带有先前 `weighted approval` commit status 的 head 会一直保留那个 pending status 直到同一事件:combined status 对每个 context 取最新的 status,因此 rollout 对每个开放 head 发布该 context 的终态 `success` status,而不是等它下一次 push。
+1 -1
View File
@@ -124,7 +124,7 @@ For GitHub PRs, inspect remote CI after the push:
gh pr checks
```
Report pending checks as pending. Inspect failures before attributing them to the branch or the environment.
Report pending checks as pending. Inspect failures before attributing them to the branch or the environment. A `weighted approval decision` check that `gh pr checks` lists as failed means the approval gate is waiting on reviewers only when its conclusion is `ACTION_REQUIRED`; the same check concluded `FAILURE` is a publisher fault, so read the `weighted approval publisher` job before attributing it to the branch.
When `gh pr checks` reports "no checks reported" and `/actions/runs?head_sha=<sha>` returns `total_count: 0`, read mergeability before suspecting the push or a dropped GitHub event:
+6 -6
View File
@@ -16,11 +16,11 @@ The [`weighted-approval` workflow](../workflows/weighted-approval.yml) publishes
## Approval scoring
The weighted approval workflow exposes two pull-request checks. The `weighted approval publisher` Actions job reports whether evaluation and status publication completed, while the `weighted approval` commit status carries the approval decision on the pull request head. Branch rules must require only the commit status with GitHub Actions as its expected source; a context-only requirement can accept a same-named status from another integration. The publisher marks the head pending before Python setup or lexer installation, so failed setup or an interrupted history fetch cannot leave a previous success in place. Setup and evaluation failures publish an error status. A completed evaluation returns `pending` below two approval points, while the pull request is a draft, or while a write-capable reviewer has an effective `CHANGES_REQUESTED` review; the blocker keeps the status pending even when counted approvals reach the threshold. It returns `success` only when the threshold is met, the pull request is ready, and no such blocker exists. If evaluation fails, the publisher writes an `error` status.
The weighted approval workflow exposes two pull-request checks. The `weighted approval publisher` Actions job reports whether evaluation and check publication completed, while the `weighted approval decision` check run carries the approval decision on the pull request head. Branch rules must require that check run with GitHub Actions as its expected source; a name-only requirement can accept a same-named check or status from another integration. The publisher updates the head's existing GitHub Actions decision check run and creates one only when none exists; a head without one starts `in_progress` and a re-evaluated head that published `success` is concluded `action_required` before Python setup or lexer installation, so failed setup or an interrupted history fetch cannot leave a previous success in place. A completed check run only ever changes conclusion, so every later write completes it again. Failures and cancellations that happen before the publish step records a conclusion conclude it as `failure`; a cancellation after that record keeps the published decision. A completed evaluation concludes `action_required` below two approval points, while the pull request is a draft, or while a write-capable reviewer has an effective `CHANGES_REQUESTED` review; the blocker keeps that conclusion even when counted approvals reach the threshold. It concludes `success` only when the threshold is met, the pull request is ready, and no such blocker exists. `action_required` blocks merging but completes the check, so tools that wait for checks to finish do not wait for reviewers; the check-run title carries the score or blocker.
Reviewers whose calculated base repository permission is `write` or `admin` count. The [approval policy](approval-policy.json) gives `@07akioni`, `@imccyu`, `@tianyicui`, `@tianyicui-bot`, `@turtle1999`, and `@turtle2099` two points each; every other write-capable reviewer gets one point. The pull-request author’s own review and reviewers without write permission do not count.
The PR author contributes `min(100, mergedPRCount) × 11 / 1000` points: 0 merged PRs → 0 points, 50 → 0.55, and 100 or more → 1.1. The publisher counts only merged PRs in this repository using the author’s immutable account ID, excluding the current PR. It stops at 100 matches and rejects incomplete history responses. Counts refresh at the next subscribed evaluation event. Author credit is separate from reviewer approvals and cannot satisfy the two-point requirement alone; the author’s own review remains excluded. Human and bot authors, including Dependabot, use the same rule. Drafts, blocking reviews, and sufficient reviewer points skip history lookup; logs mark credit as not evaluated. Otherwise, logs show author credit and the capped count separately. Below the cap, counting may scan the repository’s entire merged history; a failed required lookup publishes an error.
The PR author contributes `min(100, mergedPRCount) × 11 / 1000` points: 0 merged PRs → 0 points, 50 → 0.55, and 100 or more → 1.1. The publisher counts only merged PRs in this repository using the author’s immutable account ID, excluding the current PR. It stops at 100 matches and rejects incomplete history responses. Counts refresh at the next subscribed evaluation event. Author credit is separate from reviewer approvals and cannot satisfy the two-point requirement alone; the author’s own review remains excluded. Human and bot authors, including Dependabot, use the same rule. Drafts, blocking reviews, and sufficient reviewer points skip history lookup; logs mark credit as not evaluated. Otherwise, logs show author credit and the capped count separately. Below the cap, counting may scan the repository’s entire merged history; a failed required lookup concludes the check as `failure`.
A one-point approval receives weight `min(2, 1 + 4 × ownedLines / totalLines)` from modified or deleted old production-code lines, attributed by `git blame` at the merge base of the live base branch and exact reviewed head. Ownership of 0%, 12.5%, and 25% gives 1, 1.5, and 2 points; higher ownership remains capped at 2. The success threshold remains 2 total points, without rounding the score. New lines do not enter the denominator, and an empty denominator gives no boost. Existing two-point weights remain unchanged. GitHub commit-author accounts identify reviewers across author emails; unlinked authors remain in the denominator without contributing to a reviewer. The publisher logs measured ownership. It skips attribution when reviewer points plus author credit already meet the threshold or a blocking review exists. Displayed scores use at most three decimal places; the decision uses unrounded scores with a tolerance of `1e-12` points for floating-point error. The curve endpoints come from the policy’s default and required points.
@@ -28,13 +28,13 @@ Production source means supported code files under `src/` in `packages/`, `apps/
Each reviewer contributes only the current `APPROVED` or `CHANGES_REQUESTED` decision that GitHub returns. A `DISMISSED` record clears that reviewer's standing decision, including earlier approvals. Comment-only and pending records do not replace a decision. Reviews from deleted accounts and reviewers without current repository access do not count. The workflow does not invalidate an approval by its review commit; the repository's native pull-request rules own stale-review and latest-push requirements.
The publisher runs when a pull request opens, synchronizes, reopens, becomes ready, becomes a draft, or is edited, including a base-branch change. Conversation comment events run the publisher only when the current or previous body contains `/delegate`. Closed or merged PRs and stale review heads are skipped before status writes or dependency setup; the workflow does not subscribe to master pushes. GitHub associates `workflow_run` publisher runs with the default branch even though approval statuses target the open PR head. Pull-request, review, and comment events share one concurrency group per PR. Review submissions, edits, and dismissals run the no-permission [`weighted-approval-review-event` workflow](../workflows/weighted-approval-review-event.yml); its validated run title supplies the pull-request number to the default-branch publisher. The publisher validates the current head, fetches every review and conversation comment, and resolves current repository permission before publishing the status. Permission changes take effect on the next subscribed event.
The publisher runs when a pull request opens, synchronizes, reopens, becomes ready, becomes a draft, or is edited, including a base-branch change. Conversation comment events run the publisher only when the current or previous body contains `/delegate`. Closed or merged PRs and stale review heads are skipped before check-run writes or dependency setup; the workflow does not subscribe to master pushes. GitHub associates `workflow_run` publisher runs with the default branch even though decision check runs target the open PR head. Pull-request, review, and comment events share one concurrency group per PR. Review submissions, edits, and dismissals run the no-permission [`weighted-approval-review-event` workflow](../workflows/weighted-approval-review-event.yml); its validated run title supplies the pull-request number to the default-branch publisher. The publisher validates the current head, fetches every review and conversation comment, and resolves current repository permission before publishing the check run. Permission changes take effect on the next subscribed event.
<a id="delegating-points"></a>
## Delegating points
Post `/delegate @username` as the entire text of a PR conversation comment to transfer your reviewer points to that user's effective `APPROVED` decision on this PR. For example, `/delegate @turtle1999` lets @turtle1999's approval count your points alongside their own. Until they approve, your points do not count, even if your own approval remains active. Each evaluation reconciles every active eligible command: it dismisses the sender's existing `APPROVED` and `CHANGES_REQUESTED` reviews, then requests review from recipients who have neither approved nor already been requested. This also handles commands posted on drafts once the PR becomes ready and commands whose original event was replaced in the concurrency queue. Other requested reviewers remain unchanged. The command does not submit a review; drafts do not dismiss or request reviews. Comment-only and pending reviews cannot be dismissed. A failed dismissal publishes an error and prevents review requests; scores are refreshed after successful dismissal. Request failures are logged without changing the published score, and subsequent evaluations retry outstanding requests.
Post `/delegate @username` as the entire text of a PR conversation comment to transfer your reviewer points to that user's effective `APPROVED` decision on this PR. For example, `/delegate @turtle1999` lets @turtle1999's approval count your points alongside their own. Until they approve, your points do not count, even if your own approval remains active. Each evaluation reconciles every active eligible command: it dismisses the sender's existing `APPROVED` and `CHANGES_REQUESTED` reviews, then requests review from recipients who have neither approved nor already been requested. This also handles commands posted on drafts once the PR becomes ready and commands whose original event was replaced in the concurrency queue. Other requested reviewers remain unchanged. The command does not submit a review; drafts do not dismiss or request reviews. Comment-only and pending reviews cannot be dismissed. A failed dismissal concludes the check as `failure` and prevents review requests; scores are refreshed after successful dismissal. Request failures are logged without changing the published score, and subsequent evaluations retry outstanding requests.
Both accounts must currently have write or admin permission, and neither may be the PR author. Commands involving ineligible accounts are ignored. A sender's newest surviving eligible, author-authorized command wins, in comment creation order; editing an older comment does not move it after newer comments. Submitting any review after delegation automatically takes the points back, including an approval, change request, or comment-only review. Submitting an inline diff comment with “Add single comment” also creates a comment-only review and reclaims the points. Pending reviews do not count; a later dismissal does not restore the delegation. When timestamps are equal, the review takes precedence. A new command after the review can delegate again; editing an older command cannot reactivate it. Post `/delegate @your-own-username` to restore your own review decision. Editing or deleting a command recomputes delegation from remaining comments, so deleting the latest command can restore an older one. Quoted commands, code blocks, review bodies, inline review comments, and commands mixed with other text do not count. Surrounding whitespace is allowed; account matching is case-insensitive. An edited command is accepted only when GitHub identifies the original author as its latest editor; edits by other accounts cannot delegate or cancel that author’s points.
@@ -44,7 +44,7 @@ Each sender contributes at most once, using their own fixed weight or production
## Security
All actions in the status-writing job are pinned to commit SHAs. The job checks out only the repository default branch. It does not check out or execute pull-request code and does not use repository secrets. Only when there is no blocker, reviewer points plus author credit are insufficient, and an approval has the policy’s default weight, it fetches complete history using the job token, passes Git objects to the trusted classifier as data, and resolves commit authors in batches of 50. Fetch credentials exist only in the Git child environment. Missing history, parsing failures, or incomplete author queries fail evaluation rather than producing a partial score. The review-event workflow has no `GITHUB_TOKEN` permissions and passes only a decimal pull-request number in its run title. The publisher accepts only successful `pull_request_review` runs from the review-event workflow file, identified by `workflow_run.path`; GitHub can populate `workflow_run.name` with the expanded run title. The publisher rejects an invalid run title and a number that does not resolve to the workflow run's current pull-request head. Pull-request reviews and comments are treated as API data and escaped in logs. All publisher triggers and steps, including dependency setup, have pull-request write permission; the policy uses it to dismiss eligible senders' prior decision reviews and request recipients. Every evaluation verifies command authors and latest editors by immutable account ID through GraphQL, matching the body to the REST comment history before counting points or acting. Missing or inconsistent editor metadata fails evaluation. Non-author edits are ignored. Only active eligible delegations are reconciled; superseded or revoked commands have no effects. Dismissal failures publish an error; request failures remain separate from the approval decision.
All actions in the check-writing job are pinned to commit SHAs. The job checks out only the repository default branch. It does not check out or execute pull-request code and does not use repository secrets. Only when there is no blocker, reviewer points plus author credit are insufficient, and an approval has the policy’s default weight, it fetches complete history using the job token, passes Git objects to the trusted classifier as data, and resolves commit authors in batches of 50. Fetch credentials exist only in the Git child environment. Missing history, parsing failures, or incomplete author queries fail evaluation rather than producing a partial score. The review-event workflow has no `GITHUB_TOKEN` permissions and passes only a decimal pull-request number in its run title. The publisher accepts only successful `pull_request_review` runs from the review-event workflow file, identified by `workflow_run.path`; GitHub can populate `workflow_run.name` with the expanded run title. The publisher rejects an invalid run title and a number that does not resolve to the workflow run's current pull-request head. Pull-request reviews and comments are treated as API data and escaped in logs. All publisher triggers and steps, including dependency setup, have pull-request write permission; the policy uses it to dismiss eligible senders' prior decision reviews and request recipients. Every evaluation verifies command authors and latest editors by immutable account ID through GraphQL, matching the body to the REST comment history before counting points or acting. Missing or inconsistent editor metadata fails evaluation. Non-author edits are ignored. Only active eligible delegations are reconciled; superseded or revoked commands have no effects. Dismissal failures conclude the check as `failure`; request failures remain separate from the approval decision.
Approval policy changes take effect only after they merge into the default branch. This prevents an untrusted pull request from changing the program or policy for its own run.
@@ -52,7 +52,7 @@ Approval policy changes take effect only after they merge into the default branc
## Verification
Run `pnpm run test:approval-policy` for policy parsing, effective review decisions, review-event validation, merged-history pagination and account matching, lazy author credit, permission filtering, weighted scoring, delegation conservation and revocation, editor authorization, comment pagination, dismissal and review-request reconciliation, blockers, drafts, closed-PR skips, status publication, and API failures. [Workflow tests](../../scripts/ci-workflow.spec.ts) pin the trusted checkout, no-permission review handoff, permissions, events, and commands. The repository gate graph runs the approval policy and workflow tests in CI. The Python SDK job runs `uv run --python 3.10 --with-requirements .github/review-ownership/requirements.txt python -m unittest discover -s .github/review-ownership -p 'test_*.py'` for real Git histories, lexers, renames, shallow-history rejection, and the publisher’s fetch/analysis integration.
Run `pnpm run test:approval-policy` for policy parsing, effective review decisions, review-event validation, merged-history pagination and account matching, lazy author credit, permission filtering, weighted scoring, delegation conservation and revocation, editor authorization, comment pagination, dismissal and review-request reconciliation, blockers, drafts, closed-PR skips, check-run publication, and API failures. [Workflow tests](../../scripts/ci-workflow.spec.ts) pin the trusted checkout, no-permission review handoff, permissions, events, and commands. The repository gate graph runs the approval policy and workflow tests in CI. The Python SDK job runs `uv run --python 3.10 --with-requirements .github/review-ownership/requirements.txt python -m unittest discover -s .github/review-ownership -p 'test_*.py'` for real Git histories, lexers, renames, shallow-history rejection, and the publisher’s fetch/analysis integration.
<a id="dev-note"></a>
+79 -31
View File
@@ -10,7 +10,8 @@ import { authorCreditPoints, countMergedAuthorPulls } from './author-weight.mjs'
const API_VERSION = '2026-03-10'
const MAX_PULL_REQUEST_RECORDS = 3_000
const PAGE_SIZE = 100
const STATUS_CONTEXT = 'weighted approval'
const CHECK_NAME = 'weighted approval decision'
const PASSING_CONCLUSIONS = new Set(['success', 'neutral', 'skipped'])
const WRITABLE_PERMISSIONS = new Set(['admin', 'write'])
const REVIEW_STATES = new Set(['APPROVED', 'CHANGES_REQUESTED', 'COMMENTED', 'DISMISSED', 'PENDING'])
@@ -209,13 +210,13 @@ function timestamp(value, subject) {
/**
* Evaluate approval points from current reviews and repository permissions.
* @param {{event: unknown, policySource: string, api: (path: string, options?: {method?: string, body?: unknown}) => Promise<unknown>, getOwnership?: typeof productionOwnership, getMergedCount?: typeof countMergedAuthorPulls}} options Runtime inputs.
* @returns {Promise<{pull: {repository: string, number: number, headSha: string}, state: 'pending' | 'success', description: string, points: number, authorCredit: {mergedCount: number, points: number} | null, requiredPoints: number, approvals: Array<{login: string, points: number, delegatedTo?: string, ownership?: {ownedLines: number, totalLines: number}}>, delegations: Array<{login: string, delegatedTo: string, commentId: number, reviewIds: number[], approved: boolean}>, blockers: string[], ignoredReviewers: string[]}>} Approval decision and status payload fields; approval login owns the points, delegatedTo supplies its decision, delegations contains eligible active commands and their superseded decision review IDs, and null author credit means history was not evaluated.
* @returns {Promise<{pull: {repository: string, number: number, headSha: string}, state: 'action_required' | 'success', description: string, points: number, authorCredit: {mergedCount: number, points: number} | null, requiredPoints: number, approvals: Array<{login: string, points: number, delegatedTo?: string, ownership?: {ownedLines: number, totalLines: number}}>, delegations: Array<{login: string, delegatedTo: string, commentId: number, reviewIds: number[], approved: boolean}>, blockers: string[], ignoredReviewers: string[]}>} Approval decision and check-run output fields; approval login owns the points, delegatedTo supplies its decision, delegations contains eligible active commands and their superseded decision review IDs, and null author credit means history was not evaluated.
*/
export async function evaluateApproval({ event, policySource, api, getOwnership = productionOwnership, getMergedCount = countMergedAuthorPulls }) {
const pull = pullRequestFromEvent(event)
const policy = parseApprovalPolicy(policySource)
if (pull.draft) {
return approvalResult(pull, policy.requiredPoints, [], [], [], 'pending', 'draft pull request')
return approvalResult(pull, policy.requiredPoints, [], [], [], 'action_required', 'draft pull request')
}
const reviews = await listPullRequestReviews(api, pull.repository, pull.number)
@@ -286,11 +287,11 @@ export async function evaluateApproval({ event, policySource, api, getOwnership
return next
}, authorCredit?.points ?? 0)
if (blockers.length > 0) {
return approvalResult(pull, policy.requiredPoints, approvals, blockers, ignoredReviewers, 'pending',
return approvalResult(pull, policy.requiredPoints, approvals, blockers, ignoredReviewers, 'action_required',
`${blockers.length} blocking change request${blockers.length === 1 ? '' : 's'}`, authorCredit, activeDelegations)
}
// Tolerate floating-point addition error without rounding approval scores.
const state = points + 1e-12 >= policy.requiredPoints ? 'success' : 'pending'
const state = points + 1e-12 >= policy.requiredPoints ? 'success' : 'action_required'
return approvalResult(
pull,
policy.requiredPoints,
@@ -305,13 +306,12 @@ export async function evaluateApproval({ event, policySource, api, getOwnership
}
/**
* Evaluate and publish the required commit status, publishing an error status when evaluation fails.
* @param {{event: unknown, policySource: string, api: (path: string, options?: {method?: string, body?: unknown}) => Promise<unknown>, runUrl: string, write?: (line: string) => void, getOwnership?: typeof productionOwnership, getMergedCount?: typeof countMergedAuthorPulls}} options Runtime inputs.
* Evaluate and publish the required check run, concluding it as failure when evaluation fails.
* @param {{event: unknown, policySource: string, api: (path: string, options?: {method?: string, body?: unknown}) => Promise<unknown>, runUrl: string, write?: (line: string) => void, record?: (conclusion: string) => void, getOwnership?: typeof productionOwnership, getMergedCount?: typeof countMergedAuthorPulls}} options Runtime inputs; record receives each conclusion the run publishes.
* @returns {Promise<Awaited<ReturnType<typeof evaluateApproval>>>} Published approval decision.
*/
export async function runApprovalCheck({ event, policySource, api, runUrl, getOwnership = productionOwnership, getMergedCount = countMergedAuthorPulls, write = line => process.stdout.write(`${line}\n`) }) {
export async function runApprovalCheck({ event, policySource, api, runUrl, getOwnership = productionOwnership, getMergedCount = countMergedAuthorPulls, write = line => process.stdout.write(`${line}\n`), record = () => {} }) {
const pull = pullRequestFromEvent(event)
await publishStatus(api, pull, 'pending', 'Evaluating approval points.', runUrl)
let result
try {
result = await evaluateApproval({ event, policySource, api, getOwnership, getMergedCount })
@@ -331,7 +331,8 @@ export async function runApprovalCheck({ event, policySource, api, runUrl, getOw
result = await evaluateApproval({ event, policySource, api, getOwnership, getMergedCount })
}
} catch (error) {
await publishStatus(api, pull, 'error', 'Approval evaluation failed.', runUrl)
await publishCheck(api, pull, 'failure', 'Approval evaluation failed.', runUrl)
record('failure')
throw error
}
write(result.authorCredit
@@ -342,8 +343,9 @@ export async function runApprovalCheck({ event, policySource, api, runUrl, getOw
`@${login}: ${points}${delegatedTo ? ` (delegated to @${delegatedTo})` : ''}${ownership ? ` (${ownership.ownedLines}/${ownership.totalLines} old production lines)` : ''}`))
writeList(write, 'Blocking change requests', result.blockers.map(login => `@${login}`))
writeList(write, 'Ignored reviewers without write access', result.ignoredReviewers.map(login => `@${login}`))
await publishStatus(api, pull, result.state, result.description, runUrl)
write(`Published ${JSON.stringify(STATUS_CONTEXT)} status ${JSON.stringify(result.state)}.`)
await publishCheck(api, pull, result.state, result.description, runUrl)
record(result.state)
write(`Published ${JSON.stringify(CHECK_NAME)} check run ${JSON.stringify(result.state)}.`)
try {
await requestDelegatedReviews(result, api, write)
} catch (error) {
@@ -353,14 +355,15 @@ export async function runApprovalCheck({ event, policySource, api, runUrl, getOw
}
/**
* Revoke a previous success before dependency installation, or report its failure.
* @param {{event: unknown, api: (path: string, options: object) => Promise<unknown>, runUrl: string, phase: string}} options Publication inputs.
* @returns {Promise<void>} Completion of the status write.
* Revoke a published success before dependency installation, or conclude a failed setup.
* @param {{event: unknown, api: (path: string, options?: {method?: string, body?: unknown}) => Promise<unknown>, runUrl: string, phase: string}} options Publication inputs.
* @returns {Promise<void>} Completion of the check-run write.
*/
export async function publishApprovalPhase({ event, api, runUrl, phase }) {
if (!['pending', 'error'].includes(phase)) throw new Error('invalid approval setup phase')
await publishStatus(api, pullRequestFromEvent(event), phase,
phase === 'pending' ? 'Preparing approval evaluation.' : 'Approval setup or evaluation failed.', runUrl)
const pull = pullRequestFromEvent(event)
if (phase === 'revoke') return revokeApproval(api, pull, runUrl)
if (phase === 'failure') return publishCheck(api, pull, 'failure', 'Approval setup or evaluation failed.', runUrl)
throw new Error('invalid approval setup phase')
}
/**
@@ -414,7 +417,7 @@ export async function approvalEventFromComment({ event, api }) {
}
/**
* Refresh a pull-request event before any status write; closed PRs and stale heads are skipped.
* Refresh a pull-request event before any check-run write; closed PRs and stale heads are skipped.
* @param {{event: unknown, api: (path: string) => Promise<unknown>}} options Pull-request event and API caller.
* @returns {Promise<Record<string, unknown> | null>} Live event, or null for a closed PR or superseded head.
*/
@@ -467,18 +470,60 @@ async function reviewerPermission(api, repository, login) {
return response.permission.toLowerCase()
}
async function publishStatus(api, pull, state, description, runUrl) {
// One decision per head keeps branch rules and CI watchers unambiguous; the head's existing GitHub
// Actions run is updated and a run is created only when none exists.
async function publishCheck(api, pull, conclusion, title, runUrl) {
requireHttpsRunUrl(runUrl)
const body = { status: 'completed', conclusion, details_url: runUrl, output: { title, summary: `${title} [Workflow run](${runUrl})` } }
const runs = await decisionRuns(api, pull)
if (runs.length === 0) {
await api(`/repos/${pull.repository}/check-runs`, { method: 'POST', body: { name: CHECK_NAME, head_sha: pull.headSha, ...body } })
return
}
for (const { id } of runs) {
if (!Number.isSafeInteger(id)) throw new Error('check run has no valid id')
await api(`/repos/${pull.repository}/check-runs/${id}`, { method: 'PATCH', body })
}
}
// A head without a decision starts in progress; a published success becomes action_required, so an
// interrupted evaluation cannot leave a success that its current inputs no longer support.
async function revokeApproval(api, pull, runUrl) {
requireHttpsRunUrl(runUrl)
const title = 'Preparing approval evaluation.'
const output = { title, summary: `${title} [Workflow run](${runUrl})` }
const runs = await decisionRuns(api, pull)
if (runs.length === 0) {
await api(`/repos/${pull.repository}/check-runs`, {
method: 'POST',
body: { name: CHECK_NAME, head_sha: pull.headSha, status: 'in_progress', details_url: runUrl, output },
})
return
}
for (const { id, conclusion } of runs) {
if (!Number.isSafeInteger(id)) throw new Error('check run has no valid id')
// Revoke every conclusion a required check accepts, not only the success this publisher writes:
// a workflow job added by the pull request can produce a same-named passing check run.
if (!PASSING_CONCLUSIONS.has(conclusion)) continue
await api(`/repos/${pull.repository}/check-runs/${id}`, {
method: 'PATCH',
body: { status: 'completed', conclusion: 'action_required', details_url: runUrl, output },
})
}
}
// A check run that reached completed never returns to in_progress: GitHub answers 200 and leaves the
// run completed, so a published decision can only be replaced by another conclusion.
async function decisionRuns(api, pull) {
const listed = await api(`/repos/${pull.repository}/commits/${pull.headSha}/check-runs?check_name=${encodeURIComponent(CHECK_NAME)}&filter=all&per_page=${PAGE_SIZE}`)
if (!isRecord(listed) || !Array.isArray(listed.check_runs) || listed.total_count !== listed.check_runs.length) {
throw new Error('check-run list response is invalid or incomplete')
}
return listed.check_runs.filter(run => isRecord(run) && isRecord(run.app) && run.app.slug === 'github-actions')
}
function requireHttpsRunUrl(runUrl) {
if (!/^https:\/\/[^\s]+$/u.test(runUrl)) throw new Error('workflow run URL must use HTTPS')
if (description.length > 140) throw new Error('commit status description exceeds 140 characters')
await api(`/repos/${pull.repository}/statuses/${pull.headSha}`, {
method: 'POST',
body: {
state,
context: STATUS_CONTEXT,
description,
target_url: runUrl,
},
})
}
function pullRequestFromEvent(event) {
@@ -571,7 +616,7 @@ async function main() {
const phase = process.argv[2]
if (phase) {
await publishApprovalPhase({ event, api, runUrl: process.env.GITHUB_RUN_URL ?? '', phase })
if (phase === 'pending' && process.env.GITHUB_OUTPUT) appendFileSync(process.env.GITHUB_OUTPUT, 'active=true\n')
if (phase === 'revoke' && process.env.GITHUB_OUTPUT) appendFileSync(process.env.GITHUB_OUTPUT, 'active=true\n')
return
}
await runApprovalCheck({
@@ -579,6 +624,9 @@ async function main() {
policySource,
api,
runUrl: process.env.GITHUB_RUN_URL ?? '',
record: (conclusion) => {
if (process.env.GITHUB_OUTPUT) appendFileSync(process.env.GITHUB_OUTPUT, `concluded=${conclusion}\n`)
},
})
}
+173 -86
View File
@@ -11,14 +11,20 @@ import {
evaluateApproval as evaluateWithHistory,
listPullRequestReviews,
parseApprovalPolicy,
runApprovalCheck as runWithHistory,
runApprovalCheck as runPublisher,
publishApprovalPhase,
} from './check-approval.mjs'
const policySource = readFileSync(new URL('approval-policy.json', import.meta.url), 'utf8')
const withoutComments = api => (path, options) => path.includes('/comments?') ? [] : api(path, options)
const evaluateApproval = options => evaluateWithHistory({ getMergedCount: async () => 0, ...options, api: withoutComments(options.api) })
const runApprovalCheck = options => runWithHistory({ getMergedCount: async () => 0, ...options, api: withoutComments(options.api) })
const emptyCheckRuns = { total_count: 0, check_runs: [] }
const isCheckRunsList = path => path.includes('/commits/') && path.includes('/check-runs?')
const withoutCheckRuns = api => (path, options) =>
isCheckRunsList(path) ? emptyCheckRuns : api(path, options)
const withEmptyReads = api => withoutCheckRuns((path, options) => path.includes('/comments?') ? [] : api(path, options))
const evaluateApproval = options => evaluateWithHistory({ getMergedCount: async () => 0, ...options, api: withEmptyReads(options.api) })
const runWithHistory = options => runPublisher({ ...options, api: withoutCheckRuns(options.api) })
const runApprovalCheck = options => runWithHistory({ getMergedCount: async () => 0, ...options, api: withEmptyReads(options.api) })
const publishedState = body => body.conclusion ?? body.status
const HEAD_SHA = '1234567890abcdef1234567890abcdef12345678'
const pullRequestEvent = ({ author = 'author', draft = false } = {}) => ({
@@ -198,7 +204,7 @@ test('accepts two one-point approvals and ignores reviews without write access',
assert.deepEqual(result.ignoredReviewers, ['reader'])
})
test('keeps one one-point approval pending without failing the status', async () => {
test('requires action for one one-point approval without failing the check', async () => {
const result = await evaluateApproval({
event: pullRequestEvent(),
policySource,
@@ -209,7 +215,7 @@ test('keeps one one-point approval pending without failing the status', async ()
throw new Error(`unexpected API path ${path}`)
},
})
assert.equal(result.state, 'pending')
assert.equal(result.state, 'action_required')
assert.equal(result.points, 1)
})
@@ -228,12 +234,12 @@ test('ignores a reviewer whose collaborator permission lookup returns 404', asyn
},
})
const result = await evaluateApproval({ event: pullRequestEvent(), policySource, api })
assert.equal(result.state, 'pending')
assert.equal(result.state, 'action_required')
assert.deepEqual(result.ignoredReviewers, ['former-writer'])
})
test('keeps the status pending on a write-capable change request while ignoring the author and read-only reviewers', async () => {
const statuses = []
test('requires action on a write-capable change request while ignoring the author and read-only reviewers', async () => {
const writes = []
const result = await runApprovalCheck({
event: pullRequestEvent({ author: 'author' }),
policySource,
@@ -251,45 +257,43 @@ test('keeps the status pending on a write-capable change request while ignoring
if (path.includes('/collaborators/turtle1999/permission')) return { permission: 'admin' }
if (path.includes('/collaborators/blocker/permission')) return { permission: 'write' }
if (path.includes('/collaborators/reader/permission')) return { permission: 'read' }
if (path.includes('/statuses/')) {
statuses.push(options.body)
if (isCheckRunsList(path)) return emptyCheckRuns
if (path.includes('/check-runs')) {
writes.push(options.body)
return {}
}
throw new Error(`unexpected API path ${path}`)
},
write: () => {},
})
assert.equal(result.state, 'pending')
assert.equal(result.state, 'action_required')
assert.equal(result.description, '1 blocking change request.')
assert.equal(result.points, 2)
assert.deepEqual(result.blockers, ['blocker'])
assert.deepEqual(result.ignoredReviewers, ['reader'])
assert.deepEqual(statuses, [{
state: 'pending',
context: 'weighted approval',
description: 'Evaluating approval points.',
target_url: 'https://github.example/actions/runs/1',
}, {
state: 'pending',
context: 'weighted approval',
description: '1 blocking change request.',
target_url: 'https://github.example/actions/runs/1',
assert.deepEqual(writes, [{
name: 'weighted approval decision',
head_sha: HEAD_SHA,
status: 'completed',
conclusion: 'action_required',
details_url: 'https://github.example/actions/runs/1',
output: { title: '1 blocking change request.', summary: '1 blocking change request. [Workflow run](https://github.example/actions/runs/1)' },
}])
})
test('keeps drafts pending without reading reviews', async () => {
test('requires action on drafts without reading reviews', async () => {
const result = await evaluateApproval({
event: pullRequestEvent({ draft: true }),
policySource,
getOwnership: async () => ({ totalLines: 0, reviewerLines: {} }),
api: async () => { throw new Error('draft evaluation must not call GitHub') },
})
assert.equal(result.state, 'pending')
assert.equal(result.state, 'action_required')
assert.equal(result.points, 0)
assert.match(result.description, /draft pull request/u)
})
test('publishes the required status and replaces stale success with error on evaluation failure', async () => {
test('publishes the decision check run and concludes failure when evaluation fails', async () => {
const calls = []
const output = []
const result = await runApprovalCheck({
@@ -301,21 +305,24 @@ test('publishes the required status and replaces stale success with error on eva
calls.push({ path, options })
if (path.includes('/reviews?')) return [review('turtle2099', 'APPROVED')]
if (path.includes('/collaborators/turtle2099/permission')) return { permission: 'write' }
if (path.includes('/statuses/')) return {}
if (isCheckRunsList(path)) return emptyCheckRuns
if (path.includes('/check-runs')) return {}
throw new Error(`unexpected API path ${path}`)
},
write: line => output.push(line),
})
assert.equal(result.state, 'success')
assert.deepEqual(calls.at(-1), {
path: `/repos/deepseek-harness/deepseek-harness/statuses/${HEAD_SHA}`,
path: '/repos/deepseek-harness/deepseek-harness/check-runs',
options: {
method: 'POST',
body: {
state: 'success',
context: 'weighted approval',
description: '2/2 approval points.',
target_url: 'https://github.example/actions/runs/1',
name: 'weighted approval decision',
head_sha: HEAD_SHA,
status: 'completed',
conclusion: 'success',
details_url: 'https://github.example/actions/runs/1',
output: { title: '2/2 approval points.', summary: '2/2 approval points. [Workflow run](https://github.example/actions/runs/1)' },
},
},
})
@@ -330,7 +337,8 @@ test('publishes the required status and replaces stale success with error on eva
runUrl: 'https://github.example/actions/runs/2',
api: async (path, options = {}) => {
if (path.includes('/reviews?')) throw new Error('reviews unavailable')
if (path.includes('/statuses/')) {
if (isCheckRunsList(path)) return emptyCheckRuns
if (path.includes('/check-runs')) {
failures.push({ path, options })
return {}
}
@@ -338,9 +346,10 @@ test('publishes the required status and replaces stale success with error on eva
},
write: () => {},
}), /reviews unavailable/u)
assert.equal(failures[0].options.body.state, 'pending')
assert.equal(failures[1].options.body.state, 'error')
assert.equal(failures[1].options.body.description, 'Approval evaluation failed.')
assert.deepEqual(failures.map(({ path }) => path), ['/repos/deepseek-harness/deepseek-harness/check-runs'])
assert.equal(failures[0].options.body.status, 'completed')
assert.equal(failures[0].options.body.conclusion, 'failure')
assert.equal(failures[0].options.body.output.title, 'Approval evaluation failed.')
})
test('sends authenticated JSON and escapes an API error body', async () => {
@@ -383,7 +392,7 @@ for (const [ownedLines, totalLines, expectedPoints] of [[9, 100, 1.3599999999999
})
assert.equal(result.points, expectedPoints)
assert.equal(result.description, `${Number(expectedPoints.toFixed(3))}/2 approval points (author 0).`)
assert.equal(result.state, expectedPoints === 2 ? 'success' : 'pending')
assert.equal(result.state, expectedPoints === 2 ? 'success' : 'action_required')
assert.equal(measurements, 1)
assert.deepEqual(result.approvals[0].ownership, { ownedLines, totalLines })
})
@@ -403,7 +412,7 @@ test('does not fetch ownership when a change request blocks approval', async ()
})
assert.equal(measurements, 0)
assert.equal(result.points, 2)
assert.equal(result.state, 'pending')
assert.equal(result.state, 'action_required')
})
test('does not fetch history when approvals already have two-point weights', async () => {
@@ -415,7 +424,7 @@ test('does not fetch history when approvals already have two-point weights', asy
assert.equal(result.points, 2)
})
test('publishes error when production attribution fails', async () => {
test('concludes failure when production attribution fails', async () => {
const states = []
await assert.rejects(runApprovalCheck({
event: pullRequestEvent(), policySource, runUrl: 'https://github.example/run/1',
@@ -424,30 +433,46 @@ test('publishes error when production attribution fails', async () => {
if (path.includes('/reviews?')) return [review('writer', 'APPROVED')]
if (path.includes('/permission')) return { permission: 'write' }
if (path.includes('/comments?')) return []
states.push(options.body.state)
if (isCheckRunsList(path)) return emptyCheckRuns
states.push(publishedState(options.body))
return {}
},
}), /incomplete history/u)
assert.deepEqual(states, ['pending', 'error'])
assert.deepEqual(states, ['failure'])
})
test('revokes a previous success before starting expensive attribution', async () => {
const states = []
await runApprovalCheck({
event: pullRequestEvent(), policySource, runUrl: 'https://github.example/run/1', write: () => {},
getOwnership: async () => {
assert.deepEqual(states, ['pending'])
return { totalLines: 100, reviewerLines: { writer: 25 } }
},
test('revokes a published success without returning a completed run to in progress', async () => {
const calls = []
const checkRuns = [
{ id: 7, app: { slug: 'github-actions' }, conclusion: 'success' },
{ id: 8, app: { slug: 'github-actions' }, conclusion: 'action_required' },
{ id: 9, app: { slug: 'other-app' }, conclusion: 'success' },
{ id: 10, app: { slug: 'github-actions' }, conclusion: 'skipped' },
{ id: 11, app: { slug: 'github-actions' }, conclusion: 'neutral' },
{ id: 12, app: { slug: 'github-actions' }, conclusion: null },
]
await publishApprovalPhase({
event: pullRequestEvent(), runUrl: 'https://github.example/run/1', phase: 'revoke',
api: async (path, options) => {
if (path.includes('/reviews?')) return [review('writer', 'APPROVED')]
if (path.includes('/permission')) return { permission: 'write' }
states.push(options.body.state)
return {}
calls.push({ path, method: options?.method, body: options?.body })
return isCheckRunsList(path) ? { total_count: checkRuns.length, check_runs: checkRuns } : {}
},
})
assert.deepEqual(states, ['pending', 'success'])
assert.deepEqual(calls.map(({ path }) => path), [
`/repos/deepseek-harness/deepseek-harness/commits/${HEAD_SHA}/check-runs?check_name=weighted%20approval%20decision&filter=all&per_page=100`,
'/repos/deepseek-harness/deepseek-harness/check-runs/7',
'/repos/deepseek-harness/deepseek-harness/check-runs/10',
'/repos/deepseek-harness/deepseek-harness/check-runs/11',
])
for (const call of calls.slice(1)) {
assert.deepEqual(call.body, {
status: 'completed',
conclusion: 'action_required',
details_url: 'https://github.example/run/1',
output: { title: 'Preparing approval evaluation.', summary: 'Preparing approval evaluation. [Workflow run](https://github.example/run/1)' },
})
}
})
for (const reviewers of [['first', 'second'], ['turtle1999', 'first']]) {
@@ -477,14 +502,67 @@ test('publishes setup phases without evaluating or installing dependencies', asy
const states = []
const options = {
event: pullRequestEvent(), runUrl: 'https://github.example/run/1',
api: async (path, { body }) => { assert.match(path, /\/statuses\//u); states.push(body.state) },
api: withEmptyReads(async (path, { body }) => { assert.match(path, /\/check-runs$/u); states.push(publishedState(body)) }),
}
await publishApprovalPhase({ ...options, phase: 'pending' })
await publishApprovalPhase({ ...options, phase: 'error' })
assert.deepEqual(states, ['pending', 'error'])
await publishApprovalPhase({ ...options, phase: 'revoke' })
await publishApprovalPhase({ ...options, phase: 'failure' })
assert.deepEqual(states, ['in_progress', 'failure'])
await assert.rejects(publishApprovalPhase({ ...options, phase: 'success' }), /invalid approval setup phase/u)
})
test('updates the head\'s GitHub Actions decision check run instead of creating another', async () => {
const calls = []
const checkRuns = [
{ id: 7, app: { slug: 'github-actions' }, conclusion: 'action_required' },
{ id: 8, app: { slug: 'other-app' }, conclusion: 'action_required' },
]
await publishApprovalPhase({
event: pullRequestEvent(), runUrl: 'https://github.example/run/1', phase: 'failure',
api: async (path, options) => {
calls.push({ path, method: options?.method })
return { total_count: checkRuns.length, check_runs: checkRuns }
},
})
assert.deepEqual(calls, [
{ path: `/repos/deepseek-harness/deepseek-harness/commits/${HEAD_SHA}/check-runs?check_name=weighted%20approval%20decision&filter=all&per_page=100`, method: undefined },
{ path: '/repos/deepseek-harness/deepseek-harness/check-runs/7', method: 'PATCH' },
])
for (const response of [{}, { total_count: 101, check_runs: [] }, { total_count: 1, check_runs: [{ id: '7', app: { slug: 'github-actions' } }] }]) {
await assert.rejects(publishApprovalPhase({
event: pullRequestEvent(), runUrl: 'https://github.example/run/1', phase: 'failure', api: async () => response,
}), /check-run list response|valid id/u)
}
})
test('records each published conclusion so the failure step never replaces it', async () => {
const recorded = []
const api = async (path, options) => {
if (isCheckRunsList(path)) return emptyCheckRuns
if (path.includes('/reviews?')) return [review('turtle2099', 'APPROVED')]
if (path.includes('/permission')) return { permission: 'write' }
if (path.includes('/comments?')) return []
assert.match(path, /\/check-runs$/u)
return {}
}
await runApprovalCheck({
event: pullRequestEvent(), policySource, runUrl: 'https://github.example/run/1', api, write: () => {},
record: conclusion => recorded.push(conclusion),
})
await assert.rejects(runApprovalCheck({
event: pullRequestEvent(), policySource, runUrl: 'https://github.example/run/2', write: () => {},
getMergedCount: async () => { throw new Error('history unavailable') },
record: conclusion => recorded.push(conclusion),
api: async path => {
if (isCheckRunsList(path)) return emptyCheckRuns
if (path.includes('/reviews?')) return []
if (path.includes('/comments?')) return []
return {}
},
}), /history unavailable/u)
assert.deepEqual(recorded, ['success', 'failure'])
})
for (const [mergedCount, credit] of [[0, 0], [1, 0.011], [50, 0.55], [99, 1.089], [100, 1.1], [101, 1.1], [200, 1.1]]) {
test(`author with ${mergedCount} merged PRs contributes ${credit} points but cannot approve alone`, async () => {
const result = await evaluateApproval({
@@ -493,7 +571,7 @@ for (const [mergedCount, credit] of [[0, 0], [1, 0.011], [50, 0.55], [99, 1.089]
})
assert.equal(result.points, credit)
assert.equal(result.authorCredit.points, credit)
assert.equal(result.state, 'pending')
assert.equal(result.state, 'action_required')
assert.deepEqual(result.approvals, [])
})
}
@@ -509,7 +587,7 @@ test('combines author credit and reviewer ownership at the passing threshold', a
}
})
for (const [mergedCount, state] of [[90, 'pending'], [91, 'success'], [100, 'success']]) {
for (const [mergedCount, state] of [[90, 'action_required'], [91, 'success'], [100, 'success']]) {
test(`one ordinary approval with ${mergedCount} merged PRs is ${state}`, async () => {
const result = await evaluateApproval({
event: pullRequestEvent(), policySource, getMergedCount: async () => mergedCount,
@@ -532,14 +610,14 @@ test('blockers skip history and ignore non-write reviewers', async () => {
? [review('writer', 'APPROVED'), review('blocker', 'CHANGES_REQUESTED'), review('reader', 'APPROVED')]
: { permission: path.includes('/reader/') ? 'read' : 'write' },
})
assert.equal(result.state, 'pending')
assert.equal(result.state, 'action_required')
assert.equal(result.points, 1)
assert.equal(result.authorCredit, null)
assert.deepEqual(result.blockers, ['blocker'])
assert.deepEqual(result.ignoredReviewers, ['reader'])
})
test('drafts skip history and history failures revoke success with error', async () => {
test('drafts skip history and a failed history lookup concludes the check as failure', async () => {
await evaluateApproval({
event: pullRequestEvent({ draft: true }), policySource,
getMergedCount: async () => { throw new Error('draft must skip history') },
@@ -550,20 +628,20 @@ test('drafts skip history and history failures revoke success with error', async
getMergedCount: async () => { throw new Error('history unavailable') },
api: async (path, options) => {
if (path.includes('/reviews?')) return []
states.push(options.body.state)
states.push(publishedState(options.body))
},
}), /history unavailable/u)
assert.deepEqual(states, ['pending', 'error'])
assert.deepEqual(states, ['failure'])
})
test('a score just below the threshold remains pending even if its display rounds to two', async () => {
test('a score just below the threshold requires action even if its display rounds to two', async () => {
const result = await evaluateApproval({
event: pullRequestEvent(), policySource, getMergedCount: async () => 50,
getOwnership: async () => ({ totalLines: 1000000, reviewerLines: { writer: 112499 } }),
api: async path => path.includes('/reviews?') ? [review('writer', 'APPROVED')] : { permission: 'write' },
})
assert.equal(result.state, 'pending')
assert.equal(result.state, 'action_required')
assert.ok(result.points < 2)
})
@@ -587,14 +665,14 @@ test('the publisher counts merged history through the production API path', asyn
}
if (path.includes('/reviews?')) return [review('writer', 'APPROVED')]
if (path.includes('/permission')) return { permission: 'write' }
states.push(options.body.state)
states.push(publishedState(options.body))
return {}
},
})
assert.equal(result.points, 2)
assert.equal(result.authorCredit.points, 0.55)
assert.equal(result.approvals[0].points, 1.45)
assert.deepEqual(states, ['pending', 'success'])
assert.deepEqual(states, ['success'])
assert.equal(output[0], 'Author credit: 0.55 (50 merged PRs).')
})
@@ -612,7 +690,7 @@ test('sufficient reviewer points and drafts publish without querying author hist
return {}
},
})
assert.equal(result.state, draft ? 'pending' : 'success')
assert.equal(result.state, draft ? 'action_required' : 'success')
assert.equal(result.authorCredit, null)
assert.equal(output[0], `Author credit: not evaluated (${draft ? 'draft' : 'reviewer points suffice'}).`)
}
@@ -678,7 +756,7 @@ test('delegates the sender fixed points once after the recipient approves, retai
}) })
const points = recipientState === 'APPROVED' ? 3 : 0
assert.equal(result.points, points)
assert.equal(result.state, points === 3 && senderState !== 'CHANGES_REQUESTED' ? 'success' : 'pending')
assert.equal(result.state, points === 3 && senderState !== 'CHANGES_REQUESTED' ? 'success' : 'action_required')
assert.deepEqual(result.blockers, [
...(senderState === 'CHANGES_REQUESTED' ? ['turtle1999'] : []),
...(recipientState === 'CHANGES_REQUESTED' ? ['writer'] : []),
@@ -786,7 +864,7 @@ test('a sender submitted review takes back delegation, including comment-only an
}) })
assert.equal(result.delegations.length, state === 'PENDING' ? 1 : 0)
assert.equal(result.points, state === 'APPROVED' || state === 'PENDING' ? 3 : 1)
assert.equal(result.state, state === 'APPROVED' || state === 'PENDING' ? 'success' : 'pending')
assert.equal(result.state, state === 'APPROVED' || state === 'PENDING' ? 'success' : 'action_required')
if (state === 'APPROVED') assert.equal(result.approvals.find(({ login }) => login === 'Turtle1999').delegatedTo, undefined)
}
}
@@ -829,7 +907,8 @@ test('an active delegate command requests review once and preserves other reques
if (options?.method === 'POST') { requests.push(options.body); return {} }
return { users: [{ login: 'another-reviewer' }, ...(alreadyRequested ? [{ login: 'WRITER' }] : [])], teams: [] }
}
if (path.includes('/statuses/')) return {}
if (isCheckRunsList(path)) return emptyCheckRuns
if (path.includes('/check-runs')) return {}
return api(path, options)
},
})
@@ -857,7 +936,8 @@ test('inactive commands and drafts do not request review', async () => {
policySource, runUrl: 'https://github.example/run/1', getMergedCount: async () => 0, write: () => {},
api: async (path, options) => {
assert.ok(!path.endsWith('/requested_reviewers'))
if (path.includes('/statuses/')) return {}
if (isCheckRunsList(path)) return emptyCheckRuns
if (path.includes('/check-runs')) return {}
return api(path, options)
},
})
@@ -873,7 +953,8 @@ test('failed review requests preserve the computed score and report the retry',
event: { ...pullRequestEvent(), issue: { number: 42, pull_request: {} }, action: 'created', comment: { id: 1 } },
policySource, runUrl: 'https://github.example/run/1', getMergedCount: async () => 0, getOwnership: async () => ({ totalLines: 0, reviewerLines: {} }), write: line => output.push(line),
api: async (path, options) => {
if (path.includes('/statuses/')) { statuses.push(options.body.state); return {} }
if (isCheckRunsList(path)) return emptyCheckRuns
if (path.includes('/check-runs')) { statuses.push(publishedState(options.body)); return {} }
if (path.endsWith('/requested_reviewers')) {
if (options?.method === 'POST') throw new Error('request rejected')
return failedRead ? {} : { users: [] }
@@ -883,7 +964,7 @@ test('failed review requests preserve the computed score and report the retry',
})
assert.match(output.at(-1), failedRead ? /no users array/u : /request rejected/u)
assert.match(output.at(-1), /score is unchanged.*will retry/u)
assert.deepEqual(statuses, ['pending', 'success'])
assert.deepEqual(statuses, ['success'])
}
})
@@ -895,7 +976,8 @@ test('delegation dismisses only the sender old decisions and stays active across
const states = []
const read = delegationApi({ comments: [comment('turtle1999', '/delegate @writer')], reviews })
const api = async (path, options) => {
if (path.includes('/statuses/')) { states.push(options.body.state); return {} }
if (isCheckRunsList(path)) return emptyCheckRuns
if (path.includes('/check-runs')) { states.push(publishedState(options.body)); return {} }
if (path.endsWith('/dismissals')) {
calls.push(options)
const id = Number(path.split('/').at(-2))
@@ -919,13 +1001,13 @@ test('delegation dismisses only the sender old decisions and stays active across
assert.deepEqual(result.blockers, ['other'])
assert.equal(result.delegations.length, 1)
assert.deepEqual(result.delegations[0].reviewIds, [])
assert.deepEqual(states, ['pending', 'pending'])
assert.deepEqual(states, ['action_required'])
reviews.find(review => review.id === 21).state = 'DISMISSED'
for (const state of ['APPROVED', 'DISMISSED', 'COMMENTED', 'APPROVED']) {
reviews.find(review => review.id === 20).state = state
const next = await evaluateDelegation({ api })
assert.equal(next.delegations.length, 1)
assert.equal(next.state, state === 'APPROVED' ? 'success' : 'pending')
assert.equal(next.state, state === 'APPROVED' ? 'success' : 'action_required')
}
reviews.push(review('turtle1999', 'COMMENTED', '2026-09-16T00:00:00Z', 30))
const reclaimed = await evaluateDelegation({ api })
@@ -939,7 +1021,8 @@ test('a new sender review arriving during dismissal is not dismissed or delegate
const result = await runWithHistory({ event: { ...pullRequestEvent(), issue: { number: 42, pull_request: {} }, action: 'created', comment: { id: 1 } },
policySource, runUrl: 'https://github.example/run/1', write: () => {}, getMergedCount: async () => 0,
api: async (path, options) => {
if (path.includes('/statuses/')) return {}
if (isCheckRunsList(path)) return emptyCheckRuns
if (path.includes('/check-runs')) return {}
if (path.endsWith('/dismissals')) {
assert.ok(path.endsWith('/reviews/10/dismissals'))
reviews[0].state = 'DISMISSED'
@@ -954,14 +1037,15 @@ test('a new sender review arriving during dismissal is not dismissed or delegate
assert.deepEqual(result.blockers, ['turtle1999'])
})
test('a failed or unconfirmed dismissal prevents review requests and publishes error', async () => {
test('a failed or unconfirmed dismissal prevents review requests and concludes failure', async () => {
for (const rejected of [false, true]) {
const states = []
const read = delegationApi({ comments: [comment('turtle1999', '/delegate @writer')], reviews: [review('turtle1999', 'CHANGES_REQUESTED')] })
await assert.rejects(runWithHistory({ event: { ...pullRequestEvent(), issue: { number: 42, pull_request: {} }, action: 'created', comment: { id: 1 } },
policySource, runUrl: 'https://github.example/run/1', write: () => {},
api: async (path, options) => {
if (path.includes('/statuses/')) { states.push(options.body.state); return {} }
if (isCheckRunsList(path)) return emptyCheckRuns
if (path.includes('/check-runs')) { states.push(publishedState(options.body)); return {} }
if (path.endsWith('/dismissals')) {
if (rejected) throw new Error('dismissal denied')
return { id: 10, state: 'CHANGES_REQUESTED' }
@@ -970,7 +1054,7 @@ test('a failed or unconfirmed dismissal prevents review requests and publishes e
return read(path, options)
},
}), rejected ? /dismissal denied/u : /dismissal was not confirmed/u)
assert.deepEqual(states, ['pending', 'error'])
assert.deepEqual(states, ['failure'])
}
})
@@ -983,7 +1067,8 @@ test('reads all comment pages, logs the score owner, and fails closed on missing
event: pullRequestEvent(), policySource, runUrl: 'https://github.example/run/1',
getMergedCount: async () => 0, write: line => output.push(line),
api: async (path, options) => {
if (path.includes('/statuses/')) { statuses.push(options.body.state); return {} }
if (isCheckRunsList(path)) return emptyCheckRuns
if (path.includes('/check-runs')) { statuses.push(publishedState(options.body)); return {} }
if (path.includes('/comments?')) return comments(path)
return api(path, options)
},
@@ -996,7 +1081,7 @@ test('reads all comment pages, logs the score owner, and fails closed on missing
})
assert.equal(pages.length, 2)
assert.ok(output.includes('- @turtle1999: 2 (delegated to @writer)'))
assert.deepEqual(statuses.splice(0), ['pending', 'success'])
assert.deepEqual(statuses.splice(0), ['success'])
for (const [comments, message] of [
[() => { throw new Error('comment API unavailable') }, /comment API unavailable/u],
[() => ({}), /comments response is not an array/u],
@@ -1006,7 +1091,7 @@ test('reads all comment pages, logs the score owner, and fails closed on missing
[() => Array.from({ length: 100 }, () => comment('writer', 'text')), /comments exceed 3000/u],
]) {
await assert.rejects(run(comments), message)
assert.deepEqual(statuses.splice(0), ['pending', 'error'])
assert.deepEqual(statuses.splice(0), ['failure'])
}
})
@@ -1090,7 +1175,8 @@ test('later events reconcile all active delegations after a draft or replaced co
const effects = []
const requested = []
const api = async (path, options) => {
if (path.includes('/statuses/')) return {}
if (isCheckRunsList(path)) return emptyCheckRuns
if (path.includes('/check-runs')) return {}
if (path.endsWith('/dismissals')) {
const old = reviews.find(review => path.endsWith(`/reviews/${review.id}/dismissals`))
effects.push(old.id)
@@ -1122,7 +1208,8 @@ test('already approved delegates need no new request', async () => {
})
const result = await runWithHistory({ event: pullRequestEvent(), policySource, runUrl: 'https://github.example/run/1', write: () => {},
api: (path, options) => {
if (path.includes('/statuses/')) return {}
if (isCheckRunsList(path)) return emptyCheckRuns
if (path.includes('/check-runs')) return {}
assert.ok(!path.endsWith('/requested_reviewers'))
return read(path, options)
},
+12 -8
View File
@@ -10,9 +10,9 @@ on:
types: [completed]
permissions:
checks: write
contents: read
pull-requests: write
statuses: write
concurrency:
group: weighted-approval-${{ (github.event.pull_request.number || github.event.issue.number) && format('weighted-approval-review-event:{0}', github.event.pull_request.number || github.event.issue.number) || github.event.workflow_run.display_title }}
@@ -29,20 +29,20 @@ jobs:
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
# SECURITY: the status-writing job executes policy from the trusted default
# SECURITY: the check-writing job executes policy from the trusted default
# branch and reads pull-request reviews and comments only as API data.
- name: Check out trusted approval policy
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
with:
ref: ${{ github.event.repository.default_branch }}
persist-credentials: false
- name: Revoke previous approval status
- name: Revoke previous approval decision
id: revoke
env:
GITHUB_TOKEN: ${{ github.token }}
GITHUB_RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
run: node .github/review-ownership/check-approval.mjs pending
# SECURITY: dependency setup runs in the status-writing job.
run: node .github/review-ownership/check-approval.mjs revoke
# SECURITY: dependency setup runs in the check-writing job.
- if: steps.revoke.outputs.active == 'true'
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0
with:
@@ -52,15 +52,19 @@ jobs:
- name: Install production lexer
if: steps.revoke.outputs.active == 'true'
run: python3 -m pip install -r .github/review-ownership/requirements.txt
- name: Publish weighted approval status
- name: Publish weighted approval decision
id: publish
if: steps.revoke.outputs.active == 'true'
env:
GITHUB_TOKEN: ${{ github.token }}
GITHUB_RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
run: node .github/review-ownership/check-approval.mjs
# A failed or cancelled run must not leave an earlier conclusion standing. The publish step
# records the conclusion it wrote, so a decision already published survives a later failure or
# cancellation, and a cancelled publish step that wrote nothing still concludes failure.
- name: Publish approval setup failure
if: failure() && steps.revoke.outputs.active == 'true'
if: (failure() || cancelled()) && steps.publish.outputs.concluded == ''
env:
GITHUB_TOKEN: ${{ github.token }}
GITHUB_RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
run: node .github/review-ownership/check-approval.mjs error
run: node .github/review-ownership/check-approval.mjs failure
+3
View File
@@ -4,6 +4,9 @@
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="manifest" href="/manifest.webmanifest" />
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
<meta name="mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-title" content="DSH" />
<link rel="icon" type="image/svg+xml" href="/favicon-dark.svg" media="(prefers-color-scheme: dark)" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" media="(prefers-color-scheme: light)" />
<title>DSH Local Build</title>
+3 -1
View File
@@ -26,7 +26,8 @@
"dev": "vite",
"watch": "vite build --watch --no-emptyOutDir",
"build:preview": "pnpm --filter @deepseek-ai/dsh-experimental-webworker-runtime exec tsdown && pnpm --filter @deepseek-ai/dsh-experimental-webworker-packer exec tsdown && vite build && dsh-pack-vfs-image --out dist/preview/vfs-image.tar.gz",
"serve:preview": "http-server dist -a 0.0.0.0 -p 4173 -c-1"
"serve:preview": "http-server dist -a 0.0.0.0 -p 4173 -c-1",
"render:app-icons": "tsx scripts/render-app-icons.ts"
},
"license": "MIT",
"devDependencies": {
@@ -56,6 +57,7 @@
"playwright": "^1.49.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"sharp": "0.35.5",
"typescript": "^6.0.3",
"vite": "^6.4.3",
"vitest": "^4.1.11",
Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 65 KiB

+18 -4
View File
@@ -3,13 +3,27 @@
"short_name": "DSH",
"start_url": "./",
"scope": "./",
"display": "fullscreen",
"display": "standalone",
"background_color": "#ffffff",
"theme_color": "#ffffff",
"icons": [
{
"src": "favicon.svg",
"sizes": "any",
"type": "image/svg+xml",
"src": "icon-192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "any"
},
{
"src": "icon-512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "any"
},
{
"src": "icon-maskable-512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "maskable"
}
]
}
+189
View File
@@ -0,0 +1,189 @@
/**
* Render the install icons of the Web application from the official
* application tile.
*
* A browser installs a Web App only with a raster 192px and a 512px icon, masks
* the icon it puts on the home screen, and has no manifest support for iOS,
* which reads `apple-touch-icon` instead. The whale favicon is the bare mark
* drawn edge to edge: handed to a launcher directly it loses its fins to the
* mask, and it carries neither the tile nor a background of its own. This
* script rasterizes the official application tile (`apps/desktop/resources/icon.svg`,
* the artwork the desktop application ships) into the committed PNGs under
* `public/`. Every variant crops the tile's drop shadow, which belongs to a
* desktop window rather than a launcher; the maskable variant additionally
* scales the whale inside the safe zone a platform mask may crop, leaving the
* tile's own gradient to fill the icon so no mask meets a seam.
*
* The committed PNGs are the output; rerun
* `pnpm --filter @deepseek-ai/dsh-web-frontend run render:app-icons` after the
* tile artwork changes.
* @module render-app-icons
*/
import { readFile, writeFile } from 'node:fs/promises'
import { resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
import { assertNever } from '@deepseek-ai/dsh-util-values'
/** Vector source owned by the desktop application; 1104 units square with the tile inset. */
export const TILE_SOURCE = fileURLToPath(new URL('../../desktop/resources/icon.svg', import.meta.url))
/** Directory the generated PNGs are committed to. */
export const ICON_OUTPUT_DIRECTORY = fileURLToPath(new URL('../public/', import.meta.url))
/** Tile edge in source units; every render density is expressed against it. */
const TILE_EDGE = 1024
/** Tile inset in the source canvas: the drop shadow occupies the surrounding band. */
const TILE_INSET = { x: 40, y: 32 } as const
/** Tile centre in source units, the fixed point of the maskable scale. */
const TILE_CENTER = { x: TILE_INSET.x + TILE_EDGE / 2, y: TILE_INSET.y + TILE_EDGE / 2 } as const
/** Density at which the source's own 1:1 scale renders. */
const SOURCE_DENSITY = 72
/** Samples per output pixel along each axis before reduction. */
const RENDER_SCALE = 4
/**
* Fraction of a maskable icon the artwork keeps: the platform may crop the
* border outside it. The tile's own ink reaches 0.461 of its edge from the
* centre, so 0.868 is the largest scale a circular mask still shows whole;
* `pwa-manifest.e2e.ts` checks the committed artwork against that circle.
*/
export const MASKABLE_ARTWORK_SCALE = 0.82
/** Radius a platform mask is guaranteed to show, as a fraction of the icon edge. */
export const MASKABLE_SAFE_RADIUS_RATIO = 0.4
/** Root element of the source document, rewritten to crop the canvas to the tile. */
const SOURCE_ROOT = /^<svg[^>]*>/u
/** The tile group's drop shadow; stripped because a launcher icon has no window around it. */
const TILE_SHADOW_GROUP = '<g filter="url(#filter0_d_1637_371)">'
/** The whale group inside the tile; the only element a maskable icon scales. */
const TILE_ARTWORK_GROUP = '<g filter="url(#filter1_ddi_1637_371)">'
/** One committed install icon: the manifest or link member it serves and its edge in pixels. */
export interface AppIconVariant {
/** File name inside `public/`. */
readonly file: string
/** Square edge in pixels. */
readonly size: number
/** Which rendering the platform receives. */
readonly kind: 'tile' | 'maskable'
}
/**
* Install icons the Web application ships. Chrome requires the 192px and 512px
* raster pair to offer installation, prefers a maskable icon for the home
* screen, and iOS reads the touch icon alone.
*/
export const APP_ICON_VARIANTS: readonly AppIconVariant[] = [
{ file: 'icon-192.png', size: 192, kind: 'tile' },
{ file: 'icon-512.png', size: 512, kind: 'tile' },
{ file: 'icon-maskable-512.png', size: 512, kind: 'maskable' },
{ file: 'apple-touch-icon.png', size: 180, kind: 'tile' },
]
/** The cropped 1:1 root every variant shares, at the tile's own square extent. */
function croppedRoot(): string {
return `<svg width="${String(TILE_EDGE)}" height="${String(TILE_EDGE)}"`
+ ` viewBox="${String(TILE_INSET.x)} ${String(TILE_INSET.y)} ${String(TILE_EDGE)} ${String(TILE_EDGE)}"`
+ ' fill="none" xmlns="http://www.w3.org/2000/svg">'
}
/** A scale about the tile centre, so a masked crop stays symmetric. */
function aboutCenter(scale: number): string {
return `translate(${String(TILE_CENTER.x)} ${String(TILE_CENTER.y)}) scale(${String(scale)})`
+ ` translate(${String(-TILE_CENTER.x)} ${String(-TILE_CENTER.y)})`
}
/** Replace the source root so the icon canvas is exactly the tile. */
function reparent(source: string): string {
const trimmed = source.trim()
const root = SOURCE_ROOT.exec(trimmed)
if (root === null) throw new Error('app icons: the tile source has no root element')
if (!trimmed.includes(TILE_SHADOW_GROUP)) throw new Error('app icons: the tile source has no shadowed tile group')
if (!trimmed.includes(TILE_ARTWORK_GROUP)) throw new Error('app icons: the tile source has no artwork group')
return `${croppedRoot()}${trimmed.slice(root[0].length).replace(TILE_SHADOW_GROUP, '<g>')}`
}
/**
* Rewrite the tile source so the icon canvas is exactly the tile.
* @param source - complete `icon.svg` document.
* @returns an SVG document whose 1024-unit square is the tile without its shadow.
*/
export function tileDocument(source: string): string {
return reparent(source)
}
/**
* Rewrite the tile source into a maskable icon. The tile keeps its own gradient
* and extent, so the icon's border is the real tile rather than an extension;
* only the whale scales into the safe zone a platform mask may crop.
* @param source - complete `icon.svg` document.
* @returns an SVG document whose artwork survives a circular mask whole.
*/
export function maskableDocument(source: string): string {
const relocated = reparent(source)
return relocated.replace(
TILE_ARTWORK_GROUP,
`${TILE_ARTWORK_GROUP.slice(0, -1)} transform="${aboutCenter(MASKABLE_ARTWORK_SCALE)}">`,
)
}
/**
* Rasterize one rewritten document at a square edge.
* @param document - output of {@link tileDocument} or {@link maskableDocument}.
* @param size - square edge in pixels.
* @returns the PNG bytes.
*/
async function renderPng(document: string, size: number): Promise<Buffer> {
const { default: sharp } = await import('sharp')
const renderSize = Math.max(TILE_EDGE, size * RENDER_SCALE)
// A separate raster preserves the source's filters at full resolution and
// prevents SVG resize optimizations from bypassing the downsampling step.
const raster = await sharp(Buffer.from(document), { density: SOURCE_DENSITY * renderSize / TILE_EDGE })
.png()
.toBuffer()
return sharp(raster)
.resize(size, size, { kernel: 'lanczos3' })
.png()
.toBuffer()
}
function documentForVariant(source: string, kind: AppIconVariant['kind']): string {
switch (kind) {
case 'tile': return tileDocument(source)
case 'maskable': return maskableDocument(source)
default: return assertNever(kind)
}
}
/**
* Render every install icon from the tile source.
* @param source - complete `icon.svg` document.
* @returns one PNG per {@link APP_ICON_VARIANTS} entry, in declaration order.
*/
export async function renderAppIcons(source: string): Promise<readonly { variant: AppIconVariant; png: Buffer }[]> {
return Promise.all(APP_ICON_VARIANTS.map(async variant => ({
variant,
png: await renderPng(
documentForVariant(source, variant.kind),
variant.size,
),
})))
}
async function main(): Promise<void> {
const icons = await renderAppIcons(await readFile(TILE_SOURCE, 'utf8'))
await Promise.all(icons.map(async ({ variant, png }) => {
await writeFile(resolve(ICON_OUTPUT_DIRECTORY, variant.file), png)
}))
console.info(`app icons: wrote ${icons.map(icon => icon.variant.file).join(', ')} to ${ICON_OUTPUT_DIRECTORY}`)
}
if (process.argv[1] !== undefined && import.meta.filename === resolve(process.argv[1])) await main()
+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 })
+43
View File
@@ -0,0 +1,43 @@
/** Committed install icon dimensions and the generator's SVG crop and safe-zone scale. */
import { readFile } from 'node:fs/promises'
import { join } from 'node:path'
import { describe, expect, it } from 'vitest'
import {
APP_ICON_VARIANTS, ICON_OUTPUT_DIRECTORY, MASKABLE_ARTWORK_SCALE, TILE_SOURCE,
maskableDocument, tileDocument,
} from '../scripts/render-app-icons.ts'
import { pngSize } from './png-size.ts'
describe('web install icons', () => {
it('ships every declared variant at its declared edge', async () => {
for (const variant of APP_ICON_VARIANTS) {
expect(pngSize(await readFile(join(ICON_OUTPUT_DIRECTORY, variant.file))))
.toEqual([variant.size, variant.size])
}
})
it('crops the desktop shadow and scales only the maskable artwork', async () => {
const source = await readFile(TILE_SOURCE, 'utf8')
const tile = tileDocument(source)
const maskable = maskableDocument(source)
expect(tile).toContain('viewBox="40 32 1024 1024"')
expect(tile).not.toContain('<g filter="url(#filter0_d_1637_371)">')
expect(tile).not.toContain(`scale(${MASKABLE_ARTWORK_SCALE})`)
expect(tile).toContain('<g filter="url(#filter1_ddi_1637_371)">')
expect(tile).toContain(source.match(/<path\b[^>]*>/u)![0])
expect(tile).toContain(source.match(/<defs>[\s\S]*<\/defs>/u)![0])
expect(maskable).toContain(`transform="translate(552 544) scale(${MASKABLE_ARTWORK_SCALE}) translate(-552 -544)"`)
})
it('rejects non-PNG bytes and incomplete image headers', () => {
expect(() => pngSize(Buffer.from('<svg/>'))).toThrow(/PNG IHDR/)
expect(() => pngSize(Buffer.alloc(33))).toThrow(/PNG IHDR/)
})
it('fails loudly on a source without the tile it transforms', () => {
expect(() => tileDocument('')).toThrow(/no root element/)
expect(() => tileDocument('<svg></svg>')).toThrow(/no shadowed tile group/)
expect(() => maskableDocument('<svg><g filter="url(#filter0_d_1637_371)"></g></svg>'))
.toThrow(/no artwork group/)
})
})
+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
+13
View File
@@ -0,0 +1,13 @@
/** PNG header inspection without a native image decoder. */
const PNG_IHDR = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10, 0, 0, 0, 13, 73, 72, 68, 82])
/**
* Read the dimensions of a PNG with its mandatory first IHDR chunk.
* @param png - encoded image bytes.
* @returns width and height in pixels.
* @throws when the PNG signature or IHDR chunk is missing.
*/
export function pngSize(png: Buffer): readonly [number, number] {
if (png.length < 33 || !png.subarray(0, PNG_IHDR.length).equals(PNG_IHDR)) throw new Error('Expected a PNG IHDR chunk')
return [png.readUInt32BE(16), png.readUInt32BE(20)]
}
+144 -7
View File
@@ -2,12 +2,60 @@ import { readFile } from 'node:fs/promises'
import { fileURLToPath } from 'node:url'
import { join } from 'node:path'
import { expect, it } from 'vitest'
import { chromium, type Page } from 'playwright'
import {
APP_ICON_VARIANTS, ICON_OUTPUT_DIRECTORY, MASKABLE_ARTWORK_SCALE, MASKABLE_SAFE_RADIUS_RATIO,
TILE_SOURCE, renderAppIcons, tileDocument,
} from '../scripts/render-app-icons.ts'
const DIST_ROOT = fileURLToPath(new URL('../dist', import.meta.url))
async function pixels(page: Page, url: string, size: number) {
return page.evaluate(async ({ url, size }) => {
const image = new Image()
image.src = url
await image.decode()
const canvas = document.createElement('canvas')
canvas.width = canvas.height = size
const context = canvas.getContext('2d')!
context.drawImage(image, 0, 0, size, size)
const { data } = context.getImageData(0, 0, size, size)
let radius = 0
let ink = 0
for (let y = 0; y < size; y += 1) {
for (let x = 0; x < size; x += 1) {
const at = (y * size + x) * 4
if (data[at + 3] === 0 || data[at]! + data[at + 1]! + data[at + 2]! >= 384) continue
ink += 1
radius = Math.max(radius, Math.hypot(x - (size - 1) / 2, y - (size - 1) / 2))
}
}
return {
width: image.naturalWidth, height: image.naturalHeight, radius, ink,
corners: [0, size - 1, (size - 1) * size, size * size - 1].map(index => data[index * 4 + 3]),
}
}, { url, size })
}
function pngUrl(png: Buffer): string {
return `data:image/png;base64,${png.toString('base64')}`
}
function svgUrl(svg: string): string {
return `data:image/svg+xml;base64,${Buffer.from(svg).toString('base64')}`
}
it('ships install metadata with the built web application', async () => {
const index = await readFile(join(DIST_ROOT, 'index.html'), 'utf8')
expect(index).toContain('<meta name="viewport" content="width=device-width, initial-scale=1" />')
expect(index).toContain('<link rel="manifest" href="./manifest.webmanifest" />')
expect(index).toContain('<link rel="apple-touch-icon" href="./apple-touch-icon.png" />')
expect(index).toContain('<meta name="mobile-web-app-capable" content="yes" />')
expect(index).toContain('<meta name="apple-mobile-web-app-title" content="DSH" />')
// No `theme-color` here: `ThemePresenter` owns the single metadata node and
// follows the resolved in-application theme, which a static media query
// cannot. The manifest's `theme_color` covers the surrounding chrome before
// the presenter runs, and `settings-chrome.e2e.ts` pins the live contract.
const manifest: unknown = JSON.parse(await readFile(join(DIST_ROOT, 'manifest.webmanifest'), 'utf8'))
// No `id`: a browser resolves an explicit `id` against the start URL's origin,
@@ -18,16 +66,48 @@ it('ships install metadata with the built web application', async () => {
short_name: 'DSH',
start_url: './',
scope: './',
display: 'fullscreen',
icons: [{
src: 'favicon.svg',
sizes: 'any',
type: 'image/svg+xml',
purpose: 'any',
}],
// `standalone` keeps the phone's status bar; `fullscreen` hides it and the
// navigation bar, which strands a chat surface with no clock or battery.
display: 'standalone',
background_color: '#ffffff',
theme_color: '#ffffff',
icons: [
{ src: 'icon-192.png', sizes: '192x192', type: 'image/png', purpose: 'any' },
{ src: 'icon-512.png', sizes: '512x512', type: 'image/png', purpose: 'any' },
{ src: 'icon-maskable-512.png', sizes: '512x512', type: 'image/png', purpose: 'maskable' },
],
})
})
it('decodes every generated and shipped icon with its full artwork and opaque corners', async () => {
const browser = await chromium.launch({ headless: true })
try {
const page = await browser.newPage()
const source = await readFile(TILE_SOURCE, 'utf8')
const generated = await renderAppIcons(source)
const manifest = JSON.parse(await readFile(join(DIST_ROOT, 'manifest.webmanifest'), 'utf8')) as {
icons: readonly { src: string; sizes: string }[]
}
const declared = [...manifest.icons, { src: 'apple-touch-icon.png', sizes: '180x180' }]
expect(generated.map(({ variant }) => [variant.file, `${variant.size}x${variant.size}`]))
.toEqual(declared.map(icon => [icon.src, icon.sizes]))
for (const { variant, png } of generated) {
const reference = await pixels(page, svgUrl(tileDocument(source)), variant.size)
expect(reference.ink).toBeGreaterThan(0)
const scale = variant.kind === 'maskable' ? MASKABLE_ARTWORK_SCALE : 1
for (const bytes of [png, await readFile(join(DIST_ROOT, variant.file))]) {
const image = await pixels(page, pngUrl(bytes), variant.size)
expect([image.width, image.height], variant.file).toEqual([variant.size, variant.size])
expect(image.corners, variant.file).toEqual([255, 255, 255, 255])
expect(image.ink, variant.file).toBeGreaterThan(0)
// Two pixels allow rasterizer and downsampling differences without
// accepting missing or undersized artwork in the published PNG.
expect(Math.abs(image.radius - reference.radius * scale), variant.file).toBeLessThan(2)
}
}
} finally { await browser.close() }
})
it('ships fixed-color favicons selected by document media queries', async () => {
const index = await readFile(join(DIST_ROOT, 'index.html'), 'utf8')
expect(index).toContain('<link rel="icon" type="image/svg+xml" href="./favicon-dark.svg" media="(prefers-color-scheme: dark)" />')
@@ -39,3 +119,60 @@ it('ships fixed-color favicons selected by document media queries', async () =>
expect(dark).toContain('fill="#fff"')
expect(dark.replace('fill="#fff"', 'fill="#000"')).toBe(light)
})
it('preserves fractional coverage along the Apple icon curves', async () => {
const browser = await chromium.launch({ headless: true })
try {
const page = await browser.newPage()
const png = await readFile(join(ICON_OUTPUT_DIRECTORY, 'apple-touch-icon.png'))
const tile = tileDocument(await readFile(TILE_SOURCE, 'utf8'))
const [generated, direct] = await page.evaluate(async sources => Promise.all(sources.map(async (source) => {
const image = new Image()
image.src = source
await image.decode()
const canvas = document.createElement('canvas')
canvas.width = canvas.height = 180
const context = canvas.getContext('2d')!
context.drawImage(image, 0, 0, 180, 180)
const { data } = context.getImageData(0, 0, 180, 180)
const brightness = (x: number, y: number): number => {
const at = (y * 180 + x) * 4
return (data[at]! + data[at + 1]! + data[at + 2]!) / 3
}
let partial = 0
for (let y = 1; y < 179; y += 1) {
for (let x = 1; x < 179; x += 1) {
const value = brightness(x, y)
if (value <= 85 || value >= 200) continue
// Neighboring ink and background distinguish edge coverage from the
// source's gradients and shadows, which remain in both renderings.
const neighbors: number[] = []
for (let dy = -1; dy <= 1; dy += 1) {
for (let dx = -1; dx <= 1; dx += 1) neighbors.push(brightness(x + dx, y + dy))
}
if (Math.min(...neighbors) < 70 && Math.max(...neighbors) > 220) partial += 1
}
}
return partial
})), [`data:image/png;base64,${png.toString('base64')}`, `data:image/svg+xml;base64,${Buffer.from(tile).toString('base64')}`])
expect(generated!).toBeGreaterThan(direct!)
} finally { await browser.close() }
})
it('keeps the shipped maskable artwork inside its opaque platform safe zone', async () => {
const browser = await chromium.launch({ headless: true })
try {
const page = await browser.newPage()
const maskable = await readFile(join(DIST_ROOT, 'icon-maskable-512.png'))
const tile = tileDocument(await readFile(TILE_SOURCE, 'utf8'))
const size = APP_ICON_VARIANTS.find(variant => variant.kind === 'maskable')!.size
const scaled = await pixels(page, pngUrl(maskable), size)
const unscaled = await pixels(page, svgUrl(tile), size)
expect(scaled.radius).toBeLessThanOrEqual(MASKABLE_SAFE_RADIUS_RATIO * size)
expect(scaled.radius).toBeGreaterThan(unscaled.radius * MASKABLE_ARTWORK_SCALE - 2)
expect(scaled.corners).toEqual([255, 255, 255, 255])
expect(unscaled.radius).toBeGreaterThan(MASKABLE_SAFE_RADIUS_RATIO * size)
} finally {
await browser.close()
}
})
+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)
+2
View File
@@ -15,6 +15,7 @@
},
"include": [
"src",
"scripts",
"tests"
],
// The web e2e lane (scaffold + replay specs) boots the host spine and reads
@@ -83,6 +84,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"]
@@ -1496,6 +1497,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"]
@@ -1498,6 +1499,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; }
}
Loaded 100 of 245 files, more files were not shown because too many files have changed in this diff. Show more