Merge remote-tracking branch 'origin/master' into xtr/ptc-birpc-transport
This commit is contained in:
commit
96b32df5db
567 files changed
+8921
-3751
No files matched your search
+2
-2
@@ -9,8 +9,8 @@
|
||||
en: 2ba6c43b20a3d347
|
||||
zh: 5647179ddaa38ad9
|
||||
/agent-note-ship-experimental-capabilities-as-optional-bundles/decision:
|
||||
en: d6328ba2b218f2ac
|
||||
zh: 3731561313b1ad5e
|
||||
en: 191ca8bffb101db2
|
||||
zh: 1365bef5cd830a5c
|
||||
/agent-note-ship-experimental-capabilities-as-optional-bundles/alternatives-considered:
|
||||
en: d6f40bf02df37ab7
|
||||
zh: e5b85ce9d41e8754
|
||||
|
||||
+1
-1
@@ -10,7 +10,7 @@ The Web plugin page offered two optional bundles, Agent Teams and voice input. A
|
||||
|
||||
## Decision
|
||||
|
||||
`OPTIONAL_BUNDLES` owns the installation's default-off selections, including maintained optional capabilities and experimental capabilities. Every bundle declares `icon` and exports `./locale/*.json` with `meta.title` and `meta.description`; the Official group displays that metadata. A bundle's presentation does not determine its [experimental status](../../../../packages/experimental/README.md#status). The isolation check validates admission and still rejects experimental dependencies of nonexperimental bundles.
|
||||
`OPTIONAL_BUNDLES` owns the installation's default-off selections, including maintained optional capabilities and experimental capabilities. Every bundle declares `icon` and exports `./locale/*.json` with `meta.title` and `meta.description`; the [plugin manager page](../../../../packages/client/ui-plugin-manager/README.md#use-this-package) displays that metadata for eligible bundles and retains explicit activation for default-hidden ones. A bundle's presentation does not determine its [experimental status](../../../../packages/experimental/README.md#status). The isolation check validates admission and still rejects experimental dependencies of nonexperimental bundles.
|
||||
|
||||
An optional bundle's dependencies are downloaded with every `dsh` installation. [Reasoning translation](2026-10-05-anonymous-reasoning-translation.md) uses three experimental packages without adding third-party runtime dependencies. Admission therefore considers installation cost and whether a switch supplies a usable composition without an additional configuration form. External browser and Cua Driver providers remain explicit compositions because they add substantial runtimes or require an external executable. Stagehand requires model credentials; Python PTC replaces a core provider and conflicts with TypeScript workflow consumers. Demonstrative hooks, webhooks, and mods stay outside the list. [Session Inspector](../feature/2026-09-24-session-inspector.md) owns the Inspector's distinct activation behavior.
|
||||
|
||||
|
||||
+1
-1
@@ -10,7 +10,7 @@ Web 插件页只提供两个可选 bundle:Agent Teams 与语音输入。Auto r
|
||||
|
||||
## 决策
|
||||
|
||||
`OPTIONAL_BUNDLES` 管理安装随附且默认关闭的选项,包括维护中的可选能力与实验性能力。每个 bundle 声明 `icon`,并导出带 `meta.title` 与 `meta.description` 的 `./locale/*.json`;官方分组显示这些元数据。Bundle 的展示方式不决定其[实验性状态](../../../../packages/experimental/README.zh.md#status)。隔离检查校验准入,并继续拒绝非实验 bundle 的实验性依赖。
|
||||
`OPTIONAL_BUNDLES` 管理安装随附且默认关闭的选项,包括维护中的可选能力与实验性能力。每个 bundle 声明 `icon`,并导出带 `meta.title` 与 `meta.description` 的 `./locale/*.json`;[插件管理页](../../../../packages/client/ui-plugin-manager/README.zh.md#use-this-package)为符合展示条件的组合包显示这些元数据,并保留默认隐藏项的显式启用入口。Bundle 的展示方式不决定其[实验性状态](../../../../packages/experimental/README.zh.md#status)。隔离检查校验准入,并继续拒绝非实验 bundle 的实验性依赖。
|
||||
|
||||
可选 bundle 的依赖随每次 `dsh` 安装下载。[思考过程翻译](2026-10-05-anonymous-reasoning-translation.zh.md)使用三个实验包,不增加第三方运行时依赖。因此准入需要考虑安装成本,以及开关能否在不增加配置表单的情况下提供可用组合。外部浏览器与 Cua Driver 提供方仍使用显式组合,因为它们会增加较大的运行时或要求外部可执行文件。Stagehand 需要模型凭据;Python PTC 替换核心提供方,并与 TypeScript workflow 消费方冲突。演示性钩子、webhook 与模组不进入名单。[Session Inspector](../feature/2026-09-24-session-inspector.zh.md) 管理 Inspector 特有的激活行为。
|
||||
|
||||
|
||||
+4
-4
@@ -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
|
||||
|
||||
+2
-2
@@ -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
|
||||
|
||||
|
||||
+2
-2
@@ -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。初始化结果区分已应用、保留原稿和提交期间拒绝;拒绝不先清空,导航被后续操作取消时不写稿。
|
||||
|
||||
### 数据流
|
||||
|
||||
|
||||
+37
@@ -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 都会出现“更多”按钮。
|
||||
-19
@@ -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
|
||||
-51
@@ -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.
|
||||
-51
@@ -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()` 之前,这个阶段还不是完整的强制执行点。
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
# Bilingual-pair consistency record for 2026-10-19-pi-ai-system-reminder-fallback.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-19-pi-ai-system-reminder-fallback.md
|
||||
/agent-note-pi-ai-system-reminder-fallback:
|
||||
en: f43b5c6974289180
|
||||
zh: abc0a86b1d137189
|
||||
/agent-note-pi-ai-system-reminder-fallback/problem:
|
||||
en: 5247278483ca4e7a
|
||||
zh: ca711671a43fae76
|
||||
/agent-note-pi-ai-system-reminder-fallback/decision:
|
||||
en: 604eb72704c73328
|
||||
zh: 6661d1065f65ecf9
|
||||
/agent-note-pi-ai-system-reminder-fallback/alternatives-considered:
|
||||
en: 0e2aaf897587ebb0
|
||||
zh: 0a7f9a9cb7ddffaf
|
||||
/agent-note-pi-ai-system-reminder-fallback/consequences:
|
||||
en: 8a0c53289a04c64a
|
||||
zh: 591e602eb2036a1a
|
||||
@@ -0,0 +1,25 @@
|
||||
# Agent Note: Pi-ai system reminder fallback
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-10-19-pi-ai-system-reminder-fallback.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
Pi-ai concatenates later system messages into the initial prompt for models without native mid-conversation system support. Later system messages supplied to the adapter therefore invalidate the preceding conversation cache prefix.
|
||||
|
||||
## Decision
|
||||
|
||||
The pi-ai adapter uses the prepared model's existing `supportsMidConvoSystemMessages` capability mapping. It preserves native system messages on capable models and converts each non-empty later system message into a user message wrapped in `<system-reminder>` otherwise, before pi-ai receives the context. Leading system text remains in the initial prompt. Message order, repeated updates, and existing text-block concatenation remain intact. Durable messages retain their original roles.
|
||||
|
||||
The fallback belongs to the adapter because it depends on the selected protocol capability. Prompt admission remains owned by the agent loop, which consolidates system-prompt updates on routes without native support; this fallback only handles later system messages present in the supplied request.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Let pi-ai concatenate updates.** Retains system-role priority but changes the initial prompt whenever later system text changes, defeating prefix reuse.
|
||||
|
||||
**Advertise native support or send developer messages.** Misstates model capability or relies on another unsupported instruction role. The fallback uses ordinary user messages and does not change capability metadata.
|
||||
|
||||
## Consequences
|
||||
|
||||
The wrapper does not confer system-role authority, so reminders have user-role priority. Cache hits remain provider-dependent; tool changes and compaction can still invalidate prefixes. Both text-only and attachment-aware conversion apply the same fallback, and wire tests inspect the real pi-ai serializer output.
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
# Agent Note: Pi-ai 系统提醒回退
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-10-19-pi-ai-system-reminder-fallback.md) | 中文
|
||||
|
||||
## 问题
|
||||
|
||||
对于不原生支持对话中系统消息的模型,pi-ai 会把后续系统消息拼接进初始提示词。因此,传给适配器的后续系统消息会使此前的对话缓存前缀失效。
|
||||
|
||||
## 决策
|
||||
|
||||
pi-ai 适配器使用已准备模型的现有 `supportsMidConvoSystemMessages` 能力映射。对于具备该能力的模型,适配器保留原生系统消息;否则,在 pi-ai 接收上下文之前,将每条非空的后续系统消息转换为以 `<system-reminder>` 包裹的用户消息。开头的系统文本仍位于初始提示词中。消息顺序、重复更新和现有文本块拼接方式均保持不变。持久消息保留原始角色。
|
||||
|
||||
回退由适配器负责,因为它取决于所选协议的能力。提示词纳入仍由 Agent loop 负责,它在不支持原生更新的路由上合并系统提示词更新;此回退仅处理传入请求中已有的后续系统消息。
|
||||
|
||||
## 考虑过的替代方案
|
||||
|
||||
**让 pi-ai 拼接更新。** 保留系统角色优先级,但后续系统文本每次变化都会改变初始提示词,破坏前缀复用。
|
||||
|
||||
**宣称原生支持或发送 developer 消息。** 错报模型能力,或依赖另一种不受支持的指令角色。回退使用普通用户消息,不改变能力元数据。
|
||||
|
||||
## 后果
|
||||
|
||||
包裹文本不会赋予系统角色权限,因此提醒具有用户角色优先级。缓存命中仍取决于提供方;工具变化和压缩仍可能使前缀失效。纯文本与附件感知转换使用相同的回退,线协议测试检查真实 pi-ai 序列化器的输出。
|
||||
+25
@@ -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
|
||||
+51
@@ -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.
|
||||
+51
@@ -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 预算。
|
||||
|
||||
### 结果与取消
|
||||
|
||||
|
||||
+19
@@ -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.
|
||||
+41
@@ -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 可达。
|
||||
|
||||
真机安装、启动显示与窗口模式仍属设备检查。
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
@@ -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)).
|
||||
|
||||
@@ -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` |
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 |
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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.
|
||||
|
||||
@@ -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 例外。
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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/)
|
||||
})
|
||||
})
|
||||
@@ -8,8 +8,8 @@ import { readProfileManifest } from '@deepseek-ai/dsh-app-boot'
|
||||
import { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import type {} from '@deepseek-ai/dsh-ptc-runtime'
|
||||
import type {} from '@deepseek-ai/dsh-agent-preset-registry'
|
||||
import { expect, it } from 'vitest'
|
||||
import { launchWebScaffold, type WebScaffold } from './scaffold.ts'
|
||||
import { expect, it, vi } from 'vitest'
|
||||
import { captureStableAria, compareOrRefreshGolden, launchWebScaffold, webSnapshotMode, type WebScaffold } from './scaffold.ts'
|
||||
import { connectFreshWorkspaceZh, ZH_BROWSER_LOCALE } from './support.ts'
|
||||
|
||||
it.each([undefined, 'minimal'] as const)('adds CUA to PTC sessions while preserving default %s until the explicit persistent-runtime action', async (selectedDefault) => {
|
||||
@@ -54,9 +54,28 @@ it.each([undefined, 'minimal'] as const)('adds CUA to PTC sessions while preserv
|
||||
const toggle = card.getByRole('switch', { name: '启用 Computer Use', exact: true })
|
||||
expect(await toggle.getAttribute('aria-checked')).toBe('false')
|
||||
expect(await card.textContent()).toContain('请切换到 PTC 模式')
|
||||
const enabling = Promise.withResolvers<undefined>()
|
||||
const setEnabled = scaffold.ctx.pluginManager.setBundleEnabled.bind(scaffold.ctx.pluginManager)
|
||||
let applying: ReturnType<typeof setEnabled> | undefined
|
||||
// Hold the Host request so the pending UI does not depend on machine speed; release into the real operation.
|
||||
const enableCall = vi.spyOn(scaffold.ctx.pluginManager, 'setBundleEnabled').mockImplementationOnce((name, enabled) => {
|
||||
applying = enabling.promise.then(() => setEnabled(name, enabled))
|
||||
return applying
|
||||
})
|
||||
try {
|
||||
await toggle.click()
|
||||
await card.getByRole('status').getByText('正在应用 Computer Use 的更改', { exact: true }).waitFor()
|
||||
expect(await toggle.isEnabled()).toBe(false)
|
||||
await compareOrRefreshGolden(fileURLToPath(new URL('./expected/cua-permissions/enabling.expected.md', import.meta.url)),
|
||||
await captureStableAria(page, '[data-plugin-package="@deepseek-ai/dsh-cua-bundle"]', scaffold.workspaceCwd), webSnapshotMode())
|
||||
} finally {
|
||||
enabling.resolve(undefined)
|
||||
enableCall.mockRestore()
|
||||
await applying
|
||||
}
|
||||
// Bundle transitions await native cleanup under the browser action deadline.
|
||||
await toggle.click()
|
||||
await toggle.and(page.locator('[aria-checked="true"]')).waitFor({ state: 'visible' })
|
||||
await card.getByRole('status').getByText('正在应用 Computer Use 的更改', { exact: true }).waitFor({ state: 'hidden' })
|
||||
const setup = page.getByRole('dialog', { name: '使用 Computer Use 前需要设置', exact: true })
|
||||
await setup.waitFor()
|
||||
expect(await setup.getByText('Computer Use 插件已开启,但暂时无法检测系统权限。请前往插件详情页查看原因并重试。', { exact: true }).count()).toBe(1)
|
||||
|
||||
@@ -7,9 +7,9 @@ import { ToolCallId } from '@deepseek-ai/dsh-llm'
|
||||
import type {} from '@deepseek-ai/dsh-user-approval'
|
||||
import type {} from '@deepseek-ai/dsh-agent-preset-registry'
|
||||
import { chromium, type Browser } from 'playwright'
|
||||
import { expect, it, onTestFailed, onTestFinished } from 'vitest'
|
||||
import { expect, it, onTestFailed, onTestFinished, vi } from 'vitest'
|
||||
import { captureStableAria, compareOrRefreshGolden, launchWebScaffold, webSnapshotMode, watchConsole, type WebScaffold } from './scaffold.ts'
|
||||
import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './support.ts'
|
||||
import { connectFreshWorkspace, newEnglishPage, PLUGIN_TOGGLE_SETTLE_MS, saveFailureShot } from './support.ts'
|
||||
import { installNativeFixture, startFixtureHost } from '../../../packages/experimental/cua-macos/tests/native-fixture.ts'
|
||||
|
||||
const bundle = fileURLToPath(new URL('../../../packages/experimental/cua-bundle', import.meta.url))
|
||||
@@ -146,5 +146,96 @@ it.skipIf(process.platform !== 'darwin')('keeps system requests pending until fr
|
||||
{ method: 'openPermissionSettings', params: { permission: 'accessibility' } },
|
||||
])
|
||||
expect(events.every(event => ['permissionStatus', 'requestPermission', 'openPermissionSettings', 'applicationIcons', 'getApp'].includes(event.method))).toBe(true)
|
||||
await plugins.locator('[data-detail-document] h1').waitFor()
|
||||
const trace = await page.evaluateHandle(() => {
|
||||
const errors: string[] = []
|
||||
const stable = [...document.querySelectorAll('[data-cua-settings] [role="alert"]')].map(node => node.textContent?.trim())
|
||||
const layouts: { height: number; sections: number[]; groups: number[] }[] = []
|
||||
const documents: number[] = []
|
||||
let frame = 0
|
||||
const measure = () => {
|
||||
const documentHeight = document.querySelector('[data-plugin-detail] [data-detail-document]')?.getBoundingClientRect().height ?? 0
|
||||
if (documents.at(-1) !== documentHeight) documents.push(documentHeight)
|
||||
const settings = document.querySelector('[data-cua-settings]')
|
||||
if (settings) {
|
||||
const height = settings.getBoundingClientRect().height
|
||||
if (layouts.at(-1)?.height !== height) layouts.push({ height,
|
||||
sections: [...settings.querySelectorAll('section')].map(node => node.getBoundingClientRect().height),
|
||||
groups: [...settings.querySelectorAll('[role="group"]')].map(node => node.getBoundingClientRect().height),
|
||||
})
|
||||
}
|
||||
frame = requestAnimationFrame(measure)
|
||||
}
|
||||
measure()
|
||||
const observer = new MutationObserver(() => {
|
||||
for (const node of document.querySelectorAll('[data-cua-settings] [role="alert"]')) {
|
||||
const text = node.textContent?.trim()
|
||||
if (text && !stable.includes(text) && errors.at(-1) !== text) errors.push(text)
|
||||
}
|
||||
})
|
||||
observer.observe(document.body, { childList: true, subtree: true, characterData: true })
|
||||
return { errors, layouts, documents, stop: () => { observer.disconnect(); cancelAnimationFrame(frame) } }
|
||||
})
|
||||
try {
|
||||
const toggle = page.locator('[data-plugin-panel]').getByRole('switch', { name: 'Enable Computer Use', exact: true })
|
||||
for (const width of [1680, 390]) {
|
||||
await page.setViewportSize({ width, height: 1000 })
|
||||
if (width === 390) await page.locator('[data-sidebar-collapsed]').waitFor({ state: 'attached' })
|
||||
await page.locator('[data-animating]').waitFor({ state: 'hidden' })
|
||||
await trace.evaluate((value) => { value.layouts.length = 0; value.documents.length = 0 })
|
||||
for (let cycle = 0; cycle < 3; cycle++) {
|
||||
await toggle.click()
|
||||
await expect.poll(() => toggle.getAttribute('aria-checked'), { timeout: PLUGIN_TOGGLE_SETTLE_MS }).toBe('false')
|
||||
await expect.poll(() => toggle.isEnabled(), { timeout: PLUGIN_TOGGLE_SETTLE_MS }).toBe(true)
|
||||
await toggle.click()
|
||||
await expect.poll(() => toggle.getAttribute('aria-checked'), { timeout: PLUGIN_TOGGLE_SETTLE_MS }).toBe('true')
|
||||
await expect.poll(() => panel.getAttribute('aria-busy'), { timeout: PLUGIN_TOGGLE_SETTLE_MS }).toBe('false')
|
||||
expect(await trace.evaluate(value => value.errors)).toEqual([])
|
||||
}
|
||||
const layouts = await trace.evaluate(value => value.layouts)
|
||||
expect(layouts.length).toBeGreaterThan(0)
|
||||
const documentHeights = await trace.evaluate(value => value.documents)
|
||||
expect(documentHeights.length).toBeGreaterThan(0)
|
||||
expect(Math.max(...documentHeights) - Math.min(...documentHeights)).toBeLessThanOrEqual(1)
|
||||
const heights = layouts.map(layout => layout.height)
|
||||
expect(Math.max(...heights) - Math.min(...heights), JSON.stringify(layouts)).toBeLessThanOrEqual(1)
|
||||
}
|
||||
} finally {
|
||||
await trace.evaluate((value) => { value.stop() })
|
||||
await trace.dispose()
|
||||
}
|
||||
await writeFile(state, JSON.stringify({ accessibility: 'unavailable', screenRecording: false }))
|
||||
await page.evaluate(() => { window.dispatchEvent(new Event('focus')) })
|
||||
const diagnostic = panel.getByRole('alert')
|
||||
await diagnostic.waitFor()
|
||||
const diagnosticText = await diagnostic.innerText()
|
||||
const height = await panel.evaluate(element => element.getBoundingClientRect().height)
|
||||
const rowToggle = plugins.getByRole('switch', { name: 'Enable component @deepseek-ai/dsh-experimental-cua-macos', exact: true })
|
||||
const release = Promise.withResolvers<undefined>()
|
||||
const changeRow = scaffold.ctx.pluginManager.setPluginEnabled.bind(scaffold.ctx.pluginManager)
|
||||
let changing: ReturnType<typeof changeRow> | undefined
|
||||
const hold = vi.spyOn(scaffold.ctx.pluginManager, 'setPluginEnabled').mockImplementationOnce((entryId, enabled) => {
|
||||
changing = release.promise.then(() => changeRow(entryId, enabled))
|
||||
return changing
|
||||
})
|
||||
try {
|
||||
await rowToggle.click()
|
||||
await expect.poll(() => hold.mock.calls.length, { timeout: PLUGIN_TOGGLE_SETTLE_MS }).toBe(1)
|
||||
await expect.poll(() => panel.getAttribute('aria-busy')).toBe('true')
|
||||
expect(await diagnostic.innerText()).toBe(diagnosticText)
|
||||
expect(await diagnostic.getByRole('button', { name: 'Retry', exact: true }).isEnabled()).toBe(false)
|
||||
expect(await panel.evaluate(element => element.getBoundingClientRect().height)).toBe(height)
|
||||
} finally {
|
||||
release.resolve(undefined)
|
||||
hold.mockRestore()
|
||||
await changing
|
||||
}
|
||||
await expect.poll(() => rowToggle.getAttribute('aria-checked'), { timeout: PLUGIN_TOGGLE_SETTLE_MS }).toBe('false')
|
||||
await expect.poll(() => rowToggle.isEnabled(), { timeout: PLUGIN_TOGGLE_SETTLE_MS }).toBe(true)
|
||||
await writeFile(state, JSON.stringify({ accessibility: false, screenRecording: false, launchApplication }))
|
||||
await rowToggle.click()
|
||||
await expect.poll(() => rowToggle.getAttribute('aria-checked'), { timeout: PLUGIN_TOGGLE_SETTLE_MS }).toBe('true')
|
||||
await expect.poll(() => panel.getAttribute('aria-busy'), { timeout: PLUGIN_TOGGLE_SETTLE_MS }).toBe('false')
|
||||
await diagnostic.waitFor({ state: 'hidden' })
|
||||
expect(tripwire.pageErrors).toEqual([])
|
||||
}, 60_000)
|
||||
})
|
||||
@@ -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"
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
- button "返回 实验性插件": 实验性插件
|
||||
- button "卸载 思考过程机器翻译" [disabled]: 卸载
|
||||
- status
|
||||
- switch "启用 思考过程机器翻译" [checked]
|
||||
- heading "思考过程机器翻译" [level=3]
|
||||
- text: v{{version}} 实验性
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
- button "返回 实验性插件": 实验性插件
|
||||
- button "卸载 思考过程机器翻译" [disabled]: 卸载
|
||||
- status
|
||||
- switch "启用 思考过程机器翻译" [checked]
|
||||
- heading "思考过程机器翻译" [level=3]
|
||||
- text: v{{version}} 实验性
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
- listitem:
|
||||
- button "查看 Computer Use": Computer Use
|
||||
- text: 实验性 目前仅支持 macOS,Windows 支持正在开发中。为已有及新建 PTC 会话加载 Computer Use。请切换到 PTC 模式来使用,并授予辅助功能和屏幕录制权限。
|
||||
- status: 正在应用 Computer Use 的更改
|
||||
- switch "启用 Computer Use" [disabled]
|
||||
@@ -16,7 +16,7 @@
|
||||
- strong: Screen Recording
|
||||
- paragraph: In System Settings, allow Visual Studio Code.app to capture application windows.
|
||||
- button "Request permission"
|
||||
- paragraph: Enable the application in System Settings. Follow the macOS prompt if it names another application.
|
||||
- paragraph: Manage permissions in System Settings for the application named by macOS.
|
||||
- region "Manage applications Computer Use can access":
|
||||
- heading "Manage applications Computer Use can access" [level=3]
|
||||
- heading "Always-allowed applications" [level=4]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
- button "返回插件": 插件
|
||||
- button "夹具操作"
|
||||
- button "卸载 @fixture/live-client": 卸载
|
||||
- status
|
||||
- switch "启用 @fixture/live-client" [checked]
|
||||
- heading "@fixture/live-client" [level=3]
|
||||
- text: v0.0.1 夹具标签
|
||||
|
||||
@@ -25,24 +25,30 @@
|
||||
- listitem:
|
||||
- button "查看 智能体团队": 智能体团队
|
||||
- text: 实验性 启用团队协作、团队工具、成员列表和共享任务看板。
|
||||
- status
|
||||
- switch "启用 智能体团队"
|
||||
- listitem:
|
||||
- button "查看 语音输入": 语音输入
|
||||
- text: 实验性 在本机使用 SenseVoice 转写录音,首次使用需安装依赖。
|
||||
- status
|
||||
- switch "启用 语音输入"
|
||||
- listitem:
|
||||
- button "查看 自动授权审查": 自动授权审查
|
||||
- text: 实验性 提供自动审查权限模式,由模型在每次工具调用前判断是否授权。
|
||||
- status
|
||||
- switch "启用 自动授权审查"
|
||||
- listitem:
|
||||
- button "查看 开发者工具": 开发者工具
|
||||
- text: 实验性 查看调试会话原始数据、聊天消息分组数据,以及调试 NodeJS 后端
|
||||
- status
|
||||
- switch "启用 开发者工具"
|
||||
- heading "已安装插件" [level=3]
|
||||
- list:
|
||||
- listitem:
|
||||
- button "查看 @fixture/live-client": "@fixture/live-client"
|
||||
- status
|
||||
- switch "启用 @fixture/live-client"
|
||||
- listitem:
|
||||
- button "查看 dsh-web-scaffold-defaults": dsh-web-scaffold-defaults
|
||||
- status
|
||||
- switch "启用 dsh-web-scaffold-defaults" [checked]
|
||||
@@ -1,5 +1,6 @@
|
||||
- listitem:
|
||||
- button "查看 slow-package": slow-package
|
||||
- status
|
||||
- switch "启用 slow-package" [checked]
|
||||
|
||||
[
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
- button "Back to plugins": Plugins
|
||||
- button "Uninstall @fixture/bundle": Uninstall
|
||||
- status
|
||||
- switch "Enable @fixture/bundle"
|
||||
- heading "@fixture/bundle" [level=3]
|
||||
- text: v0.0.1
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
- button "返回插件": 插件
|
||||
- button "卸载 @fixture/bundle": 卸载
|
||||
- status
|
||||
- switch "启用 @fixture/bundle"
|
||||
- heading "@fixture/bundle" [level=3]
|
||||
- text: v0.0.1
|
||||
|
||||
@@ -25,25 +25,31 @@
|
||||
- listitem:
|
||||
- button "查看 智能体团队": 智能体团队
|
||||
- text: 实验性 启用团队协作、团队工具、成员列表和共享任务看板。
|
||||
- status
|
||||
- switch "启用 智能体团队"
|
||||
- listitem:
|
||||
- button "查看 语音输入": 语音输入
|
||||
- text: 实验性 在本机使用 SenseVoice 转写录音,首次使用需安装依赖。
|
||||
- status
|
||||
- switch "启用 语音输入"
|
||||
- listitem:
|
||||
- button "查看 自动授权审查": 自动授权审查
|
||||
- text: 实验性 提供自动审查权限模式,由模型在每次工具调用前判断是否授权。
|
||||
- status
|
||||
- switch "启用 自动授权审查"
|
||||
- listitem:
|
||||
- button "查看 开发者工具": 开发者工具
|
||||
- text: 实验性 查看调试会话原始数据、聊天消息分组数据,以及调试 NodeJS 后端
|
||||
- status
|
||||
- switch "启用 开发者工具"
|
||||
- heading "已安装插件" [level=3]
|
||||
- list:
|
||||
- listitem:
|
||||
- button "查看 @fixture/bundle": "@fixture/bundle"
|
||||
- text: Registry description for the fixture bundle.
|
||||
- status
|
||||
- switch "启用 @fixture/bundle" [checked]
|
||||
- listitem:
|
||||
- button "查看 dsh-web-scaffold-defaults": dsh-web-scaffold-defaults
|
||||
- status
|
||||
- switch "启用 dsh-web-scaffold-defaults" [checked]
|
||||
@@ -25,25 +25,31 @@
|
||||
- listitem:
|
||||
- button "查看 智能体团队": 智能体团队
|
||||
- text: 实验性 启用团队协作、团队工具、成员列表和共享任务看板。
|
||||
- status
|
||||
- switch "启用 智能体团队"
|
||||
- listitem:
|
||||
- button "查看 语音输入": 语音输入
|
||||
- text: 实验性 在本机使用 SenseVoice 转写录音,首次使用需安装依赖。
|
||||
- status
|
||||
- switch "启用 语音输入"
|
||||
- listitem:
|
||||
- button "查看 自动授权审查": 自动授权审查
|
||||
- text: 实验性 提供自动审查权限模式,由模型在每次工具调用前判断是否授权。
|
||||
- status
|
||||
- switch "启用 自动授权审查"
|
||||
- listitem:
|
||||
- button "查看 开发者工具": 开发者工具
|
||||
- text: 实验性 查看调试会话原始数据、聊天消息分组数据,以及调试 NodeJS 后端
|
||||
- status
|
||||
- switch "启用 开发者工具"
|
||||
- heading "已安装插件" [level=3]
|
||||
- list:
|
||||
- listitem:
|
||||
- button "查看 @fixture/bundle": "@fixture/bundle"
|
||||
- text: Registry description for the fixture bundle.
|
||||
- status
|
||||
- switch "启用 @fixture/bundle"
|
||||
- listitem:
|
||||
- button "查看 dsh-web-scaffold-defaults": dsh-web-scaffold-defaults
|
||||
- status
|
||||
- switch "启用 dsh-web-scaffold-defaults" [checked]
|
||||
@@ -1,5 +1,6 @@
|
||||
- button "返回插件": 插件
|
||||
- button "卸载 @fixture/missing-bundle": 卸载
|
||||
- status
|
||||
- switch "启用 @fixture/missing-bundle" [checked]
|
||||
- heading "@fixture/missing-bundle" [level=3]
|
||||
- text: 异常 未安装
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
- button "返回 实验性插件": 实验性插件
|
||||
- status
|
||||
- switch "启用 DSH 徽章技能"
|
||||
- heading "DSH 徽章技能" [level=3]
|
||||
- text: v{{version}} 实验性
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
- table:
|
||||
- rowgroup:
|
||||
- row "SYSTEM, Initial System Prompt":
|
||||
- cell "SYSTEM"
|
||||
- cell "Initial System Prompt"
|
||||
- row "USER, 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.":
|
||||
- cell "Turn 1 USER": USER
|
||||
- cell "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."
|
||||
- 'row "CONTEXT, Current runtime context. This snapshot supersedes earlier runtime-context snapshots. Current working directory: \"{{cwd}}/workspace\". Current DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}/workspace\". Some pl…"':
|
||||
- cell "CONTEXT"
|
||||
- 'cell "Current runtime context. This snapshot supersedes earlier runtime-context snapshots. Current working directory: \"{{cwd}}/workspace\". Current DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}/workspace\". Some pl…"'
|
||||
- 'row "CONTEXT, Time sampled while preparing turn 1, step 1: 2026-01-15T{{clock}}+{{clock}}[Asia/Shanghai] Browser time zone for this request: Asia/Shanghai. Interpret otherwise-unqualified dates and times in this zone. Elapsed since the preceding model-visible message: unavailable."':
|
||||
- cell "CONTEXT"
|
||||
- 'cell "Time sampled while preparing turn 1, step 1: 2026-01-15T{{clock}}+{{clock}}[Asia/Shanghai] Browser time zone for this request: Asia/Shanghai. Interpret otherwise-unqualified dates and times in this zone. Elapsed since the preceding model-visible message: unavailable."'
|
||||
- row "Request 1, compaction":
|
||||
- 'cell "Request #1"':
|
||||
- 'button "Request #1"'
|
||||
- cell
|
||||
- 'row "USER, Interjection: include the word BANANA in your final reply."':
|
||||
- cell "Turn 2 USER": USER
|
||||
- 'cell "Interjection: include the word BANANA in your final reply."'
|
||||
- row "USER, Also acknowledge this second message.":
|
||||
- cell "USER"
|
||||
- cell "Also acknowledge this second message."
|
||||
- row "SYSTEM, Tools Updated":
|
||||
- cell "SYSTEM"
|
||||
- cell "Tools Updated"
|
||||
- 'row "CONTEXT, Tool added: trajectory_order_probe"':
|
||||
- cell "CONTEXT"
|
||||
- 'cell "Tool added: trajectory_order_probe"'
|
||||
- row "Request 2, ASSISTANT, Great, let's move forward. BANANA!":
|
||||
- 'cell "Request #2 ASSISTANT"':
|
||||
- 'button "Request #2"'
|
||||
- text: ASSISTANT
|
||||
- cell "Great, let's move forward. BANANA!"
|
||||
@@ -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
|
||||
@@ -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([])
|
||||
})
|
||||
@@ -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()
|
||||
|
||||
@@ -9,7 +9,6 @@ import { readFile } from 'node:fs/promises'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import type { Browser, Locator, Page } from 'playwright'
|
||||
import { chromium } from 'playwright'
|
||||
import { ON_DEMAND_BUNDLES, OPTIONAL_BUNDLES } from '@deepseek-ai/dsh-app-boot'
|
||||
import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
|
||||
import { join } from 'node:path'
|
||||
import {
|
||||
@@ -111,8 +110,9 @@ describe('web e2e: plugin configuration pages', () => {
|
||||
await more.focus()
|
||||
await page.keyboard.press('Enter')
|
||||
await panel.getByRole('heading', { name: '实验性插件', exact: true, level: 1 }).waitFor()
|
||||
const bundles = await scaffold.ctx.pluginManager.listBundles()
|
||||
expect(await panel.locator('[data-plugin-group="more"] [data-plugin-package]').count())
|
||||
.toBe(OPTIONAL_BUNDLES.length + ON_DEMAND_BUNDLES.length)
|
||||
.toBe(bundles.filter(bundle => bundle.official && (bundle.listedByDefault !== false || bundle.enabled || bundle.installed)).length)
|
||||
expect(await basic.count()).toBe(0)
|
||||
expect(await extensions.count()).toBe(0)
|
||||
expect(await panel.locator('[data-plugin-group="bundles"]').count()).toBe(0)
|
||||
|
||||
@@ -8,7 +8,7 @@ import { fileURLToPath } from 'node:url'
|
||||
import type { Browser, Locator, Page } from 'playwright'
|
||||
import { chromium } from 'playwright'
|
||||
import { FiberState } from '@deepseek-ai/cordis'
|
||||
import { ON_DEMAND_BUNDLES, OPTIONAL_BUNDLES } from '@deepseek-ai/dsh-app-boot'
|
||||
import { readProfileManifest } from '@deepseek-ai/dsh-app-boot'
|
||||
import { createUserMessage } from '@deepseek-ai/dsh-llm'
|
||||
import { afterAll, beforeAll, describe, expect, it, onTestFailed, onTestFinished, vi } from 'vitest'
|
||||
import { join } from 'node:path'
|
||||
@@ -298,7 +298,7 @@ describe('web e2e: plugin manager', () => {
|
||||
width: node.getBoundingClientRect().width,
|
||||
height: node.getBoundingClientRect().height,
|
||||
})))
|
||||
expect(blankActions).toEqual(Array.from({ length: 4 }, () => ({ children: 0, background: 'rgba(0, 0, 0, 0)', width: 36, height: 20 })))
|
||||
expect(blankActions).toEqual(Array.from({ length: 4 }, () => ({ children: 0, background: 'rgba(0, 0, 0, 0)', width: 58, height: 20 })))
|
||||
if (shots !== undefined) {
|
||||
for (const colorScheme of ['light', 'dark'] as const) {
|
||||
await probe.emulateMedia({ colorScheme })
|
||||
@@ -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()
|
||||
@@ -582,8 +664,14 @@ describe('web e2e: plugin manager', () => {
|
||||
expect(await panel.getByText(/Cordis|Chrome DevTools/).count()).toBe(0)
|
||||
await panel.getByRole('button', { name: '更多', exact: true }).click()
|
||||
await panel.getByRole('heading', { name: '实验性插件', exact: true, level: 1 }).waitFor()
|
||||
const bundles = await scaffold.ctx.pluginManager.listBundles()
|
||||
expect(await panel.locator('[data-plugin-group="more"] [data-plugin-package]').count())
|
||||
.toBe(OPTIONAL_BUNDLES.length + ON_DEMAND_BUNDLES.length)
|
||||
.toBe(bundles.filter(bundle => bundle.official && (bundle.listedByDefault !== false || bundle.enabled || bundle.installed)).length)
|
||||
for (const name of ['@deepseek-ai/dsh-experimental-session-search', '@deepseek-ai/dsh-experimental-ralph-bundle']) {
|
||||
expect((await scaffold.ctx.pluginManager.listBundles()).find(bundle => bundle.name === name))
|
||||
.toMatchObject({ enabled: false, optional: true, availability: 'installation', listedByDefault: false })
|
||||
expect(await panel.locator(`[data-plugin-package="${name}"]`).count()).toBe(0)
|
||||
}
|
||||
const cards = await panel.locator('[data-plugin-group="more"] [data-plugin-package]').all()
|
||||
expect(await Promise.all(cards.map(async card => [
|
||||
await card.getAttribute('data-plugin-package'),
|
||||
@@ -597,10 +685,8 @@ describe('web e2e: plugin manager', () => {
|
||||
['@deepseek-ai/dsh-experimental-tool-worktree', 1],
|
||||
['@deepseek-ai/dsh-experimental-cot-translation-bundle', 1],
|
||||
['@deepseek-ai/dsh-experimental-terminal-bundle', 1],
|
||||
['@deepseek-ai/dsh-experimental-session-search', 1],
|
||||
['@deepseek-ai/dsh-experimental-session-titles-bundle', 1],
|
||||
['@deepseek-ai/dsh-experimental-badge-skill-bundle', 1],
|
||||
['@deepseek-ai/dsh-experimental-ralph-bundle', 1],
|
||||
['@deepseek-ai/dsh-experimental-plugin-market-bundle', 1],
|
||||
['@deepseek-ai/dsh-subagent-codex', 0],
|
||||
['@deepseek-ai/dsh-subagent-claude-code', 0],
|
||||
@@ -621,6 +707,36 @@ describe('web e2e: plugin manager', () => {
|
||||
expect(tripwire.pageErrors).toEqual([])
|
||||
}, 60_000)
|
||||
|
||||
it('enables both default-hidden shipped bundles by name without adding a profile dependency', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-plugin-manager-bundled-enable'))
|
||||
const profileDir = join(scaffold.harnessHome, 'profiles', 'scaffold')
|
||||
const before = readProfileManifest('dsh', profileDir).dependencies
|
||||
for (const [name, title] of [
|
||||
['@deepseek-ai/dsh-experimental-session-search', '会话搜索'],
|
||||
['@deepseek-ai/dsh-experimental-ralph-bundle', 'Ralph 循环'],
|
||||
] as const) {
|
||||
const panel = await openPluginsPanel()
|
||||
expect(await panel.locator(`[data-plugin-package="${name}"]`).count()).toBe(0)
|
||||
await panel.getByRole('button', { name: '添加插件', exact: true }).click()
|
||||
const dialog = page.getByRole('dialog', { name: '添加插件', exact: true })
|
||||
await dialog.getByRole('textbox', { name: '包名或地址', exact: true }).fill(name)
|
||||
await dialog.getByRole('button', { name: '安装', exact: true }).click()
|
||||
const ready = page.getByRole('dialog', { name: '插件已随 DSH 提供', exact: true })
|
||||
await ready.waitFor()
|
||||
expect((await scaffold.ctx.pluginManager.listBundles()).find(bundle => bundle.name === name)?.enabled).toBe(false)
|
||||
expect(await ready.getByText('无需下载,启用即可使用', { exact: true }).count()).toBe(1)
|
||||
await ready.getByRole('button', { name: '立即启用', exact: true }).click()
|
||||
const card = panel.locator(`[data-plugin-package="${name}"]`)
|
||||
await card.waitFor()
|
||||
expect((await scaffold.ctx.pluginManager.listBundles()).find(bundle => bundle.name === name)?.enabled).toBe(true)
|
||||
expect(readProfileManifest('dsh', profileDir).dependencies).toEqual(before)
|
||||
await card.getByRole('switch', { name: `启用 ${title}`, exact: true }).click()
|
||||
await card.waitFor({ state: 'hidden' })
|
||||
expect((await scaffold.ctx.pluginManager.listBundles()).find(bundle => bundle.name === name)?.enabled).toBe(false)
|
||||
}
|
||||
expect(tripwire.pageErrors).toEqual([])
|
||||
}, 60_000)
|
||||
|
||||
it('keeps off-state switch thumbs light in both themes', async () => {
|
||||
// The off-state thumb must stay light so an operable toggle cannot read as
|
||||
// disabled, whose only other difference is reduced opacity.
|
||||
@@ -1139,7 +1255,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',
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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)]
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
})
|
||||
@@ -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}`])
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -28,6 +28,8 @@ const FIXTURE = join(SNAPSHOT_DIR, 'session.v3.jsonl')
|
||||
const MID_EXPECTED = join(SNAPSHOT_DIR, 'mid-steer.expected.md')
|
||||
const SETTLED_EXPECTED = join(SNAPSHOT_DIR, 'settled.expected.md')
|
||||
const SETTLED_EXPANDED_EXPECTED = join(SNAPSHOT_DIR, 'settled-expanded.expected.md')
|
||||
const TRAJECTORY_EXPECTED = join(SNAPSHOT_DIR, 'trajectory.expected.md')
|
||||
const RETAINED_TRAJECTORY_EXPECTED = fileURLToPath(new URL('./expected/retained-steering/trajectory.expected.md', import.meta.url))
|
||||
const MODE = webSnapshotMode()
|
||||
// The question composer replaces the textarea, so fill → Queue row → Steer
|
||||
// starts only after request/context and must finish before the first replay
|
||||
@@ -80,9 +82,11 @@ describe('web e2e: mid-turn steering lands durably and visibly', () => {
|
||||
// clocks must read the fixture day or a run across Asia/Shanghai midnight
|
||||
// renders the same message with a date prefix.
|
||||
hostClock = pinHostClock()
|
||||
scaffold = await launchWebScaffold(MODE === 'record'
|
||||
? {}
|
||||
: { replayFixture: FIXTURE, paceMs: REPLAY_PACE_MS, compareReplaySession: true })
|
||||
// Trajectory previews truncate before path normalization; pin the input length across hosts.
|
||||
scaffold = await launchWebScaffold({
|
||||
workspacePathLength: 112,
|
||||
...(MODE === 'record' ? {} : { replayFixture: FIXTURE, paceMs: REPLAY_PACE_MS, compareReplaySession: true }),
|
||||
})
|
||||
scaffold.ctx.on('session/event', (_session, event) => { sessionEvents.push(event) })
|
||||
browser = await chromium.launch()
|
||||
page = await newEnglishPage(browser)
|
||||
@@ -193,6 +197,13 @@ describe('web e2e: mid-turn steering lands durably and visibly', () => {
|
||||
scaffold.workspaceCwd,
|
||||
)
|
||||
await compareOrRefreshGolden(SETTLED_EXPANDED_EXPECTED, expanded, MODE)
|
||||
await page.getByRole('tab', { name: 'Trajectory', exact: true }).click()
|
||||
await page.locator('[data-trajectory-row-key]').filter({ hasText: STEER }).waitFor({ timeout: 10_000 })
|
||||
await compareOrRefreshGolden(
|
||||
TRAJECTORY_EXPECTED,
|
||||
await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd),
|
||||
MODE,
|
||||
)
|
||||
expect(tripwire.pageErrors).toEqual([])
|
||||
expect(tripwire.warnings).toEqual([])
|
||||
}, 200_000)
|
||||
@@ -200,10 +211,131 @@ describe('web e2e: mid-turn steering lands durably and visibly', () => {
|
||||
it.skipIf(MODE === 'record')('keeps the fixture inventory closed', async () => {
|
||||
await assertFixtureInventory(SNAPSHOT_DIR, [
|
||||
'session.v3.jsonl', 'mid-steer.expected.md', 'settled.expected.md', 'settled-expanded.expected.md',
|
||||
'trajectory.expected.md',
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe.skipIf(MODE === 'record')('web e2e: retained steering and a post-stop message', () => {
|
||||
let scaffold: WebScaffold
|
||||
let browser: Browser
|
||||
let page: Page
|
||||
let tripwire: ReturnType<typeof watchConsole>
|
||||
let hostClock: MockInstance<typeof Date.now> | undefined
|
||||
const sessionEvents: SessionEvent[] = []
|
||||
const requestInputs: string[][] = []
|
||||
const releaseQuestion = Promise.withResolvers<undefined>()
|
||||
const FOLLOWUP = 'Also acknowledge this second message.'
|
||||
const RESPONSE = "Great, let's move forward. BANANA!"
|
||||
|
||||
beforeAll(async () => {
|
||||
hostClock = pinHostClock()
|
||||
scaffold = await launchWebScaffold({
|
||||
replayFixture: FIXTURE, compareReplaySession: false, workspacePathLength: 112,
|
||||
})
|
||||
scaffold.ctx.on('session/event', (_session, event) => { sessionEvents.push(event) })
|
||||
scaffold.ctx.on('llm/stream', async function* (options, next) {
|
||||
requestInputs.push(options.messages.flatMap(message =>
|
||||
message.role === 'user'
|
||||
? message.content.flatMap(block => block.type === 'text' ? [block.text] : [])
|
||||
: []))
|
||||
for await (const chunk of next()) {
|
||||
yield chunk
|
||||
if (requestInputs.length === 1 && chunk.type === 'block-start') await releaseQuestion.promise
|
||||
}
|
||||
}, { prepend: true })
|
||||
browser = await chromium.launch()
|
||||
page = await newEnglishPage(browser)
|
||||
await pinBrowserClock(page)
|
||||
tripwire = watchConsole(page)
|
||||
await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
|
||||
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
|
||||
await connectFreshWorkspace(page, scaffold.workspaceCwd)
|
||||
})
|
||||
|
||||
afterAll(async () => {
|
||||
releaseQuestion.resolve(undefined)
|
||||
try {
|
||||
try {
|
||||
await browser?.close()
|
||||
} finally {
|
||||
await scaffold?.close()
|
||||
}
|
||||
} finally {
|
||||
hostClock?.mockRestore()
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps both inputs before the response in Chat and Trajectory, including after reload', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-trajectory-input-order'))
|
||||
const input = page.locator('[data-composer-input][contenteditable="true"]').first()
|
||||
await input.fill(PROMPT)
|
||||
await input.press('Enter')
|
||||
await expect.poll(() => requestInputs.length, { timeout: 10_000 }).toBe(1)
|
||||
await input.fill(STEER)
|
||||
await input.press('Meta+Enter')
|
||||
await page.locator('[data-pending-steering]').filter({ hasText: STEER }).waitFor({ timeout: 10_000 })
|
||||
await page.getByRole('button', { name: 'Stop generating', exact: true }).click()
|
||||
releaseQuestion.resolve(undefined)
|
||||
await expect.poll(() => sessionEvents.filter(event => event.type === 'turn/end').length, { timeout: 10_000 }).toBe(1)
|
||||
expect(claimedMessages(sessionEvents, STEER)).toHaveLength(0)
|
||||
|
||||
scaffold.ctx.effect(() => scaffold.ctx.tools.register({
|
||||
name: 'trajectory_order_probe',
|
||||
description: 'A tool added between requests to exercise the tool catalog update.',
|
||||
parameters: { type: 'object', properties: {} },
|
||||
output: { schema: { type: 'null' }, render: () => [] },
|
||||
async execute() { return null },
|
||||
}))
|
||||
await input.fill(FOLLOWUP)
|
||||
await input.press('Enter')
|
||||
await expect.poll(() => sessionEvents.filter(event => event.type === 'turn/end').length, { timeout: 10_000 }).toBe(2)
|
||||
const first = claimedMessages(sessionEvents, STEER)
|
||||
const second = claimedMessages(sessionEvents, FOLLOWUP)
|
||||
const response = sessionEvents.findLast(event => event.type === 'assistant/message')
|
||||
expect(first).toHaveLength(1)
|
||||
expect(second).toHaveLength(1)
|
||||
const loggedInputs = [
|
||||
{ text: STEER, seq: first[0]!.seq },
|
||||
{ text: FOLLOWUP, seq: second[0]!.seq },
|
||||
].sort((left, right) => left.seq - right.seq)
|
||||
expect(requestInputs.at(-1)?.filter(text => text === STEER || text === FOLLOWUP))
|
||||
.toEqual(loggedInputs.map(value => value.text))
|
||||
expect(loggedInputs.every(value => value.seq < response!.seq)).toBe(true)
|
||||
|
||||
const expectOrder = async (selector: string, includeTools = false) => {
|
||||
await expect.poll(async () => {
|
||||
const text = await page.locator(selector).textContent()
|
||||
const expected = [
|
||||
...loggedInputs.map(value => value.text),
|
||||
...(includeTools ? ['Tools Updated'] : []),
|
||||
RESPONSE,
|
||||
]
|
||||
const positions = expected.map(value => text?.indexOf(value) ?? -1)
|
||||
return positions.every(position => position >= 0)
|
||||
&& positions.every((position, index) => index === 0 || positions[index - 1]! < position)
|
||||
&& expected.every(value => text?.indexOf(value) === text?.lastIndexOf(value))
|
||||
}, { timeout: 10_000 }).toBe(true)
|
||||
}
|
||||
await page.getByText(RESPONSE, { exact: true }).waitFor()
|
||||
await expectOrder('[class*="centerCol"]')
|
||||
await page.getByRole('tab', { name: 'Trajectory', exact: true }).click()
|
||||
await expectOrder('[data-trajectory-scroll]', true)
|
||||
await compareOrRefreshGolden(
|
||||
RETAINED_TRAJECTORY_EXPECTED,
|
||||
await captureStableAria(page, '[data-trajectory-scroll]', scaffold.workspaceCwd),
|
||||
MODE,
|
||||
)
|
||||
const warningStart = tripwire.warnings.length
|
||||
await page.reload({ waitUntil: 'load' })
|
||||
await page.getByRole('tab', { name: 'Trajectory', exact: true }).click()
|
||||
await expectOrder('[data-trajectory-scroll]', true)
|
||||
acknowledgeReloadConnectionLoss(tripwire, warningStart)
|
||||
expect(tripwire.pageErrors).toEqual([])
|
||||
expect(tripwire.warnings).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe.each(['reconnect', 'delayed-inbox'] as const)('web e2e: composer shortcut steers directly (%s)', (handoff) => {
|
||||
let scaffold: WebScaffold
|
||||
let browser: Browser
|
||||
|
||||
@@ -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(),
|
||||
)
|
||||
})
|
||||
@@ -32,7 +32,7 @@ it('guides a newly enabled voice plugin to installation and lets the user postpo
|
||||
await dialog.waitFor({ state: 'hidden' })
|
||||
expect(await toggle.getAttribute('aria-checked')).toBe('true')
|
||||
const card = page.locator('[data-plugin-package="@deepseek-ai/dsh-experimental-voice-input-bundle"]')
|
||||
expect(await card.getByRole('status').count()).toBe(0)
|
||||
expect(await card.getByRole('status').textContent()).toBe('')
|
||||
await toggle.click()
|
||||
await page.getByRole('switch', { name: 'Enable Voice input', exact: true, checked: false }).click()
|
||||
await dialog.getByRole('button', { name: 'Go to setup', exact: true }).click()
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
},
|
||||
"include": [
|
||||
"src",
|
||||
"scripts",
|
||||
"tests"
|
||||
],
|
||||
// The web e2e lane (scaffold + replay specs) boots the host spine and reads
|
||||
@@ -22,6 +23,7 @@
|
||||
// this client-registered project must not also hold them (one program
|
||||
// cannot see both sides of the cordis Context merges).
|
||||
"exclude": [
|
||||
"tests/hover-input.e2e.ts",
|
||||
"tests/collapse-timing.e2e.ts",
|
||||
"tests/notice-wake-tail.e2e.ts",
|
||||
"tests/fold-blank-matrix.e2e.ts",
|
||||
@@ -83,6 +85,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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)定义顺序。
|
||||
|
||||
@@ -6,5 +6,5 @@
|
||||
en: 41d772192075e133
|
||||
zh: 5ef3da69b4de1d8a
|
||||
/capability-seams-and-core-services:
|
||||
en: 3e87315977a89b5a
|
||||
zh: 626a75f3bebaa39f
|
||||
en: 753b3da3abc6aeea
|
||||
zh: aab02ec16c3f744b
|
||||
@@ -191,8 +191,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"]
|
||||
@@ -300,7 +298,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
|
||||
@@ -459,7 +456,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
|
||||
@@ -690,7 +686,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. |
|
||||
|
||||
Loaded 100 of 567 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user