Merge remote-tracking branch 'origin/master' into turtle/plugin-sticky-header-29bfdb179e18
# Conflicts: # packages/client/ui-plugin-manager/src/client/index.ts
This commit is contained in:
commit
1203a3151e
191 files changed
+6041
-847
No files matched your search
+4
-4
@@ -54,8 +54,8 @@
|
||||
en: 7865e159528e2d30
|
||||
zh: 1399d54632f4d926
|
||||
/agent-note-peer-principals-and-method-level-remote-access-control/decision/method-level-access-control/access-snapshot-stream:
|
||||
en: 41d7ac1faf2448d7
|
||||
zh: 6d523895a9abae68
|
||||
en: a928a63e8c36177c
|
||||
zh: c943c1fdc8c4bb45
|
||||
/agent-note-peer-principals-and-method-level-remote-access-control/decision/method-level-access-control/client-face:
|
||||
en: 7be28cd7e19e5377
|
||||
zh: 30b5aee957271d6c
|
||||
@@ -81,8 +81,8 @@
|
||||
en: 89452c3c3f042e75
|
||||
zh: 9bcad00a60c65303
|
||||
/agent-note-peer-principals-and-method-level-remote-access-control/consequences/verification:
|
||||
en: 048067eb02e6bf8a
|
||||
zh: 12508b59f6b206b8
|
||||
en: 2279a8c153cc5b89
|
||||
zh: 63c0b6462722f0a3
|
||||
/agent-note-peer-principals-and-method-level-remote-access-control/consequences/costs-and-limits:
|
||||
en: 1e254cec680b1a37
|
||||
zh: 06a31e057ba965aa
|
||||
|
||||
+2
-2
@@ -446,7 +446,7 @@ export const REMOTE_ACCESS_SNAPSHOT_KEY = '__DSH_REMOTE_ACCESS__'
|
||||
|
||||
| Behavior | Detail |
|
||||
|---|---|
|
||||
| First snapshot | `snapshotFor(principal)` computes a page's opening baseline. remote-access adds the global `REMOTE_ACCESS_SNAPSHOT_KEY`, set to the owner baseline `{ type: 'baseline', owner: true }`, to every index the webServer renders, through `webserver/index-inject`. A business package that renders a non-owner page (such as liveshare on the owner Host) places an assignment of that global to `snapshotFor(principal)` after every index injection row; the later assignment wins, and the Client reads the global when it mounts |
|
||||
| First snapshot | `snapshotFor(principal)` computes a page's opening baseline. The webServer renders each index for the Peer the page speaks for, and `webserver/index-inject` passes that Peer to listeners; remote-access adds the global `REMOTE_ACCESS_SNAPSHOT_KEY`, set to `snapshotFor(peer.principal)`, to every index, so the owner's page receives the owner baseline `{ type: 'baseline', owner: true }` and a page rendered for a non-owner Peer (such as a liveshare viewer page on the owner Host) receives that principal's grants. The Client reads the global when it mounts |
|
||||
| Subscription | Each Peer subscribes to one access snapshot stream, whose baseline replaces the injected one |
|
||||
| Computation | All bindings of the policy for the principal's kind are merged by equal resource; each resource's `actions` are the endpoint actions of those bindings' roles that the same decision as a call allows (one resource, without args, conditions called without args); only a resource with at least one allowed endpoint action gets a grant. Decisions and snapshots share one source: for the same principal, action, and resource (without arguments), the snapshot answer equals `decide()` |
|
||||
| Endpoint actions only | `event:<name>` actions do not enter the snapshot; the frontend decides entry visibility from endpoint actions only |
|
||||
@@ -638,7 +638,7 @@ The review rule's home is the "`@Remote` marks capability methods" section of [R
|
||||
| Resource resolution | A default field (top-level or first object argument) yields its resource; a lookup parameter that declares that kind as its `resource` yields its wire identity; a Context method gets its Session resource from the Agent Context identity in args without activating the Agent, and may declare other resources through `resources`; a `resources` reader overrides its kind, an array yields several resources, and `undefined` none; with nothing the call is `[global]`; a throw, an empty array, or a value that is not a non-empty string (including a Context identity) is invalid, refused by the Gateway with `resource-invalid` without asking `decide()`; a Context that declares no `resource` resolves by the ordinary rules; resources and `args` read the validated arguments, so an undeclared nested field changes no resource and no condition sees it, and a JSON argument failing validation returns `gateway/input-invalid` before the access check | Gateway host spec (gateway-access) |
|
||||
| Resource registry | `typert.resources` registers one default field per kind, rejects a second registration and `global`, and withdraws the registration with its fiber; a configured lookup resolver keeps the provider's `resource`; a Host Context adapter keeps its `resource` | typert-registry spec |
|
||||
| Forwarded events | A non-owner `$events` stream receives an event, whole, only when `event:<name>` is granted on its resources; an event that declares no resources is decided on `global`; an event whose reader returns `undefined` does not reach non-owners, and `decide()` is not asked; the owner receives all | Gateway host spec (gateway-access) |
|
||||
| First snapshot | `snapshotFor()` returns the owner baseline or the principal's grants; remote-access injects the owner baseline into every rendered index; the Client starts from the injected baseline and rejects a malformed one | remote-access host and client specs |
|
||||
| First snapshot | `snapshotFor()` returns the owner baseline or the principal's grants; remote-access injects the snapshot of the Peer each index speaks for; the Client starts from the injected baseline and rejects a malformed one | remote-access host and client specs |
|
||||
| Type inference | The parameter types of `resources` readers are inferred from the method; lookup positions have wire types; a missing property, more parameters than the method has, and a `global` key fail compilation; a binding whose resource kind differs from its role's fails compilation | typert-protocol spec, remote-access spec (type tests) |
|
||||
| List filtering | `session/list`, `session/search`, `session/control`, and `workspace/follow` return non-owners only items for which `allows()` is true; `workspace/follow` sends a `replace` increment when a delivered Workspace loses visibility, and the Workspace reappears once access returns; owner results are unchanged | session-controller / workspace-controller host spec |
|
||||
| Access snapshots | The allowed set in a snapshot equals the Host decision for the same principal, action, and resource without arguments; it contains endpoint actions only; a resource with no allowed action gets no grant, and one whose allowed set becomes empty is removed; `invalidate()` and policy registration or removal push increments; a wait that a change ends leaves no abort listener behind; unloading the service ends every open snapshot stream; the owner receives only `{ owner: true }` | remote-access host spec |
|
||||
|
||||
+2
-2
@@ -446,7 +446,7 @@ export const REMOTE_ACCESS_SNAPSHOT_KEY = '__DSH_REMOTE_ACCESS__'
|
||||
|
||||
| 行为 | 说明 |
|
||||
|---|---|
|
||||
| 首份快照 | `snapshotFor(principal)` 计算页面打开时的 baseline。remote-access 经 `webserver/index-inject` 为 webServer 渲染的每个 index 加入全局变量 `REMOTE_ACCESS_SNAPSHOT_KEY`,值为 owner baseline `{ type: 'baseline', owner: true }`。渲染非 owner 页面的业务包(例如 owner Host 上的 liveshare)在所有 index 注入行之后放置一条把该全局变量赋值为 `snapshotFor(principal)` 的语句;后写的赋值生效,Client 在挂载时读取该全局变量 |
|
||||
| 首份快照 | `snapshotFor(principal)` 计算页面打开时的 baseline。webServer 按页面所代表的 Peer 渲染 index,`webserver/index-inject` 把该 Peer 传给监听方;remote-access 为每个 index 加入全局变量 `REMOTE_ACCESS_SNAPSHOT_KEY`,值为 `snapshotFor(peer.principal)`,owner 页面因此得到 owner baseline `{ type: 'baseline', owner: true }`,为非 owner Peer 渲染的页面(例如 owner Host 上的 liveshare 读者页)得到该 principal 的 grant。Client 在挂载时读取该全局变量 |
|
||||
| 订阅 | 每个 Peer 订阅一条访问快照流,其 baseline 取代注入的快照 |
|
||||
| 计算 | 取该 principal kind 的 policy 的全部 binding,按资源相等归并;每个资源的 `actions` 为这些 binding 的 role 中、经与调用相同的判定(单个资源、不带 args,condition 以无 args 调用)放行的端点 action;只为至少放行一个端点 action 的资源给出 grant。判定与快照同源:同一 principal、action 与资源(不带参数)上,快照答案与 `decide()` 一致 |
|
||||
| 只含端点 action | `event:<name>` action 不进入快照;前端只据端点 action 决定入口显隐 |
|
||||
@@ -638,7 +638,7 @@ GlobalStandardProps.useWorkspaceAccess: (workspaceId: WorkspaceId, action: Remot
|
||||
| 资源解析 | 默认字段(顶层或首个对象参数)得到对应资源;声明了该种类 `resource` 的 lookup 参数得到其 wire 身份;Context 方法从 args 中的 Agent Context 身份得到其 session 资源且不激活 Agent,另可经 `resources` 声明其他资源;`resources` 读取函数覆盖该种类,数组为多个资源,`undefined` 为无;都没有为 `[global]`;抛错、空数组或非非空字符串(含 Context 身份)为非法,Gateway 以 `resource-invalid` 拒绝且不询问 `decide()`;未声明 `resource` 的 Context 按普通规则解析;资源与 `args` 读取校验后的参数,未声明的嵌套字段不改变资源、condition 也读不到它,JSON 参数校验失败在访问检查前返回 `gateway/input-invalid` | Gateway host spec(gateway-access) |
|
||||
| 资源注册表 | `typert.resources` 每个种类登记一个默认字段,拒绝重复登记与 `global`,并随 fiber 撤回;配置的 lookup resolver 保留 provider 的 `resource`;Host Context adapter 保留其 `resource` | typert-registry spec |
|
||||
| 转发事件 | 非 owner 的 `$events` 流只在 `event:<name>` 于其资源上被授权时整条收到事件;未声明资源的事件按 `global` 判定;读取函数返回 `undefined` 的事件不送达非 owner,且不询问 `decide()`;owner 全部收到 | Gateway host spec(gateway-access) |
|
||||
| 首份快照 | `snapshotFor()` 返回 owner baseline 或该 principal 的 grant;remote-access 为渲染的每个 index 注入 owner baseline;Client 从注入的 baseline 起步并拒绝格式错误的快照 | remote-access host 与 client spec |
|
||||
| 首份快照 | `snapshotFor()` 返回 owner baseline 或该 principal 的 grant;remote-access 为每个 index 注入其所代表 Peer 的快照;Client 从注入的 baseline 起步并拒绝格式错误的快照 | remote-access host 与 client spec |
|
||||
| 类型推断 | `resources` 读取函数的参数类型从方法推断;lookup 位为 wire 类型;访问不存在的属性、参数多于方法参数与 `global` 键编译失败;binding 的资源种类与 role 不符编译失败 | typert-protocol spec、remote-access spec(类型测试) |
|
||||
| 列表过滤 | `session/list`、`session/search`、`session/control`、`workspace/follow` 对非 owner 只返回 `allows()` 为 true 的项;已下发工作区失去可见性时 `workspace/follow` 发送 `replace` 增量帧,访问恢复后该工作区重新出现;owner 结果不变 | session-controller / workspace-controller host spec |
|
||||
| 访问快照 | 快照中的允许集合与 Host 对同一 principal、action、资源(不带参数)的判定一致;只含端点 action;没有放行 action 的资源不出 grant,放行集合变空时推送 `remove`;`invalidate()` 与 policy 注册、撤回后推送增量;被变化结束的等待不残留 abort 监听器;卸载服务即结束所有打开的快照流;owner 只收 `{ owner: true }` | remote-access host spec |
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
# Bilingual-pair consistency record for 2026-10-21-when-access.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-21-when-access.md
|
||||
/agent-note-whenaccess-registration-that-follows-access:
|
||||
en: 2ef7cfef514c138a
|
||||
zh: 064e3aecbfafcd9c
|
||||
/agent-note-whenaccess-registration-that-follows-access/problem:
|
||||
en: 4f88d3ee7868eaa5
|
||||
zh: 72a4ce2dde13bef7
|
||||
/agent-note-whenaccess-registration-that-follows-access/decision:
|
||||
en: a15c7c13f167e5ac
|
||||
zh: 63bcc2ef60c2f344
|
||||
/agent-note-whenaccess-registration-that-follows-access/decision/whenaccess:
|
||||
en: c168de20989cf9a4
|
||||
zh: bf3b0361beea083d
|
||||
/agent-note-whenaccess-registration-that-follows-access/decision/plugin-migration:
|
||||
en: 73577ed282c656f3
|
||||
zh: 2f60a6824218e693
|
||||
/agent-note-whenaccess-registration-that-follows-access/alternatives-considered:
|
||||
en: ddc432b693608163
|
||||
zh: 620294d8812b0a31
|
||||
/agent-note-whenaccess-registration-that-follows-access/consequences:
|
||||
en: de44fa92c49bc335
|
||||
zh: 34436fe971ab013f
|
||||
/agent-note-whenaccess-registration-that-follows-access/related:
|
||||
en: 89073ffbfcff0641
|
||||
zh: 736a180d13a08981
|
||||
@@ -0,0 +1,88 @@
|
||||
# Agent Note: whenAccess registration that follows access
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-10-21-when-access.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
Client plugins need to switch their own registrations on and off with the current Peer's access: sidebar entries, main panels, overlays, and data streams. The existing Client access surface covers only rendering:
|
||||
|
||||
| Where | Existing capability | Gap |
|
||||
|---|---|---|
|
||||
| Components | `useRemoteAccess(action)` (global), `useSessionAccess(sessionId, action)`, `useWorkspaceAccess(workspaceId, action)`, and the Session-scope `useAccess(action)` | Readable only during render; a changed answer re-renders automatically |
|
||||
| JS (`apply`, services) | `ctx.remoteAccess.source(kind, id, action)` returns a `HostObservable<boolean>` | A plugin must read `getSnapshot` and subscribe to changes itself, then register and dispose by hand |
|
||||
|
||||
Without a JS entry point, a plugin can only decide its registrations once by identity at `apply`, and they do not follow an access change.
|
||||
|
||||
## Decision
|
||||
|
||||
### whenAccess
|
||||
|
||||
The JS side has a `whenAccess` for each of the three access Hooks, from the package that publishes that Hook:
|
||||
|
||||
| Interface | Provider | Matching Hook |
|
||||
|---|---|---|
|
||||
| `ctx.remoteAccess.whenAccess(action, callback)` | remote-access | `useRemoteAccess(action)` |
|
||||
| `ctx.uiSession.whenAccess(sessionId, action, callback)` | ui-session | `useSessionAccess(sessionId, action)` |
|
||||
| `ctx.uiWorkspace.whenAccess(workspaceId, action, callback)` | ui-workspace | `useWorkspaceAccess(workspaceId, action)` |
|
||||
|
||||
```ts ignore-check
|
||||
import type { AccessEffect, RemoteEndpoint } from '@deepseek-ai/dsh-api-remote-access/client'
|
||||
|
||||
/** The global-resource member; the Session and Workspace versions take the resource id first. */
|
||||
export interface WhenAccess {
|
||||
/**
|
||||
* Keep `callback`'s effects installed while the current Peer may call `action`
|
||||
* on the resource: run it when the answer becomes true, dispose its effects in
|
||||
* reverse order when it becomes false, and run it again on the next true.
|
||||
* @returns disposer for the observation and any installed effects.
|
||||
*/
|
||||
whenAccess(action: RemoteEndpoint, callback: () => AccessEffect): () => void
|
||||
}
|
||||
```
|
||||
|
||||
`AccessEffect` is `(() => void) | Iterable<() => void, void, void>`.
|
||||
|
||||
| Rule | Content |
|
||||
|---|---|
|
||||
| Semantics | Same as `ctx.slots.inject`: the callback returns one disposer or an iterable of disposers; an iterable installs as one transaction and disposes in reverse order; the controller belongs to the caller's fiber and disposes when the plugin unloads |
|
||||
| Synchronous run | When the answer is already true, the callback runs synchronously; the owner's answer is always true, so registration order matches direct registration |
|
||||
| Stream failure | A non-owner's answer turns false and the installed effects dispose |
|
||||
| Callback failure | A setup failure at the call throws synchronously; a setup failure on a later grant stops the controller and is rethrown in a microtask |
|
||||
| Layering | remote-access knows only `global`; the Session and Workspace versions are built over remote-access's lower-level `whenResourceAccess(kind, id, action, callback)`, as their Hooks are built over `source(kind, id, action)`. Only the package that owns a resource kind calls `source` and `whenResourceAccess`; other plugins use only the access Hooks and `whenAccess` and pass no kind. All four entry points share [`when-allowed.ts`](../../../../packages/api/remote-access/src/client/when-allowed.ts) |
|
||||
|
||||
### Plugin migration
|
||||
|
||||
| Plugin | Change | Action |
|
||||
|---|---|---|
|
||||
| ui-schedule | The task catalog source, delete toast overlay, right-sidebar task tab and title, main panel, sidebar entry, turn-tail card, Session-header reminder catalog, and Session row mark and hover list move into `remoteAccess.whenAccess`; the Session-header catalog reads the per-Session `schedule.list`, so its component also decides through the Session-scope `useAccess` whether to render; the task tab type and the turn definition stay registered outside | `schedule/catalog`, `schedule/list` |
|
||||
| ui-plugin-manager | Host change subscriptions, refresh toast overlay, main panel (including `pluginNavigation`), and sidebar entry move into `remoteAccess.whenAccess`, and each grant refreshes a list already loaded; the controller and `pluginChanges` stay outside, and `pluginChanges` receives no updates while access is withdrawn. ui-entry-customization and voice-input use `pluginNavigation` optionally: without the service they hide the browse-plugins and open/details buttons and still start | `pluginManager/listBundles` |
|
||||
| ui-settings-general | The settings shell `sidebar.settings` and the `settings.open` shortcut move into `remoteAccess.whenAccess` | `settings/describe` |
|
||||
| ui-settings-account | The account state stream (including default model initialization), quota notice, settings launcher, account settings section, and `settings.models.sign-in` move into `remoteAccess.whenAccess`. On revocation the stream's disposer synchronously invalidates the account reads this grant started, closes the pending authorization tab, clears the account view, details, picture, and sign-in dialog state, ends this generation's bonus notices, which a frame of that stream begins, and then closes the stream; a frame delivered before the revocation but not yet handled is dropped. The notice controller stays outside and begins again under the next grant; the account section appears once the new stream's first frame arrives | `account/watch` |
|
||||
| ui-directory-picker-native | Both directory flows move into `remoteAccess.whenAccess` | `directoryPicker/pick` |
|
||||
| ui-message-feedback | The message feedback buttons stay registered and the component decides whether to render through the Session-scope `useAccess`; `messageFeedback/*` requests carry `sessionId`, which the Gateway resolves to that Session through the `session` resource's default field, so they declare nothing. The `/feedback` decoration has no Client check: the decoration registry is global while `sessionFeedback/record` is decided per Session, so without access the dialog still opens and the Host refuses the submission, which the dialog reports with its failure code | `messageFeedback/put` |
|
||||
| ui-deliverables, ui-open-in-app | Unchanged: an owner-only HTTP route that cannot be read falls back as before | — |
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Return after an owner check at the start of `apply` (the #6486 approach)**: fixed on identity; does not follow an access change.
|
||||
|
||||
**Trim the plugin list per connection**: a plugin cannot reload at once when access changes.
|
||||
|
||||
**Declare access in the plugin package manifest**: defines an access-control concept in the package manifest.
|
||||
|
||||
**One generic method with a resource parameter, or all three versions on remote-access**: business code would pass a kind, and remote-access would name the Session and Workspace resource kinds.
|
||||
|
||||
## Consequences
|
||||
|
||||
| Consequence | Note |
|
||||
|---|---|
|
||||
| Registrations follow access changes | Without a grant, a migrated plugin registers none of the corresponding entries for a non-owner; they appear after a grant and disappear after a revocation; the owner's answer is always true, so its registrations match direct registration |
|
||||
| The callback must hand back disposers | An effect registered through the plugin's `ctx` inside the callback also hangs on the plugin fiber; a registration whose disposer is not yielded does not dispose on access loss, only when the plugin unloads |
|
||||
| Readers receive all plugin code | Plugins load as before; the Gateway decides each call, and `whenAccess` and the Hooks control the UI |
|
||||
| Test stand-ins | [`dsh-client-test-runtime`](../../../../packages/test-support/client-runtime/README.md) replaces remote-access with fixed answers through `StaticRemoteAccess`, `staticAccessPlugin`, and `TestClientOptions.access`, so plugin specs cover the refused path |
|
||||
|
||||
## Related
|
||||
|
||||
- [Peer, principal, and remote access](2026-10-12-peer-principal-and-remote-access.md): this Note extends its Client access face.
|
||||
@@ -0,0 +1,88 @@
|
||||
# Agent Note: 按访问状态注册的 whenAccess
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-10-21-when-access.md) | 中文
|
||||
|
||||
## 问题
|
||||
|
||||
Client 插件需要按当前 Peer 的访问状态开关自己的注册:侧栏入口、主面板、浮层、数据流。现有 Client 访问面只覆盖渲染:
|
||||
|
||||
| 位置 | 现有能力 | 缺口 |
|
||||
|---|---|---|
|
||||
| 组件 | `useRemoteAccess(action)`(global)、`useSessionAccess(sessionId, action)`、`useWorkspaceAccess(workspaceId, action)`、会话作用域的 `useAccess(action)` | 只能在渲染时读,答案变化时自动重渲染 |
|
||||
| JS(`apply`、服务) | `ctx.remoteAccess.source(kind, id, action)` 返回 `HostObservable<boolean>` | 插件要自己读 `getSnapshot`、订阅变化,再手动注册与撤销 |
|
||||
|
||||
没有 JS 侧入口时,插件只能在 `apply` 时按身份一次性决定注册与否,授权变化后注册不跟随。
|
||||
|
||||
## 决策
|
||||
|
||||
### whenAccess
|
||||
|
||||
JS 侧有与三个访问 Hook 一一对应的 `whenAccess`,由各自 Hook 的发布者提供:
|
||||
|
||||
| 接口 | 提供者 | 对应 Hook |
|
||||
|---|---|---|
|
||||
| `ctx.remoteAccess.whenAccess(action, callback)` | remote-access | `useRemoteAccess(action)` |
|
||||
| `ctx.uiSession.whenAccess(sessionId, action, callback)` | ui-session | `useSessionAccess(sessionId, action)` |
|
||||
| `ctx.uiWorkspace.whenAccess(workspaceId, action, callback)` | ui-workspace | `useWorkspaceAccess(workspaceId, action)` |
|
||||
|
||||
```ts ignore-check
|
||||
import type { AccessEffect, RemoteEndpoint } from '@deepseek-ai/dsh-api-remote-access/client'
|
||||
|
||||
/** The global-resource member; the Session and Workspace versions take the resource id first. */
|
||||
export interface WhenAccess {
|
||||
/**
|
||||
* Keep `callback`'s effects installed while the current Peer may call `action`
|
||||
* on the resource: run it when the answer becomes true, dispose its effects in
|
||||
* reverse order when it becomes false, and run it again on the next true.
|
||||
* @returns disposer for the observation and any installed effects.
|
||||
*/
|
||||
whenAccess(action: RemoteEndpoint, callback: () => AccessEffect): () => void
|
||||
}
|
||||
```
|
||||
|
||||
`AccessEffect` 为 `(() => void) | Iterable<() => void, void, void>`。
|
||||
|
||||
| 规则 | 内容 |
|
||||
|---|---|
|
||||
| 语义 | 与 `ctx.slots.inject` 相同:回调返回一个 disposer 或一组 disposer;一组时按事务安装、逆序撤销;控制器属于调用方 fiber,插件卸载时撤销 |
|
||||
| 同步执行 | 调用时答案为 true 则回调同步执行;owner 的答案恒为 true,注册顺序与直接注册相同 |
|
||||
| 快照流失败 | 非 owner 的答案变为 false,已安装的效果撤销 |
|
||||
| 回调失败 | 调用时安装失败同步抛出;之后重新授权时安装失败,控制器停止并在微任务中抛出 |
|
||||
| 分层 | remote-access 只认识 global;会话版与工作区版经 remote-access 的底层方法 `whenResourceAccess(kind, id, action, callback)` 实现,与它们用 `source(kind, id, action)` 构造 Hook 相同。`source` 与 `whenResourceAccess` 只由拥有该资源种类的包调用,其他插件只用访问 Hook 与 `whenAccess`,不传 kind。四个入口共用 [`when-allowed.ts`](../../../../packages/api/remote-access/src/client/when-allowed.ts) |
|
||||
|
||||
### 插件迁移
|
||||
|
||||
| 插件 | 改法 | action |
|
||||
|---|---|---|
|
||||
| ui-schedule | 任务列表数据源、删除提示浮层、右栏任务 tab 与标题、主面板、侧栏入口、turn 尾部卡片、会话头部提醒、会话行标记与悬浮列表包进 `remoteAccess.whenAccess`;会话头部提醒读取按会话判定的 `schedule.list`,组件另在会话作用域里用 `useAccess` 决定是否渲染;任务 tab 类型与 turn 定义的注册留在外面 | `schedule/catalog`、`schedule/list` |
|
||||
| ui-plugin-manager | Host 变更订阅、刷新提示浮层、主面板(含 `pluginNavigation`)、侧栏入口包进 `remoteAccess.whenAccess`,每次授权时刷新已加载过的列表;controller 与 `pluginChanges` 留在外面,撤权期间 `pluginChanges` 不更新。依赖 `pluginNavigation` 的 ui-entry-customization 与 voice-input 改为可选使用:服务不在时隐藏「浏览插件」与「打开 / 查看详情」按钮,插件照常启动 | `pluginManager/listBundles` |
|
||||
| ui-settings-general | 设置外壳 `sidebar.settings` 与 `settings.open` 快捷键包进 `remoteAccess.whenAccess` | `settings/describe` |
|
||||
| ui-settings-account | 账号状态流(含默认模型初始化)、额度提醒、设置启动器、账号设置分区与 `settings.models.sign-in` 包进 `remoteAccess.whenAccess`。撤权时账号流的 disposer 同步作废这一轮发出的账号读取,关闭待授权的浏览器标签页,清空账号视图、详情、头像与登录对话框状态,结束本轮奖励提示(提示由该流的帧开始),再关闭流;撤权前已交付但尚未处理的帧被丢弃。提示控制器留在外面,下次授权时重新开始;账号分区等新流的第一帧到达后才出现 | `account/watch` |
|
||||
| ui-directory-picker-native | 两个目录选择流程包进 `remoteAccess.whenAccess` | `directoryPicker/pick` |
|
||||
| ui-message-feedback | 消息反馈按钮照常注册,组件在会话作用域里用 `useAccess` 判断是否渲染;`messageFeedback/*` 的请求带 `sessionId`,Gateway 按 `session` 资源的默认字段自动解析为该 Session,无需声明。`/feedback` 装饰不做前端判断:装饰注册表是全局的,而 `sessionFeedback/record` 按会话判定;没有权限时对话框照常打开,提交被 Host 拒绝并显示失败码 | `messageFeedback/put` |
|
||||
| ui-deliverables、ui-open-in-app | 不动:owner 专用 HTTP 路由读不到时走已有回退 | — |
|
||||
|
||||
## 考虑过的替代方案
|
||||
|
||||
**插件 `apply` 开头判断 owner 后 return(#6486 的做法)**:按身份写死,授权变化后不跟随。
|
||||
|
||||
**按连接裁剪插件列表**:授权变化后插件无法立即重新加载。
|
||||
|
||||
**插件包清单声明访问性**:在包清单上定义访问控制概念。
|
||||
|
||||
**一个带资源参数的通用方法,或三个版本都挂在 remote-access 上**:业务代码要传 kind,且 remote-access 会出现会话、工作区的资源种类名。
|
||||
|
||||
## 后果
|
||||
|
||||
| 后果 | 说明 |
|
||||
|---|---|
|
||||
| 注册跟随授权变化 | 迁移的插件在非 owner 无授权时不注册相应入口,授予后出现,撤销后消失;owner 的答案恒为 true,注册与直接注册相同 |
|
||||
| 回调必须交还 disposer | 回调里经插件 `ctx` 注册的效果也挂在插件 fiber 上;不 yield 其 disposer 的注册在失权时不会撤销,只在插件卸载时撤销 |
|
||||
| 读者拿到全部插件代码 | 插件照常加载;访问由 Gateway 逐次判定,界面由 `whenAccess` 与 Hook 控制 |
|
||||
| 测试替身 | [`dsh-client-test-runtime`](../../../../packages/test-support/client-runtime/README.zh.md) 的 `StaticRemoteAccess`、`staticAccessPlugin` 与 `TestClientOptions.access` 以固定答案替换 remote-access,供插件测试无权限路径 |
|
||||
|
||||
## 相关
|
||||
|
||||
- [Peer、principal 与 remote-access](2026-10-12-peer-principal-and-remote-access.zh.md):本 Note 扩展其 Client 访问面。
|
||||
@@ -104,7 +104,7 @@ async function main(): Promise<void> {
|
||||
if (process.connected) process.send?.({ type: 'platform-session', session })
|
||||
})
|
||||
const url = ctx.connection.authenticatedUrl(`${ctx.webServer.protocol}//127.0.0.1:${String(ctx.webServer.port)}`)
|
||||
if (process.connected) process.send?.({ type: 'ready', url, injections: ctx.webServer.collectIndexInjections() }, (error) => { if (error !== null) console.error(error) })
|
||||
if (process.connected) process.send?.({ type: 'ready', url, injections: ctx.webServer.collectIndexInjections(ctx.connection.browserPeer) }, (error) => { if (error !== null) console.error(error) })
|
||||
}
|
||||
|
||||
/** Upper bound of the startup diagnostic carried over IPC; the head holds the message and stack. */
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
# NavScenario: first run bash to
|
||||
|
||||
Session file:
|
||||
|
||||
```text
|
||||
{{sessionFile}}
|
||||
```
|
||||
|
||||
> NavScenario: first run bash to print exactly NAVIGATION_OK, then read nav-a.md and nav-b.md using two read calls in ONE assistant message, then reply with the single word FIRST_DONE and stop.
|
||||
|
||||
<details><summary>3 previous messages</summary>
|
||||
|
||||
**Tool: `bash`**
|
||||
|
||||
```json
|
||||
{
|
||||
"command": "echo NAVIGATION_OK",
|
||||
"description": "Print NAVIGATION_OK"
|
||||
}
|
||||
```
|
||||
|
||||
**Result**
|
||||
|
||||
```
|
||||
NAVIGATION_OK
|
||||
```
|
||||
|
||||
**Tool: `read`**
|
||||
|
||||
```json
|
||||
{
|
||||
"file_path": "nav-a.md"
|
||||
}
|
||||
```
|
||||
|
||||
**Result**
|
||||
|
||||
```
|
||||
<path>{{cwd}}/workspace/nav-a.md</path>
|
||||
<type>file</type>
|
||||
<content>
|
||||
1: # alpha nav
|
||||
|
||||
(End of file - total 1 lines)
|
||||
</content>
|
||||
```
|
||||
|
||||
**Tool: `read`**
|
||||
|
||||
```json
|
||||
{
|
||||
"file_path": "nav-b.md"
|
||||
}
|
||||
```
|
||||
|
||||
**Result**
|
||||
|
||||
```
|
||||
<path>{{cwd}}/workspace/nav-b.md</path>
|
||||
<type>file</type>
|
||||
<content>
|
||||
1: # beta nav
|
||||
|
||||
(End of file - total 1 lines)
|
||||
</content>
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
FIRST_DONE
|
||||
|
||||
> Reply in markdown with: a level-2 heading "Navigation Summary", a bulleted list of exactly two items, and a fenced code block containing echo WATERFALL. Then stop.
|
||||
|
||||
## Navigation Summary
|
||||
|
||||
- alpha nav
|
||||
- beta nav
|
||||
|
||||
```
|
||||
echo WATERFALL
|
||||
```
|
||||
@@ -6,7 +6,7 @@
|
||||
// under the standard discipline: turn 1 produces a bash call plus two
|
||||
// parallel reads in one assistant message (tool-call density for the
|
||||
// trajectory ledger/timing lanes), turn 2 a markdown-rich reply.
|
||||
import { mkdir, readFile, writeFile } from 'node:fs/promises'
|
||||
import { mkdir, readFile, stat, writeFile } from 'node:fs/promises'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { join } from 'node:path'
|
||||
import type { Browser, Page, Response } from 'playwright'
|
||||
@@ -14,7 +14,7 @@ import { chromium } from 'playwright'
|
||||
import { strFromU8, unzipSync } from 'fflate'
|
||||
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, onTestFailed } from 'vitest'
|
||||
import { parseSessionLog } from '@deepseek-ai/dsh-llm-replay'
|
||||
import { SESSION_FORMAT_VERSION, type SessionEvent } from '@deepseek-ai/dsh-session'
|
||||
import { SESSION_FORMAT_VERSION, SessionId, type SessionEvent } from '@deepseek-ai/dsh-session'
|
||||
import {
|
||||
assertFixtureInventory, captureStableAria, compareOrRefreshGolden, fixtureUserPrompts,
|
||||
launchWebScaffold, recordFixture, seedSession, watchConsole, webSnapshotMode, type WebScaffold,
|
||||
@@ -32,6 +32,7 @@ const TERMINAL_EXPECTED = join(SNAPSHOT_DIR, 'terminal-card.expected.md')
|
||||
const CODE_CARD_DIR = fileURLToPath(new URL('./expected/navigation-panes', import.meta.url))
|
||||
const COMMAND_WRAP_EXPECTED = join(CODE_CARD_DIR, 'command-wrap.expected.md')
|
||||
const CODE_CARD_EXPECTED = join(CODE_CARD_DIR, 'code-card.expected.md')
|
||||
const COPY_MARKDOWN_EXPECTED = join(CODE_CARD_DIR, 'copy-markdown.expected.md')
|
||||
const MODE = webSnapshotMode()
|
||||
const SEED_ID = 'navigation-panes-web-e2e'
|
||||
const EXPORTED_LOG_FILE = `session.v${SESSION_FORMAT_VERSION}.jsonl`
|
||||
@@ -424,6 +425,66 @@ describe('web e2e: navigation & panes over a rich seeded session', () => {
|
||||
}
|
||||
}, 120_000)
|
||||
|
||||
it.skipIf(MODE === 'record')('copies the complete Session as Markdown from the Session Header', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-navigation-copy-markdown'))
|
||||
await ensureSeedOpen(page)
|
||||
await page.context().grantPermissions(['clipboard-read', 'clipboard-write'])
|
||||
expect(await page.evaluate(() => typeof ClipboardItem)).toBe('function')
|
||||
expect(await page.evaluate(() => typeof navigator.clipboard.write)).toBe('function')
|
||||
await page.evaluate(() => {
|
||||
const clipboard = navigator.clipboard
|
||||
const originalWrite = clipboard.write.bind(clipboard)
|
||||
Reflect.set(globalThis, '__dshClipboardWrites', 0)
|
||||
Object.defineProperty(clipboard, 'write', {
|
||||
configurable: true,
|
||||
value: (...args: Parameters<typeof originalWrite>) => {
|
||||
Reflect.set(globalThis, '__dshClipboardWrites', Number(Reflect.get(globalThis, '__dshClipboardWrites')) + 1)
|
||||
return originalWrite(...args)
|
||||
},
|
||||
})
|
||||
})
|
||||
const markdownResponse = page.waitForResponse(response =>
|
||||
response.request().method() === 'GET'
|
||||
&& new URL(response.url()).pathname === '/api/session.markdown', { timeout: 30_000 })
|
||||
const copyButton = page.getByRole('button', { name: 'More actions' })
|
||||
await copyButton.click()
|
||||
await page.getByRole('menuitem', { name: 'Copy Session as Markdown' }).click()
|
||||
const response = await markdownResponse
|
||||
expect(response.status()).toBe(200)
|
||||
expect(response.headers()['content-type']).toBe('text/markdown; charset=utf-8')
|
||||
await page.getByText('Session copied as Markdown.', { exact: true }).waitFor({ timeout: 30_000 })
|
||||
expect(await page.evaluate(() => Number(Reflect.get(globalThis, '__dshClipboardWrites')))).toBe(1)
|
||||
|
||||
const sourcePath = (await scaffold.ctx.sessionPersistence.stat(SessionId(SEED_ID)))?.location?.path
|
||||
if (sourcePath === undefined) throw new Error('seeded Session has no materialized artifact location')
|
||||
expect((await stat(sourcePath)).isFile()).toBe(true)
|
||||
expect(sourcePath.startsWith(`${scaffold.persistenceRoot}/`)).toBe(true)
|
||||
const markdown = await page.evaluate(() => navigator.clipboard.readText())
|
||||
expect(markdown).toContain(`Session file:\n\n\`\`\`text\n${sourcePath}\n\`\`\``)
|
||||
const normalized = markdown
|
||||
.split(sourcePath).join('{{sessionFile}}')
|
||||
.split(scaffold.workspaceCwd).join('{{cwd}}')
|
||||
// compareOrRefreshGolden appends its own newline; the clipboard text already ends with exactly one.
|
||||
await compareOrRefreshGolden(COPY_MARKDOWN_EXPECTED, normalized.replace(/\n$/u, ''), MODE)
|
||||
expect(normalized).toContain('Session file:\n\n```text\n{{sessionFile}}\n```')
|
||||
expect(normalized).toContain('# NavScenario: first run bash to')
|
||||
expect(normalized).toContain('> NavScenario: first run bash to print exactly NAVIGATION_OK')
|
||||
expect(normalized).toContain('<details><summary>3 previous messages</summary>')
|
||||
expect(normalized).toContain('**Tool: `bash`**')
|
||||
expect(normalized).toContain('NAVIGATION_OK')
|
||||
expect(normalized).toContain('FIRST_DONE')
|
||||
expect(normalized).toContain('Navigation Summary')
|
||||
expect(normalized).not.toContain('The user wants me to follow')
|
||||
expect(normalized).not.toContain('{{system}}')
|
||||
expect(normalized).not.toContain('request/header')
|
||||
expect(normalized.endsWith('\n')).toBe(true)
|
||||
expect(normalized.endsWith('\n\n')).toBe(false)
|
||||
|
||||
await copyButton.click()
|
||||
expect(await page.getByRole('menuitem', { name: 'Copy Session as Markdown' }).isDisabled()).toBe(false)
|
||||
await page.keyboard.press('Escape')
|
||||
}, 60_000)
|
||||
|
||||
it.skipIf(MODE === 'record')('focuses the ledger by dragging an overview interval', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-navigation-timeline'))
|
||||
await ensureSeedOpen(page)
|
||||
@@ -561,7 +622,9 @@ describe('web e2e: navigation & panes over a rich seeded session', () => {
|
||||
}, 60_000)
|
||||
|
||||
it.skipIf(MODE === 'record')('keeps the recorded fixture inventory exact', async () => {
|
||||
await assertFixtureInventory(CODE_CARD_DIR, ['code-card.expected.md', 'command-wrap.expected.md'])
|
||||
await assertFixtureInventory(CODE_CARD_DIR, [
|
||||
'code-card.expected.md', 'command-wrap.expected.md', 'copy-markdown.expected.md',
|
||||
])
|
||||
await assertFixtureInventory(SNAPSHOT_DIR, [
|
||||
'session.v3.jsonl', 'search-id-results.expected.md', 'search-results.expected.md', 'trajectory.expected.md',
|
||||
'terminal-card.expected.md', 'timing.expected.md',
|
||||
|
||||
@@ -68,9 +68,43 @@ async function capMetrics(field: Locator): Promise<{ textLines: number; scrolls:
|
||||
})
|
||||
}
|
||||
|
||||
// The options carry long descriptions on purpose: the squeeze assertion below
|
||||
// needs option copy that WRAPS, which is the only text layout that reproduces a
|
||||
// collapsed row painting its copy outside its own box.
|
||||
/**
|
||||
* Read how the composer draws one long-copy element: the lines it shows,
|
||||
* whether CSS clipped it, whether its text overflows the line budget, and what
|
||||
* its toggle does while that toggle is visible.
|
||||
* @param element - a question heading or an option description.
|
||||
* @returns the drawn state of the copy.
|
||||
*/
|
||||
function readCopy(element: HTMLElement): { lines: number; clipped: boolean; overflows: boolean; toggle: 'expand' | 'collapse' | undefined } {
|
||||
const style = getComputedStyle(element)
|
||||
const toggle = element.tagName === 'H2'
|
||||
? element.nextElementSibling
|
||||
: element.closest('[data-option-row]')?.querySelector(':scope > button:not([role])')
|
||||
return {
|
||||
// A clipped box keeps its full budget and gives the dropped line back through its bottom margin.
|
||||
lines: Math.round((element.getBoundingClientRect().height + parseFloat(style.marginBottom)) / parseFloat(style.lineHeight)),
|
||||
clipped: style.maskImage !== 'none',
|
||||
overflows: element.scrollHeight > element.clientHeight + 0.5,
|
||||
toggle: toggle instanceof HTMLElement && getComputedStyle(toggle).visibility === 'visible'
|
||||
? toggle.getAttribute('aria-expanded') === 'true' ? 'collapse' : 'expand'
|
||||
: undefined,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Tell whether an expanded copy's toggle sits below the copy's last text line.
|
||||
* @param toggle - the copy's toggle button.
|
||||
* @param copy - the expanded question heading or option description.
|
||||
* @returns whether the toggle starts at or below the bottom of the text; false without the copy.
|
||||
*/
|
||||
function belowText(toggle: HTMLElement, copy: Element | null): boolean {
|
||||
if (copy === null) return false
|
||||
const textBottom = copy.getBoundingClientRect().bottom - parseFloat(getComputedStyle(copy).paddingBottom)
|
||||
return toggle.getBoundingClientRect().top >= textBottom - 0.5
|
||||
}
|
||||
|
||||
// The options carry descriptions on purpose: the squeeze assertion below
|
||||
// verifies that each description keeps to its two-line budget in the card scrollport.
|
||||
const PROMPT = 'Use the ask_user_question tool to ask me exactly one multi-select question with id "color", question "Which color do you prefer?", header "Pick one", and two options: label "Blue" with description "A cool recessive hue that reads as calm and trustworthy in long reading sessions and dense dashboards.", and label "Green" with description "A restful mid-spectrum hue with the highest perceived brightness, easiest on the eye over long sessions." Set multi_select to true. After I answer, reply with the single word DONE and stop.'
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
@@ -338,53 +372,85 @@ describe('web e2e: resident question composer round trip', () => {
|
||||
// This golden owns the stable question surface; the answered-state
|
||||
// golden below owns the resulting transcript.
|
||||
const snapshot = await captureStableAria(page, '[data-question-key]', scaffold.workspaceCwd)
|
||||
await compareOrRefreshGolden(UI_EXPECTED, snapshot, MODE)
|
||||
const descriptions = await page.locator('[data-question-key] [role="checkbox"]').evaluateAll(nodes =>
|
||||
nodes.map(node => ({
|
||||
label: node.getAttribute('aria-label'),
|
||||
description: (node.getAttribute('aria-describedby') ?? '').split(/\s+/)
|
||||
.filter(Boolean).map(id => document.getElementById(id)?.textContent ?? '').join(' '),
|
||||
})))
|
||||
await compareOrRefreshGolden(UI_EXPECTED, `${snapshot}\nChoice descriptions: ${JSON.stringify(descriptions)}`, MODE)
|
||||
const sidebar = await captureStableAria(page, '[role="treeitem"][aria-selected="true"]', scaffold.workspaceCwd)
|
||||
await compareOrRefreshGolden(SIDEBAR_EXPECTED, sidebar, MODE)
|
||||
}
|
||||
|
||||
// Squeezed card: the option rows are the capped card's scroll content, so
|
||||
// shrinking the seat must push overflow into the option list, never
|
||||
// collapse a row below the height its own copy needs — a collapsed row
|
||||
// paints its centered copy outside the row box, over the title and the
|
||||
// neighbouring rows. Measured on the live composer at seat heights that
|
||||
// force the cap, then restored for the answer gesture below. Replay only:
|
||||
// record mode must reach the recording write below, not abort on layout.
|
||||
// Squeezed card: the question and compact option rows share one scrollport,
|
||||
// while navigation and submission stay outside it. Measured on the live
|
||||
// composer at seat heights that force the cap, then restored for the answer
|
||||
// gesture below. Replay only: record mode must reach the recording write
|
||||
// below, not abort on layout.
|
||||
const originalViewport = page.viewportSize() ?? { width: 1680, height: 1000 }
|
||||
if (MODE !== 'record') {
|
||||
const original = page.viewportSize() ?? { width: 1680, height: 1000 }
|
||||
const scrollStates: boolean[] = []
|
||||
for (const height of [520, 440, 380]) {
|
||||
await page.setViewportSize({ width: 900, height })
|
||||
await page.setViewportSize({ width: 640, height })
|
||||
const squeeze = await composer.evaluate((card) => {
|
||||
// Role/ARIA selectors, not the CSS-module class names: the built
|
||||
// client hashes those.
|
||||
const rows = [...card.querySelectorAll<HTMLElement>(
|
||||
'[role="radio"], [role="checkbox"], [aria-expanded]',
|
||||
)]
|
||||
const rows = [...card.querySelectorAll<HTMLElement>('[data-option-row]')]
|
||||
const spill = rows.map(row => Math.max(...[...row.children].map((child) => {
|
||||
const box = row.getBoundingClientRect()
|
||||
const inner = child.getBoundingClientRect()
|
||||
return Math.max(box.top - inner.top, inner.bottom - box.bottom)
|
||||
})))
|
||||
const list = card.querySelector<HTMLElement>('[data-question-scroll]')
|
||||
const title = card.querySelector('h2')
|
||||
return {
|
||||
rows: rows.length,
|
||||
spill: Math.max(...spill),
|
||||
// Wrapped option text is what overflows a collapsed row, and a
|
||||
// scrolling list proves the seat is genuinely capped. Without both,
|
||||
// the spill assertion would hold vacuously.
|
||||
wrappedRows: rows.filter(row => row.getBoundingClientRect().height > 42).length,
|
||||
scrolls: list === null ? false : list.scrollHeight > list.clientHeight,
|
||||
titleInScrollport: list?.contains(title) === true,
|
||||
}
|
||||
})
|
||||
expect(squeeze.rows).toBeGreaterThan(0)
|
||||
expect(squeeze.wrappedRows).toBeGreaterThan(0)
|
||||
expect(squeeze.scrolls).toBe(true)
|
||||
for (const description of await composer.locator('[data-option-description]').all()) {
|
||||
const copy = await description.evaluate(readCopy)
|
||||
expect(copy.lines).toBeLessThanOrEqual(2)
|
||||
expect(copy.clipped).toBe(copy.overflows)
|
||||
expect(copy.toggle).toBe(copy.clipped ? 'expand' : undefined)
|
||||
}
|
||||
scrollStates.push(squeeze.scrolls)
|
||||
expect(squeeze.titleInScrollport).toBe(true)
|
||||
expect(await composer.getByRole('button', { name: 'Submit', exact: true }).isVisible()).toBe(true)
|
||||
// Sub-pixel tolerance: every row's copy stays inside its border box.
|
||||
expect(squeeze.spill).toBeLessThan(0.6)
|
||||
}
|
||||
await page.setViewportSize(original)
|
||||
expect(scrollStates).toContain(true)
|
||||
await page.setViewportSize(originalViewport)
|
||||
}
|
||||
|
||||
// The narrowest supported card clips Blue's description to one line; its
|
||||
// toggle expands the text in place without choosing Blue.
|
||||
await page.setViewportSize({ width: 390, height: 700 })
|
||||
const blue = composer.getByRole('checkbox', { name: 'Blue' })
|
||||
const blueText = composer.locator('[data-option-description]').first()
|
||||
const blueToggle = composer.locator(`button[aria-controls=${JSON.stringify(await blueText.getAttribute('id'))}]`)
|
||||
await expect.poll(() => blueText.evaluate(readCopy)).toEqual({ lines: 1, clipped: true, overflows: true, toggle: 'expand' })
|
||||
// A clipped description's dropped line takes no clicks: the custom row's top edge belongs to it.
|
||||
expect(await composer.locator('[data-option-description]').last().evaluate(readCopy)).toMatchObject({ clipped: true })
|
||||
expect(await composer.locator('textarea').evaluate((field) => {
|
||||
const row = field.closest('[class*="customRow"]')
|
||||
if (!(row instanceof HTMLElement)) return false
|
||||
const box = row.getBoundingClientRect()
|
||||
const hit = document.elementFromPoint(box.left + box.width / 2, box.top + 2)
|
||||
return hit !== null && row.contains(hit)
|
||||
})).toBe(true)
|
||||
await blueToggle.click()
|
||||
expect(await blue.getAttribute('aria-checked')).toBe('false')
|
||||
expect(await blueToggle.getAttribute('aria-expanded')).toBe('true')
|
||||
expect(await blueText.evaluate(readCopy)).toMatchObject({ clipped: false, overflows: false, toggle: 'collapse' })
|
||||
await blueToggle.click()
|
||||
// A collapsed copy's scroll timeline reports the overflow from the next frame.
|
||||
await expect.poll(() => blueText.evaluate(readCopy)).toEqual({ lines: 1, clipped: true, overflows: true, toggle: 'expand' })
|
||||
await page.setViewportSize(originalViewport)
|
||||
|
||||
// Multi-line custom answer: the field is a textarea whose hidden mirror
|
||||
// owns the box height, so a soft-wrapped or line-broken draft GROWS the
|
||||
// field instead of scrolling one line, and Shift+Enter breaks the line
|
||||
@@ -415,7 +481,6 @@ describe('web e2e: resident question composer round trip', () => {
|
||||
await custom.fill('')
|
||||
}
|
||||
|
||||
const blue = composer.getByRole('checkbox', { name: 'Blue' })
|
||||
await blue.click()
|
||||
await custom.fill('Include accessibility notes')
|
||||
expect(await blue.getAttribute('aria-checked')).toBe('true')
|
||||
@@ -536,6 +601,436 @@ describe('web e2e: resident question composer round trip', () => {
|
||||
await expect.poll(() => page.locator('[data-question-key]').count(), { timeout: 10_000 }).toBe(0)
|
||||
}, 60_000)
|
||||
|
||||
it.skipIf(MODE === 'record')('keeps short option explanations beside their labels', async () => {
|
||||
const sessionId = answeredSession
|
||||
expect(sessionId).toBeDefined()
|
||||
const agent = scaffold.ctx.agents.get(sessionId as SessionId)
|
||||
expect(agent).toBeDefined()
|
||||
const controller = new AbortController()
|
||||
const asked = scaffold.ctx.userQuestions.ask({
|
||||
agent: agent as NonNullable<typeof agent>,
|
||||
signal: controller.signal,
|
||||
questions: [{
|
||||
id: 'reasoning',
|
||||
header: '确认思考等级',
|
||||
question: '选择 HF provider 的思考等级?',
|
||||
options: [
|
||||
{ label: '高(high)', description: '倾向更充分的推理,通常更慢、消耗更多 token,需模型支持。' },
|
||||
{ label: '中(medium)', description: '平衡推理深度与速度;需模型支持。' },
|
||||
{ label: '低(low)', description: '优先速度与较低 token 消耗;需模型支持。' },
|
||||
{ label: '先查看当前配置', description: '先确认 HF provider 的模型及其支持的思考参数,再决定。' },
|
||||
],
|
||||
}],
|
||||
})
|
||||
const outcome = asked.then(value => ({ value }), (error: unknown) => ({ error }))
|
||||
const composer = page.locator('[data-question-key]')
|
||||
const original = page.viewportSize() ?? { width: 1680, height: 1000 }
|
||||
let completed = false
|
||||
try {
|
||||
await page.setViewportSize({ width: 1328, height: 720 })
|
||||
await composer.getByRole('heading', { name: '选择 HF provider 的思考等级?' }).waitFor()
|
||||
expect(await composer.getByRole('heading').evaluate(readCopy))
|
||||
.toEqual({ lines: 1, clipped: false, overflows: false, toggle: undefined })
|
||||
expect(await composer.getByRole('button', { name: 'Expand', exact: true }).count()).toBe(0)
|
||||
const rows = await composer.locator('[data-option-row]').evaluateAll(options => options.map((row) => {
|
||||
const label = row.querySelector<HTMLElement>('[data-option-line] > span:first-child')
|
||||
const description = row.querySelector<HTMLElement>('[data-option-description]')
|
||||
if (label === null || description === null) return null
|
||||
const style = getComputedStyle(description)
|
||||
return {
|
||||
sameLine: description.getBoundingClientRect().top < label.getBoundingClientRect().bottom,
|
||||
fontSize: style.fontSize,
|
||||
lineHeight: style.lineHeight,
|
||||
maskImage: style.maskImage,
|
||||
}
|
||||
}))
|
||||
expect(rows).toHaveLength(4)
|
||||
expect(rows).toEqual(Array.from({ length: 4 }, () => ({
|
||||
sameLine: true, fontSize: '13px', lineHeight: '20px', maskImage: 'none',
|
||||
})))
|
||||
await composer.getByRole('radio', { name: '中(medium)' }).click()
|
||||
await composer.getByRole('button', { name: 'Submit', exact: true }).click()
|
||||
expect(await outcome).toEqual({ value: { answers: [{ id: 'reasoning', selected: ['中(medium)'] }] } })
|
||||
completed = true
|
||||
} finally {
|
||||
controller.abort()
|
||||
const cleanup = await Promise.allSettled([outcome, page.setViewportSize(original)])
|
||||
if (completed) {
|
||||
const failure = cleanup.find((result): result is PromiseRejectedResult => result.status === 'rejected')
|
||||
if (failure !== undefined) throw failure.reason
|
||||
}
|
||||
}
|
||||
}, 60_000)
|
||||
|
||||
it.skipIf(MODE === 'record')('keeps long questions answerable in short and zoomed viewports', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-question-long-copy'))
|
||||
const sessionId = answeredSession
|
||||
expect(sessionId).toBeDefined()
|
||||
const agent = scaffold.ctx.agents.get(sessionId as SessionId)
|
||||
expect(agent).toBeDefined()
|
||||
const question = Array.from({ length: 8 }, () => 'The release gate reports that the installed application graph still contains an experimental package through a default dependency. Which approach should we use to preserve explicit installation while keeping the supported application graph valid?').join(' ')
|
||||
const detail = 'Review the shipped profile, package ownership, upgrade impact, and the path users follow before choosing. The answer must preserve the existing release invariant, avoid a hidden compatibility path, and remain understandable to people who did not participate in the implementation discussion.'
|
||||
const description = Array.from({ length: 48 }, (_, index) =>
|
||||
`Migration step ${String(index + 1)} removes the experimental client from shipped templates, requires an explicit profile install, names the affected users, and records the cross-platform validation before rollout.`).join(' ')
|
||||
const controller = new AbortController()
|
||||
const asked = scaffold.ctx.userQuestions.ask({
|
||||
agent: agent as NonNullable<typeof agent>,
|
||||
signal: controller.signal,
|
||||
questions: [{
|
||||
id: 'release',
|
||||
header: 'Release decision',
|
||||
question,
|
||||
detail,
|
||||
multiSelect: true,
|
||||
options: [
|
||||
{ label: 'Explicit install', description },
|
||||
{ label: 'Keep the default edge', description: 'Keep the current setup.' },
|
||||
{ label: 'Move the package', description: 'Move it to a separately installed package with an explicit profile entry.' },
|
||||
],
|
||||
}, { id: 'followup', question: 'Who should own the rollout?', options: [{ label: 'Release team' }] }],
|
||||
})
|
||||
const outcome = asked.then(value => ({ value }), (error: unknown) => ({ error }))
|
||||
|
||||
const composer = page.locator('[data-question-key]')
|
||||
const original = page.viewportSize() ?? { width: 1680, height: 1000 }
|
||||
let bodyCompleted = false
|
||||
try {
|
||||
await composer.waitFor({ timeout: 30_000 })
|
||||
const heading = composer.getByRole('heading', { name: question })
|
||||
const descriptionOf = (label: string) => composer.locator('[data-option-row]', {
|
||||
has: page.getByRole('checkbox', { name: label, exact: true }),
|
||||
}).locator('[data-option-description]')
|
||||
const toggleFor = async (copy: Locator) =>
|
||||
composer.locator(`button[aria-controls=${JSON.stringify(await copy.getAttribute('id'))}]`)
|
||||
const readMetrics = () => composer.evaluate((card) => {
|
||||
const scroll = card.querySelector<HTMLElement>('[data-question-scroll]')
|
||||
const title = card.querySelector<HTMLElement>('h2')
|
||||
const submit = [...card.querySelectorAll<HTMLButtonElement>('button')]
|
||||
.find(button => button.textContent === 'Next')
|
||||
if (scroll === null || title === null || submit === undefined) return null
|
||||
const submitBox = submit.getBoundingClientRect()
|
||||
return {
|
||||
footerFits: submitBox.top >= -0.5 && submitBox.bottom <= window.innerHeight + 0.5,
|
||||
scrolls: scroll.scrollHeight > scroll.clientHeight,
|
||||
titleInScrollport: scroll.contains(title),
|
||||
}
|
||||
})
|
||||
// Cover common desktop sizes plus the CSS-pixel equivalents of a
|
||||
// progressively zoomed 640x420 window without changing browser-global zoom.
|
||||
const scrollStates: boolean[] = []
|
||||
for (const viewport of [
|
||||
{ width: 1440, height: 900 },
|
||||
{ width: 900, height: 600 },
|
||||
{ width: 640, height: 420 },
|
||||
{ width: 512, height: 336 },
|
||||
{ width: 427, height: 280 },
|
||||
{ width: 390, height: 700 },
|
||||
]) {
|
||||
await page.setViewportSize(viewport)
|
||||
await expect.poll(readMetrics).not.toBeNull()
|
||||
const metrics = await readMetrics()
|
||||
expect(metrics?.footerFits).toBe(true)
|
||||
expect(metrics?.titleInScrollport).toBe(true)
|
||||
scrollStates.push(metrics?.scrolls ?? false)
|
||||
// Past its three-line budget the question keeps two lines; past its
|
||||
// two-line budget a description keeps one. Copy within budget shows in full.
|
||||
await expect.poll(() => heading.evaluate(readCopy))
|
||||
.toEqual({ lines: 2, clipped: true, overflows: true, toggle: 'expand' })
|
||||
expect(await descriptionOf('Explicit install').evaluate(readCopy))
|
||||
.toEqual({ lines: 1, clipped: true, overflows: true, toggle: 'expand' })
|
||||
expect(await descriptionOf('Keep the default edge').evaluate(readCopy))
|
||||
.toEqual({ lines: 1, clipped: false, overflows: false, toggle: undefined })
|
||||
for (const copy of await composer.locator('[data-option-description]').all()) {
|
||||
const drawn = await copy.evaluate(readCopy)
|
||||
expect(drawn.clipped).toBe(drawn.overflows)
|
||||
expect(drawn.toggle).toBe(drawn.clipped ? 'expand' : undefined)
|
||||
expect(drawn.lines).toBeLessThanOrEqual(drawn.clipped ? 1 : 2)
|
||||
}
|
||||
if (viewport.width === 512) {
|
||||
expect(await descriptionOf('Move the package').evaluate(readCopy))
|
||||
.toEqual({ lines: 2, clipped: false, overflows: false, toggle: undefined })
|
||||
}
|
||||
const textMetrics = await composer.locator('[data-option-description]').evaluateAll(nodes => nodes.map((node) => {
|
||||
const style = getComputedStyle(node)
|
||||
const rect = node.getBoundingClientRect()
|
||||
return { fontSize: style.fontSize, lineHeight: style.lineHeight,
|
||||
fitsViewport: rect.left >= 0 && rect.right <= innerWidth }
|
||||
}))
|
||||
expect(textMetrics).toEqual(Array.from({ length: 3 }, () => ({
|
||||
fontSize: '13px', lineHeight: '20px', fitsViewport: true,
|
||||
})))
|
||||
}
|
||||
expect(scrollStates).toContain(false)
|
||||
expect(scrollStates).toContain(true)
|
||||
|
||||
// A clipped question's dropped line takes no clicks: the detail's first line belongs to the detail.
|
||||
expect(await composer.getByText(detail, { exact: true }).evaluate((text) => {
|
||||
const box = text.getBoundingClientRect()
|
||||
const hit = document.elementFromPoint(box.left + 8, box.top + 3)
|
||||
return hit !== null && text.contains(hit)
|
||||
})).toBe(true)
|
||||
|
||||
// The question expands in place; its choices move down inside the
|
||||
// scrollport while navigation and submission stay reachable.
|
||||
const questionToggle = await toggleFor(heading)
|
||||
expect(await questionToggle.getAttribute('aria-expanded')).toBe('false')
|
||||
expect(await questionToggle.getAttribute('aria-label')).toBe('Expand')
|
||||
expect(await questionToggle.getAttribute('aria-describedby')).toBe(await heading.getAttribute('id'))
|
||||
await questionToggle.click()
|
||||
expect(await questionToggle.getAttribute('aria-expanded')).toBe('true')
|
||||
expect(await questionToggle.getAttribute('aria-label')).toBe('Collapse')
|
||||
const expandedQuestion = await heading.evaluate(readCopy)
|
||||
expect(expandedQuestion).toMatchObject({ clipped: false, overflows: false, toggle: 'collapse' })
|
||||
expect(expandedQuestion.lines).toBeGreaterThan(3)
|
||||
expect(await questionToggle.evaluate(belowText, await heading.elementHandle())).toBe(true)
|
||||
expect(await readMetrics()).toEqual({ footerFits: true, scrolls: true, titleInScrollport: true })
|
||||
await questionToggle.click()
|
||||
await expect.poll(() => heading.evaluate(readCopy)).toEqual({ lines: 2, clipped: true, overflows: true, toggle: 'expand' })
|
||||
|
||||
const detailText = composer.getByText(detail, { exact: true })
|
||||
expect(await detailText.evaluate(node => node.closest('[data-question-scroll]') !== null)).toBe(true)
|
||||
await detailText.scrollIntoViewIfNeeded()
|
||||
expect(await detailText.isVisible()).toBe(true)
|
||||
expect(await composer.getByRole('button', { name: 'Next', exact: true }).isVisible()).toBe(true)
|
||||
|
||||
// A description expands in place without choosing its option, and its
|
||||
// toggle stays described by the text it controls.
|
||||
const explicit = composer.getByRole('checkbox', { name: 'Explicit install' })
|
||||
const explicitText = descriptionOf('Explicit install')
|
||||
const descriptionToggle = await toggleFor(explicitText)
|
||||
const descriptionId = await explicitText.getAttribute('id')
|
||||
expect(await explicit.getAttribute('aria-describedby')).toBe(descriptionId)
|
||||
expect(await descriptionToggle.getAttribute('aria-describedby')).toBe(descriptionId)
|
||||
expect(await descriptionToggle.getAttribute('aria-label')).toBe('Expand')
|
||||
await descriptionToggle.click()
|
||||
expect(await explicit.getAttribute('aria-checked')).toBe('false')
|
||||
const expandedDescription = await explicitText.evaluate(readCopy)
|
||||
expect(expandedDescription).toMatchObject({ clipped: false, overflows: false, toggle: 'collapse' })
|
||||
expect(expandedDescription.lines).toBeGreaterThan(2)
|
||||
expect(await descriptionToggle.evaluate(belowText, await explicitText.elementHandle())).toBe(true)
|
||||
expect(await explicitText.evaluate((text) => {
|
||||
const row = text.closest('[data-option-row]') as HTMLElement
|
||||
return [...row.querySelectorAll<HTMLElement>('*')].every(child =>
|
||||
child.getBoundingClientRect().bottom <= row.getBoundingClientRect().bottom + 0.5)
|
||||
})).toBe(true)
|
||||
|
||||
// The toggle's icon keeps non-text contrast on the card in both themes.
|
||||
const palette: string[] = []
|
||||
const originalDarkTheme = await page.locator('body').getAttribute('data-ds-dark-theme')
|
||||
try {
|
||||
for (const dark of [false, true]) {
|
||||
await page.evaluate((value) => { document.body.toggleAttribute('data-ds-dark-theme', value) }, dark)
|
||||
const colors = await descriptionToggle.evaluate((toggle) => {
|
||||
const card = toggle.closest('section')
|
||||
return card === null ? null : { text: getComputedStyle(toggle).color, surface: getComputedStyle(card).backgroundColor }
|
||||
})
|
||||
if (colors === null) throw new Error('The question card is unavailable')
|
||||
const channels = (color: string) => color.match(/[\d.]+/g)?.slice(0, 3).map(Number) ?? []
|
||||
const luminance = (color: string) => channels(color).reduce((total, channel, index) => {
|
||||
const normalized = channel / 255
|
||||
const linear = normalized <= 0.04045 ? normalized / 12.92 : ((normalized + 0.055) / 1.055) ** 2.4
|
||||
return total + linear * [0.2126, 0.7152, 0.0722][index]!
|
||||
}, 0)
|
||||
const foreground = luminance(colors.text)
|
||||
const background = luminance(colors.surface)
|
||||
expect((Math.max(foreground, background) + 0.05) / (Math.min(foreground, background) + 0.05))
|
||||
.toBeGreaterThanOrEqual(3)
|
||||
palette.push(colors.surface)
|
||||
}
|
||||
expect(palette[0]).not.toBe(palette[1])
|
||||
} finally {
|
||||
await page.evaluate((previous) => {
|
||||
if (previous === null) document.body.removeAttribute('data-ds-dark-theme')
|
||||
else document.body.setAttribute('data-ds-dark-theme', previous)
|
||||
}, originalDarkTheme)
|
||||
}
|
||||
await descriptionToggle.click()
|
||||
await expect.poll(() => explicitText.evaluate(readCopy))
|
||||
.toEqual({ lines: 1, clipped: true, overflows: true, toggle: 'expand' })
|
||||
|
||||
await composer.getByRole('checkbox', { name: 'Explicit install' }).click()
|
||||
const answer = composer.getByRole('textbox')
|
||||
await answer.fill('Keep the migration steps explicit.')
|
||||
await answer.press('Enter')
|
||||
await composer.getByRole('heading', { name: 'Who should own the rollout?' }).waitFor()
|
||||
expect(await answer.evaluate(node => document.activeElement === node)).toBe(true)
|
||||
await page.keyboard.type('Release owner')
|
||||
expect(await answer.inputValue()).toBe('Release owner')
|
||||
await composer.getByRole('button', { name: 'Submit', exact: true }).click()
|
||||
expect(await outcome).toEqual({
|
||||
value: {
|
||||
answers: [{
|
||||
id: 'release',
|
||||
selected: ['Explicit install'],
|
||||
custom: 'Keep the migration steps explicit.',
|
||||
}, { id: 'followup', selected: [], custom: 'Release owner' }],
|
||||
},
|
||||
})
|
||||
await expect.poll(() => page.locator('[data-question-key]').count(), { timeout: 10_000 }).toBe(0)
|
||||
bodyCompleted = true
|
||||
} finally {
|
||||
controller.abort()
|
||||
const cleanup = await Promise.allSettled([outcome, page.setViewportSize(original)])
|
||||
if (bodyCompleted) {
|
||||
const failure = cleanup.find((result): result is PromiseRejectedResult => result.status === 'rejected')
|
||||
if (failure !== undefined) throw failure.reason
|
||||
}
|
||||
}
|
||||
}, 60_000)
|
||||
|
||||
it.skipIf(MODE === 'record')('re-evaluates long copy when the chat column or the window changes width', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-question-chat-width'))
|
||||
const sessionId = answeredSession
|
||||
expect(sessionId).toBeDefined()
|
||||
const agent = scaffold.ctx.agents.get(sessionId as SessionId)
|
||||
expect(agent).toBeDefined()
|
||||
// Sized for the chat-width drag range: the question takes three lines or
|
||||
// fewer in the widest chat column and more in the narrowest, and the staged
|
||||
// description takes two lines or fewer and more.
|
||||
const question = `${Array.from({ length: 2 }, () => 'The rollout plan must name the release owner, the verification lanes that gate it, and the rollback path before the candidate is cut.').join(' ')} Which plan should we adopt?`
|
||||
const staged = 'Stage the rollout behind the release channel first, keep the experimental client out of every shipped template, ask plugin authors to declare the dependency explicitly, and publish the upgrade note together with the cross-platform validation results.'
|
||||
const explicit = Array.from({ length: 24 }, (_, index) =>
|
||||
`Step ${String(index + 1)} removes the experimental client from shipped templates and records the validation before rollout.`).join(' ')
|
||||
const controller = new AbortController()
|
||||
const asked = scaffold.ctx.userQuestions.ask({
|
||||
agent: agent as NonNullable<typeof agent>,
|
||||
signal: controller.signal,
|
||||
questions: [{
|
||||
id: 'rollout',
|
||||
header: 'Rollout decision',
|
||||
question,
|
||||
options: [
|
||||
{ label: 'Explicit install', description: explicit },
|
||||
{ label: 'Stage the rollout', description: staged },
|
||||
{ label: 'Keep the default edge', description: 'Keep the current setup.' },
|
||||
],
|
||||
}],
|
||||
})
|
||||
const outcome = asked.then(value => ({ value }), (error: unknown) => ({ error }))
|
||||
|
||||
const composer = page.locator('[data-question-key]')
|
||||
const frame = page.locator('[style*="grid-template-columns"]').first()
|
||||
const original = page.viewportSize() ?? { width: 1680, height: 1000 }
|
||||
let bodyCompleted = false
|
||||
try {
|
||||
await page.setViewportSize({ width: 1680, height: 1000 })
|
||||
await composer.waitFor({ timeout: 30_000 })
|
||||
const heading = composer.getByRole('heading', { name: question })
|
||||
const descriptionOf = (label: string) => composer.locator('[data-option-row]', {
|
||||
has: page.getByRole('radio', { name: label, exact: true }),
|
||||
}).locator('[data-option-description]')
|
||||
const toggleFor = async (copy: Locator) =>
|
||||
composer.locator(`button[aria-controls=${JSON.stringify(await copy.getAttribute('id'))}]`)
|
||||
// Settled layout: no AppFrame column transition in flight, then every
|
||||
// collapsed copy drawn as its own overflow decides, the expanded copy
|
||||
// still expanded, and the actions reachable.
|
||||
const settle = async () => {
|
||||
await expect.poll(() => frame.evaluate(node => node.hasAttribute('data-animating'))).toBe(false)
|
||||
const read = async () => {
|
||||
const card = await composer.locator('section').evaluate((section) => {
|
||||
const scroll = section.querySelector<HTMLElement>('[data-question-scroll]')
|
||||
const submit = [...section.querySelectorAll<HTMLButtonElement>('button')]
|
||||
.find(button => button.textContent === 'Submit')
|
||||
const submitBox = submit?.getBoundingClientRect()
|
||||
return {
|
||||
width: Math.round(section.getBoundingClientRect().width),
|
||||
actionsReachable: submitBox !== undefined && submitBox.top >= -0.5 && submitBox.bottom <= window.innerHeight + 0.5,
|
||||
titleInScrollport: scroll?.contains(section.querySelector('h2')) === true,
|
||||
}
|
||||
})
|
||||
return {
|
||||
card,
|
||||
question: await heading.evaluate(readCopy),
|
||||
explicit: await descriptionOf('Explicit install').evaluate(readCopy),
|
||||
staged: await descriptionOf('Stage the rollout').evaluate(readCopy),
|
||||
short: await descriptionOf('Keep the default edge').evaluate(readCopy),
|
||||
}
|
||||
}
|
||||
await expect.poll(async () => {
|
||||
const state = await read()
|
||||
const decided = (copy: Awaited<ReturnType<typeof read>>['question'], budget: number) =>
|
||||
copy.clipped === copy.overflows && copy.toggle === (copy.clipped ? 'expand' : undefined)
|
||||
&& copy.lines <= (copy.clipped ? budget - 1 : budget)
|
||||
return state.card.actionsReachable && state.card.titleInScrollport
|
||||
&& decided(state.question, 3) && decided(state.staged, 2) && decided(state.short, 2)
|
||||
&& !state.explicit.clipped && !state.explicit.overflows && state.explicit.toggle === 'collapse'
|
||||
}).toBe(true)
|
||||
return await read()
|
||||
}
|
||||
// The right transcript handle widens the content column by twice its
|
||||
// outward travel. The composer seat covers the handle's lower part, so
|
||||
// the gesture grabs it beside the transcript.
|
||||
const dragChatWidth = async (travel: number) => {
|
||||
const box = await page.locator('[data-width-handle="right"]').boundingBox()
|
||||
if (box === null) throw new Error('The chat width handle is not rendered')
|
||||
const x = box.x + box.width / 2
|
||||
const y = box.y + 120
|
||||
await page.mouse.move(x, y)
|
||||
await page.mouse.down()
|
||||
try {
|
||||
await page.mouse.move(x + travel, y, { steps: 8 })
|
||||
} finally {
|
||||
await page.mouse.up()
|
||||
}
|
||||
}
|
||||
|
||||
// Expanded copy is the user's choice and survives every width change below.
|
||||
const explicitToggle = await toggleFor(descriptionOf('Explicit install'))
|
||||
await explicitToggle.click()
|
||||
expect(await explicitToggle.getAttribute('aria-expanded')).toBe('true')
|
||||
expect(await composer.getByRole('radio', { name: 'Explicit install', exact: true }).getAttribute('aria-checked')).toBe('false')
|
||||
|
||||
await dragChatWidth(-400)
|
||||
const narrow = await settle()
|
||||
expect(narrow.question).toEqual({ lines: 2, clipped: true, overflows: true, toggle: 'expand' })
|
||||
expect(narrow.staged).toEqual({ lines: 1, clipped: true, overflows: true, toggle: 'expand' })
|
||||
|
||||
await dragChatWidth(600)
|
||||
const wide = await settle()
|
||||
expect(wide.card.width).toBeGreaterThan(narrow.card.width)
|
||||
expect(wide.question).toMatchObject({ clipped: false, overflows: false, toggle: undefined })
|
||||
expect(wide.staged).toEqual({ lines: 2, clipped: false, overflows: false, toggle: undefined })
|
||||
expect(wide.short).toEqual({ lines: 1, clipped: false, overflows: false, toggle: undefined })
|
||||
|
||||
// The right sidebar narrows the conversation column, and collapsing it restores the wide state.
|
||||
await page.getByRole('button', { name: 'Open right sidebar', exact: true }).click()
|
||||
const withSidebar = await settle()
|
||||
expect(withSidebar.card.width).toBeLessThan(wide.card.width)
|
||||
await page.getByRole('button', { name: 'Collapse right sidebar', exact: true }).click()
|
||||
expect(await settle()).toEqual(wide)
|
||||
|
||||
// A smaller window clamps the dragged width; restoring the window restores it.
|
||||
await page.setViewportSize({ width: 900, height: 700 })
|
||||
const smallWindow = await settle()
|
||||
expect(smallWindow.card.width).toBeLessThan(wide.card.width)
|
||||
expect(smallWindow.question).toEqual({ lines: 2, clipped: true, overflows: true, toggle: 'expand' })
|
||||
expect(smallWindow.staged).toEqual({ lines: 1, clipped: true, overflows: true, toggle: 'expand' })
|
||||
await page.setViewportSize({ width: 1680, height: 1000 })
|
||||
expect(await settle()).toEqual(wide)
|
||||
|
||||
await explicitToggle.click()
|
||||
await expect.poll(() => descriptionOf('Explicit install').evaluate(readCopy))
|
||||
.toEqual({ lines: 1, clipped: true, overflows: true, toggle: 'expand' })
|
||||
await composer.getByRole('radio', { name: 'Stage the rollout', exact: true }).click()
|
||||
await composer.getByRole('button', { name: 'Submit', exact: true }).click()
|
||||
expect(await outcome).toEqual({ value: { answers: [{ id: 'rollout', selected: ['Stage the rollout'] }] } })
|
||||
await expect.poll(() => page.locator('[data-question-key]').count(), { timeout: 10_000 }).toBe(0)
|
||||
bodyCompleted = true
|
||||
} finally {
|
||||
controller.abort()
|
||||
// The dragged width is a persisted preference; dropping it and resizing republishes the default.
|
||||
const cleanup = await Promise.allSettled([
|
||||
outcome,
|
||||
page.evaluate(() => { localStorage.removeItem('dsh.conversation.contentWidth') })
|
||||
.then(() => page.setViewportSize(original)),
|
||||
])
|
||||
if (bodyCompleted) {
|
||||
const failure = cleanup.find((result): result is PromiseRejectedResult => result.status === 'rejected')
|
||||
if (failure !== undefined) throw failure.reason
|
||||
}
|
||||
}
|
||||
}, 90_000)
|
||||
|
||||
})
|
||||
|
||||
describe.skipIf(MODE === 'record')('web e2e: cancelled question transcript', () => {
|
||||
|
||||
@@ -1103,6 +1103,58 @@ describe('web e2e: shipped right Sidebar', () => {
|
||||
expect(tripwire.warnings).toEqual([])
|
||||
}, 90_000)
|
||||
|
||||
it('keeps a dragged tab when a middle click is chorded with the primary button', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-sidebar-right-middle-drag'))
|
||||
const column = await resetSidebar(page)
|
||||
await proseChip(page).click()
|
||||
await expect.poll(() => tabTitles(column)).toEqual(['Files', SAMPLE_NAME])
|
||||
const sample = column.getByRole('tab').filter({ hasText: SAMPLE_NAME })
|
||||
const from = await pointIn(sample, 0.25, 0.5)
|
||||
const to = await pointIn(column.getByRole('tab').filter({ hasText: 'Files' }), 0.25, 0.5)
|
||||
await page.mouse.move(from.x, from.y)
|
||||
await page.mouse.down()
|
||||
try {
|
||||
await page.mouse.move(to.x, to.y, { steps: 8 })
|
||||
await column.locator('[data-dockkit-caret="0"]').waitFor({ state: 'visible' })
|
||||
await page.mouse.down({ button: 'middle' })
|
||||
await page.mouse.up({ button: 'middle' })
|
||||
} finally {
|
||||
await page.mouse.up()
|
||||
}
|
||||
await expect.poll(() => tabTitles(column)).toEqual([SAMPLE_NAME, 'Files'])
|
||||
expect(tripwire.pageErrors).toEqual([])
|
||||
expect(tripwire.warnings).toEqual([])
|
||||
})
|
||||
|
||||
it('closes background and active tabs with the middle button and keeps the last guide', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-sidebar-right-middle-close'))
|
||||
const column = await resetSidebar(page)
|
||||
await proseChip(page).click()
|
||||
await expect.poll(() => tabTitles(column)).toEqual(['Files', SAMPLE_NAME])
|
||||
const sample = column.getByRole('tab').filter({ hasText: SAMPLE_NAME })
|
||||
const files = column.getByRole('tab').filter({ hasText: 'Files' })
|
||||
const selected = () => column.locator('[role="tab"][aria-selected="true"] [data-dockkit-tab-title]').allTextContents()
|
||||
await expect.poll(selected).toEqual([SAMPLE_NAME])
|
||||
|
||||
await files.click({ button: 'middle' })
|
||||
await expect.poll(() => tabTitles(column)).toEqual([SAMPLE_NAME])
|
||||
expect(await selected()).toEqual([SAMPLE_NAME])
|
||||
await column.locator('[data-dockkit-add-tab]').click()
|
||||
await expect.poll(() => tabTitles(column)).toEqual([SAMPLE_NAME, 'Start'])
|
||||
await sample.click()
|
||||
await expect.poll(selected).toEqual([SAMPLE_NAME])
|
||||
await sample.click({ button: 'middle' })
|
||||
await expect.poll(() => tabTitles(column)).toEqual(['Start'])
|
||||
expect(await selected()).toEqual(['Start'])
|
||||
|
||||
await column.getByRole('tab').click({ button: 'middle' })
|
||||
expect(await tabTitles(column)).toEqual(['Start'])
|
||||
expect(await column.locator('[data-sidebar-right-open]').count()).toBe(1)
|
||||
expect(await column.locator('[data-dockkit-tab-quiet]').count()).toBe(1)
|
||||
expect(tripwire.pageErrors).toEqual([])
|
||||
expect(tripwire.warnings).toEqual([])
|
||||
})
|
||||
|
||||
it('preserves the open surface after a reload', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-sidebar-right-reload'))
|
||||
const frame = page.locator('[class*="frame"]').first()
|
||||
|
||||
@@ -224,6 +224,8 @@ describe.skipIf(MODE === 'record')('web e2e: retained steering and a post-stop m
|
||||
let hostClock: MockInstance<typeof Date.now> | undefined
|
||||
const sessionEvents: SessionEvent[] = []
|
||||
const requestInputs: string[][] = []
|
||||
let firstRequestSignal: AbortSignal | undefined
|
||||
const questionStarted = Promise.withResolvers<undefined>()
|
||||
const releaseQuestion = Promise.withResolvers<undefined>()
|
||||
const FOLLOWUP = 'Also acknowledge this second message.'
|
||||
const RESPONSE = "Great, let's move forward. BANANA!"
|
||||
@@ -239,9 +241,13 @@ describe.skipIf(MODE === 'record')('web e2e: retained steering and a post-stop m
|
||||
message.role === 'user'
|
||||
? message.content.flatMap(block => block.type === 'text' ? [block.text] : [])
|
||||
: []))
|
||||
if (requestInputs.length === 1) firstRequestSignal = options.signal
|
||||
for await (const chunk of next()) {
|
||||
yield chunk
|
||||
if (requestInputs.length === 1 && chunk.type === 'block-start') await releaseQuestion.promise
|
||||
if (requestInputs.length === 1 && chunk.type === 'block-start') {
|
||||
questionStarted.resolve(undefined)
|
||||
await releaseQuestion.promise
|
||||
}
|
||||
}
|
||||
}, { prepend: true })
|
||||
browser = await chromium.launch()
|
||||
@@ -271,11 +277,13 @@ describe.skipIf(MODE === 'record')('web e2e: retained steering and a post-stop m
|
||||
const input = page.locator('[data-composer-input][contenteditable="true"]').first()
|
||||
await input.fill(PROMPT)
|
||||
await input.press('Enter')
|
||||
await questionStarted.promise
|
||||
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()
|
||||
await expect.poll(() => firstRequestSignal?.aborted).toBe(true)
|
||||
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)
|
||||
|
||||
@@ -1708,7 +1708,7 @@ export interface Config {
|
||||
|
||||
## `@deepseek-ai/dsh-host-webserver`
|
||||
|
||||
- `source`: [`packages/host/webserver/src/index.ts:76`](../packages/host/webserver/src/index.ts)
|
||||
- `source`: [`packages/host/webserver/src/index.ts:79`](../packages/host/webserver/src/index.ts)
|
||||
|
||||
```ts config-catalog
|
||||
/** Web server listen, TLS, and response-compression config. */
|
||||
@@ -2892,7 +2892,7 @@ export interface Config {
|
||||
## `@deepseek-ai/dsh-session-log-export`
|
||||
|
||||
- `inject`: `commands` · `connection`
|
||||
- `source`: [`packages/session-query/session-log-export/src/index.ts:46`](../packages/session-query/session-log-export/src/index.ts)
|
||||
- `source`: [`packages/session-query/session-log-export/src/index.ts:48`](../packages/session-query/session-log-export/src/index.ts)
|
||||
|
||||
```ts config-catalog
|
||||
/** Session-log archive policy. */
|
||||
|
||||
@@ -1710,7 +1710,7 @@ export interface Config {
|
||||
|
||||
## `@deepseek-ai/dsh-host-webserver`
|
||||
|
||||
- `source`: [`packages/host/webserver/src/index.ts:76`](../packages/host/webserver/src/index.ts)
|
||||
- `source`: [`packages/host/webserver/src/index.ts:79`](../packages/host/webserver/src/index.ts)
|
||||
|
||||
```ts config-catalog
|
||||
/** Web server listen, TLS, and response-compression config. */
|
||||
@@ -2894,7 +2894,7 @@ export interface Config {
|
||||
## `@deepseek-ai/dsh-session-log-export`
|
||||
|
||||
- `inject`: `commands` · `connection`
|
||||
- `source`: [`packages/session-query/session-log-export/src/index.ts:46`](../packages/session-query/session-log-export/src/index.ts)
|
||||
- `source`: [`packages/session-query/session-log-export/src/index.ts:48`](../packages/session-query/session-log-export/src/index.ts)
|
||||
|
||||
```ts config-catalog
|
||||
/** Session-log archive policy. */
|
||||
|
||||
@@ -82,7 +82,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac
|
||||
| `tools/ptc-dispatch-log` | `waterfall` | [`packages/core/tool-executor-ptc/src/runtime-types.ts:46`](../packages/core/tool-executor-ptc/src/runtime-types.ts) | [`tool-executor-ptc`](../packages/core/tool-executor-ptc) (`waterfall`) | [`spill-policy`](../packages/spill/spill-policy) |
|
||||
| `tools/result` | `emit` | [`packages/core/tools/src/events.ts:49`](../packages/core/tools/src/events.ts) | [`tools`](../packages/core/tools) (`events.dispatch`) | [`agent-instructions`](../packages/context/agent-instructions), `claude-code-mods`, [`subagent`](../packages/subagent/subagent), [`tool-present`](../packages/deliverables/tool-present) |
|
||||
| `user-questions/request` | `waterfall` | [`packages/interaction/user-questions/src/types.ts:174`](../packages/interaction/user-questions/src/types.ts) | [`user-questions`](../packages/interaction/user-questions) (`waterfall`) | `remotes`, `server` |
|
||||
| `webserver/index-inject` | `emit` | [`packages/host/webserver/src/index.ts:39`](../packages/host/webserver/src/index.ts) | `webserver` (`emit`) | `connection`, `inspector`, `modules`, `remote-access`, `shortcuts`, `ui-sidebar-documentpreview`, `ui-theme` |
|
||||
| `webserver/index-inject` | `emit` | [`packages/host/webserver/src/index.ts:42`](../packages/host/webserver/src/index.ts) | `webserver` (`emit`) | `connection`, `inspector`, `modules`, `remote-access`, `shortcuts`, `ui-sidebar-documentpreview`, `ui-theme` |
|
||||
| `workflow/agent-end` | `emit` | [`packages/workflow/workflow/src/index.ts:79`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`tool-workflow`](../packages/workflow/tool-workflow) |
|
||||
| `workflow/agent-start` | `emit` | [`packages/workflow/workflow/src/index.ts:68`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`tool-workflow`](../packages/workflow/tool-workflow) |
|
||||
| `workflow/end` | `emit` | [`packages/workflow/workflow/src/index.ts:89`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | - |
|
||||
|
||||
@@ -84,7 +84,7 @@
|
||||
| `tools/ptc-dispatch-log` | `waterfall` | [`packages/core/tool-executor-ptc/src/runtime-types.ts:46`](../packages/core/tool-executor-ptc/src/runtime-types.ts) | [`tool-executor-ptc`](../packages/core/tool-executor-ptc) (`waterfall`) | [`spill-policy`](../packages/spill/spill-policy) |
|
||||
| `tools/result` | `emit` | [`packages/core/tools/src/events.ts:49`](../packages/core/tools/src/events.ts) | [`tools`](../packages/core/tools) (`events.dispatch`) | [`agent-instructions`](../packages/context/agent-instructions), `claude-code-mods`, [`subagent`](../packages/subagent/subagent), [`tool-present`](../packages/deliverables/tool-present) |
|
||||
| `user-questions/request` | `waterfall` | [`packages/interaction/user-questions/src/types.ts:174`](../packages/interaction/user-questions/src/types.ts) | [`user-questions`](../packages/interaction/user-questions) (`waterfall`) | `remotes`, `server` |
|
||||
| `webserver/index-inject` | `emit` | [`packages/host/webserver/src/index.ts:39`](../packages/host/webserver/src/index.ts) | `webserver` (`emit`) | `connection`, `inspector`, `modules`, `remote-access`, `shortcuts`, `ui-sidebar-documentpreview`, `ui-theme` |
|
||||
| `webserver/index-inject` | `emit` | [`packages/host/webserver/src/index.ts:42`](../packages/host/webserver/src/index.ts) | `webserver` (`emit`) | `connection`, `inspector`, `modules`, `remote-access`, `shortcuts`, `ui-sidebar-documentpreview`, `ui-theme` |
|
||||
| `workflow/agent-end` | `emit` | [`packages/workflow/workflow/src/index.ts:79`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`tool-workflow`](../packages/workflow/tool-workflow) |
|
||||
| `workflow/agent-start` | `emit` | [`packages/workflow/workflow/src/index.ts:68`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`tool-workflow`](../packages/workflow/tool-workflow) |
|
||||
| `workflow/end` | `emit` | [`packages/workflow/workflow/src/index.ts:89`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | - |
|
||||
|
||||
@@ -14,9 +14,9 @@
|
||||
/session-persistence/crash-recovery-preserves-an-interrupted-turn:
|
||||
en: 534044a0ce482d52
|
||||
zh: d2e8a7dfeb066946
|
||||
/session-persistence/sessionlocation-refusal-diagnostics-artifact-target:
|
||||
en: 886589f052a916d7
|
||||
zh: 2ca126aa700c933c
|
||||
/session-persistence/sessionlocation-per-session-artifact-metadata:
|
||||
en: e310ff426d848262
|
||||
zh: 8a7667349b0549a7
|
||||
/session-persistence/sessionheader-metadata-beside-the-log:
|
||||
en: 5795ae03d2d163cd
|
||||
zh: a46ceeb22cfbb6a1
|
||||
|
||||
@@ -113,16 +113,16 @@ Repair therefore writes only under write ownership: a live session's write handl
|
||||
|
||||
Read-only observation is `open(id, 'read')`: the handle serves validated contiguous prefix slices, never a torn tail, and repeated reads on one handle never observe an older state than a prior read. There is no persistence-side prepared-Session cache: session-query owns its cold-read cache, keying one balanced cold Session per id on the `stat().revision` change token and re-reading only when the token changes. The [handle-based persistence Agent Note](../../.agents/notes/implemented/architecture/2026-08-27-handle-based-session-persistence.md) owns this lifecycle; the archived [Session preparation record](../../.agents/notes/archived/architecture/2026-08-05-session-preparation.md) documents the original publication-boundary `SessionPreparation` decision.
|
||||
|
||||
## `SessionLocation` — refusal-diagnostics artifact target
|
||||
## `SessionLocation` — per-Session artifact metadata
|
||||
|
||||
`SessionLocation` is not a consumer-facing query: log access goes through a session handle's `read`. It survives only as refusal diagnostics, letting a `SessionFormatUnsupportedError` name the raw log a build refused to interpret. JSONL supplies the absolute transcript path inside its project/session directory; a backend without one artifact per session supplies nothing.
|
||||
`SessionLocation` is observational metadata for diagnostics and export; log access still goes through a session handle's `read`. Materialized persistence snapshots carry the selected artifact path, and a `SessionFormatUnsupportedError` can name the raw log a build refused to interpret. JSONL supplies the absolute transcript path inside its project/session directory; a backend without one artifact per session supplies nothing.
|
||||
|
||||
```ts type-equiv
|
||||
/**
|
||||
* A backend-resolved, per-session local artifact location. Carried only by
|
||||
* refusal diagnostics ({@link SessionFormatUnsupportedError}) so a user can
|
||||
* find the raw log a build refused to interpret; it is not a consumer-facing
|
||||
* query — log access goes through a session handle's `read`.
|
||||
* A backend-resolved, per-Session local artifact path used by observational
|
||||
* snapshots and refusal diagnostics. It names a backend target for the Session
|
||||
* and does not authorize reads; log access still goes through a session
|
||||
* handle's `read`. It is not persisted Session content.
|
||||
*/
|
||||
interface SessionLocation {
|
||||
/** Backend-specific artifact kind, for example `jsonl`. */
|
||||
@@ -319,6 +319,12 @@ interface SessionPersistenceSnapshot {
|
||||
readonly eventCount?: number
|
||||
/** Physical artifact byte size, when the backend can provide it cheaply (JSONL); otherwise absent. */
|
||||
readonly sizeBytes?: number
|
||||
/**
|
||||
* Backend-resolved location of the selected materialized per-Session
|
||||
* artifact, for diagnostics and export. Absent when the backend has no
|
||||
* materialized per-Session file, including an unmaterialized pending Session.
|
||||
*/
|
||||
readonly location?: SessionLocation
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
@@ -113,16 +113,16 @@ interface SessionHandle extends AsyncDisposable {
|
||||
|
||||
只读观察即 `open(id, 'read')`:句柄提供经过验证的连续前缀切片,绝不返回撕裂尾部,且同一句柄上的重复读取绝不会观察到比先前读取更旧的状态。持久化侧不存在已准备 Session 缓存:session-query 拥有自己的冷读缓存,按 `stat().revision` 变更令牌为每个 id 缓存一个已配平的冷 Session,仅在令牌变化时重新读取。该生命周期由[基于句柄的持久化 Agent Note](../../.agents/notes/implemented/architecture/2026-08-27-handle-based-session-persistence.zh.md)定义;已归档的 [Session 准备阶段记录](../../.agents/notes/archived/architecture/2026-08-05-session-preparation.md)记载了发布边界 `SessionPreparation` 最初的决策。
|
||||
|
||||
## `SessionLocation`——拒绝诊断的产物目标
|
||||
## `SessionLocation`——每一 Session 的产物元数据
|
||||
|
||||
`SessionLocation` 不是面向消费者的查询:日志访问走会话句柄的 `read`。它仅作为拒绝诊断存在,使 `SessionFormatUnsupportedError` 能指出本构建拒绝解读的原始日志。JSONL 提供其项目/会话目录内 transcript(文本记录)的绝对路径;没有逐会话工件的后端则不提供。
|
||||
`SessionLocation` 用于诊断与导出的观察元数据;日志访问仍走会话句柄的 `read`。已物化的持久化快照携带选中产物路径,`SessionFormatUnsupportedError` 也能指出本构建拒绝解读的原始日志。JSONL 提供其项目/会话目录内 transcript(文本记录)的绝对路径;没有逐会话工件的后端则不提供。
|
||||
|
||||
```ts type-equiv
|
||||
/**
|
||||
* A backend-resolved, per-session local artifact location. Carried only by
|
||||
* refusal diagnostics ({@link SessionFormatUnsupportedError}) so a user can
|
||||
* find the raw log a build refused to interpret; it is not a consumer-facing
|
||||
* query — log access goes through a session handle's `read`.
|
||||
* A backend-resolved, per-Session local artifact path used by observational
|
||||
* snapshots and refusal diagnostics. It names a backend target for the Session
|
||||
* and does not authorize reads; log access still goes through a session
|
||||
* handle's `read`. It is not persisted Session content.
|
||||
*/
|
||||
interface SessionLocation {
|
||||
/** Backend-specific artifact kind, for example `jsonl`. */
|
||||
@@ -319,6 +319,12 @@ interface SessionPersistenceSnapshot {
|
||||
readonly eventCount?: number
|
||||
/** Physical artifact byte size, when the backend can provide it cheaply (JSONL); otherwise absent. */
|
||||
readonly sizeBytes?: number
|
||||
/**
|
||||
* Backend-resolved location of the selected materialized per-Session
|
||||
* artifact, for diagnostics and export. Absent when the backend has no
|
||||
* materialized per-Session file, including an unmaterialized pending Session.
|
||||
*/
|
||||
readonly location?: SessionLocation
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
@@ -561,10 +561,9 @@ decide(request: RemoteAccessRequest<Principal>): RemoteAccessDecision
|
||||
allows(resource: RemoteAccessResource): boolean
|
||||
|
||||
/**
|
||||
* The baseline a page opens with before its snapshot stream connects. A
|
||||
* business package rendering a non-owner page assigns this value to
|
||||
* {@link REMOTE_ACCESS_SNAPSHOT_KEY} after every index injection row,
|
||||
* replacing the owner baseline the webServer index carries.
|
||||
* The baseline a page opens with before its snapshot stream connects; every
|
||||
* index the webServer renders carries it under
|
||||
* {@link REMOTE_ACCESS_SNAPSHOT_KEY} for the Peer the page speaks for.
|
||||
* @param principal - the page's caller identity.
|
||||
* @returns the owner baseline for the owner; otherwise the principal's current grants.
|
||||
*/
|
||||
|
||||
@@ -561,10 +561,9 @@ decide(request: RemoteAccessRequest<Principal>): RemoteAccessDecision
|
||||
allows(resource: RemoteAccessResource): boolean
|
||||
|
||||
/**
|
||||
* The baseline a page opens with before its snapshot stream connects. A
|
||||
* business package rendering a non-owner page assigns this value to
|
||||
* {@link REMOTE_ACCESS_SNAPSHOT_KEY} after every index injection row,
|
||||
* replacing the owner baseline the webServer index carries.
|
||||
* The baseline a page opens with before its snapshot stream connects; every
|
||||
* index the webServer renders carries it under
|
||||
* {@link REMOTE_ACCESS_SNAPSHOT_KEY} for the Peer the page speaks for.
|
||||
* @param principal - the page's caller identity.
|
||||
* @returns the owner baseline for the owner; otherwise the principal's current grants.
|
||||
*/
|
||||
|
||||
@@ -3,8 +3,8 @@
|
||||
# After editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write docs/subsystems/web-server.md
|
||||
/http-server:
|
||||
en: c9b2f67d3248c496
|
||||
zh: 5fa36d2837ff4f63
|
||||
en: 56b268a69964f651
|
||||
zh: c8ac8f16bf58a98a
|
||||
/http-server/routes:
|
||||
en: e949c3738a35b58f
|
||||
zh: 138e3aaeb319ddc4
|
||||
@@ -12,5 +12,5 @@
|
||||
en: 4b162bd9f280eb06
|
||||
zh: 3bea98dfee88c582
|
||||
/http-server/the-service:
|
||||
en: cc36972b8458279f
|
||||
zh: c129044935d44769
|
||||
en: bbf84a6cd0460dc2
|
||||
zh: cc63ce45b0675c6d
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
English | [中文](web-server.zh.md)
|
||||
|
||||
[dsh-host-webserver](../../packages/host/webserver) is the browser HTTP carrier for the GUI host: one `node:http` server — `node:https` when `tls` is configured — providing `ctx.webServer`, a named-route registry, optional gzip response compression, index.html transform callbacks, and one fallback handler that a plugin may claim. It is not part of the agent loop and not a capability seam; it knows no harness concepts, and another plugin registers every feature route, including the `/api` bridge, plugin bundles, and the HMR event stream ([layering reference](../../packages/boot/app-boot/README.md)). It serves browsers only: Electron loads the built files over `file://` and sends fetch requests through an IPC bridge instead of this server.
|
||||
[dsh-host-webserver](../../packages/host/webserver) is the browser HTTP carrier for the GUI host: one `node:http` server — `node:https` when `tls` is configured — providing `ctx.webServer`, a named-route registry, optional gzip response compression, index.html transform callbacks, and one fallback handler that a plugin may claim. It is not part of the agent loop and not a capability seam; its only harness concept is the Peer an index render speaks for, and another plugin registers every feature route, including the `/api` bridge, plugin bundles, and the HMR event stream ([layering reference](../../packages/boot/app-boot/README.md)). It serves browsers only: Electron loads the built files over `file://` and sends fetch requests through an IPC bridge instead of this server.
|
||||
|
||||
Source: [`packages/host/webserver/src/index.ts`](../../packages/host/webserver/src/index.ts)
|
||||
|
||||
@@ -81,7 +81,7 @@ No composition can bind every interface at once: the unspecified address is reje
|
||||
|
||||
## The service
|
||||
|
||||
`WebServer` (`ctx.webServer`) listens immediately on activation; a listen failure (EADDRINUSE…) rejects initialization, and the boot process reports the failed fiber. The `tls` pair is read once before the listener binds, and an unreadable, empty, or mismatched pair rejects initialization the same way instead of falling back to HTTP. `register(route)` adds one named route and returns its disposer; a duplicate `(kind, path)` throws because route patterns are a composition-level contract and a collision is a misconfiguration. Gzip wraps eligible socket-backed responses inside the server, so route handlers retain direct `ServerResponse` ownership and no response-writing API is added to the service. Existing content encodings, `Cache-Control: no-transform`, ranges, SSE, ZIP, and the packaged `.gz` Worker image remain identity responses. `collectIndexInjections()` gathers structured `IndexInjection` rows over one `webserver/index-inject` emit, and `renderIndex(html)` renders them into successful root and configured index responses before applying the raw `tapIndex(transform)` escape-hatch transforms in registration order; [dsh-client-modules](../../packages/client/modules) answers the event with the boot manifest rows. `port` reads the listening port, including the port assigned by the OS when `config.port` is 0, and `protocol` reports the scheme the listener serves (`http:` or `https:`).
|
||||
`WebServer` (`ctx.webServer`) listens immediately on activation; a listen failure (EADDRINUSE…) rejects initialization, and the boot process reports the failed fiber. The `tls` pair is read once before the listener binds, and an unreadable, empty, or mismatched pair rejects initialization the same way instead of falling back to HTTP. `register(route)` adds one named route and returns its disposer; a duplicate `(kind, path)` throws because route patterns are a composition-level contract and a collision is a misconfiguration. Gzip wraps eligible socket-backed responses inside the server, so route handlers retain direct `ServerResponse` ownership and no response-writing API is added to the service. Existing content encodings, `Cache-Control: no-transform`, ranges, SSE, ZIP, and the packaged `.gz` Worker image remain identity responses. `collectIndexInjections(peer)` gathers structured `IndexInjection` rows over one `webserver/index-inject` emit that passes the Peer the page speaks for, and `renderIndex(html, peer)` renders them into successful root and configured index responses before applying the raw `tapIndex(transform)` escape-hatch transforms in registration order; [dsh-client-modules](../../packages/client/modules) answers the event with the boot manifest rows. `port` reads the listening port, including the port assigned by the OS when `config.port` is 0, and `protocol` reports the scheme the listener serves (`http:` or `https:`).
|
||||
|
||||
A request whose handling throws (a malformed %-escape hitting `decodeURIComponent`, a client dropping mid-body) is logged as a warning and answered 400 — or the socket destroyed when headers are already out — never a process exit. Disposal pairs `close()` with `closeAllConnections()` because a handler may hold its response open (SSE) and such connections never end on their own; without the force-close, teardown would hang. A TLS socket counts as a connection only after its handshake, so the server tracks raw TLS sockets beside upgraded ones and destroys both at disposal. The package never prints: the URL line belongs to the shell. Per-package operational detail, including the dev-mode bundle watch pipeline, stays in the [README](../../packages/host/webserver/README.md).
|
||||
|
||||
@@ -235,20 +235,23 @@ tapIndex(transform: (html: string) => string): () => void
|
||||
applyIndexTaps(html: string): string
|
||||
|
||||
/**
|
||||
* Gather the structured injection table: one `webserver/index-inject` emit,
|
||||
* every subscriber pushes its current rows. Fresh per call, so subscribers
|
||||
* read live state (module graph, theme preference) at emit time.
|
||||
* Gather the structured injection table for one Peer: one
|
||||
* `webserver/index-inject` emit, every subscriber pushes its current rows.
|
||||
* Fresh per call, so subscribers read live state (module graph, theme
|
||||
* preference, the Peer's access) at emit time.
|
||||
* @param peer - the Peer the page speaks for.
|
||||
* @returns rows in subscriber activation order.
|
||||
*/
|
||||
collectIndexInjections(): IndexInjection[]
|
||||
collectIndexInjections(peer: PeerScope): IndexInjection[]
|
||||
|
||||
/**
|
||||
* Render one index.html body: the structured injection table first, then
|
||||
* the raw `tapIndex` transforms over the result.
|
||||
* Render one index.html body for one Peer: the structured injection table
|
||||
* first, then the raw `tapIndex` transforms over the result.
|
||||
* @param html - the raw index.html body.
|
||||
* @param peer - the Peer the page speaks for.
|
||||
* @returns the transformed body.
|
||||
*/
|
||||
renderIndex(html: string): string
|
||||
renderIndex(html: string, peer: PeerScope): string
|
||||
```
|
||||
|
||||
Source: [`packages/host/webserver/src/index.ts`](../../packages/host/webserver/src/index.ts)
|
||||
@@ -285,17 +288,19 @@ Source: [`packages/client/connection/src/index.ts`](../../packages/client/connec
|
||||
|
||||
#### `webserver/index-inject` — emit
|
||||
|
||||
Collect the structured index injection table. Emitted on every index render and every worker boot-payload request; listeners push their current rows, so a row's data is read fresh at emit time.
|
||||
Collect the structured index injection table for the page `peer` speaks for. Emitted on every index render and every worker boot-payload request; listeners push their current rows, so a row's data is read fresh at emit time.
|
||||
|
||||
```ts cordis-catalog
|
||||
/**
|
||||
* Collect the structured index injection table. Emitted on every index
|
||||
* render and every worker boot-payload request; listeners push their
|
||||
* current rows, so a row's data is read fresh at emit time.
|
||||
* Collect the structured index injection table for the page `peer` speaks
|
||||
* for. Emitted on every index render and every worker boot-payload
|
||||
* request; listeners push their current rows, so a row's data is read
|
||||
* fresh at emit time.
|
||||
* @param table - Mutable row table; listeners append in activation order.
|
||||
* @param peer - The Peer the rendered page speaks for.
|
||||
* @mode emit
|
||||
*/
|
||||
'webserver/index-inject'(table: IndexInjection[]): void
|
||||
'webserver/index-inject'(table: IndexInjection[], peer: PeerScope): void
|
||||
```
|
||||
|
||||
Source: [`packages/host/webserver/src/index.ts`](../../packages/host/webserver/src/index.ts)
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
[English](web-server.md) | 中文
|
||||
|
||||
[dsh-host-webserver](../../packages/host/webserver) 是 GUI Host 的浏览器 HTTP 载体:一个 `node:http` 服务器——配置 `tls` 时改用 `node:https`——提供 `ctx.webServer`、具名路由注册表、可选的 gzip 响应压缩、index.html 转换回调,以及一个可由插件认领的回退处理器。它不属于 agent loop(智能体循环),也不是能力 seam;它不了解任何 harness 概念。其他插件负责注册所有功能路由,包括 `/api` 桥接、插件 bundle 和 HMR(热模块替换)事件流([分层说明](../../packages/boot/app-boot/README.zh.md))。该服务器只服务浏览器:Electron 通过 `file://` 加载已构建文件,并经 IPC 桥接发送 fetch 请求,不使用本服务器。
|
||||
[dsh-host-webserver](../../packages/host/webserver) 是 GUI Host 的浏览器 HTTP 载体:一个 `node:http` 服务器——配置 `tls` 时改用 `node:https`——提供 `ctx.webServer`、具名路由注册表、可选的 gzip 响应压缩、index.html 转换回调,以及一个可由插件认领的回退处理器。它不属于 agent loop(智能体循环),也不是能力 seam;它唯一涉及的 harness 概念是 index 渲染所代表的 Peer。其他插件负责注册所有功能路由,包括 `/api` 桥接、插件 bundle 和 HMR(热模块替换)事件流([分层说明](../../packages/boot/app-boot/README.zh.md))。该服务器只服务浏览器:Electron 通过 `file://` 加载已构建文件,并经 IPC 桥接发送 fetch 请求,不使用本服务器。
|
||||
|
||||
源码:[`packages/host/webserver/src/index.ts`](../../packages/host/webserver/src/index.ts)
|
||||
|
||||
@@ -81,7 +81,7 @@ interface TlsConfig {
|
||||
|
||||
## 服务
|
||||
|
||||
`WebServer`(`ctx.webServer`)在激活时立即监听;监听失败(EADDRINUSE 等)会使初始化被拒绝,启动进程会报告失败的 fiber。`tls` 证书对在监听器绑定前读取一次;文件不可读、为空或二者不匹配时同样会使初始化被拒绝,而不是回退到 HTTP。`register(route)` 添加一条具名路由并返回其 disposer;重复的 `(kind, path)` 抛出异常,因为路由模式是组合层约定,冲突即配置错误。Gzip 在服务器内部包装符合条件且基于 socket 的响应,因此 route handler 继续直接持有 `ServerResponse`,服务也不新增响应写出 API。已有内容编码、`Cache-Control: no-transform`、范围响应、SSE、ZIP 与打包后的 `.gz` Worker 镜像均保持 identity 响应。`collectIndexInjections()` 经一次 `webserver/index-inject` emit 收集结构化 `IndexInjection` 行,`renderIndex(html)` 把它们渲染进成功的根路径和配置 index 响应,随后再按注册顺序应用原始的 `tapIndex(transform)` 逃生口转换;[dsh-client-modules](../../packages/client/modules) 以启动 manifest(元数据清单)行回应该事件。`port` 读取监听端口,包括 `config.port` 为 0 时操作系统分配的端口,`protocol` 则报告监听器提供的协议(`http:` 或 `https:`)。
|
||||
`WebServer`(`ctx.webServer`)在激活时立即监听;监听失败(EADDRINUSE 等)会使初始化被拒绝,启动进程会报告失败的 fiber。`tls` 证书对在监听器绑定前读取一次;文件不可读、为空或二者不匹配时同样会使初始化被拒绝,而不是回退到 HTTP。`register(route)` 添加一条具名路由并返回其 disposer;重复的 `(kind, path)` 抛出异常,因为路由模式是组合层约定,冲突即配置错误。Gzip 在服务器内部包装符合条件且基于 socket 的响应,因此 route handler 继续直接持有 `ServerResponse`,服务也不新增响应写出 API。已有内容编码、`Cache-Control: no-transform`、范围响应、SSE、ZIP 与打包后的 `.gz` Worker 镜像均保持 identity 响应。`collectIndexInjections(peer)` 经一次传入页面所代表 Peer 的 `webserver/index-inject` emit 收集结构化 `IndexInjection` 行,`renderIndex(html, peer)` 把它们渲染进成功的根路径和配置 index 响应,随后再按注册顺序应用原始的 `tapIndex(transform)` 逃生口转换;[dsh-client-modules](../../packages/client/modules) 以启动 manifest(元数据清单)行回应该事件。`port` 读取监听端口,包括 `config.port` 为 0 时操作系统分配的端口,`protocol` 则报告监听器提供的协议(`http:` 或 `https:`)。
|
||||
|
||||
处理过程中抛出异常的请求(畸形的 % 转义撞上 `decodeURIComponent`、客户端在请求体中途断开)会记录为警告并应答 400(响应头已发出时则销毁 socket),绝不导致进程退出。dispose(资源释放)把 `close()` 与 `closeAllConnections()` 配对使用,因为处理器可能像 SSE(Server-Sent Events)那样保持响应打开,而这类连接永远不会自行结束;没有强制关闭,拆卸就会挂起。TLS socket 只有完成握手后才计入连接,因此服务器会把原始 TLS socket 与升级后的 socket 一并跟踪,并在 dispose 时销毁两者。该包从不打印输出:URL 行归 shell 所有。逐包运维细节(含开发模式的 bundle 监视流水线)留在 [README](../../packages/host/webserver/README.zh.md) 中。
|
||||
|
||||
@@ -235,20 +235,23 @@ tapIndex(transform: (html: string) => string): () => void
|
||||
applyIndexTaps(html: string): string
|
||||
|
||||
/**
|
||||
* Gather the structured injection table: one `webserver/index-inject` emit,
|
||||
* every subscriber pushes its current rows. Fresh per call, so subscribers
|
||||
* read live state (module graph, theme preference) at emit time.
|
||||
* Gather the structured injection table for one Peer: one
|
||||
* `webserver/index-inject` emit, every subscriber pushes its current rows.
|
||||
* Fresh per call, so subscribers read live state (module graph, theme
|
||||
* preference, the Peer's access) at emit time.
|
||||
* @param peer - the Peer the page speaks for.
|
||||
* @returns rows in subscriber activation order.
|
||||
*/
|
||||
collectIndexInjections(): IndexInjection[]
|
||||
collectIndexInjections(peer: PeerScope): IndexInjection[]
|
||||
|
||||
/**
|
||||
* Render one index.html body: the structured injection table first, then
|
||||
* the raw `tapIndex` transforms over the result.
|
||||
* Render one index.html body for one Peer: the structured injection table
|
||||
* first, then the raw `tapIndex` transforms over the result.
|
||||
* @param html - the raw index.html body.
|
||||
* @param peer - the Peer the page speaks for.
|
||||
* @returns the transformed body.
|
||||
*/
|
||||
renderIndex(html: string): string
|
||||
renderIndex(html: string, peer: PeerScope): string
|
||||
```
|
||||
|
||||
Source: [`packages/host/webserver/src/index.ts`](../../packages/host/webserver/src/index.ts)
|
||||
@@ -285,17 +288,19 @@ Source: [`packages/client/connection/src/index.ts`](../../packages/client/connec
|
||||
|
||||
#### `webserver/index-inject` — emit
|
||||
|
||||
Collect the structured index injection table. Emitted on every index render and every worker boot-payload request; listeners push their current rows, so a row's data is read fresh at emit time.
|
||||
Collect the structured index injection table for the page `peer` speaks for. Emitted on every index render and every worker boot-payload request; listeners push their current rows, so a row's data is read fresh at emit time.
|
||||
|
||||
```ts cordis-catalog
|
||||
/**
|
||||
* Collect the structured index injection table. Emitted on every index
|
||||
* render and every worker boot-payload request; listeners push their
|
||||
* current rows, so a row's data is read fresh at emit time.
|
||||
* Collect the structured index injection table for the page `peer` speaks
|
||||
* for. Emitted on every index render and every worker boot-payload
|
||||
* request; listeners push their current rows, so a row's data is read
|
||||
* fresh at emit time.
|
||||
* @param table - Mutable row table; listeners append in activation order.
|
||||
* @param peer - The Peer the rendered page speaks for.
|
||||
* @mode emit
|
||||
*/
|
||||
'webserver/index-inject'(table: IndexInjection[]): void
|
||||
'webserver/index-inject'(table: IndexInjection[], peer: PeerScope): void
|
||||
```
|
||||
|
||||
Source: [`packages/host/webserver/src/index.ts`](../../packages/host/webserver/src/index.ts)
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
# Bilingual-pair consistency record for guide.md (docs/i18n/README.md): per heading
|
||||
# section, a hash of its English and Chinese blocks outside code blocks and generated regions.
|
||||
# After editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write docs/upgrade-guide/v0.2.1-rc.2/webserver-index-peer/guide.md
|
||||
/:
|
||||
en: 4182112137303167
|
||||
zh: 8451ce873cfca164
|
||||
/webserver-index-rendering-takes-a-peer:
|
||||
en: 7986c2aaf1511f53
|
||||
zh: a6f78fc166e1ea19
|
||||
/webserver-index-rendering-takes-a-peer/change:
|
||||
en: 07a11388305a0124
|
||||
zh: 662003b29c238d30
|
||||
/webserver-index-rendering-takes-a-peer/migration:
|
||||
en: 7ef144b29ffb2f39
|
||||
zh: defb62ecc4ba6c2e
|
||||
@@ -0,0 +1,17 @@
|
||||
---
|
||||
kind: upgrade-guide
|
||||
description: "WebServer index rendering takes the Peer the page speaks for, and the `webserver/index-inject` event passes that Peer to listeners."
|
||||
---
|
||||
# WebServer index rendering takes a Peer
|
||||
|
||||
English | [中文](guide.zh.md)
|
||||
|
||||
## Change
|
||||
|
||||
`renderIndex(html)` and `collectIndexInjections()` on `ctx.webServer` from `@deepseek-ai/dsh-host-webserver` took no Peer, and every index carried the owner's access snapshot. They are now `renderIndex(html, peer)` and `collectIndexInjections(peer)`, and the `webserver/index-inject` event passes the same Peer as its second argument, so the remote-access snapshot in the index belongs to that Peer. Code that calls either method breaks at compile time; listeners that read only the table keep working.
|
||||
|
||||
## Migration
|
||||
|
||||
1. Pass the Peer the page speaks for to every `ctx.webServer.renderIndex(html)` and `ctx.webServer.collectIndexInjections()` call. A page served to the owner's browser passes `ctx.connection.browserPeer`.
|
||||
2. In a test that emits `webserver/index-inject` itself, pass a Peer after the table: `ctx.emit('webserver/index-inject', table, peer)`.
|
||||
3. Run your TypeScript build and confirm no call to either method reports a missing argument.
|
||||
@@ -0,0 +1,17 @@
|
||||
---
|
||||
kind: upgrade-guide
|
||||
description: "WebServer 渲染 index 时需要传入页面所代表的 Peer,`webserver/index-inject` 事件会把该 Peer 传给监听方。"
|
||||
---
|
||||
# WebServer 渲染 index 需要传入 Peer
|
||||
|
||||
[English](guide.md) | 中文
|
||||
|
||||
## 变更
|
||||
|
||||
`@deepseek-ai/dsh-host-webserver` 中 `ctx.webServer` 的 `renderIndex(html)` 与 `collectIndexInjections()` 不接收 Peer,每个 index 携带的都是 owner 的访问快照。现在它们改为 `renderIndex(html, peer)` 与 `collectIndexInjections(peer)`,`webserver/index-inject` 事件以第二个参数传入同一个 Peer,index 中的 remote-access 快照因此属于该 Peer。调用这两个方法的代码会在编译时报错;只读取表格的监听方不受影响。
|
||||
|
||||
## 迁移
|
||||
|
||||
1. 每处 `ctx.webServer.renderIndex(html)` 与 `ctx.webServer.collectIndexInjections()` 调用都传入页面所代表的 Peer。提供给 owner 浏览器的页面传入 `ctx.connection.browserPeer`。
|
||||
2. 自行 emit `webserver/index-inject` 的测试在表格之后传入一个 Peer:`ctx.emit('webserver/index-inject', table, peer)`。
|
||||
3. 运行 TypeScript 构建,确认这两个方法的调用都不再报缺少参数。
|
||||
@@ -15,11 +15,11 @@
|
||||
en: a18ecc232e3c3933
|
||||
zh: b8e43580403f6d77
|
||||
/deepseek-ai-dsh-api-remote-access/host-service-remoteaccessservice-ctx-key-remoteaccess:
|
||||
en: 9175835ea5c5bff1
|
||||
zh: 0e33b266fdac3d13
|
||||
en: 8a4be795c7aa2ef0
|
||||
zh: 02550b975e2e8d61
|
||||
/deepseek-ai-dsh-api-remote-access/client-face:
|
||||
en: 1db01235b6af84fa
|
||||
zh: c0ced511cc1841a0
|
||||
en: d4cf8f646fdf2d39
|
||||
zh: 247f643e991aa806
|
||||
/deepseek-ai-dsh-api-remote-access/model-experience:
|
||||
en: 8b07250cecacd3aa
|
||||
zh: d28ab93980bdcdad
|
||||
|
||||
@@ -43,7 +43,7 @@ When `bindings()`, a condition, or `explain()` throws, the decision is refused w
|
||||
|
||||
A forwarded event is decided as the action `event:<name>` on the resources its forwarding entry's readers name, or on `global` when the entry declares none, and reaches a non-owner whole or not at all; its payload is never trimmed. An event carries every resource it declares, so a reader that returns `undefined` makes the event's resources invalid, and the Gateway delivers it to no non-owner without asking `decide()`. An event names at most one resource, so collection changes travel on follow streams (`session/follow`, `session/control`, `workspace/follow`), whose methods decide each item inline through `allows()`.
|
||||
|
||||
The service adds a `global` row named `REMOTE_ACCESS_SNAPSHOT_KEY` (`__DSH_REMOTE_ACCESS__`) holding the owner baseline `{ type: 'baseline', owner: true }` to every index the webServer renders through `webserver/index-inject`. A business package that renders a non-owner page, such as a liveshare viewer page rendered on the owner Host, places an assignment of that global to `ctx.remoteAccess.snapshotFor(principal)` after every index injection row, for example a `global` row appended to the end of `ctx.webServer.collectIndexInjections()`. The later assignment replaces the owner baseline, and the Client entry reads the global when it mounts, so the page opens with the viewer's grants instead of the owner's.
|
||||
The service adds a `global` row named `REMOTE_ACCESS_SNAPSHOT_KEY` (`__DSH_REMOTE_ACCESS__`) holding `snapshotFor(peer.principal)` to every index the webServer renders through `webserver/index-inject`, for the Peer that render speaks for: the owner's browser page opens with `{ type: 'baseline', owner: true }`, and a page rendered for a non-owner Peer, such as a liveshare viewer page rendered on the owner Host, opens with that viewer's grants. The Client entry reads the global when it mounts.
|
||||
|
||||
| `details.reason` of `remote-access/denied` | Cause |
|
||||
|---|---|
|
||||
@@ -60,9 +60,10 @@ Snapshot grants list, per resource the principal's bindings name, the sorted end
|
||||
The Client entry reads the baseline the index injected under `REMOTE_ACCESS_SNAPSHOT_KEY` synchronously, mounts the generated `remoteAccess` contribution itself through `mountAccess(ctx, contribution, initial)`, follows the snapshot stream, and provides:
|
||||
|
||||
- the Client service `ctx.remoteAccess.source(kind, id, action)`, an identity-stable boolean source per `(kind, id, action)` for one resource of a declared kind;
|
||||
- `ctx.remoteAccess.whenAccess(action, callback)`, which keeps the callback's effects installed while the `global` resource allows `action`: the callback runs when the answer becomes true, its effects dispose in reverse order when it becomes false, and it runs again on the next true. The callback returns one disposer or an iterable of disposers, as with `ctx.slots.inject`; it runs synchronously when the answer is already true, as it always is for the owner; the controller belongs to the caller's fiber. A setup failure while allowed throws at once; a later one retires the controller and is rethrown in a microtask. `whenResourceAccess(kind, id, action, callback)` is the same for one resource of a declared kind;
|
||||
- the standard Hook `useRemoteAccess(action)`, which answers on the `global` resource.
|
||||
|
||||
Domain UI packages publish their resource Hooks over `source()`: [ui-session](../../client/ui-session/README.md) provides `useSessionAccess(sessionId, action)` and the Session-scope `useAccess(action)` on Session resources, a different name from the `global` `useRemoteAccess`, and [ui-workspace](../../client/ui-workspace/README.md) provides `useWorkspaceAccess(workspaceId, action)` on Workspace resources. A Hook's `action` is a `RemoteEndpoint`, an endpoint some Client Remote namespace declares. A non-owner answer is whether that resource's grant lists the action and never falls back to the `global` answer, so a resource without a grant, or whose grant lists no action, answers false. Every answer is true for the owner; a non-owner page without an injected baseline, and a non-owner page whose stream fails terminally, answers false; the terminal failure is reported with its error, for a non-owner with the advice to reload the page and for the owner with the note that its answers are unaffected; a reconnect keeps the last snapshot. A malformed injected baseline fails the mount. Hooks never issue requests, and the Host still checks every call. [`dsh-client-test-runtime`](../../test-support/client-runtime/README.md) replaces this row with allow-all access in Client specs.
|
||||
Domain UI packages publish their resource Hooks over `source()` and their `whenAccess` over `whenResourceAccess()`: [ui-session](../../client/ui-session/README.md) provides `useSessionAccess(sessionId, action)`, the Session-scope `useAccess(action)`, and `ctx.uiSession.whenAccess(sessionId, action, callback)` on Session resources, a different name from the `global` `useRemoteAccess`, and [ui-workspace](../../client/ui-workspace/README.md) provides `useWorkspaceAccess(workspaceId, action)` and `ctx.uiWorkspace.whenAccess(workspaceId, action, callback)` on Workspace resources. Only the package that owns a resource kind calls `source()` and `whenResourceAccess()`; other plugins read access through the access Hooks and the `whenAccess` methods. A Hook's `action` is a `RemoteEndpoint`, an endpoint some Client Remote namespace declares. A non-owner answer is whether that resource's grant lists the action and never falls back to the `global` answer, so a resource without a grant, or whose grant lists no action, answers false. Every answer is true for the owner; a non-owner page without an injected baseline, and a non-owner page whose stream fails terminally, answers false; the terminal failure is reported with its error, for a non-owner with the advice to reload the page and for the owner with the note that its answers are unaffected; a reconnect keeps the last snapshot. A malformed injected baseline fails the mount. Hooks never issue requests, and the Host still checks every call. [`dsh-client-test-runtime`](../../test-support/client-runtime/README.md) replaces this row with fixed answers, allow-all by default, in Client specs.
|
||||
|
||||
<a id="model-experience"></a>
|
||||
## Model Experience
|
||||
|
||||
@@ -43,7 +43,7 @@ Gateway 依次从 `@Remote` 与 `@RemoteScope` 的 `resources` 读取函数、lo
|
||||
|
||||
转发事件按其转发条目的读取函数指明的资源(条目未声明时为 `global`)判定 action `event:<name>`,对非 owner 整条送达或整条不送,从不裁剪 payload。事件携带其声明的每个资源,因此读取函数返回 `undefined` 时该事件的资源非法,Gateway 不询问 `decide()`,不送达任何非 owner。一个事件至多指明一个资源,因此集合类变化走 follow 类流(`session/follow`、`session/control`、`workspace/follow`),由这些方法内联经 `allows()` 逐项判定。
|
||||
|
||||
本服务经 `webserver/index-inject` 为 webServer 渲染的每个 index 加入名为 `REMOTE_ACCESS_SNAPSHOT_KEY`(`__DSH_REMOTE_ACCESS__`)的 `global` 行,值为 owner baseline `{ type: 'baseline', owner: true }`。渲染非 owner 页面的业务包(例如在 owner Host 上渲染 liveshare 读者页)在所有 index 注入行之后放置一条把该全局赋值为 `ctx.remoteAccess.snapshotFor(principal)` 的语句,例如追加到 `ctx.webServer.collectIndexInjections()` 末尾的一条 `global` 行。后写的赋值覆盖 owner baseline,Client 入口在挂载时读取该全局,因此页面以读者的 grant 而非 owner 的快照打开。
|
||||
本服务经 `webserver/index-inject` 为 webServer 渲染的每个 index 加入名为 `REMOTE_ACCESS_SNAPSHOT_KEY`(`__DSH_REMOTE_ACCESS__`)的 `global` 行,值为该次渲染所代表 Peer 的 `snapshotFor(peer.principal)`:owner 的浏览器页面以 `{ type: 'baseline', owner: true }` 打开,为非 owner Peer 渲染的页面(例如在 owner Host 上渲染的 liveshare 读者页)以该读者的 grant 打开。Client 入口在挂载时读取该全局。
|
||||
|
||||
| `remote-access/denied` 的 `details.reason` | 原因 |
|
||||
|---|---|
|
||||
@@ -60,9 +60,10 @@ Gateway 依次从 `@Remote` 与 `@RemoteScope` 的 `resources` 读取函数、lo
|
||||
Client 入口同步读取 index 注入在 `REMOTE_ACCESS_SNAPSHOT_KEY` 下的 baseline,经 `mountAccess(ctx, contribution, initial)` 自行挂载生成的 `remoteAccess` contribution,订阅快照流,并提供:
|
||||
|
||||
- Client 服务 `ctx.remoteAccess.source(kind, id, action)`,为已声明种类的某个资源上的每个 `(kind, id, action)` 提供一个身份稳定的布尔 source;
|
||||
- `ctx.remoteAccess.whenAccess(action, callback)`,在 `global` 资源允许 `action` 期间保持回调的效果:答案变为 true 时执行回调,变为 false 时逆序撤销其效果,下一次变为 true 时再次执行。回调返回一个 disposer 或一组 disposer,与 `ctx.slots.inject` 相同;答案已为 true 时(owner 恒为 true)回调同步执行;控制器属于调用方 fiber。允许期间的安装失败立即抛出;之后的安装失败使控制器停止,并在微任务中重新抛出。`whenResourceAccess(kind, id, action, callback)` 对已声明种类的某个资源做同样的事;
|
||||
- 标准 Hook `useRemoteAccess(action)`,回答 `global` 资源上的访问。
|
||||
|
||||
领域 UI 包在 `source()` 之上发布各自资源的 Hook:[ui-session](../../client/ui-session/README.zh.md) 提供 Session 资源上的 `useSessionAccess(sessionId, action)` 与会话作用域的 `useAccess(action)`(与 `global` 资源上的 `useRemoteAccess` 不同名),[ui-workspace](../../client/ui-workspace/README.zh.md) 提供 Workspace 资源上的 `useWorkspaceAccess(workspaceId, action)`。Hook 的 `action` 类型为 `RemoteEndpoint`,即某个 Client Remote namespace 声明的端点。非 owner 的答案是该资源的 grant 是否列出该 action,不回退 `global` 答案,因此没有 grant 或 grant 未列出任何 action 的资源回答 false。owner 的所有答案为 true;没有注入 baseline 的非 owner 页面、快照流终止失败的非 owner 页面,所有答案为 false;终止失败会连同错误一并报告,非 owner 附“刷新页面重连”的提示,owner 附“答案不受影响”的说明;重连期间保留上次快照。注入的 baseline 格式错误时挂载失败。Hook 从不发请求,Host 仍检查每次调用。[`dsh-client-test-runtime`](../../test-support/client-runtime/README.zh.md) 在 Client spec 中以全允许的访问替换本行。
|
||||
领域 UI 包在 `source()` 之上发布各自资源的 Hook,在 `whenResourceAccess()` 之上发布各自的 `whenAccess`:[ui-session](../../client/ui-session/README.zh.md) 提供 Session 资源上的 `useSessionAccess(sessionId, action)`、会话作用域的 `useAccess(action)` 与 `ctx.uiSession.whenAccess(sessionId, action, callback)`(与 `global` 资源上的 `useRemoteAccess` 不同名),[ui-workspace](../../client/ui-workspace/README.zh.md) 提供 Workspace 资源上的 `useWorkspaceAccess(workspaceId, action)` 与 `ctx.uiWorkspace.whenAccess(workspaceId, action, callback)`。只有拥有某个资源种类的包才调用 `source()` 与 `whenResourceAccess()`;其他插件经访问 Hook 与各 `whenAccess` 读取访问。Hook 的 `action` 类型为 `RemoteEndpoint`,即某个 Client Remote namespace 声明的端点。非 owner 的答案是该资源的 grant 是否列出该 action,不回退 `global` 答案,因此没有 grant 或 grant 未列出任何 action 的资源回答 false。owner 的所有答案为 true;没有注入 baseline 的非 owner 页面、快照流终止失败的非 owner 页面,所有答案为 false;终止失败会连同错误一并报告,非 owner 附“刷新页面重连”的提示,owner 附“答案不受影响”的说明;重连期间保留上次快照。注入的 baseline 格式错误时挂载失败。Hook 从不发请求,Host 仍检查每次调用。[`dsh-client-test-runtime`](../../test-support/client-runtime/README.zh.md) 在 Client spec 中以固定答案(默认全允许)的访问替换本行。
|
||||
|
||||
<a id="model-experience"></a>
|
||||
## 模型体验
|
||||
|
||||
@@ -2,8 +2,11 @@
|
||||
* Client face: starts from the access snapshot the index injected, follows
|
||||
* the calling Peer's snapshot stream, provides the `remoteAccess` Client
|
||||
* service whose `source(kind, id, action)` answers for any declared-kind
|
||||
* resource, and publishes `useRemoteAccess(action)` on the global resource.
|
||||
* Domain UI adapters publish their own resource Hooks over `source()`. Every
|
||||
* resource and whose `whenAccess(action, callback)` keeps effects installed
|
||||
* while the global resource allows an action, and publishes
|
||||
* `useRemoteAccess(action)` on the global resource. Domain UI adapters
|
||||
* publish their own resource Hooks over `source()` and their own
|
||||
* `whenAccess` over `whenResourceAccess()`. Every
|
||||
* answer is a boolean: true for the owner, false without a snapshot or after
|
||||
* a non-owner stream fails; the Host enforces every call regardless.
|
||||
* @module @deepseek-ai/dsh-api-remote-access/client
|
||||
@@ -18,7 +21,7 @@ import { parseInjectedBaseline } from './state.ts'
|
||||
export type {
|
||||
RemoteAccessAction, RemoteAccessEndpoint, RemoteAccessFrame, RemoteAccessGrant, RemoteAccessResource, TypertResourceKind,
|
||||
} from '../types.ts'
|
||||
export type { ClientRemoteAccess, RemoteEndpoint } from './mount.ts'
|
||||
export type { AccessEffect, ClientRemoteAccess, RemoteEndpoint } from './mount.ts'
|
||||
|
||||
/** Required Client services: the Remote mount face and the Slot registry. */
|
||||
export const inject = ['remote', 'slots']
|
||||
|
||||
@@ -19,6 +19,9 @@ import type {} from '@deepseek-ai/dsh-client-ui-renderer/client'
|
||||
import type { TypertRemoteContribution, TypertRemoteMap, TypertResourceKind } from '@deepseek-ai/dsh-typert-protocol'
|
||||
import type { RemoteAccessFrame } from '../types.ts'
|
||||
import { ClientAccessState, type Baseline } from './state.ts'
|
||||
import { whenAllowed, type AccessEffect } from './when-allowed.ts'
|
||||
|
||||
export type { AccessEffect } from './when-allowed.ts'
|
||||
|
||||
/** Every endpoint the Client Remote namespaces declare. */
|
||||
export type RemoteEndpoint = Extract<keyof TypertRemoteMap, string>
|
||||
@@ -29,12 +32,38 @@ export interface ClientRemoteAccess {
|
||||
* Whether the current Peer may call `action` on one resource.
|
||||
* True for the owner; false without a baseline and after a non-owner
|
||||
* stream fails; otherwise whether the resource's grant lists the action.
|
||||
* Only the package that owns a resource kind calls it, to build that kind's
|
||||
* access Hook; other plugins read access through the access Hooks or
|
||||
* `whenAccess`.
|
||||
* @param kind - resource kind declared in `TypertResourceMap`.
|
||||
* @param id - resource identity.
|
||||
* @param action - endpoint action the caller would invoke.
|
||||
* @returns identity-stable boolean source for this `(kind, id, action)`.
|
||||
*/
|
||||
source(kind: TypertResourceKind, id: string, action: string): HostObservable<boolean>
|
||||
/**
|
||||
* Keep `callback`'s effects installed while the current Peer may call
|
||||
* `action` on the global resource: run it when the answer becomes true,
|
||||
* dispose its effects in reverse order when it becomes false, and run it
|
||||
* again on the next true. The callback runs synchronously when the answer
|
||||
* is already true, as it always is for the owner. Effect forms and fiber
|
||||
* ownership match `ctx.slots.inject`.
|
||||
* @param action - endpoint action the guarded effects depend on.
|
||||
* @param callback - creates one disposer or an iterable of disposers.
|
||||
* @returns idempotent disposer for the observation and any installed effects.
|
||||
*/
|
||||
whenAccess(action: RemoteEndpoint, callback: () => AccessEffect): () => void
|
||||
/**
|
||||
* {@link whenAccess} for one resource of a declared kind. Only the package
|
||||
* that owns a resource kind calls it, to build that kind's `whenAccess`, as
|
||||
* it builds the kind's access Hook over {@link source}.
|
||||
* @param kind - resource kind declared in `TypertResourceMap`.
|
||||
* @param id - resource identity.
|
||||
* @param action - endpoint action the guarded effects depend on.
|
||||
* @param callback - creates one disposer or an iterable of disposers.
|
||||
* @returns idempotent disposer for the observation and any installed effects.
|
||||
*/
|
||||
whenResourceAccess(kind: TypertResourceKind, id: string, action: RemoteEndpoint, callback: () => AccessEffect): () => void
|
||||
}
|
||||
|
||||
declare module '@deepseek-ai/cordis' {
|
||||
@@ -66,6 +95,16 @@ class ClientRemoteAccessService extends Service implements ClientRemoteAccess {
|
||||
source(kind: TypertResourceKind, id: string, action: string): HostObservable<boolean> {
|
||||
return this.state.resource(kind, id, action)
|
||||
}
|
||||
|
||||
// Prototype methods: the Cordis service proxy binds `this.ctx` to the caller's Context.
|
||||
whenAccess(action: RemoteEndpoint, callback: () => AccessEffect): () => void {
|
||||
return whenAllowed(this.ctx, this.state.global(action), callback, `remoteAccess.whenAccess(${JSON.stringify(action)})`)
|
||||
}
|
||||
|
||||
whenResourceAccess(kind: TypertResourceKind, id: string, action: RemoteEndpoint, callback: () => AccessEffect): () => void {
|
||||
return whenAllowed(this.ctx, this.state.resource(kind, id, action), callback,
|
||||
`remoteAccess.whenResourceAccess(${JSON.stringify(kind)}, ${JSON.stringify(id)}, ${JSON.stringify(action)})`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
/**
|
||||
* The effect controller behind `whenAccess`: install a callback's effects
|
||||
* while one access answer is true and dispose them while it is false.
|
||||
* @module @deepseek-ai/dsh-api-remote-access/src/client/when-allowed
|
||||
*/
|
||||
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
import type { HostObservable } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
|
||||
/** One synchronous effect installed while access holds: a disposer, or disposers installed as one transaction. */
|
||||
export type AccessEffect = (() => void) | Iterable<() => void, void, void>
|
||||
|
||||
/**
|
||||
* Install `callback`'s effects while `answer` is true. The callback runs
|
||||
* synchronously when the answer is already true; a later true runs it again
|
||||
* after a false disposed the previous installation in reverse order. The
|
||||
* controller belongs to `ctx`'s fiber.
|
||||
* @param ctx - the caller's Context; it owns the controller and every installation.
|
||||
* @param answer - the access answer to follow.
|
||||
* @param callback - creates one disposer or an iterable of disposers.
|
||||
* @param label - effect label for diagnostics.
|
||||
* @returns idempotent disposer for the controller and any installed effects.
|
||||
* @throws callback setup failures synchronously when the answer is already true.
|
||||
*/
|
||||
export function whenAllowed(
|
||||
ctx: Context,
|
||||
answer: HostObservable<boolean>,
|
||||
callback: () => AccessEffect,
|
||||
label: string,
|
||||
): () => void {
|
||||
const disposeController = ctx.effect(() => {
|
||||
let installed: (() => void) | undefined
|
||||
let stopped = false
|
||||
|
||||
const uninstall = (): void => {
|
||||
const dispose = installed
|
||||
installed = undefined
|
||||
dispose?.()
|
||||
}
|
||||
|
||||
const reconcile = (): void => {
|
||||
if (stopped) return
|
||||
const allowed = answer.getSnapshot()
|
||||
if (allowed === (installed !== undefined)) return
|
||||
if (!allowed) {
|
||||
uninstall()
|
||||
return
|
||||
}
|
||||
// Each installation is a nested Cordis effect, so an iterable callback
|
||||
// installs transactionally and disposes in reverse order.
|
||||
const disposeEffect = ctx.effect(callback, `${label}: allowed`)
|
||||
installed = () => { void disposeEffect() }
|
||||
}
|
||||
|
||||
const unsubscribe = answer.subscribe(() => { changed() })
|
||||
|
||||
const stop = (): void => {
|
||||
if (stopped) return
|
||||
// A failed installation retires the controller: a later grant never retries it.
|
||||
stopped = true
|
||||
unsubscribe()
|
||||
uninstall()
|
||||
}
|
||||
|
||||
const changed = (): void => {
|
||||
try {
|
||||
reconcile()
|
||||
} catch (error) {
|
||||
stop()
|
||||
if ((error as { code?: unknown } | null)?.code === 'INACTIVE_EFFECT') return
|
||||
const failure = error instanceof Error ? error : new Error(String(error))
|
||||
queueMicrotask(() => { throw failure })
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
reconcile()
|
||||
} catch (error) {
|
||||
stop()
|
||||
throw error
|
||||
}
|
||||
return stop
|
||||
}, label)
|
||||
return () => { void disposeController() }
|
||||
}
|
||||
@@ -134,10 +134,10 @@ export class RemoteAccessService extends TypertRemoteService {
|
||||
constructor(ctx: Context) {
|
||||
super(ctx, 'remoteAccess')
|
||||
ctx.effect(() => () => { this.lifetime.abort() }, 'remoteAccess: snapshot generations')
|
||||
// Every index the webServer renders belongs to the owner; snapshotFor documents non-owner pages.
|
||||
// Every index opens with the access snapshot of the Peer it speaks for.
|
||||
ctx.inject(['webServer'], (webCtx) => {
|
||||
webCtx.on('webserver/index-inject', (table) => {
|
||||
table.push({ kind: 'global', name: REMOTE_ACCESS_SNAPSHOT_KEY, value: OWNER_BASELINE })
|
||||
webCtx.on('webserver/index-inject', (table, peer) => {
|
||||
table.push({ kind: 'global', name: REMOTE_ACCESS_SNAPSHOT_KEY, value: this.snapshotFor(peer.principal) })
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -244,10 +244,9 @@ export class RemoteAccessService extends TypertRemoteService {
|
||||
}
|
||||
|
||||
/**
|
||||
* The baseline a page opens with before its snapshot stream connects. A
|
||||
* business package rendering a non-owner page assigns this value to
|
||||
* {@link REMOTE_ACCESS_SNAPSHOT_KEY} after every index injection row,
|
||||
* replacing the owner baseline the webServer index carries.
|
||||
* The baseline a page opens with before its snapshot stream connects; every
|
||||
* index the webServer renders carries it under
|
||||
* {@link REMOTE_ACCESS_SNAPSHOT_KEY} for the Peer the page speaks for.
|
||||
* @param principal - the page's caller identity.
|
||||
* @returns the owner baseline for the owner; otherwise the principal's current grants.
|
||||
*/
|
||||
|
||||
@@ -5,6 +5,7 @@ import type { TypertRemoteContribution, TypertResource } from '@deepseek-ai/dsh-
|
||||
import type { RemoteAccessFrame, RemoteAccessGrant } from '../src/types.ts'
|
||||
import { ClientAccessState, parseInjectedBaseline, type Baseline } from '../src/client/state.ts'
|
||||
import { mountAccess } from '../src/client/mount.ts'
|
||||
import { whenAllowed } from '../src/client/when-allowed.ts'
|
||||
|
||||
declare module '@deepseek-ai/dsh-typert-protocol' {
|
||||
interface TypertResourceMap {
|
||||
@@ -12,6 +13,10 @@ declare module '@deepseek-ai/dsh-typert-protocol' {
|
||||
'test-shelf': TypertResource<string>
|
||||
'test-desk': TypertResource<string>
|
||||
}
|
||||
interface TypertRemoteMap {
|
||||
/** Test-only endpoint the `whenAccess` specs guard effects on. */
|
||||
'test/guarded': () => Promise<never>
|
||||
}
|
||||
}
|
||||
|
||||
const GRANTED: Extract<RemoteAccessFrame, { type: 'baseline' }> = {
|
||||
@@ -289,3 +294,223 @@ describe('remote-access Client mount', () => {
|
||||
await access.dispose()
|
||||
})
|
||||
})
|
||||
|
||||
/** A baseline whose grants allow `test/guarded` on the listed resources. */
|
||||
function followOn(...resources: RemoteAccessGrant['resource'][]): Extract<RemoteAccessFrame, { type: 'baseline' }> {
|
||||
return { type: 'baseline', owner: false, grants: resources.map(resource => ({ resource, actions: ['test/guarded'] })) }
|
||||
}
|
||||
|
||||
describe('remote-access whenAccess', () => {
|
||||
it('installs the effects while the global resource allows the action, disposes them in reverse order on loss, and reinstalls on the next grant', async () => {
|
||||
const access = await mounted()
|
||||
const log: string[] = []
|
||||
const guarded = access.ctx.plugin({
|
||||
inject: ['remoteAccess'],
|
||||
apply: (ctx: Context) => {
|
||||
ctx.remoteAccess.whenAccess('test/guarded', function* () {
|
||||
log.push('install')
|
||||
yield () => { log.push('dispose first') }
|
||||
yield () => { log.push('dispose second') }
|
||||
})
|
||||
},
|
||||
})
|
||||
await guarded.await()
|
||||
expect(log).toEqual([])
|
||||
|
||||
access.stream().push(followOn({ kind: 'global' }))
|
||||
await vi.waitFor(() => { expect(log).toEqual(['install']) })
|
||||
// A frame that leaves the answer unchanged installs nothing again.
|
||||
access.stream().push({ type: 'upsert', grant: { resource: { kind: 'test-shelf', id: 's1' }, actions: ['session/fork'] } })
|
||||
access.stream().push({ type: 'upsert', grant: { resource: { kind: 'global' }, actions: ['session/list'] } })
|
||||
await vi.waitFor(() => { expect(log).toEqual(['install', 'dispose second', 'dispose first']) })
|
||||
access.stream().push({ type: 'upsert', grant: { resource: { kind: 'global' }, actions: ['test/guarded'] } })
|
||||
await vi.waitFor(() => { expect(log).toHaveLength(4) })
|
||||
expect(log[3]).toBe('install')
|
||||
|
||||
await guarded.dispose()
|
||||
expect(log.slice(4)).toEqual(['dispose second', 'dispose first'])
|
||||
await access.dispose()
|
||||
})
|
||||
|
||||
it('runs the callback at once for the owner and disposes it with the returned disposer', async () => {
|
||||
const access = await mounted({ type: 'baseline', owner: true })
|
||||
const dispose = vi.fn()
|
||||
const callback = vi.fn(() => dispose)
|
||||
const stop = access.ctx.remoteAccess.whenAccess('test/guarded', callback)
|
||||
expect(callback).toHaveBeenCalledOnce()
|
||||
stop()
|
||||
await vi.waitFor(() => { expect(dispose).toHaveBeenCalledOnce() })
|
||||
await access.dispose()
|
||||
})
|
||||
|
||||
it('follows one resource of a declared kind through whenResourceAccess', async () => {
|
||||
const access = await mounted(followOn({ kind: 'test-shelf', id: 's1' }))
|
||||
const dispose = vi.fn()
|
||||
const shelf = vi.fn(() => dispose)
|
||||
const desk = vi.fn(() => () => {})
|
||||
access.ctx.remoteAccess.whenResourceAccess('test-shelf', 's1', 'test/guarded', shelf)
|
||||
access.ctx.remoteAccess.whenResourceAccess('test-desk', 's1', 'test/guarded', desk)
|
||||
expect(shelf).toHaveBeenCalledOnce()
|
||||
expect(desk).not.toHaveBeenCalled()
|
||||
access.stream().push({ type: 'remove', resource: { kind: 'test-shelf', id: 's1' } })
|
||||
await vi.waitFor(() => { expect(dispose).toHaveBeenCalledOnce() })
|
||||
await access.dispose()
|
||||
})
|
||||
|
||||
it('disposes a non-owner\'s effects once its stream fails terminally', async () => {
|
||||
const access = await mounted(followOn({ kind: 'global' }))
|
||||
vi.spyOn(access.ctx.logger, 'error').mockImplementation(() => undefined)
|
||||
const dispose = vi.fn()
|
||||
access.ctx.remoteAccess.whenAccess('test/guarded', () => dispose)
|
||||
access.stream().fail(new Error('carrier closed for good'))
|
||||
await vi.waitFor(() => { expect(dispose).toHaveBeenCalledOnce() })
|
||||
await access.dispose()
|
||||
})
|
||||
|
||||
it('throws a setup failure at once while allowed, and reports a later one without retrying it', async () => {
|
||||
const owner = await mounted({ type: 'baseline', owner: true })
|
||||
expect(() => owner.ctx.remoteAccess.whenAccess('test/guarded', () => { throw new Error('setup failed') }))
|
||||
.toThrow('setup failed')
|
||||
await owner.dispose()
|
||||
|
||||
const access = await mounted()
|
||||
const failures: unknown[] = []
|
||||
const onLoud = (error: unknown): void => { failures.push(error) }
|
||||
process.on('uncaughtException', onLoud)
|
||||
try {
|
||||
const failing = vi.fn((): () => void => { throw new Error('late failure') })
|
||||
const throwsValue = vi.fn(function* (): Generator<() => void> {
|
||||
yield () => {}
|
||||
throw new Error('late value')
|
||||
})
|
||||
const later = vi.fn(() => () => {})
|
||||
access.ctx.remoteAccess.whenAccess('test/guarded', failing)
|
||||
access.ctx.remoteAccess.whenAccess('test/guarded', throwsValue)
|
||||
access.ctx.remoteAccess.whenAccess('test/guarded', later)
|
||||
access.stream().push(followOn({ kind: 'global' }))
|
||||
await vi.waitFor(() => { expect(failures).toHaveLength(2) })
|
||||
expect(failures.map(String)).toEqual(['Error: late failure', 'Error: late value'])
|
||||
expect(later).toHaveBeenCalledOnce()
|
||||
access.stream().push({ type: 'remove', resource: { kind: 'global' } })
|
||||
access.stream().push({ type: 'upsert', grant: { resource: { kind: 'global' }, actions: ['test/guarded'] } })
|
||||
await vi.waitFor(() => { expect(later).toHaveBeenCalledTimes(2) })
|
||||
expect(failing).toHaveBeenCalledOnce()
|
||||
expect(throwsValue).toHaveBeenCalledOnce()
|
||||
} finally {
|
||||
process.off('uncaughtException', onLoud)
|
||||
}
|
||||
await access.dispose()
|
||||
})
|
||||
|
||||
it('skips a controller another installation stopped during the same notification', async () => {
|
||||
const access = await mounted()
|
||||
let stopLater = (): void => {}
|
||||
const first = vi.fn(() => {
|
||||
stopLater()
|
||||
return () => {}
|
||||
})
|
||||
const later = vi.fn(() => () => {})
|
||||
access.ctx.remoteAccess.whenAccess('test/guarded', first)
|
||||
stopLater = access.ctx.remoteAccess.whenAccess('test/guarded', later)
|
||||
access.stream().push(followOn({ kind: 'global' }))
|
||||
await vi.waitFor(() => { expect(first).toHaveBeenCalledOnce() })
|
||||
expect(later).not.toHaveBeenCalled()
|
||||
await access.dispose()
|
||||
})
|
||||
|
||||
it('installs nothing for a grant that arrives while its caller is unloading', async () => {
|
||||
const access = await mounted()
|
||||
const callback = vi.fn(() => () => {})
|
||||
let release = (): void => {}
|
||||
const blocked = new Promise<void>((resolve) => { release = resolve })
|
||||
const pauseUnload = vi.fn(async () => { await blocked })
|
||||
const caller = access.ctx.plugin({
|
||||
inject: ['remoteAccess'],
|
||||
apply: (ctx: Context) => {
|
||||
ctx.remoteAccess.whenAccess('test/guarded', callback)
|
||||
ctx.effect(() => pauseUnload, 'pause caller unload')
|
||||
},
|
||||
})
|
||||
await caller.await()
|
||||
const disposing = caller.dispose()
|
||||
await vi.waitFor(() => { expect(pauseUnload).toHaveBeenCalledOnce() })
|
||||
access.stream().push(followOn({ kind: 'global' }))
|
||||
await vi.waitFor(() => { expect(value(access.global('test/guarded'))).toBe(true) })
|
||||
expect(callback).not.toHaveBeenCalled()
|
||||
release()
|
||||
await disposing
|
||||
await access.dispose()
|
||||
})
|
||||
})
|
||||
|
||||
/** A hand-driven access answer that notifies synchronously. */
|
||||
function drivenAnswer(): { readonly answer: HostObservable<boolean>; set(allowed: boolean): void } {
|
||||
let allowed = false
|
||||
const listeners = new Set<() => void>()
|
||||
return {
|
||||
answer: {
|
||||
getSnapshot: () => allowed,
|
||||
subscribe: (listener) => {
|
||||
listeners.add(listener)
|
||||
return () => { listeners.delete(listener) }
|
||||
},
|
||||
},
|
||||
set(next) {
|
||||
allowed = next
|
||||
for (const listener of [...listeners]) listener()
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
describe('whenAllowed', () => {
|
||||
it('installs nothing when the answer turns true while its caller is unloading', async () => {
|
||||
const root = new Context()
|
||||
const driven = drivenAnswer()
|
||||
const callback = vi.fn(() => () => {})
|
||||
const caller = root.plugin({
|
||||
apply: (ctx: Context) => {
|
||||
whenAllowed(ctx, driven.answer, callback, 'test')
|
||||
// Disposed before the controller: it turns the answer true mid-unload.
|
||||
ctx.effect(() => () => { driven.set(true) }, 'grant during unload')
|
||||
},
|
||||
})
|
||||
await caller.await()
|
||||
await caller.dispose()
|
||||
expect(callback).not.toHaveBeenCalled()
|
||||
await root.fiber.dispose()
|
||||
})
|
||||
|
||||
it('ignores its disposer after a later failure retired it, and reports a non-Error failure as an Error', async () => {
|
||||
const root = new Context()
|
||||
const driven = drivenAnswer()
|
||||
const failures: unknown[] = []
|
||||
const onLoud = (error: unknown): void => { failures.push(error) }
|
||||
process.on('uncaughtException', onLoud)
|
||||
try {
|
||||
const stop = whenAllowed(root, driven.answer, () => { throw new Error('late failure') }, 'test')
|
||||
driven.set(true)
|
||||
await vi.waitFor(() => { expect(failures).toHaveLength(1) })
|
||||
expect(() => { stop() }).not.toThrow()
|
||||
// A faulty answer that throws a non-Error value is reported as an Error naming it.
|
||||
let reads = 0
|
||||
const listeners: (() => void)[] = []
|
||||
whenAllowed(root, {
|
||||
getSnapshot: () => {
|
||||
reads++
|
||||
if (reads > 1) throw 'unreadable answer'
|
||||
return false
|
||||
},
|
||||
subscribe: (listener) => {
|
||||
listeners.push(listener)
|
||||
return () => {}
|
||||
},
|
||||
}, () => () => {}, 'test')
|
||||
for (const listener of listeners) listener()
|
||||
await vi.waitFor(() => { expect(failures).toHaveLength(2) })
|
||||
expect(String(failures[1])).toBe('Error: unreadable answer')
|
||||
} finally {
|
||||
process.off('uncaughtException', onLoud)
|
||||
}
|
||||
await root.fiber.dispose()
|
||||
})
|
||||
})
|
||||
@@ -400,18 +400,27 @@ describe('first snapshots', () => {
|
||||
expect(REMOTE_ACCESS_SNAPSHOT_KEY).toBe('__DSH_REMOTE_ACCESS__')
|
||||
})
|
||||
|
||||
it('injects the owner baseline into every index the webServer renders', async () => {
|
||||
const { ctx } = await setup()
|
||||
const render = (): IndexInjection[] => {
|
||||
it('injects the access snapshot of the Peer each index speaks for', async () => {
|
||||
const { ctx, access } = await setup()
|
||||
const policy = new GuestPolicy()
|
||||
policy.hold(GUEST, { role: READER, resource: GLOBAL })
|
||||
access.registerPolicy('test-guest', policy)
|
||||
const render = (principal: Principal): IndexInjection[] => {
|
||||
const table: IndexInjection[] = []
|
||||
ctx.emit('webserver/index-inject', table)
|
||||
ctx.emit('webserver/index-inject', table, new Peer(ctx, { principal }))
|
||||
return table
|
||||
}
|
||||
expect(render()).toEqual([])
|
||||
expect(render(OWNER)).toEqual([])
|
||||
ctx.provide('webServer', {} as Context['webServer'])
|
||||
await vi.waitFor(() => {
|
||||
expect(render()).toEqual([{ kind: 'global', name: '__DSH_REMOTE_ACCESS__', value: { type: 'baseline', owner: true } }])
|
||||
expect(render(OWNER)).toEqual([{ kind: 'global', name: '__DSH_REMOTE_ACCESS__', value: { type: 'baseline', owner: true } }])
|
||||
})
|
||||
expect(render(GUEST)).toEqual([{ kind: 'global', name: '__DSH_REMOTE_ACCESS__', value: {
|
||||
type: 'baseline', owner: false, grants: [{ resource: GLOBAL, actions: ['notes/read', 'notes/rooms', 'notes/write'] }],
|
||||
} }])
|
||||
// A grant change reaches the next render.
|
||||
policy.hold(GUEST)
|
||||
expect(render(GUEST)).toEqual([{ kind: 'global', name: '__DSH_REMOTE_ACCESS__', value: { type: 'baseline', owner: false, grants: [] } }])
|
||||
})
|
||||
|
||||
it('lists per bound resource the endpoint actions decide() allows there without arguments, and no grant where none is allowed', async () => {
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
"src/client/index.ts",
|
||||
"src/client/mount.ts",
|
||||
"src/client/state.ts",
|
||||
"src/client/when-allowed.ts",
|
||||
"src/snapshot.ts",
|
||||
"src/types.ts"
|
||||
],
|
||||
|
||||
@@ -10,7 +10,7 @@ import { loadOverlayPatches } from '@deepseek-ai/dsh-app-boot'
|
||||
import WorkspaceFiles, { type WorkspaceFileScope } from '@deepseek-ai/dsh-api-workspace-files'
|
||||
import OfficeToPdf from '@deepseek-ai/dsh-office-to-pdf'
|
||||
import * as DocumentPreview from '@deepseek-ai/dsh-client-ui-sidebar-documentpreview'
|
||||
import type { IndexInjection } from '@deepseek-ai/dsh-host-webserver'
|
||||
import type { IndexInjection, WebServer } from '@deepseek-ai/dsh-host-webserver'
|
||||
import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
|
||||
import SandboxPolicyService from '@deepseek-ai/dsh-sandbox-policy'
|
||||
@@ -20,6 +20,9 @@ import TypertRegistry from '@deepseek-ai/dsh-typert-registry'
|
||||
import type { Converter, ConverterOptions } from '@deepseek-ai/libreoffice-kit'
|
||||
import { expect, it, onTestFinished, vi } from 'vitest'
|
||||
|
||||
/** The Peer an index render speaks for; this listener reads no field of it. */
|
||||
const PAGE_PEER = { principal: { kind: 'owner' } } as Parameters<WebServer['renderIndex']>[1]
|
||||
|
||||
const kit = vi.hoisted(() => ({ create: vi.fn<(options?: ConverterOptions) => Promise<Converter>>() }))
|
||||
vi.mock('@deepseek-ai/libreoffice-kit', () => ({ createConverter: kit.create }))
|
||||
|
||||
@@ -92,7 +95,7 @@ it('loads the shipped Office rows with separately patched settings and authorize
|
||||
const entries = new Map([...ctx.loader.entries()].map(entry => [entry.options.id, entry]))
|
||||
for (const row of configured) await entries.get(row.id)!.fiber!.await()
|
||||
const injections: IndexInjection[] = []
|
||||
ctx.emit('webserver/index-inject', injections)
|
||||
ctx.emit('webserver/index-inject', injections, PAGE_PEER)
|
||||
expect(injections).toEqual([
|
||||
{ kind: 'global', name: '__DSH_DOCUMENT_PREVIEW_CONFIG__', value: clientConfig },
|
||||
])
|
||||
|
||||
@@ -22,6 +22,9 @@ import {
|
||||
import { DEFAULT_MAX_REQUEST_BODY_BYTES } from '../src/http-bridge.ts'
|
||||
import { provideBrowserCredentials } from './browser-credentials.ts'
|
||||
|
||||
/** The Peer an index render speaks for; this listener reads no field of it. */
|
||||
const PAGE_PEER = { principal: { kind: 'owner' } } as Parameters<WebServer['renderIndex']>[1]
|
||||
|
||||
/** Structural webServer fake recording both route registries. */
|
||||
function fakeHttpServer(
|
||||
routes: WebRoute[],
|
||||
@@ -272,7 +275,7 @@ describe('connection node half', () => {
|
||||
const { ctx, dispose } = await mounted({ recovery: { generationReadyTimeoutMs: 25_000 } })
|
||||
try {
|
||||
const rows: IndexInjection[] = []
|
||||
ctx.emit('webserver/index-inject', rows)
|
||||
ctx.emit('webserver/index-inject', rows, PAGE_PEER)
|
||||
expect(rows).toEqual([{
|
||||
kind: 'global', name: '__DSH_CONNECTION_RECOVERY__', value: {
|
||||
backoffBaseMs: 500, backoffFactor: 2, backoffMaxMs: 10_000,
|
||||
@@ -281,7 +284,7 @@ describe('connection node half', () => {
|
||||
}])
|
||||
await dispose()
|
||||
const after: IndexInjection[] = []
|
||||
ctx.emit('webserver/index-inject', after)
|
||||
ctx.emit('webserver/index-inject', after, PAGE_PEER)
|
||||
expect(after).toEqual([])
|
||||
} finally {
|
||||
await dispose()
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import type { IndexInjection } from '@deepseek-ai/dsh-host-webserver'
|
||||
import type { IndexInjection, WebServer } from '@deepseek-ai/dsh-host-webserver'
|
||||
import { expect, it } from 'vitest'
|
||||
import { Config, apply } from '../src/index.ts'
|
||||
|
||||
/** The Peer an index render speaks for; this listener reads no field of it. */
|
||||
const PAGE_PEER = { principal: { kind: 'owner' } } as Parameters<WebServer['renderIndex']>[1]
|
||||
|
||||
it('validates positive sequence timing and publishes it through the product page injection', async () => {
|
||||
expect(Config({}).stopSequenceMs).toBe(500)
|
||||
expect(Config({ stopSequenceMs: 2_147_483_646 }).stopSequenceMs).toBe(2_147_483_646)
|
||||
@@ -11,7 +14,7 @@ it('validates positive sequence timing and publishes it through the product page
|
||||
try {
|
||||
apply(ctx, Config({ stopSequenceMs: 800 }))
|
||||
const table: IndexInjection[] = []
|
||||
await ctx.parallel('webserver/index-inject', table)
|
||||
await ctx.parallel('webserver/index-inject', table, PAGE_PEER)
|
||||
expect(table).toEqual([{ kind: 'global', name: '__DSH_SHORTCUTS_CONFIG__', value: { stopSequenceMs: 800 } }])
|
||||
} finally { await ctx.fiber.dispose() }
|
||||
})
|
||||
@@ -18,8 +18,8 @@
|
||||
en: 1e2172c756246031
|
||||
zh: f33dc7b40b14cceb
|
||||
/deepseek-ai-dsh-client-ui-directory-picker-native/use-this-package/when-to-choose-it:
|
||||
en: bcba761168d2162c
|
||||
zh: d484e2305e074c0b
|
||||
en: f30bb7c6aae98fec
|
||||
zh: 1d28613c86531119
|
||||
/deepseek-ai-dsh-client-ui-directory-picker-native/understand-the-implementation:
|
||||
en: f7a287740b4b08f9
|
||||
zh: 3347375c4e486b22
|
||||
|
||||
@@ -33,6 +33,8 @@ In the local Electron application, this flow uses the narrow preload directory-p
|
||||
|
||||
Choose this surface when the browser runs on the same machine as the Host, so an OS dialog can open there. Choose the [`-browse`](../ui-directory-picker-browse/README.md) surface when the browser is remote or in-process and no local chooser exists. The two surfaces fill the same slots, so switching is a composition change, not a code change.
|
||||
|
||||
Access: both directory flows are registered only while the current Peer may call `directoryPicker/pick`. The owner always has access.
|
||||
|
||||
-----
|
||||
|
||||
<a id="understand-the-implementation"></a>
|
||||
|
||||
@@ -33,6 +33,8 @@ kind: "package-reference"
|
||||
|
||||
当浏览器与 Host 运行在同一台机器上、操作系统对话框可以在那里打开时,选择此表面。当浏览器为远程或进程内、没有本地选择器时,选择 [`-browse`](../ui-directory-picker-browse/README.zh.md) 表面。两个表面填充相同的 slot,因此切换只是组合改动,而非代码改动。
|
||||
|
||||
访问控制:两个目录选择流程只在当前 Peer 可以调用 `directoryPicker/pick` 时注册。owner 始终有权限。
|
||||
|
||||
-----
|
||||
|
||||
<a id="understand-the-implementation"></a>
|
||||
|
||||
@@ -44,6 +44,7 @@
|
||||
"@deepseek-ai/cordis": "workspace:~"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-api-remote-access": "workspace:*",
|
||||
"@deepseek-ai/dsh-client-test-runtime": "workspace:*",
|
||||
"@deepseek-ai/dsh-client-ui-renderer": "workspace:*",
|
||||
"@deepseek-ai/dsh-client-ui-workspace": "workspace:*",
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
/** Native directory flow using the local desktop bridge or the Host's OS chooser. */
|
||||
import type { Context as ClientContext } from '@deepseek-ai/cordis'
|
||||
// Type-only: pulls the remoteAccess service merge (ctx.remoteAccess).
|
||||
import type {} from '@deepseek-ai/dsh-api-remote-access/client'
|
||||
// Type-only: pulls the SlotMap merge declaring the directory-flow holes.
|
||||
import type {} from '@deepseek-ai/dsh-client-ui-workspace/client'
|
||||
// Type-only: pulls the SlotRegistry service merge (ctx.slots).
|
||||
@@ -8,13 +10,14 @@ import type { NativeFlowInjected } from './flow.ts'
|
||||
import { NativeDirectoryFlow } from './flow.ts'
|
||||
|
||||
|
||||
/** Required services (cordis fiber inject): the slot registry and workspace UI service. */
|
||||
export const inject = ['slots', 'uiWorkspace']
|
||||
/** Required services (cordis fiber inject): the slot registry, workspace UI service, and access. */
|
||||
export const inject = ['slots', 'uiWorkspace', 'remoteAccess']
|
||||
|
||||
/**
|
||||
* Client plugin body: register the renderless native flow into both
|
||||
* directory-flow holes through `slots.inject()` because the ui-workspace
|
||||
* entries may activate later or replace their declarations.
|
||||
* Client plugin body: while the current Peer may open the Host's directory
|
||||
* chooser, register the renderless native flow into both directory-flow holes
|
||||
* through `slots.inject()` because the ui-workspace entries may activate later
|
||||
* or replace their declarations.
|
||||
* @param ctx - client root context.
|
||||
*/
|
||||
export function apply(ctx: ClientContext): void {
|
||||
@@ -26,13 +29,14 @@ export function apply(ctx: ClientContext): void {
|
||||
// Both declaration lifetimes must be live before the pair installs; the
|
||||
// generator makes the two registrations one transactional effect. The
|
||||
// outer/inner nesting order is arbitrary; neither hole has precedence.
|
||||
ctx.slots.inject('conversation.hero.workspace.directoryFlow', () =>
|
||||
ctx.slots.inject('sidebar.workspaces.directoryFlow', function* () {
|
||||
yield ctx.slots.register({
|
||||
name: 'conversation.hero.workspace.directoryFlow', inject: injected,
|
||||
}, NativeDirectoryFlow)
|
||||
yield ctx.slots.register({
|
||||
name: 'sidebar.workspaces.directoryFlow', inject: injected,
|
||||
}, NativeDirectoryFlow)
|
||||
}))
|
||||
ctx.remoteAccess.whenAccess('directoryPicker/pick', () =>
|
||||
ctx.slots.inject('conversation.hero.workspace.directoryFlow', () =>
|
||||
ctx.slots.inject('sidebar.workspaces.directoryFlow', function* () {
|
||||
yield ctx.slots.register({
|
||||
name: 'conversation.hero.workspace.directoryFlow', inject: injected,
|
||||
}, NativeDirectoryFlow)
|
||||
yield ctx.slots.register({
|
||||
name: 'sidebar.workspaces.directoryFlow', inject: injected,
|
||||
}, NativeDirectoryFlow)
|
||||
})))
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import { describe, expect, it, vi } from 'vitest'
|
||||
import { act, cleanup, render } from '@testing-library/react'
|
||||
import { afterEach } from 'vitest'
|
||||
import { SlotRegistry } from '@deepseek-ai/dsh-client-ui-renderer/client'
|
||||
import { StaticRemoteAccess } from '@deepseek-ai/dsh-client-test-runtime'
|
||||
import type { DirectoryFlowOwnerProps } from '@deepseek-ai/dsh-client-ui-workspace/client'
|
||||
import { apply, inject } from '../src/client/index.ts'
|
||||
import { NativeDirectoryFlow } from '../src/client/flow.ts'
|
||||
@@ -29,9 +30,10 @@ afterEach(() => { cleanup(); vi.unstubAllGlobals(); vi.restoreAllMocks(); deskto
|
||||
|
||||
const HOLES = ['conversation.hero.workspace.directoryFlow', 'sidebar.workspaces.directoryFlow'] as const
|
||||
|
||||
async function bench() {
|
||||
async function bench(allows: (action: string) => boolean = () => true) {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SlotRegistry).await()
|
||||
new StaticRemoteAccess(ctx, allows)
|
||||
const pickDirectory = vi.fn(async (): Promise<string | null> => '/tmp/picked')
|
||||
ctx.provide('uiWorkspace', { pickDirectory } as never)
|
||||
const slots = ctx.get('slots') as SlotRegistry
|
||||
@@ -52,7 +54,19 @@ function owner(overrides: Partial<DirectoryFlowOwnerProps> = {}): DirectoryFlowO
|
||||
|
||||
describe('directory-picker-native client half', () => {
|
||||
it('declares the services it drives', () => {
|
||||
expect(inject).toEqual(['slots', 'uiWorkspace'])
|
||||
expect(inject).toEqual(['slots', 'uiWorkspace', 'remoteAccess'])
|
||||
})
|
||||
|
||||
it('fills no directory flow while the current Peer may not open the Host chooser', async () => {
|
||||
const allows = vi.fn(() => false)
|
||||
const b = await bench(allows)
|
||||
b.declare()
|
||||
const fiber = b.ctx.plugin({ inject: [...inject], apply })
|
||||
await fiber.await()
|
||||
expect(allows).toHaveBeenCalledWith('directoryPicker/pick')
|
||||
for (const hole of HOLES) expect(b.slots.entries(hole)).toEqual([])
|
||||
await fiber.dispose()
|
||||
await b.ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('fills both directory-flow holes for declarations before or after apply, and leaves with its fiber', async () => {
|
||||
|
||||
@@ -15,11 +15,11 @@
|
||||
en: 42038fdc402b9c2a
|
||||
zh: 99890f8f908624ce
|
||||
/deepseek-ai-dsh-client-ui-dockkit/the-two-layers:
|
||||
en: ea872ff2c2089037
|
||||
zh: b3ec3a218eacd5c8
|
||||
en: 99356ea8e3d1e7aa
|
||||
zh: 870f746730d0e6cf
|
||||
/deepseek-ai-dsh-client-ui-dockkit/embedding-it:
|
||||
en: bc9ecb9633fb3208
|
||||
zh: fa141e77c2bf6eae
|
||||
en: c4ba08038bfa6f30
|
||||
zh: 95db30618b52babe
|
||||
/deepseek-ai-dsh-client-ui-dockkit/interaction-rules-worth-keeping:
|
||||
en: 7e5c1e9a9f9f244f
|
||||
zh: 7e3a8fcef86bc7dd
|
||||
|
||||
@@ -39,6 +39,8 @@ A docking layout kit: a split tree of tabbed panes with invertible operations, a
|
||||
|
||||
**The components** render a layout snapshot and report settled intents — one per gesture, never a drag frame. A drag previews in local state while the gesture's own facts stay in its closure; on release the net result leaves through one `DockIntents` call — a strip release reports the caret slot as drawn, the dragged chip counted, and `planPlaceTab` turns that into the reorder or the move. That is what lets an embedder record exactly one history entry per gesture. The strip follows the WAI-ARIA tabs pattern with manual activation: the selected chip is in the tab order; Left and Right (wrapping), Home, and End move focus between chips without selecting; Enter or Space selects the focused chip through the same intent as a click. A chip is a capsule carrying one control, its close; the context menu (a secondary press on the chip) carries the same close plus the embedder's items — a menu that would hold no item at all never shows — and renders in a portal positioned against the chip because the chip box clips its overflow on purpose (see below). After the chips sits the add control, which asks the embedder (`DockIntents.addTab`) to seat its seeded tab; the embedder's `canAddTab(paneId)` decides per pane whether the control is drawn at all. Copying a tab has no kit control — it is the embedder's API — and floating is the drag released clear of the surface.
|
||||
|
||||
Middle-clicking anywhere on a closable tab chip, including its close control, closes it through the same intent as its close button, without selecting it first. The chip owns the middle click and suppresses nested title content’s default action. Closing a background tab preserves the selected tab. Only the primary button starts tab dragging; a middle click while another mouse button is held does not close the tab.
|
||||
|
||||
Tab context menus show the close action name without a shortcut hint: they act on the clicked tab, while shortcuts can target another focused pane. An unmodified Escape dismisses the foreground tab menu without closing the tab and returns focus from a menu item to its tab without drawing a focus outline. Tab and arrow navigation retain the tab’s visible focus indicator. Composition and held-key repeats do not dismiss it.
|
||||
|
||||
<a id="embedding-it"></a>
|
||||
@@ -52,7 +54,7 @@ Everything host-specific arrives through props:
|
||||
| `TabRenderer` | one tab's body (`renderTab`), drawn flush to the pane's edges and the unbordered strip's bottom edge with the insets it chooses, and optionally what its chip or panel header shows as a title (`renderTabTitle`, falling back to the record's `title`); the embedder dispatches on `tab.kind` |
|
||||
| `DockIntents` | the settled results of every gesture |
|
||||
|
||||
`DockController` satisfies `DockIntents` as written, so an embedding can pass it to `DockLayout` for retained Sidebar layouts or `DockSurface` for recursive split trees. An embedder that routes through its own store implements the same method names instead. Three props carry control policy rather than gestures: `canSplit` (surface-wide, the pane budget; disables the split control with `splitPaneDisabled`), `canAddTab(paneId)` (per pane, omits the add control; leave it out to draw one in every pane), and `canCloseTab(tabId)` (per tab, withholds the chip's close control and the menu's close item together; leave it out to keep every tab closable). Hiding the add control moves nothing else in the strip, and a withheld close moves nothing in the chip — the close control paints over the title's end rather than beside it. A pane's lone chip whose close is withheld draws quiet — no capsule, no hover fill — since there is nothing to select against and nothing to do to it. The kit adds one policy of its own, the room rule below, which disables a pane's split control with `splitPaneNarrow`; `onRoom(fits)` reports its readings so an embedder splitting programmatically can honour the same rule. A visible blocked split control supports click or tap to read its explanation without splitting or focusing the pane.
|
||||
`DockController` satisfies `DockIntents` as written, so an embedding can pass it to `DockLayout` for retained Sidebar layouts or `DockSurface` for recursive split trees. An embedder that routes through its own store implements the same method names instead. Three props carry control policy rather than gestures: `canSplit` (surface-wide, the pane budget; disables the split control with `splitPaneDisabled`), `canAddTab(paneId)` (per pane, omits the add control; leave it out to draw one in every pane), and `canCloseTab(tabId)` (per tab, withholds the chip's close control and the menu's close item and prevents middle-click closing; leave it out to keep every tab closable). Hiding the add control moves nothing else in the strip, and a withheld close moves nothing in the chip — the close control paints over the title's end rather than beside it. A pane's lone chip whose close is withheld draws quiet — no capsule, no hover fill — since there is nothing to select against and nothing to do to it. The kit adds one policy of its own, the room rule below, which disables a pane's split control with `splitPaneNarrow`; `onRoom(fits)` reports its readings so an embedder splitting programmatically can honour the same rule. A visible blocked split control supports click or tap to read its explanation without splitting or focusing the pane.
|
||||
|
||||
`dropZones="horizontal"` offers two half-pane hints; once budget or width forbids another split, the whole body accepts a move. A hint is a dashed card inset 8px inside its region, showing the zone's glyph and `labels.dropZone[zone]`; the preview layer covers all tab-body content, while the card under the pointer takes the accent and its neighbour stays a quiet outline. `minPaneFraction` sets the preview minimum, and `planResizeSplit` accepts the same minimum for the committed operation. The Sidebar uses 0.2 and enforces two panes in its own store. The generic engine retains its tree and other split directions. `hideSplitWhenBlocked` hides a blocked split control — pane budget spent or pane too narrow — instead of rendering it disabled; its default is false.
|
||||
|
||||
|
||||
@@ -39,6 +39,8 @@ kind: "package-reference"
|
||||
|
||||
**组件**渲染布局快照并上报已落定的意图——每次手势一条,绝不上报拖动帧。拖动过程中在本地状态里预览,手势自身的事实留在它的闭包里;松手时净结果通过一次 `DockIntents` 调用离开——在标签条上松手上报的是按绘制顺序数出的插入槽位(被拖的 chip 也计入),由 `planPlaceTab` 换算成重排或移动。正是这一点让嵌入方能为每次手势记录恰好一条历史。标签条遵循 WAI-ARIA tabs 模式的手动激活:选中的 chip 在 Tab 键序里;左右方向键(循环)、Home、End 只在 chip 之间移动焦点而不选中;Enter 或空格选中当前聚焦的 chip,走与点击相同的意图。chip 是一个胶囊,携带唯一的控件——它的关闭按钮;上下文菜单(在 chip 上的次键按下)携带同样的关闭项加上嵌入方的条目——一个连一项都没有的菜单绝不展示——并渲染在按 chip 定位的 portal 里,因为 chip 盒会故意裁掉溢出(见下文)。chip 之后是添加控件,它请嵌入方(`DockIntents.addTab`)安放其种子 tab;嵌入方的 `canAddTab(paneId)` 按格决定是否绘制该控件。复制 tab 没有套件控件——那是嵌入方的 API——而浮出就是把拖动松手在停靠区之外。
|
||||
|
||||
中键点击可关闭标签页 chip 的任意位置(包括关闭控件)时,通过与关闭按钮相同的意图关闭,无需先选中。chip 接管中键点击,并阻止嵌套标题内容的默认动作。关闭后台标签页会保留当前选中的标签页。只有主键会开始拖动标签页;其它鼠标按键仍按住时,中键点击不会关闭标签页。
|
||||
|
||||
标签上下文菜单只显示关闭操作名,不显示快捷键:菜单操作针对被点击的标签,快捷键则可能作用于其他焦点分栏。无修饰键的 Esc 关闭前台标签菜单而不关闭标签,并将菜单项的焦点交还标签,不绘制焦点外轮廓线。Tab 和方向键导航保留标签页的可见焦点提示。输入法组合输入和长按重复事件不关闭菜单。
|
||||
|
||||
<a id="embedding-it"></a>
|
||||
@@ -52,7 +54,7 @@ kind: "package-reference"
|
||||
| `TabRenderer` | 一个 tab 的正文(`renderTab`),贴着格的边缘和(不带边线的)tab 条底边绘制、自己决定留白,以及可选的 chip 或浮窗头部显示的标题(`renderTabTitle`,回退到记录的 `title`);嵌入方按 `tab.kind` 分发 |
|
||||
| `DockIntents` | 每次手势落定的结果 |
|
||||
|
||||
`DockController` 原样满足 `DockIntents`,因此嵌入方可将它交给 `DockLayout` 来保活 Sidebar 布局,或交给 `DockSurface` 来渲染递归分割树。经由自己 store 路由的嵌入方则实现同名方法。有三个 props 承载的是控制策略而非手势:`canSplit`(整面有效,即格预算;用 `splitPaneDisabled` 禁用分栏控件)、`canAddTab(paneId)`(按格,省略添加控件;不传则每格都画)与 `canCloseTab(tabId)`(按 tab,把 chip 的关闭控件和菜单的关闭项一并收起;不传则每个 tab 都可关闭)。隐藏添加控件不会移动 tab 条里的其它任何东西,收起关闭也不会移动 chip 里的任何东西——关闭控件压在标题末端之上而非并排。某格仅剩的一个 chip 在关闭被收起时画成安静样式——没有胶囊底色,没有悬停填充——因为既没有别的 tab 可供选择,也没有任何可对它做的事。套件自己再加一条策略,即下文的空间规则,它用 `splitPaneNarrow` 禁用某格的分栏控件;`onRoom(fits)` 上报其读数,让以编程方式分栏的嵌入方能遵守同一规则。可见的禁用分栏控件支持鼠标或触摸点击查看原因,不会分栏或聚焦窗格。
|
||||
`DockController` 原样满足 `DockIntents`,因此嵌入方可将它交给 `DockLayout` 来保活 Sidebar 布局,或交给 `DockSurface` 来渲染递归分割树。经由自己 store 路由的嵌入方则实现同名方法。有三个 props 承载的是控制策略而非手势:`canSplit`(整面有效,即格预算;用 `splitPaneDisabled` 禁用分栏控件)、`canAddTab(paneId)`(按格,省略添加控件;不传则每格都画)与 `canCloseTab(tabId)`(按 tab,把 chip 的关闭控件和菜单的关闭项一并收起,并禁止中键关闭;不传则每个 tab 都可关闭)。隐藏添加控件不会移动 tab 条里的其它任何东西,收起关闭也不会移动 chip 里的任何东西——关闭控件压在标题末端之上而非并排。某格仅剩的一个 chip 在关闭被收起时画成安静样式——没有胶囊底色,没有悬停填充——因为既没有别的 tab 可供选择,也没有任何可对它做的事。套件自己再加一条策略,即下文的空间规则,它用 `splitPaneNarrow` 禁用某格的分栏控件;`onRoom(fits)` 上报其读数,让以编程方式分栏的嵌入方能遵守同一规则。可见的禁用分栏控件支持鼠标或触摸点击查看原因,不会分栏或聚焦窗格。
|
||||
|
||||
`dropZones="horizontal"` 提供左右两个半区提示;预算或宽度不允许再拆时,正文整格接收移动。提示是一张内缩 8px 的虚线卡片,显示该落区的图形和 `labels.dropZone[zone]`;预览层覆盖全部 tab 正文,指针所在的卡片取强调色,另一张保持安静的轮廓。`minPaneFraction` 控制预览的最小比例,`planResizeSplit` 接受相同最小值以约束提交;Sidebar使用0.2并在自己的store限制两格。通用引擎仍保留原有树与其它分割方向。 `hideSplitWhenBlocked` 在分栏被阻止时(窗格预算已满或格太窄)直接隐藏分栏控件而不是渲染禁用态,默认值为 false。
|
||||
|
||||
|
||||
@@ -47,9 +47,9 @@ export interface DockSurfaceProps {
|
||||
*/
|
||||
readonly canAddTab?: (paneId: PaneId) => boolean
|
||||
/**
|
||||
* Whether a tab draws its close control and its menu's close item. Called
|
||||
* Whether a tab may close via its close control, menu item, or middle click. Called
|
||||
* per rendered chip on every render; omit to keep every tab closable.
|
||||
* `false` removes both routes without moving the chip: the close control
|
||||
* `false` blocks these routes without moving the chip: the close control
|
||||
* paints over the title's end rather than beside it, so the chip is the same
|
||||
* width either way. The menu still opens and carries the embedder's items.
|
||||
*/
|
||||
|
||||
@@ -105,13 +105,11 @@ export function TabMenu({ labels, anchor, onClose, onDismiss, extras }: TabMenuP
|
||||
data-dockkit-tab-menu
|
||||
// Unplaced for one layout pass: measured, then positioned.
|
||||
style={position ?? { visibility: 'hidden', top: 0, left: 0 }}
|
||||
// React bubbles a portal's events to its tab chip, so both halves of a
|
||||
// press must stop here: the press would otherwise start a tab drag and
|
||||
// capture the pointer, which retargets the release and swallows this
|
||||
// item's click; the click would otherwise also focus a tab the item may
|
||||
// have removed.
|
||||
// Portal events reach the owning chip: a press would start its drag,
|
||||
// a click would focus it, and a middle click would close it.
|
||||
onPointerDown={(event) => { event.stopPropagation() }}
|
||||
onClick={(event) => { event.stopPropagation() }}
|
||||
onAuxClick={(event) => { event.stopPropagation() }}
|
||||
>
|
||||
{onClose !== undefined && (
|
||||
<button type="button" role="menuitem" className={css.menuItem} data-dockkit-menu-close onClick={onClose}>
|
||||
|
||||
@@ -6,10 +6,12 @@
|
||||
* A chip is a capsule carrying one control, its close, shown over its right
|
||||
* end while the chip is active, hovered, or focused; the context menu
|
||||
* (secondary press) carries the same close plus whatever the embedder appends.
|
||||
* Both close routes draw only while the embedder's `canCloseTab` allows, and
|
||||
* a pane's lone chip whose close is withheld draws quiet — no capsule, no
|
||||
* hover fill — since there is nothing to select against and nothing to do to
|
||||
* it.
|
||||
* A middle click anywhere on the chip, including its close control, closes
|
||||
* without selecting the tab, unless another mouse button is still held.
|
||||
* The embedder's `canCloseTab`
|
||||
* governs all close routes. A pane's lone chip whose close is withheld draws
|
||||
* quiet — no capsule, no hover fill — since there is nothing to select against
|
||||
* and nothing to do to it.
|
||||
* Between neighbouring chips sits a slot: a fixed-width box drawing a
|
||||
* hairline, blank beside the active chip, and the drop caret when a drag
|
||||
* targets that index, so a caret never widens the row; the two end slots
|
||||
@@ -282,10 +284,19 @@ export function TabStrip({ state, pane, callbacks }: TabPanelProps): ReactNode {
|
||||
// replaced — which would abandon every drag. A drag that ends
|
||||
// elsewhere fires no click, and its own operation carries focus.
|
||||
onPointerDown={(event) => {
|
||||
// A secondary press is the menu, never a drag.
|
||||
if (event.button === 2) return
|
||||
if (event.button !== 0) return
|
||||
callbacks.onTabPressed(tabId, event)
|
||||
}}
|
||||
onMouseDown={(event) => {
|
||||
// Prevent middle-button autoscroll or selection paste before release.
|
||||
if (event.button === 1) event.preventDefault()
|
||||
}}
|
||||
onAuxClick={(event) => {
|
||||
if (event.button !== 1) return
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
if (closable && event.buttons === 0) callbacks.onCloseTab(tabId)
|
||||
}}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation()
|
||||
activate(tabId)
|
||||
|
||||
@@ -27,7 +27,7 @@ export interface PaneCallbacks {
|
||||
readonly hideSplitWhenBlocked?: boolean
|
||||
/** Whether a pane's strip draws the add control. */
|
||||
readonly canAddTab: (paneId: PaneId) => boolean
|
||||
/** Whether a tab draws its close control and its menu's close item. */
|
||||
/** Whether a tab may close via its close control, menu item, or middle click. */
|
||||
readonly canCloseTab: (tabId: TabId) => boolean
|
||||
/** Live drop preview, or `undefined` while nothing is being dragged. */
|
||||
readonly dropTarget: DropTarget | undefined
|
||||
|
||||
@@ -978,6 +978,27 @@ describe('DockSurface', () => {
|
||||
})
|
||||
|
||||
describe('tab drags', () => {
|
||||
it('keeps a dragged tab when the middle button is released while the primary button stays down', () => {
|
||||
const controller = seededController()
|
||||
const tabId = controller.openContent({ contentId: 'file:a', title: 'a.txt', kind: 'file' })
|
||||
const state = controller.getSnapshot().state
|
||||
const paneId = getPane(state, state.rootId).id
|
||||
layOut([paneId], 420)
|
||||
const intents = spyIntents()
|
||||
intents.closeTab.mockImplementation(controller.closeTab.bind(controller))
|
||||
intents.placeTab.mockImplementation(controller.placeTab.bind(controller))
|
||||
renderSurface(controller, intents)
|
||||
const chip = screen.getByRole('tab', { name: /a\.txt/u })
|
||||
|
||||
drag(chip, FILE_CHIP, [30, 18], false)
|
||||
fireEvent(chip, new MouseEvent('auxclick', { button: 1, buttons: 1, bubbles: true, cancelable: true }))
|
||||
expect(intents.closeTab).not.toHaveBeenCalled()
|
||||
fireEvent.pointerUp(window, { pointerId: 7, button: 0, buttons: 0, clientX: 30, clientY: 18 })
|
||||
expect(intents.placeTab).toHaveBeenCalledExactlyOnceWith(tabId, paneId, 0)
|
||||
expect(getPane(controller.getSnapshot().state, paneId).tabs[0]).toBe(tabId)
|
||||
expect(document.querySelector('[data-dockkit-caret]')).toBeNull()
|
||||
})
|
||||
|
||||
it('previews a caret at the slot under the pointer and reports that slot on release', () => {
|
||||
const { intents, first, fileTabId, chip } = twoPanes()
|
||||
// Left of the first chip's midpoint: slot 0.
|
||||
|
||||
@@ -38,6 +38,66 @@ function mounted(controller = seededController(), options: Partial<ComponentProp
|
||||
return { controller, view, redraw, callbacks, node, body: (id: TabId) => node(`[data-body="${id}"]`) }
|
||||
}
|
||||
|
||||
it.each([
|
||||
{ active: false, targetControl: false },
|
||||
{ active: true, targetControl: false },
|
||||
{ active: false, targetControl: true },
|
||||
{ active: true, targetControl: true },
|
||||
])('closes a tab by middle click without selecting it first (active: $active, close control: $targetControl)', ({ active, targetControl }) => {
|
||||
const controller = seededController()
|
||||
const first = controller.openContent({ kind: 'file', contentId: 'file:a', title: 'A' })
|
||||
const second = controller.openContent({ kind: 'file', contentId: 'file:b', title: 'B' })
|
||||
const h = mounted(controller)
|
||||
const focus = vi.spyOn(h.callbacks, 'onFocusTab')
|
||||
const close = vi.spyOn(h.callbacks, 'onCloseTab').mockImplementation((id) => {
|
||||
controller.closeTab(id)
|
||||
h.redraw()
|
||||
})
|
||||
const target = active ? second : first
|
||||
const chip = h.node(`[data-dockkit-tab="${target}"]${targetControl ? ' [data-dockkit-tab-close]' : ''}`)
|
||||
fireEvent.pointerDown(chip, { button: 1, pointerId: 7 })
|
||||
expect(fireEvent.mouseDown(chip, { button: 1 })).toBe(false)
|
||||
expect(close).not.toHaveBeenCalled()
|
||||
expect(h.callbacks.onTabPressed).not.toHaveBeenCalled()
|
||||
fireEvent.pointerUp(chip, { button: 1, pointerId: 7 })
|
||||
fireEvent.mouseUp(chip, { button: 1 })
|
||||
expect(fireEvent(chip, new MouseEvent('auxclick', { button: 1, bubbles: true, cancelable: true }))).toBe(false)
|
||||
expect(close).toHaveBeenCalledExactlyOnceWith(target)
|
||||
expect(focus).not.toHaveBeenCalled()
|
||||
expect(h.view.container.querySelector(`[data-dockkit-tab="${target}"]`)).toBeNull()
|
||||
expect(h.node(`[data-dockkit-tab="${active ? first : second}"]`).getAttribute('aria-selected')).toBe('true')
|
||||
})
|
||||
|
||||
it('keeps an unclosable tab on middle click', () => {
|
||||
const h = mounted()
|
||||
vi.spyOn(h.callbacks, 'canCloseTab').mockReturnValue(false)
|
||||
h.redraw()
|
||||
const chip = h.node('[data-dockkit-tab]')
|
||||
fireEvent(chip, new MouseEvent('auxclick', { button: 1, bubbles: true, cancelable: true }))
|
||||
expect(h.callbacks.onCloseTab).not.toHaveBeenCalled()
|
||||
expect(h.node('[data-dockkit-tab]')).toBe(chip)
|
||||
})
|
||||
|
||||
it('does not treat a middle click inside the tab menu as a click on the tab title', () => {
|
||||
const h = mounted()
|
||||
fireEvent.contextMenu(h.node('[data-dockkit-tab]'))
|
||||
const item = h.view.getByRole('menuitem', { name: TEST_LABELS.closeTab })
|
||||
fireEvent(item, new MouseEvent('auxclick', { button: 1, bubbles: true, cancelable: true }))
|
||||
expect(h.callbacks.onCloseTab).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('keeps primary selection and secondary menus separate from middle-click closing', () => {
|
||||
const h = mounted()
|
||||
const chip = h.node('[data-dockkit-tab]')
|
||||
for (const button of [0, 2]) {
|
||||
expect(fireEvent.mouseDown(chip, { button })).toBe(true)
|
||||
fireEvent.pointerDown(chip, { button, pointerId: 7 })
|
||||
expect(fireEvent(chip, new MouseEvent('auxclick', { button, bubbles: true, cancelable: true }))).toBe(true)
|
||||
}
|
||||
expect(h.callbacks.onTabPressed).toHaveBeenCalledTimes(1)
|
||||
expect(h.callbacks.onCloseTab).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('retains visited bodies and hands keyboard focus to the newly selected strip', () => {
|
||||
const controller = seededController()
|
||||
controller.setExpanded(true)
|
||||
|
||||
@@ -15,11 +15,11 @@
|
||||
en: 76368b6b1da79c80
|
||||
zh: 52384aee991ac542
|
||||
/deepseek-ai-dsh-client-ui-entry-customization/use-this-package:
|
||||
en: 7cc90968634e3f7b
|
||||
zh: 54f58e17b14f7404
|
||||
en: e642513bcfd42d68
|
||||
zh: 38b9109ec017900d
|
||||
/deepseek-ai-dsh-client-ui-entry-customization/understand-the-implementation:
|
||||
en: 3dcb1f29d03d5afe
|
||||
zh: 5ee39637636cd062
|
||||
en: 2f1a873bebc098c8
|
||||
zh: c2ec33e50ff2ccc0
|
||||
/deepseek-ai-dsh-client-ui-entry-customization/model-experience:
|
||||
en: b891f311c09d536f
|
||||
zh: 68c0f41fbfed0d73
|
||||
|
||||
@@ -34,6 +34,8 @@ Open **More** beneath the left navigation entries, **More conversation views** a
|
||||
|
||||
On Desktop, **Browse other plugins** and **Create your plugin** report `more_menu_item_click` before they act, with `action=browse_plugins` or `action=create_plugin` and the menu's `region`: `sidebar` for **More**, `conversation` for **More conversation views**, and `rightbar` for **More tools**. Opening an entry and changing a pin report nothing, and the menus report no exposure events. Without the analytics service, both actions behave the same and report nothing.
|
||||
|
||||
The browse-plugins row appears only while the `pluginNavigation` service is present; the plugin starts without it.
|
||||
|
||||
-----
|
||||
|
||||
<a id="understand-the-implementation"></a>
|
||||
@@ -44,7 +46,7 @@ On Desktop, **Browse other plugins** and **Create your plugin** report `more_men
|
||||
|
||||
[`apply`](src/client/index.ts) registers [`EntryMenuContent`](src/client/EntryMenuContent.tsx) under id `entry-customization` into `sidebar.panellist.actions`, `conversation.session.header.views.actions`, and `sidebar.right.tab.guide.actions` through `ctx.slots.inject`, so each registration follows its region's declaration lifetime. The regions render these slots inside their own `Menu` and supply the entries, their pin state, `onOpen`, `onPinnedChange`, and `onDismiss` as owner props. The rows call `onDismiss` before opening an entry or running a footer action; pin changes leave the menu open.
|
||||
|
||||
Each registration's `inject` supplies its region and two callbacks. Each callback first calls `ctx.get('productAnalytics')?.track('more_menu_item_click', { region, action })`. Browsing then calls `pluginNavigation.openList()`. Creation then calls `uiWorkspace.startSession(undefined, { agentPreset: 'cordis', prompt, whenDraftExists: 'append' })` with the region's localized prompt; it does not go through `ui-agent-preset`, so the preset chip plays no introduction. The [`dsh-web-app` bundle patch](../../bundle/web-app/cordis.patch.yml) mounts the browser plugin.
|
||||
Each registration's `inject` supplies its region and two callbacks. Each callback first calls `ctx.get('productAnalytics')?.track('more_menu_item_click', { region, action })`. Browsing then calls `ctx.get('pluginNavigation')?.openList()`; ui-plugin-manager provides that service only while the Plugins page has access, so this package reads it optionally and never lists it in `inject`. Creation then calls `uiWorkspace.startSession(undefined, { agentPreset: 'cordis', prompt, whenDraftExists: 'append' })` with the region's localized prompt; it does not go through `ui-agent-preset`, so the preset chip plays no introduction. The [`dsh-web-app` bundle patch](../../bundle/web-app/cordis.patch.yml) mounts the browser plugin.
|
||||
|
||||
</details>
|
||||
|
||||
|
||||
@@ -34,6 +34,8 @@ kind: "package-reference"
|
||||
|
||||
在桌面端,**浏览更多插件**与**创建你的插件**在执行前上报 `more_menu_item_click`,`action` 为 `browse_plugins` 或 `create_plugin`,`region` 为菜单所在区域:**更多**为 `sidebar`,**更多会话视图**为 `conversation`,**更多工具**为 `rightbar`。打开入口和更改 Pin 状态不上报,菜单也不上报曝光事件。没有埋点服务时,两项操作行为不变且不上报。
|
||||
|
||||
「浏览插件」一行只在 `pluginNavigation` 服务存在时出现;没有该服务时插件照常启动。
|
||||
|
||||
-----
|
||||
|
||||
<a id="understand-the-implementation"></a>
|
||||
@@ -44,7 +46,7 @@ kind: "package-reference"
|
||||
|
||||
[`apply`](src/client/index.ts) 通过 `ctx.slots.inject` 以 id `entry-customization` 把 [`EntryMenuContent`](src/client/EntryMenuContent.tsx) 注册到 `sidebar.panellist.actions`、`conversation.session.header.views.actions` 和 `sidebar.right.tab.guide.actions`,每个注册项跟随所在区域声明的生命周期。各区域在自己的 `Menu` 中渲染这些 slot,并以 owner props 提供入口、Pin 状态、`onOpen`、`onPinnedChange` 与 `onDismiss`。菜单行先调用 `onDismiss`,再打开入口或执行底部操作;Pin 变化不关闭菜单。
|
||||
|
||||
每个注册项的 `inject` 提供所属区域与两个回调。每个回调先调用 `ctx.get('productAnalytics')?.track('more_menu_item_click', { region, action })`。浏览随后调用 `pluginNavigation.openList()`。创建随后按区域取本地化提示语,调用 `uiWorkspace.startSession(undefined, { agentPreset: 'cordis', prompt, whenDraftExists: 'append' })`;该调用不经过 `ui-agent-preset`,预设标签不播放介绍动画。[`dsh-web-app` bundle patch](../../bundle/web-app/cordis.patch.yml) 挂载浏览器插件。
|
||||
每个注册项的 `inject` 提供所属区域与两个回调。每个回调先调用 `ctx.get('productAnalytics')?.track('more_menu_item_click', { region, action })`。浏览随后调用 `ctx.get('pluginNavigation')?.openList()`;ui-plugin-manager 只在插件页有权限时提供该服务,因此本包按可选方式读取,不把它写进 `inject`。创建随后按区域取本地化提示语,调用 `uiWorkspace.startSession(undefined, { agentPreset: 'cordis', prompt, whenDraftExists: 'append' })`;该调用不经过 `ui-agent-preset`,预设标签不播放介绍动画。[`dsh-web-app` bundle patch](../../bundle/web-app/cordis.patch.yml) 挂载浏览器插件。
|
||||
|
||||
</details>
|
||||
|
||||
|
||||
@@ -25,6 +25,7 @@
|
||||
"@deepseek-ai/cordis": "workspace:~",
|
||||
"@deepseek-ai/dsh-client-locale": "workspace:*",
|
||||
"@deepseek-ai/dsh-client-product-analytics": "workspace:*",
|
||||
"@deepseek-ai/dsh-client-store": "workspace:*",
|
||||
"@deepseek-ai/dsh-client-ui-sidebar": "workspace:*",
|
||||
"@deepseek-ai/dsh-client-ui-conversation": "workspace:*",
|
||||
"@deepseek-ai/dsh-client-ui-sidebar-right": "workspace:*",
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
/** Rows of a navigation region's More menu: customizable entries, then plugin discovery and creation. */
|
||||
/** Rows of a navigation region's More menu: customizable entries, plugin discovery while reachable, and creation. */
|
||||
import {
|
||||
IconPluginPinwheelOutlineRegular, IconPlusOutlineRegular, MenuItemButton,
|
||||
} from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import type { HostObservable, InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import type {} from '@deepseek-ai/dsh-client-ui-sidebar/client'
|
||||
import { EntryPickerRow } from './EntryPickerRow.tsx'
|
||||
|
||||
@@ -13,6 +13,10 @@ export type EntryRegion = 'sidebar' | 'conversation' | 'rightbar'
|
||||
export interface EntryMenuInjected {
|
||||
/** Region of this registration; selects pin copy and the Creator prompt. */
|
||||
readonly region: EntryRegion
|
||||
readonly hooks: {
|
||||
/** Whether the Plugins page navigation service is present; the discovery row shows only while it is. */
|
||||
readonly pluginNavigation: HostObservable<boolean>
|
||||
}
|
||||
/** Open the Plugins main list. */
|
||||
readonly browsePlugins: () => void
|
||||
/** Start a Creator draft with this region's prompt without sending it. */
|
||||
@@ -29,8 +33,9 @@ type EntryMenuContentProps = PropsRuntime<'sidebar.panellist.actions'>
|
||||
* @returns Menu rows for the region's More menu.
|
||||
*/
|
||||
export function EntryMenuContent({
|
||||
entries, onOpen, onPinnedChange, onDismiss, region, browsePlugins, createPlugin, t,
|
||||
entries, onOpen, onPinnedChange, onDismiss, region, usePluginNavigation, browsePlugins, createPlugin, t,
|
||||
}: EntryMenuContentProps) {
|
||||
const browsable = usePluginNavigation(present => present)
|
||||
return <>
|
||||
{entries.map((entry) => {
|
||||
const pinLabel = t(`${entry.pinned ? 'unpin' : 'pin'}.${region}`)
|
||||
@@ -39,11 +44,12 @@ export function EntryMenuContent({
|
||||
onOpen={() => { onDismiss(); onOpen(entry.id) }}
|
||||
onPin={() => { onPinnedChange(entry.id, !entry.pinned) }} />
|
||||
})}
|
||||
<MenuItemButton icon={<IconPluginPinwheelOutlineRegular size={16} />} separatorBefore={entries.length > 0}
|
||||
{browsable && <MenuItemButton icon={<IconPluginPinwheelOutlineRegular size={16} />} separatorBefore={entries.length > 0}
|
||||
onSelect={() => { onDismiss(); browsePlugins() }}>
|
||||
{t('install')}
|
||||
</MenuItemButton>
|
||||
<MenuItemButton icon={<IconPlusOutlineRegular size={16} />} onSelect={() => { onDismiss(); createPlugin() }}>
|
||||
</MenuItemButton>}
|
||||
<MenuItemButton icon={<IconPlusOutlineRegular size={16} />} separatorBefore={!browsable && entries.length > 0}
|
||||
onSelect={() => { onDismiss(); createPlugin() }}>
|
||||
{t('create')}
|
||||
</MenuItemButton>
|
||||
</>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
/** Rows for the More menus of the left sidebar, Conversation View tabs, and right-sidebar guide. */
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
import { createSnapshotStore } from '@deepseek-ai/dsh-client-store'
|
||||
import type {} from '@deepseek-ai/dsh-client-locale/client'
|
||||
import type {} from '@deepseek-ai/dsh-client-product-analytics/client'
|
||||
import type {} from '@deepseek-ai/dsh-client-ui-renderer/client'
|
||||
@@ -18,21 +19,30 @@ declare module '@deepseek-ai/dsh-client-ui-slots' {
|
||||
}
|
||||
}
|
||||
|
||||
/** Services used by the menu rows' footer actions. */
|
||||
export const inject = ['slots', 'locale', 'pluginNavigation', 'uiWorkspace']
|
||||
/** Services used by the menu rows' footer actions; the optional `pluginNavigation` shows the discovery row while present. */
|
||||
export const inject = ['slots', 'locale', 'uiWorkspace']
|
||||
|
||||
/**
|
||||
* Register the same menu rows into each region's More menu.
|
||||
* @param ctx - Browser context with slot, locale, plugin navigation, and Workspace services.
|
||||
* @param ctx - Browser context with slot, locale, and Workspace services, and the optional plugin navigation.
|
||||
*/
|
||||
export function apply(ctx: Context): void {
|
||||
ctx.effect(() => ctx.locale.register('entryCustomization', { en, zh, ja }))
|
||||
const t = ctx.locale.bind('entryCustomization')
|
||||
// The Plugins page provides its navigation only while the current Peer may use it.
|
||||
const pluginNavigation = createSnapshotStore(false)
|
||||
ctx.inject(['pluginNavigation'], (scope: Context) => {
|
||||
scope.effect(() => {
|
||||
pluginNavigation.set(true)
|
||||
return () => { pluginNavigation.set(false) }
|
||||
}, 'entry-customization: plugin navigation presence')
|
||||
})
|
||||
const injected = (region: EntryRegion): EntryMenuInjected => ({
|
||||
region,
|
||||
hooks: { pluginNavigation },
|
||||
browsePlugins: () => {
|
||||
ctx.get('productAnalytics')?.track('more_menu_item_click', { region, action: 'browse_plugins' })
|
||||
ctx.pluginNavigation.openList()
|
||||
ctx.get('pluginNavigation')?.openList()
|
||||
},
|
||||
createPlugin: () => {
|
||||
ctx.get('productAnalytics')?.track('more_menu_item_click', { region, action: 'create_plugin' })
|
||||
|
||||
@@ -34,6 +34,7 @@ function content(overrides: Partial<ContentProps> = {}) {
|
||||
const props = {
|
||||
entries: [{ id: 'notes', label: 'Notes', pinned: true }, { id: 'tasks', label: 'Tasks', pinned: false }],
|
||||
region: 'sidebar', t: makeTranslate(en), renderFactorySlot: unused,
|
||||
usePluginNavigation: <T,>(select: (present: boolean) => T): T => select(true),
|
||||
usePanelInfo: unused, useSessions: unused, useSessionStatus: unused, useSessionRetainInfo: unused,
|
||||
useResource: unused, useWorkspaces: unused,
|
||||
...callbacks,
|
||||
@@ -112,7 +113,10 @@ function Frame({ renderSlot }: PropsRenderSlots<Seats>) {
|
||||
</>
|
||||
}
|
||||
|
||||
async function bench(language: 'en' | 'zh' = 'en', { analytics = true }: { analytics?: boolean } = {}) {
|
||||
async function bench(
|
||||
language: 'en' | 'zh' = 'en',
|
||||
{ analytics = true, navigation = true }: { analytics?: boolean; navigation?: boolean } = {},
|
||||
) {
|
||||
const runtime = await SlotTestRuntime.create()
|
||||
runtimes.add(runtime)
|
||||
const openList = vi.fn(), startSession = vi.fn(), track = vi.fn<TrackProductEvent>()
|
||||
@@ -123,7 +127,7 @@ async function bench(language: 'en' | 'zh' = 'en', { analytics = true }: { analy
|
||||
locale.setLocale(language)
|
||||
ctx.provide('locale', locale)
|
||||
ctx.slots.installLocale(locale)
|
||||
ctx.provide('pluginNavigation', { openList } as never)
|
||||
if (navigation) ctx.provide('pluginNavigation', { openList } as never)
|
||||
ctx.provide('uiWorkspace', { startSession } as never)
|
||||
if (analytics) ctx.provide('productAnalytics', { track } as never)
|
||||
},
|
||||
@@ -139,6 +143,29 @@ async function bench(language: 'en' | 'zh' = 'en', { analytics = true }: { analy
|
||||
}
|
||||
|
||||
describe('More menu registrations', () => {
|
||||
it('shows plugin discovery only while the Plugins page navigation is present', async () => {
|
||||
const b = await bench('en', { navigation: false })
|
||||
const sidebar = () => within(b.view.getByRole('menu', { name: 'sidebar.panellist.actions' }))
|
||||
expect(sidebar().queryByRole('menuitem', { name: 'Browse other plugins' })).toBeNull()
|
||||
expect(sidebar().getByRole('menuitem', { name: 'Create your plugin' })).toBeTruthy()
|
||||
// The Creator row takes the separator the absent discovery row would carry.
|
||||
expect(sidebar().getAllByRole('separator')).toHaveLength(1)
|
||||
const injected = b.runtime.slots.entries('sidebar.panellist.actions')[0]!.inject!() as { browsePlugins: () => void }
|
||||
expect(() => { injected.browsePlugins() }).not.toThrow()
|
||||
|
||||
const navigation = await b.runtime.mount({
|
||||
inject: [],
|
||||
apply(ctx: Context) { ctx.provide('pluginNavigation', { openList: b.openList } as never) },
|
||||
})
|
||||
await act(async () => { await Promise.resolve() })
|
||||
fireEvent.click(sidebar().getByRole('menuitem', { name: 'Browse other plugins' }))
|
||||
expect(b.openList).toHaveBeenCalledExactlyOnceWith()
|
||||
|
||||
await navigation.dispose()
|
||||
await act(async () => { await Promise.resolve() })
|
||||
expect(sidebar().queryByRole('menuitem', { name: 'Browse other plugins' })).toBeNull()
|
||||
})
|
||||
|
||||
it('registers one row set per region and removes all of them when the plugin unloads', async () => {
|
||||
const b = await bench()
|
||||
for (const seat of SEATS) {
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
// @vitest-environment jsdom
|
||||
/** A page whose Peer may not list plugin bundles still boots, and the More menus keep their other rows. */
|
||||
import { expect } from 'vitest'
|
||||
import { createClientTest, webApp } from '@deepseek-ai/dsh-client-test-runtime/src/assembly/index.ts'
|
||||
|
||||
const withoutPluginManager = createClientTest({ roster: webApp }, { access: action => action !== 'pluginManager/listBundles' })
|
||||
|
||||
withoutPluginManager('boots the shipped Web client without the Plugins page navigation', async ({ start }) => {
|
||||
const c = await start()
|
||||
expect(c.ctx.get('pluginNavigation')).toBeUndefined()
|
||||
expect(c.ctx.slots.entries('sidebar.panellist.actions').map(entry => entry.options.id)).toContain('entry-customization')
|
||||
}, 60_000)
|
||||
@@ -13,6 +13,7 @@
|
||||
{ "path": "../../client/ui-workspace" },
|
||||
{ "path": "../../client/ui-primitives" },
|
||||
{ "path": "../../client/ui-renderer" },
|
||||
{ "path": "../../client/ui-slots" }
|
||||
{ "path": "../../client/ui-slots" },
|
||||
{ "path": "../store" }
|
||||
]
|
||||
}
|
||||
@@ -18,8 +18,8 @@
|
||||
en: e0127a604ff4aebb
|
||||
zh: 38f1b3db30f81b22
|
||||
/deepseek-ai-dsh-client-ui-message-feedback/use-this-package/failures:
|
||||
en: f060de7d9f44b058
|
||||
zh: 60770de36be6e90f
|
||||
en: e4807e47bca06517
|
||||
zh: 649a3f9f62771865
|
||||
/deepseek-ai-dsh-client-ui-message-feedback/understand-the-implementation:
|
||||
en: 61260495346ceb3e
|
||||
zh: 32e05d3eb82fcaf2
|
||||
|
||||
@@ -31,6 +31,8 @@ Mount this plugin alongside `ui-conversation` and `ui-commands`; the Like/Dislik
|
||||
|
||||
A rating or list-load failure shows inline in the row; a submission failure shows in a warning toast while the dialog stays open so the draft can be corrected. Only finalized messages reach the message entry — an interruption-frozen partial carries no `messageId` and therefore no feedback controls.
|
||||
|
||||
Access: the rating buttons render only while the current Peer may call `messageFeedback/put` on the Session; the `/feedback` decoration has no Client check, and the Host refuses a submission without access.
|
||||
|
||||
-----
|
||||
|
||||
<a id="understand-the-implementation"></a>
|
||||
|
||||
@@ -31,6 +31,8 @@ kind: "package-reference"
|
||||
|
||||
评分或列表加载失败在行内展示;提交失败通过警告 toast 展示,弹窗保持打开以便修正草稿。只有已定稿的消息能到达消息条目——被中断冻结的部分输出不带 `messageId`,因此没有反馈控件。
|
||||
|
||||
访问控制:评分按钮只在当前 Peer 可以在该会话上调用 `messageFeedback/put` 时渲染;`/feedback` 装饰不做前端判断,没有权限时提交由 Host 拒绝。
|
||||
|
||||
-----
|
||||
|
||||
<a id="understand-the-implementation"></a>
|
||||
|
||||
@@ -17,12 +17,17 @@ import type { MessageFeedbackActionProps } from './slots.ts'
|
||||
import css from './MessageFeedbackActions.module.css'
|
||||
|
||||
/**
|
||||
* One message's feedback controls.
|
||||
* @param props - the owner's message identity, the injected verbs, and the
|
||||
* shared feedback hook.
|
||||
* @returns the rating buttons with any failure notice beside them.
|
||||
* One message's feedback controls, rendered only while the current Peer may
|
||||
* record message feedback in this Session.
|
||||
* @param props - the owner's message identity, the injected verbs, the
|
||||
* shared feedback hook, and the Session access Hook.
|
||||
* @returns the rating buttons with any failure notice beside them, or nothing without access.
|
||||
*/
|
||||
export function MessageFeedbackActions({
|
||||
export function MessageFeedbackActions(props: MessageFeedbackActionProps) {
|
||||
return props.useAccess('messageFeedback/put') ? <FeedbackRatingButtons {...props} /> : null
|
||||
}
|
||||
|
||||
function FeedbackRatingButtons({
|
||||
messageId, ensure, current, retract, openDialog, useFeedback, t,
|
||||
}: MessageFeedbackActionProps) {
|
||||
const item = useFeedback(view => view.items.get(messageId))
|
||||
|
||||
@@ -45,6 +45,8 @@ function mount(options: {
|
||||
ensureResult?: MessageFeedbackActionResult
|
||||
retractResult?: MessageFeedbackActionResult
|
||||
status?: MessageFeedbackView['status']
|
||||
/** The Session access answer for recording message feedback; allowed when omitted. */
|
||||
allowed?: boolean
|
||||
} = {}) {
|
||||
const view: MessageFeedbackView = {
|
||||
status: options.status ?? 'ready',
|
||||
@@ -58,12 +60,22 @@ function mount(options: {
|
||||
const current = vi.fn((_id: MessageId) => options.committed ?? options.current)
|
||||
const useFeedback = (<T,>(select: (v: MessageFeedbackView) => T): T =>
|
||||
useSyncExternalStore(() => () => {}, () => select(view))) as never
|
||||
const useAccess = vi.fn((_action: string) => options.allowed ?? true)
|
||||
const props = { messageId: MSG, ensure, current, retract, openDialog, useFeedback, t } as unknown as
|
||||
Parameters<typeof MessageFeedbackActions>[0]
|
||||
return { ...render(<MessageFeedbackActions {...props} />), ensure, retract, openDialog }
|
||||
return { ...render(<MessageFeedbackActions {...props} useAccess={useAccess} />), ensure, retract, openDialog, useAccess }
|
||||
}
|
||||
|
||||
describe('MessageFeedbackActions', () => {
|
||||
it('renders nothing and reads nothing while the current Peer may not record message feedback in this Session', () => {
|
||||
const ui = mount({ allowed: false })
|
||||
|
||||
expect(ui.useAccess).toHaveBeenCalledWith('messageFeedback/put')
|
||||
expect(ui.queryByLabelText(zh['action.like'])).toBeNull()
|
||||
expect(ui.queryByLabelText(zh['action.dislike'])).toBeNull()
|
||||
expect(ui.ensure).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('renders both rating buttons unpressed with no recorded feedback', () => {
|
||||
const ui = mount()
|
||||
|
||||
@@ -222,7 +234,7 @@ describe('MessageFeedbackActions', () => {
|
||||
useFeedback,
|
||||
t,
|
||||
} as unknown as Parameters<typeof MessageFeedbackActions>[0]
|
||||
const ui = render(<MessageFeedbackActions {...props} />)
|
||||
const ui = render(<MessageFeedbackActions {...props} useAccess={() => true} />)
|
||||
const errors: unknown[] = []
|
||||
const onError = (event: ErrorEvent): void => { errors.push(event.error) }
|
||||
window.addEventListener('error', onError)
|
||||
@@ -252,7 +264,7 @@ describe('MessageFeedbackActions', () => {
|
||||
useFeedback,
|
||||
t,
|
||||
} as unknown as Parameters<typeof MessageFeedbackActions>[0]
|
||||
const ui = render(<MessageFeedbackActions {...props} />)
|
||||
const ui = render(<MessageFeedbackActions {...props} useAccess={() => true} />)
|
||||
const errors: unknown[] = []
|
||||
const onError = (event: ErrorEvent): void => { errors.push(event.error) }
|
||||
window.addEventListener('error', onError)
|
||||
@@ -286,7 +298,7 @@ describe('MessageFeedbackActions', () => {
|
||||
useFeedback,
|
||||
t,
|
||||
} as unknown as Parameters<typeof MessageFeedbackActions>[0]
|
||||
const ui = render(<MessageFeedbackActions {...props} />)
|
||||
const ui = render(<MessageFeedbackActions {...props} useAccess={() => true} />)
|
||||
const errors: unknown[] = []
|
||||
const onError = (event: ErrorEvent): void => { errors.push(event.error) }
|
||||
window.addEventListener('error', onError)
|
||||
|
||||
@@ -36,20 +36,20 @@
|
||||
en: 54d7666c630da471
|
||||
zh: d7ded257d0fd5559
|
||||
/deepseek-ai-dsh-client-ui-plugin-manager/use-this-package/page-tabs:
|
||||
en: 1de4fe390871d0da
|
||||
zh: 1723ae12ccbe3697
|
||||
en: fc469bab4fbd6028
|
||||
zh: 8f5a04e1b9a46471
|
||||
/deepseek-ai-dsh-client-ui-plugin-manager/understand-the-implementation:
|
||||
en: 203168fc87e939bd
|
||||
zh: 72d0ccd2c54f3f44
|
||||
/deepseek-ai-dsh-client-ui-plugin-manager/understand-the-implementation/registration:
|
||||
en: de110cb51ae1e984
|
||||
zh: 8703ded36ae7a1f1
|
||||
en: 93871919adaf1082
|
||||
zh: 67c9ef0b500e6917
|
||||
/deepseek-ai-dsh-client-ui-plugin-manager/understand-the-implementation/the-store:
|
||||
en: 21a0bbe8eb92229c
|
||||
zh: 096ff63db61992bb
|
||||
/deepseek-ai-dsh-client-ui-plugin-manager/understand-the-implementation/configuration-slots:
|
||||
en: 954a30f20c8b3d39
|
||||
zh: e76919d88f19c89a
|
||||
en: b3b8bd3606fc9e97
|
||||
zh: 7cd90e182073c8eb
|
||||
/deepseek-ai-dsh-client-ui-plugin-manager/further-exploration:
|
||||
en: f9a42dfed0360d6a
|
||||
zh: 455a70bcb6c69624
|
||||
|
||||
@@ -112,6 +112,8 @@ A row's page exists only while a `plugins.row.config` entry names the row, so a
|
||||
|
||||
Another plugin adds a tab after the page's own **Installed** tab by registering into the `plugins.tab` list slot; the entry's `label` names the tab and `order` places it. The tabs appear under the page header once one entry exists and are hidden on a bundle's, row's, or official plugin's page; the selected tab survives leaving **Plugins**, and a tab whose entry leaves falls back to **Installed**. The page renders the selected entry in place of its groups and hides its own refresh and add buttons there. The owner props carry `install({ spec, registry?, title, version })` and `openPackage(name)`. `install` is for a version the person already confirmed in the tab: the install dialog opens at its check screen, without the spec form, names the plugin with `title` and `version` above the spec, which it shows in smaller monospace text, keeps that name on the progress and result screens, with the installed version once done, asks `registry` first for that install only and never remembers it, and offers **Retry** after a refusal; a hidden installation reopens instead. `openPackage` switches to **Installed** and opens the bundle's page when that tab lists it, and that page's back button returns to the tab that opened it; a bundle the page leaves out, such as a built-in profile bundle, leaves the Installed list open. A confirmed install offers no GitHub mirror recovery, since that recovery reopens the spec form, and its back button cancels a running install. [`ui-plugin-market`](../ui-plugin-market/README.md) contributes the **Discover** tab here.
|
||||
|
||||
Access: the Plugins entry, page, refresh notice, Host change subscriptions, and `pluginNavigation` exist only while the current Peer may call `pluginManager/listBundles`; each later grant re-reads a list already loaded, and `pluginChanges` receives no updates while access is withdrawn. The owner always has access.
|
||||
|
||||
-----
|
||||
|
||||
<a id="understand-the-implementation"></a>
|
||||
@@ -130,7 +132,7 @@ The browser plugin registers the `plugins` sidebar entry and its `main` panel th
|
||||
|
||||
The add control reuses `Button`, `Menu`, and `MenuItemButton`. Both installation actions call the same `openInstall` callback and use `InstallDialog` and `PluginManagerController`. The root-scoped list slot `plugins.add.actions` accepts contributed `MenuItemButton` rows after the install action; each contribution receives an `onDismiss()` callback to close the menu before starting its action.
|
||||
|
||||
Other Client plugins can inject `pluginNavigation` and call `openList()` to select the Plugins main list, including when a detail page is already open. This browsing action preserves the current Session and opens no installation dialog. Installation remains an explicit action on the Plugins page.
|
||||
While the Plugins page has access, other Client plugins can read `pluginNavigation` and call `openList()` to select the Plugins main list, including when a detail page is already open. This browsing action preserves the current Session and opens no installation dialog. Installation remains an explicit action on the Plugins page.
|
||||
|
||||
### The store
|
||||
|
||||
@@ -140,7 +142,7 @@ Other Client plugins can inject `pluginNavigation` and call `openList()` to sele
|
||||
|
||||
Custom item pages use the Host entry id as their registration id; row pages use the bundle package and row id. The page owner supplies `form.state` and `form.mutate(operations, expectedRevision)` when the entry exposes editable Config fields. A custom page owns its draft and validation display, and may reuse `ConfigField` from ui-primitives. Bundle-wide pages can contain several entries and have no single form.
|
||||
|
||||
The page's `main` registration declares `plugins.item`, `plugins.bundle.config`, and `plugins.row.config` as its children, so the slots exist while the page does and a registrant's `ctx.slots.inject` waits for them. `configLedgerSource` projects the three ledgers into one observable — the official items in ledger order with their labels resolved in the active locale, and the bundle and row keys — cached until a ledger or the locale moves; the page binds it as `useConfigLedger` beside the store and never names a configurable plugin itself. The registration-owned navigation store selects the main list, the full experimental plugin list, a bundle, an official plugin, or a bundle row; switching away from the Plugins panel resets it to the list, while React remounts preserve the selected target. A fresh registration starts on the main list. Installation highlighting scrolls to a card only when it is on the current page; it does not change the selected page. Other Client plugins inject `pluginNavigation` and call `ctx.pluginNavigation.openBundle(packageName)` to open bundle details without changing the current Session. The target survives the initial inventory load; an absent bundle displays the list. A registration lives with the browser half that made it. `dsh-client-modules` attaches a package's browser half to the Loader row whose specifier is the bare package name, so every page a bundle registers, for itself or for any of its rows, goes away when that row is switched off; a sub-plugin whose page must outlive the other rows ships as its own package. Cards and detail pages show the Experimental marker only for packages in `EXPERIMENTAL_BUNDLES` in [presentation.ts](src/client/presentation.ts); package-name prefixes do not select the marker.
|
||||
The page's `main` registration declares `plugins.item`, `plugins.bundle.config`, and `plugins.row.config` as its children, so the slots exist while the page does and a registrant's `ctx.slots.inject` waits for them. `configLedgerSource` projects the three ledgers into one observable — the official items in ledger order with their labels resolved in the active locale, and the bundle and row keys — cached until a ledger or the locale moves; the page binds it as `useConfigLedger` beside the store and never names a configurable plugin itself. The registration-owned navigation store selects the main list, the full experimental plugin list, a bundle, an official plugin, or a bundle row; switching away from the Plugins panel resets it to the list, while React remounts preserve the selected target. A fresh registration starts on the main list. Installation highlighting scrolls to a card only when it is on the current page; it does not change the selected page. Other Client plugins read `pluginNavigation` optionally, through `ctx.get` or `ctx.inject`, and call its `openBundle(packageName)` to open bundle details without changing the current Session; listing it in a plugin's `inject` keeps that plugin pending on a page without access. The target survives the initial inventory load; an absent bundle displays the list. A registration lives with the browser half that made it. `dsh-client-modules` attaches a package's browser half to the Loader row whose specifier is the bare package name, so every page a bundle registers, for itself or for any of its rows, goes away when that row is switched off; a sub-plugin whose page must outlive the other rows ships as its own package. Cards and detail pages show the Experimental marker only for packages in `EXPERIMENTAL_BUNDLES` in [presentation.ts](src/client/presentation.ts); package-name prefixes do not select the marker.
|
||||
|
||||
`plugins.bundle.config` supplies bundle detail configuration, keyed by npm package name. Client plugins can subscribe to the read-only `ctx.pluginChanges.bundle(packageName)` source to pause their queries while a bundle or one of its rows is being changed. Notifications precede the management request and continue when the page closes; subscribers release their observation on disposal. `plugins.bundle.activation` offers bundle-owned guidance after explicit enablement from the list, with callbacks to dismiss it or open the bundle details. It does not appear merely because an enabled bundle was listed.
|
||||
|
||||
|
||||
@@ -112,6 +112,8 @@ ctx.slots.inject('plugins.detail.section', () => ctx.slots.register({
|
||||
|
||||
其他插件向 `plugins.tab` 列表 slot 注册条目,即可在页面自带的**已安装**标签页之后添加一个标签页;条目的 `label` 为标签页命名,`order` 决定其位置。至少存在一个条目时,标签页显示在页头下方;在组合包、行或官方插件的页面上隐藏。离开**插件**后,所选标签页保持不变;所选标签页的条目被移除时,回到**已安装**。页面渲染所选条目以代替自身的分组,并在此时隐藏自身的刷新和添加按钮。owner props 提供 `install({ spec, registry?, title, version })` 和 `openPackage(name)`。`install` 用于用户已在标签页中确认的版本:安装对话框在检查界面打开,不显示 spec 表单,在以较小等宽字显示的 spec 上方用 `title` 和 `version` 标明插件,并在进度和结果界面保持该名称,完成后显示已安装的版本;只在这一次安装中首先使用 `registry`,且从不记住它;被拒绝后提供**重试**;如果有隐藏中的安装任务,则改为重新打开该任务。`openPackage` 切换到**已安装**,并在该标签页列出该组合包时打开其页面,该页面的返回按钮回到打开它的标签页;页面不列出的组合包(例如内置 profile 组合包)只打开「已安装」列表。已确认的安装不提供 GitHub 镜像恢复,因为该恢复会重新打开 spec 表单;其返回按钮用于取消正在进行的安装。[`ui-plugin-market`](../ui-plugin-market/README.zh.md) 在这里贡献**发现**标签页。
|
||||
|
||||
访问控制:插件入口、页面、刷新提示、Host 变更订阅与 `pluginNavigation` 只在当前 Peer 可以调用 `pluginManager/listBundles` 时存在;之后每次授权都会重读已加载过的列表,撤权期间 `pluginChanges` 不更新。owner 始终有权限。
|
||||
|
||||
-----
|
||||
|
||||
<a id="understand-the-implementation"></a>
|
||||
@@ -130,7 +132,7 @@ Host 入口通过生成的 Remote 接口暴露 `pluginRegistryProbe.fastest()`
|
||||
|
||||
添加控件复用 `Button`、`Menu` 和 `MenuItemButton`。两个安装入口调用同一个 `openInstall` 回调,使用 `InstallDialog` 与 `PluginManagerController`。root 作用域的 list slot `plugins.add.actions` 接受贡献的 `MenuItemButton` 行,放在安装项之后;每个贡献者接收 `onDismiss()` 回调,在开始动作前关闭菜单。
|
||||
|
||||
其他 Client 插件可注入 `pluginNavigation` 并调用 `openList()`,选中插件主列表,已打开详情页时也如此。此浏览操作保留当前 Session,不打开安装对话框。安装仍需在插件页显式发起。
|
||||
插件页有权限时,其他 Client 插件可读取 `pluginNavigation` 并调用 `openList()`,选中插件主列表,已打开详情页时也如此。此浏览操作保留当前 Session,不打开安装对话框。安装仍需在插件页显式发起。
|
||||
|
||||
### store
|
||||
|
||||
@@ -140,7 +142,7 @@ Host 入口通过生成的 Remote 接口暴露 `pluginRegistryProbe.fastest()`
|
||||
|
||||
自定义条目页以 Host 条目 id 作为注册 id;行页面使用 bundle 包名和行 id。当条目提供可编辑 Config 字段时,页面宿主传入 `form.state` 和 `form.mutate(operations, expectedRevision)`。自定义页面负责草稿和校验提示,并可复用 ui-primitives 的 `ConfigField`。整个 bundle 的页面可以包含多个条目,因此没有单一表单。
|
||||
|
||||
页面的 `main` 注册把 `plugins.item`、`plugins.bundle.config` 与 `plugins.row.config` 声明为子 slot,因此它们与页面同生,注册方的 `ctx.slots.inject` 会等到它们出现。`configLedgerSource` 把三份账本投影成一个可观察对象——按账本顺序排列、标签按当前语言解析的官方条目,以及组合包与行的键——在账本或语言变化前保持缓存;页面把它作为 `useConfigLedger` 绑在 store 旁边,自身从不点名任何可配置插件。注册拥有的导航 store 选择主列表、完整的实验性插件列表、某个组合包、某个官方插件或组合包的某一行;切换离开插件面板时重置为列表,React 重新挂载则保留所选目标。新的注册从主列表开始。安装后的高亮仅在当前页面有对应卡片时滚动定位,不改变所选页面。其他 Client 插件注入 `pluginNavigation`,调用 `ctx.pluginNavigation.openBundle(packageName)` 即可打开组合包详情,不改变当前 Session。首次读取清单期间保留导航目标;组合包不存在时显示列表。注册与做出它的浏览器半侧同生共死。`dsh-client-modules` 只把一个包的浏览器半侧挂在说明符恰为包名的那一行 Loader 行上,所以组合包为自己或任一行注册的页面,都会在那一行被关闭时一起消失;需要在其他行关闭时仍保留页面的子插件,应作为独立的包发布。 卡片和详情页仅为 [presentation.ts](src/client/presentation.ts) 中 `EXPERIMENTAL_BUNDLES` 名单内的包显示实验性标签,包名前缀不决定标签。
|
||||
页面的 `main` 注册把 `plugins.item`、`plugins.bundle.config` 与 `plugins.row.config` 声明为子 slot,因此它们与页面同生,注册方的 `ctx.slots.inject` 会等到它们出现。`configLedgerSource` 把三份账本投影成一个可观察对象——按账本顺序排列、标签按当前语言解析的官方条目,以及组合包与行的键——在账本或语言变化前保持缓存;页面把它作为 `useConfigLedger` 绑在 store 旁边,自身从不点名任何可配置插件。注册拥有的导航 store 选择主列表、完整的实验性插件列表、某个组合包、某个官方插件或组合包的某一行;切换离开插件面板时重置为列表,React 重新挂载则保留所选目标。新的注册从主列表开始。安装后的高亮仅在当前页面有对应卡片时滚动定位,不改变所选页面。其他 Client 插件经 `ctx.get` 或 `ctx.inject` 以可选方式读取 `pluginNavigation`,调用其 `openBundle(packageName)` 即可打开组合包详情,不改变当前 Session;把它写进插件的 `inject` 会让该插件在无权限的页面上一直等待。首次读取清单期间保留导航目标;组合包不存在时显示列表。注册与做出它的浏览器半侧同生共死。`dsh-client-modules` 只把一个包的浏览器半侧挂在说明符恰为包名的那一行 Loader 行上,所以组合包为自己或任一行注册的页面,都会在那一行被关闭时一起消失;需要在其他行关闭时仍保留页面的子插件,应作为独立的包发布。 卡片和详情页仅为 [presentation.ts](src/client/presentation.ts) 中 `EXPERIMENTAL_BUNDLES` 名单内的包显示实验性标签,包名前缀不决定标签。
|
||||
|
||||
`plugins.bundle.config` 以 npm 包名为 key,提供 Bundle 详情配置。Client 插件可订阅只读的 `ctx.pluginChanges.bundle(packageName)`,在组合包或其组件行变更期间暂停查询。通知先于管理请求发出,页面关闭后仍会更新;订阅方在卸载时释放观察。`plugins.bundle.activation` 在用户从列表显式启用后提供 Bundle 自有引导,并传入关闭引导和打开详情的回调。仅列出已启用的 Bundle 不会触发引导。
|
||||
|
||||
|
||||
@@ -55,6 +55,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/cordis": "workspace:~",
|
||||
"@deepseek-ai/dsh-api-remote-access": "workspace:*",
|
||||
"@deepseek-ai/dsh-api-remotes": "workspace:*",
|
||||
"@deepseek-ai/dsh-client-locale": "workspace:*",
|
||||
"@deepseek-ai/dsh-client-store": "workspace:*",
|
||||
|
||||
@@ -16,6 +16,8 @@ import type { HostObservable } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import type { MainPanelId } from '@deepseek-ai/dsh-client-ui-layout/client'
|
||||
import type {} from '@deepseek-ai/dsh-client-ui-sidebar/client'
|
||||
import type {} from '@deepseek-ai/dsh-client-ui-renderer/client'
|
||||
// Type-only: the ctx.remoteAccess Context merge.
|
||||
import type {} from '@deepseek-ai/dsh-api-remote-access/client'
|
||||
// Type-only: the ctx.remote Context merge and the forwarded-event key face.
|
||||
import type {} from '@deepseek-ai/dsh-api-remotes/client'
|
||||
// Type-only: the forwarded events' own declaration (`$on`'s key face resolves
|
||||
@@ -80,12 +82,13 @@ export const NS = 'pluginManager'
|
||||
/** The id shared by the sidebar entry and the main panel it opens. */
|
||||
export const PANEL_ID = 'plugins' as MainPanelId
|
||||
|
||||
/** Services required by the sidebar registration and the Remote methods; the inventory says whether the Host manages a profile. */
|
||||
export const inject = ['slots', 'locale', 'remote', 'remote.pluginManager', 'remote.pluginInventory', 'remote.pluginRegistryProbe', 'configForms', 'layout']
|
||||
/** Services required by the sidebar registration, the Remote methods, and access; the inventory says whether the Host manages a profile. */
|
||||
export const inject = ['slots', 'locale', 'remote', 'remote.pluginManager', 'remote.pluginInventory', 'remote.pluginRegistryProbe', 'configForms', 'layout', 'remoteAccess']
|
||||
|
||||
/**
|
||||
* Contribute the Plugins entry to the sidebar with the management page it
|
||||
* opens, and keep it current on the Host's change events.
|
||||
* opens, and keep it current on the Host's change events, while the current
|
||||
* Peer may list the installed bundles.
|
||||
* @param ctx - the browser plugin context.
|
||||
*/
|
||||
export function apply(ctx: ClientContext): void {
|
||||
@@ -94,82 +97,79 @@ export function apply(ctx: ClientContext): void {
|
||||
const controller = new PluginManagerController(ctx)
|
||||
ctx.effect(() => () => { controller.dispose() }, 'ui-plugin-manager: controller')
|
||||
ctx.provide('pluginChanges', { bundle: packageName => controller.observeChanges(packageName) })
|
||||
// The Host says when what is installed, enabled, or composed changed — from
|
||||
// this page, the CLI, or another browser — and streams install output.
|
||||
ctx.effect(() => {
|
||||
// A page never rendered holds no snapshot to refresh.
|
||||
const refresh = (): void => {
|
||||
// A profile change can replace a bundle's files, and a restarted Host is read afresh.
|
||||
controller.details.reset()
|
||||
if (controller.getSnapshot().status !== 'idle') void controller.load()
|
||||
}
|
||||
const disposers = [
|
||||
ctx.remote.$on('plugin-manager/changed', refresh),
|
||||
ctx.remote.$on('plugin-manager/install-log', (chunk) => { controller.appendLog(chunk) }),
|
||||
ctx.remote.$on('plugin-manager/install-state', (progress) => { controller.installProgress(progress) }),
|
||||
ctx.remote.$on('plugin-manager/update-state', (progress) => { controller.updateProgress(progress) }),
|
||||
ctx.on('connection/reset', refresh),
|
||||
]
|
||||
return () => { for (const dispose of disposers) dispose() }
|
||||
}, 'ui-plugin-manager: host invalidations')
|
||||
|
||||
// The page is a global panel: it belongs to the profile, not to a Session,
|
||||
// and the sidebar's entry selects it. What is installed and switched on is
|
||||
// the page's own; a plugin's configuration arrives through the slots the
|
||||
// page declares here, so the page never names a configurable plugin.
|
||||
const configLedger = configLedgerSource(ctx)
|
||||
const face = controller.inject(configLedger, tabLedgerSource(ctx), text => ctx.locale.resolveText(text))
|
||||
ctx.slots.inject('shell.overlay', () => ctx.slots.register({
|
||||
name: 'shell.overlay', id: 'plugin-manager.refresh-toast', locale: NS,
|
||||
inject: (): PluginRefreshToastFace => ({
|
||||
hooks: { pluginManager: face.hooks.pluginManager },
|
||||
dismissNotice: face.dismissNotice,
|
||||
}),
|
||||
}, PluginRefreshToast))
|
||||
ctx.slots.inject('main', function* () {
|
||||
const handle = createNavigationStore(), instance = handle.create()
|
||||
const store: typeof handle = { ...handle, create: () => instance }
|
||||
yield ctx.slots.register({
|
||||
name: 'main',
|
||||
key: PANEL_ID,
|
||||
ctx.remoteAccess.whenAccess('pluginManager/listBundles', function* () {
|
||||
// The Host says when what is installed, enabled, or composed changed — from
|
||||
// this page, the CLI, or another browser — and streams install output.
|
||||
// A page never rendered holds no snapshot to refresh.
|
||||
const refresh = (): void => {
|
||||
// A profile change can replace a bundle's files, and a restarted Host is read afresh.
|
||||
controller.details.reset()
|
||||
if (controller.getSnapshot().status !== 'idle') void controller.load()
|
||||
}
|
||||
// Host changes made while access was withdrawn reached no subscription below.
|
||||
refresh()
|
||||
yield ctx.remote.$on('plugin-manager/changed', refresh)
|
||||
yield ctx.remote.$on('plugin-manager/install-log', (chunk) => { controller.appendLog(chunk) })
|
||||
yield ctx.remote.$on('plugin-manager/install-state', (progress) => { controller.installProgress(progress) })
|
||||
yield ctx.remote.$on('plugin-manager/update-state', (progress) => { controller.updateProgress(progress) })
|
||||
yield ctx.on('connection/reset', refresh)
|
||||
yield ctx.slots.inject('shell.overlay', () => ctx.slots.register({
|
||||
name: 'shell.overlay', id: 'plugin-manager.refresh-toast', locale: NS,
|
||||
inject: (): PluginRefreshToastFace => ({
|
||||
hooks: { pluginManager: face.hooks.pluginManager },
|
||||
dismissNotice: face.dismissNotice,
|
||||
}),
|
||||
}, PluginRefreshToast))
|
||||
yield ctx.slots.inject('main', function* () {
|
||||
const handle = createNavigationStore(), instance = handle.create()
|
||||
const store: typeof handle = { ...handle, create: () => instance }
|
||||
yield ctx.slots.register({
|
||||
name: 'main',
|
||||
key: PANEL_ID,
|
||||
locale: NS,
|
||||
store,
|
||||
inject: () => ({ ...face, createHeaderRef: createPluginHeaderRef }),
|
||||
children: {
|
||||
'plugins.add.actions': { kind: 'list', scope: 'root' },
|
||||
'plugins.tab': { kind: 'list', scope: 'root' },
|
||||
'plugins.item': { kind: 'list', scope: 'root' },
|
||||
'plugins.bundle.activation': { kind: 'keyed', scope: 'root' },
|
||||
'plugins.bundle.config': { kind: 'keyed', scope: 'root' },
|
||||
'plugins.row.config': { kind: 'keyed', scope: 'root' },
|
||||
'plugins.detail.actions': { kind: 'list', scope: 'root' },
|
||||
'plugins.detail.badge': { kind: 'list', scope: 'root' },
|
||||
'plugins.detail.section': { kind: 'list', scope: 'root' },
|
||||
},
|
||||
}, PluginManagerPage)
|
||||
yield ctx.layout.panelInfo.subscribe(() => {
|
||||
if (ctx.layout.panelInfo.getSnapshot().activePanelId !== PANEL_ID) instance.actions.setView({ kind: 'list' })
|
||||
})
|
||||
const disposeNavigation = ctx.reflect.provide('pluginNavigation', {
|
||||
openList: () => {
|
||||
ctx.layout.selectPanel(PANEL_ID)
|
||||
instance.actions.setTab('installed')
|
||||
instance.actions.setView({ kind: 'list' })
|
||||
},
|
||||
openBundle: (packageName: string) => {
|
||||
ctx.layout.selectPanel(PANEL_ID)
|
||||
instance.actions.setTab('installed')
|
||||
instance.actions.setView({ kind: 'package', name: packageName })
|
||||
},
|
||||
})
|
||||
yield () => { void disposeNavigation() }
|
||||
})
|
||||
yield ctx.slots.inject('sidebar.panellist', () => ctx.slots.register({
|
||||
name: 'sidebar.panellist',
|
||||
id: PANEL_ID,
|
||||
order: 0,
|
||||
label: () => t('panel'),
|
||||
locale: NS,
|
||||
store,
|
||||
inject: () => ({ ...face, createHeaderRef: createPluginHeaderRef }),
|
||||
children: {
|
||||
'plugins.add.actions': { kind: 'list', scope: 'root' },
|
||||
'plugins.tab': { kind: 'list', scope: 'root' },
|
||||
'plugins.item': { kind: 'list', scope: 'root' },
|
||||
'plugins.bundle.activation': { kind: 'keyed', scope: 'root' },
|
||||
'plugins.bundle.config': { kind: 'keyed', scope: 'root' },
|
||||
'plugins.row.config': { kind: 'keyed', scope: 'root' },
|
||||
'plugins.detail.actions': { kind: 'list', scope: 'root' },
|
||||
'plugins.detail.badge': { kind: 'list', scope: 'root' },
|
||||
'plugins.detail.section': { kind: 'list', scope: 'root' },
|
||||
},
|
||||
}, PluginManagerPage)
|
||||
yield ctx.layout.panelInfo.subscribe(() => {
|
||||
if (ctx.layout.panelInfo.getSnapshot().activePanelId !== PANEL_ID) instance.actions.setView({ kind: 'list' })
|
||||
})
|
||||
const disposeNavigation = ctx.reflect.provide('pluginNavigation', {
|
||||
openList: () => {
|
||||
ctx.layout.selectPanel(PANEL_ID)
|
||||
instance.actions.setTab('installed')
|
||||
instance.actions.setView({ kind: 'list' })
|
||||
},
|
||||
openBundle: (packageName: string) => {
|
||||
ctx.layout.selectPanel(PANEL_ID)
|
||||
instance.actions.setTab('installed')
|
||||
instance.actions.setView({ kind: 'package', name: packageName })
|
||||
},
|
||||
})
|
||||
yield () => { void disposeNavigation() }
|
||||
}, PluginsPanelIcon))
|
||||
})
|
||||
ctx.slots.inject('sidebar.panellist', () => ctx.slots.register({
|
||||
name: 'sidebar.panellist',
|
||||
id: PANEL_ID,
|
||||
order: 0,
|
||||
label: () => t('panel'),
|
||||
locale: NS,
|
||||
}, PluginsPanelIcon))
|
||||
|
||||
}
|
||||
@@ -8,7 +8,7 @@ import { createSnapshotStore } from '@deepseek-ai/dsh-client-store'
|
||||
import type { ILayout, PanelInfo } from '@deepseek-ai/dsh-client-ui-layout/client'
|
||||
import { SlotRegistry } from '@deepseek-ai/dsh-client-ui-renderer/client'
|
||||
import { resolveSlotLabel } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import { allowAllAccess, TestRemote, usePinnedBrowserLanguages } from '@deepseek-ai/dsh-client-test-runtime'
|
||||
import { allowAllAccess, StaticRemoteAccess, TestRemote, usePinnedBrowserLanguages } from '@deepseek-ai/dsh-client-test-runtime'
|
||||
import * as settings from '@deepseek-ai/dsh-client-ui-settings/client'
|
||||
import { apply, inject, NS, PANEL_ID } from '../src/client/index.ts'
|
||||
import { PluginManagerPage } from '../src/client/PluginManagerPage.tsx'
|
||||
@@ -21,10 +21,53 @@ usePinnedBrowserLanguages('zh-CN')
|
||||
beforeEach(() => { localStorage.clear() })
|
||||
afterEach(cleanup)
|
||||
|
||||
async function bench() {
|
||||
type AccessEffect = (() => void) | Iterable<() => void>
|
||||
|
||||
/** One guarded callback, the Context that owns it, and the disposer of its current installation. */
|
||||
interface Guard {
|
||||
readonly owner: Context
|
||||
readonly callback: () => AccessEffect
|
||||
dispose: (() => void) | undefined
|
||||
}
|
||||
|
||||
/** A `remoteAccess` stand-in whose one answer a spec switches, installing or withdrawing every guarded effect. */
|
||||
class SwitchableAccess extends Service {
|
||||
private allowed = true
|
||||
private readonly guards: Guard[] = []
|
||||
|
||||
constructor(serviceCtx: Context) {
|
||||
super(serviceCtx, 'remoteAccess')
|
||||
}
|
||||
|
||||
whenAccess(_action: string, callback: () => AccessEffect): () => void {
|
||||
const guard: Guard = { owner: this.ctx, callback, dispose: undefined }
|
||||
this.guards.push(guard)
|
||||
this.install(guard)
|
||||
return () => {}
|
||||
}
|
||||
|
||||
/** @param allowed - the answer every guarded effect follows from now on. */
|
||||
set(allowed: boolean): void {
|
||||
this.allowed = allowed
|
||||
for (const guard of this.guards) {
|
||||
if (allowed) this.install(guard)
|
||||
else { guard.dispose?.(); guard.dispose = undefined }
|
||||
}
|
||||
}
|
||||
|
||||
private install(guard: Guard): void {
|
||||
if (!this.allowed || guard.dispose !== undefined) return
|
||||
const dispose = guard.owner.effect(guard.callback)
|
||||
guard.dispose = () => { void dispose() }
|
||||
}
|
||||
}
|
||||
|
||||
async function bench(allows: ((action: string) => boolean) | 'switchable' = () => true) {
|
||||
const ctx = new Context()
|
||||
onTestFinished(async () => { await ctx.fiber.dispose() })
|
||||
await ctx.plugin(SlotRegistry).await()
|
||||
const access = allows === 'switchable' ? new SwitchableAccess(ctx) : undefined
|
||||
if (allows !== 'switchable') new StaticRemoteAccess(ctx, allows)
|
||||
const locale = new LocaleRuntime(ctx)
|
||||
ctx.provide('locale', locale)
|
||||
class LocaleHolder extends Service {
|
||||
@@ -50,7 +93,7 @@ async function bench() {
|
||||
ctx.provide('layout', { panelInfo, selectPanel, beginNavigation: () => new AbortController().signal,
|
||||
toggleSidebar: vi.fn(), openRightbar: vi.fn(), closeRightbar: vi.fn() })
|
||||
await ctx.plugin(settings).await()
|
||||
return { ctx, slots: ctx.get('slots') as SlotRegistry, locale, list, remote, selectPanel, panelInfo }
|
||||
return { ctx, slots: ctx.get('slots') as SlotRegistry, locale, list, remote, selectPanel, panelInfo, access }
|
||||
}
|
||||
|
||||
function declare(slots: SlotRegistry): () => void {
|
||||
@@ -153,7 +196,41 @@ describe('ui-plugin-manager browser plugin', () => {
|
||||
})
|
||||
|
||||
it('declares only the services the page and its Remote methods use', () => {
|
||||
expect(inject).toEqual(['slots', 'locale', 'remote', 'remote.pluginManager', 'remote.pluginInventory', 'remote.pluginRegistryProbe', 'configForms', 'layout'])
|
||||
expect(inject).toEqual(['slots', 'locale', 'remote', 'remote.pluginManager', 'remote.pluginInventory', 'remote.pluginRegistryProbe', 'configForms', 'layout', 'remoteAccess'])
|
||||
})
|
||||
|
||||
it('reads the Host again on a later grant after showing the page, and not while access is withdrawn', async () => {
|
||||
const b = await bench('switchable')
|
||||
declare(b.slots)
|
||||
await b.ctx.plugin({ inject: [...inject], apply }).await()
|
||||
const injected: object = b.slots.entries('main')[0]!.inject!()
|
||||
const face = injected as PluginManagerFace
|
||||
face.ensure()
|
||||
await vi.waitFor(() => { expect(face.hooks.pluginManager.getSnapshot().status).toBe('ready') })
|
||||
expect(b.list).toHaveBeenCalledTimes(1)
|
||||
|
||||
b.access!.set(false)
|
||||
expect(b.slots.entries('main')).toEqual([])
|
||||
b.remote.emit('plugin-manager/changed', [{ reason: 'install' }])
|
||||
await Promise.resolve()
|
||||
expect(b.list).toHaveBeenCalledTimes(1)
|
||||
|
||||
b.access!.set(true)
|
||||
expect(b.slots.entries('main')).toHaveLength(1)
|
||||
await vi.waitFor(() => { expect(b.list).toHaveBeenCalledTimes(2) })
|
||||
})
|
||||
|
||||
it('contributes the entry, page, and navigation only while the current Peer may list bundles, and observes changes either way', async () => {
|
||||
const allows = vi.fn(() => false)
|
||||
const b = await bench(allows)
|
||||
declare(b.slots)
|
||||
const fiber = b.ctx.plugin({ inject: [...inject], apply })
|
||||
await fiber.await()
|
||||
expect(allows).toHaveBeenCalledWith('pluginManager/listBundles')
|
||||
for (const name of ['main', 'shell.overlay', 'sidebar.panellist'] as const) expect(b.slots.entries(name)).toEqual([])
|
||||
expect(b.ctx.get('pluginNavigation')).toBeUndefined()
|
||||
expect(b.ctx.get('pluginChanges')?.bundle('dsh-any').getSnapshot()).toBe(false)
|
||||
await fiber.dispose()
|
||||
})
|
||||
|
||||
it('registers the sidebar entry and its page, which reads the Host only once rendered and follows Host changes', async () => {
|
||||
|
||||
@@ -18,8 +18,8 @@
|
||||
en: 4c0dca55e4e9866d
|
||||
zh: 9ee6b565c7a81353
|
||||
/deepseek-ai-dsh-client-ui-schedule/use-this-package/time-zone-data-source:
|
||||
en: 6bd6882dbbbefcb0
|
||||
zh: 03aa15f1a65d13dc
|
||||
en: 47cd16c142e82385
|
||||
zh: 181849a3c7a167fd
|
||||
/deepseek-ai-dsh-client-ui-schedule/understand-the-implementation:
|
||||
en: 959145f9a08c3913
|
||||
zh: caae642a82d2acd3
|
||||
|
||||
@@ -57,6 +57,8 @@ The Sidebar's Session rows mark scheduled Sessions: while a row is idle, unarchi
|
||||
|
||||
Time-zone inventory and localization are offline. `Intl` reads ICU/CLDR tables embedded in the browser runtime and never fetches or downloads zone data while DSH runs. There is no crawler to refresh; a third-party locale supplies `time.locale` and translates only the selector's own UI copy.
|
||||
|
||||
Access: the plugin registers these surfaces only while the current Peer may call `schedule/catalog`, and the Session-header reminder catalog renders only while that Session also allows `schedule/list`; the task tab type and the turn definition stay registered. The owner always has access.
|
||||
|
||||
<a id="understand-the-implementation"></a>
|
||||
## Understand the implementation
|
||||
|
||||
|
||||
@@ -57,6 +57,8 @@ kind: "package-reference"
|
||||
|
||||
时区清单与本地化完全离线。`Intl` 读取浏览器运行时内嵌的 ICU/CLDR 表,DSH 运行时不会获取或下载时区数据。没有需要刷新的爬虫;第三方语言包提供 `time.locale`,并仅翻译选择器自身的 UI 文案。
|
||||
|
||||
访问控制:只有当前 Peer 可以调用 `schedule/catalog` 时本插件才注册这些界面,会话头部的提醒还要求该会话允许 `schedule/list` 才渲染;任务 tab 类型与 turn 定义始终注册。owner 始终有权限。
|
||||
|
||||
<a id="understand-the-implementation"></a>
|
||||
## 理解实现
|
||||
|
||||
|
||||
@@ -54,6 +54,7 @@
|
||||
"@deepseek-ai/cordis": "workspace:~"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-api-remote-access": "workspace:*",
|
||||
"@deepseek-ai/dsh-api-remotes": "workspace:*",
|
||||
"@deepseek-ai/dsh-api-session-controller": "workspace:*",
|
||||
"@deepseek-ai/dsh-api-workspace-controller": "workspace:*",
|
||||
|
||||
@@ -31,8 +31,17 @@ export type ScheduleCatalogActionProps =
|
||||
const SECOND_MS = 1_000
|
||||
const MEASURE_STYLE: CSSProperties = { visibility: 'hidden', left: 0, top: 0 }
|
||||
|
||||
/** Current-Session reminder catalog with durable deletion. */
|
||||
export function ScheduleCatalogAction({
|
||||
/**
|
||||
* Current-Session reminder catalog with durable deletion, rendered only while
|
||||
* the current Peer may list this Session's reminders.
|
||||
* @param props - the Session seats, the injected catalog and verbs, and localized copy.
|
||||
* @returns the reminder catalog action, or nothing without access.
|
||||
*/
|
||||
export function ScheduleCatalogAction(props: ScheduleCatalogActionProps) {
|
||||
return props.useAccess('schedule/list') ? <SessionScheduleCatalog {...props} /> : null
|
||||
}
|
||||
|
||||
function SessionScheduleCatalog({
|
||||
useSession, useCatalog, onDelete, onRetry, openTaskDetail, t,
|
||||
}: ScheduleCatalogActionProps) {
|
||||
const openState = useSession(snapshot => snapshot.openState)
|
||||
|
||||
@@ -38,6 +38,7 @@ import type {} from '@deepseek-ai/dsh-client-ui-sidebar-right/client'
|
||||
import type {} from '@deepseek-ai/dsh-client-ui-tool/client'
|
||||
import type {} from '@deepseek-ai/dsh-client-ui-workspace/client'
|
||||
import type { MainPanelId } from '@deepseek-ai/dsh-client-ui-layout/client'
|
||||
import type {} from '@deepseek-ai/dsh-api-remote-access/client'
|
||||
import type {} from '@deepseek-ai/dsh-api-remotes/client'
|
||||
import type {} from '@deepseek-ai/dsh-client-connection/client'
|
||||
import type { SessionId } from '@deepseek-ai/dsh-session/types'
|
||||
@@ -73,16 +74,16 @@ declare module '@deepseek-ai/dsh-client-ui-slots' {
|
||||
}
|
||||
}
|
||||
|
||||
/** Required services for catalogs, ambient Session marks, the right Sidebar, Remote queries, and original-Session navigation. */
|
||||
/** Required services for catalogs, ambient Session marks, the right Sidebar, Remote queries, original-Session navigation, and access. */
|
||||
export const inject = [
|
||||
'slots', 'locale', 'remote', 'remote.schedule', 'conversation', 'uiConversation', 'uiWorkspace', 'sessions',
|
||||
'workspaces', 'sidebarRightTabs', 'sidebarRight',
|
||||
'workspaces', 'sidebarRightTabs', 'sidebarRight', 'remoteAccess',
|
||||
]
|
||||
|
||||
/**
|
||||
* Register the Host task page, the Session-header reminder catalog, the
|
||||
* right-Sidebar page that shows one task's detail, and the transcript card of
|
||||
* one created task.
|
||||
* one created task while the current Peer may read the task catalog.
|
||||
* @param ctx - browser services used by these contributions.
|
||||
*/
|
||||
export function apply(ctx: ClientContext): void {
|
||||
@@ -90,157 +91,159 @@ export function apply(ctx: ClientContext): void {
|
||||
const managerDictionaries = { zh: managerZh, en: managerEn, ja: managerJa }
|
||||
ctx.effect(() => ctx.locale.register(MANAGER_NS, managerDictionaries), 'ui-schedule: manager dictionaries')
|
||||
const t = ctx.locale.bind(MANAGER_NS)
|
||||
const manager: CatalogInjected<ScheduleCatalogEntry> = createCatalogSource<ScheduleCatalogEntry>({
|
||||
list: () => ctx.remote.schedule.catalog(),
|
||||
remove: async (id) => {
|
||||
const record = manager.hooks.catalog.getSnapshot().records.find(item => item.id === id)
|
||||
if (record === undefined) return { ok: true, value: { id, deleted: false, code: 'schedule_not_found' } }
|
||||
return ctx.remote.schedule.delete({ sessionId: record.sessionId, id })
|
||||
},
|
||||
subscribeChanged: listener => ctx.remote.$on('schedule/changed', listener),
|
||||
subscribeReset: listener => ctx.on('connection/reset', listener),
|
||||
})
|
||||
// One outcome store behind one `shell.overlay` entry announces every deletion
|
||||
// this plugin's surfaces settle, so the notice outlives the panel, popover, or
|
||||
// tab the deletion started in.
|
||||
const deleteToast = createDeleteToastSource()
|
||||
const reportedDelete = (onDelete: (id: ScheduleId) => Promise<CatalogDeleteOutcome>) =>
|
||||
async (id: ScheduleId): Promise<CatalogDeleteOutcome> => {
|
||||
const outcome = await onDelete(id)
|
||||
deleteToast.report(outcome)
|
||||
return outcome
|
||||
}
|
||||
ctx.slots.inject('shell.overlay', () => ctx.slots.register({
|
||||
name: 'shell.overlay', id: 'schedule.delete-toast', locale: MANAGER_NS,
|
||||
inject: () => ({ hooks: deleteToast.hooks, dismiss: deleteToast.dismiss }),
|
||||
}, ScheduleDeleteToast))
|
||||
/**
|
||||
* Forward one compare-and-update request, which may replace the task name,
|
||||
* the instruction, and/or the timing, and refresh the authoritative catalog
|
||||
* after an accepted or stale outcome.
|
||||
* @param request - complete expected record with the optional content and timing change.
|
||||
* @returns the original Remote mutation result.
|
||||
*/
|
||||
const updateTask = async (request: ScheduleUpdateRequest) => {
|
||||
const result = await ctx.remote.schedule.update(request)
|
||||
if (result.ok && ('record' in result.value || result.value.code === 'schedule_conflict'
|
||||
|| result.value.code === 'schedule_ended' || result.value.code === 'schedule_not_found')) {
|
||||
// The write is in, so this readback must not join a read that was sent
|
||||
// before it: naming every request seen so far makes the source issue a new
|
||||
// one, the way the post-deletion readback supersedes.
|
||||
await manager.onRetry(manager.hooks.catalog.getSnapshot().readRequest)
|
||||
}
|
||||
return result
|
||||
}
|
||||
const loadHistory = (request: ScheduleDeliveryHistoryRequest) => ctx.remote.schedule.history(request)
|
||||
const openSession = (id: SessionId): void => {
|
||||
if (sessionLinkState(id, ctx.sessions.list.getSnapshot(), ctx.workspaces.list.getSnapshot()) === 'available') {
|
||||
ctx.uiWorkspace.openSession(id)
|
||||
}
|
||||
}
|
||||
const detail: TaskDetailInjected & ScheduleTaskCatalogInjected = {
|
||||
hooks: manager.hooks,
|
||||
onDelete: reportedDelete(manager.onDelete),
|
||||
onRetry: manager.onRetry,
|
||||
onUpdateTiming: updateTask,
|
||||
loadHistory,
|
||||
onOpenSession: openSession,
|
||||
}
|
||||
// The Sidebar persists a tab's layout record and not its navigation
|
||||
// parameters, so this page type keeps the task it last showed per Session and
|
||||
// tab id. That Session's committed tab ids bound every write, so a closed tab
|
||||
// leaves no entry behind.
|
||||
const taskBindings = new TaskTabBindings(
|
||||
sessionId => ctx.sidebarRight.tabsIn(sessionId).map(tab => tab.id),
|
||||
)
|
||||
ctx.effect(() => ctx.sidebarRightTabs.register(scheduleTaskDefinition(t)), 'ui-schedule: task tab type')
|
||||
ctx.slots.inject('sidebar.right.pane.tab', () => ctx.slots.register({
|
||||
name: 'sidebar.right.pane.tab',
|
||||
key: SCHEDULE_TASK_ID,
|
||||
locale: MANAGER_NS,
|
||||
inject: (): ScheduleTaskTabInjected => ({ ...detail, taskBindings }),
|
||||
}, ScheduleTaskTab))
|
||||
ctx.slots.inject('sidebar.right.pane.tab.title', () => ctx.slots.register({
|
||||
name: 'sidebar.right.pane.tab.title',
|
||||
key: SCHEDULE_TASK_ID,
|
||||
locale: MANAGER_NS,
|
||||
inject: (): ScheduleTaskCatalogInjected & ScheduleTaskBindingInjected => ({ hooks: detail.hooks, taskBindings }),
|
||||
}, ScheduleTaskTabTitle))
|
||||
ctx.slots.inject('main', () => ctx.slots.register({
|
||||
name: 'main',
|
||||
key: PANEL_ID,
|
||||
locale: MANAGER_NS,
|
||||
inject: (): TaskManagerInjected => ({
|
||||
...detail,
|
||||
// The page has no creation form: a new reminder starts in a Session.
|
||||
onNewTask: () => { ctx.uiWorkspace.startSession() },
|
||||
}),
|
||||
}, TaskManagerPage))
|
||||
ctx.slots.inject('sidebar.panellist', () => ctx.slots.register({
|
||||
name: 'sidebar.panellist',
|
||||
id: PANEL_ID,
|
||||
order: 10,
|
||||
locale: MANAGER_NS,
|
||||
label: () => t('panel'),
|
||||
}, TaskManagerIcon))
|
||||
// The created task is a Turn-level element, not a Tool-group row: the Turn
|
||||
// Definition publishes the settled result and this tail list entry renders
|
||||
// the card beneath the closing prose.
|
||||
// Definition publishes the settled result and the tail list entry below
|
||||
// renders the card beneath the closing prose.
|
||||
ctx.uiConversation.events.register(scheduleTurnDefinition)
|
||||
ctx.slots.inject('conversation.chat.turnTail', () => ctx.slots.register({
|
||||
name: 'conversation.chat.turnTail',
|
||||
id: 'schedule-created',
|
||||
order: 20,
|
||||
locale: MANAGER_NS,
|
||||
inject: (sessionId: SessionId): ScheduleTurnCardInjected => ({
|
||||
hooks: { catalog: manager.hooks.catalog },
|
||||
ctx.remoteAccess.whenAccess('schedule/catalog', function* () {
|
||||
const manager: CatalogInjected<ScheduleCatalogEntry> = createCatalogSource<ScheduleCatalogEntry>({
|
||||
list: () => ctx.remote.schedule.catalog(),
|
||||
remove: async (id) => {
|
||||
const record = manager.hooks.catalog.getSnapshot().records.find(item => item.id === id)
|
||||
if (record === undefined) return { ok: true, value: { id, deleted: false, code: 'schedule_not_found' } }
|
||||
return ctx.remote.schedule.delete({ sessionId: record.sessionId, id })
|
||||
},
|
||||
subscribeChanged: listener => ctx.remote.$on('schedule/changed', listener),
|
||||
subscribeReset: listener => ctx.on('connection/reset', listener),
|
||||
})
|
||||
// One outcome store behind one `shell.overlay` entry announces every deletion
|
||||
// this plugin's surfaces settle, so the notice outlives the panel, popover, or
|
||||
// tab the deletion started in.
|
||||
const deleteToast = createDeleteToastSource()
|
||||
const reportedDelete = (onDelete: (id: ScheduleId) => Promise<CatalogDeleteOutcome>) =>
|
||||
async (id: ScheduleId): Promise<CatalogDeleteOutcome> => {
|
||||
const outcome = await onDelete(id)
|
||||
deleteToast.report(outcome)
|
||||
return outcome
|
||||
}
|
||||
yield ctx.slots.inject('shell.overlay', () => ctx.slots.register({
|
||||
name: 'shell.overlay', id: 'schedule.delete-toast', locale: MANAGER_NS,
|
||||
inject: () => ({ hooks: deleteToast.hooks, dismiss: deleteToast.dismiss }),
|
||||
}, ScheduleDeleteToast))
|
||||
/**
|
||||
* Forward one compare-and-update request, which may replace the task name,
|
||||
* the instruction, and/or the timing, and refresh the authoritative catalog
|
||||
* after an accepted or stale outcome.
|
||||
* @param request - complete expected record with the optional content and timing change.
|
||||
* @returns the original Remote mutation result.
|
||||
*/
|
||||
const updateTask = async (request: ScheduleUpdateRequest) => {
|
||||
const result = await ctx.remote.schedule.update(request)
|
||||
if (result.ok && ('record' in result.value || result.value.code === 'schedule_conflict'
|
||||
|| result.value.code === 'schedule_ended' || result.value.code === 'schedule_not_found')) {
|
||||
// The write is in, so this readback must not join a read that was sent
|
||||
// before it: naming every request seen so far makes the source issue a new
|
||||
// one, the way the post-deletion readback supersedes.
|
||||
await manager.onRetry(manager.hooks.catalog.getSnapshot().readRequest)
|
||||
}
|
||||
return result
|
||||
}
|
||||
const loadHistory = (request: ScheduleDeliveryHistoryRequest) => ctx.remote.schedule.history(request)
|
||||
const openSession = (id: SessionId): void => {
|
||||
if (sessionLinkState(id, ctx.sessions.list.getSnapshot(), ctx.workspaces.list.getSnapshot()) === 'available') {
|
||||
ctx.uiWorkspace.openSession(id)
|
||||
}
|
||||
}
|
||||
const detail: TaskDetailInjected & ScheduleTaskCatalogInjected = {
|
||||
hooks: manager.hooks,
|
||||
onDelete: reportedDelete(manager.onDelete),
|
||||
onRetry: manager.onRetry,
|
||||
openTaskDetail: (id) => {
|
||||
ctx.sidebarRight.openTab(SCHEDULE_TASK_KIND, { params: { sessionId, id } })
|
||||
},
|
||||
}),
|
||||
}, ScheduleTurnCard))
|
||||
// The Session header keeps one per-Session source: it lists the open
|
||||
// Session's active tasks and starts only while that header is mounted. The
|
||||
// Sidebar row seats read the shared `manager` catalog instead, so every
|
||||
// visible row projects one Host query rather than issuing its own.
|
||||
const createSource: SessionScheduleSourceFactory = sessionId =>
|
||||
createSessionScheduleSource(ctx, sessionId)
|
||||
ctx.slots.inject(
|
||||
'conversation.session.header.utilities',
|
||||
() => ctx.slots.register({
|
||||
name: 'conversation.session.header.utilities',
|
||||
id: 'schedule-catalog',
|
||||
// Sits left of the overflow menu (order 0) and right of the header's
|
||||
// leading utilities (order -10), matching the shipped mock.
|
||||
order: -5,
|
||||
onUpdateTiming: updateTask,
|
||||
loadHistory,
|
||||
onOpenSession: openSession,
|
||||
}
|
||||
// The Sidebar persists a tab's layout record and not its navigation
|
||||
// parameters, so this page type keeps the task it last showed per Session and
|
||||
// tab id. That Session's committed tab ids bound every write, so a closed tab
|
||||
// leaves no entry behind.
|
||||
const taskBindings = new TaskTabBindings(
|
||||
sessionId => ctx.sidebarRight.tabsIn(sessionId).map(tab => tab.id),
|
||||
)
|
||||
yield ctx.slots.inject('sidebar.right.pane.tab', () => ctx.slots.register({
|
||||
name: 'sidebar.right.pane.tab',
|
||||
key: SCHEDULE_TASK_ID,
|
||||
locale: MANAGER_NS,
|
||||
inject: (): ScheduleTaskTabInjected => ({ ...detail, taskBindings }),
|
||||
}, ScheduleTaskTab))
|
||||
yield ctx.slots.inject('sidebar.right.pane.tab.title', () => ctx.slots.register({
|
||||
name: 'sidebar.right.pane.tab.title',
|
||||
key: SCHEDULE_TASK_ID,
|
||||
locale: MANAGER_NS,
|
||||
inject: (): ScheduleTaskCatalogInjected & ScheduleTaskBindingInjected => ({ hooks: detail.hooks, taskBindings }),
|
||||
}, ScheduleTaskTabTitle))
|
||||
yield ctx.slots.inject('main', () => ctx.slots.register({
|
||||
name: 'main',
|
||||
key: PANEL_ID,
|
||||
locale: MANAGER_NS,
|
||||
inject: (): TaskManagerInjected => ({
|
||||
...detail,
|
||||
// The page has no creation form: a new reminder starts in a Session.
|
||||
onNewTask: () => { ctx.uiWorkspace.startSession() },
|
||||
}),
|
||||
}, TaskManagerPage))
|
||||
yield ctx.slots.inject('sidebar.panellist', () => ctx.slots.register({
|
||||
name: 'sidebar.panellist',
|
||||
id: PANEL_ID,
|
||||
order: 10,
|
||||
locale: MANAGER_NS,
|
||||
label: () => t('panel'),
|
||||
}, TaskManagerIcon))
|
||||
yield ctx.slots.inject('conversation.chat.turnTail', () => ctx.slots.register({
|
||||
name: 'conversation.chat.turnTail',
|
||||
id: 'schedule-created',
|
||||
order: 20,
|
||||
locale: MANAGER_NS,
|
||||
inject: (sessionId: SessionId): ScheduleTurnCardInjected => ({
|
||||
hooks: { catalog: manager.hooks.catalog },
|
||||
onRetry: manager.onRetry,
|
||||
openTaskDetail: (id) => {
|
||||
ctx.sidebarRight.openTab(SCHEDULE_TASK_KIND, { params: { sessionId, id } })
|
||||
},
|
||||
}),
|
||||
}, ScheduleTurnCard))
|
||||
// The Session header keeps one per-Session source: it lists the open
|
||||
// Session's active tasks and starts only while that header is mounted. The
|
||||
// Sidebar row seats read the shared `manager` catalog instead, so every
|
||||
// visible row projects one Host query rather than issuing its own.
|
||||
const createSource: SessionScheduleSourceFactory = sessionId =>
|
||||
createSessionScheduleSource(ctx, sessionId)
|
||||
yield ctx.slots.inject(
|
||||
'conversation.session.header.utilities',
|
||||
() => ctx.slots.register({
|
||||
name: 'conversation.session.header.utilities',
|
||||
id: 'schedule-catalog',
|
||||
// Sits left of the overflow menu (order 0) and right of the header's
|
||||
// leading utilities (order -10), matching the shipped mock.
|
||||
order: -5,
|
||||
locale: NS,
|
||||
inject: (sessionId) => {
|
||||
const source = createSource(sessionId)
|
||||
return {
|
||||
...source,
|
||||
// The header's deletions settle through the same app-wide toast as the
|
||||
// Tasks page and the task tab.
|
||||
onDelete: reportedDelete(source.onDelete),
|
||||
openTaskDetail: (id: ScheduleId) => {
|
||||
ctx.sidebarRight.openTab(SCHEDULE_TASK_KIND, { params: { sessionId, id } })
|
||||
},
|
||||
}
|
||||
},
|
||||
}, ScheduleCatalogAction),
|
||||
)
|
||||
yield ctx.slots.inject('sidebar.session.row.leading', () => ctx.slots.register({
|
||||
name: 'sidebar.session.row.leading',
|
||||
id: 'schedule-mark',
|
||||
order: 10,
|
||||
locale: NS,
|
||||
inject: (sessionId) => {
|
||||
const source = createSource(sessionId)
|
||||
return {
|
||||
...source,
|
||||
// The header's deletions settle through the same app-wide toast as the
|
||||
// Tasks page and the task tab.
|
||||
onDelete: reportedDelete(source.onDelete),
|
||||
openTaskDetail: (id: ScheduleId) => {
|
||||
ctx.sidebarRight.openTab(SCHEDULE_TASK_KIND, { params: { sessionId, id } })
|
||||
},
|
||||
}
|
||||
},
|
||||
}, ScheduleCatalogAction),
|
||||
)
|
||||
ctx.slots.inject('sidebar.session.row.leading', () => ctx.slots.register({
|
||||
name: 'sidebar.session.row.leading',
|
||||
id: 'schedule-mark',
|
||||
order: 10,
|
||||
locale: NS,
|
||||
inject: () => ({ hooks: { catalog: manager.hooks.catalog } }),
|
||||
}, SessionScheduleMark))
|
||||
ctx.slots.inject('sidebar.session.row.hover', () => ctx.slots.register({
|
||||
name: 'sidebar.session.row.hover',
|
||||
id: 'schedule-tasks',
|
||||
order: 10,
|
||||
locale: NS,
|
||||
inject: () => ({ hooks: { catalog: manager.hooks.catalog } }),
|
||||
}, SessionScheduleHover))
|
||||
inject: () => ({ hooks: { catalog: manager.hooks.catalog } }),
|
||||
}, SessionScheduleMark))
|
||||
yield ctx.slots.inject('sidebar.session.row.hover', () => ctx.slots.register({
|
||||
name: 'sidebar.session.row.hover',
|
||||
id: 'schedule-tasks',
|
||||
order: 10,
|
||||
locale: NS,
|
||||
inject: () => ({ hooks: { catalog: manager.hooks.catalog } }),
|
||||
}, SessionScheduleHover))
|
||||
})
|
||||
}
|
||||
@@ -4,7 +4,7 @@ import { describe, expect, it, vi, type Mock } from 'vitest'
|
||||
import { SlotRegistry } from '@deepseek-ai/dsh-client-ui-renderer/client'
|
||||
import { UiConversation } from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||
import { resolveSlotLabel, type HostObservable, type StoredEntry } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import { stubConfigForm } from '@deepseek-ai/dsh-client-test-runtime'
|
||||
import { StaticRemoteAccess, stubConfigForm } from '@deepseek-ai/dsh-client-test-runtime'
|
||||
import { apply as applyLocale, inject as localeInject } from '@deepseek-ai/dsh-client-locale/client'
|
||||
import { apply, inject } from '../src/client/index.ts'
|
||||
import { apply as applyNode } from '../src/index.ts'
|
||||
@@ -86,9 +86,11 @@ class RemoteStub extends Service {
|
||||
async function baseContext(
|
||||
remote: RemoteStubInput = { $on: () => () => {} },
|
||||
sidebar: SidebarStubs = sidebarStubs(),
|
||||
allows: (action: string) => boolean = () => true,
|
||||
): Promise<Context> {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SlotRegistry).await()
|
||||
new StaticRemoteAccess(ctx, allows)
|
||||
new UiConversation(ctx, { binding: () => undefined } as never)
|
||||
ctx.provide('connection', { api: { settings: {} }, isLoopback: false } as never)
|
||||
ctx.provide('configForms', { developerTools: { enabled: createSnapshotStore(true) }, get: () => stubConfigForm().scope } as never)
|
||||
@@ -140,10 +142,29 @@ describe('ui-schedule browser half', () => {
|
||||
it('declares only the services used by registration', () => {
|
||||
expect(inject).toEqual([
|
||||
'slots', 'locale', 'remote', 'remote.schedule', 'conversation', 'uiConversation', 'uiWorkspace', 'sessions',
|
||||
'workspaces', 'sidebarRightTabs', 'sidebarRight',
|
||||
'workspaces', 'sidebarRightTabs', 'sidebarRight', 'remoteAccess',
|
||||
])
|
||||
})
|
||||
|
||||
it('contributes its surfaces only while the current Peer may read the task catalog', async () => {
|
||||
for (const allowed of [false, true]) {
|
||||
const sidebar = sidebarStubs()
|
||||
const allows = vi.fn(() => allowed)
|
||||
const ctx = await baseContext(undefined, sidebar, allows)
|
||||
const header = declareHeader(ctx)
|
||||
const fiber = ctx.plugin({ inject: [...inject], apply })
|
||||
await fiber.await()
|
||||
expect(allows).toHaveBeenCalledWith('schedule/catalog')
|
||||
// The Session-header reminder catalog is this plugin's entry point into the page.
|
||||
expect(headerEntryIds(ctx)).toEqual(allowed ? ['schedule-catalog'] : [])
|
||||
// The task tab type is a definition, registered either way.
|
||||
expect(sidebar.register).toHaveBeenCalledOnce()
|
||||
await fiber.dispose()
|
||||
header()
|
||||
await ctx.fiber.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
it('waits for the header declaration, orders between static context and Jobs, and tears down', async () => {
|
||||
const ctx = await baseContext()
|
||||
const fiber = ctx.plugin({ inject: [...inject], apply })
|
||||
|
||||
@@ -108,6 +108,7 @@ function props(
|
||||
records: readonly ScheduleRecord[] | undefined,
|
||||
openState: SessionSnapshot['openState'] = 'open',
|
||||
dictionary: typeof zh | typeof en = en,
|
||||
allowed = true,
|
||||
): ScheduleCatalogActionProps {
|
||||
const snapshot = sessionSnapshot(openState)
|
||||
const useSession = <T,>(select: (value: SessionSnapshot) => T): T => select(snapshot)
|
||||
@@ -120,6 +121,7 @@ function props(
|
||||
return {
|
||||
sessionId: SESSION,
|
||||
useSession,
|
||||
useAccess: vi.fn((_action: string) => allowed),
|
||||
useCatalog,
|
||||
onDelete: vi.fn(async () => {}),
|
||||
onRetry: vi.fn(async () => {}),
|
||||
@@ -140,6 +142,19 @@ function metadataLines(): Element[] {
|
||||
}
|
||||
|
||||
describe('ScheduleCatalogAction visibility', () => {
|
||||
it('renders nothing and reads no catalog while the current Peer may not list this Session\'s reminders', () => {
|
||||
const refused = props([record('active', 'after', START + 60_000)], 'open', en, false)
|
||||
let catalogReads = 0
|
||||
const useCatalog: ScheduleCatalogActionProps['useCatalog'] = (select) => {
|
||||
catalogReads++
|
||||
return refused.useCatalog(select)
|
||||
}
|
||||
const view = render(<ScheduleCatalogAction {...refused} useCatalog={useCatalog} />)
|
||||
expect(view.container.innerHTML).toBe('')
|
||||
expect(refused.useAccess).toHaveBeenCalledWith('schedule/list')
|
||||
expect(catalogReads).toBe(0)
|
||||
})
|
||||
|
||||
it('hides the entry once a successful read proves there are no active reminders', () => {
|
||||
const active = [record('active', 'after', START + 60_000)]
|
||||
const view = render(<ScheduleCatalogAction {...props(active, 'cold')} />)
|
||||
|
||||
@@ -15,8 +15,8 @@
|
||||
en: 12e1ffc25a2d6695
|
||||
zh: 321156dea1356e55
|
||||
/deepseek-ai-dsh-client-ui-session/access-hooks:
|
||||
en: 619ba442c3e40fb0
|
||||
zh: af0206a774ac9bc1
|
||||
en: 7bf4bca5607248fa
|
||||
zh: 665d5c4e9cd29f68
|
||||
/deepseek-ai-dsh-client-ui-session/model-experience:
|
||||
en: cb2d4451e8bf8520
|
||||
zh: 6d3adbb930d90c59
|
||||
|
||||
@@ -24,7 +24,7 @@ Running status comes from Host list baselines or status events. Subagent catalog
|
||||
<a id="access-hooks"></a>
|
||||
## Access Hooks
|
||||
|
||||
The package injects the Client `remoteAccess` service and publishes Session access Hooks over its `source()`: the root `useSessionAccess(sessionId, action)` answers on one Session resource, and the Session-scope `useAccess(action)` answers on the bound Session's resource; the `global` resource's answer is a different Hook, `useRemoteAccess(action)`, so neither shadows the other. The SessionMaybe-scope `useAccess(action)` returns `undefined` while no Session is bound, which callers treat as not allowed; a decision on the `global` resource reads `useRemoteAccess` instead. Snapshot semantics belong to [`dsh-api-remote-access`](../../api/remote-access/README.md#client-face).
|
||||
The package injects the Client `remoteAccess` service and publishes Session access Hooks over its `source()`: the root `useSessionAccess(sessionId, action)` answers on one Session resource, and the Session-scope `useAccess(action)` answers on the bound Session's resource; the `global` resource's answer is a different Hook, `useRemoteAccess(action)`, so neither shadows the other. `ctx.uiSession.whenAccess(sessionId, action, callback)` is the JS counterpart of `useSessionAccess`: it keeps the callback's effects installed while the current Peer may call `action` on that Session, with the semantics of `ctx.remoteAccess.whenAccess`. The SessionMaybe-scope `useAccess(action)` returns `undefined` while no Session is bound, which callers treat as not allowed; a decision on the `global` resource reads `useRemoteAccess` instead. Snapshot semantics belong to [`dsh-api-remote-access`](../../api/remote-access/README.md#client-face).
|
||||
|
||||
<a id="model-experience"></a>
|
||||
## Model Experience
|
||||
|
||||
@@ -24,7 +24,7 @@ kind: "package-reference"
|
||||
<a id="access-hooks"></a>
|
||||
## 访问 Hook
|
||||
|
||||
本包注入 Client `remoteAccess` 服务,并在其 `source()` 之上发布会话访问 Hook:根作用域的 `useSessionAccess(sessionId, action)` 回答某一个 Session 资源上的访问,Session 作用域的 `useAccess(action)` 回答所绑定 Session 资源上的访问;`global` 资源上的答案是另一个 Hook `useRemoteAccess(action)`,两者互不遮蔽。SessionMaybe 作用域的 `useAccess(action)` 在未绑定 Session 时返回 `undefined`,调用方将其视为不允许;需要 `global` 资源上的判断时读 `useRemoteAccess`。快照语义归 [`dsh-api-remote-access`](../../api/remote-access/README.zh.md#client-face)。
|
||||
本包注入 Client `remoteAccess` 服务,并在其 `source()` 之上发布会话访问 Hook:根作用域的 `useSessionAccess(sessionId, action)` 回答某一个 Session 资源上的访问,Session 作用域的 `useAccess(action)` 回答所绑定 Session 资源上的访问;`global` 资源上的答案是另一个 Hook `useRemoteAccess(action)`,两者互不遮蔽。`ctx.uiSession.whenAccess(sessionId, action, callback)` 是 `useSessionAccess` 在 JS 侧的对应:当前 Peer 能在该 Session 上调用 `action` 期间保持回调的效果,语义同 `ctx.remoteAccess.whenAccess`。SessionMaybe 作用域的 `useAccess(action)` 在未绑定 Session 时返回 `undefined`,调用方将其视为不允许;需要 `global` 资源上的判断时读 `useRemoteAccess`。快照语义归 [`dsh-api-remote-access`](../../api/remote-access/README.zh.md#client-face)。
|
||||
|
||||
<a id="model-experience"></a>
|
||||
## 模型体验
|
||||
|
||||
@@ -12,7 +12,7 @@ import type {
|
||||
} from '@deepseek-ai/dsh-api-session-controller/client'
|
||||
import type { SessionId } from '@deepseek-ai/dsh-session/types'
|
||||
import type {} from '@deepseek-ai/dsh-api-remotes/client'
|
||||
import type { RemoteEndpoint } from '@deepseek-ai/dsh-api-remote-access/client'
|
||||
import type { AccessEffect, RemoteEndpoint } from '@deepseek-ai/dsh-api-remote-access/client'
|
||||
import { notifySubscribers } from '@deepseek-ai/dsh-client-store'
|
||||
import { WeakMapWithValues } from '@deepseek-ai/dsh-util-values'
|
||||
import { standardHookPropName } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
@@ -360,6 +360,24 @@ export class UiSession extends Service {
|
||||
return this.sourceFor(owner)
|
||||
}
|
||||
|
||||
/**
|
||||
* Keep `callback`'s effects installed while the current Peer may call
|
||||
* `action` on one Session resource, with the semantics of
|
||||
* `ctx.remoteAccess.whenAccess`: `useSessionAccess` answers the same
|
||||
* question during render.
|
||||
* @param sessionId - the Session resource the guarded effects depend on.
|
||||
* @param action - endpoint action the guarded effects depend on.
|
||||
* @param callback - creates one disposer or an iterable of disposers.
|
||||
* @returns idempotent disposer owned by the caller's Cordis fiber.
|
||||
*/
|
||||
whenAccess(sessionId: SessionId, action: RemoteEndpoint, callback: () => AccessEffect): () => void {
|
||||
// `ctx.get` traces the service to the caller's Context, which then owns the controller.
|
||||
const access = this.ctx.get('remoteAccess')
|
||||
/* v8 ignore next -- this plugin injects remoteAccess, so the service is active while uiSession is reachable. */
|
||||
if (access === undefined) throw new Error('ui-session: whenAccess requires the remoteAccess service')
|
||||
return access.whenResourceAccess('session', sessionId, action, callback)
|
||||
}
|
||||
|
||||
/**
|
||||
* Register one Session-scoped standard-source contribution.
|
||||
* @param descriptor - static member roster and per-binding resolver.
|
||||
|
||||
@@ -10,6 +10,7 @@ import type {
|
||||
} from '@deepseek-ai/dsh-api-session-controller/client'
|
||||
import { MutableSessionEventSource } from '@deepseek-ai/dsh-api-session-controller/client'
|
||||
import { createSnapshotStore } from '@deepseek-ai/dsh-client-store'
|
||||
import { StaticRemoteAccess } from '@deepseek-ai/dsh-client-test-runtime'
|
||||
import type { HostObservable, RootStandardSourceContribution } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import type { SessionId } from '@deepseek-ai/dsh-session/types'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
@@ -728,7 +729,10 @@ describe('ui-session apply', () => {
|
||||
}
|
||||
return source
|
||||
}),
|
||||
whenAccess: vi.fn(() => () => {}),
|
||||
whenResourceAccess: vi.fn((): () => void => stopGuard),
|
||||
}
|
||||
const stopGuard = vi.fn()
|
||||
ctx.provide('sessions', bench.sessions)
|
||||
ctx.provide('slots', slots as never)
|
||||
ctx.provide('remoteAccess', remoteAccess)
|
||||
@@ -758,6 +762,30 @@ describe('ui-session apply', () => {
|
||||
const binding = bench.binding(sessionId('s2'))
|
||||
const scoped = ctx.uiSession.bindingSource(bench.reference(binding)).getSnapshot()
|
||||
expect(scoped.keyedHooks.access?.('session/fork')).toBe(remoteAccess.source('session', sessionId('s2'), 'session/fork'))
|
||||
|
||||
// `whenAccess` follows the same Session resource through the service's resource form.
|
||||
const guarded = vi.fn(() => () => {})
|
||||
expect(ctx.uiSession.whenAccess(sessionId('s1'), 'session/fork', guarded)).toBe(stopGuard)
|
||||
expect(remoteAccess.whenResourceAccess).toHaveBeenCalledWith('session', sessionId('s1'), 'session/fork', guarded)
|
||||
})
|
||||
|
||||
it('hangs Session-guarded effects on the calling plugin, so they leave when it unloads', async () => {
|
||||
const ctx = new Context()
|
||||
const bench = createSessionsBench(ctx)
|
||||
ctx.provide('sessions', bench.sessions)
|
||||
ctx.provide('slots', { provideRoot: vi.fn(), installScope: vi.fn(), bindStoreScope: vi.fn() } as never)
|
||||
new StaticRemoteAccess(ctx)
|
||||
apply(ctx)
|
||||
const dispose = vi.fn()
|
||||
const caller = ctx.plugin({
|
||||
inject: ['uiSession'],
|
||||
apply: (callerCtx: Context) => { callerCtx.uiSession.whenAccess(sessionId('s1'), 'session/fork', () => dispose) },
|
||||
})
|
||||
await caller.await()
|
||||
expect(dispose).not.toHaveBeenCalled()
|
||||
await caller.dispose()
|
||||
expect(dispose).toHaveBeenCalledOnce()
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('keeps the Host loader half inert', () => {
|
||||
|
||||
@@ -18,8 +18,8 @@
|
||||
en: 45aa7b44d5424d9b
|
||||
zh: 20c7d344c4e9bce0
|
||||
/deepseek-ai-dsh-client-ui-settings-account/use-this-package/desktop-onboarding:
|
||||
en: c1c4db71fd09ca13
|
||||
zh: a40c0a01e9951945
|
||||
en: 7ae4e68f9491b068
|
||||
zh: f7478269bf8d28b3
|
||||
/deepseek-ai-dsh-client-ui-settings-account/understand-the-implementation:
|
||||
en: 186b2046cfb5ef76
|
||||
zh: 57b042a618fb612e
|
||||
|
||||
@@ -63,6 +63,8 @@ The Desktop preload marker enables the introduction after account credentials ar
|
||||
|
||||
The welcome page requires Get started. The credit page always appears. A positive balance confirmed at first entry selects Continue as the primary action and Add credits as the secondary action. This choice stays fixed until the onboarding controller is recreated and is never persisted; late balance results cannot change the buttons. Only a confirmed absence of positive credit adds a confirmation before continuing without recharge; pending or failed queries do not block continuing. Purpose descriptions remain unchanged when selected. Returning from the native top-up view keeps the credit page and re-reads profile and balance in the background after any read already running, whether loading or payment succeeded. That read does not recompute the funded-mode choice, so a completed recharge leaves the buttons as first entered while continuing still evaluates the refreshed balance. Next confirms only a known zero balance; Skip uses the credit warning on the credit page and the setup warning on the question pages. Office-only use completes with compact presentation; development or both purposes also ask for process detail. Back preserves selections, and explicit skip from any supported step applies standard process, compact performance/usage, and disabled Coding Tools. This flow creates no workspace or demonstration task.
|
||||
|
||||
Access: the account stream, quota notice, settings launcher, account settings section, and models sign-in entry exist only while the current Peer may call `account/watch`; Desktop onboarding and the platform page are not gated. Losing that access discards the account reads that grant started, closes a pending authorization tab, and clears the account view, details, picture, and sign-in dialog state; a later grant shows the account section again once its first frame arrives. The owner always has access.
|
||||
|
||||
<a id="understand-the-implementation"></a>
|
||||
## Understand the implementation
|
||||
|
||||
|
||||
@@ -63,6 +63,8 @@ Desktop preload 标记在账号凭证已存储后启用引导。进度属于本
|
||||
|
||||
欢迎页必须点击“开始设置”。额度页始终展示。首次进入时若已确认有正余额,则主操作为“继续”,次操作为“去充值”。该选择在引导控制器重建前保持固定且不持久化,后到的余额结果不会改变按钮。仅在确认不存在正余额时,继续而不充值才需确认;查询中或失败时不阻止继续。用途卡片选中后描述不变。从原生充值视图返回后保留额度页,并在等待任何已在进行的读取结束后于后台重读资料与余额,不受页面加载或支付结果影响。该读取不会重新计算该选择,因此充值成功后按钮仍保持首次进入时的状态,而继续前行仍按刷新后的余额判断。“下一步”仅在确认零余额时提示;额度页的“跳过”使用充值提示,答题页使用设置提示。仅选办公用途时以简洁展示完成;选择开发或两种用途时还会询问过程详情。上一步保留已有选择,任意可跳过步骤均采用标准工作过程、简洁性能与用量以及关闭代码工作工具的默认值。此流程不创建工作区或示例任务。
|
||||
|
||||
访问控制:账号状态流、额度提醒、设置启动器、账号设置分区与模型登录入口只在当前 Peer 可以调用 `account/watch` 时存在;桌面引导与平台页面不受此控制。失去该权限时,这一轮发出的账号读取作废,待授权的浏览器标签页关闭,账号视图、详情、头像与登录对话框状态清空;之后再次授权时,账号分区等新流的第一帧到达后才重新出现。owner 始终有权限。
|
||||
|
||||
<a id="understand-the-implementation"></a>
|
||||
## 理解实现
|
||||
|
||||
|
||||
@@ -50,6 +50,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/cordis": "workspace:~",
|
||||
"@deepseek-ai/dsh-api-remote-access": "workspace:*",
|
||||
"@deepseek-ai/dsh-api-remotes": "workspace:*",
|
||||
"@deepseek-ai/dsh-client-locale": "workspace:*",
|
||||
"@deepseek-ai/dsh-client-store": "workspace:*",
|
||||
|
||||
@@ -8,6 +8,7 @@ import type {} from '@deepseek-ai/dsh-client-ui-renderer/client'
|
||||
import type {} from '@deepseek-ai/dsh-client-ui-theme/client'
|
||||
import type {} from '@deepseek-ai/dsh-client-ui-chat/client'
|
||||
import type {} from '@deepseek-ai/dsh-client-ui-layout/client'
|
||||
import type {} from '@deepseek-ai/dsh-api-remote-access/client'
|
||||
import type {} from '@deepseek-ai/dsh-api-remotes/client'
|
||||
import type { AccountView, AccountDetails } from '@deepseek-ai/dsh-deepseek-account/types'
|
||||
import type { OnboardingChange } from './onboarding-contract.ts'
|
||||
@@ -41,7 +42,7 @@ declare module '@deepseek-ai/dsh-client-ui-slots' {
|
||||
}
|
||||
|
||||
/** Services required by account settings. */
|
||||
export const inject = ['slots', 'locale', 'remote', 'remote.account', 'remote.session', 'theme', 'configForms']
|
||||
export const inject = ['slots', 'locale', 'remote', 'remote.account', 'remote.session', 'theme', 'configForms', 'remoteAccess']
|
||||
/** Register account UI in Desktop and plain Web clients. @param ctx - client plugin context. */
|
||||
export function apply(ctx: Context): void {
|
||||
const desktop = 'dshDesktop' in globalThis
|
||||
@@ -116,36 +117,58 @@ export function apply(ctx: Context): void {
|
||||
return request
|
||||
}
|
||||
ctx.effect(() => () => { revision++ }, 'account: details request lifetime')
|
||||
const stream = ctx.remote.$stream<AccountView>({
|
||||
name: 'account', open: signal => ctx.remote.account.watch(signal), ended: () => new Error('account stream ended'),
|
||||
})
|
||||
let disposed = false
|
||||
ctx.effect(() => () => { disposed = true; return stream.dispose() }, 'account: state stream')
|
||||
void (async () => {
|
||||
for await (const frame of stream) {
|
||||
// The account stream, and the reads it starts, follow access to this host's account state.
|
||||
ctx.remoteAccess.whenAccess('account/watch', function* () {
|
||||
const stream = ctx.remote.$stream<AccountView>({
|
||||
name: 'account', open: signal => ctx.remote.account.watch(signal), ended: () => new Error('account stream ended'),
|
||||
})
|
||||
// Read across closures: the disposer ends this grant while the frame loop awaits.
|
||||
const grant = { ended: false }
|
||||
// Withdrawing access invalidates the reads this grant started, closes the
|
||||
// pending authorization tab, and clears the account view and the sign-in
|
||||
// dialog state, synchronously, so the next grant starts from no account
|
||||
// state. `notices.end()` disposes this generation's notice lifetime, which a
|
||||
// frame of this stream begins; the notice controller stays outside and
|
||||
// begins again under the next grant.
|
||||
yield () => {
|
||||
grant.ended = true
|
||||
revision++
|
||||
refreshing = undefined
|
||||
avatar.reset()
|
||||
const initialize = frame.value.status === 'credential-stored' && frame.value.attempt?.phase === 'succeeded'
|
||||
&& snapshot.view?.attempt?.phase !== 'succeeded'
|
||||
const { notices: previousNotices, ...previous } = snapshot
|
||||
publish({ ...previous, view: frame.value, details: undefined, avatar: undefined, failed: false,
|
||||
...(frame.value.status === 'credential-stored' && previousNotices ? { notices: previousNotices } : {}) })
|
||||
browser.update(frame.value)
|
||||
frame.accept()
|
||||
if (frame.value.status === 'credential-stored') notices.begin()
|
||||
else notices.end()
|
||||
if (initialize) void (async () => {
|
||||
try {
|
||||
const initialized = await ctx.remote.session.initializeDefaultModel()
|
||||
if (!initialized.ok) console.error('[deepseek-account] default model initialization failed', { reason: 'refused' })
|
||||
} catch (_error) {
|
||||
console.error('[deepseek-account] default model initialization failed', { reason: 'disconnected' })
|
||||
}
|
||||
})()
|
||||
void refresh()
|
||||
browser.dispose()
|
||||
const { loginFailed: _loginFailed, ...current } = snapshot
|
||||
publish({ ...current, view: undefined, details: undefined, avatar: undefined, failed: false, loginVisible: false })
|
||||
notices.end()
|
||||
return stream.dispose()
|
||||
}
|
||||
})().catch(() => { if (!disposed) publish({ ...snapshot, failed: true }) })
|
||||
void (async () => {
|
||||
for await (const frame of stream) {
|
||||
// A frame the stream delivered before the revocation is not this grant's to apply.
|
||||
if (grant.ended) return
|
||||
revision++
|
||||
refreshing = undefined
|
||||
avatar.reset()
|
||||
const initialize = frame.value.status === 'credential-stored' && frame.value.attempt?.phase === 'succeeded'
|
||||
&& snapshot.view?.attempt?.phase !== 'succeeded'
|
||||
const { notices: previousNotices, ...previous } = snapshot
|
||||
publish({ ...previous, view: frame.value, details: undefined, avatar: undefined, failed: false,
|
||||
...(frame.value.status === 'credential-stored' && previousNotices ? { notices: previousNotices } : {}) })
|
||||
browser.update(frame.value)
|
||||
frame.accept()
|
||||
if (frame.value.status === 'credential-stored') notices.begin()
|
||||
else notices.end()
|
||||
if (initialize) void (async () => {
|
||||
try {
|
||||
const initialized = await ctx.remote.session.initializeDefaultModel()
|
||||
if (!initialized.ok) console.error('[deepseek-account] default model initialization failed', { reason: 'refused' })
|
||||
} catch (_error) {
|
||||
console.error('[deepseek-account] default model initialization failed', { reason: 'disconnected' })
|
||||
}
|
||||
})()
|
||||
void refresh()
|
||||
}
|
||||
})().catch(() => { if (!grant.ended) publish({ ...snapshot, failed: true }) })
|
||||
})
|
||||
const nativePlatform = (globalThis as typeof globalThis & { dshPlatform?: PlatformBridge }).dshPlatform
|
||||
// One request channel for the single native Platform view. It always exists
|
||||
// so every entry can observe whether a page is showing; only a native bridge
|
||||
@@ -287,17 +310,17 @@ export function apply(ctx: Context): void {
|
||||
}),
|
||||
}, DesktopOnboardingEntry))
|
||||
}
|
||||
ctx.slots.inject('settings.models.sign-in', () => ctx.slots.register({
|
||||
ctx.remoteAccess.whenAccess('account/watch', () => ctx.slots.inject('settings.models.sign-in', () => ctx.slots.register({
|
||||
name: 'settings.models.sign-in', locale: 'settings.account', inject: () => operations,
|
||||
}, AccountOnboarding))
|
||||
ctx.slots.inject('shell.quota-notice', () => ctx.slots.register({
|
||||
}, AccountOnboarding)))
|
||||
ctx.remoteAccess.whenAccess('account/watch', () => ctx.slots.inject('shell.quota-notice', () => ctx.slots.register({
|
||||
name: 'shell.quota-notice', locale: 'settings.account',
|
||||
select: owner => owner.code === 'ACCOUNT_QUOTA' ? owner : null,
|
||||
inject: (): AccountQuotaNoticeInjected => ({
|
||||
hooks: { account: operations.hooks.account, platformPage: platformPages },
|
||||
...nativePlatform === undefined ? {} : { openPlatformPage: platformPageOpener(refreshAccount) },
|
||||
}),
|
||||
}, AccountQuotaNotice))
|
||||
}, AccountQuotaNotice)))
|
||||
if (nativePlatform !== undefined) {
|
||||
ctx.slots.inject('shell.overlay', () => ctx.slots.register({
|
||||
name: 'shell.overlay', id: 'account.platform-page', locale: 'settings.account',
|
||||
@@ -307,10 +330,10 @@ export function apply(ctx: Context): void {
|
||||
}),
|
||||
}, AccountPlatformHost))
|
||||
}
|
||||
ctx.slots.inject('settings.launcher', () => ctx.slots.register({
|
||||
ctx.remoteAccess.whenAccess('account/watch', () => ctx.slots.inject('settings.launcher', () => ctx.slots.register({
|
||||
name: 'settings.launcher', locale: 'settings.account', inject: () => operations,
|
||||
}, AccountMenu))
|
||||
ctx.slots.inject('settings.section', () => {
|
||||
}, AccountMenu)))
|
||||
ctx.remoteAccess.whenAccess('account/watch', () => ctx.slots.inject('settings.section', () => {
|
||||
let unregister: (() => void) | undefined
|
||||
const update = () => {
|
||||
if (snapshot.view?.status === 'credential-stored') {
|
||||
@@ -326,5 +349,5 @@ export function apply(ctx: Context): void {
|
||||
listeners.add(update)
|
||||
update()
|
||||
return () => { listeners.delete(update); unregister?.() }
|
||||
})
|
||||
}))
|
||||
}
|
||||
@@ -0,0 +1,312 @@
|
||||
// @vitest-environment jsdom
|
||||
/** Account surfaces follow `account/watch` access: a revocation withdraws them and a later grant restores them. */
|
||||
import { afterEach, expect, it, vi } from 'vitest'
|
||||
import type { AccountView } from '@deepseek-ai/dsh-deepseek-account/types'
|
||||
import { apply, type AccountSectionInjected } from '../src/client/index.ts'
|
||||
|
||||
afterEach(() => { vi.unstubAllEnvs() })
|
||||
|
||||
type AccessEffect = (() => void) | Iterable<() => void>
|
||||
|
||||
/** One `whenAccess` controller: its callback and the disposers of its current installation. */
|
||||
interface Guard {
|
||||
readonly action: string
|
||||
readonly install: () => AccessEffect
|
||||
installed: (() => void)[] | undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Install every guard's current effect.
|
||||
* @param guards - Guards registering this run's access controllers.
|
||||
*/
|
||||
function grant(guards: readonly Guard[]): void {
|
||||
for (const guard of guards) {
|
||||
const effect = guard.install()
|
||||
guard.installed = typeof effect === 'function' ? [effect] : [...effect]
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Dispose every installed effect in reverse install order.
|
||||
* @param guards - Guards registering this run's access controllers.
|
||||
*/
|
||||
function revoke(guards: readonly Guard[]): void {
|
||||
for (const guard of [...guards].reverse()) {
|
||||
for (const dispose of (guard.installed ?? []).reverse()) dispose()
|
||||
guard.installed = undefined
|
||||
}
|
||||
}
|
||||
|
||||
it('withdraws every account surface and the account stream on revocation, and restores them on the next grant', async () => {
|
||||
vi.stubEnv('DSH_CLIENT_VERSION', '0.0.0-test')
|
||||
const links = { usageUrl: 'https://example.test/usage', topUpUrl: 'https://example.test/top_up' }
|
||||
const signedIn: AccountView = { status: 'credential-stored', links, attempt: null }
|
||||
const guards: Guard[] = []
|
||||
const registered = new Map<string, number>()
|
||||
const streams: { readonly signal: AbortSignal; readonly accepted: Promise<undefined> }[] = []
|
||||
const lifetime: (() => void)[] = []
|
||||
const ctx = {
|
||||
effect: (install: () => (() => void) | undefined) => { const dispose = install(); if (dispose) lifetime.push(dispose) },
|
||||
locale: { register: () => () => {}, bind: () => (key: string) => key, getSnapshot: () => ({ active: 'en' }) },
|
||||
configForms: {
|
||||
get: () => ({ subscribe: () => () => {}, getSnapshot: () => ({ status: 'loading', value: undefined }) }),
|
||||
describe: () => ({ subscribe: () => () => {}, getSnapshot: () => ({ error: null }) }),
|
||||
},
|
||||
slots: {
|
||||
inject: (_name: string, install: () => (() => void) | undefined) => {
|
||||
const dispose = install()
|
||||
return () => { dispose?.() }
|
||||
},
|
||||
register: (entry: { readonly name: string }) => {
|
||||
registered.set(entry.name, (registered.get(entry.name) ?? 0) + 1)
|
||||
return () => { registered.set(entry.name, registered.get(entry.name)! - 1) }
|
||||
},
|
||||
},
|
||||
remoteAccess: {
|
||||
whenAccess: (action: string, install: () => AccessEffect) => {
|
||||
guards.push({ action, install, installed: undefined })
|
||||
return () => {}
|
||||
},
|
||||
},
|
||||
remote: {
|
||||
$on: () => () => {},
|
||||
session: { initializeDefaultModel: async () => ({ ok: true, value: undefined }) },
|
||||
account: { getProfile: async () => ({ ok: true, value: null }), getBalance: async () => ({ ok: true, value: null }) },
|
||||
$stream: () => {
|
||||
const abort = new AbortController()
|
||||
const accepted = Promise.withResolvers<undefined>()
|
||||
streams.push({ signal: abort.signal, accepted: accepted.promise })
|
||||
return {
|
||||
signal: abort.signal,
|
||||
dispose: () => { abort.abort() },
|
||||
async *[Symbol.asyncIterator]() {
|
||||
yield { value: signedIn, accept: () => { accepted.resolve(undefined) } }
|
||||
await new Promise((resolve) => { abort.signal.addEventListener('abort', resolve) })
|
||||
},
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
const surfaces = (): Record<string, number | undefined> => Object.fromEntries(
|
||||
['settings.launcher', 'shell.quota-notice', 'settings.models.sign-in', 'settings.section'].map(name => [name, registered.get(name)]),
|
||||
)
|
||||
try {
|
||||
apply(ctx as never)
|
||||
expect(new Set(guards.map(guard => guard.action))).toEqual(new Set(['account/watch']))
|
||||
grant(guards)
|
||||
await streams[0]!.accepted
|
||||
expect(surfaces()).toEqual({ 'settings.launcher': 1, 'shell.quota-notice': 1, 'settings.models.sign-in': 1, 'settings.section': 1 })
|
||||
|
||||
revoke(guards)
|
||||
expect(streams[0]!.signal.aborted).toBe(true)
|
||||
expect(surfaces()).toEqual({ 'settings.launcher': 0, 'shell.quota-notice': 0, 'settings.models.sign-in': 0, 'settings.section': 0 })
|
||||
|
||||
grant(guards)
|
||||
await streams[1]!.accepted
|
||||
expect(surfaces()).toEqual({ 'settings.launcher': 1, 'shell.quota-notice': 1, 'settings.models.sign-in': 1, 'settings.section': 1 })
|
||||
} finally {
|
||||
revoke(guards)
|
||||
for (const dispose of lifetime.reverse()) dispose()
|
||||
}
|
||||
})
|
||||
|
||||
it('drops reads a revoked grant started, and the next grant shows no account section before its first frame', async () => {
|
||||
vi.stubEnv('DSH_CLIENT_VERSION', '0.0.0-test')
|
||||
const links = { usageUrl: 'https://example.test/usage', topUpUrl: 'https://example.test/top_up' }
|
||||
const signedIn: AccountView = { status: 'credential-stored', links, attempt: null }
|
||||
const guards: Guard[] = []
|
||||
const registered = new Map<string, number>()
|
||||
let operations: AccountSectionInjected | undefined
|
||||
const profile = Promise.withResolvers<{ ok: true; value: { status: 'failed' } }>()
|
||||
const firstFrames: { release: () => void; accepted: Promise<undefined> }[] = []
|
||||
const lifetime: (() => void)[] = []
|
||||
const ctx = {
|
||||
effect: (install: () => (() => void) | undefined) => { const dispose = install(); if (dispose) lifetime.push(dispose) },
|
||||
locale: { register: () => () => {}, bind: () => (key: string) => key, getSnapshot: () => ({ active: 'en' }) },
|
||||
configForms: {
|
||||
get: () => ({ subscribe: () => () => {}, getSnapshot: () => ({ status: 'loading', value: undefined }) }),
|
||||
describe: () => ({ subscribe: () => () => {}, getSnapshot: () => ({ error: null }) }),
|
||||
},
|
||||
slots: {
|
||||
inject: (_name: string, install: () => (() => void) | undefined) => {
|
||||
const dispose = install()
|
||||
return () => { dispose?.() }
|
||||
},
|
||||
register: (entry: { readonly name: string; readonly inject?: () => AccountSectionInjected }) => {
|
||||
if (entry.name === 'settings.launcher') operations = entry.inject?.()
|
||||
registered.set(entry.name, (registered.get(entry.name) ?? 0) + 1)
|
||||
return () => { registered.set(entry.name, registered.get(entry.name)! - 1) }
|
||||
},
|
||||
},
|
||||
remoteAccess: {
|
||||
whenAccess: (action: string, install: () => AccessEffect) => {
|
||||
guards.push({ action, install, installed: undefined })
|
||||
return () => {}
|
||||
},
|
||||
},
|
||||
remote: {
|
||||
$on: () => () => {},
|
||||
session: { initializeDefaultModel: async () => ({ ok: true, value: undefined }) },
|
||||
account: { getProfile: async () => await profile.promise, getBalance: async () => ({ ok: true, value: null }) },
|
||||
$stream: () => {
|
||||
const abort = new AbortController()
|
||||
const gate = Promise.withResolvers<undefined>()
|
||||
const accepted = Promise.withResolvers<undefined>()
|
||||
firstFrames.push({ release: () => { gate.resolve(undefined) }, accepted: accepted.promise })
|
||||
return {
|
||||
signal: abort.signal,
|
||||
dispose: () => { abort.abort() },
|
||||
async *[Symbol.asyncIterator]() {
|
||||
await gate.promise
|
||||
yield { value: signedIn, accept: () => { accepted.resolve(undefined) } }
|
||||
await new Promise((resolve) => { abort.signal.addEventListener('abort', resolve) })
|
||||
},
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
try {
|
||||
apply(ctx as never)
|
||||
grant(guards)
|
||||
firstFrames[0]!.release()
|
||||
await firstFrames[0]!.accepted
|
||||
expect(registered.get('settings.section')).toBe(1)
|
||||
|
||||
// The profile read started under the first grant settles only after the revocation.
|
||||
revoke(guards)
|
||||
expect(operations!.hooks.account.getSnapshot()).toMatchObject({ view: undefined, details: undefined })
|
||||
profile.resolve({ ok: true, value: { status: 'failed' } })
|
||||
await new Promise(resolve => setTimeout(resolve, 0))
|
||||
expect(operations!.hooks.account.getSnapshot().details).toBeUndefined()
|
||||
|
||||
grant(guards)
|
||||
expect(registered.get('settings.section')).toBe(0)
|
||||
firstFrames[1]!.release()
|
||||
await firstFrames[1]!.accepted
|
||||
expect(registered.get('settings.section')).toBe(1)
|
||||
} finally {
|
||||
revoke(guards)
|
||||
for (const dispose of lifetime.reverse()) dispose()
|
||||
}
|
||||
})
|
||||
|
||||
/** Stream behaviour of one grant's account stream in {@link revocationHarness}. */
|
||||
type StreamScript = (yieldFrame: () => void) => void
|
||||
|
||||
/**
|
||||
* The account plugin over hand-built services whose account streams follow `scripts`, one per grant.
|
||||
* @param scripts - per-grant hooks run right before that stream delivers its first frame.
|
||||
* @returns the plugin's access guards, Remote spies, slot counts, and teardown.
|
||||
*/
|
||||
function revocationHarness(scripts: StreamScript[]) {
|
||||
const links = { usageUrl: 'https://example.test/usage', topUpUrl: 'https://example.test/top_up' }
|
||||
const signedIn: AccountView = { status: 'credential-stored', links, attempt: null }
|
||||
const guards: Guard[] = []
|
||||
const registered = new Map<string, number>()
|
||||
const accepted: Promise<undefined>[] = []
|
||||
const lifetime: (() => void)[] = []
|
||||
let operations: AccountSectionInjected | undefined
|
||||
const getProfile = vi.fn(async () => ({ ok: true, value: null }))
|
||||
const getUnnotifiedBonuses = vi.fn(async () => ({ ok: true, value: null }))
|
||||
const startSignIn = vi.fn<() => Promise<{ ok: false }>>()
|
||||
const ctx = {
|
||||
effect: (install: () => (() => void) | undefined) => { const dispose = install(); if (dispose) lifetime.push(dispose) },
|
||||
locale: { register: () => () => {}, bind: () => (key: string) => key, getSnapshot: () => ({ active: 'en' }) },
|
||||
configForms: {
|
||||
get: () => ({ subscribe: () => () => {}, getSnapshot: () => ({ status: 'loading', value: undefined }) }),
|
||||
describe: () => ({ subscribe: () => () => {}, getSnapshot: () => ({ error: null }) }),
|
||||
},
|
||||
theme: { getTheme: () => ({ active: { colorScheme: 'light' } }) },
|
||||
slots: {
|
||||
inject: (_name: string, install: () => (() => void) | undefined) => {
|
||||
const dispose = install()
|
||||
return () => { dispose?.() }
|
||||
},
|
||||
register: (entry: { readonly name: string; readonly inject?: () => AccountSectionInjected }) => {
|
||||
if (entry.name === 'settings.launcher') operations = entry.inject?.()
|
||||
registered.set(entry.name, (registered.get(entry.name) ?? 0) + 1)
|
||||
return () => { registered.set(entry.name, registered.get(entry.name)! - 1) }
|
||||
},
|
||||
},
|
||||
remoteAccess: {
|
||||
whenAccess: (action: string, install: () => AccessEffect) => {
|
||||
guards.push({ action, install, installed: undefined })
|
||||
return () => {}
|
||||
},
|
||||
},
|
||||
remote: {
|
||||
$on: () => () => {},
|
||||
session: { initializeDefaultModel: async () => ({ ok: true, value: undefined }) },
|
||||
account: {
|
||||
getProfile, getBalance: async () => ({ ok: true, value: null }), getUnnotifiedBonuses, startSignIn,
|
||||
},
|
||||
$stream: () => {
|
||||
const abort = new AbortController()
|
||||
const delivered = Promise.withResolvers<undefined>()
|
||||
const script = scripts[accepted.length]!
|
||||
accepted.push(delivered.promise)
|
||||
return {
|
||||
signal: abort.signal,
|
||||
dispose: () => { abort.abort() },
|
||||
async *[Symbol.asyncIterator]() {
|
||||
await Promise.resolve()
|
||||
script(() => {})
|
||||
yield { value: signedIn, accept: () => { delivered.resolve(undefined) } }
|
||||
await new Promise((resolve) => { abort.signal.addEventListener('abort', resolve) })
|
||||
},
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
apply(ctx as never)
|
||||
return {
|
||||
guards, registered, accepted, getProfile, getUnnotifiedBonuses, startSignIn,
|
||||
operations: () => operations!,
|
||||
dispose: () => { revoke(guards); for (const dispose of lifetime.reverse()) dispose() },
|
||||
}
|
||||
}
|
||||
|
||||
it('ignores a frame the stream delivered just before the revocation', async () => {
|
||||
vi.stubEnv('DSH_CLIENT_VERSION', '0.0.0-test')
|
||||
// The stream hands out its first frame after access is already withdrawn.
|
||||
const b = revocationHarness([() => { revoke(b.guards) }])
|
||||
try {
|
||||
grant(b.guards)
|
||||
await new Promise(resolve => setTimeout(resolve, 0))
|
||||
expect(b.operations().hooks.account.getSnapshot().view).toBeUndefined()
|
||||
expect(b.registered.get('settings.section') ?? 0).toBe(0)
|
||||
expect(b.getProfile).not.toHaveBeenCalled()
|
||||
expect(b.getUnnotifiedBonuses).not.toHaveBeenCalled()
|
||||
} finally {
|
||||
b.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
it('closes the sign-in dialog, its failure, and the pending authorization tab on revocation', async () => {
|
||||
vi.stubEnv('DSH_CLIENT_VERSION', '0.0.0-test')
|
||||
const popup = { opener: null, closed: false, close: vi.fn(), location: { replace: vi.fn() } }
|
||||
const open = vi.spyOn(window, 'open').mockReturnValue(popup as never)
|
||||
const b = revocationHarness([() => {}, () => {}])
|
||||
try {
|
||||
grant(b.guards)
|
||||
await b.accepted[0]
|
||||
b.startSignIn.mockResolvedValueOnce({ ok: false })
|
||||
await expect(b.operations().start()).rejects.toThrow('account start failed')
|
||||
expect(b.operations().hooks.account.getSnapshot()).toMatchObject({ loginVisible: true, loginFailed: true })
|
||||
revoke(b.guards)
|
||||
expect(b.operations().hooks.account.getSnapshot().loginVisible).toBe(false)
|
||||
expect(b.operations().hooks.account.getSnapshot().loginFailed).toBeUndefined()
|
||||
|
||||
grant(b.guards)
|
||||
await b.accepted[1]
|
||||
popup.close.mockClear()
|
||||
b.startSignIn.mockReturnValueOnce(new Promise(() => {}))
|
||||
void b.operations().start()
|
||||
expect(open).toHaveBeenCalledTimes(2)
|
||||
revoke(b.guards)
|
||||
expect(popup.close).toHaveBeenCalledOnce()
|
||||
} finally {
|
||||
b.dispose()
|
||||
open.mockRestore()
|
||||
}
|
||||
})
|
||||
@@ -23,6 +23,7 @@ import { AccountQuotaNotice } from '../src/client/AccountQuotaNotice.tsx'
|
||||
import type { AccountQuotaNoticeInjected } from '../src/client/AccountQuotaNotice.tsx'
|
||||
|
||||
const it = createClientTest({ roster: webApp })
|
||||
const withoutAccountAccess = createClientTest({ roster: webApp }, { access: action => action !== 'account/watch' })
|
||||
const SELF = '@deepseek-ai/dsh-client-ui-settings-account'
|
||||
const view: AccountView = {
|
||||
status: 'signed-out', attempt: null,
|
||||
@@ -77,6 +78,15 @@ it('registers account sign-in in a plain browser without the Desktop-only surfac
|
||||
}
|
||||
}, 60_000)
|
||||
|
||||
withoutAccountAccess('opens no account stream and registers no account surface while the current Peer may not watch the account', async ({ start }) => {
|
||||
const c = await start()
|
||||
expect(c.mock.streams.end('account/watch')).toBe(0)
|
||||
expect(c.ctx.slots.entries('settings.launcher')).toHaveLength(0)
|
||||
expect(quotaNoticeEntry(c)).toBeUndefined()
|
||||
expect(c.ctx.slots.entries('settings.models.sign-in')).toHaveLength(0)
|
||||
expect(c.ctx.slots.entries('settings.section').some(entry => entry.options.id === 'account')).toBe(false)
|
||||
}, 60_000)
|
||||
|
||||
it('claims account balance notices from the frame-wide quota chain and declines generic quota', async ({ start }) => {
|
||||
vi.stubGlobal('dshDesktop', {})
|
||||
const c = await start()
|
||||
@@ -312,7 +322,8 @@ it('coalesces refreshes, publishes independent failures, and rejects stale respo
|
||||
await c.unload(SELF)
|
||||
pendingAgain.resolve(ok(profile))
|
||||
await request
|
||||
expect(actions.hooks.account.getSnapshot().details?.profile).toEqual({ status: 'failed' })
|
||||
// Unloading ends the grant, which clears the account state; the late profile publishes nothing.
|
||||
expect(actions.hooks.account.getSnapshot()).toMatchObject({ view: undefined, details: undefined })
|
||||
}, 60_000)
|
||||
|
||||
for (const desktop of [false, true]) it(`uses the browser or Desktop login carrier (Desktop: ${desktop}) and exposes operation errors`, async ({ start, mock }) => {
|
||||
|
||||
@@ -1,24 +1,27 @@
|
||||
/** Public questionnaire options are emitted in the page bootstrap. */
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import { expect, it, onTestFinished, vi } from 'vitest'
|
||||
import type { IndexInjection } from '@deepseek-ai/dsh-host-webserver'
|
||||
import type { IndexInjection, WebServer } from '@deepseek-ai/dsh-host-webserver'
|
||||
import { apply, Config } from '../src/index.ts'
|
||||
import { CONTACT_CONFIG_GLOBAL } from '../src/contact-config.ts'
|
||||
|
||||
/** The Peer an index render speaks for; this listener reads no field of it. */
|
||||
const PAGE_PEER = { principal: { kind: 'owner' } } as Parameters<WebServer['renderIndex']>[1]
|
||||
|
||||
it('injects configured contact options and removes the listener on disposal', async () => {
|
||||
const ctx = new Context()
|
||||
onTestFinished(() => ctx.fiber.dispose())
|
||||
const config: Config = Config({ contactSource: 'harness', bonusAckRetryDelayMs: 25, bonusAckRetryMaxDelayMs: 100 })
|
||||
await ctx.plugin({ Config, apply }, { contactSource: 'harness', bonusAckRetryDelayMs: 25, bonusAckRetryMaxDelayMs: 100 })
|
||||
const table: IndexInjection[] = []
|
||||
ctx.emit('webserver/index-inject', table)
|
||||
ctx.emit('webserver/index-inject', table, PAGE_PEER)
|
||||
expect(table).toEqual([{ kind: 'global', name: CONTACT_CONFIG_GLOBAL, value: {
|
||||
contactFormUrl: config.contactFormUrl, contactSource: config.contactSource,
|
||||
bonusAckRetryDelayMs: 25, bonusAckRetryMaxDelayMs: 100,
|
||||
} }])
|
||||
await ctx.fiber.dispose()
|
||||
table.length = 0
|
||||
ctx.emit('webserver/index-inject', table)
|
||||
ctx.emit('webserver/index-inject', table, PAGE_PEER)
|
||||
expect(table).toEqual([])
|
||||
})
|
||||
|
||||
|
||||
@@ -44,6 +44,14 @@ it.each(['accepted', 'refused', 'disconnected', 'pending'] as const)('initialize
|
||||
return () => {}
|
||||
},
|
||||
},
|
||||
remoteAccess: {
|
||||
whenAccess: (_action: string, install: () => (() => void) | Iterable<() => void> | undefined) => {
|
||||
const effect = install()
|
||||
if (typeof effect === 'function') disposers.push(effect)
|
||||
else if (effect !== undefined) for (const dispose of effect) disposers.push(dispose)
|
||||
return () => {}
|
||||
},
|
||||
},
|
||||
remote: {
|
||||
$on: () => () => {},
|
||||
session: { initializeDefaultModel },
|
||||
|
||||
@@ -24,8 +24,8 @@
|
||||
en: 597df68b5913e8d7
|
||||
zh: bc9114f3e877679a
|
||||
/deepseek-ai-dsh-client-ui-settings-general/use-this-package/onboarding-steps:
|
||||
en: 27f0f6ec55f00228
|
||||
zh: 69b47ff1efd81c17
|
||||
en: ba55fe92bf49dc5a
|
||||
zh: 9a0e1345496a0ae6
|
||||
/deepseek-ai-dsh-client-ui-settings-general/understand-the-implementation:
|
||||
en: 1531b45780d0689f
|
||||
zh: 844548208c08a9c9
|
||||
|
||||
@@ -59,6 +59,8 @@ On a loopback browser, the shell renders **Open configuration file** only when t
|
||||
|
||||
The onboarding ledger projects in ascending order and mounts exactly one step at a time. Registrants own durable completion, capability readiness, copy, mutations, and their visible wrapper, so independently registered flows cannot stack and the shell does not become a second configuration fact source. Visible steps own their dialog chrome and app-root `inert` lifecycle.
|
||||
|
||||
Access: the settings shell and the `settings.open` shortcut are registered only while the current Peer may call `settings/describe`. The owner always has access.
|
||||
|
||||
-----
|
||||
|
||||
<a id="understand-the-implementation"></a>
|
||||
|
||||
@@ -59,6 +59,8 @@ Web 与桌面端的通用设置底部显示当前发布版本,使用构建注
|
||||
|
||||
引导账本按升序投影,每次只挂载一个步骤。注册方持有持久化完成状态、能力就绪状态、文案、变更操作与可见包装,因此独立注册的流程无法堆叠,外壳也不会成为第二个配置事实来源。可见步骤自行持有弹窗框架与应用根节点 `inert` 生命周期。
|
||||
|
||||
访问控制:设置外壳与 `settings.open` 快捷键只在当前 Peer 可以调用 `settings/describe` 时注册。owner 始终有权限。
|
||||
|
||||
-----
|
||||
|
||||
<a id="understand-the-implementation"></a>
|
||||
|
||||
Loaded 100 of 191 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user