Merge master into personalized pages experiment

This commit is contained in:
ZiyaZhang committed 2026-10-20 04:52:17 -07:00
commit 50f21daa84
486 files changed
+7433 -3469

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 都会出现“更多”按钮。
@@ -1,19 +0,0 @@
# Bilingual-pair consistency record for 2026-10-17-agent-controller-message-delivery.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-17-agent-controller-message-delivery.md
/agent-note-delivering-messages-from-outside-an-agent-through-the-agent-controller:
en: 9105f8148e107f43
zh: a160e87bb0605da4
/agent-note-delivering-messages-from-outside-an-agent-through-the-agent-controller/problem:
en: 5071232d9743096c
zh: b553a626538b3466
/agent-note-delivering-messages-from-outside-an-agent-through-the-agent-controller/decision:
en: 1b61d351c4d2bdf1
zh: d6f8180b30d97b34
/agent-note-delivering-messages-from-outside-an-agent-through-the-agent-controller/alternatives-considered:
en: acc465cfe02b624c
zh: db1d06574f647130
/agent-note-delivering-messages-from-outside-an-agent-through-the-agent-controller/consequences:
en: 9d8486586d044709
zh: 24c7249421fd2541
@@ -1,51 +0,0 @@
# Agent Note: Delivering messages from outside an Agent through the Agent controller
Status: implemented
English | [中文](2026-10-17-agent-controller-message-delivery.zh.md)
## Problem
Plugins need to decide, with Host context, whether a message someone sends may enter a Session now. The experimental forge panel must create a requested worktree before the first message reaches the Agent, and must stop a message after the Session's worktree was removed. The Host had no point for that decision. `session/prompt` appended a prompt to the Agent inbox synchronously, and the only seam every sender passes, `agent/pre-step`, runs after the inbox accepted the message and the client already showed it as sent. A pre-step rejection ends the turn without returning the message to its sender, and a queued message reaches pre-step only when its turn starts. A gate in the Web composer, tried first, had no Host context and was bypassed by every other client.
## Decision
The core package `dsh-agent-controller` owns how a message from outside an Agent enters its inbox. The base bundle mounts it as `ctx.agentController`, so every base-backed profile can deliver through it; `dsh-agent` and `agent-loop` keep their inbox methods unchanged.
| Part | Contract |
|---|---|
| Entry | `ctx.agentController.deliver(agent, { mode, source, signal, prepare, beforeInsert })` prepares the content, builds the message, asks the `agent-controller/admission` waterfall for an `AdmissionDecision`, checks the sender's cancellation and that the Agent is still registered, calls `beforeInsert`, then `followup()` or `steer()`, and resolves with the message id once the message is in the inbox |
| Sender work | `prepare` turns the sender's content into durable message content inside the Agent's delivery order; the optional `beforeInsert` runs synchronously immediately before the append, for bookkeeping that must precede the Agent's wake |
| Listeners | Run in registration order with Agent scope filtering and receive the Agent, the message, and a signal that aborts when the sender cancels or the controller stops; return the decision of `next()` to admit, await before `next()` to hold, and return `{ kind: 'reject', reason, message, presented }` to refuse without asking later listeners; throw only when the check itself fails |
| Delivery mode | Stays with the sender; listeners do not see it, because no admission policy depends on it |
| Order | Messages to one Agent are prepared, decided, and appended one at a time in arrival order; a sender that cancels before the append is not admitted and stops waiting at once, even behind a held message; disposing the controller stops every message that has not entered an inbox and finishes once those deliveries settled |
| Failures | A rejection raises `AgentAdmissionRejected`, a `HarnessError` with code `AGENT_ADMISSION_REJECTED`, with its reason and `presented`; senders recognize it by that code rather than its class and import only types from this package; a thrown listener error raises it with `agent-controller/admission-failed`, and an Agent that left the registry with `agent-controller/agent-gone`; errors from `prepare`, `beforeInsert`, `followup()`, and `steer()` pass through; no listener runs after the message entered the inbox |
Only senders that deliver work on someone's behalf call `deliver()`. Messages the Agent's own machinery produces, such as job results, goal rounds, plan transitions, and question answers, keep `followup()`, `steer()`, and `inject()`: they belong to work that was already admitted. A listener must not wait for a person when the message source is unattended, such as a schedule or a webhook; it passes or refuses such a message.
`presented` tells an interactive client that the refusing plugin explains the refusal in its own interface, so the client adds no generic notice.
The Web Session Remote adopts the controller first. `session/prompt` resolves or resumes the Agent, acknowledges a repeated `requestId` itself, and delivers with a `prepare` that checks image input against the Session model, resolves upload receipts, and stores attachments, and a `beforeInsert` that binds the receipts to the request. It maps a refusal to `session/prompt-rejected` with `reason` and `presented`. The call stays open while a listener holds the message, and the client's cancellation stops it. The SDK server, ACP, headless runs, schedules, webhooks, and subagent prompts move to `deliver()` in later changes, each mapping a refusal to its own protocol. Until they do, a plugin that must cover them keeps an `agent/pre-step` check.
Request deduplication stays in the Session Remote. It reads the Session history synchronously, and the [synchronous-read decision](2026-09-09-deprecate-synchronous-session-event-reads.md) prohibits copying such a read into new production code; the controller can take over deduplication once a projection records accepted request ids.
The Web client keeps its existing echo: Enter clears the composer and shows the message at once, and a refusal reaches the client as an ordinary prompt failure, which retires the echo and restores the draft unless new text was typed meanwhile.
## Alternatives considered
- **An admission stage in `dsh-agent`.** `ctx.agents.admit()` with an `agent/admission` event reaches every sender as well, but it adds a delivery concern and a new extension point to the Agent core, whose contract the loop implements. A package composed in the base bundle reaches the same senders without changing that contract.
- **A Session Remote event, or a layer inside the Session Controller.** It fits the Web path, but the Session Controller is composed only in the Web profile, so ACP, SDK, and headless senders could never reach it.
- **`agent/pre-step`.** It covers every sender, but it runs after the inbox accepted the message and the client showed it as sent, a refusal drops the message instead of returning it, and a queued message is checked only when its turn starts.
- **Gated, asynchronous `followup()` and `steer()`.** Every internal sender would pass listeners meant for outside input, and their callers rely on synchronous enqueueing.
- **A composer gate in the Web client.** It has no Host context, and other clients and `ctx.conversation.send` bypass it.
- **Appending in the innermost step, with refusals thrown.** Listeners could act once `next()` returned with the message already in the inbox, but that capability, which no listener uses, cost three special cases: a listener failure after the append could only be logged, the inbox's own refusal had to be told apart from a listener failure, and a refusal thrown from another module copy of the error class needed a structural check. A returned decision keeps the stage a pure decision, as `agent/pre-step` is; a plugin that reacts to admitted messages listens to `agent/inbox/inserted`.
The liveshare proposal in PR #3854 also introduced an admission hook, for caller identity. [Peer principals](2026-10-12-peer-principal-and-remote-access.md) replaced it; this stage decides whether the Agent can take a message now, independent of who sent it.
## Consequences
- A listener that holds one message keeps later messages to the same Agent waiting; other Agents proceed.
- While a listener holds a Web prompt, the client already shows its echo; a refusal then retires the echo and returns the draft.
- `prepare` stores attachments before the decision, so a refused prompt can leave a stored attachment unreferenced.
- Image prompts deliver inside the Session Remote's image admission chain, so a model switch waits while a listener holds an image prompt.
- Until every outside sender adopts `deliver()`, the stage is not a complete enforcement point.
@@ -1,51 +0,0 @@
# Agent Note:经 Agent 控制器投递来自 Agent 外部的消息
Status: implemented
[English](2026-10-17-agent-controller-message-delivery.md) | 中文
## 问题
插件需要借助 Host 侧的上下文,判断别人发来的一条消息现在能否进入 Session。实验性的 forge 面板必须在第一条消息到达 Agent 之前建好所请求的工作树,并在 Session 的工作树被删除后拦下消息。Host 此前没有做这个判断的位置:`session/prompt` 同步地把提示词追加进 Agent 收件箱,而所有发送方都会经过的唯一扩展点 `agent/pre-step` 运行在收件箱接受消息、客户端已显示为已发送之后。pre-step 拒绝会结束本轮,却不会把消息退回给发送方;排队的消息也要等到它的轮次开始才会到达 pre-step。最初尝试的 Web 输入框闸门拿不到 Host 上下文,其他客户端也都会绕过它。
## 决策
core 包 `dsh-agent-controller` 负责来自 Agent 外部的消息如何进入其收件箱。base 组合包把它挂载为 `ctx.agentController`,因此每个基于 base 的 profile 都能经它投递;`dsh-agent` 与 `agent-loop` 的收件箱方法保持不变。
| 部分 | 约定 |
|---|---|
| 入口 | `ctx.agentController.deliver(agent, { mode, source, signal, prepare, beforeInsert })` 准备内容、构造消息、向 `agent-controller/admission` waterfall 要一个 `AdmissionDecision`,检查发送方是否取消、Agent 是否仍在注册表中,调用 `beforeInsert`,再调用 `followup()` 或 `steer()`,消息进入收件箱后返回消息 id |
| 发送方的工作 | `prepare` 在该 Agent 的投递顺序内把发送方的内容转成持久的消息内容;可选的 `beforeInsert` 紧挨在放入之前同步运行,用于必须先于 Agent 唤醒完成的登记 |
| 监听器 | 按注册顺序运行,按 Agent 作用域过滤,拿到 Agent、消息和一个在发送方取消或控制器停止时中止的信号;返回 `next()` 的决定即放行,在 `next()` 前 await 即暂留,返回 `{ kind: 'reject', reason, message, presented }` 即拒绝且不再询问后面的监听器;只在检查本身执行失败时抛出异常 |
| 投递方式 | 留在发送方,监听器看不到,因为目前没有按投递方式区分的准入策略 |
| 顺序 | 同一个 Agent 的消息按到达顺序逐条准备、决定并放入;在放入之前取消的发送方不会被接纳,并立即结束等待,即使排在被暂留的消息之后;销毁控制器会停止所有尚未进入收件箱的消息,并在这些投递结束后才完成 |
| 失败 | 拒绝时抛出带其 reason 与 `presented` 的 `AgentAdmissionRejected`,它是 code 为 `AGENT_ADMISSION_REJECTED` 的 `HarnessError`;发送方按这个 code 而不是按类识别拒绝,对本包只导入类型;监听器抛出异常时 reason 为 `agent-controller/admission-failed`,Agent 离开注册表时为 `agent-controller/agent-gone`;`prepare`、`beforeInsert`、`followup()` 与 `steer()` 的错误原样抛出;消息进入收件箱之后不再有监听器运行 |
只有代表他人送来工作的发送方调用 `deliver()`。Agent 自身机制产生的消息,例如后台任务结果、goal 轮次、plan 切换和问题答复,继续使用 `followup()`、`steer()` 与 `inject()`:它们属于已经接纳的工作。消息来源无人值守时(例如定时任务或 webhook),监听器不得等待人来处理,只能放行或拒绝。
`presented` 告诉交互式客户端,拒绝它的插件会在自己的界面里给出说明,客户端因此不再追加通用提示。
Web Session Remote 首先接入控制器。`session/prompt` 自己解析或恢复 Agent、确认重复的 `requestId`,再调用投递:`prepare` 按 Session 模型检查图片输入、解析上传凭证并存储附件,`beforeInsert` 把凭证绑定到该请求。它把拒绝映射为带 `reason` 与 `presented` 的 `session/prompt-rejected`。监听器暂留消息期间调用保持未完成,客户端取消会让它停止。SDK 服务端、ACP、headless 运行、定时任务、webhook 与子代理提示词在后续改动中改用 `deliver()`,各自把拒绝映射到自己的协议。在此之前,必须覆盖它们的插件仍保留一个 `agent/pre-step` 检查。
请求去重留在 Session Remote。它同步读取 Session 历史,而[同步读取决策](2026-09-09-deprecate-synchronous-session-event-reads.zh.md)禁止把这类读取复制到新的生产代码;等有投影记录已接收的请求 id 后,控制器才能接管去重。
Web 客户端保留现有的回显方式:Enter 立即清空 composer 并显示消息;拒绝在客户端表现为一次普通的提示词失败,回显撤下,草稿回到 composer,除非期间已经输入了新内容。
## 备选方案
- **在 `dsh-agent` 里加接纳阶段。** `ctx.agents.admit()` 加 `agent/admission` 事件同样能覆盖所有发送方,但它把投递职责和一个新扩展点加进了 Agent 核心,而 Agent 的约定由循环实现。组合在 base 组合包里的独立包能覆盖同样的发送方,又不改变这项约定。
- **Session Remote 自己的事件,或 Session Controller 内部的一层。** 它适合 Web 路径,但 Session Controller 只在 Web profile 中组合,ACP、SDK 与 headless 发送方永远无法使用它。
- **`agent/pre-step`。** 它覆盖所有发送方,但运行在收件箱接受消息、客户端显示为已发送之后;拒绝会丢掉消息而不是退回;排队的消息要到轮次开始才会被检查。
- **让 `followup()` 与 `steer()` 异步并经过闸门。** 每个内部发送方都会经过为外部输入准备的监听器,而它们的调用方依赖同步入队。
- **Web 客户端的输入框闸门。** 它拿不到 Host 上下文,其他客户端和 `ctx.conversation.send` 都会绕过它。
- **在最内层步骤放入消息,并以抛出异常表示拒绝。** 监听器能在 `next()` 返回、消息已进入收件箱之后继续执行,但这项没有监听器使用的能力带来三处特殊处理:放入之后的监听器失败只能记录日志;要把收件箱自身的拒绝与监听器失败区分开;另一个模块副本抛出的错误类需要按结构识别。返回决定让这个阶段和 `agent/pre-step` 一样只做决定;需要对已接纳消息作出反应的插件监听 `agent/inbox/inserted`。
PR #3854 中的 liveshare 方案也引入过一个接纳钩子,用于调用方身份。[Peer principals](2026-10-12-peer-principal-and-remote-access.zh.md) 取代了它;本阶段判断 Agent 现在能否接收一条消息,与发送者是谁无关。
## 影响
- 监听器暂留一条消息时,同一 Agent 之后的消息都要等待;其他 Agent 不受影响。
- 监听器暂留一条 Web 提示词期间,客户端已经显示其回显;随后被拒绝时,回显撤下,草稿回到 composer。
- `prepare` 在决定之前存储附件,因此被拒绝的提示词可能留下一个无人引用的已存附件。
- 图片提示词在 Session Remote 的图片接纳链内投递,因此监听器暂留图片提示词期间,切换模型要等待。
- 在所有外部发送方都改用 `deliver()` 之前,这个阶段还不是完整的强制执行点。
@@ -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 和上述升级指南中“不再保证当前预设定义”的说明。
@@ -15,8 +15,8 @@
en: db0a2deb9193a00d
zh: dab5a65b88957464
/agent-note-experimental-auto-review-before-each-tool-call/decision/one-complete-reviewer-request:
en: 3a07a59b6c5ec26c
zh: e4f3290dc6d26368
en: 7bd9fef90e8639b9
zh: e2a096885c9c1e83
/agent-note-experimental-auto-review-before-each-tool-call/decision/result-and-cancellation:
en: 647fe68c52b907e0
zh: 267a739bad44ad65
@@ -30,19 +30,20 @@ The reviewer derives authority from existing Session facts. Text with `source.ki
### One complete reviewer request
The integration uses only the latest `request/header.config` provider/model and the shipped adapter's default reasoning. It neither compares redundant route metadata nor copies the main agent's request. The request has five fixed partitions:
The integration uses only the latest `request/header.config` provider/model and the shipped adapter's default reasoning. It neither compares redundant route metadata nor copies the main agent's request. The request is a fixed system prompt followed by user messages:
| Partition | Retained input |
| Part | Retained input |
| --- | --- |
| `REVIEW_POLICY` | Fixed classification, source authority, and strict result rules; allow executes immediately with Full access and no later confirmation |
| `ENVIRONMENT` | Existing Session header `cwd` only |
| `PROJECT_INSTRUCTIONS` | Visible project instructions with their original sources and constraint role |
| `FILTERED_HISTORY` | Current compaction surface's sourced user/direct-parent messages, checkpoints, image/attachment facts, and historical call names with logged arguments |
| `PENDING_ACTION` | Tool name, description, parameter schema, and parsed arguments |
| System prompt `REVIEW_POLICY` | Fixed classification, source authority, message layout, and strict result rules; allow executes immediately with Full access and no later confirmation |
| First user message `ENVIRONMENT` | Existing Session header `cwd` only |
| One user message per retained item | Current compaction surface's sourced user/direct-parent messages, project instructions with the constraint role, checkpoints, image/attachment facts, historical call names with logged arguments, and a `tool-schema` item before the first call that used each reviewed schema, in surface order |
| Final user message | The pending call with its logged arguments; the latest earlier `tool-schema` item with its mode and name gives the tool description and parameter schema |
The reviewer builds those two action sections from the Session's complete action history: an authorization is the earlier call the `REVIEW_POLICY` source rules make eligible, and a duplicate or conflicting identity has to be visible anywhere in the log rather than inside a recent window, so neither the Session projections nor a bounded read serves the decision. The read is the deprecated synchronous `snapshotEvents()` under a line-scoped `typescript/no-deprecated` waiver.
Each item is its own message and keeps the same text in later reviews: the pending call becomes a historical call, and the integration remembers, for each live Session, the schema each reviewed call showed so that it reappears at the same position. A review's messages therefore begin with all messages of the previous review. Providers that cache only at message ends, such as OpenAI implicit breakpoints on GPT-5.6 and later, or at a final marker with lookback, such as Anthropic `cache_control`, reuse the previous review; one growing user message let them reuse only the policy. The schema memory is process-local, so the first review after a restart omits earlier schemas and misses the cache once.
The main agent's V3 `system/message` nodes, assistant text/reasoning, and tool results are excluded. The current call must belong to the open step recorded by `step/start`; missing step ownership fails closed. It appears only in `PENDING_ACTION`; an unstarted sibling has no historical call fact. Native schema comes from the latest request header. PTC captures a frozen schema at binding construction and passes it through the scheduler into `ToolExecution`; descriptions and parameter schemas never enter start/settle events or the Session/SDK wire. Missing, inconsistent, or ambiguous action facts reject the call without consulting the live registry. An oversized request fails closed without summarization, truncation, another compaction pass, or a small output-token budget.
The reviewer builds the history and the pending call from the Session's complete action history: an authorization is the earlier call the `REVIEW_POLICY` source rules make eligible, and a duplicate or conflicting identity has to be visible anywhere in the log rather than inside a recent window, so neither the Session projections nor a bounded read serves the decision. The read is the deprecated synchronous `snapshotEvents()` under a line-scoped `typescript/no-deprecated` waiver.
The main agent's V3 `system/message` nodes, assistant text/reasoning, and tool results are excluded. The current call must belong to the open step recorded by `step/start`; missing step ownership fails closed. It appears only as the final message; an unstarted sibling has no historical call fact. Native schema comes from the latest request header. PTC captures a frozen schema at binding construction and passes it through the scheduler into `ToolExecution`; descriptions and parameter schemas never enter start/settle events or the Session/SDK wire. Missing, inconsistent, or ambiguous action facts reject the call without consulting the live registry. An oversized request fails closed without summarization, truncation, another compaction pass, or a small output-token budget.
### Result and cancellation
@@ -30,19 +30,20 @@ Reviewer 从既有 Session 事实派生权威。`source.kind === 'user'` 的文
### 一次完整 reviewer 请求
Integration 只使用最新 `request/header.config` 的 provider/model 与 shipped adapter 默认 reasoning。它既不比较冗余 route metadata,也不复制主 agent 请求。请求固定包含五个分区:
Integration 只使用最新 `request/header.config` 的 provider/model 与 shipped adapter 默认 reasoning。它既不比较冗余 route metadata,也不复制主 agent 请求。请求由固定 system prompt 和其后的 user 消息组成:
| 分区 | 保留输入 |
| 部分 | 保留输入 |
| --- | --- |
| `REVIEW_POLICY` | 固定分类、来源权威与严格结果规则;allow 后立即以 Full access 执行,没有后续确认 |
| `ENVIRONMENT` | 仅既有 Session header `cwd` |
| `PROJECT_INSTRUCTIONS` | 可见项目指令,保留原始来源并赋予约束角色 |
| `FILTERED_HISTORY` | 当前 compaction surface 中带来源的用户/直接父级消息、checkpoint、图片/附件事实,以及历史调用名称与日志参数 |
| `PENDING_ACTION` | 工具名称、描述、参数 schema 与解析后的 arguments |
| System prompt `REVIEW_POLICY` | 固定分类、来源权威、消息布局与严格结果规则;allow 后立即以 Full access 执行,没有后续确认 |
| 第一条 user 消息 `ENVIRONMENT` | 仅既有 Session header `cwd` |
| 每个保留条目一条 user 消息 | 当前 compaction surface 中带来源的用户/直接父级消息、带约束角色的项目指令、checkpoint、图片/附件事实、历史调用名称与日志参数,以及在首次使用某个已审 schema 的调用之前出现的 `tool-schema` 条目,按 surface 顺序排列 |
| 最后一条 user 消息 | 待审调用及其日志参数;其之前同 mode 与名称的最新 `tool-schema` 条目给出工具描述与参数 schema |
reviewer 从 Session 的完整动作历史构建这两个动作分节:授权是符合 `REVIEW_POLICY` 来源规则的那个更早调用,而重复或冲突的身份必须在整份日志的任何位置都可见,而不是只在近期窗口内可见,因此 Session 投影与有界读取都无法支撑该判定。这次读取使用已废弃的同步 `snapshotEvents()`,并带有行级 `typescript/no-deprecated` 豁免。
每个条目各占一条消息,并在后续评审中保持相同文本:待审调用会变成历史调用,integration 为每个存活 Session 记住每次已审调用展示的 schema,使其在同一位置再次出现。因此一次评审的消息以上一次评审的全部消息开头。只在消息结尾缓存的 provider(如 GPT-5.6 及以后的 OpenAI 隐式断点),或在末尾标记处向前回溯的 provider(如 Anthropic `cache_control`),都能复用上一次评审;单条不断增长的 user 消息只能让它们复用策略。Schema 记忆只在进程内,因此重启后的第一次评审会缺少先前的 schema,并有一次缓存未命中。
主 agent 的 V3 `system/message` 节点、assistant 正文/reasoning 与 tool results 全部排除。当前调用必须属于 `step/start` 记录的开放 step;缺少 step 归属时拒绝执行。该调用只在 `PENDING_ACTION` 出现;尚未开始的 sibling 没有历史调用事实。原生 schema 来自最新 request header。PTC 在 binding 构造时捕获冻结 schema,经由调度器传入 `ToolExecution`;描述与参数 schema 不进入开始/结算事件或 Session/SDK wire。动作事实缺失、不一致或有歧义时拒绝调用,不查询 live registry。超窗请求直接拒绝,不做摘要、截断、额外 compaction 或设置小型输出 token 预算。
reviewer 从 Session 的完整动作历史构建历史与待审调用:授权是符合 `REVIEW_POLICY` 来源规则的那个更早调用,而重复或冲突的身份必须在整份日志的任何位置都可见,而不是只在近期窗口内可见,因此 Session 投影与有界读取都无法支撑该判定。这次读取使用已废弃的同步 `snapshotEvents()`,并带有行级 `typescript/no-deprecated` 豁免。
主 agent 的 V3 `system/message` 节点、assistant 正文/reasoning 与 tool results 全部排除。当前调用必须属于 `step/start` 记录的开放 step;缺少 step 归属时拒绝执行。该调用只作为最后一条消息出现;尚未开始的 sibling 没有历史调用事实。原生 schema 来自最新 request header。PTC 在 binding 构造时捕获冻结 schema,经由调度器传入 `ToolExecution`;描述与参数 schema 不进入开始/结算事件或 Session/SDK wire。动作事实缺失、不一致或有歧义时拒绝调用,不查询 live registry。超窗请求直接拒绝,不做摘要、截断、额外 compaction 或设置小型输出 token 预算。
### 结果与取消
@@ -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 可达。
真机安装、启动显示与窗口模式仍属设备检查。
+1 -1
View File
@@ -56,7 +56,7 @@ Scrollbars stay inside their container: use the shared scrollbar styles rather t
## Responsive layouts
Use [dsh-responsive-ui](../dsh-responsive-ui/SKILL.md) for responsive layouts, text truncation, title wrapping, icon-only controls, and focus during width changes.
Use [dsh-responsive-ui](../dsh-responsive-ui/SKILL.md) for responsive layouts, mobile and touch detection, text truncation, title wrapping, icon-only controls, and focus during width changes.
## Platform window adaptation
+1
View File
@@ -23,6 +23,7 @@ description: Use when reviewing a pull request in the deepseek-harness repo —
- [dsh-ci-test-reliability](../dsh-ci-test-reliability/SKILL.md): isolation and regression-proof rules for resource-owning, asynchronous, or flaky tests and fixtures.
- [dsh-client-ui-ux](../dsh-client-ui-ux/SKILL.md): feedback-surface choice, overlay safety, platform window adaptation, and visual token discipline for product-user-visible GUI changes.
- [dsh-error-handling](../dsh-error-handling/SKILL.md): failure classification, handling ownership, containment, state guarantees, and actionable reports.
- [dsh-session-projection](../dsh-session-projection/SKILL.md): projection ownership, state size, historical reads, and migrations.
- [docs/testing.md](../../../docs/testing.md) and the [quality-gates reference](../../../docs/testing.md): required test tiers and gates.
- [Agent Notes](../../notes/README.md): design rationale. Treat disagreement with an Agent Note as a design discussion, not an automatic veto.
- For bilingual changes, read [translation-rules.md](../../../docs/i18n/translation-rules.md) and [terminology.md](../../../docs/i18n/terminology.md); the extended translation skill is outside automatic review and runs only on explicit user invocation.
+10 -5
View File
@@ -1,6 +1,6 @@
---
name: dsh-responsive-ui
description: Use when designing, implementing, or reviewing responsive client layouts, text truncation, title wrapping, icon-only controls, mobile hover, tooltips and keyboard behavior, and focus during width changes in DeepSeek Harness.
description: Use when designing, implementing, or reviewing responsive client layouts, mobile and touch detection, text truncation, title wrapping, icon-only controls, mobile hover, tooltips and keyboard behavior, and focus during width changes in DeepSeek Harness.
---
# Responsive client UI
@@ -39,10 +39,10 @@ Use a viewport width of 390 CSS px as the primary acceptance minimum. There must
## 6. Mobile hover behavior
1. Enable mouse-hover effects only on devices that support hovering. Use `@media (hover: hover)`, not viewport width, to determine this.
2. Tapping on a phone must not leave behind backgrounds, highlights, or action-button visibility intended only for hover.
3. Preserve normal selected, enabled, and pressed states, as well as focus indicators for keyboard interaction.
4. Try to preserve focus when the width changes, unless the focused element disappears from the page as a result.
1. Enable hover effects under `@media (hover: hover)`, independently of viewport width. This queries the primary pointing device, not the current event; JavaScript hover handlers must ignore touch events on mixed-input devices.
2. Tapping on a phone must not leave behind backgrounds, highlights, or action-button visibility intended only for hover. Without hover, actions remain visible or reachable through an explicit control. Keyboard users can reach and reveal the same actions.
3. Preserve normal selected, expanded, enabled, and pressed states, as well as focus indicators. Keep these styles independent of hover capability. Do not clear focus to remove a hover effect; `:focus-visible` can also apply to text fields focused by touch.
4. Preserve focus during width changes when the control remains visible and interactive. Avoid remounting controls only to change layout. If the focused control is hidden or removed, move focus to a suitable remaining control without opening the mobile keyboard unnecessarily.
## 7. Mobile tooltips
@@ -52,3 +52,8 @@ Use a viewport width of 390 CSS px as the primary acceptance minimum. There must
## 8. Mobile keyboard
On mobile, do not raise the input keyboard automatically unless it is explicitly intended.
## 9. Input capabilities and mobile text fields
1. Prefer CSS media features for presentation: `(pointer: coarse)` describes the primary pointer's accuracy, `(hover: none)` describes its inability to hover, and width queries select layout breaks. These queries do not detect a physical keyboard. Never branch layout on a parsed user agent.
2. Text fields take a 16px floor under `(pointer: coarse)` to avoid iOS Safari's focus zoom. Fine pointers keep the existing compact field sizes. Use the field rules in `packages/client/ui-plugin-manager/src/client/PluginManagerPage.module.css` as the reference.
@@ -0,0 +1,19 @@
---
name: dsh-session-projection
description: Use when creating, extending, or reviewing a DeepSeek Harness session projection, or migrating a fold helper to one.
---
# DeepSeek Harness Session Projections
Follow the [projection API](../../../packages/session/session-projection/README.md) for registration and lifecycle details.
1. Decide whether to extend an existing projection or create one; check whether an existing owner already maintains the needed state.
2. Follow the README's [service ownership rules](../../../packages/session/session-projection/README.md#register-and-read): keep projection definitions and handles private to the owning service's implementation, and expose domain methods to consumers.
3. Require `sessionProjections` when the service needs projected state to function. Optional registration is for contributions the service can operate without.
4. Evaluate retained state size as the session grows. Do not accumulate state proportional to session length, including collections of event sequence numbers. Growth is acceptable only when slow enough to remain negligible. When the data would keep growing, consider incremental loading instead of retaining the complete state.
5. `apply` is synchronous; historical reads belong in asynchronous consumers. When later reads need event payloads, consider retaining sequence numbers within rule 4's state-size limits and letting consumers resolve them asynchronously.
6. When replacing a helper such as `foldSessionTitle` with a projection, remove the superseded helper.
7. After migrating to Session persistence, stop using `fold` in names and descriptions of the migrated logic.
8. Do not add calls to deprecated synchronous readers (`snapshotEvents`, `eventAt`, `ownEvents`) or wrappers exposing the same access ([decision](../../notes/implemented/architecture/2026-09-09-deprecate-synchronous-session-event-reads.md)). Assume the full event log is not resident in memory and random reads may require asynchronous disk access.
9. Consider state size, replay cost, and per-event work; add complexity only for a concrete performance need.
10. Asynchronous `register()` is usually called in `[Service.init]`. If event listeners are registered afterward, check initialization ordering: when needed, install listeners before awaiting registration; if a listener needs the returned handle, await its readiness in a callback that supports asynchronous waiting. Otherwise, an event may fire before the listener is installed and be missed. Determine whether this race exists from the actual initialization flow and event trigger paths; do not move listeners earlier or add waits mechanically.
+1
View File
@@ -135,6 +135,7 @@ Real-API tests/demos read `DEEPSEEK_API_KEY`, optional `DEEPSEEK_BASE_URL`, and
- **Switch on discriminant tags.** Closed unions end in `assertNever`; merge-extensible unions fall through a documented default.
- **Waterfall listeners MUST call `next()`** to delegate; returning without it short-circuits the chain ([semantics](docs/cordis-primer.md#cordis-waterfall-semantics)).
- **Model-visible ⟺ logged**: anything that reaches a model request must be reconstructable from the session log; a new model-visible input requires a session event.
- **Session projections:** follow [dsh-session-projection](.agents/skills/dsh-session-projection/SKILL.md) when creating, extending, or reviewing one, or migrating a fold helper to one.
- **Plugins, not loop changes**: new behavior goes on documented extension points; changing `agent-loop` requires updating docs/architecture.md.
- **A capability seam comprises Service Definition / Service Provider / Consumer roles.** It is complete, never one role; split only when roles evolve independently ([glossary](docs/glossary.md#capability-seam)).
- **Prefer maintained dependencies over hand-rolling** when they genuinely delete owned code and tests ([policy](.agents/notes/implemented/process/2026-07-26-dependencies-over-hand-rolling.md)).
-3
View File
@@ -40,8 +40,6 @@ flowchart LR
cfg --> plugin_dsh_base_user_questions
plugin_dsh_base_agent["agent<br/>@deepseek-ai/dsh-agent"]
cfg --> plugin_dsh_base_agent
plugin_dsh_base_agent_controller["agent-controller<br/>@deepseek-ai/dsh-agent-controller"]
cfg --> plugin_dsh_base_agent_controller
plugin_dsh_base_plugin_package_inventory_deepseek["plugin-package-inventory-deepseek<br/>@deepseek-ai/dsh-plugin-package-inventory-deepseek"]
cfg --> plugin_dsh_base_plugin_package_inventory_deepseek
plugin_dsh_base_agent_default_model["agent-default-model<br/>@deepseek-ai/dsh-agent-default-model"]
@@ -220,7 +218,6 @@ flowchart LR
| `session-title-llm` | `@deepseek-ai/dsh-session-title-first-prompt-llm` |
| `user-questions` | `@deepseek-ai/dsh-user-questions` |
| `agent` | `@deepseek-ai/dsh-agent` |
| `agent-controller` | `@deepseek-ai/dsh-agent-controller` |
| `plugin-package-inventory-deepseek` | `@deepseek-ai/dsh-plugin-package-inventory-deepseek` |
| `agent-default-model` | `@deepseek-ai/dsh-agent-default-model` |
| `jobs` | `@deepseek-ai/dsh-jobs-local` |
+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()
+2 -2
View File
@@ -9,8 +9,8 @@
en: 1f9c004f3eb465fb
zh: 3e45ef8014bad48d
/apps-web-browser-e2e/these-are-host-face-tests:
en: 8c406d423c8e2137
zh: a66a144fd146f56c
en: 101d687b1d98c5b7
zh: f8c31115e8a15be7
/apps-web-browser-e2e/do-not-import-deepseek-ai-dsh-client-here:
en: 7cc75a6413647142
zh: a8c541f9b93e9f7f
+2
View File
@@ -32,6 +32,8 @@ Explicit scrolling uses `scrollIntoView` from `support.ts`: it resolves the loca
They type-check in the root `tsconfig.host.json`, not in the Client aggregate, because they read Host services directly: `ctx.connection`, the Host `SessionStore`, and `ctx.sessionProjectionCache`. Driving a browser at runtime does not make a file part of the Client program — the two faces merge Cordis `Context` under the same keys with different services, so one program cannot see both. Moving these files into the Client aggregate makes every Host-service access fail to compile.
A new scenario joins both program lists in the same change: its file in the root `tsconfig.host.json` include and in the Client aggregate's `apps/web/tsconfig.json` exclude. That project includes all of `tests`, so a file missing from the exclude compiles as Client code, and the Client compiler gate of `pnpm run typecheck` keeps failing with Host-service errors until it is listed.
## Do not import `@deepseek-ai/dsh-client-*` here
Importing a Client package — a value or a type — pulls its TypeScript project and referenced projects into the Host build graph. Generated Remote declarations are available before package compilation, but this does not make the Host and Client Cordis `Context` declarations compatible. Keep these tests on the Host face and use only the explicit built-client harness exception below.
+2
View File
@@ -32,6 +32,8 @@ pnpm --filter @deepseek-ai/dsh-web-frontend exec playwright install --with-deps
它们在根 `tsconfig.host.json` 中做类型检查,而不在 Client aggregate 中,因为它们直接读取 Host 服务:`ctx.connection`、Host 侧 `SessionStore` 与 `ctx.sessionProjectionCache`。运行时驱动浏览器并不使一个文件成为 Client 程序的一部分——两个 face 在相同的键上以不同服务合并 Cordis `Context`,因此单个程序无法同时看见两者。把这些文件挪进 Client aggregate 会让每一处 Host 服务访问都无法编译。
新增场景要在同一次改动里登记到两份工程清单:根 `tsconfig.host.json` 的 include,以及 Client aggregate `apps/web/tsconfig.json` 的 exclude。该工程包含整个 `tests` 目录,漏登记 exclude 的文件会按 Client 代码编译,`pnpm run typecheck` 的 Client compiler 关卡会一直以 Host 服务错误失败,直到它被列入。
## 不要在此 import `@deepseek-ai/dsh-client-*`
import 一个 Client 包——无论值还是类型——都会把它的 TypeScript 工程及其引用的工程拉进 Host 构建图。生成的 Remote 声明在包编译之前就绪,但这并不使 Host 与 Client 的 Cordis `Context` 声明兼容。保留这些测试的 Host face 归属,只使用下文明确的 built-client harness 例外。
+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
@@ -0,0 +1,8 @@
# Touch tooltips
Touch refresh: one refresh request; no tooltip
Touch explanation: visible within the 390px viewport
- tooltip "在这里配置官方插件,安装和管理其他插件。内置插件列表及运行状态可在「设置 → 内置插件」中查看"
Explanation closes on another tap or an outside touch
Keyboard focus after touch: refresh tooltip visible
Mouse hover after touch: refresh tooltip visible
@@ -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
@@ -0,0 +1,5 @@
# Touch copy feedback
Copy succeeds and the button returns from Copied to Copy.
Tooltips during copy feedback: 0
Tooltips after emulated mouse entry: 0
+242
View File
@@ -0,0 +1,242 @@
/** Pointer, keyboard, and resize behavior in the assembled client. */
import { fileURLToPath } from 'node:url'
import { chromium, type Locator } from 'playwright'
import { expect, it, onTestFailed, onTestFinished } from 'vitest'
import { launchWebScaffold, seedSession, watchConsole } from './scaffold.ts'
import { createChatScrollFixture } from './chat-scroll-fixture.ts'
import { connectFreshWorkspace, saveFailureShot } from './support.ts'
it.each([false, true])('shows the new-session shortcut on keyboard focus with touch=%s', async (hasTouch) => {
const scaffold = await launchWebScaffold()
onTestFinished(() => scaffold.close())
const browser = await chromium.launch()
onTestFinished(() => browser.close())
const context = await browser.newContext({ viewport: { width: 1280, height: 900 }, hasTouch, locale: 'en-US' })
await context.addInitScript(() => { Object.defineProperty(navigator, 'platform', { value: 'MacIntel' }) })
const page = await context.newPage()
onTestFailed(() => saveFailureShot(page, `new-session-focus-${String(hasTouch)}`))
await page.goto(scaffold.authenticatedUrl)
const button = page.getByRole('button', { name: 'New session', exact: true }).filter({ has: page.locator('kbd') })
await button.waitFor()
const shortcut = button.locator('[class*="newSessionShortcut"]')
const mask = button.locator('[class*="newSessionLabelMask"]')
const opacity = () => shortcut.evaluate(element => getComputedStyle(element).opacity)
const maskImage = () => mask.evaluate(element => getComputedStyle(element).maskImage)
expect(await page.evaluate(() => matchMedia('(hover: none)').matches)).toBe(hasTouch)
expect(await opacity()).toBe('0')
expect(await maskImage()).toBe('none')
await button.focus()
await page.keyboard.press('Shift+Tab')
await page.keyboard.press('Tab')
expect(await button.evaluate(element => element === document.activeElement && element.matches(':focus-visible'))).toBe(true)
expect(await opacity()).toBe('1')
expect(await maskImage()).not.toBe('none')
await page.keyboard.press('Tab')
expect(await opacity()).toBe('0')
expect(await maskImage()).toBe('none')
if (hasTouch) {
await button.tap()
expect(await button.evaluate(element => element.matches(':focus-visible'))).toBe(false)
expect(await opacity()).toBe('0')
expect(await maskImage()).toBe('none')
} else {
await button.hover()
expect(await opacity()).toBe('1')
expect(await maskImage()).not.toBe('none')
}
})
it.each([false, true])('keeps workspace and archived search actions reachable with touch=%s', async (hasTouch) => {
const scaffold = await launchWebScaffold()
onTestFinished(() => scaffold.close())
const fixture = createChatScrollFixture({ markerPrefix: 'ROW_ACTIONS', title: 'Archived row actions', turns: 1 })
const id = await seedSession(scaffold, fixture.log, 'row-actions')
const browser = await chromium.launch()
onTestFinished(() => browser.close())
const page = await browser.newPage({ viewport: { width: 1280, height: 900 }, hasTouch, locale: 'en-US' })
await page.addInitScript(() => { Object.defineProperty(navigator, 'platform', { value: 'MacIntel' }) })
onTestFailed(() => saveFailureShot(page, `row-actions-${String(hasTouch)}`))
const activate = async (target: Locator) => {
if (hasTouch) await target.tap()
else await target.click()
}
const openFixture = async () => {
await activate(page.getByRole('button', { name: 'Search sessions', exact: true }))
await page.getByRole('textbox', { name: 'Search session names', exact: true }).fill(fixture.markers.user(1))
await activate(page.getByRole('tree', { name: 'Search results' }).getByRole('treeitem'))
await expect.poll(() => row.getAttribute('aria-selected')).toBe('true')
}
const row = page.locator(`[data-row-key="session:${id}"]`)
await page.goto(scaffold.authenticatedUrl)
await connectFreshWorkspace(page, scaffold.workspaceCwd)
await openFixture()
await activate(page.getByRole('button', { name: 'View options', exact: true }))
await activate(page.getByRole('menuitem', { name: 'WorkSpace', exact: true }))
const menu = page.getByRole('button', { name: 'Workspace actions for workspace', exact: true, includeHidden: true })
const project = page.locator('[data-row-key^="workspace:"]').filter({ has: menu })
await page.mouse.move(0, 0)
expect(await page.evaluate(() => matchMedia('(hover: none)').matches)).toBe(hasTouch)
expect(await menu.isVisible()).toBe(hasTouch)
if (!hasTouch) await project.hover()
await activate(menu)
await page.getByRole('menuitem', { name: 'Rename', exact: true }).waitFor()
await page.keyboard.press('Escape')
if (!hasTouch) await project.hover()
await activate(page.getByRole('button', { name: 'New session in workspace', exact: true }))
await expect.poll(() => row.getAttribute('aria-selected')).not.toBe('true')
expect(await project.getAttribute('aria-expanded')).toBe('true')
await openFixture()
if (!hasTouch) await row.hover()
const actions = row.getByRole('button', { name: /^Session actions for / })
await activate(actions)
const archive = page.getByRole('menuitem', { name: 'Archive session', exact: true })
const shortcut = archive.locator('[aria-hidden="true"]').filter({ has: page.locator('kbd') })
await archive.waitFor()
expect(await shortcut.count()).toBe(1)
expect(await page.evaluate(() => matchMedia('(pointer: coarse)').matches)).toBe(hasTouch)
expect(await shortcut.isVisible()).toBe(!hasTouch)
for (const key of ['ArrowDown', 'Tab']) {
await page.keyboard.press(key)
expect(await page.locator('html').getAttribute('data-input-modality')).toBe('keyboard')
expect(await page.getByRole('menu').evaluate(menu => menu.contains(document.activeElement))).toBe(true)
expect(await shortcut.isVisible()).toBe(true)
if (hasTouch) {
await page.setViewportSize({ width: 390, height: 900 })
await expect.poll(() => shortcut.evaluate((element) => {
const bounds = element.getBoundingClientRect()
return bounds.left >= 0 && bounds.right <= window.innerWidth
})).toBe(true)
await page.setViewportSize({ width: 1280, height: 900 })
}
await page.keyboard.press('Escape')
if (!hasTouch) await row.hover()
await activate(actions)
await archive.waitFor()
expect(await shortcut.isVisible()).toBe(!hasTouch)
}
await activate(archive)
await row.waitFor({ state: 'detached' })
await activate(page.getByRole('button', { name: 'View options', exact: true }))
await activate(page.getByRole('menuitem', { name: 'All conversations (show archived)', exact: true }))
await activate(page.getByRole('button', { name: 'Search sessions', exact: true }))
await page.getByRole('textbox', { name: 'Search session names', exact: true }).fill(fixture.markers.user(1))
const result = page.getByRole('tree', { name: 'Search results' }).getByRole('treeitem')
await result.waitFor()
const restore = result.getByRole('button', { name: 'Unarchive session', exact: true, includeHidden: true })
expect(await restore.isVisible()).toBe(hasTouch)
if (!hasTouch) await result.hover()
await activate(restore)
await expect.poll(() => scaffold.ctx.workspaceRegistry.archivedSessionIds.includes(id)).toBe(false)
await restore.waitFor({ state: 'detached' })
await activate(result)
await expect.poll(() => row.getAttribute('aria-selected')).toBe('true')
})
it.each([false, true])('keeps actions and focus usable with touch=%s across layout widths', async (hasTouch) => {
const scaffold = await launchWebScaffold()
onTestFinished(() => scaffold.close())
const fixture = createChatScrollFixture({ markerPrefix: 'HOVER', title: 'Hover input', turns: 1 })
const id = await seedSession(scaffold, fixture.log, 'hover-input')
const browser = await chromium.launch()
onTestFinished(() => browser.close())
const context = await browser.newContext({
viewport: { width: 1280, height: 900 }, hasTouch, locale: 'en-US', timezoneId: 'Asia/Shanghai',
})
const page = await context.newPage()
const consoleWatch = watchConsole(page)
onTestFailed(() => saveFailureShot(page, `hover-input-${String(hasTouch)}`))
await page.goto(scaffold.authenticatedUrl)
await connectFreshWorkspace(page, scaffold.workspaceCwd)
expect(await page.evaluate(() => matchMedia('(hover: hover)').matches)).toBe(!hasTouch)
await page.getByRole('button', { name: 'Search sessions' }).click()
await page.getByRole('textbox', { name: 'Search session names', exact: true }).fill(fixture.markers.user(1))
const result = page.getByRole('tree', { name: 'Search results' }).getByRole('treeitem')
await expect.poll(() => result.count()).toBe(1)
await result.click()
await page.getByText(fixture.markers.assistant(1), { exact: false }).last().waitFor()
const row = page.locator(`[data-row-key="session:${id}"]`)
await row.waitFor({ state: 'visible' })
expect(await row.getAttribute('aria-selected')).toBe('true')
const copy = page.locator('[data-clock="end"] > button').first()
const background = () => copy.evaluate(element => getComputedStyle(element).backgroundColor)
await page.mouse.move(0, 0)
const resting = await background()
if (hasTouch) {
await copy.tap()
expect(await background()).toBe(resting)
expect(await row.getAttribute('aria-selected')).toBe('true')
expect(await page.getByRole('tooltip').count()).toBe(0)
} else {
await copy.hover()
await expect.poll(background).not.toBe(resting)
await page.mouse.move(0, 0)
await expect.poll(background).toBe(resting)
}
await page.keyboard.press('Tab')
await copy.focus()
expect(await copy.evaluate(element => element.matches(':focus-visible'))).toBe(true)
await row.focus()
expect(await row.evaluate(element => document.activeElement === element)).toBe(true)
await page.keyboard.press('Enter')
expect(await row.getAttribute('aria-selected')).toBe('true')
const image = fileURLToPath(new URL('../../../snapshots/session/read-image/workspace/red.png', import.meta.url))
await page.locator('input[type="file"]').setInputFiles(image)
const remove = page.getByRole('button', { name: 'Remove image red.png', exact: true })
await remove.waitFor({ state: 'visible' })
if (hasTouch) {
expect(await remove.evaluate(element => getComputedStyle(element).opacity)).toBe('1')
await remove.tap()
} else {
await remove.focus()
await expect.poll(() => remove.evaluate(element => getComputedStyle(element).opacity)).toBe('1')
await page.keyboard.press('Enter')
}
await remove.waitFor({ state: 'detached' })
const input = page.locator('[data-composer-input]')
await input.fill('keep this draft')
await input.press('Home')
await input.press('Shift+End')
const element = await input.elementHandle()
const selection = () => input.evaluate(node => ({
focused: document.activeElement === node,
text: node.textContent,
selected: window.getSelection()?.toString(),
}))
const before = await selection()
expect(before.focused).toBe(true)
for (const width of [390, 768, 1280]) {
await page.setViewportSize({ width, height: 900 })
expect(await element!.evaluate(node => node.isConnected)).toBe(true)
expect(await selection()).toEqual(before)
}
await page.getByRole('button', { name: 'Open right sidebar', exact: true }).click()
const panel = page.locator('[data-sidebar-right-panel][data-sidebar-right-open]')
await panel.getByRole('button', { name: 'Fullscreen', exact: true }).click()
await panel.locator('[data-dockkit-split-button]').click()
await expect.poll(() => panel.locator('[data-dockkit-pane]').count()).toBe(2)
await page.mouse.move(0, 0)
await page.keyboard.press('Escape')
await page.getByRole('tooltip').waitFor({ state: 'detached' })
const blockedSplit = panel.locator('[data-dockkit-split-button]').last()
expect(await blockedSplit.isDisabled()).toBe(true)
const hit = await blockedSplit.evaluate((button) => {
const bounds = button.getBoundingClientRect()
const x = bounds.x + bounds.width / 2
const y = bounds.y + bounds.height / 2
const target = document.elementFromPoint(x, y)
return { x, y, wrapperReceivesInput: target === button.parentElement, cursor: target && getComputedStyle(target).cursor }
})
expect(hit.wrapperReceivesInput).toBe(true)
expect(['auto', 'default']).toContain(hit.cursor)
// Coordinate input exercises CSS hit testing over the disabled button itself.
if (hasTouch) await page.touchscreen.tap(hit.x, hit.y)
else await page.mouse.click(hit.x, hit.y)
await page.getByRole('tooltip').filter({ hasText: 'Two panes is the limit' }).waitFor()
expect(await panel.locator('[data-dockkit-pane]').count()).toBe(2)
expect(consoleWatch.pageErrors).toEqual([])
})
+40 -2
View File
@@ -20,8 +20,8 @@
// NO_ADAPTER.
import { fileURLToPath } from 'node:url'
import type { Browser, Page } from 'playwright'
import { chromium } from 'playwright'
import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
import { chromium, webkit } from 'playwright'
import { afterAll, beforeAll, describe, expect, it, onTestFailed, onTestFinished } from 'vitest'
import { createMessage, createUserMessage } from '@deepseek-ai/dsh-llm'
import {
SESSION_FORMAT_VERSION,
@@ -374,6 +374,44 @@ describe('web e2e: markdown tables fill the column, wide ones break out and scro
expect(tripwire.pageErrors).toEqual([])
}, 120_000)
it.each(['chromium', 'webkit'] as const)('keeps wide tables horizontally scrollable without hover in %s', async (engine) => {
const touchBrowser = await ({ chromium, webkit })[engine].launch()
onTestFinished(() => touchBrowser.close())
// Mobile WebKit has no wheel driver; touch emulation also disables hover in its desktop viewport.
const context = await touchBrowser.newContext({
viewport: { width: 390, height: 844 }, hasTouch: true, isMobile: engine === 'chromium', locale: 'en-US',
})
const touchPage = await context.newPage()
const touchConsole = watchConsole(touchPage)
onTestFailed(() => saveFailureShot(touchPage, `markdown-wide-table-touch-${engine}`))
await touchPage.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
const group = touchPage.getByRole('treeitem').first()
await touchPage.getByRole('button', { name: 'Open sidebar', exact: true }).first().click()
await group.click()
await touchPage.getByRole('treeitem').filter({ hasText: 'Markdown wide tables' }).click()
await touchPage.getByText(TAIL_MARKER, { exact: true }).waitFor()
expect(await touchPage.evaluate(() => matchMedia('(hover: hover)').matches)).toBe(false)
const wide = touchPage.locator('[class*="tableScroll"]', { hasText: WIDE_MARKER })
await wide.scrollIntoViewIfNeeded()
expect(await wide.evaluate(element => ({
overflow: getComputedStyle(element).overflowX,
overflowing: element.scrollWidth > element.clientWidth,
focused: element.matches(':focus-visible'),
}))).toEqual({ overflow: 'auto', overflowing: true, focused: false })
const box = await wide.boundingBox()
if (box === null) throw new Error('Wide table is not visible')
// A wheel input must scroll the native overflow container without hover or focus.
// Assigning scrollLeft would also work with overflow:hidden and miss this regression.
await touchPage.mouse.move(box.x + box.width / 2, box.y + box.height / 2)
await touchPage.mouse.wheel(5_000, 0)
await expect.poll(() => wide.evaluate(element => element.scrollLeft)).toBeGreaterThan(0)
await expect.poll(() => wide.evaluate((element) => {
const last = element.querySelector('thead th:last-child')!
return last.getBoundingClientRect().right <= element.getBoundingClientRect().right + 1
})).toBe(true)
expect(touchConsole.pageErrors).toEqual([])
})
it('reveals the wide table scrollbar on hover only', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-markdown-wide-table-scrollbar'))
await sweep()
+83 -1
View File
@@ -488,6 +488,88 @@ describe('web e2e: plugin manager', () => {
}
})
it('keeps touch actions direct before keyboard focus and mouse hover', async () => {
const context = await browser.newContext({
viewport: { width: 1280, height: 900 }, locale: ZH_BROWSER_LOCALE,
})
try {
const probe = await context.newPage()
const input = await context.newCDPSession(probe)
// Native touch input preserves desktop hover capability for mixed-input devices.
const tap = async (target: Locator): Promise<void> => {
await target.scrollIntoViewIfNeeded()
const bounds = await target.boundingBox()
if (bounds === null) throw new Error('Touch target is not visible')
await input.send('Input.dispatchTouchEvent', {
type: 'touchStart', touchPoints: [{ x: bounds.x + bounds.width / 2, y: bounds.y + bounds.height / 2 }],
})
await input.send('Input.dispatchTouchEvent', { type: 'touchEnd', touchPoints: [] })
}
const consoleWatch = watchConsole(probe)
onTestFailed(() => saveFailureShot(probe, 'web-e2e-touch-tooltips'))
await probe.clock.install()
await probe.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
await probe.getByRole('button', { name: '插件', exact: true }).click()
const panel = probe.locator('[data-plugin-panel]')
const refresh = panel.getByRole('button', { name: '刷新', exact: true })
const info = panel.getByRole('button', { name: '插件说明', exact: true })
await expect.poll(() => refresh.isEnabled()).toBe(true)
await probe.setViewportSize({ width: 390, height: 844 })
await probe.evaluate(() => document.fonts.ready)
const calls = vi.spyOn(scaffold.ctx.pluginManager, 'listBundles')
try {
await tap(refresh)
await expect.poll(() => calls.mock.calls.length).toBe(1)
await expect.poll(() => refresh.isEnabled()).toBe(true)
} finally {
calls.mockRestore()
}
await probe.clock.pauseAt(await probe.evaluate(() => Date.now() + 1000))
await probe.clock.runFor(500)
expect(await probe.getByRole('tooltip').count()).toBe(0)
const facts = ['Touch refresh: one refresh request; no tooltip']
await tap(info)
const help = probe.getByRole('tooltip')
await help.waitFor()
const bounds = await help.boundingBox()
if (bounds === null) throw new Error('Tapped plugin explanation is not visible')
expect(bounds.x).toBeGreaterThanOrEqual(12)
expect(bounds.x + bounds.width).toBeLessThanOrEqual(390 - 12)
expect(bounds.y).toBeGreaterThanOrEqual(12)
expect(bounds.y + bounds.height).toBeLessThanOrEqual(844 - 12)
facts.push('Touch explanation: visible within the 390px viewport', await help.ariaSnapshot())
await tap(info)
expect(await help.count()).toBe(0)
await tap(info)
await help.waitFor()
await tap(panel.getByRole('heading', { name: '插件', exact: true }))
expect(await help.count()).toBe(0)
facts.push('Explanation closes on another tap or an outside touch')
await probe.keyboard.press('Tab')
await refresh.focus()
await probe.clock.runFor(500)
await probe.getByRole('tooltip').filter({ hasText: '刷新' }).waitFor()
facts.push('Keyboard focus after touch: refresh tooltip visible')
await probe.keyboard.press('Tab')
expect(await probe.evaluate(() => matchMedia('(any-hover: hover)').matches)).toBe(true)
await refresh.hover()
await probe.clock.runFor(500)
await probe.getByRole('tooltip').filter({ hasText: '刷新' }).waitFor()
facts.push('Mouse hover after touch: refresh tooltip visible')
await probe.mouse.move(0, 843)
expect(await help.count()).toBe(0)
await input.detach()
await probe.clock.resume()
await compareOrRefreshGolden(join(SNAPSHOT_DIR, 'touch-tooltips.expected.md'),
['# Touch tooltips', '', ...facts].join('\n'), MODE)
expect(consoleWatch.pageErrors).toEqual([])
} finally {
await context.close()
}
})
it('starts with an unavailable selected bundle and lets the user clear its error', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-plugin-manager-missing-bundle'))
const panel = await openPluginsPanel()
@@ -1140,7 +1222,7 @@ describe('web e2e: plugin manager', () => {
expect(tripwire.warnings).toEqual([])
await assertFixtureInventory(SNAPSHOT_DIR, [
'manager.expected.md', 'live-enabled.expected.md', 'missing-bundle.expected.md', 'exports.expected.md', 'exports-en.expected.md', 'icons.expected.md', 'loading.expected.md', 'refresh.expected.md',
'ime-enter.expected.md', 'header-responsive.expected.md', 'preview.expected.md',
'ime-enter.expected.md', 'header-responsive.expected.md', 'preview.expected.md', 'touch-tooltips.expected.md',
])
})
})
+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)]
}
+25
View File
@@ -175,6 +175,31 @@ fs.appendFileSync(${JSON.stringify(openLog)}, JSON.stringify({ path, action, con
expect(await opened()).toHaveLength(count + 1)
expect((await opened()).at(-1)).toEqual({ action: 'open', path: await realpath(join(cwd, name)), content: bytes })
}
const success = row.locator('[data-presented-file]').filter({ hasText: '说明.txt' }).locator('[data-success]')
await success.waitFor()
const animation = await success.evaluate((element) => {
const style = getComputedStyle(element)
const fade = element.getAnimations()[0]
if (fade === undefined) throw new Error('Successful file feedback has no fade animation')
// Seek the CSS animation so the 200 ms fade does not race the assertion.
fade.pause()
fade.currentTime = 5100
return {
delay: style.animationDelay, duration: style.animationDuration,
fill: style.animationFillMode, opacity: Number(getComputedStyle(element).opacity),
}
})
expect(animation.delay).toBe('5s')
expect(animation.duration).toBe('0.2s')
expect(animation.fill).toBe('forwards')
expect(animation.opacity).toBeGreaterThan(0)
expect(animation.opacity).toBeLessThan(1)
try {
await page.emulateMedia({ reducedMotion: 'reduce' })
expect(await success.evaluate(element => getComputedStyle(element).animationName)).toBe('none')
} finally {
await page.emulateMedia({ reducedMotion: 'no-preference' })
}
}
expect(downloads).toEqual([])
const response = await page.request.get(new URL(`/api/session.export?sessionId=${sessionId}`, scaffold.authenticatedUrl).href)
+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()
}
})
+20 -20
View File
@@ -47,12 +47,13 @@ const AUTO_PARENT_ADJUST = 'AUTO_PARENT_ADJUST'
/** Identify the one-shot Auto Review request and check its request-only outer input. */
function isAutoReviewRequest(options: GenerateOptions): boolean {
if (options.system?.startsWith('REVIEW_POLICY\n') !== true) return false
expect(options.messages).toHaveLength(1)
const message = options.messages[0]
expect(message).toMatchObject({ role: 'user', content: [{ type: 'text' }] })
expect(message?.content).toHaveLength(1)
expect(message).not.toHaveProperty('id')
expect(message).not.toHaveProperty('source')
expect(options.messages.length).toBeGreaterThanOrEqual(2)
for (const message of options.messages) {
expect(message).toMatchObject({ role: 'user', content: [{ type: 'text' }] })
expect(message.content).toHaveLength(1)
expect(message).not.toHaveProperty('id')
expect(message).not.toHaveProperty('source')
}
return true
}
@@ -150,21 +151,19 @@ interface ChildScriptState {
phase: number
}
/** Parse the fixed review request sections emitted by the production plugin. */
/** Parse the history items and pending action emitted by the production plugin. */
function childReviewSections(options: GenerateOptions): Record<string, unknown> {
const block = options.messages[0]?.content[0]
if (block?.type !== 'text') throw new Error('shipped child review request has no text body')
const labels = ['ENVIRONMENT', 'PROJECT_INSTRUCTIONS', 'FILTERED_HISTORY', 'PENDING_ACTION'] as const
const sections: Record<string, unknown> = {}
for (const [index, label] of labels.entries()) {
const prefix = `${label}\n`
const start = block.text.indexOf(prefix)
if (start < 0) throw new Error(`shipped child review request is missing ${label}`)
const next = labels[index + 1]
const end = next === undefined ? block.text.length : block.text.indexOf(`\n\n${next}\n`, start)
sections[label] = JSON.parse(block.text.slice(start + prefix.length, end)) as unknown
const items = options.messages.slice(1).map((message) => {
const block = message.content[0]
if (block?.type !== 'text') throw new Error('shipped child review message has no text body')
return JSON.parse(block.text) as Record<string, unknown>
})
const call = items.at(-1)
if (call?.kind !== 'tool-call') throw new Error('shipped child review request does not end with its pending call')
return {
FILTERED_HISTORY: items.slice(0, -1).filter(item => item.kind !== 'tool-schema'),
PENDING_ACTION: { name: call.name, arguments: JSON.parse(call.arguments as string) as Record<string, unknown> },
}
return sections
}
/** One native tool-call completion in the provider-neutral stream vocabulary. */
@@ -790,7 +789,8 @@ it('routes one browser-authored Auto request through the same model and asks the
.filter(block => block.type === 'text')
.map(block => block.text)
.join('') ?? ''
expect(reviewInput).toContain('PENDING_ACTION')
expect(reviewInput).toContain('ENVIRONMENT')
expect(reviewInput).toContain('"kind": "tool-schema"')
expect(reviewInput).toContain(requestId)
expect(reviewInput).toContain(targetPath)
expect(approvalReasons).toEqual([`Auto review denied tool "bash": ${AUTO_RAW_REASON}`])
+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')
+8 -3
View File
@@ -71,17 +71,22 @@ function measureList(page: Page): Promise<ListMetrics> {
probe.remove()
return value
}
// Computed pseudo style folds in hover even at rest, so inspect the cascade.
// Computed pseudo style folds in hover even at rest, so inspect active CSS groups.
// Cross-origin sheets may throw and cannot contain the app-owned rule.
const styleRules = (rules: CSSRuleList): CSSStyleRule[] => [...rules].flatMap((rule) => {
if (rule instanceof CSSStyleRule) return [rule]
if (rule instanceof CSSMediaRule && !matchMedia(rule.conditionText).matches) return []
if (rule instanceof CSSSupportsRule && !CSS.supports(rule.conditionText)) return []
return rule instanceof CSSGroupingRule ? styleRules(rule.cssRules) : []
})
const hoverRules = [...document.styleSheets]
.flatMap((sheet) => {
try {
return [...sheet.cssRules]
return styleRules(sheet.cssRules)
} catch {
return []
}
})
.filter((rule): rule is CSSStyleRule => rule instanceof CSSStyleRule)
.filter(rule => rule.selectorText === '::-webkit-scrollbar-thumb:hover')
.map(rule => rule.style.getPropertyValue('background-color'))
const style = getComputedStyle(list)
@@ -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
+50 -3
View File
@@ -12,14 +12,15 @@ import { tmpdir } from 'node:os'
import { fileURLToPath } from 'node:url'
import { join } from 'node:path'
import type { Browser, Locator, Page } from 'playwright'
import { chromium } from 'playwright'
import { afterEach, describe, expect, it, onTestFailed, vi } from 'vitest'
import { chromium, devices, webkit } from 'playwright'
import { afterEach, describe, expect, it, onTestFailed, onTestFinished, vi } from 'vitest'
import type { ReplayOverrideDoc } from '@deepseek-ai/dsh-llm-replay'
import type { SessionEvent } from '@deepseek-ai/dsh-session'
import {
acknowledgeReloadConnectionLoss, assertFixtureInventory, captureStableAria, compareOrRefreshGolden, fixtureUserPrompts,
launchWebScaffold, recordFixture, watchConsole, webSnapshotMode, type WebScaffold,
launchWebScaffold, recordFixture, seedSession, watchConsole, webSnapshotMode, type WebScaffold,
} from './scaffold.ts'
import { createChatScrollFixture } from './chat-scroll-fixture.ts'
import { openSettings, connectFreshWorkspace, expandOwningTurnProcess, newEnglishPage, saveFailureShot, WEB_FIXTURE_TIME } from './support.ts'
const SNAPSHOT_DIR = fileURLToPath(new URL('../../../snapshots/web/turn-tail-actions', import.meta.url))
@@ -347,3 +348,49 @@ describe('web e2e: assistant IconActions wait for the turn to end', () => {
)
})
})
it('keeps copy tooltips hidden during a WebKit touch copy', async () => {
const scaffold = await launchWebScaffold({})
const browserPromise = webkit.launch()
onTestFinished(async () => {
try { await (await browserPromise).close() }
finally { await scaffold.close() }
})
const browser = await browserPromise
const context = await browser.newContext({
...devices['iPhone 13'], locale: 'en-US', viewport: { width: 1280, height: 900 },
})
const page = await context.newPage()
// LAN HTTP pages use the clipboard fallback on iOS.
await page.addInitScript(() => {
Object.defineProperty(navigator, 'clipboard', { configurable: true, value: undefined })
})
const fixture = createChatScrollFixture({ markerPrefix: 'TOOLTIP_TOUCH', title: 'Touch tooltips', turns: 1 })
await seedSession(scaffold, fixture.log, 'tooltip-touch-web-e2e')
await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
await connectFreshWorkspace(page, scaffold.workspaceCwd, 'tooltip-touch')
await page.getByRole('button', { name: 'Search sessions' }).click()
await page.getByRole('textbox', { name: 'Search session names', exact: true }).fill(fixture.markers.user(1))
const result = page.getByRole('tree', { name: 'Search results' }).getByRole('treeitem')
await expect.poll(() => result.count(), { timeout: 60_000 }).toBe(1)
await result.click()
await page.getByText(fixture.markers.assistant(1), { exact: false }).last().waitFor()
await page.setViewportSize({ width: 390, height: 844 })
expect(await page.evaluate(() => matchMedia('(any-hover: hover)').matches)).toBe(false)
const copy = page.getByRole('button', { name: 'Copy', exact: true }).last()
await copy.tap()
await expect.poll(() => page.getByRole('button', { name: 'Copied', exact: true }).count()).toBe(1)
const duringCopy = await page.getByRole('tooltip').count()
expect(duringCopy).toBe(0)
await expect.poll(() => page.getByRole('button', { name: 'Copied', exact: true }).count(), { timeout: 10_000 }).toBe(0)
// iOS may synthesize mouse entry when the checkmark is replaced by the copy icon.
await copy.dispatchEvent('pointerover', { pointerType: 'mouse', bubbles: true })
const afterCopy = await page.getByRole('tooltip').count()
expect(afterCopy).toBe(0)
await compareOrRefreshGolden(
fileURLToPath(new URL('./expected/tooltip-touch/copy.expected.md', import.meta.url)),
['# Touch copy feedback', '', 'Copy succeeds and the button returns from Copied to Copy.',
`Tooltips during copy feedback: ${duringCopy}`, `Tooltips after emulated mouse entry: ${afterCopy}`].join('\n'),
webSnapshotMode(),
)
})
+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)
+3
View File
@@ -15,6 +15,7 @@
},
"include": [
"src",
"scripts",
"tests"
],
// The web e2e lane (scaffold + replay specs) boots the host spine and reads
@@ -23,6 +24,7 @@
// cannot see both sides of the cordis Context merges).
"exclude": [
"tests/creation-recommendations.e2e.ts",
"tests/hover-input.e2e.ts",
"tests/collapse-timing.e2e.ts",
"tests/notice-wake-tail.e2e.ts",
"tests/fold-blank-matrix.e2e.ts",
@@ -84,6 +86,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",
+4 -4
View File
@@ -18,11 +18,11 @@
en: b9eeb18645121a8b
zh: 70d87a12c43e042a
/deepseek-harness-architecture/core-packages:
en: f4dde8809a713bad
zh: ccbee814b4e8b4b7
en: 20ef882e44b06a10
zh: c10c3934ed5570e9
/deepseek-harness-architecture/events:
en: c00467ff3a210ef5
zh: 7619d7cca723fa6b
en: e89802f066e4955d
zh: ea2fb4bef94e086d
/deepseek-harness-architecture/turn-flow:
en: e6402b28c0ed15fa
zh: 6c07be9047b42056
-2
View File
@@ -61,7 +61,6 @@ Electron starts the private Desktop Host in Electron Node mode. The Host invokes
| [`core/tools`](subsystems/tools.md) | The scoped tool registry and guarded execution pipeline | `ctx.tools` |
| [`core/agent`](subsystems/core.md) | The `Agent` interface, live registry, and `agent/*` events | `ctx.agents` |
| [`core/agent-loop`](subsystems/core.md) | The default driver implementing that interface | `ctx.agentLoop` |
| [`core/agent-controller`](subsystems/core.md#messages-from-outside-an-agent) | Admission and delivery of outside messages | `ctx.agentController` |
| [`core/scope`](subsystems/scope.md) | The per-agent scoped-registration primitive | library, no key |
| [`llm/llm`](subsystems/llm-streaming.md) | Message and stream vocabulary plus the adapter seam | `ctx.llm` |
@@ -71,7 +70,6 @@ Events are the extension points, and picking the right domain is the first decis
- **Session events** are durable facts appended to the log and broadcast through `session/event`. Use one when the fact must survive a reload.
- **Agent events** (`agent/*`) carry a live `Agent`: inbox, step, status, request, validation, continuation. Use one to observe or intercept work in flight.
- **Delivery admission** (`agent-controller/admission`) holds or refuses outside input before it enters the inbox.
- **Capability events** attach policy and adapters to a seam (`fs/*`, `tools/*`, `telemetry/*`) without importing the loop.
AgentLoop awaits serial `agent/created` before queued work and rolls back failed initialization. Unload retains resources through Agent teardown; [agent-loop](../packages/core/agent-loop/README.md#understand-the-implementation) defines ordering.
-2
View File
@@ -61,7 +61,6 @@ Electron 使用 Electron Node 模式启动私有 Desktop Host。Host 调用共
| [`core/tools`](subsystems/tools.zh.md) | 作用域化的工具注册表和带把关的执行流水线 | `ctx.tools` |
| [`core/agent`](subsystems/core.zh.md) | `Agent` 接口、活跃 agent 注册表和 `agent/*` 事件 | `ctx.agents` |
| [`core/agent-loop`](subsystems/core.zh.md) | 实现该接口的默认驱动器 | `ctx.agentLoop` |
| [`core/agent-controller`](subsystems/core.zh.md#messages-from-outside-an-agent) | 外部消息的准入与投递 | `ctx.agentController` |
| [`core/scope`](subsystems/scope.zh.md) | 按 agent 划分作用域的注册原语 | 库,无 ctx 键 |
| [`llm/llm`](subsystems/llm-streaming.zh.md) | 消息与流式词汇表,以及适配器 seam | `ctx.llm` |
@@ -73,7 +72,6 @@ Electron 使用 Electron Node 模式启动私有 Desktop Host。Host 调用共
- **会话事件**是追加到日志并通过 `session/event` 广播的持久事实。当某个事实必须在重新加载后仍然存在时,使用它。
- **Agent 事件**(`agent/*`)携带活跃 `Agent`:inbox、步骤、状态、请求、验证、续跑。要观察或拦截进行中的工作时,使用它。
- **投递准入**(`agent-controller/admission`)在外部输入进入收件箱之前暂留或拒绝它。
- **能力事件**无需导入循环即可向某个 seam(`fs/*`、`tools/*`、`telemetry/*`)附加策略和适配器。
AgentLoop 在启动已排队工作前等待串行 `agent/created`,初始化失败则回滚。卸载在 Agent teardown 期间保留所需资源;[agent-loop](../packages/core/agent-loop/README.zh.md#understand-the-implementation)定义顺序。
+2 -2
View File
@@ -6,5 +6,5 @@
en: 41d772192075e133
zh: 5ef3da69b4de1d8a
/capability-seams-and-core-services:
en: 10d60e051766df80
zh: 7e6b668b48f2e015
en: 91dcdb8544d0724b
zh: 250f61f3a84f2fb5
-5
View File
@@ -194,8 +194,6 @@ flowchart LR
pkg_skill_office["skill-office"]
svc_agents["ctx.agents<br/>Agent service"]
pkg_acp["acp"]
pkg_agent_controller["agent-controller"]
svc_agentController["ctx.agentController<br/>Outside-message delivery"]
svc_agentDefaultModel["ctx.agentDefaultModel<br/>Default Agent model selection"]
svc_agentLoop["ctx.agentLoop<br/>Concrete loop driver"]
pkg_base["base"]
@@ -303,7 +301,6 @@ flowchart LR
svc_dynamicCordisRunner["ctx.dynamicCordisRunner<br/>Dynamic Cordis package host runner"]
svc_cordisInspect["ctx.cordisInspect<br/>Dynamic Cordis inspect registry"]
pkg_agent --> svc_agents
pkg_agent_controller --> svc_agentController
pkg_agent_default_model --> svc_agentDefaultModel
pkg_agent_loop --> svc_agentLoop
pkg_agent_preset_registry --> svc_agentPresets
@@ -463,7 +460,6 @@ flowchart LR
pkg_working_directory --> svc_workingDirectory
pkg_workspace --> svc_workspaceRegistry
pkg_workspace_changes --> svc_workspaceChanges
svc_agentController --> pkg_api_session_controller
svc_agentDefaultModel --> pkg_api_session_controller
svc_agentDefaultModel --> pkg_headless
svc_agentLoop --> pkg_base
@@ -696,7 +692,6 @@ flowchart LR
| `ctx.sessionProjectionCache` | `core` | [`session-projection-cache`](../packages/session/session-projection-cache) | - | [`api-session-controller`](../packages/api/session-controller), [`session-query`](../packages/session-query/session-query), [`session-reference`](../packages/context/session-reference) | - | Durably checkpoints projection unit states per session (throttled + turn/end/detach mandatory points), serves cached projection views, and accelerates prepared-Session projection hydration. |
| `ctx.skills` | `seam` | [`skill`](../packages/skill/skill) | [`sandbox-windows-acl`](../packages/sandbox/sandbox-windows-acl), [`skill-badge`](../packages/experimental/skill-badge), [`skill-filesystem`](../packages/skill/skill-filesystem), [`skill-office`](../packages/skill/skill-office) | [`tool-skill`](../packages/skill/tool-skill) | - | Merges provider skill catalogs; tool-skill renders the session-prefix catalog and loads complete skill bodies. |
| `ctx.agents` | `core` | [`agent`](../packages/core/agent) | - | [`agent-loop`](../packages/core/agent-loop), [`acp`](../packages/acp/acp), [`subagent`](../packages/subagent/subagent) | - | Owns live Agent handles, the create/resume factory seam, and process-local initiator propagation. |
| `ctx.agentController` | `core` | [`agent-controller`](../packages/core/agent-controller) | - | [`api-session-controller`](../packages/api/session-controller) | - | Prepares, admits through the agent-controller/admission stage, and appends messages that senders bring on someone's behalf, one at a time per Agent. |
| `ctx.agentDefaultModel` | `core` | [`agent-default-model`](../packages/core/agent-default-model) | - | [`api-session-controller`](../packages/api/session-controller), [`headless`](../packages/bundle/headless) | - | Reads the default ModelSelection from volatile Config and saves selections through the profile editor. |
| `ctx.agentLoop` | `bundle` | [`agent-loop`](../packages/core/agent-loop) | - | [`base`](../packages/bundle/base), [`sdk-minimal`](../packages/bundle/sdk-minimal) | - | The one concrete loop plugin; extension packages depend on dsh-agent events and services, not on this package. |
| `ctx.schedule` | `core` | [`schedule`](../packages/schedule/schedule) | - | - | - | Stores tasks independently of Session activation and queues due messages in the original Session. |
-5
View File
@@ -196,8 +196,6 @@ flowchart LR
pkg_skill_office["skill-office"]
svc_agents["ctx.agents<br/>Agent service"]
pkg_acp["acp"]
pkg_agent_controller["agent-controller"]
svc_agentController["ctx.agentController<br/>Outside-message delivery"]
svc_agentDefaultModel["ctx.agentDefaultModel<br/>Default Agent model selection"]
svc_agentLoop["ctx.agentLoop<br/>Concrete loop driver"]
pkg_base["base"]
@@ -305,7 +303,6 @@ flowchart LR
svc_dynamicCordisRunner["ctx.dynamicCordisRunner<br/>Dynamic Cordis package host runner"]
svc_cordisInspect["ctx.cordisInspect<br/>Dynamic Cordis inspect registry"]
pkg_agent --> svc_agents
pkg_agent_controller --> svc_agentController
pkg_agent_default_model --> svc_agentDefaultModel
pkg_agent_loop --> svc_agentLoop
pkg_agent_preset_registry --> svc_agentPresets
@@ -465,7 +462,6 @@ flowchart LR
pkg_working_directory --> svc_workingDirectory
pkg_workspace --> svc_workspaceRegistry
pkg_workspace_changes --> svc_workspaceChanges
svc_agentController --> pkg_api_session_controller
svc_agentDefaultModel --> pkg_api_session_controller
svc_agentDefaultModel --> pkg_headless
svc_agentLoop --> pkg_base
@@ -698,7 +694,6 @@ flowchart LR
| `ctx.sessionProjectionCache` | `core` | [`session-projection-cache`](../packages/session/session-projection-cache) | - | [`api-session-controller`](../packages/api/session-controller), [`session-query`](../packages/session-query/session-query), [`session-reference`](../packages/context/session-reference) | - | 按会话持久保存投影单元状态的检查点(节流检查点,以及轮次/结束/分离时的必选检查点),提供缓存投影视图,并加速 prepared Session 的投影恢复。 |
| `ctx.skills` | `seam` | [`skill`](../packages/skill/skill) | [`sandbox-windows-acl`](../packages/sandbox/sandbox-windows-acl), [`skill-badge`](../packages/experimental/skill-badge), [`skill-filesystem`](../packages/skill/skill-filesystem), [`skill-office`](../packages/skill/skill-office) | [`tool-skill`](../packages/skill/tool-skill) | - | 合并提供方的 skill(技能)目录;tool-skill 渲染会话前缀目录,并加载完整的 skill 正文。 |
| `ctx.agents` | `core` | [`agent`](../packages/core/agent) | - | [`agent-loop`](../packages/core/agent-loop), [`acp`](../packages/acp/acp), [`subagent`](../packages/subagent/subagent) | - | 拥有实时 Agent 句柄、创建/恢复工厂 seam,以及进程本地的发起方传播。 |
| `ctx.agentController` | `core` | [`agent-controller`](../packages/core/agent-controller) | - | [`api-session-controller`](../packages/api/session-controller) | - | 准备发送方代表他人送来的消息,经 agent-controller/admission 阶段接纳后放入收件箱,每个 Agent 逐条处理。 |
| `ctx.agentDefaultModel` | `core` | [`agent-default-model`](../packages/core/agent-default-model) | - | [`api-session-controller`](../packages/api/session-controller), [`headless`](../packages/bundle/headless) | - | Reads the default ModelSelection from volatile Config and saves selections through the profile editor. |
| `ctx.agentLoop` | `bundle` | [`agent-loop`](../packages/core/agent-loop) | - | [`base`](../packages/bundle/base), [`sdk-minimal`](../packages/bundle/sdk-minimal) | - | 唯一的具体循环插件;扩展包依赖 dsh-agent 的事件和服务,而不依赖此包。 |
| `ctx.schedule` | `core` | [`schedule`](../packages/schedule/schedule) | - | - | - | 独立于 Session 的加载状态存储任务,并将到期消息排入原 Session。 |
+3 -3
View File
@@ -225,7 +225,7 @@ export interface Config {
## `@deepseek-ai/dsh-api-session-controller`
- `inject`: `agentController` · `agentDefaultModel` · `agents` · `attachments` · `fileUploads` · `fs` · `llm` · `remoteAccess` · `sessions` · `sessionProjections` · `sessionQuery` · `typert` · `workspaceRegistry`
- `inject`: `agentDefaultModel` · `agents` · `attachments` · `fileUploads` · `fs` · `llm` · `remoteAccess` · `sessions` · `sessionProjections` · `sessionQuery` · `typert` · `workspaceRegistry`
- `source`: [`packages/api/session-controller/src/index.ts:93`](../packages/api/session-controller/src/index.ts)
```ts config-catalog
@@ -4698,7 +4698,6 @@ These load from a `cordis.yml` entry with no `config:` block; they declare no co
| --- | --- | --- |
| `@deepseek-ai/dsh-acp-app` | `cmdlineArgs` | [`packages/bundle/acp-app/src/index.ts`](../packages/bundle/acp-app/src/index.ts) |
| `@deepseek-ai/dsh-agent` | `sessionProjections` | [`packages/core/agent/src/index.ts`](../packages/core/agent/src/index.ts) |
| `@deepseek-ai/dsh-agent-controller` | `agents` | [`packages/core/agent-controller/src/index.ts`](../packages/core/agent-controller/src/index.ts) |
| `@deepseek-ai/dsh-api-account-controller` | `deepseekAccount` · `agents` | [`packages/api/account-controller/src/index.ts`](../packages/api/account-controller/src/index.ts) |
| `@deepseek-ai/dsh-api-remote-access` | — | [`packages/api/remote-access/src/index.ts`](../packages/api/remote-access/src/index.ts) |
| `@deepseek-ai/dsh-api-remotes` | `typertGateway` | [`packages/api/remotes/src/index.ts`](../packages/api/remotes/src/index.ts) |
@@ -4720,6 +4719,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) |
@@ -4774,7 +4774,7 @@ These load from a `cordis.yml` entry with no `config:` block; they declare no co
| `@deepseek-ai/dsh-experimental-client-ui-creation-recommendations` | — | [`packages/experimental/client-ui-creation-recommendations/src/index.ts`](../packages/experimental/client-ui-creation-recommendations/src/index.ts) |
| `@deepseek-ai/dsh-experimental-client-ui-voice-input` | — | [`packages/experimental/client-ui-voice-input/src/index.ts`](../packages/experimental/client-ui-voice-input/src/index.ts) |
| `@deepseek-ai/dsh-experimental-computer-use-cua-driver-native` | `computerUse` · `tools` · `systemPrompt` | [`packages/experimental/computer-use-cua-driver-native/src/index.ts`](../packages/experimental/computer-use-cua-driver-native/src/index.ts) |
| `@deepseek-ai/dsh-experimental-creation-recommendations` | `tools` · `pluginManager` · `agents` · `sessionProjections` · `remoteAccess` · `subagents` · `profileContext` · `skills` | [`packages/experimental/creation-recommendations/src/index.ts`](../packages/experimental/creation-recommendations/src/index.ts) |
| `@deepseek-ai/dsh-experimental-creation-recommendations` | — | [`packages/experimental/creation-recommendations/src/index.ts`](../packages/experimental/creation-recommendations/src/index.ts) |
| `@deepseek-ai/dsh-experimental-session-inspector` | — | [`packages/experimental/session-inspector/src/index.ts`](../packages/experimental/session-inspector/src/index.ts) |
| `@deepseek-ai/dsh-experimental-tool-worktree` | `tools` · `worktrees` | [`packages/experimental/tool-worktree/src/index.ts`](../packages/experimental/tool-worktree/src/index.ts) |
| `@deepseek-ai/dsh-fs-observation-policy` | — | [`packages/fs/fs-observation-policy/src/index.ts`](../packages/fs/fs-observation-policy/src/index.ts) |
+3 -3
View File
@@ -227,7 +227,7 @@ export interface Config {
## `@deepseek-ai/dsh-api-session-controller`
- `inject`: `agentController` · `agentDefaultModel` · `agents` · `attachments` · `fileUploads` · `fs` · `llm` · `remoteAccess` · `sessions` · `sessionProjections` · `sessionQuery` · `typert` · `workspaceRegistry`
- `inject`: `agentDefaultModel` · `agents` · `attachments` · `fileUploads` · `fs` · `llm` · `remoteAccess` · `sessions` · `sessionProjections` · `sessionQuery` · `typert` · `workspaceRegistry`
- `source`: [`packages/api/session-controller/src/index.ts:93`](../packages/api/session-controller/src/index.ts)
```ts config-catalog
@@ -4700,7 +4700,6 @@ export interface Config {
| --- | --- | --- |
| `@deepseek-ai/dsh-acp-app` | `cmdlineArgs` | [`packages/bundle/acp-app/src/index.ts`](../packages/bundle/acp-app/src/index.ts) |
| `@deepseek-ai/dsh-agent` | `sessionProjections` | [`packages/core/agent/src/index.ts`](../packages/core/agent/src/index.ts) |
| `@deepseek-ai/dsh-agent-controller` | `agents` | [`packages/core/agent-controller/src/index.ts`](../packages/core/agent-controller/src/index.ts) |
| `@deepseek-ai/dsh-api-account-controller` | `deepseekAccount` · `agents` | [`packages/api/account-controller/src/index.ts`](../packages/api/account-controller/src/index.ts) |
| `@deepseek-ai/dsh-api-remote-access` | — | [`packages/api/remote-access/src/index.ts`](../packages/api/remote-access/src/index.ts) |
| `@deepseek-ai/dsh-api-remotes` | `typertGateway` | [`packages/api/remotes/src/index.ts`](../packages/api/remotes/src/index.ts) |
@@ -4722,6 +4721,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) |
@@ -4776,7 +4776,7 @@ export interface Config {
| `@deepseek-ai/dsh-experimental-client-ui-creation-recommendations` | — | [`packages/experimental/client-ui-creation-recommendations/src/index.ts`](../packages/experimental/client-ui-creation-recommendations/src/index.ts) |
| `@deepseek-ai/dsh-experimental-client-ui-voice-input` | — | [`packages/experimental/client-ui-voice-input/src/index.ts`](../packages/experimental/client-ui-voice-input/src/index.ts) |
| `@deepseek-ai/dsh-experimental-computer-use-cua-driver-native` | `computerUse` · `tools` · `systemPrompt` | [`packages/experimental/computer-use-cua-driver-native/src/index.ts`](../packages/experimental/computer-use-cua-driver-native/src/index.ts) |
| `@deepseek-ai/dsh-experimental-creation-recommendations` | `tools` · `pluginManager` · `agents` · `sessionProjections` · `remoteAccess` · `subagents` · `profileContext` · `skills` | [`packages/experimental/creation-recommendations/src/index.ts`](../packages/experimental/creation-recommendations/src/index.ts) |
| `@deepseek-ai/dsh-experimental-creation-recommendations` | — | [`packages/experimental/creation-recommendations/src/index.ts`](../packages/experimental/creation-recommendations/src/index.ts) |
| `@deepseek-ai/dsh-experimental-session-inspector` | — | [`packages/experimental/session-inspector/src/index.ts`](../packages/experimental/session-inspector/src/index.ts) |
| `@deepseek-ai/dsh-experimental-tool-worktree` | `tools` · `worktrees` | [`packages/experimental/tool-worktree/src/index.ts`](../packages/experimental/tool-worktree/src/index.ts) |
| `@deepseek-ai/dsh-fs-observation-policy` | — | [`packages/fs/fs-observation-policy/src/index.ts`](../packages/fs/fs-observation-policy/src/index.ts) |
+5 -6
View File
@@ -8,7 +8,6 @@ This matrix shows which packages dispatch each harness-owned event and which pac
<!-- BEGIN GENERATED event-producer-consumer:events -->
| Event | Mode | Declared in | Dispatchers | Listeners |
| --- | --- | --- | --- | --- |
| `agent-controller/admission` | `waterfall` | [`packages/core/agent-controller/src/types.ts:79`](../packages/core/agent-controller/src/types.ts) | [`agent-controller`](../packages/core/agent-controller) (`waterfall`) | - |
| `agent-loop/config-start-failed` | `emit` | [`packages/core/agent-loop/src/index.ts:196`](../packages/core/agent-loop/src/index.ts) | [`agent-loop`](../packages/core/agent-loop) (`events.dispatch`) | - |
| `agent-preset/selected` | `emit` | [`packages/preset/agent-preset-registry/src/types.ts:66`](../packages/preset/agent-preset-registry/src/types.ts) | [`agent-preset-registry`](../packages/preset/agent-preset-registry) (`emit`) | [`cua`](../packages/experimental/cua), `remotes` |
| `agent/assistant-stream` | `emit` | [`packages/core/agent/src/runtime-types.ts:363`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`headless`](../packages/bundle/headless), `session-controller` |
@@ -23,11 +22,11 @@ This matrix shows which packages dispatch each harness-owned event and which pac
| `agent/request-error` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:353`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`compaction-basic`](../packages/compaction/compaction-basic), [`compaction-image-offload`](../packages/compaction/compaction-image-offload), [`llm-retry`](../packages/llm/llm-retry) |
| `agent/status` | `emit` | [`packages/core/agent/src/runtime-types.ts:280`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), `agent-team`, [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), `server`, `session-controller` |
| `agent/turn-stopping` | `serial` | [`packages/core/agent/src/runtime-types.ts:381`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`hooks-claude-code`](../packages/experimental/hooks-claude-code), [`hooks-codex`](../packages/experimental/hooks-codex), [`workspace-changes`](../packages/deliverables/workspace-changes) |
| `api-session/activity` | `emit` | [`packages/api/session-controller/src/types.ts:632`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
| `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:612`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
| `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:639`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
| `api-session/removed` | `emit` | [`packages/api/session-controller/src/types.ts:618`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
| `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:625`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
| `api-session/activity` | `emit` | [`packages/api/session-controller/src/types.ts:626`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
| `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:606`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
| `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:633`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
| `api-session/removed` | `emit` | [`packages/api/session-controller/src/types.ts:612`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
| `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:619`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
| `app-boot/config-reload` | `emit` | [`packages/boot/app-boot/src/index.ts:54`](../packages/boot/app-boot/src/index.ts) | [`app-boot`](../packages/boot/app-boot) (`emit`) | [`settings`](../packages/settings/settings) |
| `approval/request` | `waterfall` | [`packages/interaction/user-approval/src/types.ts:87`](../packages/interaction/user-approval/src/types.ts) | [`user-approval`](../packages/interaction/user-approval) (`waterfall`) | [`acp`](../packages/acp/acp), `remotes` |
| `authorization/settled` | `emit` | [`packages/credentials/authorization/src/index.ts:57`](../packages/credentials/authorization/src/index.ts) | [`authorization`](../packages/credentials/authorization) (`events.dispatch`) | - |
+5 -6
View File
@@ -10,7 +10,6 @@
<!-- BEGIN GENERATED event-producer-consumer:events -->
| Event | Mode | Declared in | Dispatchers | Listeners |
| --- | --- | --- | --- | --- |
| `agent-controller/admission` | `waterfall` | [`packages/core/agent-controller/src/types.ts:79`](../packages/core/agent-controller/src/types.ts) | [`agent-controller`](../packages/core/agent-controller) (`waterfall`) | - |
| `agent-loop/config-start-failed` | `emit` | [`packages/core/agent-loop/src/index.ts:196`](../packages/core/agent-loop/src/index.ts) | [`agent-loop`](../packages/core/agent-loop) (`events.dispatch`) | - |
| `agent-preset/selected` | `emit` | [`packages/preset/agent-preset-registry/src/types.ts:66`](../packages/preset/agent-preset-registry/src/types.ts) | [`agent-preset-registry`](../packages/preset/agent-preset-registry) (`emit`) | [`cua`](../packages/experimental/cua), `remotes` |
| `agent/assistant-stream` | `emit` | [`packages/core/agent/src/runtime-types.ts:363`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`headless`](../packages/bundle/headless), `session-controller` |
@@ -25,11 +24,11 @@
| `agent/request-error` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:353`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`compaction-basic`](../packages/compaction/compaction-basic), [`compaction-image-offload`](../packages/compaction/compaction-image-offload), [`llm-retry`](../packages/llm/llm-retry) |
| `agent/status` | `emit` | [`packages/core/agent/src/runtime-types.ts:280`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), `agent-team`, [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), `server`, `session-controller` |
| `agent/turn-stopping` | `serial` | [`packages/core/agent/src/runtime-types.ts:381`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`hooks-claude-code`](../packages/experimental/hooks-claude-code), [`hooks-codex`](../packages/experimental/hooks-codex), [`workspace-changes`](../packages/deliverables/workspace-changes) |
| `api-session/activity` | `emit` | [`packages/api/session-controller/src/types.ts:632`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
| `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:612`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
| `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:639`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
| `api-session/removed` | `emit` | [`packages/api/session-controller/src/types.ts:618`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
| `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:625`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
| `api-session/activity` | `emit` | [`packages/api/session-controller/src/types.ts:626`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
| `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:606`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
| `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:633`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
| `api-session/removed` | `emit` | [`packages/api/session-controller/src/types.ts:612`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
| `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:619`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
| `app-boot/config-reload` | `emit` | [`packages/boot/app-boot/src/index.ts:54`](../packages/boot/app-boot/src/index.ts) | [`app-boot`](../packages/boot/app-boot) (`emit`) | [`settings`](../packages/settings/settings) |
| `approval/request` | `waterfall` | [`packages/interaction/user-approval/src/types.ts:87`](../packages/interaction/user-approval/src/types.ts) | [`user-approval`](../packages/interaction/user-approval) (`waterfall`) | [`acp`](../packages/acp/acp), `remotes` |
| `authorization/settled` | `emit` | [`packages/credentials/authorization/src/index.ts:57`](../packages/credentials/authorization/src/index.ts) | [`authorization`](../packages/credentials/authorization) (`events.dispatch`) | - |
+3 -7
View File
@@ -41,7 +41,6 @@ flowchart TD
end
subgraph group_core["packages/core"]
pkg_agent["agent"]
pkg_agent_controller["agent-controller"]
pkg_agent_default_model["agent-default-model"]
pkg_agent_loop["agent-loop"]
pkg_agent_tool_presentation["agent-tool-presentation"]
@@ -168,6 +167,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"]
@@ -642,9 +642,6 @@ flowchart TD
pkg_llm_retry --> pkg_session
pkg_llm_retry --> pkg_session_projection
pkg_llm_retry --> pkg_timeout
pkg_agent_controller --> pkg_agent
pkg_agent_controller --> pkg_llm
pkg_agent_controller --> pkg_scope
pkg_agent_default_model --> pkg_agent
pkg_agent_default_model --> pkg_llm
pkg_goal --> pkg_agent
@@ -1280,7 +1277,6 @@ flowchart TD
pkg_tool_subagent_control --> pkg_subagent
pkg_tool_subagent_control --> pkg_tools
pkg_api_session_controller --> pkg_agent
pkg_api_session_controller --> pkg_agent_controller
pkg_api_session_controller --> pkg_agent_default_model
pkg_api_session_controller --> pkg_agent_preset_registry
pkg_api_session_controller --> pkg_api_gateway
@@ -1501,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` | — |
@@ -1661,7 +1658,6 @@ flowchart TD
| [`pwsh-local`](../packages/shell/pwsh-local) | `shell` | [`shell`](../packages/shell/shell), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) |
| [`llm-deepseek-api-key`](../packages/llm/llm-deepseek-api-key) | `llm` | [`credentials`](../packages/credentials/credentials), [`launch-environment`](../packages/util/launch-environment), [`llm`](../packages/llm/llm), [`llm-deepseek`](../packages/llm/llm-deepseek) |
| [`llm-retry`](../packages/llm/llm-retry) | `llm` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`timeout`](../packages/util/timeout) |
| [`agent-controller`](../packages/core/agent-controller) | `core` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope) |
| [`agent-default-model`](../packages/core/agent-default-model) | `core` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm) |
| [`goal`](../packages/goal/goal) | `goal` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`typert-protocol`](../packages/typert/protocol) |
| [`file-reference`](../packages/context/file-reference) | `context` | [`agent`](../packages/core/agent) |
@@ -1781,7 +1777,7 @@ flowchart TD
| [`subagent-spawn-in-process`](../packages/subagent/subagent-spawn-in-process) | `subagent` | [`subagent`](../packages/subagent/subagent) |
| [`tool-subagent`](../packages/subagent/tool-subagent) | `subagent` | [`agent`](../packages/core/agent), [`agent-preset-registry`](../packages/preset/agent-preset-registry), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) |
| [`tool-subagent-control`](../packages/subagent/tool-subagent-control) | `subagent` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools) |
| [`api-session-controller`](../packages/api/session-controller) | `api` | [`agent`](../packages/core/agent), [`agent-controller`](../packages/core/agent-controller), [`agent-default-model`](../packages/core/agent-default-model), [`agent-preset-registry`](../packages/preset/agent-preset-registry), [`api-gateway`](../packages/api/gateway), [`attachment`](../packages/attachment/attachment), [`client-connection`](../packages/client/connection), [`client-file-upload`](../packages/client/file-upload), [`commands`](../packages/interaction/commands), [`credentials`](../packages/credentials/credentials), [`file-reference`](../packages/context/file-reference), [`fs`](../packages/fs/fs), [`llm`](../packages/llm/llm), [`native-command`](../packages/util/native-command), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-query`](../packages/session-query/session-query), [`session-title`](../packages/session/session-title), [`settings`](../packages/settings/settings), [`skill`](../packages/skill/skill), [`subagent`](../packages/subagent/subagent), [`typert-protocol`](../packages/typert/protocol), [`typert-registry`](../packages/typert/registry), [`util-time`](../packages/util/time), [`util-values`](../packages/util/values), [`util-workspace-path`](../packages/util/workspace-path), [`working-directory`](../packages/session/working-directory), [`workspace`](../packages/workspace/workspace) |
| [`api-session-controller`](../packages/api/session-controller) | `api` | [`agent`](../packages/core/agent), [`agent-default-model`](../packages/core/agent-default-model), [`agent-preset-registry`](../packages/preset/agent-preset-registry), [`api-gateway`](../packages/api/gateway), [`attachment`](../packages/attachment/attachment), [`client-connection`](../packages/client/connection), [`client-file-upload`](../packages/client/file-upload), [`commands`](../packages/interaction/commands), [`credentials`](../packages/credentials/credentials), [`file-reference`](../packages/context/file-reference), [`fs`](../packages/fs/fs), [`llm`](../packages/llm/llm), [`native-command`](../packages/util/native-command), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-query`](../packages/session-query/session-query), [`session-title`](../packages/session/session-title), [`settings`](../packages/settings/settings), [`skill`](../packages/skill/skill), [`subagent`](../packages/subagent/subagent), [`typert-protocol`](../packages/typert/protocol), [`typert-registry`](../packages/typert/registry), [`util-time`](../packages/util/time), [`util-values`](../packages/util/values), [`util-workspace-path`](../packages/util/workspace-path), [`working-directory`](../packages/session/working-directory), [`workspace`](../packages/workspace/workspace) |
| [`plugin-market`](../packages/boot/plugin-market) | `boot` | [`plugin-manager`](../packages/boot/plugin-manager), [`settings`](../packages/settings/settings), [`subprocess`](../packages/subprocess/subprocess), [`tools`](../packages/core/tools), [`typert-protocol`](../packages/typert/protocol) |
| [`headless`](../packages/bundle/headless) | `bundle` | [`agent`](../packages/core/agent), [`agent-default-model`](../packages/core/agent-default-model), [`fs`](../packages/fs/fs), [`llm`](../packages/llm/llm), [`permission-presets`](../packages/interaction/permission-presets), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`session-query`](../packages/session-query/session-query), [`subagent`](../packages/subagent/subagent), [`user-approval`](../packages/interaction/user-approval) |
| [`compaction-basic`](../packages/compaction/compaction-basic) | `compaction` | [`agent`](../packages/core/agent), [`commands`](../packages/interaction/commands), [`compaction`](../packages/compaction/compaction), [`compaction-tool-result-pruner`](../packages/compaction/compaction-tool-result-pruner), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`token-meter`](../packages/llm/token-meter) |
+3 -7
View File
@@ -43,7 +43,6 @@ flowchart TD
end
subgraph group_core["packages/core"]
pkg_agent["agent"]
pkg_agent_controller["agent-controller"]
pkg_agent_default_model["agent-default-model"]
pkg_agent_loop["agent-loop"]
pkg_agent_tool_presentation["agent-tool-presentation"]
@@ -170,6 +169,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"]
@@ -644,9 +644,6 @@ flowchart TD
pkg_llm_retry --> pkg_session
pkg_llm_retry --> pkg_session_projection
pkg_llm_retry --> pkg_timeout
pkg_agent_controller --> pkg_agent
pkg_agent_controller --> pkg_llm
pkg_agent_controller --> pkg_scope
pkg_agent_default_model --> pkg_agent
pkg_agent_default_model --> pkg_llm
pkg_goal --> pkg_agent
@@ -1282,7 +1279,6 @@ flowchart TD
pkg_tool_subagent_control --> pkg_subagent
pkg_tool_subagent_control --> pkg_tools
pkg_api_session_controller --> pkg_agent
pkg_api_session_controller --> pkg_agent_controller
pkg_api_session_controller --> pkg_agent_default_model
pkg_api_session_controller --> pkg_agent_preset_registry
pkg_api_session_controller --> pkg_api_gateway
@@ -1503,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` | — |
@@ -1663,7 +1660,6 @@ flowchart TD
| [`pwsh-local`](../packages/shell/pwsh-local) | `shell` | [`shell`](../packages/shell/shell), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) |
| [`llm-deepseek-api-key`](../packages/llm/llm-deepseek-api-key) | `llm` | [`credentials`](../packages/credentials/credentials), [`launch-environment`](../packages/util/launch-environment), [`llm`](../packages/llm/llm), [`llm-deepseek`](../packages/llm/llm-deepseek) |
| [`llm-retry`](../packages/llm/llm-retry) | `llm` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`timeout`](../packages/util/timeout) |
| [`agent-controller`](../packages/core/agent-controller) | `core` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope) |
| [`agent-default-model`](../packages/core/agent-default-model) | `core` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm) |
| [`goal`](../packages/goal/goal) | `goal` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`typert-protocol`](../packages/typert/protocol) |
| [`file-reference`](../packages/context/file-reference) | `context` | [`agent`](../packages/core/agent) |
@@ -1783,7 +1779,7 @@ flowchart TD
| [`subagent-spawn-in-process`](../packages/subagent/subagent-spawn-in-process) | `subagent` | [`subagent`](../packages/subagent/subagent) |
| [`tool-subagent`](../packages/subagent/tool-subagent) | `subagent` | [`agent`](../packages/core/agent), [`agent-preset-registry`](../packages/preset/agent-preset-registry), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) |
| [`tool-subagent-control`](../packages/subagent/tool-subagent-control) | `subagent` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools) |
| [`api-session-controller`](../packages/api/session-controller) | `api` | [`agent`](../packages/core/agent), [`agent-controller`](../packages/core/agent-controller), [`agent-default-model`](../packages/core/agent-default-model), [`agent-preset-registry`](../packages/preset/agent-preset-registry), [`api-gateway`](../packages/api/gateway), [`attachment`](../packages/attachment/attachment), [`client-connection`](../packages/client/connection), [`client-file-upload`](../packages/client/file-upload), [`commands`](../packages/interaction/commands), [`credentials`](../packages/credentials/credentials), [`file-reference`](../packages/context/file-reference), [`fs`](../packages/fs/fs), [`llm`](../packages/llm/llm), [`native-command`](../packages/util/native-command), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-query`](../packages/session-query/session-query), [`session-title`](../packages/session/session-title), [`settings`](../packages/settings/settings), [`skill`](../packages/skill/skill), [`subagent`](../packages/subagent/subagent), [`typert-protocol`](../packages/typert/protocol), [`typert-registry`](../packages/typert/registry), [`util-time`](../packages/util/time), [`util-values`](../packages/util/values), [`util-workspace-path`](../packages/util/workspace-path), [`working-directory`](../packages/session/working-directory), [`workspace`](../packages/workspace/workspace) |
| [`api-session-controller`](../packages/api/session-controller) | `api` | [`agent`](../packages/core/agent), [`agent-default-model`](../packages/core/agent-default-model), [`agent-preset-registry`](../packages/preset/agent-preset-registry), [`api-gateway`](../packages/api/gateway), [`attachment`](../packages/attachment/attachment), [`client-connection`](../packages/client/connection), [`client-file-upload`](../packages/client/file-upload), [`commands`](../packages/interaction/commands), [`credentials`](../packages/credentials/credentials), [`file-reference`](../packages/context/file-reference), [`fs`](../packages/fs/fs), [`llm`](../packages/llm/llm), [`native-command`](../packages/util/native-command), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-query`](../packages/session-query/session-query), [`session-title`](../packages/session/session-title), [`settings`](../packages/settings/settings), [`skill`](../packages/skill/skill), [`subagent`](../packages/subagent/subagent), [`typert-protocol`](../packages/typert/protocol), [`typert-registry`](../packages/typert/registry), [`util-time`](../packages/util/time), [`util-values`](../packages/util/values), [`util-workspace-path`](../packages/util/workspace-path), [`working-directory`](../packages/session/working-directory), [`workspace`](../packages/workspace/workspace) |
| [`plugin-market`](../packages/boot/plugin-market) | `boot` | [`plugin-manager`](../packages/boot/plugin-manager), [`settings`](../packages/settings/settings), [`subprocess`](../packages/subprocess/subprocess), [`tools`](../packages/core/tools), [`typert-protocol`](../packages/typert/protocol) |
| [`headless`](../packages/bundle/headless) | `bundle` | [`agent`](../packages/core/agent), [`agent-default-model`](../packages/core/agent-default-model), [`fs`](../packages/fs/fs), [`llm`](../packages/llm/llm), [`permission-presets`](../packages/interaction/permission-presets), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`session-query`](../packages/session-query/session-query), [`subagent`](../packages/subagent/subagent), [`user-approval`](../packages/interaction/user-approval) |
| [`compaction-basic`](../packages/compaction/compaction-basic) | `compaction` | [`agent`](../packages/core/agent), [`commands`](../packages/interaction/commands), [`compaction`](../packages/compaction/compaction), [`compaction-tool-result-pruner`](../packages/compaction/compaction-tool-result-pruner), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`token-meter`](../packages/llm/token-meter) |
+4 -4
View File
@@ -1347,8 +1347,8 @@
en: 2786fef560851bb4
zh: 5d787475356b2606
/session-persistence-event-catalog/resolved-persistence-types/string:
en: 324d7ebffee10aad
zh: 31f7b8a18571665a
en: 41c03b03d1bd5a44
zh: 1b0e430af6c140b3
/session-persistence-event-catalog/resolved-persistence-types/string~2:
en: 0089620bf4638263
zh: 1922bef1713094bb
@@ -1833,8 +1833,8 @@
en: 809a23c25ded8497
zh: e9cd23e281f8d31f
/session-persistence-event-catalog/resolved-persistence-types/kind-user:
en: db30e659a1cc4c06
zh: 8585ea4c8178ab37
en: 53b7abac679ee252
zh: 63eb6a015986ef6d
/session-persistence-event-catalog/resolved-persistence-types/kind-user-approval:
en: a52d2dfbaf844127
zh: 4c5306ca40f20873
+2 -2
View File
@@ -5583,7 +5583,7 @@ Array of `number`.
SHA-256: `4cd48f3e5108bd6ebedf7301c3638839b8b2b9db8dbd493974177fbdeb91a01b`
Sources: [`packages/api/session-controller/src/types.ts:412`](../packages/api/session-controller/src/types.ts) · [`packages/attachment/attachment/src/brand.ts:6`](../packages/attachment/attachment/src/brand.ts) · [`packages/compaction/compaction/src/brand.ts:4`](../packages/compaction/compaction/src/brand.ts) · [`packages/core/session/src/types.ts:21`](../packages/core/session/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:21`](../packages/experimental/agent-team/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:33`](../packages/experimental/agent-team/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:9`](../packages/experimental/agent-team/src/types.ts) · [`packages/experimental/webhook/src/brand.ts:12`](../packages/experimental/webhook/src/brand.ts) · [`packages/experimental/webhook/src/brand.ts:6`](../packages/experimental/webhook/src/brand.ts) · [`packages/experimental/webhook/src/brand.ts:9`](../packages/experimental/webhook/src/brand.ts) · [`packages/feedback/message-feedback/src/types.ts:14`](../packages/feedback/message-feedback/src/types.ts) · [`packages/goal/goal/src/types.ts:17`](../packages/goal/goal/src/types.ts) · [`packages/interaction/commands/src/brand.ts:31`](../packages/interaction/commands/src/brand.ts) · [`packages/interaction/user-approval/src/types.ts:17`](../packages/interaction/user-approval/src/types.ts) · [`packages/jobs/jobs/src/brand.ts:19`](../packages/jobs/jobs/src/brand.ts) · [`packages/llm/llm-retry/src/brand.ts:4`](../packages/llm/llm-retry/src/brand.ts) · [`packages/llm/llm/src/brand.ts:16`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:31`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:43`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:67`](../packages/llm/llm/src/brand.ts) · [`packages/schedule/schedule/src/types.ts:14`](../packages/schedule/schedule/src/types.ts) · [`packages/session/session-title/src/index.ts:42`](../packages/session/session-title/src/index.ts) · [`packages/session/session-title/src/types.ts:17`](../packages/session/session-title/src/types.ts) · [`packages/workflow/workflow/src/types.ts:13`](../packages/workflow/workflow/src/types.ts)
Sources: [`packages/api/session-controller/src/types.ts:406`](../packages/api/session-controller/src/types.ts) · [`packages/attachment/attachment/src/brand.ts:6`](../packages/attachment/attachment/src/brand.ts) · [`packages/compaction/compaction/src/brand.ts:4`](../packages/compaction/compaction/src/brand.ts) · [`packages/core/session/src/types.ts:21`](../packages/core/session/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:21`](../packages/experimental/agent-team/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:33`](../packages/experimental/agent-team/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:9`](../packages/experimental/agent-team/src/types.ts) · [`packages/experimental/webhook/src/brand.ts:12`](../packages/experimental/webhook/src/brand.ts) · [`packages/experimental/webhook/src/brand.ts:6`](../packages/experimental/webhook/src/brand.ts) · [`packages/experimental/webhook/src/brand.ts:9`](../packages/experimental/webhook/src/brand.ts) · [`packages/feedback/message-feedback/src/types.ts:14`](../packages/feedback/message-feedback/src/types.ts) · [`packages/goal/goal/src/types.ts:17`](../packages/goal/goal/src/types.ts) · [`packages/interaction/commands/src/brand.ts:31`](../packages/interaction/commands/src/brand.ts) · [`packages/interaction/user-approval/src/types.ts:17`](../packages/interaction/user-approval/src/types.ts) · [`packages/jobs/jobs/src/brand.ts:19`](../packages/jobs/jobs/src/brand.ts) · [`packages/llm/llm-retry/src/brand.ts:4`](../packages/llm/llm-retry/src/brand.ts) · [`packages/llm/llm/src/brand.ts:16`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:31`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:43`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:67`](../packages/llm/llm/src/brand.ts) · [`packages/schedule/schedule/src/types.ts:14`](../packages/schedule/schedule/src/types.ts) · [`packages/session/session-title/src/index.ts:42`](../packages/session/session-title/src/index.ts) · [`packages/session/session-title/src/types.ts:17`](../packages/session/session-title/src/types.ts) · [`packages/workflow/workflow/src/types.ts:13`](../packages/workflow/workflow/src/types.ts)
`string`
@@ -7554,7 +7554,7 @@ Sources: [`packages/core/tools/src/index.ts:35`](../packages/core/tools/src/inde
SHA-256: `6dacb1584f2bfbe209dc6b6f578d51da6d55595f64d0e63c727c98f359278def`
Sources: [`packages/api/session-controller/src/types.ts:421`](../packages/api/session-controller/src/types.ts)
Sources: [`packages/api/session-controller/src/types.ts:415`](../packages/api/session-controller/src/types.ts)
| Property | Presence | Type |
|---|---|---|
+2 -2
View File
@@ -5585,7 +5585,7 @@ SHA-256: `5d03ba38734809bcbd2a55221bd938b3b3fc34b49bb686c6113e9e4931e3aa78`
SHA-256: `4cd48f3e5108bd6ebedf7301c3638839b8b2b9db8dbd493974177fbdeb91a01b`
来源:[`packages/api/session-controller/src/types.ts:412`](../packages/api/session-controller/src/types.ts) · [`packages/attachment/attachment/src/brand.ts:6`](../packages/attachment/attachment/src/brand.ts) · [`packages/compaction/compaction/src/brand.ts:4`](../packages/compaction/compaction/src/brand.ts) · [`packages/core/session/src/types.ts:21`](../packages/core/session/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:21`](../packages/experimental/agent-team/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:33`](../packages/experimental/agent-team/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:9`](../packages/experimental/agent-team/src/types.ts) · [`packages/experimental/webhook/src/brand.ts:12`](../packages/experimental/webhook/src/brand.ts) · [`packages/experimental/webhook/src/brand.ts:6`](../packages/experimental/webhook/src/brand.ts) · [`packages/experimental/webhook/src/brand.ts:9`](../packages/experimental/webhook/src/brand.ts) · [`packages/feedback/message-feedback/src/types.ts:14`](../packages/feedback/message-feedback/src/types.ts) · [`packages/goal/goal/src/types.ts:17`](../packages/goal/goal/src/types.ts) · [`packages/interaction/commands/src/brand.ts:31`](../packages/interaction/commands/src/brand.ts) · [`packages/interaction/user-approval/src/types.ts:17`](../packages/interaction/user-approval/src/types.ts) · [`packages/jobs/jobs/src/brand.ts:19`](../packages/jobs/jobs/src/brand.ts) · [`packages/llm/llm-retry/src/brand.ts:4`](../packages/llm/llm-retry/src/brand.ts) · [`packages/llm/llm/src/brand.ts:16`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:31`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:43`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:67`](../packages/llm/llm/src/brand.ts) · [`packages/schedule/schedule/src/types.ts:14`](../packages/schedule/schedule/src/types.ts) · [`packages/session/session-title/src/index.ts:42`](../packages/session/session-title/src/index.ts) · [`packages/session/session-title/src/types.ts:17`](../packages/session/session-title/src/types.ts) · [`packages/workflow/workflow/src/types.ts:13`](../packages/workflow/workflow/src/types.ts)
来源:[`packages/api/session-controller/src/types.ts:406`](../packages/api/session-controller/src/types.ts) · [`packages/attachment/attachment/src/brand.ts:6`](../packages/attachment/attachment/src/brand.ts) · [`packages/compaction/compaction/src/brand.ts:4`](../packages/compaction/compaction/src/brand.ts) · [`packages/core/session/src/types.ts:21`](../packages/core/session/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:21`](../packages/experimental/agent-team/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:33`](../packages/experimental/agent-team/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:9`](../packages/experimental/agent-team/src/types.ts) · [`packages/experimental/webhook/src/brand.ts:12`](../packages/experimental/webhook/src/brand.ts) · [`packages/experimental/webhook/src/brand.ts:6`](../packages/experimental/webhook/src/brand.ts) · [`packages/experimental/webhook/src/brand.ts:9`](../packages/experimental/webhook/src/brand.ts) · [`packages/feedback/message-feedback/src/types.ts:14`](../packages/feedback/message-feedback/src/types.ts) · [`packages/goal/goal/src/types.ts:17`](../packages/goal/goal/src/types.ts) · [`packages/interaction/commands/src/brand.ts:31`](../packages/interaction/commands/src/brand.ts) · [`packages/interaction/user-approval/src/types.ts:17`](../packages/interaction/user-approval/src/types.ts) · [`packages/jobs/jobs/src/brand.ts:19`](../packages/jobs/jobs/src/brand.ts) · [`packages/llm/llm-retry/src/brand.ts:4`](../packages/llm/llm-retry/src/brand.ts) · [`packages/llm/llm/src/brand.ts:16`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:31`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:43`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:67`](../packages/llm/llm/src/brand.ts) · [`packages/schedule/schedule/src/types.ts:14`](../packages/schedule/schedule/src/types.ts) · [`packages/session/session-title/src/index.ts:42`](../packages/session/session-title/src/index.ts) · [`packages/session/session-title/src/types.ts:17`](../packages/session/session-title/src/types.ts) · [`packages/workflow/workflow/src/types.ts:13`](../packages/workflow/workflow/src/types.ts)
`string`
@@ -7556,7 +7556,7 @@ SHA-256: `8fcb0b44a575e998665c053643d1ab04c82f39253ebd190ac0c975b78e40b543`
SHA-256: `6dacb1584f2bfbe209dc6b6f578d51da6d55595f64d0e63c727c98f359278def`
来源:[`packages/api/session-controller/src/types.ts:421`](../packages/api/session-controller/src/types.ts)
来源:[`packages/api/session-controller/src/types.ts:415`](../packages/api/session-controller/src/types.ts)
| 属性 | 存在性 | 类型 |
|---|---|---|
+2 -2
View File
@@ -23574,7 +23574,7 @@
"packages/workflow/workflow/src/types.ts#WorkflowRunId"
],
"sources": [
"packages/api/session-controller/src/types.ts:412",
"packages/api/session-controller/src/types.ts:406",
"packages/attachment/attachment/src/brand.ts:6",
"packages/compaction/compaction/src/brand.ts:4",
"packages/core/session/src/types.ts:21",
@@ -24096,7 +24096,7 @@
"digest": "6dacb1584f2bfbe209dc6b6f578d51da6d55595f64d0e63c727c98f359278def",
"names": [],
"sources": [
"packages/api/session-controller/src/types.ts:421"
"packages/api/session-controller/src/types.ts:415"
]
},
{
+3 -6
View File
@@ -6,8 +6,8 @@
en: f6ae07d8902e8edf
zh: c92fef531c6da331
/core/the-spine-package-by-package:
en: 60926dc99f41024d
zh: f40370b034f91463
en: c90cb23528ddb6c8
zh: 62724b7255b586b6
/core/creation-and-ownership:
en: ab09a38b4e1f0883
zh: 81e2d14c4036ab6c
@@ -16,10 +16,7 @@
zh: 25bc9cb60ee1520a
/core/initiating-agent:
en: 6c8c47ac78de2dde
zh: acde99c8808ea891
/core/messages-from-outside-an-agent:
en: cf087fd9ff5edb87
zh: c3bb56b17cd76c52
zh: c9d527a25a85d1d9
/core/interception-decisions:
en: 6fccceb0a5f1cad9
zh: f43c30c2386d8cc8
+1 -125
View File
@@ -15,10 +15,9 @@ A turn flows through the six packages in one loop: the driver in [`agent-loop`](
| `tools/` | The scoped tool registry and guarded execution pipeline (`ctx.tools`) | [tools.md](tools.md) |
| `agent/` | The `Agent` interface, live registry, initiator scope, and `agent/*` event vocabulary (`ctx.agents`) | this page |
| `agent-loop/` | The concrete driver implementing the public `Agent` contract (`ctx.agentLoop`) | this page |
| `agent-controller/` | Delivery of messages from outside an Agent through the `agent-controller/admission` stage (`ctx.agentController`) | this page |
| `scope/` | The scoped-registration primitive the registries and loop build per-agent scoping on | [scope.md](scope.md) |
`scope/` is the one non-service package: a dependency-free library (`createScope`/`scopeOf`/`scopeTarget`) that sits below `session/` and `system-prompt/` in the module graph precisely so they can consume it without a cycle. `agent-loop` is the one concrete implementation of the public `Agent` contract and lives here because it is the harness's default product loop; it runs each driver inside `ctx.agents.withInitiator()`. Extension plugins depend on `agent` — including when they need the initiating Agent — and never on `agent-loop` directly, so the loop stays swappable. `agent-controller` sits in front of the loop: a sender that brings a message on someone's behalf delivers it through `ctx.agentController` instead of appending to the inbox itself. [`dsh-base`](../../packages/bundle/base/README.md) is the default product composition, while [`dsh-sdk-minimal`](../../packages/bundle/sdk-minimal/README.md) declares a smaller standalone tree.
`scope/` is the one non-service package: a dependency-free library (`createScope`/`scopeOf`/`scopeTarget`) that sits below `session/` and `system-prompt/` in the module graph precisely so they can consume it without a cycle. `agent-loop` is the one concrete implementation of the public `Agent` contract and lives here because it is the harness's default product loop; it runs each driver inside `ctx.agents.withInitiator()`. Extension plugins depend on `agent` — including when they need the initiating Agent — and never on `agent-loop` directly, so the loop stays swappable. [`dsh-base`](../../packages/bundle/base/README.md) is the default product composition, while [`dsh-sdk-minimal`](../../packages/bundle/sdk-minimal/README.md) declares a smaller standalone tree.
## Creation and ownership
@@ -306,65 +305,6 @@ The [event taxonomy](../architecture.md#events) owns the `agent/*` lifecycle, ch
The process-local initiator carried by `ctx.agents` is the exact `Agent` above, not a separate frame or copied identity. Ambient presence is neither liveness proof nor authorization; the [initiator-scope decision](../../.agents/notes/implemented/architecture/2026-07-15-agent-initiator-scope.md) defines its lifetime and scope rules.
## Messages from outside an Agent
A sender that brings a message on someone's behalf, such as the Web Session Remote, calls `ctx.agentController.deliver` from [`agent-controller`](../../packages/core/agent-controller/README.md) instead of `followup()` or `steer()`. The controller prepares the content in the Agent's delivery order, asks the `agent-controller/admission` waterfall for a decision, and appends an admitted message; messages the Agent's own machinery produces enter without the stage. The [delivery decision](../../.agents/notes/implemented/architecture/2026-10-17-agent-controller-message-delivery.md) explains why the stage lives outside `agent`.
Source: [`packages/core/agent-controller/src/types.ts`](../../packages/core/agent-controller/src/types.ts)
```ts type-equiv
/** How a delivered message enters the inbox: `followup` for its own turn, `steer` for the nearest step. */
type DeliveryMode = 'followup' | 'steer'
```
```ts type-equiv
/** One message a sender delivers on someone's behalf. */
interface DeliveryRequest {
/** Where the message enters once it is admitted. */
readonly mode: DeliveryMode
/** Durable attribution recorded on the message. */
readonly source: MessageSource
/** The sender's cancellation; it ends the sender's wait at once and stops a message that has not entered the inbox. */
readonly signal: AbortSignal
/**
* Turn the sender's content into durable message content, such as by
* storing attachments. It runs in the Agent's delivery order, after the
* earlier messages to the same Agent settled; its failure reaches the
* sender unchanged.
* @param signal - aborted when the sender cancels or the controller stops; controller disposal waits for the preparation to settle.
* @returns the content of the message.
*/
readonly prepare: (signal: AbortSignal) => Promise<UserMessage['content']>
/**
* Record what the sender needs before the Agent wakes. It runs
* synchronously after admission, immediately before the message enters the
* inbox. A throw keeps the message out of the inbox and reaches the sender
* unchanged; the sender reverts its own effects when `deliver` rejects.
* @param message - the admitted message.
*/
readonly beforeInsert?: (message: UserMessage) => void
}
```
A listener admits by returning the decision of `next()`, holds the message by awaiting before `next()`, and refuses it by returning a `reject` decision:
```ts type-equiv
/** The outcome of the admission stage for one message. */
type AdmissionDecision =
/** The message enters the inbox. */
| { readonly kind: 'admit' }
/** The message stays out of the inbox. */
| {
readonly kind: 'reject'
/** Code of the refusal, owned by its source, such as `forge/worktree-missing`. */
readonly reason: string
/** Explanation for a sender that shows nothing of the refusing plugin. */
readonly message: string
/** Whether the refusing plugin explains the refusal in its own interface. */
readonly presented: boolean
}
```
## Interception decisions
Pre-step decisions use the same identified `UserMessage` type as durable user-role input. The entered batch is authoritative and preserves every message's `id` and `source`. Hook bridges map their native decision fields onto this typed result.
@@ -497,33 +437,6 @@ interface TurnBoundaryProjection {
Generated from source by `scripts/gen-cordis-catalog.ts` (verified fresh by `pnpm run verify-cordis-catalog` in doc-sync; regenerate with `pnpm run gen-cordis-catalog`) — the language sides differ only in locale-specific paired document paths. Signature blocks use a `ts cordis-catalog` fence and keep the original source JSDoc; dispatch modes are defined in the [primer](../cordis-primer.md#dispatch-modes), and the framework-inherited `ctx` API lives in [cordis-api/inherited.md](../cordis-api/inherited.md).
<a id="ctxagentcontroller--agentcontroller"></a>
### `ctx.agentController` — `AgentController`
Delivers the messages that senders, such as the Web Session Remote, bring on someone's behalf. Messages to one Agent are prepared, decided, and appended one at a time in arrival order; messages to different Agents do not wait for each other. Disposal stops every message that has not entered an inbox and finishes once each delivery it stopped has settled.
```ts cordis-catalog
/**
* Deliver one message from outside the Agent: prepare its content, ask the
* `agent-controller/admission` stage for a decision, then append an
* admitted message with `followup` or `steer`.
* @param agent - the receiving Agent.
* @param request - delivery mode, source, cancellation, and the sender's content preparation.
* @returns the id of the message once it is in the inbox.
* @throws {@link AgentAdmissionRejected} when a listener rejects the message
* or throws, or when the Agent left the registry first; the error of
* `prepare`, `beforeInsert`, `followup`, or `steer` unchanged; the sender's
* cancellation reason as soon as it cancels, also while the message waits
* behind another one; the stop reason once the controller stops.
*/
async deliver(agent: Agent, request: DeliveryRequest): Promise<MessageId>
```
Types: [MessageId](llm-streaming.md)
Source: [`packages/core/agent-controller/src/index.ts`](../../packages/core/agent-controller/src/index.ts)
<a id="ctxagentdefaultmodel--agentdefaultmodelconfig"></a>
### `ctx.agentDefaultModel` — `AgentDefaultModelConfig`
@@ -1186,43 +1099,6 @@ Types: [Scoped](scope.md)
Source: [`packages/core/agent/src/runtime-types.ts`](../../packages/core/agent/src/runtime-types.ts)
<a id="agent-controller-events"></a>
### `agent-controller/*` events
<a id="agent-controlleradmission--waterfall"></a>
#### `agent-controller/admission` — waterfall
Decide whether one message from outside an Agent enters its inbox. `ctx.agentController.deliver` runs this stage and appends the message only after the whole chain admitted it; messages the Agent's own machinery produces enter through `followup`, `steer`, or `inject` without it. Listeners run in registration order, and messages to one Agent are decided one at a time in arrival order. A listener admits by returning the decision of `next()`, whose last step admits; awaiting before `next()` holds the message, and returning a `reject` decision refuses it without asking later listeners. A thrown error means the listener's check itself failed. A listener must not wait for a person when the message source is unattended, such as a schedule or a webhook.
```ts cordis-catalog
/**
* Decide whether one message from outside an Agent enters its inbox.
* `ctx.agentController.deliver` runs this stage and appends the message
* only after the whole chain admitted it; messages the Agent's own
* machinery produces enter through `followup`, `steer`, or `inject`
* without it. Listeners run in registration order, and messages to one
* Agent are decided one at a time in arrival order. A listener admits by
* returning the decision of `next()`, whose last step admits; awaiting
* before `next()` holds the message, and returning a `reject` decision
* refuses it without asking later listeners. A thrown error means the
* listener's check itself failed. A listener must not wait for a person
* when the message source is unattended, such as a schedule or a webhook.
* @param payload.agent - the receiving Agent.
* @param payload.message - the message as it will enter the inbox.
* @param payload.signal - aborted when the sender cancels or the controller stops; a holding
* listener must settle once it aborts, because controller disposal waits for it.
* Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): Agent-scoped listeners receive only that Agent.
* @mode waterfall
*/
'agent-controller/admission'(this: Scoped<Agent>, payload: { agent: Agent; message: UserMessage; signal: AbortSignal }, next: () => Promise<AdmissionDecision>): Promise<AdmissionDecision>
```
Types: [Scoped](scope.md) · [UserMessage](session.md)
Source: [`packages/core/agent-controller/src/types.ts`](../../packages/core/agent-controller/src/types.ts)
<a id="agent-loop-events"></a>
### `agent-loop/*` events
+1 -127
View File
@@ -15,10 +15,9 @@
| `tools/` | 带作用域的工具注册表与受保护的执行流水线(`ctx.tools`) | [tools.md](tools.zh.md) |
| `agent/` | `Agent` 接口、实时注册表、发起者作用域与 `agent/*` 事件词汇(`ctx.agents`) | 本页 |
| `agent-loop/` | 实现公开 `Agent` 约定的具体 driver(`ctx.agentLoop`) | 本页 |
| `agent-controller/` | 外部消息经 `agent-controller/admission` 阶段投递进 Agent(`ctx.agentController`) | 本页 |
| `scope/` | 注册表与循环用于构建按 agent 作用域的注册原语 | [scope.md](scope.zh.md) |
`scope/` 是这里唯一的非服务包:一个零依赖库(`createScope`/`scopeOf`/`scopeTarget`),在模块图中位于 `session/` 与 `system-prompt/` 之下,正是为了让它们消费它而不形成环。`agent-loop` 是公开 `Agent` 约定的唯一具体实现,放在这里因为它是 harness 的默认产品循环;它在 `ctx.agents.withInitiator()` 内运行每个 driver。扩展插件依赖 `agent`——包括需要发起 Agent 时——而绝不直接依赖 `agent-loop`,因此循环保持可替换。`agent-controller` 位于循环之前:代表他人送来消息的发送方经 `ctx.agentController` 投递,而不是自己写入收件箱。[`dsh-base`](../../packages/bundle/base/README.zh.md) 是默认产品组合,[`dsh-sdk-minimal`](../../packages/bundle/sdk-minimal/README.zh.md) 则声明一棵更小的独立配置树。
`scope/` 是这里唯一的非服务包:一个零依赖库(`createScope`/`scopeOf`/`scopeTarget`),在模块图中位于 `session/` 与 `system-prompt/` 之下,正是为了让它们消费它而不形成环。`agent-loop` 是公开 `Agent` 约定的唯一具体实现,放在这里因为它是 harness 的默认产品循环;它在 `ctx.agents.withInitiator()` 内运行每个 driver。扩展插件依赖 `agent`——包括需要发起 Agent 时——而绝不直接依赖 `agent-loop`,因此循环保持可替换。[`dsh-base`](../../packages/bundle/base/README.zh.md) 是默认产品组合,[`dsh-sdk-minimal`](../../packages/bundle/sdk-minimal/README.zh.md) 则声明一棵更小的独立配置树。
<a id="creation-and-ownership"></a>
@@ -312,67 +311,6 @@ cause 是由 TypeScript 强制约束的同进程输入。活跃的取消持有
`ctx.agents` 携带的进程本地 initiator 就是上面的确切 `Agent`,不是单独的 frame 或复制的标识。环境中存在该值既不能证明存活,也不代表授权;[initiator 作用域决策](../../.agents/notes/implemented/architecture/2026-07-15-agent-initiator-scope.zh.md)定义其生命周期和作用域规则。
<a id="messages-from-outside-an-agent"></a>
## 来自 Agent 外部的消息
代表他人送来消息的发送方(例如 Web Session Remote)调用 [`agent-controller`](../../packages/core/agent-controller/README.zh.md) 的 `ctx.agentController.deliver`,而不是 `followup()` 或 `steer()`。控制器按该 Agent 的投递顺序准备内容,向 `agent-controller/admission` waterfall 请求决定,再放入被接纳的消息;Agent 自身机制产生的消息不经过这个阶段。[投递决策](../../.agents/notes/implemented/architecture/2026-10-17-agent-controller-message-delivery.zh.md)说明了这个阶段为什么不放在 `agent` 里。
源码:[`packages/core/agent-controller/src/types.ts`](../../packages/core/agent-controller/src/types.ts)
```ts type-equiv
/** How a delivered message enters the inbox: `followup` for its own turn, `steer` for the nearest step. */
type DeliveryMode = 'followup' | 'steer'
```
```ts type-equiv
/** One message a sender delivers on someone's behalf. */
interface DeliveryRequest {
/** Where the message enters once it is admitted. */
readonly mode: DeliveryMode
/** Durable attribution recorded on the message. */
readonly source: MessageSource
/** The sender's cancellation; it ends the sender's wait at once and stops a message that has not entered the inbox. */
readonly signal: AbortSignal
/**
* Turn the sender's content into durable message content, such as by
* storing attachments. It runs in the Agent's delivery order, after the
* earlier messages to the same Agent settled; its failure reaches the
* sender unchanged.
* @param signal - aborted when the sender cancels or the controller stops; controller disposal waits for the preparation to settle.
* @returns the content of the message.
*/
readonly prepare: (signal: AbortSignal) => Promise<UserMessage['content']>
/**
* Record what the sender needs before the Agent wakes. It runs
* synchronously after admission, immediately before the message enters the
* inbox. A throw keeps the message out of the inbox and reaches the sender
* unchanged; the sender reverts its own effects when `deliver` rejects.
* @param message - the admitted message.
*/
readonly beforeInsert?: (message: UserMessage) => void
}
```
监听器返回 `next()` 的决定即放行,在 `next()` 之前 await 即暂留消息,返回 `reject` 决定即拒绝:
```ts type-equiv
/** The outcome of the admission stage for one message. */
type AdmissionDecision =
/** The message enters the inbox. */
| { readonly kind: 'admit' }
/** The message stays out of the inbox. */
| {
readonly kind: 'reject'
/** Code of the refusal, owned by its source, such as `forge/worktree-missing`. */
readonly reason: string
/** Explanation for a sender that shows nothing of the refusing plugin. */
readonly message: string
/** Whether the refusing plugin explains the refusal in its own interface. */
readonly presented: boolean
}
```
<a id="interception-decisions"></a>
## 拦截决策
@@ -511,33 +449,6 @@ interface TurnBoundaryProjection {
Generated from source by `scripts/gen-cordis-catalog.ts` (verified fresh by `pnpm run verify-cordis-catalog` in doc-sync; regenerate with `pnpm run gen-cordis-catalog`) — the language sides differ only in locale-specific paired document paths. Signature blocks use a `ts cordis-catalog` fence and keep the original source JSDoc; dispatch modes are defined in the [primer](../cordis-primer.zh.md#dispatch-modes), and the framework-inherited `ctx` API lives in [cordis-api/inherited.md](../cordis-api/inherited.md).
<a id="ctxagentcontroller--agentcontroller"></a>
### `ctx.agentController` — `AgentController`
Delivers the messages that senders, such as the Web Session Remote, bring on someone's behalf. Messages to one Agent are prepared, decided, and appended one at a time in arrival order; messages to different Agents do not wait for each other. Disposal stops every message that has not entered an inbox and finishes once each delivery it stopped has settled.
```ts cordis-catalog
/**
* Deliver one message from outside the Agent: prepare its content, ask the
* `agent-controller/admission` stage for a decision, then append an
* admitted message with `followup` or `steer`.
* @param agent - the receiving Agent.
* @param request - delivery mode, source, cancellation, and the sender's content preparation.
* @returns the id of the message once it is in the inbox.
* @throws {@link AgentAdmissionRejected} when a listener rejects the message
* or throws, or when the Agent left the registry first; the error of
* `prepare`, `beforeInsert`, `followup`, or `steer` unchanged; the sender's
* cancellation reason as soon as it cancels, also while the message waits
* behind another one; the stop reason once the controller stops.
*/
async deliver(agent: Agent, request: DeliveryRequest): Promise<MessageId>
```
Types: [MessageId](llm-streaming.zh.md)
Source: [`packages/core/agent-controller/src/index.ts`](../../packages/core/agent-controller/src/index.ts)
<a id="ctxagentdefaultmodel--agentdefaultmodelconfig"></a>
### `ctx.agentDefaultModel` — `AgentDefaultModelConfig`
@@ -1200,43 +1111,6 @@ Types: [Scoped](scope.zh.md)
Source: [`packages/core/agent/src/runtime-types.ts`](../../packages/core/agent/src/runtime-types.ts)
<a id="agent-controller-events"></a>
### `agent-controller/*` events
<a id="agent-controlleradmission--waterfall"></a>
#### `agent-controller/admission` — waterfall
Decide whether one message from outside an Agent enters its inbox. `ctx.agentController.deliver` runs this stage and appends the message only after the whole chain admitted it; messages the Agent's own machinery produces enter through `followup`, `steer`, or `inject` without it. Listeners run in registration order, and messages to one Agent are decided one at a time in arrival order. A listener admits by returning the decision of `next()`, whose last step admits; awaiting before `next()` holds the message, and returning a `reject` decision refuses it without asking later listeners. A thrown error means the listener's check itself failed. A listener must not wait for a person when the message source is unattended, such as a schedule or a webhook.
```ts cordis-catalog
/**
* Decide whether one message from outside an Agent enters its inbox.
* `ctx.agentController.deliver` runs this stage and appends the message
* only after the whole chain admitted it; messages the Agent's own
* machinery produces enter through `followup`, `steer`, or `inject`
* without it. Listeners run in registration order, and messages to one
* Agent are decided one at a time in arrival order. A listener admits by
* returning the decision of `next()`, whose last step admits; awaiting
* before `next()` holds the message, and returning a `reject` decision
* refuses it without asking later listeners. A thrown error means the
* listener's check itself failed. A listener must not wait for a person
* when the message source is unattended, such as a schedule or a webhook.
* @param payload.agent - the receiving Agent.
* @param payload.message - the message as it will enter the inbox.
* @param payload.signal - aborted when the sender cancels or the controller stops; a holding
* listener must settle once it aborts, because controller disposal waits for it.
* Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): Agent-scoped listeners receive only that Agent.
* @mode waterfall
*/
'agent-controller/admission'(this: Scoped<Agent>, payload: { agent: Agent; message: UserMessage; signal: AbortSignal }, next: () => Promise<AdmissionDecision>): Promise<AdmissionDecision>
```
Types: [Scoped](scope.zh.md) · [UserMessage](session.zh.md)
Source: [`packages/core/agent-controller/src/types.ts`](../../packages/core/agent-controller/src/types.ts)
<a id="agent-loop-events"></a>
### `agent-loop/*` events
+2 -6
View File
@@ -930,14 +930,10 @@ workspaceDesktop(): { name: string; available: boolean; fileManager: 'finder' |
@Remote('fork') async fork(request: SessionForkRequest): Promise<SessionForkValue>
/**
* Admit one prompt after explicitly resuming its Session. The prompt is
* delivered through `ctx.agentController`, whose `agent-controller/admission`
* stage runs before it enters the inbox, so the call can wait for a listener
* that holds it.
* Admit one prompt after explicitly resuming its Session.
* @param request - Session identity, prompt content, source metadata, and delivery mode.
* @param signal - caller cancellation; it also stops a prompt waiting for or held at the admission stage.
* @param signal - caller cancellation before prompt admission begins.
* @returns acknowledgement that the Agent accepted the prompt.
* @throws `session/prompt-rejected` when an admission listener refuses the prompt or fails.
*/
@Remote('prompt') async prompt(request: SessionPromptRequest, signal: AbortSignal): Promise<SessionPromptValue>
+2 -6
View File
@@ -934,14 +934,10 @@ workspaceDesktop(): { name: string; available: boolean; fileManager: 'finder' |
@Remote('fork') async fork(request: SessionForkRequest): Promise<SessionForkValue>
/**
* Admit one prompt after explicitly resuming its Session. The prompt is
* delivered through `ctx.agentController`, whose `agent-controller/admission`
* stage runs before it enters the inbox, so the call can wait for a listener
* that holds it.
* Admit one prompt after explicitly resuming its Session.
* @param request - Session identity, prompt content, source metadata, and delivery mode.
* @param signal - caller cancellation; it also stops a prompt waiting for or held at the admission stage.
* @param signal - caller cancellation before prompt admission begins.
* @returns acknowledgement that the Agent accepted the prompt.
* @throws `session/prompt-rejected` when an admission listener refuses the prompt or fails.
*/
@Remote('prompt') async prompt(request: SessionPromptRequest, signal: AbortSignal): Promise<SessionPromptValue>
+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,再使用新会话,应打开同一个空白会话并显示该草稿。
+5 -2
View File
@@ -9,8 +9,11 @@
en: dc8c41fbf9869179
zh: e45976a585cb9c86
/web-ui-style-reference/component-rules:
en: 6715806a6f84d141
zh: 94b7a6ede4919512
en: 4ee4a6fd2917306a
zh: d7d68c140a698ab0
/web-ui-style-reference/input-capabilities:
en: bf2e98bce68743e2
zh: fc58dcbb60bb944a
/web-ui-style-reference/corner-radii-and-settings-cards:
en: 118972f6c2bd6ce4
zh: 85f8416b03e4dd8b
+12 -1
View File
@@ -21,7 +21,7 @@ Global style sheets belong in `ui-theme/src/styles/`. Component styles live besi
- Keep source text, terminal output, and diff lines unwrapped when their component contract requires column preservation; use the shared scrollbar styles rather than component-specific scrollbar selectors.
- Put presentation in CSS. Inline React styles may pass component-local custom-property values but must not encode theme branches.
- A relative `url()` in a plugin stylesheet points to a file in the package's `src/client/assets/`, and the build rewrites it to the [package asset](../packages/client/AGENTS.md#dependency-declaration) URL. `data:`, `#fragment`, `/`-rooted, scheme, and empty `url('')` references stay unchanged, a relative file reference with a fragment or query fails the build, and stylesheets from dependencies are not rewritten.
- Preserve keyboard focus visibility and reduced-motion behavior when adding transitions or hover-only controls.
- Put `:hover` selectors under `@media (hover: hover)`, independently of viewport width. Keep selected, expanded, pressed, and keyboard-focus styles outside that condition. Without hover, actions remain visible or accessible through an explicit control; pointer handlers ignore touch when opening hover-only previews. Preserve keyboard focus visibility and reduced-motion behavior.
- Rounded corners inherit the global superellipse smoothing from ui-theme's `corner-shape.css` on supporting engines. Pair `corner-shape: round` with every full-round `border-radius` (`50%`, `100%`, or a pill radius) so circles and capsules keep circular arcs; the ui-theme corner-shape spec enforces the pairing.
- Elevated surfaces (menus, popovers, modals, panels, floating buttons, the composer) set `border: 0` and take `box-shadow: var(--dsw-elevation-panel)`, `var(--dsw-elevation-prominent)`, or the composer's `var(--dsw-elevation-soft)` (larger blur at lower alpha): the 0.5px hairline stroke is the first shadow layer, and `--dsw-elevation-stroke-color` rebinds or suppresses it per surface or state. Never pair a `--dsw-alias-border-*` border with an lv/elevation shadow — the ui-theme elevation spec rejects the pairing; state-colored borders (warn panels) stay real borders.
- Existing and new dropdown, context, submenu, and selection menus use `Menu` or wrap custom content in `MenuSurface`. The material is the theme-owned `--dsw-menu-surface-fill` plus `--dsw-menu-backdrop-filter`; feature and platform CSS must not override either. The ui-theme menu spec rejects unwrapped menu/listbox renderers, material overrides, and token redefinitions. The schedule-owned `TaskMenu` and `ClockPicker` retain their existing containers and material as explicit exceptions ([owner](../packages/client/ui-schedule/README.md)).
@@ -30,6 +30,17 @@ Global style sheets belong in `ui-theme/src/styles/`. Component styles live besi
- Flat borders and separators that use a neutral `--dsw-alias-border-*` token draw at `0.5px` — buttons, inputs, cards, row dividers, and separators drawn as filled boxes (menu separators, the conversation header seam, markdown `hr`, vertical rails) share the hairline weight, which Chromium paints as one device pixel. Dashed affordances and state-colored borders keep 1px; spinner ring tracks keep their width through the spec's explicit allowlist. The ui-theme elevation spec rejects wider neutral solid borders.
- Clickable artifact links (markdown anchors, prose file mentions, web source and fetch links, produced-file chips, workflow member links) color through `--dsw-alias-link` at `font-weight: 500`, with no underline at rest and a dotted 3px-offset underline on hover/focus. Compact Thinking Markdown keeps tertiary text color and a resting dotted underline ([compact presentation](../packages/client/ui-primitives/README.md#rendering-agent-output)). Text-leading anchors also lead with the ui-primitives `LinkIconMedium` category glyph riding `currentColor`, which for a well-known external host is that site's own mark instead of the globe; workflow member links and image-only anchors carry no glyph, and tool-row file links keep their grey dotted affordance.
<a id="input-capabilities"></a>
## Input capabilities
`pointer` and `hover` describe the primary pointing device chosen by the browser; `any-hover` considers every available pointing device. None detects a physical keyboard.
- Use `(pointer: coarse)` for presentation that depends on pointing accuracy, such as touch-sized controls and text fields.
- Keep CSS hover effects under `(hover: hover)` so a touch-first layout remains usable without hover.
- JavaScript hover previews use `(any-hover: hover)` when accepting mouse entry and ignore touch entry. A secondary mouse can therefore open a preview even when the touchscreen remains the primary pointer. Enabling all CSS hover styles through `any-hover` would also enable them for touch-generated `:hover` states.
On a mixed-input device, a mouse preview can appear while the primary-pointer CSS hover style stays inactive. Keyboard focus and shortcut hints follow actual keyboard interaction independently of these capability queries; [Menu and input modality](../packages/client/ui-primitives/README.md#input-modality) define the shared behavior.
<a id="corner-radii-and-settings-cards"></a>
## Corner radii and settings cards
+12 -1
View File
@@ -21,7 +21,7 @@
- 当组件约定要求保留列结构时,源码文本、终端输出和 diff 行不得换行;使用共享滚动条样式,不得定义组件专用滚动条选择器。
- 呈现规则写在 CSS 中。React 内联样式可以传递组件局部自定义属性值,但不得编码主题分支。
- 插件样式表中的相对 `url()` 指向本包 `src/client/assets/` 中的文件,构建会将其改写为[包资源](../packages/client/AGENTS.md#dependency-declaration) URL。`data:`、`#fragment`、以 `/` 开头、带 scheme 的引用以及空 `url('')` 引用保持不变,带片段或查询串的相对文件引用会让构建失败,依赖包中的样式表不会被改写。
- 添加过渡动画或仅悬停可见的控件时,保留清晰可见的键盘焦点和减少动态效果行为。
- 将 `:hover` 选择器放在 `@media (hover: hover)` 内,不依赖视口宽度。选中、展开、按下和键盘焦点样式保留在该条件之外。无 hover 时,操作保持可见或通过明确控件访问;打开纯悬停预览的指针事件处理忽略触摸。保留清晰可见的键盘焦点和减少动态效果行为。
- 支持的引擎上,圆角继承 ui-theme `corner-shape.css` 的全局超级椭圆平滑。每个正圆 `border-radius`(`50%`、`100%` 或胶囊半径)必须配对 `corner-shape: round`,使圆形与胶囊保持圆弧;ui-theme 的 corner-shape spec 强制这一配对。
- 高层级表面(菜单、浮层、对话框、面板、悬浮按钮、输入框)设 `border: 0` 并使用 `box-shadow: var(--dsw-elevation-panel)`、`var(--dsw-elevation-prominent)` 或输入框专用的 `var(--dsw-elevation-soft)`(更大模糊、更低透明度):0.5px 发丝描边是第一层投影,`--dsw-elevation-stroke-color` 可按表面或状态重绑或抑制描边。不得将 `--dsw-alias-border-*` border 与 lv/elevation 投影配对——ui-theme 的 elevation spec 会拒绝;状态色 border(warn 面板)保持真 border。
- 存量和新增的下拉菜单、上下文菜单、子菜单及选择菜单使用 `Menu`,自定义内容用 `MenuSurface` 包裹。材质由主题的 `--dsw-menu-surface-fill` 和 `--dsw-menu-backdrop-filter` 组成;功能及平台 CSS 不得覆盖两者。ui-theme 菜单检查拒绝未使用共享容器的 menu/listbox 渲染、材质覆盖和 token 重定义。计划任务自有的 `TaskMenu` 与 `ClockPicker` 是明确例外,保留现有容器和材质([所属包](../packages/client/ui-schedule/README.zh.md))。
@@ -30,6 +30,17 @@
- 使用中性 `--dsw-alias-border-*` token 的平面边框与分割线一律 `0.5px`——按钮、输入框、卡片、行分割线,以及以填充盒绘制的分隔线(菜单分隔、对话标题栏接缝、markdown `hr`、竖向轨道线)共用发丝线粗细,Chromium 将其绘制为一个设备像素。dashed 记号与状态色 border 保持 1px;spinner 圆环经 spec 的显式豁免保留原宽度。更宽的中性 solid border 会被 ui-theme elevation spec 拒绝。
- 可点击产物链接(Markdown 锚点、正文文件引用、网页来源与抓取链接、产物 chips、workflow 成员链接)经 `--dsw-alias-link` 着色、`font-weight: 500`,默认无下划线,hover/focus 时为 3px offset 的点状下划线。紧凑 Thinking Markdown 保持 tertiary 文字色和默认点状下划线([紧凑展示](../packages/client/ui-primitives/README.zh.md#rendering-agent-output))。带文字的锚点另以 ui-primitives 的 `LinkIconMedium` 分类图形(随 `currentColor`)作前置;目的地是已知外部站点时改用该站点自己的标记而非地球;workflow 成员链接与只包图片的锚点不带图形,工具行文件链接保持其灰色点线示能。
<a id="input-capabilities"></a>
## 输入能力
`pointer` 与 `hover` 描述浏览器选定的主指针,`any-hover` 则考虑所有可用指针。三者都不检测物理键盘。
- `(pointer: coarse)` 用于依赖指针精度的呈现,例如适合触摸的控件和文本框尺寸。
- CSS 悬停效果保留在 `(hover: hover)` 下,使以触屏为主的布局无需悬停也能使用。
- JavaScript 悬停预览在接受 mouse 进入事件时使用 `(any-hover: hover)`,并忽略 touch 进入事件。因此,即使触屏仍是主指针,外接鼠标也能打开预览。如果通过 `any-hover` 启用全部 CSS 悬停样式,触摸产生的 `:hover` 状态也会应用这些样式。
混合输入设备上,鼠标预览可以出现,而基于主指针的 CSS 悬停样式保持关闭。键盘焦点和快捷键提示根据实际键盘交互显示,不依赖这些能力查询;共享行为见 [Menu 与输入方式](../packages/client/ui-primitives/README.zh.md#input-modality)。
<a id="corner-radii-and-settings-cards"></a>
## 圆角与设置卡片
Loaded 100 of 486 files, more files were not shown because too many files have changed in this diff. Show more