Merge pull request #6792 from deepseek-harness/when-access

feat(remote-access): 插件注册跟随访问状态(whenAccess),index 按 Peer 注入访问快照
This commit is contained in:
imccyu authored and GitHub committed 2026-10-21 18:42:23 +08:00
commit ad340232fa
127 files changed
+2175 -592

No files matched your search

@@ -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
@@ -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 |
@@ -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 访问面。
+1 -1
View File
@@ -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. */
+1 -1
View File
@@ -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. */
+1 -1
View File
@@ -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. */
+1 -1
View File
@@ -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`) | - |
+1 -1
View File
@@ -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`) | - |
+3 -4
View File
@@ -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 -4
View File
@@ -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.
*/
+4 -4
View File
@@ -3,8 +3,8 @@
# After editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write docs/subsystems/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
+19 -14
View File
@@ -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)
+19 -14
View File
@@ -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 构建,确认这两个方法的调用都不再报缺少参数。
+4 -4
View File
@@ -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
+3 -2
View File
@@ -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
+3 -2
View File
@@ -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() }
}
+6 -7
View File
@@ -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: 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
+4 -2
View File
@@ -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
@@ -79,12 +81,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 {
@@ -93,82 +96,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,
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,
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 () => {
+2 -2
View File
@@ -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
+2
View File
@@ -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
+2
View File
@@ -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>
## 理解实现
+1
View File
@@ -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)
+154 -151
View File
@@ -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')} />)
+2 -2
View File
@@ -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
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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>
## 模型体验
+19 -1
View File
@@ -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>
@@ -57,6 +57,7 @@
},
"devDependencies": {
"@deepseek-ai/cordis": "workspace:~",
"@deepseek-ai/dsh-api-remote-access": "workspace:*",
"@deepseek-ai/dsh-api-remotes": "workspace:*",
"@deepseek-ai/dsh-client-connection": "workspace:*",
"@deepseek-ai/dsh-client-locale": "workspace:*",
@@ -9,6 +9,8 @@
* Export discipline: packages/client/AGENTS.md.
*/
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 ctx.remote merge and its fixed Host facts.
import type {} from '@deepseek-ai/dsh-api-remotes/client'
import type { ConnectionHandle } from '@deepseek-ai/dsh-client-connection/client'
@@ -70,7 +72,7 @@ const NS = 'settings'
* ui-settings' apply, whose activation order relative to this one is NOT
* constrained; registrations depend on their slots through `slots.inject()`.
*/
export const inject = ['slots', 'locale', 'connection', 'remote', 'remote.settings', 'configForms', 'shortcuts', 'modules']
export const inject = ['slots', 'locale', 'connection', 'remote', 'remote.settings', 'configForms', 'shortcuts', 'modules', 'remoteAccess']
/**
* Register the `settings` dictionaries, the chrome content, and the General
@@ -185,46 +187,48 @@ export function apply(ctx: ClientContext): void {
},
},
})
ctx.slots.inject('sidebar.settings', () => {
const shellHandle = createSettingsShellStore()
const shellInstance = shellHandle.create()
const shellStore: typeof shellHandle = { ...shellHandle, create: () => shellInstance }
const disposeCommand = ctx.shortcuts.register({
id: 'settings.open' as ShortcutCommandId, label: () => t('shortcut.open'), aliases: ['settings', 'preferences'],
defaults: {
'desktop:macos': { code: 'Comma', modifiers: ['primary'] },
'desktop:windows': { code: 'Comma', modifiers: ['primary'] },
'desktop:linux': { code: 'Comma', modifiers: ['primary'] },
'web:macos': { code: 'Comma', modifiers: ['primary', 'alt'] },
'web:windows': { code: 'Comma', modifiers: ['primary', 'alt'] },
},
regions: ['page', 'editable', 'terminal'], modals: ['settings'],
resolve: ({ modal }) => {
if (modal !== null && modal !== 'settings') return { status: 'blocked', reason: 'modal' }
return { status: 'handled', run: () => {
if (modal === 'settings') closeTopModal(document)
else shellInstance.actions.open()
} }
},
})
// The settings shell reads this host's configuration, so it follows access to its description.
ctx.remoteAccess.whenAccess('settings/describe', () =>
ctx.slots.inject('sidebar.settings', () => {
const shellHandle = createSettingsShellStore()
const shellInstance = shellHandle.create()
const shellStore: typeof shellHandle = { ...shellHandle, create: () => shellInstance }
const disposeCommand = ctx.shortcuts.register({
id: 'settings.open' as ShortcutCommandId, label: () => t('shortcut.open'), aliases: ['settings', 'preferences'],
defaults: {
'desktop:macos': { code: 'Comma', modifiers: ['primary'] },
'desktop:windows': { code: 'Comma', modifiers: ['primary'] },
'desktop:linux': { code: 'Comma', modifiers: ['primary'] },
'web:macos': { code: 'Comma', modifiers: ['primary', 'alt'] },
'web:windows': { code: 'Comma', modifiers: ['primary', 'alt'] },
},
regions: ['page', 'editable', 'terminal'], modals: ['settings'],
resolve: ({ modal }) => {
if (modal !== null && modal !== 'settings') return { status: 'blocked', reason: 'modal' }
return { status: 'handled', run: () => {
if (modal === 'settings') closeTopModal(document)
else shellInstance.actions.open()
} }
},
})
const disposeSlot = ctx.slots.register({
name: 'sidebar.settings',
locale: NS,
store: shellStore,
children: {
'settings.launcher': { kind: 'single', scope: 'root' },
'settings.trigger': { kind: 'single', scope: 'root' },
'settings.header': { kind: 'single', scope: 'root' },
'settings.action': { kind: 'list', scope: 'root' },
'settings.close': { kind: 'single', scope: 'root' },
'settings.section': { kind: 'list', scope: 'root' },
'settings.onboarding': { kind: 'list', scope: 'root' },
},
inject: shellInjected,
}, SettingsRoot)
return () => { disposeCommand(); disposeSlot() }
})
const disposeSlot = ctx.slots.register({
name: 'sidebar.settings',
locale: NS,
store: shellStore,
children: {
'settings.launcher': { kind: 'single', scope: 'root' },
'settings.trigger': { kind: 'single', scope: 'root' },
'settings.header': { kind: 'single', scope: 'root' },
'settings.action': { kind: 'list', scope: 'root' },
'settings.close': { kind: 'single', scope: 'root' },
'settings.section': { kind: 'list', scope: 'root' },
'settings.onboarding': { kind: 'list', scope: 'root' },
},
inject: shellInjected,
}, SettingsRoot)
return () => { disposeCommand(); disposeSlot() }
}))
ctx.slots.inject('settings.trigger', () =>
ctx.slots.register({ name: 'settings.trigger', locale: NS }, TriggerContent))
@@ -96,7 +96,7 @@ function setPageUrl(url: string): void {
describe('ui-settings-general apply', () => {
it('declares the services it uses', () => {
expect(inject).toEqual(['slots', 'locale', 'connection', 'remote', 'remote.settings', 'configForms', 'shortcuts', 'modules'])
expect(inject).toEqual(['slots', 'locale', 'connection', 'remote', 'remote.settings', 'configForms', 'shortcuts', 'modules', 'remoteAccess'])
})
it('fills the five seats of the shell it declares, with the locale-following General label', async ({ mock, start }) => {
@@ -16,6 +16,7 @@ import type { DesktopUpdatePresentation } from '../src/types.ts'
const SELF = '@deepseek-ai/dsh-client-ui-settings-general'
const SIDEBAR = '@deepseek-ai/dsh-client-ui-sidebar'
const it = createClientTest({ roster: webApp })
const withoutSettingsAccess = createClientTest({ roster: webApp }, { access: action => action !== 'settings/describe' })
/** The whole roster's first boot pays the cold module transform of every plugin package. */
const COLD_BOOT_TIMEOUT_MS = 60_000
@@ -77,7 +78,7 @@ describe('ui-settings-general shell', () => {
}, COLD_BOOT_TIMEOUT_MS)
it('declares its services', () => {
expect(inject).toEqual(['slots', 'locale', 'connection', 'remote', 'remote.settings', 'configForms', 'shortcuts', 'modules'])
expect(inject).toEqual(['slots', 'locale', 'connection', 'remote', 'remote.settings', 'configForms', 'shortcuts', 'modules', 'remoteAccess'])
})
it('occupies sidebar.settings, declared by ui-sidebar, and declares every child slot', async ({ start }) => {
@@ -86,6 +87,11 @@ describe('ui-settings-general shell', () => {
for (const name of CHILD_NAMES) expect(c.ctx.slots.spec(name)).toEqual(CHILD_SPECS[name])
}, COLD_BOOT_TIMEOUT_MS)
withoutSettingsAccess('leaves sidebar.settings unoccupied while the current Peer may not read the settings description', async ({ start }) => {
const c = await start()
expect(c.ctx.slots.entries('sidebar.settings')).toEqual([])
}, COLD_BOOT_TIMEOUT_MS)
it('projects the section ledger: product sections in order, defaults for bare rows, stable snapshots', async ({ start }) => {
const c = await start()
const { sections } = injectedOf(c).hooks
@@ -17,10 +17,13 @@ import {
import { ModelsSection } from '../src/client/ModelsSection.tsx'
import { DeepSeekOnboardingDialog } from '../src/client/DeepSeekOnboardingDialog.tsx'
import { WelcomeNotice } from '../src/client/WelcomeNotice.tsx'
import type { IndexInjection } from '@deepseek-ai/dsh-host-webserver'
import type { IndexInjection, WebServer } from '@deepseek-ai/dsh-host-webserver'
import * as hostPlugin from '../src/index.ts'
import { ONBOARDING_CONFIG_GLOBAL } from '../src/onboarding-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]
afterEach(() => { vi.unstubAllGlobals() })
// These specs assert the shipped Chinese copy. The lane has no jsdom `window`,
@@ -74,7 +77,7 @@ describe('ui-settings-models apply', () => {
const host = ctx.plugin(hostPlugin, { credentialOnboarding: false })
await host.await()
const rows: IndexInjection[] = []
ctx.emit('webserver/index-inject', rows)
ctx.emit('webserver/index-inject', rows, PAGE_PEER)
expect(rows).toEqual([{ kind: 'global', name: ONBOARDING_CONFIG_GLOBAL, value: { credentialOnboarding: false } }])
for (const row of rows) if (row.kind === 'global') vi.stubGlobal(row.name, row.value)
const plugin = ctx.plugin({ inject: [...inject], apply })
@@ -87,7 +90,7 @@ describe('ui-settings-models apply', () => {
expect(slots.entries('settings.onboarding')).toEqual([])
await host.dispose()
const after: IndexInjection[] = []
ctx.emit('webserver/index-inject', after)
ctx.emit('webserver/index-inject', after, PAGE_PEER)
expect(after).toEqual([])
} finally {
await ctx.fiber.dispose()
@@ -1,10 +1,13 @@
/** Host configuration supplies bounded Office reuse settings to browser pages. */
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, onTestFinished } from 'vitest'
import { Config } from '../src/config.ts'
import * as host 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('exposes Client cache and spreadsheet limits on the Host entry', () => {
expect(host.Config).toBe(Config)
expect(Config({})).toEqual({ office: {
@@ -22,10 +25,10 @@ it('embeds YAML cache settings in browser pages and withdraws them on disposal',
const fiber = ctx.plugin(host, config)
await fiber.await()
const rows: IndexInjection[] = []
ctx.emit('webserver/index-inject', rows)
ctx.emit('webserver/index-inject', rows, PAGE_PEER)
expect(rows).toEqual([{ kind: 'global', name: '__DSH_DOCUMENT_PREVIEW_CONFIG__', value: config }])
await fiber.dispose()
const after: IndexInjection[] = []
ctx.emit('webserver/index-inject', after)
ctx.emit('webserver/index-inject', after, PAGE_PEER)
expect(after).toEqual([])
})
@@ -1,6 +1,6 @@
import { Context } from '@deepseek-ai/cordis'
import { describe, expect, it } from 'vitest'
import type { IndexInjection } from '@deepseek-ai/dsh-host-webserver'
import type { IndexInjection, WebServer } from '@deepseek-ai/dsh-host-webserver'
import * as HostPlugin from '../src/index.ts'
import { liveConfig, omitsGeneratedPage } from '../../../settings/settings/tests/live-config.ts'
import { plainConfig } from '../../../settings/settings/src/schema.ts'
@@ -8,11 +8,14 @@ import {
DEFAULT_PREFERENCE, Config, apply,
} from '@deepseek-ai/dsh-client-ui-theme'
/** 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]
/** Collect the injection table the way an index render or boot payload does. */
function collect(ctx: Context): IndexInjection[] {
const table: IndexInjection[] = []
ctx.emit('webserver/index-inject', table)
ctx.emit('webserver/index-inject', table, PAGE_PEER)
return table
}
@@ -33,8 +33,8 @@
en: 9fb88260380bf093
zh: 3bace2b96ecd4a82
/deepseek-ai-dsh-client-ui-workspace/use-this-package/active-schedule-markers:
en: 0ae90db57a09c62c
zh: 453f350f1dea2ac4
en: 57f47add55b9f5c2
zh: af7fe7f6b32cd30a
/deepseek-ai-dsh-client-ui-workspace/understand-the-implementation:
en: 96a37e74555a36b7
zh: d03941523509a5cf
+1 -1
View File
@@ -83,7 +83,7 @@ Once both Workspace and Session startup baselines are ready, an empty installati
The first-use directory name and its stored title are fixed, so neither follows the reader's language: `workspaces.initializeDefault` carries no names, and one installation keeps one on-disk path and one stored title across language switches. A Workspace still carrying that automatic title is labeled with the localized default name wherever this package shows it — sidebar rows and their hover card, search result meta, the hero picker menu, and the rename and delete dialogs — through the controller's `workspaceDisplayTitle`. The rename dialog is seeded with the label on screen while the stored title decides whether confirming saves, so confirming the untouched prefill on such a Workspace pins that name and the row stops following the language; the duplicate-title check excludes the target by Workspace identity rather than by title.
The package injects the Client `remoteAccess` service and provides the root standard Hook `useWorkspaceAccess(workspaceId, action)`, which answers whether the current Peer may call `action` on one Workspace resource; its snapshot semantics belong to [`dsh-api-remote-access`](../../api/remote-access/README.md#client-face).
The package injects the Client `remoteAccess` service and provides the root standard Hook `useWorkspaceAccess(workspaceId, action)`, which answers whether the current Peer may call `action` on one Workspace resource, and `ctx.uiWorkspace.whenAccess(workspaceId, action, callback)`, which keeps the callback's effects installed while that answer is true, with the semantics of `ctx.remoteAccess.whenAccess`; snapshot semantics belong to [`dsh-api-remote-access`](../../api/remote-access/README.md#client-face).
<a id="understand-the-implementation"></a>
## Understand the implementation
+1 -1
View File
@@ -83,7 +83,7 @@ Workspace 和 Session 的启动基线均就绪后,空安装环境调用 `works
首次使用的目录名与其存储标题都是固定的,都不跟随读者语言:`workspaces.initializeDefault` 不携带任何名称,同一安装环境在切换语言后仍只有一个磁盘路径和一个存储标题。仍保留该自动标题的工作区,在本包展示它的所有位置——侧栏行及其悬浮卡片、搜索结果元信息、hero 选择器菜单、重命名与删除对话框——都通过控制器的 `workspaceDisplayTitle` 显示为本地化的默认名称。重命名对话框以屏幕上的标签作为初值,而判断确认是否需要保存用的是存储标题,因此对这类工作区直接确认未改动的初值即可固定该名称,该行不再跟随语言;重名检查按工作区身份而非标题排除目标自身。
本包注入 Client `remoteAccess` 服务,并提供根作用域标准 Hook `useWorkspaceAccess(workspaceId, action)`,回答当前 Peer 能否在某个 Workspace 资源上调用 `action`;其快照语义归 [`dsh-api-remote-access`](../../api/remote-access/README.zh.md#client-face)。
本包注入 Client `remoteAccess` 服务,并提供根作用域标准 Hook `useWorkspaceAccess(workspaceId, action)`,回答当前 Peer 能否在某个 Workspace 资源上调用 `action`,以及 `ctx.uiWorkspace.whenAccess(workspaceId, action, callback)`,在该答案为 true 期间保持回调的效果,语义同 `ctx.remoteAccess.whenAccess`;快照语义归 [`dsh-api-remote-access`](../../api/remote-access/README.zh.md#client-face)。
<a id="understand-the-implementation"></a>
## 理解实现
@@ -1,6 +1,7 @@
/** Workspace archive and directory UI capability. */
import { Service, type Context } from '@deepseek-ai/cordis'
import type { AccessEffect, RemoteEndpoint } from '@deepseek-ai/dsh-api-remote-access/client'
import type { ClientRemote, DirectoryListing, RemoteFailure } from '@deepseek-ai/dsh-api-remotes/client'
import type {
ISessions,
@@ -130,6 +131,17 @@ export interface UiWorkspace {
* @returns created absolute path.
*/
createDirectory(path: string, name: string): Promise<string>
/**
* Keep `callback`'s effects installed while the current Peer may call
* `action` on one Workspace resource, with the semantics of
* `ctx.remoteAccess.whenAccess`: `useWorkspaceAccess` answers the same
* question during render.
* @param workspaceId - the Workspace 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(workspaceId: WorkspaceId, action: RemoteEndpoint, callback: () => AccessEffect): () => void
}
declare module '@deepseek-ai/cordis' {
@@ -406,6 +418,14 @@ class UiWorkspaceService extends Service implements UiWorkspace {
return result.value
}
whenAccess(workspaceId: WorkspaceId, 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 uiWorkspace is reachable. */
if (access === undefined) throw new Error('ui-workspace: whenAccess requires the remoteAccess service')
return access.whenResourceAccess('workspace', workspaceId, action, callback)
}
private watchNavigation(): () => void {
let initial: 'waiting' | 'connecting' | 'done' = 'waiting'
const reconcile = (): void => {
@@ -10,7 +10,7 @@ import type { SessionId } from '@deepseek-ai/dsh-session/types'
import { SlotRegistry } from '@deepseek-ai/dsh-client-ui-renderer/client'
import type { HostObservable, PropsRenderSlots, StoredEntry } from '@deepseek-ai/dsh-client-ui-slots'
import type { ShortcutCommand } from '@deepseek-ai/dsh-client-shortcuts/client'
import { RemoteError, TestRemote } from '@deepseek-ai/dsh-client-test-runtime'
import { RemoteError, StaticRemoteAccess, TestRemote } from '@deepseek-ai/dsh-client-test-runtime'
import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client'
import { apply, inject } from '@deepseek-ai/dsh-client-ui-workspace/client'
import type { WorkspaceBrowserInjected, WorkspacePickerInjected } from '@deepseek-ai/dsh-client-ui-workspace/client'
@@ -56,7 +56,7 @@ const workspaceState = (
const ALLOWED: HostObservable<boolean> = createSnapshotStore(true)
async function bench() {
async function bench(access: 'fake' | 'static' = 'fake') {
const ctx = new Context()
const commands = new Map<string, ShortcutCommand>()
ctx.provide('shortcuts', {
@@ -69,7 +69,10 @@ async function bench() {
ctx.provide('uiConversation', {})
await ctx.plugin(SlotRegistry).await()
const accessSource = vi.fn((_kind: string, _id: string, _action: string): HostObservable<boolean> => ALLOWED)
ctx.provide('remoteAccess', { source: accessSource })
const stopGuard = vi.fn()
const whenResourceAccess = vi.fn((): () => void => stopGuard)
if (access === 'static') new StaticRemoteAccess(ctx)
else ctx.provide('remoteAccess', { source: accessSource, whenAccess: vi.fn(() => () => {}), whenResourceAccess })
const create = vi.fn(async (input: { name: string } | { path: string }) => ({
workspaceId: 'ws-new' as never,
path: 'name' in input ? `/projects/${input.name}` : input.path,
@@ -162,7 +165,7 @@ async function bench() {
locale.setLocale('zh')
ctx.provide('locale', locale)
return {
ctx, slots: ctx.get('slots') as SlotRegistry, locale, create, rename, commands, accessSource,
ctx, slots: ctx.get('slots') as SlotRegistry, locale, create, rename, commands, accessSource, whenResourceAccess, stopGuard,
retain, using, selectPanel, search, renameSession, binding, fork, pickDirectory, pinSession, unpinSession,
workspacesSubscribe, initializeDefault, history, scope, sessionOf,
setWorkspaces: (snapshot: WorkspaceSnapshot): void => { workspaceSnapshot = snapshot },
@@ -233,6 +236,25 @@ describe('ui-workspace apply', () => {
expect(keyed?.('w1', 'workspace/follow')).toBe(ALLOWED)
expect(b.accessSource).toHaveBeenCalledWith('workspace', 'w1', 'workspace/follow')
expect(keyed?.('w1')).toBeUndefined()
// `whenAccess` follows the same Workspace resource through the service's resource form.
const guarded = vi.fn(() => () => {})
expect(b.ctx.uiWorkspace.whenAccess('w1' as WorkspaceId, 'workspace/follow', guarded)).toBe(b.stopGuard)
expect(b.whenResourceAccess).toHaveBeenCalledWith('workspace', 'w1', 'workspace/follow', guarded)
})
it('hangs Workspace-guarded effects on the calling plugin, so they leave when it unloads', async () => {
const b = await bench('static')
onTestFinished(() => b.ctx.fiber.dispose())
await b.ctx.plugin({ inject: [...inject], apply }).await()
const dispose = vi.fn()
const caller = b.ctx.plugin({
inject: ['uiWorkspace'],
apply: (callerCtx: Context) => { callerCtx.uiWorkspace.whenAccess('w1' as WorkspaceId, 'workspace/follow', () => dispose) },
})
await caller.await()
expect(dispose).not.toHaveBeenCalled()
await caller.dispose()
expect(dispose).toHaveBeenCalledOnce()
})
it('reports a default Workspace creation failure through the shared notice overlay', async () => {
@@ -15,8 +15,8 @@
en: d152484eb41ac6b4
zh: 364c80ee00a374ff
/deepseek-ai-dsh-experimental-client-ui-voice-input/use-this-package:
en: 4db1c67430cfa691
zh: 484808eeff0d7692
en: b7a99b656839e8a0
zh: 5c6e2635eb20fc11
/deepseek-ai-dsh-experimental-client-ui-voice-input/understand-the-implementation:
en: 8fe96eeef9afe7d7
zh: 11936f647ded39eb
@@ -31,6 +31,8 @@ Use the native **Input device** selector in bundle details to follow the system
Before downloading or retrying, **Model download source** offers Automatic and the origins advertised by the Host. SenseVoice normally offers Hugging Face and HF-Mirror; a fixed private deployment exposes only its configured origin. Manual selection uses only that source, while Automatic preserves the provider’s comparison and fallback policy. The choice is retained for retries in the current card, without changing deployment configuration or recognition preferences. Source controls are unavailable during preparation and hidden when resources are ready.
`pluginNavigation` is optional: the plugin starts without it, and the setup dialog offers the details button only while the service is present.
-----
<a id="understand-the-implementation"></a>
@@ -31,6 +31,8 @@ kind: "package-reference"
下载或重试前,**模型下载源**提供自动选择及 Host 公布的源。SenseVoice 默认提供 Hugging Face 和 HF-Mirror;固定私有部署仅显示配置的源。手动选择只使用指定源,自动选择沿用提供方的比较与回退策略。当前卡片中的重试会保留选择,不修改部署配置或识别偏好。准备期间不能改源,资源就绪后隐藏源选择器。
`pluginNavigation` 为可选服务:没有它时插件照常启动,设置对话框只在该服务存在时提供详情按钮。
-----
<a id="understand-the-implementation"></a>
@@ -31,7 +31,12 @@ export interface VoiceInputActions {
/** Entry-injected Host readiness and microphone operations. */
export interface VoiceInputInjected extends VoiceInputActions {
hooks: { speechReadiness: HostObservable<SpeechReadiness>; microphoneDevice: HostObservable<MicrophoneDevice> }
hooks: {
speechReadiness: HostObservable<SpeechReadiness>
microphoneDevice: HostObservable<MicrophoneDevice>
/** Whether the Plugins page navigation service is present; the setup dialog offers the details only while it is. */
pluginNavigation: HostObservable<boolean>
}
}
/** Composer-owned expansion and draft actions; preferences stay in plugin settings. */
@@ -58,8 +63,9 @@ async function disposeRecording(capture: Recording): Promise<void> {
/** Render a compact microphone or an expanded capture, transcription, or retry row. */
export function VoiceInput({ sessionId, inputActions, locked, onActiveChange,
createRecording, transcribe, openSettings, useSpeechReadiness, t }: VoiceInputProps) {
createRecording, transcribe, openSettings, useSpeechReadiness, usePluginNavigation, t }: VoiceInputProps) {
const readiness = useSpeechReadiness(value => value), catalog = readiness.catalog
const navigable = usePluginNavigation(present => present)
const provider = catalog?.providers.find(item => item.id === catalog.selection.providerId)
const usable = readiness.connected && (provider?.preparation.phase === 'ready' || provider?.preparation.phase === 'standby'
|| provider?.preparation.phase === 'waking')
@@ -153,7 +159,8 @@ export function VoiceInput({ sessionId, inputActions, locked, onActiveChange,
</Tooltip>
<VoiceSetupDialog open={setupOpen && !usable}
needsInstallation={readiness.connected && provider?.location === 'host-local' && provider.preparation.phase === 'unprepared'}
onDismiss={() => { setSetupOpen(false) }} onOpenDetails={() => { setSetupOpen(false); openSettings() }} t={t} />
onDismiss={() => { setSetupOpen(false) }}
onOpenDetails={navigable ? () => { setSetupOpen(false); openSettings() } : undefined} t={t} />
</>
return <div className={css.captureRow} data-voice-activity={phase}>
<Button type="button" className={css.roundButton} size="sm" aria-label={t(pending ? 'discard' : 'cancel')}
@@ -5,8 +5,13 @@ import type {} from '@deepseek-ai/dsh-client-ui-plugin-manager/client'
import type { NS } from './locales.ts'
type VoiceSetupDialogProps = PropsLocale<typeof NS>
& Pick<PropsRuntime<'plugins.bundle.activation'>, 'onDismiss' | 'onOpenDetails'>
& { open: boolean; needsInstallation: boolean }
& Pick<PropsRuntime<'plugins.bundle.activation'>, 'onDismiss'>
& {
open: boolean
needsInstallation: boolean
/** Open the plugin details; the details button is absent while it is undefined. */
onOpenDetails: PropsRuntime<'plugins.bundle.activation'>['onOpenDetails'] | undefined
}
/**
* Guide activation or microphone clicks to the existing plugin details.
@@ -18,7 +23,9 @@ export function VoiceSetupDialog({ open, needsInstallation, onDismiss, onOpenDet
closeLabel={t('cancel')} onClose={onDismiss}
footer={<>
<Button variant="ghost" onClick={onDismiss}>{t('setupPrompt.later')}</Button>
<Button variant="primary" data-modal-autofocus onClick={onOpenDetails}>{t(needsInstallation ? 'setupPrompt.open' : 'setupPrompt.details')}</Button>
{onOpenDetails !== undefined && <Button variant="primary" data-modal-autofocus onClick={onOpenDetails}>
{t(needsInstallation ? 'setupPrompt.open' : 'setupPrompt.details')}
</Button>}
</>}>
<p>{t(needsInstallation ? 'setupPrompt.body' : 'setupPrompt.unavailableBody')}</p>
</Modal>
Loaded 100 of 127 files, more files were not shown because too many files have changed in this diff. Show more