diff --git a/.agents/notes/implemented/architecture/2026-10-12-peer-principal-and-remote-access.i18n.yaml b/.agents/notes/implemented/architecture/2026-10-12-peer-principal-and-remote-access.i18n.yaml index fbf95020c0..9378bc4f0b 100644 --- a/.agents/notes/implemented/architecture/2026-10-12-peer-principal-and-remote-access.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-10-12-peer-principal-and-remote-access.i18n.yaml @@ -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 diff --git a/.agents/notes/implemented/architecture/2026-10-12-peer-principal-and-remote-access.md b/.agents/notes/implemented/architecture/2026-10-12-peer-principal-and-remote-access.md index 324606f3ab..c9894ae409 100644 --- a/.agents/notes/implemented/architecture/2026-10-12-peer-principal-and-remote-access.md +++ b/.agents/notes/implemented/architecture/2026-10-12-peer-principal-and-remote-access.md @@ -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:` 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:` 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 | diff --git a/.agents/notes/implemented/architecture/2026-10-12-peer-principal-and-remote-access.zh.md b/.agents/notes/implemented/architecture/2026-10-12-peer-principal-and-remote-access.zh.md index afbd49de7f..ed124e8159 100644 --- a/.agents/notes/implemented/architecture/2026-10-12-peer-principal-and-remote-access.zh.md +++ b/.agents/notes/implemented/architecture/2026-10-12-peer-principal-and-remote-access.zh.md @@ -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:` 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:` 于其资源上被授权时整条收到事件;未声明资源的事件按 `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 | diff --git a/.agents/notes/implemented/architecture/2026-10-21-when-access.i18n.yaml b/.agents/notes/implemented/architecture/2026-10-21-when-access.i18n.yaml new file mode 100644 index 0000000000..2a74d80a3d --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-10-21-when-access.i18n.yaml @@ -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 diff --git a/.agents/notes/implemented/architecture/2026-10-21-when-access.md b/.agents/notes/implemented/architecture/2026-10-21-when-access.md new file mode 100644 index 0000000000..5936accdf5 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-10-21-when-access.md @@ -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` | 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. diff --git a/.agents/notes/implemented/architecture/2026-10-21-when-access.zh.md b/.agents/notes/implemented/architecture/2026-10-21-when-access.zh.md new file mode 100644 index 0000000000..242be55f65 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-10-21-when-access.zh.md @@ -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` | 插件要自己读 `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 访问面。 diff --git a/apps/desktop-host/src/index.ts b/apps/desktop-host/src/index.ts index 7ff0cb6473..6ce80e1aa5 100644 --- a/apps/desktop-host/src/index.ts +++ b/apps/desktop-host/src/index.ts @@ -104,7 +104,7 @@ async function main(): Promise { 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. */ diff --git a/apps/web/tests/expected/navigation-panes/copy-markdown.expected.md b/apps/web/tests/expected/navigation-panes/copy-markdown.expected.md new file mode 100644 index 0000000000..7de5e961c1 --- /dev/null +++ b/apps/web/tests/expected/navigation-panes/copy-markdown.expected.md @@ -0,0 +1,81 @@ +# NavScenario: first run bash to + +Session file: + +```text +{{sessionFile}} +``` + +> NavScenario: first run bash to print exactly NAVIGATION_OK, then read nav-a.md and nav-b.md using two read calls in ONE assistant message, then reply with the single word FIRST_DONE and stop. + +
3 previous messages + +**Tool: `bash`** + +```json +{ + "command": "echo NAVIGATION_OK", + "description": "Print NAVIGATION_OK" +} +``` + +**Result** + +``` +NAVIGATION_OK +``` + +**Tool: `read`** + +```json +{ + "file_path": "nav-a.md" +} +``` + +**Result** + +``` +{{cwd}}/workspace/nav-a.md +file + +1: # alpha nav + +(End of file - total 1 lines) + +``` + +**Tool: `read`** + +```json +{ + "file_path": "nav-b.md" +} +``` + +**Result** + +``` +{{cwd}}/workspace/nav-b.md +file + +1: # beta nav + +(End of file - total 1 lines) + +``` + +
+ +FIRST_DONE + +> Reply in markdown with: a level-2 heading "Navigation Summary", a bulleted list of exactly two items, and a fenced code block containing echo WATERFALL. Then stop. + +## Navigation Summary + +- alpha nav +- beta nav + +``` +echo WATERFALL +``` diff --git a/apps/web/tests/navigation-panes.e2e.ts b/apps/web/tests/navigation-panes.e2e.ts index effd31c913..ae8726c23f 100644 --- a/apps/web/tests/navigation-panes.e2e.ts +++ b/apps/web/tests/navigation-panes.e2e.ts @@ -6,7 +6,7 @@ // under the standard discipline: turn 1 produces a bash call plus two // parallel reads in one assistant message (tool-call density for the // trajectory ledger/timing lanes), turn 2 a markdown-rich reply. -import { mkdir, readFile, writeFile } from 'node:fs/promises' +import { mkdir, readFile, stat, writeFile } from 'node:fs/promises' import { fileURLToPath } from 'node:url' import { join } from 'node:path' import type { Browser, Page, Response } from 'playwright' @@ -14,7 +14,7 @@ import { chromium } from 'playwright' import { strFromU8, unzipSync } from 'fflate' import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, onTestFailed } from 'vitest' import { parseSessionLog } from '@deepseek-ai/dsh-llm-replay' -import { SESSION_FORMAT_VERSION, type SessionEvent } from '@deepseek-ai/dsh-session' +import { SESSION_FORMAT_VERSION, SessionId, type SessionEvent } from '@deepseek-ai/dsh-session' import { assertFixtureInventory, captureStableAria, compareOrRefreshGolden, fixtureUserPrompts, launchWebScaffold, recordFixture, seedSession, watchConsole, webSnapshotMode, type WebScaffold, @@ -32,6 +32,7 @@ const TERMINAL_EXPECTED = join(SNAPSHOT_DIR, 'terminal-card.expected.md') const CODE_CARD_DIR = fileURLToPath(new URL('./expected/navigation-panes', import.meta.url)) const COMMAND_WRAP_EXPECTED = join(CODE_CARD_DIR, 'command-wrap.expected.md') const CODE_CARD_EXPECTED = join(CODE_CARD_DIR, 'code-card.expected.md') +const COPY_MARKDOWN_EXPECTED = join(CODE_CARD_DIR, 'copy-markdown.expected.md') const MODE = webSnapshotMode() const SEED_ID = 'navigation-panes-web-e2e' const EXPORTED_LOG_FILE = `session.v${SESSION_FORMAT_VERSION}.jsonl` @@ -424,6 +425,66 @@ describe('web e2e: navigation & panes over a rich seeded session', () => { } }, 120_000) + it.skipIf(MODE === 'record')('copies the complete Session as Markdown from the Session Header', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-navigation-copy-markdown')) + await ensureSeedOpen(page) + await page.context().grantPermissions(['clipboard-read', 'clipboard-write']) + expect(await page.evaluate(() => typeof ClipboardItem)).toBe('function') + expect(await page.evaluate(() => typeof navigator.clipboard.write)).toBe('function') + await page.evaluate(() => { + const clipboard = navigator.clipboard + const originalWrite = clipboard.write.bind(clipboard) + Reflect.set(globalThis, '__dshClipboardWrites', 0) + Object.defineProperty(clipboard, 'write', { + configurable: true, + value: (...args: Parameters) => { + Reflect.set(globalThis, '__dshClipboardWrites', Number(Reflect.get(globalThis, '__dshClipboardWrites')) + 1) + return originalWrite(...args) + }, + }) + }) + const markdownResponse = page.waitForResponse(response => + response.request().method() === 'GET' + && new URL(response.url()).pathname === '/api/session.markdown', { timeout: 30_000 }) + const copyButton = page.getByRole('button', { name: 'More actions' }) + await copyButton.click() + await page.getByRole('menuitem', { name: 'Copy Session as Markdown' }).click() + const response = await markdownResponse + expect(response.status()).toBe(200) + expect(response.headers()['content-type']).toBe('text/markdown; charset=utf-8') + await page.getByText('Session copied as Markdown.', { exact: true }).waitFor({ timeout: 30_000 }) + expect(await page.evaluate(() => Number(Reflect.get(globalThis, '__dshClipboardWrites')))).toBe(1) + + const sourcePath = (await scaffold.ctx.sessionPersistence.stat(SessionId(SEED_ID)))?.location?.path + if (sourcePath === undefined) throw new Error('seeded Session has no materialized artifact location') + expect((await stat(sourcePath)).isFile()).toBe(true) + expect(sourcePath.startsWith(`${scaffold.persistenceRoot}/`)).toBe(true) + const markdown = await page.evaluate(() => navigator.clipboard.readText()) + expect(markdown).toContain(`Session file:\n\n\`\`\`text\n${sourcePath}\n\`\`\``) + const normalized = markdown + .split(sourcePath).join('{{sessionFile}}') + .split(scaffold.workspaceCwd).join('{{cwd}}') + // compareOrRefreshGolden appends its own newline; the clipboard text already ends with exactly one. + await compareOrRefreshGolden(COPY_MARKDOWN_EXPECTED, normalized.replace(/\n$/u, ''), MODE) + expect(normalized).toContain('Session file:\n\n```text\n{{sessionFile}}\n```') + expect(normalized).toContain('# NavScenario: first run bash to') + expect(normalized).toContain('> NavScenario: first run bash to print exactly NAVIGATION_OK') + expect(normalized).toContain('
3 previous messages') + expect(normalized).toContain('**Tool: `bash`**') + expect(normalized).toContain('NAVIGATION_OK') + expect(normalized).toContain('FIRST_DONE') + expect(normalized).toContain('Navigation Summary') + expect(normalized).not.toContain('The user wants me to follow') + expect(normalized).not.toContain('{{system}}') + expect(normalized).not.toContain('request/header') + expect(normalized.endsWith('\n')).toBe(true) + expect(normalized.endsWith('\n\n')).toBe(false) + + await copyButton.click() + expect(await page.getByRole('menuitem', { name: 'Copy Session as Markdown' }).isDisabled()).toBe(false) + await page.keyboard.press('Escape') + }, 60_000) + it.skipIf(MODE === 'record')('focuses the ledger by dragging an overview interval', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-navigation-timeline')) await ensureSeedOpen(page) @@ -561,7 +622,9 @@ describe('web e2e: navigation & panes over a rich seeded session', () => { }, 60_000) it.skipIf(MODE === 'record')('keeps the recorded fixture inventory exact', async () => { - await assertFixtureInventory(CODE_CARD_DIR, ['code-card.expected.md', 'command-wrap.expected.md']) + await assertFixtureInventory(CODE_CARD_DIR, [ + 'code-card.expected.md', 'command-wrap.expected.md', 'copy-markdown.expected.md', + ]) await assertFixtureInventory(SNAPSHOT_DIR, [ 'session.v3.jsonl', 'search-id-results.expected.md', 'search-results.expected.md', 'trajectory.expected.md', 'terminal-card.expected.md', 'timing.expected.md', diff --git a/apps/web/tests/question-composer.e2e.ts b/apps/web/tests/question-composer.e2e.ts index ae8f9818ec..b999cab7cf 100644 --- a/apps/web/tests/question-composer.e2e.ts +++ b/apps/web/tests/question-composer.e2e.ts @@ -68,9 +68,43 @@ async function capMetrics(field: Locator): Promise<{ textLines: number; scrolls: }) } -// The options carry long descriptions on purpose: the squeeze assertion below -// needs option copy that WRAPS, which is the only text layout that reproduces a -// collapsed row painting its copy outside its own box. +/** + * Read how the composer draws one long-copy element: the lines it shows, + * whether CSS clipped it, whether its text overflows the line budget, and what + * its toggle does while that toggle is visible. + * @param element - a question heading or an option description. + * @returns the drawn state of the copy. + */ +function readCopy(element: HTMLElement): { lines: number; clipped: boolean; overflows: boolean; toggle: 'expand' | 'collapse' | undefined } { + const style = getComputedStyle(element) + const toggle = element.tagName === 'H2' + ? element.nextElementSibling + : element.closest('[data-option-row]')?.querySelector(':scope > button:not([role])') + return { + // A clipped box keeps its full budget and gives the dropped line back through its bottom margin. + lines: Math.round((element.getBoundingClientRect().height + parseFloat(style.marginBottom)) / parseFloat(style.lineHeight)), + clipped: style.maskImage !== 'none', + overflows: element.scrollHeight > element.clientHeight + 0.5, + toggle: toggle instanceof HTMLElement && getComputedStyle(toggle).visibility === 'visible' + ? toggle.getAttribute('aria-expanded') === 'true' ? 'collapse' : 'expand' + : undefined, + } +} + +/** + * Tell whether an expanded copy's toggle sits below the copy's last text line. + * @param toggle - the copy's toggle button. + * @param copy - the expanded question heading or option description. + * @returns whether the toggle starts at or below the bottom of the text; false without the copy. + */ +function belowText(toggle: HTMLElement, copy: Element | null): boolean { + if (copy === null) return false + const textBottom = copy.getBoundingClientRect().bottom - parseFloat(getComputedStyle(copy).paddingBottom) + return toggle.getBoundingClientRect().top >= textBottom - 0.5 +} + +// The options carry descriptions on purpose: the squeeze assertion below +// verifies that each description keeps to its two-line budget in the card scrollport. const PROMPT = 'Use the ask_user_question tool to ask me exactly one multi-select question with id "color", question "Which color do you prefer?", header "Pick one", and two options: label "Blue" with description "A cool recessive hue that reads as calm and trustworthy in long reading sessions and dense dashboards.", and label "Green" with description "A restful mid-spectrum hue with the highest perceived brightness, easiest on the eye over long sessions." Set multi_select to true. After I answer, reply with the single word DONE and stop.' function isRecord(value: unknown): value is Record { @@ -338,53 +372,85 @@ describe('web e2e: resident question composer round trip', () => { // This golden owns the stable question surface; the answered-state // golden below owns the resulting transcript. const snapshot = await captureStableAria(page, '[data-question-key]', scaffold.workspaceCwd) - await compareOrRefreshGolden(UI_EXPECTED, snapshot, MODE) + const descriptions = await page.locator('[data-question-key] [role="checkbox"]').evaluateAll(nodes => + nodes.map(node => ({ + label: node.getAttribute('aria-label'), + description: (node.getAttribute('aria-describedby') ?? '').split(/\s+/) + .filter(Boolean).map(id => document.getElementById(id)?.textContent ?? '').join(' '), + }))) + await compareOrRefreshGolden(UI_EXPECTED, `${snapshot}\nChoice descriptions: ${JSON.stringify(descriptions)}`, MODE) const sidebar = await captureStableAria(page, '[role="treeitem"][aria-selected="true"]', scaffold.workspaceCwd) await compareOrRefreshGolden(SIDEBAR_EXPECTED, sidebar, MODE) } - // Squeezed card: the option rows are the capped card's scroll content, so - // shrinking the seat must push overflow into the option list, never - // collapse a row below the height its own copy needs — a collapsed row - // paints its centered copy outside the row box, over the title and the - // neighbouring rows. Measured on the live composer at seat heights that - // force the cap, then restored for the answer gesture below. Replay only: - // record mode must reach the recording write below, not abort on layout. + // Squeezed card: the question and compact option rows share one scrollport, + // while navigation and submission stay outside it. Measured on the live + // composer at seat heights that force the cap, then restored for the answer + // gesture below. Replay only: record mode must reach the recording write + // below, not abort on layout. + const originalViewport = page.viewportSize() ?? { width: 1680, height: 1000 } if (MODE !== 'record') { - const original = page.viewportSize() ?? { width: 1680, height: 1000 } + const scrollStates: boolean[] = [] for (const height of [520, 440, 380]) { - await page.setViewportSize({ width: 900, height }) + await page.setViewportSize({ width: 640, height }) const squeeze = await composer.evaluate((card) => { - // Role/ARIA selectors, not the CSS-module class names: the built - // client hashes those. - const rows = [...card.querySelectorAll( - '[role="radio"], [role="checkbox"], [aria-expanded]', - )] + const rows = [...card.querySelectorAll('[data-option-row]')] const spill = rows.map(row => Math.max(...[...row.children].map((child) => { const box = row.getBoundingClientRect() const inner = child.getBoundingClientRect() return Math.max(box.top - inner.top, inner.bottom - box.bottom) }))) const list = card.querySelector('[data-question-scroll]') + const title = card.querySelector('h2') return { rows: rows.length, spill: Math.max(...spill), - // Wrapped option text is what overflows a collapsed row, and a - // scrolling list proves the seat is genuinely capped. Without both, - // the spill assertion would hold vacuously. - wrappedRows: rows.filter(row => row.getBoundingClientRect().height > 42).length, scrolls: list === null ? false : list.scrollHeight > list.clientHeight, + titleInScrollport: list?.contains(title) === true, } }) expect(squeeze.rows).toBeGreaterThan(0) - expect(squeeze.wrappedRows).toBeGreaterThan(0) - expect(squeeze.scrolls).toBe(true) + for (const description of await composer.locator('[data-option-description]').all()) { + const copy = await description.evaluate(readCopy) + expect(copy.lines).toBeLessThanOrEqual(2) + expect(copy.clipped).toBe(copy.overflows) + expect(copy.toggle).toBe(copy.clipped ? 'expand' : undefined) + } + scrollStates.push(squeeze.scrolls) + expect(squeeze.titleInScrollport).toBe(true) + expect(await composer.getByRole('button', { name: 'Submit', exact: true }).isVisible()).toBe(true) // Sub-pixel tolerance: every row's copy stays inside its border box. expect(squeeze.spill).toBeLessThan(0.6) } - await page.setViewportSize(original) + expect(scrollStates).toContain(true) + await page.setViewportSize(originalViewport) } + // The narrowest supported card clips Blue's description to one line; its + // toggle expands the text in place without choosing Blue. + await page.setViewportSize({ width: 390, height: 700 }) + const blue = composer.getByRole('checkbox', { name: 'Blue' }) + const blueText = composer.locator('[data-option-description]').first() + const blueToggle = composer.locator(`button[aria-controls=${JSON.stringify(await blueText.getAttribute('id'))}]`) + await expect.poll(() => blueText.evaluate(readCopy)).toEqual({ lines: 1, clipped: true, overflows: true, toggle: 'expand' }) + // A clipped description's dropped line takes no clicks: the custom row's top edge belongs to it. + expect(await composer.locator('[data-option-description]').last().evaluate(readCopy)).toMatchObject({ clipped: true }) + expect(await composer.locator('textarea').evaluate((field) => { + const row = field.closest('[class*="customRow"]') + if (!(row instanceof HTMLElement)) return false + const box = row.getBoundingClientRect() + const hit = document.elementFromPoint(box.left + box.width / 2, box.top + 2) + return hit !== null && row.contains(hit) + })).toBe(true) + await blueToggle.click() + expect(await blue.getAttribute('aria-checked')).toBe('false') + expect(await blueToggle.getAttribute('aria-expanded')).toBe('true') + expect(await blueText.evaluate(readCopy)).toMatchObject({ clipped: false, overflows: false, toggle: 'collapse' }) + await blueToggle.click() + // A collapsed copy's scroll timeline reports the overflow from the next frame. + await expect.poll(() => blueText.evaluate(readCopy)).toEqual({ lines: 1, clipped: true, overflows: true, toggle: 'expand' }) + await page.setViewportSize(originalViewport) + // Multi-line custom answer: the field is a textarea whose hidden mirror // owns the box height, so a soft-wrapped or line-broken draft GROWS the // field instead of scrolling one line, and Shift+Enter breaks the line @@ -415,7 +481,6 @@ describe('web e2e: resident question composer round trip', () => { await custom.fill('') } - const blue = composer.getByRole('checkbox', { name: 'Blue' }) await blue.click() await custom.fill('Include accessibility notes') expect(await blue.getAttribute('aria-checked')).toBe('true') @@ -536,6 +601,436 @@ describe('web e2e: resident question composer round trip', () => { await expect.poll(() => page.locator('[data-question-key]').count(), { timeout: 10_000 }).toBe(0) }, 60_000) + it.skipIf(MODE === 'record')('keeps short option explanations beside their labels', async () => { + const sessionId = answeredSession + expect(sessionId).toBeDefined() + const agent = scaffold.ctx.agents.get(sessionId as SessionId) + expect(agent).toBeDefined() + const controller = new AbortController() + const asked = scaffold.ctx.userQuestions.ask({ + agent: agent as NonNullable, + signal: controller.signal, + questions: [{ + id: 'reasoning', + header: '确认思考等级', + question: '选择 HF provider 的思考等级?', + options: [ + { label: '高(high)', description: '倾向更充分的推理,通常更慢、消耗更多 token,需模型支持。' }, + { label: '中(medium)', description: '平衡推理深度与速度;需模型支持。' }, + { label: '低(low)', description: '优先速度与较低 token 消耗;需模型支持。' }, + { label: '先查看当前配置', description: '先确认 HF provider 的模型及其支持的思考参数,再决定。' }, + ], + }], + }) + const outcome = asked.then(value => ({ value }), (error: unknown) => ({ error })) + const composer = page.locator('[data-question-key]') + const original = page.viewportSize() ?? { width: 1680, height: 1000 } + let completed = false + try { + await page.setViewportSize({ width: 1328, height: 720 }) + await composer.getByRole('heading', { name: '选择 HF provider 的思考等级?' }).waitFor() + expect(await composer.getByRole('heading').evaluate(readCopy)) + .toEqual({ lines: 1, clipped: false, overflows: false, toggle: undefined }) + expect(await composer.getByRole('button', { name: 'Expand', exact: true }).count()).toBe(0) + const rows = await composer.locator('[data-option-row]').evaluateAll(options => options.map((row) => { + const label = row.querySelector('[data-option-line] > span:first-child') + const description = row.querySelector('[data-option-description]') + if (label === null || description === null) return null + const style = getComputedStyle(description) + return { + sameLine: description.getBoundingClientRect().top < label.getBoundingClientRect().bottom, + fontSize: style.fontSize, + lineHeight: style.lineHeight, + maskImage: style.maskImage, + } + })) + expect(rows).toHaveLength(4) + expect(rows).toEqual(Array.from({ length: 4 }, () => ({ + sameLine: true, fontSize: '13px', lineHeight: '20px', maskImage: 'none', + }))) + await composer.getByRole('radio', { name: '中(medium)' }).click() + await composer.getByRole('button', { name: 'Submit', exact: true }).click() + expect(await outcome).toEqual({ value: { answers: [{ id: 'reasoning', selected: ['中(medium)'] }] } }) + completed = true + } finally { + controller.abort() + const cleanup = await Promise.allSettled([outcome, page.setViewportSize(original)]) + if (completed) { + const failure = cleanup.find((result): result is PromiseRejectedResult => result.status === 'rejected') + if (failure !== undefined) throw failure.reason + } + } + }, 60_000) + + it.skipIf(MODE === 'record')('keeps long questions answerable in short and zoomed viewports', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-question-long-copy')) + const sessionId = answeredSession + expect(sessionId).toBeDefined() + const agent = scaffold.ctx.agents.get(sessionId as SessionId) + expect(agent).toBeDefined() + const question = Array.from({ length: 8 }, () => 'The release gate reports that the installed application graph still contains an experimental package through a default dependency. Which approach should we use to preserve explicit installation while keeping the supported application graph valid?').join(' ') + const detail = 'Review the shipped profile, package ownership, upgrade impact, and the path users follow before choosing. The answer must preserve the existing release invariant, avoid a hidden compatibility path, and remain understandable to people who did not participate in the implementation discussion.' + const description = Array.from({ length: 48 }, (_, index) => + `Migration step ${String(index + 1)} removes the experimental client from shipped templates, requires an explicit profile install, names the affected users, and records the cross-platform validation before rollout.`).join(' ') + const controller = new AbortController() + const asked = scaffold.ctx.userQuestions.ask({ + agent: agent as NonNullable, + signal: controller.signal, + questions: [{ + id: 'release', + header: 'Release decision', + question, + detail, + multiSelect: true, + options: [ + { label: 'Explicit install', description }, + { label: 'Keep the default edge', description: 'Keep the current setup.' }, + { label: 'Move the package', description: 'Move it to a separately installed package with an explicit profile entry.' }, + ], + }, { id: 'followup', question: 'Who should own the rollout?', options: [{ label: 'Release team' }] }], + }) + const outcome = asked.then(value => ({ value }), (error: unknown) => ({ error })) + + const composer = page.locator('[data-question-key]') + const original = page.viewportSize() ?? { width: 1680, height: 1000 } + let bodyCompleted = false + try { + await composer.waitFor({ timeout: 30_000 }) + const heading = composer.getByRole('heading', { name: question }) + const descriptionOf = (label: string) => composer.locator('[data-option-row]', { + has: page.getByRole('checkbox', { name: label, exact: true }), + }).locator('[data-option-description]') + const toggleFor = async (copy: Locator) => + composer.locator(`button[aria-controls=${JSON.stringify(await copy.getAttribute('id'))}]`) + const readMetrics = () => composer.evaluate((card) => { + const scroll = card.querySelector('[data-question-scroll]') + const title = card.querySelector('h2') + const submit = [...card.querySelectorAll('button')] + .find(button => button.textContent === 'Next') + if (scroll === null || title === null || submit === undefined) return null + const submitBox = submit.getBoundingClientRect() + return { + footerFits: submitBox.top >= -0.5 && submitBox.bottom <= window.innerHeight + 0.5, + scrolls: scroll.scrollHeight > scroll.clientHeight, + titleInScrollport: scroll.contains(title), + } + }) + // Cover common desktop sizes plus the CSS-pixel equivalents of a + // progressively zoomed 640x420 window without changing browser-global zoom. + const scrollStates: boolean[] = [] + for (const viewport of [ + { width: 1440, height: 900 }, + { width: 900, height: 600 }, + { width: 640, height: 420 }, + { width: 512, height: 336 }, + { width: 427, height: 280 }, + { width: 390, height: 700 }, + ]) { + await page.setViewportSize(viewport) + await expect.poll(readMetrics).not.toBeNull() + const metrics = await readMetrics() + expect(metrics?.footerFits).toBe(true) + expect(metrics?.titleInScrollport).toBe(true) + scrollStates.push(metrics?.scrolls ?? false) + // Past its three-line budget the question keeps two lines; past its + // two-line budget a description keeps one. Copy within budget shows in full. + await expect.poll(() => heading.evaluate(readCopy)) + .toEqual({ lines: 2, clipped: true, overflows: true, toggle: 'expand' }) + expect(await descriptionOf('Explicit install').evaluate(readCopy)) + .toEqual({ lines: 1, clipped: true, overflows: true, toggle: 'expand' }) + expect(await descriptionOf('Keep the default edge').evaluate(readCopy)) + .toEqual({ lines: 1, clipped: false, overflows: false, toggle: undefined }) + for (const copy of await composer.locator('[data-option-description]').all()) { + const drawn = await copy.evaluate(readCopy) + expect(drawn.clipped).toBe(drawn.overflows) + expect(drawn.toggle).toBe(drawn.clipped ? 'expand' : undefined) + expect(drawn.lines).toBeLessThanOrEqual(drawn.clipped ? 1 : 2) + } + if (viewport.width === 512) { + expect(await descriptionOf('Move the package').evaluate(readCopy)) + .toEqual({ lines: 2, clipped: false, overflows: false, toggle: undefined }) + } + const textMetrics = await composer.locator('[data-option-description]').evaluateAll(nodes => nodes.map((node) => { + const style = getComputedStyle(node) + const rect = node.getBoundingClientRect() + return { fontSize: style.fontSize, lineHeight: style.lineHeight, + fitsViewport: rect.left >= 0 && rect.right <= innerWidth } + })) + expect(textMetrics).toEqual(Array.from({ length: 3 }, () => ({ + fontSize: '13px', lineHeight: '20px', fitsViewport: true, + }))) + } + expect(scrollStates).toContain(false) + expect(scrollStates).toContain(true) + + // A clipped question's dropped line takes no clicks: the detail's first line belongs to the detail. + expect(await composer.getByText(detail, { exact: true }).evaluate((text) => { + const box = text.getBoundingClientRect() + const hit = document.elementFromPoint(box.left + 8, box.top + 3) + return hit !== null && text.contains(hit) + })).toBe(true) + + // The question expands in place; its choices move down inside the + // scrollport while navigation and submission stay reachable. + const questionToggle = await toggleFor(heading) + expect(await questionToggle.getAttribute('aria-expanded')).toBe('false') + expect(await questionToggle.getAttribute('aria-label')).toBe('Expand') + expect(await questionToggle.getAttribute('aria-describedby')).toBe(await heading.getAttribute('id')) + await questionToggle.click() + expect(await questionToggle.getAttribute('aria-expanded')).toBe('true') + expect(await questionToggle.getAttribute('aria-label')).toBe('Collapse') + const expandedQuestion = await heading.evaluate(readCopy) + expect(expandedQuestion).toMatchObject({ clipped: false, overflows: false, toggle: 'collapse' }) + expect(expandedQuestion.lines).toBeGreaterThan(3) + expect(await questionToggle.evaluate(belowText, await heading.elementHandle())).toBe(true) + expect(await readMetrics()).toEqual({ footerFits: true, scrolls: true, titleInScrollport: true }) + await questionToggle.click() + await expect.poll(() => heading.evaluate(readCopy)).toEqual({ lines: 2, clipped: true, overflows: true, toggle: 'expand' }) + + const detailText = composer.getByText(detail, { exact: true }) + expect(await detailText.evaluate(node => node.closest('[data-question-scroll]') !== null)).toBe(true) + await detailText.scrollIntoViewIfNeeded() + expect(await detailText.isVisible()).toBe(true) + expect(await composer.getByRole('button', { name: 'Next', exact: true }).isVisible()).toBe(true) + + // A description expands in place without choosing its option, and its + // toggle stays described by the text it controls. + const explicit = composer.getByRole('checkbox', { name: 'Explicit install' }) + const explicitText = descriptionOf('Explicit install') + const descriptionToggle = await toggleFor(explicitText) + const descriptionId = await explicitText.getAttribute('id') + expect(await explicit.getAttribute('aria-describedby')).toBe(descriptionId) + expect(await descriptionToggle.getAttribute('aria-describedby')).toBe(descriptionId) + expect(await descriptionToggle.getAttribute('aria-label')).toBe('Expand') + await descriptionToggle.click() + expect(await explicit.getAttribute('aria-checked')).toBe('false') + const expandedDescription = await explicitText.evaluate(readCopy) + expect(expandedDescription).toMatchObject({ clipped: false, overflows: false, toggle: 'collapse' }) + expect(expandedDescription.lines).toBeGreaterThan(2) + expect(await descriptionToggle.evaluate(belowText, await explicitText.elementHandle())).toBe(true) + expect(await explicitText.evaluate((text) => { + const row = text.closest('[data-option-row]') as HTMLElement + return [...row.querySelectorAll('*')].every(child => + child.getBoundingClientRect().bottom <= row.getBoundingClientRect().bottom + 0.5) + })).toBe(true) + + // The toggle's icon keeps non-text contrast on the card in both themes. + const palette: string[] = [] + const originalDarkTheme = await page.locator('body').getAttribute('data-ds-dark-theme') + try { + for (const dark of [false, true]) { + await page.evaluate((value) => { document.body.toggleAttribute('data-ds-dark-theme', value) }, dark) + const colors = await descriptionToggle.evaluate((toggle) => { + const card = toggle.closest('section') + return card === null ? null : { text: getComputedStyle(toggle).color, surface: getComputedStyle(card).backgroundColor } + }) + if (colors === null) throw new Error('The question card is unavailable') + const channels = (color: string) => color.match(/[\d.]+/g)?.slice(0, 3).map(Number) ?? [] + const luminance = (color: string) => channels(color).reduce((total, channel, index) => { + const normalized = channel / 255 + const linear = normalized <= 0.04045 ? normalized / 12.92 : ((normalized + 0.055) / 1.055) ** 2.4 + return total + linear * [0.2126, 0.7152, 0.0722][index]! + }, 0) + const foreground = luminance(colors.text) + const background = luminance(colors.surface) + expect((Math.max(foreground, background) + 0.05) / (Math.min(foreground, background) + 0.05)) + .toBeGreaterThanOrEqual(3) + palette.push(colors.surface) + } + expect(palette[0]).not.toBe(palette[1]) + } finally { + await page.evaluate((previous) => { + if (previous === null) document.body.removeAttribute('data-ds-dark-theme') + else document.body.setAttribute('data-ds-dark-theme', previous) + }, originalDarkTheme) + } + await descriptionToggle.click() + await expect.poll(() => explicitText.evaluate(readCopy)) + .toEqual({ lines: 1, clipped: true, overflows: true, toggle: 'expand' }) + + await composer.getByRole('checkbox', { name: 'Explicit install' }).click() + const answer = composer.getByRole('textbox') + await answer.fill('Keep the migration steps explicit.') + await answer.press('Enter') + await composer.getByRole('heading', { name: 'Who should own the rollout?' }).waitFor() + expect(await answer.evaluate(node => document.activeElement === node)).toBe(true) + await page.keyboard.type('Release owner') + expect(await answer.inputValue()).toBe('Release owner') + await composer.getByRole('button', { name: 'Submit', exact: true }).click() + expect(await outcome).toEqual({ + value: { + answers: [{ + id: 'release', + selected: ['Explicit install'], + custom: 'Keep the migration steps explicit.', + }, { id: 'followup', selected: [], custom: 'Release owner' }], + }, + }) + await expect.poll(() => page.locator('[data-question-key]').count(), { timeout: 10_000 }).toBe(0) + bodyCompleted = true + } finally { + controller.abort() + const cleanup = await Promise.allSettled([outcome, page.setViewportSize(original)]) + if (bodyCompleted) { + const failure = cleanup.find((result): result is PromiseRejectedResult => result.status === 'rejected') + if (failure !== undefined) throw failure.reason + } + } + }, 60_000) + + it.skipIf(MODE === 'record')('re-evaluates long copy when the chat column or the window changes width', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-question-chat-width')) + const sessionId = answeredSession + expect(sessionId).toBeDefined() + const agent = scaffold.ctx.agents.get(sessionId as SessionId) + expect(agent).toBeDefined() + // Sized for the chat-width drag range: the question takes three lines or + // fewer in the widest chat column and more in the narrowest, and the staged + // description takes two lines or fewer and more. + const question = `${Array.from({ length: 2 }, () => 'The rollout plan must name the release owner, the verification lanes that gate it, and the rollback path before the candidate is cut.').join(' ')} Which plan should we adopt?` + const staged = 'Stage the rollout behind the release channel first, keep the experimental client out of every shipped template, ask plugin authors to declare the dependency explicitly, and publish the upgrade note together with the cross-platform validation results.' + const explicit = Array.from({ length: 24 }, (_, index) => + `Step ${String(index + 1)} removes the experimental client from shipped templates and records the validation before rollout.`).join(' ') + const controller = new AbortController() + const asked = scaffold.ctx.userQuestions.ask({ + agent: agent as NonNullable, + signal: controller.signal, + questions: [{ + id: 'rollout', + header: 'Rollout decision', + question, + options: [ + { label: 'Explicit install', description: explicit }, + { label: 'Stage the rollout', description: staged }, + { label: 'Keep the default edge', description: 'Keep the current setup.' }, + ], + }], + }) + const outcome = asked.then(value => ({ value }), (error: unknown) => ({ error })) + + const composer = page.locator('[data-question-key]') + const frame = page.locator('[style*="grid-template-columns"]').first() + const original = page.viewportSize() ?? { width: 1680, height: 1000 } + let bodyCompleted = false + try { + await page.setViewportSize({ width: 1680, height: 1000 }) + await composer.waitFor({ timeout: 30_000 }) + const heading = composer.getByRole('heading', { name: question }) + const descriptionOf = (label: string) => composer.locator('[data-option-row]', { + has: page.getByRole('radio', { name: label, exact: true }), + }).locator('[data-option-description]') + const toggleFor = async (copy: Locator) => + composer.locator(`button[aria-controls=${JSON.stringify(await copy.getAttribute('id'))}]`) + // Settled layout: no AppFrame column transition in flight, then every + // collapsed copy drawn as its own overflow decides, the expanded copy + // still expanded, and the actions reachable. + const settle = async () => { + await expect.poll(() => frame.evaluate(node => node.hasAttribute('data-animating'))).toBe(false) + const read = async () => { + const card = await composer.locator('section').evaluate((section) => { + const scroll = section.querySelector('[data-question-scroll]') + const submit = [...section.querySelectorAll('button')] + .find(button => button.textContent === 'Submit') + const submitBox = submit?.getBoundingClientRect() + return { + width: Math.round(section.getBoundingClientRect().width), + actionsReachable: submitBox !== undefined && submitBox.top >= -0.5 && submitBox.bottom <= window.innerHeight + 0.5, + titleInScrollport: scroll?.contains(section.querySelector('h2')) === true, + } + }) + return { + card, + question: await heading.evaluate(readCopy), + explicit: await descriptionOf('Explicit install').evaluate(readCopy), + staged: await descriptionOf('Stage the rollout').evaluate(readCopy), + short: await descriptionOf('Keep the default edge').evaluate(readCopy), + } + } + await expect.poll(async () => { + const state = await read() + const decided = (copy: Awaited>['question'], budget: number) => + copy.clipped === copy.overflows && copy.toggle === (copy.clipped ? 'expand' : undefined) + && copy.lines <= (copy.clipped ? budget - 1 : budget) + return state.card.actionsReachable && state.card.titleInScrollport + && decided(state.question, 3) && decided(state.staged, 2) && decided(state.short, 2) + && !state.explicit.clipped && !state.explicit.overflows && state.explicit.toggle === 'collapse' + }).toBe(true) + return await read() + } + // The right transcript handle widens the content column by twice its + // outward travel. The composer seat covers the handle's lower part, so + // the gesture grabs it beside the transcript. + const dragChatWidth = async (travel: number) => { + const box = await page.locator('[data-width-handle="right"]').boundingBox() + if (box === null) throw new Error('The chat width handle is not rendered') + const x = box.x + box.width / 2 + const y = box.y + 120 + await page.mouse.move(x, y) + await page.mouse.down() + try { + await page.mouse.move(x + travel, y, { steps: 8 }) + } finally { + await page.mouse.up() + } + } + + // Expanded copy is the user's choice and survives every width change below. + const explicitToggle = await toggleFor(descriptionOf('Explicit install')) + await explicitToggle.click() + expect(await explicitToggle.getAttribute('aria-expanded')).toBe('true') + expect(await composer.getByRole('radio', { name: 'Explicit install', exact: true }).getAttribute('aria-checked')).toBe('false') + + await dragChatWidth(-400) + const narrow = await settle() + expect(narrow.question).toEqual({ lines: 2, clipped: true, overflows: true, toggle: 'expand' }) + expect(narrow.staged).toEqual({ lines: 1, clipped: true, overflows: true, toggle: 'expand' }) + + await dragChatWidth(600) + const wide = await settle() + expect(wide.card.width).toBeGreaterThan(narrow.card.width) + expect(wide.question).toMatchObject({ clipped: false, overflows: false, toggle: undefined }) + expect(wide.staged).toEqual({ lines: 2, clipped: false, overflows: false, toggle: undefined }) + expect(wide.short).toEqual({ lines: 1, clipped: false, overflows: false, toggle: undefined }) + + // The right sidebar narrows the conversation column, and collapsing it restores the wide state. + await page.getByRole('button', { name: 'Open right sidebar', exact: true }).click() + const withSidebar = await settle() + expect(withSidebar.card.width).toBeLessThan(wide.card.width) + await page.getByRole('button', { name: 'Collapse right sidebar', exact: true }).click() + expect(await settle()).toEqual(wide) + + // A smaller window clamps the dragged width; restoring the window restores it. + await page.setViewportSize({ width: 900, height: 700 }) + const smallWindow = await settle() + expect(smallWindow.card.width).toBeLessThan(wide.card.width) + expect(smallWindow.question).toEqual({ lines: 2, clipped: true, overflows: true, toggle: 'expand' }) + expect(smallWindow.staged).toEqual({ lines: 1, clipped: true, overflows: true, toggle: 'expand' }) + await page.setViewportSize({ width: 1680, height: 1000 }) + expect(await settle()).toEqual(wide) + + await explicitToggle.click() + await expect.poll(() => descriptionOf('Explicit install').evaluate(readCopy)) + .toEqual({ lines: 1, clipped: true, overflows: true, toggle: 'expand' }) + await composer.getByRole('radio', { name: 'Stage the rollout', exact: true }).click() + await composer.getByRole('button', { name: 'Submit', exact: true }).click() + expect(await outcome).toEqual({ value: { answers: [{ id: 'rollout', selected: ['Stage the rollout'] }] } }) + await expect.poll(() => page.locator('[data-question-key]').count(), { timeout: 10_000 }).toBe(0) + bodyCompleted = true + } finally { + controller.abort() + // The dragged width is a persisted preference; dropping it and resizing republishes the default. + const cleanup = await Promise.allSettled([ + outcome, + page.evaluate(() => { localStorage.removeItem('dsh.conversation.contentWidth') }) + .then(() => page.setViewportSize(original)), + ]) + if (bodyCompleted) { + const failure = cleanup.find((result): result is PromiseRejectedResult => result.status === 'rejected') + if (failure !== undefined) throw failure.reason + } + } + }, 90_000) + }) describe.skipIf(MODE === 'record')('web e2e: cancelled question transcript', () => { diff --git a/apps/web/tests/sidebar-right.e2e.ts b/apps/web/tests/sidebar-right.e2e.ts index 989b773556..79fc706ec9 100644 --- a/apps/web/tests/sidebar-right.e2e.ts +++ b/apps/web/tests/sidebar-right.e2e.ts @@ -1103,6 +1103,58 @@ describe('web e2e: shipped right Sidebar', () => { expect(tripwire.warnings).toEqual([]) }, 90_000) + it('keeps a dragged tab when a middle click is chorded with the primary button', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-sidebar-right-middle-drag')) + const column = await resetSidebar(page) + await proseChip(page).click() + await expect.poll(() => tabTitles(column)).toEqual(['Files', SAMPLE_NAME]) + const sample = column.getByRole('tab').filter({ hasText: SAMPLE_NAME }) + const from = await pointIn(sample, 0.25, 0.5) + const to = await pointIn(column.getByRole('tab').filter({ hasText: 'Files' }), 0.25, 0.5) + await page.mouse.move(from.x, from.y) + await page.mouse.down() + try { + await page.mouse.move(to.x, to.y, { steps: 8 }) + await column.locator('[data-dockkit-caret="0"]').waitFor({ state: 'visible' }) + await page.mouse.down({ button: 'middle' }) + await page.mouse.up({ button: 'middle' }) + } finally { + await page.mouse.up() + } + await expect.poll(() => tabTitles(column)).toEqual([SAMPLE_NAME, 'Files']) + expect(tripwire.pageErrors).toEqual([]) + expect(tripwire.warnings).toEqual([]) + }) + + it('closes background and active tabs with the middle button and keeps the last guide', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-sidebar-right-middle-close')) + const column = await resetSidebar(page) + await proseChip(page).click() + await expect.poll(() => tabTitles(column)).toEqual(['Files', SAMPLE_NAME]) + const sample = column.getByRole('tab').filter({ hasText: SAMPLE_NAME }) + const files = column.getByRole('tab').filter({ hasText: 'Files' }) + const selected = () => column.locator('[role="tab"][aria-selected="true"] [data-dockkit-tab-title]').allTextContents() + await expect.poll(selected).toEqual([SAMPLE_NAME]) + + await files.click({ button: 'middle' }) + await expect.poll(() => tabTitles(column)).toEqual([SAMPLE_NAME]) + expect(await selected()).toEqual([SAMPLE_NAME]) + await column.locator('[data-dockkit-add-tab]').click() + await expect.poll(() => tabTitles(column)).toEqual([SAMPLE_NAME, 'Start']) + await sample.click() + await expect.poll(selected).toEqual([SAMPLE_NAME]) + await sample.click({ button: 'middle' }) + await expect.poll(() => tabTitles(column)).toEqual(['Start']) + expect(await selected()).toEqual(['Start']) + + await column.getByRole('tab').click({ button: 'middle' }) + expect(await tabTitles(column)).toEqual(['Start']) + expect(await column.locator('[data-sidebar-right-open]').count()).toBe(1) + expect(await column.locator('[data-dockkit-tab-quiet]').count()).toBe(1) + expect(tripwire.pageErrors).toEqual([]) + expect(tripwire.warnings).toEqual([]) + }) + it('preserves the open surface after a reload', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-sidebar-right-reload')) const frame = page.locator('[class*="frame"]').first() diff --git a/apps/web/tests/steering.e2e.ts b/apps/web/tests/steering.e2e.ts index 2d457fcb98..3b47c0a4b5 100644 --- a/apps/web/tests/steering.e2e.ts +++ b/apps/web/tests/steering.e2e.ts @@ -224,6 +224,8 @@ describe.skipIf(MODE === 'record')('web e2e: retained steering and a post-stop m let hostClock: MockInstance | undefined const sessionEvents: SessionEvent[] = [] const requestInputs: string[][] = [] + let firstRequestSignal: AbortSignal | undefined + const questionStarted = Promise.withResolvers() const releaseQuestion = Promise.withResolvers() const FOLLOWUP = 'Also acknowledge this second message.' const RESPONSE = "Great, let's move forward. BANANA!" @@ -239,9 +241,13 @@ describe.skipIf(MODE === 'record')('web e2e: retained steering and a post-stop m message.role === 'user' ? message.content.flatMap(block => block.type === 'text' ? [block.text] : []) : [])) + if (requestInputs.length === 1) firstRequestSignal = options.signal for await (const chunk of next()) { yield chunk - if (requestInputs.length === 1 && chunk.type === 'block-start') await releaseQuestion.promise + if (requestInputs.length === 1 && chunk.type === 'block-start') { + questionStarted.resolve(undefined) + await releaseQuestion.promise + } } }, { prepend: true }) browser = await chromium.launch() @@ -271,11 +277,13 @@ describe.skipIf(MODE === 'record')('web e2e: retained steering and a post-stop m const input = page.locator('[data-composer-input][contenteditable="true"]').first() await input.fill(PROMPT) await input.press('Enter') + await questionStarted.promise await expect.poll(() => requestInputs.length, { timeout: 10_000 }).toBe(1) await input.fill(STEER) await input.press('Meta+Enter') await page.locator('[data-pending-steering]').filter({ hasText: STEER }).waitFor({ timeout: 10_000 }) await page.getByRole('button', { name: 'Stop generating', exact: true }).click() + await expect.poll(() => firstRequestSignal?.aborted).toBe(true) releaseQuestion.resolve(undefined) await expect.poll(() => sessionEvents.filter(event => event.type === 'turn/end').length, { timeout: 10_000 }).toBe(1) expect(claimedMessages(sessionEvents, STEER)).toHaveLength(0) diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 5fe7af967b..a0dc5e3054 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -1708,7 +1708,7 @@ export interface Config { ## `@deepseek-ai/dsh-host-webserver` -- `source`: [`packages/host/webserver/src/index.ts:76`](../packages/host/webserver/src/index.ts) +- `source`: [`packages/host/webserver/src/index.ts:79`](../packages/host/webserver/src/index.ts) ```ts config-catalog /** Web server listen, TLS, and response-compression config. */ @@ -2892,7 +2892,7 @@ export interface Config { ## `@deepseek-ai/dsh-session-log-export` - `inject`: `commands` · `connection` -- `source`: [`packages/session-query/session-log-export/src/index.ts:46`](../packages/session-query/session-log-export/src/index.ts) +- `source`: [`packages/session-query/session-log-export/src/index.ts:48`](../packages/session-query/session-log-export/src/index.ts) ```ts config-catalog /** Session-log archive policy. */ diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index 28f1deca1e..29ceb92e3b 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -1710,7 +1710,7 @@ export interface Config { ## `@deepseek-ai/dsh-host-webserver` -- `source`: [`packages/host/webserver/src/index.ts:76`](../packages/host/webserver/src/index.ts) +- `source`: [`packages/host/webserver/src/index.ts:79`](../packages/host/webserver/src/index.ts) ```ts config-catalog /** Web server listen, TLS, and response-compression config. */ @@ -2894,7 +2894,7 @@ export interface Config { ## `@deepseek-ai/dsh-session-log-export` - `inject`: `commands` · `connection` -- `source`: [`packages/session-query/session-log-export/src/index.ts:46`](../packages/session-query/session-log-export/src/index.ts) +- `source`: [`packages/session-query/session-log-export/src/index.ts:48`](../packages/session-query/session-log-export/src/index.ts) ```ts config-catalog /** Session-log archive policy. */ diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index 24831b91db..f9546b5cc3 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -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`) | - | diff --git a/docs/event-producer-consumer.zh.md b/docs/event-producer-consumer.zh.md index e2bcf34a84..843b22525b 100644 --- a/docs/event-producer-consumer.zh.md +++ b/docs/event-producer-consumer.zh.md @@ -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`) | - | diff --git a/docs/subsystems/persistence.i18n.yaml b/docs/subsystems/persistence.i18n.yaml index aaf59a0f16..054615f66d 100644 --- a/docs/subsystems/persistence.i18n.yaml +++ b/docs/subsystems/persistence.i18n.yaml @@ -14,9 +14,9 @@ /session-persistence/crash-recovery-preserves-an-interrupted-turn: en: 534044a0ce482d52 zh: d2e8a7dfeb066946 -/session-persistence/sessionlocation-refusal-diagnostics-artifact-target: - en: 886589f052a916d7 - zh: 2ca126aa700c933c +/session-persistence/sessionlocation-per-session-artifact-metadata: + en: e310ff426d848262 + zh: 8a7667349b0549a7 /session-persistence/sessionheader-metadata-beside-the-log: en: 5795ae03d2d163cd zh: a46ceeb22cfbb6a1 diff --git a/docs/subsystems/persistence.md b/docs/subsystems/persistence.md index 3c0c2586a7..8f35e17f05 100644 --- a/docs/subsystems/persistence.md +++ b/docs/subsystems/persistence.md @@ -113,16 +113,16 @@ Repair therefore writes only under write ownership: a live session's write handl Read-only observation is `open(id, 'read')`: the handle serves validated contiguous prefix slices, never a torn tail, and repeated reads on one handle never observe an older state than a prior read. There is no persistence-side prepared-Session cache: session-query owns its cold-read cache, keying one balanced cold Session per id on the `stat().revision` change token and re-reading only when the token changes. The [handle-based persistence Agent Note](../../.agents/notes/implemented/architecture/2026-08-27-handle-based-session-persistence.md) owns this lifecycle; the archived [Session preparation record](../../.agents/notes/archived/architecture/2026-08-05-session-preparation.md) documents the original publication-boundary `SessionPreparation` decision. -## `SessionLocation` — refusal-diagnostics artifact target +## `SessionLocation` — per-Session artifact metadata -`SessionLocation` is not a consumer-facing query: log access goes through a session handle's `read`. It survives only as refusal diagnostics, letting a `SessionFormatUnsupportedError` name the raw log a build refused to interpret. JSONL supplies the absolute transcript path inside its project/session directory; a backend without one artifact per session supplies nothing. +`SessionLocation` is observational metadata for diagnostics and export; log access still goes through a session handle's `read`. Materialized persistence snapshots carry the selected artifact path, and a `SessionFormatUnsupportedError` can name the raw log a build refused to interpret. JSONL supplies the absolute transcript path inside its project/session directory; a backend without one artifact per session supplies nothing. ```ts type-equiv /** - * A backend-resolved, per-session local artifact location. Carried only by - * refusal diagnostics ({@link SessionFormatUnsupportedError}) so a user can - * find the raw log a build refused to interpret; it is not a consumer-facing - * query — log access goes through a session handle's `read`. + * A backend-resolved, per-Session local artifact path used by observational + * snapshots and refusal diagnostics. It names a backend target for the Session + * and does not authorize reads; log access still goes through a session + * handle's `read`. It is not persisted Session content. */ interface SessionLocation { /** Backend-specific artifact kind, for example `jsonl`. */ @@ -319,6 +319,12 @@ interface SessionPersistenceSnapshot { readonly eventCount?: number /** Physical artifact byte size, when the backend can provide it cheaply (JSONL); otherwise absent. */ readonly sizeBytes?: number + /** + * Backend-resolved location of the selected materialized per-Session + * artifact, for diagnostics and export. Absent when the backend has no + * materialized per-Session file, including an unmaterialized pending Session. + */ + readonly location?: SessionLocation } ``` diff --git a/docs/subsystems/persistence.zh.md b/docs/subsystems/persistence.zh.md index dba0685409..c88b884fdb 100644 --- a/docs/subsystems/persistence.zh.md +++ b/docs/subsystems/persistence.zh.md @@ -113,16 +113,16 @@ interface SessionHandle extends AsyncDisposable { 只读观察即 `open(id, 'read')`:句柄提供经过验证的连续前缀切片,绝不返回撕裂尾部,且同一句柄上的重复读取绝不会观察到比先前读取更旧的状态。持久化侧不存在已准备 Session 缓存:session-query 拥有自己的冷读缓存,按 `stat().revision` 变更令牌为每个 id 缓存一个已配平的冷 Session,仅在令牌变化时重新读取。该生命周期由[基于句柄的持久化 Agent Note](../../.agents/notes/implemented/architecture/2026-08-27-handle-based-session-persistence.zh.md)定义;已归档的 [Session 准备阶段记录](../../.agents/notes/archived/architecture/2026-08-05-session-preparation.md)记载了发布边界 `SessionPreparation` 最初的决策。 -## `SessionLocation`——拒绝诊断的产物目标 +## `SessionLocation`——每一 Session 的产物元数据 -`SessionLocation` 不是面向消费者的查询:日志访问走会话句柄的 `read`。它仅作为拒绝诊断存在,使 `SessionFormatUnsupportedError` 能指出本构建拒绝解读的原始日志。JSONL 提供其项目/会话目录内 transcript(文本记录)的绝对路径;没有逐会话工件的后端则不提供。 +`SessionLocation` 用于诊断与导出的观察元数据;日志访问仍走会话句柄的 `read`。已物化的持久化快照携带选中产物路径,`SessionFormatUnsupportedError` 也能指出本构建拒绝解读的原始日志。JSONL 提供其项目/会话目录内 transcript(文本记录)的绝对路径;没有逐会话工件的后端则不提供。 ```ts type-equiv /** - * A backend-resolved, per-session local artifact location. Carried only by - * refusal diagnostics ({@link SessionFormatUnsupportedError}) so a user can - * find the raw log a build refused to interpret; it is not a consumer-facing - * query — log access goes through a session handle's `read`. + * A backend-resolved, per-Session local artifact path used by observational + * snapshots and refusal diagnostics. It names a backend target for the Session + * and does not authorize reads; log access still goes through a session + * handle's `read`. It is not persisted Session content. */ interface SessionLocation { /** Backend-specific artifact kind, for example `jsonl`. */ @@ -319,6 +319,12 @@ interface SessionPersistenceSnapshot { readonly eventCount?: number /** Physical artifact byte size, when the backend can provide it cheaply (JSONL); otherwise absent. */ readonly sizeBytes?: number + /** + * Backend-resolved location of the selected materialized per-Session + * artifact, for diagnostics and export. Absent when the backend has no + * materialized per-Session file, including an unmaterialized pending Session. + */ + readonly location?: SessionLocation } ``` diff --git a/docs/subsystems/typert.md b/docs/subsystems/typert.md index 29b5d4e3b7..d68d4a1a36 100644 --- a/docs/subsystems/typert.md +++ b/docs/subsystems/typert.md @@ -561,10 +561,9 @@ decide(request: RemoteAccessRequest): 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. */ diff --git a/docs/subsystems/typert.zh.md b/docs/subsystems/typert.zh.md index ce33a70436..270e5509bd 100644 --- a/docs/subsystems/typert.zh.md +++ b/docs/subsystems/typert.zh.md @@ -561,10 +561,9 @@ decide(request: RemoteAccessRequest): 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. */ diff --git a/docs/subsystems/web-server.i18n.yaml b/docs/subsystems/web-server.i18n.yaml index 9a9d78794a..bd4c01c442 100644 --- a/docs/subsystems/web-server.i18n.yaml +++ b/docs/subsystems/web-server.i18n.yaml @@ -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 diff --git a/docs/subsystems/web-server.md b/docs/subsystems/web-server.md index c85f7a9b08..c6c731343a 100644 --- a/docs/subsystems/web-server.md +++ b/docs/subsystems/web-server.md @@ -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) diff --git a/docs/subsystems/web-server.zh.md b/docs/subsystems/web-server.zh.md index d108ad5f67..21a97be800 100644 --- a/docs/subsystems/web-server.zh.md +++ b/docs/subsystems/web-server.zh.md @@ -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) diff --git a/docs/upgrade-guide/v0.2.1-rc.2/webserver-index-peer/guide.i18n.yaml b/docs/upgrade-guide/v0.2.1-rc.2/webserver-index-peer/guide.i18n.yaml new file mode 100644 index 0000000000..1a41ab4629 --- /dev/null +++ b/docs/upgrade-guide/v0.2.1-rc.2/webserver-index-peer/guide.i18n.yaml @@ -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 diff --git a/docs/upgrade-guide/v0.2.1-rc.2/webserver-index-peer/guide.md b/docs/upgrade-guide/v0.2.1-rc.2/webserver-index-peer/guide.md new file mode 100644 index 0000000000..42f8bdc773 --- /dev/null +++ b/docs/upgrade-guide/v0.2.1-rc.2/webserver-index-peer/guide.md @@ -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. diff --git a/docs/upgrade-guide/v0.2.1-rc.2/webserver-index-peer/guide.zh.md b/docs/upgrade-guide/v0.2.1-rc.2/webserver-index-peer/guide.zh.md new file mode 100644 index 0000000000..de6d1c58da --- /dev/null +++ b/docs/upgrade-guide/v0.2.1-rc.2/webserver-index-peer/guide.zh.md @@ -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 构建,确认这两个方法的调用都不再报缺少参数。 diff --git a/packages/api/remote-access/README.i18n.yaml b/packages/api/remote-access/README.i18n.yaml index df8ad59235..461af0d1f9 100644 --- a/packages/api/remote-access/README.i18n.yaml +++ b/packages/api/remote-access/README.i18n.yaml @@ -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 diff --git a/packages/api/remote-access/README.md b/packages/api/remote-access/README.md index 8d9df9e542..ab35a6b598 100644 --- a/packages/api/remote-access/README.md +++ b/packages/api/remote-access/README.md @@ -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:` 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. ## Model Experience diff --git a/packages/api/remote-access/README.zh.md b/packages/api/remote-access/README.zh.md index 8d593f7132..514f885b33 100644 --- a/packages/api/remote-access/README.zh.md +++ b/packages/api/remote-access/README.zh.md @@ -43,7 +43,7 @@ Gateway 依次从 `@Remote` 与 `@RemoteScope` 的 `resources` 读取函数、lo 转发事件按其转发条目的读取函数指明的资源(条目未声明时为 `global`)判定 action `event:`,对非 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 中以固定答案(默认全允许)的访问替换本行。 ## 模型体验 diff --git a/packages/api/remote-access/src/client/index.ts b/packages/api/remote-access/src/client/index.ts index 396f56a16a..f3e5123b5b 100644 --- a/packages/api/remote-access/src/client/index.ts +++ b/packages/api/remote-access/src/client/index.ts @@ -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'] diff --git a/packages/api/remote-access/src/client/mount.ts b/packages/api/remote-access/src/client/mount.ts index 257aa396c2..68b868a8a7 100644 --- a/packages/api/remote-access/src/client/mount.ts +++ b/packages/api/remote-access/src/client/mount.ts @@ -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 @@ -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 + /** + * 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 { 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)})`) + } } /** diff --git a/packages/api/remote-access/src/client/when-allowed.ts b/packages/api/remote-access/src/client/when-allowed.ts new file mode 100644 index 0000000000..e1c2ba890c --- /dev/null +++ b/packages/api/remote-access/src/client/when-allowed.ts @@ -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, + 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() } +} diff --git a/packages/api/remote-access/src/index.ts b/packages/api/remote-access/src/index.ts index 5b3cf41e0f..98ee668ca4 100644 --- a/packages/api/remote-access/src/index.ts +++ b/packages/api/remote-access/src/index.ts @@ -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. */ diff --git a/packages/api/remote-access/tests/client-access.client.spec.ts b/packages/api/remote-access/tests/client-access.client.spec.ts index b73944ec58..5a70579b01 100644 --- a/packages/api/remote-access/tests/client-access.client.spec.ts +++ b/packages/api/remote-access/tests/client-access.client.spec.ts @@ -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 'test-desk': TypertResource } + interface TypertRemoteMap { + /** Test-only endpoint the `whenAccess` specs guard effects on. */ + 'test/guarded': () => Promise + } } const GRANTED: Extract = { @@ -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 { + 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((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; 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() + }) +}) diff --git a/packages/api/remote-access/tests/remote-access.host.spec.ts b/packages/api/remote-access/tests/remote-access.host.spec.ts index f29d4d1744..101991e0c4 100644 --- a/packages/api/remote-access/tests/remote-access.host.spec.ts +++ b/packages/api/remote-access/tests/remote-access.host.spec.ts @@ -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 () => { diff --git a/packages/api/remote-access/tsconfig.client.json b/packages/api/remote-access/tsconfig.client.json index 0ddeaeac30..c365c4f780 100644 --- a/packages/api/remote-access/tsconfig.client.json +++ b/packages/api/remote-access/tsconfig.client.json @@ -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" ], diff --git a/packages/bundle/web-app/tests/document-preview.spec.ts b/packages/bundle/web-app/tests/document-preview.spec.ts index dcf2bcff18..9af5ca6a4f 100644 --- a/packages/bundle/web-app/tests/document-preview.spec.ts +++ b/packages/bundle/web-app/tests/document-preview.spec.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[1] + const kit = vi.hoisted(() => ({ create: vi.fn<(options?: ConverterOptions) => Promise>() })) 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 }, ]) diff --git a/packages/client/connection/tests/node-half.host.spec.ts b/packages/client/connection/tests/node-half.host.spec.ts index b3793c9cfd..c3497a95c2 100644 --- a/packages/client/connection/tests/node-half.host.spec.ts +++ b/packages/client/connection/tests/node-half.host.spec.ts @@ -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[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() diff --git a/packages/client/shortcuts/tests/config.host.spec.ts b/packages/client/shortcuts/tests/config.host.spec.ts index 13ad7bc00a..ec507217c5 100644 --- a/packages/client/shortcuts/tests/config.host.spec.ts +++ b/packages/client/shortcuts/tests/config.host.spec.ts @@ -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[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() } }) diff --git a/packages/client/ui-directory-picker-native/README.i18n.yaml b/packages/client/ui-directory-picker-native/README.i18n.yaml index 80a96f4d74..d7fe01a1f6 100644 --- a/packages/client/ui-directory-picker-native/README.i18n.yaml +++ b/packages/client/ui-directory-picker-native/README.i18n.yaml @@ -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 diff --git a/packages/client/ui-directory-picker-native/README.md b/packages/client/ui-directory-picker-native/README.md index b23d48bbb5..7a77767050 100644 --- a/packages/client/ui-directory-picker-native/README.md +++ b/packages/client/ui-directory-picker-native/README.md @@ -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. + ----- diff --git a/packages/client/ui-directory-picker-native/README.zh.md b/packages/client/ui-directory-picker-native/README.zh.md index 57cbad2d5d..0bb78f28d1 100644 --- a/packages/client/ui-directory-picker-native/README.zh.md +++ b/packages/client/ui-directory-picker-native/README.zh.md @@ -33,6 +33,8 @@ kind: "package-reference" 当浏览器与 Host 运行在同一台机器上、操作系统对话框可以在那里打开时,选择此表面。当浏览器为远程或进程内、没有本地选择器时,选择 [`-browse`](../ui-directory-picker-browse/README.zh.md) 表面。两个表面填充相同的 slot,因此切换只是组合改动,而非代码改动。 +访问控制:两个目录选择流程只在当前 Peer 可以调用 `directoryPicker/pick` 时注册。owner 始终有权限。 + ----- diff --git a/packages/client/ui-directory-picker-native/package.json b/packages/client/ui-directory-picker-native/package.json index 6f54248246..ff25c9ae17 100644 --- a/packages/client/ui-directory-picker-native/package.json +++ b/packages/client/ui-directory-picker-native/package.json @@ -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:*", diff --git a/packages/client/ui-directory-picker-native/src/client/index.ts b/packages/client/ui-directory-picker-native/src/client/index.ts index efec56fc49..1bdcafecdd 100644 --- a/packages/client/ui-directory-picker-native/src/client/index.ts +++ b/packages/client/ui-directory-picker-native/src/client/index.ts @@ -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) + }))) } diff --git a/packages/client/ui-directory-picker-native/tests/client-flow.client.spec.tsx b/packages/client/ui-directory-picker-native/tests/client-flow.client.spec.tsx index ed033999aa..fd3b22d315 100644 --- a/packages/client/ui-directory-picker-native/tests/client-flow.client.spec.tsx +++ b/packages/client/ui-directory-picker-native/tests/client-flow.client.spec.tsx @@ -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 => '/tmp/picked') ctx.provide('uiWorkspace', { pickDirectory } as never) const slots = ctx.get('slots') as SlotRegistry @@ -52,7 +54,19 @@ function owner(overrides: Partial = {}): 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 () => { diff --git a/packages/client/ui-dockkit/README.i18n.yaml b/packages/client/ui-dockkit/README.i18n.yaml index 9adce0d3b1..2f06976029 100644 --- a/packages/client/ui-dockkit/README.i18n.yaml +++ b/packages/client/ui-dockkit/README.i18n.yaml @@ -15,11 +15,11 @@ en: 42038fdc402b9c2a zh: 99890f8f908624ce /deepseek-ai-dsh-client-ui-dockkit/the-two-layers: - en: ea872ff2c2089037 - zh: b3ec3a218eacd5c8 + en: 99356ea8e3d1e7aa + zh: 870f746730d0e6cf /deepseek-ai-dsh-client-ui-dockkit/embedding-it: - en: bc9ecb9633fb3208 - zh: fa141e77c2bf6eae + en: c4ba08038bfa6f30 + zh: 95db30618b52babe /deepseek-ai-dsh-client-ui-dockkit/interaction-rules-worth-keeping: en: 7e5c1e9a9f9f244f zh: 7e3a8fcef86bc7dd diff --git a/packages/client/ui-dockkit/README.md b/packages/client/ui-dockkit/README.md index ff8e81e5eb..0ba54c2c8e 100644 --- a/packages/client/ui-dockkit/README.md +++ b/packages/client/ui-dockkit/README.md @@ -39,6 +39,8 @@ A docking layout kit: a split tree of tabbed panes with invertible operations, a **The components** render a layout snapshot and report settled intents — one per gesture, never a drag frame. A drag previews in local state while the gesture's own facts stay in its closure; on release the net result leaves through one `DockIntents` call — a strip release reports the caret slot as drawn, the dragged chip counted, and `planPlaceTab` turns that into the reorder or the move. That is what lets an embedder record exactly one history entry per gesture. The strip follows the WAI-ARIA tabs pattern with manual activation: the selected chip is in the tab order; Left and Right (wrapping), Home, and End move focus between chips without selecting; Enter or Space selects the focused chip through the same intent as a click. A chip is a capsule carrying one control, its close; the context menu (a secondary press on the chip) carries the same close plus the embedder's items — a menu that would hold no item at all never shows — and renders in a portal positioned against the chip because the chip box clips its overflow on purpose (see below). After the chips sits the add control, which asks the embedder (`DockIntents.addTab`) to seat its seeded tab; the embedder's `canAddTab(paneId)` decides per pane whether the control is drawn at all. Copying a tab has no kit control — it is the embedder's API — and floating is the drag released clear of the surface. +Middle-clicking anywhere on a closable tab chip, including its close control, closes it through the same intent as its close button, without selecting it first. The chip owns the middle click and suppresses nested title content’s default action. Closing a background tab preserves the selected tab. Only the primary button starts tab dragging; a middle click while another mouse button is held does not close the tab. + Tab context menus show the close action name without a shortcut hint: they act on the clicked tab, while shortcuts can target another focused pane. An unmodified Escape dismisses the foreground tab menu without closing the tab and returns focus from a menu item to its tab without drawing a focus outline. Tab and arrow navigation retain the tab’s visible focus indicator. Composition and held-key repeats do not dismiss it. @@ -52,7 +54,7 @@ Everything host-specific arrives through props: | `TabRenderer` | one tab's body (`renderTab`), drawn flush to the pane's edges and the unbordered strip's bottom edge with the insets it chooses, and optionally what its chip or panel header shows as a title (`renderTabTitle`, falling back to the record's `title`); the embedder dispatches on `tab.kind` | | `DockIntents` | the settled results of every gesture | -`DockController` satisfies `DockIntents` as written, so an embedding can pass it to `DockLayout` for retained Sidebar layouts or `DockSurface` for recursive split trees. An embedder that routes through its own store implements the same method names instead. Three props carry control policy rather than gestures: `canSplit` (surface-wide, the pane budget; disables the split control with `splitPaneDisabled`), `canAddTab(paneId)` (per pane, omits the add control; leave it out to draw one in every pane), and `canCloseTab(tabId)` (per tab, withholds the chip's close control and the menu's close item together; leave it out to keep every tab closable). Hiding the add control moves nothing else in the strip, and a withheld close moves nothing in the chip — the close control paints over the title's end rather than beside it. A pane's lone chip whose close is withheld draws quiet — no capsule, no hover fill — since there is nothing to select against and nothing to do to it. The kit adds one policy of its own, the room rule below, which disables a pane's split control with `splitPaneNarrow`; `onRoom(fits)` reports its readings so an embedder splitting programmatically can honour the same rule. A visible blocked split control supports click or tap to read its explanation without splitting or focusing the pane. +`DockController` satisfies `DockIntents` as written, so an embedding can pass it to `DockLayout` for retained Sidebar layouts or `DockSurface` for recursive split trees. An embedder that routes through its own store implements the same method names instead. Three props carry control policy rather than gestures: `canSplit` (surface-wide, the pane budget; disables the split control with `splitPaneDisabled`), `canAddTab(paneId)` (per pane, omits the add control; leave it out to draw one in every pane), and `canCloseTab(tabId)` (per tab, withholds the chip's close control and the menu's close item and prevents middle-click closing; leave it out to keep every tab closable). Hiding the add control moves nothing else in the strip, and a withheld close moves nothing in the chip — the close control paints over the title's end rather than beside it. A pane's lone chip whose close is withheld draws quiet — no capsule, no hover fill — since there is nothing to select against and nothing to do to it. The kit adds one policy of its own, the room rule below, which disables a pane's split control with `splitPaneNarrow`; `onRoom(fits)` reports its readings so an embedder splitting programmatically can honour the same rule. A visible blocked split control supports click or tap to read its explanation without splitting or focusing the pane. `dropZones="horizontal"` offers two half-pane hints; once budget or width forbids another split, the whole body accepts a move. A hint is a dashed card inset 8px inside its region, showing the zone's glyph and `labels.dropZone[zone]`; the preview layer covers all tab-body content, while the card under the pointer takes the accent and its neighbour stays a quiet outline. `minPaneFraction` sets the preview minimum, and `planResizeSplit` accepts the same minimum for the committed operation. The Sidebar uses 0.2 and enforces two panes in its own store. The generic engine retains its tree and other split directions. `hideSplitWhenBlocked` hides a blocked split control — pane budget spent or pane too narrow — instead of rendering it disabled; its default is false. diff --git a/packages/client/ui-dockkit/README.zh.md b/packages/client/ui-dockkit/README.zh.md index 7eddb644ce..39acc02792 100644 --- a/packages/client/ui-dockkit/README.zh.md +++ b/packages/client/ui-dockkit/README.zh.md @@ -39,6 +39,8 @@ kind: "package-reference" **组件**渲染布局快照并上报已落定的意图——每次手势一条,绝不上报拖动帧。拖动过程中在本地状态里预览,手势自身的事实留在它的闭包里;松手时净结果通过一次 `DockIntents` 调用离开——在标签条上松手上报的是按绘制顺序数出的插入槽位(被拖的 chip 也计入),由 `planPlaceTab` 换算成重排或移动。正是这一点让嵌入方能为每次手势记录恰好一条历史。标签条遵循 WAI-ARIA tabs 模式的手动激活:选中的 chip 在 Tab 键序里;左右方向键(循环)、Home、End 只在 chip 之间移动焦点而不选中;Enter 或空格选中当前聚焦的 chip,走与点击相同的意图。chip 是一个胶囊,携带唯一的控件——它的关闭按钮;上下文菜单(在 chip 上的次键按下)携带同样的关闭项加上嵌入方的条目——一个连一项都没有的菜单绝不展示——并渲染在按 chip 定位的 portal 里,因为 chip 盒会故意裁掉溢出(见下文)。chip 之后是添加控件,它请嵌入方(`DockIntents.addTab`)安放其种子 tab;嵌入方的 `canAddTab(paneId)` 按格决定是否绘制该控件。复制 tab 没有套件控件——那是嵌入方的 API——而浮出就是把拖动松手在停靠区之外。 +中键点击可关闭标签页 chip 的任意位置(包括关闭控件)时,通过与关闭按钮相同的意图关闭,无需先选中。chip 接管中键点击,并阻止嵌套标题内容的默认动作。关闭后台标签页会保留当前选中的标签页。只有主键会开始拖动标签页;其它鼠标按键仍按住时,中键点击不会关闭标签页。 + 标签上下文菜单只显示关闭操作名,不显示快捷键:菜单操作针对被点击的标签,快捷键则可能作用于其他焦点分栏。无修饰键的 Esc 关闭前台标签菜单而不关闭标签,并将菜单项的焦点交还标签,不绘制焦点外轮廓线。Tab 和方向键导航保留标签页的可见焦点提示。输入法组合输入和长按重复事件不关闭菜单。 @@ -52,7 +54,7 @@ kind: "package-reference" | `TabRenderer` | 一个 tab 的正文(`renderTab`),贴着格的边缘和(不带边线的)tab 条底边绘制、自己决定留白,以及可选的 chip 或浮窗头部显示的标题(`renderTabTitle`,回退到记录的 `title`);嵌入方按 `tab.kind` 分发 | | `DockIntents` | 每次手势落定的结果 | -`DockController` 原样满足 `DockIntents`,因此嵌入方可将它交给 `DockLayout` 来保活 Sidebar 布局,或交给 `DockSurface` 来渲染递归分割树。经由自己 store 路由的嵌入方则实现同名方法。有三个 props 承载的是控制策略而非手势:`canSplit`(整面有效,即格预算;用 `splitPaneDisabled` 禁用分栏控件)、`canAddTab(paneId)`(按格,省略添加控件;不传则每格都画)与 `canCloseTab(tabId)`(按 tab,把 chip 的关闭控件和菜单的关闭项一并收起;不传则每个 tab 都可关闭)。隐藏添加控件不会移动 tab 条里的其它任何东西,收起关闭也不会移动 chip 里的任何东西——关闭控件压在标题末端之上而非并排。某格仅剩的一个 chip 在关闭被收起时画成安静样式——没有胶囊底色,没有悬停填充——因为既没有别的 tab 可供选择,也没有任何可对它做的事。套件自己再加一条策略,即下文的空间规则,它用 `splitPaneNarrow` 禁用某格的分栏控件;`onRoom(fits)` 上报其读数,让以编程方式分栏的嵌入方能遵守同一规则。可见的禁用分栏控件支持鼠标或触摸点击查看原因,不会分栏或聚焦窗格。 +`DockController` 原样满足 `DockIntents`,因此嵌入方可将它交给 `DockLayout` 来保活 Sidebar 布局,或交给 `DockSurface` 来渲染递归分割树。经由自己 store 路由的嵌入方则实现同名方法。有三个 props 承载的是控制策略而非手势:`canSplit`(整面有效,即格预算;用 `splitPaneDisabled` 禁用分栏控件)、`canAddTab(paneId)`(按格,省略添加控件;不传则每格都画)与 `canCloseTab(tabId)`(按 tab,把 chip 的关闭控件和菜单的关闭项一并收起,并禁止中键关闭;不传则每个 tab 都可关闭)。隐藏添加控件不会移动 tab 条里的其它任何东西,收起关闭也不会移动 chip 里的任何东西——关闭控件压在标题末端之上而非并排。某格仅剩的一个 chip 在关闭被收起时画成安静样式——没有胶囊底色,没有悬停填充——因为既没有别的 tab 可供选择,也没有任何可对它做的事。套件自己再加一条策略,即下文的空间规则,它用 `splitPaneNarrow` 禁用某格的分栏控件;`onRoom(fits)` 上报其读数,让以编程方式分栏的嵌入方能遵守同一规则。可见的禁用分栏控件支持鼠标或触摸点击查看原因,不会分栏或聚焦窗格。 `dropZones="horizontal"` 提供左右两个半区提示;预算或宽度不允许再拆时,正文整格接收移动。提示是一张内缩 8px 的虚线卡片,显示该落区的图形和 `labels.dropZone[zone]`;预览层覆盖全部 tab 正文,指针所在的卡片取强调色,另一张保持安静的轮廓。`minPaneFraction` 控制预览的最小比例,`planResizeSplit` 接受相同最小值以约束提交;Sidebar使用0.2并在自己的store限制两格。通用引擎仍保留原有树与其它分割方向。 `hideSplitWhenBlocked` 在分栏被阻止时(窗格预算已满或格太窄)直接隐藏分栏控件而不是渲染禁用态,默认值为 false。 diff --git a/packages/client/ui-dockkit/src/components/DockSurface.tsx b/packages/client/ui-dockkit/src/components/DockSurface.tsx index 4803955cb0..eea2ea1724 100644 --- a/packages/client/ui-dockkit/src/components/DockSurface.tsx +++ b/packages/client/ui-dockkit/src/components/DockSurface.tsx @@ -47,9 +47,9 @@ export interface DockSurfaceProps { */ readonly canAddTab?: (paneId: PaneId) => boolean /** - * Whether a tab draws its close control and its menu's close item. Called + * Whether a tab may close via its close control, menu item, or middle click. Called * per rendered chip on every render; omit to keep every tab closable. - * `false` removes both routes without moving the chip: the close control + * `false` blocks these routes without moving the chip: the close control * paints over the title's end rather than beside it, so the chip is the same * width either way. The menu still opens and carries the embedder's items. */ diff --git a/packages/client/ui-dockkit/src/components/TabMenu.tsx b/packages/client/ui-dockkit/src/components/TabMenu.tsx index 3effad9f79..beb7c926b6 100644 --- a/packages/client/ui-dockkit/src/components/TabMenu.tsx +++ b/packages/client/ui-dockkit/src/components/TabMenu.tsx @@ -105,13 +105,11 @@ export function TabMenu({ labels, anchor, onClose, onDismiss, extras }: TabMenuP data-dockkit-tab-menu // Unplaced for one layout pass: measured, then positioned. style={position ?? { visibility: 'hidden', top: 0, left: 0 }} - // React bubbles a portal's events to its tab chip, so both halves of a - // press must stop here: the press would otherwise start a tab drag and - // capture the pointer, which retargets the release and swallows this - // item's click; the click would otherwise also focus a tab the item may - // have removed. + // Portal events reach the owning chip: a press would start its drag, + // a click would focus it, and a middle click would close it. onPointerDown={(event) => { event.stopPropagation() }} onClick={(event) => { event.stopPropagation() }} + onAuxClick={(event) => { event.stopPropagation() }} > {onClose !== undefined && (
diff --git a/packages/client/ui-entry-customization/README.zh.md b/packages/client/ui-entry-customization/README.zh.md index 4b0169538c..daa91719b3 100644 --- a/packages/client/ui-entry-customization/README.zh.md +++ b/packages/client/ui-entry-customization/README.zh.md @@ -34,6 +34,8 @@ kind: "package-reference" 在桌面端,**浏览更多插件**与**创建你的插件**在执行前上报 `more_menu_item_click`,`action` 为 `browse_plugins` 或 `create_plugin`,`region` 为菜单所在区域:**更多**为 `sidebar`,**更多会话视图**为 `conversation`,**更多工具**为 `rightbar`。打开入口和更改 Pin 状态不上报,菜单也不上报曝光事件。没有埋点服务时,两项操作行为不变且不上报。 +「浏览插件」一行只在 `pluginNavigation` 服务存在时出现;没有该服务时插件照常启动。 + ----- @@ -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) 挂载浏览器插件。 diff --git a/packages/client/ui-entry-customization/package.json b/packages/client/ui-entry-customization/package.json index 51ef4894fb..f98c8ed620 100644 --- a/packages/client/ui-entry-customization/package.json +++ b/packages/client/ui-entry-customization/package.json @@ -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:*", diff --git a/packages/client/ui-entry-customization/src/client/EntryMenuContent.tsx b/packages/client/ui-entry-customization/src/client/EntryMenuContent.tsx index 0370d547ee..22a73babc1 100644 --- a/packages/client/ui-entry-customization/src/client/EntryMenuContent.tsx +++ b/packages/client/ui-entry-customization/src/client/EntryMenuContent.tsx @@ -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 + } /** 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) }} /> })} - } separatorBefore={entries.length > 0} + {browsable && } separatorBefore={entries.length > 0} onSelect={() => { onDismiss(); browsePlugins() }}> {t('install')} - - } onSelect={() => { onDismiss(); createPlugin() }}> + } + } separatorBefore={!browsable && entries.length > 0} + onSelect={() => { onDismiss(); createPlugin() }}> {t('create')} diff --git a/packages/client/ui-entry-customization/src/client/index.ts b/packages/client/ui-entry-customization/src/client/index.ts index d838a25749..ba41d131a9 100644 --- a/packages/client/ui-entry-customization/src/client/index.ts +++ b/packages/client/ui-entry-customization/src/client/index.ts @@ -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' }) diff --git a/packages/client/ui-entry-customization/tests/menu.client.spec.tsx b/packages/client/ui-entry-customization/tests/menu.client.spec.tsx index 25560a9f65..1a38e84430 100644 --- a/packages/client/ui-entry-customization/tests/menu.client.spec.tsx +++ b/packages/client/ui-entry-customization/tests/menu.client.spec.tsx @@ -34,6 +34,7 @@ function content(overrides: Partial = {}) { const props = { entries: [{ id: 'notes', label: 'Notes', pinned: true }, { id: 'tasks', label: 'Tasks', pinned: false }], region: 'sidebar', t: makeTranslate(en), renderFactorySlot: unused, + usePluginNavigation: (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) { } -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() @@ -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) { diff --git a/packages/client/ui-entry-customization/tests/reader-boot.client.spec.ts b/packages/client/ui-entry-customization/tests/reader-boot.client.spec.ts new file mode 100644 index 0000000000..539dd64964 --- /dev/null +++ b/packages/client/ui-entry-customization/tests/reader-boot.client.spec.ts @@ -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) diff --git a/packages/client/ui-entry-customization/tsconfig.json b/packages/client/ui-entry-customization/tsconfig.json index 4933d135c5..e65933f5ea 100644 --- a/packages/client/ui-entry-customization/tsconfig.json +++ b/packages/client/ui-entry-customization/tsconfig.json @@ -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" } ] } diff --git a/packages/client/ui-message-feedback/README.i18n.yaml b/packages/client/ui-message-feedback/README.i18n.yaml index e171909bad..91205d3552 100644 --- a/packages/client/ui-message-feedback/README.i18n.yaml +++ b/packages/client/ui-message-feedback/README.i18n.yaml @@ -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 diff --git a/packages/client/ui-message-feedback/README.md b/packages/client/ui-message-feedback/README.md index ef293870b6..feffb193d6 100644 --- a/packages/client/ui-message-feedback/README.md +++ b/packages/client/ui-message-feedback/README.md @@ -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. + ----- diff --git a/packages/client/ui-message-feedback/README.zh.md b/packages/client/ui-message-feedback/README.zh.md index fce7d27049..27a879c2c8 100644 --- a/packages/client/ui-message-feedback/README.zh.md +++ b/packages/client/ui-message-feedback/README.zh.md @@ -31,6 +31,8 @@ kind: "package-reference" 评分或列表加载失败在行内展示;提交失败通过警告 toast 展示,弹窗保持打开以便修正草稿。只有已定稿的消息能到达消息条目——被中断冻结的部分输出不带 `messageId`,因此没有反馈控件。 +访问控制:评分按钮只在当前 Peer 可以在该会话上调用 `messageFeedback/put` 时渲染;`/feedback` 装饰不做前端判断,没有权限时提交由 Host 拒绝。 + ----- diff --git a/packages/client/ui-message-feedback/src/client/MessageFeedbackActions.tsx b/packages/client/ui-message-feedback/src/client/MessageFeedbackActions.tsx index 6709f79984..c95a28d21b 100644 --- a/packages/client/ui-message-feedback/src/client/MessageFeedbackActions.tsx +++ b/packages/client/ui-message-feedback/src/client/MessageFeedbackActions.tsx @@ -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') ? : null +} + +function FeedbackRatingButtons({ messageId, ensure, current, retract, openDialog, useFeedback, t, }: MessageFeedbackActionProps) { const item = useFeedback(view => view.items.get(messageId)) diff --git a/packages/client/ui-message-feedback/tests/message-feedback-actions.client.spec.tsx b/packages/client/ui-message-feedback/tests/message-feedback-actions.client.spec.tsx index 4121676697..2e80707a58 100644 --- a/packages/client/ui-message-feedback/tests/message-feedback-actions.client.spec.tsx +++ b/packages/client/ui-message-feedback/tests/message-feedback-actions.client.spec.tsx @@ -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 = ((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[0] - return { ...render(), ensure, retract, openDialog } + return { ...render(), 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[0] - const ui = render() + const ui = render( 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[0] - const ui = render() + const ui = render( 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[0] - const ui = render() + const ui = render( true} />) const errors: unknown[] = [] const onError = (event: ErrorEvent): void => { errors.push(event.error) } window.addEventListener('error', onError) diff --git a/packages/client/ui-plugin-manager/README.i18n.yaml b/packages/client/ui-plugin-manager/README.i18n.yaml index bd46f67c62..16a9ce7a2c 100644 --- a/packages/client/ui-plugin-manager/README.i18n.yaml +++ b/packages/client/ui-plugin-manager/README.i18n.yaml @@ -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 diff --git a/packages/client/ui-plugin-manager/README.md b/packages/client/ui-plugin-manager/README.md index ac5b84e59f..5a659bc446 100644 --- a/packages/client/ui-plugin-manager/README.md +++ b/packages/client/ui-plugin-manager/README.md @@ -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. + ----- @@ -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. diff --git a/packages/client/ui-plugin-manager/README.zh.md b/packages/client/ui-plugin-manager/README.zh.md index aad0a52dcd..f58f0478c5 100644 --- a/packages/client/ui-plugin-manager/README.zh.md +++ b/packages/client/ui-plugin-manager/README.zh.md @@ -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 始终有权限。 + ----- @@ -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 不会触发引导。 diff --git a/packages/client/ui-plugin-manager/package.json b/packages/client/ui-plugin-manager/package.json index 1cf5abf9b8..a9eceb424d 100644 --- a/packages/client/ui-plugin-manager/package.json +++ b/packages/client/ui-plugin-manager/package.json @@ -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:*", diff --git a/packages/client/ui-plugin-manager/src/client/index.ts b/packages/client/ui-plugin-manager/src/client/index.ts index 6e80e3cb3c..54dce2da8b 100644 --- a/packages/client/ui-plugin-manager/src/client/index.ts +++ b/packages/client/ui-plugin-manager/src/client/index.ts @@ -16,6 +16,8 @@ import type { HostObservable } from '@deepseek-ai/dsh-client-ui-slots' import type { MainPanelId } from '@deepseek-ai/dsh-client-ui-layout/client' import type {} from '@deepseek-ai/dsh-client-ui-sidebar/client' import type {} from '@deepseek-ai/dsh-client-ui-renderer/client' +// Type-only: the ctx.remoteAccess Context merge. +import type {} from '@deepseek-ai/dsh-api-remote-access/client' // Type-only: the ctx.remote Context merge and the forwarded-event key face. import type {} from '@deepseek-ai/dsh-api-remotes/client' // Type-only: the forwarded events' own declaration (`$on`'s key face resolves @@ -80,12 +82,13 @@ export const NS = 'pluginManager' /** The id shared by the sidebar entry and the main panel it opens. */ export const PANEL_ID = 'plugins' as MainPanelId -/** Services required by the sidebar registration and the Remote methods; the inventory says whether the Host manages a profile. */ -export const inject = ['slots', 'locale', 'remote', 'remote.pluginManager', 'remote.pluginInventory', 'remote.pluginRegistryProbe', 'configForms', 'layout'] +/** Services required by the sidebar registration, the Remote methods, and access; the inventory says whether the Host manages a profile. */ +export const inject = ['slots', 'locale', 'remote', 'remote.pluginManager', 'remote.pluginInventory', 'remote.pluginRegistryProbe', 'configForms', 'layout', 'remoteAccess'] /** * Contribute the Plugins entry to the sidebar with the management page it - * opens, and keep it current on the Host's change events. + * opens, and keep it current on the Host's change events, while the current + * Peer may list the installed bundles. * @param ctx - the browser plugin context. */ export function apply(ctx: ClientContext): void { @@ -94,82 +97,79 @@ export function apply(ctx: ClientContext): void { const controller = new PluginManagerController(ctx) ctx.effect(() => () => { controller.dispose() }, 'ui-plugin-manager: controller') ctx.provide('pluginChanges', { bundle: packageName => controller.observeChanges(packageName) }) - // The Host says when what is installed, enabled, or composed changed — from - // this page, the CLI, or another browser — and streams install output. - ctx.effect(() => { - // A page never rendered holds no snapshot to refresh. - const refresh = (): void => { - // A profile change can replace a bundle's files, and a restarted Host is read afresh. - controller.details.reset() - if (controller.getSnapshot().status !== 'idle') void controller.load() - } - const disposers = [ - ctx.remote.$on('plugin-manager/changed', refresh), - ctx.remote.$on('plugin-manager/install-log', (chunk) => { controller.appendLog(chunk) }), - ctx.remote.$on('plugin-manager/install-state', (progress) => { controller.installProgress(progress) }), - ctx.remote.$on('plugin-manager/update-state', (progress) => { controller.updateProgress(progress) }), - ctx.on('connection/reset', refresh), - ] - return () => { for (const dispose of disposers) dispose() } - }, 'ui-plugin-manager: host invalidations') - // The page is a global panel: it belongs to the profile, not to a Session, // and the sidebar's entry selects it. What is installed and switched on is // the page's own; a plugin's configuration arrives through the slots the // page declares here, so the page never names a configurable plugin. const configLedger = configLedgerSource(ctx) const face = controller.inject(configLedger, tabLedgerSource(ctx), text => ctx.locale.resolveText(text)) - ctx.slots.inject('shell.overlay', () => ctx.slots.register({ - name: 'shell.overlay', id: 'plugin-manager.refresh-toast', locale: NS, - inject: (): PluginRefreshToastFace => ({ - hooks: { pluginManager: face.hooks.pluginManager }, - dismissNotice: face.dismissNotice, - }), - }, PluginRefreshToast)) - ctx.slots.inject('main', function* () { - const handle = createNavigationStore(), instance = handle.create() - const store: typeof handle = { ...handle, create: () => instance } - yield ctx.slots.register({ - name: 'main', - key: PANEL_ID, + ctx.remoteAccess.whenAccess('pluginManager/listBundles', function* () { + // The Host says when what is installed, enabled, or composed changed — from + // this page, the CLI, or another browser — and streams install output. + // A page never rendered holds no snapshot to refresh. + const refresh = (): void => { + // A profile change can replace a bundle's files, and a restarted Host is read afresh. + controller.details.reset() + if (controller.getSnapshot().status !== 'idle') void controller.load() + } + // Host changes made while access was withdrawn reached no subscription below. + refresh() + yield ctx.remote.$on('plugin-manager/changed', refresh) + yield ctx.remote.$on('plugin-manager/install-log', (chunk) => { controller.appendLog(chunk) }) + yield ctx.remote.$on('plugin-manager/install-state', (progress) => { controller.installProgress(progress) }) + yield ctx.remote.$on('plugin-manager/update-state', (progress) => { controller.updateProgress(progress) }) + yield ctx.on('connection/reset', refresh) + yield ctx.slots.inject('shell.overlay', () => ctx.slots.register({ + name: 'shell.overlay', id: 'plugin-manager.refresh-toast', locale: NS, + inject: (): PluginRefreshToastFace => ({ + hooks: { pluginManager: face.hooks.pluginManager }, + dismissNotice: face.dismissNotice, + }), + }, PluginRefreshToast)) + yield ctx.slots.inject('main', function* () { + const handle = createNavigationStore(), instance = handle.create() + const store: typeof handle = { ...handle, create: () => instance } + yield ctx.slots.register({ + name: 'main', + key: PANEL_ID, + locale: NS, + store, + inject: () => ({ ...face, createHeaderRef: createPluginHeaderRef }), + children: { + 'plugins.add.actions': { kind: 'list', scope: 'root' }, + 'plugins.tab': { kind: 'list', scope: 'root' }, + 'plugins.item': { kind: 'list', scope: 'root' }, + 'plugins.bundle.activation': { kind: 'keyed', scope: 'root' }, + 'plugins.bundle.config': { kind: 'keyed', scope: 'root' }, + 'plugins.row.config': { kind: 'keyed', scope: 'root' }, + 'plugins.detail.actions': { kind: 'list', scope: 'root' }, + 'plugins.detail.badge': { kind: 'list', scope: 'root' }, + 'plugins.detail.section': { kind: 'list', scope: 'root' }, + }, + }, PluginManagerPage) + yield ctx.layout.panelInfo.subscribe(() => { + if (ctx.layout.panelInfo.getSnapshot().activePanelId !== PANEL_ID) instance.actions.setView({ kind: 'list' }) + }) + const disposeNavigation = ctx.reflect.provide('pluginNavigation', { + openList: () => { + ctx.layout.selectPanel(PANEL_ID) + instance.actions.setTab('installed') + instance.actions.setView({ kind: 'list' }) + }, + openBundle: (packageName: string) => { + ctx.layout.selectPanel(PANEL_ID) + instance.actions.setTab('installed') + instance.actions.setView({ kind: 'package', name: packageName }) + }, + }) + yield () => { void disposeNavigation() } + }) + yield ctx.slots.inject('sidebar.panellist', () => ctx.slots.register({ + name: 'sidebar.panellist', + id: PANEL_ID, + order: 0, + label: () => t('panel'), locale: NS, - store, - inject: () => ({ ...face, createHeaderRef: createPluginHeaderRef }), - children: { - 'plugins.add.actions': { kind: 'list', scope: 'root' }, - 'plugins.tab': { kind: 'list', scope: 'root' }, - 'plugins.item': { kind: 'list', scope: 'root' }, - 'plugins.bundle.activation': { kind: 'keyed', scope: 'root' }, - 'plugins.bundle.config': { kind: 'keyed', scope: 'root' }, - 'plugins.row.config': { kind: 'keyed', scope: 'root' }, - 'plugins.detail.actions': { kind: 'list', scope: 'root' }, - 'plugins.detail.badge': { kind: 'list', scope: 'root' }, - 'plugins.detail.section': { kind: 'list', scope: 'root' }, - }, - }, PluginManagerPage) - yield ctx.layout.panelInfo.subscribe(() => { - if (ctx.layout.panelInfo.getSnapshot().activePanelId !== PANEL_ID) instance.actions.setView({ kind: 'list' }) - }) - const disposeNavigation = ctx.reflect.provide('pluginNavigation', { - openList: () => { - ctx.layout.selectPanel(PANEL_ID) - instance.actions.setTab('installed') - instance.actions.setView({ kind: 'list' }) - }, - openBundle: (packageName: string) => { - ctx.layout.selectPanel(PANEL_ID) - instance.actions.setTab('installed') - instance.actions.setView({ kind: 'package', name: packageName }) - }, - }) - yield () => { void disposeNavigation() } + }, PluginsPanelIcon)) }) - ctx.slots.inject('sidebar.panellist', () => ctx.slots.register({ - name: 'sidebar.panellist', - id: PANEL_ID, - order: 0, - label: () => t('panel'), - locale: NS, - }, PluginsPanelIcon)) - } diff --git a/packages/client/ui-plugin-manager/tests/browser-plugin.client.spec.tsx b/packages/client/ui-plugin-manager/tests/browser-plugin.client.spec.tsx index 83f72145d7..2a6f019ad4 100644 --- a/packages/client/ui-plugin-manager/tests/browser-plugin.client.spec.tsx +++ b/packages/client/ui-plugin-manager/tests/browser-plugin.client.spec.tsx @@ -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 () => { diff --git a/packages/client/ui-schedule/README.i18n.yaml b/packages/client/ui-schedule/README.i18n.yaml index d3f7274631..f21ddb5997 100644 --- a/packages/client/ui-schedule/README.i18n.yaml +++ b/packages/client/ui-schedule/README.i18n.yaml @@ -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 diff --git a/packages/client/ui-schedule/README.md b/packages/client/ui-schedule/README.md index bfcb80179c..da8d202d0d 100644 --- a/packages/client/ui-schedule/README.md +++ b/packages/client/ui-schedule/README.md @@ -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. + ## Understand the implementation diff --git a/packages/client/ui-schedule/README.zh.md b/packages/client/ui-schedule/README.zh.md index b201a5ca5d..057e42a2ac 100644 --- a/packages/client/ui-schedule/README.zh.md +++ b/packages/client/ui-schedule/README.zh.md @@ -57,6 +57,8 @@ kind: "package-reference" 时区清单与本地化完全离线。`Intl` 读取浏览器运行时内嵌的 ICU/CLDR 表,DSH 运行时不会获取或下载时区数据。没有需要刷新的爬虫;第三方语言包提供 `time.locale`,并仅翻译选择器自身的 UI 文案。 +访问控制:只有当前 Peer 可以调用 `schedule/catalog` 时本插件才注册这些界面,会话头部的提醒还要求该会话允许 `schedule/list` 才渲染;任务 tab 类型与 turn 定义始终注册。owner 始终有权限。 + ## 理解实现 diff --git a/packages/client/ui-schedule/package.json b/packages/client/ui-schedule/package.json index dce1c30814..5d149a8d39 100644 --- a/packages/client/ui-schedule/package.json +++ b/packages/client/ui-schedule/package.json @@ -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:*", diff --git a/packages/client/ui-schedule/src/client/ScheduleCatalogAction.tsx b/packages/client/ui-schedule/src/client/ScheduleCatalogAction.tsx index abeba05542..9aa03b8f81 100644 --- a/packages/client/ui-schedule/src/client/ScheduleCatalogAction.tsx +++ b/packages/client/ui-schedule/src/client/ScheduleCatalogAction.tsx @@ -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') ? : null +} + +function SessionScheduleCatalog({ useSession, useCatalog, onDelete, onRetry, openTaskDetail, t, }: ScheduleCatalogActionProps) { const openState = useSession(snapshot => snapshot.openState) diff --git a/packages/client/ui-schedule/src/client/index.ts b/packages/client/ui-schedule/src/client/index.ts index 4aa83ae1a8..8c6dcb3c5f 100644 --- a/packages/client/ui-schedule/src/client/index.ts +++ b/packages/client/ui-schedule/src/client/index.ts @@ -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 = createCatalogSource({ - 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) => - async (id: ScheduleId): Promise => { - 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 = createCatalogSource({ + 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) => + async (id: ScheduleId): Promise => { + 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)) + }) } diff --git a/packages/client/ui-schedule/tests/browser-plugin.client.spec.ts b/packages/client/ui-schedule/tests/browser-plugin.client.spec.ts index 4fa73699b9..768c478bef 100644 --- a/packages/client/ui-schedule/tests/browser-plugin.client.spec.ts +++ b/packages/client/ui-schedule/tests/browser-plugin.client.spec.ts @@ -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 { 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 }) diff --git a/packages/client/ui-schedule/tests/schedule-catalog-action.client.spec.tsx b/packages/client/ui-schedule/tests/schedule-catalog-action.client.spec.tsx index 9aadcc8bfb..5e0a6e1670 100644 --- a/packages/client/ui-schedule/tests/schedule-catalog-action.client.spec.tsx +++ b/packages/client/ui-schedule/tests/schedule-catalog-action.client.spec.tsx @@ -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 = (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() + 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() diff --git a/packages/client/ui-session/README.i18n.yaml b/packages/client/ui-session/README.i18n.yaml index 447f8f489b..3a3d92d9d5 100644 --- a/packages/client/ui-session/README.i18n.yaml +++ b/packages/client/ui-session/README.i18n.yaml @@ -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 diff --git a/packages/client/ui-session/README.md b/packages/client/ui-session/README.md index 79ac9f0e73..4c3928b35d 100644 --- a/packages/client/ui-session/README.md +++ b/packages/client/ui-session/README.md @@ -24,7 +24,7 @@ Running status comes from Host list baselines or status events. Subagent catalog ## 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). ## Model Experience diff --git a/packages/client/ui-session/README.zh.md b/packages/client/ui-session/README.zh.md index 157cc79a7f..fb63e227cd 100644 --- a/packages/client/ui-session/README.zh.md +++ b/packages/client/ui-session/README.zh.md @@ -24,7 +24,7 @@ kind: "package-reference" ## 访问 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)。 ## 模型体验 diff --git a/packages/client/ui-session/src/client/index.ts b/packages/client/ui-session/src/client/index.ts index 29aeebce04..860a82287c 100644 --- a/packages/client/ui-session/src/client/index.ts +++ b/packages/client/ui-session/src/client/index.ts @@ -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. diff --git a/packages/client/ui-session/tests/ui-session.client.spec.ts b/packages/client/ui-session/tests/ui-session.client.spec.ts index ae105e7b8e..c12901a0d8 100644 --- a/packages/client/ui-session/tests/ui-session.client.spec.ts +++ b/packages/client/ui-session/tests/ui-session.client.spec.ts @@ -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', () => { diff --git a/packages/client/ui-settings-account/README.i18n.yaml b/packages/client/ui-settings-account/README.i18n.yaml index 1612f7825a..f6f6403a3f 100644 --- a/packages/client/ui-settings-account/README.i18n.yaml +++ b/packages/client/ui-settings-account/README.i18n.yaml @@ -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 diff --git a/packages/client/ui-settings-account/README.md b/packages/client/ui-settings-account/README.md index 2b02a9d990..6388b08d0b 100644 --- a/packages/client/ui-settings-account/README.md +++ b/packages/client/ui-settings-account/README.md @@ -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. + ## Understand the implementation diff --git a/packages/client/ui-settings-account/README.zh.md b/packages/client/ui-settings-account/README.zh.md index 7485d31445..8200c6e468 100644 --- a/packages/client/ui-settings-account/README.zh.md +++ b/packages/client/ui-settings-account/README.zh.md @@ -63,6 +63,8 @@ Desktop preload 标记在账号凭证已存储后启用引导。进度属于本 欢迎页必须点击“开始设置”。额度页始终展示。首次进入时若已确认有正余额,则主操作为“继续”,次操作为“去充值”。该选择在引导控制器重建前保持固定且不持久化,后到的余额结果不会改变按钮。仅在确认不存在正余额时,继续而不充值才需确认;查询中或失败时不阻止继续。用途卡片选中后描述不变。从原生充值视图返回后保留额度页,并在等待任何已在进行的读取结束后于后台重读资料与余额,不受页面加载或支付结果影响。该读取不会重新计算该选择,因此充值成功后按钮仍保持首次进入时的状态,而继续前行仍按刷新后的余额判断。“下一步”仅在确认零余额时提示;额度页的“跳过”使用充值提示,答题页使用设置提示。仅选办公用途时以简洁展示完成;选择开发或两种用途时还会询问过程详情。上一步保留已有选择,任意可跳过步骤均采用标准工作过程、简洁性能与用量以及关闭代码工作工具的默认值。此流程不创建工作区或示例任务。 +访问控制:账号状态流、额度提醒、设置启动器、账号设置分区与模型登录入口只在当前 Peer 可以调用 `account/watch` 时存在;桌面引导与平台页面不受此控制。失去该权限时,这一轮发出的账号读取作废,待授权的浏览器标签页关闭,账号视图、详情、头像与登录对话框状态清空;之后再次授权时,账号分区等新流的第一帧到达后才重新出现。owner 始终有权限。 + ## 理解实现 diff --git a/packages/client/ui-settings-account/package.json b/packages/client/ui-settings-account/package.json index 681232a393..41b221656b 100644 --- a/packages/client/ui-settings-account/package.json +++ b/packages/client/ui-settings-account/package.json @@ -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:*", diff --git a/packages/client/ui-settings-account/src/client/index.ts b/packages/client/ui-settings-account/src/client/index.ts index 4847b9b6e9..b70e5097a1 100644 --- a/packages/client/ui-settings-account/src/client/index.ts +++ b/packages/client/ui-settings-account/src/client/index.ts @@ -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({ - 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({ + 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?.() } - }) + })) } diff --git a/packages/client/ui-settings-account/tests/access-revocation.client.spec.ts b/packages/client/ui-settings-account/tests/access-revocation.client.spec.ts new file mode 100644 index 0000000000..c969148d1b --- /dev/null +++ b/packages/client/ui-settings-account/tests/access-revocation.client.spec.ts @@ -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() + const streams: { readonly signal: AbortSignal; readonly accepted: Promise }[] = [] + 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() + 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 => 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() + let operations: AccountSectionInjected | undefined + const profile = Promise.withResolvers<{ ok: true; value: { status: 'failed' } }>() + const firstFrames: { release: () => void; accepted: Promise }[] = [] + 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() + const accepted = Promise.withResolvers() + 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() + const accepted: Promise[] = [] + 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() + 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() + } +}) diff --git a/packages/client/ui-settings-account/tests/apply.client.spec.ts b/packages/client/ui-settings-account/tests/apply.client.spec.ts index 0146bbfa4a..300641d109 100644 --- a/packages/client/ui-settings-account/tests/apply.client.spec.ts +++ b/packages/client/ui-settings-account/tests/apply.client.spec.ts @@ -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 }) => { diff --git a/packages/client/ui-settings-account/tests/config.client.spec.ts b/packages/client/ui-settings-account/tests/config.client.spec.ts index a5a33dca61..850cf78660 100644 --- a/packages/client/ui-settings-account/tests/config.client.spec.ts +++ b/packages/client/ui-settings-account/tests/config.client.spec.ts @@ -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[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([]) }) diff --git a/packages/client/ui-settings-account/tests/initialization.client.spec.ts b/packages/client/ui-settings-account/tests/initialization.client.spec.ts index daa962d263..a17217f0d2 100644 --- a/packages/client/ui-settings-account/tests/initialization.client.spec.ts +++ b/packages/client/ui-settings-account/tests/initialization.client.spec.ts @@ -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 }, diff --git a/packages/client/ui-settings-general/README.i18n.yaml b/packages/client/ui-settings-general/README.i18n.yaml index 85030a3d13..377d9797cd 100644 --- a/packages/client/ui-settings-general/README.i18n.yaml +++ b/packages/client/ui-settings-general/README.i18n.yaml @@ -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 diff --git a/packages/client/ui-settings-general/README.md b/packages/client/ui-settings-general/README.md index 1bfd4e9aab..0b14da3bcb 100644 --- a/packages/client/ui-settings-general/README.md +++ b/packages/client/ui-settings-general/README.md @@ -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. + ----- diff --git a/packages/client/ui-settings-general/README.zh.md b/packages/client/ui-settings-general/README.zh.md index 932dc56e46..d1e276b141 100644 --- a/packages/client/ui-settings-general/README.zh.md +++ b/packages/client/ui-settings-general/README.zh.md @@ -59,6 +59,8 @@ Web 与桌面端的通用设置底部显示当前发布版本,使用构建注 引导账本按升序投影,每次只挂载一个步骤。注册方持有持久化完成状态、能力就绪状态、文案、变更操作与可见包装,因此独立注册的流程无法堆叠,外壳也不会成为第二个配置事实来源。可见步骤自行持有弹窗框架与应用根节点 `inert` 生命周期。 +访问控制:设置外壳与 `settings.open` 快捷键只在当前 Peer 可以调用 `settings/describe` 时注册。owner 始终有权限。 + ----- diff --git a/packages/client/ui-settings-general/package.json b/packages/client/ui-settings-general/package.json index 29d5e63785..944ce57932 100644 --- a/packages/client/ui-settings-general/package.json +++ b/packages/client/ui-settings-general/package.json @@ -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:*", diff --git a/packages/client/ui-settings-general/src/client/index.ts b/packages/client/ui-settings-general/src/client/index.ts index 7183dab668..180df12f10 100644 --- a/packages/client/ui-settings-general/src/client/index.ts +++ b/packages/client/ui-settings-general/src/client/index.ts @@ -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)) diff --git a/packages/client/ui-settings-general/tests/apply.client.spec.ts b/packages/client/ui-settings-general/tests/apply.client.spec.ts index 691a9ca717..2b3a09a468 100644 --- a/packages/client/ui-settings-general/tests/apply.client.spec.ts +++ b/packages/client/ui-settings-general/tests/apply.client.spec.ts @@ -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 }) => { diff --git a/packages/client/ui-settings-general/tests/shell.client.spec.ts b/packages/client/ui-settings-general/tests/shell.client.spec.ts index 47b7460e2c..a072a81df9 100644 --- a/packages/client/ui-settings-general/tests/shell.client.spec.ts +++ b/packages/client/ui-settings-general/tests/shell.client.spec.ts @@ -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 diff --git a/packages/client/ui-settings-models/tests/apply.client.spec.ts b/packages/client/ui-settings-models/tests/apply.client.spec.ts index f9dac62ceb..66e3eae78c 100644 --- a/packages/client/ui-settings-models/tests/apply.client.spec.ts +++ b/packages/client/ui-settings-models/tests/apply.client.spec.ts @@ -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[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() diff --git a/packages/client/ui-sidebar-documentpreview/tests/config.host.spec.ts b/packages/client/ui-sidebar-documentpreview/tests/config.host.spec.ts index 6646008850..8a09bdd497 100644 --- a/packages/client/ui-sidebar-documentpreview/tests/config.host.spec.ts +++ b/packages/client/ui-sidebar-documentpreview/tests/config.host.spec.ts @@ -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[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([]) }) diff --git a/packages/client/ui-sidebar-right/README.i18n.yaml b/packages/client/ui-sidebar-right/README.i18n.yaml index 4c93ea2e93..c781146562 100644 --- a/packages/client/ui-sidebar-right/README.i18n.yaml +++ b/packages/client/ui-sidebar-right/README.i18n.yaml @@ -24,8 +24,8 @@ en: 2614d3f179290606 zh: 88c08ee260ba57b7 /deepseek-ai-dsh-client-ui-sidebar-right/state: - en: 2b15cdf5f1383291 - zh: 57018036d77363ba + en: 98597ce087c48075 + zh: c5466bf7955ea7b0 /deepseek-ai-dsh-client-ui-sidebar-right/extension-seats: en: ef2af36136c4a919 zh: 48d992e2c923a130 @@ -36,8 +36,8 @@ en: 717b86e05b44ebdd zh: 0f4f694d0ea1d7f8 /deepseek-ai-dsh-client-ui-sidebar-right/the-guide: - en: dfb9e0911e4278f1 - zh: e395e10f61ad040d + en: c67935eddef999fe + zh: 0648664ded92ee82 /deepseek-ai-dsh-client-ui-sidebar-right/copy: en: 4315a1c03ca5fef3 zh: c09a09f1d9b99107 diff --git a/packages/client/ui-sidebar-right/README.md b/packages/client/ui-sidebar-right/README.md index ea0269b716..b5839bd05d 100644 --- a/packages/client/ui-sidebar-right/README.md +++ b/packages/client/ui-sidebar-right/README.md @@ -69,7 +69,7 @@ Carrying the mint counter in the surface is what makes a recorded sequence repla After every action the kit's settle planner keeps the expanded surface populated: a docked pane whose last tab was moved out or floated is merged away, and an emptied root pane receives the default page. A collapsed surface holds no such backfill — a session starts collapsed and empty, and a close that collapses the column leaves it empty — the expansion that would first show an empty layout is what seeds the default page. Explicit closing follows [the default-page and close rules](#the-guide). While expanded there is always at least one tab, and never an empty pane — so there is no separate "close pane" gesture. -The docked surface's last tab carries one more rule, decided in the store's `closeTab` and mirrored to the kit through `canCloseTab`: the guide standing as the only docked tab draws no close control and no menu close item — its chip sits quiet, and with no extension item contributed a secondary press opens no menu — and a programmatic close of it records nothing; any other tab standing alone closes together with the column in one entry — the layout stays empty until the next expansion seeds its current default page. Floating panels take no part in the rule: the foreground Session's floats render whether or not the column is expanded, and their tabs close freely. +The docked surface's last tab carries one more rule, decided in the store's `closeTab` and mirrored to the kit through `canCloseTab`: the guide standing as the only docked tab draws no close control or menu close item and ignores middle clicks — its chip sits quiet, and with no extension item contributed a secondary press opens no menu — and a programmatic close of it records nothing; any other tab standing alone closes together with the column in one entry — the layout stays empty until the next expansion seeds its current default page. Floating panels take no part in the rule: the foreground Session's floats render whether or not the column is expanded, and their tabs close freely. The store saves each Session's layout, tab identities, selection, split ratios, floating rectangles, presentation and identity counter as JSON under `dsh.sidebar-right.v1.` in localStorage. Reload restores that layout before tab bodies render; switching Sessions keeps their layouts independent. Providers restore their own content from the retained tab identity and resource address, including [terminal reconnection](../ui-sidebar-terminal/README.md#use-this-package). Navigation parameters, resource contents and live connections are not layout state. Undo history stays in memory and resets on reload. Saved field types, node membership, selection, identity counters and the one-pane or two-horizontal-pane dock layout are validated before adoption or startup discovery; invalid data clears only its Session key. Storage failures leave the current layout usable in memory. Windows at the same origin share the last saved layout per Session; each active window keeps its own current layout until reload. @@ -118,7 +118,7 @@ While the root-scoped `sidebar.right.tab.guide.actions` list has registrations, A tab type can declare `multiple: true` for independent instances such as terminals. Each open receives a distinct content address, so placement and docking preserve those instances; ordinary pages retain per-pane uniqueness. -Default pages depend on the number of registered guide entries, not the number of tab types or open tabs. Exactly one entry opens its page directly; zero or multiple entries open the guide. Explicitly adding a guide still opens the guide, even with one entry. The sole docked guide is the only tab that cannot close; closing any other sole tab also collapses the column. The chip, context menu, and `close` API apply the same rule. +Default pages depend on the number of registered guide entries, not the number of tab types or open tabs. Exactly one entry opens its page directly; zero or multiple entries open the guide. Explicitly adding a guide still opens the guide, even with one entry. The sole docked guide is the only tab that cannot close; closing any other sole tab also collapses the column. The close control, context menu, middle click, and `close` API apply the same rule. The guide tab is a muted compass over one entry capsule per `guide` entry the registered types contributed, in `order`, centred in the body; the guide has no words of its own. A capsule shows the entry's glyph — or the guide's quieter cube placeholder when the entry registered none — and its title; while at most four entries are listed, an entry that registered a `description` shows it under the title, and a longer list drops every description. Picking a default capsule calls `tab.actions.openTab(entry.kind, { replaceTab: true })`, so the guide gives way to the page it opened. A pane holds at most one guide tab. The strip's add control is drawn only while its pane holds none and opens one there with `openTab('guide', { paneId, revealIfOpened: false })`, so a guide in another pane does not capture the click; opening the guide into a pane that already has one focuses it instead; a guide dragged, dropped, or docked into such a pane merges into it — the arriving guide closes and the pane's own is focused; `duplicateTab` on the guide records nothing. A split, an expanded empty root pane, and the pane a sole tab vacates by dropping on its own edge use the same default-page rule, one tab per new pane; the self-edge drop leaves the dragged tab focused. A plain `openTab('guide')` opens or focuses the guide only within the active or named pane. Splitting an empty pane does nothing and returns no new pane. The product allows two horizontal panes, initially equal, with divider ratios limited to 20%–80%. Insufficient width blocks a new split; with two panes already present, a body drop moves the tab between panes instead of creating a third. At the two-pane limit, split controls are disabled with a focusable reason; closing back to one pane enables them. diff --git a/packages/client/ui-sidebar-right/README.zh.md b/packages/client/ui-sidebar-right/README.zh.md index 052b38e2c8..cfb16e3e53 100644 --- a/packages/client/ui-sidebar-right/README.zh.md +++ b/packages/client/ui-sidebar-right/README.zh.md @@ -69,7 +69,7 @@ root 作用域的 `rightbar` 入口通过独立的 `rightbar.session` 子树渲 每个动作之后,套件的 settle planner 保证展开的停靠面有内容:最后一个 tab 被搬走或浮出的停靠格会被并掉;根格被清空时填入默认页。折叠的停靠面没有这种回填——会话以折叠且为空的停靠面开始,收起整列的关闭也让它保持为空——首次会展示空布局的那次展开才播种默认页。显式关闭遵循[默认页与关闭规则](#the-guide)。展开期间永远至少有一个 tab,永远没有空格——因此没有单独的「关闭格」手势。 -停靠面的最后一个 tab 还多带一条规则,由 store 的 `closeTab` 决定并经 `canCloseTab` 镜像给套件:作为唯一停靠 tab 的引导页不画关闭控件也不画菜单里的关闭项——它的 chip 呈安静样式,在没有扩展条目时次键按下也不弹出菜单——对它的编程式关闭什么都不记录;任何其它 tab 独自留下时,点击关闭会连同整列一起收起,记为一条历史,布局保持为空,直到下次展开时创建当时的默认页。浮动面板不参与这条规则:前台 Session 的浮窗无论列是否展开都会渲染,其 tab 照常关闭。 +停靠面的最后一个 tab 还多带一条规则,由 store 的 `closeTab` 决定并经 `canCloseTab` 镜像给套件:作为唯一停靠 tab 的引导页不画关闭控件或菜单里的关闭项,也忽略中键点击——它的 chip 呈安静样式,在没有扩展条目时次键按下也不弹出菜单——对它的编程式关闭什么都不记录;任何其它 tab 独自留下时,点击关闭会连同整列一起收起,记为一条历史,布局保持为空,直到下次展开时创建当时的默认页。浮动面板不参与这条规则:前台 Session 的浮窗无论列是否展开都会渲染,其 tab 照常关闭。 store 将每个 Session 的布局、标签身份、选中项、分栏比例、浮窗矩形、呈现方式和身份计数以 JSON 保存到 localStorage 的 `dsh.sidebar-right.v1.`。刷新时在渲染标签正文前恢复布局;不同 Session 的布局独立。provider 根据保留的标签身份和资源地址恢复自身内容,包括[终端重连](../ui-sidebar-terminal/README.zh.md#use-this-package)。导航参数、资源内容和活动连接不属于布局状态。撤销历史只保留在内存中,刷新后清空。采用布局或启动探测前会校验保存字段类型、节点成员关系、选中项、身份计数,以及单格或左右两格的停靠布局;无效数据只清除对应 Session 的 key。存储失败时,当前布局仍可在内存中使用。同源窗口共享每个 Session 最后保存的布局;活动窗口在刷新前保留各自的当前布局。 @@ -118,7 +118,7 @@ root 作用域的 `sidebar.right.tab.guide.actions` list 有注册项时,引 终端等需要独立实例的 tab 类型可声明 `multiple: true`。每次打开都会获得独立内容地址,因此移动和停靠会保留这些实例;普通页面仍在每个格内去重。 -默认页取决于已注册的引导入口数,不取决于 tab 类型数或已打开的 tab 数。恰好一个入口时直接打开对应页面;没有入口或有多个入口时打开引导页。即使只有一个入口,显式添加引导页仍会打开引导页。只有作为唯一停靠 tab 的引导页不可关闭;关闭其它任何唯一 tab 时会同时收起整列。chip、上下文菜单与 `close` API 使用同一规则。 +默认页取决于已注册的引导入口数,不取决于 tab 类型数或已打开的 tab 数。恰好一个入口时直接打开对应页面;没有入口或有多个入口时打开引导页。即使只有一个入口,显式添加引导页仍会打开引导页。只有作为唯一停靠 tab 的引导页不可关闭;关闭其它任何唯一 tab 时会同时收起整列。关闭控件、上下文菜单、中键与 `close` API 使用同一规则。 引导 tab 是一枚弱化的罗盘,下方是各已注册类型贡献的每个 `guide` 条目一个入口胶囊,按 `order` 排列并在正文中居中;引导页自己没有文字。胶囊显示条目的图标——条目没注册图标时用引导页自己更浅的立方体占位符——和标题;列出的条目不超过四个时,注册了 `description` 的条目在标题下显示它,更长的列表则去掉所有描述。点击默认胶囊会调用 `tab.actions.openTab(entry.kind, { replaceTab: true })`,于是引导页让位给它打开的页。一个格最多持有一个引导 tab。tab 条的添加控件只在该格没有引导 tab 时绘制,并以 `openTab('guide', { paneId, revealIfOpened: false })` 在该格打开一个,这样别的格里的引导页不会截走这次点击;把引导页开进已有引导页的格则改为聚焦它;把引导页拖入、放入或收回到这样的格会合并进去——来者关闭,该格自己的被聚焦;对引导页 `duplicateTab` 不记录任何东西。分栏、展开且为空的根格、以及唯一 tab 拖到本格边缘分屏后腾出的格使用相同的默认页规则,每个新格一个 tab;这种本格边缘拖放让被拖的 tab 保持聚焦。普通的 `openTab('guide')` 只在活跃或指定分栏内打开或聚焦引导页。对空分栏执行 split 不产生变化,也不返回新分栏。产品最多保留左右两格,默认均分,分隔条限定在 20%~80%。宽度不足以容纳两格时不允许新分栏;已有两格时,正文拖放用于跨格移动,不再创建第三格。达到两格上限时禁用分栏控件并提供可聚焦的原因提示;关闭回单格后重新启用。 diff --git a/packages/client/ui-theme/tests/config.host.spec.ts b/packages/client/ui-theme/tests/config.host.spec.ts index 69a10570b8..0de2aac907 100644 --- a/packages/client/ui-theme/tests/config.host.spec.ts +++ b/packages/client/ui-theme/tests/config.host.spec.ts @@ -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[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 } diff --git a/packages/client/ui-user-questions/README.i18n.yaml b/packages/client/ui-user-questions/README.i18n.yaml index e2e000bc53..a307a1361c 100644 --- a/packages/client/ui-user-questions/README.i18n.yaml +++ b/packages/client/ui-user-questions/README.i18n.yaml @@ -18,8 +18,8 @@ en: 8d3815c19858ac85 zh: 94fc905fa202d334 /deepseek-ai-dsh-client-ui-user-questions/use-this-package/answering: - en: 0fa156d3d698cf0e - zh: cbcd082b9a188d6b + en: 8900fbf9cbd72083 + zh: 67a951d58a029a58 /deepseek-ai-dsh-client-ui-user-questions/use-this-package/reading-a-settled-question-back: en: 10975f68ff9c6fcc zh: 92246aadc363f0eb @@ -27,8 +27,8 @@ en: 6b12f1362c614a39 zh: 27d9cf54d4cbd5d2 /deepseek-ai-dsh-client-ui-user-questions/use-this-package/failure-and-recovery: - en: 3509678f4d183834 - zh: 625542b79e434eb5 + en: 3ad0e0fd224aabfc + zh: db15f400e6f2bfd7 /deepseek-ai-dsh-client-ui-user-questions/understand-the-implementation: en: 77e1b2585acdf67b zh: d433ec185c6b8f57 diff --git a/packages/client/ui-user-questions/README.md b/packages/client/ui-user-questions/README.md index 9a6175d227..a637ac1251 100644 --- a/packages/client/ui-user-questions/README.md +++ b/packages/client/ui-user-questions/README.md @@ -29,7 +29,7 @@ When the agent asks a question, the attached card temporarily occupies the compo ### Answering -A multi-select draft keeps its selected labels while the user opens or edits the custom answer, so its submitted item may carry both `selected` and `custom`; a single-select custom answer remains exclusive. Question detail reuses the assistant-output `MarkdownText` primitive, including its GFM rendering and untrusted-content policy. The capped card keeps its title, navigation, and submission actions fixed while long detail and choices share an internal scroll region. "Skip" retains other drafts and emits the existing blank `{ selected: [] }` result for that item. Close is not an answer: a question the Host named by tool call only leaves the composer seat, and its `ask_user_question` tool call row brings the panel back; a request that carries no tool call has no row to return from, so closing it rejects the whole wait as `ASK_CANCELLED`. +A multi-select draft keeps its selected labels while the user opens or edits the custom answer, so its submitted item may carry both `selected` and `custom`; a single-select custom answer remains exclusive. Question detail reuses the assistant-output `MarkdownText` primitive, including its GFM rendering and untrusted-content policy. The capped card keeps navigation and submission actions fixed while the question, long detail, and choices share an internal scroll region. A question appears in full up to three rendered lines; a longer one shows two, fades at the end of the second, and places a downward-chevron expand button there that expands it in place. Option descriptions use 13px text with a 20px line height and sit beside their label when they fit, otherwise below it; a description appears in full up to two lines, and a longer one shows one line with the same fade and expand button. Expanded copy keeps an upward-chevron collapse button on a line below it. Both buttons are named **Expand** or **Collapse**, show that name in a hover tooltip, and are described by the text they control. CSS scroll timelines decide which copy overflows; a browser without them shows all copy in full. "Skip" retains other drafts and emits the existing blank `{ selected: [] }` result for that item. Close is not an answer: a question the Host named by tool call only leaves the composer seat, and its `ask_user_question` tool call row brings the panel back; a request that carries no tool call has no row to return from, so closing it rejects the whole wait as `ASK_CANCELLED`. Fixed approval questions use the same pending-question and answer channel. A feature plugin can supply a higher-priority composer presentation. When none matches, the generic form keeps the offered options answerable; the caller still decides which responses grant permission. @@ -45,7 +45,7 @@ A `plan-review` intent — set by `dsh-plan-mode` on the `exit_plan_mode` review ### Failure and recovery -An answer field autofocuses only when its answer channel is ready and no countdown exists. Manual focus and blur during the claim handshake are retained: the countdown starts paused only if the answer surface still has focus when the remaining duration arrives. +An answer field autofocuses only when its answer channel is ready and no countdown exists. Custom-answer focus survives Enter navigation between option questions; navigation resets the question scroll position and collapses expanded copy. Manual focus and blur during the claim handshake are retained: the countdown starts paused only if the answer surface still has focus when the remaining duration arrives. Returning a local answer does not release its foreground claim: the Host closes that stream after accepting the waterfall outcome. The plugin owns the claim through this delivery interval. Explicit delegation releases it before calling the next answerer, and plugin disposal cancels any remaining claims. diff --git a/packages/client/ui-user-questions/README.zh.md b/packages/client/ui-user-questions/README.zh.md index b62f3c1db2..85a4cc8902 100644 --- a/packages/client/ui-user-questions/README.zh.md +++ b/packages/client/ui-user-questions/README.zh.md @@ -29,7 +29,7 @@ Web 客户端在聊天输入框旁显示 agent(智能体)的提问。用户 ### 作答 -用户打开或编辑自定义答案时,多选题草稿会保留已选中的标签,因此提交项可以同时携带 `selected` 与 `custom`;单选题的自定义答案仍保持互斥。问题详情复用助手输出的 `MarkdownText` 原语,包括其 GFM 渲染与不受信任内容策略。限高卡片保持标题、导航与提交动作固定,超长的详情与选项共享内部滚动区。「跳过」会保留其他草稿,并为该项发出既有的空 `{ selected: [] }` 结果。关闭不是一种回答:Host 以工具调用命名的问题只是离开编辑器位置,其 `ask_user_question` 工具调用行会把面板带回来;不携带工具调用的请求没有可返回的行,关闭它仍以 `ASK_CANCELLED` 拒绝整个等待。 +用户打开或编辑自定义答案时,多选题草稿会保留已选中的标签,因此提交项可以同时携带 `selected` 与 `custom`;单选题的自定义答案仍保持互斥。问题详情复用助手输出的 `MarkdownText` 原语,包括其 GFM 渲染与不受信任内容策略。限高卡片保持导航与提交动作固定,问题正文、超长详情和选项共享内部滚动区。问题在实际排版不超过三行时完整显示;更长的问题只显示两行,第二行末尾渐隐并放置向下箭头的展开按钮,点击后原地展开。选项说明使用 13px 字号和 20px 行高,放得下时排在标签旁,否则排在标签下方;说明不超过两行时完整显示,更长的说明只显示一行,同样渐隐并放置展开按钮。展开后的文字下方单独一行显示向上箭头的收起按钮。两种按钮名为「展开」或「收起」,悬停时显示同样的提示,并以其控制的文字作为无障碍描述。由 CSS 滚动时间线判断文字是否溢出;不支持该特性的浏览器完整显示全部文字。「跳过」会保留其他草稿,并为该项发出既有的空 `{ selected: [] }` 结果。关闭不是一种回答:Host 以工具调用命名的问题只是离开编辑器位置,其 `ask_user_question` 工具调用行会把面板带回来;不携带工具调用的请求没有可返回的行,关闭它仍以 `ASK_CANCELLED` 拒绝整个等待。 固定审批问题复用待处理问题和回答通道。功能插件可以提供优先级更高的 composer;没有匹配的呈现时,通用表单仍可选择并提交原有选项,由调用方判断哪些回答可以授予权限。 @@ -45,7 +45,7 @@ Web 客户端在聊天输入框旁显示 agent(智能体)的提问。用户 ### 失败与恢复 -回答输入框只有在回答通道就绪且没有倒计时时才自动聚焦。接手握手期间的手动聚焦与失焦会被保留:剩余时长到达时,只有回答区域仍持有焦点,倒计时才以暂停状态开始。 +回答输入框只有在回答通道就绪且没有倒计时时才自动聚焦。在带选项的问题间按 Enter 切题时,自定义回答框保留焦点;切题会重置问题滚动位置并收起已展开的文字。接手握手期间的手动聚焦与失焦会被保留:剩余时长到达时,只有回答区域仍持有焦点,倒计时才以暂停状态开始。 本地返回回答不会释放前台接手记录:Host 接受 waterfall 结果后才关闭该 stream。插件在这段传输期间继续拥有接手记录。显式委托会在调用下一个回答方前释放它,插件卸载则取消剩余接手记录。 diff --git a/packages/client/ui-user-questions/src/client/QuestionComposer.module.css b/packages/client/ui-user-questions/src/client/QuestionComposer.module.css index 8de8c84028..5c530aef72 100644 --- a/packages/client/ui-user-questions/src/client/QuestionComposer.module.css +++ b/packages/client/ui-user-questions/src/client/QuestionComposer.module.css @@ -60,6 +60,7 @@ align-items: center; gap: 4px; flex-shrink: 0; + margin-left: auto; } .waitButton { @@ -85,6 +86,7 @@ } .headingBlock { + flex: 1; min-width: 0; } @@ -103,8 +105,70 @@ font-weight: 500; } +/* Long copy keeps one line fewer than its budget once it overflows: a + question shows in full up to three lines, an option description up to two; + longer copy drops its last line, fades at the end of the line before, and + shows its expand button there. Each box keeps its full budget, plus a 2px + allowance for glyphs taller than the line, so the scroll timeline that + reports the overflow never depends on the collapsed layout. A negative + bottom margin, not a smaller box, gives the dropped line back to the row, + and clip-path removes that line from hit testing as well as from view. */ +.questionTitle { + position: relative; + flex-shrink: 0; + margin: 5px 24px 0; + timeline-scope: --question-copy; +} + +.questionTitle[data-expanded] { + padding-bottom: 24px; +} + +.titlePreview { + max-height: calc(3lh + 2px); + margin: 0 0 -2px; + padding-bottom: 2px; + overflow: hidden; + overflow-wrap: anywhere; + white-space: pre-wrap; + scroll-timeline: --question-copy block; + /* Longhands: the bundled lightningcss folds animation-timeline into the + animation shorthand, a form browsers reject. */ + animation-name: question-clipped; + animation-fill-mode: both; + animation-timeline: --question-copy; +} + +@keyframes question-clipped { + from, to { + margin-bottom: calc(-1lh - 2px); + clip-path: inset(0 0 calc(1lh + 2px) 0); + mask-image: linear-gradient(#000, #000), linear-gradient(to right, #000 calc(100% - 56px), transparent calc(100% - 28px)); + mask-size: 100% 1lh, 100% 1lh; + mask-position: 0 0, 0 1lh; + mask-repeat: no-repeat; + } +} + +.questionToggle { + position: absolute; + right: -4px; + bottom: -1px; + animation-name: copy-toggle-shown; + animation-fill-mode: both; + animation-timeline: --question-copy; +} + +.cardMinimized .title { + display: -webkit-box; + overflow: hidden; + -webkit-box-orient: vertical; + -webkit-line-clamp: 2; +} + .detail { - margin: 0 2px 8px; + flex-shrink: 0; + margin: 0 24px 8px; } .footerActions { @@ -175,29 +239,32 @@ padding: 4px 12px; } +.optionRow { + position: relative; + width: 100%; + min-height: 40px; + flex-shrink: 0; + border: 0.5px solid transparent; + border-radius: var(--dsw-radius-md); + background: transparent; + transition: background-color 120ms ease, border-color 120ms ease; + timeline-scope: --description-copy; +} + .option { display: flex; - /* flex-start, not center: with a wrapped description the indicator must - stay on the FIRST line (centering drifts it down the taller copy block). - The 8px padding makes a single-line row 40px exactly, so nothing reads - as top-heavy; .number/.checkbox re-center against the first line box. */ + /* flex-start keeps the indicator aligned with the first label line. */ align-items: flex-start; gap: 8px; width: 100%; + min-width: 0; min-height: 40px; - /* Rows are the scroll content, never the slack absorber: a shrinkable row - collapses to min-height while its wrapped copy keeps the taller - intrinsic height, and centered content then paints outside the row box — - over the title and the next row. Overflow belongs to .options. */ - flex-shrink: 0; padding: 8px 12px 8px 8px; - border: 1px solid transparent; - border-radius: var(--dsw-radius-md); + border: 0; background: transparent; color: inherit; text-align: left; cursor: pointer; - transition: background-color 120ms ease, border-color 120ms ease; } .optionSelected { @@ -205,7 +272,7 @@ } @media (hover: hover) { - .option:hover:not(:disabled) { + .optionRowInteractive:hover { background: var(--dsw-alias-interactive-bg-hover); } } @@ -277,12 +344,17 @@ } .optionCopy { - min-width: 0; + display: flex; flex: 1; + flex-wrap: wrap; + align-items: baseline; + gap: 0 6px; + min-width: 0; } .optionLine { display: flex; + max-width: 100%; align-items: baseline; flex-wrap: wrap; gap: 2px 6px; @@ -305,10 +377,84 @@ } .description { + display: block; + width: max-content; + max-width: 100%; + min-width: 0; + max-height: calc(2lh + 2px); + margin-bottom: -2px; + padding-bottom: 2px; + overflow: hidden; + overflow-wrap: anywhere; + white-space: pre-wrap; color: var(--dsw-alias-label-tertiary); - font-size: 14px; - line-height: 24px; + font-size: 13px; + line-height: 20px; font-weight: 400; + scroll-timeline: --description-copy block; + animation-name: description-clipped; + animation-fill-mode: both; + animation-timeline: --description-copy; +} + +@keyframes description-clipped { + from, to { + margin-bottom: calc(-1lh - 2px); + clip-path: inset(0 0 calc(1lh + 2px) 0); + mask-image: linear-gradient(to right, #000 calc(100% - 56px), transparent calc(100% - 28px)); + mask-size: 100% 1lh; + mask-repeat: no-repeat; + } +} + +.descriptionToggle { + position: absolute; + right: 8px; + bottom: 6px; + animation-name: copy-toggle-shown; + animation-fill-mode: both; + animation-timeline: --description-copy; +} + +/* Expanded copy keeps its collapse button on a line of its own below the text. */ +.optionRow[data-expanded] .option { + padding-bottom: 32px; +} + +.questionTitle[data-expanded] .titlePreview, +.optionRow[data-expanded] .description { + max-height: none; + animation: none; +} + +/* Shares .iconButton's 24px round control; hidden until its copy overflows or expands. */ +.copyToggle { + visibility: hidden; +} + +@keyframes copy-toggle-shown { + from, to { + visibility: visible; + } +} + +.questionTitle[data-expanded] .copyToggle, +.optionRow[data-expanded] .copyToggle { + visibility: visible; + animation: none; +} + +/* Without scroll timelines clipped copy cannot be told apart, so it shows in full. */ +@supports not (animation-timeline: scroll()) { + .titlePreview, + .description { + max-height: none; + animation: none; + } + + .copyToggle { + display: none; + } } /* Read-only note standing in for a question the user skipped: it takes the @@ -337,7 +483,7 @@ it pushes the inline input past the footer. */ flex-shrink: 0; padding: 8px 12px 8px 8px; - border: 1px solid transparent; + border: 0.5px solid transparent; border-radius: var(--dsw-radius-md); transition: background-color 120ms ease, border-color 120ms ease; } @@ -477,6 +623,10 @@ padding: 8px 6px; } + .descriptionToggle { + right: 2px; + } + .footer { align-items: flex-end; padding: 0 10px; @@ -488,7 +638,7 @@ } @media (prefers-reduced-motion: reduce) { - .option, + .optionRow, .customRow { transition: none; } diff --git a/packages/client/ui-user-questions/src/client/QuestionComposer.tsx b/packages/client/ui-user-questions/src/client/QuestionComposer.tsx index 8d3ad551ba..0baf6e54c5 100644 --- a/packages/client/ui-user-questions/src/client/QuestionComposer.tsx +++ b/packages/client/ui-user-questions/src/client/QuestionComposer.tsx @@ -1,12 +1,12 @@ import { - useEffect, useMemo, useRef, useState, + useEffect, useId, useMemo, useRef, useState, type ChangeEvent, type FocusEvent, type KeyboardEvent, } from 'react' import clsx from 'clsx' import { Button, IconCheckOutlineRegular, IconChevronDownOutlineRegular, IconChevronLeftOutlineRegular, IconChevronRightOutlineRegular, IconChevronUpOutlineRegular, IconCloseOutlineRegular, - IconEditOutlineRegular, MarkdownText, + IconEditOutlineRegular, MarkdownText, Tooltip, } from '@deepseek-ai/dsh-client-ui-primitives' import { planReviewOf, type QuestionAnswer, type QuestionCardSnapshot, type QuestionComposerProps } from './contract/slots.ts' import type { PendingQuestion } from './contract/slots.ts' @@ -27,6 +27,16 @@ const REMOVED_CARD: QuestionCardSnapshot = { state: 'open', waitState: 'counting', countdown: undefined, channel: 'none', closed: true, } +/** Long copy the user expanded on one question: the question itself or an option description by index. */ +interface ExpandedCopy { + /** Index of the question the expansions belong to. */ + readonly index: number + /** Expanded copy of that question: `'question'` or an option index. */ + readonly copies: ReadonlySet<'question' | number> +} + +const NOTHING_EXPANDED: ExpandedCopy['copies'] = new Set() + /** * Split the conventional recommendation suffix without changing the answer value. * @param label - Original option label returned if selected. @@ -157,6 +167,7 @@ type QuestionFlowProps = { pending: PendingQuestion } & Pick function QuestionFlow({ pending, t, useStore, useQuestionCard, actions }: QuestionFlowProps) { + const descriptionIdPrefix = useId() const questions = pending.questions // A read-only card built from a settled call's transcript: the same panel // over the recorded answers, with nothing left to submit. @@ -241,8 +252,25 @@ function QuestionFlow({ pending, t, useStore, useQuestionCard, actions }: Questi // oxlint-disable-next-line typescript/no-non-null-assertion const draft = drafts[index]! const hasOptions = (question.options?.length ?? 0) > 0 + // CSS decides which copy is clipped; only the user's expansions are state, + // and they apply only while their question is the one shown. + const [expandedCopy, setExpandedCopy] = useState({ index, copies: NOTHING_EXPANDED }) + const expanded = expandedCopy.index === index ? expandedCopy.copies : NOTHING_EXPANDED + + const questionToggleLabel = t(expanded.has('question') ? 'text.collapse' : 'text.expand') + + const toggleCopy = (copy: 'question' | number): void => { + const copies = new Set(expanded) + if (!copies.delete(copy)) copies.add(copy) + setExpandedCopy({ index, copies }) + } const replaceProgress = (nextIndex: number, nextDrafts: QuestionDraftAnswer[]): void => { + if (nextIndex !== index) { + // Another question starts at its top with its long copy collapsed. + if (answerSurface.current !== null) answerSurface.current.scrollTop = 0 + setExpandedCopy({ index: nextIndex, copies: NOTHING_EXPANDED }) + } actions.replace(pending.key, { index: nextIndex, drafts: nextDrafts, @@ -436,12 +464,16 @@ function QuestionFlow({ pending, t, useStore, useQuestionCard, actions }: Questi aria-labelledby={`question-${pending.key}-${String(index)}`} >
-
- {question.header !== undefined &&
{question.header}
} -

