Merge branch 'master' into fix/4728-macos-localizations

This commit is contained in:
Christina Lee authored and GitHub committed 2026-09-20 20:56:39 +08:00
commit ea05e57b60
70 files changed
+857 -228

No files matched your search

@@ -0,0 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-09-20-session-open-read-coordination.md
2026-09-20-session-open-read-coordination.md: a4907df1d7dd38e4c532c38fb6ff10362a4a3898
2026-09-20-session-open-read-coordination.zh.md: 759a45d8c52a62ea7a0b30ceed4003f8804a42c3
@@ -0,0 +1,88 @@
# Agent Note: Session opening cache identity and read ordering
Status: implemented
English | [中文](2026-09-20-session-open-read-coordination.zh.md)
## Problem
Opening a historical Session can make `session.follow`, composer catalog prewarming, and RPCs with Agent parameters access the same cold log concurrently. `skills/list` requests a complete observation itself; `commands/list`, `goals/get`, `fileReferences/list`, and `sessionReferenceResolver/candidates` can initiate resume through the Agent lookup during parameter resolution. Auxiliary requests that do not wait for the history opening snapshot compete with the main conversation for reading, parsing, and projection computation instead of reusing completed results.
Reuse of completed observations has a separate problem: Cordis can return different proxy objects for accesses to the same service. Comparing the object references returned by `ctx.get('sessionPersistence')` can mistake one underlying service for different instances. Removing instance identity checks entirely is also unsafe because persistence revisions are comparable only within the corresponding service instance and Session identity.
Subagent navigation combines two needs: displaying a conversation at a known address, and discovering its parent or descendant catalogs. Refreshing parent projections before navigating through an existing parent-child address adds prerequisite work and can reject an address that the Host could validate and open when the Client catalog cache is absent.
## Decision
### Identify the cache producer with a service-owned Symbol
`SessionPersistence.identity` is a `Symbol('sessionPersistence')` created once per service instance. Proxy access preserves that value, while a replacement service instance receives a different value. The prepared observation cache looks up the Session id and compares both `persistence.identity` and `stat().revision`; the same instance and revision can reuse a completed preparation.
This identity exists only within the process and does not enter Session headers, events, or persisted files. Existing cache capacity, leases, live precedence, and revision invalidation remain unchanged. This fix does not merge Promises for unfinished cold reads; stable identity prevents false invalidation after completion, not duplicate concurrent work across all callers.
### Auxiliary RPCs wait for the current conversation's initial open to succeed
The Client uses the existing `sessions.using()` at the actual RPC call site, rather than only delaying prewarm hooks. Menu candidates, explicit reference queries, and later catalog refreshes therefore use the same waiting entry point. Each operation first checks that its target already has a Client binding, so background prewarming does not reopen a closed Session.
| Client consumer | RPC after waiting | Temporary reference source |
| --- | --- | --- |
| Skill catalog | `skills/list` | `skillCatalog` |
| Command catalog | `commands/list` | `commandCatalog` |
| Goal activation state | `goals/get` | `goalActivation` |
| `@` file and Session candidates | `fileReferences/list`, `sessionReferenceResolver/candidates` | Shared `referenceCandidates` |
`sessions.using()` waits for `reference.ready`, then for the operation's returned Promise, and finally releases its reference. `ready` means that the initial `Session.open()` attempt has settled, not that it succeeded; these consumers also require `openState === 'open'` and send no auxiliary RPC after a failed open. The Goal reader additionally checks that its captured binding has not been replaced and logs rejected reads without clearing existing display state.
Skills retain the shared catalog request's own cancellation signal; `@` queries use the current candidate request's signal for the history wait and both RPCs. Unquoted `@` queries still fetch files and Sessions concurrently after waiting, while quoted paths still query files only. Commands and Goals gain no new cancellation protocol. These references retain Client data and follow streams, grant no new Host Agent authority, and do not prohibit normal Agent resume after the opening snapshot.
### Conversations use follow; catalog branches retain explicit reads
The main conversation's opening `follow` snapshot already carries that Session's complete projection baseline. Later changes to shared projection values notify subscribers, so switching Sessions or opening a menu does not require another read of the same baseline.
- Main-view navigation does not separately refresh the selected Session's projections; restoring a saved subagent address does not prewarm its parent.
- The Sidebar retains the target child directly from its complete parent-child address, without making a parent-catalog read a prerequisite for opening the conversation.
- The header dropdown's `changeOpen` only changes presentation state and does not refresh `rootSessionId`. Child expansion and failed-read retries retain explicit refreshes under the UI callback name `refreshProjection`; the underlying `sessions.refreshProjections` API is not renamed.
- Team navigation uses the existing Lead and roster member ids to open `{ parentSessionId, childSessionId, mode: 'continuable' }` directly. It neither refreshes the parent catalog nor requires the target to be present in the Client catalog; member-role and main-view ownership checks, plus the UI restrictions on provisioning and failed members, remain unchanged.
A known address does not waive authorization checks. When opening child history, the Host still checks the target's own header parent and origin, plus the mode and descriptor ownership in its own `subagent` projection. Later continuation remains subject to the existing direct-parent authorization rules.
## Implementation scope
- Session formats, migrations, the Host observation's `all | none` policy, and the response fields of `session.projections` are unchanged.
- There is no global cold-read singleflight, and Agent lookups are not universally replaced with read-only lookups.
- `SessionManager.handleConnected()` still refreshes previously requested catalogs in bulk; this decision does not guarantee one cold read on reconnect.
- Tool-side `listDescendants` can still read child Sessions on cache misses, and expanding a child node in the Client can still cold-read that node. Ordinary main-session opening is not equivalent to every descendant-enumeration scenario.
- `@` Session candidates still enumerate headers and obtain names from live projections or projection caches, falling back to ids on misses. Existing ranking, the default limit of 50, and direct-subagent grouping remain unchanged; discovery does not recursively traverse `subagentCatalog`.
## Alternatives considered
**Remove persistence-instance comparison.** Rejected. A matching Session id and revision cannot establish that two persistence services provide the same data; replacing a service must still invalidate old preparations.
**Unwrap Cordis proxies in callers or cache service objects per Context.** Rejected. The cache needs the producer's identity, not the identity of an object returned by a particular service access. A per-instance Symbol expresses that requirement without propagating proxy implementation details.
**Add shared singleflight for every cold read.** Outside this implementation. It could merge more concurrent calls, but would require separate rules for independent waiter cancellation, leases, and task ownership during service replacement. It also would not replace the ordering requirement that auxiliary RPCs must not resume an Agent ahead of the opening snapshot.
**Delay only warm hooks, or merely await the extra projection request.** Rejected. Explicit menu queries could still bypass warm hooks, and serializing a redundant projection request does not remove its work. The wait belongs at the actual auxiliary RPC entry point; reads already supplied by follow are removed.
**Keep Team's parent-catalog presence check.** Rejected. The Team roster already provides member identity, and the existing Host history entry point can validate an explicit address. Requiring a Client catalog cache hit for navigation mistakes data that has not loaded for a child that does not exist.
## Consequences
An ordinary main Session completes its own history and projection read before auxiliary operations reuse the completed observation or attached state. The Client's direct-child list consumes the main Session's catalog without reading each child's log merely to display its row. Catalog and conversation opening are separate operations, so navigating to a known child no longer adds parent-projection prewarming.
Auxiliary catalogs become available after the conversation's opening snapshot, and operations retain temporary Client references until they finish. This wait covers only the current Client generation's initial open, not readiness after every underlying follow replacement. Reconnect and exceptional retry scenarios still require their own verification.
A missing header root catalog remains a specific coverage gap: if it belongs to an unopened parent and no other source supplies the catalog, opening the dropdown alone does not fetch it, and the current UI may continue to display a loading notice. This implementation adds neither a new parent-catalog loading entry point nor a shared unknown-state interaction protocol.
## Verification and known gaps
During local navigation to a long historical main Session, the user observed repeated `readColdSessionLog` calls converge to one using temporary timers and call stacks, and verified direct teammate navigation. This result describes that operation's cold-read helper invocation count, not every underlying file I/O, every reconnect scenario, or a single unit of end-to-end cost. Private Session content and identities from diagnosis are not test fixtures.
Focused TypeScript builds and bundles were run for affected packages during development; the complete change has no gate, behavior-test, or assembled Web snapshot verification result. Existing tests have only partial callback renames and still contain old fixture names, root-menu-opening refresh expectations, Sidebar parent-refresh prerequisites, and Team rejection of missing catalogs. These are known verification gaps, not passing checks.
## Relationship to existing records
- [Session observations and projection-owned Client state](../architecture/2026-08-25-session-observations-and-projection-owned-client-state.md) retains ownership of read cuts and shared values. This record adds cache-producer identity and opening-snapshot consumption order; the older claim of sharing unfinished cold loads does not match the current reader and is not evidence that this change implements singleflight.
- [Client Session references, reference sources, and UI state](../architecture/2026-09-15-client-session-references.md) retains ownership of `retain`, `ready`, `using`, and explicit-address semantics. This record limits temporary references to four classes of auxiliary reads as independent asynchronous operations, rather than adding references to every UI action.
- [Web subagent catalogs consume shared projections](../simplification/2026-09-08-web-subagent-catalog-projections.md) retains ownership of the generic projection store, explicit catalog reads, and control updates. This implementation applies its rule that opening a conversation uses the follow baseline, without replacing the catalog mechanism.
- [Web subagent catalogs and human continuation](../feature/2026-07-27-web-subagent-conversations.md) retains ownership of presentation and continuation authorization. This record changes only prerequisite navigation reads and root-menu refresh timing, not the child's own Host validation; the older description of interactive loading for missing root catalogs must be reconciled with the coverage gap recorded here.
@@ -0,0 +1,88 @@
# Agent Note: 会话首帧读取的缓存身份与调用时序
Status: implemented
[English](2026-09-20-session-open-read-coordination.md) | 中文
## 问题
打开历史会话时,`session.follow`、输入框目录预热和依赖 Agent 参数的 RPC 可能同时访问同一份冷日志。`skills/list` 自行请求完整 observation;`commands/list`、`goals/get`、`fileReferences/list` 与 `sessionReferenceResolver/candidates` 则可以在参数解析阶段经 Agent lookup 发起恢复。辅助请求不等待历史首帧,会与主对话争用读取、解析和 projection 计算,而不是复用已经完成的结果。
完成后的 observation 复用还有一个独立问题:Cordis 对同一服务的访问可以返回不同代理对象。以 `ctx.get('sessionPersistence')` 返回对象的引用相等性判断服务身份,会把同一底层服务误判成另一个实例。另一方面,彻底去掉实例身份检查也不安全,因为持久化 revision 只在对应服务实例和 Session 身份内可比较。
子会话导航混合了两种需求:展示一个已知地址的对话,以及发现该会话的父目录或后代目录。已有父子地址的导航再次刷新 parent projection,既增加前置工作,也会在目录缓存缺席时拒绝一个本可由 Host 校验并打开的地址。
## 决策
### 用服务自身的 Symbol 标识缓存生产者
`SessionPersistence.identity` 是每个服务实例创建一次的 `Symbol('sessionPersistence')`。代理访问保留这个值,服务被替换后新实例获得不同值。Prepared observation cache 按 Session id 查找,并同时比较 `persistence.identity` 与 `stat().revision`;相同实例、相同 revision 可以复用已完成的 preparation。
该身份只存在于进程内,不进入 Session header、事件或持久化文件。现有缓存容量、lease、live 优先级和 revision 失效规则不变。这个修复不增加尚未完成的冷读取之间的 Promise 合并;稳定身份解决的是完成后的误失效,不是所有调用方的并发去重。
### 辅助 RPC 等待当前对话的首次打开成功
Client 在实际 RPC 发送位置使用既有 `sessions.using()`,不只延迟预热钩子。菜单候选、显式引用查询和后续目录刷新因而经过相同的等待入口。每个操作先确认目标已经有 Client binding,不因后台预热重新打开一个已经关闭的会话。
| Client 消费方 | 等待后的 RPC | 临时引用来源 |
| --- | --- | --- |
| Skill 目录 | `skills/list` | `skillCatalog` |
| Command 目录 | `commands/list` | `commandCatalog` |
| Goal 激活状态 | `goals/get` | `goalActivation` |
| `@` 文件与会话候选 | `fileReferences/list`、`sessionReferenceResolver/candidates` | 共用 `referenceCandidates` |
`sessions.using()` 先等待 `reference.ready`,再等待操作返回的 Promise,最后释放自己的引用。`ready` 表示首次 `Session.open()` 尝试已结算,不保证成功;这些消费方还检查 `openState === 'open'`,失败时不发送辅助 RPC。Goal 读方还确认捕获的 binding 未被替代,并记录读取拒绝而不清除已有展示状态。
Skill 保留共享目录请求自己的取消信号;`@` 查询将当前候选请求的信号用于历史等待和两个 RPC。未加引号的 `@` 在等待后仍并行查询文件和会话,加引号的路径仍只查询文件。Command 与 Goal 不新增取消协议。这里的引用持有 Client 数据和跟随流,不赋予 Host Agent 新权限,也不禁止首帧之后发生正常 Agent 恢复。
### 对话使用 follow,目录分支保留显式读取
主对话的 `follow` 首帧已经携带该 Session 的完整 projection baseline。后续共享 projection 值变化会通知订阅者,不需要在切换会话或打开菜单时再次读取相同 baseline。
- 主视图切换不再额外刷新所选 Session 的 projection;恢复保存的子会话地址不再预先刷新其 parent。
- 侧栏从完整父子地址直接保留目标子会话,不把父目录读取作为打开对话的前提。
- Header 下拉菜单的 `changeOpen` 只改变展示状态,不刷新 `rootSessionId`。展开子节点和失败重试仍调用显式刷新,UI 回调命名为 `refreshProjection`;底层 `sessions.refreshProjections` API 不改名。
- Team 点击使用已有 lead 与 roster 成员 id,直接打开 `{ parentSessionId, childSessionId, mode: 'continuable' }`。它不刷新 parent catalog,也不要求目标预先出现在 Client catalog 中;成员角色、主视图持有检查及 UI 对 provisioning、failed 成员的限制保持不变。
已知地址不等于免除授权校验。Host 在打开子会话历史时,仍检查目标自身 header 的 parent、origin,以及自身 `subagent` projection 的 mode 和描述符归属。后续 continuation 仍受直接 parent 的既有授权规则约束。
## 实现边界
- 没有修改 Session 格式、迁移、Host observation 的 `all | none` 策略或 `session.projections` 返回字段。
- 没有实现全局 cold-read singleflight,也没有把所有 Agent lookup 改成只读 lookup。
- 没有删除 `SessionManager.handleConnected()` 对此前请求目录的批量刷新;本决策不保证重连只有一次冷读。
- 工具侧 `listDescendants` 的缓存缺失仍可能读取子会话;前端展开一个子节点也仍可能冷读该节点。普通主会话首开不等于所有后代枚举场景。
- `@` 会话候选仍枚举 header,并从 live projection 或 projection cache 取名称;缓存缺失回退到 id。原有排序、默认 50 条上限和直接 subagent 分组规则不变,没有改成沿 `subagentCatalog` 递归发现。
## 考虑过的替代方案
**取消 persistence 实例比较。** 不采用。同一个 Session id 和 revision 不能证明两个持久化服务提供同一份数据;服务替换仍必须使旧 preparation 失效。
**在调用方解包 Cordis 代理或按 Context 缓存服务对象。** 不采用。缓存需要的是生产者身份,而不是某次服务访问返回对象的身份;每实例 Symbol 足够表达这个要求,不需要传播代理实现细节。
**为所有冷读增加统一 singleflight。** 未纳入本次实现。它可以合并更多并发调用,但需要另外定义独立等待方取消、lease 和服务替换期间的任务归属;它也不能代替“辅助 RPC 不抢在首帧前恢复 Agent”的时序要求。
**只延迟 warm,或只给额外 projection 请求补 await。** 不采用。显式菜单查询仍能绕过 warm;串行执行一个重复 projection 请求也没有删除重复工作。等待放在实际辅助 RPC 入口,已有 follow 能提供的读取直接取消。
**保留 Team 的父目录存在性检查。** 不采用。Team roster 已给出成员身份,显式地址可以由现有 Host 历史入口验证;把 Client catalog 缓存命中作为导航前提,会把数据尚未加载误当成子会话不存在。
## 后果
普通主会话先完成自己的历史和 projection 读取,辅助操作再复用已完成的 observation 或已挂载状态。前端直接子代列表消费主会话的 catalog,不为显示每一行读取对应子会话正文。目录与对话的打开入口分开,导航到一个已知子会话不再附带 parent projection 预热。
代价是辅助目录首次可用时间晚于对话首帧;操作还会持有临时 Client 引用直到结束。该等待只对应当前 Client generation 的首次打开,不是每次底层 follow 重建的就绪屏障。所有重连和异常重试场景仍需各自验证。
Header 根目录缺失仍是明确的覆盖缺口:若它属于未打开的 parent,且没有其他来源提供 catalog,仅打开下拉菜单不会补读;现有 UI 仍可能显示加载提示。本次没有增加新的父目录加载入口,也没有实现统一的 unknown 交互协议。
## 验证与已知缺口
用户在本地长历史主会话切换流程中,通过临时 `readColdSessionLog` 计时和调用栈观察到重复调用收敛为一次,并验证了 teammate 直接导航。这个结果只描述该操作的冷读辅助函数调用次数,不代表所有底层文件 I/O、全部重连场景或端到端性能都只有一次成本。诊断期间的私有 Session 内容和身份不作为测试 fixture。
开发期对相关包执行过定向 TypeScript 构建与打包;尚无这批完整变更的门禁、行为测试或组装 Web 快照验证结果。现有测试只完成部分回调改名,仍有旧 fixture 名称、打开根菜单即刷新、侧栏先刷新 parent,以及 Team 拒绝缺失 catalog 的预期需要调整。这些是已知验证缺口,不是已经通过的检查。
## 与既有记录的关系
- [Session observation 与 projection 所有的客户端状态](../architecture/2026-08-25-session-observations-and-projection-owned-client-state.zh.md)继续拥有读取切面和共享值的职责划分。本记录补充缓存生产者身份与首帧消费顺序;旧记录中“包括未完成冷加载的共享”描述与当前 reader 实现不一致,不能作为本次 singleflight 已实现的依据。
- [Client Session 引用、引用来源与 UI 状态](../architecture/2026-09-15-client-session-references.zh.md)继续拥有 `retain`、`ready`、`using` 与显式地址语义。本记录限定四类辅助读取作为独立异步操作持有临时引用,不把额外引用扩展到所有 UI 动作。
- [Web 子代理目录消费共享 projection](../simplification/2026-09-08-web-subagent-catalog-projections.zh.md)继续拥有通用 projection store、显式目录读取和 control 更新。本次实现落实“打开会话使用 follow baseline”的规则,不替换整个目录机制。
- [Web subagent 目录与用户继续交互](../feature/2026-07-27-web-subagent-conversations.zh.md)继续拥有展示和 continuation 授权。本记录只调整导航前置读取及根菜单刷新时机,不取消子会话自身的 Host 校验;旧记录对缺失根目录交互加载的描述需与这里的覆盖缺口一起核对。
@@ -26,3 +26,4 @@
- switch "Developer tools"
- text: Send behavior while busy What Enter and the Send button do while the agent is running; Cmd/Ctrl+Enter uses the other behavior
- button "Queue"
- text: "Current version: {{version}}"
@@ -26,3 +26,4 @@
- switch "Developer tools"
- text: Send behavior while busy What Enter and the Send button do while the agent is running; Cmd/Ctrl+Enter uses the other behavior
- button "Queue"
- text: "Current version: {{version}}"
@@ -26,3 +26,4 @@
- switch "开发者工具"
- text: 繁忙时的发送行为 智能体运行时 Enter 键和发送按钮的行为;Cmd/Ctrl+Enter 使用另一行为
- button "排队发送"
- text: 当前版本:{{version}}
+8 -3
View File
@@ -29,6 +29,8 @@ const PLUGIN_INSTANCES_EXPECTED = join(SNAPSHOT_DIR, 'plugin-instances.expected.
const DIALOG_EN_EXPECTED = join(SNAPSHOT_DIR, 'dialog-en.expected.md')
const PLUGIN_ROW_SELECTOR = '[data-plugin-scope="preset"] [data-plugin-entry="tool-subagent"]'
const MODE = webSnapshotMode()
const { version } = JSON.parse(await readFile(new URL('../../../package.json', import.meta.url), 'utf8')) as { version: string }
const versionCapture = { replacements: [[version, '{{version}}']] as const }
describe('web e2e: settings modal and General preferences', () => {
let scaffold: WebScaffold
@@ -93,8 +95,9 @@ describe('web e2e: settings modal and General preferences', () => {
await expect.poll(() => openRequests, { timeout: 5_000 }).toBe(1)
await expect.poll(() => openDocument.isEnabled(), { timeout: 5_000 }).toBe(true)
await page.unroute('**/api/settings/openSettingsDocument')
await dialog.getByText(`当前版本:${version}`, { exact: true }).waitFor()
// Golden of the freshly opened dialog (default zh, General active).
const snapshot = await captureStableAria(page, '[role="dialog"]', scaffold.workspaceCwd)
const snapshot = await captureStableAria(page, '[role="dialog"]', scaffold.workspaceCwd, versionCapture)
await compareOrRefreshGolden(DIALOG_EXPECTED, snapshot, MODE)
// Section switch: aria-current moves (the Models page itself has its own scenario file).
await dialog.getByRole('button', { name: '模型' }).click()
@@ -647,6 +650,7 @@ describe('web e2e: settings modal and General preferences', () => {
// ...and the attribute follows that switch, in the assembled app.
await expect.poll(() => page.evaluate(() => document.documentElement.lang), { timeout: 5_000 }).toBe('en')
expect(await enDialog.getByRole('button', { name: 'General' }).getAttribute('aria-current')).toBe('true')
await enDialog.getByText(`Current version: ${version}`, { exact: true }).waitFor()
await expect.poll(() => enDialog.getByText('Appearance', { exact: true }).count(), { timeout: 5_000 }).toBe(1)
expect(await page.evaluate(() => localStorage.getItem('dsh.locale'))).toBeNull()
await expect.poll(async () => readFile(join(scaffold.harnessHome, 'settings.yaml'), 'utf8'), { timeout: 5_000 })
@@ -756,7 +760,8 @@ describe('web e2e: settings modal and General preferences', () => {
// Golden of the English fallback dialog — the visible output this change
// produces. The zh golden above covers the detected-locale surface, so
// the pair pins both directions of the resolution.
const snapshot = await captureStableAria(frPage, '[role="dialog"]', fresh.workspaceCwd)
await dialog.getByText(`Current version: ${version}`, { exact: true }).waitFor()
const snapshot = await captureStableAria(frPage, '[role="dialog"]', fresh.workspaceCwd, versionCapture)
await compareOrRefreshGolden(DIALOG_EN_EXPECTED, snapshot, MODE)
expect(frTripwire.pageErrors).toEqual([])
expect(frTripwire.warnings).toEqual([])
@@ -780,7 +785,7 @@ describe('web e2e: settings modal and General preferences', () => {
const dialog = withoutBrowser.getByRole('dialog', { name: 'Settings' })
await dialog.getByRole('button', { name: 'Detailed', exact: true }).waitFor()
expect(await dialog.getByText('Open chat links in', { exact: true }).count()).toBe(0)
const snapshot = await captureStableAria(withoutBrowser, '[role="dialog"]', fresh.workspaceCwd)
const snapshot = await captureStableAria(withoutBrowser, '[role="dialog"]', fresh.workspaceCwd, versionCapture)
await compareOrRefreshGolden(join(SNAPSHOT_DIR, 'dialog-no-browser.expected.md'), snapshot, MODE)
expect(browserConsole.pageErrors).toEqual([])
expect(browserConsole.warnings).toEqual([])
+2 -2
View File
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/client/ui-commands/README.md
README.md: f8cba29179f9960042a4a9e67e86496591a76d08
README.zh.md: 1cea3ae7aea857f2b2d219abf0a7192c2f222792
README.md: 7fada747562ff15a96997d8bffe9aeb6f9a18fb5
README.zh.md: 2f532cbd0f3d33e304074644cc6cb02b7151d3e1
+2
View File
@@ -49,6 +49,8 @@ When the composer submits with images or generic files, only a host command decl
`src/client/contract.ts` defines contribution and decoration registration, plus `dismiss(name)`, which closes that command's open popups and confirmations: dismissal aborts pending option loads, prevents their late results from reopening the popup, and preserves composer drafts. `CommandDirectory` owns the per-session wire cache and resolves typed commands through `resolution.ts`; that module owns first-party identity matching and localized input spellings. `matchSpace` reads the ready cache synchronously, while `matchEnter` waits for readiness and rejects on warmup failure or cancellation. Forwarded catalog and connection events invalidate the cache. After a matched Host execution, this browser emits `command/executed`; other clients observe only the durable command events. `PopupSelectController` owns popup state, and `PopupSelectView` occupies the input overlay. `presentation.ts` owns row labels, icons, and sections; its helpers and the resolution helpers stay internal to the plugin.
Each command-directory fetch requires an existing retained Client Session and waits for its initial history open to succeed before sending `commands.list`. A temporary `commandCatalog` reference holds that Session until the fetch settles. An unretained Session or a failed open rejects without sending the RPC, so background catalog refreshes do not reopen closed Sessions.
</details>
-----
+2
View File
@@ -49,6 +49,8 @@ composer 携带图片或通用文件提交时,只有声明了 `input.attachmen
`src/client/contract.ts` 定义贡献项和装饰的注册接口,以及 `dismiss(name)`:它关闭该命令已打开的弹窗与确认对话框,中止待完成的选项加载,阻止晚到结果重新打开弹窗,并保留 composer 草稿。`CommandDirectory` 负责会话级协议缓存,并通过 `resolution.ts` 解析输入命令;该模块负责内置命令标识匹配和本地化输入写法。`matchSpace` 同步读取就绪缓存,`matchEnter` 等待缓存就绪,预热失败或取消时拒绝。转发的目录和连接事件使缓存失效。宿主执行匹配的命令后,本浏览器发布 `command/executed`,其他客户端只观察持久命令事件。`PopupSelectController` 负责弹窗状态,`PopupSelectView` 占据输入浮层。`presentation.ts` 负责行标题、图标和分节,展示与解析辅助函数均留在插件内部。
每次命令目录拉取都要求客户端已持有该会话,并等待首次历史打开成功后才发送 `commands.list`。临时的 `commandCatalog` 引用持续持有会话,直到拉取结束。未被持有的会话或打开失败会直接拒绝,不发送 RPC,因此后台目录刷新不会重新打开已关闭的会话。
</details>
-----
@@ -9,6 +9,8 @@
* `rankByName`). A host/contribution name collision fails loud. Every
* execute addresses the session's agent by sessionId — sessions are always
* agent-backed.
* Catalog RPCs retain an existing Client Session through completion and
* wait for its initial history open to succeed before contacting the Host.
*/
import { Service } from '@deepseek-ai/cordis'
import type { Context } from '@deepseek-ai/cordis'
@@ -34,6 +36,13 @@ import { builtinRowFace, sectionRows } from './presentation.ts'
import { claimToken } from './resolution.ts'
import type { TokenSegment } from './popup.ts'
declare module '@deepseek-ai/dsh-api-session-controller/client' {
interface SessionReferenceSourceMap {
/** A command-catalog fetch waiting for initial history and its RPC result. */
commandCatalog: unknown
}
}
declare module '@deepseek-ai/cordis' {
interface Events {
/**
@@ -86,10 +95,20 @@ export class CommandUiRuntime extends Service implements CommandUiContract {
if (locale === undefined) throw new Error('ui-commands: locale service unavailable')
this.t = locale.bind('command')
this.directory = new CommandDirectory(async (sessionId) => {
if (this.sessions().subagentAddress(sessionId) !== undefined) return []
const result = await ctx.remote.commands.list(sessionId)
if (!result.ok) throw new Error(`command.list failed: ${result.error.code}: ${result.error.message}`)
return result.value
const sessions = this.sessions()
if (sessions.subagentAddress(sessionId) !== undefined) return []
if (sessions.binding(sessionId) === undefined) {
throw new Error(`command catalog requires a retained session "${sessionId}"`)
}
return sessions.using(sessionId, { source: 'commandCatalog' }, async (reference) => {
const state = reference.binding.session.getSnapshot()
if (state.openState !== 'open') {
throw state.openError ?? new Error(`session "${sessionId}" is not open`)
}
const result = await ctx.remote.commands.list(sessionId)
if (!result.ok) throw new Error(`command.list failed: ${result.error.code}: ${result.error.message}`)
return result.value
})
})
const inputTriggers = ctx.get('inputTriggers')
if (inputTriggers === undefined) throw new Error('ui-commands: slash service unavailable')
@@ -8,7 +8,7 @@
*/
import { Context } from '@deepseek-ai/cordis'
import { describe, expect, it, onTestFinished } from 'vitest'
import { createScope, scopeOf } from '@deepseek-ai/dsh-api-session-controller/client'
import { TestSessions } from '@deepseek-ai/dsh-client-test-runtime'
import { SlotRegistry } from '@deepseek-ai/dsh-client-ui-renderer/client'
import type { SessionId } from '@deepseek-ai/dsh-session/types'
import type { InputTriggerSource } from '@deepseek-ai/dsh-client-ui-input-trigger/client'
@@ -28,21 +28,16 @@ async function bench() {
return () => { sources.delete(`${src.trigger} ${src.name}`) }
},
})
const scopes = new Map<SessionId, Context>()
const bindings = new Map<SessionId, {
readonly sessionId: SessionId
readonly session: { readonly sessionId: SessionId }
readonly ctx: Context
}>()
ctx.provide('sessions', {
scope: (id: SessionId) => scopes.get(id),
scopeOf: (c: Context) => scopeOf(c),
sessionOf: (c: Context) => bindings.get(scopeOf(c)!)?.session,
binding: (id: SessionId) => bindings.get(id),
subagentAddress: (id: SessionId) => id === sid('child')
? { parentSessionId: sid('parent'), childSessionId: id, mode: 'continuable' as const }
: undefined,
const sessions = new TestSessions(async (action) => { await action() }, ctx)
onTestFinished(async () => {
await sessions.disposeScopes()
await ctx.fiber.dispose()
})
ctx.provide('sessions', sessions)
await sessions.add({ id: 's1' })
await sessions.add({ id: 'child' })
sessions.retainFor(ctx, sid('s1'))
sessions.retainFor(ctx, { parentSessionId: sid('parent'), childSessionId: sid('child'), mode: 'continuable' })
const commandsRemote = { list: () => Promise.resolve({ ok: true as const, value: [] }) }
// The service subscribes its cache-invalidation events on construction, so
// the Remote face needs `$on` even where this spec dispatches none.
@@ -57,10 +52,8 @@ async function bench() {
await fiber.await()
const mint = (key: string) => {
const id = sid(key)
const handle = createScope(ctx, id)
scopes.set(id, handle.ctx)
bindings.set(id, { sessionId: id, session: { sessionId: id }, ctx: handle.ctx })
return handle
const binding = sessions.binding(id) ?? sessions.retainFor(ctx, id).binding
return { ctx: binding.ctx, fiber: binding.ctx.fiber }
}
return { ctx, fiber, sources, slots: ctx.slots, mint }
}
@@ -1,6 +1,6 @@
/**
* CommandUiRuntime tests on a real cordis Context with fake slash/connection
* faces and real session scopes (createScope): session-keyed candidate
* faces and retained TestSessions: session-keyed candidate
* synthesis (host catalog by sessionId + contributions by availability,
* collision fail-loud), the dispatch decision table cell by cell, matchSpace
* hot-key policy, matchEnter strong-wait / reject, the sessionId execute
@@ -11,9 +11,10 @@ import { Context } from '@deepseek-ai/cordis'
import { describe, expect, it, onTestFinished, vi } from 'vitest'
import type { CommandResult } from '@deepseek-ai/dsh-commands/types'
import { CommandDefinitionId } from '@deepseek-ai/dsh-commands/brand'
import { createScope, scopeOf } from '@deepseek-ai/dsh-api-session-controller/client'
import { scopeOf } from '@deepseek-ai/dsh-api-session-controller/client'
import type { SessionId } from '@deepseek-ai/dsh-session/types'
import { RemoteError, TestRemote } from '@deepseek-ai/dsh-client-test-runtime'
import { RemoteError, TestRemote, TestSessions } from '@deepseek-ai/dsh-client-test-runtime'
import type { SessionFixture } from '@deepseek-ai/dsh-client-test-runtime'
import { IconGoalOutlineRegular } from '@deepseek-ai/dsh-client-ui-primitives'
import type { ClientSessionContext, ConsumeTokenRequest, InputTriggerPick, InputTriggerSource, SubmitAttachment } from '@deepseek-ai/dsh-client-ui-input-trigger/client'
import type { CommandContribution, CommandDecoration, PopupSelectSpec, SelectOption } from '../src/client/contract.ts'
@@ -44,6 +45,8 @@ interface BenchOptions {
execute?: (payload: { sessionId: SessionId; line: string }) => Promise<ExecuteValue>
translate?: (namespace: string, key: string, params?: Record<string, unknown>) => string
addressed?: SessionId
initialOpen?: SessionFixture['initialOpen']
snapshot?: SessionFixture['snapshot']
}
/**
@@ -105,18 +108,30 @@ async function bench(opts: BenchOptions = {}) {
opts.translate?.(ns, key, params)
?? `${ns}:${key}${params === undefined ? '' : JSON.stringify(params)}`,
})
// Real scope tags behind a fake sessions face.
const scopes = new Map<SessionId, { ctx: Context; fiber: { dispose(): Promise<void> } }>()
const bindings = new Map<SessionId, { sessionId: SessionId; session: { sessionId: SessionId }; ctx: Context }>()
const removeSessions = ctx.provide('sessions', {
scope: (id: SessionId) => scopes.get(id)?.ctx,
scopeOf: (c: Context) => scopeOf(c),
sessionOf: (scopeCtx: Context) => [...bindings.values()].find(binding => binding.ctx === scopeCtx)?.session,
binding: (id: SessionId) => bindings.get(id),
subagentAddress: (id: SessionId) => id === opts.addressed
? { parentSessionId: sid('parent'), childSessionId: id, mode: 'continuable' as const }
: undefined,
const sessions = new TestSessions(async (action) => { await action() }, ctx)
onTestFinished(async () => {
await sessions.disposeScopes()
await ctx.fiber.dispose()
})
const removeSessions = ctx.provide('sessions', sessions)
for (const id of new Set(['s1', 's2', 'other', 'ghost', ...opts.addressed === undefined ? [] : [opts.addressed]])) {
await sessions.add({
id,
...opts.initialOpen === undefined ? {} : { initialOpen: opts.initialOpen },
...opts.snapshot === undefined ? {} : { snapshot: opts.snapshot },
})
}
const mint = (key: string) => {
const id = sid(key)
const target = id === opts.addressed
? { parentSessionId: sid('parent'), childSessionId: id, mode: 'continuable' as const }
: id
const binding = sessions.binding(id) ?? sessions.retainFor(ctx, target).binding
return { ctx: binding.ctx, fiber: binding.ctx.fiber }
}
mint('s1')
mint('s2')
if (opts.addressed !== undefined) mint(opts.addressed)
const remote = Object.assign(new TestRemote(ctx), { commands: commandsRemote })
ctx.provide('remote.commands', commandsRemote)
const executions: Array<{ sessionId: SessionId; name: string; result: CommandResult }> = []
@@ -142,24 +157,13 @@ async function bench(opts: BenchOptions = {}) {
const command = ctx.get('commandUi') as CommandUiRuntime
const source = registered.get('/ command')
if (source === undefined) throw new Error('command source not registered')
const mint = (key: string) => {
const id = sid(key)
const handle = createScope(ctx, id)
const binding = { sessionId: id, session: { sessionId: id }, ctx: handle.ctx }
scopes.set(id, handle)
bindings.set(id, binding)
handle.ctx.effect(() => () => {
if (bindings.get(id) === binding) bindings.delete(id)
})
return handle
}
/** Warm one session's catalog through the source's own candidate pull. */
const warm = async (session: ClientSessionContext) => {
await source.candidates(session, { query: '', position: 'leading', drilled: false, signal: new AbortController().signal })
}
return {
ctx, fiber, command, source, mint, warm, listCalls, executeCalls, executions, registered, notices, focuses, remote,
removeSessions, removeConversation,
removeSessions, removeConversation, sessions,
}
}
@@ -215,15 +219,51 @@ describe('registration', () => {
it('the warm hook prewarms the session key: one pull per session, no duplicate over pending', async () => {
const { source, listCalls } = await bench()
source.warm!(proj('s1'))
expect(listCalls).toEqual([{ sessionId: sid('s1') }])
await vi.waitFor(() => { expect(listCalls).toEqual([{ sessionId: sid('s1') }]) })
source.warm!(proj('s2'))
expect(listCalls).toEqual([{ sessionId: sid('s1') }, { sessionId: sid('s2') }])
await vi.waitFor(() => { expect(listCalls).toEqual([{ sessionId: sid('s1') }, { sessionId: sid('s2') }]) })
source.warm!(proj('s1')) // s1 already pending → no duplicate pull
expect(listCalls).toHaveLength(2)
})
})
describe('candidates', () => {
it('waits for initial history and retains the Session until the catalog RPC settles', async () => {
const opened = Promise.withResolvers<undefined>()
const response = Promise.withResolvers<{ commands: CommandDescriptor[] }>()
const b = await bench({ initialOpen: () => opened.promise, commands: () => response.promise })
try {
const pending = b.source.candidates(proj('s1'), req(''))
expect(b.sessions.retainInfo(sid('s1')).getSnapshot().retainedBy.commandCatalog).toBe(1)
expect(b.listCalls).toEqual([])
opened.resolve(undefined)
await vi.waitFor(() => { expect(b.listCalls).toHaveLength(1) })
expect(b.sessions.retainInfo(sid('s1')).getSnapshot().referenceCount).toBe(2)
response.resolve({ commands: S1_CMDS })
await pending
expect(b.sessions.retainInfo(sid('s1')).getSnapshot().referenceCount).toBe(1)
} finally {
opened.resolve(undefined)
response.resolve({ commands: S1_CMDS })
}
})
it.each([true, false])('refuses an unsuccessful history open (reported error: %s)', async (reported) => {
const error = new RemoteError('gateway/internal', 'history unavailable', {})
const b = await bench({ snapshot: { openState: 'error', openError: reported ? error : null } })
await expect(b.source.candidates(proj('s1'), req(''))).rejects.toThrow(reported ? 'history unavailable' : 'is not open')
expect(b.listCalls).toEqual([])
expect(b.sessions.retainInfo(sid('s1')).getSnapshot().referenceCount).toBe(1)
})
it('does not reopen a Session released before catalog lookup', async () => {
const b = await bench()
await b.mint('s1').fiber.dispose()
await expect(b.source.candidates(proj('s1'), req(''))).rejects.toThrow('requires a retained session')
expect(b.sessions.binding(sid('s1'))).toBeUndefined()
expect(b.listCalls).toEqual([])
})
it('does not fetch Agent-bound commands for an addressed child', async () => {
const b = await bench({ addressed: sid('child') })
await expect(b.warm(proj('child'))).resolves.toBeUndefined()
@@ -677,6 +717,7 @@ describe('matchEnter (enter column)', () => {
commands: () => new Promise((resolve) => { release = resolve }),
})
const wait = source.matchEnter!(proj('s1'), '/goal args', signal(), { attachments: 0 })
await vi.waitFor(() => { expect(release).toBeTypeOf('function') })
release({ commands: S1_CMDS })
const outcome = await wait
if (outcome === undefined || outcome === 'handled' || !('claim' in outcome)) throw new Error('expected claim')
@@ -906,10 +947,12 @@ describe('detached admission notices', () => {
})
it('a torn-down scope drops the failure notice', async () => {
const { source, warm, notices } = await bench({
const { source, warm, notices, mint } = await bench({
execute: () => Promise.reject(new Error('orphan failure')),
})
await warm(proj('ghost')) // never minted: scopeFor misses
const scope = mint('ghost')
await warm(proj('ghost'))
await scope.fiber.dispose()
menuPick(source, 'plan', proj('ghost'))
await flush()
expect(notices).toEqual([])
@@ -1122,6 +1165,7 @@ describe('directory invalidation events', () => {
ctx.emit('connection/reset')
// Hard reset: silent until the rewarm lands.
expect(source.matchSpace!(proj('s2'), '/attach')).toBeUndefined()
await vi.waitFor(() => { expect(release).toBeTypeOf('function') })
release({ commands: S2_CMDS })
await new Promise(resolve => setTimeout(resolve, 0))
expect(source.matchSpace!(proj('s2'), '/attach')).not.toBeUndefined()
+2 -2
View File
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/client/ui-goal/README.md
README.md: 7e6e0502db9720f788711fa3e98cab1581cc8738
README.zh.md: 7e3fb604469ec73238f0843fdc95e6ea175c1d03
README.md: ac23ac87c5b1bfe3eb849679c30a2ed283f33455
README.zh.md: bc9971a5e8917bf636de34a742530b113e4e4453
+2
View File
@@ -45,6 +45,8 @@ A rejected mutation surfaces the Remote error inline on the strip; loading, abse
The durable goal arrives through `useProjection('goal')` (seeded by the history tail page and updated by `session/projection` frames). The inject face carries a registrant-private activation hook source plus the four mutation verbs. That source starts only while the framework hook observes it, reads `ctx.remote.goals.get`, subscribes to `goal/activation-changed`, and refreshes on running-state or connection resets. Live-event epochs invalidate in-flight reads, so a stale HTTP result cannot overwrite a newer activation edge; running refreshes retain the last known activation until the read resolves. The strip owns no domain store or cross-plugin cache. Each mutation reads the CAS ref from the session's current projected value at call time, and the RPC's compare-and-set is the staleness guard. The strip single-flights mutations synchronously because a pending render cannot fence same-frame clicks. The command-input projection is a separate Conversation Definition that builds a `command-input` Chat Node before the generic command result Node; it never creates `user/message` or a model turn.
Activation reads hold a temporary `goalActivation` Client reference and send `goals.get` only after that Session's initial history open succeeds. A stale binding or failed open sends no RPC. Rejected reads are logged without changing the projected goal or last known activation; the temporary reference is released when the read settles.
</details>
-----
+2
View File
@@ -45,6 +45,8 @@ Web GUI 的 goal 界面同时显示持久 goal 状态及当前的进程本地激
持久 goal 经 `useProjection('goal')` 到达(由历史尾页播种、`session/projection` 帧更新)。注入面携带注册方私有的激活钩子源与四个变更动词。该源仅在框架钩子观察它时启动;启动后会读取 `ctx.remote.goals.get`、订阅 `goal/activation-changed`,并在 running 状态或连接重置时刷新。实时事件 epoch 会让在途读取失效,因此较旧的 HTTP 响应不能覆盖较新的 activation 变化;running 刷新会保留最后一次已知 activation,直到读取完成。条带不持有领域存储或跨插件缓存。每个变更在调用时从会话当前投影值读取 CAS ref,比较并交换(RPC 的 CAS)就是陈旧性护栏。由于 React 的 pending 渲染无法拦住同一帧内的点击,条带会同步为变更建立 single-flight 防护。指令输入投影是独立的 Conversation Definition,在通用命令结果 Node 之前构建 `command-input` Chat Node;它绝不创建 `user/message` 或模型轮次。
激活状态读取持有临时的 `goalActivation` 客户端引用,仅在该会话首次历史打开成功后发送 `goals.get`。binding 已失效或打开失败时不发送 RPC。读取被拒绝时记录错误,不改变投影中的目标或最后一次已知激活状态;读取结束时释放临时引用。
</details>
-----
@@ -13,7 +13,7 @@ export interface GoalActivationDeps {
readonly projection: HostObservable<GoalProjection | null | undefined>
/** Session snapshot; running flips trigger a fresh authoritative read. */
readonly session: HostObservable<{ readonly running: boolean }>
/** Read the current live goal at call time. */
/** Read live activation after initial history; rejected reads leave the current snapshot unchanged. */
readonly getGoal: () => Promise<RemoteResult<GoalView | undefined>>
/** Subscribe to activation edges after the transport delivers them. */
readonly subscribeActivation: (listener: (goal: GoalActivationChanged['goal']) => void) => () => void
@@ -69,6 +69,8 @@ export function createGoalActivationSource(deps: GoalActivationDeps): HostObserv
return
}
publish({ id: goal.id, revision: goal.revision, activation: goal.activation })
}, (error: unknown) => {
console.warn('[ui-goal] goal activation read failed:', error)
})
}
+21 -1
View File
@@ -4,6 +4,8 @@
* `useProjection('goal')`. A registrant-private activation hook source owns
* the live Remote read and event subscription; the inject face carries that
* hook plus the four mutation verbs through the generated Goal Remote API.
* Activation reads retain the Client Session through completion and wait
* for its initial history open to succeed before contacting the Host.
* This plugin does not create goals; deployments may expose /goal separately.
*/
import type { Context as ClientContext } from '@deepseek-ai/cordis'
@@ -38,6 +40,13 @@ export type {
} from './slots.ts'
export type { GoalKey } from './locales.ts'
declare module '@deepseek-ai/dsh-api-session-controller/client' {
interface SessionReferenceSourceMap {
/** A live goal read waiting for initial history and its RPC result. */
goalActivation: unknown
}
}
declare module '@deepseek-ai/dsh-client-ui-slots' {
interface LocaleNamespaceMap {
/** The goal strip's copy. */
@@ -91,7 +100,18 @@ export function apply(ctx: ClientContext): void {
const goalActivation = createGoalActivationSource({
projection: binding.session.projections.faceOf('goal') as HostObservable<GoalProjection | null | undefined>,
session: binding.session,
getGoal: () => ctx.remote.goals.get(sessionId),
getGoal: async () => {
if (sessions.binding(sessionId) !== binding) {
throw new Error(`ui-goal: session "${sessionId}" is unavailable`)
}
return sessions.using(sessionId, { source: 'goalActivation' }, async (reference) => {
const state = reference.binding.session.getSnapshot()
if (state.openState !== 'open') {
throw state.openError ?? new Error(`session "${sessionId}" is not open`)
}
return ctx.remote.goals.get(sessionId)
})
},
subscribeActivation: listener => ctx.remote.$on('goal/activation-changed', (event) => {
if (event.sessionId === sessionId) listener(event.goal)
}),
@@ -10,7 +10,7 @@
* plugin fiber (HMR safety), and the node half stays inert.
*/
import { Context, Service } from '@deepseek-ai/cordis'
import { describe, expect, it, vi } from 'vitest'
import { describe, expect, it, onTestFinished, vi } from 'vitest'
import { cleanup, render, screen, waitFor } from '@testing-library/react'
import { afterEach } from 'vitest'
import { SlotRegistry } from '@deepseek-ai/dsh-client-ui-renderer/client'
@@ -18,7 +18,8 @@ import { UiConversation } from '@deepseek-ai/dsh-client-ui-conversation/client'
import type { SessionId } from '@deepseek-ai/dsh-session/types'
import type { GoalActivation, GoalId, GoalProjection, GoalView } from '@deepseek-ai/dsh-goal/client'
import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client'
import { makeTranslate, RemoteError } from '@deepseek-ai/dsh-client-test-runtime'
import { makeTranslate, RemoteError, TestSessions } from '@deepseek-ai/dsh-client-test-runtime'
import type { SessionFixture } from '@deepseek-ai/dsh-client-test-runtime'
import type { RemoteFailure } from '@deepseek-ai/dsh-api-remotes/client'
import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts'
import type { GoalActivationSnapshot, GoalBarActions, GoalBarInjected } from '../src/client/slots.ts'
@@ -52,25 +53,25 @@ async function bench(options: {
projection?: GoalProjection | null | undefined
activation?: GoalActivation
failWith?: RemoteFailure
initialOpen?: SessionFixture['initialOpen']
snapshot?: SessionFixture['snapshot']
} = {}) {
const ctx = new Context()
const calls: { method: string; args: unknown[] }[] = []
const sessions = {
binding: (id: SessionId) => id === sid('missing') ? undefined : ({
sessionId: id,
session: {
getSnapshot: () => ({ running: false }),
subscribe: () => () => {},
projections: { faceOf: (key: string) => ({
getSnapshot: () => (key === 'goal' ? options.projection : undefined),
subscribe: () => () => {},
}) },
},
ctx,
}),
}
const sessions = new TestSessions(async (action) => { await action() }, ctx)
onTestFinished(async () => {
await sessions.disposeScopes()
await ctx.fiber.dispose()
})
await sessions.add({
id: 's1',
...options.initialOpen === undefined ? {} : { initialOpen: options.initialOpen },
...options.snapshot === undefined ? {} : { snapshot: options.snapshot },
})
await sessions.setProjection('s1', 'goal', options.projection)
const owner = sessions.retainFor(ctx, sid('s1'))
ctx.provide('sessions', sessions)
const conversationEvents = new UiConversation(ctx, sessions as never).events
const conversationEvents = new UiConversation(ctx, sessions).events
function answer<T>(method: string, value: T) {
return (...args: unknown[]) => {
calls.push({ method, args })
@@ -145,6 +146,8 @@ async function bench(options: {
ctx,
fiber,
calls,
sessions,
owner,
emitActivation: remote.emitActivation.bind(remote),
definitions: () => conversationEvents.entries(),
remountGoals: () => { activeGoals = goals('remounted-goals') },
@@ -163,6 +166,65 @@ async function bench(options: {
}
describe('ui-goal browser plugin', () => {
it('waits for initial history before reading activation and releases the read reference', async () => {
const opened = Promise.withResolvers<undefined>()
const b = await bench({ projection: makeProjection(), activation: 'disarmed', initialOpen: () => opened.promise })
await b.fiber.await()
const source = b.entry()!.inject!(sid('s1')).hooks.goalActivation
const dispose = source.subscribe(() => {})
try {
expect(b.calls).toEqual([])
expect(b.sessions.retainInfo(sid('s1')).getSnapshot().retainedBy.goalActivation).toBe(1)
opened.resolve(undefined)
await waitFor(() => { expect(source.getSnapshot().activation).toBe('disarmed') })
expect(b.calls).toEqual([{ method: 'goals/get', args: [sid('s1')] }])
expect(b.sessions.retainInfo(sid('s1')).getSnapshot().referenceCount).toBe(1)
} finally {
opened.resolve(undefined)
dispose()
}
})
it.each([true, false])('keeps projected goal state after an unsuccessful history open (reported error: %s)', async (reported) => {
const error = new RemoteError('gateway/internal', 'history unavailable', {})
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
onTestFinished(() => { warn.mockRestore() })
const b = await bench({ projection: makeProjection(), snapshot: { openState: 'error', openError: reported ? error : null } })
await b.fiber.await()
const source = b.entry()!.inject!(sid('s1')).hooks.goalActivation
const dispose = source.subscribe(() => {})
try {
await waitFor(() => { expect(warn).toHaveBeenCalledOnce() })
expect(warn).toHaveBeenCalledWith('[ui-goal] goal activation read failed:', expect.objectContaining({
message: reported ? 'history unavailable' : 'session "s1" is not open',
}))
expect(source.getSnapshot()).toEqual({ id: GOAL_ID, revision: 3 })
expect(b.calls).toEqual([])
expect(b.sessions.retainInfo(sid('s1')).getSnapshot().referenceCount).toBe(1)
} finally {
dispose()
}
})
it('refuses activation reads captured from a replaced Session binding', async () => {
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
onTestFinished(() => { warn.mockRestore() })
const b = await bench({ projection: makeProjection() })
await b.fiber.await()
const source = b.entry()!.inject!(sid('s1')).hooks.goalActivation
b.owner.release()
b.sessions.retainFor(b.ctx, sid('s1'))
const dispose = source.subscribe(() => {})
try {
await waitFor(() => { expect(warn).toHaveBeenCalledWith('[ui-goal] goal activation read failed:', expect.objectContaining({
message: 'ui-goal: session "s1" is unavailable',
})) })
expect(b.calls).toEqual([])
} finally {
dispose()
}
})
it('registers the GoalBar dock, command input Definition, and keyed Chat renderer', async () => {
const b = await bench()
await b.fiber.await()
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/client/ui-primitives/README.md
README.md: 09950210eaef57a290362037f3d603366fe00168
README.zh.md: aa95cbf1ce7fa79c3d1d5466ca8ed4d6942ad03e
README.md: 12fe2648e383189198ff972709819e6f1eb4c02f
README.zh.md: 3c6f62fee22c5ad7e750c860724f60ca6dd9fbcf
+1 -1
View File
@@ -44,7 +44,7 @@ Check this table before writing a control in a feature package. A plugin cannot
| `Pill` | Selectable capsule button for view switchers and filters; takes `active` and `onClick`. |
| `SegmentedTabs` | Controlled equal-width tabs with a sliding indicator and Left/Right, Home, and End navigation. The caller supplies labels, tab/panel ids, and panel content. |
| `Tag` | Read-only capsule badge; `tone` selects one of eight palettes. |
| `StateDot` | Solid green `done`, amber `warning`, red `error`, and neutral-grey `idle` marks in a 10px slot, plus a tertiary-grey 14px rotating `ongoing` loader. `aria-hidden`, so the render site owns the name. |
| `StateDot` | Solid green `done`, amber `warning`, red `error`, and neutral-grey `idle` marks in a 10px slot, plus a tertiary-grey 14px rotating `ongoing` loader whose animations pin to document time zero so every visible loader rotates in phase. `aria-hidden`, so the render site owns the name. |
| `ConnectionIndicator` | Inline connection-recovery control across outage, retry, and recovered states. |
| `DisclosureRow` | 24px compact disclosure that lays title and content side by side. |
| `Modal` | Centered dialog over a page mask. |
+1 -1
View File
@@ -44,7 +44,7 @@ kind: "package-library"
| `Pill` | 可选中的胶囊按钮,用于视图切换与筛选器;接受 `active` 与 `onClick`。 |
| `SegmentedTabs` | 受控的等宽分段标签,支持滑动指示条及左/右方向键、Home、End 导航。调用方提供文案、标签与面板 id,以及面板内容。 |
| `Tag` | 只读胶囊徽章;`tone` 选择八种配色之一。 |
| `StateDot` | 10px 槽内的绿色 `done`、琥珀色 `warning`、红色 `error`、中性灰色 `idle` 圆点,以及 tertiary 灰色 14px 旋转 `ongoing` loading。它是 `aria-hidden` 的,名称由渲染点提供。 |
| `StateDot` | 10px 槽内的绿色 `done`、琥珀色 `warning`、红色 `error`、中性灰色 `idle` 圆点,以及 tertiary 灰色 14px 旋转 `ongoing` loading,其动画固定到文档时间零点,所以所有可见 loading 同相旋转。它是 `aria-hidden` 的,名称由渲染点提供。 |
| `ConnectionIndicator` | 行内连接恢复控件,覆盖断线、重试与已恢复三种状态。 |
| `DisclosureRow` | 24px 紧凑折叠行,标题与内容左右排列。 |
| `Modal` | 页面遮罩之上的居中对话框。 |
@@ -32,7 +32,8 @@
}
/* Ongoing is the sole non-dot state: a quiet full ring sits behind a centered
* breathing arc while the complete glyph rotates continuously. */
* breathing arc while the complete glyph rotates continuously. Both animations
* share one 1.5s period so pinning their start times aligns them together. */
.spinner {
flex: none;
color: var(--dsw-alias-label-tertiary);
@@ -7,6 +7,20 @@ import css from './StateDot.module.css'
*/
export type StateDotState = 'done' | 'warning' | 'ongoing' | 'error' | 'idle'
/**
* Pin the loader's CSS animations to document time zero. A CSS animation starts
* when its element is inserted, so loaders mounted at different moments rotate
* out of phase; one shared start time keeps every visible loader in step.
* @param element - the mounted loader, or null on unmount.
*/
function syncSpinner(element: SVGSVGElement | null): void {
if (element === null) return
// jsdom (the unit lane) implements no Web Animations despite lib.dom's
// non-optional typing; the optional call leaves that lane unsynced.
const spinner = element as { getAnimations?: SVGSVGElement['getAnimations'] }
for (const animation of spinner.getAnimations?.({ subtree: true }) ?? []) animation.startTime = 0
}
/**
* Render a state dot.
* @param props.state - which of `done`, `warning`, `ongoing`, `error`, or `idle` to show.
@@ -23,6 +37,7 @@ export function StateDot({ state, size, className }: {
if (state === 'ongoing') {
return (
<svg
ref={syncSpinner}
className={clsx(css.spinner, className)}
data-state="ongoing"
width={edge}
@@ -1,6 +1,6 @@
// @vitest-environment jsdom
import { cleanup, render } from '@testing-library/react'
import { afterEach, describe, expect, it } from 'vitest'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { StateDot } from '@deepseek-ai/dsh-client-ui-primitives'
import type { StateDotState } from '@deepseek-ai/dsh-client-ui-primitives'
@@ -56,3 +56,21 @@ describe('StateDot', () => {
expect(bad('paused')).toBe('paused')
})
})
describe('StateDot ongoing phase', () => {
it('pins every loader animation to document time zero on mount', () => {
const animations = [{ startTime: 42 }, { startTime: 7 }]
const getAnimations = vi.fn(() => animations)
const proto = SVGElement.prototype as { getAnimations?: () => { startTime: number }[] }
proto.getAnimations = getAnimations
try {
const { unmount } = render(<StateDot state="ongoing" />)
expect(getAnimations).toHaveBeenCalledWith({ subtree: true })
expect(animations.map(animation => animation.startTime)).toEqual([0, 0])
unmount()
expect(getAnimations).toHaveBeenCalledTimes(1)
} finally {
delete proto.getAnimations
}
})
})
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/client/ui-reference/README.md
README.md: 6b5046f1c6300f5ec02d3737617019874762d47a
README.zh.md: 99ba6fda18a73f5686f5e02403e48d5e556f9c3c
README.md: 57b5ae2440cf3dd4512cf44361b334dc7f3a58c9
README.zh.md: fa5af63c939f2bb71f371262dbbf137180c63f8e
+2
View File
@@ -53,6 +53,8 @@ The source keeps candidate encoding internal to the registration effect: the `/c
For an unquoted token, the browser starts the `fileReferences/list` and `sessionReferenceResolver/candidates` Remote calls together, then deterministically orders files, direct subagents of the current Session, and other Sessions. Rows use the resolver's display title, which prefers a subagent's creation label while ordinary Sessions retain their projected title, and render under locale-owned group headings without a redundant raw `reference` source title. A session row is dated from the Host session list's `updatedAt` through the same relative-time bucket that list uses, so one session reads the same age on both surfaces; a session the list does not carry falls back to the candidate's creation time. A drilled query publishes a breadcrumb from the workspace root to the current directory; each crumb carries the drill payload a folder row would, so returning to a step and descending into one are one outcome.
Candidate requests require an existing retained Client Session and share one temporary `referenceCandidates` reference. Both discovery calls wait for that Session's initial history open to succeed; an unretained Session or failed open prevents both RPCs. The candidate request's cancellation signal covers the history wait and both calls, and the temporary reference is released when the lookup settles.
### Serialization
File picks preserve the natural text defined by the shared `@path` grammar as the hidden serialized and clipboard form. Session picks use the canonical `@[label](dsh-session:…)` mention; serialization never reconstructs identity from the visible title.
@@ -53,6 +53,8 @@ Web 用户需要从同一个 `@` 补全菜单提及文件、文件夹或会话
对于未加引号的 token,浏览器会同时启动 `fileReferences/list` 与 `sessionReferenceResolver/candidates` Remote 调用,再按文件、当前 Session 的直接 subagent、其他 Session 的确定性顺序排列。各行使用 resolver 的显示标题;subagent 优先使用创建标签,普通 Session 保持投影标题,并渲染在 locale 管理的分组标题下,不显示重复的原始 `reference` source 标题。会话行用宿主会话列表的 `updatedAt` 经该列表相同的相对时间分档标注时间,因此同一个会话在两处读到的时长一致;列表中没有的会话回落到候选自带的创建时间。下钻后的查询会发布一条从工作区根目录到当前目录的面包屑;每一节携带的下钻载荷与文件夹行相同,因此「回到某一步」与「进入某一层」是同一个结果。
候选请求要求客户端已持有该会话,并共用一个临时的 `referenceCandidates` 引用。两个发现调用都等待该会话首次历史打开成功;未持有会话或打开失败时,均不发送 RPC。候选请求的取消信号同时覆盖历史等待和两个调用,查询结束时释放临时引用。
### 序列化
文件选择把共享 `@path` 语法所定义的自然文本保留为隐藏的序列化与剪贴板形式。会话选择使用规范的 `@[label](dsh-session:…)` mention;序列化永远不会根据可见标题重建身份。
@@ -2,6 +2,8 @@
* Unified Web `@` reference source. File and session discovery run through
* the cancellable generated Remote namespaces in parallel with deterministic
* ordering and labels.
* Candidate requests retain an existing Client Session through completion
* and wait for its initial history open to succeed before contacting the Host.
*
* Rows carry only what distinguishes them: a file names its parent directory
* (nothing at the workspace root), a directory listing names none because its
@@ -28,6 +30,13 @@ import type { SessionReferenceMentionCandidate } from '@deepseek-ai/dsh-session-
import { abbreviateHomePath, fileAddressFor } from '@deepseek-ai/dsh-util-workspace-path'
import { en, NS, zh, type ReferenceKey } from './locales.ts'
declare module '@deepseek-ai/dsh-api-session-controller/client' {
interface SessionReferenceSourceMap {
/** File and Session candidates waiting for initial history and their RPC results. */
referenceCandidates: unknown
}
}
/** Required services: the trigger registry, the Remote namespaces, and the copy. */
export const inject = [
'inputTriggers', 'locale', 'sessions', 'remote', 'remote.fileReferences',
@@ -47,13 +56,27 @@ export function apply(ctx: ClientContext): void {
name: 'reference',
showGroupTitle: false,
async candidates(session: ClientSessionContext, { query, quoted, drilled, signal }) {
const fileLookup = ctx.remote.fileReferences.list(session.sessionId, query, signal)
.then(result => result.ok ? result.value : [])
const sessionLookup = quoted === true
? Promise.resolve([] as SessionReferenceMentionCandidate[])
: ctx.remote.sessionReferenceResolver.candidates(session.sessionId, query, signal)
.then(result => result.ok ? result.value : [])
const [fileItems, sessionItems] = await Promise.all([fileLookup, sessionLookup])
if (sessions.binding(session.sessionId) === undefined) {
throw new Error(`reference candidates require a retained session "${session.sessionId}"`)
}
const [fileItems, sessionItems] = await sessions.using(
session.sessionId,
{ source: 'referenceCandidates', signal },
async (reference) => {
signal.throwIfAborted()
const state = reference.binding.session.getSnapshot()
if (state.openState !== 'open') {
throw state.openError ?? new Error(`session "${session.sessionId}" is not open`)
}
const fileLookup = ctx.remote.fileReferences.list(session.sessionId, query, signal)
.then(result => result.ok ? result.value : [])
const sessionLookup = quoted === true
? Promise.resolve([] as SessionReferenceMentionCandidate[])
: ctx.remote.sessionReferenceResolver.candidates(session.sessionId, query, signal)
.then(result => result.ok ? result.value : [])
return Promise.all([fileLookup, sessionLookup])
},
)
if (signal.aborted) return []
// The header already names the directory being listed; rows repeat it only
// when there is no header to carry it.
@@ -4,10 +4,11 @@
* round-trip, and registration lifecycle.
*/
import { Context, Service } from '@deepseek-ai/cordis'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { afterEach, beforeEach, describe, expect, it, onTestFinished, vi } from 'vitest'
import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client'
import type { SessionId } from '@deepseek-ai/dsh-session/types'
import { RemoteError } from '@deepseek-ai/dsh-client-test-runtime'
import { RemoteError, TestSessions } from '@deepseek-ai/dsh-client-test-runtime'
import type { SessionFixture } from '@deepseek-ai/dsh-client-test-runtime'
import type {
CandidateRequest, ClientSessionContext, InputTriggerCandidate, InputTriggerSource,
} from '@deepseek-ai/dsh-client-ui-input-trigger/client'
@@ -80,7 +81,8 @@ async function bench(
parentId?: SessionId
projectionValues?: { title?: string | null }
}> = {},
): Promise<{ ctx: Context; fiber: ReturnType<Context['plugin']>; source: InputTriggerSource }> {
opening: Pick<SessionFixture, 'initialOpen' | 'snapshot'> = {},
) {
const ctx = new Context()
ctx.provide('sidebarRight', { openResource: vi.fn() })
let source: InputTriggerSource | undefined
@@ -101,11 +103,22 @@ async function bench(
ctx.provide('remote.fileReferences', { list: files })
ctx.provide('remote.sessionReferenceResolver', { candidates: sessions })
ctx.provide('locale', new LocaleRuntime(ctx))
ctx.provide('sessions', { list: { getSnapshot: () => ({ byId: listed }) } })
const sessionStore = new TestSessions(async (action) => { await action() }, ctx)
onTestFinished(async () => {
await sessionStore.disposeScopes()
await ctx.fiber.dispose()
})
ctx.provide('sessions', sessionStore)
const summary = listed[session.sessionId]
await sessionStore.add({ id: session.sessionId, ...opening, ...summary === undefined ? {} : { summary } })
for (const [id, summary] of Object.entries(listed)) {
if (id !== session.sessionId) await sessionStore.add({ id, summary })
}
const owner = sessionStore.retainFor(ctx, session.sessionId)
const fiber = ctx.plugin({ inject: [...inject], apply })
await fiber.await()
if (source === undefined) throw new Error('reference source was not registered')
return { ctx, fiber, source }
return { ctx, fiber, source, sessions: sessionStore, owner }
}
describe('apply', () => {
@@ -150,6 +163,68 @@ describe('apply', () => {
})
describe('candidates', () => {
it('waits for initial history before both lookups and releases its reference after they settle', async () => {
const opened = Promise.withResolvers<undefined>()
const response = Promise.withResolvers<RemoteEnvelope<FileReferenceCandidate[]>>()
const files = vi.fn(() => response.promise)
const lookup = vi.fn(() => Promise.resolve({ ok: true as const, value: [] }))
const b = await bench(files, lookup, {}, { initialOpen: () => opened.promise })
try {
const pending = b.source.candidates(session, request(''))
expect(b.sessions.retainInfo(session.sessionId).getSnapshot().retainedBy.referenceCandidates).toBe(1)
expect(files).not.toHaveBeenCalled()
expect(lookup).not.toHaveBeenCalled()
opened.resolve(undefined)
await vi.waitFor(() => { expect(files).toHaveBeenCalledOnce() })
expect(lookup).toHaveBeenCalledOnce()
expect(b.sessions.retainInfo(session.sessionId).getSnapshot().referenceCount).toBe(2)
response.resolve({ ok: true, value: [] })
await expect(pending).resolves.toEqual([])
expect(b.sessions.retainInfo(session.sessionId).getSnapshot().referenceCount).toBe(1)
} finally {
opened.resolve(undefined)
response.resolve({ ok: true, value: [] })
}
})
it.each([true, false])('refuses an unsuccessful history open (reported error: %s)', async (reported) => {
const error = new RemoteError('gateway/internal', 'history unavailable', {})
const files = vi.fn(() => Promise.resolve({ ok: true as const, value: [] }))
const lookup = vi.fn(() => Promise.resolve({ ok: true as const, value: [] }))
const b = await bench(files, lookup, {}, { snapshot: { openState: 'error', openError: reported ? error : null } })
await expect(b.source.candidates(session, request(''))).rejects.toThrow(reported ? 'history unavailable' : 'is not open')
expect(files).not.toHaveBeenCalled()
expect(lookup).not.toHaveBeenCalled()
expect(b.sessions.retainInfo(session.sessionId).getSnapshot().referenceCount).toBe(1)
})
it('does not reopen a released Session for candidates', async () => {
const b = await bench()
b.owner.release()
await expect(b.source.candidates(session, request(''))).rejects.toThrow('require a retained session')
expect(b.sessions.binding(session.sessionId)).toBeUndefined()
})
it('cancels a query waiting for history without starting either lookup', async () => {
const opened = Promise.withResolvers<undefined>()
const controller = new AbortController()
const files = vi.fn(() => Promise.resolve({ ok: true as const, value: [] }))
const lookup = vi.fn(() => Promise.resolve({ ok: true as const, value: [] }))
const b = await bench(files, lookup, {}, { initialOpen: () => opened.promise })
try {
const pending = b.source.candidates(session, request('', { signal: controller.signal }))
const reason = new Error('query cancelled')
const rejected = expect(pending).rejects.toBe(reason)
controller.abort(reason)
await rejected
expect(files).not.toHaveBeenCalled()
expect(lookup).not.toHaveBeenCalled()
expect(b.sessions.retainInfo(session.sessionId).getSnapshot().referenceCount).toBe(1)
} finally {
opened.resolve(undefined)
}
})
it('starts both Remote lookups together and renders files before sessions with stable labels', async () => {
let releaseFiles!: () => void
let releaseSessions!: () => void
@@ -194,7 +269,7 @@ describe('candidates', () => {
}))
const { source } = await bench(files, sessions, { source: { updatedAt: UPDATED_AT } })
const pending = source.candidates(session, request('re'))
expect(files).toHaveBeenCalledTimes(1)
await vi.waitFor(() => { expect(files).toHaveBeenCalledTimes(1) })
expect(sessions).toHaveBeenCalledTimes(1)
releaseSessions()
releaseFiles()
@@ -269,9 +344,13 @@ describe('candidates', () => {
it('drops a completed result when the query signal was superseded', async () => {
const controller = new AbortController()
const { source } = await bench()
const gate = Promise.withResolvers<RemoteEnvelope<FileReferenceCandidate[]>>()
const files = vi.fn(() => gate.promise)
const { source } = await bench(files)
const pending = source.candidates(session, request('', { signal: controller.signal }))
await vi.waitFor(() => { expect(files).toHaveBeenCalledOnce() })
controller.abort()
gate.resolve({ ok: true, value: [] })
await expect(pending).resolves.toEqual([])
})
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/client/ui-settings-general/README.md
README.md: 2337b7db8f5b8d2713d17f83092d948657a5acdb
README.zh.md: 2fd25f3d1b44c400b4400397e94d641766d9ae62
README.md: e06ecf5f36234f634cbff708f592fbe83c9f175e
README.zh.md: 6eb54b49a7a7f1a55a6f9389c57f736533715185
@@ -33,9 +33,11 @@ In Desktop, the account-row update control shows availability, progress, verific
### The General section
The current release version appears at the bottom of General Settings in Web and Desktop, using the build’s `DSH_CLIENT_VERSION` metadata and the active language. Partial builds without version metadata omit the row.
The Developer tools switch controls the shared preference described by [ui-settings](../ui-settings/README.md#use-this-package). It is available in both Web and desktop, follows accepted changes immediately, and disables duplicate input while a write settles. A failed write displays localized retry guidance.
The General section holds the built-in Developer tools row and rows registered into `settings.general.item` by feature packages. Each registrant owns its row copy and behavior. The Appearance row, for example, lives in ui-theme.
The General section holds the built-in Developer tools and Current version rows alongside rows registered into `settings.general.item` by feature packages. Each registrant owns its row copy and behavior. The Appearance row, for example, lives in ui-theme.
### Opening the configuration file
@@ -53,7 +55,7 @@ The onboarding ledger projects in ascending order and mounts exactly one step at
<details>
<summary>Implementation internals — click to expand</summary>
The shell owns the chrome and the projections; it contributes the Developer tools row, while feature registrants own their additional content and copy.
The shell owns the chrome and the projections; it contributes the Developer tools and Current version rows, while feature registrants own their additional content and copy.
### Ledger projections
@@ -104,7 +106,7 @@ None; this package neither assembles nor sends a provider request.
These limits define what the shell itself provides versus what features must supply; they are current package constraints.
- **Additional General rows require their feature plugins** — the shell supplies Developer tools; feature plugins supply the remaining preferences.
- **Additional General rows require their feature plugins** — the shell supplies Developer tools and Current version; feature plugins supply the remaining preferences.
- **The Windows caption badge keeps a side-opening bubble** — `DesktopUpdateBadge` occupies `sidebar.toggle.badge` in the caption and requests `side="right"`, so the Desktop-owned menu text can cover its bubble while the sidebar is collapsed on Windows; the sidebar toggle and New Session bubbles open below the caption instead (#4688).
<a id="dev-note"></a>
@@ -33,9 +33,11 @@ kind: "package-reference"
### 「通用」分区
Web 与桌面端的通用设置底部显示当前发布版本,使用构建注入的 `DSH_CLIENT_VERSION` 元数据和当前语言。缺少版本元数据的局部构建不显示该行。
开发者工具开关控制 [ui-settings](../ui-settings/README.zh.md#use-this-package) 定义的共享偏好。Web 和桌面端均提供此开关,立即跟随已接受的变更,并在写入完成前禁用重复输入。写入失败时显示本地化的重试提示。
「通用」分区承载内置的开发者工具行和功能包注册进 `settings.general.item` 的行。每个注册方拥有自己的行文案与行为。例如「外观」行位于 ui-theme。
「通用」分区承载内置的开发者工具行与当前版本行,以及功能包注册进 `settings.general.item` 的行。每个注册方拥有自己的行文案与行为。例如「外观」行位于 ui-theme。
### 打开配置文件
@@ -53,7 +55,7 @@ kind: "package-reference"
<details>
<summary>实现细节——点击展开</summary>
外壳拥有界面框架与投影,并提供开发者工具行;功能注册方拥有其余内容与文案。
外壳拥有界面框架与投影,并提供开发者工具行与当前版本行;功能注册方拥有其余内容与文案。
### 账本投影
@@ -104,7 +106,7 @@ kind: "package-reference"
这些限制说明外壳自身提供什么、功能包必须提供什么;它们是当前包约束。
- **额外的通用设置行需要相应功能插件**:外壳提供开发者工具;功能插件提供其他偏好。
- **额外的通用设置行需要相应功能插件**:外壳提供开发者工具与当前版本;功能插件提供其他偏好。
- **Windows 顶栏徽标的气泡仍向右展开**:`DesktopUpdateBadge` 占用顶栏的 `sidebar.toggle.badge` 且请求 `side="right"`,侧栏收起时其气泡会被 Desktop 菜单文字遮挡;侧栏开关与新建会话的气泡则改在顶栏下方展开(#4688)。
<a id="dev-note"></a>
@@ -0,0 +1,7 @@
.row {
padding: 16px 0;
color: var(--dsw-alias-label-primary);
font-size: 14px;
line-height: 22px;
overflow-wrap: anywhere;
}
@@ -0,0 +1,14 @@
/** Installed release version in General Settings for Web and Desktop. */
import type { PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
import css from './CurrentVersionRow.module.css'
/**
* Render the version embedded by the client build; partial builds without metadata omit the row.
* @param props - runtime share and localized copy.
* @returns the current release label, or nothing when build metadata is absent.
*/
export function CurrentVersionRow({ t }: PropsRuntime<'settings.general.item'> & PropsLocale<'settings'>) {
const version = process.env.DSH_CLIENT_VERSION
if (version === undefined) return null
return <div className={css.row}>{t('general.currentVersion', { version })}</div>
}
@@ -29,6 +29,7 @@ import type { DesktopUpdateBridge } from './desktop-update-bridge.ts'
import { DesktopUpdateSource } from './desktop-update-source.ts'
import { CloseLabel, HeaderContent, TriggerContent } from './chrome.tsx'
import { GeneralSection } from './GeneralSection.tsx'
import { CurrentVersionRow } from './CurrentVersionRow.tsx'
import { DeveloperToolsRow, type DeveloperToolsRowInjected } from './DeveloperToolsRow.tsx'
import { SettingsDocumentAction } from './SettingsDocumentAction.tsx'
import type { SettingsDocumentActionInjected } from './SettingsDocumentAction.tsx'
@@ -76,6 +77,10 @@ export function apply(ctx: ClientContext): void {
setEnabled: enabled => ctx.settingsScope.developerTools.setEnabled(enabled),
}),
}, DeveloperToolsRow))
// Last row: every feature-registered preference row orders below 100.
ctx.slots.inject('settings.general.item', () => ctx.slots.register({
name: 'settings.general.item', id: 'current-version', order: 100, locale: NS,
}, CurrentVersionRow))
ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'ui-settings-general: dictionaries')
const connection = ctx.get('connection') as ConnectionHandle
const carrier = (globalThis as typeof globalThis & { dshDesktop?: { protocolVersion: number; updates?: DesktopUpdateBridge } }).dshDesktop
@@ -26,6 +26,7 @@ export const zh = {
'openDocument': '打开配置文件',
'openDocument.error': '无法打开配置文件',
'general.nav': '通用设置',
'general.currentVersion': '当前版本:{version}',
'developerTools.title': '开发者工具',
'developerTools.error': '保存失败,请重试',
'developerTools.description': '显示用于调试和排查问题的工具与信息',
@@ -65,6 +66,7 @@ export const en = {
'openDocument': 'Open configuration file',
'openDocument.error': 'Could not open configuration file',
'general.nav': 'General',
'general.currentVersion': 'Current version: {version}',
'developerTools.title': 'Developer tools',
'developerTools.error': 'Could not save. Please try again.',
'developerTools.description': 'Show tools and information for debugging and troubleshooting',
@@ -102,7 +102,7 @@ describe('ui-settings-general apply', () => {
expect(generalLabel(c)).toBe('通用设置')
expect(c.ctx.slots.spec('settings.general.item')).toEqual({ kind: 'list', scope: 'root' })
// The shared developer-tool control belongs to General; onboarding remains feature-owned.
expect(c.ctx.slots.entries('settings.general.item').filter(row => row.locale === NS).map(row => row.options.id)).toEqual(['developer-tools'])
expect(c.ctx.slots.entries('settings.general.item').filter(row => row.locale === NS).map(row => row.options.id)).toEqual(['developer-tools', 'current-version'])
expect(c.ctx.slots.entries('settings.onboarding').filter(row => row.locale === NS)).toEqual([])
const developerRow = c.ctx.slots.entries('settings.general.item').find(row => row.options.id === 'developer-tools')!
const developer = (developerRow.inject as unknown as () => DeveloperToolsRowInjected)()
@@ -213,7 +213,7 @@ describe('ui-settings-general apply', () => {
expect(ownEntries(c, name)[0]).not.toBe(before[index])
})
expect(c.ctx.slots.spec('settings.general.item')).toEqual({ kind: 'list', scope: 'root' })
expect(c.ctx.slots.entries('settings.general.item').filter(row => row.locale === NS).map(row => row.options.id)).toEqual(['developer-tools'])
expect(c.ctx.slots.entries('settings.general.item').filter(row => row.locale === NS).map(row => row.options.id)).toEqual(['developer-tools', 'current-version'])
// The recovered registrations still ride the locale path.
const english = localeView('en', 1)
const chinese = localeView('zh', 2)
@@ -237,6 +237,7 @@ describe('ui-settings-general apply', () => {
await c.unload(SELF)
await c.flush()
for (const [name] of SEATS) expect(ownEntries(c, name)).toHaveLength(0)
expect(c.ctx.slots.entries('settings.general.item').filter(row => row.locale === NS)).toEqual([])
expect(c.ctx.slots.spec('settings.general.item')).toBeUndefined()
})
})
@@ -22,11 +22,12 @@ function derivedDocumentStore(remote: object) {
const ctx = { remote } as never
return new SettingsDocumentStore(ctx, new SettingsDescribeMirror(ctx))
}
import { en } from '../src/client/locales.ts'
import { en, zh } from '../src/client/locales.ts'
import { CurrentVersionRow } from '../src/client/CurrentVersionRow.tsx'
import { DesktopUpdateBadge } from '../src/client/DesktopUpdateIndicator.tsx'
import type { DesktopUpdateView } from '../src/client/desktop-update-bridge.ts'
afterEach(cleanup)
afterEach(() => { cleanup(); vi.unstubAllEnvs() })
// The seat's key domain is settings ∪ common; the stub answers from the
// package dictionary and falls back to the key like the real chain.
@@ -222,3 +223,26 @@ it('reports a failed developer-tool write and allows retry', async () => {
await waitFor(() => { expect(toggle.getAttribute('aria-checked')).toBe('true') })
expect(screen.queryByRole('alert')).toBeNull()
})
describe('current version', () => {
it.each([
['Current version: 1.2.3-rc.4', en],
['当前版本:1.2.3-rc.4', zh],
])('renders the localized release label %s', (expected, dictionary) => {
vi.stubEnv('DSH_CLIENT_VERSION', '1.2.3-rc.4')
const translate: TriggerContentProps['t'] = (key, params) => {
let text = (dictionary as Record<string, string>)[key] ?? key
for (const [name, value] of Object.entries(params ?? {})) text = text.replace(`{${name}}`, String(value))
return text
}
render(<CurrentVersionRow {...kit} t={translate} />)
expect(screen.getByText(expected)).toBeTruthy()
expect(screen.queryByRole('button')).toBeNull()
})
it('omits the row when a partial build has no version metadata', () => {
vi.stubEnv('DSH_CLIENT_VERSION', undefined)
const view = render(<CurrentVersionRow {...kit} t={t} />)
expect(view.container.textContent).toBe('')
})
})
+2 -2
View File
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/client/ui-skill/README.md
README.md: b2687d59fc6566d9ea8a0c0d4d5b16eea38282b5
README.zh.md: 7e6c8a7ecee2fe10d5826b91c108733bb4d3746f
README.md: fdd8cb7fddb54cf73d897bb278086428d0e41214
README.zh.md: 2090711d5f91bf1efba2f8e19d8d361f9ffb99d9
+2
View File
@@ -51,6 +51,8 @@ The source implements no adjudication hooks and no reference codec: the pick lan
Catalogs cache per ordinary session with a single-flight fetch; the scope-birth `warm` hook prewarms the session's entry, the forwarded `agent-preset/selected` owner event drops that one session's entry (the catalog belongs to the preset, and a blank session may switch after the warm), and `connection/reset` clears everything. Catalog-addressed continuable children resolve no skill candidates locally because the existing skill RPC requires an attached session; viewing their persisted history must not activate them. The list RPC rides the plugin's root-context connection captured at registration; draft chip visuals derive from the `lexicon` scan.
Each catalog fetch requires an existing retained Client Session and waits for its initial history open to succeed before sending `skills/list`. A temporary `skillCatalog` reference holds that Session until the fetch settles. An unretained Session or a failed open rejects without sending the RPC; preset invalidation, connection reset, and plugin disposal also cancel a pending history wait.
### Registration
The `/client` exports are the plugin body (`apply`/`inject`) only; the source object is internal to the registration effect. The tool row registers the `skill` wire name in ui-tool's keyed `tool.call.toolview` slot.
+2
View File
@@ -51,6 +51,8 @@ source 不实现任何裁决钩子,也没有引用 codec:pick 落下字面
目录按普通会话缓存,拉取走 single-flight;scope 创建时的 `warm` 钩子预热该会话的缓存项,转发的 owner 事件 `agent-preset/selected` 丢弃该会话这一项(目录属于 preset,而空会话可能在预热之后才切换),`connection/reset` 清空全部缓存。由目录寻址的可继续 subagent 在客户端解析为没有 skill 候选,因为现有 skill RPC 要求会话已挂载;查看其持久化历史不得激活它。列表 RPC 使用插件注册时捕获的根上下文连接;草稿 chip 视觉由 `lexicon` 扫描派生。
每次目录拉取都要求客户端已持有该会话,并等待首次历史打开成功后才发送 `skills/list`。临时的 `skillCatalog` 引用持续持有会话,直到拉取结束。未被持有的会话或打开失败会直接拒绝,不发送 RPC;预设失效、连接重置和插件释放也会取消正在等待的历史打开。
### 注册
`/client` 导出接口只有插件主体(`apply`/`inject`);source 对象是注册 effect 的内部实现。工具行把 `skill` wire 名称注册进 ui-tool 的 keyed `tool.call.toolview` slot。
+23 -4
View File
@@ -25,6 +25,8 @@
* not kill the prewarm other consumers will hit, so it carries its own
* abort (fired only on invalidation/teardown) while a candidates caller
* with an aborted signal just returns early.
* Catalog RPCs retain an existing Client Session through completion and
* wait for its initial history open to succeed before contacting the Host.
*
* This browser half also owns the `skill` keyed toolview: a replay-stable
* accent row derived only from each logged call/result slice.
@@ -45,6 +47,13 @@ import type {} from '@deepseek-ai/dsh-client-ui-renderer/client'
import { SkillRow } from './SkillRow.tsx'
import { en, NS, zh, type SkillKey } from './locales.ts'
declare module '@deepseek-ai/dsh-api-session-controller/client' {
interface SessionReferenceSourceMap {
/** A skill-catalog fetch waiting for initial history and its RPC result. */
skillCatalog: unknown
}
}
declare module '@deepseek-ai/dsh-client-ui-slots' {
interface LocaleNamespaceMap {
/** The dedicated skill tool row's copy. */
@@ -100,10 +109,20 @@ export function apply(ctx: ClientContext): void {
if (existing !== undefined) return existing
const abort = new AbortController()
const promise = (async () => {
const result = await skills.list({ sessionId }, abort.signal)
abort.signal.throwIfAborted()
if (!result.ok) throw new Error(`skills/list failed: ${result.error.code}: ${result.error.message}`)
return result.value.skills
if (sessions.binding(sessionId) === undefined) {
throw new Error(`skill catalog requires a retained session "${sessionId}"`)
}
return sessions.using(sessionId, { source: 'skillCatalog', signal: abort.signal }, async (reference) => {
abort.signal.throwIfAborted()
const state = reference.binding.session.getSnapshot()
if (state.openState !== 'open') {
throw state.openError ?? new Error(`session "${sessionId}" is not open`)
}
const result = await skills.list({ sessionId }, abort.signal)
abort.signal.throwIfAborted()
if (!result.ok) throw new Error(`skills/list failed: ${result.error.code}: ${result.error.message}`)
return result.value.skills
})
})()
const entry: CatalogFetch = { promise, abort }
fetches.set(sessionId, entry)
@@ -18,7 +18,8 @@ import { describe, expect, it, onTestFinished, vi } from 'vitest'
import type { SessionId } from '@deepseek-ai/dsh-session/types'
import { SlotRegistry } from '@deepseek-ai/dsh-client-ui-renderer/client'
import { InputTriggerService } from '@deepseek-ai/dsh-client-ui-input-trigger/client'
import { RemoteError, TestRemote } from '@deepseek-ai/dsh-client-test-runtime'
import { RemoteError, TestRemote, TestSessions } from '@deepseek-ai/dsh-client-test-runtime'
import type { SessionFixture } from '@deepseek-ai/dsh-client-test-runtime'
import type { RemoteFailure } from '@deepseek-ai/dsh-api-remotes/client'
import type { ClientSessionContext, InputTriggerSource } from '@deepseek-ai/dsh-client-ui-input-trigger/client'
import { apply, inject } from '../src/client/index.ts'
@@ -60,24 +61,30 @@ function providePresentation(ctx: Context): PresentationCapture {
}
/** Boot the plugin over fake slash/connection faces; returns the captured source and its ctx. */
async function bench(list: ListFn, addressed?: SessionId) {
async function bench(list: ListFn, addressed?: SessionId, opening: Pick<SessionFixture, 'initialOpen' | 'snapshot'> = {}) {
const ctx = new Context()
const openResource = vi.fn()
ctx.provide('sidebarRight', { openResource })
let captured: InputTriggerSource | undefined
ctx.provide('inputTriggers', { registerSource: (src: InputTriggerSource) => { captured = src; return () => {} } })
ctx.provide('sessions', {
list: { getSnapshot: () => ({ byId: {} }) },
subagentAddress: (id: SessionId) => id === addressed
? { parentSessionId: sid('parent'), childSessionId: id, mode: 'continuable' as const }
: undefined,
const sessions = new TestSessions(async (action) => { await action() }, ctx)
ctx.provide('sessions', sessions)
onTestFinished(async () => {
await sessions.disposeScopes()
await ctx.fiber.dispose()
})
for (const id of ['s1', 's2', 'preview', 'first', 'second', 'child']) {
await sessions.add({ id, ...opening })
sessions.retainFor(ctx, id === addressed
? { parentSessionId: sid('parent'), childSessionId: addressed, mode: 'continuable' }
: sid(id))
}
const remote = new TestRemote(ctx, { skills: { list } })
providePresentation(ctx)
const fiber = ctx.plugin({ inject: [...inject], apply })
onTestFinished(async () => { await fiber.dispose() })
await fiber.await()
return { ctx, source: captured!, remote, fiber, openResource }
return { ctx, source: captured!, remote, fiber, openResource, sessions }
}
const CATALOG: SkillRow[] = [
@@ -174,6 +181,60 @@ describe('apply', () => {
})
describe('candidates: sessionId addressing', () => {
it('waits for history and retains the Session until the shared skill RPC settles', async () => {
const opened = Promise.withResolvers<undefined>()
const response = Promise.withResolvers<ListResult>()
const list = vi.fn(() => response.promise)
const b = await bench(list, undefined, { initialOpen: () => opened.promise })
try {
const pending = b.source.candidates(proj('s1'), req(''))
expect(b.sessions.retainInfo(sid('s1')).getSnapshot().retainedBy.skillCatalog).toBe(1)
expect(list).not.toHaveBeenCalled()
opened.resolve(undefined)
await vi.waitFor(() => { expect(list).toHaveBeenCalledOnce() })
expect(b.sessions.retainInfo(sid('s1')).getSnapshot().referenceCount).toBe(2)
response.resolve({ ok: true, value: { skills: CATALOG } })
await pending
expect(b.sessions.retainInfo(sid('s1')).getSnapshot().referenceCount).toBe(1)
} finally {
opened.resolve(undefined)
response.resolve({ ok: true, value: { skills: CATALOG } })
}
})
it.each([true, false])('refuses an unsuccessful history open (reported error: %s)', async (reported) => {
const error = new RemoteError('gateway/internal', 'history unavailable', {})
const list = vi.fn(listOk(CATALOG))
const b = await bench(list, undefined, { snapshot: { openState: 'error', openError: reported ? error : null } })
await expect(b.source.candidates(proj('s1'), req(''))).rejects.toThrow(reported ? 'history unavailable' : 'is not open')
expect(list).not.toHaveBeenCalled()
expect(b.sessions.retainInfo(sid('s1')).getSnapshot().referenceCount).toBe(1)
})
it('does not reopen a released Session for skill discovery', async () => {
const list = vi.fn(listOk(CATALOG))
const b = await bench(list)
await b.sessions.binding(sid('s1'))!.ctx.fiber.dispose()
await expect(b.source.candidates(proj('s1'), req(''))).rejects.toThrow('requires a retained session')
expect(list).not.toHaveBeenCalled()
expect(b.sessions.binding(sid('s1'))).toBeUndefined()
})
it('releases the shared read when the plugin unloads before history is ready', async () => {
const opened = Promise.withResolvers<undefined>()
const list = vi.fn(listOk(CATALOG))
const b = await bench(list, undefined, { initialOpen: () => opened.promise })
try {
const pending = expect(b.source.candidates(proj('s1'), req(''))).rejects.toThrow()
await b.fiber.dispose()
await pending
expect(list).not.toHaveBeenCalled()
expect(b.sessions.retainInfo(sid('s1')).getSnapshot().referenceCount).toBe(1)
} finally {
opened.resolve(undefined)
}
})
it('lists via {sessionId} and ranks case-insensitive subsequence matches with prefixes first', async () => {
const { list, payloads } = countingList()
const { source } = await bench(list)
@@ -406,7 +467,7 @@ describe('reference preview', () => {
expect(source.openReference!(session, { ref: '/review' })).toBe(true)
const candidates = source.candidates(session, req(''))
expect(openResource).not.toHaveBeenCalled()
expect(list).toHaveBeenCalledTimes(1)
await vi.waitFor(() => { expect(list).toHaveBeenCalledTimes(1) })
gate.resolve({ ok: true, value: { skills: rows } })
await candidates
expect(openResource).toHaveBeenCalledExactlyOnceWith('dsh-resource://file/session/preview//skills/review/SKILL.md')
+2 -2
View File
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/client/ui-subagent/README.md
README.md: d0dc10f17b2b83d7a605ee622ea027698ebeac77
README.zh.md: 1019fd3e7d6fbd8b259c6e94dd830c47ebb2058e
README.md: d1451ea0deecd3d6e39f4ce8a43abdcd3a8603e8
README.zh.md: e50985cd6934c881beecf4d9ff631af37e3a9297
+3 -1
View File
@@ -27,7 +27,7 @@ Use this package to browse every subagent conversation beneath a parent session,
The session header keeps the current session title as the lineage breadcrumb and appends a `/` count trigger when its direct catalog has entries or a read has failed. An absent catalog, an empty loading catalog, or a successfully loaded empty catalog hides the count trigger. The trigger opens that direct catalog, reports its total and running counts, and loads nested catalogs only when their rows expand. Select any depth to open that child's conversation with its exact `{parentSessionId, childSessionId, mode}` address, or use the row's trailing arrow to open the same address in the right Sidebar, preferring a separate pane when room permits.
This package registers the `dsh-resource://subagentchat/session/<child>?parent=<parent>&mode=<mode>` resource and builtin Sidebar tab type. The resource refreshes the direct-parent catalog before it retains the child `SessionReference`, and releases the reference when the tab record closes. The tab renders the shared `conversation.content` Factory through `sidebar.chat.conversation`, fixes the local View to Chat, and omits the main Conversation header and width controls.
This package registers the `dsh-resource://subagentchat/session/<child>?parent=<parent>&mode=<mode>` resource and builtin Sidebar tab type. The resource retains the child `SessionReference` directly from its address without refreshing the parent catalog, and releases the reference when the tab record closes. The tab renders the shared `conversation.content` Factory through `sidebar.chat.conversation`, fixes the local View to Chat, and omits the main Conversation header and width controls.
### Browsing the tree
@@ -55,6 +55,8 @@ The catalog and composer behavior are specified by the [Web subagent conversatio
The header lineage renderer reads `projectionsBySession` through the standard `useSessions` hook. The renderer selects `subagentCatalog` from each Session’s shared values for membership, disclosure, and counts; Activity prefers the unified UI status and falls back to Session summaries; summaries supply titles and usage. Expanding a row loads its initial catalog when needed. Live projection frames update every loaded level without menu subscriptions or repeated membership queries. A row remains expandable while its catalog is absent, loading, or failed, and becomes a known leaf after a ready empty catalog.
Opening a catalog dropdown does not request its root catalog. Child-catalog expansion and failed-read retries call `refreshProjection`; shared projection-value changes update the display automatically.
Breadcrumb addresses derive from the Provider-bound Session address and loaded parent catalogs, including never-selected ancestors.
### Duration, completion, and tokens
+3 -1
View File
@@ -27,7 +27,7 @@ kind: "package-reference"
会话页头保留当前会话 title 作为谱系面包屑,并在直接目录有子项或读取失败时追加 `/` 数量触发器。目录缺席、空目录加载中或成功加载为空时,均隐藏数量触发器。触发器打开该直接目录,报告总数与运行数,并且只在行展开时加载嵌套目录。选择任意深度,即可用该子会话的确切 `{parentSessionId, childSessionId, mode}` 地址打开其对话;也可以使用行尾箭头在右侧 Sidebar 打开同一地址,并在空间允许时优先使用独立分栏。
本包注册 `dsh-resource://subagentchat/session/<child>?parent=<parent>&mode=<mode>` 资源与 builtin Sidebar tab 类型。资源先刷新直接 parent 目录,再保留 child 的 `SessionReference`,并在 tab 记录关闭时释放 reference。tab 通过 `sidebar.chat.conversation` 渲染共享 `conversation.content` Factory,把局部 View 固定为 Chat,并省略主 Conversation 的 Header 与宽度控制。
本包注册 `dsh-resource://subagentchat/session/<child>?parent=<parent>&mode=<mode>` 资源与 builtin Sidebar tab 类型。资源直接根据地址保留 child 的 `SessionReference`,不刷新 parent 目录,并在 tab 记录关闭时释放 reference。tab 通过 `sidebar.chat.conversation` 渲染共享 `conversation.content` Factory,把局部 View 固定为 Chat,并省略主 Conversation 的 Header 与宽度控制。
### 浏览目录
@@ -55,6 +55,8 @@ kind: "package-reference"
页头谱系 renderer 通过标准 `useSessions` 钩子读取 `projectionsBySession`。renderer 从每个 Session 的共享值中选择 `subagentCatalog`,用于成员关系、展开控件与数量;活动状态优先使用统一 UI status,缺少时使用 Session 摘要;摘要提供标题与用量。展开行会按需加载初始目录。实时 projection 帧更新所有已加载层级,无需菜单订阅或重复成员查询。行在目录缺席、加载中或失败时保持可展开,并在目录就绪且为空后成为已知叶子。
打开目录下拉菜单不会请求其根目录。展开子节点目录或重试失败读取时调用 `refreshProjection`;共享投影值的变化会自动更新显示。
面包屑地址从 Provider 所绑定的 Session 地址和已加载的 parent 目录推导,也包括从未选中过的祖先。
### 耗时、完成状态与 token
@@ -25,7 +25,7 @@ type Catalogs = Readonly<Record<SessionId, SubagentCatalogSnapshot>>
export interface SubagentCatalogInjected {
openChild: (address: SubagentAddress) => void
openChildAside: (address: SubagentAddress) => void
refresh: (parentSessionId: SessionId) => void
refreshProjection: (parentSessionId: SessionId) => void
}
/** Full props for the session-header lineage renderer. */
@@ -42,7 +42,7 @@ interface CatalogRowsProps {
level: number
openChild: (address: SubagentAddress) => void
openChildAside: (address: SubagentAddress) => void
refresh: (parentSessionId: SessionId) => void
refreshProjection: (parentSessionId: SessionId) => void
toggleBranch: (childSessionId: SessionId) => void
closeCatalog: () => void
}
@@ -200,7 +200,7 @@ function isKnownLeaf(catalog: SubagentCatalogSnapshot | undefined): boolean {
/** Render one catalog level and recurse only through explicitly expanded rows. */
function CatalogRows({
parentSessionId, currentSessionId, catalog, catalogs, summaries, expanded, level,
openChild, openChildAside, refresh, toggleBranch, closeCatalog, t,
openChild, openChildAside, refreshProjection, toggleBranch, closeCatalog, t,
}: CatalogRowsProps & { t: TranslateNS<typeof NS> }) {
const [now, setNow] = useState(() => Date.now())
const running = catalog.entries.some(entry => entry.activity === 'running')
@@ -222,7 +222,7 @@ function CatalogRows({
<button
type="button"
className={css.refresh}
onClick={() => { refresh(parentSessionId) }}
onClick={() => { refreshProjection(parentSessionId) }}
>
<IconRefreshOutlineRegular size={14} />
{t('retry')}
@@ -383,7 +383,7 @@ function CatalogRows({
level={level + 1}
openChild={openChild}
openChildAside={openChildAside}
refresh={refresh}
refreshProjection={refreshProjection}
toggleBranch={toggleBranch}
closeCatalog={closeCatalog}
t={t}
@@ -446,7 +446,7 @@ function catalogMenuPosition(trigger: HTMLButtonElement): CSSProperties {
/** One trigger-plus-tree dropdown over the catalog rooted at `rootSessionId`. */
function CatalogDropdown({
rootSessionId, currentSessionId, displayTitle, openTitle, variant, separator = false,
useSessions, useSessionStatus, openChild, openChildAside, refresh, t,
useSessions, useSessionStatus, openChild, openChildAside, refreshProjection, t,
}: CatalogDropdownProps) {
const ancestorSwitcher = variant === 'switcher' && openTitle !== undefined
const projections = useSessions(state => state.projectionsBySession)
@@ -506,7 +506,6 @@ function CatalogDropdown({
if (trigger === null) return
setOpen(true)
setMenuPosition(catalogMenuPosition(trigger))
refresh(rootSessionId)
}
else {
setOpen(false)
@@ -555,7 +554,7 @@ function CatalogDropdown({
return
}
setExpanded(current => new Set(current).add(childSessionId))
refresh(childSessionId)
refreshProjection(childSessionId)
}
useEffect(() => {
@@ -711,7 +710,7 @@ function CatalogDropdown({
level={1}
openChild={openChild}
openChildAside={openChildAside}
refresh={refresh}
refreshProjection={refreshProjection}
toggleBranch={toggleBranch}
closeCatalog={() => { changeOpen(false) }}
t={t}
@@ -729,7 +728,7 @@ function CatalogDropdown({
*/
export function SubagentHeaderLineage({
lineageSessionId, displayTitle, openTitle,
useSessions, useSession, useSessionStatus, openChild, openChildAside, refresh, t,
useSessions, useSession, useSessionStatus, openChild, openChildAside, refreshProjection, t,
}: SubagentHeaderLineageProps) {
const address = useSession(session => session.subagent?.address)
const parentId = useSessions((state) => {
@@ -739,7 +738,7 @@ export function SubagentHeaderLineage({
}
return undefined
})
const shared = { useSessions, useSessionStatus, openChild, openChildAside, refresh, t }
const shared = { useSessions, useSessionStatus, openChild, openChildAside, refreshProjection, t }
if (parentId === undefined) {
return (
<CatalogDropdown
@@ -57,7 +57,6 @@ export function apply(ctx: ClientContext): void {
ctx.inject(['resources', 'sidebarRightTabs'], (scope) => {
registerSidebarChat(scope, ctx.locale.bind(NS))
})
const sessions = ctx.sessions
const catalogActions = (_parentSessionId: SessionId): SubagentCatalogInjected => ({
openChild(address: SubagentAddress) {
ctx.uiWorkspace.openSession(address)
@@ -68,8 +67,8 @@ export function apply(ctx: ClientContext): void {
preferNewPane: true,
})
},
refresh(parentSessionId: SessionId) {
void sessions.refreshProjections(parentSessionId)
refreshProjection(parentSessionId: SessionId) {
void ctx.sessions.refreshProjections(parentSessionId)
},
})
ctx.slots.inject(
@@ -106,8 +106,6 @@ function subagentChatResourceProvider(sessions: ISessions): ResourceProvider<'su
const address = parseSubagentChatAddress(resourceAddress)
if (address === undefined) throw new Error(`ui-subagent: invalid chat resource address "${resourceAddress}"`)
if (isAbortRequested(signal)) return
await sessions.refreshProjections(address.parentSessionId)
if (isAbortRequested(signal)) return
const reference = sessions.retain(address, { source: 'sidebarChat', signal })
try {
yield { ok: true, value: { address, reference } }
@@ -119,7 +119,7 @@ describe('apply', () => {
}
actions.openChild(address)
actions.openChildAside(address)
actions.refresh(sid('parent'))
actions.refreshProjection(sid('parent'))
expect(face.actionCalls).toEqual([
{ method: 'openSession', args: [address] },
{
@@ -1,7 +1,7 @@
// @vitest-environment jsdom
import { afterEach, describe, expect, it, vi } from 'vitest'
import { act, cleanup, fireEvent, render, screen, within } from '@testing-library/react'
import { makeTranslate, RemoteError } from '@deepseek-ai/dsh-client-test-runtime'
import { makeTranslate, RemoteError, sessionSnapshot } from '@deepseek-ai/dsh-client-test-runtime'
import type {
SessionListState, SessionSummary, SessionSnapshot,
} from '@deepseek-ai/dsh-api-session-controller/client'
@@ -80,20 +80,42 @@ function props(
function useSessions<T>(select: (snapshot: SessionListState) => T): T {
return select(state)
}
const snapshot: SessionSnapshot = {
...sessionSnapshot(PARENT),
subagent: boundAddress === undefined ? null : { address: boundAddress },
}
const unused = (): never => { throw new Error('Subagent header fixture does not provide this slot source or action') }
const standard = {
usePanelInfo: unused,
useSessionRetainInfo: unused,
useWorkspaces: unused,
useResource: unused,
useProjection: unused,
useConversation: unused,
useInput: unused,
useChat: unused,
useTrajectory: unused,
inputActions: {
setDraft: unused,
addAttachments: unused,
removeAttachment: unused,
pruneAttachments: unused,
submit: unused,
},
}
return {
...standard,
sessionId: PARENT,
useSessions,
useSessionStatus: <T,>(select: (snapshot: SessionStatusSnapshot) => T): T => select(statuses),
useSession: <T,>(select: (snapshot: SessionSnapshot) => T): T => select({
subagent: boundAddress === undefined ? undefined : { address: boundAddress },
} as SessionSnapshot),
useSession: <T,>(select: (snapshot: SessionSnapshot) => T): T => select(snapshot),
openChild: vi.fn(),
openChildAside: vi.fn(),
refresh: vi.fn(),
refreshProjection: vi.fn(),
lineageSessionId: PARENT,
displayTitle: 'Parent title',
t,
} as unknown as SubagentHeaderLineageProps
} satisfies SubagentHeaderLineageProps
}
function summary(id: SessionId, updatedAt: number): SessionSummary {
@@ -208,7 +230,7 @@ describe('SubagentHeaderLineage', () => {
const trigger = screen.getByRole('button', { name: /1 个子代理,正在运行/ })
hoverCatalog(trigger)
expect(input.refresh).toHaveBeenCalledWith(PARENT)
expect(input.refreshProjection).not.toHaveBeenCalled()
expect(screen.getAllByRole('treeitem')).toHaveLength(2)
expect(screen.getByText('正在扫描项目文件 · 可继续 · 正在运行')).toBeTruthy()
expect(screen.getByText('一次性 · 当前未运行')).toBeTruthy()
@@ -570,7 +592,7 @@ describe('SubagentHeaderLineage', () => {
hoverCatalog(screen.getByRole('button', { name: /1 个子代理,正在运行/ }))
fireEvent.click(screen.getByRole('button', { name: '展开 worker 的下级子代理' }))
expect(input.refresh).toHaveBeenCalledWith(CHILD)
expect(input.refreshProjection).toHaveBeenCalledWith(CHILD)
const nested = screen.getByRole('treeitem', { name: /indexer/ })
expect(nested.getAttribute('aria-level')).toBe('2')
@@ -598,7 +620,7 @@ describe('SubagentHeaderLineage', () => {
hoverCatalog(screen.getByRole('button', { name: /1 个子代理,正在运行/ }))
fireEvent.click(screen.getByRole('button', { name: '展开 worker 的下级子代理' }))
expect(deferred.refresh).toHaveBeenCalledWith(CHILD)
expect(deferred.refreshProjection).toHaveBeenCalledWith(CHILD)
expect(screen.getByRole('group').getAttribute('aria-busy')).toBe('true')
expect(screen.getByText('正在加载子代理…')).toBeTruthy()
expect(screen.queryByRole('treeitem', { name: '正在加载子代理' })).toBeNull()
@@ -687,7 +709,7 @@ describe('SubagentHeaderLineage', () => {
hoverCatalog(screen.getByRole('button', { name: /0 个子代理/ }))
expect(screen.getByText('index down')).toBeTruthy()
fireEvent.click(screen.getByRole('button', { name: /重试/ }))
expect(failed.refresh).toHaveBeenCalledWith(PARENT)
expect(failed.refreshProjection).toHaveBeenCalledWith(PARENT)
})
it('does not expose summary-only descendants as catalog rows', () => {
@@ -710,8 +732,7 @@ describe('SubagentHeaderLineage', () => {
})
it('hides a bare loading catalog and keeps the error fallback without focusable rows', async () => {
// Selecting any session schedules a catalog refresh; a loading snapshot
// with no other evidence of children must not flash the action in.
// A loading snapshot without evidence of children must not flash the action in.
const loading = props(catalog({ entries: [], state: 'loading' }))
const view = render(<SubagentHeaderLineage {...loading} />)
expect(screen.queryByRole('button')).toBeNull()
@@ -795,7 +816,7 @@ describe('SubagentHeaderLineage', () => {
expect(switcherIcon?.getAttribute('height')).toBe('16')
hoverCatalog(switcher)
expect(input.refresh).toHaveBeenCalledWith(PARENT)
expect(input.refreshProjection).not.toHaveBeenCalled()
const current = screen.getByRole('treeitem', { name: /worker/ })
expect(current.getAttribute('aria-current')).toBe('true')
expect(within(current).getByText('worker').className).toContain('currentLabel')
@@ -896,7 +917,7 @@ describe('SubagentHeaderLineage', () => {
render(<SubagentHeaderLineage {...input} />)
expect(screen.getByRole('button', { name: '切换子代理:正在扫描项目文件' })).toBeTruthy()
expect(input.refresh).not.toHaveBeenCalled()
expect(input.refreshProjection).not.toHaveBeenCalled()
})
it('keeps a nested title switcher scoped to its direct-parent catalog', () => {
@@ -922,7 +943,7 @@ describe('SubagentHeaderLineage', () => {
hoverCatalog(screen.getByRole('button', { name: '切换子代理:indexer' }))
expect(input.refresh).toHaveBeenCalledWith(CHILD)
expect(input.refreshProjection).not.toHaveBeenCalled()
const current = screen.getByRole('treeitem', { name: /indexer/ })
expect(current.getAttribute('aria-current')).toBe('true')
expect(within(current).getByText('indexer').className).toContain('currentLabel')
@@ -116,7 +116,7 @@ describe('Sidebar chat registration', () => {
const controller = new AbortController()
const stream = provider!.open(subagentChatAddress(ADDRESS), { signal: controller.signal })[Symbol.asyncIterator]()
expect(await stream.next()).toEqual({ done: false, value: { ok: true, value: { address: ADDRESS, reference } } })
expect(refreshProjections).toHaveBeenCalledWith(PARENT)
expect(refreshProjections).not.toHaveBeenCalled()
expect(retain).toHaveBeenCalledWith(ADDRESS, { source: 'sidebarChat', signal: controller.signal })
const completion = stream.next()
await Promise.resolve()
@@ -131,17 +131,6 @@ describe('Sidebar chat registration', () => {
expect(await yielded.next()).toEqual({ done: true, value: undefined })
expect(release).toHaveBeenCalledTimes(2)
let finishRefresh: (() => void) | undefined
refreshProjections.mockImplementationOnce(() => new Promise<void>((resolve) => { finishRefresh = resolve }))
const abortedDuringRefresh = new AbortController()
const pending = provider!.open(
subagentChatAddress(ADDRESS), { signal: abortedDuringRefresh.signal },
)[Symbol.asyncIterator]().next()
await vi.waitFor(() => { expect(finishRefresh).toBeTypeOf('function') })
abortedDuringRefresh.abort()
finishRefresh?.()
expect(await pending).toEqual({ done: true, value: undefined })
const alreadyAborted = new AbortController()
alreadyAborted.abort()
const stopped = provider!.open(subagentChatAddress(ADDRESS), { signal: alreadyAborted.signal })[Symbol.asyncIterator]()
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/client/ui-workspace/README.md
README.md: 7d9ed7d8410998a41cec47fd6d4acd916bdf5de9
README.zh.md: 3595d48d1cd101dc6a892aedca8f9b35d0497ad7
README.md: ef58466364ae97707f1e0b5f0fdab5dc86c57dd5
README.zh.md: 1cf866a0f3813619e5d3637dc053d49e28ab18ec
+2
View File
@@ -67,6 +67,8 @@ The value is intentionally best effort for cold Sessions. An identity-matching u
`ctx.uiWorkspace.openSession(target)` synchronously replaces the owned `mainView` reference and returns the main area to Conversation without waiting for `reference.ready`, so history loading renders inside the selected Session view. The target may be a known Session id or a durable direct-parent subagent address; an explicit address does not require a preloaded parent catalog. `openWorkspace(id, beforeOpen?)` opens its result only if no later navigation has superseded the request; New Session uses `openWorkspace`. `forkSession(id)` creates the child without navigating or superseding a pending navigation. The optional synchronous preparation callback runs after the target is retained and only for a current Workspace request, so superseded requests do not move composer drafts. Navigation or owner disposal suppresses the late UI commit, not the underlying Session creation. Startup restoration retains its main reference without changing the selected panel or cancelling a later navigation. Archiving the main Session releases its reference and clears the main selection. Selection failure leaves a global panel visible. Session rows read `usePanelInfo` to suppress their selected appearance while a global panel is active; search and directory-picker focus alone do not leave that panel.
Navigation and startup restoration obtain the selected Session's projections from its `follow`; neither issues a separate projection refresh for that Session or its parent.
New Session tries to acquire the first eligible blank in catalog order; startup restoration tries the saved blank. If that writer is held, navigation creates a new Session without trying other blanks. Other acquisition failures abort the request and are currently reported only to the console. Released blanks retain their slash-command state when reused. Later navigation cancels a pending startup selection.
<a id="understand-the-implementation"></a>
@@ -67,6 +67,8 @@ Session 行渲染运行时的实时 `pendingInteraction` 分类:审批显示**
`ctx.uiWorkspace.openSession(target)` 会同步替换其拥有的 `mainView` reference,并让主区域返回 Conversation,而不等待 `reference.ready`,因此历史加载会显示在已经选中的 Session 视图内。目标可以是已知 Session id,也可以是持久的直接父子 subagent 地址;显式地址不要求预先加载 parent catalog。`openWorkspace(id, beforeOpen?)` 仅在请求未被后续导航替代时打开结果;新会话使用 `openWorkspace`。`forkSession(id)` 创建子会话,不导航,也不替代尚未完成的导航。可选的同步准备回调在目标被 retain 后执行,并且仅对仍有效的 Workspace 请求执行,因此过期请求不会搬移 composer 草稿。后续导航或 owner 释放会阻止晚到的 UI 提交,但不取消底层 Session 创建。启动恢复会 retain 主 reference,不改变已选面板,也不取消后续导航。归档主 Session 会释放其 reference 并清除主选择。选择失败时保留当前全局面板。Session 行读取 `usePanelInfo`,在全局面板活跃时不显示 Session 选中样式;仅把焦点移到搜索框或目录选择器不会离开该面板。
导航和启动恢复通过所选 Session 的 `follow` 获取投影,不会另行刷新该 Session 或其父会话的投影。
新建会话尝试获取列表中第一个符合条件的空白会话;启动恢复尝试已保存的空白会话。若该写锁被占用,导航直接新建 Session,不再尝试其他空白会话。其他获取错误会中止请求,目前只报告到控制台。已释放的空白会话被复用时保留 slash 命令状态。后续导航会取消尚未完成的启动选择。
<a id="understand-the-implementation"></a>
@@ -303,7 +303,6 @@ class UiWorkspaceService extends Service implements UiWorkspace {
private async restoreSelection(workspaces: WorkspaceSnapshot, sessions: SessionListState): Promise<void> {
const saved = this.selection.getSnapshot()
if (saved.subagentAddress !== undefined) {
void this.sessions.refreshProjections(saved.subagentAddress.parentSessionId)
this.replaceMain(saved.subagentAddress, this.lifetime.signal, 'preserve')
return
}
@@ -373,7 +372,6 @@ class UiWorkspaceService extends Service implements UiWorkspace {
const previous = this.mainReference
this.mainReference = reference
previous?.release()
void this.sessions.refreshProjections(reference.sessionId)
if (panel === 'reveal') this.ctx.layout.selectPanel(null)
}
@@ -300,7 +300,7 @@ describe('UiWorkspaceService', () => {
b.uiWorkspace.openSession(sid('target'))
expect(b.selectPanel).toHaveBeenCalledWith(null)
expect(b.sessions.retain).toHaveBeenCalledWith(sid('target'), { source: 'mainView' })
expect(b.sessions.refreshProjections).toHaveBeenCalledWith(sid('target'))
expect(b.sessions.refreshProjections).not.toHaveBeenCalled()
})
it('keeps the current panel when retaining the target fails', () => {
@@ -827,10 +827,7 @@ describe('UiWorkspaceService', () => {
})
expect(b.sessions.retain).toHaveBeenCalledExactlyOnceWith(address, { source: 'mainView' })
expect(b.sessions.refreshProjections.mock.calls).toEqual([
[address.parentSessionId],
[address.childSessionId],
])
expect(b.sessions.refreshProjections).not.toHaveBeenCalled()
})
it('persists a catalog-resolved address after string subagent navigation', () => {
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/experimental/client-ui-agent-team/README.md
README.md: 34e89cfe92db9656cf2fc948e154627c0d5f9407
README.zh.md: e01cbbb534282fb2c6731579fc753954bcde314b
README.md: 6d45e618d937cfadcec238c6d0d7dcf3e8747501
README.zh.md: 879b1784bbe3d2ef6f3f66ab66b9dda7c820a0ed
@@ -29,7 +29,7 @@ Enable this package through [`@deepseek-ai/dsh-experimental-agent-team-profile`]
### Inspect and navigate the roster
Opening the panel calls `agentTeams/view`. Roster rows show durable names, turn availability, model, and diagnostics. Provisioning and running members use the shared ongoing loader, inactive members use idle, and failed members use error. Selecting a healthy teammate refreshes the existing direct-child catalog and opens the ordinary `{ parentSessionId, childSessionId, mode: 'continuable' }` address. History and later human prompts continue through the stable addressed-subagent conversation path; this package adds no Team-specific address field.
Opening the panel calls `agentTeams/view`. Roster rows show durable names, turn availability, model, and diagnostics. Provisioning and running members use the shared ongoing loader, inactive members use idle, and failed members use error. Selecting a healthy teammate opens the ordinary `{ parentSessionId, childSessionId, mode: 'continuable' }` address from its Lead and roster identity without refreshing or checking the parent catalog. The Host validates the parent, child, and mode when history opens. History and later human prompts continue through the stable addressed-subagent conversation path; this package adds no Team-specific address field.
### Manage the task board
@@ -29,7 +29,7 @@ kind: "package-reference"
### 检查并导航 roster
打开 panel 会调用 `agentTeams/view`。Roster row 展示持久 name、轮次可用状态、model 与 diagnostics。provisioning 和 running 成员使用共享 ongoing loading,inactive 成员使用 idle 灰点,failed 成员使用 error 红点。选择健康 teammate 时,系统刷新既有直接 child catalog,并打开普通的 `{ parentSessionId, childSessionId, mode: 'continuable' }` address。History 与后续人类提示词继续使用稳定 addressed-subagent 会话路径;本包不会添加 Team 专用 address 字段。
打开 panel 会调用 `agentTeams/view`。Roster row 展示持久 name、轮次可用状态、model 与 diagnostics。provisioning 和 running 成员使用共享 ongoing loading,inactive 成员使用 idle 灰点,failed 成员使用 error 红点。选择健康 teammate 时,系统直接根据其 Lead 与 roster 身份打开普通的 `{ parentSessionId, childSessionId, mode: 'continuable' }` address,不刷新或检查 parent catalog。Host 在打开历史时校验 parent、child 与 mode。History 与后续人类提示词继续使用稳定 addressed-subagent 会话路径;本包不会添加 Team 专用 address 字段。
### 管理任务板
@@ -44,7 +44,7 @@ export interface TeamActionInjected {
writeScopes?: string[]
owner?: string
}) => Promise<TeamTaskActionResult>
openTeammate: (sessionId: SessionId, member: TeamRosterMember) => Promise<void>
openTeammate: (sessionId: SessionId, member: TeamRosterMember) => void
}
/** Full props of the Team conversation-header action. */
@@ -312,7 +312,11 @@ export function TeamAction({
disabled={member.role === 'lead' || member.status === 'failed' || member.status === 'provisioning'}
title={member.role === 'teammate' ? t('open') : undefined}
onClick={() => {
void openTeammate(sessionId, member).catch((reason: unknown) => { setError(String(reason)) })
try {
openTeammate(sessionId, member)
} catch (reason) {
setError(String(reason))
}
}}
>
<StateDot state={memberDotState(member.status)} />
@@ -51,14 +51,10 @@ function registerUi(ctx: ClientContext): void {
...owner === undefined ? {} : { owner },
})
},
async openTeammate(sessionId: SessionId, member: TeamRosterMember): Promise<void> {
openTeammate(sessionId: SessionId, member: TeamRosterMember): void {
if (member.role !== 'teammate') return
const parentSessionId = leadSessionId(sessionId)
await sessions.refreshProjections(parentSessionId)
if ((sessions.retainInfo(sessionId).getSnapshot().retainedBy.mainView ?? 0) === 0) return
const catalog = sessions.list.getSnapshot().projectionsBySession[parentSessionId]
const child = catalog?.values.subagentCatalog?.find(entry => entry.id === member.id)
if (child === undefined) return
ctx.uiWorkspace.openSession({
parentSessionId,
childSessionId: member.id,
@@ -24,7 +24,6 @@ async function bench(options: {
conflict?: boolean
registrationFailure?: boolean
remoteFailure?: 'view' | 'update'
refreshGate?: Promise<void>
catalog?: 'missing' | 'empty'
} = {}) {
const ctx = new Context()
@@ -110,7 +109,7 @@ async function bench(options: {
: undefined,
refreshProjections: (id: SessionId) => {
navigation.push(['refresh', id])
return options.refreshGate ?? Promise.resolve()
return Promise.resolve()
},
retainInfo: (id: SessionId) => ({
getSnapshot: () => ({
@@ -185,7 +184,7 @@ describe('ui-team browser plugin', () => {
])
expect(b.calls.at(-1)?.args[1]).toMatchObject({ owner: 'worker' })
await actions.openTeammate(SESSION, {
actions.openTeammate(SESSION, {
id: SESSION,
name: 'lead',
role: 'lead',
@@ -238,7 +237,7 @@ describe('ui-team browser plugin', () => {
})
})
it('refreshes the descriptor catalog before opening a continuable teammate address', async () => {
it('opens a continuable teammate address without refreshing the parent catalog', async () => {
const b = await bench()
const actions = (b.entry()!.inject as unknown as () => TeamActionInjected)()
const member: TeamRosterMember = {
@@ -248,9 +247,8 @@ describe('ui-team browser plugin', () => {
status: 'inactive',
diagnostics: [],
}
await actions.openTeammate(SESSION, member)
actions.openTeammate(SESSION, member)
expect(b.navigation).toEqual([
['refresh', SESSION],
['open', {
parentSessionId: SESSION,
childSessionId: CHILD,
@@ -263,7 +261,7 @@ describe('ui-team browser plugin', () => {
const b = await bench({ addressed: true })
const actions = (b.entry()!.inject as unknown as () => TeamActionInjected)()
await actions.load(CHILD)
await actions.openTeammate(CHILD, {
actions.openTeammate(CHILD, {
id: CHILD,
name: 'worker',
role: 'teammate',
@@ -272,7 +270,6 @@ describe('ui-team browser plugin', () => {
})
expect(b.calls[0]).toEqual({ method: 'agentTeams/view', args: [SESSION] })
expect(b.navigation).toEqual([
['refresh', SESSION],
['open', {
parentSessionId: SESSION,
childSessionId: CHILD,
@@ -281,36 +278,34 @@ describe('ui-team browser plugin', () => {
])
})
it('does not open a teammate after navigation switches during catalog refresh', async () => {
const refresh = Promise.withResolvers<undefined>()
const b = await bench({ refreshGate: refresh.promise })
it('does not open a teammate from a conversation outside the main view', async () => {
const b = await bench()
const actions = (b.entry()!.inject as unknown as () => TeamActionInjected)()
const opening = actions.openTeammate(SESSION, {
b.select('other-session' as SessionId)
actions.openTeammate(SESSION, {
id: CHILD,
name: 'worker',
role: 'teammate',
status: 'inactive',
diagnostics: [],
})
expect(b.navigation).toEqual([['refresh', SESSION]])
b.select('other-session' as SessionId)
refresh.resolve(undefined)
await opening
expect(b.navigation).toEqual([['refresh', SESSION]])
expect(b.navigation).toEqual([])
})
it('does not open a teammate missing from the parent catalog', async () => {
it('opens a teammate when the parent catalog is missing or empty', async () => {
for (const catalog of ['missing', 'empty'] as const) {
const b = await bench({ catalog })
const actions = (b.entry()!.inject as unknown as () => TeamActionInjected)()
await actions.openTeammate(SESSION, {
actions.openTeammate(SESSION, {
id: CHILD,
name: 'worker',
role: 'teammate',
status: 'inactive',
diagnostics: [],
})
expect(b.navigation).toEqual([['refresh', SESSION]])
expect(b.navigation).toEqual([
['open', { parentSessionId: SESSION, childSessionId: CHILD, mode: 'continuable' }],
])
await b.fiber.dispose()
}
})
@@ -84,7 +84,7 @@ function actions(overrides: Partial<TeamActionInjected> = {}): TeamActionInjecte
ok: true,
value: { ok: true, value: { ...task, revision: 2 } },
}),
openTeammate: () => Promise.resolve(),
openTeammate: () => {},
...overrides,
}
}
@@ -119,7 +119,7 @@ describe('TeamAction', () => {
})
it('loads roster/task diagnostics on open and navigates a healthy teammate', async () => {
const openTeammate = vi.fn(() => Promise.resolve())
const openTeammate = vi.fn()
render(<TeamAction {...props(actions({ openTeammate }))} />)
fireEvent.click(screen.getByRole('button', { name: /Agent Team/u }))
const worker = await screen.findByRole('button', { name: /worker/u })
@@ -495,7 +495,7 @@ describe('TeamAction', () => {
],
}
const load = vi.fn(() => Promise.resolve({ ok: true as const, value: richView }))
const openTeammate = vi.fn(() => Promise.reject(new Error('navigation failed')))
const openTeammate = vi.fn(() => { throw new Error('navigation failed') })
render(<TeamAction {...props(actions({ load, openTeammate }))} />)
fireEvent.click(screen.getByRole('button', { name: /Agent Team/u }))
expect(await screen.findByText('provider failed')).toBeTruthy()
@@ -1994,6 +1994,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
'client-ui-conversation EnterBehaviorRow id \'composer-enter\'',
'client-ui-permission-presets PermissionRow id \'permission\'',
'client-ui-settings-general DeveloperToolsRow id \'developer-tools\'',
'client-ui-settings-general CurrentVersionRow id \'current-version\'',
'client-ui-theme AppearanceRow id \'appearance\'',
'client-ui-theme FontSizeRow id \'font-size\'',
],
@@ -1848,6 +1848,11 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
summary: 'Durable append-only session storage addressed through per-session handles.',
description: 'Durable append-only session storage addressed through per-session handles.\n\nStorage semantics shared by every backend: events are contiguous from seq 0 and never rewritten; a torn physical tail is never returned to a reader and is truncated by the write path before its first append; reads validate current-format records only and refuse unknown vocabulary fail-closed. `append` persists best-effort; `flush` — per handle or service-wide — is the durability barrier.\n\nVisibility: a created session is observable through `stat`/`list`/`open` in this process from the moment `create` resolves, even while a backend defers physical materialization (a pure optimization); other processes see the session only once it materializes, and a session that never materialized before a crash never existed. `SessionHandle.flush` forces materialization.\n\nFreshness: once an `append` or `flush` resolves, reads started afterwards on this backend instance observe at least that prefix.',
methods: [
{
signature: 'readonly identity: symbol = Symbol(\'sessionPersistence\')',
description: 'Process-local instance identity, stable through Context proxies and distinct after service replacement.',
parameters: [],
},
{
signature: 'abstract create(header: SessionHeader, options?: SessionPersistenceCreateOptions): Promise<SessionHandle>',
description: 'Create a new stored session and take its write ownership.',
@@ -54,8 +54,8 @@ export interface SessionObservationOptions {
* instance still reports the same revision.
*/
interface PreparedEntry {
/** The persistence instance whose `stat` produced {@link revision}; revisions from another instance are incomparable. */
readonly persistence: SessionPersistence
/** Stable service identity whose `stat` produced this revision; proxy references are not instance identities. */
readonly persistenceIdentity: symbol
/** Durable revision observed by `stat` immediately before the log read. */
readonly revision: SessionPersistenceRevision
/** Unpublished Session restored from the balanced log; never entered into the store. */
@@ -109,7 +109,7 @@ export class SessionObservationReader {
const snapshot = await this.statSource(persistence, sessionId, signal)
const attachedDuringStat = this.ctx.sessions.get(sessionId)
if (attachedDuringStat !== undefined) return this.live(attachedDuringStat, projectionMode)
let entry = this.cachedEntry(persistence, sessionId, snapshot.revision)
let entry = this.cachedEntry(persistence.identity, sessionId, snapshot.revision)
if (entry === undefined) {
const loaded = await this.loadSource(persistence, sessionId, signal)
throwIfObservationAborted(signal)
@@ -138,7 +138,7 @@ export class SessionObservationReader {
)
}
entry = {
persistence,
persistenceIdentity: persistence.identity,
revision: snapshot.revision,
session,
events: Object.freeze(seed),
@@ -201,12 +201,12 @@ export class SessionObservationReader {
/** Return a still-valid cached entry and mark it most recently used. */
private cachedEntry(
persistence: SessionPersistence,
persistenceIdentity: symbol,
sessionId: SessionId,
revision: SessionPersistenceRevision,
): PreparedEntry | undefined {
const cached = this.cache.get(sessionId)
if (cached === undefined || cached.persistence !== persistence || cached.revision !== revision) {
if (cached === undefined || cached.persistenceIdentity !== persistenceIdentity || cached.revision !== revision) {
return undefined
}
this.cache.delete(sessionId)
@@ -133,6 +133,9 @@ declare module '@deepseek-ai/cordis' {
* on this backend instance observe at least that prefix.
*/
export abstract class SessionPersistence extends Service {
/** Process-local instance identity, stable through Context proxies and distinct after service replacement. */
readonly identity: symbol = Symbol('sessionPersistence')
constructor(ctx: Context) {
super(ctx, 'sessionPersistence')
}
-3
View File
@@ -823,9 +823,6 @@
"packages/client/ui-subagent/tests/browser-plugin.client.spec.ts": {
"810a8e63d34628d25866491b3c6455a51b2d114261b67e62b42728b765370993": 1
},
"packages/client/ui-subagent/tests/conversation-ui.client.spec.tsx": {
"92d757187bb2dba986b160c2417c7b4a8b199784d441bfa0777d8077774b932b": 1
},
"packages/client/ui-subagent/tests/sidebar-chat.client.spec.tsx": {
"024f8748e8908a495117a23a7673046a871f0d8187f8fe561aceba1269aefa31": 1,
"6f46259a9f4093d68b3fc7859c263f21b6318197b74aba368e8904321ab5c4f9": 1,