- {question.question} -

-
+ {(question.header !== undefined || minimized) && ( +
+ {question.header !== undefined &&
{question.header}
} + {minimized && ( +

+ {question.question} +

+ )} +
+ )}
{countdown !== undefined && card.waitState !== 'waiting' && card.waitState !== 'editing' && card.waitState !== 'continued' && ( @@ -492,6 +524,27 @@ function QuestionFlow({ pending, t, useStore, useQuestionCard, actions }: Questi onFocusCapture={focusAnswerSurface} onBlurCapture={blurAnswerSurface} > +
+

+ {question.question} +

+ + + +
{question.detail !== undefined && (
)} @@ -499,40 +552,73 @@ function QuestionFlow({ pending, t, useStore, useQuestionCard, actions }: Questi {(question.options ?? []).map((option, optionIndex) => { const selected = draft.selected.includes(option.label) const display = parseRecommendedLabel(option.label) + const hasDescription = option.description !== undefined && option.description.trim() !== '' + const descriptionExpanded = expanded.has(optionIndex) + const descriptionToggleLabel = t(descriptionExpanded ? 'text.collapse' : 'text.expand') + const descriptionId = `${descriptionIdPrefix}-question-${String(index)}-option-${String(optionIndex)}-description` return ( - + + {hasDescription && ( + + + + )} +
) })} diff --git a/packages/client/ui-user-questions/src/client/locales.ts b/packages/client/ui-user-questions/src/client/locales.ts index 95fa1c3560..83ec122186 100644 --- a/packages/client/ui-user-questions/src/client/locales.ts +++ b/packages/client/ui-user-questions/src/client/locales.ts @@ -25,6 +25,8 @@ export const zh = { 'nav.maximize': '展开问题卡片', 'nav.cancel': '放弃整组问题', 'nav.close': '收起问题面板,可从工具调用重新打开', + 'text.expand': '展开', + 'text.collapse': '收起', 'option.recommended': '推荐', 'custom.placeholder': '输入你的答案', 'action.skip': '跳过', @@ -63,6 +65,8 @@ export const en = { 'nav.maximize': 'Expand the question card', 'nav.cancel': 'Dismiss all questions', 'nav.close': 'Close the panel — reopen it from the tool call', + 'text.expand': 'Expand', + 'text.collapse': 'Collapse', 'option.recommended': 'Recommended', 'custom.placeholder': 'Type your answer', 'action.skip': 'Skip', @@ -98,6 +102,8 @@ export const ja = { 'nav.maximize': '質問カードを展開', 'nav.cancel': 'すべての質問を閉じる', 'nav.close': 'パネルを閉じる — ツール呼び出しから再度開けます', + 'text.expand': '展開', + 'text.collapse': '折りたたむ', 'option.recommended': '推奨', 'custom.placeholder': '回答を入力', 'action.skip': 'スキップ', diff --git a/packages/client/ui-user-questions/tests/question-composer-styles.client.spec.ts b/packages/client/ui-user-questions/tests/question-composer-styles.client.spec.ts new file mode 100644 index 0000000000..7778302296 --- /dev/null +++ b/packages/client/ui-user-questions/tests/question-composer-styles.client.spec.ts @@ -0,0 +1,111 @@ +/** Checks CSS layout declarations that jsdom cannot measure. */ +import { readFileSync } from 'node:fs' +import { fileURLToPath } from 'node:url' +import { describe, expect, it } from 'vitest' + +const stylesheet = readFileSync( + fileURLToPath(new URL('../src/client/QuestionComposer.module.css', import.meta.url)), + 'utf8', +).replace(/\/\*[\s\S]*?\*\//g, ' ') + +const escape = (text: string): string => text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + +function split(body: string | undefined): string[] { + return (body ?? '').split(';').map(part => part.trim()).filter(Boolean) +} + +function declarations(selector: string): string[] { + const rule = new RegExp(`(?:^|\\})\\s*${escape(selector)}\\s*\\{([^{}]*)\\}`).exec(stylesheet) + if (rule === null) throw new Error(`QuestionComposer.module.css has no \`${selector}\` rule`) + return split(rule[1]) +} + +function keyframes(name: string): string[] { + const rule = new RegExp(`@keyframes ${escape(name)}\\s*\\{\\s*from, to\\s*\\{([^{}]*)\\}`).exec(stylesheet) + if (rule === null) throw new Error(`QuestionComposer.module.css has no \`${name}\` keyframes`) + return split(rule[1]) +} + +describe('QuestionComposer long-copy layout', () => { + it('reads question overflow against three lines and keeps two once it overflows', () => { + expect(declarations('.questionTitle')).toEqual(expect.arrayContaining(['flex-shrink: 0', 'timeline-scope: --question-copy'])) + expect(declarations('.titlePreview')).toEqual(expect.arrayContaining([ + 'max-height: calc(3lh + 2px)', + 'margin: 0 0 -2px', + 'padding-bottom: 2px', + 'overflow: hidden', + 'scroll-timeline: --question-copy block', + 'animation-name: question-clipped', + 'animation-fill-mode: both', + 'animation-timeline: --question-copy', + ])) + expect(keyframes('question-clipped')).toEqual(expect.arrayContaining([ + 'margin-bottom: calc(-1lh - 2px)', + 'clip-path: inset(0 0 calc(1lh + 2px) 0)', + 'mask-size: 100% 1lh, 100% 1lh', + 'mask-position: 0 0, 0 1lh', + ])) + expect(declarations('.questionToggle')).toEqual(expect.arrayContaining([ + 'animation-name: copy-toggle-shown', + 'animation-timeline: --question-copy', + ])) + }) + + it('reads description overflow against two lines and keeps one once it overflows', () => { + expect(declarations('.optionRow')).toContain('timeline-scope: --description-copy') + expect(declarations('.description')).toEqual(expect.arrayContaining([ + 'display: block', + 'width: max-content', + 'max-width: 100%', + 'max-height: calc(2lh + 2px)', + 'margin-bottom: -2px', + 'padding-bottom: 2px', + 'overflow: hidden', + 'font-size: 13px', + 'line-height: 20px', + 'scroll-timeline: --description-copy block', + 'animation-name: description-clipped', + 'animation-fill-mode: both', + 'animation-timeline: --description-copy', + ])) + expect(keyframes('description-clipped')).toEqual(expect.arrayContaining([ + 'margin-bottom: calc(-1lh - 2px)', + 'clip-path: inset(0 0 calc(1lh + 2px) 0)', + 'mask-size: 100% 1lh', + ])) + expect(declarations('.descriptionToggle')).toEqual(expect.arrayContaining([ + 'animation-name: copy-toggle-shown', + 'animation-timeline: --description-copy', + ])) + }) + + it('shows a toggle only through the overflow timeline or an expansion', () => { + expect(declarations('.copyToggle')).toContain('visibility: hidden') + expect(keyframes('copy-toggle-shown')).toEqual(['visibility: visible']) + expect(declarations('.questionTitle[data-expanded] .titlePreview,\n.optionRow[data-expanded] .description')) + .toEqual(['max-height: none', 'animation: none']) + expect(declarations('.questionTitle[data-expanded] .copyToggle,\n.optionRow[data-expanded] .copyToggle')) + .toEqual(['visibility: visible', 'animation: none']) + }) + + it('names timelines through longhands that the bundled CSS keeps apart', () => { + // lightningcss merges `animation` with `animation-timeline` into a shorthand browsers reject. + const timed = [...stylesheet.matchAll(/\{([^{}]*animation-timeline[^{}]*)\}/g)].map(match => match[1] ?? '') + expect(timed).toHaveLength(4) + for (const body of timed) expect(split(body).some(part => part.startsWith('animation:'))).toBe(false) + }) + + it('shows all copy where scroll timelines are unsupported', () => { + const fallback = /@supports not \(animation-timeline: scroll\(\)\)\s*\{([\s\S]*?)\n\}/.exec(stylesheet)?.[1] ?? '' + expect(fallback).toMatch(/\.titlePreview,\s*\.description\s*\{\s*max-height: none;\s*animation: none;\s*\}/) + expect(fallback).toMatch(/\.copyToggle\s*\{\s*display: none;\s*\}/) + }) + + it('keeps the question and choices in the card scrollport', () => { + expect(declarations('.body')).toEqual(expect.arrayContaining([ + 'min-height: 0', + 'overflow-y: auto', + ])) + expect(declarations('.questionTitle')).toContain('flex-shrink: 0') + }) +}) diff --git a/packages/client/ui-user-questions/tests/user-questions-composer.client.spec.tsx b/packages/client/ui-user-questions/tests/user-questions-composer.client.spec.tsx index 9b4dd8e69d..4b46adcbd0 100644 --- a/packages/client/ui-user-questions/tests/user-questions-composer.client.spec.tsx +++ b/packages/client/ui-user-questions/tests/user-questions-composer.client.spec.tsx @@ -24,7 +24,13 @@ afterEach(cleanup) const SID = 's1' as SessionId const seatOver = (dict: Record, common: Record): QuestionComposerProps['t'] => - (key => dict[key] ?? common[key] ?? key) + (key, params) => { + const template = dict[key] ?? common[key] ?? key + return params === undefined + ? template + : template.replace(/\{(\w+)\}/g, (match, name: string) => + name in params ? String(params[name]) : match) + } type SessionState = Parameters[0]>[0] type ConversationState = Parameters[0]>[0] @@ -258,6 +264,7 @@ describe('QuestionComposer', () => { const detail = screen.getByText('按当前空缺岗位的优先级选择。') const scrollRegion = detail.closest('[data-question-scroll]') expect(scrollRegion).toBeTruthy() + expect(scrollRegion?.contains(screen.getByRole('heading', { name: '选择候选人类型' }))).toBe(true) expect(scrollRegion?.contains(screen.getByRole('radio', { name: /工程落地型/ }))).toBe(true) expect(scrollRegion?.contains(screen.getByText('下一题').closest('button'))).toBe(false) fireEvent.click(screen.getByRole('radio', { name: /工程落地型/ })) @@ -294,6 +301,104 @@ describe('QuestionComposer', () => { expect(screen.getByRole('button', { name: '正在提交…' }).disabled).toBe(true) }) + it('expands long copy in place without choosing an option', () => { + const description = 'Remove the experimental client from every shipped template and require an explicit profile install.' + const { carrier } = wait([{ + id: 'release', question: 'How should the release graph change?', + options: [ + { label: 'Explicit install', description }, + { label: 'Optional bundle' }, + { label: 'Empty description', description: '' }, + { label: 'Whitespace description', description: ' ' }, + ], + }]) + render() + + // CSS shows each toggle only while its copy overflows; every candidate is rendered. + const heading = screen.getByRole('heading', { name: 'How should the release graph change?' }) + const [questionToggle, descriptionToggle, ...rest] = screen.getAllByRole('button', { name: '展开' }) + expect(rest).toHaveLength(0) + if (questionToggle === undefined || descriptionToggle === undefined) throw new Error('Both copy toggles must render') + expect(questionToggle.getAttribute('aria-describedby')).toBe(heading.id) + expect(questionToggle.getAttribute('aria-controls')).toBe(heading.id) + const option = screen.getByRole('radio', { name: 'Explicit install' }) + const text = screen.getByText(description) + expect(option.getAttribute('aria-describedby')).toBe(text.id) + expect(descriptionToggle.getAttribute('aria-controls')).toBe(text.id) + expect(descriptionToggle.getAttribute('aria-describedby')).toBe(text.id) + expect(descriptionToggle.getAttribute('aria-expanded')).toBe('false') + + fireEvent.click(descriptionToggle) + expect(descriptionToggle.getAttribute('aria-expanded')).toBe('true') + expect(descriptionToggle.getAttribute('aria-label')).toBe('收起') + expect(text.closest('[data-option-row]')?.hasAttribute('data-expanded')).toBe(true) + expect(screen.getByText(description)).toBe(text) + expect(option.getAttribute('aria-checked')).toBe('false') + expect(questionToggle.getAttribute('aria-expanded')).toBe('false') + + fireEvent.click(questionToggle) + expect(questionToggle.getAttribute('aria-expanded')).toBe('true') + expect(questionToggle.getAttribute('aria-label')).toBe('收起') + expect(heading.parentElement?.hasAttribute('data-expanded')).toBe(true) + fireEvent.click(questionToggle) + fireEvent.click(descriptionToggle) + expect(heading.parentElement?.hasAttribute('data-expanded')).toBe(false) + expect(text.closest('[data-option-row]')?.hasAttribute('data-expanded')).toBe(false) + expect(screen.getAllByRole('button', { name: '展开' })).toEqual([questionToggle, descriptionToggle]) + }) + + it('resets scrolling and expanded copy when the question changes', () => { + const firstDescription = 'First question detail that was expanded.' + const secondDescription = 'Second question detail starts collapsed.' + const { carrier } = wait([ + { + id: 'first', question: 'First question?', + options: [{ label: 'Continue', description: firstDescription }], + }, + { + id: 'second', question: 'Second question?', + options: [{ label: 'Continue', description: secondDescription }], + }, + ]) + render() + + expect(screen.getByRole('region', { name: 'First question?' })).toBeTruthy() + const firstScroll = screen.getByText('First question?').closest('[data-question-scroll]') as HTMLElement + firstScroll.scrollTop = 80 + for (const toggle of screen.getAllByRole('button', { name: '展开' })) fireEvent.click(toggle) + expect(screen.getAllByRole('button', { name: '收起' }).map(toggle => toggle.getAttribute('aria-expanded'))).toEqual(['true', 'true']) + fireEvent.click(screen.getByRole('radio', { name: 'Continue' })) + + const secondScroll = screen.getByText('Second question?').closest('[data-question-scroll]') as HTMLElement + expect(screen.queryByRole('region', { name: 'First question?' })).toBeNull() + expect(screen.getByRole('region', { name: 'Second question?' })).toBeTruthy() + expect(secondScroll).toBe(firstScroll) + expect(secondScroll.scrollTop).toBe(0) + expect(screen.queryByRole('button', { name: '收起' })).toBeNull() + expect(secondScroll.querySelector('[data-expanded]')).toBeNull() + expect(screen.queryByText(firstDescription)).toBeNull() + const descriptionToggle = screen.getAllByRole('button', { name: '展开' })[1] + expect(document.getElementById(descriptionToggle?.getAttribute('aria-controls') ?? '')?.textContent).toBe(secondDescription) + }) + + it('keeps expansions with their question when the shared position moves elsewhere', () => { + const { carrier } = wait([ + { id: 'first', question: 'First question?', options: [{ label: 'Continue', description: 'First detail.' }] }, + { id: 'second', question: 'Second question?', options: [{ label: 'Continue', description: 'Second detail.' }] }, + ]) + render() + fireEvent.click(screen.getAllByRole('button', { name: '展开' })[1] as HTMLElement) + expect(screen.getByRole('button', { name: '收起' })).toBeTruthy() + + // Another surface of the same Session moves the shared question position. + const blank = { selected: [], custom: '', skipped: false } + act(() => { kit.actions.replace(carrier.key, { index: 1, drafts: [blank, blank] }) }) + + expect(screen.getByRole('region', { name: 'Second question?' })).toBeTruthy() + expect(screen.queryByRole('button', { name: '收起' })).toBeNull() + expect(document.querySelector('[data-expanded]')).toBeNull() + }) + it('renders plan detail through the shared assistant Markdown primitive', () => { const { carrier } = wait([{ id: 'plan', @@ -864,6 +969,8 @@ describe('review card', () => { const chosen = screen.getByRole('radio', { name: '工程落地型' }) expect(chosen.getAttribute('aria-checked')).toBe('true') expect(chosen.disabled).toBe(true) + // Long copy stays readable on a frozen card. + expect(screen.getAllByRole('button', { name: '展开' }).map(toggle => toggle.disabled)).toEqual([false, false, false]) // Nothing is left to send, and the unused free-text field would read as // somewhere to type. expect(screen.queryByRole('button', { name: '跳过' })).toBeNull() @@ -938,3 +1045,25 @@ describe('review card', () => { expect(screen.getByText(en['review.skipped'])).toBeTruthy() }) }) + +it('retains custom-answer focus across Enter navigation between option questions', () => { + const { carrier } = wait([ + { id: 'one', question: 'First?', options: [{ label: 'One', description: 'First description' }] }, + { id: 'two', question: 'Second?', options: [{ label: 'Two' }] }, + ]) + render() + const choice = screen.getByRole('radio', { name: 'One' }) + expect(choice.getAttribute('aria-describedby')).toBeTruthy() + expect(document.getElementById(choice.getAttribute('aria-describedby') as string)?.textContent).toBe('First description') + const first = screen.getByRole('textbox') + act(() => { first.focus() }) + fireEvent.change(first, { target: { value: 'first answer' } }) + fireEvent.keyDown(first, { key: 'Enter' }) + expect(screen.getByRole('heading', { name: 'Second?' })).toBeTruthy() + const second = screen.getByRole('textbox') + expect(second).toBe(first) + expect(document.activeElement).toBe(second) + fireEvent.change(second, { target: { value: 'second answer' } }) + expect((second as HTMLTextAreaElement).value).toBe('second answer') + expect(screen.getByRole('radio', { name: 'Two' }).getAttribute('aria-describedby')).toBeNull() +}) diff --git a/packages/client/ui-workspace/README.i18n.yaml b/packages/client/ui-workspace/README.i18n.yaml index fb827521a7..34d19ae2bd 100644 --- a/packages/client/ui-workspace/README.i18n.yaml +++ b/packages/client/ui-workspace/README.i18n.yaml @@ -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 diff --git a/packages/client/ui-workspace/README.md b/packages/client/ui-workspace/README.md index 5849927e52..bc696e0520 100644 --- a/packages/client/ui-workspace/README.md +++ b/packages/client/ui-workspace/README.md @@ -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). ## Understand the implementation diff --git a/packages/client/ui-workspace/README.zh.md b/packages/client/ui-workspace/README.zh.md index a067dd0788..1c714bb93d 100644 --- a/packages/client/ui-workspace/README.zh.md +++ b/packages/client/ui-workspace/README.zh.md @@ -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)。 ## 理解实现 diff --git a/packages/client/ui-workspace/src/client/navigation.ts b/packages/client/ui-workspace/src/client/navigation.ts index 55e03054ce..b4db2dd32e 100644 --- a/packages/client/ui-workspace/src/client/navigation.ts +++ b/packages/client/ui-workspace/src/client/navigation.ts @@ -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 + /** + * 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 => { diff --git a/packages/client/ui-workspace/tests/apply.client.spec.ts b/packages/client/ui-workspace/tests/apply.client.spec.ts index eade6885ff..bf5acfea6b 100644 --- a/packages/client/ui-workspace/tests/apply.client.spec.ts +++ b/packages/client/ui-workspace/tests/apply.client.spec.ts @@ -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 = createSnapshotStore(true) -async function bench() { +async function bench(access: 'fake' | 'static' = 'fake') { const ctx = new Context() const commands = new Map() 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 => 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 () => { diff --git a/packages/experimental/client-ui-voice-input/README.i18n.yaml b/packages/experimental/client-ui-voice-input/README.i18n.yaml index c906ffc9c7..6aa37a6ef0 100644 --- a/packages/experimental/client-ui-voice-input/README.i18n.yaml +++ b/packages/experimental/client-ui-voice-input/README.i18n.yaml @@ -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 diff --git a/packages/experimental/client-ui-voice-input/README.md b/packages/experimental/client-ui-voice-input/README.md index 3eba1c3825..2aa4fdd707 100644 --- a/packages/experimental/client-ui-voice-input/README.md +++ b/packages/experimental/client-ui-voice-input/README.md @@ -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. + ----- diff --git a/packages/experimental/client-ui-voice-input/README.zh.md b/packages/experimental/client-ui-voice-input/README.zh.md index b7e9164a02..3dadc0d92d 100644 --- a/packages/experimental/client-ui-voice-input/README.zh.md +++ b/packages/experimental/client-ui-voice-input/README.zh.md @@ -31,6 +31,8 @@ kind: "package-reference" 下载或重试前,**模型下载源**提供自动选择及 Host 公布的源。SenseVoice 默认提供 Hugging Face 和 HF-Mirror;固定私有部署仅显示配置的源。手动选择只使用指定源,自动选择沿用提供方的比较与回退策略。当前卡片中的重试会保留选择,不修改部署配置或识别偏好。准备期间不能改源,资源就绪后隐藏源选择器。 +`pluginNavigation` 为可选服务:没有它时插件照常启动,设置对话框只在该服务存在时提供详情按钮。 + ----- diff --git a/packages/experimental/client-ui-voice-input/src/client/VoiceInput.tsx b/packages/experimental/client-ui-voice-input/src/client/VoiceInput.tsx index 7b60ca9b7e..a396f1d7ab 100644 --- a/packages/experimental/client-ui-voice-input/src/client/VoiceInput.tsx +++ b/packages/experimental/client-ui-voice-input/src/client/VoiceInput.tsx @@ -31,7 +31,12 @@ export interface VoiceInputActions { /** Entry-injected Host readiness and microphone operations. */ export interface VoiceInputInjected extends VoiceInputActions { - hooks: { speechReadiness: HostObservable; microphoneDevice: HostObservable } + hooks: { + speechReadiness: HostObservable + microphoneDevice: HostObservable + /** Whether the Plugins page navigation service is present; the setup dialog offers the details only while it is. */ + pluginNavigation: HostObservable + } } /** Composer-owned expansion and draft actions; preferences stay in plugin settings. */ @@ -58,8 +63,9 @@ async function disposeRecording(capture: Recording): Promise { /** 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, { setSetupOpen(false) }} onOpenDetails={() => { setSetupOpen(false); openSettings() }} t={t} /> + onDismiss={() => { setSetupOpen(false) }} + onOpenDetails={navigable ? () => { setSetupOpen(false); openSettings() } : undefined} t={t} /> return
- + {onOpenDetails !== undefined && } }>

{t(needsInstallation ? 'setupPrompt.body' : 'setupPrompt.unavailableBody')}

diff --git a/packages/experimental/client-ui-voice-input/src/client/mount.ts b/packages/experimental/client-ui-voice-input/src/client/mount.ts index d79d281def..bfe499f91b 100644 --- a/packages/experimental/client-ui-voice-input/src/client/mount.ts +++ b/packages/experimental/client-ui-voice-input/src/client/mount.ts @@ -6,6 +6,7 @@ import type {} from '@deepseek-ai/dsh-client-locale/client' import type {} from '@deepseek-ai/dsh-client-ui-renderer/client' import type {} from '@deepseek-ai/dsh-client-ui-conversation/client' import type { TypertRemoteContribution } from '@deepseek-ai/dsh-typert-protocol' +import { createSnapshotStore } from '@deepseek-ai/dsh-client-store' import { VoiceInput, type VoiceInputInjected } from './VoiceInput.tsx' import { Recording } from './audio.ts' import { createMicrophoneDeviceStore } from './microphone-device.ts' @@ -15,7 +16,7 @@ import { observeReadiness } from './readiness.ts' import { VoicePreparation } from './PreparationCard.tsx' import { VoiceSetupPrompt } from './VoiceSetupPrompt.tsx' -export const inject = ['remote', 'slots', 'locale', 'pluginNavigation'] +export const inject = ['remote', 'slots', 'locale'] function registerUi(ctx: Context): void { ctx.effect(() => ctx.locale.register(NS, { zh, en, ja })) @@ -24,9 +25,17 @@ function registerUi(ctx: Context): void { const readiness = observeReadiness(ctx) ctx.effect(() => readiness.dispose) ctx.effect(() => async () => { await Promise.all([...recordings].map(recording => recording.dispose())) }) + // 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) } + }, 'voice-input: plugin navigation presence') + }) const actions: VoiceInputInjected = { - openSettings: () => { ctx.pluginNavigation.openBundle('@deepseek-ai/dsh-experimental-voice-input-bundle') }, - hooks: { speechReadiness: readiness.state, microphoneDevice: microphone }, + openSettings: () => { ctx.get('pluginNavigation')?.openBundle('@deepseek-ai/dsh-experimental-voice-input-bundle') }, + hooks: { speechReadiness: readiness.state, microphoneDevice: microphone, pluginNavigation }, selectMicrophone: (device) => { microphone.set(device) }, createRecording: () => { const recording = new Recording(() => { recordings.delete(recording) }, microphone.getSnapshot().id) @@ -62,7 +71,7 @@ function registerUi(ctx: Context): void { */ export async function mountVoiceInput(ctx: Context, contribution: TypertRemoteContribution): Promise<() => Promise> { const disposeRemote = await ctx.remote.$mount(contribution) - const ui = ctx.inject(['remote.speech', 'slots', 'locale', 'pluginNavigation'], registerUi) + const ui = ctx.inject(['remote.speech', 'slots', 'locale'], registerUi) try { await ui } catch (error) { await ui.dispose(); await disposeRemote(); throw error } return async () => { await ui.dispose(); await disposeRemote() } } diff --git a/packages/experimental/client-ui-voice-input/tests/mount.client.spec.ts b/packages/experimental/client-ui-voice-input/tests/mount.client.spec.ts index 3287f92a1f..a328473da6 100644 --- a/packages/experimental/client-ui-voice-input/tests/mount.client.spec.ts +++ b/packages/experimental/client-ui-voice-input/tests/mount.client.spec.ts @@ -34,7 +34,7 @@ function assertVoiceActions(value: Record): asserts value is Re assert(typeof value.hooks === 'object' && value.hooks !== null) } -async function fixture(fail = false) { +async function fixture(fail = false, navigation = true) { const ctx = new Context(), unmount = vi.fn(async () => {}) class Remote extends Service { constructor() { super(ctx, 'remote') } @@ -45,7 +45,7 @@ async function fixture(fail = false) { } new Remote() const openBundle = vi.fn() - ctx.provide('pluginNavigation', { openBundle }) + if (navigation) ctx.provide('pluginNavigation', { openBundle }) const configure = vi.fn(async () => ({ ok: true, value: {} })) const prepare = vi.fn(async () => ({ ok: true, value: {} })) const cancelPreparation = vi.fn(async () => ({ ok: true, value: {} })) @@ -63,6 +63,28 @@ async function fixture(fail = false) { return { ctx, unmount, openBundle, configure, prepare, cancelPreparation, transcribe } } +it('starts without the Plugins page navigation and follows its presence', async () => { + hostApply() + const b = await fixture(false, false) + try { + await b.ctx.plugin({ inject: [...inject], apply: ctx => mountVoiceInput(ctx, REMOTE) }) + const entry = b.ctx.slots.entries('conversation.input.activity').find(item => item.component === VoiceInput) + const actions = entry!.inject!() + assertVoiceActions(actions) + expect(actions.hooks.pluginNavigation.getSnapshot()).toBe(false) + expect(() => { actions.openSettings() }).not.toThrow() + const navigation = b.ctx.plugin({ apply: (ctx: Context) => { ctx.provide('pluginNavigation', { openBundle: b.openBundle } as never) } }) + await navigation + await vi.waitFor(() => { expect(actions.hooks.pluginNavigation.getSnapshot()).toBe(true) }) + actions.openSettings() + expect(b.openBundle).toHaveBeenCalledWith('@deepseek-ai/dsh-experimental-voice-input-bundle') + await navigation.dispose() + expect(actions.hooks.pluginNavigation.getSnapshot()).toBe(false) + } finally { + await b.ctx.fiber.dispose() + } +}) + it('withdraws its Remote, localized slot and microphone captures on disposal', async () => { hostApply() const b = await fixture() diff --git a/packages/experimental/client-ui-voice-input/tests/voice-input.client.spec.tsx b/packages/experimental/client-ui-voice-input/tests/voice-input.client.spec.tsx index 2dd0f150cf..b0e23ff464 100644 --- a/packages/experimental/client-ui-voice-input/tests/voice-input.client.spec.tsx +++ b/packages/experimental/client-ui-voice-input/tests/voice-input.client.spec.tsx @@ -18,7 +18,7 @@ beforeEach(() => { vi.stubGlobal('requestAnimationFrame', vi.fn(() => 1)); vi.st afterEach(() => { cleanup(); vi.useRealTimers(); vi.unstubAllGlobals() }) const id = 'sensevoice-local' as SpeechProviderId const transcript: Transcript = { text: '检查 TypeScript 类型', audioSeconds: 2, inferenceSeconds: 0.4 } -function fixture(recording?: Recording) { +function fixture(recording?: Recording, navigable = true) { const capture = Object.assign(new Recording(() => {}), { start: vi.fn(async () => {}), stop: vi.fn(async () => new Uint8Array(48)), amplitude: () => 0, dispose: vi.fn(async () => {}) }) const inputActions = { notify: vi.fn(), insertQuote: () => false, @@ -35,6 +35,7 @@ function fixture(recording?: Recording) { openSettings: vi.fn(), prepare: vi.fn(async () => {}), cancelPreparation: vi.fn(async () => {}), configure: vi.fn(async () => {}), selectMicrophone: vi.fn(), useMicrophoneDevice: bindSnapshotSelector(createSnapshotStore({ id: '', label: '' })), useSpeechReadiness: bindSnapshotSelector(readiness), createRecording: () => recording ?? capture, + usePluginNavigation: bindSnapshotSelector(createSnapshotStore(navigable)), t: makeTranslate(zh, commonZh) } const view = render() return { props, capture, inputActions, readiness, transcribe, view } @@ -335,6 +336,18 @@ it.each([ expect(b.props.openSettings).toHaveBeenCalledOnce() }) +it('offers no plugin details from setup guidance while the Plugins page navigation is absent', () => { + const b = fixture(undefined, false), state = b.readiness.getSnapshot() + act(() => { b.readiness.set({ ...state, catalog: { ...state.catalog!, providers: [ + { ...state.catalog!.providers[0]!, preparation: { phase: 'unprepared' } }, + ] } }) }) + fireEvent.click(screen.getByRole('button', { name: zh['setupPrompt.trigger'] })) + const dialog = within(screen.getByRole('dialog')) + expect(dialog.queryByRole('button', { name: zh['setupPrompt.open'] })).toBeNull() + expect(dialog.getByRole('button', { name: zh['setupPrompt.later'] })).toBeTruthy() + expect(b.props.openSettings).not.toHaveBeenCalled() +}) + it('moves focus into setup guidance, contains Tab traversal, and restores the draft focus on dismissal', () => { const b = fixture() render(