diff --git a/.agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.i18n.yaml index 7d108bbf7d..f633a831d3 100644 --- a/.agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.i18n.yaml @@ -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 .agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.md -2026-07-22-slot-type-chain-implementation.md: 0e493a19e232acf6f289714e53e0cf64b3ffc4b0 -2026-07-22-slot-type-chain-implementation.zh.md: 5e5c2ec808cec6949396a333c6a86e267bb4268b +2026-07-22-slot-type-chain-implementation.md: 98585a42f71844594da4ada19d7438a7d8321702 +2026-07-22-slot-type-chain-implementation.zh.md: d1093537ccbc5ee5ff2dc18c234233ffca39879d diff --git a/.agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.md b/.agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.md index 0e493a19e2..98585a42f7 100644 --- a/.agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.md +++ b/.agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.md @@ -12,6 +12,8 @@ The page is composed at runtime from independently loaded plugins, so the UI nee ## Decision +Global main-panel selection and its root lifetime are defined by the [global main-panels decision](2026-09-08-global-main-panels.md). + One sentence: **the ui-renderer renders only `'root'`; a plugin composes UI through a single `register` call that simultaneously occupies a slot, declares+authorizes its child slots, declares its store, and injects its business face; components are pure functions whose props arrive in four shares, each auto-derived from its single source of truth.** ### 'root' is the only a-priori slot @@ -25,7 +27,7 @@ ctx.slots.register({ name: 'root', children: { 'sidebar': { kind: 'single', scope: 'root' }, - 'conversation': { kind: 'single', scope: 'session' }, + 'main': { kind: 'keyed', scope: 'root' }, }, store: createLayoutStore, // StoreHandle or factory (below) inject: injectFrame, // business face (below) diff --git a/.agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.zh.md b/.agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.zh.md index 5e5c2ec808..d1093537cc 100644 --- a/.agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.zh.md @@ -12,6 +12,8 @@ Status: implemented ## 决策 +全局主面板选择及其 root 生命周期由[全局主面板决策](2026-09-08-global-main-panels.zh.md)定义。 + 一句话:**ui-renderer 只渲染 `'root'`;插件用单独一次 `register` 调用组合 UI——这一次调用同时占用 slot、声明并授权子 slot、声明 store、注入业务面;组件是纯函数,props 分四份额到达,每一份额都从各自唯一的真源自动推导。** ### 'root' 是唯一的先验 slot @@ -25,7 +27,7 @@ ctx.slots.register({ name: 'root', children: { 'sidebar': { kind: 'single', scope: 'root' }, - 'conversation': { kind: 'single', scope: 'session' }, + 'main': { kind: 'keyed', scope: 'root' }, }, store: createLayoutStore, // StoreHandle or factory (below) inject: injectFrame, // business face (below) diff --git a/.agents/notes/implemented/architecture/2026-09-01-parent-owned-subagent-catalog.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-01-parent-owned-subagent-catalog.i18n.yaml new file mode 100644 index 0000000000..697c410f3a --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-01-parent-owned-subagent-catalog.i18n.yaml @@ -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/architecture/2026-09-01-parent-owned-subagent-catalog.md +2026-09-01-parent-owned-subagent-catalog.md: 5926af77219680a48af1e5fb062ddc11a91a2280 +2026-09-01-parent-owned-subagent-catalog.zh.md: ddc77a665207169cfc048b9f2cfbd4f912cb93dc diff --git a/.agents/notes/implemented/architecture/2026-09-01-parent-owned-subagent-catalog.md b/.agents/notes/implemented/architecture/2026-09-01-parent-owned-subagent-catalog.md new file mode 100644 index 0000000000..5926af7721 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-01-parent-owned-subagent-catalog.md @@ -0,0 +1,49 @@ +# Agent Note: Parent-owned subagent catalog events + +Status: implemented + +English | [中文](2026-09-01-parent-owned-subagent-catalog.zh.md) + +## Problem + +Direct-child discovery once reconstructed a catalog from the global Session corpus and each selected child's log. Creation already knows the direct parent, child id, mode, and label, so repository-wide enumeration and child-log reads duplicated an owned fact and made browser refresh cost depend on unrelated Sessions. + +The child descriptor remains necessary for recovery and composition, but it cannot be the discovery source because a reader must find and open the child before it can read the descriptor. Forks add a separate requirement: a seeded copy of a parent log must not inherit the original Session's children. + +## Decision + +The parent Session's required `subagent/catalog` events are the persistent authority for direct-child discovery. Each event is one successful creation fact containing `childId`, `childCreatedAt`, mode, and the mode-discriminated label. Remote one-shot runs without a local Session remain outside this catalog. Invalid own facts, including unsupported payload versions, reject projection restoration because silently dropping a required fact would return an incomplete catalog. + +Creation publishes only successful facts. A one-shot run appends the catalog event after its provider returns a local child and before the run reaches its caller. A continuable run admits the initial prompt, appends the catalog event, then returns the child id. If admission or catalog append fails, creation fails and releases the activation; there is no compensating catalog event or rollback protocol. + +The child header and `subagent/descriptor` remain authoritative for recovery and composition. An Activation and the exact parent relationship remain authoritative for authorization and delivery. Mode and label are snapshotted once and the same detached values reach the parent catalog fact and child descriptor. + +The registered `subagentCatalog` projection materializes the parent facts. It delegates storage, append, iteration, and checkpoint validation to [`dsh-chunked-list`](../../../../packages/util/chunked-list/README.md), which stores facts in a persistent stack of 64-entry chunks, so an append copies at most the head chunk in bounded O(1) work. Materialization visits chunks from oldest to newest and preserves parent catalog event order in O(D) time for D facts. Concurrent creation is ordered by successful catalog append, independent of child timestamps and ids. A projection checkpoint clones the state once in O(D); projection-cache writes remain asynchronous and use the existing mandatory creation, turn-end, and disposal points. + +The utility owns chunk layout and its shared capacity constant; the catalog owns event validation, fork filtering, and row conversion. Catalog projection state version 2 stores generic chunk values, so the projection registry rebuilds incompatible caches from Session events. Session event payloads and public catalog rows retain their formats. + +Fork isolation uses the exact `Session.inheritedEventCount` supplied to projection initialization. The fold ignores `subagent/catalog` events below that offset. The state stores the inherited offset but not each event seq because acceptance is decided during folding. + +Headless snapshot collection assigns sibling fixture roles by their parent catalog order, regardless of child creation timestamps: provider startup can publish an older Session after a newer one. The collection preserves each log verbatim. + +Snapshot normalizers zero `childCreatedAt` because it originates from the process clock. Event order and source-event references remain intact: adjacent facts can come from sequential creation, so adjacency does not establish commutativity. + +Current-writer snapshot expectations include catalog facts even when replay input retains a historical Session generation. The comparison preserves the catalog and its source-event references; historical replay files remain unchanged. + +## Alternatives considered + +**A flat immutable array.** Appending with `[...facts, fact]` copies D facts, so creation is O(D). Mutating a shared array would violate projection state ownership and checkpoint safety. + +**A node-per-fact linked list.** It provides O(1) append and O(D) read, but persisted projection checkpoints form JSON nested D levels deep. Sixty-four-entry chunks preserve the asymptotic costs while reducing nesting. + +**Separate host-state observation output.** Returning internal projection states duplicates the existing observation result mechanism and copies states unrelated to child discovery. A catalog view supplies the direct-child list through the existing typed projection map. + +**A durable SQLite child index.** An index would create another write path, reconciliation protocol, schema, and corruption surface for a fact already ordered in the parent Session log. + +**A compensating failure event.** Recording catalog membership before initial prompt admission requires a second operation, pairing rules, rollback cleanup, and client reconciliation. Delaying the success fact until admission completes removes that protocol. + +## Consequences + +Session observations and client snapshots expose the direct-child list through `projections.values.subagentCatalog`. The projection change feed publishes a complete list when catalog state changes. Each view costs O(D), so D creations can incur O(D²) cumulative view work; this follows the existing projection mechanism. Direct-child and descendant listing still use the Session corpus and child identity projection. + +Backends that do not know the required event refuse the log under the existing Session event mechanism. Pre-release format policy requires no fallback scan for old logs. diff --git a/.agents/notes/implemented/architecture/2026-09-01-parent-owned-subagent-catalog.zh.md b/.agents/notes/implemented/architecture/2026-09-01-parent-owned-subagent-catalog.zh.md new file mode 100644 index 0000000000..ddc77a6652 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-01-parent-owned-subagent-catalog.zh.md @@ -0,0 +1,49 @@ +# Agent Note: parent 自有的 subagent 目录事件 + +Status: implemented + +[English](2026-09-01-parent-owned-subagent-catalog.md) | 中文 + +## 问题 + +直接 child discovery 曾从全局 Session 语料与每个入选 child 的日志重建目录。创建过程已经知道直接 parent、child id、mode 与 label,因此仓库范围枚举和 child 日志读取重复推导了已有归属的事实,并让浏览器刷新成本取决于无关 Session。 + +child descriptor 对恢复与 composition 仍然必要,但它不能作为 discovery 来源,因为读取方必须先找到并打开 child 才能读取 descriptor。fork 还有独立要求:从 parent 日志播种的副本不能继承原 Session 的 child。 + +## 决策 + +parent Session 的 required `subagent/catalog` 事件是直接 child discovery 的持久化权威。每个事件都是一条成功创建事实,包含 `childId`、`childCreatedAt`、mode 与按 mode 区分的 label。没有本地 Session 的远程 one-shot run 不进入该目录。无效的自身 fact(包括不支持的 payload 版本)会使 projection 恢复失败,因为静默丢弃 required fact 会返回不完整的目录。 + +创建只发布成功事实。one-shot run 在 provider 返回本地 child 后、run 到达调用方前追加目录事件。continuable run 先准入初始 prompt,再追加目录事件,最后返回 child id。准入或目录追加失败时,创建失败并释放 activation;不存在补偿目录事件或 rollback 协议。 + +child header 与 `subagent/descriptor` 继续拥有恢复与 composition 权威。Activation 与精确 parent 关系继续拥有授权与投递权威。mode 与 label 只快照一次,同一份分离值写入 parent catalog fact 与 child descriptor。 + +注册的 `subagentCatalog` projection 物化 parent fact。它将存储、追加、迭代和检查点校验交给 [`dsh-chunked-list`](../../../../packages/util/chunked-list/README.zh.md),后者以每块 64 项的持久 stack 保存事实,因此 append 最多复制 head chunk,以有界 O(1) 工作完成。materialization 从旧到新访问 chunk,对 D 条事实以 O(D) 时间保留父目录事件顺序。并发创建按目录成功追加的顺序排列,与 child 时间戳和 id 无关。projection checkpoint 以 O(D) 克隆 state;projection-cache 继续异步写入,并使用既有创建、turn-end 与 disposal 强制点。 + +工具库拥有分块布局及其共享容量常量;目录拥有事件校验、fork 过滤和目录行转换。目录 projection state 版本 2 保存通用块值,因此 projection registry 从 Session 事件重建不兼容的缓存。Session 事件载荷和公开目录行保持各自格式。 + +fork 隔离使用 projection 初始化时提供的精确 `Session.inheritedEventCount`。fold 忽略该 offset 之前的 `subagent/catalog` 事件。state 保存 inherited offset,但不保存每条 event seq,因为接受判定已在 fold 时完成。 + +Headless 快照采集按父目录顺序分配同父子级的 fixture 角色,不依赖子级创建时间戳:provider 启动可能在较新的 Session 之后发布较旧的 Session。采集过程原样保留每份日志。 + +snapshot normalizer 会把 `childCreatedAt` 归零,因为它来自 process clock。事件顺序与来源事件引用保持不变:相邻 fact 也可能来自顺序创建,因此相邻关系不能证明可交换性。 + +即使 replay 输入保留历史 Session generation,当前 writer 的快照预期也包含 catalog 事实。比较保留 catalog 及其来源事件引用;历史 replay 文件保持不变。 + +## 考虑过的替代方案 + +**扁平不可变数组。** 用 `[...facts, fact]` append 会复制 D 个 fact,因此创建是 O(D)。修改共享数组会违反 projection state ownership 与 checkpoint 安全。 + +**每 fact 一个 node 的 linked list。** 它提供 O(1) append 与 O(D) read,但持久 projection checkpoint 会形成 D 层 JSON 嵌套。每块 64 项保留渐进复杂度,同时降低嵌套深度。 + +**独立的 host state 观察输出。** 返回内部 projection state 会重复已有观察结果机制,并复制与子级发现无关的状态。目录视图通过既有的类型化 projection map 提供直接子级列表。 + +**持久 SQLite child index。** index 会为 parent Session 日志中已有顺序的 fact 增加另一套写路径、reconciliation protocol、schema 与 corruption surface。 + +**补偿失败事件。** 在初始 prompt 准入前记录 catalog membership 会引入第二种 operation、配对规则、rollback 清理与 client reconciliation。把成功事实推迟到准入完成后即可删除该协议。 + +## 后果 + +Session 观察和客户端快照通过 `projections.values.subagentCatalog` 暴露直接子级列表。目录状态变化时,projection 变更通知发布完整列表。每次视图计算成本为 O(D),因此 D 次创建的累计视图工作量可能为 O(D²);这沿用既有 projection 机制。直接子级和后代列表仍使用 Session 语料库与子级身份 projection。 + +不认识该 required event 的 backend 会按既有 Session event 机制拒绝日志。pre-release format policy 不要求为旧日志保留 fallback scan。 diff --git a/.agents/notes/implemented/architecture/2026-09-08-global-main-panels.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-08-global-main-panels.i18n.yaml new file mode 100644 index 0000000000..f5b5d88586 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-08-global-main-panels.i18n.yaml @@ -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/architecture/2026-09-08-global-main-panels.md +2026-09-08-global-main-panels.md: 75be68ac1bf6dceb812a5aedbca74ce58df929b3 +2026-09-08-global-main-panels.zh.md: 343f183367a5cfbd13127e32542f689c50306c89 diff --git a/.agents/notes/implemented/architecture/2026-09-08-global-main-panels.md b/.agents/notes/implemented/architecture/2026-09-08-global-main-panels.md new file mode 100644 index 0000000000..75be68ac1b --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-08-global-main-panels.md @@ -0,0 +1,37 @@ +# Agent Note: Global main panels without default UI additions + +Status: implemented + +English | [中文](2026-09-08-global-main-panels.zh.md) + +## Problem + +Plugins need application-wide views that do not belong to a Session. A Session-scoped Conversation view cannot provide that lifetime, and replacing the Conversation's single slot removes the ordinary conversation surface. Adding this extension must not add navigation controls or reserved space to the default application. + +## Decision + +The layout declares a root-scoped keyed `main` slot. The reserved `conversation` key belongs to the Conversation plugin, whose `main.conversation` child retains optional-Session binding. Other main entries receive no implicit Session binding. + +The sidebar owns the root-scoped `sidebar.panellist` list. Each list entry supplies its icon and an id matching its main entry; its string or locale-aware label provides plain visible text, the accessible name, and the collapsed tooltip. The shipped composition registers no panel entry, so the empty list has no DOM or spacing. Selection validates the live main entry and rejects a missing key without replacing the current panel. + +One eagerly created root store is shared by the renderer and layout controller. Its `panelInfo` and `layoutInfo` objects preserve independent references. The framework supplies `usePanelInfo`; individual rows and main content subscribe to their required selection values, while AppFrame reads only layout information. The right Sidebar's root controller decides whether to mount its Session subtree and reports the resulting track requirements to the frame. + +`uiWorkspace.openSession(id)` selects the Session before returning the main area to the Conversation, including when the same Session is selected again. `openWorkspace` and `forkSession` use the layout's `beginNavigation()` abort signal and their own service lifetime to commit only the latest navigation. The Workspace preparation callback moves drafts synchronously only while the request remains current. Supersession prevents a late UI commit, not Session creation. Panel navigation neither cancels the retained Session nor writes a Session event. + +DOM focus is not navigation selection. Search and directory-picker controls can receive focus while the global panel and its selected sidebar row remain visible; opening a Session changes the main selection. + +## Alternatives considered + +**Session-scoped main views.** Their lifetime and standard props bind application-wide state to whichever Session happens to be current. + +**A second navigation stack.** Back buttons and saved return destinations are unnecessary when New Session and workspace Session rows already provide explicit destinations. + +**React title slots.** Navigation entries use the same plain label for visible text and accessibility; a separate title registration is outside that presentation. + +**Flat selection and layout state with shallow comparison.** Separating the two stored objects preserves reference equality directly and avoids allocating and comparing a fresh layout projection on every panel selection. + +## Consequences + +The default sidebar snapshots remain unchanged. Extension panels have no right Sidebar, and selecting a different global panel does not change layout preferences. Switching between a Conversation with a visible right Sidebar and a global panel still changes the required column widths; this is not a promise of zero browser layout work. + +Panel selection is transient and resets on reload. Plugin disposal removes its contributions; removing the selected main entry returns the main area to the Conversation. Tests register real temporary panels and cover row interaction, focus, independent stored references, invalid ids, superseded asynchronous navigation, declaration lifetimes, and the empty default sidebar. The [Slots reference](../../../../docs/subsystems/slots.md) owns the composition API. diff --git a/.agents/notes/implemented/architecture/2026-09-08-global-main-panels.zh.md b/.agents/notes/implemented/architecture/2026-09-08-global-main-panels.zh.md new file mode 100644 index 0000000000..343f183367 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-08-global-main-panels.zh.md @@ -0,0 +1,37 @@ +# Agent Note: 不增加默认界面的全局主面板 + +Status: implemented + +[English](2026-09-08-global-main-panels.md) | 中文 + +## 问题 + +插件需要不属于任何会话的应用级视图。会话作用域的 Conversation 视图无法提供这种生命周期,而替换 Conversation 的 single slot 又会移除普通会话界面。增加此扩展不能在默认应用中增加导航控件或预留空间。 + +## 决策 + +布局声明 root 作用域的 keyed `main` slot。保留的 `conversation` key 属于 Conversation 插件,其 `main.conversation` 子 slot 保留可选的会话绑定。其他主面板条目不获得隐式会话绑定。 + +侧栏拥有 root 作用域的 `sidebar.panellist` list。每个 list 条目提供图标,以及与主面板条目匹配的 id;字符串或随语言变化的标签提供普通可见文字、无障碍名称和折叠提示。默认组合不注册面板条目,因此空列表没有 DOM 或间距。选中操作检查实时主面板条目,对缺失的 key 报错而不替换当前面板。 + +渲染器与布局控制器共享一个直接创建的 root 存储。其 `panelInfo` 和 `layoutInfo` 对象保持独立的引用。框架提供 `usePanelInfo`;各行和中央内容订阅所需的选中态值,AppFrame 仅读取布局信息。右侧 Sidebar 的 root 控制器决定是否挂载其会话子树,并把最终所需的列宽报告给框架。 + +`uiWorkspace.openSession(id)` 先选中会话,再将中央区域切回 Conversation,包括再次选中同一个会话的情况。`openWorkspace` 和 `forkSession` 使用布局的 `beginNavigation()` abort signal 与自身 service 生命周期,只提交最新导航。工作区准备回调仅在请求仍有效时同步搬移草稿。请求过期会阻止晚到的 UI 提交,但不阻止会话创建。面板导航既不取消保留的会话,也不写入会话事件。 + +DOM 焦点不是导航选中态。搜索和目录选择控件可以获得焦点,同时保留全局面板及其侧栏行的选中态;打开会话才改变中央区域的选中态。 + +## 考虑过的替代方案 + +**会话作用域的主视图。** 其生命周期和标准 props 会把应用级状态绑定到恰好处于当前态的会话。 + +**另一套导航栈。** 新会话和工作区会话行已经提供明确目标,不需要返回按钮或保存返回目的地。 + +**React 标题 slot。** 导航条目的可见文字与无障碍名称使用同一个普通标签;独立的标题注册不属于这一呈现方式。 + +**平铺选中态和布局状态,再做浅比较。** 将两者存为独立对象可以直接保持引用相等,避免每次选择面板都分配并比较新的布局投影。 + +## 后果 + +默认侧栏快照保持不变。扩展面板没有右侧 Sidebar,选择另一个全局面板不会改变布局偏好。在显示右侧 Sidebar 的 Conversation 与全局面板之间切换时,所需列宽仍会变化;这并不保证浏览器完全不计算布局。 + +面板选中态是瞬时状态,刷新后重置。插件 dispose(资源释放)会移除其贡献;移除当前选中的主面板条目会使中央区域回到 Conversation。测试注册真实临时面板,覆盖行交互、焦点、存储引用的独立性、无效 id、过期异步导航、声明生命周期和默认空侧栏。[Slots 参考](../../../../docs/subsystems/slots.zh.md)拥有组合 API 的说明。 diff --git a/.agents/notes/implemented/process/2026-07-30-generated-third-party-notices.i18n.yaml b/.agents/notes/implemented/process/2026-07-30-generated-third-party-notices.i18n.yaml index 91c1bc8481..95ff31f9f1 100644 --- a/.agents/notes/implemented/process/2026-07-30-generated-third-party-notices.i18n.yaml +++ b/.agents/notes/implemented/process/2026-07-30-generated-third-party-notices.i18n.yaml @@ -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 .agents/notes/implemented/process/2026-07-30-generated-third-party-notices.md -2026-07-30-generated-third-party-notices.md: b81f386f0d0e820b0361c775fb4c45a9e633d04b -2026-07-30-generated-third-party-notices.zh.md: d3b3e1686bfdf8901902f995205c1dbdd6dfbf38 +2026-07-30-generated-third-party-notices.md: 41ac0c75ca55c81f2055c867bd029ee7e4a350f9 +2026-07-30-generated-third-party-notices.zh.md: 4097831828287e7180cf37d713fa4694ae028b31 diff --git a/.agents/notes/implemented/process/2026-07-30-generated-third-party-notices.md b/.agents/notes/implemented/process/2026-07-30-generated-third-party-notices.md index b81f386f0d..41ac0c75ca 100644 --- a/.agents/notes/implemented/process/2026-07-30-generated-third-party-notices.md +++ b/.agents/notes/implemented/process/2026-07-30-generated-third-party-notices.md @@ -20,7 +20,7 @@ One trigger gap is accepted rather than worked around: lefthook inspects only fi The file discloses **direct** dependencies by default. The complete npm closure with pinned versions already lives in `pnpm-lock.yaml` (`pnpm licenses list` renders it) and the Python closure in `python/sdk/uv.lock`; re-materializing either as prose would be a second, worse copy. The one explicit transitive disclosure is the official Claude platform payload set declared by `@anthropic-ai/claude-agent-sdk` through `optionalDependencies`, because those packages carry the distributed Claude Code executable rather than ordinary library implementation detail. -**Tiering is by declaring area, not by manifest section.** A package is a runtime dependency when any manifest outside `DEV_ONLY_AREAS` — the root manifest, `packages/test-support/`, `packages/test-support/client-runtime/`, `website/`, `native/` — names it under `dependencies` or `optionalDependencies`. Section names alone are wrong in both directions: a test-support package declares `vitest` under `dependencies` without shipping it, and the root source-run scripts execute through `tsx`, which no manifest declares as a runtime dependency at all (the generator marks it runtime explicitly). +**Tiering follows distribution, not manifest section.** Installed runtime libraries are identified by `dependencies` or `optionalDependencies` outside `DEV_ONLY_AREAS` — the root manifest, `packages/test-support/`, `packages/test-support/client-runtime/`, `website/`, `native/`. Browser inputs resolved by the shipping tsdown and Vite configurations also count as runtime, even in `devDependencies`; [browser third-party build inputs](2026-09-08-browser-third-party-build-inputs.md) owns that classification. Test-support dependencies do not ship merely because their manifest says `dependencies`, and the generator explicitly discloses `tsx` because source launches execute through its ESM hook. The runtime tier deliberately covers **every mountable plugin**, not just what the CLI, Web UI, and Python runtime load by default. Source execution can mount any plugin package from a user's `cordis.yml`; `@modelcontextprotocol/sdk` and the OpenTelemetry packages therefore reach real users even though no default assembly imports them. Under-disclosure is the costly direction for a legal notice. diff --git a/.agents/notes/implemented/process/2026-07-30-generated-third-party-notices.zh.md b/.agents/notes/implemented/process/2026-07-30-generated-third-party-notices.zh.md index d3b3e1686b..4097831828 100644 --- a/.agents/notes/implemented/process/2026-07-30-generated-third-party-notices.zh.md +++ b/.agents/notes/implemented/process/2026-07-30-generated-third-party-notices.zh.md @@ -20,7 +20,7 @@ Status: implemented 文件默认只披露**直接**依赖。完整的 npm 闭包连同锁定版本已记录在 `pnpm-lock.yaml`(`pnpm licenses list` 可渲染),Python 闭包记录在 `python/sdk/uv.lock`;再用散文誊一遍只会得到一份更差的副本。唯一明确披露的传递依赖,是 `@anthropic-ai/claude-agent-sdk` 通过 `optionalDependencies` 声明的官方 Claude 平台载荷集合,因为这些包承载随产品分发的 Claude Code 可执行文件,而非普通的库实现细节。 -**分层依据是声明方所在区域,而非 manifest 字段名。** 只要 `DEV_ONLY_AREAS` 之外的任一 manifest——即根 manifest、`packages/test-support/`、`packages/test-support/client-runtime/`、`website/`、`native/` 之外——在 `dependencies` 或 `optionalDependencies` 里点名某个包,它就是运行时依赖。单看字段名在两个方向上都会出错:测试支撑包把 `vitest` 写在 `dependencies` 里却并不交付它;而根目录的源码运行脚本通过 `tsx` 执行,根本没有任何 manifest 把它声明为运行时依赖,只能由生成器显式标记。 +**分层依据是分发内容,而非 manifest 字段名。** 安装的运行时库由 `DEV_ONLY_AREAS` 之外的 `dependencies` 或 `optionalDependencies` 识别;排除区域为根 manifest、`packages/test-support/`、`packages/test-support/client-runtime/`、`website/`、`native/`。发布所用的 tsdown 与 Vite 配置解析到的浏览器输入也属于运行时,即使它们位于 `devDependencies`;[浏览器第三方构建输入](2026-09-08-browser-third-party-build-inputs.zh.md)拥有这项分类。测试支撑依赖不会仅因字段写成 `dependencies` 就被交付,而生成器显式披露 `tsx`,因为源码启动通过其 ESM 钩子执行。 运行时层刻意覆盖**所有可挂载的插件**,而不止 CLI、Web UI 与 Python 运行时默认加载的那些。从源码运行时,用户可以通过 `cordis.yml` 挂载任何插件包;因此,`@modelcontextprotocol/sdk` 与 OpenTelemetry 系列即使没有任何默认装配引入,也会触达真实用户。对法务披露而言,披露不足才是代价更高的那个方向。 diff --git a/.agents/notes/implemented/process/2026-08-26-published-dependency-faces.i18n.yaml b/.agents/notes/implemented/process/2026-08-26-published-dependency-faces.i18n.yaml index d44c9594bb..d24d629c9f 100644 --- a/.agents/notes/implemented/process/2026-08-26-published-dependency-faces.i18n.yaml +++ b/.agents/notes/implemented/process/2026-08-26-published-dependency-faces.i18n.yaml @@ -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 .agents/notes/implemented/process/2026-08-26-published-dependency-faces.md -2026-08-26-published-dependency-faces.md: 25e9f2ce139a7cd4efb64dbe71d49d8c9f88c24b -2026-08-26-published-dependency-faces.zh.md: ccc198b164b7450b6840862faf23b546c99fa2a6 +2026-08-26-published-dependency-faces.md: 1a94ccedcf6f614c7853c96c329e88d037167460 +2026-08-26-published-dependency-faces.zh.md: 4ac3f461018c21047af6359ca5e27d050da5a2c4 diff --git a/.agents/notes/implemented/process/2026-08-26-published-dependency-faces.md b/.agents/notes/implemented/process/2026-08-26-published-dependency-faces.md index 25e9f2ce13..1a94ccedcf 100644 --- a/.agents/notes/implemented/process/2026-08-26-published-dependency-faces.md +++ b/.agents/notes/implemented/process/2026-08-26-published-dependency-faces.md @@ -28,7 +28,7 @@ A workspace package reached by a runtime value import from the Host entry closur An export whose constructor identity or module state must be shared appears in `peerRequiredHostExports`; importing one such export keeps the whole package edge in matching `peerDependencies` and `devDependencies`. Each export-table key is an exact module specifier and each value is a reviewed export set. The verifier follows runtime local imports from the Host entry, records named and default imports and re-exports, and rejects exports covered by neither the package list nor an export table; namespace, dynamic, and side-effect imports remain unbounded unless the complete exact entry is package-classified. -Workspace imports used by the Client bundle, type-only imports, module augmentations, `dsh.client.inject`, invariant companions, and existing metadata-only peers belong only in `devDependencies`. Ordinary third-party packages imported by the Host runtime belong in `dependencies`; other third-party relationships keep their declared section. Workspace references use `workspace:^`. +Workspace imports used by the Client bundle, type-only imports, module augmentations, `dsh.client.inject`, and existing metadata-only peers belong only in `devDependencies`. Host runtime imports, including additional Node entries, follow the Host classification. [Browser third-party build inputs](2026-09-08-browser-third-party-build-inputs.md) governs ordinary third-party declarations; it partially supersedes their preservation in this decision. Workspace references use `workspace:^`. Some development relationships exist only in `dsh.client.inject` or TypeScript project references. The policy's `configurationOnlyDevDependencies` table names only those reviewed edges and keeps them in `devDependencies`. diff --git a/.agents/notes/implemented/process/2026-08-26-published-dependency-faces.zh.md b/.agents/notes/implemented/process/2026-08-26-published-dependency-faces.zh.md index ccc198b164..4ac3f46101 100644 --- a/.agents/notes/implemented/process/2026-08-26-published-dependency-faces.zh.md +++ b/.agents/notes/implemented/process/2026-08-26-published-dependency-faces.zh.md @@ -28,7 +28,7 @@ Host 入口闭包中的运行期 value import 所到达的 workspace 包,只 constructor 身份或模块状态必须共享的导出列入 `peerRequiredHostExports`;一旦使用这类导出,整条包依赖边就保留在范围一致的 `peerDependencies` 与 `devDependencies` 中。每个导出表的 key 都是精确 module specifier,每个 value 都是经审查的导出集合。验证器从 Host 入口沿运行期本地 import 扫描,记录具名与默认 import 和 re-export,并拒绝既没有包级分类、也没有导出级分类的导出;除非完整的精确入口已按包分类,否则 namespace、dynamic 和 side-effect import 仍无法限定范围。 -Client bundle 使用的 workspace import、纯类型 import、模块扩充、`dsh.client.inject`、invariant companion 和仅有元数据的现存 peer 只属于 `devDependencies`。Host 运行时导入的普通第三方包属于 `dependencies`;其他第三方关系保持原区段。Workspace 引用使用 `workspace:^`。 +Client bundle 使用的 workspace import、纯类型 import、模块扩充、`dsh.client.inject` 和仅有元数据的现存 peer 只属于 `devDependencies`。Host 运行时 import(包括额外 Node 入口)遵循 Host 分类。[浏览器第三方构建输入](2026-09-08-browser-third-party-build-inputs.zh.md)规定普通第三方声明,部分取代本决策对它们原区段的保留。Workspace 引用使用 `workspace:^`。 部分开发期关系只存在于 `dsh.client.inject` 或 TypeScript project reference 中。策略的 `configurationOnlyDevDependencies` 表只列出这些已评审的依赖边,并将它们保留在 `devDependencies` 中。 diff --git a/.agents/notes/implemented/process/2026-09-08-browser-third-party-build-inputs.i18n.yaml b/.agents/notes/implemented/process/2026-09-08-browser-third-party-build-inputs.i18n.yaml new file mode 100644 index 0000000000..7fc8e364f2 --- /dev/null +++ b/.agents/notes/implemented/process/2026-09-08-browser-third-party-build-inputs.i18n.yaml @@ -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/process/2026-09-08-browser-third-party-build-inputs.md +2026-09-08-browser-third-party-build-inputs.md: 97e61f3b99955cb402f9e622b2d40e3ee2953d79 +2026-09-08-browser-third-party-build-inputs.zh.md: e23c4e9d4615c7c5bc788beafcf6b94699f211ce diff --git a/.agents/notes/implemented/process/2026-09-08-browser-third-party-build-inputs.md b/.agents/notes/implemented/process/2026-09-08-browser-third-party-build-inputs.md new file mode 100644 index 0000000000..97e61f3b99 --- /dev/null +++ b/.agents/notes/implemented/process/2026-09-08-browser-third-party-build-inputs.md @@ -0,0 +1,35 @@ +# Agent Note: Browser third-party libraries as build inputs + +Status: implemented + +English | [中文](2026-09-08-browser-third-party-build-inputs.zh.md) + +## Problem + +Prebuilt browser plugins distribute their third-party implementations inside JavaScript, but production npm dependencies still make installers download those libraries separately and resolve their peers. When React is declared only for development, installers can select a different React version for those extra dependencies than the browser artifact uses. Both `use-sync-external-store@1.2.0` and `@tanstack/react-virtual@3.14.9` support the current React 18; this problem does not require a React upgrade. + +## Decision + +Browser-only third-party dependencies belong in `devDependencies`, including implementations inlined into dynamic plugins, static browser-library inputs, and React shared by the Web shell. This partially supersedes the preservation of ordinary third-party declarations in [published dependency faces](2026-08-26-published-dependency-faces.md); that note continues to govern package selection, Host value dependencies, and Cordis identity. + +The dependency classifier collects build inputs from source imports and JSX. Third-party libraries reachable by the Host runtime take precedence as `dependencies`; additional Node build entries must also be checked. Type-only source references do not create Host runtime dependencies. Existing configuration-metadata and shared-Host-export classifications remain unchanged. + +Npm sections do not select browser bundling behavior. Dynamic plugins inline private libraries and obtain React and other shared modules from the platform table; static browser libraries retain bare imports and styles for the final Vite build. Static packages are Web-shell build inputs, not independently installed libraries with every rebundling dependency provided. Source builds need development dependencies; installed published Web artifacts do not. + +License classification follows distributed content. Dependency resolution through the real browser build configurations covers dynamic plugins and the Web shell; resolved third-party implementations remain [runtime disclosures](2026-07-30-generated-third-party-notices.md) even when manifests declare them for development. Test tools, erased type imports, and build tools do not become distributed code merely by appearing in `devDependencies`. + +## Alternatives considered + +**Declare another production React or override peer resolution.** This retains an extra installed graph that the browser does not use, without making that installed copy the browser's shared instance. + +**Inline every static-library dependency early.** This changes Vite's third-party chunks, caching, and CSS handling; dependency classification does not require those build changes. + +**Move every third-party dependency of a Client-bearing package.** Dual-face packages still load Host libraries, including `fflate` for ZIP output and `zod` for RPC validation; those installation relationships must remain. + +**Classify license disclosures directly by manifest section.** Distributing browser code and having an installer download a same-named package are different facts; that distinction cannot remove license checks on distributed code. + +## Consequences + +Production dependencies do not install third-party libraries a second time solely for browser implementations. React and React DOM retain one build version, and plugins consume the Web shell's shared instance. Classification tests constrain browser dev-only inputs, Host precedence, and idempotent repair; publication checks reject React installation leaks, while browser artifact verification independently covers module loading. + +Classification remains source-based. License resolution likewise must not depend on existing `lib/` files or write build outputs; its tests cover real resolution, type erasure, asset references, and missing dependencies. Developers independently consuming static packages or public types supply the corresponding build dependencies themselves; this decision adds no standalone browser-library support promise. diff --git a/.agents/notes/implemented/process/2026-09-08-browser-third-party-build-inputs.zh.md b/.agents/notes/implemented/process/2026-09-08-browser-third-party-build-inputs.zh.md new file mode 100644 index 0000000000..e23c4e9d46 --- /dev/null +++ b/.agents/notes/implemented/process/2026-09-08-browser-third-party-build-inputs.zh.md @@ -0,0 +1,35 @@ +# Agent Note: 浏览器第三方库作为构建输入 + +Status: implemented + +[English](2026-09-08-browser-third-party-build-inputs.md) | 中文 + +## 问题 + +预构建浏览器插件的第三方实现已经随 JavaScript 分发,但生产 NPM 依赖仍让安装器另外下载这些库并解析其对等依赖(peer dependency)。React 只在构建期声明时,安装器可以为这些额外依赖选择与浏览器产物不同的 React 版本。`use-sync-external-store@1.2.0` 与 `@tanstack/react-virtual@3.14.9` 都支持当前 React 18;问题不要求升级 React。 + +## 决策 + +浏览器专用第三方依赖属于 `devDependencies`,包括动态插件内联的实现、静态浏览器库的输入,以及由 Web 壳提供共享实例的 React。该规则部分取代[已发布依赖分类](2026-08-26-published-dependency-faces.zh.md)对普通第三方声明的保留策略;该文档继续规定包的选择范围、Host 值依赖与 Cordis 实例身份。 + +依赖分类器从源码导入和 JSX 收集构建输入。Host 运行时可达的第三方库优先归入 `dependencies`;额外 Node 构建入口也必须纳入检查。源码中的纯类型引用不产生 Host 运行时依赖。配置元数据与共享 Host 导出的既有分类保持不变。 + +NPM 字段不决定浏览器打包方式。动态插件继续内联私有库,并从平台模块表读取 React 等共享模块;静态浏览器库继续保留裸导入及样式,由最终 Vite 构建处理。静态包是 Web 壳的构建输入,不承诺独立安装后具备二次打包所需的全部依赖。源码构建需要开发依赖;安装已发布的 Web 产物不需要它们。 + +许可证分类以交付内容为准。真实浏览器构建配置的依赖解析同时覆盖动态插件与 Web 壳;解析到的第三方实现仍计入[运行时披露](2026-07-30-generated-third-party-notices.zh.md),即使清单将其列为开发依赖。测试工具、纯类型导入和构建工具不会仅因处于 `devDependencies` 而算作分发代码。 + +## 考虑过的替代方案 + +**为生产安装额外声明 React,或强制覆盖对等依赖。** 这保留了浏览器不使用的额外安装图,且并未使安装副本成为浏览器共享实例。 + +**把静态库的所有依赖提前内联。** 这会改变 Vite 对第三方分块、缓存和 CSS 的处理;依赖分类不需要改变这些构建行为。 + +**把带 Client 的包的全部第三方依赖移走。** 双面包仍可能在 Host 加载库,例如 ZIP 输出所用的 `fflate` 和 RPC 校验所用的 `zod`;这些安装关系必须保留。 + +**按清单字段直接划分许可证披露。** 浏览器代码的交付与安装器是否下载同名包不是同一事实,不能因此取消对分发代码的许可证检查。 + +## 结果 + +生产依赖不再为纯浏览器实现重复安装第三方库。React 与 React DOM 保持同一构建版本,插件继续消费 Web 壳的共享实例。分类测试同时约束浏览器 dev-only、Host 优先和修复幂等;发布检查拒绝 React 安装泄漏,浏览器产物验证独立覆盖实际模块加载。 + +分类检查保留源码执行方式。许可证解析也不能依赖已有 `lib/` 或写入构建输出;其测试覆盖真实解析、纯类型擦除、资源引用与缺失依赖。独立消费静态包或公开类型的开发者需要自己提供相应构建依赖;本决策不增加独立浏览器库的支持承诺。 diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index 43c4263136..9004b1906f 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -27,7 +27,7 @@ The Cordis framework and its foundation libraries are source-vendored into this ## Runtime npm dependencies -External packages that a workspace package resolves at runtime. The tier covers every plugin a user can mount from `cordis.yml` — not only what the `dsh` CLI, Web UI, and Python SDK runtime load by default. +External packages installed for runtime use or distributed inside the prebuilt browser artifacts. Browser inputs are resolved through the shipping tsdown and Vite configurations, independently of npm dependency sections. The tier covers every plugin a user can mount from `cordis.yml` — not only what the `dsh` CLI, Web UI, and Python SDK runtime load by default. | Package | License | | --- | --- | @@ -55,7 +55,6 @@ External packages that a workspace package resolves at runtime. The tier covers | [`@shikijs/langs`](https://github.com/shikijs/shiki) | MIT | | [`@standard-schema/spec`](https://github.com/standard-schema/standard-schema) | MIT | | [`@tanstack/react-virtual`](https://github.com/TanStack/virtual) | MIT | -| [`@types/mdast`](https://github.com/DefinitelyTyped/DefinitelyTyped) | MIT | | [`@vscode/ripgrep`](https://github.com/microsoft/vscode-ripgrep) | MIT | | [`@xterm/headless`](https://github.com/xtermjs/xterm.js) | MIT | | [`@yarnpkg/parsers`](https://github.com/yarnpkg/berry) | BSD-2-Clause | @@ -88,7 +87,6 @@ External packages that a workspace package resolves at runtime. The tier covers | [`micromark-util-classify-character`](https://github.com/micromark/micromark/tree/main/packages/micromark-util-classify-character) | MIT | | [`micromark-util-sanitize-uri`](https://github.com/micromark/micromark/tree/main/packages/micromark-util-sanitize-uri) | MIT | | [`micromark-util-symbol`](https://github.com/micromark/micromark/tree/main/packages/micromark-util-symbol) | MIT | -| [`micromark-util-types`](https://github.com/micromark/micromark/tree/main/packages/micromark-util-types) | MIT | | [`mime-types`](https://github.com/jshttp/mime-types) | MIT | | [`negotiator`](https://github.com/jshttp/negotiator) | MIT | | [`node-addon-require-builtin`](https://www.npmjs.com/package/node-addon-require-builtin) | MIT | @@ -138,7 +136,7 @@ The installed SDK 0.3.263 declares the following optional platform packages. Eac ## Development-only npm dependencies -External packages **directly declared** only by repository tooling, test infrastructure, the documentation site, the demo leaves, or the native launcher's build workspace. No shipped surface names them itself. A package here may still be pulled in transitively by a runtime dependency — `pnpm-lock.yaml` is the authority on the full closure — so this tier records who declares a package, not what a build ultimately bundles. +External packages **directly declared** for development, tests, types, or tooling, without a runtime installation or browser-build relationship. A package here may still be pulled in transitively by a runtime dependency — `pnpm-lock.yaml` is the authority on that full closure. | Package | License | | --- | --- | @@ -155,6 +153,7 @@ External packages **directly declared** only by repository tooling, test infrast | [`@types/compression`](https://github.com/DefinitelyTyped/DefinitelyTyped) | MIT | | [`@types/js-yaml`](https://github.com/DefinitelyTyped/DefinitelyTyped) | MIT | | [`@types/jsdom`](https://github.com/DefinitelyTyped/DefinitelyTyped) | MIT | +| [`@types/mdast`](https://github.com/DefinitelyTyped/DefinitelyTyped) | MIT | | [`@types/mime-types`](https://github.com/DefinitelyTyped/DefinitelyTyped) | MIT | | [`@types/negotiator`](https://github.com/DefinitelyTyped/DefinitelyTyped) | MIT | | [`@types/node`](https://github.com/DefinitelyTyped/DefinitelyTyped) | MIT | @@ -190,6 +189,7 @@ External packages **directly declared** only by repository tooling, test infrast | [`lefthook`](https://github.com/evilmartians/lefthook) | MIT | | [`lightningcss`](https://github.com/parcel-bundler/lightningcss) | MPL-2.0 | | [`mermaid`](https://github.com/mermaid-js/mermaid) | MIT | +| [`micromark-util-types`](https://github.com/micromark/micromark/tree/main/packages/micromark-util-types) | MIT | | [`msgpackr`](http://github.com/kriszyp/msgpackr) | MIT | | [`oxlint`](https://github.com/oxc-project/oxc) | MIT | | [`oxlint-tsgolint`](https://github.com/oxc-project/tsgolint) | MIT | diff --git a/apps/cli/tests/profiles/headless/tests/expected/subagent-inheritance/parent.expected.jsonl b/apps/cli/tests/profiles/headless/tests/expected/subagent-inheritance/parent.expected.jsonl index 7b41f6017e..44d31a36ab 100644 --- a/apps/cli/tests/profiles/headless/tests/expected/subagent-inheritance/parent.expected.jsonl +++ b/apps/cli/tests/profiles/headless/tests/expected/subagent-inheritance/parent.expected.jsonl @@ -22,6 +22,7 @@ {"type":"session/title","data":{"title":"Tighten this session to read-only.","messageSeqs":[3],"source":{"kind":"fallback"}}} {"type":"assistant/message","data":{"turn":2,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"delegate-write","name":"subagent","arguments":"{\"description\": \"Delegated write probe\", \"prompt\": \"Use the write tool exactly once with file_path set to exactly the relative path inherited.txt and content escaped. If the write is denied, reply with the single word CHILD_DENIED and the denial marker line; do not retry and do not request escalation. If it succeeds, reply CHILD_WROTE.\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{sessionId}}"},"usage":{"inputTokens":10,"outputTokens":5},"stream":[{"type":"chunk","time":0,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"tool-call-chunks","time0":0,"index":0,"dt":[],"id":"delegate-write","name":"subagent","args":["{\"description\": \"Delegated write probe\", \"prompt\": \"Use the write tool exactly once with file_path set to exactly the relative path inherited.txt and content escaped. If the write is denied, reply with the single word CHILD_DENIED and the denial marker line; do not retry and do not request escalation. If it succeeds, reply CHILD_WROTE.\"}"]},{"type":"chunk","time":0,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"delegate-write","name":"subagent","arguments":"{\"description\": \"Delegated write probe\", \"prompt\": \"Use the write tool exactly once with file_path set to exactly the relative path inherited.txt and content escaped. If the write is denied, reply with the single word CHILD_DENIED and the denial marker line; do not retry and do not request escalation. If it succeeds, reply CHILD_WROTE.\"}"}}},{"type":"chunk","time":0,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}},{"type":"chunk","time":0,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} {"type":"tool/call","data":{"turn":2,"step":1,"callId":"delegate-write","name":"subagent","arguments":"{\"description\": \"Delegated write probe\", \"prompt\": \"Use the write tool exactly once with file_path set to exactly the relative path inherited.txt and content escaped. If the write is denied, reply with the single word CHILD_DENIED and the denial marker line; do not retry and do not request escalation. If it succeeds, reply CHILD_WROTE.\"}"}} +{"type":"subagent/catalog","data":{"version":0,"childId":"{{sessionId}}","childCreatedAt":0,"mode":"one-shot","label":"Delegated write probe"}} {"type":"tool/result","data":{"turn":2,"step":1,"message":{"source":{"kind":"tool","callId":"delegate-write"},"content":[{"type":"tool-result","toolCallId":"delegate-write","content":[{"type":"text","text":"CHILD_DENIED [sandbox: file access denied under read-only mode]"}],"isError":false}],"role":"user","id":"{{sessionId}}"}},"sourceEventSeqs":[22],"surfaceOp":"append"} {"type":"step/end","data":{"turn":2,"step":1}} {"type":"step/start","data":{"turn":2,"step":2}} diff --git a/apps/cli/tests/profiles/headless/tests/expected/subagent-settlement/stream-json.expected.jsonl b/apps/cli/tests/profiles/headless/tests/expected/subagent-settlement/stream-json.expected.jsonl index c2d9145aaf..b027378207 100644 --- a/apps/cli/tests/profiles/headless/tests/expected/subagent-settlement/stream-json.expected.jsonl +++ b/apps/cli/tests/profiles/headless/tests/expected/subagent-settlement/stream-json.expected.jsonl @@ -12,16 +12,17 @@ {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/message","seq":14,"time":0,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"start-child","name":"subagent","arguments":"{\"description\":\"Return child result\",\"prompt\":\"Reply with exactly CHILD_RESULT and nothing else. Do not call send_message.\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{sessionId}}"},"usage":{"inputTokens":10,"outputTokens":5},"stream":[{"type":"chunk","time":0,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"tool-call-chunks","time0":0,"index":0,"dt":[],"id":"start-child","name":"subagent","args":["{\"description\":\"Return child result\",\"prompt\":\"Reply with exactly CHILD_RESULT and nothing else. Do not call send_message.\"}"]},{"type":"chunk","time":0,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"start-child","name":"subagent","arguments":"{\"description\":\"Return child result\",\"prompt\":\"Reply with exactly CHILD_RESULT and nothing else. Do not call send_message.\"}"}}},{"type":"chunk","time":0,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}},{"type":"chunk","time":0,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"}} {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"tool/call","seq":15,"time":0,"data":{"turn":1,"step":1,"callId":"start-child","name":"subagent","arguments":"{\"description\":\"Return child result\",\"prompt\":\"Reply with exactly CHILD_RESULT and nothing else. Do not call send_message.\"}"}}} {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"session/title","seq":16,"time":0,"data":{"title":"Subagent settlement","messageSeqs":[8],"source":{"kind":"provider","provider":"session-title-first-prompt-llm","model":{"provider":"deepseek-official","model":"deepseek-v4-flash"}}}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"tool/result","seq":17,"time":0,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"start-child"},"content":[{"type":"tool-result","toolCallId":"start-child","content":[{"type":"text","text":"started subagent {{sessionId}}"}],"isError":false}],"role":"user","id":"{{sessionId}}"}},"sourceEventSeqs":[15],"surfaceOp":"append"}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/end","seq":18,"time":0,"data":{"turn":1,"step":1}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"agent/inbox/spliced","seq":19,"time":0,"data":{"target":"next-step","start":0,"inserted":[{"content":[{"type":"text","text":"Background subagent {{sessionId}} finished and will do no further work unless you send it more."},{"type":"text","text":"Its closing message:"},{"type":"text","text":"CHILD_RESULT"}],"source":{"kind":"subagent-settled","form":"notice","summary":"Background subagent {{sessionId}} finished and will do no further work unless you send it more.","senderSessionId":"{{sessionId}}"},"role":"user","id":"{{sessionId}}"}]}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/start","seq":20,"time":0,"data":{"turn":1,"step":2}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/message","seq":21,"time":0,"data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"text","text":"STARTED"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{sessionId}}"},"usage":{"inputTokens":10,"outputTokens":5},"stream":[{"type":"chunk","time":0,"chunk":{"type":"block-start","index":0,"blockType":"text"}},{"type":"text-chunks","time0":0,"index":0,"dt":[],"texts":["STARTED"]},{"type":"chunk","time":0,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"STARTED"}}},{"type":"chunk","time":0,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}},{"type":"chunk","time":0,"chunk":{"type":"finish","reason":{"kind":"stop"}}}]},"surfaceOp":"append"}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/end","seq":22,"time":0,"data":{"turn":1,"step":2}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"agent/inbox/spliced","seq":23,"time":0,"data":{"target":"next-step","start":0,"removedCount":1,"inserted":[]}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/start","seq":24,"time":0,"data":{"turn":1,"step":3}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"user/message","seq":25,"time":0,"data":{"content":[{"type":"text","text":"Background subagent {{sessionId}} finished and will do no further work unless you send it more."},{"type":"text","text":"Its closing message:"},{"type":"text","text":"CHILD_RESULT"}],"source":{"kind":"subagent-settled","form":"notice","summary":"Background subagent {{sessionId}} finished and will do no further work unless you send it more.","senderSessionId":"{{sessionId}}"},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/message","seq":26,"time":0,"data":{"turn":1,"step":3,"message":{"role":"assistant","content":[{"type":"text","text":"PARENT_RECEIVED_CHILD_RESULT"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{sessionId}}"},"usage":{"inputTokens":10,"outputTokens":5},"stream":[{"type":"chunk","time":0,"chunk":{"type":"block-start","index":0,"blockType":"text"}},{"type":"text-chunks","time0":0,"index":0,"dt":[],"texts":["PARENT_RECEIVED_CHILD_RESULT"]},{"type":"chunk","time":0,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"PARENT_RECEIVED_CHILD_RESULT"}}},{"type":"chunk","time":0,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}},{"type":"chunk","time":0,"chunk":{"type":"finish","reason":{"kind":"stop"}}}]},"surfaceOp":"append"}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/end","seq":27,"time":0,"data":{"turn":1,"step":3}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"turn/end","seq":28,"time":0,"data":{"turn":1,"reason":{"kind":"completed"}}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"subagent/catalog","seq":17,"time":0,"data":{"version":0,"childId":"{{sessionId}}","childCreatedAt":0,"mode":"continuable","label":"Return child result"}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"tool/result","seq":18,"time":0,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"start-child"},"content":[{"type":"tool-result","toolCallId":"start-child","content":[{"type":"text","text":"started subagent {{sessionId}}"}],"isError":false}],"role":"user","id":"{{sessionId}}"}},"sourceEventSeqs":[15],"surfaceOp":"append"}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/end","seq":19,"time":0,"data":{"turn":1,"step":1}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"agent/inbox/spliced","seq":20,"time":0,"data":{"target":"next-step","start":0,"inserted":[{"content":[{"type":"text","text":"Background subagent {{sessionId}} finished and will do no further work unless you send it more."},{"type":"text","text":"Its closing message:"},{"type":"text","text":"CHILD_RESULT"}],"source":{"kind":"subagent-settled","form":"notice","summary":"Background subagent {{sessionId}} finished and will do no further work unless you send it more.","senderSessionId":"{{sessionId}}"},"role":"user","id":"{{sessionId}}"}]}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/start","seq":21,"time":0,"data":{"turn":1,"step":2}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/message","seq":22,"time":0,"data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"text","text":"STARTED"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{sessionId}}"},"usage":{"inputTokens":10,"outputTokens":5},"stream":[{"type":"chunk","time":0,"chunk":{"type":"block-start","index":0,"blockType":"text"}},{"type":"text-chunks","time0":0,"index":0,"dt":[],"texts":["STARTED"]},{"type":"chunk","time":0,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"STARTED"}}},{"type":"chunk","time":0,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}},{"type":"chunk","time":0,"chunk":{"type":"finish","reason":{"kind":"stop"}}}]},"surfaceOp":"append"}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/end","seq":23,"time":0,"data":{"turn":1,"step":2}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"agent/inbox/spliced","seq":24,"time":0,"data":{"target":"next-step","start":0,"removedCount":1,"inserted":[]}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/start","seq":25,"time":0,"data":{"turn":1,"step":3}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"user/message","seq":26,"time":0,"data":{"content":[{"type":"text","text":"Background subagent {{sessionId}} finished and will do no further work unless you send it more."},{"type":"text","text":"Its closing message:"},{"type":"text","text":"CHILD_RESULT"}],"source":{"kind":"subagent-settled","form":"notice","summary":"Background subagent {{sessionId}} finished and will do no further work unless you send it more.","senderSessionId":"{{sessionId}}"},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/message","seq":27,"time":0,"data":{"turn":1,"step":3,"message":{"role":"assistant","content":[{"type":"text","text":"PARENT_RECEIVED_CHILD_RESULT"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{sessionId}}"},"usage":{"inputTokens":10,"outputTokens":5},"stream":[{"type":"chunk","time":0,"chunk":{"type":"block-start","index":0,"blockType":"text"}},{"type":"text-chunks","time0":0,"index":0,"dt":[],"texts":["PARENT_RECEIVED_CHILD_RESULT"]},{"type":"chunk","time":0,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"PARENT_RECEIVED_CHILD_RESULT"}}},{"type":"chunk","time":0,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}},{"type":"chunk","time":0,"chunk":{"type":"finish","reason":{"kind":"stop"}}}]},"surfaceOp":"append"}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/end","seq":28,"time":0,"data":{"turn":1,"step":3}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"turn/end","seq":29,"time":0,"data":{"turn":1,"reason":{"kind":"completed"}}}} {"type":"result","sessionId":"{{sessionId}}","output":"PARENT_RECEIVED_CHILD_RESULT","usage":{"inputTokens":30,"outputTokens":15}} diff --git a/apps/web/tests/lifecycle-chrome.e2e.ts b/apps/web/tests/lifecycle-chrome.e2e.ts index 30d9f922be..f42337be37 100644 --- a/apps/web/tests/lifecycle-chrome.e2e.ts +++ b/apps/web/tests/lifecycle-chrome.e2e.ts @@ -15,7 +15,7 @@ import { fileURLToPath } from 'node:url' import { join } from 'node:path' import type { Browser, Page, WebSocketRoute } from 'playwright' import { chromium } from 'playwright' -import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest' +import { afterAll, beforeAll, describe, expect, it, onTestFailed, onTestFinished } from 'vitest' import type { SessionEvent } from '@deepseek-ai/dsh-session' import { acknowledgeReloadConnectionLoss, assertFixtureInventory, captureExpandedTurnProcessAria, @@ -72,10 +72,17 @@ describe('web e2e: lifecycle & chrome (workspace flow / reload / dark mode)', () it.skipIf(MODE === 'record')('opens the shared slash menu from plus with only Command candidates', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-command-menu-launcher')) + const input = page.locator('[data-composer-input]').first() + onTestFinished(async () => { + await input.press('Escape') + await writeComposerDraft(page, input, '') + await page.getByRole('listbox', { name: 'Trigger suggestions' }).waitFor({ state: 'hidden' }) + }) const launcher = page.getByRole('button', { name: 'Commands' }) await launcher.click() const menu = page.getByRole('listbox', { name: 'Trigger suggestions' }) - await menu.waitFor({ timeout: 10_000 }) + await menu.getByRole('option').first().waitFor({ timeout: 10_000 }) + await menu.getByRole('status').waitFor({ state: 'hidden', timeout: 10_000 }) const snapshot = await captureStableAria(page, '[role="listbox"]', scaffold.workspaceCwd) await compareOrRefreshGolden(COMMAND_MENU_EXPECTED, snapshot, MODE) expect(snapshot).toContain('text: Commands') @@ -84,9 +91,9 @@ describe('web e2e: lifecycle & chrome (workspace flow / reload / dark mode)', () const launchedBox = await menu.boundingBox() await page.locator('[data-composer-input]').first().press('Escape') await expect.poll(() => menu.count()).toBe(0) - const input = page.locator('[data-composer-input]').first() await writeComposerDraft(page, input, '/') - await menu.waitFor({ timeout: 10_000 }) + await menu.getByRole('option').first().waitFor({ timeout: 10_000 }) + await menu.getByRole('status').waitFor({ state: 'hidden', timeout: 10_000 }) const typedBox = await menu.boundingBox() expect(launchedBox).not.toBeNull() expect(typedBox).not.toBeNull() @@ -114,7 +121,8 @@ describe('web e2e: lifecycle & chrome (workspace flow / reload / dark mode)', () const launcher = zhPage.getByRole('button', { name: '指令' }) await launcher.click() const menu = zhPage.getByRole('listbox', { name: '触发候选建议' }) - await menu.waitFor({ timeout: 10_000 }) + await menu.getByRole('option').first().waitFor({ timeout: 10_000 }) + await menu.getByRole('status').waitFor({ state: 'hidden', timeout: 10_000 }) const snapshot = await captureStableAria(zhPage, '[role="listbox"]', scaffold.workspaceCwd) await compareOrRefreshGolden(COMMAND_MENU_ZH_EXPECTED, snapshot, MODE) expect(zhTripwire.pageErrors).toEqual([]) diff --git a/apps/web/tests/settings-chrome.e2e.ts b/apps/web/tests/settings-chrome.e2e.ts index e617c9aead..8ef2af7048 100644 --- a/apps/web/tests/settings-chrome.e2e.ts +++ b/apps/web/tests/settings-chrome.e2e.ts @@ -12,7 +12,7 @@ import { readFile } from 'node:fs/promises' import { fileURLToPath } from 'node:url' import type { Browser, Locator, Page } from 'playwright' import { chromium } from 'playwright' -import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest' +import { afterAll, beforeAll, describe, expect, it, onTestFailed, onTestFinished } from 'vitest' import { join } from 'node:path' import { SessionId } from '@deepseek-ai/dsh-session' import { @@ -378,6 +378,10 @@ describe('web e2e: settings modal and General preferences', () => { it('steps the content font size, applies it to body, and persists across reload', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-settings-font-size')) + onTestFinished(async () => { + await page.keyboard.press('Escape') + await page.getByRole('dialog', { name: '设置', exact: true }).waitFor({ state: 'hidden' }) + }) const readFontSize = async (target: Page = page): Promise => await target.evaluate( () => document.body.style.getPropertyValue('--dsh-content-font-size'), ) @@ -392,6 +396,24 @@ describe('web e2e: settings modal and General preferences', () => { probe.remove() return size }) + // The displayed value is optimistic; wait for the write before the next step. + const stepFontSize = async (button: Locator, px: number): Promise => { + const [response] = await Promise.all([ + page.waitForResponse((reply) => { + if (new URL(reply.url()).pathname !== '/api/settings/mutate' || reply.request().method() !== 'POST') return false + const request = reply.request().postDataJSON() as { payload: { args: { ns: string } } } + return request.payload.args.ns === 'ui-theme' + }), + button.click(), + ]) + expect(await response.finished()).toBeNull() + const envelope = await response.json() as { result: { ok: boolean } } + expect(envelope.result.ok).toBe(true) + await expect.poll(async () => readFile(join(scaffold.harnessHome, 'settings.yaml'), 'utf8'), { timeout: 5_000 }) + .toMatch(new RegExp(`ui-theme:\n(?:\\s+\\w+: .*\n)*?\\s+fontSize: ${px}`)) + await page.getByRole('dialog', { name: '设置' }).getByText(String(px), { exact: true }).waitFor({ timeout: 5_000 }) + await expect.poll(readFontSize, { timeout: 5_000 }).toBe(`${px}px`) + } expect(await readFontSize()).toBe('14px') expect(await readSecondaryFontSize()).toBe('13px') await page.getByRole('button', { name: '设置', exact: true }).click() @@ -400,17 +422,12 @@ describe('web e2e: settings modal and General preferences', () => { // The stepper reveals its arrows on hover; the up arrow steps 14 → 15 → 16. await dialog.getByText('14', { exact: true }).hover() const increase = dialog.getByRole('button', { name: '增大字号' }) - await increase.click() - await dialog.getByText('15', { exact: true }).waitFor({ timeout: 5_000 }) + await stepFontSize(increase, 15) // 15 is the piecewise boundary: the secondary tier holds at 13px (−2) // where the ≤14 branch would have given 14px (−1). await expect.poll(readSecondaryFontSize, { timeout: 5_000 }).toBe('13px') - await increase.click() - await dialog.getByText('16', { exact: true }).waitFor({ timeout: 5_000 }) - await expect.poll(readFontSize, { timeout: 5_000 }).toBe('16px') + await stepFontSize(increase, 16) await expect.poll(readSecondaryFontSize, { timeout: 5_000 }).toBe('14px') - await expect.poll(async () => readFile(join(scaffold.harnessHome, 'settings.yaml'), 'utf8'), { timeout: 5_000 }) - .toMatch(/ui-theme:\n(?:\s+\w+: .*\n)*?\s+fontSize: 16/) await page.keyboard.press('Escape') // Reload: the boot script embeds the durable size and ThemeRuntime seeds @@ -429,11 +446,8 @@ describe('web e2e: settings modal and General preferences', () => { await restored.waitFor({ timeout: 10_000 }) await restored.getByText('16', { exact: true }).hover() const decrease = restored.getByRole('button', { name: '减小字号' }) - await decrease.click() - await restored.getByText('15', { exact: true }).waitFor({ timeout: 5_000 }) - await decrease.click() - await restored.getByText('14', { exact: true }).waitFor({ timeout: 5_000 }) - await expect.poll(readFontSize, { timeout: 5_000 }).toBe('14px') + await stepFontSize(decrease, 15) + await stepFontSize(decrease, 14) await page.keyboard.press('Escape') expect(tripwire.pageErrors).toEqual([]) }, 90_000) diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts index dff22a99ec..0e15b4f088 100644 --- a/apps/web/vite.config.ts +++ b/apps/web/vite.config.ts @@ -46,15 +46,21 @@ function rejectStandaloneServe(): Plugin { */ function emitPreviewPage(): Plugin { let bootstrapFile: string | undefined + let write = true return { name: 'dsh-emit-preview-page', + configResolved(config) { + write = config.build.write + }, generateBundle(_options, bundle) { + if (!write) return for (const item of Object.values(bundle)) { if (item.type === 'chunk' && item.isEntry && item.name === 'bootstrap') bootstrapFile = item.fileName } if (bootstrapFile === undefined) throw new Error('vite: preview bootstrap entry missing from the bundle') }, async closeBundle() { + if (!write) return // A build that failed before generateBundle has no page to splice. if (bootstrapFile === undefined) return const page = await readFile(src('./dist/index.html'), 'utf8') diff --git a/docs/config-catalog.i18n.yaml b/docs/config-catalog.i18n.yaml index 23b1b9016f..291854fdb6 100644 --- a/docs/config-catalog.i18n.yaml +++ b/docs/config-catalog.i18n.yaml @@ -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 docs/config-catalog.md -config-catalog.md: 900d8d65632322170e806ea1190072531853260c -config-catalog.zh.md: 07693911ae6b1cf20608d774c55a578874471ca3 +config-catalog.md: 4078317ede9d5938dcf680747006409197a94869 +config-catalog.zh.md: 0f94c711b3cd5f06d6969eef8153df9ca0f1adba diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 900d8d6563..4078317ede 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -3539,6 +3539,7 @@ Imported as libraries by other packages; a `cordis.yml` cannot load them. - `@deepseek-ai/dsh-atomic-write` ([`packages/util/atomic-write/src/index.ts`](../packages/util/atomic-write/src/index.ts)) - `@deepseek-ai/dsh-base` ([`packages/bundle/base/src/index.ts`](../packages/bundle/base/src/index.ts)) - `@deepseek-ai/dsh-brand` ([`packages/util/brand/src/index.ts`](../packages/util/brand/src/index.ts)) +- `@deepseek-ai/dsh-chunked-list` ([`packages/util/chunked-list/src/index.ts`](../packages/util/chunked-list/src/index.ts)) - `@deepseek-ai/dsh-client-store` ([`packages/client/store/src/index.ts`](../packages/client/store/src/index.ts)) - `@deepseek-ai/dsh-client-test-runtime` ([`packages/test-support/client-runtime/src/index.ts`](../packages/test-support/client-runtime/src/index.ts)) - `@deepseek-ai/dsh-client-ui-dockkit` ([`packages/client/ui-dockkit/src/index.ts`](../packages/client/ui-dockkit/src/index.ts)) diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index 07693911ae..0f94c711b3 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -3540,6 +3540,7 @@ export interface Config { - `@deepseek-ai/dsh-atomic-write`([`packages/util/atomic-write/src/index.ts`](../packages/util/atomic-write/src/index.ts)) - `@deepseek-ai/dsh-base`([`packages/bundle/base/src/index.ts`](../packages/bundle/base/src/index.ts)) - `@deepseek-ai/dsh-brand`([`packages/util/brand/src/index.ts`](../packages/util/brand/src/index.ts)) +- `@deepseek-ai/dsh-chunked-list`([`packages/util/chunked-list/src/index.ts`](../packages/util/chunked-list/src/index.ts)) - `@deepseek-ai/dsh-client-store`([`packages/client/store/src/index.ts`](../packages/client/store/src/index.ts)) - `@deepseek-ai/dsh-client-test-runtime`([`packages/test-support/client-runtime/src/index.ts`](../packages/test-support/client-runtime/src/index.ts)) - `@deepseek-ai/dsh-client-ui-dockkit`([`packages/client/ui-dockkit/src/index.ts`](../packages/client/ui-dockkit/src/index.ts)) diff --git a/docs/event-producer-consumer.i18n.yaml b/docs/event-producer-consumer.i18n.yaml index f0492f3a9f..0117f66a78 100644 --- a/docs/event-producer-consumer.i18n.yaml +++ b/docs/event-producer-consumer.i18n.yaml @@ -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 docs/event-producer-consumer.md -event-producer-consumer.md: 7c33981cb58d4bad7c1b410fcf61d4649f6322c1 +event-producer-consumer.md: d5ee3453e8694de70cfd64e44b3ce724aa76cd86 event-producer-consumer.zh.md: d857561a7d9dbdf4c4d971a1854d43d3ecc4fd2a diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index 7c33981cb5..d5ee3453e8 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -55,10 +55,10 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `settings/document-updated` | `emit` | [`packages/settings/settings/src/types.ts:105`](../packages/settings/settings/src/types.ts) | [`settings`](../packages/settings/settings) (`events.dispatch`) | `remotes` | | `settings/updated` | `emit` | [`packages/settings/settings/src/types.ts:92`](../packages/settings/settings/src/types.ts) | [`settings`](../packages/settings/settings) (`events.dispatch`) | [`settings`](../packages/settings/settings) | | `skills/change` | `emit` | [`packages/skill/skill/src/index.ts:298`](../packages/skill/skill/src/index.ts) | [`skill`](../packages/skill/skill) (`events.dispatch`) | - | -| `subagent/end` | `emit` | [`packages/subagent/subagent/src/index.ts:168`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), `server`, [`subagent`](../packages/subagent/subagent) | -| `subagent/provider-added` | `emit` | [`packages/subagent/subagent/src/index.ts:142`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`emit`) | [`subagent`](../packages/subagent/subagent), [`tool-subagent`](../packages/subagent/tool-subagent) | -| `subagent/provider-removed` | `emit` | [`packages/subagent/subagent/src/index.ts:148`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`subagent`](../packages/subagent/subagent), [`tool-subagent`](../packages/subagent/tool-subagent) | -| `subagent/start` | `emit` | [`packages/subagent/subagent/src/index.ts:159`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`subagent`](../packages/subagent/subagent) | +| `subagent/end` | `emit` | [`packages/subagent/subagent/src/index.ts:170`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), `server`, [`subagent`](../packages/subagent/subagent) | +| `subagent/provider-added` | `emit` | [`packages/subagent/subagent/src/index.ts:144`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`emit`) | [`subagent`](../packages/subagent/subagent), [`tool-subagent`](../packages/subagent/tool-subagent) | +| `subagent/provider-removed` | `emit` | [`packages/subagent/subagent/src/index.ts:150`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`subagent`](../packages/subagent/subagent), [`tool-subagent`](../packages/subagent/tool-subagent) | +| `subagent/start` | `emit` | [`packages/subagent/subagent/src/index.ts:161`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`subagent`](../packages/subagent/subagent) | | `system-prompt/assemble` | `waterfall` | [`packages/core/system-prompt/src/index.ts:31`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`waterfall`) | [`agent`](../packages/core/agent), [`agent-presets`](../packages/preset/agent-presets), [`session-reference`](../packages/context/session-reference), [`system-prompt`](../packages/core/system-prompt) | | `system-prompt/change` | `emit` | [`packages/core/system-prompt/src/index.ts:37`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`emit`) | - | | `tools/change` | `emit` | [`packages/core/tools/src/index.ts:199`](../packages/core/tools/src/index.ts) | [`agent-presets`](../packages/preset/agent-presets) (`emit`), [`tools`](../packages/core/tools) (`emit`) | [`tool-subagent`](../packages/subagent/tool-subagent) | diff --git a/docs/module-graph.i18n.yaml b/docs/module-graph.i18n.yaml index ba1dd07a2f..594186cd2f 100644 --- a/docs/module-graph.i18n.yaml +++ b/docs/module-graph.i18n.yaml @@ -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 docs/module-graph.md -module-graph.md: 233ae6b3fb49b07a2f7237aef2674c782df07720 -module-graph.zh.md: 64c5aa749aa76631c308d4b724ed1cd356068019 +module-graph.md: 9d229ba5d25fbf9f4763a96667150dd2103d0231 +module-graph.zh.md: 830f777dde991098ad2f4279a879ecf9cb144900 diff --git a/docs/module-graph.md b/docs/module-graph.md index 233ae6b3fb..9d229ba5d2 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -10,6 +10,7 @@ flowchart TD subgraph group_util["packages/util"] pkg_atomic_write["atomic-write"] pkg_brand["brand"] + pkg_chunked_list["chunked-list"] pkg_deque["deque"] pkg_home_paths["home-paths"] pkg_http_proxy["http-proxy"] @@ -1172,6 +1173,7 @@ flowchart TD | --- | --- | --- | | [`atomic-write`](../packages/util/atomic-write) | `util` | — | | [`brand`](../packages/util/brand) | `util` | — | +| [`chunked-list`](../packages/util/chunked-list) | `util` | — | | [`deque`](../packages/util/deque) | `util` | — | | [`home-paths`](../packages/util/home-paths) | `util` | — | | [`http-proxy`](../packages/util/http-proxy) | `util` | — | diff --git a/docs/module-graph.zh.md b/docs/module-graph.zh.md index 64c5aa749a..830f777dde 100644 --- a/docs/module-graph.zh.md +++ b/docs/module-graph.zh.md @@ -12,6 +12,7 @@ flowchart TD subgraph group_util["packages/util"] pkg_atomic_write["atomic-write"] pkg_brand["brand"] + pkg_chunked_list["chunked-list"] pkg_deque["deque"] pkg_home_paths["home-paths"] pkg_http_proxy["http-proxy"] @@ -1174,6 +1175,7 @@ flowchart TD | --- | --- | --- | | [`atomic-write`](../packages/util/atomic-write) | `util` | — | | [`brand`](../packages/util/brand) | `util` | — | +| [`chunked-list`](../packages/util/chunked-list) | `util` | — | | [`deque`](../packages/util/deque) | `util` | — | | [`home-paths`](../packages/util/home-paths) | `util` | — | | [`http-proxy`](../packages/util/http-proxy) | `util` | — | diff --git a/docs/persistence-catalog.i18n.yaml b/docs/persistence-catalog.i18n.yaml index 1e60882ff3..147c3a869a 100644 --- a/docs/persistence-catalog.i18n.yaml +++ b/docs/persistence-catalog.i18n.yaml @@ -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 docs/persistence-catalog.md -persistence-catalog.md: b239204810f92d866d309214678a22901e6f3ef6 -persistence-catalog.zh.md: 86d81a5a64322c98ea288c0aa0289550153eedf0 +persistence-catalog.md: 06242185a988d6908be0c66e13a45af4e4974e1c +persistence-catalog.zh.md: bc6f4f623e60962df7adfd3771d4bf95b1c1ff3b diff --git a/docs/persistence-catalog.md b/docs/persistence-catalog.md index b239204810..06242185a9 100644 --- a/docs/persistence-catalog.md +++ b/docs/persistence-catalog.md @@ -761,6 +761,20 @@ Source: [`packages/core/session/src/types.ts:287`](../packages/core/session/src/ ### `subagent/*` + + +#### `subagent/catalog` — log-only + +```ts persistence-catalog +/** + * A direct child's complete discovery fact. + * @param data - versioned parent-owned catalog entry. + */ +'subagent/catalog': SubagentCatalogEvent +``` + +Source: [`packages/subagent/subagent/src/catalog.ts:40`](../packages/subagent/subagent/src/catalog.ts) + #### `subagent/descriptor` — log-only diff --git a/docs/persistence-catalog.zh.md b/docs/persistence-catalog.zh.md index 86d81a5a64..bc6f4f623e 100644 --- a/docs/persistence-catalog.zh.md +++ b/docs/persistence-catalog.zh.md @@ -763,6 +763,20 @@ export type SessionEvent = { ### `subagent/*` + + +#### `subagent/catalog` — log-only + +```ts persistence-catalog +/** + * A direct child's complete discovery fact. + * @param data - versioned parent-owned catalog entry. + */ +'subagent/catalog': SubagentCatalogEvent +``` + +来源:[`packages/subagent/subagent/src/catalog.ts:40`](../packages/subagent/subagent/src/catalog.ts) + #### `subagent/descriptor` — log-only diff --git a/docs/subsystems/sidebar-right.i18n.yaml b/docs/subsystems/sidebar-right.i18n.yaml index 1b16e2a532..98c8a3a7b2 100644 --- a/docs/subsystems/sidebar-right.i18n.yaml +++ b/docs/subsystems/sidebar-right.i18n.yaml @@ -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 docs/subsystems/sidebar-right.md -sidebar-right.md: db4103a282287d8ea163d66b19efec27e0a0630f -sidebar-right.zh.md: 1cb0b1e67e99acd7cb6aa12272cd57a5fdd207e0 +sidebar-right.md: 59eda91862282d240d49764d53c17f531ff4bf21 +sidebar-right.zh.md: 3ecb5e9ef496471316414b6aeb0c683a78188025 diff --git a/docs/subsystems/sidebar-right.md b/docs/subsystems/sidebar-right.md index db4103a282..59eda91862 100644 --- a/docs/subsystems/sidebar-right.md +++ b/docs/subsystems/sidebar-right.md @@ -8,7 +8,7 @@ This page is the reference for the subsystem's contracts: addresses, tab-type re ## Position and ownership -One docking surface exists per Session, held in a session-scoped slot store and drawn by the `rightbar` seat; a reload returns every session to the collapsed default, and switching sessions keeps each surface where it was ([state](../../packages/client/ui-sidebar-right/README.md#state)). The surface's every change is one recorded history entry computed by the kit's pure planners; a docked pane never stays empty, and the last pane reseeds the guide tab. +One docking surface exists per Session, held in a session-scoped slot store and drawn by `rightbar.session`. The root-scoped `rightbar` controller mounts that seat only while Conversation is selected; a reload returns every session to the collapsed default, and switching sessions keeps each surface where it was ([state](../../packages/client/ui-sidebar-right/README.md#state)). The surface's every change is one recorded history entry computed by the kit's pure planners; a docked pane never stays empty, and the last pane reseeds the guide tab. A tab type is two registrations that share one `kind`: a static definition in `ctx.sidebarRightTabs` saying which addresses the type opens, and a keyed slot registration supplying its body. The framework injects `useTabInfo()` for live Sidebar, pane and tab information; each type keeps its own state in its slot store. Packages import each other's declarations only as types. diff --git a/docs/subsystems/sidebar-right.zh.md b/docs/subsystems/sidebar-right.zh.md index 1cb0b1e67e..3ecb5e9ef4 100644 --- a/docs/subsystems/sidebar-right.zh.md +++ b/docs/subsystems/sidebar-right.zh.md @@ -8,7 +8,7 @@ ## 定位与归属 -每个会话恰有一个停靠面,保存在会话作用域的 slot store 里、由 `rightbar` 席位绘制;刷新页面后每个会话回到折叠的默认态,切换会话时各自的面保持原状([状态](../../packages/client/ui-sidebar-right/README.zh.md#state))。面的每一次变化都是 kit 纯规划器算出的一条历史记录;停靠的 pane 从不空着,最后一个 pane 会重新种入引导 tab。 +每个会话恰有一个停靠面,保存在会话作用域的 slot store 里、由 `rightbar.session` 绘制。root 作用域的 `rightbar` 控制器仅在选中 Conversation 时挂载该席位;刷新页面后每个会话回到折叠的默认态,切换会话时各自的面保持原状([状态](../../packages/client/ui-sidebar-right/README.zh.md#state))。面的每一次变化都是 kit 纯规划器算出的一条历史记录;停靠的 pane 从不空着,最后一个 pane 会重新种入引导 tab。 一个 tab 类型是共用一个 `kind` 的两次注册:在 `ctx.sidebarRightTabs` 里的静态定义说明该类型打开哪些地址,一次 keyed slot 注册提供它的正文。框架注入 `useTabInfo()` 以读取 Sidebar、窗格和标签的实时信息;各类型把自身状态放在 slot store 里。各包之间只以类型形式引用彼此的声明。 diff --git a/docs/subsystems/slots.i18n.yaml b/docs/subsystems/slots.i18n.yaml index 49925a9aa5..d829845174 100644 --- a/docs/subsystems/slots.i18n.yaml +++ b/docs/subsystems/slots.i18n.yaml @@ -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 docs/subsystems/slots.md -slots.md: 136607b035c4a40c8ab96d6bf933e6b5c8a925bf -slots.zh.md: b3a54048f5df20ad3ef271aea868ce15d5e18bce +slots.md: e659a0ccff548dcf1d9aa6f422d1773a838a62ef +slots.zh.md: 92d1af68f8b67637342055d3c11aea29194048ad diff --git a/docs/subsystems/slots.md b/docs/subsystems/slots.md index 136607b035..e659a0ccff 100644 --- a/docs/subsystems/slots.md +++ b/docs/subsystems/slots.md @@ -82,6 +82,7 @@ The shipped adapters add these standard props. They are available according to t |---|---|---| | every scope | `useSessions`, `useSessionPendingInteraction` | `ui-session` | | every scope | `useWorkspaces` | `ui-workspace` | +| every scope | `usePanelInfo` | `ui-layout` | | `session` | `sessionId`, `useSession`, `useProjection` | `ui-session` | | `session-maybe` | optional `sessionId`, `useSession`, `useProjection` results | `ui-session` | | `session` | `useConversation`, `useInput`, `inputActions` | `ui-conversation` | @@ -112,6 +113,7 @@ root ├─ sidebar │ ├─ sidebar.brand.mark │ ├─ sidebar.brand.name +│ ├─ sidebar.panellist │ ├─ sidebar.footer.action │ ├─ sidebar.workspaces │ │ └─ sidebar.workspaces.directoryFlow @@ -127,43 +129,45 @@ root │ ├─ settings.models.footer │ └─ settings.plugins.tab │ └─ settings.plugin.item -├─ conversation -│ ├─ conversation.session -│ │ └─ conversation.view -│ │ ├─ conversation.chat.node -│ │ │ ├─ conversation.chat.assistant-actions -│ │ │ ├─ conversation.chat.commandview -│ │ │ ├─ conversation.chat.turnTail -│ │ │ └─ tool.call.toolview -│ │ │ ├─ tool.call.images -│ │ │ └─ tool.view.cordis -│ │ ├─ conversation.message.images -│ │ └─ conversation.trajectory.images -│ ├─ conversation.session.header -│ │ ├─ conversation.session.header.lineage -│ │ ├─ conversation.session.header.actions -│ │ ├─ conversation.session.header.utilities -│ │ └─ conversation.session.header.corner -│ ├─ conversation.composer -│ │ └─ conversation.approval.detail -│ ├─ conversation.composer.bar -│ │ ├─ conversation.input.attachments -│ │ ├─ conversation.input.plan -│ │ └─ conversation.input.model -│ ├─ conversation.input.overlay -│ ├─ conversation.input.dock -│ ├─ conversation.composer.dock -│ ├─ conversation.input.left -│ ├─ conversation.input.right -│ ├─ conversation.hero.brand.mark -│ ├─ conversation.hero.workspace -│ │ └─ conversation.hero.workspace.directoryFlow -│ └─ conversation.hero.agentPreset +├─ main +│ └─ main.conversation +│ ├─ conversation.session +│ │ └─ conversation.view +│ │ ├─ conversation.chat.node +│ │ │ ├─ conversation.chat.assistant-actions +│ │ │ ├─ conversation.chat.commandview +│ │ │ ├─ conversation.chat.turnTail +│ │ │ └─ tool.call.toolview +│ │ │ ├─ tool.call.images +│ │ │ └─ tool.view.cordis +│ │ ├─ conversation.message.images +│ │ └─ conversation.trajectory.images +│ ├─ conversation.session.header +│ │ ├─ conversation.session.header.lineage +│ │ ├─ conversation.session.header.actions +│ │ ├─ conversation.session.header.utilities +│ │ └─ conversation.session.header.corner +│ ├─ conversation.composer +│ │ └─ conversation.approval.detail +│ ├─ conversation.composer.bar +│ │ ├─ conversation.input.attachments +│ │ ├─ conversation.input.plan +│ │ └─ conversation.input.model +│ ├─ conversation.input.overlay +│ ├─ conversation.input.dock +│ ├─ conversation.composer.dock +│ ├─ conversation.input.left +│ ├─ conversation.input.right +│ ├─ conversation.hero.brand.mark +│ ├─ conversation.hero.workspace +│ │ └─ conversation.hero.workspace.directoryFlow +│ └─ conversation.hero.agentPreset ├─ rightbar -│ ├─ sidebar.right.pane.tab -│ │ └─ sidebar.right.tab.guide -│ ├─ sidebar.right.pane.tab.title -│ └─ sidebar.right.tab.menu.item +│ └─ rightbar.session +│ ├─ sidebar.right.pane.tab +│ │ └─ sidebar.right.tab.guide +│ ├─ sidebar.right.pane.tab.title +│ └─ sidebar.right.tab.menu.item └─ shell.overlay ``` diff --git a/docs/subsystems/slots.zh.md b/docs/subsystems/slots.zh.md index b3a54048f5..92d1af68f8 100644 --- a/docs/subsystems/slots.zh.md +++ b/docs/subsystems/slots.zh.md @@ -82,6 +82,7 @@ Slot 声明固定两个相互独立的维度。 |---|---|---| | 所有 scope | `useSessions`、`useSessionPendingInteraction` | `ui-session` | | 所有 scope | `useWorkspaces` | `ui-workspace` | +| 所有作用域 | `usePanelInfo` | `ui-layout` | | `session` | `sessionId`、`useSession`、`useProjection` | `ui-session` | | `session-maybe` | 结果可选的 `sessionId`、`useSession`、`useProjection` | `ui-session` | | `session` | `useConversation`、`useInput`、`inputActions` | `ui-conversation` | @@ -112,6 +113,7 @@ root ├─ sidebar │ ├─ sidebar.brand.mark │ ├─ sidebar.brand.name +│ ├─ sidebar.panellist │ ├─ sidebar.footer.action │ ├─ sidebar.workspaces │ │ └─ sidebar.workspaces.directoryFlow @@ -127,43 +129,45 @@ root │ ├─ settings.models.footer │ └─ settings.plugins.tab │ └─ settings.plugin.item -├─ conversation -│ ├─ conversation.session -│ │ └─ conversation.view -│ │ ├─ conversation.chat.node -│ │ │ ├─ conversation.chat.assistant-actions -│ │ │ ├─ conversation.chat.commandview -│ │ │ ├─ conversation.chat.turnTail -│ │ │ └─ tool.call.toolview -│ │ │ ├─ tool.call.images -│ │ │ └─ tool.view.cordis -│ │ ├─ conversation.message.images -│ │ └─ conversation.trajectory.images -│ ├─ conversation.session.header -│ │ ├─ conversation.session.header.lineage -│ │ ├─ conversation.session.header.actions -│ │ ├─ conversation.session.header.utilities -│ │ └─ conversation.session.header.corner -│ ├─ conversation.composer -│ │ └─ conversation.approval.detail -│ ├─ conversation.composer.bar -│ │ ├─ conversation.input.attachments -│ │ ├─ conversation.input.plan -│ │ └─ conversation.input.model -│ ├─ conversation.input.overlay -│ ├─ conversation.input.dock -│ ├─ conversation.composer.dock -│ ├─ conversation.input.left -│ ├─ conversation.input.right -│ ├─ conversation.hero.brand.mark -│ ├─ conversation.hero.workspace -│ │ └─ conversation.hero.workspace.directoryFlow -│ └─ conversation.hero.agentPreset +├─ main +│ └─ main.conversation +│ ├─ conversation.session +│ │ └─ conversation.view +│ │ ├─ conversation.chat.node +│ │ │ ├─ conversation.chat.assistant-actions +│ │ │ ├─ conversation.chat.commandview +│ │ │ ├─ conversation.chat.turnTail +│ │ │ └─ tool.call.toolview +│ │ │ ├─ tool.call.images +│ │ │ └─ tool.view.cordis +│ │ ├─ conversation.message.images +│ │ └─ conversation.trajectory.images +│ ├─ conversation.session.header +│ │ ├─ conversation.session.header.lineage +│ │ ├─ conversation.session.header.actions +│ │ ├─ conversation.session.header.utilities +│ │ └─ conversation.session.header.corner +│ ├─ conversation.composer +│ │ └─ conversation.approval.detail +│ ├─ conversation.composer.bar +│ │ ├─ conversation.input.attachments +│ │ ├─ conversation.input.plan +│ │ └─ conversation.input.model +│ ├─ conversation.input.overlay +│ ├─ conversation.input.dock +│ ├─ conversation.composer.dock +│ ├─ conversation.input.left +│ ├─ conversation.input.right +│ ├─ conversation.hero.brand.mark +│ ├─ conversation.hero.workspace +│ │ └─ conversation.hero.workspace.directoryFlow +│ └─ conversation.hero.agentPreset ├─ rightbar -│ ├─ sidebar.right.pane.tab -│ │ └─ sidebar.right.tab.guide -│ ├─ sidebar.right.pane.tab.title -│ └─ sidebar.right.tab.menu.item +│ └─ rightbar.session +│ ├─ sidebar.right.pane.tab +│ │ └─ sidebar.right.tab.guide +│ ├─ sidebar.right.pane.tab.title +│ └─ sidebar.right.tab.menu.item └─ shell.overlay ``` diff --git a/docs/subsystems/subagent.i18n.yaml b/docs/subsystems/subagent.i18n.yaml index f64a9d5c91..4750a413d5 100644 --- a/docs/subsystems/subagent.i18n.yaml +++ b/docs/subsystems/subagent.i18n.yaml @@ -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 docs/subsystems/subagent.md -subagent.md: 55e8f8af23cb71c1103c017c09e9dfd2ef365b75 -subagent.zh.md: 71a71b33771ff83ccaa6802358b8534c11930181 +subagent.md: 5f927f4c0bef57c0fda0bcb39280a5ac2012343e +subagent.zh.md: ce827f154989404825e322682c4af6ba83182253 diff --git a/docs/subsystems/subagent.md b/docs/subsystems/subagent.md index 55e8f8af23..5f927f4c0b 100644 --- a/docs/subsystems/subagent.md +++ b/docs/subsystems/subagent.md @@ -8,6 +8,8 @@ Service Definition: [dsh-subagent](../../packages/subagent/subagent) (`ctx.subag Sources: [`packages/subagent/subagent/src/types.ts`](../../packages/subagent/subagent/src/types.ts), [`packages/subagent/subagent/src/index.ts`](../../packages/subagent/subagent/src/index.ts), and [`packages/subagent/subagent/src/continuation.ts`](../../packages/subagent/subagent/src/continuation.ts) +The `subagentCatalog` projection exposes `SubagentCatalogEntry[]` in parent event order through Session observations and client snapshots. Each entry contains the child id, creation time, mode, and mode-dependent label; fork-inherited catalog facts are excluded. [The subagent package](../../packages/subagent/subagent/README.md) owns catalog creation and persistence semantics. + ## Two kinds of capability, discovered two ways A provider advertises its **start-time** features on a static descriptor the service checks BEFORE a one-shot run exists; a request that needs one the provider lacks is rejected loud (`SubagentError('UNSUPPORTED_CAPABILITY')`), never accepted-then-ignored. Those flags describe only the one-shot [`start()`](#the-provider-contract-subagentprovider) path, where the provider composes the child. **Continuable** children are composed by the continuation manager itself, so they are gated by one optional method whose presence IS the capability, with TS narrowing as the discovery mechanism: [`SubagentProvider.prepareContinuable`](#the-provider-contract-subagentprovider). @@ -672,6 +674,8 @@ list(): string[] * fulfills; a rejection therefore has no run for the caller to dispose and * emits no run lifecycle events. Post-publication turn and infrastructure * failures settle through the returned run. + * A catalog append failure disposes the run and handles its result rejection; + * the caller receives the catalog error even if disposal also fails. * @param name - the provider to use. * @param request - child label, prompt, parent, signal, and optional capabilities. * @returns the published holder-owned run. diff --git a/docs/subsystems/subagent.zh.md b/docs/subsystems/subagent.zh.md index 71a71b3377..ce827f1549 100644 --- a/docs/subsystems/subagent.zh.md +++ b/docs/subsystems/subagent.zh.md @@ -8,6 +8,8 @@ Service Definition:[dsh-subagent](../../packages/subagent/subagent)(`ctx.sub 源码:[`packages/subagent/subagent/src/types.ts`](../../packages/subagent/subagent/src/types.ts)、[`packages/subagent/subagent/src/index.ts`](../../packages/subagent/subagent/src/index.ts)和 [`packages/subagent/subagent/src/continuation.ts`](../../packages/subagent/subagent/src/continuation.ts) +`subagentCatalog` projection 通过 Session 观察和客户端快照暴露按父会话事件排序的 `SubagentCatalogEntry[]`。每个条目包含子级 id、创建时间、模式和依模式确定的标签;fork 继承的目录事实不在其中。[subagent 包](../../packages/subagent/subagent/README.zh.md) 定义目录创建和持久化语义。 + ## 两类能力,两种发现方式 提供方通过一个静态描述符公布其**启动时**功能,服务会在单次 run 存在之前即行检查;如果请求依赖提供方不具备的功能,会被明确拒绝(`SubagentError('UNSUPPORTED_CAPABILITY')`),绝不会被接受后静默忽略。这些 flag 仅描述单次 [`start()`](#the-provider-contract-subagentprovider) 路径,即由提供方组合子 agent 的路径。**可继续**子 agent 由继续执行管理器自行组合,因此它们由唯一一个可选方法把关,方法存在即为能力,并以 TypeScript 的类型收窄作为发现机制:[`SubagentProvider.prepareContinuable`](#the-provider-contract-subagentprovider)。 @@ -676,6 +678,8 @@ list(): string[] * fulfills; a rejection therefore has no run for the caller to dispose and * emits no run lifecycle events. Post-publication turn and infrastructure * failures settle through the returned run. + * A catalog append failure disposes the run and handles its result rejection; + * the caller receives the catalog error even if disposal also fails. * @param name - the provider to use. * @param request - child label, prompt, parent, signal, and optional capabilities. * @returns the published holder-owned run. diff --git a/lefthook.yml b/lefthook.yml index 1c2a693c88..237202e7e0 100644 --- a/lefthook.yml +++ b/lefthook.yml @@ -28,7 +28,7 @@ pre-commit: # lefthook only inspects files present on disk — so that one case still # falls through to the freshness assertion in the test lane. - name: third-party notices (staged) - glob: '{package.json,*/package.json,*/*/package.json,*/*/*/package.json,*/*/*/*/package.json,pnpm-workspace.yaml,*/*/pnpm-workspace.yaml,pnpm-lock.yaml,vendor/README.md,python/*/pyproject.toml,scripts/gen-third-party-notices.ts,scripts/build-exe-for-python-sdk.ts}' + glob: '{package.json,*/package.json,*/*/package.json,*/*/*/package.json,*/*/*/*/package.json,pnpm-workspace.yaml,*/*/pnpm-workspace.yaml,pnpm-lock.yaml,vendor/README.md,python/*/pyproject.toml,scripts/gen-third-party-notices.ts,scripts/browser-bundled-externals.ts,scripts/build-exe-for-python-sdk.ts,tsconfig.base*.json,packages/*/*/src/**/*,packages/*/*/tsdown.config.ts,packages/client/tsdown.client.ts,apps/*/src/**/*,apps/*/vite.config.ts}' run: node_modules/.bin/tsx scripts/gen-third-party-notices.ts && git add THIRD_PARTY_NOTICES.md - name: whitespace (staged) diff --git a/packages/client/AGENTS.md b/packages/client/AGENTS.md index 0d1633b902..91e57ed5e9 100644 --- a/packages/client/AGENTS.md +++ b/packages/client/AGENTS.md @@ -60,9 +60,9 @@ Npm sections describe installation and development relationships; each build fac 1. **Every client package keeps Cordis in matching `peerDependencies` and `devDependencies`.** This includes the static packages because their Node face participates in the same Cordis plugin contract. 2. **A package under `packages/client/` is always covered; `dsh.client` marks a Client/Host package outside that directory.** Explicit include/exclude entries handle exceptions. Every covered package's Host entry is scanned, while a `./client` export alone does not select dependency policy. -3. **Browser and type relationships are development-only.** Client imports, type-only imports, module augmentations, TypeScript project references, `dsh.client.inject`, invariant companions, and metadata-only peers belong only in `devDependencies`. Configuration-only entries that Knip cannot infer from imports are listed in the dependency policy and projected into `knip.json` by `--fix`. +3. **Browser and type relationships are development-only.** Workspace and third-party Client imports, type-only imports, module augmentations, TypeScript project references, `dsh.client.inject`, and metadata-only peers belong only in `devDependencies`. Configuration-only entries absent from imports are listed in the dependency policy; Node companion value imports follow the Host rules below. 4. **Host value imports require classified exports.** A workspace value reached from the package's Host entry belongs only in `dependencies` when its exact module specifier and runtime export appear in `safeHostDependencyExports`. Exports whose identity or module state must be shared appear in `peerRequiredHostExports` and keep the whole package edge in matching `peerDependencies` and `devDependencies`. The verifier rejects unclassified exports before `--fix` writes manifests. -5. **Ordinary installed libraries stay in `dependencies`.** This includes private implementation libraries bundled into `lib/client.js` and bare imports left in a statically linked `lib/index.js`; the final Vite host, not the library build, merges and splits the latter. +5. **Only Host runtime libraries require installation dependencies.** Browser-only third-party implementations belong in `devDependencies`, whether inlined into `lib/client.js` or retained as bare imports in a static browser library. Static libraries are Web-shell build inputs; the final Vite build merges their imports and styles. Distributed browser code remains subject to runtime license disclosure ([decision](../../.agents/notes/implemented/process/2026-09-08-browser-third-party-build-inputs.md)). 6. **Browser and Node build faces declare externality independently.** A dynamic browser half uses the baseline plus `dsh.client.external`; a statically linked face externalizes every bare specifier; a Node face externalizes its production dependencies ([`tsdown.client.ts`](tsdown.client.ts)). Moving a name between npm sections must not silently change bundle contents. 7. **Keep the published payload closed.** Every relative runtime import and emitted asset must be covered by `files`; the repository publint pass checks the exact publication view. diff --git a/packages/client/locale/tests/language-row.client.spec.tsx b/packages/client/locale/tests/language-row.client.spec.tsx index f10adf664e..d65a1c82f0 100644 --- a/packages/client/locale/tests/language-row.client.spec.tsx +++ b/packages/client/locale/tests/language-row.client.spec.tsx @@ -12,6 +12,7 @@ import { createLanguageRowStore } from '../src/client/settings-store.ts' // Every fixture carries the resource hook the resources plugin merges into GlobalStandardProps. const useResource = (() => ({ status: 'none' as const, value: undefined, failure: undefined, reload: () => {} })) as GlobalStandardProps['useResource'] +const usePanelInfo: GlobalStandardProps['usePanelInfo'] = selector => selector({ activePanelId: null }) afterEach(cleanup) @@ -41,7 +42,7 @@ function mount(active = 'en') { const props: LanguageRowComponentProps = { useSessions: emptySessions(), useSessionPendingInteraction, - useResource, + usePanelInfo, useResource, useWorkspaces: emptyWorkspaces(), useStore: bindSnapshotSelector(store), actions: store.actions, diff --git a/packages/client/store/README.i18n.yaml b/packages/client/store/README.i18n.yaml index d6af2417f1..657b1a396d 100644 --- a/packages/client/store/README.i18n.yaml +++ b/packages/client/store/README.i18n.yaml @@ -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/store/README.md -README.md: 9d675c1189ae194b913293c89904b4c4f39c879e -README.zh.md: 3ff170b71c320688e50c02c20479ee1d94061a87 +README.md: 0d4525ab6eb2a58bdea89673b96f9aaf5e0e2cf8 +README.zh.md: b66a2e31675c82d933526d40ed9eba20bc5077d9 diff --git a/packages/client/store/README.md b/packages/client/store/README.md index 9d675c1189..0d4525ab6e 100644 --- a/packages/client/store/README.md +++ b/packages/client/store/README.md @@ -32,6 +32,7 @@ None; the stores neither assemble nor send model requests. - **Persistence is browser-local** — persisted stores use JSON in `localStorage`; non-browser runtimes disable persistence, and the package provides no cross-device synchronization. +- **Web-shell build input** — the static ESM retains third-party imports for Vite; independent consumers supply its development dependencies ([dependency rules](../AGENTS.md#dependency-declaration)). diff --git a/packages/client/store/README.zh.md b/packages/client/store/README.zh.md index 3ff170b71c..b66a2e3167 100644 --- a/packages/client/store/README.zh.md +++ b/packages/client/store/README.zh.md @@ -32,6 +32,7 @@ kind: "package-library" - **持久化仅限浏览器本地**——持久化 store 使用 `localStorage` 中的 JSON;非浏览器运行时会禁用持久化,本包也不提供跨设备同步。 +- **Web 壳构建输入**——静态 ESM 为 Vite 保留第三方导入;独立消费方自行提供开发依赖([依赖规则](../AGENTS.md#dependency-declaration))。 diff --git a/packages/client/store/package.json b/packages/client/store/package.json index 172439a947..6bb0c0a212 100644 --- a/packages/client/store/package.json +++ b/packages/client/store/package.json @@ -22,15 +22,13 @@ "./package.json": "./package.json" }, "license": "MIT", - "dependencies": { - "immer": "^10.1.1", - "zustand": "~4.4.7" - }, "peerDependencies": { "@deepseek-ai/cordis": "workspace:^" }, "devDependencies": { - "@deepseek-ai/cordis": "workspace:^" + "@deepseek-ai/cordis": "workspace:^", + "zustand": "~4.4.7", + "immer": "^10.1.1" }, "files": [ "lib/index.js", diff --git a/packages/client/ui-attachment/package.json b/packages/client/ui-attachment/package.json index 7679b48621..9adf41af13 100644 --- a/packages/client/ui-attachment/package.json +++ b/packages/client/ui-attachment/package.json @@ -42,9 +42,6 @@ "watch": "tsdown --watch" }, "license": "MIT", - "dependencies": { - "clsx": "^2.0.0" - }, "devDependencies": { "@deepseek-ai/cordis": "workspace:^", "@types/react": "~18.3.1", @@ -58,7 +55,8 @@ "react": "^18.2.0", "react-dom": "^18.2.0", "@deepseek-ai/dsh-attachment": "workspace:^", - "@deepseek-ai/dsh-client-ui-tool": "workspace:^" + "@deepseek-ai/dsh-client-ui-tool": "workspace:^", + "clsx": "^2.0.0" }, "files": [ "lib/index.js", diff --git a/packages/client/ui-attachment/tests/message-image.client.spec.tsx b/packages/client/ui-attachment/tests/message-image.client.spec.tsx index 7da3a878e9..3e86094c74 100644 --- a/packages/client/ui-attachment/tests/message-image.client.spec.tsx +++ b/packages/client/ui-attachment/tests/message-image.client.spec.tsx @@ -12,6 +12,7 @@ import { MessageImages } from '../src/client/MessageImages.tsx' // Every session-scope fixture carries the resource hook the resources plugin merges into GlobalStandardProps. const useResource = (() => ({ status: 'none' as const, value: undefined, failure: undefined, reload: () => {} })) as GlobalStandardProps['useResource'] +const usePanelInfo: GlobalStandardProps['usePanelInfo'] = selector => selector({ activePanelId: null }) afterEach(cleanup) @@ -272,7 +273,7 @@ describe('ImageGallery', () => { sessionId: 'message-images-test' as MessageImagesProps['sessionId'], useSession, useSessions, - useResource, + usePanelInfo, useResource, useSessionPendingInteraction, useWorkspaces, useProjection: () => undefined, diff --git a/packages/client/ui-chat/tests/apply-inject.client.spec.tsx b/packages/client/ui-chat/tests/apply-inject.client.spec.tsx index 590cc5602c..947eb739ed 100644 --- a/packages/client/ui-chat/tests/apply-inject.client.spec.tsx +++ b/packages/client/ui-chat/tests/apply-inject.client.spec.tsx @@ -16,6 +16,7 @@ import { apply as applyChat, inject as injectChat, type ChatViewInjected, } from '@deepseek-ai/dsh-client-ui-chat/client' import { SessionSeq, type SessionId } from '@deepseek-ai/dsh-session/types' +import type { WorkspaceId } from '@deepseek-ai/dsh-workspace/types' import { createChatStore } from '../src/client/stores.ts' usePinnedBrowserLanguages('zh-CN') @@ -57,7 +58,11 @@ async function bench() { ) new TestRemote(runtime.ctx, { session: { openWorkspacePath } }) runtime.ctx.provide('uiWorkspace', { - connectWorkspace: vi.fn(async () => ROOT), + openWorkspace: vi.fn(async (_workspaceId: WorkspaceId, beforeOpen: (id: SessionId) => void) => { + beforeOpen(ROOT) + runtime.sessions.open(ROOT) + }), + openSession: (id: SessionId) => { runtime.sessions.open(id) }, } as never) const session = sessionFakeFor() await runtime.sessions.add({ @@ -69,7 +74,7 @@ async function bench() { runtime.ctx.provide('locale', locale) runtime.slots.installLocale(locale) await runtime.root.declare({ - 'conversation': { kind: 'single', scope: 'session-maybe' }, + 'main': { kind: 'keyed', scope: 'root' }, }, (_props: { renderSlot?: unknown }) => null) await runtime.mount({ inject: [...injectConversation], apply: applyConversation }) await runtime.mount({ inject: [...injectChat], apply: applyChat }) diff --git a/packages/client/ui-chat/tests/chat-apply.client.spec.tsx b/packages/client/ui-chat/tests/chat-apply.client.spec.tsx index fc1cf43bc5..ac364e4cb7 100644 --- a/packages/client/ui-chat/tests/chat-apply.client.spec.tsx +++ b/packages/client/ui-chat/tests/chat-apply.client.spec.tsx @@ -9,6 +9,7 @@ import { resolveSlotLabel } from '@deepseek-ai/dsh-client-ui-slots' import type { ObservableSnapshot } from '@deepseek-ai/dsh-client-store' import type { SessionBinding } from '@deepseek-ai/dsh-api-session-controller/client' import type { SessionId } from '@deepseek-ai/dsh-session/types' +import type { WorkspaceId } from '@deepseek-ai/dsh-workspace/types' import { apply as applyConversation, inject as injectConversation, } from '@deepseek-ai/dsh-client-ui-conversation/client' @@ -44,7 +45,11 @@ async function bench() { runtime.ctx.provide('layout', { openRightbar: vi.fn(), closeRightbar: vi.fn() } as never) runtime.ctx.provide('sidebarRight', { openResource: vi.fn() } as never) runtime.ctx.provide('uiWorkspace', { - connectWorkspace: vi.fn(async () => SID), + openWorkspace: vi.fn(async (_workspaceId: WorkspaceId, beforeOpen: (id: SessionId) => void) => { + beforeOpen(SID) + runtime.sessions.open(SID) + }), + openSession: (id: SessionId) => { runtime.sessions.open(id) }, } as never) new TestRemote(runtime.ctx, { session: { openWorkspacePath: vi.fn(async () => ({ ok: true, value: { opened: true } })) }, @@ -53,7 +58,7 @@ async function bench() { runtime.ctx.provide('locale', locale) runtime.slots.installLocale(locale) await runtime.root.declare({ - 'conversation': { kind: 'single', scope: 'session-maybe' }, + 'main': { kind: 'keyed', scope: 'root' }, 'conversation.approval.detail': { kind: 'single', scope: 'session' }, 'settings.general.item': { kind: 'list', scope: 'root' }, }, (_props: { renderSlot?: unknown }) => null) @@ -120,7 +125,8 @@ describe('Chat apply wiring', () => { await b.chat.dispose() expect(b.runtime.slots.entries('conversation.view')).toHaveLength(0) expect(b.runtime.slots.spec('conversation.chat.node')).toBeUndefined() - expect(b.runtime.slots.entries('conversation')).toHaveLength(1) + expect(b.runtime.slots.entries('main').map(row => row.options.key)).toEqual(['conversation']) + expect(b.runtime.slots.entries('main.conversation')).toHaveLength(1) expect(b.runtime.ctx.get('uiConversation')).toBeDefined() await b.runtime.dispose() }) diff --git a/packages/client/ui-chat/tests/chat-view.client.spec.tsx b/packages/client/ui-chat/tests/chat-view.client.spec.tsx index 7b72d280dc..fc8fd9c15e 100644 --- a/packages/client/ui-chat/tests/chat-view.client.spec.tsx +++ b/packages/client/ui-chat/tests/chat-view.client.spec.tsx @@ -364,6 +364,7 @@ function makeHarness( // ChatView never invokes it (pass-through stub). const SessionProviderStub: ChatViewSlotProps['SessionProvider'] = ({ children }) => <>{children} const props: ChatViewSlotProps = { + usePanelInfo: selector => selector({ activePanelId: null }), sessionId: SID, useSession: bindSnapshotSelector(session.source), useChat: bindSnapshotSelector(chatSource.source), diff --git a/packages/client/ui-chat/tests/transcript-view-row.client.spec.tsx b/packages/client/ui-chat/tests/transcript-view-row.client.spec.tsx index 5aa8e31047..7004e75a37 100644 --- a/packages/client/ui-chat/tests/transcript-view-row.client.spec.tsx +++ b/packages/client/ui-chat/tests/transcript-view-row.client.spec.tsx @@ -35,6 +35,7 @@ function mount(mode: 'normal' | 'compact' = 'compact') { const source = createSnapshotStore(mode) const setTranscriptView = vi.fn((next: 'normal' | 'compact') => { source.set(next) }) const props: TranscriptViewRowProps = { + usePanelInfo: selector => selector({ activePanelId: null }), useSessions: emptySessions(), useSessionPendingInteraction: noPendingInteraction(), useWorkspaces: emptyWorkspaces(), diff --git a/packages/client/ui-commands/package.json b/packages/client/ui-commands/package.json index bc7b069162..40ce5a3bff 100644 --- a/packages/client/ui-commands/package.json +++ b/packages/client/ui-commands/package.json @@ -41,9 +41,6 @@ "watch": "tsdown --watch" }, "license": "MIT", - "dependencies": { - "clsx": "^2.0.0" - }, "peerDependencies": { "@deepseek-ai/cordis": "workspace:^" }, @@ -64,7 +61,8 @@ "@deepseek-ai/dsh-client-store": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-client-ui-renderer": "workspace:^", - "@deepseek-ai/dsh-client-ui-session": "workspace:^" + "@deepseek-ai/dsh-client-ui-session": "workspace:^", + "clsx": "^2.0.0" }, "files": [ "lib/index.js", diff --git a/packages/client/ui-conversation/README.i18n.yaml b/packages/client/ui-conversation/README.i18n.yaml index b28c9069b7..a1eea2e97a 100644 --- a/packages/client/ui-conversation/README.i18n.yaml +++ b/packages/client/ui-conversation/README.i18n.yaml @@ -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-conversation/README.md -README.md: edfcb83193d4513fba846f673d3572c35e8c0d1e -README.zh.md: f92e4c77ba3d3a2ceb96758e5817e447ba2ec1ac +README.md: 19a210bb200dae359920e85ff944b68242f45b4b +README.zh.md: c5a56871eb99607c924b2b2e048de72d918e6a8b diff --git a/packages/client/ui-conversation/README.md b/packages/client/ui-conversation/README.md index edfcb83193..19a210bb20 100644 --- a/packages/client/ui-conversation/README.md +++ b/packages/client/ui-conversation/README.md @@ -36,7 +36,9 @@ Target packages declaration-merge their snapshot and Location data maps, then re ## Shell and standard props -The package registers the optional-Session `conversation` shell, strict Session header/body entries, View list, composer chain and bar, input regions, Hero regions, queue dock, draft persistence, and phase calculation. `ctx.uiSession.provide()` materializes the Conversation and input sources from the same Session binding and supplies `inputActions` as a stable standard prop. +Workspace selection uses `uiWorkspace.openWorkspace` to prepare the target and commit navigation. Draft text and attachments move in its synchronous preparation callback only while that request is current; later navigation or owner disposal leaves the original draft intact. + +The package occupies the root-scoped `main` key `conversation`, whose wrapper declares the optional-Session `main.conversation` shell. It registers strict Session header/body entries, View list, composer chain and bar, input regions, Hero regions, queue dock, draft persistence, and phase calculation. `ctx.uiSession.provide()` materializes the Conversation and input sources from the same Session binding and supplies `inputActions` as a stable standard prop. View selection is deterministic: a registered persisted selection wins, otherwise registered `chat` wins, otherwise no View renders. It never chooses the first registered View. Shell phase combines Session lifecycle with the active-target set; no target-specific snapshot is read by the shell. diff --git a/packages/client/ui-conversation/README.zh.md b/packages/client/ui-conversation/README.zh.md index f92e4c77ba..c5a56871eb 100644 --- a/packages/client/ui-conversation/README.zh.md +++ b/packages/client/ui-conversation/README.zh.md @@ -36,7 +36,9 @@ target package 通过 declaration merge 扩展 snapshot 与 Location data map, ## Shell 与标准 props -本包注册 optional-Session `conversation` shell、strict Session header/body、View list、composer chain 与 bar、输入区域、Hero 区域、queue dock、草稿持久化和 phase 计算。`ctx.uiSession.provide()` 从同一个 Session binding 物化 Conversation 与 input source,并将 `inputActions` 作为稳定标准 prop 提供。 +工作区选择使用 `uiWorkspace.openWorkspace` 准备目标并提交导航。草稿文字和附件仅在请求仍有效时,通过它的同步准备回调搬移;后续导航或所有者释放会保留原草稿。 + +本包占据 root 作用域 `main` 中的 `conversation` key,其包装层声明 optional-Session `main.conversation` shell。本包注册strict Session header/body、View list、composer chain 与 bar、输入区域、Hero 区域、queue dock、草稿持久化和 phase 计算。`ctx.uiSession.provide()` 从同一个 Session binding 物化 Conversation 与 input source,并将 `inputActions` 作为稳定标准 prop 提供。 View 选择规则固定:有效且已注册的持久化选择优先,其次是已注册的 `chat`,否则不渲染 View;绝不选择第一个已注册 View。Shell phase 只组合 Session lifecycle 与 active-target set,不读取任何 target-specific snapshot。 diff --git a/packages/client/ui-conversation/package.json b/packages/client/ui-conversation/package.json index 8902063d3c..987aa9574f 100644 --- a/packages/client/ui-conversation/package.json +++ b/packages/client/ui-conversation/package.json @@ -46,13 +46,7 @@ }, "license": "MIT", "dependencies": { - "clsx": "^2.0.0", - "@deepseek-ai/schemastery": "workspace:^", - "@lexical/history": "^0.49.0", - "@lexical/plain-text": "^0.49.0", - "@lexical/text": "^0.49.0", - "@lexical/utils": "^0.49.0", - "lexical": "^0.49.0" + "@deepseek-ai/schemastery": "workspace:^" }, "peerDependencies": { "@deepseek-ai/cordis": "workspace:^" @@ -93,7 +87,13 @@ "react-dom": "^18.2.0", "@types/react-dom": "~18.3.0", "zod": "^4.4.3", - "@deepseek-ai/dsh-settings": "workspace:^" + "@deepseek-ai/dsh-settings": "workspace:^", + "lexical": "^0.49.0", + "clsx": "^2.0.0", + "@lexical/utils": "^0.49.0", + "@lexical/text": "^0.49.0", + "@lexical/plain-text": "^0.49.0", + "@lexical/history": "^0.49.0" }, "files": [ "lib/index.js", diff --git a/packages/client/ui-conversation/src/client/apply.ts b/packages/client/ui-conversation/src/client/apply.ts index 65b8374919..7eba82cfc0 100644 --- a/packages/client/ui-conversation/src/client/apply.ts +++ b/packages/client/ui-conversation/src/client/apply.ts @@ -28,6 +28,7 @@ import { queueDockEntry } from './queue/QueueDock.tsx' import { EnterBehaviorRow } from './settings/EnterBehaviorRow.tsx' import type { EnterBehaviorRowInjected } from './settings/EnterBehaviorRow.tsx' import { ConversationRoot } from './skeleton/ConversationRoot.tsx' +import { ConversationPanel } from './skeleton/ConversationPanel.tsx' import { ConversationSession, ConversationSessionHeader } from './skeleton/ConversationSession.tsx' import { InputBar } from './skeleton/InputBar.tsx' import { todoDockEntry } from './skeleton/TodoPanel.tsx' @@ -84,9 +85,11 @@ const ABSENT_FILE_UPLOADS = { } interface WorkspaceNavigation { - connectWorkspace( + openSession(sessionId: SessionId): void + openWorkspace( workspaceId: Parameters[0], - ): Promise + beforeOpen: (sessionId: SessionId) => void, + ): Promise } /** Resolve the session-scoped Conversation action face, failing loud. */ @@ -214,7 +217,7 @@ export function apply(ctx: Context, config: Config = Config({})): void { }) const registerConversationRoot = () => slots.register({ - name: 'conversation', + name: 'main.conversation', locale: NS, children: { 'conversation.session': { kind: 'single', scope: 'session' }, @@ -230,8 +233,7 @@ export function apply(ctx: Context, config: Config = Config({})): void { hooks: { composerBlock: sessionId === undefined ? ABSENT_BLOCK : composerBlocks.storeFor(sessionId), }, - selectWorkspace: async (workspaceId) => { - const nextId = await workspaceNavigation.connectWorkspace(workspaceId) + selectWorkspace: workspaceId => workspaceNavigation.openWorkspace(workspaceId, (nextId) => { if (sessionId !== undefined && nextId !== sessionId) { const from = inputHub.shell(sessionId) const draft = from.snapshot.draft @@ -251,8 +253,7 @@ export function apply(ctx: Context, config: Config = Config({})): void { } } } - sessions.open(nextId) - }, + }), }), }, ConversationRoot) @@ -284,7 +285,7 @@ export function apply(ctx: Context, config: Config = Config({})): void { store: conversationStore, inject: (sessionId: SessionId, actions: BoundActions): ConversationSessionHeaderInjected => ({ hooks: { conversationViews }, - open: (id) => { sessions.open(id) }, + open: (id) => { workspaceNavigation.openSession(id) }, selectView: (view) => { activateView(sessionId, view) actions.setView(view) @@ -384,7 +385,12 @@ export function apply(ctx: Context, config: Config = Config({})): void { }, }, InputBar) - slots.inject('conversation', function* () { + slots.inject('main', function* () { + yield slots.register({ + name: 'main', + key: 'conversation', + children: { 'main.conversation': { kind: 'single', scope: 'session-maybe' } }, + }, ConversationPanel) yield registerConversationRoot() yield registerConversationSession() yield registerConversationHeader() diff --git a/packages/client/ui-conversation/src/client/contract/slots.ts b/packages/client/ui-conversation/src/client/contract/slots.ts index eb38a0f261..cf76e02b1b 100644 --- a/packages/client/ui-conversation/src/client/contract/slots.ts +++ b/packages/client/ui-conversation/src/client/contract/slots.ts @@ -117,6 +117,8 @@ export type UseConversationViews = SnapshotSelectorHook declare module '@deepseek-ai/dsh-client-ui-slots' { interface SlotMap { + /** Conversation shell beneath its root-scoped main-panel entry. */ + 'main.conversation': { kind: 'single'; scope: 'session-maybe' } /** Strict per-Session Conversation body. */ 'conversation.session': { kind: 'single'; scope: 'session' } /** Strict per-Session title, actions, and View navigation. */ @@ -364,7 +366,7 @@ export interface HeroBrandMarkOwnerProps { /** Full props of the resident optional-Session Conversation shell. */ export type ConversationSlotProps = - PropsRuntime<'conversation'> + PropsRuntime<'main.conversation'> & PropsRenderSlots< | 'conversation.session' | 'conversation.session.header' | 'conversation.composer' | 'conversation.composer.bar' diff --git a/packages/client/ui-conversation/src/client/skeleton/ConversationPanel.tsx b/packages/client/ui-conversation/src/client/skeleton/ConversationPanel.tsx new file mode 100644 index 0000000000..8bd11aac33 --- /dev/null +++ b/packages/client/ui-conversation/src/client/skeleton/ConversationPanel.tsx @@ -0,0 +1,12 @@ +/** Root-scoped main occupant; Session binding belongs to its Conversation child. */ +import type { PropsRenderSlots, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' +import type {} from '../contract/slots.ts' + +/** + * Render the Conversation with optional current-Session binding. + * @param props - main-slot inputs and the declared Conversation renderer. + * @returns the Conversation subtree. + */ +export function ConversationPanel({ renderSlot }: PropsRuntime<'main'> & PropsRenderSlots<'main.conversation'>) { + return renderSlot('main.conversation', {}) +} diff --git a/packages/client/ui-conversation/tests/apply-inject.client.spec.tsx b/packages/client/ui-conversation/tests/apply-inject.client.spec.tsx index 2dde9ce7b3..c6d6226bfe 100644 --- a/packages/client/ui-conversation/tests/apply-inject.client.spec.tsx +++ b/packages/client/ui-conversation/tests/apply-inject.client.spec.tsx @@ -49,7 +49,14 @@ async function bench() { } runtime.ctx.provide('settingsScope', { bind: () => stubSettingsScope().scope } as never) const connectWorkspace = vi.fn(async () => ROOT) - runtime.ctx.provide('uiWorkspace', { connectWorkspace } as never) + runtime.ctx.provide('uiWorkspace', { + openWorkspace: async (_workspaceId: WorkspaceId, beforeOpen: (id: SessionId) => void) => { + const id = await connectWorkspace() + beforeOpen(id) + runtime.sessions.open(id) + }, + openSession: (id: SessionId) => { runtime.sessions.open(id) }, + } as never) const sessionFake = sessionFakeFor() await runtime.sessions.add({ id: ROOT, @@ -60,12 +67,12 @@ async function bench() { runtime.ctx.provide('locale', locale) runtime.slots.installLocale(locale) await runtime.root.declare({ - 'conversation': { kind: 'single', scope: 'session-maybe' }, + 'main': { kind: 'keyed', scope: 'root' }, }, (_props: { renderSlot?: unknown }) => null) const feature = await runtime.mount({ inject: [...inject], apply }) runtime.renderRoot() - const entryOf = (key: 'conversation' | 'conversation.session' | 'conversation.session.header' | 'conversation.composer.bar') => + const entryOf = (key: 'main.conversation' | 'conversation.session' | 'conversation.session.header' | 'conversation.composer.bar') => runtime.slots.entries(key)[0]! const conversationApi = (id: SessionId) => { const entry = entryOf('conversation.session') @@ -77,7 +84,7 @@ async function bench() { return { instance, injected } } const residentApi = (id: SessionId | undefined) => { - const entry = entryOf('conversation') + const entry = entryOf('main.conversation') return (entry.inject as unknown as (sessionId: SessionId | undefined) => ConversationInjected)(id) } const headerApi = (id: SessionId) => { diff --git a/packages/client/ui-conversation/tests/apply-wiring.client.spec.tsx b/packages/client/ui-conversation/tests/apply-wiring.client.spec.tsx index eccf4b87a0..3c59deb1c2 100644 --- a/packages/client/ui-conversation/tests/apply-wiring.client.spec.tsx +++ b/packages/client/ui-conversation/tests/apply-wiring.client.spec.tsx @@ -14,14 +14,20 @@ const SID = 'session-1' as SessionId async function bench(options: { declareConversation?: boolean } = {}) { const runtime = await SlotTestRuntime.create() - runtime.ctx.provide('uiWorkspace', { connectWorkspace: vi.fn(async () => SID) } as never) + runtime.ctx.provide('uiWorkspace', { + openWorkspace: vi.fn(async (_workspaceId: unknown, beforeOpen: (id: SessionId) => void) => { + beforeOpen(SID) + runtime.sessions.open(SID) + }), + openSession: (id: SessionId) => { runtime.sessions.open(id) }, + } as never) runtime.ctx.provide('settingsScope', { bind: () => stubSettingsScope().scope } as never) const locale = new LocaleRuntime(runtime.ctx) runtime.ctx.provide('locale', locale) runtime.slots.installLocale(locale) if (options.declareConversation !== false) { await runtime.root.declare({ - 'conversation': { kind: 'single', scope: 'session-maybe' }, + 'main': { kind: 'keyed', scope: 'root' }, 'settings.general.item': { kind: 'list', scope: 'root' }, }, (_props: { renderSlot?: unknown }) => null) } @@ -31,7 +37,7 @@ async function bench(options: { declareConversation?: boolean } = {}) { function entry( runtime: SlotTestRuntime, - key: 'conversation' | 'conversation.session' | 'conversation.session.header', + key: 'main.conversation' | 'conversation.session' | 'conversation.session.header', ) { return runtime.slots.entries(key)[0] as { store?: unknown } | undefined } @@ -39,14 +45,18 @@ function entry( describe('target-neutral Conversation apply wiring', () => { it('waits for the layout-owned conversation declaration before registering its subtree', async () => { const b = await bench({ declareConversation: false }) - expect(b.runtime.slots.entries('conversation')).toHaveLength(0) + expect(b.runtime.slots.entries('main')).toHaveLength(0) + expect(b.runtime.slots.entries('main.conversation')).toHaveLength(0) await b.runtime.root.declare({ - 'conversation': { kind: 'single', scope: 'session-maybe' }, + 'main': { kind: 'keyed', scope: 'root' }, 'settings.general.item': { kind: 'list', scope: 'root' }, }, (_props: { renderSlot?: unknown }) => null) - expect(b.runtime.slots.entries('conversation')).toHaveLength(1) + expect(b.runtime.slots.entries('main').map(row => row.options.key)).toEqual(['conversation']) + expect(b.runtime.slots.entries('main.conversation')).toHaveLength(1) + expect(b.runtime.slots.spec('main.conversation')) + .toEqual({ kind: 'single', scope: 'session-maybe' }) expect(b.runtime.slots.entries('conversation.session')).toHaveLength(1) expect(b.runtime.slots.entries('conversation.session.header')).toHaveLength(1) expect(b.runtime.slots.entries('conversation.composer.bar')).toHaveLength(1) @@ -65,7 +75,7 @@ describe('target-neutral Conversation apply wiring', () => { const b = await bench() const session = entry(b.runtime, 'conversation.session') const header = entry(b.runtime, 'conversation.session.header') - expect(entry(b.runtime, 'conversation')?.store).toBeUndefined() + expect(entry(b.runtime, 'main.conversation')?.store).toBeUndefined() expect(session?.store).toBeDefined() expect(header?.store).toBe(session?.store) expect(b.runtime.slots.spec('conversation.composer')) @@ -110,7 +120,9 @@ describe('target-neutral Conversation apply wiring', () => { await b.feature.dispose() expect(b.runtime.ctx.get('conversation')).toBeUndefined() expect(b.runtime.ctx.get('uiConversation')).toBeUndefined() - expect(b.runtime.slots.entries('conversation')).toHaveLength(0) + expect(b.runtime.slots.entries('main')).toHaveLength(0) + expect(b.runtime.slots.entries('main.conversation')).toHaveLength(0) + expect(b.runtime.slots.spec('main.conversation')).toBeUndefined() expect(b.runtime.slots.spec('conversation.view')).toBeUndefined() await b.runtime.dispose() }) diff --git a/packages/client/ui-conversation/tests/assembly-surfaces.client.spec.tsx b/packages/client/ui-conversation/tests/assembly-surfaces.client.spec.tsx index fd99320d93..573a732780 100644 --- a/packages/client/ui-conversation/tests/assembly-surfaces.client.spec.tsx +++ b/packages/client/ui-conversation/tests/assembly-surfaces.client.spec.tsx @@ -12,6 +12,7 @@ import { import { InputHub } from '../src/client/input/hub.ts' import { apply, inject, type EmptyWorkspaceOwnerProps } from '@deepseek-ai/dsh-client-ui-conversation/client' import type { SessionId } from '@deepseek-ai/dsh-session/types' +import type { WorkspaceId } from '@deepseek-ai/dsh-workspace/types' // jsdom implements no Range geometry (Lexical's scroll-into-view measures the // caret with one once the surface is genuinely contenteditable). @@ -40,13 +41,13 @@ beforeEach(() => { vi.stubGlobal('ResizeObserver', ResizeObserverStub) }) -type AppRootProps = PropsRenderSlots<'conversation'> +type AppRootProps = PropsRenderSlots<'main'> function AppRoot({ renderSlot }: AppRootProps) { - return <>{renderSlot('conversation', {})} + return <>{renderSlot('main', {}, { entryKey: 'conversation' })} } const LAYOUT_CHILDREN = { - 'conversation': { kind: 'single', scope: 'session-maybe' }, + 'main': { kind: 'keyed', scope: 'root' }, } as const function WorkspaceProbe({ open }: EmptyWorkspaceOwnerProps) { @@ -60,7 +61,13 @@ function WorkspaceProbe({ open }: EmptyWorkspaceOwnerProps) { async function bench(opts?: { blank?: boolean }) { const runtime = await SlotTestRuntime.create() - runtime.ctx.provide('uiWorkspace', { connectWorkspace: vi.fn(async () => SID) } as never) + runtime.ctx.provide('uiWorkspace', { + openWorkspace: vi.fn(async (_workspaceId: WorkspaceId, beforeOpen: (id: SessionId) => void) => { + beforeOpen(SID) + runtime.sessions.open(SID) + }), + openSession: (id: SessionId) => { runtime.sessions.open(id) }, + } as never) runtime.ctx.provide('settingsScope', { bind: () => stubSettingsScope().scope } as never) const locale = new LocaleRuntime(runtime.ctx) runtime.ctx.provide('locale', locale) @@ -82,7 +89,13 @@ async function bench(opts?: { blank?: boolean }) { describe('resident composer', () => { it('renders the locked view state while no session exists at all', async () => { const runtime = await SlotTestRuntime.create() - runtime.ctx.provide('uiWorkspace', { connectWorkspace: vi.fn(async () => SID) } as never) + runtime.ctx.provide('uiWorkspace', { + openWorkspace: vi.fn(async (_workspaceId: WorkspaceId, beforeOpen: (id: SessionId) => void) => { + beforeOpen(SID) + runtime.sessions.open(SID) + }), + openSession: (id: SessionId) => { runtime.sessions.open(id) }, + } as never) runtime.ctx.provide('settingsScope', { bind: () => stubSettingsScope().scope } as never) const locale = new LocaleRuntime(runtime.ctx) runtime.ctx.provide('locale', locale) @@ -109,7 +122,13 @@ describe('resident composer', () => { it('keeps the complete Hero tree mounted when the first Workspace session appears', async () => { const runtime = await SlotTestRuntime.create() - runtime.ctx.provide('uiWorkspace', { connectWorkspace: vi.fn(async () => SID) } as never) + runtime.ctx.provide('uiWorkspace', { + openWorkspace: vi.fn(async (_workspaceId: WorkspaceId, beforeOpen: (id: SessionId) => void) => { + beforeOpen(SID) + runtime.sessions.open(SID) + }), + openSession: (id: SessionId) => { runtime.sessions.open(id) }, + } as never) runtime.ctx.provide('settingsScope', { bind: () => stubSettingsScope().scope } as never) const locale = new LocaleRuntime(runtime.ctx) runtime.ctx.provide('locale', locale) @@ -174,7 +193,13 @@ describe('resident composer', () => { describe('prompt rejection through the assembled composer', () => { it('renders the promptError alert strip and keeps the draft in the machine', async () => { const runtime = await SlotTestRuntime.create() - runtime.ctx.provide('uiWorkspace', { connectWorkspace: vi.fn(async () => SID) } as never) + runtime.ctx.provide('uiWorkspace', { + openWorkspace: vi.fn(async (_workspaceId: WorkspaceId, beforeOpen: (id: SessionId) => void) => { + beforeOpen(SID) + runtime.sessions.open(SID) + }), + openSession: (id: SessionId) => { runtime.sessions.open(id) }, + } as never) runtime.ctx.provide('settingsScope', { bind: () => stubSettingsScope().scope } as never) const locale = new LocaleRuntime(runtime.ctx) runtime.ctx.provide('locale', locale) diff --git a/packages/client/ui-conversation/tests/enter-behavior-row.client.spec.tsx b/packages/client/ui-conversation/tests/enter-behavior-row.client.spec.tsx index 0cba7c36b5..4af4d1dbd6 100644 --- a/packages/client/ui-conversation/tests/enter-behavior-row.client.spec.tsx +++ b/packages/client/ui-conversation/tests/enter-behavior-row.client.spec.tsx @@ -40,6 +40,7 @@ function mount() { const policy = new ComposerSubmissionPolicy() const setBusyEnter = vi.fn((behavior: 'queue' | 'steer') => { policy.setBusyEnter(behavior) }) const props: EnterBehaviorRowProps = { + usePanelInfo: selector => selector({ activePanelId: null }), useSessions: emptySessions(), useSessionPendingInteraction: noPendingInteraction(), useResource, diff --git a/packages/client/ui-conversation/tests/input-bar.client.spec.tsx b/packages/client/ui-conversation/tests/input-bar.client.spec.tsx index 051aa295e3..75c30b117d 100644 --- a/packages/client/ui-conversation/tests/input-bar.client.spec.tsx +++ b/packages/client/ui-conversation/tests/input-bar.client.spec.tsx @@ -164,6 +164,7 @@ function bench(over?: BenchOptions) { return null }) as never const props: InputBarProps = { + usePanelInfo: selector => selector({ activePanelId: null }), sessionId: SID, SessionProvider: ({ children }) => children, useSession: bindSnapshotSelector(session), diff --git a/packages/client/ui-conversation/tests/input-matrix.client.spec.tsx b/packages/client/ui-conversation/tests/input-matrix.client.spec.tsx index c333d25ada..57319d7087 100644 --- a/packages/client/ui-conversation/tests/input-matrix.client.spec.tsx +++ b/packages/client/ui-conversation/tests/input-matrix.client.spec.tsx @@ -48,6 +48,7 @@ function mountBar(shell: SessionInputShell, over?: { running?: boolean; disabled removed: over?.disabled ?? false, }) const props: InputBarProps = { + usePanelInfo: selector => selector({ activePanelId: null }), sessionId: SID, SessionProvider: ({ children }) => children, useSession: bindSnapshotSelector(session), diff --git a/packages/client/ui-conversation/tests/input-scenarios.client.spec.tsx b/packages/client/ui-conversation/tests/input-scenarios.client.spec.tsx index 9e41f82eb7..88ab3a809f 100644 --- a/packages/client/ui-conversation/tests/input-scenarios.client.spec.tsx +++ b/packages/client/ui-conversation/tests/input-scenarios.client.spec.tsx @@ -137,6 +137,7 @@ async function scopedBench(register?: (inputTriggers: InputTriggerService) => vo const wiring = shell const sessionStore = createSnapshotStore(sessionSnapshot(sessionId)) const barProps: InputBarProps = { + usePanelInfo: selector => selector({ activePanelId: null }), sessionId, SessionProvider: ({ children }) => children, useSession: bindSnapshotSelector(sessionStore), diff --git a/packages/client/ui-conversation/tests/queue-dock.client.spec.tsx b/packages/client/ui-conversation/tests/queue-dock.client.spec.tsx index 446ce0defd..08db337005 100644 --- a/packages/client/ui-conversation/tests/queue-dock.client.spec.tsx +++ b/packages/client/ui-conversation/tests/queue-dock.client.spec.tsx @@ -73,11 +73,13 @@ function liveSession(initial: SessionSnapshot) { const INPUT_STATE: InputState = { draft: '', attachmentIds: [], draftRev: 0, phase: 'plain', occurrences: [], queue: [] } const t: QueueDockProps['t'] = makeTranslate(zh, commonZh) +const usePanelInfo: GlobalStandardProps['usePanelInfo'] = selector => selector({ activePanelId: null }) function kitFor(snapshot: SessionSnapshot, injected: Partial = {}) { return { sessionId: SID, t, + usePanelInfo, useSessions: (() => { throw new Error('unused') }) as unknown as SnapshotSelectorHook, useResource, useSessionPendingInteraction: bindSnapshotSelector( diff --git a/packages/client/ui-conversation/tests/skeleton.client.spec.tsx b/packages/client/ui-conversation/tests/skeleton.client.spec.tsx index f1735ce415..9ffecfdfaa 100644 --- a/packages/client/ui-conversation/tests/skeleton.client.spec.tsx +++ b/packages/client/ui-conversation/tests/skeleton.client.spec.tsx @@ -199,6 +199,7 @@ function mount( useChat={useChat} useTrajectory={useTrajectory} useSessions={props.useSessions} + usePanelInfo={props.usePanelInfo} useResource={useResource} useSessionPendingInteraction={useSessionPendingInteraction} useWorkspaces={props.useWorkspaces} @@ -225,6 +226,7 @@ function mount( useChat={useChat} useTrajectory={useTrajectory} useSessions={props.useSessions} + usePanelInfo={props.usePanelInfo} useResource={useResource} useSessionPendingInteraction={useSessionPendingInteraction} useWorkspaces={props.useWorkspaces} @@ -251,6 +253,7 @@ function mount( useSession={useSession} useConversation={useConversation} useSessions={props.useSessions} + usePanelInfo={props.usePanelInfo} useSessionPendingInteraction={useSessionPendingInteraction} useWorkspaces={props.useWorkspaces} useProjection={(() => undefined)} @@ -295,6 +298,7 @@ function mount( : (opts?.fallback ?? null) )) as ConversationRootProps['renderSlotChain'] const props: ConversationRootProps = { + usePanelInfo: selector => selector({ activePanelId: null }), sessionId: SID, SessionProvider: ({ children }) => children, useSession, diff --git a/packages/client/ui-directory-picker-browse/package.json b/packages/client/ui-directory-picker-browse/package.json index aaf735a27f..ec0a9d8ed0 100644 --- a/packages/client/ui-directory-picker-browse/package.json +++ b/packages/client/ui-directory-picker-browse/package.json @@ -41,9 +41,6 @@ "watch": "tsdown --watch" }, "license": "MIT", - "dependencies": { - "clsx": "^2.0.0" - }, "peerDependencies": { "@deepseek-ai/cordis": "workspace:^" }, @@ -59,7 +56,8 @@ "@types/react": "~18.3.1", "@deepseek-ai/cordis": "workspace:^", "react": "^18.2.0", - "react-dom": "^18.2.0" + "react-dom": "^18.2.0", + "clsx": "^2.0.0" }, "files": [ "lib/index.js", diff --git a/packages/client/ui-dockkit/README.i18n.yaml b/packages/client/ui-dockkit/README.i18n.yaml index bd0993ceba..19c343ed54 100644 --- a/packages/client/ui-dockkit/README.i18n.yaml +++ b/packages/client/ui-dockkit/README.i18n.yaml @@ -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-dockkit/README.md -README.md: e012295852ad3c957695fcfbd0568a45b3f54856 -README.zh.md: 2423887d60460651a224ffe1cb0fc9b61363c0e2 +README.md: 330bff66577464a26eac50d92ee54cc0cdefc8df +README.zh.md: f5259400055a28ea99f2205f9ba0a28d2cc13bd2 diff --git a/packages/client/ui-dockkit/README.md b/packages/client/ui-dockkit/README.md index e012295852..330bff6657 100644 --- a/packages/client/ui-dockkit/README.md +++ b/packages/client/ui-dockkit/README.md @@ -74,6 +74,8 @@ These are not stylistic; each one fixes a defect found in a real browser. ## Build shape +Its static ESM retains third-party imports for the Web shell's Vite build; independent consumers supply its development dependencies ([dependency rules](../AGENTS.md#dependency-declaration)). + The package is statically linked: tsdown's `staticLinked` preset emits one browser ESM bundle at `lib/index.js` (every bare specifier stays an import, sourcemaps chain to the sources) and ships the stylesheet under `lib/` at its `src`-relative path, and the Web shell resolves the package name and bundles that artifact itself, so vite stays the only owner of class hashing. One consequence is load-bearing — the kit keeps **one** stylesheet, `dockkit.module.css`, because a consumer de-duplicates injected sheets by file name and a collision would drop one silently. diff --git a/packages/client/ui-dockkit/README.zh.md b/packages/client/ui-dockkit/README.zh.md index 2423887d60..f525940005 100644 --- a/packages/client/ui-dockkit/README.zh.md +++ b/packages/client/ui-dockkit/README.zh.md @@ -74,6 +74,8 @@ tab 的 `kind` 是不透明字符串。种子 tab 是工厂(`DockControllerOpt ## 构建形态 +静态 ESM 为 Web 壳的 Vite 构建保留第三方导入;独立消费方自行提供开发依赖([依赖规则](../AGENTS.md#dependency-declaration))。 + 本包静态链接:tsdown 的 `staticLinked` 预设在 `lib/index.js` 产出一个浏览器 ESM bundle(所有裸说明符保持为 import,sourcemap 链回源码),并把样式表按其相对 `src` 的路径放到 `lib/` 下;Web 外壳按包名解析并自行打包该产物,因此 vite 仍是 class 哈希的唯一拥有者。有一个后果是承重的——套件只保留**一张**样式表 `dockkit.module.css`,因为消费方按文件名去重注入的样式表,撞名会静默丢掉一张。 diff --git a/packages/client/ui-dockkit/package.json b/packages/client/ui-dockkit/package.json index 8e7a97e4dd..968d1ba505 100644 --- a/packages/client/ui-dockkit/package.json +++ b/packages/client/ui-dockkit/package.json @@ -22,18 +22,15 @@ "./package.json": "./package.json" }, "license": "MIT", - "dependencies": { - "clsx": "^2.0.0", - "react": "^18.2.0", - "react-dom": "^18.2.0" - }, "devDependencies": { "@deepseek-ai/cordis": "workspace:^", "@deepseek-ai/dsh-brand": "workspace:^", "@testing-library/react": "^16.1.0", "@types/react": "~18.3.1", "@types/react-dom": "~18.3.0", - "react-dom": "^18.2.0" + "react-dom": "^18.2.0", + "react": "^18.2.0", + "clsx": "^2.0.0" }, "files": [ "lib/index.js", diff --git a/packages/client/ui-input-trigger/package.json b/packages/client/ui-input-trigger/package.json index a5d7b679d9..65e30abb16 100644 --- a/packages/client/ui-input-trigger/package.json +++ b/packages/client/ui-input-trigger/package.json @@ -41,9 +41,6 @@ "watch": "tsdown --watch" }, "license": "MIT", - "dependencies": { - "clsx": "^2.0.0" - }, "peerDependencies": { "@deepseek-ai/cordis": "workspace:^" }, @@ -61,7 +58,8 @@ "@deepseek-ai/dsh-api-session-controller": "workspace:^", "@deepseek-ai/dsh-client-store": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", - "@deepseek-ai/dsh-client-ui-session": "workspace:^" + "@deepseek-ai/dsh-client-ui-session": "workspace:^", + "clsx": "^2.0.0" }, "files": [ "lib/index.js", diff --git a/packages/client/ui-layout/README.i18n.yaml b/packages/client/ui-layout/README.i18n.yaml index eb1cbebbee..8d680bc72f 100644 --- a/packages/client/ui-layout/README.i18n.yaml +++ b/packages/client/ui-layout/README.i18n.yaml @@ -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-layout/README.md -README.md: 0e353e21f39ba8a84e661f1c540db2c2424bcbfa -README.zh.md: 16471fdabb46bbfb35158ef85aa2544db4ceae93 +README.md: 4fe19fb9ec0a549dc76ca311d0564cbe86c9f5f9 +README.zh.md: 7a61e7808ffb3d81214388a2792a901ced8d9564 diff --git a/packages/client/ui-layout/README.md b/packages/client/ui-layout/README.md index 0e353e21f3..4fe19fb9ec 100644 --- a/packages/client/ui-layout/README.md +++ b/packages/client/ui-layout/README.md @@ -25,7 +25,9 @@ This package provides the Web GUI's three-column AppFrame, edge-column widths, a ## Use this package -The root slot composes the sidebar, conversation, and right column. The sidebar spans 264–420px, defaults to 280px, and retains a 56px rail when collapsed; below 1024px it collapses automatically, and opening the right panel collapses a manually expanded sidebar. The right panel first opens at 45% of the viewport, then retains the user's pixel preference, capped at 70%. To protect 400px for the center, the frame first reduces the right panel to 300px, then reports insufficient room so its occupant closes it, and only then compresses the center further. Dragging has no transition delay; the right handle is absent while closed or fullscreen. +The root slot composes the sidebar, main content, and right column. The sidebar spans 264–420px, defaults to 280px, and retains a 56px rail when collapsed; below 1024px it collapses automatically, and opening the right panel collapses a manually expanded sidebar. The right panel first opens at 45% of the viewport, then retains the user's pixel preference, capped at 70%. To protect 400px for the center, the frame first reduces the right panel to 300px, then reports insufficient room so its occupant closes it, and only then compresses the center further. Dragging has no transition delay; the right handle is absent while closed or fullscreen. + +Global panels occupy the root-scoped `main` keyed slot; `conversation` is the reserved key for the Conversation. `ctx.layout.selectPanel(id)` selects a registered panel, and `null` selects the Conversation without changing the current Session. No global panel is registered by the shipped composition. ### Theme presentation @@ -39,7 +41,9 @@ The presenter consumes resolved theme snapshots and projects them onto the docum
Implementation internals — click to expand -One registration declares four child slots and binds `ctx.layout` methods `toggleSidebar`, `openRightbar(track, fullscreen)`, and `closeRightbar`. The store owns the single frame-width measurement, width preferences, and the occupant's presentation report. The `rightbar` owner supplies actual `width`, `viewportWidth`, and normal-presentation eligibility `canShow`; insufficient room causes a deterministic close, never automatic reopening on widening. Fullscreen hides the width handle without releasing a track the occupant retains. AppFrame always mounts the conversation and right columns; a connected Session renders through `SessionProvider`, and without one the right column is an empty zero-width track. It projects the selected Session title over the build-configured product title or the localized `common.brand.localBuild` fallback, so locale revisions update document metadata with the root entry. The theme presenter is a second effect: pure DOM writes from resolved snapshots — initial state through the getter once, then event-driven only, with no React path. It applies palette, font-size, and token variables before measuring the rendered background as the single color authority. Fullscreen presentation suppresses grid and handle transitions; its occupant reports the new columns only after covering the frame. Fullscreen exit keeps transitions suppressed while the frame installs its destination geometry: close removes the right track, and restore retains it. Subsequent normal geometry actions restore ordinary transitions. +`selectPanel(id)` checks the live `main` registry before changing selection; an absent key throws and leaves the current panel intact. `beginNavigation()` returns an abort signal for an asynchronous UI navigation. A later call, a valid panel selection (including repeated selection), or layout disposal aborts that signal without cancelling underlying Session creation. Consumers check the signal before committing navigation or moving drafts. + +One registration declares four child slots and binds `ctx.layout` methods `selectPanel`, `toggleSidebar`, `openRightbar(track, fullscreen)`, and `closeRightbar`. One root store separates `panelInfo` selection from `layoutInfo` measurements, width preferences, and presentation reports. `usePanelInfo` subscribes to the stable selection object; AppFrame subscribes to the stable layout object. The `rightbar` owner supplies actual `width`, `viewportWidth`, and normal-presentation eligibility `canShow`; insufficient room causes a deterministic close, never automatic reopening on widening. Fullscreen hides the width handle without releasing a track the occupant retains. AppFrame keeps the column containers mounted. The right column's root controller renders `rightbar.session` through `SessionProvider` only while the Conversation is selected; its unmount report releases the track. The independent title component uses the selected Session title only while the Conversation is visible, with the build-configured product title or localized `common.brand.localBuild` as its fallback; locale revisions update that fallback. The theme presenter is a second effect: pure DOM writes from resolved snapshots — initial state through the getter once, then event-driven only, with no React path. It applies palette, font-size, and token variables before measuring the rendered background as the single color authority. Fullscreen presentation suppresses grid and handle transitions; its occupant reports the new columns only after covering the frame. Fullscreen exit keeps transitions suppressed while the frame installs its destination geometry: close removes the right track, and restore retains it. Subsequent normal geometry actions restore ordinary transitions.
@@ -51,7 +55,7 @@ One registration declares four child slots and binds `ctx.layout` methods `toggl Read these pages when the layout surface is not enough. They move from the frame to the columns it renders and the theme it presents. - [ui-sidebar](../ui-sidebar/README.md) — occupies the `sidebar` column and its seats. -- [ui-conversation](../ui-conversation/README.md) — occupies the `conversation` column. +- [ui-conversation](../ui-conversation/README.md) — occupies the `main` key `conversation`. - [ui-sidebar-right](../ui-sidebar-right/README.md) — occupies the `rightbar` column with one docking surface per session. - [ui-theme](../ui-theme/README.md) — the theme seam whose resolved snapshots the presenter consumes. - [Web client architecture](../../../.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.md) — how browser plugin rows load and register slots. diff --git a/packages/client/ui-layout/README.zh.md b/packages/client/ui-layout/README.zh.md index 16471fdabb..7a61e7808f 100644 --- a/packages/client/ui-layout/README.zh.md +++ b/packages/client/ui-layout/README.zh.md @@ -25,7 +25,9 @@ kind: "package-reference" ## 使用本包 -本插件在 root slot 组合侧栏、会话与右栏。左栏为264~420px,默认280px,收起后保留56px;窗口低于1024px时自动收起,打开右栏也会收起手动展开的左栏。右栏首次打开使用窗口宽度的45%,之后保留用户像素偏好,上限为70%;中栏不足400px时先把右栏压到300px,仍不足则通知占用方收起,最后才继续压缩中栏。拖拽跟手且无过渡延迟,关闭或全屏时不显示右栏拖拽区。 +本插件在 root slot 组合侧栏、中央内容与右栏。左栏为264~420px,默认280px,收起后保留56px;窗口低于1024px时自动收起,打开右栏也会收起手动展开的左栏。右栏首次打开使用窗口宽度的45%,之后保留用户像素偏好,上限为70%;中栏不足400px时先把右栏压到300px,仍不足则通知占用方收起,最后才继续压缩中栏。拖拽跟手且无过渡延迟,关闭或全屏时不显示右栏拖拽区。 + +全局面板占据 root 作用域的 `main` keyed slot;`conversation` 是为会话界面保留的 key。`ctx.layout.selectPanel(id)` 选中已注册面板,`null` 则选中会话界面,但不改变当前会话。默认组合不注册任何全局面板。 ### 主题呈现 @@ -39,7 +41,9 @@ kind: "package-reference"
实现细节——点击展开 -一次注册声明四个子slot并绑定 `ctx.layout` 的 `toggleSidebar`、`openRightbar(track, fullscreen)` 与 `closeRightbar`。store持有唯一的frame宽度测量、左右栏偏好及占用方报告的呈现状态。`rightbar` 的owner参数为实际 `width`、`viewportWidth` 与普通呈现的 `canShow`;占用方在空间不足时执行确定性的收起,变宽不自行重新展开。全屏隐藏宽度手柄,但不自行释放占用方要求保留的轨道。AppFrame 始终挂载会话与右栏;已连接 Session 经 `SessionProvider` 渲染,没有 Session 时右栏是一条空的零宽轨道。它把所选 Session 标题投影到构建配置的产品标题或本地化 `common.brand.localBuild` 回退值之上,因此 locale revision 会随根 entry 一起更新文档元数据。主题呈现器是第二个 effect:从解析后的快照做纯 DOM 写入——初始状态经 getter 读取一次,此后仅事件驱动,不经过 React。它先应用调色板、字号与 token 变量,再把渲染出的背景测量为唯一的颜色依据。 全屏呈现禁用网格和手柄过渡;占用方完全覆盖框架后才报告新的列宽。 退出全屏时,框架先保持无过渡并安装目标布局:关闭移除右轨道,恢复保留右轨道。后续普通几何操作恢复正常过渡。 +`selectPanel(id)` 在改变选中态前检查实时 `main` 注册表;缺失的 key 会抛错并保留当前面板。`beginNavigation()` 为异步 UI 导航返回 abort signal。后续调用、有效面板选择(包括重复选择)或布局释放会中止该 signal,但不取消底层会话创建。消费者在提交导航或搬移草稿前检查 signal。 + +一次注册声明四个子slot并绑定 `ctx.layout` 的 `selectPanel`、`toggleSidebar`、`openRightbar(track, fullscreen)` 与 `closeRightbar`。同一个 root 存储把 `panelInfo` 选中态与 `layoutInfo` 测量、宽度偏好、呈现报告分开。`usePanelInfo` 订阅引用稳定的选中态对象,AppFrame 订阅引用稳定的布局对象。`rightbar` 的owner参数为实际 `width`、`viewportWidth` 与普通呈现的 `canShow`;占用方在空间不足时执行确定性的收起,变宽不自行重新展开。全屏隐藏宽度手柄,但不自行释放占用方要求保留的轨道。AppFrame 保持各列容器挂载。右栏的 root 控制器仅在选中会话界面时,经 `SessionProvider` 渲染 `rightbar.session`;内容卸载时的报告释放列宽。独立的标题组件仅在会话界面可见时使用所选会话标题,以构建配置的产品标题或本地化 `common.brand.localBuild` 为回退值;语言变化会更新该回退值。主题呈现器是第二个 effect:从解析后的快照做纯 DOM 写入——初始状态经 getter 读取一次,此后仅事件驱动,不经过 React。它先应用调色板、字号与 token 变量,再把渲染出的背景测量为唯一的颜色依据。 全屏呈现禁用网格和手柄过渡;占用方完全覆盖框架后才报告新的列宽。 退出全屏时,框架先保持无过渡并安装目标布局:关闭移除右轨道,恢复保留右轨道。后续普通几何操作恢复正常过渡。
@@ -51,7 +55,7 @@ kind: "package-reference" 当布局面不够用时阅读以下页面。它们从框架进入它所渲染的栏与它所呈现的主题。 - [ui-sidebar](../ui-sidebar/README.zh.md)——占据 `sidebar` 栏及其座位。 -- [ui-conversation](../ui-conversation/README.zh.md)——占据 `conversation` 栏。 +- [ui-conversation](../ui-conversation/README.zh.md)——占据 `main` 中的 `conversation` key。 - [ui-sidebar-right](../ui-sidebar-right/README.zh.md)——以每会话一个停靠面占据 `rightbar` 栏。 - [ui-theme](../ui-theme/README.zh.md)——呈现器消费其解析快照的主题 seam。 - [Web 客户端架构](../../../.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.zh.md)——浏览器插件行如何加载并注册槽位。 diff --git a/packages/client/ui-layout/package.json b/packages/client/ui-layout/package.json index 1f10a544ad..a2c025f39d 100644 --- a/packages/client/ui-layout/package.json +++ b/packages/client/ui-layout/package.json @@ -45,6 +45,7 @@ "@deepseek-ai/cordis": "workspace:^" }, "devDependencies": { + "@deepseek-ai/dsh-brand": "workspace:^", "@deepseek-ai/dsh-client-locale": "workspace:^", "@deepseek-ai/dsh-client-store": "workspace:^", "@deepseek-ai/dsh-client-ui-renderer": "workspace:^", diff --git a/packages/client/ui-layout/src/client/AppFrame.tsx b/packages/client/ui-layout/src/client/AppFrame.tsx index 905341f503..05bb4b9494 100644 --- a/packages/client/ui-layout/src/client/AppFrame.tsx +++ b/packages/client/ui-layout/src/client/AppFrame.tsx @@ -3,10 +3,9 @@ * shell renders only 'root'). Owns the grid tracks (sidebar | center | * rightbar), the drag handles (pointer capture + rAF throttle), the column * solve (columns.ts), and the child-slot render decisions: the sidebar slot - * renders HERE with live parameters from that solve, and the session-aware - * occupants render in fixed column positions; the strict right-column entry - * gates itself on current-session availability while the session-maybe - * conversation retains identity. + * receives live parameters from that solve. The root-scoped main slot selects + * the Conversation or a global panel. Each column occupant owns its Session + * binding and reports the geometry it needs. * * The right column is a track, not a box: its occupant draws its panel anchored * to the frame's right edge at the resolved normal width, and the @@ -15,7 +14,7 @@ * track but hides the outer resize handle. Everything arrives through the framework * shares — zero cordis or framework imports, zero self-made hooks. */ -import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react' +import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react' import type { ReactNode } from 'react' import type { PropsLocale, PropsRenderSlots, PropsRuntime, PropsStore, @@ -28,7 +27,7 @@ import css from './AppFrame.module.css' /** Full composed props: runtime share + child-slot render share + store share. */ export type AppFrameProps = & PropsRuntime<'root'> - & PropsRenderSlots<'sidebar' | 'conversation' | 'rightbar' | 'shell.overlay'> + & PropsRenderSlots<'sidebar' | 'main' | 'rightbar' | 'shell.overlay'> & PropsStore> & PropsLocale<'common'> @@ -37,6 +36,12 @@ function CenterColumn(props: { children?: ReactNode }) { return
{props.children}
} +/** Subscribe to the main key without subscribing the column frame to each panel id. */ +function MainPanel({ usePanelInfo, renderSlot }: Pick, 'usePanelInfo'> & PropsRenderSlots<'main'>) { + const panelId = usePanelInfo(info => info.activePanelId) + return renderSlot('main', {}, { entryKey: panelId ?? 'conversation' }) +} + /** * Right column grid item. Zero-width unless the occupant asked for a track; the * occupant's panel is positioned against the column's right edge, which never @@ -116,18 +121,14 @@ function DragHandle(props: { side: 'sidebar' | 'rightbar'; left: number; onStart export function AppFrame({ useStore, useSessions, + usePanelInfo, actions, renderSlot, - SessionProvider, t, }: AppFrameProps) { - const panels = useStore(s => s) - const documentTitle = useSessions((s) => { - const current = s.current - return current === undefined ? undefined : s.byId[current]?.title - }) + const layoutInfo = useStore(state => state.layoutInfo) const frameRef = useRef(null) - const viewport = panels.viewportWidth + const viewport = layoutInfo.viewportWidth // Track the frame's own box (not the window): rAF-throttled ResizeObserver. useLayoutEffect(() => { @@ -157,15 +158,15 @@ export function AppFrame({ }, [actions]) const narrow = viewport < SIDEBAR_AUTO_COLLAPSE - const sidebarCollapsed = narrow ? !panels.narrowExpanded : panels.sidebar === 0 + const sidebarCollapsed = narrow ? !layoutInfo.narrowExpanded : layoutInfo.sidebar === 0 const sidebarPreference = sidebarCollapsed ? 0 - : panels.sidebar === 0 ? SIDEBAR_DEFAULT : panels.sidebar - const rightbarPreference = panels.rightbar ?? viewport * RIGHTBAR_DEFAULT_RATIO + : layoutInfo.sidebar === 0 ? SIDEBAR_DEFAULT : layoutInfo.sidebar + const rightbarPreference = layoutInfo.rightbar ?? viewport * RIGHTBAR_DEFAULT_RATIO // Opening on a narrow frame collapses the left sidebar. Eligibility must // include that space before the occupant's first shown report arrives. - const normal = computeColumns(viewport, !panels.rightbarShown && narrow ? 0 : sidebarPreference, rightbarPreference) - const cols = computeColumns(viewport, sidebarPreference, panels.rightbarTrack ? rightbarPreference : 0) + const normal = computeColumns(viewport, !layoutInfo.rightbarShown && narrow ? 0 : sidebarPreference, rightbarPreference) + const cols = computeColumns(viewport, sidebarPreference, layoutInfo.rightbarTrack ? rightbarPreference : 0) const colsRef = useRef(cols) colsRef.current = cols const rightbarWidth = useRef(normal.rightbar) @@ -189,6 +190,14 @@ export function AppFrame({ actions.setRightbar(rightbarBase.current - dx) }, [actions]) const productTitle = process.env.DSH_CLIENT_TITLE ?? t('brand.localBuild') + const sidebar = useMemo(() => renderSlot('sidebar', { + collapsed: sidebarCollapsed, + width: cols.sidebar, + }), [renderSlot, sidebarCollapsed, cols.sidebar]) + const main = useMemo(() => ( + + ), [usePanelInfo, renderSlot]) + const overlays = useMemo(() => renderSlot('shell.overlay', {}), [renderSlot]) return (
- {/* Render-site slot call with live concession output: a closed - sidebar keeps the mounted slot at the compact-rail width, and the - component sees its rendered state as owner params decided here - (collapsed follows the resolved rail, so a derived auto-collapse - renders the rail UI too). */} - {renderSlot('sidebar', { - collapsed: sidebarCollapsed, - width: cols.sidebar, - })} + {sidebar}
<> - {/* Both column occupants stay at fixed tree positions from first - paint — no loading gate: a bare status line reads worse than - the shell's own pending rendering. The conversation is - session-maybe; SessionProvider withholds the strict right-column - entry while no session is current. */} - {renderSlot('conversation', {})} + {main} - {/* Strict session entry: with no session there is no surface, and the - column is an empty zero-width track. The occupant receives the - panel width it should draw at; the track is the frame's business. */} - - {renderSlot('rightbar', { width: normal.rightbar, viewportWidth: viewport, canShow: normal.rightbar > 0 })} - + {renderSlot('rightbar', { width: normal.rightbar, viewportWidth: viewport, canShow: normal.rightbar > 0 })}
- {renderSlot('shell.overlay', {})} + {overlays}
{/* The collapsed rail is fixed-width: no resize handle while closed. */} {!sidebarCollapsed && } - {panels.rightbarShown && !panels.rightbarFullscreen && normal.rightbar > 0 && ( + {layoutInfo.rightbarShown && !layoutInfo.rightbarFullscreen && normal.rightbar > 0 && ( )}
diff --git a/packages/client/ui-layout/src/client/DocumentTitle.tsx b/packages/client/ui-layout/src/client/DocumentTitle.tsx index 4060a3b187..69cd5f4cfe 100644 --- a/packages/client/ui-layout/src/client/DocumentTitle.tsx +++ b/packages/client/ui-layout/src/client/DocumentTitle.tsx @@ -1,9 +1,9 @@ +/** Browser title selection follows the active main panel without subscribing the frame. */ import { useEffect } from 'react' +import type { PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' /** Props for the browser title projection. */ -export interface DocumentTitleProps { - /** Durable title of the selected session, or undefined for the product title. */ - title?: string +export type DocumentTitleProps = Pick, 'useSessions' | 'usePanelInfo'> & { /** Build-configured or localized product title. */ productTitle: string } @@ -14,7 +14,12 @@ export interface DocumentTitleProps { * @param props - Selected session title projection. * @returns No rendered content. */ -export function DocumentTitle({ title, productTitle }: DocumentTitleProps): null { +export function DocumentTitle({ useSessions, usePanelInfo, productTitle }: DocumentTitleProps): null { + const showSessionTitle = usePanelInfo(info => info.activePanelId === null) + const title = useSessions((state) => { + const current = state.current + return !showSessionTitle || current === undefined ? undefined : state.byId[current]?.title + }) useEffect(() => { document.title = title === undefined ? productTitle : `${title} — ${productTitle}` return () => { document.title = productTitle } diff --git a/packages/client/ui-layout/src/client/index.ts b/packages/client/ui-layout/src/client/index.ts index 649215b96d..ffbbd68bb9 100644 --- a/packages/client/ui-layout/src/client/index.ts +++ b/packages/client/ui-layout/src/client/index.ts @@ -3,8 +3,8 @@ * the runtime's built-in 'root' slot and, in the same breath, declares the * four child slots (declaration = exclusive render authority), seats the * layout store (panel geometry), and wires the panel-action service face. - * ctx.layout is the cross-plugin panel-action contract; navigation state lives - * with the runtime sessions service. A second effect seats the theme + * ctx.layout selects the main panel and controls column geometry; Session + * selection belongs to the Session Controller. A second effect seats the theme * presenter, which projects ctx.theme snapshots onto document.body. */ import type { Context as ClientContext } from '@deepseek-ai/cordis' @@ -12,7 +12,8 @@ import type {} from '@deepseek-ai/dsh-client-locale/client' import type {} from '@deepseek-ai/dsh-client-ui-renderer/client' import type {} from '@deepseek-ai/dsh-client-ui-session/client' import type {} from '@deepseek-ai/dsh-client-ui-theme/client' -import type { PanelActions } from './service.ts' +import type { HostObservable, SnapshotSelectorHook } from '@deepseek-ai/dsh-client-ui-slots' +import type { PanelInfo } from './service.ts' import { AppFrame } from './AppFrame.tsx' import { createLayoutStore } from './stores.ts' import { LayoutController } from './service.ts' @@ -24,7 +25,10 @@ import { ThemePresenter } from './theme-presenter.ts' // OwnerShare contracts below are the render-side halves registrants compose // against; the frame components and the store factory are package-internal. export { LayoutController } from './service.ts' -export type { ILayout } from './service.ts' +export type { ILayout, MainPanelId, PanelInfo } from './service.ts' + +/** Selector hook over root-scoped panel selection. */ +export type UsePanelInfo = SnapshotSelectorHook declare module '@deepseek-ai/cordis' { interface Context { @@ -34,6 +38,11 @@ declare module '@deepseek-ai/cordis' { } declare module '@deepseek-ai/dsh-client-ui-slots' { + interface GlobalStandardProps { + /** Subscribe to the selected main panel independently of parent renders. */ + usePanelInfo: UsePanelInfo + } + interface SlotMap { // The 'root' entry itself is the runtime's built-in slot (declared // there); these four are the frame's children, declared by the same @@ -51,18 +60,10 @@ declare module '@deepseek-ai/dsh-client-ui-slots' { */ 'sidebar': { kind: 'single'; scope: 'root'; owner: SidebarOwnerProps } /** - * The whole center column, across both the no-session hero and a live - * conversation. OCCUPIED by ui-conversation's ConversationRoot, which - * declares the session body, composer, and input seats inside it — - * registering here replaces the entire conversation surface (and removes - * every seat it declares) rather than adding to it. - * - * Current-session-optional: the occupant owns both states without - * changing its React identity, so it keeps its own state across a session - * switch. It receives no owner props; session facts arrive through the - * framework hooks of the `session-maybe` scope. + * Central panel selected by sidebar entry id. The reserved `conversation` + * key hosts the Conversation; other keys receive no Session binding. */ - 'conversation': { kind: 'single'; scope: 'session-maybe'; owner: ConvOwnerProps } + 'main': { kind: 'keyed'; scope: 'root' } /** * The right column: a track the centre makes room for, or nothing. OCCUPIED * by the right Sidebar, which uses the resolved column width in normal @@ -73,10 +74,10 @@ declare module '@deepseek-ai/dsh-client-ui-slots' { * occupant's own recorded business — it reports the composition of its * expanded and presentation state through `ctx.layout`, and the frame sizes * the track and places the resize handle from that. The expand control is - * not this column's: it is a button in the conversation header. With no - * current session nothing is mounted here. + * not this column's: it is a button in the conversation header. The root + * occupant decides when to render its Session-bound content. */ - 'rightbar': { kind: 'single'; scope: 'session'; owner: RightbarOwnerProps } + 'rightbar': { kind: 'single'; scope: 'root'; owner: RightbarOwnerProps } /** * Frame-wide floating layer, above every column and outside their scroll * containers. Deliberately generic and unowned by any feature: a badge, a @@ -105,9 +106,6 @@ export interface SidebarOwnerProps { width: number } -/** Conversation owner share: business state and actions belong to the registrant. */ -export interface ConvOwnerProps {} - /** Right column owner share: resolved normal geometry and opening eligibility. */ export interface RightbarOwnerProps { /** Resolved normal panel width in px, not the saved preference; zero if it cannot fit. */ @@ -127,34 +125,44 @@ export const inject = ['slots', 'theme', 'locale'] /** * Client plugin body: provide ctx.layout, then one register() call — AppFrame * into 'root' with the four child-slot declarations, the layout store seat, - * and the inject hook that hands the store's bound actions to the service. + * and the shared root instance supplying commands and the panel-info source. * @param ctx - client root context. */ export function apply(ctx: ClientContext): void { - const layout = new LayoutController() ctx.effect(() => { + const handle = createLayoutStore() + const instance = handle.create() + const store: typeof handle = { ...handle, create: () => instance } + const layout = new LayoutController(instance.actions, id => + ctx.slots.entries('main').some(entry => entry.options.key === id)) + const retainMainPanels = (): void => { + instance.actions.retainMainPanels(ctx.slots.entries('main').flatMap(entry => + entry.options.key === undefined ? [] : [entry.options.key])) + } + const panelInfo: HostObservable = { + getSnapshot: () => instance.getSnapshot().panelInfo, + subscribe: listener => instance.subscribe(listener), + } + const disposePanelInfo = ctx.slots.provideRoot({ hooks: { panelInfo } }) const disposeService = ctx.reflect.provide('layout', layout) const disposeRegistration = ctx.slots.register({ name: 'root', locale: 'common', children: { 'sidebar': { kind: 'single', scope: 'root' }, - 'conversation': { kind: 'single', scope: 'session-maybe' }, - 'rightbar': { kind: 'single', scope: 'session' }, + 'main': { kind: 'keyed', scope: 'root' }, + 'rightbar': { kind: 'single', scope: 'root' }, 'shell.overlay': { kind: 'list', scope: 'root' }, }, - // Exclusive store: the factory itself — the framework instantiates per - // entry and delivers useStore/actions to AppFrame as standard props. - store: createLayoutStore, - // The hook's only side effect connects the root store to ctx.layout; - // conversation business actions belong to their registrants. - inject: (actions: PanelActions) => { - layout.attachPanels(actions) - return {} - }, + store, }, AppFrame) + const disposePanels = ctx.slots.subscribe('main', retainMainPanels) + retainMainPanels() return () => { + layout.dispose() + disposePanels() disposeRegistration() + disposePanelInfo() // provide()'s disposer settles asynchronously; teardown is synchronous fire-and-forget. void disposeService() } diff --git a/packages/client/ui-layout/src/client/service.ts b/packages/client/ui-layout/src/client/service.ts index 46369f0346..14a3924d63 100644 --- a/packages/client/ui-layout/src/client/service.ts +++ b/packages/client/ui-layout/src/client/service.ts @@ -1,26 +1,42 @@ /** * LayoutController: the cross-plugin panel-action face behind ctx.layout. - * Panel geometry itself lives in the root entry's layout store (stores.ts); + * Panel geometry and main-panel selection live in the root layout store; * the current-session selection lives with the runtime sessions service, and * the per-session active view dissolved into ui-conversation's session store * (its only consumer). What remains here is the contract other plugins' - * apply worlds reach for panel transitions (sidebar toggle from ui-sidebar, + * apply worlds reach for panel transitions (main-panel selection and sidebar toggle, * right-panel show/hide from ui-sidebar-right) — writes stay inside the - * store's declared action set, delivered as the registration's bound actions. + * store's declared action set, shared with the root registration. */ import type { BoundActions } from '@deepseek-ai/dsh-client-ui-slots' +import type { Branded } from '@deepseek-ai/dsh-brand' import type { createLayoutStore } from './stores.ts' +/** Identity shared by a sidebar panel entry and its main-slot occupant. */ +export type MainPanelId = Branded<'MainPanelId'> + +/** Root-scoped navigation state exposed to panel-aware components. */ +export interface PanelInfo { + /** Selected global panel; null displays the current Conversation. */ + readonly activePanelId: MainPanelId | null +} + /** The layout store's bound action set (framework-baked, draft params peeled). */ export type PanelActions = BoundActions> -/** - * The outward layout face (`ctx.layout`): the panel transitions other - * plugins may trigger — and exactly what a test fake must supply. The - * attachPanels wiring hook stays on the concrete class (root-entry assembly - * only). - */ +/** Panel navigation and geometry actions exposed through ctx.layout. */ export interface ILayout { + /** + * Select a global central panel without changing the current Session. + * @param panelId - registered main key, or null to show the Conversation. + * @throws if the selected main key is not registered; preserves the current selection. + */ + selectPanel(panelId: MainPanelId | null): void + /** + * Start an asynchronous navigation, superseding any earlier pending navigation. + * @returns a signal aborted by the next navigation or layout disposal; check it before committing UI state. + */ + beginNavigation(): AbortSignal /** Toggle the sidebar panel (closed ⟷ contract default width). */ toggleSidebar(): void /** @@ -37,39 +53,50 @@ export interface ILayout { /** Cross-plugin panel-action face (ctx.layout). */ export class LayoutController implements ILayout { - #panels: PanelActions | undefined + private navigation = new AbortController() /** - * Adopt the root entry's bound store actions. Called from the root - * registration's inject hook (a sanctioned assembly side effect), so the - * face is live from the entry's first render; on entry re-register the - * fresh actions overwrite the stale set. - * @param actions - bound actions of the entry's layout store instance. + * @param panels - actions of the instance shared with the root entry. + * @param hasMainPanel - checks the live main-slot registry for a panel id. */ - attachPanels(actions: PanelActions): void { - this.#panels = actions + constructor( + private readonly panels: PanelActions, + private readonly hasMainPanel: (id: MainPanelId) => boolean, + ) {} + + /** Select a global panel or return to the Conversation. */ + selectPanel(panelId: MainPanelId | null): void { + if (panelId !== null && !this.hasMainPanel(panelId)) { + throw new Error(`layout.selectPanel: main panel "${panelId}" is not registered`) + } + this.navigation.abort() + this.panels.selectPanel(panelId) + } + + /** @returns the new pending navigation's cancellation signal. */ + beginNavigation(): AbortSignal { + this.navigation.abort() + this.navigation = new AbortController() + return this.navigation.signal + } + + /** Invalidate pending navigations when the layout owner is unloaded. */ + dispose(): void { + this.navigation.abort() } /** Toggle the sidebar panel (closed ⟷ contract default width). */ toggleSidebar(): void { - this.#require().toggleSidebar() + this.panels.toggleSidebar() } /** Report the right panel's track and fullscreen presentation. */ openRightbar(track: boolean, fullscreen: boolean): void { - this.#require().openRightbar(track, fullscreen) + this.panels.openRightbar(track, fullscreen) } /** Report the right panel as hidden: no track, no handle. */ closeRightbar(): void { - this.#require().closeRightbar() - } - - #require(): PanelActions { - // Callers are UI gestures, which cannot fire before the root entry - // rendered (the inject hook runs in its first render) — reaching this - // unwired is a boot-order bug, not a race to tolerate. - if (this.#panels === undefined) throw new Error('layout: panel actions not wired (root entry not mounted)') - return this.#panels + this.panels.closeRightbar() } } diff --git a/packages/client/ui-layout/src/client/stores.ts b/packages/client/ui-layout/src/client/stores.ts index 9191745a66..3a53caf4f1 100644 --- a/packages/client/ui-layout/src/client/stores.ts +++ b/packages/client/ui-layout/src/client/stores.ts @@ -3,6 +3,7 @@ * The registration supplies a fresh store and binds its actions to ctx.layout. */ import { defineStore, type EngineStoreHandle } from '@deepseek-ai/dsh-client-store' +import type { MainPanelId } from './service.ts' import { clampWidth, RIGHTBAR_DEFAULT_RATIO, RIGHTBAR_MAX_RATIO, RIGHTBAR_MIN, SIDEBAR_AUTO_COLLAPSE, SIDEBAR_DEFAULT, SIDEBAR_MAX, SIDEBAR_MIN, @@ -13,6 +14,14 @@ import { * the right panel's expanded state belongs to its occupant. */ type LayoutState = { + panelInfo: { + /** Null selects the Conversation; global panels keep the current Session intact. */ + activePanelId: MainPanelId | null + } + layoutInfo: LayoutInfo +} + +type LayoutInfo = { sidebar: number /** Last positive frame measurement; window width bootstraps the first render. */ viewportWidth: number @@ -47,6 +56,8 @@ type LayoutState = { * return type); drift fails assignability at the defineStore call. */ type LayoutActions = { + selectPanel: (draft: LayoutState, panelId: MainPanelId | null) => void + retainMainPanels: (draft: LayoutState, panelIds: readonly string[]) => void setSidebar: (draft: LayoutState, px: number) => void toggleSidebar: (draft: LayoutState) => void setViewportWidth: (draft: LayoutState, width: number) => void @@ -67,56 +78,67 @@ type LayoutActions = { export function createLayoutStore(): EngineStoreHandle { const handle = defineStore({ init: (): LayoutState => ({ - sidebar: SIDEBAR_DEFAULT, - viewportWidth: window.innerWidth, - narrowExpanded: false, - rightbar: null, - rightbarShown: false, - rightbarTrack: false, - rightbarFullscreen: false, - rightbarInstant: false, + panelInfo: { activePanelId: null }, + layoutInfo: { + sidebar: SIDEBAR_DEFAULT, + viewportWidth: window.innerWidth, + narrowExpanded: false, + rightbar: null, + rightbarShown: false, + rightbarTrack: false, + rightbarFullscreen: false, + rightbarInstant: false, + }, }), actions: { + selectPanel: (d, panelId: MainPanelId | null) => { + d.panelInfo.activePanelId = panelId + }, + retainMainPanels: (d, panelIds: readonly string[]) => { + if (d.panelInfo.activePanelId !== null && !panelIds.includes(d.panelInfo.activePanelId)) { + d.panelInfo.activePanelId = null + } + }, setSidebar: (d, px: number) => { - d.rightbarInstant = false - d.sidebar = clampWidth(px, SIDEBAR_MIN, SIDEBAR_MAX) + d.layoutInfo.rightbarInstant = false + d.layoutInfo.sidebar = clampWidth(px, SIDEBAR_MIN, SIDEBAR_MAX) }, // Narrow toggles flip only the override: the width preference survives // untouched, so re-widening restores the pre-squeeze layout. toggleSidebar: (d) => { - d.rightbarInstant = false - if (d.viewportWidth < SIDEBAR_AUTO_COLLAPSE) d.narrowExpanded = !d.narrowExpanded - else d.sidebar = d.sidebar === 0 ? SIDEBAR_DEFAULT : 0 + d.layoutInfo.rightbarInstant = false + if (d.layoutInfo.viewportWidth < SIDEBAR_AUTO_COLLAPSE) d.layoutInfo.narrowExpanded = !d.layoutInfo.narrowExpanded + else d.layoutInfo.sidebar = d.layoutInfo.sidebar === 0 ? SIDEBAR_DEFAULT : 0 }, // Crossing the breakpoint in either direction drops the override: the // narrow default is auto-collapsed, the wide state is the preference. setViewportWidth: (d, width: number) => { - if (d.viewportWidth === width) return - d.rightbarInstant = false - if ((d.viewportWidth < SIDEBAR_AUTO_COLLAPSE) !== (width < SIDEBAR_AUTO_COLLAPSE)) { - d.narrowExpanded = false + if (d.layoutInfo.viewportWidth === width) return + d.layoutInfo.rightbarInstant = false + if ((d.layoutInfo.viewportWidth < SIDEBAR_AUTO_COLLAPSE) !== (width < SIDEBAR_AUTO_COLLAPSE)) { + d.layoutInfo.narrowExpanded = false } - d.viewportWidth = width + d.layoutInfo.viewportWidth = width }, setRightbar: (d, px: number) => { - d.rightbarInstant = false - d.rightbar = clampWidth(px, RIGHTBAR_MIN, Math.max(RIGHTBAR_MIN, d.viewportWidth * RIGHTBAR_MAX_RATIO)) + d.layoutInfo.rightbarInstant = false + d.layoutInfo.rightbar = clampWidth(px, RIGHTBAR_MIN, Math.max(RIGHTBAR_MIN, d.layoutInfo.viewportWidth * RIGHTBAR_MAX_RATIO)) }, openRightbar: (d, track: boolean, fullscreen: boolean) => { - if (!d.rightbarShown || d.rightbarTrack !== track || d.rightbarFullscreen !== fullscreen) { - d.rightbarInstant = d.rightbarFullscreen && !fullscreen + if (!d.layoutInfo.rightbarShown || d.layoutInfo.rightbarTrack !== track || d.layoutInfo.rightbarFullscreen !== fullscreen) { + d.layoutInfo.rightbarInstant = d.layoutInfo.rightbarFullscreen && !fullscreen } - if (!d.rightbarShown && d.viewportWidth < SIDEBAR_AUTO_COLLAPSE) d.narrowExpanded = false - d.rightbar ??= Math.max(RIGHTBAR_MIN, Math.round(d.viewportWidth * RIGHTBAR_DEFAULT_RATIO)) - d.rightbarShown = true - d.rightbarTrack = track - d.rightbarFullscreen = fullscreen + if (!d.layoutInfo.rightbarShown && d.layoutInfo.viewportWidth < SIDEBAR_AUTO_COLLAPSE) d.layoutInfo.narrowExpanded = false + d.layoutInfo.rightbar ??= Math.max(RIGHTBAR_MIN, Math.round(d.layoutInfo.viewportWidth * RIGHTBAR_DEFAULT_RATIO)) + d.layoutInfo.rightbarShown = true + d.layoutInfo.rightbarTrack = track + d.layoutInfo.rightbarFullscreen = fullscreen }, closeRightbar: (d) => { - if (d.rightbarShown) d.rightbarInstant = d.rightbarFullscreen - d.rightbarShown = false - d.rightbarTrack = false - d.rightbarFullscreen = false + if (d.layoutInfo.rightbarShown) d.layoutInfo.rightbarInstant = d.layoutInfo.rightbarFullscreen + d.layoutInfo.rightbarShown = false + d.layoutInfo.rightbarTrack = false + d.layoutInfo.rightbarFullscreen = false }, }, }) diff --git a/packages/client/ui-layout/tests/app-frame.client.spec.tsx b/packages/client/ui-layout/tests/app-frame.client.spec.tsx index 62623b9b81..55e51bdeac 100644 --- a/packages/client/ui-layout/tests/app-frame.client.spec.tsx +++ b/packages/client/ui-layout/tests/app-frame.client.spec.tsx @@ -1,14 +1,13 @@ // @vitest-environment jsdom /** Frame interactions with a real store and explicitly driven browser measurements. */ -import type { GlobalStandardProps } from '@deepseek-ai/dsh-client-ui-slots' +import type { GlobalStandardProps, RenderOpts } from '@deepseek-ai/dsh-client-ui-slots' +import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-test-runtime' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { act, cleanup, render } from '@testing-library/react' -import { useSyncExternalStore } from 'react' import { AppFrame } from '../src/client/AppFrame.tsx' import type { AppFrameProps } from '../src/client/AppFrame.tsx' -import type { RightbarOwnerProps, SidebarOwnerProps } from '../src/client/index.ts' +import type { MainPanelId, RightbarOwnerProps, SidebarOwnerProps } from '../src/client/index.ts' import { createLayoutStore } from '../src/client/stores.ts' -import type { SessionListState } from '@deepseek-ai/dsh-api-session-controller/client' import type { WorkspaceSnapshot } from '@deepseek-ai/dsh-api-workspace-controller/client' import type { SessionId } from '@deepseek-ai/dsh-session/types' @@ -19,8 +18,6 @@ let workspacesReady = true type AttentionSnapshot = Parameters[0]>[0] const noAttention: AttentionSnapshot = new Map() const useSessionPendingInteraction: AppFrameProps['useSessionPendingInteraction'] = selector => selector(noAttention) -const SessionProviderStub: AppFrameProps['SessionProvider'] = ({ children, empty }) => - selectedSession === undefined ? <>{empty?.() ?? null} : <>{children} let observers: ResizeObserverStub[] class ResizeObserverStub { @@ -63,19 +60,15 @@ function resize(width: number): void { }) } -function hookOf(inst: { subscribe: (fn: () => void) => () => void; getSnapshot: () => T }) { - return function useSelector(sel: (s: T) => S): S { return sel(useSyncExternalStore(inst.subscribe, inst.getSnapshot)) } -} - function mountFrame(windowWidth = frameWidth) { vi.stubGlobal('innerWidth', windowWidth) const instance = createLayoutStore().create() - const slotCalls: { key: string; props: object }[] = [] - const renderSlot = ((key: string, owner: object) => { - slotCalls.push({ key, props: owner }) - return
- }) as AppFrameProps['renderSlot'] - const useSessions = ((sel: (s: SessionListState) => unknown) => sel({ + const slotCalls: { key: string; props: object; options: RenderOpts | undefined }[] = [] + const renderSlot: AppFrameProps['renderSlot'] = (key, owner, options) => { + slotCalls.push({ key, props: owner, options }) + return
+ } + const useSessions: AppFrameProps['useSessions'] = sel => sel({ ids: selectedSession === undefined ? [] : [selectedSession], byId: selectedSession === undefined ? {} : { [selectedSession]: { @@ -85,22 +78,29 @@ function mountFrame(windowWidth = frameWidth) { }, current: selectedSession, phase: 'ready', - } as SessionListState)) as AppFrameProps['useSessions'] + subagentsByParent: {}, + jobsBySession: {}, + currentAddress: undefined, + }) const workspaceState: WorkspaceSnapshot = { items: [], archivedSessionIds: [], state: 'idle', phase: 'ready', error: null, ...(workspacesReady ? {} : { state: 'loading' as const, phase: 'pending' as const }), } - const useStore = hookOf(instance) + const useStore = bindSnapshotSelector(instance) + const usePanelInfo = bindSnapshotSelector({ + getSnapshot: () => instance.getSnapshot().panelInfo, + subscribe: listener => instance.subscribe(listener), + }) const element = () => ( unknown) => sel(workspaceState)) as AppFrameProps['useWorkspaces']} - SessionProvider={SessionProviderStub} + useWorkspaces={sel => sel(workspaceState)} t={key => key === 'brand.localBuild' ? 'DSH Local Build' : key} /> ) @@ -202,22 +202,22 @@ describe('AppFrame', () => { expect(tracks(frame)).toEqual([280, 0]) expect(sidebarOwner()).toEqual({ collapsed: false, width: 280 }) expect(rightOwner()).toEqual({ width: 864, viewportWidth: 1920, canShow: true }) - expect(slotCalls.find(c => c.key === 'conversation')!.props).toEqual({}) + expect(slotCalls.find(c => c.key === 'main')).toEqual({ key: 'main', props: {}, options: { entryKey: 'conversation' } }) }) - it('retains conversation and sidebar content without a current Session', () => { + it('renders the main, sidebar, and root-scoped rightbar outlets without a current Session', () => { selectedSession = undefined - const { frame, getByTestId, queryByTestId } = mountFrame() - expect(getByTestId('conversation-content')).toBeTruthy() + const { frame, getByTestId } = mountFrame() + expect(getByTestId('main-content').getAttribute('data-entry-key')).toBe('conversation') expect(getByTestId('sidebar-content')).toBeTruthy() - expect(queryByTestId('rightbar-content')).toBeNull() + expect(getByTestId('rightbar-content')).toBeTruthy() expect(tracks(frame)).toEqual([280, 0]) }) it('renders both occupants before workspace baselines settle', () => { workspacesReady = false const { getByTestId } = mountFrame() - expect(getByTestId('conversation-content')).toBeTruthy() + expect(getByTestId('main-content').getAttribute('data-entry-key')).toBe('conversation') expect(getByTestId('rightbar-content')).toBeTruthy() }) @@ -229,16 +229,34 @@ describe('AppFrame', () => { expect(getByTestId('sidebar-content')).toBeTruthy() expect(frame.querySelector('[data-side="sidebar"]')).toBeNull() }) + + it('switches only the keyed main outlet when the active panel changes', () => { + selectedSessionTitle = 'Session title' + const { instance, frame, slotCalls, getByTestId } = mountFrame() + const sessionId = selectedSession + const layoutInfo = instance.getSnapshot().layoutInfo + for (const panelId of ['panel-a' as MainPanelId, 'panel-b' as MainPanelId, null]) { + slotCalls.length = 0 + act(() => { instance.actions.selectPanel(panelId) }) + expect(slotCalls).toEqual([{ key: 'main', props: {}, options: { entryKey: panelId ?? 'conversation' } }]) + expect(getByTestId('main-content').getAttribute('data-entry-key')).toBe(panelId ?? 'conversation') + expect(instance.getSnapshot().panelInfo).toEqual({ activePanelId: panelId }) + expect(instance.getSnapshot().layoutInfo).toBe(layoutInfo) + expect(tracks(frame)).toEqual([280, 0]) + expect(selectedSession).toBe(sessionId) + expect(document.title).toBe(panelId === null ? 'Session title — DSH Local Build' : 'DSH Local Build') + } + }) }) describe('AppFrame normal width concessions', () => { it('measures the frame, not the window, before choosing the first-open preference', () => { frameWidth = 1000 const { instance, rightOwner } = mountFrame(1920) - expect(instance.getSnapshot().viewportWidth).toBe(1000) + expect(instance.getSnapshot().layoutInfo.viewportWidth).toBe(1000) expect(rightOwner()).toEqual({ width: 450, viewportWidth: 1000, canShow: true }) act(() => { instance.actions.openRightbar(true, false) }) - expect(instance.getSnapshot().rightbar).toBe(450) + expect(instance.getSnapshot().layoutInfo.rightbar).toBe(450) resize(1920) expect(rightOwner().width).toBe(450) }) @@ -255,7 +273,7 @@ describe('AppFrame normal width concessions', () => { expect(tracks(frame)).toEqual([420, 0]) expect(rightOwner()).toEqual({ width: 0, viewportWidth: 1119, canShow: false }) expect(frame.querySelector('[data-side="rightbar"]')).toBeNull() - expect(instance.getSnapshot()).toMatchObject({ rightbarShown: true, rightbar: 864 }) + expect(instance.getSnapshot().layoutInfo).toMatchObject({ rightbarShown: true, rightbar: 864 }) act(() => { instance.actions.closeRightbar() }) resize(455) expect(tracks(frame)).toEqual([56, 0]) @@ -271,7 +289,7 @@ describe('AppFrame normal width concessions', () => { expect(rightOwner()).toEqual({ width: 344, viewportWidth: 800, canShow: true }) act(() => { instance.actions.openRightbar(true, false) }) expect(tracks(frame)).toEqual([56, 344]) - expect(instance.getSnapshot()).toMatchObject({ narrowExpanded: false, rightbar: 360 }) + expect(instance.getSnapshot().layoutInfo).toMatchObject({ narrowExpanded: false, rightbar: 360 }) expect(rightOwner().canShow).toBe(true) }) @@ -312,7 +330,7 @@ describe('AppFrame normal width concessions', () => { resize(980) act(() => { instance.actions.toggleSidebar() }) expect(tracks(frame)[0]).toBe(280) - expect(instance.getSnapshot().sidebar).toBe(0) + expect(instance.getSnapshot().layoutInfo.sidebar).toBe(0) }) }) @@ -404,7 +422,7 @@ describe('AppFrame right panel presentation', () => { act(() => { instance.actions.openRightbar(false, true) }) expect(tracks(frame)).toEqual([56, 0]) expect(rightOwner()).toEqual({ width: 0, viewportWidth: 700, canShow: false }) - expect(instance.getSnapshot().rightbarShown).toBe(true) + expect(instance.getSnapshot().layoutInfo.rightbarShown).toBe(true) expect(frame.querySelector('[data-side="rightbar"]')).toBeNull() }) @@ -415,7 +433,7 @@ describe('AppFrame right panel presentation', () => { expect(tracks(frame)).toEqual([280, 0]) expect(rightOwner().width).toBe(420) drag(handleFor(frame, 'rightbar'), 680, 690) - expect(instance.getSnapshot().rightbar).toBe(410) + expect(instance.getSnapshot().layoutInfo.rightbar).toBe(410) expect(rightOwner().width).toBe(410) expect(tracks(frame)[1]).toBe(0) }) @@ -436,7 +454,7 @@ describe('AppFrame pointer resizing', () => { act(flushFrames) expect(tracks(frame)[0]).toBe(360) pointer(handle, 'pointerup', 360) - expect(instance.getSnapshot().sidebar).toBe(360) + expect(instance.getSnapshot().layoutInfo.sidebar).toBe(360) expect(frame.dataset.dragging).toBeUndefined() expect(handle.hasPointerCapture(1)).toBe(false) }) @@ -450,7 +468,7 @@ describe('AppFrame pointer resizing', () => { expect(tracks(frame)[1]).toBe(420) expect(handle.style.left).toBe('680px') drag(handle, 680, 690) - expect(instance.getSnapshot().rightbar).toBe(410) + expect(instance.getSnapshot().layoutInfo.rightbar).toBe(410) expect(rightOwner().width).toBe(410) expect(tracks(frame)[1]).toBe(410) expect(handle.style.left).toBe('690px') @@ -474,10 +492,10 @@ describe('AppFrame pointer resizing', () => { pointer(handle, 'pointerdown', 280) pointer(handle, 'pointermove', 320) pointer(handle, 'pointerup', 360) - expect(instance.getSnapshot().sidebar).toBe(360) + expect(instance.getSnapshot().layoutInfo.sidebar).toBe(360) expect(animationFrames.size).toBe(0) act(flushFrames) - expect(instance.getSnapshot().sidebar).toBe(360) + expect(instance.getSnapshot().layoutInfo.sidebar).toBe(360) }) it('ignores uncaptured motion, secondary buttons, and a second pointer', () => { @@ -493,9 +511,9 @@ describe('AppFrame pointer resizing', () => { pointer(handle, 'pointermove', 500, 9) pointer(handle, 'pointerup', 500, 9) expect(animationFrames.size).toBe(0) - expect(instance.getSnapshot().sidebar).toBe(280) + expect(instance.getSnapshot().layoutInfo.sidebar).toBe(280) pointer(handle, 'pointerup', 300) - expect(instance.getSnapshot().sidebar).toBe(300) + expect(instance.getSnapshot().layoutInfo.sidebar).toBe(300) }) it.each(['pointercancel', 'lostpointercapture'])('ends %s without committing queued motion', (event) => { @@ -506,7 +524,7 @@ describe('AppFrame pointer resizing', () => { if (event === 'lostpointercapture') handle.releasePointerCapture(1) pointer(handle, event, 340) act(flushFrames) - expect(instance.getSnapshot().sidebar).toBe(280) + expect(instance.getSnapshot().layoutInfo.sidebar).toBe(280) expect(animationFrames.size).toBe(0) expect(frame.dataset.dragging).toBeUndefined() expect(handle.hasPointerCapture(1)).toBe(false) @@ -526,7 +544,7 @@ describe('AppFrame pointer resizing', () => { const settled = instance.getSnapshot() act(flushFrames) expect(instance.getSnapshot()).toBe(settled) - expect(instance.getSnapshot().rightbar).toBe(864) + expect(instance.getSnapshot().layoutInfo.rightbar).toBe(864) expect(animationFrames.size).toBe(0) expect(handle.hasPointerCapture(1)).toBe(false) if (change !== 'unmount') expect(frame.dataset.dragging).toBeUndefined() @@ -544,17 +562,17 @@ describe('AppFrame frame measurement lifecycle', () => { observer.fire() }) expect(animationFrames.size).toBe(1) - expect(instance.getSnapshot().viewportWidth).toBe(1920) + expect(instance.getSnapshot().layoutInfo.viewportWidth).toBe(1920) act(flushFrames) - expect(instance.getSnapshot().viewportWidth).toBe(1200) + expect(instance.getSnapshot().layoutInfo.viewportWidth).toBe(1200) expect(rightOwner().viewportWidth).toBe(1200) - expect(instance.getSnapshot().rightbar).toBeNull() + expect(instance.getSnapshot().layoutInfo.rightbar).toBeNull() }) it('retains the last positive measurement while the frame is hidden', () => { const { instance, rightOwner } = mountFrame() resize(0) - expect(instance.getSnapshot().viewportWidth).toBe(1920) + expect(instance.getSnapshot().layoutInfo.viewportWidth).toBe(1920) expect(rightOwner().viewportWidth).toBe(1920) }) @@ -568,7 +586,7 @@ describe('AppFrame frame measurement lifecycle', () => { expect(observer.disconnected).toBe(true) expect(animationFrames.size).toBe(0) act(() => { observer.fire(); flushFrames() }) - expect(instance.getSnapshot().viewportWidth).toBe(1920) + expect(instance.getSnapshot().layoutInfo.viewportWidth).toBe(1920) expect(animationFrames.size).toBe(0) }) }) diff --git a/packages/client/ui-layout/tests/apply.client.spec.ts b/packages/client/ui-layout/tests/apply.client.spec.ts index 38732eda2c..74c58022c9 100644 --- a/packages/client/ui-layout/tests/apply.client.spec.ts +++ b/packages/client/ui-layout/tests/apply.client.spec.ts @@ -1,20 +1,58 @@ // @vitest-environment jsdom -import { Context } from '@deepseek-ai/cordis' +import { Context, type Fiber } from '@deepseek-ai/cordis' import { stubSettingsScope } from '@deepseek-ai/dsh-client-test-runtime' -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { SlotRegistry } from '@deepseek-ai/dsh-client-ui-renderer/client' +import type { SlotRendererHost } from '@deepseek-ai/dsh-client-ui-slots' import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client' import { apply as themeApply, inject as themeInject, ThemeRuntime } from '@deepseek-ai/dsh-client-ui-theme/client' import { apply, inject, LayoutController } from '@deepseek-ai/dsh-client-ui-layout/client' import { apply as nodeApply } from '@deepseek-ai/dsh-client-ui-layout' +import type { MainPanelId } from '../src/client/service.ts' +import type { createLayoutStore } from '../src/client/stores.ts' + +const owners = new Set() +let originalRootStyle: string | null +let originalBodyStyle: string | null +let originalDarkTheme: string | null +let originalThemeMetadata: Element[] beforeEach(() => { - document.head.querySelectorAll('meta[name="theme-color"]').forEach((node) => { node.remove() }) + originalRootStyle = document.documentElement.getAttribute('style') + originalBodyStyle = document.body.getAttribute('style') + originalDarkTheme = document.body.getAttribute('data-ds-dark-theme') + originalThemeMetadata = [...document.head.querySelectorAll('meta[name="theme-color"]')] + originalThemeMetadata.forEach((node) => { node.remove() }) + vi.stubGlobal('innerWidth', 1920) }) +afterEach(async () => { + try { + for (const owner of owners) await owner.dispose() + } finally { + owners.clear() + restoreAttribute(document.documentElement, 'style', originalRootStyle) + restoreAttribute(document.body, 'style', originalBodyStyle) + restoreAttribute(document.body, 'data-ds-dark-theme', originalDarkTheme) + document.head.querySelectorAll('meta[name="theme-color"]').forEach((node) => { node.remove() }) + document.head.append(...originalThemeMetadata) + vi.unstubAllGlobals() + } +}) + +function restoreAttribute(element: Element, name: string, value: string | null): void { + if (value === null) element.removeAttribute(name) + else element.setAttribute(name, value) +} + async function bench() { - const ctx = new Context() + const root = new Context() + let ctx: Context | undefined + const owner = root.plugin((ownedContext: Context) => { ctx = ownedContext }) + owners.add(owner) + await owner.await() + if (ctx === undefined) throw new Error('the fixture owner did not activate') const slotsFiber = ctx.plugin(SlotRegistry) // Theme registers its Appearance settings row and requires the connection // seam for persistence; model this bench as a remote, memory-only browser. @@ -25,7 +63,15 @@ async function bench() { ctx.provide('settingsScope', { bind: () => stubSettingsScope().scope } as never) await ctx.plugin({ inject: themeInject, apply: themeApply }).await() await slotsFiber.await() - return { ctx, slots: ctx.get('slots') as SlotRegistry } + const slots = ctx.get('slots') as SlotRegistry + let host: SlotRendererHost | undefined + slots.install({ renderRoot: (value) => { host = value; return null } }) + const rendererHost = (): SlotRendererHost => { + slots.renderSlot('root', {}) + if (host === undefined) throw new Error('the root renderer did not receive its host') + return host + } + return { ctx, slots, rendererHost } } describe('ui-layout client apply', () => { @@ -33,30 +79,46 @@ describe('ui-layout client apply', () => { expect(inject).toEqual(['slots', 'theme', 'locale']) }) - it('provides ctx.layout and registers AppFrame into root with the three child declarations', async () => { + it('provides ctx.layout and declares the four root-scoped frame slots', async () => { const { ctx, slots } = await bench() const fiber = ctx.plugin({ inject: [...inject], apply }) await fiber.await() expect(ctx.get('layout')).toBeInstanceOf(LayoutController) - // The one register() call occupied 'root'… expect(slots.entries('root')).toHaveLength(1) - // …and declared the three children in the ledger. expect(slots.spec('sidebar')).toEqual({ kind: 'single', scope: 'root' }) - expect(slots.spec('conversation')).toEqual({ kind: 'single', scope: 'session-maybe' }) + expect(slots.spec('main')).toEqual({ kind: 'keyed', scope: 'root' }) + expect(slots.spec('rightbar')).toEqual({ kind: 'single', scope: 'root' }) + expect(slots.spec('shell.overlay')).toEqual({ kind: 'list', scope: 'root' }) }) - it('injects no business face and attaches the layout actions', async () => { - const { ctx, slots } = await bench() + it('shares a pre-created instance between service actions, root rendering, and panelInfo', async () => { + const { ctx, slots, rendererHost } = await bench() const fiber = ctx.plugin({ inject: [...inject], apply }) await fiber.await() - const actions = { - setSidebar: vi.fn(), setDetails: vi.fn(), toggleSidebar: vi.fn(), openDetails: vi.fn(), closeDetails: vi.fn(), - } - const injected = (slots.entries('root')[0]!.inject as (actions: never) => object)(actions as never) - expect(injected).toEqual({}) + const entry = slots.entries('root')[0]! + expect(entry.inject).toBeUndefined() + const handle = entry.store as ReturnType + const instance = handle.create() + expect(handle.create()).toBe(instance) const layout = ctx.get('layout') as LayoutController + expect(() => { layout.selectPanel('missing' as MainPanelId) }).toThrow('main panel "missing" is not registered') layout.toggleSidebar() - expect(actions.toggleSidebar).toHaveBeenCalledOnce() + expect(instance.getSnapshot().layoutInfo.sidebar).toBe(0) + const host = rendererHost() + expect(host.storeOf(entry, undefined)).toBe(instance) + const panelInfo = host.root.getSnapshot().hooks.panelInfo! + expect(panelInfo.getSnapshot()).toBe(instance.getSnapshot().panelInfo) + const panelId = 'panel-a' as MainPanelId + const disposePanel = slots.register({ name: 'main', key: panelId }, () => null) + layout.selectPanel(panelId) + expect(panelInfo.getSnapshot()).toEqual({ activePanelId: panelId }) + disposePanel() + await vi.waitFor(() => { expect(panelInfo.getSnapshot()).toEqual({ activePanelId: null }) }) + expect(() => { layout.selectPanel(panelId) }).toThrow('main panel "panel-a" is not registered') + expect(instance.getSnapshot().layoutInfo.sidebar).toBe(0) + const pending = layout.beginNavigation() + await fiber.dispose() + expect(pending.aborted).toBe(true) }) it('theme presenter applies the initial snapshot, follows theme/change, and unwinds on dispose', async () => { @@ -85,13 +147,19 @@ describe('ui-layout client apply', () => { }) it('teardown unwinds the service, the root registration, and the child declarations', async () => { - const { ctx, slots } = await bench() + const { ctx, slots, rendererHost } = await bench() const fiber = ctx.plugin({ inject: [...inject], apply }) await fiber.await() + const host = rendererHost() + expect(host.root.getSnapshot().hooks.panelInfo).toBeDefined() await fiber.dispose() expect(ctx.get('layout')).toBeUndefined() expect(slots.entries('root')).toHaveLength(0) expect(slots.spec('sidebar')).toBeUndefined() + expect(slots.spec('main')).toBeUndefined() + expect(slots.spec('rightbar')).toBeUndefined() + expect(slots.spec('shell.overlay')).toBeUndefined() + expect(host.root.getSnapshot().hooks.panelInfo).toBeUndefined() // The built-in root declaration survives entry teardown (renderer-owned). expect(slots.spec('root')).toEqual({ kind: 'single', scope: 'root' }) }) diff --git a/packages/client/ui-layout/tests/document-title.client.spec.tsx b/packages/client/ui-layout/tests/document-title.client.spec.tsx index 2cec953cb1..5c3184970f 100644 --- a/packages/client/ui-layout/tests/document-title.client.spec.tsx +++ b/packages/client/ui-layout/tests/document-title.client.spec.tsx @@ -1,36 +1,82 @@ // @vitest-environment jsdom -import { afterEach, describe, expect, it, vi } from 'vitest' -import { cleanup, render } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { act, cleanup, render } from '@testing-library/react' +import { createSnapshotStore } from '@deepseek-ai/dsh-client-store' +import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-test-runtime' +import type { SessionListState } from '@deepseek-ai/dsh-api-session-controller/client' +import type { SessionId } from '@deepseek-ai/dsh-session/types' import { DocumentTitle } from '../src/client/DocumentTitle.tsx' +import type { MainPanelId, PanelInfo } from '../src/client/service.ts' +let originalTitle: string +beforeEach(() => { originalTitle = document.title }) afterEach(() => { - cleanup() - document.title = '' - vi.unstubAllEnvs() + try { cleanup() } finally { document.title = originalTitle } }) +function titleSources() { + const sessionId = 'session-title' as SessionId + const sessions = createSnapshotStore({ + ids: [sessionId], + byId: { [sessionId]: { id: sessionId, displayTitle: 'Test', running: false, blank: false, updatedAt: 1 } }, + current: sessionId, + phase: 'ready', + subagentsByParent: {}, + jobsBySession: {}, + currentAddress: undefined, + }) + const panelInfo = createSnapshotStore({ activePanelId: null }) + return { + sessionId, sessions, panelInfo, + props: { useSessions: bindSnapshotSelector(sessions), usePanelInfo: bindSnapshotSelector(panelInfo) }, + } +} + describe('DocumentTitle', () => { it('projects a durable title and restores the product title', () => { - vi.stubEnv('DSH_CLIENT_TITLE', 'DeepSeek Harness') + const { sessionId, sessions, props } = titleSources() document.title = 'stale title' - const mounted = render() + const mounted = render() expect(document.title).toBe('DeepSeek Harness') - mounted.rerender() + act(() => { sessions.update((state) => { state.byId[sessionId]!.title = 'First title' }) }) expect(document.title).toBe('First title — DeepSeek Harness') - mounted.rerender() + act(() => { sessions.update((state) => { state.byId[sessionId]!.title = 'Revised title' }) }) expect(document.title).toBe('Revised title — DeepSeek Harness') - mounted.rerender() + act(() => { sessions.update((state) => { state.current = undefined }) }) expect(document.title).toBe('DeepSeek Harness') mounted.unmount() expect(document.title).toBe('DeepSeek Harness') }) - it('uses the generic title when the build provides no title', () => { - vi.stubEnv('DSH_CLIENT_TITLE', '') - delete process.env.DSH_CLIENT_TITLE - const mounted = render() + it('uses the localized product title supplied by the frame', () => { + const { sessionId, sessions, props } = titleSources() + sessions.update((state) => { state.byId[sessionId]!.title = 'First title' }) + const mounted = render() expect(document.title).toBe('First title — DSH Local Build') mounted.unmount() expect(document.title).toBe('DSH Local Build') }) + + it('keeps the product title across global panels and restores the latest Session title on return', () => { + const { sessionId, sessions, panelInfo, props } = titleSources() + sessions.update((state) => { state.byId[sessionId]!.title = 'Session title' }) + render() + expect(document.title).toBe('Session title — Product') + act(() => { panelInfo.set({ activePanelId: 'panel-a' as MainPanelId }) }) + expect(document.title).toBe('Product') + act(() => { sessions.update((state) => { state.byId[sessionId]!.title = 'Updated title' }) }) + expect(document.title).toBe('Product') + act(() => { panelInfo.set({ activePanelId: 'panel-b' as MainPanelId }) }) + expect(document.title).toBe('Product') + expect(sessions.getSnapshot().current).toBe(sessionId) + act(() => { panelInfo.set({ activePanelId: null }) }) + expect(document.title).toBe('Updated title — Product') + }) + + it('uses the product title when the current Session row is not available', () => { + const { sessions, props } = titleSources() + sessions.update((state) => { state.byId = {}; state.ids = [] }) + render() + expect(document.title).toBe('Product') + }) }) diff --git a/packages/client/ui-layout/tests/layout-store.client.spec.ts b/packages/client/ui-layout/tests/layout-store.client.spec.ts index 60dacf2f4b..edf2a5a3c9 100644 --- a/packages/client/ui-layout/tests/layout-store.client.spec.ts +++ b/packages/client/ui-layout/tests/layout-store.client.spec.ts @@ -2,6 +2,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { createLayoutStore } from '../src/client/stores.ts' +import type { MainPanelId } from '../src/client/service.ts' beforeEach(() => { vi.stubGlobal('innerWidth', 1920) }) afterEach(() => { vi.unstubAllGlobals(); vi.restoreAllMocks() }) @@ -10,14 +11,17 @@ describe('createLayoutStore', () => { it('starts with the default sidebar and no right panel preference', () => { const { store } = createLayoutStore().create() expect(store.getSnapshot()).toEqual({ - sidebar: 280, - viewportWidth: 1920, - narrowExpanded: false, - rightbar: null, - rightbarShown: false, - rightbarTrack: false, - rightbarFullscreen: false, - rightbarInstant: false, + panelInfo: { activePanelId: null }, + layoutInfo: { + sidebar: 280, + viewportWidth: 1920, + narrowExpanded: false, + rightbar: null, + rightbarShown: false, + rightbarTrack: false, + rightbarFullscreen: false, + rightbarInstant: false, + }, }) }) @@ -27,26 +31,26 @@ describe('createLayoutStore', () => { const b = createLayoutStore().create() a.actions.setSidebar(400) a.actions.openRightbar(true, false) - expect(b.store.getSnapshot().sidebar).toBe(280) - expect(b.store.getSnapshot().rightbar).toBeNull() + expect(b.store.getSnapshot().layoutInfo.sidebar).toBe(280) + expect(b.store.getSnapshot().layoutInfo.rightbar).toBeNull() expect(write).not.toHaveBeenCalled() }) it('clamps the sidebar to 264–420px', () => { const { store, actions } = createLayoutStore().create() actions.setSidebar(1) - expect(store.getSnapshot().sidebar).toBe(264) + expect(store.getSnapshot().layoutInfo.sidebar).toBe(264) actions.setSidebar(9999) - expect(store.getSnapshot().sidebar).toBe(420) + expect(store.getSnapshot().layoutInfo.sidebar).toBe(420) }) it('toggles the wide sidebar between closed and default width', () => { const { store, actions } = createLayoutStore().create() actions.setSidebar(400) actions.toggleSidebar() - expect(store.getSnapshot().sidebar).toBe(0) + expect(store.getSnapshot().layoutInfo.sidebar).toBe(0) actions.toggleSidebar() - expect(store.getSnapshot().sidebar).toBe(280) + expect(store.getSnapshot().layoutInfo.sidebar).toBe(280) }) it('keeps the sidebar preference while toggling its narrow override', () => { @@ -54,9 +58,9 @@ describe('createLayoutStore', () => { actions.setSidebar(400) actions.setViewportWidth(980) actions.toggleSidebar() - expect(store.getSnapshot()).toMatchObject({ sidebar: 400, viewportWidth: 980, narrowExpanded: true }) + expect(store.getSnapshot().layoutInfo).toMatchObject({ sidebar: 400, viewportWidth: 980, narrowExpanded: true }) actions.toggleSidebar() - expect(store.getSnapshot()).toMatchObject({ sidebar: 400, narrowExpanded: false }) + expect(store.getSnapshot().layoutInfo).toMatchObject({ sidebar: 400, narrowExpanded: false }) }) it('clears the manual override only when crossing 1024px', () => { @@ -65,39 +69,101 @@ describe('createLayoutStore', () => { actions.toggleSidebar() actions.setViewportWidth(980) actions.setViewportWidth(1023) - expect(store.getSnapshot().narrowExpanded).toBe(true) + expect(store.getSnapshot().layoutInfo.narrowExpanded).toBe(true) actions.setViewportWidth(1024) - expect(store.getSnapshot().narrowExpanded).toBe(false) + expect(store.getSnapshot().layoutInfo.narrowExpanded).toBe(false) actions.setViewportWidth(980) - expect(store.getSnapshot().narrowExpanded).toBe(false) + expect(store.getSnapshot().layoutInfo.narrowExpanded).toBe(false) }) }) +describe('main panel selection', () => { + const panelA = 'panel-a' as MainPanelId + const panelB = 'panel-b' as MainPanelId + + it('changes only panelInfo when switching panels and returning to the Conversation', () => { + const { store, actions } = createLayoutStore().create() + actions.setSidebar(400) + actions.openRightbar(true, true) + actions.closeRightbar() + const layoutInfo = store.getSnapshot().layoutInfo + for (const activePanelId of [panelA, panelB, null]) { + const previousPanelInfo = store.getSnapshot().panelInfo + actions.selectPanel(activePanelId) + expect(store.getSnapshot().panelInfo).toEqual({ activePanelId }) + expect(store.getSnapshot().panelInfo).not.toBe(previousPanelInfo) + expect(store.getSnapshot().layoutInfo).toBe(layoutInfo) + } + }) + + it('keeps the complete snapshot when selecting the current panel again', () => { + const { store, actions } = createLayoutStore().create() + actions.selectPanel(panelA) + const selected = store.getSnapshot() + actions.selectPanel(panelA) + expect(store.getSnapshot()).toBe(selected) + expect(store.getSnapshot().panelInfo.activePanelId).toBe(panelA) + }) + + it('returns to the Conversation only when the selected main registration disappears', () => { + const { store, actions } = createLayoutStore().create() + const initial = store.getSnapshot() + actions.retainMainPanels([]) + expect(store.getSnapshot()).toBe(initial) + actions.selectPanel(panelA) + const selected = store.getSnapshot() + actions.retainMainPanels(['conversation', panelA, panelB]) + expect(store.getSnapshot()).toBe(selected) + actions.retainMainPanels(['conversation', panelB]) + expect(store.getSnapshot().panelInfo).toEqual({ activePanelId: null }) + expect(store.getSnapshot().layoutInfo).toBe(selected.layoutInfo) + }) + + it.each(['setSidebar', 'toggleSidebar', 'setViewportWidth', 'setRightbar', 'openRightbar', 'closeRightbar'] as const)( + 'preserves panelInfo identity when %s changes layoutInfo', (action) => { + const { store, actions } = createLayoutStore().create() + actions.selectPanel(panelA) + if (action === 'closeRightbar') actions.openRightbar(true, true) + const previous = store.getSnapshot() + switch (action) { + case 'setSidebar': actions.setSidebar(400); break + case 'toggleSidebar': actions.toggleSidebar(); break + case 'setViewportWidth': actions.setViewportWidth(980); break + case 'setRightbar': actions.setRightbar(500); break + case 'openRightbar': actions.openRightbar(true, true); break + case 'closeRightbar': actions.closeRightbar(); break + } + expect(store.getSnapshot().panelInfo).toBe(previous.panelInfo) + expect(store.getSnapshot().layoutInfo).not.toBe(previous.layoutInfo) + }, + ) +}) + describe('right panel', () => { it('initializes at 45% of the latest frame only on first opening', () => { const { store, actions } = createLayoutStore().create() actions.setViewportWidth(1000) - expect(store.getSnapshot().rightbar).toBeNull() + expect(store.getSnapshot().layoutInfo.rightbar).toBeNull() actions.openRightbar(true, false) - expect(store.getSnapshot().rightbar).toBe(450) + expect(store.getSnapshot().layoutInfo.rightbar).toBe(450) actions.setViewportWidth(2000) actions.openRightbar(true, true) - expect(store.getSnapshot().rightbar).toBe(450) + expect(store.getSnapshot().layoutInfo.rightbar).toBe(450) actions.closeRightbar() actions.openRightbar(true, false) - expect(store.getSnapshot().rightbar).toBe(450) + expect(store.getSnapshot().layoutInfo.rightbar).toBe(450) }) it('keeps track and fullscreen reports independent and clears both on close', () => { const { store, actions } = createLayoutStore().create() actions.openRightbar(true, false) - expect(store.getSnapshot()).toMatchObject({ rightbarShown: true, rightbarTrack: true, rightbarFullscreen: false }) + expect(store.getSnapshot().layoutInfo).toMatchObject({ rightbarShown: true, rightbarTrack: true, rightbarFullscreen: false }) actions.openRightbar(true, true) - expect(store.getSnapshot()).toMatchObject({ rightbarShown: true, rightbarTrack: true, rightbarFullscreen: true }) + expect(store.getSnapshot().layoutInfo).toMatchObject({ rightbarShown: true, rightbarTrack: true, rightbarFullscreen: true }) actions.openRightbar(false, true) - expect(store.getSnapshot()).toMatchObject({ rightbarShown: true, rightbarTrack: false, rightbarFullscreen: true }) + expect(store.getSnapshot().layoutInfo).toMatchObject({ rightbarShown: true, rightbarTrack: false, rightbarFullscreen: true }) actions.closeRightbar() - expect(store.getSnapshot()).toMatchObject({ rightbarShown: false, rightbarTrack: false, rightbarFullscreen: false }) + expect(store.getSnapshot().layoutInfo).toMatchObject({ rightbarShown: false, rightbarTrack: false, rightbarFullscreen: false }) }) it('keeps dragged px preferences across resize, close, and reopen', () => { @@ -105,29 +171,29 @@ describe('right panel', () => { actions.openRightbar(true, false) actions.setRightbar(1100) actions.setViewportWidth(800) - expect(store.getSnapshot().rightbar).toBe(1100) + expect(store.getSnapshot().layoutInfo.rightbar).toBe(1100) actions.closeRightbar() actions.openRightbar(false, true) - expect(store.getSnapshot().rightbar).toBe(1100) + expect(store.getSnapshot().layoutInfo.rightbar).toBe(1100) }) it('clamps drag preferences to 300px and 70% of the current frame', () => { const { store, actions } = createLayoutStore().create() actions.setViewportWidth(1600) actions.setRightbar(9999) - expect(store.getSnapshot().rightbar).toBe(1120) + expect(store.getSnapshot().layoutInfo.rightbar).toBe(1120) actions.setViewportWidth(1000) actions.setRightbar(9999) - expect(store.getSnapshot().rightbar).toBe(700) + expect(store.getSnapshot().layoutInfo.rightbar).toBe(700) actions.setRightbar(1) - expect(store.getSnapshot().rightbar).toBe(300) + expect(store.getSnapshot().layoutInfo.rightbar).toBe(300) }) it('retains a minimum normal preference when first opened fullscreen on a phone', () => { const { store, actions } = createLayoutStore().create() actions.setViewportWidth(320) actions.openRightbar(false, true) - expect(store.getSnapshot().rightbar).toBe(300) + expect(store.getSnapshot().layoutInfo.rightbar).toBe(300) }) it('collapses a manually expanded narrow sidebar on opening, not presentation reports', () => { @@ -136,23 +202,23 @@ describe('right panel', () => { actions.setViewportWidth(800) actions.toggleSidebar() actions.openRightbar(true, false) - expect(store.getSnapshot()).toMatchObject({ sidebar: 400, narrowExpanded: false }) + expect(store.getSnapshot().layoutInfo).toMatchObject({ sidebar: 400, narrowExpanded: false }) actions.toggleSidebar() actions.openRightbar(true, true) - expect(store.getSnapshot().narrowExpanded).toBe(true) + expect(store.getSnapshot().layoutInfo.narrowExpanded).toBe(true) actions.closeRightbar() actions.openRightbar(true, false) - expect(store.getSnapshot().narrowExpanded).toBe(false) + expect(store.getSnapshot().layoutInfo.narrowExpanded).toBe(false) }) it('keeps the wide sidebar preference and never opens a closed right panel on resize', () => { const { store, actions } = createLayoutStore().create() actions.setSidebar(420) actions.openRightbar(true, false) - expect(store.getSnapshot().sidebar).toBe(420) + expect(store.getSnapshot().layoutInfo.sidebar).toBe(420) actions.closeRightbar() actions.setViewportWidth(3000) - expect(store.getSnapshot()).toMatchObject({ sidebar: 420, rightbarShown: false, rightbarTrack: false }) + expect(store.getSnapshot().layoutInfo).toMatchObject({ sidebar: 420, rightbarShown: false, rightbarTrack: false }) }) }) @@ -161,7 +227,7 @@ describe('right panel instant geometry', () => { const { store, actions } = createLayoutStore().create() actions.openRightbar(track, true) actions.closeRightbar() - expect(store.getSnapshot()).toMatchObject({ + expect(store.getSnapshot().layoutInfo).toMatchObject({ rightbarShown: false, rightbarTrack: false, rightbarFullscreen: false, rightbarInstant: true, }) const closed = store.getSnapshot() @@ -173,38 +239,38 @@ describe('right panel instant geometry', () => { const { store, actions } = createLayoutStore().create() actions.openRightbar(true, true) actions.openRightbar(true, false) - expect(store.getSnapshot()).toMatchObject({ + expect(store.getSnapshot().layoutInfo).toMatchObject({ rightbarShown: true, rightbarTrack: true, rightbarFullscreen: false, rightbarInstant: true, }) const restored = store.getSnapshot() actions.openRightbar(true, false) expect(store.getSnapshot()).toBe(restored) actions.openRightbar(false, false) - expect(store.getSnapshot().rightbarInstant).toBe(false) + expect(store.getSnapshot().layoutInfo.rightbarInstant).toBe(false) }) it('allows a normal close to animate, including after restoring from fullscreen', () => { const { store, actions } = createLayoutStore().create() actions.openRightbar(true, false) actions.closeRightbar() - expect(store.getSnapshot().rightbarInstant).toBe(false) + expect(store.getSnapshot().layoutInfo.rightbarInstant).toBe(false) actions.openRightbar(true, true) actions.openRightbar(true, false) - expect(store.getSnapshot().rightbarInstant).toBe(true) + expect(store.getSnapshot().layoutInfo.rightbarInstant).toBe(true) actions.closeRightbar() - expect(store.getSnapshot()).toMatchObject({ rightbarTrack: false, rightbarInstant: false }) + expect(store.getSnapshot().layoutInfo).toMatchObject({ rightbarTrack: false, rightbarInstant: false }) }) it.each(['setSidebar', 'toggleSidebar', 'setRightbar', 'setViewportWidth'] as const)('clears instant geometry on %s', (action) => { const { store, actions } = createLayoutStore().create() actions.openRightbar(true, true) actions.closeRightbar() - expect(store.getSnapshot().rightbarInstant).toBe(true) + expect(store.getSnapshot().layoutInfo.rightbarInstant).toBe(true) if (action === 'toggleSidebar') actions.toggleSidebar() else actions[action](action === 'setViewportWidth' ? 1800 : 350) - expect(store.getSnapshot().rightbarInstant).toBe(false) + expect(store.getSnapshot().layoutInfo.rightbarInstant).toBe(false) actions.closeRightbar() - expect(store.getSnapshot().rightbarInstant).toBe(false) + expect(store.getSnapshot().layoutInfo.rightbarInstant).toBe(false) }) it('does not let an unchanged frame measurement reset the fullscreen-exit marker', () => { @@ -212,7 +278,7 @@ describe('right panel instant geometry', () => { actions.openRightbar(true, true) actions.closeRightbar() const closed = store.getSnapshot() - actions.setViewportWidth(closed.viewportWidth) + actions.setViewportWidth(closed.layoutInfo.viewportWidth) expect(store.getSnapshot()).toBe(closed) }) @@ -221,6 +287,6 @@ describe('right panel instant geometry', () => { actions.openRightbar(true, true) actions.closeRightbar() actions.openRightbar(true, fullscreen) - expect(store.getSnapshot()).toMatchObject({ rightbarShown: true, rightbarFullscreen: fullscreen, rightbarInstant: false }) + expect(store.getSnapshot().layoutInfo).toMatchObject({ rightbarShown: true, rightbarFullscreen: fullscreen, rightbarInstant: false }) }) }) diff --git a/packages/client/ui-layout/tests/service.client.spec.ts b/packages/client/ui-layout/tests/service.client.spec.ts index a95c6a5b44..7ccadb5c3c 100644 --- a/packages/client/ui-layout/tests/service.client.spec.ts +++ b/packages/client/ui-layout/tests/service.client.spec.ts @@ -1,9 +1,11 @@ import { describe, expect, it, vi } from 'vitest' import { LayoutController } from '../src/client/service.ts' -import type { PanelActions } from '../src/client/service.ts' +import type { MainPanelId, PanelActions } from '../src/client/service.ts' function fakePanels(): PanelActions { return { + selectPanel: vi.fn(), + retainMainPanels: vi.fn(), setSidebar: vi.fn(), toggleSidebar: vi.fn(), setViewportWidth: vi.fn(), @@ -14,10 +16,9 @@ function fakePanels(): PanelActions { } describe('LayoutController', () => { - it('forwards the right column transitions to the attached set', () => { - const service = new LayoutController() + it('forwards right column transitions to the constructor-supplied actions', () => { const panels = fakePanels() - service.attachPanels(panels) + const service = new LayoutController(panels, () => true) service.openRightbar(true, false) service.openRightbar(true, true) @@ -32,10 +33,9 @@ describe('LayoutController', () => { expect(panels.setRightbar).not.toHaveBeenCalled() }) - it('forwards the three panel actions to the attached set', () => { - const service = new LayoutController() + it('can toggle the sidebar immediately after construction', () => { const panels = fakePanels() - service.attachPanels(panels) + const service = new LayoutController(panels, () => true) service.toggleSidebar() @@ -43,21 +43,62 @@ describe('LayoutController', () => { expect(panels.setSidebar).not.toHaveBeenCalled() }) - it('fails loud before the root entry wired its actions', () => { - const service = new LayoutController() - expect(() => { service.toggleSidebar() }).toThrow(/panel actions not wired/) + it('forwards panel selection and returning to the Conversation without changing geometry', () => { + const panels = fakePanels() + const service = new LayoutController(panels, () => true) + const panelId = 'panel-a' as MainPanelId + service.selectPanel(panelId) + service.selectPanel(panelId) + service.selectPanel(null) + expect(panels.selectPanel).toHaveBeenNthCalledWith(1, panelId) + expect(panels.selectPanel).toHaveBeenNthCalledWith(2, panelId) + expect(panels.selectPanel).toHaveBeenNthCalledWith(3, null) + expect(panels.toggleSidebar).not.toHaveBeenCalled() + expect(panels.openRightbar).not.toHaveBeenCalled() + expect(panels.closeRightbar).not.toHaveBeenCalled() }) - it('re-attach overwrites the stale action set (entry re-register)', () => { - const service = new LayoutController() - const stale = fakePanels() - const fresh = fakePanels() - service.attachPanels(stale) - service.attachPanels(fresh) + it('keeps separately constructed controllers bound to their own instances', () => { + const first = fakePanels() + const second = fakePanels() + const firstService = new LayoutController(first, () => true) + const secondService = new LayoutController(second, () => true) + firstService.toggleSidebar() + expect(first.toggleSidebar).toHaveBeenCalledTimes(1) + expect(second.toggleSidebar).not.toHaveBeenCalled() + secondService.closeRightbar() + expect(first.closeRightbar).not.toHaveBeenCalled() + expect(second.closeRightbar).toHaveBeenCalledTimes(1) + }) - service.toggleSidebar() + it('rejects an absent main entry without changing selection or cancelling pending navigation', () => { + const panels = fakePanels() + const present = new Set(['panel-a']) + const service = new LayoutController(panels, id => present.has(id)) + service.selectPanel('panel-a' as MainPanelId) + const navigation = service.beginNavigation() + present.delete('panel-a') + expect(() => { service.selectPanel('panel-a' as MainPanelId) }).toThrow('main panel "panel-a" is not registered') + expect(panels.selectPanel).toHaveBeenCalledOnce() + expect(navigation.aborted).toBe(false) + service.selectPanel(null) + expect(navigation.aborted).toBe(true) + }) - expect(stale.toggleSidebar).not.toHaveBeenCalled() - expect(fresh.toggleSidebar).toHaveBeenCalledTimes(1) + it('supersedes asynchronous navigation on another request, any valid selection, and disposal', () => { + const service = new LayoutController(fakePanels(), () => true) + const first = service.beginNavigation() + const second = service.beginNavigation() + expect(first.aborted).toBe(true) + expect(second.aborted).toBe(false) + service.selectPanel('panel-a' as MainPanelId) + expect(second.aborted).toBe(true) + const repeated = service.beginNavigation() + service.selectPanel('panel-a' as MainPanelId) + expect(repeated.aborted).toBe(true) + const pending = service.beginNavigation() + service.dispose() + expect(pending.aborted).toBe(true) + service.dispose() }) }) diff --git a/packages/client/ui-layout/tsconfig.json b/packages/client/ui-layout/tsconfig.json index 79a7f1ce54..ed6a3944d2 100644 --- a/packages/client/ui-layout/tsconfig.json +++ b/packages/client/ui-layout/tsconfig.json @@ -8,6 +8,9 @@ "src" ], "references": [ + { + "path": "../../util/brand" + }, { "path": "../../../vendor/cordis" }, diff --git a/packages/client/ui-model-selection/package.json b/packages/client/ui-model-selection/package.json index 4d87ef6ca0..d3c89480e8 100644 --- a/packages/client/ui-model-selection/package.json +++ b/packages/client/ui-model-selection/package.json @@ -63,14 +63,12 @@ "@deepseek-ai/dsh-client-store": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-client-ui-renderer": "workspace:^", - "@deepseek-ai/dsh-client-ui-session": "workspace:^" + "@deepseek-ai/dsh-client-ui-session": "workspace:^", + "clsx": "^2.1.1" }, "files": [ "lib/index.js", "lib/client.js", "lib/types/**/*.d.ts" - ], - "dependencies": { - "clsx": "^2.1.1" - } + ] } diff --git a/packages/client/ui-permission-presets/tests/permission-presets-row.client.spec.tsx b/packages/client/ui-permission-presets/tests/permission-presets-row.client.spec.tsx index 5ef380f656..b1af331463 100644 --- a/packages/client/ui-permission-presets/tests/permission-presets-row.client.spec.tsx +++ b/packages/client/ui-permission-presets/tests/permission-presets-row.client.spec.tsx @@ -13,6 +13,7 @@ import { PermissionPresetSettingsController } from '../src/client/settings-store // Every fixture carries the resource hook the resources plugin merges into GlobalStandardProps. const useResource = (() => ({ status: 'none' as const, value: undefined, failure: undefined, reload: () => {} })) as GlobalStandardProps['useResource'] +const usePanelInfo: GlobalStandardProps['usePanelInfo'] = selector => selector({ activePanelId: null }) const schema = new SettingsSchemaService(new Context()) @@ -60,7 +61,7 @@ const useSessionPendingInteraction: PermissionRowProps['useSessionPendingInterac const runtime = { useSessions: (() => { throw new Error('unused') }) as never, useSessionPendingInteraction, - useResource, + usePanelInfo, useResource, useWorkspaces: (() => { throw new Error('unused') }) as never, } diff --git a/packages/client/ui-primitives/README.i18n.yaml b/packages/client/ui-primitives/README.i18n.yaml index 4bda8cc5f7..6dd7e78078 100644 --- a/packages/client/ui-primitives/README.i18n.yaml +++ b/packages/client/ui-primitives/README.i18n.yaml @@ -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: 18b136c78539d8ba6da3b17239112967faf63602 -README.zh.md: f91435e44a5ca79b245fde3dd025a732d35e54e9 +README.md: 3e0922690cc68a477e4b1d5ca1ed142d713369f0 +README.zh.md: de8487499ad31affc95986125c910b368ae39b95 diff --git a/packages/client/ui-primitives/README.md b/packages/client/ui-primitives/README.md index 18b136c785..3e0922690c 100644 --- a/packages/client/ui-primitives/README.md +++ b/packages/client/ui-primitives/README.md @@ -25,6 +25,8 @@ Use `dsh-client-ui-primitives` to build web-client controls and render agent out ## Use this package +This package is a Web-shell build input. Its static ESM retains third-party imports and styles for Vite; independent consumers supply its development dependencies ([dependency rules](../AGENTS.md#dependency-declaration)). + Compose feature UI from these atoms whenever the web client needs a standard control or an agent-output renderer. They render through React only and take `--dsw-*` design tokens from the theme, so they fit any plugin without importing the theme or the slot system. diff --git a/packages/client/ui-primitives/README.zh.md b/packages/client/ui-primitives/README.zh.md index f91435e44a..de8487499a 100644 --- a/packages/client/ui-primitives/README.zh.md +++ b/packages/client/ui-primitives/README.zh.md @@ -25,6 +25,8 @@ kind: "package-library" ## 使用本包 +本包是 Web 壳的构建输入。静态 ESM 为 Vite 保留第三方导入和样式;独立消费方自行提供开发依赖([依赖规则](../AGENTS.md#dependency-declaration))。 + 只要 Web 客户端需要标准控件或 agent 输出渲染器,就用这些原子组件拼装功能 UI。它们只经 React 渲染,并从主题取得 `--dsw-*` 设计 token,因此无需导入主题或 slot 系统即可适配任意插件。 diff --git a/packages/client/ui-primitives/package.json b/packages/client/ui-primitives/package.json index 41fbdb4ebe..2613c3aa98 100644 --- a/packages/client/ui-primitives/package.json +++ b/packages/client/ui-primitives/package.json @@ -22,32 +22,30 @@ "./package.json": "./package.json" }, "license": "MIT", - "dependencies": { - "@shikijs/langs": "^4.3.1", - "@types/mdast": "^4.0.4", - "anser": "^2.3.5", - "clsx": "^2.0.0", - "katex": "^0.16.47", - "mdast-util-from-markdown": "^2.0.3", - "mdast-util-gfm": "^3.1.0", - "mdast-util-math": "^3.0.0", - "micromark-core-commonmark": "^2.0.3", - "micromark-extension-gfm": "^3.0.0", - "micromark-extension-math": "^3.1.0", - "micromark-factory-space": "^2.0.1", - "micromark-util-character": "^2.1.1", - "micromark-util-classify-character": "^2.0.1", - "micromark-util-sanitize-uri": "^2.0.1", - "micromark-util-symbol": "^2.0.1", - "micromark-util-types": "^2.0.2", - "react": "^18.2.0", - "react-dom": "^18.2.0", - "shiki": "^4.3.1" - }, "devDependencies": { "@types/react": "~18.3.1", "@types/react-dom": "~18.3.0", - "@deepseek-ai/cordis": "workspace:^" + "@deepseek-ai/cordis": "workspace:^", + "react": "^18.2.0", + "clsx": "^2.0.0", + "react-dom": "^18.2.0", + "anser": "^2.3.5", + "katex": "^0.16.47", + "micromark-core-commonmark": "^2.0.3", + "micromark-util-character": "^2.1.1", + "micromark-util-classify-character": "^2.0.1", + "micromark-util-symbol": "^2.0.1", + "micromark-util-types": "^2.0.2", + "shiki": "^4.3.1", + "@shikijs/langs": "^4.3.1", + "@types/mdast": "^4.0.4", + "micromark-factory-space": "^2.0.1", + "micromark-extension-math": "^3.1.0", + "mdast-util-from-markdown": "^2.0.3", + "mdast-util-gfm": "^3.1.0", + "mdast-util-math": "^3.0.0", + "micromark-extension-gfm": "^3.0.0", + "micromark-util-sanitize-uri": "^2.0.1" }, "files": [ "lib/index.js", diff --git a/packages/client/ui-renderer/package.json b/packages/client/ui-renderer/package.json index 32b616d5a2..2aefdb4b1e 100644 --- a/packages/client/ui-renderer/package.json +++ b/packages/client/ui-renderer/package.json @@ -40,9 +40,6 @@ "watch": "tsdown --watch" }, "license": "MIT", - "dependencies": { - "use-sync-external-store": "1.2.0" - }, "peerDependencies": { "@deepseek-ai/cordis": "workspace:^" }, @@ -55,7 +52,8 @@ "@types/react-dom": "~18.3.0", "@types/use-sync-external-store": "^1.5.0", "react": "^18.2.0", - "react-dom": "^18.2.0" + "react-dom": "^18.2.0", + "use-sync-external-store": "1.2.0" }, "files": [ "lib/index.js", diff --git a/packages/client/ui-settings-general/package.json b/packages/client/ui-settings-general/package.json index a31d2dad8d..2d33e995f4 100644 --- a/packages/client/ui-settings-general/package.json +++ b/packages/client/ui-settings-general/package.json @@ -43,8 +43,7 @@ }, "license": "MIT", "dependencies": { - "@deepseek-ai/schemastery": "workspace:^", - "clsx": "^2.0.0" + "@deepseek-ai/schemastery": "workspace:^" }, "peerDependencies": { "@deepseek-ai/cordis": "workspace:^" @@ -64,7 +63,8 @@ "react": "^18.2.0", "@deepseek-ai/dsh-client-ui-renderer": "workspace:^", "@deepseek-ai/dsh-client-ui-session": "workspace:^", - "@deepseek-ai/dsh-settings": "workspace:^" + "@deepseek-ai/dsh-settings": "workspace:^", + "clsx": "^2.0.0" }, "files": [ "lib/index.js", diff --git a/packages/client/ui-settings-general/tests/components.client.spec.tsx b/packages/client/ui-settings-general/tests/components.client.spec.tsx index c3daeaaeb6..c9cb8eadad 100644 --- a/packages/client/ui-settings-general/tests/components.client.spec.tsx +++ b/packages/client/ui-settings-general/tests/components.client.spec.tsx @@ -13,6 +13,7 @@ import { SettingsDocumentStore } from '../src/client/settings-document-store.ts' // Every fixture carries the resource hook the resources plugin merges into GlobalStandardProps. const useResource = (() => ({ status: 'none' as const, value: undefined, failure: undefined, reload: () => {} })) as GlobalStandardProps['useResource'] +const usePanelInfo: GlobalStandardProps['usePanelInfo'] = selector => selector({ activePanelId: null }) /** Store over a real mirror derived from the same scripted context. */ function derivedDocumentStore(remote: object) { @@ -32,7 +33,7 @@ const unusedHook = (() => { throw new Error('unused by settings-general componen type AttentionSnapshot = Parameters[0]>[0] const noAttention: AttentionSnapshot = new Map() const useSessionPendingInteraction: TriggerContentProps['useSessionPendingInteraction'] = selector => selector(noAttention) -const kit = { useSessions: unusedHook, useSessionPendingInteraction, useResource, useWorkspaces: unusedHook } +const kit = { useSessions: unusedHook, useSessionPendingInteraction, usePanelInfo, useResource, useWorkspaces: unusedHook } describe('chrome content', () => { it('TriggerContent renders the icon with the label in the wide column', () => { diff --git a/packages/client/ui-settings-general/tests/settings-root.client.spec.tsx b/packages/client/ui-settings-general/tests/settings-root.client.spec.tsx index 3ebce030b4..bb936d5659 100644 --- a/packages/client/ui-settings-general/tests/settings-root.client.spec.tsx +++ b/packages/client/ui-settings-general/tests/settings-root.client.spec.tsx @@ -10,6 +10,7 @@ import { en } from '../src/client/locales.ts' // Every fixture carries the resource hook the resources plugin merges into GlobalStandardProps. const useResource = (() => ({ status: 'none' as const, value: undefined, failure: undefined, reload: () => {} })) as GlobalStandardProps['useResource'] +const usePanelInfo: GlobalStandardProps['usePanelInfo'] = selector => selector({ activePanelId: null }) afterEach(() => { cleanup() @@ -76,7 +77,7 @@ function mount({ const props: SettingsRootComponentProps = { useSessions, useSessionPendingInteraction, - useResource, + usePanelInfo, useResource, useWorkspaces: unusedHook, wide, reconnect, diff --git a/packages/client/ui-settings-models/tests/onboarding-dialog.client.spec.tsx b/packages/client/ui-settings-models/tests/onboarding-dialog.client.spec.tsx index 9f10184eaa..f4a05c9363 100644 --- a/packages/client/ui-settings-models/tests/onboarding-dialog.client.spec.tsx +++ b/packages/client/ui-settings-models/tests/onboarding-dialog.client.spec.tsx @@ -17,6 +17,7 @@ import { settingsSchema } from './settings-schema.client.ts' // Every fixture carries the resource hook the resources plugin merges into GlobalStandardProps. const useResource = (() => ({ status: 'none' as const, value: undefined, failure: undefined, reload: () => {} })) as GlobalStandardProps['useResource'] +const usePanelInfo: GlobalStandardProps['usePanelInfo'] = selector => selector({ activePanelId: null }) afterEach(() => { cleanup() @@ -147,7 +148,7 @@ function harness(options: { openSection, useSessions: unusedHook, useSessionPendingInteraction, - useResource, + usePanelInfo, useResource, useWorkspaces: unusedHook, controller, useModels: bindSnapshotSelector(controller.store), diff --git a/packages/client/ui-settings-models/tests/welcome-notice.client.spec.tsx b/packages/client/ui-settings-models/tests/welcome-notice.client.spec.tsx index 59b2b7acd8..41abead4e8 100644 --- a/packages/client/ui-settings-models/tests/welcome-notice.client.spec.tsx +++ b/packages/client/ui-settings-models/tests/welcome-notice.client.spec.tsx @@ -10,6 +10,7 @@ import { SettingsScopeController } from '@deepseek-ai/dsh-client-ui-settings/src // Every fixture carries the resource hook the resources plugin merges into GlobalStandardProps. const useResource = (() => ({ status: 'none' as const, value: undefined, failure: undefined, reload: () => {} })) as GlobalStandardProps['useResource'] +const usePanelInfo: GlobalStandardProps['usePanelInfo'] = selector => selector({ activePanelId: null }) /** Stateless schema service for scope construction in this jsdom fixture. */ const schemaService = new SettingsSchemaService(new Context()) @@ -93,7 +94,7 @@ function mount( openSection: vi.fn(), useSessions: unusedHook, useSessionPendingInteraction, - useResource, + usePanelInfo, useResource, useWorkspaces: unusedHook, controller, useWelcome: bindSnapshotSelector(controller.store), diff --git a/packages/client/ui-settings-plugins/package.json b/packages/client/ui-settings-plugins/package.json index 9f82713d90..c4b27fae88 100644 --- a/packages/client/ui-settings-plugins/package.json +++ b/packages/client/ui-settings-plugins/package.json @@ -54,14 +54,12 @@ "@deepseek-ai/dsh-client-ui-slots": "workspace:^", "@types/react": "~18.3.1", "react": "^18.2.0", - "@deepseek-ai/dsh-client-ui-renderer": "workspace:^" + "@deepseek-ai/dsh-client-ui-renderer": "workspace:^", + "clsx": "^2.0.0" }, "files": [ "lib/index.js", "lib/client.js", "lib/types/**/*.d.ts" - ], - "dependencies": { - "clsx": "^2.0.0" - } + ] } diff --git a/packages/client/ui-sidebar-files/package.json b/packages/client/ui-sidebar-files/package.json index 9e1a7f0663..ce97ee1ffc 100644 --- a/packages/client/ui-sidebar-files/package.json +++ b/packages/client/ui-sidebar-files/package.json @@ -41,11 +41,6 @@ "watch": "tsdown --watch" }, "license": "MIT", - "dependencies": { - "clsx": "^2.0.0", - "react": "^18.2.0", - "react-dom": "^18.2.0" - }, "peerDependencies": { "@deepseek-ai/cordis": "workspace:^" }, @@ -66,7 +61,10 @@ "@deepseek-ai/dsh-util-workspace-path": "workspace:^", "@testing-library/react": "^16.1.0", "@types/react": "~18.3.1", - "@types/react-dom": "~18.3.0" + "@types/react-dom": "~18.3.0", + "react": "^18.2.0", + "clsx": "^2.0.0", + "react-dom": "^18.2.0" }, "files": [ "lib/index.js", diff --git a/packages/client/ui-sidebar-right/README.i18n.yaml b/packages/client/ui-sidebar-right/README.i18n.yaml index 8d11c28b28..eff8b1d7a3 100644 --- a/packages/client/ui-sidebar-right/README.i18n.yaml +++ b/packages/client/ui-sidebar-right/README.i18n.yaml @@ -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-sidebar-right/README.md -README.md: feb75c6184098f54961ca161b4dc5ca5d0c7fc78 -README.zh.md: 03c841dfdaac970e938cf934e7f09d151268be8b +README.md: 7520a927f8e77b4fca1bbb7e61fe415dda814b4b +README.zh.md: 648a2c20d8e55a48a684a92a351b7900f3a8d517 diff --git a/packages/client/ui-sidebar-right/README.md b/packages/client/ui-sidebar-right/README.md index feb75c6184..7520a927f8 100644 --- a/packages/client/ui-sidebar-right/README.md +++ b/packages/client/ui-sidebar-right/README.md @@ -54,6 +54,8 @@ While the panel is hidden, one button in the conversation header's corner seat ( The panel takes the conversation's ground colour and content font sizes rather than a raised layer of its own: it is a column of the page, not a card over it. +The `rightbar` entry is a root-scoped controller. It reads `usePanelInfo` and mounts the Session-scoped `rightbar.session` subtree only while the Conversation is selected. Switching to a global panel hides the right Sidebar and releases its frame track without deleting the Session's tab state. + ## State diff --git a/packages/client/ui-sidebar-right/README.zh.md b/packages/client/ui-sidebar-right/README.zh.md index 03c841dfda..648a2c20d8 100644 --- a/packages/client/ui-sidebar-right/README.zh.md +++ b/packages/client/ui-sidebar-right/README.zh.md @@ -54,6 +54,8 @@ kind: "package-reference" 面板取会话区的底色与正文字号,而不是自成一层浮起的表面:它是页面的一列,不是压在页面上的卡片。 +`rightbar` 入口是 root 作用域的控制器。它读取 `usePanelInfo`,仅在选中会话界面时挂载 session 作用域的 `rightbar.session` 子树。切换到全局面板会隐藏右侧 Sidebar 并释放框架列宽,但不删除会话的 tab 状态。 + ## 状态 diff --git a/packages/client/ui-sidebar-right/package.json b/packages/client/ui-sidebar-right/package.json index 699bebc01f..c8fad0215c 100644 --- a/packages/client/ui-sidebar-right/package.json +++ b/packages/client/ui-sidebar-right/package.json @@ -42,12 +42,6 @@ "watch": "tsdown --watch" }, "license": "MIT", - "dependencies": { - "clsx": "^2.0.0", - "picomatch": "^4.0.4", - "react": "^18.2.0", - "react-dom": "^18.2.0" - }, "peerDependencies": { "@deepseek-ai/cordis": "workspace:^" }, @@ -69,7 +63,11 @@ "@testing-library/react": "^16.1.0", "@types/picomatch": "^4.0.2", "@types/react": "~18.3.1", - "@types/react-dom": "~18.3.0" + "@types/react-dom": "~18.3.0", + "react": "^18.2.0", + "react-dom": "^18.2.0", + "picomatch": "^4.0.4", + "clsx": "^2.0.0" }, "files": [ "lib/index.js", diff --git a/packages/client/ui-sidebar-right/src/client/contract/slots.ts b/packages/client/ui-sidebar-right/src/client/contract/slots.ts index 49709181c5..f964ee97c6 100644 --- a/packages/client/ui-sidebar-right/src/client/contract/slots.ts +++ b/packages/client/ui-sidebar-right/src/client/contract/slots.ts @@ -21,6 +21,7 @@ * therefore live with their declarer. */ import type {} from '@deepseek-ai/dsh-client-ui-slots' +import type { RightbarOwnerProps } from '@deepseek-ai/dsh-client-ui-layout/client' // The locale plugin's own merge carries the shared `common` vocabulary that the // lookup chain consults after this namespace misses. import type {} from '@deepseek-ai/dsh-client-locale/client' @@ -37,6 +38,8 @@ declare module '@deepseek-ai/dsh-client-ui-slots' { } interface SlotMap { + /** Session content selected by the root-scoped right Sidebar controller. */ + 'rightbar.session': { kind: 'single'; scope: 'session'; owner: RightbarOwnerProps } /** * One tab's body, dispatched with the `id` of the type in force for * `tab.kind`. A tab type registers here under its definition's `id` and diff --git a/packages/client/ui-sidebar-right/src/client/index.ts b/packages/client/ui-sidebar-right/src/client/index.ts index 0971b9d21c..0fc4a4d2e2 100644 --- a/packages/client/ui-sidebar-right/src/client/index.ts +++ b/packages/client/ui-sidebar-right/src/client/index.ts @@ -32,6 +32,7 @@ import type {} from './contract/slots.ts' import { GuideBody, type GuideInjected } from './tabs/guide/GuideBody.tsx' import { ExpandButton } from './shell/ExpandButton.tsx' import { RightbarSeat, type SidebarRightInjected } from './shell/SidebarRight.tsx' +import { RightbarRoot } from './shell/RightbarRoot.tsx' import { createSidebarRightController, type SidebarRightController } from './service.ts' import { SidebarRightTabRegistry } from './tab-registry.ts' import { createSidebarRightStore } from './stores.ts' @@ -143,21 +144,27 @@ export function apply(ctx: ClientContext): void { } const disposeTypes = [tabs.register(guideDefinition(t))] - const disposeSeat = ctx.slots.inject('rightbar', () => ctx.slots.register({ - name: 'rightbar', - locale: NS, - children: { - 'sidebar.right.pane.tab': { kind: 'keyed', scope: 'session', inject: { hooks: { tabInfo: tabInfoFactory } } }, - 'sidebar.right.pane.tab.title': { kind: 'keyed', scope: 'session', inject: { hooks: { tabInfo: tabInfoFactory } } }, - 'sidebar.right.tab.menu.item': { kind: 'list', scope: 'session' }, - }, - store, - inject: (sessionId): SidebarRightInjected => ({ - ...injected, - keyedHooks: { tabNavigation: key => controller.tabDomain.occurrence(sessionId, { id: key as TabId }).navigation }, - occurrence: tab => controller.tabDomain.occurrence(sessionId, tab), - }), - }, RightbarSeat)) + const disposeSeat = ctx.slots.inject('rightbar', function* () { + yield ctx.slots.register({ + name: 'rightbar', + children: { 'rightbar.session': { kind: 'single', scope: 'session' } }, + }, RightbarRoot) + yield ctx.slots.register({ + name: 'rightbar.session', + locale: NS, + children: { + 'sidebar.right.pane.tab': { kind: 'keyed', scope: 'session', inject: { hooks: { tabInfo: tabInfoFactory } } }, + 'sidebar.right.pane.tab.title': { kind: 'keyed', scope: 'session', inject: { hooks: { tabInfo: tabInfoFactory } } }, + 'sidebar.right.tab.menu.item': { kind: 'list', scope: 'session' }, + }, + store, + inject: (sessionId): SidebarRightInjected => ({ + ...injected, + keyedHooks: { tabNavigation: key => controller.tabDomain.occurrence(sessionId, { id: key as TabId }).navigation }, + occurrence: tab => controller.tabDomain.occurrence(sessionId, tab), + }), + }, RightbarSeat) + }) // The expand button shares the panel's store: it only needs to know whether // the panel is expanded, and to ask for it to be. The header's corner seat // is its own place, past the utilities, so showing and hiding it moves diff --git a/packages/client/ui-sidebar-right/src/client/service.ts b/packages/client/ui-sidebar-right/src/client/service.ts index 25e702b612..ca5ad487a3 100644 --- a/packages/client/ui-sidebar-right/src/client/service.ts +++ b/packages/client/ui-sidebar-right/src/client/service.ts @@ -24,8 +24,8 @@ * hand the store one settled intent and record the navigation in the Tab * domain. Placement is the caller's option, never a type's property. * - * Wiring follows `LayoutController.attachPanels`: the registration hands the - * service its store actions, and the service is the face other plugins hold. + * The registration adopts Session stores and injects the mounted seat binding; + * callers use the service's navigation methods. */ import type { FloatRect, PaneId, TabId, TabRecord } from '@deepseek-ai/dsh-client-ui-dockkit' import { activeDockPaneId, canSplit, dockPaneIds, findTabPane, getPane } from '@deepseek-ai/dsh-client-ui-dockkit' diff --git a/packages/client/ui-sidebar-right/src/client/shell/RightbarRoot.tsx b/packages/client/ui-sidebar-right/src/client/shell/RightbarRoot.tsx new file mode 100644 index 0000000000..46420e5970 --- /dev/null +++ b/packages/client/ui-sidebar-right/src/client/shell/RightbarRoot.tsx @@ -0,0 +1,20 @@ +/** Root-scoped controller for the right Sidebar's Session content. */ +import type { PropsRenderSlots, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' +import type {} from '../contract/slots.ts' + +/** + * Render the Session-bound Sidebar only while the Conversation is selected. + * @param props - frame geometry, panel selection, and the authorized Session renderer. + * @returns the current Session's right Sidebar, or no content for a global panel. + */ +export function RightbarRoot({ + usePanelInfo, SessionProvider, renderSlot, width, viewportWidth, canShow, +}: PropsRuntime<'rightbar'> & PropsRenderSlots<'rightbar.session'>) { + const visible = usePanelInfo(info => info.activePanelId === null) + if (!visible) return null + return ( + + {renderSlot('rightbar.session', { width, viewportWidth, canShow })} + + ) +} diff --git a/packages/client/ui-sidebar-right/src/client/shell/SidebarRight.tsx b/packages/client/ui-sidebar-right/src/client/shell/SidebarRight.tsx index ec0c9333e4..008ba4f387 100644 --- a/packages/client/ui-sidebar-right/src/client/shell/SidebarRight.tsx +++ b/packages/client/ui-sidebar-right/src/client/shell/SidebarRight.tsx @@ -33,8 +33,7 @@ import { createPortal } from 'react-dom' import type { HostObservable, InjectFace, PropsLocale, PropsRenderSlots, PropsRuntime, PropsStore, } from '@deepseek-ai/dsh-client-ui-slots' -// The frame declares the `rightbar` seat this component fills. -import type {} from '@deepseek-ai/dsh-client-ui-layout/client' +import type {} from '../contract/slots.ts' import type { DockIntents, DockMode, FloatRect, TabId, TabRecord, TabRenderer } from '@deepseek-ai/dsh-client-ui-dockkit' import { canSplit, dockPaneIds, DockSurface, findPaneContentTab, FloatLayer } from '@deepseek-ai/dsh-client-ui-dockkit' import type { HalvesFit, LayoutState, PaneId } from '@deepseek-ai/dsh-client-ui-dockkit' @@ -108,7 +107,7 @@ export interface SidebarRightInjected { /** The column seat's props: session scope, so the session arrives as a standard prop. */ export type RightbarSeatProps = - & PropsRuntime<'rightbar'> + & PropsRuntime<'rightbar.session'> & Children & Store & PropsLocale<'sidebarRight'> diff --git a/packages/client/ui-sidebar-right/tests/apply.client.spec.ts b/packages/client/ui-sidebar-right/tests/apply.client.spec.ts index c54f98f2af..3f9a30d29b 100644 --- a/packages/client/ui-sidebar-right/tests/apply.client.spec.ts +++ b/packages/client/ui-sidebar-right/tests/apply.client.spec.ts @@ -10,6 +10,7 @@ */ import { describe, expect, it, vi } from 'vitest' import { Context } from '@deepseek-ai/cordis' +import type { SlotRegistry } from '@deepseek-ai/dsh-client-ui-renderer/client' import type { SessionId } from '@deepseek-ai/dsh-session/types' import { apply, inject } from '../src/client/index.ts' import type { GuideInjected, SidebarRightInjected } from '../src/client/index.ts' @@ -18,6 +19,7 @@ import { SidebarRightController } from '../src/client/service.ts' import { SidebarRightTabRegistry } from '../src/client/tab-registry.ts' import type { createSidebarRightStore } from '../src/client/stores.ts' import { RightbarSeat } from '../src/client/shell/SidebarRight.tsx' +import { RightbarRoot } from '../src/client/shell/RightbarRoot.tsx' import { ExpandButton } from '../src/client/shell/ExpandButton.tsx' import { GuideBody } from '../src/client/tabs/guide/GuideBody.tsx' import { GUIDE_ID } from '../src/client/tabs/guide/definition.ts' @@ -28,7 +30,7 @@ const SESSION = 's-test' as SessionId interface Recorded { name: string key?: string - locale: string + locale?: string store?: unknown children?: unknown inject?: (sessionId: SessionId) => unknown @@ -39,7 +41,7 @@ async function boot() { const ctx = new Context() const registered: Recorded[] = [] const slots = { - inject: vi.fn((_name: string, register: () => () => void) => register()), + inject: vi.fn((_name: string, register: Parameters[1]) => ctx.effect(register)), register: vi.fn((options: Omit, component: unknown) => { const entry: Recorded = { ...options, component } registered.push(entry) @@ -90,26 +92,25 @@ describe('ui-sidebar-right apply', () => { expect(guide?.id).toBe(GUIDE_ID) expect(guide?.priority).toBe('builtin') expect(guide?.title('sidebar://guide')).toBe('tab.guide.title') - // Three registrations: the panel seat, the header's corner seat, and the - // guide body under the guide implementation's id. expect(registered.map(entry => [entry.name, entry.key, entry.locale, entry.component])).toEqual([ - ['rightbar', undefined, 'sidebarRight', RightbarSeat], + ['rightbar', undefined, undefined, RightbarRoot], + ['rightbar.session', undefined, 'sidebarRight', RightbarSeat], ['conversation.session.header.corner', undefined, 'sidebarRight', ExpandButton], ['sidebar.right.pane.tab', GUIDE_ID, 'sidebarRight', GuideBody], ]) // The panel declares the extension seats; the guide declares its chain child. - expect(Object.keys(seat('rightbar').children as object)).toEqual([ + expect(Object.keys(seat('rightbar.session').children as object)).toEqual([ 'sidebar.right.pane.tab', 'sidebar.right.pane.tab.title', 'sidebar.right.tab.menu.item', ]) expect(seat('sidebar.right.pane.tab').children).toMatchObject({ 'sidebar.right.tab.guide': { kind: 'chain', scope: 'session' } }) // Both seats read one store: the button only needs to know whether the panel is expanded. - expect(seat('rightbar').store).toBeDefined() - expect(seat('conversation.session.header.corner').store).toBe(seat('rightbar').store) + expect(seat('rightbar.session').store).toBeDefined() + expect(seat('conversation.session.header.corner').store).toBe(seat('rightbar.session').store) }) it('hands the panel seat the frame report, the service binding, the opens, the observable registry, and the Tab domain', async () => { const { ctx, layout, resources, seat, injectedOf } = await boot() - const injected = injectedOf(seat('rightbar')) as SidebarRightInjected + const injected = injectedOf(seat('rightbar.session')) as SidebarRightInjected // The frame learns the composition of expanded and presentation, nothing else. injected.syncPresentation({ shown: true, track: true, fullscreen: false }) expect(layout.openRightbar).toHaveBeenLastCalledWith(true, false) @@ -128,7 +129,7 @@ describe('ui-sidebar-right apply', () => { unsubscribe() // The binding makes the service act on this seat's session; the seat's // store instance is minted here from the handle the registration declared. - const handle = seat('rightbar').store as ReturnType + const handle = seat('rightbar.session').store as ReturnType const instance = handle.create() const release = injected.bindService({ sessionId: SESSION, actions: instance.actions, surfaces: {}, canSplitPane: () => true }) injected.openTab('guide', { revealIfOpened: false }) @@ -145,7 +146,7 @@ describe('ui-sidebar-right apply', () => { it('adopts each session\'s store instance as the runtime mints it, so a tab\'s own actions land with no seat bound', async () => { const { ctx, resources, seat } = await boot() - const handle = seat('rightbar').store as ReturnType + const handle = seat('rightbar.session').store as ReturnType // Both seats declare the same wrapped handle, so either minting adopts. expect(seat('conversation.session.header.corner').store).toBe(handle) const instance = handle.create(SESSION) @@ -178,8 +179,8 @@ describe('ui-sidebar-right apply', () => { it('takes every registration and both faces back when disposed, aborting the open records, so a reload registers again', async () => { const { ctx, registered, dictionaries, fiber, seat, injectedOf } = await boot() - const injected = injectedOf(seat('rightbar')) as SidebarRightInjected - const handle = seat('rightbar').store as ReturnType + const injected = injectedOf(seat('rightbar.session')) as SidebarRightInjected + const handle = seat('rightbar.session').store as ReturnType // Minted under the session key, so the instance is adopted and the teardown releases it. const instance = handle.create(SESSION) injected.bindService({ sessionId: SESSION, actions: instance.actions, surfaces: {}, canSplitPane: () => true }) @@ -199,6 +200,6 @@ describe('ui-sidebar-right apply', () => { expect(dictionaries.size).toBe(0) await ctx.plugin({ inject: [...inject], apply }).await() expect(ctx.sidebarRightTabs.get('guide')?.id).toBe(GUIDE_ID) - expect(registered).toHaveLength(3) + expect(registered).toHaveLength(4) }) }) diff --git a/packages/client/ui-sidebar-right/tests/seat.client.spec.tsx b/packages/client/ui-sidebar-right/tests/seat.client.spec.tsx index adf00ef762..19d28629e8 100644 --- a/packages/client/ui-sidebar-right/tests/seat.client.spec.tsx +++ b/packages/client/ui-sidebar-right/tests/seat.client.spec.tsx @@ -6,6 +6,7 @@ import { useState } from 'react' import { SlotTestRuntime } from '@deepseek-ai/dsh-client-test-runtime' import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client' import type { PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' +import type { MainPanelId } from '@deepseek-ai/dsh-client-ui-layout/client' import type { PaneId, SplitId, TabId } from '@deepseek-ai/dsh-client-ui-dockkit' import { dockPaneIds, getPane } from '@deepseek-ai/dsh-client-ui-dockkit' import type { SessionId } from '@deepseek-ai/dsh-session/types' @@ -67,7 +68,7 @@ async function mountSeat(viewportWidth = 1440, canShow = true) { runtime.ctx.provide('locale', locale) runtime.slots.installLocale(locale) await runtime.declare({ - 'rightbar': { kind: 'single', scope: 'session' }, + 'rightbar': { kind: 'single', scope: 'root' }, 'conversation.session.header.corner': { kind: 'single', scope: 'session' }, }) await runtime.sessions.add({ id: SESSION }) @@ -98,7 +99,7 @@ async function mountSeat(viewportWidth = 1440, canShow = true) { runtime.slots.register({ name: 'sidebar.right.pane.tab.title', key: 'test/text' }, Title) }) const view = runtime.renderSlot('rightbar', { width: 420, viewportWidth, canShow }) - const instance = runtime.storeOf('rightbar', SESSION) as ReturnType['create']> + const instance = runtime.storeOf('rightbar.session', SESSION) as ReturnType['create']> const controller = runtime.ctx.sidebarRight const layout = () => instance.getSnapshot().bySession[SESSION]!.layout const open = (name = 'a.txt', options?: Parameters[1]) => { @@ -115,6 +116,19 @@ function element(container: HTMLElement, selector: string): HTMLElement { } describe('RightbarSeat presentation', () => { + it('hides for a global main panel and retains the Session sidebar state', async () => { + const h = await mountSeat() + h.open('retained.txt') + const retained = h.layout() + act(() => { h.runtime.panelInfo.set({ activePanelId: 'other-panel' as MainPanelId }) }) + expect(h.view.container.querySelector('[data-sidebar-right-panel]')).toBeNull() + expect(h.frame.closeRightbar).toHaveBeenCalled() + expect(h.layout()).toBe(retained) + act(() => { h.runtime.panelInfo.set({ activePanelId: null }) }) + expect(h.view.container.querySelector('[data-sidebar-right-panel]')).not.toBeNull() + expect(h.layout()).toBe(retained) + }) + it('keeps the panel mounted while collapsed and releases the frame on unmount', async () => { const h = await mountSeat() const panel = element(h.view.container, '[data-sidebar-right-panel]') diff --git a/packages/client/ui-sidebar-textpreview/package.json b/packages/client/ui-sidebar-textpreview/package.json index 756b6a3dab..7801a14e1d 100644 --- a/packages/client/ui-sidebar-textpreview/package.json +++ b/packages/client/ui-sidebar-textpreview/package.json @@ -41,11 +41,6 @@ "watch": "tsdown --watch" }, "license": "MIT", - "dependencies": { - "clsx": "^2.0.0", - "react": "^18.2.0", - "react-dom": "^18.2.0" - }, "peerDependencies": { "@deepseek-ai/cordis": "workspace:^" }, @@ -67,7 +62,10 @@ "@deepseek-ai/dsh-util-workspace-path": "workspace:^", "@testing-library/react": "^16.1.0", "@types/react": "~18.3.1", - "@types/react-dom": "~18.3.0" + "@types/react-dom": "~18.3.0", + "react": "^18.2.0", + "clsx": "^2.0.0", + "react-dom": "^18.2.0" }, "files": [ "lib/index.js", diff --git a/packages/client/ui-sidebar/README.i18n.yaml b/packages/client/ui-sidebar/README.i18n.yaml index d4d0d29e1d..061d06f859 100644 --- a/packages/client/ui-sidebar/README.i18n.yaml +++ b/packages/client/ui-sidebar/README.i18n.yaml @@ -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-sidebar/README.md -README.md: 87b04e965fbe26591e38d49a5c272113edfd28aa -README.zh.md: 5a7f050d7099437e5c8be965e89be405492d5353 +README.md: 1b520213809ca98a6819483a4ee5ea4f451841d2 +README.zh.md: 8caae9f14eb4b7a70e674b9cef7245aaf077b378 diff --git a/packages/client/ui-sidebar/README.md b/packages/client/ui-sidebar/README.md index 87b04e965f..1b52021380 100644 --- a/packages/client/ui-sidebar/README.md +++ b/packages/client/ui-sidebar/README.md @@ -31,6 +31,10 @@ The sidebar is the navigation shell: users see the brand, start new sessions, co The expanded brand row renders `sidebar.brand.mark` and `sidebar.brand.name` as independent single slots; the collapsed rail renders the same mark slot. Without occupants, the shell uses the fish mark and a localized local-build label. A complete build stacks a code badge below the label as `version[-commit][-dirty]`, using `DSH_CLIENT_VERSION`, the optional 7-character `DSH_CLIENT_COMMIT_HASH`, and `DSH_CLIENT_GIT_DIRTY=true`; missing version metadata omits the badge. New Session targets the explicit Workspace used by a scoped action, otherwise the current Session's Workspace, otherwise the most recently active Workspace; when none exists it clears into the blank New Session page. +### Global panel entries + +Plugins add an icon component to the root-scoped `sidebar.panellist` list with an `id`, optional `order`, and a string or locale-aware `label`. The same id addresses the component registered in the layout's root-scoped `main` keyed slot; selecting a missing main entry throws without changing the current selection. The label supplies plain visible text, the accessible name, and the collapsed tooltip. Each row reads its own selected state through `usePanelInfo`; moving DOM focus to search or a directory picker does not change the displayed panel or its selected row. With no registrations, neither the list nor spacing for it is rendered. The shipped composition registers no example panel. + ### Collapse behavior During a live collapse, the expanded content fades out at its current width, the upper controls share one fade and leftward translation into the 56px rail, and the layout's column slide ends the motion. A page that starts collapsed renders the rail statically, and reduced-motion mode disables both transitions. The bottom-pinned `sidebar.settings` control shares the fade timing but has no horizontal translation. @@ -47,7 +51,7 @@ Scrollbars in the column are a pointer affordance: the shell rebinds the scrollb
Implementation internals — click to expand -The shell is pure composition: `SidebarRootComponentProps` composes the layout owner share, the global `useSessions` and `useWorkspaces` hooks, the declared brand, the `sidebar.workspaces` and `sidebar.settings` child slots, and injected `startSession` plus sidebar-toggle callbacks. There is no plugin store. +The shell is pure composition: `SidebarRootComponentProps` composes the layout owner share, the global `useSessions` and `useWorkspaces` hooks, the declared brand, the `sidebar.workspaces` and `sidebar.settings` child slots, and injected navigation callbacks. Panel entries and their optional titles use the same composition path. Panel metadata is derived from list registrations and locale changes; selection belongs to the layout store. ### Slot discipline @@ -100,4 +104,4 @@ None.
-**Runtime invariant:** No companion is published. A pure-consumer plugin deriving its rows in-component from the standard useSessions delivery — it emits no cordis events and owns no cross-plugin mutable state; derivation and interaction behavior are asserted directly by this package's tree/component specs. +**Runtime invariant:** No companion is published. Panel metadata is a read-only presentation projection of the Slot registry and locale, with no independent write API. The registry owns entry identity and disposal; this package's assembly tests assert the projection after registration and locale notifications settle. The shell owns no separate navigation state to reconcile with those sources. diff --git a/packages/client/ui-sidebar/README.zh.md b/packages/client/ui-sidebar/README.zh.md index 5a7f050d70..8caae9f14e 100644 --- a/packages/client/ui-sidebar/README.zh.md +++ b/packages/client/ui-sidebar/README.zh.md @@ -31,6 +31,10 @@ dsh Web 客户端的侧边栏让用户识别当前构建、启动新会话、将 展开的品牌行把 `sidebar.brand.mark` 与 `sidebar.brand.name` 渲染为两个独立的 single slot;收起轨道则渲染同一个 mark slot。没有占位者时,外壳使用鱼形标记和本地化的本地构建标签。完整构建会在标签下方显示代码徽标;该徽标使用 `DSH_CLIENT_VERSION`、可选的 7 位 `DSH_CLIENT_COMMIT_HASH` 与 `DSH_CLIENT_GIT_DIRTY=true` 组装成 `version[-commit][-dirty]`;缺少版本元数据时不显示徽标。New Session 优先使用作用域操作明确指定的 Workspace,否则使用当前 Session 所属 Workspace,再否则使用最近活跃 Workspace;一个 Workspace 都没有时则清空选择,进入空白 New Session 页面。 +### 全局面板入口 + +插件在 root 作用域的 `sidebar.panellist` list 中注册图标组件,提供 `id`、可选 `order`,以及字符串或随语言变化的 `label`。同一个 id 寻址布局中 root 作用域 `main` keyed slot 的组件;选择不存在的主面板条目会抛错,并保留当前选中态。标签提供普通可见文字、无障碍名称和折叠提示。每一行通过 `usePanelInfo` 读取自己的选中态;DOM 焦点移到搜索框或目录选择器时,显示的面板及其列表项选中态不变。没有注册项时,列表及其间距均不渲染。默认组合不注册示例面板。 + ### 折叠行为 实时收起时,展开内容在当前宽度淡出,上方控件共用一次淡入并左移进入 56px 轨道,由布局的栏滑动结束整段动画。页面初始即为收起状态时会静态渲染轨道;减少动态效果模式会禁用两段过渡。固定在底部的 `sidebar.settings` 控件只共用淡入时序,不发生横向位移。 @@ -47,7 +51,7 @@ dsh Web 客户端的侧边栏让用户识别当前构建、启动新会话、将
实现细节——点击展开 -外壳是纯组合:`SidebarRootComponentProps` 组合布局 owner share、全局 `useSessions` 与 `useWorkspaces` 钩子、已声明的品牌、`sidebar.workspaces` 与 `sidebar.settings` 子 slot,以及注入的 `startSession` 与侧边栏切换回调。这里没有插件 store。 +外壳是纯组合:`SidebarRootComponentProps` 组合布局 owner share、全局 `useSessions` 与 `useWorkspaces` 钩子、已声明的品牌、`sidebar.workspaces` 与 `sidebar.settings` 子 slot,以及注入的导航回调。面板入口及其可选标题使用相同的组合方式。面板元数据由列表注册和语言变化派生;选中态属于布局存储。 ### Slot 纪律 @@ -100,4 +104,4 @@ dsh Web 客户端的侧边栏让用户识别当前构建、启动新会话、将
-**运行时不变式:** 不发布伴生入口。这是从标准 `useSessions` delivery 直接派生行的纯消费插件,不发出 Cordis 事件,也不持有跨插件可变状态。 +**运行时不变式:** 不发布伴生入口。面板元数据是 Slot 注册表与 locale 的只读呈现投影,没有独立写入 API。注册表拥有条目身份和释放;本包的装配测试在注册和语言通知完成后断言投影。外壳没有需要与这些来源协调的独立导航状态。 diff --git a/packages/client/ui-sidebar/package.json b/packages/client/ui-sidebar/package.json index a6f1b9d4a3..3fb78453b2 100644 --- a/packages/client/ui-sidebar/package.json +++ b/packages/client/ui-sidebar/package.json @@ -43,15 +43,13 @@ "watch": "tsdown --watch" }, "license": "MIT", - "dependencies": { - "clsx": "^2.0.0" - }, "peerDependencies": { "@deepseek-ai/cordis": "workspace:^" }, "devDependencies": { "@deepseek-ai/dsh-api-workspace-controller": "workspace:^", "@deepseek-ai/dsh-client-locale": "workspace:^", + "@deepseek-ai/dsh-client-store": "workspace:^", "@deepseek-ai/dsh-client-test-runtime": "workspace:^", "@deepseek-ai/dsh-client-ui-layout": "workspace:^", "@deepseek-ai/dsh-client-ui-primitives": "workspace:^", @@ -61,7 +59,8 @@ "@deepseek-ai/dsh-client-ui-workspace": "workspace:^", "@types/react": "~18.3.1", "@deepseek-ai/cordis": "workspace:^", - "react": "^18.2.0" + "react": "^18.2.0", + "clsx": "^2.0.0" }, "files": [ "lib/index.js", diff --git a/packages/client/ui-sidebar/src/client/SidebarRoot.module.css b/packages/client/ui-sidebar/src/client/SidebarRoot.module.css index 4ea10606e1..f899b50c53 100644 --- a/packages/client/ui-sidebar/src/client/SidebarRoot.module.css +++ b/packages/client/ui-sidebar/src/client/SidebarRoot.module.css @@ -58,13 +58,14 @@ from { opacity: 0; } } -/* At the 150ms rail settle, the four upper controls enter from the former +/* At the 150ms rail settle, the upper controls enter from the former rail right edge over the remaining 150ms of the AppFrame track transition. The bottom-pinned settings seat shares their opacity timeline but stays horizontally fixed. Only a live collapse gets .railIn; a cold collapsed render stays static. */ .railIn .iconButton, .railIn .newSession, +.railIn .panelList, .railIn .regionArea { animation: rail-in 150ms var(--ds-ease-in-out) backwards; } @@ -296,6 +297,74 @@ max-width: 0; } +.panelList { + flex: none; + display: flex; + flex-direction: column; + gap: 4px; + margin-bottom: 8px; +} + +.panelRow { + display: flex; + align-items: center; + gap: 8px; + width: 100%; + min-height: 36px; + padding: 7px 8px; + box-sizing: border-box; + border: none; + border-radius: 8px; + background: transparent; + color: var(--dsw-alias-label-secondary); + font: inherit; + line-height: 22px; + text-align: left; + cursor: pointer; +} + +.panelRow:hover { + background: var(--dsw-alias-interactive-bg-hover); +} + +.panelRow.panelActive { + background: var(--dsw-alias-interactive-bg-active); + color: var(--dsw-alias-label-primary); + font-weight: 500; +} + +.panelRow:focus-visible { + outline: 2px solid var(--dsw-alias-label-primary); + outline-offset: -2px; +} + +.panelGlyph { + flex: none; + display: inline-flex; + align-items: center; + justify-content: center; +} + +.panelTitle { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.collapsed .panelList { + gap: 12px; + margin-bottom: 12px; +} + +.collapsed .panelRow { + width: 36px; + height: 36px; + justify-content: center; + padding: 0; + color: var(--dsw-alias-label-primary); +} + /* Region seat: always mounted so the foot never moves. Its trailing margin cancels the wide shell inset so the nested scrollbar can sit at the sidebar edge; the browser restores that inset inside its own rows. */ @@ -351,6 +420,7 @@ .fading > *, .railIn .iconButton, .railIn .newSession, + .railIn .panelList, .railIn .footArea, .railIn .regionArea { transition: none; diff --git a/packages/client/ui-sidebar/src/client/SidebarRoot.tsx b/packages/client/ui-sidebar/src/client/SidebarRoot.tsx index 9dde97bf9e..35fdee173b 100644 --- a/packages/client/ui-sidebar/src/client/SidebarRoot.tsx +++ b/packages/client/ui-sidebar/src/client/SidebarRoot.tsx @@ -1,12 +1,13 @@ /** - * Sidebar shell: column geometry only. Collapse is a slide plus crossfade: + * Sidebar shell: column geometry and global panel navigation. + * Collapse is a slide plus crossfade: * content freezes at its expanded width (inline style) and fades out in place * while the sliding column (AppFrame grid tracks) clips it — nothing reflows - * mid-slide. At settle the wide-only content unmounts and the four upper + * mid-slide. At settle the wide-only content unmounts and the upper * controls enter the 56px rail from the same horizontal offset (one icon each, * same top-down order) on one fade that ends with the slide. The bottom-pinned * settings control only fades. The workspace/session browsing region between - * the New Session button and the foot is the `sidebar.workspaces` registrant's, + * global panel rows and the foot is the `sidebar.workspaces` registrant's, * and the foot holds `sidebar.settings` plus `sidebar.footer.action`; the shell * hands them the wide flag (plus an expand request callback for the browser). * @@ -20,7 +21,10 @@ import clsx from 'clsx' import { FishLogo, IconNewChatOutline16, IconPanelLeftOutline16, Tooltip, } from '@deepseek-ai/dsh-client-ui-primitives' -import type { SidebarRootComponentProps } from './contract/slots.ts' +import type { InjectFace, PropsRenderSlots, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' +import type { + SidebarPanelMetadata, SidebarRootComponentProps, SidebarRootInjected, SidebarSectionOwnerProps, +} from './contract/slots.ts' import css from './SidebarRoot.module.css' /** Wide-content unmount delay; matches the 150ms wide-content fade-out. */ @@ -44,6 +48,38 @@ function localBuildVersion(): string | undefined { + (process.env.DSH_CLIENT_GIT_DIRTY === 'true' ? '-dirty' : '') } +type PanelRowProps = + Pick + & Pick + & Pick, 'usePanelInfo'> + & Pick, 'selectPanel'> + & PropsRenderSlots<'sidebar.panellist'> + +/** Each panel row subscribes only to its own selection state. */ +function PanelRow({ id, label, wide, usePanelInfo, selectPanel, renderSlot }: PanelRowProps) { + const active = usePanelInfo(info => info.activePanelId === id) + return ( + + + + ) +} + /** * Render the sidebar column shell. * @param props - composed slot props (runtime share + injected callbacks, contract/slots.ts). @@ -54,9 +90,13 @@ export function SidebarRoot({ width, startSession, toggleSidebar, + selectPanel, + usePanels, + usePanelInfo, t, renderSlot, }: SidebarRootComponentProps) { + const panels = usePanels(snapshot => snapshot) // Wide content stays mounted while the collapse animates (fading via // .collapsed .wide), unmounts at settle, and remounts right away on expand. const [settled, setSettled] = useState(collapsed) @@ -199,6 +239,22 @@ export function SidebarRoot({ + {panels.length > 0 && ( + + )} + {/* The browsing region fills the column between the controls and the foot in both states; its rail icon column rides the same slot. */}
diff --git a/packages/client/ui-sidebar/src/client/contract/slots.ts b/packages/client/ui-sidebar/src/client/contract/slots.ts index 4b4e2e1538..df2933430e 100644 --- a/packages/client/ui-sidebar/src/client/contract/slots.ts +++ b/packages/client/ui-sidebar/src/client/contract/slots.ts @@ -1,17 +1,16 @@ /** * Sidebar slot contract: the registrant-side props composition for the * layout-owned `sidebar` slot, plus the holes this shell declares. The shell - * owns column geometry (fold state machine, brand row, New Session); - * everything between the section header and the list bottom is the + * owns column geometry, the brand row, New Session, and global panel rows; + * everything between the workspace section header and the list bottom is the * `sidebar.workspaces` registrant's (ui-workspace), and the foot is the * `sidebar.settings` registrant's (ui-settings), followed by optional footer * actions in `sidebar.footer.action`. */ -import type { PropsLocale, PropsRenderSlots, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' +import type { InjectFace, PropsLocale, PropsRenderSlots, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' +import type { ObservableSnapshot } from '@deepseek-ai/dsh-client-store' import type { WorkspaceId } from '@deepseek-ai/dsh-api-workspace-controller/client' -// Type-only: pulls ui-layout's SlotMap merge (the 'sidebar' entry) into every -// program that sees this contract, so PropsRuntime<'sidebar'> resolves. -import type {} from '@deepseek-ai/dsh-client-ui-layout/client' +import type { MainPanelId } from '@deepseek-ai/dsh-client-ui-layout/client' declare module '@deepseek-ai/dsh-client-ui-slots' { interface SlotMap { @@ -26,6 +25,11 @@ declare module '@deepseek-ai/dsh-client-ui-slots' { * package's `sidebar` entry; the shell supplies a generic text fallback. */ 'sidebar.brand.name': { kind: 'single'; scope: 'root'; owner: SidebarBrandNameOwnerProps } + /** + * Global panel icons. Each list id addresses the matching main panel; + * the sidebar owns the button and resolves its label from list metadata. + */ + 'sidebar.panellist': { kind: 'list'; scope: 'root'; owner: SidebarPanelIconOwnerProps } /** * The workspace/session browsing region: section header, search, the * grouped/flat session list, and every workspace dialog. Declared by this @@ -59,6 +63,24 @@ export interface SidebarBrandNameOwnerProps { children?: never } +/** Icon presentation supplied by the global panel row. */ +export interface SidebarPanelIconOwnerProps { + /** Requested square edge in pixels. */ + size: number + /** Whether this panel is selected in the main column. */ + active: boolean +} + +/** Serializable metadata for one active global panel list registration. */ +export interface SidebarPanelMetadata { + /** List id and matching main panel key. */ + id: MainPanelId + /** Ascending row order; ties retain registration order. */ + order: number + /** Row title and accessible name: resolved label, or the id when omitted. */ + label: string +} + /** * Owner share of the browser hole — the only facts crossing the shell/region * boundary. Business data and actions arrive through the region's own inject. @@ -87,8 +109,7 @@ export interface SidebarFooterActionOwnerProps { /** * Registrant-private injected share (arrives via the register inject - * factory). The shell keeps only its own controls: starting a Session from - * the New Session button and toggling the column. + * factory). The renderer binds the panel metadata source to usePanels. */ export type SidebarRootInjected = { /** @@ -99,20 +120,25 @@ export type SidebarRootInjected = { startSession: (workspaceId?: WorkspaceId) => void /** Toggle the sidebar column through the layout service. */ toggleSidebar: () => void + /** Select the global panel addressed by a sidebar row. */ + selectPanel: (id: MainPanelId) => void + /** Private reactive sources bound to framework selector hooks. */ + hooks: { panels: ObservableSnapshot } } /** * Full component props: layout owner state/actions plus the declared holes' * render shares, this package's injected callbacks, and the standard locale - * seat. No store is registered. + * seat. Panel metadata arrives through an injected observable. */ export type SidebarRootComponentProps = PropsRuntime<'sidebar'> & PropsRenderSlots< | 'sidebar.brand.mark' | 'sidebar.brand.name' + | 'sidebar.panellist' | 'sidebar.workspaces' | 'sidebar.settings' | 'sidebar.footer.action' > - & SidebarRootInjected & PropsLocale<'sidebar'> + & InjectFace & PropsLocale<'sidebar'> diff --git a/packages/client/ui-sidebar/src/client/index.ts b/packages/client/ui-sidebar/src/client/index.ts index 2103f133e2..38c384f697 100644 --- a/packages/client/ui-sidebar/src/client/index.ts +++ b/packages/client/ui-sidebar/src/client/index.ts @@ -1,29 +1,33 @@ -/** Registers the sidebar shell into the layout-owned slot. */ +/** Registers the sidebar shell and global panel navigation. */ import type { Context as ClientContext } from '@deepseek-ai/cordis' +import { createSnapshotStore } from '@deepseek-ai/dsh-client-store' +import { resolveSlotLabel } from '@deepseek-ai/dsh-client-ui-slots' +import type { MainPanelId } from '@deepseek-ai/dsh-client-ui-layout/client' // Type-only: pulls the locale plugin's Context merge (ctx.locale). import type {} from '@deepseek-ai/dsh-client-locale/client' // Type-only: pulls the SlotRegistry service merge (ctx.slots). import type {} from '@deepseek-ai/dsh-client-ui-renderer/client' // Type-only: pulls the Session root standard-props merge. import type {} from '@deepseek-ai/dsh-client-ui-session/client' -import type { SidebarRootInjected } from './contract/slots.ts' +import type { SidebarPanelMetadata, SidebarRootInjected } from './contract/slots.ts' import { SidebarRoot } from './SidebarRoot.tsx' import { en, zh, type SidebarKey } from './locales.ts' export type { SidebarBrandMarkOwnerProps, SidebarBrandNameOwnerProps, SidebarFooterActionOwnerProps, + SidebarPanelIconOwnerProps, SidebarPanelMetadata, SidebarRootComponentProps, SidebarRootInjected, SidebarSectionOwnerProps, SidebarSettingsOwnerProps, } from './contract/slots.ts' export type { SidebarKey } from './locales.ts' declare module '@deepseek-ai/dsh-client-ui-slots' { interface LocaleNamespaceMap { - /** Sidebar shell controls copy. */ + /** Sidebar controls and global panel copy. */ sidebar: SidebarKey } } -/** Dictionary namespace owned by this plugin (shell controls copy). */ +/** Dictionary namespace owned by this plugin. */ const NS = 'sidebar' interface WorkspaceNavigation { @@ -39,29 +43,43 @@ export const inject = ['slots', 'layout', 'uiWorkspace', 'locale'] export function apply(ctx: ClientContext): void { const workspaceNavigation = ctx.get('uiWorkspace') as unknown as WorkspaceNavigation ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'ui-sidebar: dictionaries') + const panels = createSnapshotStore([]) + const syncPanels = (): void => { + const next = ctx.slots.entriesOfSlot('sidebar.panellist').map(({ options }) => { + // The list registration requires an id; StoredEntry erases the slot kind. + const id = options.id as MainPanelId + return { id, order: options.order ?? 0, label: resolveSlotLabel(options.label) ?? id } + }).sort((a, b) => a.order - b.order) + const previous = panels.getSnapshot() + if (previous.length === next.length && previous.every((panel, index) => { + const candidate = next[index] as SidebarPanelMetadata + return panel.id === candidate.id && panel.order === candidate.order && panel.label === candidate.label + })) return + panels.set(next) + } + ctx.effect(() => ctx.slots.subscribe('sidebar.panellist', syncPanels), 'ui-sidebar: panel entries') + ctx.effect(() => ctx.locale.subscribe(syncPanels), 'ui-sidebar: panel labels') const injectProps = (): SidebarRootInjected => ({ // The shell's New Session button rides the Workspace UI's shared action // (current Session Workspace, then recent Workspace). startSession: (workspaceId) => { workspaceNavigation.startSession(workspaceId) }, toggleSidebar: () => { ctx.layout.toggleSidebar() }, + selectPanel: (id) => { ctx.layout.selectPanel(id) }, + hooks: { panels }, }) - ctx.effect( - () => ctx.slots.register({ - name: 'sidebar', - locale: NS, - // The shell owns geometry; ui-workspace registers the whole browsing - // region (header, search, session list, workspace dialogs), ui-settings - // registers the foot trigger + settings panel. - children: { - 'sidebar.brand.mark': { kind: 'single', scope: 'root' }, - 'sidebar.brand.name': { kind: 'single', scope: 'root' }, - 'sidebar.workspaces': { kind: 'single', scope: 'root' }, - 'sidebar.settings': { kind: 'single', scope: 'root' }, - 'sidebar.footer.action': { kind: 'list', scope: 'root' }, - }, - inject: injectProps, - }, SidebarRoot), - 'ui-sidebar: slot registration', - ) + ctx.slots.inject('sidebar', () => ctx.slots.register({ + name: 'sidebar', + locale: NS, + children: { + 'sidebar.brand.mark': { kind: 'single', scope: 'root' }, + 'sidebar.brand.name': { kind: 'single', scope: 'root' }, + 'sidebar.panellist': { kind: 'list', scope: 'root' }, + 'sidebar.workspaces': { kind: 'single', scope: 'root' }, + 'sidebar.settings': { kind: 'single', scope: 'root' }, + 'sidebar.footer.action': { kind: 'list', scope: 'root' }, + }, + inject: injectProps, + }, SidebarRoot)) + syncPanels() } diff --git a/packages/client/ui-sidebar/src/client/locales.ts b/packages/client/ui-sidebar/src/client/locales.ts index 8cf5ac6d7b..bdd1486333 100644 --- a/packages/client/ui-sidebar/src/client/locales.ts +++ b/packages/client/ui-sidebar/src/client/locales.ts @@ -1,4 +1,4 @@ -/** `sidebar` namespace dictionaries: shell controls (brand row, New Session, fold toggle). */ +/** `sidebar` namespace dictionaries for shell controls and global panels. */ /** Simplified Chinese dictionary (the key-set source of truth). */ export const zh = { @@ -6,6 +6,7 @@ export const zh = { 'session.new.label': '新建会话', 'toggle.open': '打开侧边栏', 'toggle.collapse': '收起侧边栏', + 'panels.label': '全局面板', } satisfies Record /** The sidebar namespace key union. */ @@ -17,4 +18,5 @@ export const en = { 'session.new.label': 'New session', 'toggle.open': 'Open sidebar', 'toggle.collapse': 'Collapse sidebar', + 'panels.label': 'Global panels', } satisfies Record diff --git a/packages/client/ui-sidebar/tests/apply.client.spec.tsx b/packages/client/ui-sidebar/tests/apply.client.spec.tsx index e638e1fb9d..701de45e74 100644 --- a/packages/client/ui-sidebar/tests/apply.client.spec.tsx +++ b/packages/client/ui-sidebar/tests/apply.client.spec.tsx @@ -1,16 +1,36 @@ /** Sidebar shell slot registration and its Session/layout callbacks. */ -import { Context } from '@deepseek-ai/cordis' -import { describe, expect, it, vi } from 'vitest' +import { Context, type Fiber } from '@deepseek-ai/cordis' +import { afterEach, describe, expect, expectTypeOf, it, vi } from 'vitest' import { SlotRegistry } from '@deepseek-ai/dsh-client-ui-renderer/client' +import type { PropsRenderSlots } from '@deepseek-ai/dsh-client-ui-slots' import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client' import { apply, inject } from '@deepseek-ai/dsh-client-ui-sidebar/client' import type { SidebarRootInjected } from '@deepseek-ai/dsh-client-ui-sidebar/client' +import type { MainPanelId } from '@deepseek-ai/dsh-client-ui-layout/client' import { apply as hostApply } from '../src/index.ts' +const owners = new Set() +afterEach(async () => { + try { + for (const owner of owners) await owner.dispose() + } finally { + owners.clear() + } +}) + +function SidebarFrame({ renderSlot }: PropsRenderSlots<'sidebar'>) { + return renderSlot('sidebar', { collapsed: false, width: 300 }) +} + async function bench(declare = true) { - const ctx = new Context() + const root = new Context() + let ctx: Context | undefined + const owner = root.plugin((owned: Context) => { ctx = owned }) + owners.add(owner) + await owner.await() + if (ctx === undefined) throw new Error('the sidebar fixture owner did not activate') await ctx.plugin(SlotRegistry).await() - const layout = { toggleSidebar: vi.fn() } + const layout = { toggleSidebar: vi.fn(), selectPanel: vi.fn() } const uiWorkspace = { startSession: vi.fn() } ctx.provide('layout', layout) ctx.provide('uiWorkspace', uiWorkspace as never) @@ -18,8 +38,11 @@ async function bench(declare = true) { const slots = ctx.get('slots') as SlotRegistry if (declare) { slots.register( - { name: 'root', children: { 'sidebar': { kind: 'single', scope: 'root' } } } as never, - () => null, + { name: 'root', children: { + 'sidebar': { kind: 'single', scope: 'root' }, + 'main': { kind: 'keyed', scope: 'root' }, + } }, + SidebarFrame, ) } return { ctx, slots, layout, uiWorkspace } @@ -43,10 +66,13 @@ describe('ui-sidebar apply', () => { expect(b.slots.spec('sidebar.workspaces')).toEqual({ kind: 'single', scope: 'root' }) expect(b.slots.spec('sidebar.settings')).toEqual({ kind: 'single', scope: 'root' }) expect(b.slots.spec('sidebar.footer.action')).toEqual({ kind: 'list', scope: 'root' }) + expect(b.slots.spec('sidebar.panellist')).toEqual({ kind: 'list', scope: 'root' }) // Copy rides the standard locale seat, not the inject face. expect(b.slots.entries('sidebar')[0]!.locale).toBe('sidebar') const injected = (b.slots.entries('sidebar')[0]!.inject as () => SidebarRootInjected)() - expect(Object.keys(injected)).toEqual(['startSession', 'toggleSidebar']) + expect(Object.keys(injected)).toEqual(['startSession', 'toggleSidebar', 'selectPanel', 'hooks']) + expect(injected.hooks.panels.getSnapshot()).toEqual([]) + expect(b.slots.entries('main')).toEqual([]) // Both arms delegate to the Workspace UI's shared New Session action. injected.startSession('workspace' as never) expect(b.uiWorkspace.startSession).toHaveBeenCalledWith('workspace') @@ -54,11 +80,55 @@ describe('ui-sidebar apply', () => { expect(b.uiWorkspace.startSession).toHaveBeenLastCalledWith(undefined) injected.toggleSidebar() expect(b.layout.toggleSidebar).toHaveBeenCalledOnce() + const panelId = 'custom-panel' as MainPanelId + injected.selectPanel(panelId) + expect(b.layout.selectPanel).toHaveBeenCalledExactlyOnceWith(panelId) + expectTypeOf[0]>().toEqualTypeOf() }) - it('fails when no live owner declared the sidebar slot', async () => { + it('waits for the sidebar declaration before registering', async () => { const b = await bench(false) - await expect(b.ctx.plugin({ inject: [...inject], apply })).rejects.toThrow(/not declared/) + const fiber = b.ctx.plugin({ inject: [...inject], apply }) + await fiber.await() + expect(b.slots.entries('sidebar')).toHaveLength(0) + const disposeRoot = b.slots.register({ + name: 'root', + children: { sidebar: { kind: 'single', scope: 'root' } }, + }, SidebarFrame) + expect(b.slots.entries('sidebar')).toHaveLength(1) + disposeRoot() + await fiber.dispose() + }) + + it('projects plugin-owned panel entries and removes them with their owner', async () => { + const b = await bench() + const sidebar = b.ctx.plugin({ inject: [...inject], apply }) + await sidebar.await() + const injected = (b.slots.entries('sidebar')[0]!.inject as () => SidebarRootInjected)() + const panel = b.ctx.plugin({ + inject: ['slots'], + apply(ctx: Context) { + ctx.slots.register({ name: 'main', key: 'custom-panel' }, () => 'Custom panel') + ctx.slots.register({ + name: 'sidebar.panellist', id: 'custom-panel', order: 20, label: 'Custom panel', + }, () => null) + }, + }) + try { + await panel.await() + await vi.waitFor(() => { + expect(injected.hooks.panels.getSnapshot()).toEqual([ + { id: 'custom-panel', order: 20, label: 'Custom panel' }, + ]) + }) + expect(b.slots.entries('main').map(entry => entry.options.key)).toEqual(['custom-panel']) + await panel.dispose() + await vi.waitFor(() => { expect(injected.hooks.panels.getSnapshot()).toEqual([]) }) + expect(b.slots.entries('main')).toEqual([]) + } finally { + await panel.dispose() + await sidebar.dispose() + } }) it('removes the entry and child declaration on teardown', async () => { @@ -71,5 +141,7 @@ describe('ui-sidebar apply', () => { expect(b.slots.spec('sidebar.brand.name')).toBeUndefined() expect(b.slots.spec('sidebar.workspaces')).toBeUndefined() expect(b.slots.spec('sidebar.footer.action')).toBeUndefined() + expect(b.slots.spec('sidebar.panellist')).toBeUndefined() + expect(b.slots.entries('main')).toHaveLength(0) }) }) diff --git a/packages/client/ui-sidebar/tests/panel-list.client.spec.tsx b/packages/client/ui-sidebar/tests/panel-list.client.spec.tsx new file mode 100644 index 0000000000..41a01d53ef --- /dev/null +++ b/packages/client/ui-sidebar/tests/panel-list.client.spec.tsx @@ -0,0 +1,276 @@ +// @vitest-environment jsdom +/** Global panel rows and DOM focus through the production slot renderer. */ +import type { Context } from '@deepseek-ai/cordis' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { act, cleanup, fireEvent, waitFor, within } from '@testing-library/react' +import { SlotTestRuntime } from '@deepseek-ai/dsh-client-test-runtime' +import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client' +import { en as commonEn } from '@deepseek-ai/dsh-client-locale/src/locales/en.ts' +import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts' +import { IconGlobeOutline14 } from '@deepseek-ai/dsh-client-ui-primitives' +import type { ILayout, MainPanelId } from '@deepseek-ai/dsh-client-ui-layout/client' +import type { PropsRenderSlots, PropsRuntime, SlotLabel } from '@deepseek-ai/dsh-client-ui-slots' +import { apply, inject } from '../src/client/index.ts' + +declare module '@deepseek-ai/dsh-client-ui-slots' { + interface LocaleNamespaceMap { + 'sidebar-panel-test': 'alpha' + } +} + +const ALPHA = 'sidebar-test-alpha' as MainPanelId +const BETA = 'sidebar-test-beta' as MainPanelId +const runtimes = new Set() + +afterEach(async () => { + try { + for (const runtime of runtimes) await runtime.dispose() + } finally { + runtimes.clear() + cleanup() + } +}) + +async function bench(collapsed = false) { + const runtime = await SlotTestRuntime.create() + runtimes.add(runtime) + const locale = new LocaleRuntime(runtime.ctx) + locale.setLocale('en') + const layout = { + beginNavigation: vi.fn(() => new AbortController().signal), + toggleSidebar: vi.fn(), + selectPanel: vi.fn((activePanelId: MainPanelId | null) => { runtime.panelInfo.set({ activePanelId }) }), + openRightbar: vi.fn(), + closeRightbar: vi.fn(), + } satisfies ILayout + await runtime.mount({ + inject: ['slots'], + apply(ctx: Context) { + ctx.provide('layout', layout) + ctx.provide('uiWorkspace', { startSession: vi.fn() } as never) + ctx.provide('locale', locale) + ctx.effect(() => locale.register('common', { zh: commonZh, en: commonEn }), 'panel test: common locale') + ctx.effect(() => locale.register('sidebar-panel-test', { + zh: { alpha: '甲面板' }, en: { alpha: 'Alpha panel' }, + }), 'panel test: panel locale') + ctx.slots.installLocale(locale) + ctx.slots.inject('main', () => ctx.slots.register({ name: 'main', key: 'conversation' }, () => ( +

Conversation content

+ ))) + }, + }) + function Frame({ usePanelInfo, renderSlot }: PropsRuntime<'root'> & PropsRenderSlots<'sidebar' | 'main'>) { + const activePanelId = usePanelInfo(info => info.activePanelId) + return ( + <> + +
{renderSlot('main', {}, { entryKey: activePanelId ?? 'conversation' })}
+ + ) + } + await runtime.root.declare({ + sidebar: { kind: 'single', scope: 'root' }, + main: { kind: 'keyed', scope: 'root' }, + }, Frame) + const sidebar = await runtime.mount({ inject: [...inject], apply }) + const view = runtime.renderRoot() + return { runtime, locale, layout, sidebar, view } +} + +interface TestPanel { + id: MainPanelId + heading: string + order?: number + label?: SlotLabel +} + +async function mountPanel(runtime: SlotTestRuntime, { heading, ...metadata }: TestPanel) { + function Icon({ size, active }: PropsRuntime<'sidebar.panellist'>) { + return ( + + + + ) + } + function Body() { + return ( +
+

{heading}

+ + +
+ ) + } + return runtime.mount({ + inject: ['slots'], + apply(ctx: Context) { + ctx.slots.inject('main', () => ctx.slots.register({ name: 'main', key: metadata.id }, Body)) + ctx.slots.inject('sidebar.panellist', () => ctx.slots.register({ name: 'sidebar.panellist', ...metadata }, Icon)) + }, + }) +} + +async function mountPanels(runtime: SlotTestRuntime, locale: LocaleRuntime, betaOrder = 10) { + const t = locale.bind('sidebar-panel-test') + const alpha = await mountPanel(runtime, { id: ALPHA, heading: 'Alpha content', label: () => t('alpha'), order: 20 }) + const beta = await mountPanel(runtime, { id: BETA, heading: 'Beta content', label: 'Beta panel', order: betaOrder }) + return { alpha, beta } +} + +describe('sidebar global panels', () => { + it('adds late registrations, removes each plugin contribution, and leaves no empty panel-list DOM', async () => { + const { runtime, locale, view } = await bench() + expect(runtime.slots.entries('sidebar.panellist')).toEqual([]) + expect(view.queryByRole('navigation')).toBeNull() + expect(view.container.querySelector('[data-slot="sidebar.panellist"]')).toBeNull() + expect(view.getByText('Conversation content')).toBeTruthy() + + const { alpha, beta } = await mountPanels(runtime, locale) + const navigation = await view.findByRole('navigation', { name: 'Global panels' }) + expect(within(navigation).getAllByRole('button')).toHaveLength(2) + fireEvent.click(within(navigation).getByRole('button', { name: 'Alpha panel' })) + expect(view.getByRole('heading', { name: 'Alpha content' })).toBeTruthy() + + await alpha.dispose() + await waitFor(() => { + expect(view.queryByRole('button', { name: 'Alpha panel' })).toBeNull() + expect(view.queryByRole('heading', { name: 'Alpha content' })).toBeNull() + }) + expect(runtime.slots.entries('main').map(entry => entry.options.key)).toEqual(['conversation', BETA]) + fireEvent.click(within(navigation).getByRole('button', { name: 'Beta panel' })) + expect(view.getByRole('heading', { name: 'Beta content' })).toBeTruthy() + + await beta.dispose() + await waitFor(() => { expect(view.queryByRole('navigation')).toBeNull() }) + expect(view.queryByRole('heading', { name: 'Beta content' })).toBeNull() + expect(view.container.querySelector('[data-slot="sidebar.panellist"]')).toBeNull() + expect(runtime.slots.entries('sidebar.panellist')).toEqual([]) + expect(runtime.slots.entries('main').map(entry => entry.options.key)).toEqual(['conversation']) + }) + + it.each([false, true])('renders ordered rows, icon sizes, and tooltip labels with collapsed=%s', async (collapsed) => { + const { runtime, locale, view } = await bench(collapsed) + await mountPanels(runtime, locale) + const navigation = await view.findByRole('navigation', { name: 'Global panels' }) + const rows = within(navigation).getAllByRole('button') + expect(rows.map(row => row.getAttribute('aria-label'))).toEqual(['Beta panel', 'Alpha panel']) + for (const [id, label] of [[BETA, 'Beta panel'], [ALPHA, 'Alpha panel']] as const) { + const row = within(navigation).getByRole('button', { name: label }) + expect(row.getAttribute('aria-current')).toBeNull() + expect(row.textContent).toBe(collapsed ? '' : label) + expect(row.querySelectorAll('svg')).toHaveLength(1) + const icon = within(row).getByTestId(`${id}-icon`) + expect(icon.getAttribute('data-active')).toBe('false') + expect(icon.querySelector('svg')?.getAttribute('width')).toBe(collapsed ? '18' : '16') + expect(icon.querySelector('svg')?.getAttribute('height')).toBe(collapsed ? '18' : '16') + act(() => { row.focus() }) + expect(document.activeElement).toBe(row) + if (collapsed) expect(view.getByRole('tooltip').textContent).toBe(label) + else expect(view.queryByRole('tooltip')).toBeNull() + act(() => { row.blur() }) + expect(document.activeElement).not.toBe(row) + expect(view.queryByRole('tooltip')).toBeNull() + } + }) + + it('keeps registration order when panel rows have equal order', async () => { + const { runtime, locale, view } = await bench() + await mountPanels(runtime, locale, 20) + const navigation = await view.findByRole('navigation', { name: 'Global panels' }) + expect(within(navigation).getAllByRole('button').map(row => row.textContent)).toEqual(['Alpha panel', 'Beta panel']) + }) + + it('selects registered main content and keeps a repeated selection active', async () => { + const { runtime, locale, layout, view } = await bench() + await mountPanels(runtime, locale) + const navigation = await view.findByRole('navigation', { name: 'Global panels' }) + const alpha = within(navigation).getByRole('button', { name: 'Alpha panel' }) + const beta = within(navigation).getByRole('button', { name: 'Beta panel' }) + fireEvent.click(alpha) + expect(layout.selectPanel).toHaveBeenLastCalledWith(ALPHA) + expect(runtime.panelInfo.getSnapshot()).toEqual({ activePanelId: ALPHA }) + expect(alpha.getAttribute('aria-current')).toBe('page') + expect(beta.getAttribute('aria-current')).toBeNull() + expect(within(alpha).getByTestId(`${ALPHA}-icon`).getAttribute('data-active')).toBe('true') + expect(view.getByRole('heading', { name: 'Alpha content' })).toBeTruthy() + expect(view.queryByRole('heading', { name: 'Beta content' })).toBeNull() + + fireEvent.click(alpha) + expect(layout.selectPanel).toHaveBeenLastCalledWith(ALPHA) + expect(alpha.getAttribute('aria-current')).toBe('page') + expect(view.getByRole('heading', { name: 'Alpha content' })).toBeTruthy() + + fireEvent.click(beta) + expect(layout.selectPanel).toHaveBeenLastCalledWith(BETA) + expect(runtime.panelInfo.getSnapshot()).toEqual({ activePanelId: BETA }) + expect(alpha.getAttribute('aria-current')).toBeNull() + expect(beta.getAttribute('aria-current')).toBe('page') + expect(within(alpha).getByTestId(`${ALPHA}-icon`).getAttribute('data-active')).toBe('false') + expect(within(beta).getByTestId(`${BETA}-icon`).getAttribute('data-active')).toBe('true') + expect(view.queryByRole('heading', { name: 'Alpha content' })).toBeNull() + expect(view.getByRole('heading', { name: 'Beta content' })).toBeTruthy() + }) + + it('refreshes locale labels without re-registering icons or changing plain labels', async () => { + const { runtime, locale, view } = await bench() + await mountPanels(runtime, locale) + const navigation = await view.findByRole('navigation', { name: 'Global panels' }) + const entries = runtime.slots.entries('sidebar.panellist') + act(() => { locale.setLocale('zh') }) + await waitFor(() => { + expect(within(navigation).getByRole('button', { name: '甲面板' }).textContent).toBe('甲面板') + }) + expect(within(navigation).getByRole('button', { name: 'Beta panel' }).textContent).toBe('Beta panel') + expect(runtime.slots.entries('sidebar.panellist')).toBe(entries) + }) + + it('uses an omitted label and order as the panel id and order zero', async () => { + const { runtime, view } = await bench() + await mountPanel(runtime, { id: ALPHA, heading: 'Alpha content', label: 'Alpha panel', order: 20 }) + await mountPanel(runtime, { id: BETA, heading: 'Beta content' }) + const navigation = await view.findByRole('navigation', { name: 'Global panels' }) + expect(within(navigation).getAllByRole('button').map(row => row.textContent)).toEqual([BETA, 'Alpha panel']) + fireEvent.click(within(navigation).getByRole('button', { name: BETA })) + expect(view.getByRole('heading', { name: 'Beta content' })).toBeTruthy() + }) + + it.each([false, true])('keeps main selection while DOM focus moves between controls with collapsed=%s', async (collapsed) => { + const { runtime, locale, layout, view } = await bench(collapsed) + await mountPanels(runtime, locale) + const navigation = await view.findByRole('navigation', { name: 'Global panels' }) + const alpha = within(navigation).getByRole('button', { name: 'Alpha panel' }) + const beta = within(navigation).getByRole('button', { name: 'Beta panel' }) + act(() => { alpha.focus() }) + expect(document.activeElement).toBe(alpha) + expect(runtime.panelInfo.getSnapshot()).toEqual({ activePanelId: null }) + expect(alpha.getAttribute('aria-current')).toBeNull() + expect(layout.selectPanel).not.toHaveBeenCalled() + fireEvent.click(alpha) + const selected = runtime.panelInfo.getSnapshot() + const textbox = view.getByRole('textbox', { name: 'Alpha content input' }) + const action = view.getByRole('button', { name: 'Alpha content action' }) + for (const target of [textbox, action, beta, alpha]) { + act(() => { target.focus() }) + expect(document.activeElement).toBe(target) + expect(runtime.panelInfo.getSnapshot()).toBe(selected) + expect(alpha.getAttribute('aria-current')).toBe('page') + expect(beta.getAttribute('aria-current')).toBeNull() + expect(view.getByRole('heading', { name: 'Alpha content' })).toBeTruthy() + expect(view.queryByRole('heading', { name: 'Beta content' })).toBeNull() + } + expect(layout.selectPanel).toHaveBeenCalledExactlyOnceWith(ALPHA) + }) + + it('disposes the sidebar while retaining independently owned main panel bodies', async () => { + const { runtime, locale, sidebar, view } = await bench() + await mountPanels(runtime, locale) + fireEvent.click(await view.findByRole('button', { name: 'Alpha panel' })) + await sidebar.dispose() + expect(view.queryByRole('navigation')).toBeNull() + expect(runtime.slots.spec('sidebar.panellist')).toBeUndefined() + expect(runtime.slots.entries('sidebar.panellist')).toEqual([]) + expect(view.getByRole('heading', { name: 'Alpha content' })).toBeTruthy() + act(() => { locale.setLocale('zh') }) + expect(view.queryByRole('navigation')).toBeNull() + }) +}) diff --git a/packages/client/ui-sidebar/tests/pointer-scrollbars.client.spec.tsx b/packages/client/ui-sidebar/tests/pointer-scrollbars.client.spec.tsx index ed5b687e73..e010a3385f 100644 --- a/packages/client/ui-sidebar/tests/pointer-scrollbars.client.spec.tsx +++ b/packages/client/ui-sidebar/tests/pointer-scrollbars.client.spec.tsx @@ -14,6 +14,7 @@ import { en } from '../src/client/locales.ts' // Every fixture carries the resource hook the resources plugin merges into GlobalStandardProps. const useResource = (() => ({ status: 'none' as const, value: undefined, failure: undefined, reload: () => {} })) as GlobalStandardProps['useResource'] +const usePanelInfo: GlobalStandardProps['usePanelInfo'] = selector => selector({ activePanelId: null }) /** Pinned column box; the shell compares pointer coordinates against it. */ const COLUMN_WIDTH = 280 @@ -40,6 +41,7 @@ function mountColumn(): { column: HTMLElement; quiet: () => boolean } { {}} usePanels={selector => selector([])} useResource={useResource} useWorkspaces={neverHook} startSession={vi.fn()} toggleSidebar={vi.fn()} t={t} renderSlot={((_key: string, owner: SidebarSectionOwnerProps) => diff --git a/packages/client/ui-sidebar/tests/sidebar-root.client.spec.tsx b/packages/client/ui-sidebar/tests/sidebar-root.client.spec.tsx index 9973a1958b..c7f890303f 100644 --- a/packages/client/ui-sidebar/tests/sidebar-root.client.spec.tsx +++ b/packages/client/ui-sidebar/tests/sidebar-root.client.spec.tsx @@ -13,6 +13,7 @@ import { en as commonEn } from '@deepseek-ai/dsh-client-locale/src/locales/en.ts // Every fixture carries the resource hook the resources plugin merges into GlobalStandardProps. const useResource = (() => ({ status: 'none' as const, value: undefined, failure: undefined, reload: () => {} })) as GlobalStandardProps['useResource'] +const usePanelInfo: GlobalStandardProps['usePanelInfo'] = selector => selector({ activePanelId: null }) // English-dictionary translate stub: the shell renders the same copy the // assertions below query by accessible name. @@ -45,6 +46,7 @@ function mountShell({ collapsed = false, width = 300 }: { collapsed?: boolean; w {}} usePanels={selector => selector([])} useResource={useResource} useWorkspaces={neverHook} startSession={startSession} toggleSidebar={toggleSidebar} t={t} renderSlot={(( @@ -110,6 +112,7 @@ describe('SidebarRoot shell', () => { const { container } = render( {}} usePanels={selector => selector([])} useResource={useResource} useWorkspaces={neverHook} startSession={vi.fn()} toggleSidebar={vi.fn()} t={t} renderSlot={((_key: string, _owner: unknown, options?: { fallback?: ReactNode }) => @@ -129,6 +132,7 @@ describe('SidebarRoot shell', () => { render( {}} usePanels={selector => selector([])} useResource={useResource} useWorkspaces={neverHook} startSession={vi.fn()} toggleSidebar={vi.fn()} t={t} renderSlot={((_key: string, _owner: unknown, options?: { fallback?: ReactNode }) => @@ -143,6 +147,7 @@ describe('SidebarRoot shell', () => { render( {}} usePanels={selector => selector([])} useResource={useResource} useWorkspaces={neverHook} startSession={vi.fn()} toggleSidebar={vi.fn()} t={t} renderSlot={((_key: string, _owner: unknown, options?: { fallback?: ReactNode }) => diff --git a/packages/client/ui-sidebar/tsconfig.json b/packages/client/ui-sidebar/tsconfig.json index ed840c93a4..5d20bd9741 100644 --- a/packages/client/ui-sidebar/tsconfig.json +++ b/packages/client/ui-sidebar/tsconfig.json @@ -17,6 +17,9 @@ { "path": "../ui-slots" }, + { + "path": "../store" + }, { "path": "../ui-primitives" }, diff --git a/packages/client/ui-theme/package.json b/packages/client/ui-theme/package.json index b642fe86a4..5f9dd4940c 100644 --- a/packages/client/ui-theme/package.json +++ b/packages/client/ui-theme/package.json @@ -56,7 +56,8 @@ "@deepseek-ai/dsh-host-webserver": "workspace:^", "@types/react": "~18.3.1", "react": "^18.2.0", - "@deepseek-ai/dsh-settings": "workspace:^" + "@deepseek-ai/dsh-settings": "workspace:^", + "clsx": "^2.0.0" }, "files": [ "lib/index.js", @@ -69,7 +70,6 @@ "watch": "tsdown --watch" }, "dependencies": { - "clsx": "^2.0.0", "@deepseek-ai/schemastery": "workspace:^" } } diff --git a/packages/client/ui-theme/tests/appearance-row.client.spec.tsx b/packages/client/ui-theme/tests/appearance-row.client.spec.tsx index 4e6b61616e..5ad602ff05 100644 --- a/packages/client/ui-theme/tests/appearance-row.client.spec.tsx +++ b/packages/client/ui-theme/tests/appearance-row.client.spec.tsx @@ -13,6 +13,7 @@ import type { ThemePreference } from '../src/client/index.ts' // Every fixture carries the resource hook the resources plugin merges into GlobalStandardProps. const useResource = (() => ({ status: 'none' as const, value: undefined, failure: undefined, reload: () => {} })) as GlobalStandardProps['useResource'] +const usePanelInfo: GlobalStandardProps['usePanelInfo'] = selector => selector({ activePanelId: null }) afterEach(cleanup) @@ -47,7 +48,7 @@ function mount(preference: ThemePreference = 'system') { const props: AppearanceRowComponentProps = { useSessions: emptySessions(), useSessionPendingInteraction, - useResource, + usePanelInfo, useResource, useWorkspaces: emptyWorkspaces(), useStore: bindSnapshotSelector(store), actions: store.actions, diff --git a/packages/client/ui-theme/tests/font-size-row.client.spec.tsx b/packages/client/ui-theme/tests/font-size-row.client.spec.tsx index 3c4601e1f8..c917901cab 100644 --- a/packages/client/ui-theme/tests/font-size-row.client.spec.tsx +++ b/packages/client/ui-theme/tests/font-size-row.client.spec.tsx @@ -14,6 +14,7 @@ import { createFontSizeRowStore } from '../src/client/settings-store.ts' // Every fixture carries the resource hook the resources plugin merges into GlobalStandardProps. const useResource = (() => ({ status: 'none' as const, value: undefined, failure: undefined, reload: () => {} })) as GlobalStandardProps['useResource'] +const usePanelInfo: GlobalStandardProps['usePanelInfo'] = selector => selector({ activePanelId: null }) afterEach(cleanup) @@ -49,7 +50,7 @@ function mount(fontSize = 14) { const props: FontSizeRowComponentProps = { useSessions: emptySessions(), useSessionPendingInteraction, - useResource, + usePanelInfo, useResource, useWorkspaces: emptyWorkspaces(), useStore: bindSnapshotSelector(store), actions: store.actions, diff --git a/packages/client/ui-tool/package.json b/packages/client/ui-tool/package.json index 453d7867bb..3a3722d64d 100644 --- a/packages/client/ui-tool/package.json +++ b/packages/client/ui-tool/package.json @@ -41,9 +41,6 @@ "watch": "tsdown --watch" }, "license": "MIT", - "dependencies": { - "clsx": "^2.0.0" - }, "peerDependencies": { "@deepseek-ai/cordis": "workspace:^" }, @@ -73,7 +70,8 @@ "@deepseek-ai/dsh-client-ui-renderer": "workspace:^", "@deepseek-ai/dsh-client-ui-session": "workspace:^", "@deepseek-ai/dsh-util-workspace-path": "workspace:^", - "@deepseek-ai/dsh-attachment": "workspace:^" + "@deepseek-ai/dsh-attachment": "workspace:^", + "clsx": "^2.0.0" }, "files": [ "lib/index.js", diff --git a/packages/client/ui-tool/tests/assembly-surfaces.client.spec.tsx b/packages/client/ui-tool/tests/assembly-surfaces.client.spec.tsx index 636124555e..7959b9c60a 100644 --- a/packages/client/ui-tool/tests/assembly-surfaces.client.spec.tsx +++ b/packages/client/ui-tool/tests/assembly-surfaces.client.spec.tsx @@ -6,6 +6,7 @@ import { cleanup, fireEvent, waitFor } from '@testing-library/react' import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client' import type { ISession } from '@deepseek-ai/dsh-api-session-controller/client' import type { SessionId } from '@deepseek-ai/dsh-session/types' +import type { WorkspaceId } from '@deepseek-ai/dsh-workspace/types' import type { TodoItem } from '@deepseek-ai/dsh-client-ui-conversation/client' import { apply as applyChat, inject as injectChat, type ToolResultNode, @@ -59,13 +60,13 @@ const bashResult = (seq: number, callId: string, over?: Partial) }) /** Test-owned AppFrame role: declares and renders the resident conversation area. */ -type AppRootProps = PropsRenderSlots<'conversation'> +type AppRootProps = PropsRenderSlots<'main'> function AppRoot({ renderSlot }: AppRootProps) { - return <>{renderSlot('conversation', {})} + return <>{renderSlot('main', {}, { entryKey: 'conversation' })} } const LAYOUT_CHILDREN = { - 'conversation': { kind: 'single', scope: 'session-maybe' }, + 'main': { kind: 'keyed', scope: 'root' }, } as const async function bench(nodes: ToolResultNode[]) { @@ -79,7 +80,11 @@ async function bench(nodes: ToolResultNode[]) { runtime.ctx.provide('layout', { openDetails: vi.fn(), closeDetails: vi.fn() }) runtime.ctx.provide('sidebarRight', { openResource: vi.fn() } as never) runtime.ctx.provide('uiWorkspace', { - connectWorkspace: vi.fn(async () => SID), + openWorkspace: vi.fn(async (_workspaceId: WorkspaceId, beforeOpen: (id: SessionId) => void) => { + beforeOpen(SID) + runtime.sessions.open(SID) + }), + openSession: (id: SessionId) => { runtime.sessions.open(id) }, } as never) const locale = new LocaleRuntime(runtime.ctx) runtime.ctx.provide('locale', locale) diff --git a/packages/client/ui-tool/tests/toolview-slot.client.spec.tsx b/packages/client/ui-tool/tests/toolview-slot.client.spec.tsx index bb471caff9..67417ba4a3 100644 --- a/packages/client/ui-tool/tests/toolview-slot.client.spec.tsx +++ b/packages/client/ui-tool/tests/toolview-slot.client.spec.tsx @@ -4,6 +4,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { cleanup } from '@testing-library/react' import type { ISession } from '@deepseek-ai/dsh-api-session-controller/client' import type { SessionId } from '@deepseek-ai/dsh-session/types' +import type { WorkspaceId } from '@deepseek-ai/dsh-workspace/types' import { apply as applyChat, inject as injectChat, type ToolResultNode, } from '@deepseek-ai/dsh-client-ui-chat/client' @@ -42,13 +43,13 @@ const toolResult = (seq: number, callId: string, name: string, args = '{"command }) /** Test-owned AppFrame role: declares and renders the resident conversation area. */ -type AppRootProps = PropsRenderSlots<'conversation'> +type AppRootProps = PropsRenderSlots<'main'> function AppRoot({ renderSlot }: AppRootProps) { - return <>{renderSlot('conversation', {})} + return <>{renderSlot('main', {}, { entryKey: 'conversation' })} } const LAYOUT_CHILDREN = { - 'conversation': { kind: 'single', scope: 'session-maybe' }, + 'main': { kind: 'keyed', scope: 'root' }, } as const /** @@ -66,7 +67,11 @@ async function bench(nodes: ToolResultNode[]) { const sidebarRight = { openResource: vi.fn<(address: string) => void>() } runtime.ctx.provide('sidebarRight', sidebarRight as never) runtime.ctx.provide('uiWorkspace', { - connectWorkspace: vi.fn(async () => SID), + openWorkspace: vi.fn(async (_workspaceId: WorkspaceId, beforeOpen: (id: SessionId) => void) => { + beforeOpen(SID) + runtime.sessions.open(SID) + }), + openSession: (id: SessionId) => { runtime.sessions.open(id) }, } as never) const locale = new LocaleRuntime(runtime.ctx) runtime.ctx.provide('locale', locale) @@ -212,7 +217,11 @@ describe('registrant declaration injection', () => { runtime.ctx.provide('layout', { openDetails: vi.fn(), closeDetails: vi.fn() }) runtime.ctx.provide('sidebarRight', { openResource: vi.fn() } as never) runtime.ctx.provide('uiWorkspace', { - connectWorkspace: vi.fn(async () => SID), + openWorkspace: vi.fn(async (_workspaceId: WorkspaceId, beforeOpen: (id: SessionId) => void) => { + beforeOpen(SID) + runtime.sessions.open(SID) + }), + openSession: (id: SessionId) => { runtime.sessions.open(id) }, } as never) const locale = new LocaleRuntime(runtime.ctx) runtime.ctx.provide('locale', locale) diff --git a/packages/client/ui-trajectory/package.json b/packages/client/ui-trajectory/package.json index 53965b1cb7..7f9752a6a8 100644 --- a/packages/client/ui-trajectory/package.json +++ b/packages/client/ui-trajectory/package.json @@ -42,10 +42,6 @@ "watch": "tsdown --watch" }, "license": "MIT", - "dependencies": { - "@tanstack/react-virtual": "^3.14.9", - "diff": "^9.0.0" - }, "peerDependencies": { "@deepseek-ai/cordis": "workspace:^" }, @@ -70,7 +66,9 @@ "@deepseek-ai/dsh-client-ui-renderer": "workspace:^", "@deepseek-ai/dsh-client-ui-session": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", - "@deepseek-ai/dsh-attachment": "workspace:^" + "@deepseek-ai/dsh-attachment": "workspace:^", + "@tanstack/react-virtual": "^3.14.9", + "diff": "^9.0.0" }, "files": [ "lib/index.js", diff --git a/packages/client/ui-trajectory/tests/views.client.spec.tsx b/packages/client/ui-trajectory/tests/views.client.spec.tsx index 91ba0468f1..79a0e14b0e 100644 --- a/packages/client/ui-trajectory/tests/views.client.spec.tsx +++ b/packages/client/ui-trajectory/tests/views.client.spec.tsx @@ -55,6 +55,7 @@ import { t as tTrajectory, tZh } from './locale.client.ts' // Every session-scope fixture carries the resource hook the resources plugin merges into GlobalStandardProps. const useResource = (() => ({ status: 'none' as const, value: undefined, failure: undefined, reload: () => {} })) as GlobalStandardProps['useResource'] +const usePanelInfo: GlobalStandardProps['usePanelInfo'] = selector => selector({ activePanelId: null }) function TrajectoryTimeline( props: Omit, 't'>, @@ -224,7 +225,7 @@ function standaloneProps( sessionId: SID, useChat: bindSnapshotSelector(createSnapshotStore(EMPTY_CHAT_SNAPSHOT)), useSessions: emptySessions(), - useResource, + usePanelInfo, useResource, useSessionPendingInteraction: bindSnapshotSelector( createSnapshotStore(new Map()), ), @@ -351,7 +352,7 @@ function mount(fixture: Awaited>) { useConversation, useConversationViews, useSessions, - useResource, + usePanelInfo, useResource, useSessionPendingInteraction, useWorkspaces, useProjection, diff --git a/packages/client/ui-user-questions/package.json b/packages/client/ui-user-questions/package.json index 58b6a1c836..b32352c522 100644 --- a/packages/client/ui-user-questions/package.json +++ b/packages/client/ui-user-questions/package.json @@ -43,9 +43,6 @@ "watch": "tsdown --watch" }, "license": "MIT", - "dependencies": { - "clsx": "^2.0.0" - }, "peerDependencies": { "@deepseek-ai/cordis": "workspace:^" }, @@ -68,7 +65,8 @@ "react": "^18.2.0", "@deepseek-ai/dsh-client-ui-conversation": "workspace:^", "@deepseek-ai/dsh-client-connection": "workspace:^", - "@deepseek-ai/dsh-session": "workspace:^" + "@deepseek-ai/dsh-session": "workspace:^", + "clsx": "^2.0.0" }, "files": [ "lib/index.js", diff --git a/packages/client/ui-user-questions/tests/plan-review-panel.client.spec.tsx b/packages/client/ui-user-questions/tests/plan-review-panel.client.spec.tsx index f3cc685d4d..238cc195fd 100644 --- a/packages/client/ui-user-questions/tests/plan-review-panel.client.spec.tsx +++ b/packages/client/ui-user-questions/tests/plan-review-panel.client.spec.tsx @@ -14,6 +14,7 @@ import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts // Every session-scope fixture carries the resource hook the resources plugin merges into GlobalStandardProps. const useResource = (() => ({ status: 'none' as const, value: undefined, failure: undefined, reload: () => {} })) as GlobalStandardProps['useResource'] +const usePanelInfo: GlobalStandardProps['usePanelInfo'] = selector => selector({ activePanelId: null }) afterEach(cleanup) @@ -115,7 +116,7 @@ const kit: Omit = { pendingInteraction: undefined, useSession: selector => selector(sessionState), useSessions: selector => selector(sessionList), - useResource, + usePanelInfo, useResource, useSessionPendingInteraction: selector => selector(attentionState), useWorkspaces: selector => selector(workspaceState), useConversation: selector => selector(conversationState), diff --git a/packages/client/ui-user-questions/tests/user-questions-composer.client.spec.tsx b/packages/client/ui-user-questions/tests/user-questions-composer.client.spec.tsx index 80d7d9307c..90848b3e9e 100644 --- a/packages/client/ui-user-questions/tests/user-questions-composer.client.spec.tsx +++ b/packages/client/ui-user-questions/tests/user-questions-composer.client.spec.tsx @@ -13,6 +13,7 @@ import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts // Every session-scope fixture carries the resource hook the resources plugin merges into GlobalStandardProps. const useResource = (() => ({ status: 'none' as const, value: undefined, failure: undefined, reload: () => {} })) as GlobalStandardProps['useResource'] +const usePanelInfo: GlobalStandardProps['usePanelInfo'] = selector => selector({ activePanelId: null }) afterEach(cleanup) @@ -114,7 +115,7 @@ const kitBase: Omit = pendingInteraction: undefined, useSession: selector => selector(sessionState), useSessions: selector => selector(sessionList), - useResource, + usePanelInfo, useResource, useSessionPendingInteraction: selector => selector(attentionState), useWorkspaces: selector => selector(workspaceState), useConversation: selector => selector(conversationState), diff --git a/packages/client/ui-workflow-run/tests/workflow-run.client.spec.tsx b/packages/client/ui-workflow-run/tests/workflow-run.client.spec.tsx index e6b38074a5..4f99fb3b44 100644 --- a/packages/client/ui-workflow-run/tests/workflow-run.client.spec.tsx +++ b/packages/client/ui-workflow-run/tests/workflow-run.client.spec.tsx @@ -34,6 +34,7 @@ import type {} from '../src/client/index.ts' // Every session-scope fixture carries the resource hook the resources plugin merges into GlobalStandardProps. const useResource = (() => ({ status: 'none' as const, value: undefined, failure: undefined, reload: () => {} })) as GlobalStandardProps['useResource'] +const usePanelInfo: GlobalStandardProps['usePanelInfo'] = selector => selector({ activePanelId: null }) afterEach(cleanup) @@ -314,7 +315,7 @@ function panelProps(data: WorkflowRunChatData, sessions = listState(), openSessi node: node(data), sessionId: PARENT_ID, useSessions: selector => selector(sessions), - useResource, + usePanelInfo, useResource, useSessionPendingInteraction: selector => selector(panelAttention), useSession: selector => selector(panelSession), useProjection: () => undefined, diff --git a/packages/client/ui-workspace/README.i18n.yaml b/packages/client/ui-workspace/README.i18n.yaml index 26b70e0843..a79ee51c23 100644 --- a/packages/client/ui-workspace/README.i18n.yaml +++ b/packages/client/ui-workspace/README.i18n.yaml @@ -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: a819b07fecb74faeb3bebb6665aaabfdc8ecfba6 -README.zh.md: f9565b12bccd596fa61d1f1436f82b090139f506 +README.md: 857b38c2ca08ca4e9078d8f9dfcfe8462be50722 +README.zh.md: cf6f2d273f00add793cc5ac588db968c22007256 diff --git a/packages/client/ui-workspace/README.md b/packages/client/ui-workspace/README.md index a819b07fec..857b38c2ca 100644 --- a/packages/client/ui-workspace/README.md +++ b/packages/client/ui-workspace/README.md @@ -51,6 +51,8 @@ The value is intentionally best effort for cold Sessions. An identity-matching u ----- +`ctx.uiWorkspace.openSession(id)` selects the Session and returns the main area to the Conversation as one UI navigation action, including when that Session was already current. `openWorkspace(id, beforeOpen?)` and `forkSession(id)` open their result only if no later navigation has superseded the request; New Session uses `openWorkspace`. The optional synchronous preparation callback runs 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. 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. + ## Understand the implementation diff --git a/packages/client/ui-workspace/README.zh.md b/packages/client/ui-workspace/README.zh.md index f9565b12bc..cf6f2d273f 100644 --- a/packages/client/ui-workspace/README.zh.md +++ b/packages/client/ui-workspace/README.zh.md @@ -51,6 +51,8 @@ Session 行渲染运行时的实时 `pendingInteraction` 分类:审批显示** ----- +`ctx.uiWorkspace.openSession(id)` 将选中会话与中央区域返回会话界面作为一次 UI 导航操作,包括目标会话已经是当前会话的情况。`openWorkspace(id, beforeOpen?)` 和 `forkSession(id)` 仅在请求未被后续导航替代时打开结果;新会话使用 `openWorkspace`。可选的同步准备回调仅对仍有效的工作区请求执行,因此过期请求不会搬移 composer 草稿。后续导航或所有者释放会阻止晚到的 UI 提交,但不取消底层会话创建。选中失败时保留当前全局面板。会话行读取 `usePanelInfo`,在全局面板活跃时不显示会话选中样式;仅把焦点移到搜索框或目录选择器不会离开该面板。 + ## 理解实现 diff --git a/packages/client/ui-workspace/package.json b/packages/client/ui-workspace/package.json index 72876b6d9e..3a35cae4d4 100644 --- a/packages/client/ui-workspace/package.json +++ b/packages/client/ui-workspace/package.json @@ -34,6 +34,7 @@ "@deepseek-ai/dsh-client-connection", "@deepseek-ai/dsh-client-locale", "@deepseek-ai/dsh-client-ui-conversation", + "@deepseek-ai/dsh-client-ui-layout", "@deepseek-ai/dsh-client-ui-renderer", "@deepseek-ai/dsh-client-ui-session", "@deepseek-ai/dsh-client-ui-sidebar" @@ -46,9 +47,6 @@ "watch": "tsdown --watch" }, "license": "MIT", - "dependencies": { - "clsx": "^2.0.0" - }, "peerDependencies": { "@deepseek-ai/cordis": "workspace:^" }, @@ -61,6 +59,7 @@ "@deepseek-ai/dsh-client-store": "workspace:^", "@deepseek-ai/dsh-client-test-runtime": "workspace:^", "@deepseek-ai/dsh-client-ui-conversation": "workspace:^", + "@deepseek-ai/dsh-client-ui-layout": "workspace:^", "@deepseek-ai/dsh-client-ui-primitives": "workspace:^", "@deepseek-ai/dsh-client-ui-renderer": "workspace:^", "@deepseek-ai/dsh-client-ui-session": "workspace:^", @@ -72,7 +71,8 @@ "@deepseek-ai/dsh-util-workspace-path": "workspace:^", "@types/react": "~18.3.1", "@deepseek-ai/cordis": "workspace:^", - "react": "^18.2.0" + "react": "^18.2.0", + "clsx": "^2.0.0" }, "files": [ "lib/index.js", diff --git a/packages/client/ui-workspace/src/client/index.ts b/packages/client/ui-workspace/src/client/index.ts index 7c8e1108dc..9204fe7774 100644 --- a/packages/client/ui-workspace/src/client/index.ts +++ b/packages/client/ui-workspace/src/client/index.ts @@ -20,6 +20,7 @@ import type {} from '@deepseek-ai/dsh-api-workspace-controller/client' import type {} from '@deepseek-ai/dsh-client-locale/client' // Type-only: pulls the SlotRegistry service merge (ctx.slots). import type {} from '@deepseek-ai/dsh-client-ui-renderer/client' +import type {} from '@deepseek-ai/dsh-client-ui-layout/client' // Type-only: pulls the Session root standard-hook merge. import type {} from '@deepseek-ai/dsh-client-ui-session/client' import type { WorkspaceBrowserInjected, WorkspacePickerInjected } from './contract/slots.ts' @@ -60,7 +61,7 @@ const NS = 'workspace' * declaration through `slots.inject()` instead of assuming order. */ export const inject = [ - 'slots', 'sessions', 'workspaces', 'locale', 'remote', 'remote.directoryPicker', + 'slots', 'sessions', 'workspaces', 'locale', 'remote', 'remote.directoryPicker', 'layout', ] /** @@ -95,11 +96,14 @@ export function apply(ctx: Context): void { subscribe: listener => ctx.on('connection/reset', listener), } const pickerFlowSource = flowSource('conversation.hero.workspace.directoryFlow') + const openSession: WorkspaceBrowserInjected['open'] = (sessionId) => { + uiWorkspace.openSession(sessionId) + } const browserInjected = (): WorkspaceBrowserInjected => ({ // Explicit group actions keep their target; unscoped New Session inherits // the current Session Workspace before the recent-Workspace fallback. startSession: (workspaceId) => { uiWorkspace.startSession(workspaceId) }, - open: (sessionId) => { sessions.open(sessionId) }, + open: openSession, searchSessions, searchResultLimit: sessions.searchResultLimit, renameSession: async (sessionId, title) => { @@ -111,8 +115,7 @@ export function apply(ctx: Context): void { if (!result.ok) throw new Error(result.error.message) }, forkSession: (sessionId) => { - sessions.fork({ sessionId, increaseTitle: true }) - .then((childId) => { sessions.open(childId) }) + uiWorkspace.forkSession(sessionId) .catch(() => { // Fork or child-rename failure keeps the current selection. }) diff --git a/packages/client/ui-workspace/src/client/navigation.ts b/packages/client/ui-workspace/src/client/navigation.ts index a460afe23a..c968595d8a 100644 --- a/packages/client/ui-workspace/src/client/navigation.ts +++ b/packages/client/ui-workspace/src/client/navigation.ts @@ -10,9 +10,28 @@ import type { IWorkspaces, WorkspaceId, WorkspaceView, } from '@deepseek-ai/dsh-api-workspace-controller/client' import type { SessionId } from '@deepseek-ai/dsh-session/types' +import type {} from '@deepseek-ai/dsh-client-ui-layout/client' /** Workspace archive and directory operations consumed by Client UI domains. */ export interface UiWorkspace { + /** + * Select a Session and show its Conversation as one UI navigation action. + * @param sessionId - listed or retained Session to display. + */ + openSession(sessionId: SessionId): void + /** + * Connect a Workspace and open its Session unless a later navigation supersedes it. + * @param workspaceId - target Workspace. + * @param beforeOpen - optional synchronous preparation for the selected Session, skipped after supersession. + * @returns completion; a superseded request may create a Session but does not open it. + */ + openWorkspace(workspaceId: WorkspaceId, beforeOpen?: (sessionId: SessionId) => void): Promise + /** + * Fork a Session and open the child unless a later navigation supersedes it. + * @param sessionId - source Session. + * @returns completion; a superseded request leaves its child available without selecting it. + */ + forkSession(sessionId: SessionId): Promise /** * Resolve the reusable or newly created blank Session for a Workspace. * @param workspaceId - target Workspace. @@ -70,6 +89,7 @@ export class DirectoryBrowseError extends Error { /** Implements Workspace archive and directory UI operations. */ class UiWorkspaceService extends Service implements UiWorkspace { private readonly connecting = new Map>() + private readonly lifetime = new AbortController() /** * @param ctx - Client root Context. @@ -111,6 +131,26 @@ class UiWorkspaceService extends Service implements UiWorkspace { return attempt } + openSession(sessionId: SessionId): void { + this.sessions.open(sessionId) + this.ctx.layout.selectPanel(null) + } + + async openWorkspace(workspaceId: WorkspaceId, beforeOpen?: (sessionId: SessionId) => void): Promise { + const navigation = AbortSignal.any([this.ctx.layout.beginNavigation(), this.lifetime.signal]) + const isCurrent = (): boolean => !navigation.aborted + const sessionId = await this.connectWorkspace(workspaceId) + if (!isCurrent()) return + beforeOpen?.(sessionId) + if (isCurrent()) this.openSession(sessionId) + } + + async forkSession(sessionId: SessionId): Promise { + const navigation = AbortSignal.any([this.ctx.layout.beginNavigation(), this.lifetime.signal]) + const childId = await this.sessions.fork({ sessionId, increaseTitle: true }) + if (!navigation.aborted) this.openSession(childId) + } + startSession(workspaceId?: WorkspaceId): void { const workspace = this.workspaces.list.getSnapshot() const sessions = this.sessions.list.getSnapshot() @@ -124,10 +164,10 @@ class UiWorkspaceService extends Service implements UiWorkspace { const target = workspaceId ?? currentWorkspaceId ?? recent if (target === undefined) { this.sessions.clear() + this.ctx.layout.selectPanel(null) return } - void this.connectWorkspace(target).then( - (sessionId) => { this.sessions.open(sessionId) }, + void this.openWorkspace(target).catch( (reason: unknown) => { console.warn('new session failed:', reason) }, ) } @@ -156,9 +196,8 @@ class UiWorkspaceService extends Service implements UiWorkspace { private watchNavigation(): () => void { let initial: 'waiting' | 'connecting' | 'done' = 'waiting' - let disposed = false const reconcile = (): void => { - if (disposed) return + if (this.lifetime.signal.aborted) return if (this.clearArchivedCurrent()) return if (initial !== 'waiting') return const workspace = this.workspaces.list.getSnapshot() @@ -176,14 +215,14 @@ class UiWorkspaceService extends Service implements UiWorkspace { initial = 'connecting' void this.connectWorkspace(target).then( (sessionId) => { - if (disposed) return + if (this.lifetime.signal.aborted) return if (this.sessions.list.getSnapshot().current === undefined) { this.sessions.open(sessionId) } initial = 'done' }, (reason: unknown) => { - if (disposed) return + if (this.lifetime.signal.aborted) return initial = 'waiting' console.warn('initial workspace selection failed:', reason) }, @@ -193,7 +232,7 @@ class UiWorkspaceService extends Service implements UiWorkspace { const disposeSessions = this.sessions.list.subscribe(reconcile) reconcile() return () => { - disposed = true + this.lifetime.abort() disposeSessions() disposeWorkspaces() } diff --git a/packages/client/ui-workspace/src/client/rows/WorkspaceBrowser.tsx b/packages/client/ui-workspace/src/client/rows/WorkspaceBrowser.tsx index 0ef21ddb82..585e9c8766 100644 --- a/packages/client/ui-workspace/src/client/rows/WorkspaceBrowser.tsx +++ b/packages/client/ui-workspace/src/client/rows/WorkspaceBrowser.tsx @@ -235,7 +235,7 @@ function workspaceGroupHalf(e: { clientY: number; currentTarget: HTMLElement }): type SessionTreeProps = Pick< WorkspaceBrowserProps, 'useSessions' | 'useSessionPendingInteraction' | 'startSession' | 'open' | 'forkSession' - | 'insertWorkspaceBefore' | 'insertSessionBefore' | 't' + | 'insertWorkspaceBefore' | 'insertSessionBefore' | 't' | 'usePanelInfo' > & { /** Host account home for POSIX hover-path abbreviation. */ home?: string | undefined @@ -275,16 +275,17 @@ type SessionTreeProps = Pick< /** The scrolling session tree; unmounting drops the sessions subscription and expand-all state. */ function SessionTree({ useSessions, useSessionPendingInteraction, startSession, open, forkSession, workspaces, archivedSessionIds, - workspaceReady, + workspaceReady, usePanelInfo, onRenameRequest, onDeleteRequest, onSessionRename, onSessionArchive, insertWorkspaceBefore, insertSessionBefore, orderBy, groupExpansion, setGroupExpanded, sessionOrderByAccount, sessionUpdatedAtByAccount, syncSessionOrderAccount, setSessionOrder, home, t, revealSessionId, onSessionRevealed, }: SessionTreeProps) { + const panelActive = usePanelInfo(info => info.activePanelId !== null) const list = useSessions(s => s) const pendingInteractions = useSessionPendingInteraction(s => s) - const current = list.current + const current = panelActive ? undefined : list.current const revealGroup = revealSessionId === undefined || !workspaceReady ? undefined : owningGroupKey(workspaces, revealSessionId) @@ -619,7 +620,7 @@ function SessionTree({ /** The flat "In one list" body: every session is one draggable top-level row. */ function FlatList({ useSessions, useSessionPendingInteraction, open, forkSession, onSessionRename, onSessionArchive, - archivedSessionIds, + archivedSessionIds, usePanelInfo, orderBy, sessionOrderByAccount, sessionUpdatedAtByAccount, syncSessionOrderAccount, setSessionOrder, revealSessionId, onSessionRevealed, t, }: Pick< @@ -631,6 +632,7 @@ function FlatList({ | 'onSessionRename' | 'onSessionArchive' | 'archivedSessionIds' + | 'usePanelInfo' | 'orderBy' | 'sessionOrderByAccount' | 'sessionUpdatedAtByAccount' @@ -640,6 +642,7 @@ function FlatList({ | 'onSessionRevealed' | 't' >) { + const panelActive = usePanelInfo(info => info.activePanelId !== null) const list = useSessions(s => s) const pendingInteractions = useSessionPendingInteraction(s => s) const baseRows = useMemo( @@ -706,7 +709,7 @@ function FlatList({ & { +}: Pick & { workspaces: readonly WorkspaceView[] archivedSessionIds: readonly SessionNode['id'][] query: string remote: RemoteSearchState resultLimit: number }) { + const panelActive = usePanelInfo(info => info.activePanelId !== null) const list = useSessions(s => s) const pendingInteractions = useSessionPendingInteraction(s => s) const currentRemote = remote.query === query @@ -798,7 +803,7 @@ function SearchResults({ @@ -833,6 +838,7 @@ function SearchResults({ */ export function WorkspaceBrowser({ wide, + usePanelInfo, expandSidebar, useSessions, useSessionPendingInteraction, @@ -1251,6 +1257,7 @@ export function WorkspaceBrowser({ {wide && (normalizedQuery !== '' ? ( ({})) const open = vi.fn() const clear = vi.fn() + const selectPanel = vi.fn() + ctx.provide('layout', { selectPanel, beginNavigation: () => new AbortController().signal }) const search = vi.fn(async () => ({ ok: true as const, value: { items: [{ sessionId: 'session' as never, snippet: 'match' }], hasMore: false }, @@ -71,7 +73,7 @@ async function bench() { ctx.provide('locale', locale) return { ctx, slots: ctx.get('slots') as SlotRegistry, locale, create, rename, - insertSessionBefore, open, clear, search, renameSession, binding, fork, pickDirectory, + insertSessionBefore, open, clear, selectPanel, search, renameSession, binding, fork, pickDirectory, } } @@ -90,7 +92,7 @@ describe('ui-workspace apply', () => { it('declares the services it drives', () => { expect(inject).toEqual([ - 'slots', 'sessions', 'workspaces', 'locale', 'remote', 'remote.directoryPicker', + 'slots', 'sessions', 'workspaces', 'locale', 'remote', 'remote.directoryPicker', 'layout', ]) }) diff --git a/packages/client/ui-workspace/tests/host-home-staleness.client.spec.tsx b/packages/client/ui-workspace/tests/host-home-staleness.client.spec.tsx index 1b3e256c97..0dd5eecea9 100644 --- a/packages/client/ui-workspace/tests/host-home-staleness.client.spec.tsx +++ b/packages/client/ui-workspace/tests/host-home-staleness.client.spec.tsx @@ -28,6 +28,7 @@ function SidebarFrame({ renderSlot }: FrameProps) { /** The assembled sidebar over one Workspace inside the POSIX home the Host reports. */ async function bench() { const runtime = await SlotTestRuntime.create() + runtime.ctx.provide('layout', { selectPanel: vi.fn() }) runtime.releaseWorkspaceSource() const directoryPicker = {} const remote = new TestRemote(runtime.ctx) diff --git a/packages/client/ui-workspace/tests/rename-assembly.client.spec.tsx b/packages/client/ui-workspace/tests/rename-assembly.client.spec.tsx index d57a89ba8b..b814e39803 100644 --- a/packages/client/ui-workspace/tests/rename-assembly.client.spec.tsx +++ b/packages/client/ui-workspace/tests/rename-assembly.client.spec.tsx @@ -33,6 +33,7 @@ beforeEach(() => { localStorage.clear() }) /** Runtime with the locale face installed (the browser entry declares `locale:` — zh default backs the t seat). */ async function createRuntime(): Promise { const runtime = await SlotTestRuntime.create() + runtime.ctx.provide('layout', { selectPanel: vi.fn() }) runtime.releaseWorkspaceSource() // The rename flow never picks a directory; the namespace only has to be there // for ui-workspace's inject to settle. diff --git a/packages/client/ui-workspace/tests/workspace-browser.client.spec.tsx b/packages/client/ui-workspace/tests/workspace-browser.client.spec.tsx index 6958423e5f..b25b32e166 100644 --- a/packages/client/ui-workspace/tests/workspace-browser.client.spec.tsx +++ b/packages/client/ui-workspace/tests/workspace-browser.client.spec.tsx @@ -9,6 +9,7 @@ import type { } from '@deepseek-ai/dsh-api-workspace-controller/client' import type { SessionPendingInteractionSnapshot } from '@deepseek-ai/dsh-client-ui-session/client' import type { SessionId } from '@deepseek-ai/dsh-session/types' +import type { MainPanelId } from '@deepseek-ai/dsh-client-ui-layout/client' import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts' import type { WorkspaceBrowserProps } from '../src/client/contract/slots.ts' import { createWorkspaceViewStore, FLAT_SESSION_ORDER_KEY } from '../src/client/stores.ts' @@ -18,6 +19,7 @@ import { zh } from '../src/client/locales.ts' // Every fixture carries the resource hook the resources plugin merges into GlobalStandardProps. const useResource = (() => ({ status: 'none' as const, value: undefined, failure: undefined, reload: () => {} })) as GlobalStandardProps['useResource'] +const usePanelInfo: GlobalStandardProps['usePanelInfo'] = selector => selector({ activePanelId: null }) afterEach(cleanup) const scrollIntoView = vi.fn() @@ -78,7 +80,7 @@ function mount(overrides: Partial = {}) { expandSidebar: vi.fn(), useSessions: hook(sessionState([])), useSessionPendingInteraction: hook(noPendingInteraction), - useResource, + usePanelInfo, useResource, useWorkspaces: hook(workspaceState([])), useStore: bindSnapshotSelector(store), actions: store.actions, @@ -111,6 +113,30 @@ function rerender(b: ReturnType, overrides: Partial { + it('moves focus into Workspace controls without selecting a Session while a main panel is active', () => { + const panelInfo = { activePanelId: 'panel-a' as MainPanelId } + const b = mount({ + usePanelInfo: hook(panelInfo), + useSessions: hook(sessionState([summary('current', 1)], { current: sid('current') })), + useWorkspaces: hook(workspaceState([workspace('alpha', ['current'])])), + }) + fireEvent.click(screen.getByText('alpha')) + const current = screen.getByText('current').closest('[role="treeitem"]') + expect(current?.getAttribute('aria-selected')).toBe('false') + fireEvent.click(screen.getByRole('button', { name: '搜索会话' })) + const input = screen.getByPlaceholderText('搜索会话…') + expect(document.activeElement).toBe(input) + fireEvent.click(screen.getByRole('button', { name: '清除搜索' })) + const add = screen.getByRole('button', { name: '添加工作区' }) + add.focus() + fireEvent.click(add) + expect(document.activeElement).toBe(add) + expect(screen.getByTestId('directory-flow')).toBeTruthy() + expect(panelInfo.activePanelId).toBe('panel-a') + expect(b.props.open).not.toHaveBeenCalled() + expect(b.props.startSession).not.toHaveBeenCalled() + }) + it('workspace hover card shows a POSIX home descendant as ~', () => { vi.useFakeTimers() try { diff --git a/packages/client/ui-workspace/tests/workspace-picker.client.spec.tsx b/packages/client/ui-workspace/tests/workspace-picker.client.spec.tsx index b2ac4a6d1a..3783a1d16a 100644 --- a/packages/client/ui-workspace/tests/workspace-picker.client.spec.tsx +++ b/packages/client/ui-workspace/tests/workspace-picker.client.spec.tsx @@ -16,6 +16,7 @@ import { zh } from '../src/client/locales.ts' // Every fixture carries the resource hook the resources plugin merges into GlobalStandardProps. const useResource = (() => ({ status: 'none' as const, value: undefined, failure: undefined, reload: () => {} })) as GlobalStandardProps['useResource'] +const usePanelInfo: GlobalStandardProps['usePanelInfo'] = selector => selector({ activePanelId: null }) afterEach(cleanup) @@ -98,7 +99,7 @@ function mount( anchorRef={anchorRef} useSessions={hook(sessions)} useSessionPendingInteraction={hook(noPendingInteraction)} - useResource={useResource} + usePanelInfo={usePanelInfo} useResource={useResource} useWorkspaces={hook(workspaceState(nextItems))} onPick={onPick} onClose={onClose} @@ -220,7 +221,7 @@ describe('WorkspacePicker', () => { , @@ -237,7 +238,7 @@ describe('WorkspacePicker', () => { , diff --git a/packages/client/ui-workspace/tests/workspaces-service.client.spec.ts b/packages/client/ui-workspace/tests/workspaces-service.client.spec.ts index 393619bbb7..52f106f0f0 100644 --- a/packages/client/ui-workspace/tests/workspaces-service.client.spec.ts +++ b/packages/client/ui-workspace/tests/workspaces-service.client.spec.ts @@ -10,6 +10,8 @@ import type { ClientRemote, DirectoryListing } from '@deepseek-ai/dsh-api-remote import { RemoteError } from '@deepseek-ai/dsh-client-test-runtime' import type { RemoteResult } from '@deepseek-ai/dsh-api-remotes/client' import { SessionId } from '@deepseek-ai/dsh-session/types' +import { LayoutController } from '@deepseek-ai/dsh-client-ui-layout/client' +import type { MainPanelId } from '@deepseek-ai/dsh-client-ui-layout/client' import { DirectoryBrowseError, UiWorkspaceService } from '../src/client/navigation.ts' const sid = (id: string): SessionId => SessionId(id) @@ -108,6 +110,7 @@ class FakeSessions { readonly create: ReturnType> readonly open: ReturnType void>> readonly clear: ReturnType void>> + readonly fork = vi.fn(async () => sid('forked')) constructor(initial: SessionListState) { this.list = new MutableSource(initial) @@ -189,6 +192,14 @@ interface BenchOptions { function bench(options: BenchOptions = {}) { const ctx = new Context() + const layout = new LayoutController({ + selectPanel: vi.fn(), retainMainPanels: vi.fn(), + setSidebar: vi.fn(), toggleSidebar: vi.fn(), setViewportWidth: vi.fn(), + setRightbar: vi.fn(), openRightbar: vi.fn(), closeRightbar: vi.fn(), + }, () => true) + const selectPanel = vi.spyOn(layout, 'selectPanel') + ctx.provide('layout', layout) + ctx.effect(() => () => { layout.dispose() }) const directoryPicker = new FakeDirectoryPicker() const workspaces = new FakeWorkspaces(options.workspaces ?? workspaceState([], [], 'pending')) const sessions = new FakeSessions(options.sessions ?? sessionState([], undefined, 'pending')) @@ -198,7 +209,7 @@ function bench(options: BenchOptions = {}) { workspaces, sessions as unknown as ISessions, ) - return { ctx, directoryPicker, sessions, uiWorkspace, workspaces } + return { ctx, directoryPicker, sessions, uiWorkspace, workspaces, layout, selectPanel } } async function flush(): Promise { @@ -207,6 +218,126 @@ async function flush(): Promise { } describe('UiWorkspaceService', () => { + it('selects a Session before revealing its Conversation, including the current Session', () => { + const current = sid('current') + const b = bench({ sessions: sessionState([summary('current')], current) }) + b.selectPanel.mockImplementation(() => { + expect(b.sessions.list.getSnapshot().current).toBe(current) + }) + b.uiWorkspace.openSession(current) + expect(b.sessions.open).toHaveBeenCalledWith(current) + expect(b.selectPanel).toHaveBeenCalledWith(null) + expect(b.sessions.open.mock.invocationCallOrder[0]).toBeLessThan(b.selectPanel.mock.invocationCallOrder[0]!) + }) + + it('keeps the current panel when selecting a Session throws', () => { + const b = bench() + b.sessions.open.mockImplementationOnce(() => { throw new Error('selection failed') }) + expect(() => { b.uiWorkspace.openSession(sid('target')) }).toThrow('selection failed') + expect(b.selectPanel).not.toHaveBeenCalled() + }) + + it('leaves a later panel selection in place when New Session finishes', async () => { + const b = bench({ + sessions: sessionState([summary('current')], sid('current')), + workspaces: workspaceState([workspace('alpha')]), + }) + const created = Promise.withResolvers() + b.sessions.create.mockReturnValue(created.promise) + const opening = vi.spyOn(b.uiWorkspace, 'openWorkspace') + b.uiWorkspace.startSession(wid('alpha')) + b.layout.selectPanel('panel-a' as MainPanelId) + created.resolve(sid('late')) + await opening.mock.results[0]!.value + expect(b.sessions.open).not.toHaveBeenCalled() + expect(b.selectPanel).toHaveBeenCalledExactlyOnceWith('panel-a') + expect(b.sessions.list.getSnapshot().current).toBe(sid('current')) + }) + + it('opens only the latest Workspace request when creation completes out of order', async () => { + const b = bench({ + sessions: sessionState([summary('current')], sid('current')), + workspaces: workspaceState([workspace('alpha'), workspace('beta')]), + }) + const older = Promise.withResolvers() + const newer = Promise.withResolvers() + b.sessions.create.mockImplementation(options => options?.workspaceId === wid('alpha') ? older.promise : newer.promise) + const oldDraft = vi.fn() + const newDraft = vi.fn() + const first = b.uiWorkspace.openWorkspace(wid('alpha'), oldDraft) + const second = b.uiWorkspace.openWorkspace(wid('beta'), newDraft) + newer.resolve(sid('newer')) + await second + older.resolve(sid('older')) + await first + expect(oldDraft).not.toHaveBeenCalled() + expect(newDraft).toHaveBeenCalledExactlyOnceWith(sid('newer')) + expect(b.sessions.open).toHaveBeenCalledExactlyOnceWith(sid('newer')) + }) + + it('does not move drafts or reopen a Workspace after reselecting the current Session', async () => { + const b = bench({ + sessions: sessionState([summary('current')], sid('current')), + workspaces: workspaceState([workspace('alpha')]), + }) + const created = Promise.withResolvers() + b.sessions.create.mockReturnValue(created.promise) + const moveDraft = vi.fn() + const pending = b.uiWorkspace.openWorkspace(wid('alpha'), moveDraft) + b.uiWorkspace.openSession(sid('current')) + created.resolve(sid('late')) + await pending + expect(moveDraft).not.toHaveBeenCalled() + expect(b.sessions.open).toHaveBeenCalledExactlyOnceWith(sid('current')) + }) + + it('keeps navigation performed by preparation and propagates preparation failures', async () => { + const b = bench({ + sessions: sessionState([summary('current')], sid('current')), + workspaces: workspaceState([workspace('alpha')]), + }) + await b.uiWorkspace.openWorkspace(wid('alpha'), () => { b.layout.selectPanel('panel-a' as MainPanelId) }) + expect(b.sessions.open).not.toHaveBeenCalled() + await expect(b.uiWorkspace.openWorkspace(wid('alpha'), () => { throw new Error('draft failed') })).rejects.toThrow('draft failed') + expect(b.sessions.open).not.toHaveBeenCalled() + }) + + it('opens a fork only while its navigation is current and preserves fork failures', async () => { + const b = bench() + await b.uiWorkspace.forkSession(sid('source')) + expect(b.sessions.fork).toHaveBeenCalledWith({ sessionId: sid('source'), increaseTitle: true }) + expect(b.sessions.open).toHaveBeenCalledExactlyOnceWith(sid('forked')) + const forked = Promise.withResolvers() + b.sessions.fork.mockReturnValueOnce(forked.promise) + const pending = b.uiWorkspace.forkSession(sid('source')) + b.layout.selectPanel('panel-a' as MainPanelId) + forked.resolve(sid('late-fork')) + await pending + expect(b.sessions.open).toHaveBeenCalledOnce() + b.sessions.fork.mockRejectedValueOnce(new Error('fork failed')) + await expect(b.uiWorkspace.forkSession(sid('source'))).rejects.toThrow('fork failed') + expect(b.sessions.open).toHaveBeenCalledOnce() + }) + + it('does not open a pending Workspace or fork after the navigation owner is disposed', async () => { + for (const kind of ['workspace', 'fork'] as const) { + const b = bench({ + sessions: sessionState([summary('current')], sid('current')), + workspaces: workspaceState([workspace('alpha')]), + }) + const resolved = Promise.withResolvers() + b.sessions.create.mockReturnValue(resolved.promise) + b.sessions.fork.mockReturnValue(resolved.promise) + const pending = kind === 'workspace' + ? b.uiWorkspace.openWorkspace(wid('alpha')) + : b.uiWorkspace.forkSession(sid('current')) + await b.ctx.fiber.dispose() + resolved.resolve(sid('late')) + await pending + expect(b.sessions.open).not.toHaveBeenCalled() + } + }) + it('reuses only an unarchived member blank and coalesces concurrent creation', async () => { const b = bench() const memberBlank = sid('member-blank') diff --git a/packages/client/ui-workspace/tsconfig.json b/packages/client/ui-workspace/tsconfig.json index 8fd074799a..f9cab4ddc3 100644 --- a/packages/client/ui-workspace/tsconfig.json +++ b/packages/client/ui-workspace/tsconfig.json @@ -47,6 +47,9 @@ { "path": "../ui-conversation" }, + { + "path": "../ui-layout" + }, { "path": "../ui-renderer" }, diff --git a/packages/core/session/src/known-event-types.ts b/packages/core/session/src/known-event-types.ts index b21b7ec576..decb838cb5 100644 --- a/packages/core/session/src/known-event-types.ts +++ b/packages/core/session/src/known-event-types.ts @@ -54,6 +54,7 @@ export const KNOWN_SESSION_EVENT_TYPES: ReadonlySet = new Set([ 'session/title-llm-request', 'step/end', 'step/start', + 'subagent/catalog', 'subagent/descriptor', 'subagent/model-selection-policy', 'system/message', diff --git a/packages/extensions/cordis-client-runner/src/client/api-catalog.ts b/packages/extensions/cordis-client-runner/src/client/api-catalog.ts index 47bf4af64a..dfc66c56bf 100644 --- a/packages/extensions/cordis-client-runner/src/client/api-catalog.ts +++ b/packages/extensions/cordis-client-runner/src/client/api-catalog.ts @@ -83,9 +83,21 @@ export interface TypeApiEntry { export const SERVICE_API: readonly ServiceApiEntry[] = [ { key: 'layout', - summary: 'The outward layout face (`ctx.layout`): the panel transitions other plugins may trigger — and exactly what a test fake must supply.', - description: 'The outward layout face (`ctx.layout`): the panel transitions other plugins may trigger — and exactly what a test fake must supply. The attachPanels wiring hook stays on the concrete class (root-entry assembly only).', + summary: 'Panel navigation and geometry actions exposed through ctx.layout.', + description: 'Panel navigation and geometry actions exposed through ctx.layout.', methods: [ + { + signature: 'selectPanel(panelId: MainPanelId | null): void', + description: 'Select a global central panel without changing the current Session.', + parameters: [{ name: 'panelId', description: 'registered main key, or null to show the Conversation.' }], + throws: ['if the selected main key is not registered; preserves the current selection.'], + }, + { + signature: 'beginNavigation(): AbortSignal', + description: 'Start an asynchronous navigation, superseding any earlier pending navigation.', + parameters: [], + returns: 'a signal aborted by the next navigation or layout disposal; check it before committing UI state.', + }, { signature: 'toggleSidebar(): void', description: 'Toggle the sidebar panel (closed ⟷ contract default width).', @@ -314,6 +326,23 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ summary: 'Workspace archive and directory operations consumed by Client UI domains.', description: 'Workspace archive and directory operations consumed by Client UI domains.', methods: [ + { + signature: 'openSession(sessionId: SessionId): void', + description: 'Select a Session and show its Conversation as one UI navigation action.', + parameters: [{ name: 'sessionId', description: 'listed or retained Session to display.' }], + }, + { + signature: 'openWorkspace(workspaceId: WorkspaceId, beforeOpen?: (sessionId: SessionId) => void): Promise', + description: 'Connect a Workspace and open its Session unless a later navigation supersedes it.', + parameters: [{ name: 'workspaceId', description: 'target Workspace.' }, { name: 'beforeOpen', description: 'optional synchronous preparation for the selected Session, skipped after supersession.' }], + returns: 'completion; a superseded request may create a Session but does not open it.', + }, + { + signature: 'forkSession(sessionId: SessionId): Promise', + description: 'Fork a Session and open the child unless a later navigation supersedes it.', + parameters: [{ name: 'sessionId', description: 'source Session.' }], + returns: 'completion; a superseded request leaves its child available without selecting it.', + }, { signature: 'connectWorkspace(workspaceId: WorkspaceId): Promise', description: 'Resolve the reusable or newly created blank Session for a Workspace.', @@ -609,6 +638,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'LocaleSnapshot', declaration: 'export interface LocaleSnapshot {\n active: LocaleId;\n locales: readonly LocaleDefinition[];\n revision: number;\n}', }, + { + name: 'MainPanelId', + declaration: 'export type MainPanelId = Branded<\'MainPanelId\'>;', + }, { name: 'MatchedShare', declaration: 'export type MatchedShare = E[\'kind\'] extends \'chain\' ? {\n matched: M;\n} : object;', diff --git a/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts b/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts index e9806f64d4..d6232bd312 100644 --- a/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts +++ b/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts @@ -79,41 +79,6 @@ export const CLIENT_NOTES: readonly string[] = [ // verbatim; that sameness IS the contract a registrant reads, so clone // detection is told to skip the data rather than the file. export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ - { - key: 'conversation', - kind: 'single', - scope: 'session-maybe', - summary: 'The whole center column, across both the no-session hero and a live conversation.', - doc: 'The whole center column, across both the no-session hero and a live\nconversation. OCCUPIED by ui-conversation\'s ConversationRoot, which\ndeclares the session body, composer, and input seats inside it —\nregistering here replaces the entire conversation surface (and removes\nevery seat it declares) rather than adding to it.\n\nCurrent-session-optional: the occupant owns both states without\nchanging its React identity, so it keeps its own state across a session\nswitch. It receives no owner props; session facts arrive through the\nframework hooks of the `session-maybe` scope.', - registerOptions: [], - ownerProps: [ - '/** Conversation owner share: business state and actions belong to the registrant. */\nexport interface ConvOwnerProps {}', - ], - ownerPropsReferences: [], - standardProps: [ - 'useResource: UseResource', - 'useWorkspaces: SnapshotSelectorHook', - 'useSessions: UseSessions', - 'useSessionPendingInteraction: UseSessionPendingInteraction', - 'useWorkspaces: SnapshotSelectorHook', - 'useConversation: MaybeSnapshotSelectorHook', - 'useInput: MaybeSnapshotSelectorHook', - 'inputActions: InputActions | undefined', - 'useSession: MaybeSnapshotSelectorHook', - 'sessionId: SessionId | undefined', - 'useProjection: UseProjection', - ], - keyDomain: '', - hookContext: '', - slotInject: '', - declaredBy: 'an entry in \'root\' (client-ui-layout), so it exists while that entry is mounted', - occupants: [ - 'client-ui-conversation ConversationRoot', - ], - replaceRisk: 'shadows-shipped-ui', - example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation\', () => ctx.slots.register(\n { name: \'conversation\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-layout/src/client/index.ts:65', - }, { key: 'conversation.approval.detail', kind: 'single', @@ -128,6 +93,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ standardProps: [ 'useResource: UseResource', 'useWorkspaces: SnapshotSelectorHook', + 'usePanelInfo: UsePanelInfo', 'useSessions: UseSessions', 'useSessionPendingInteraction: UseSessionPendingInteraction', 'useWorkspaces: SnapshotSelectorHook', @@ -186,6 +152,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ standardProps: [ 'useResource: UseResource', 'useWorkspaces: SnapshotSelectorHook', + 'usePanelInfo: UsePanelInfo', 'useSessions: UseSessions', 'useSessionPendingInteraction: UseSessionPendingInteraction', 'useWorkspaces: SnapshotSelectorHook', @@ -234,6 +201,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ standardProps: [ 'useResource: UseResource', 'useWorkspaces: SnapshotSelectorHook', + 'usePanelInfo: UsePanelInfo', 'useSessions: UseSessions', 'useSessionPendingInteraction: UseSessionPendingInteraction', 'useWorkspaces: SnapshotSelectorHook', @@ -283,6 +251,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ standardProps: [ 'useResource: UseResource', 'useWorkspaces: SnapshotSelectorHook', + 'usePanelInfo: UsePanelInfo', 'useSessions: UseSessions', 'useSessionPendingInteraction: UseSessionPendingInteraction', 'useWorkspaces: SnapshotSelectorHook', @@ -345,6 +314,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ standardProps: [ 'useResource: UseResource', 'useWorkspaces: SnapshotSelectorHook', + 'usePanelInfo: UsePanelInfo', 'useSessions: UseSessions', 'useSessionPendingInteraction: UseSessionPendingInteraction', 'useWorkspaces: SnapshotSelectorHook', @@ -393,6 +363,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ standardProps: [ 'useResource: UseResource', 'useWorkspaces: SnapshotSelectorHook', + 'usePanelInfo: UsePanelInfo', 'useSessions: UseSessions', 'useSessionPendingInteraction: UseSessionPendingInteraction', 'useWorkspaces: SnapshotSelectorHook', @@ -408,7 +379,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ keyDomain: '', hookContext: '', slotInject: '', - declaredBy: 'an entry in \'conversation\' (client-ui-conversation), so it exists while that entry is mounted', + declaredBy: 'an entry in \'main.conversation\' (client-ui-conversation), so it exists while that entry is mounted', occupants: [ 'client-ui-approval ApprovalPanel', 'client-ui-subagent SubagentReadOnlyComposer', @@ -416,7 +387,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'none', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.composer\', () => ctx.slots.register(\n { name: \'conversation.composer\', select: owner => null },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-conversation/src/client/contract/slots.ts:157', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:159', }, { key: 'conversation.composer.bar', @@ -434,6 +405,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ standardProps: [ 'useResource: UseResource', 'useWorkspaces: SnapshotSelectorHook', + 'usePanelInfo: UsePanelInfo', 'useSessions: UseSessions', 'useSessionPendingInteraction: UseSessionPendingInteraction', 'useWorkspaces: SnapshotSelectorHook', @@ -447,13 +419,13 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ keyDomain: '', hookContext: '', slotInject: '', - declaredBy: 'an entry in \'conversation\' (client-ui-conversation), so it exists while that entry is mounted', + declaredBy: 'an entry in \'main.conversation\' (client-ui-conversation), so it exists while that entry is mounted', occupants: [ 'client-ui-conversation InputBar', ], replaceRisk: 'shadows-shipped-ui', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.composer.bar\', () => ctx.slots.register(\n { name: \'conversation.composer.bar\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-conversation/src/client/contract/slots.ts:175', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:177', }, { key: 'conversation.composer.dock', @@ -486,6 +458,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ standardProps: [ 'useResource: UseResource', 'useWorkspaces: SnapshotSelectorHook', + 'usePanelInfo: UsePanelInfo', 'useSessions: UseSessions', 'useSessionPendingInteraction: UseSessionPendingInteraction', 'useWorkspaces: SnapshotSelectorHook', @@ -507,7 +480,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'none', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.composer.dock\', () => ctx.slots.register(\n { name: \'conversation.composer.dock\', id: \'my-entry\', order: 100, label: \'My entry\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-conversation/src/client/contract/slots.ts:169', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:171', }, { key: 'conversation.hero.agentPreset', @@ -523,6 +496,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ standardProps: [ 'useResource: UseResource', 'useWorkspaces: SnapshotSelectorHook', + 'usePanelInfo: UsePanelInfo', 'useSessions: UseSessions', 'useSessionPendingInteraction: UseSessionPendingInteraction', 'useWorkspaces: SnapshotSelectorHook', @@ -530,13 +504,13 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ keyDomain: '', hookContext: '', slotInject: '', - declaredBy: 'an entry in \'conversation\' (client-ui-conversation), so it exists while that entry is mounted', + declaredBy: 'an entry in \'main.conversation\' (client-ui-conversation), so it exists while that entry is mounted', occupants: [ 'client-ui-agent-preset AgentPresetSeat', ], replaceRisk: 'shadows-shipped-ui', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.hero.agentPreset\', () => ctx.slots.register(\n { name: \'conversation.hero.agentPreset\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-conversation/src/client/contract/slots.ts:163', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:165', }, { key: 'conversation.hero.brand.mark', @@ -552,6 +526,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ standardProps: [ 'useResource: UseResource', 'useWorkspaces: SnapshotSelectorHook', + 'usePanelInfo: UsePanelInfo', 'useSessions: UseSessions', 'useSessionPendingInteraction: UseSessionPendingInteraction', 'useWorkspaces: SnapshotSelectorHook', @@ -559,11 +534,11 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ keyDomain: '', hookContext: '', slotInject: '', - declaredBy: 'an entry in \'conversation\' (client-ui-conversation), so it exists while that entry is mounted', + declaredBy: 'an entry in \'main.conversation\' (client-ui-conversation), so it exists while that entry is mounted', occupants: [], replaceRisk: 'none', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.hero.brand.mark\', () => ctx.slots.register(\n { name: \'conversation.hero.brand.mark\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-conversation/src/client/contract/slots.ts:161', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:163', }, { key: 'conversation.hero.workspace', @@ -581,6 +556,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ standardProps: [ 'useResource: UseResource', 'useWorkspaces: SnapshotSelectorHook', + 'usePanelInfo: UsePanelInfo', 'useSessions: UseSessions', 'useSessionPendingInteraction: UseSessionPendingInteraction', 'useWorkspaces: SnapshotSelectorHook', @@ -588,13 +564,13 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ keyDomain: '', hookContext: '', slotInject: '', - declaredBy: 'an entry in \'conversation\' (client-ui-conversation), so it exists while that entry is mounted', + declaredBy: 'an entry in \'main.conversation\' (client-ui-conversation), so it exists while that entry is mounted', occupants: [ 'client-ui-workspace WorkspacePicker', ], replaceRisk: 'shadows-shipped-ui', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.hero.workspace\', () => ctx.slots.register(\n { name: \'conversation.hero.workspace\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-conversation/src/client/contract/slots.ts:159', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:161', }, { key: 'conversation.hero.workspace.directoryFlow', @@ -610,6 +586,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ standardProps: [ 'useResource: UseResource', 'useWorkspaces: SnapshotSelectorHook', + 'usePanelInfo: UsePanelInfo', 'useSessions: UseSessions', 'useSessionPendingInteraction: UseSessionPendingInteraction', 'useWorkspaces: SnapshotSelectorHook', @@ -644,6 +621,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ standardProps: [ 'useResource: UseResource', 'useWorkspaces: SnapshotSelectorHook', + 'usePanelInfo: UsePanelInfo', 'useSessions: UseSessions', 'useSessionPendingInteraction: UseSessionPendingInteraction', 'useWorkspaces: SnapshotSelectorHook', @@ -663,7 +641,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'shadows-shipped-ui', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.input.attachments\', () => ctx.slots.register(\n { name: \'conversation.input.attachments\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-conversation/src/client/contract/slots.ts:177', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:179', }, { key: 'conversation.input.dock', @@ -701,6 +679,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ standardProps: [ 'useResource: UseResource', 'useWorkspaces: SnapshotSelectorHook', + 'usePanelInfo: UsePanelInfo', 'useSessions: UseSessions', 'useSessionPendingInteraction: UseSessionPendingInteraction', 'useWorkspaces: SnapshotSelectorHook', @@ -716,7 +695,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ keyDomain: '', hookContext: '', slotInject: '', - declaredBy: 'an entry in \'conversation\' (client-ui-conversation), so it exists while that entry is mounted', + declaredBy: 'an entry in \'main.conversation\' (client-ui-conversation), so it exists while that entry is mounted', occupants: [ 'client-ui-conversation QueueDock id \'queue\'', 'client-ui-conversation TodoDock id \'todo\'', @@ -724,7 +703,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'none', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.input.dock\', () => ctx.slots.register(\n { name: \'conversation.input.dock\', id: \'my-entry\', order: 100, label: \'My entry\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-conversation/src/client/contract/slots.ts:165', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:167', }, { key: 'conversation.input.left', @@ -757,6 +736,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ standardProps: [ 'useResource: UseResource', 'useWorkspaces: SnapshotSelectorHook', + 'usePanelInfo: UsePanelInfo', 'useSessions: UseSessions', 'useSessionPendingInteraction: UseSessionPendingInteraction', 'useWorkspaces: SnapshotSelectorHook', @@ -776,7 +756,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ occupants: [], replaceRisk: 'none', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.input.left\', () => ctx.slots.register(\n { name: \'conversation.input.left\', id: \'my-entry\', order: 100, label: \'My entry\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-conversation/src/client/contract/slots.ts:171', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:173', }, { key: 'conversation.input.model', @@ -792,6 +772,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ standardProps: [ 'useResource: UseResource', 'useWorkspaces: SnapshotSelectorHook', + 'usePanelInfo: UsePanelInfo', 'useSessions: UseSessions', 'useSessionPendingInteraction: UseSessionPendingInteraction', 'useWorkspaces: SnapshotSelectorHook', @@ -813,7 +794,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'shadows-shipped-ui', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.input.model\', () => ctx.slots.register(\n { name: \'conversation.input.model\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-conversation/src/client/contract/slots.ts:185', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:187', }, { key: 'conversation.input.overlay', @@ -846,6 +827,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ standardProps: [ 'useResource: UseResource', 'useWorkspaces: SnapshotSelectorHook', + 'usePanelInfo: UsePanelInfo', 'useSessions: UseSessions', 'useSessionPendingInteraction: UseSessionPendingInteraction', 'useWorkspaces: SnapshotSelectorHook', @@ -868,7 +850,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'none', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.input.overlay\', () => ctx.slots.register(\n { name: \'conversation.input.overlay\', id: \'my-entry\', order: 100, label: \'My entry\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-conversation/src/client/contract/slots.ts:167', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:169', }, { key: 'conversation.input.plan', @@ -884,6 +866,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ standardProps: [ 'useResource: UseResource', 'useWorkspaces: SnapshotSelectorHook', + 'usePanelInfo: UsePanelInfo', 'useSessions: UseSessions', 'useSessionPendingInteraction: UseSessionPendingInteraction', 'useWorkspaces: SnapshotSelectorHook', @@ -905,7 +888,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'shadows-shipped-ui', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.input.plan\', () => ctx.slots.register(\n { name: \'conversation.input.plan\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-conversation/src/client/contract/slots.ts:183', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:185', }, { key: 'conversation.input.right', @@ -938,6 +921,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ standardProps: [ 'useResource: UseResource', 'useWorkspaces: SnapshotSelectorHook', + 'usePanelInfo: UsePanelInfo', 'useSessions: UseSessions', 'useSessionPendingInteraction: UseSessionPendingInteraction', 'useWorkspaces: SnapshotSelectorHook', @@ -957,7 +941,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ occupants: [], replaceRisk: 'none', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.input.right\', () => ctx.slots.register(\n { name: \'conversation.input.right\', id: \'my-entry\', order: 100, label: \'My entry\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-conversation/src/client/contract/slots.ts:173', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:175', }, { key: 'conversation.message.images', @@ -977,6 +961,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ standardProps: [ 'useResource: UseResource', 'useWorkspaces: SnapshotSelectorHook', + 'usePanelInfo: UsePanelInfo', 'useSessions: UseSessions', 'useSessionPendingInteraction: UseSessionPendingInteraction', 'useWorkspaces: SnapshotSelectorHook', @@ -1012,6 +997,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ standardProps: [ 'useResource: UseResource', 'useWorkspaces: SnapshotSelectorHook', + 'usePanelInfo: UsePanelInfo', 'useSessions: UseSessions', 'useSessionPendingInteraction: UseSessionPendingInteraction', 'useWorkspaces: SnapshotSelectorHook', @@ -1027,13 +1013,13 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ keyDomain: '', hookContext: '', slotInject: '', - declaredBy: 'an entry in \'conversation\' (client-ui-conversation), so it exists while that entry is mounted', + declaredBy: 'an entry in \'main.conversation\' (client-ui-conversation), so it exists while that entry is mounted', occupants: [ 'client-ui-conversation ConversationSession', ], replaceRisk: 'shadows-shipped-ui', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.session\', () => ctx.slots.register(\n { name: \'conversation.session\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-conversation/src/client/contract/slots.ts:121', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:123', }, { key: 'conversation.session.header', @@ -1047,6 +1033,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ standardProps: [ 'useResource: UseResource', 'useWorkspaces: SnapshotSelectorHook', + 'usePanelInfo: UsePanelInfo', 'useSessions: UseSessions', 'useSessionPendingInteraction: UseSessionPendingInteraction', 'useWorkspaces: SnapshotSelectorHook', @@ -1062,13 +1049,13 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ keyDomain: '', hookContext: '', slotInject: '', - declaredBy: 'an entry in \'conversation\' (client-ui-conversation), so it exists while that entry is mounted', + declaredBy: 'an entry in \'main.conversation\' (client-ui-conversation), so it exists while that entry is mounted', occupants: [ 'client-ui-conversation ConversationSessionHeader', ], replaceRisk: 'shadows-shipped-ui', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.session.header\', () => ctx.slots.register(\n { name: \'conversation.session.header\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-conversation/src/client/contract/slots.ts:123', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:125', }, { key: 'conversation.session.header.actions', @@ -1103,6 +1090,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ standardProps: [ 'useResource: UseResource', 'useWorkspaces: SnapshotSelectorHook', + 'usePanelInfo: UsePanelInfo', 'useSessions: UseSessions', 'useSessionPendingInteraction: UseSessionPendingInteraction', 'useWorkspaces: SnapshotSelectorHook', @@ -1127,7 +1115,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'none', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.session.header.actions\', () => ctx.slots.register(\n { name: \'conversation.session.header.actions\', id: \'my-entry\', order: 100, label: \'My entry\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-conversation/src/client/contract/slots.ts:131', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:133', }, { key: 'conversation.session.header.corner', @@ -1143,6 +1131,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ standardProps: [ 'useResource: UseResource', 'useWorkspaces: SnapshotSelectorHook', + 'usePanelInfo: UsePanelInfo', 'useSessions: UseSessions', 'useSessionPendingInteraction: UseSessionPendingInteraction', 'useWorkspaces: SnapshotSelectorHook', @@ -1164,7 +1153,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'shadows-shipped-ui', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.session.header.corner\', () => ctx.slots.register(\n { name: \'conversation.session.header.corner\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-conversation/src/client/contract/slots.ts:149', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:151', }, { key: 'conversation.session.header.lineage', @@ -1182,6 +1171,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ standardProps: [ 'useResource: UseResource', 'useWorkspaces: SnapshotSelectorHook', + 'usePanelInfo: UsePanelInfo', 'useSessions: UseSessions', 'useSessionPendingInteraction: UseSessionPendingInteraction', 'useWorkspaces: SnapshotSelectorHook', @@ -1203,7 +1193,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'shadows-shipped-ui', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.session.header.lineage\', () => ctx.slots.register(\n { name: \'conversation.session.header.lineage\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-conversation/src/client/contract/slots.ts:125', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:127', }, { key: 'conversation.session.header.utilities', @@ -1238,6 +1228,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ standardProps: [ 'useResource: UseResource', 'useWorkspaces: SnapshotSelectorHook', + 'usePanelInfo: UsePanelInfo', 'useSessions: UseSessions', 'useSessionPendingInteraction: UseSessionPendingInteraction', 'useWorkspaces: SnapshotSelectorHook', @@ -1260,7 +1251,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'none', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.session.header.utilities\', () => ctx.slots.register(\n { name: \'conversation.session.header.utilities\', id: \'my-entry\', order: 100, label: \'My entry\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-conversation/src/client/contract/slots.ts:137', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:139', }, { key: 'conversation.trajectory.images', @@ -1280,6 +1271,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ standardProps: [ 'useResource: UseResource', 'useWorkspaces: SnapshotSelectorHook', + 'usePanelInfo: UsePanelInfo', 'useSessions: UseSessions', 'useSessionPendingInteraction: UseSessionPendingInteraction', 'useWorkspaces: SnapshotSelectorHook', @@ -1338,6 +1330,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ standardProps: [ 'useResource: UseResource', 'useWorkspaces: SnapshotSelectorHook', + 'usePanelInfo: UsePanelInfo', 'useSessions: UseSessions', 'useSessionPendingInteraction: UseSessionPendingInteraction', 'useWorkspaces: SnapshotSelectorHook', @@ -1360,14 +1353,83 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'none', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.view\', () => ctx.slots.register(\n { name: \'conversation.view\', id: \'my-entry\', order: 100, label: \'My entry\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-conversation/src/client/contract/slots.ts:155', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:157', + }, + { + key: 'main', + kind: 'keyed', + scope: 'root', + summary: 'Central panel selected by sidebar entry id.', + doc: 'Central panel selected by sidebar entry id. The reserved `conversation`\nkey hosts the Conversation; other keys receive no Session binding.', + registerOptions: [ + { + name: 'key', + requirement: 'required', + type: 'string', + doc: 'Your cell key: the entry renders where the owner dispatches this exact key. Registering an already-occupied key replaces that occupant.', + }, + ], + ownerProps: [], + ownerPropsReferences: [], + standardProps: [ + 'useResource: UseResource', + 'useWorkspaces: SnapshotSelectorHook', + 'usePanelInfo: UsePanelInfo', + 'useSessions: UseSessions', + 'useSessionPendingInteraction: UseSessionPendingInteraction', + 'useWorkspaces: SnapshotSelectorHook', + ], + keyDomain: 'open: any string the owner dispatches (no compile-time key set), already taken: conversation', + hookContext: '', + slotInject: '', + declaredBy: 'an entry in \'root\' (client-ui-layout), so it exists while that entry is mounted', + occupants: [ + 'client-ui-conversation ConversationPanel key \'conversation\'', + ], + replaceRisk: 'shadows-shipped-ui', + example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'main\', () => ctx.slots.register(\n { name: \'main\', key: \'\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', + source: 'packages/client/ui-layout/src/client/index.ts:66', + }, + { + key: 'main.conversation', + kind: 'single', + scope: 'session-maybe', + summary: 'Conversation shell beneath its root-scoped main-panel entry.', + doc: 'Conversation shell beneath its root-scoped main-panel entry.', + registerOptions: [], + ownerProps: [], + ownerPropsReferences: [], + standardProps: [ + 'useResource: UseResource', + 'useWorkspaces: SnapshotSelectorHook', + 'usePanelInfo: UsePanelInfo', + 'useSessions: UseSessions', + 'useSessionPendingInteraction: UseSessionPendingInteraction', + 'useWorkspaces: SnapshotSelectorHook', + 'useConversation: MaybeSnapshotSelectorHook', + 'useInput: MaybeSnapshotSelectorHook', + 'inputActions: InputActions | undefined', + 'useSession: MaybeSnapshotSelectorHook', + 'sessionId: SessionId | undefined', + 'useProjection: UseProjection', + ], + keyDomain: '', + hookContext: '', + slotInject: '', + declaredBy: 'an entry in \'main\' (client-ui-conversation), so it exists while that entry is mounted', + occupants: [ + 'client-ui-conversation ConversationRoot', + ], + replaceRisk: 'shadows-shipped-ui', + example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'main.conversation\', () => ctx.slots.register(\n { name: \'main.conversation\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:121', }, { key: 'rightbar', kind: 'single', - scope: 'session', + scope: 'root', summary: 'The right column: a track the centre makes room for, or nothing.', - doc: 'The right column: a track the centre makes room for, or nothing. OCCUPIED\nby the right Sidebar, which uses the resolved column width in normal\nmode and covers the viewport in fullscreen, retaining the wide-screen\ncolumn reservation underneath.\n\nWhether the panel is shown, and whether it takes a track, is the\noccupant\'s own recorded business — it reports the composition of its\nexpanded and presentation state through `ctx.layout`, and the frame sizes\nthe track and places the resize handle from that. The expand control is\nnot this column\'s: it is a button in the conversation header. With no\ncurrent session nothing is mounted here.', + doc: 'The right column: a track the centre makes room for, or nothing. OCCUPIED\nby the right Sidebar, which uses the resolved column width in normal\nmode and covers the viewport in fullscreen, retaining the wide-screen\ncolumn reservation underneath.\n\nWhether the panel is shown, and whether it takes a track, is the\noccupant\'s own recorded business — it reports the composition of its\nexpanded and presentation state through `ctx.layout`, and the frame sizes\nthe track and places the resize handle from that. The expand control is\nnot this column\'s: it is a button in the conversation header. The root\noccupant decides when to render its Session-bound content.', registerOptions: [], ownerProps: [ '/** Right column owner share: resolved normal geometry and opening eligibility. */\nexport interface RightbarOwnerProps {\n /** Resolved normal panel width in px, not the saved preference; zero if it cannot fit. */\n width: number\n /** Current frame width in px. */\n viewportWidth: number\n /**\n * Whether a normal right panel can retain 300px beside a 400px center.\n * Before a narrow opening, includes the space from collapsing the left sidebar.\n */\n canShow: boolean\n}', @@ -1376,6 +1438,37 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ standardProps: [ 'useResource: UseResource', 'useWorkspaces: SnapshotSelectorHook', + 'usePanelInfo: UsePanelInfo', + 'useSessions: UseSessions', + 'useSessionPendingInteraction: UseSessionPendingInteraction', + 'useWorkspaces: SnapshotSelectorHook', + ], + keyDomain: '', + hookContext: '', + slotInject: '', + declaredBy: 'an entry in \'root\' (client-ui-layout), so it exists while that entry is mounted', + occupants: [ + 'client-ui-sidebar-right RightbarRoot', + ], + replaceRisk: 'shadows-shipped-ui', + example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'rightbar\', () => ctx.slots.register(\n { name: \'rightbar\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', + source: 'packages/client/ui-layout/src/client/index.ts:80', + }, + { + key: 'rightbar.session', + kind: 'single', + scope: 'session', + summary: 'Session content selected by the root-scoped right Sidebar controller.', + doc: 'Session content selected by the root-scoped right Sidebar controller.', + registerOptions: [], + ownerProps: [ + '/** Right column owner share: resolved normal geometry and opening eligibility. */\nexport interface RightbarOwnerProps {\n /** Resolved normal panel width in px, not the saved preference; zero if it cannot fit. */\n width: number\n /** Current frame width in px. */\n viewportWidth: number\n /**\n * Whether a normal right panel can retain 300px beside a 400px center.\n * Before a narrow opening, includes the space from collapsing the left sidebar.\n */\n canShow: boolean\n}', + ], + ownerPropsReferences: [], + standardProps: [ + 'useResource: UseResource', + 'useWorkspaces: SnapshotSelectorHook', + 'usePanelInfo: UsePanelInfo', 'useSessions: UseSessions', 'useSessionPendingInteraction: UseSessionPendingInteraction', 'useWorkspaces: SnapshotSelectorHook', @@ -1391,13 +1484,13 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ keyDomain: '', hookContext: '', slotInject: '', - declaredBy: 'an entry in \'root\' (client-ui-layout), so it exists while that entry is mounted', + declaredBy: 'an entry in \'rightbar\' (client-ui-sidebar-right), so it exists while that entry is mounted', occupants: [ 'client-ui-sidebar-right RightbarSeat', ], replaceRisk: 'shadows-shipped-ui', - example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'rightbar\', () => ctx.slots.register(\n { name: \'rightbar\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-layout/src/client/index.ts:79', + example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'rightbar.session\', () => ctx.slots.register(\n { name: \'rightbar.session\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', + source: 'packages/client/ui-sidebar-right/src/client/contract/slots.ts:42', }, { key: 'root', @@ -1413,6 +1506,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ standardProps: [ 'useResource: UseResource', 'useWorkspaces: SnapshotSelectorHook', + 'usePanelInfo: UsePanelInfo', 'useSessions: UseSessions', 'useSessionPendingInteraction: UseSessionPendingInteraction', 'useWorkspaces: SnapshotSelectorHook', @@ -1461,6 +1555,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ standardProps: [ 'useResource: UseResource', 'useWorkspaces: SnapshotSelectorHook', + 'usePanelInfo: UsePanelInfo', 'useSessions: UseSessions', 'useSessionPendingInteraction: UseSessionPendingInteraction', 'useWorkspaces: SnapshotSelectorHook', @@ -1490,6 +1585,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ standardProps: [ 'useResource: UseResource', 'useWorkspaces: SnapshotSelectorHook', + 'usePanelInfo: UsePanelInfo', 'useSessions: UseSessions', 'useSessionPendingInteraction: UseSessionPendingInteraction', 'useWorkspaces: SnapshotSelectorHook', @@ -1538,6 +1634,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ standardProps: [ 'useResource: UseResource', 'useWorkspaces: SnapshotSelectorHook', + 'usePanelInfo: UsePanelInfo', 'useSessions: UseSessions', 'useSessionPendingInteraction: UseSessionPendingInteraction', 'useWorkspaces: SnapshotSelectorHook', @@ -1572,6 +1669,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ standardProps: [ 'useResource: UseResource', 'useWorkspaces: SnapshotSelectorHook', + 'usePanelInfo: UsePanelInfo', 'useSessions: UseSessions', 'useSessionPendingInteraction: UseSessionPendingInteraction', 'useWorkspaces: SnapshotSelectorHook', @@ -1620,6 +1718,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ standardProps: [ 'useResource: UseResource', 'useWorkspaces: SnapshotSelectorHook', + 'usePanelInfo: UsePanelInfo', 'useSessions: UseSessions', 'useSessionPendingInteraction: UseSessionPendingInteraction', 'useWorkspaces: SnapshotSelectorHook', @@ -1656,6 +1755,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ standardProps: [ 'useResource: UseResource', 'useWorkspaces: SnapshotSelectorHook', + 'usePanelInfo: UsePanelInfo', 'useSessions: UseSessions', 'useSessionPendingInteraction: UseSessionPendingInteraction', 'useWorkspaces: SnapshotSelectorHook', @@ -1702,6 +1802,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ standardProps: [ 'useResource: UseResource', 'useWorkspaces: SnapshotSelectorHook', + 'usePanelInfo: UsePanelInfo', 'useSessions: UseSessions', 'useSessionPendingInteraction: UseSessionPendingInteraction', 'useWorkspaces: SnapshotSelectorHook', @@ -1739,6 +1840,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ standardProps: [ 'useResource: UseResource', 'useWorkspaces: SnapshotSelectorHook', + 'usePanelInfo: UsePanelInfo', 'useSessions: UseSessions', 'useSessionPendingInteraction: UseSessionPendingInteraction', 'useWorkspaces: SnapshotSelectorHook', @@ -1790,6 +1892,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ standardProps: [ 'useResource: UseResource', 'useWorkspaces: SnapshotSelectorHook', + 'usePanelInfo: UsePanelInfo', 'useSessions: UseSessions', 'useSessionPendingInteraction: UseSessionPendingInteraction', 'useWorkspaces: SnapshotSelectorHook', @@ -1839,6 +1942,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ standardProps: [ 'useResource: UseResource', 'useWorkspaces: SnapshotSelectorHook', + 'usePanelInfo: UsePanelInfo', 'useSessions: UseSessions', 'useSessionPendingInteraction: UseSessionPendingInteraction', 'useWorkspaces: SnapshotSelectorHook', @@ -1871,6 +1975,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ standardProps: [ 'useResource: UseResource', 'useWorkspaces: SnapshotSelectorHook', + 'usePanelInfo: UsePanelInfo', 'useSessions: UseSessions', 'useSessionPendingInteraction: UseSessionPendingInteraction', 'useWorkspaces: SnapshotSelectorHook', @@ -1917,6 +2022,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ standardProps: [ 'useResource: UseResource', 'useWorkspaces: SnapshotSelectorHook', + 'usePanelInfo: UsePanelInfo', 'useSessions: UseSessions', 'useSessionPendingInteraction: UseSessionPendingInteraction', 'useWorkspaces: SnapshotSelectorHook', @@ -1928,7 +2034,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ occupants: [], replaceRisk: 'none', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'shell.overlay\', () => ctx.slots.register(\n { name: \'shell.overlay\', id: \'my-entry\', order: 100, label: \'My entry\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-layout/src/client/index.ts:90', + source: 'packages/client/ui-layout/src/client/index.ts:91', }, { key: 'sidebar', @@ -1944,6 +2050,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ standardProps: [ 'useResource: UseResource', 'useWorkspaces: SnapshotSelectorHook', + 'usePanelInfo: UsePanelInfo', 'useSessions: UseSessions', 'useSessionPendingInteraction: UseSessionPendingInteraction', 'useWorkspaces: SnapshotSelectorHook', @@ -1957,7 +2064,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'shadows-shipped-ui', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'sidebar\', () => ctx.slots.register(\n { name: \'sidebar\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-layout/src/client/index.ts:52', + source: 'packages/client/ui-layout/src/client/index.ts:61', }, { key: 'sidebar.brand.mark', @@ -1973,6 +2080,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ standardProps: [ 'useResource: UseResource', 'useWorkspaces: SnapshotSelectorHook', + 'usePanelInfo: UsePanelInfo', 'useSessions: UseSessions', 'useSessionPendingInteraction: UseSessionPendingInteraction', 'useWorkspaces: SnapshotSelectorHook', @@ -1986,7 +2094,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'shadows-shipped-ui', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'sidebar.brand.mark\', () => ctx.slots.register(\n { name: \'sidebar.brand.mark\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-sidebar/src/client/contract/slots.ts:23', + source: 'packages/client/ui-sidebar/src/client/contract/slots.ts:22', }, { key: 'sidebar.brand.name', @@ -2002,6 +2110,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ standardProps: [ 'useResource: UseResource', 'useWorkspaces: SnapshotSelectorHook', + 'usePanelInfo: UsePanelInfo', 'useSessions: UseSessions', 'useSessionPendingInteraction: UseSessionPendingInteraction', 'useWorkspaces: SnapshotSelectorHook', @@ -2015,7 +2124,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'shadows-shipped-ui', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'sidebar.brand.name\', () => ctx.slots.register(\n { name: \'sidebar.brand.name\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-sidebar/src/client/contract/slots.ts:28', + source: 'packages/client/ui-sidebar/src/client/contract/slots.ts:27', }, { key: 'sidebar.footer.action', @@ -2050,6 +2159,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ standardProps: [ 'useResource: UseResource', 'useWorkspaces: SnapshotSelectorHook', + 'usePanelInfo: UsePanelInfo', 'useSessions: UseSessions', 'useSessionPendingInteraction: UseSessionPendingInteraction', 'useWorkspaces: SnapshotSelectorHook', @@ -2063,7 +2173,54 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'none', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'sidebar.footer.action\', () => ctx.slots.register(\n { name: \'sidebar.footer.action\', id: \'my-entry\', order: 100, label: \'My entry\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-sidebar/src/client/contract/slots.ts:46', + source: 'packages/client/ui-sidebar/src/client/contract/slots.ts:50', + }, + { + key: 'sidebar.panellist', + kind: 'list', + scope: 'root', + summary: 'Global panel icons.', + doc: 'Global panel icons. Each list id addresses the matching main panel;\nthe sidebar owns the button and resolves its label from list metadata.', + registerOptions: [ + { + name: 'id', + requirement: 'required', + type: 'string', + doc: 'Your cell key. Use an id of your own: a fresh id is added beside the shipped entries, while reusing a shipped id puts you in THAT cell and replaces it. Owners that filter by id address you by it.', + }, + { + name: 'order', + requirement: 'optional', + type: 'number', + doc: 'Position among the entries, ascending (default 0).', + }, + { + name: 'label', + requirement: 'optional', + type: 'string | (() => string)', + doc: 'Display text where the owner projects one (nav rows, tabs). A thunk is re-read on every projection, so localized text follows the active locale without re-registering.', + }, + ], + ownerProps: [ + '/** Icon presentation supplied by the global panel row. */\nexport interface SidebarPanelIconOwnerProps {\n /** Requested square edge in pixels. */\n size: number\n /** Whether this panel is selected in the main column. */\n active: boolean\n}', + ], + ownerPropsReferences: [], + standardProps: [ + 'useResource: UseResource', + 'useWorkspaces: SnapshotSelectorHook', + 'usePanelInfo: UsePanelInfo', + 'useSessions: UseSessions', + 'useSessionPendingInteraction: UseSessionPendingInteraction', + 'useWorkspaces: SnapshotSelectorHook', + ], + keyDomain: '', + hookContext: '', + slotInject: '', + declaredBy: 'an entry in \'sidebar\' (client-ui-sidebar), so it exists while that entry is mounted', + occupants: [], + replaceRisk: 'none', + example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'sidebar.panellist\', () => ctx.slots.register(\n { name: \'sidebar.panellist\', id: \'my-entry\', order: 100, label: \'My entry\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', + source: 'packages/client/ui-sidebar/src/client/contract/slots.ts:32', }, { key: 'sidebar.right.pane.tab', @@ -2084,6 +2241,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ standardProps: [ 'useResource: UseResource', 'useWorkspaces: SnapshotSelectorHook', + 'usePanelInfo: UsePanelInfo', 'useSessions: UseSessions', 'useSessionPendingInteraction: UseSessionPendingInteraction', 'useWorkspaces: SnapshotSelectorHook', @@ -2099,7 +2257,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ keyDomain: 'open: any string the owner dispatches (no compile-time key set), none are taken yet', hookContext: 'TabHookContext', slotInject: 'SidebarRightTabInjected', - declaredBy: 'an entry in \'rightbar\' (client-ui-sidebar-right), so it exists while that entry is mounted', + declaredBy: 'an entry in \'rightbar.session\' (client-ui-sidebar-right), so it exists while that entry is mounted', occupants: [ 'client-ui-sidebar-files FilesBody', 'client-ui-sidebar-right GuideBody', @@ -2107,7 +2265,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'none', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'sidebar.right.pane.tab\', () => ctx.slots.register(\n { name: \'sidebar.right.pane.tab\', key: \'\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-sidebar-right/src/client/contract/slots.ts:47', + source: 'packages/client/ui-sidebar-right/src/client/contract/slots.ts:50', }, { key: 'sidebar.right.pane.tab.title', @@ -2128,6 +2286,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ standardProps: [ 'useResource: UseResource', 'useWorkspaces: SnapshotSelectorHook', + 'usePanelInfo: UsePanelInfo', 'useSessions: UseSessions', 'useSessionPendingInteraction: UseSessionPendingInteraction', 'useWorkspaces: SnapshotSelectorHook', @@ -2143,11 +2302,11 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ keyDomain: 'open: any string the owner dispatches (no compile-time key set), none are taken yet', hookContext: 'TabHookContext', slotInject: 'SidebarRightTabInjected', - declaredBy: 'an entry in \'rightbar\' (client-ui-sidebar-right), so it exists while that entry is mounted', + declaredBy: 'an entry in \'rightbar.session\' (client-ui-sidebar-right), so it exists while that entry is mounted', occupants: [], replaceRisk: 'none', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'sidebar.right.pane.tab.title\', () => ctx.slots.register(\n { name: \'sidebar.right.pane.tab.title\', key: \'\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-sidebar-right/src/client/contract/slots.ts:61', + source: 'packages/client/ui-sidebar-right/src/client/contract/slots.ts:64', }, { key: 'sidebar.right.tab.guide', @@ -2168,6 +2327,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ standardProps: [ 'useResource: UseResource', 'useWorkspaces: SnapshotSelectorHook', + 'usePanelInfo: UsePanelInfo', 'useSessions: UseSessions', 'useSessionPendingInteraction: UseSessionPendingInteraction', 'useWorkspaces: SnapshotSelectorHook', @@ -2187,7 +2347,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ occupants: [], replaceRisk: 'none', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'sidebar.right.tab.guide\', () => ctx.slots.register(\n { name: \'sidebar.right.tab.guide\', select: owner => null },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-sidebar-right/src/client/contract/slots.ts:72', + source: 'packages/client/ui-sidebar-right/src/client/contract/slots.ts:75', }, { key: 'sidebar.right.tab.menu.item', @@ -2224,6 +2384,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ standardProps: [ 'useResource: UseResource', 'useWorkspaces: SnapshotSelectorHook', + 'usePanelInfo: UsePanelInfo', 'useSessions: UseSessions', 'useSessionPendingInteraction: UseSessionPendingInteraction', 'useWorkspaces: SnapshotSelectorHook', @@ -2239,11 +2400,11 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ keyDomain: '', hookContext: '', slotInject: '', - declaredBy: 'an entry in \'rightbar\' (client-ui-sidebar-right), so it exists while that entry is mounted', + declaredBy: 'an entry in \'rightbar.session\' (client-ui-sidebar-right), so it exists while that entry is mounted', occupants: [], replaceRisk: 'none', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'sidebar.right.tab.menu.item\', () => ctx.slots.register(\n { name: \'sidebar.right.tab.menu.item\', id: \'my-entry\', order: 100, label: \'My entry\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-sidebar-right/src/client/contract/slots.ts:83', + source: 'packages/client/ui-sidebar-right/src/client/contract/slots.ts:86', }, { key: 'sidebar.settings', @@ -2259,6 +2420,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ standardProps: [ 'useResource: UseResource', 'useWorkspaces: SnapshotSelectorHook', + 'usePanelInfo: UsePanelInfo', 'useSessions: UseSessions', 'useSessionPendingInteraction: UseSessionPendingInteraction', 'useWorkspaces: SnapshotSelectorHook', @@ -2272,7 +2434,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'shadows-shipped-ui', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'sidebar.settings\', () => ctx.slots.register(\n { name: \'sidebar.settings\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-sidebar/src/client/contract/slots.ts:41', + source: 'packages/client/ui-sidebar/src/client/contract/slots.ts:45', }, { key: 'sidebar.workspaces', @@ -2288,6 +2450,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ standardProps: [ 'useResource: UseResource', 'useWorkspaces: SnapshotSelectorHook', + 'usePanelInfo: UsePanelInfo', 'useSessions: UseSessions', 'useSessionPendingInteraction: UseSessionPendingInteraction', 'useWorkspaces: SnapshotSelectorHook', @@ -2301,7 +2464,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'shadows-shipped-ui', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'sidebar.workspaces\', () => ctx.slots.register(\n { name: \'sidebar.workspaces\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-sidebar/src/client/contract/slots.ts:35', + source: 'packages/client/ui-sidebar/src/client/contract/slots.ts:39', }, { key: 'sidebar.workspaces.directoryFlow', @@ -2317,6 +2480,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ standardProps: [ 'useResource: UseResource', 'useWorkspaces: SnapshotSelectorHook', + 'usePanelInfo: UsePanelInfo', 'useSessions: UseSessions', 'useSessionPendingInteraction: UseSessionPendingInteraction', 'useWorkspaces: SnapshotSelectorHook', @@ -2350,6 +2514,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ standardProps: [ 'useResource: UseResource', 'useWorkspaces: SnapshotSelectorHook', + 'usePanelInfo: UsePanelInfo', 'useSessions: UseSessions', 'useSessionPendingInteraction: UseSessionPendingInteraction', 'useWorkspaces: SnapshotSelectorHook', @@ -2398,6 +2563,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ standardProps: [ 'useResource: UseResource', 'useWorkspaces: SnapshotSelectorHook', + 'usePanelInfo: UsePanelInfo', 'useSessions: UseSessions', 'useSessionPendingInteraction: UseSessionPendingInteraction', 'useWorkspaces: SnapshotSelectorHook', @@ -2461,6 +2627,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ standardProps: [ 'useResource: UseResource', 'useWorkspaces: SnapshotSelectorHook', + 'usePanelInfo: UsePanelInfo', 'useSessions: UseSessions', 'useSessionPendingInteraction: UseSessionPendingInteraction', 'useWorkspaces: SnapshotSelectorHook', diff --git a/packages/extensions/cordis-client-runner/tests/api-catalog.client.spec.ts b/packages/extensions/cordis-client-runner/tests/api-catalog.client.spec.ts index 9e2e106b9e..e291d7c82e 100644 --- a/packages/extensions/cordis-client-runner/tests/api-catalog.client.spec.ts +++ b/packages/extensions/cordis-client-runner/tests/api-catalog.client.spec.ts @@ -3,6 +3,14 @@ import { EVENT_API, queryServiceApi, SERVICE_API } from '../src/client/api-catal describe('Client Cordis inspect catalog', () => { it('publishes the split Workspace Controller and UI navigation services', () => { + expect(SERVICE_API.find(service => service.key === 'layout')?.methods.map(method => method.signature)) + .toEqual([ + 'selectPanel(panelId: MainPanelId | null): void', + 'beginNavigation(): AbortSignal', + 'toggleSidebar(): void', + 'openRightbar(track: boolean, fullscreen: boolean): void', + 'closeRightbar(): void', + ]) expect(SERVICE_API.find(service => service.key === 'workspaces')?.methods.map(method => method.signature)) .toEqual([ 'create(input: { path: string }): Promise', @@ -13,6 +21,9 @@ describe('Client Cordis inspect catalog', () => { ]) expect(SERVICE_API.find(service => service.key === 'uiWorkspace')?.methods.map(method => method.signature)) .toEqual([ + 'openSession(sessionId: SessionId): void', + 'openWorkspace(workspaceId: WorkspaceId, beforeOpen?: (sessionId: SessionId) => void): Promise', + 'forkSession(sessionId: SessionId): Promise', 'connectWorkspace(workspaceId: WorkspaceId): Promise', 'startSession(workspaceId?: WorkspaceId): void', 'archiveSession(sessionId: SessionId): Promise', diff --git a/packages/extensions/tool-cordis/src/api-catalog.ts b/packages/extensions/tool-cordis/src/api-catalog.ts index f74e1d9e51..149a4e20d8 100644 --- a/packages/extensions/tool-cordis/src/api-catalog.ts +++ b/packages/extensions/tool-cordis/src/api-catalog.ts @@ -2351,7 +2351,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ }, { signature: 'async start(name: string, request: SubagentStartRequest): Promise', - description: 'Establish a published child on the named provider. Capability and semantic checks run before delegation. Provider ownership lasts until its promise fulfills; a rejection therefore has no run for the caller to dispose and emits no run lifecycle events. Post-publication turn and infrastructure failures settle through the returned run.', + description: 'Establish a published child on the named provider. Capability and semantic checks run before delegation. Provider ownership lasts until its promise fulfills; a rejection therefore has no run for the caller to dispose and emits no run lifecycle events. Post-publication turn and infrastructure failures settle through the returned run. A catalog append failure disposes the run and handles its result rejection; the caller receives the catalog error even if disposal also fails.', parameters: [{ name: 'name', description: 'the provider to use.' }, { name: 'request', description: 'child label, prompt, parent, signal, and optional capabilities.' }], returns: 'the published holder-owned run.', }, diff --git a/packages/subagent/subagent/README.i18n.yaml b/packages/subagent/subagent/README.i18n.yaml index f9edd9b52a..9a6bff0b24 100644 --- a/packages/subagent/subagent/README.i18n.yaml +++ b/packages/subagent/subagent/README.i18n.yaml @@ -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/subagent/subagent/README.md -README.md: 6b58f36fcd4d6e8a198ddc86e80ccc28491a0a34 -README.zh.md: be99e7c91f13c6d83a6375047ec5f21d55b21053 +README.md: 5f9668fd7a14cdfca33ab72a217c07372a24502f +README.zh.md: 563ed06237088c4bc250ca3c4f5a863b6ffe8239 diff --git a/packages/subagent/subagent/README.md b/packages/subagent/subagent/README.md index 6b58f36fcd..5f9668fd7a 100644 --- a/packages/subagent/subagent/README.md +++ b/packages/subagent/subagent/README.md @@ -96,6 +96,8 @@ A request is validated against the provider's advertised capabilities, a durable The manager reserves a child identity, resolves the durable descriptor, creates (or cold-resumes) the child Agent, installs it in an Activation, and submits the prompt. Model-authored messages cross one parent/child edge through fixed Steer scheduling; browser human prompts choose Queue or best-effort Steer through an internal adapter, while other host protocols may retain Queue for distinct turns. A Session queue command admits a live subagent-owned Agent only from its own continuable descriptor. Settlement waits for Agent activity to finish, an empty Inbox, and no owned children, then flushes final Session state with admission open. Under the child lock, the manager revalidates the wake generation, Session sequence, Inbox, and owned children; the synchronous task entry of `Agent.runMaintenance()` claims the idle phase and closes the private subagent Inbox in the same JavaScript turn before handle disposal. An absent direct-child Activation cold-resumes from the persisted session. When a resident Activation settles, the manager tells the child's direct parent in the parent's own turn stream. +Successful local child creation appends a `subagent/catalog` fact to the parent Session. One-shot creation records it after the provider returns; continuable creation records it after initial inbox admission and before returning the child id. Failure releases the child without publishing a compensating catalog event. A one-shot catalog append failure handles the run’s result rejection and preserves the catalog error; disposal failures are logged separately. The `subagentCatalog` projection excludes fork-inherited facts and exposes a direct-child list through `projections.values.subagentCatalog` in Session observations and client snapshots. Invalid own catalog payloads, including unsupported versions, reject projection restoration. Its immutable storage and checkpoint validation use [`dsh-chunked-list`](../../util/chunked-list/README.md). Its view preserves parent catalog event order in O(D) time for D facts. [The parent-catalog decision](../../../.agents/notes/implemented/architecture/2026-09-01-parent-owned-subagent-catalog.md) owns ordering, persistence costs, and alternatives. + ### Ownership and invariants - **Publication is the boundary** — before it the provider owns the setup and must roll back on failure; after it the caller owns the run and must dispose it. diff --git a/packages/subagent/subagent/README.zh.md b/packages/subagent/subagent/README.zh.md index be99e7c91f..563ed06237 100644 --- a/packages/subagent/subagent/README.zh.md +++ b/packages/subagent/subagent/README.zh.md @@ -96,6 +96,8 @@ kind: "package-reference" 管理器预留 child 身份、解析持久化描述符、创建(或冷恢复)child、把它安装进 Activation 并提交提示词。模型编写的消息通过固定 Steer 调度跨一条 parent/child 边;浏览器人类 prompt 通过内部适配器选择 Queue 或 best-effort Steer,其他 host 协议仍可保留 Queue 以创建独立轮次。Session queue command 仅根据 child 自身的 continuable descriptor 准入在线 subagent-owned Agent。Settlement 会等待 Agent 活动结束、Inbox 为空且没有所拥有子级,再在准入开放时 flush 最终 Session 状态。管理器随后在 child lock 内重新验证 wake generation、Session 序号、Inbox 与所拥有子级;`Agent.runMaintenance()` 的同步 task 入口会占用 idle 阶段,并在同一个 JavaScript turn 内关闭私有 subagent Inbox,然后才 dispose handle。直接 child 不存在 Activation 时会从持久化会话冷恢复。当驻留 Activation 结算时,管理器会在 parent 自身的轮次流中告知该 child 的直接 parent。 +本地子级创建成功时,父 Session 追加一条 `subagent/catalog` 事实。一次性创建在 provider 返回后记录;可继续创建在初始 inbox 准入后、返回子级 id 前记录。失败会释放子级,不发布补偿性目录事件。一次性目录追加失败时会处理 run 的结果拒绝,并保留目录错误;资源释放失败会单独记录。`subagentCatalog` projection 排除 fork 继承的事实,通过 Session 观察和客户端快照中的 `projections.values.subagentCatalog` 暴露直接子级列表。无效的自身 catalog payload(包括不支持的版本)会使 projection 恢复失败。其不可变存储和检查点校验使用 [`dsh-chunked-list`](../../util/chunked-list/README.zh.md)。其视图对 D 条事实以 O(D) 时间保留父目录事件顺序。[父目录决策](../../../.agents/notes/implemented/architecture/2026-09-01-parent-owned-subagent-catalog.zh.md) 说明排序、持久化成本和替代方案。 + ### 所有权与不变式 - **发布即边界**——发布前提供方拥有设置并须在失败时回滚;发布后调用方拥有运行并须 dispose(资源释放)它。 diff --git a/packages/subagent/subagent/package.json b/packages/subagent/subagent/package.json index 306236896d..22e000973f 100644 --- a/packages/subagent/subagent/package.json +++ b/packages/subagent/subagent/package.json @@ -54,6 +54,7 @@ "license": "MIT", "dependencies": { "@deepseek-ai/dsh-brand": "workspace:^", + "@deepseek-ai/dsh-chunked-list": "workspace:^", "@deepseek-ai/dsh-util-values": "workspace:^", "zod": "^4.4.3" }, diff --git a/packages/subagent/subagent/src/catalog.ts b/packages/subagent/subagent/src/catalog.ts new file mode 100644 index 0000000000..3ec29bbd2f --- /dev/null +++ b/packages/subagent/subagent/src/catalog.ts @@ -0,0 +1,156 @@ +/** + * Parent-owned durable subagent catalog events and their chunked projection. + * + * @module @deepseek-ai/dsh-subagent/catalog + */ + +import { z } from 'zod' +import { appendChunkedList, chunkedListSchema, iterateChunkedList } from '@deepseek-ai/dsh-chunked-list' +import type { ChunkedList } from '@deepseek-ai/dsh-chunked-list' +import type { + Session, + SessionEvent, + SessionHeader, + SessionId, + SessionLogOffset, +} from '@deepseek-ai/dsh-session' +import type { ProjectionDefinition } from '@deepseek-ai/dsh-session-projection' +import type { SubagentCatalogEntry } from './projection-types.ts' + +/** Current payload version for `subagent/catalog` events. */ +export const SUBAGENT_CATALOG_VERSION = 0 + +/** One complete parent-owned catalog fact. */ +export type SubagentCatalogEvent = + & { + readonly version: 0 + readonly childId: SessionId + readonly childCreatedAt: number + } & ( + | { readonly mode: 'one-shot'; readonly label?: string } + | { readonly mode: 'continuable'; readonly label: string } + ) + +declare module '@deepseek-ai/dsh-session/types' { + interface SessionEventMap { + /** + * A direct child's complete discovery fact. + * @param data - versioned parent-owned catalog entry. + */ + 'subagent/catalog': SubagentCatalogEvent + } +} + +/** Host fold state for one parent catalog. */ +export interface SubagentCatalogState { + readonly inheritedEventCount: SessionLogOffset + readonly head?: ChunkedList | undefined +} + +const sessionIdSchema = z.string() as unknown as z.ZodType +const oneShotCatalogSchema = z.object({ + version: z.literal(SUBAGENT_CATALOG_VERSION), + childId: sessionIdSchema, + childCreatedAt: z.number().int().nonnegative(), + mode: z.literal('one-shot'), + label: z.string().optional(), +}).strict() +const continuableCatalogSchema = z.object({ + version: z.literal(SUBAGENT_CATALOG_VERSION), + childId: sessionIdSchema, + childCreatedAt: z.number().int().nonnegative(), + mode: z.literal('continuable'), + label: z.string(), +}).strict() +const eventDataSchema = z.union([ + oneShotCatalogSchema, + continuableCatalogSchema, +]) as unknown as z.ZodType +const viewSchema = z.array(z.union([ + oneShotCatalogSchema.omit({ version: true, childId: true, childCreatedAt: true }).extend({ + id: sessionIdSchema, + createdAt: oneShotCatalogSchema.shape.childCreatedAt, + }), + continuableCatalogSchema.omit({ version: true, childId: true, childCreatedAt: true }).extend({ + id: sessionIdSchema, + createdAt: continuableCatalogSchema.shape.childCreatedAt, + }), +])) as unknown as z.ZodType +const stateSchema: z.ZodType = z.object({ + inheritedEventCount: z.number().int().nonnegative() as unknown as z.ZodType, + head: chunkedListSchema(eventDataSchema).optional(), +}).strict() + +declare module '@deepseek-ai/dsh-session-projection/types' { + interface SessionProjectionStateMap { + subagentCatalog: SubagentCatalogState + } +} + +/** + * Materialize direct children from their parent's successful creation facts. + * @param state - parent catalog fold state. + * @returns current direct-child rows in parent catalog event order. + */ +function subagentCatalogEntries(state: SubagentCatalogState): SubagentCatalogEntry[] { + const entries: SubagentCatalogEntry[] = [] + for (const data of iterateChunkedList(state.head)) { + entries.push(data.mode === 'one-shot' + ? { + id: data.childId, + createdAt: data.childCreatedAt, + mode: data.mode, + ...data.label === undefined ? {} : { label: data.label }, + } + : { + id: data.childId, + createdAt: data.childCreatedAt, + mode: data.mode, + label: data.label, + }) + } + return entries +} + +/** Parent-owned direct-child catalog projection; invalid own facts reject restoration. */ +export const subagentCatalogProjectionDefinition = { + key: 'subagentCatalog', + stateSchema, + init: (_header: SessionHeader, inheritedEventCount: SessionLogOffset) => ({ inheritedEventCount }), + apply: (state, event: SessionEvent) => { + if (event.type !== 'subagent/catalog' || event.seq < state.inheritedEventCount) return state + return { ...state, head: appendChunkedList(state.head, eventDataSchema.parse(event.data)) } + }, + stateVersion: 2, + wire: { viewSchema, view: subagentCatalogEntries }, +} satisfies ProjectionDefinition<'subagentCatalog', SubagentCatalogState> + +/** + * Append a complete direct-child discovery fact to its parent Session. + * @param parent - durable direct parent receiving the discovery fact. + * @param child - established child's immutable Session metadata. + * @param descriptor - mode-discriminated creation label frozen with the child. + */ +export function establishCatalogChild( + parent: Session, + child: SessionHeader, + descriptor: + | { readonly mode: 'one-shot'; readonly label?: string } + | { readonly mode: 'continuable'; readonly label: string }, +): void { + parent.append('subagent/catalog', descriptor.mode === 'one-shot' + ? { + version: SUBAGENT_CATALOG_VERSION, + childId: child.id, + childCreatedAt: child.createdAt, + mode: descriptor.mode, + ...descriptor.label === undefined ? {} : { label: descriptor.label }, + } + : { + version: SUBAGENT_CATALOG_VERSION, + childId: child.id, + childCreatedAt: child.createdAt, + mode: descriptor.mode, + label: descriptor.label, + }) +} diff --git a/packages/subagent/subagent/src/client.ts b/packages/subagent/subagent/src/client.ts index 8a643d6284..baa704b200 100644 --- a/packages/subagent/subagent/src/client.ts +++ b/packages/subagent/subagent/src/client.ts @@ -5,4 +5,4 @@ */ export type * from './control-types.ts' -export type { SubagentIdentityProjection, SubagentTimingProjection } from './projection-types.ts' +export type { SubagentCatalogEntry, SubagentIdentityProjection, SubagentTimingProjection } from './projection-types.ts' diff --git a/packages/subagent/subagent/src/continuation-activation.ts b/packages/subagent/subagent/src/continuation-activation.ts index 2579e4aa5b..df2b6da439 100644 --- a/packages/subagent/subagent/src/continuation-activation.ts +++ b/packages/subagent/subagent/src/continuation-activation.ts @@ -499,7 +499,6 @@ export class ContinuableActivationRegistry { } finally { this.wake(activation) } - activation.announced = true return message.id } diff --git a/packages/subagent/subagent/src/continuation.ts b/packages/subagent/subagent/src/continuation.ts index b5ffced75e..b86d5cd836 100644 --- a/packages/subagent/subagent/src/continuation.ts +++ b/packages/subagent/subagent/src/continuation.ts @@ -39,6 +39,7 @@ import { } from './continuation-messages.ts' import { assertSubagentMaxDepth } from './depth.ts' import { foldSubagentDescriptor, snapshotSubagentDescriptor } from './descriptor.ts' +import { establishCatalogChild } from './catalog.ts' import { SubagentError } from './error.ts' import { isAdjacentAgentSendMessageTool } from './internal.ts' import type { ActivationObserver } from './lifecycle.ts' @@ -170,13 +171,15 @@ export class SubagentContinuationManager { composition: { persona: request.persona, toolFilter: request.toolFilter }, signal: spec.signal, }) - return this.submitMaterialized( + const childHeader = activation.handle.agent.session.header + return await this.submitMaterialized( activation, isAdjacentAgentSendMessageTool(this.ctx.get('tools')?.get('send_message', activation.handle.agent)) ? withContinuableReturnGuidance(parent.id, request.prompt) : request.prompt, { source: { kind: 'user' }, signal: spec.signal, delivery: 'queue' }, parent, + () => { establishCatalogChild(parent.session, childHeader, descriptor) }, ) }) return { childId, messageId } @@ -307,7 +310,9 @@ export class SubagentContinuationManager { return undefined } } - return this.submitAdmitted(activation, content, options, parent) + const messageId = this.submitAdmitted(activation, content, options, parent) + activation.announced = true + return messageId }) /* v8 ignore start -- only a delivery that lost the disposal cutoff retries. */ if (live !== undefined) return live @@ -448,12 +453,13 @@ export class SubagentContinuationManager { return await this.submitMaterialized(activation, content, options, parent) } - /** Submit to a freshly materialized Activation or roll it back completely. */ + /** Admit a materialized child, commit its creation fact, and release it on failure. */ private async submitMaterialized( activation: Activation, content: ContentBlock[], options: ChildDeliveryOptions, parent: Agent, + commit?: () => void, ): Promise { try { if (contentHasImage(content)) { @@ -462,11 +468,18 @@ export class SubagentContinuationManager { throw new SubagentError(`subagent "${activation.childId}" is closing`, 'ACTIVATION_CLOSING') } } - return this.submitAdmitted(activation, content, options, parent) + const messageId = this.submitAdmitted(activation, content, options, parent) + commit?.() + activation.announced = true + return messageId } catch (error: unknown) { - /* v8 ignore next -- rollback disposal failures must not mask the - * pre-acceptance signal, drain, or lifecycle failure. */ - await this.activations.dispose(activation).catch(() => undefined) + try { + await this.activations.dispose(activation) + } catch (cleanupError: unknown) { + this.ctx.logger.warn( + `subagent continuation: disposal after admission or catalog append failure also failed: ${String(cleanupError)}`, + ) + } throw error } } diff --git a/packages/subagent/subagent/src/index.ts b/packages/subagent/subagent/src/index.ts index 88977cdcae..8a91e06e56 100644 --- a/packages/subagent/subagent/src/index.ts +++ b/packages/subagent/subagent/src/index.ts @@ -74,8 +74,10 @@ import { listChildren as listSubagentChildren, listDescendants as listSubagentDe import type { SubagentDescendantListEntry, SubagentListEntry } from './list-children.ts' import { snapshotSubagentDescriptor } from './descriptor.ts' import { subagentIdentityProjectionDefinition, subagentTimingProjectionDefinition } from './projection.ts' +import { establishCatalogChild, subagentCatalogProjectionDefinition } from './catalog.ts' import { deliverSubagentPrompt } from './internal.ts' +export type {} from './catalog.ts' export * from './out-of-process.ts' export { AssistantOutputFold, finalAssistantOutput } from './assistant-output.ts' export { SubagentRunId } from './types.ts' @@ -208,6 +210,7 @@ export class SubagentRuntime extends TypertRemoteService { }, 'subagents.continuationBinding()') }) ctx.inject(['sessionProjections'], (projectionCtx) => { + projectionCtx.sessionProjections.register(subagentCatalogProjectionDefinition) projectionCtx.sessionProjections.register(subagentTimingProjectionDefinition) projectionCtx.sessionProjections.register(subagentIdentityProjectionDefinition) }) @@ -544,6 +547,8 @@ export class SubagentRuntime extends TypertRemoteService { * fulfills; a rejection therefore has no run for the caller to dispose and * emits no run lifecycle events. Post-publication turn and infrastructure * failures settle through the returned run. + * A catalog append failure disposes the run and handles its result rejection; + * the caller receives the catalog error even if disposal also fails. * @param name - the provider to use. * @param request - child label, prompt, parent, signal, and optional capabilities. * @returns the published holder-owned run. @@ -559,7 +564,25 @@ export class SubagentRuntime extends TypertRemoteService { ...request.label !== undefined ? { label: request.label } : {}, }) const resolved: ResolvedSubagentStartRequest = { ...request, descriptor } - return observeRun(this.emitLifecycle, name, request.parent, await provider.start(resolved)) + const run = await provider.start(resolved) + const child = run.localAgent?.session + if (child !== undefined) { + try { + establishCatalogChild(request.parent.session, child.header, descriptor) + } catch (error: unknown) { + // No caller receives this run; the catalog error owns the failed start. + void run.result.catch(() => undefined) + try { + await run.dispose() + } catch (cleanupError: unknown) { + this.ctx.logger.warn( + `subagent: disposal after catalog append failure also failed: ${String(cleanupError)}`, + ) + } + throw error + } + } + return observeRun(this.emitLifecycle, name, request.parent, run) } /** diff --git a/packages/subagent/subagent/src/projection-types.ts b/packages/subagent/subagent/src/projection-types.ts index bf31d8b0aa..931459df3d 100644 --- a/packages/subagent/subagent/src/projection-types.ts +++ b/packages/subagent/subagent/src/projection-types.ts @@ -4,7 +4,18 @@ * @module @deepseek-ai/dsh-subagent/projection-types */ -import type { SessionSeq } from '@deepseek-ai/dsh-session/types' +import type { SessionId, SessionSeq } from '@deepseek-ai/dsh-session/types' + +/** One current direct-child discovery row materialized from parent facts. */ +export type SubagentCatalogEntry = + & { + readonly id: SessionId + readonly createdAt: number + } + & ( + | { readonly mode: 'one-shot'; readonly label?: string } + | { readonly mode: 'continuable'; readonly label: string } + ) /** Durable active-turn timing for one descriptor-backed child session. */ export interface SubagentTimingProjection { @@ -50,6 +61,8 @@ export type SubagentIdentityProjection = declare module '@deepseek-ai/dsh-session-projection/types' { interface SessionProjectionMap { + /** Direct children in parent catalog event order, excluding fork-inherited facts. */ + subagentCatalog: SubagentCatalogEntry[] /** Active-turn duration for a descriptor-backed subagent session. */ subagentTiming: SubagentTimingProjection /** diff --git a/packages/subagent/subagent/tests/catalog.spec.ts b/packages/subagent/subagent/tests/catalog.spec.ts new file mode 100644 index 0000000000..cfd51813fe --- /dev/null +++ b/packages/subagent/subagent/tests/catalog.spec.ts @@ -0,0 +1,161 @@ +import { describe, expect, it, vi } from 'vitest' +import { z } from 'zod' +import { Context } from '@deepseek-ai/cordis' +import SessionStore from '@deepseek-ai/dsh-session' +import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' +import { + SessionLogOffset, + SessionSeq, + SESSION_FORMAT_VERSION, + SessionId, +} from '@deepseek-ai/dsh-session' +import type { SessionEvent, SessionHeader } from '@deepseek-ai/dsh-session' +import { + subagentCatalogProjectionDefinition, +} from '../src/catalog.ts' +import type { SubagentCatalogState } from '../src/catalog.ts' + +const header: SessionHeader = { + version: SESSION_FORMAT_VERSION, + id: SessionId('catalog-parent'), + createdAt: 0, + isSeeded: false, +} + +/** Fold one event list through the pure definition, as the registry does for a log. */ +function fold(events: readonly SessionEvent[]): SubagentCatalogState { + let state = subagentCatalogProjectionDefinition.init(header, SessionLogOffset(0)) + for (const event of events) state = subagentCatalogProjectionDefinition.apply(state, event) + return state +} + +function fact( + seq: number, + childId: string, + childCreatedAt: number, + descriptor: + | { readonly mode: 'one-shot'; readonly label?: string } + | { readonly mode: 'continuable'; readonly label: string }, +): SessionEvent<'subagent/catalog'> { + return { + type: 'subagent/catalog', + seq: SessionSeq(seq), + time: 0, + data: { + version: 0, + childId: SessionId(childId), + childCreatedAt, + ...descriptor, + }, + } +} + +describe('subagent catalog projection', () => { + it('publishes detached catalog views and changes only for new own facts', async () => { + const ctx = new Context() + try { + await ctx.plugin(SessionStore) + await ctx.plugin(SessionProjectionRegistry) + ctx.sessionProjections.register(subagentCatalogProjectionDefinition) + const parent = ctx.sessions.create(header.id) + const changes = vi.fn() + ctx.sessionProjections.onChanged(changes) + expect(ctx.sessionProjections.snapshot(parent).values.subagentCatalog).toEqual([]) + + parent.append('subagent/catalog', fact(0, 'first', 1, { mode: 'one-shot' }).data) + const first = ctx.sessionProjections.snapshot(parent) + expect(first.values.subagentCatalog).toEqual([ + { id: SessionId('first'), createdAt: 1, mode: 'one-shot' }, + ]) + changes.mockClear() + parent.append('session/title', { title: 'Parent', messageSeqs: [], source: { kind: 'user' } }) + expect(changes).not.toHaveBeenCalled() + parent.append('subagent/catalog', fact(2, 'second', 2, { mode: 'continuable', label: 'Next' }).data) + expect(changes).toHaveBeenCalledOnce() + expect(first.values.subagentCatalog).toHaveLength(1) + const second = ctx.sessionProjections.snapshot(parent) + expect(second.values.subagentCatalog).toHaveLength(2) + second.values.subagentCatalog?.pop() + expect(ctx.sessionProjections.snapshot(parent).values.subagentCatalog).toHaveLength(2) + } finally { + await ctx.fiber.dispose() + } + }) + + it('preserves event order across chunk rollover and checkpoint restoration', () => { + const first = fold(Array.from({ length: 64 }, (_, i) => + fact(i, `child-${i}`, 130 - i, { mode: 'one-shot' }))) + const checkpoint = JSON.stringify(first) + let state = first + for (let i = 64; i < 130; i += 1) { + state = subagentCatalogProjectionDefinition.apply( + state, fact(i, `child-${i}`, 130 - i, { mode: 'one-shot' }), + ) + } + const restored = subagentCatalogProjectionDefinition.stateSchema.parse( + JSON.parse(JSON.stringify(state)), + ) + + expect(JSON.stringify(first)).toBe(checkpoint) + expect(subagentCatalogProjectionDefinition.wire.view(first)).toHaveLength(64) + expect(subagentCatalogProjectionDefinition.wire.view(restored).map(entry => entry.id)) + .toEqual(Array.from({ length: 130 }, (_, i) => SessionId(`child-${i}`))) + }) + + it('validates recursive chunk state and materializes every label variant in event order', () => { + const events: SessionEvent[] = [ + fact(0, 'child-b', 1, { mode: 'one-shot' }), + fact(1, 'child-a', 1, { mode: 'one-shot', label: 'once' }), + fact(2, 'child-d', 3, { mode: 'continuable', label: 'later' }), + ] + const state = fold(events) + + expect(subagentCatalogProjectionDefinition.stateSchema.parse(JSON.parse(JSON.stringify(state)))) + .toEqual(state) + const view = subagentCatalogProjectionDefinition.wire.view(state) + expect(subagentCatalogProjectionDefinition.wire.viewSchema.parse(JSON.parse(JSON.stringify(view)))).toEqual(view) + expect(() => subagentCatalogProjectionDefinition.wire.viewSchema.parse([ + { id: 'bad', createdAt: 1, mode: 'continuable' }, + ])).toThrow() + expect(view).toEqual([ + { id: SessionId('child-b'), createdAt: 1, mode: 'one-shot' }, + { id: SessionId('child-a'), createdAt: 1, mode: 'one-shot', label: 'once' }, + { id: SessionId('child-d'), createdAt: 3, mode: 'continuable', label: 'later' }, + ]) + }) + + it('ignores unrelated and inherited events without changing state', () => { + const seededHeader = { ...header, isSeeded: true } + const initial = subagentCatalogProjectionDefinition.init(seededHeader, SessionLogOffset(2)) + const unrelated = subagentCatalogProjectionDefinition.apply(initial, { + type: 'turn/start', seq: SessionSeq(2), time: 0, data: { turn: 1 }, + }) + const inherited = subagentCatalogProjectionDefinition.apply( + unrelated, + fact(1, 'inherited', 1, { mode: 'one-shot' }), + ) + + expect(unrelated).toBe(initial) + expect(inherited).toBe(initial) + }) + + it.each([ + { version: 9, childId: 'child', childCreatedAt: 0, mode: 'one-shot' }, + { version: 0, childId: 'child', childCreatedAt: 0, mode: 'continuable' }, + { version: 0, childId: 'child', childCreatedAt: -1, mode: 'one-shot' }, + ])('refuses to restore a catalog containing an invalid fact: %j', async (data) => { + const ctx = new Context() + try { + await ctx.plugin(SessionStore) + await ctx.plugin(SessionProjectionRegistry) + ctx.sessionProjections.register(subagentCatalogProjectionDefinition) + const events = [ + fact(0, 'valid-child', 0, { mode: 'one-shot' }), + { type: 'subagent/catalog', seq: SessionSeq(1), time: 0, data } as unknown as SessionEvent, + ] + expect(() => ctx.sessionProjections.restore({}, events, SessionLogOffset(0), header, SessionLogOffset(0))).toThrow(z.ZodError) + } finally { + await ctx.fiber.dispose() + } + }) +}) diff --git a/packages/subagent/subagent/tests/continuation.spec.ts b/packages/subagent/subagent/tests/continuation.spec.ts index 298183cdc1..f2bbd16ef5 100644 --- a/packages/subagent/subagent/tests/continuation.spec.ts +++ b/packages/subagent/subagent/tests/continuation.spec.ts @@ -7,7 +7,7 @@ import type { Agent } from '@deepseek-ai/dsh-agent' import AgentLoop from '@deepseek-ai/dsh-agent-loop' import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit' import { SessionId } from '@deepseek-ai/dsh-session' -import type { SessionEvent } from '@deepseek-ai/dsh-session' +import type { SessionEvent, SessionEventMap } from '@deepseek-ai/dsh-session' import JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl' import * as toolSchedule from '@deepseek-ai/dsh-schedule' import * as SubagentSpawn from '@deepseek-ai/dsh-subagent-spawn-in-process' @@ -421,6 +421,67 @@ describe('SubagentRuntime.startContinuable', () => { }) }) + it('releases the Activation when the parent catalog append fails', async () => { + const { ctx, parent } = await setup([textResponse('unused')]) + const childId = SessionId('00000000-0000-4000-8000-000000000321') + const catalogFailure = new Error('catalog append failed') + const cleanupFailure = new Error('activation disposal also failed') + const appendCatalog = parent.session.append.bind(parent.session) as ( + type: 'subagent/catalog', + data: SessionEventMap['subagent/catalog'], + ) => SessionEvent<'subagent/catalog'> + vi.spyOn(parent.session, 'append').mockImplementation(((type: string, data: unknown) => { + if (type === 'subagent/catalog') { + const activation = continuationActivations(ctx).get(childId) + if (activation === undefined) throw new Error('expected live Activation') + const dispose = activation.handle.dispose.bind(activation.handle) + vi.spyOn(activation.handle, 'dispose').mockImplementation(async () => { + await dispose() + throw cleanupFailure + }) + throw catalogFailure + } + return appendCatalog('subagent/catalog', data as SessionEventMap['subagent/catalog']) + }) as typeof parent.session.append) + + await expect(ctx.subagents.startContinuable({ + ...startSpec(parent), + childId, + })).rejects.toBe(catalogFailure) + + await vi.waitFor(() => { expect(ctx.agents.get(childId)).toBeUndefined() }) + expect(parent.session.snapshotEvents().filter(event => event.type === 'subagent/catalog')).toEqual([]) + }) + + it('preserves prompt admission failure when Activation disposal also fails', async () => { + const { ctx, parent } = await setup([textResponse('unused')]) + const childId = SessionId('00000000-0000-4000-8000-000000000322') + const admissionFailure = new Error('prompt admission failed') + const cleanupFailure = new Error('activation disposal also failed') + const controller = new AbortController() + const warnings: string[] = [] + ctx.logger.warn = (message: string) => { warnings.push(message) } + ctx.on('subagent/start', () => { + const activation = continuationActivations(ctx).get(childId) + if (activation === undefined) throw new Error('expected live Activation') + const dispose = activation.handle.dispose.bind(activation.handle) + vi.spyOn(activation.handle, 'dispose').mockImplementation(async () => { + await dispose() + throw cleanupFailure + }) + controller.abort(admissionFailure) + }) + + await expect(ctx.subagents.startContinuable({ + ...startSpec(parent, 'spawn', controller.signal), + childId, + })).rejects.toBe(admissionFailure) + await vi.waitFor(() => { expect(ctx.agents.get(childId)).toBeUndefined() }) + expect(warnings.some(warning => warning.startsWith( + 'subagent continuation: disposal after admission or catalog append failure also failed:', + ))).toBe(true) + }) + it('rolls an unpublished Activation back when lifecycle publication fails', async () => { const { ctx, parent } = await setup([textResponse('unused')]) const ends: SubagentRunEndInfo[] = [] diff --git a/packages/subagent/subagent/tests/service.spec.ts b/packages/subagent/subagent/tests/service.spec.ts index e74c684405..f40fc11dff 100644 --- a/packages/subagent/subagent/tests/service.spec.ts +++ b/packages/subagent/subagent/tests/service.spec.ts @@ -1,4 +1,4 @@ -import { describe, expect, expectTypeOf, it, vi } from 'vitest' +import { describe, expect, expectTypeOf, it, onTestFinished, vi } from 'vitest' import { Context } from '@deepseek-ai/cordis' import { type Agent } from '@deepseek-ai/dsh-agent' @@ -18,7 +18,7 @@ import SubagentRuntime, { type SubagentRunEndInfo, type SubagentStartRequest, } from '@deepseek-ai/dsh-subagent' -import { SessionId, type SessionEvent } from '@deepseek-ai/dsh-session' +import { Session, SessionId, type SessionEvent } from '@deepseek-ai/dsh-session' import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' function fakeParent(id = 'parent-1'): Agent { @@ -73,6 +73,24 @@ async function service(): Promise<{ ctx: Context; subagents: SubagentRuntime }> } describe('SubagentRuntime', () => { + it('releases its catalog projection binding with the service fiber', async () => { + const ctx = new Context() + await ctx.plugin(SessionProjectionRegistry) + const fiber = await ctx.plugin(SubagentRuntime) + const parent = Session.create(SessionId('catalog-parent')) + parent.append('subagent/catalog', { + version: 0, + childId: SessionId('catalog-child'), + childCreatedAt: 1, + mode: 'one-shot', + }) + expect(ctx.sessionProjections.snapshot(parent).values.subagentCatalog).toHaveLength(1) + + await fiber.dispose() + + expect(ctx.sessionProjections.stateOf(parent, 'subagentCatalog')).toBeUndefined() + }) + it('registers, lists, looks up, starts, and removes providers', async () => { const { ctx, subagents } = await service() const added: string[] = [] @@ -252,6 +270,45 @@ describe('SubagentRuntime', () => { expect(lifecycle).not.toHaveBeenCalled() }) + it.each([false, true])('handles a rejected local result after catalog failure (disposal fails: %s)', async (failsDisposal) => { + const { ctx, subagents } = await service() + onTestFinished(() => ctx.fiber.dispose()) + const parentSession = Session.create(SessionId('catalog-parent')) + const childSession = Session.create(SessionId('catalog-child')) + const parent = { id: parentSession.id, session: parentSession } as Agent + const localAgent = { id: childSession.id, session: childSession } as Agent + const result = Promise.withResolvers() + const cleanupFailure = new Error('dispose also failed') + const warnings = vi.spyOn(ctx.logger, 'warn') + const dispose = vi.fn(async () => { + result.reject(new Error('run infrastructure failed')) + // Cross Node's unhandled-rejection checkpoint while disposal is pending. + await new Promise(resolve => setImmediate(resolve)) + if (failsDisposal) throw cleanupFailure + }) + subagents.registerProvider({ + name: 'catalog-failure', + capabilities: NO_CAPS, + inheritsParentContext: false, + start: () => Promise.resolve({ + id: childSession.id, + localAgent, + result: result.promise, + dispose, + }), + }) + const catalogFailure = new Error('catalog unavailable') + const append = vi.spyOn(parentSession, 'append').mockImplementation(() => { + throw catalogFailure + }) + + await expect(subagents.start('catalog-failure', baseRequest({ parent }))) + .rejects.toBe(catalogFailure) + expect(append).toHaveBeenCalledOnce() + expect(dispose).toHaveBeenCalledOnce() + expect(warnings).toHaveBeenCalledTimes(failsDisposal ? 1 : 0) + }) + it('emits an enriched end event and maps result rejection to error telemetry', async () => { const { ctx, subagents } = await service() const completed = new StubProvider('completed', NO_CAPS, { diff --git a/packages/subagent/subagent/tsconfig.json b/packages/subagent/subagent/tsconfig.json index 4a90ca128c..923a2e119c 100644 --- a/packages/subagent/subagent/tsconfig.json +++ b/packages/subagent/subagent/tsconfig.json @@ -67,6 +67,9 @@ }, { "path": "../../runtime-diagnostics/invariants" + }, + { + "path": "../../util/chunked-list" } ] } diff --git a/packages/test-support/client-runtime/README.i18n.yaml b/packages/test-support/client-runtime/README.i18n.yaml index 9c209aae99..67a7969a90 100644 --- a/packages/test-support/client-runtime/README.i18n.yaml +++ b/packages/test-support/client-runtime/README.i18n.yaml @@ -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/test-support/client-runtime/README.md -README.md: 616eab2cd5ef03ae4e514ca335b0c60b43d62cf0 -README.zh.md: 08effcef7eaacad67b5496507925337e772d4ce9 +README.md: 0b6caf7237520ca1a10fe0bbdf3c12c6d793461f +README.zh.md: 40d27ac534f0f93d539101e2006a37a918318a81 diff --git a/packages/test-support/client-runtime/README.md b/packages/test-support/client-runtime/README.md index 616eab2cd5..0b6caf7237 100644 --- a/packages/test-support/client-runtime/README.md +++ b/packages/test-support/client-runtime/README.md @@ -29,7 +29,7 @@ This package gives a browser feature spec a real runtime to mount against: creat ### Setting up a feature spec -`SlotTestRuntime.create()` assembles the runtime, `declare(children)` registers an auto frame whose per-key `
` wrappers become snapshot roots, `mount(plugin)` runs the feature on a real fiber, and `renderSlot(key, owner)` returns the slot-local view with scoped queries and in-place updates: +`SlotTestRuntime.create()` assembles the runtime, `declare(children)` registers an auto frame whose per-key `
` wrappers become snapshot roots, `mount(plugin)` runs the feature on a real fiber, and `renderSlot(key, owner, opts?)` returns the slot-local view with scoped queries and in-place updates: ```text const runtime = await SlotTestRuntime.create() @@ -42,6 +42,8 @@ await runtime.dispose() `mount` prechecks required services and fails loud when one is missing — `provide(name, value)` supplies an extra service first. The runtime provides an unavailable `fileUpload` stub so assemblies can mount; replace `runtime.fileUpload.upload` before mounting when a test exercises upload behavior. `storeOf(key, scopeKey)` returns the live store instance the renderer hands a slot's component for identity and action-driven-write assertions. +The optional render options select a keyed entry with `entryKey` or a list item with `only`; `view.update(owner)` retains that selection. `runtime.panelInfo` supplies the default `usePanelInfo` source with no global panel selected. Release it with `releasePanelInfoSource()` before mounting the production Layout owner. `dispose()` releases both default Workspace and panel-info root sources; early release is idempotent and does not remove replacement owners. + ### Local DOM snapshots A registered snapshot serializer folds CSS-module class hashes (`_frame_a1b2c3` → `frame`) so `.snap` files stay structural, and collapses `` internals to a `data-content` fingerprint. Suites needing a custom page frame use `root.declare(children, Frame)` instead of the auto frame; `dispose()` tears down views, feature fibers, minted scopes, and persisted store state on one axis and is idempotent. diff --git a/packages/test-support/client-runtime/README.zh.md b/packages/test-support/client-runtime/README.zh.md index 08effcef7e..40d27ac534 100644 --- a/packages/test-support/client-runtime/README.zh.md +++ b/packages/test-support/client-runtime/README.zh.md @@ -29,7 +29,7 @@ kind: "package-library" ### 搭建功能测试 -`SlotTestRuntime.create()` 组装运行时,`declare(children)` 注册一个自动 frame,其逐 key 的 `
` 包裹层成为快照根,`mount(plugin)` 在真实 fiber 上运行功能,`renderSlot(key, owner)` 返回带限定查询与原位更新的 slot 局部视图: +`SlotTestRuntime.create()` 组装运行时,`declare(children)` 注册一个自动 frame,其逐 key 的 `
` 包裹层成为快照根,`mount(plugin)` 在真实 fiber 上运行功能,`renderSlot(key, owner, opts?)` 返回带限定查询与原位更新的 slot 局部视图: ```text const runtime = await SlotTestRuntime.create() @@ -42,6 +42,8 @@ await runtime.dispose() `mount` 会预检必需服务,缺失时自明报错——先用 `provide(name, value)` 提供额外服务。运行时会提供不可用的 `fileUpload` 替身,使装配可以挂载;测试上传行为时,需要在挂载前替换 `runtime.fileUpload.upload`。`storeOf(key, scopeKey)` 返回渲染器交给 slot 组件的实时 store 实例,用于身份与动作驱动写入断言。 +可选渲染参数通过 `entryKey` 选择 keyed 条目,或通过 `only` 选择 list 条目;`view.update(owner)` 保留该选择。`runtime.panelInfo` 提供默认的 `usePanelInfo` 数据源,初始不选中全局面板。挂载生产 Layout 所有者之前,先调用 `releasePanelInfoSource()` 释放该数据源。`dispose()` 同时释放默认的工作区与面板信息根数据源;提前释放是幂等的,不会移除替代它们的所有者。 + ### 局部 DOM 快照 注册的快照序列化器把 CSS-module 哈希类名折回语义名(`_frame_a1b2c3` → `frame`),使 `.snap` 文件只含结构,并把 `` 内部折叠为 `data-content` 指纹。需要自定义页面 frame 的套件改用 `root.declare(children, Frame)` 而非自动 frame;`dispose()` 沿单一轴拆除视图、feature fiber、已铸 scope 与持久化 store 状态,且幂等。 diff --git a/packages/test-support/client-runtime/package.json b/packages/test-support/client-runtime/package.json index 8d9984b6bb..88298cd72b 100644 --- a/packages/test-support/client-runtime/package.json +++ b/packages/test-support/client-runtime/package.json @@ -54,6 +54,7 @@ "@deepseek-ai/dsh-client-store": "workspace:^", "@deepseek-ai/dsh-client-ui-chat": "workspace:^", "@deepseek-ai/dsh-client-ui-conversation": "workspace:^", + "@deepseek-ai/dsh-client-ui-layout": "workspace:^", "@deepseek-ai/dsh-client-ui-renderer": "workspace:^", "@deepseek-ai/dsh-client-ui-session": "workspace:^", "@deepseek-ai/dsh-client-ui-settings": "workspace:^", diff --git a/packages/test-support/client-runtime/src/index.ts b/packages/test-support/client-runtime/src/index.ts index 7d0d37c9cb..23bc01bdfb 100644 --- a/packages/test-support/client-runtime/src/index.ts +++ b/packages/test-support/client-runtime/src/index.ts @@ -29,8 +29,10 @@ import { apply as applyUiSession, inject as uiSessionInject, } from '@deepseek-ai/dsh-client-ui-session/client' import type { SessionId } from '@deepseek-ai/dsh-session/types' +import { createSnapshotStore } from '@deepseek-ai/dsh-client-store' +import type { PanelInfo } from '@deepseek-ai/dsh-client-ui-layout/client' import type { - ChildrenDecl, ComposedProps, HostObservable, OwnerOf, SlotComponent, SlotMap, SlotRenderer, + ChildrenDecl, ComposedProps, HostObservable, OwnerOf, RenderOpts, SlotComponent, SlotMap, SlotRenderer, SlotRendererHost, SnapshotSelectorHook, StoreInstanceLike, } from '@deepseek-ai/dsh-client-ui-slots' import { registerDomSnapshotSerializer } from './snapshot.ts' @@ -125,7 +127,7 @@ export interface TestFileUpload { * {@link SlotView.update} drive React through the standard uSES boundary. */ class OwnerPropsCell { - private readonly owners = new Map() + private readonly owners = new Map() private readonly listeners = new Set<() => void>() private version = 0 @@ -147,15 +149,16 @@ class OwnerPropsCell { * caller wraps in act). * @param key - slot key. * @param owner - owner props share. + * @param opts - explicit keyed or list selection for the render site. */ - set(key: string, owner: object): void { - this.owners.set(key, owner) + set(key: string, owner: object, opts?: RenderOpts): void { + this.owners.set(key, { owner, opts }) this.version += 1 for (const fn of [...this.listeners]) fn() } /** Keys with supplied owner props, in first-supply order. */ - entries(): readonly (readonly [string, object])[] { + entries(): readonly (readonly [string, { owner: object; opts: RenderOpts | undefined }])[] { return [...this.owners.entries()] } } @@ -217,6 +220,8 @@ export class SlotTestRuntime { readonly sessions: TestSessions /** Workspaces double (list observable, recorded intent actions). */ readonly workspaces: TestWorkspaces + /** Test-owned panel selection used by the framework's usePanelInfo hook. */ + readonly panelInfo = createSnapshotStore({ activePanelId: null }) /** Mutable file-upload stub; replace `upload` in suites that exercise the capability. */ readonly fileUpload: TestFileUpload @@ -233,6 +238,7 @@ export class SlotTestRuntime { private readonly autoDeclared = new Set() private autoRootView: RenderResult | undefined private readonly disposeWorkspaceSource: () => void + private readonly disposePanelInfoSource: () => void private constructor(ctx: Context, slots: SlotRegistry) { this.ctx = ctx @@ -248,6 +254,7 @@ export class SlotTestRuntime { ctx.provide('workspaces', this.workspaces) ctx.provide('fileUpload', this.fileUpload as never) this.disposeWorkspaceSource = slots.provideRoot({ hooks: { workspaces: this.workspaces.list } }) + this.disposePanelInfoSource = slots.provideRoot({ hooks: { panelInfo: this.panelInfo } }) // Capturing install: the production renderer does the rendering; the // wrapper only takes the host face for storeOf (no machinery copied). const renderer = createSlotRenderer() @@ -309,6 +316,11 @@ export class SlotTestRuntime { this.disposeWorkspaceSource() } + /** Release the default panel hook before mounting the production Layout owner. */ + releasePanelInfoSource(): void { + this.disposePanelInfoSource() + } + /** * Render the root slot tree through the ctx-level entry (the shell's own * entry point): `ctx.slots.renderSlot('root', {})` under Testing Library. @@ -333,13 +345,13 @@ export class SlotTestRuntime { async declare(children: ChildrenDecl): Promise { for (const key of Object.keys(children)) this.autoDeclared.add(key) const cell = this.ownerCell - const AutoFrame = (props: { renderSlot: (key: string, owner: object) => ReactNode }) => { + const AutoFrame = (props: { renderSlot: (key: string, owner: object, opts?: RenderOpts) => ReactNode }) => { useSyncExternalStore(cell.subscribe, cell.getVersion) // Keyed Fragments only: the renderer's outlet anchor is the one // `[data-slot]` element — the frame adding its own would nest // duplicate anchors under the same key. - return createElement(Fragment, null, cell.entries().map(([key, owner]) => - createElement(Fragment, { key }, props.renderSlot(key, owner)))) + return createElement(Fragment, null, cell.entries().map(([key, { owner, opts }]) => + createElement(Fragment, { key }, props.renderSlot(key, owner, opts)))) } await this.root.declare(children as never, AutoFrame as never) } @@ -352,16 +364,17 @@ export class SlotTestRuntime { * slot of the same tree. * @param key - a key declared through {@link SlotTestRuntime.declare}. * @param owner - owner props share for the render site. + * @param opts - explicit keyed or list selection; retained by view updates. * @returns the slot-local view (snapshot container, scoped queries, owner updates). */ - renderSlot(key: K, owner: OwnerOf): SlotView { + renderSlot(key: K, owner: OwnerOf, opts?: RenderOpts): SlotView { if (!this.autoDeclared.has(key)) { throw new Error(`renderSlot('${key}') without declare() — declare the key first (or use root.declare for a custom frame)`) } const install = (next: object): void => { // Synchronous cell write inside act: the frame re-renders through uSES. act(() => { - this.ownerCell.set(key, next) + this.ownerCell.set(key, next, opts) }) } install(owner) @@ -410,7 +423,7 @@ export class SlotTestRuntime { /** * Tear down: unmount React trees first, then dispose feature fibers, the - * root registration, minted session scopes, and persisted test state. + * root registration and standard sources, minted session scopes, and persisted test state. * Idempotent. * @returns completion of the teardown. */ @@ -421,6 +434,8 @@ export class SlotTestRuntime { for (const view of this.views.splice(0)) view.unmount() for (const handle of this.handles.splice(0)) await handle.dispose() this.root.release() + this.disposeWorkspaceSource() + this.disposePanelInfoSource() await this.sessions.disposeScopes() localStorage.clear() } diff --git a/packages/test-support/client-runtime/tests/helpers.client.spec.tsx b/packages/test-support/client-runtime/tests/helpers.client.spec.tsx index 484fa8cc01..389c1a0d90 100644 --- a/packages/test-support/client-runtime/tests/helpers.client.spec.tsx +++ b/packages/test-support/client-runtime/tests/helpers.client.spec.tsx @@ -5,6 +5,8 @@ import { createSnapshotStore } from '@deepseek-ai/dsh-client-store' import { EMPTY_CHAT_SNAPSHOT } from '@deepseek-ai/dsh-client-ui-chat/client' import { EMPTY_CONVERSATION_SNAPSHOT } from '@deepseek-ai/dsh-client-ui-conversation/client' import type { SessionId } from '@deepseek-ai/dsh-session/types' +import type { MainPanelId, PanelInfo } from '@deepseek-ai/dsh-client-ui-layout/client' +import type { PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' import { afterAll, afterEach, describe, expect, it, vi } from 'vitest' import { bindSnapshotSelector, @@ -17,6 +19,12 @@ import { const originalLanguages = [...navigator.languages] const originalLanguage = navigator.language +declare module '@deepseek-ai/dsh-client-ui-slots' { + interface SlotMap { + 'trt.panel-info': { kind: 'keyed'; scope: 'root'; owner: { label: string } } + } +} + usePinnedBrowserLanguages('zh-CN', 'en-US') afterEach(cleanup) afterAll(() => { @@ -38,6 +46,63 @@ function entry(seq: number): SessionLiveEventEntry { } describe('fixture helpers', () => { + it.each([false, true])('retracts default root sources without removing replacements (release first: %s)', async (releaseFirst) => { + const runtime = await SlotTestRuntime.create() + const hooks = { workspaces: runtime.workspaces.list, panelInfo: runtime.panelInfo } + let releaseReplacement: (() => void) | undefined + try { + if (releaseFirst) { + runtime.releaseWorkspaceSource() + runtime.releasePanelInfoSource() + } else { + await runtime.dispose() + } + releaseReplacement = runtime.slots.provideRoot({ hooks }) + await runtime.dispose() + await runtime.dispose() + for (const key of ['workspaces', 'panelInfo'] as const) { + expect(() => runtime.slots.provideRoot({ hooks: { [key]: hooks[key] } })) + .toThrow(`duplicate root standard hook '${key}'`) + } + } finally { + try { + releaseReplacement?.() + runtime.releaseWorkspaceSource() + runtime.releasePanelInfoSource() + } finally { + await runtime.dispose() + } + } + }) + + it('drives panel hooks, retains keyed selection on owner updates, and releases the default source', async () => { + const runtime = await SlotTestRuntime.create() + try { + await runtime.declare({ 'trt.panel-info': { kind: 'keyed', scope: 'root' } }) + runtime.slots.register({ name: 'trt.panel-info', key: 'probe' }, + ({ usePanelInfo, label }: PropsRuntime<'trt.panel-info'>) => ( + {label}:{usePanelInfo(info => info.activePanelId) ?? 'conversation'} + )) + const view = runtime.renderSlot('trt.panel-info', { label: 'first' }, { entryKey: 'probe' }) + expect(view.container.textContent).toBe('first:conversation') + act(() => { runtime.panelInfo.set({ activePanelId: 'custom' as MainPanelId }) }) + expect(view.container.textContent).toBe('first:custom') + view.update({ label: 'next' }) + expect(view.container.textContent).toBe('next:custom') + const replacement = createSnapshotStore({ activePanelId: null }) + await act(async () => { + runtime.releasePanelInfoSource() + await runtime.mount({ + inject: ['slots'], + apply(ctx) { ctx.slots.provideRoot({ hooks: { panelInfo: replacement } }) }, + }) + }) + expect(view.container.textContent).toBe('next:conversation') + } finally { + await runtime.dispose() + } + }) + it('rejects an upload until a suite replaces the default stub', async () => { const runtime = await SlotTestRuntime.create() expect(runtime.fileUpload.available).toBe(false) diff --git a/packages/test-support/client-runtime/tsconfig.json b/packages/test-support/client-runtime/tsconfig.json index f9abd79351..bc8fbf0688 100644 --- a/packages/test-support/client-runtime/tsconfig.json +++ b/packages/test-support/client-runtime/tsconfig.json @@ -38,6 +38,9 @@ { "path": "../../client/ui-conversation" }, + { + "path": "../../client/ui-layout" + }, { "path": "../../client/ui-slots" }, diff --git a/packages/test-support/session-snapshot/README.i18n.yaml b/packages/test-support/session-snapshot/README.i18n.yaml index 7eeb15e7c5..8cc6afcbb6 100644 --- a/packages/test-support/session-snapshot/README.i18n.yaml +++ b/packages/test-support/session-snapshot/README.i18n.yaml @@ -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/test-support/session-snapshot/README.md -README.md: 533baf73e4913d1c64ea018e7c99099da1a1348b -README.zh.md: c3926c3e7d7ec53064a5d3dac3048e0d934b4c5c +README.md: c8d2ecc7c9d187006b711fdf4efd1ca205773cd9 +README.zh.md: 20fea4e4351a857908d5a9d9f78c1f80131c0ada diff --git a/packages/test-support/session-snapshot/README.md b/packages/test-support/session-snapshot/README.md index 533baf73e4..c8d2ecc7c9 100644 --- a/packages/test-support/session-snapshot/README.md +++ b/packages/test-support/session-snapshot/README.md @@ -70,7 +70,7 @@ defineAcpSnapshotSuite({ Each recorded-session directory carries a closed `snapshot.yml` manifest plus canonical parent and contiguous child roles. Parent filenames are `session[.vN].jsonl`; children are `session.[.vN].jsonl`; v0 omits `.v0`, positive versions use lowercase `.vN`, and every filename agrees with its header. A role may retain older generations, but the harness selects the numerically highest one. An owning manifest may declare `sessionFormat.version` plus one or more closed `coverage` names to keep that historical generation as an explicit migration fixture; absence tracks the current writer. The manifest also names the scenario, shipped profile, composition/header class, recording source, and only the replay, platform, permission, environment, workspace, or input facts the completed Session cannot reconstruct. Storage guards check tool results and portable paths in every selected parent and child role. Prompt/schema scrubbing, message identity, and prompt-before-request ordering apply to current generations; retained predecessors keep their historical representation. The adapter registers expected-output, Session-log, and optional `workspace.expected/` comparisons; guards reject orphan directories, missing roles, noncanonical names, absolute paths, malformed manifests, and platform-specific separators. -`normalizeSessionSnapshot` retains the complete Session header and event payloads but omits top-level `seq`/`time` envelopes from committed fixtures after normalizing paths and scrubbing system-prompt text and tool schemas; it also normalizes embedded stream clocks and historical packed-row `seq0`/`time0` envelopes. Replay synthesizes the top-level envelopes in memory, while runtime persistence continues to write complete logs. Multi-session comparison validates expected and harvested logs through the strict build-static Session format catalog before identity redaction and normalization; source filenames cannot alter format validation. A retained historical replay input is not the native V3 writer-output oracle: structural migration preserves request meaning but can produce a different event layout. Normalization preserves unexpected request-header fields, including `system`, so regressions remain visible. Versionless protocol-adapter unit fixtures remain outside the released Session format corpus. Current v3 fixtures use one row per event; retained v0/v1 fixtures may use canonical packed rows. The [temporary repository migrator](../../../scripts/migrate-packed-session-fixtures.ts) (`pnpm run migrate:packed-session-fixtures`) rewrites older historical layouts, and its [removal proposal](../../../.agents/notes/proposed/process/2026-07-26-remove-packed-session-fixture-migrator.md) owns its deletion. +`normalizeSessionSnapshot` retains the complete Session header and event payloads but omits top-level `seq`/`time` envelopes from committed fixtures after normalizing paths and scrubbing system-prompt text and tool schemas; it also normalizes embedded stream clocks and historical packed-row `seq0`/`time0` envelopes, and catalog child-creation clocks. Event order and source-event references are preserved. Replay synthesizes the top-level envelopes in memory, while runtime persistence continues to write complete logs. Multi-session comparison validates expected and harvested logs through the strict build-static Session format catalog before identity redaction and normalization; source filenames cannot alter format validation. A retained historical replay input is not the native V3 writer-output oracle: structural migration preserves request meaning but can produce a different event layout. Normalization preserves unexpected request-header fields, including `system`, so regressions remain visible. Versionless protocol-adapter unit fixtures remain outside the released Session format corpus. Current v3 fixtures use one row per event; retained v0/v1 fixtures may use canonical packed rows. The [temporary repository migrator](../../../scripts/migrate-packed-session-fixtures.ts) (`pnpm run migrate:packed-session-fixtures`) rewrites older historical layouts, and its [removal proposal](../../../.agents/notes/proposed/process/2026-07-26-remove-packed-session-fixture-migrator.md) owns its deletion. Spill scenarios save through the real local provider under a private temporary root. Their fixture adapter exposes fixed-length logical locators and maps only this run’s saved locators back to real files for retrieval, preserving preview budgets without writing into a shared logical path. Known snapshot spill paths normalize to stable locator tokens, including paths quoted inside JSON omission notices with JSON-escaped Windows separators. Refresh extraction preserves the matched serialized path spelling for literal replacement. Normalization changes only the locator: saved byte lengths and omission counts remain comparison evidence. diff --git a/packages/test-support/session-snapshot/README.zh.md b/packages/test-support/session-snapshot/README.zh.md index c3926c3e7d..20fea4e435 100644 --- a/packages/test-support/session-snapshot/README.zh.md +++ b/packages/test-support/session-snapshot/README.zh.md @@ -70,7 +70,7 @@ defineAcpSnapshotSuite({ 每个已记录 Session 目录携带封闭的 `snapshot.yml` manifest,以及规范 parent 与连续 child 角色。parent 文件名是 `session[.vN].jsonl`;child 是 `session.[.vN].jsonl`;v0 省略 `.v0`,正版本使用小写 `.vN`,且每个文件名与其 header 一致。一个角色可以保留旧 generation,但 harness 会选择数值最高的一项。拥有 fixture 的 manifest 可以声明 `sessionFormat.version` 与一个或多个封闭 `coverage` 名称,把该历史 generation 保留为显式迁移 fixture;省略此字段时跟随当前 writer。manifest 还会指名场景、随附 profile、组合/header 类别、录制来源,以及已完成 Session 无法重建的 replay、平台、权限、环境、workspace 或输入事实。存储保护检查每个选定 parent 与 child 角色的工具结果和可移植路径。提示词/schema 擦除、消息身份及提示词先于请求的顺序检查适用于当前 generation;保留的前代维持其历史表示。适配器注册预期输出、Session 日志与可选 `workspace.expected/` 比较;保护会拒绝遗留目录、缺失角色、非规范名称、绝对路径、malformed manifest 与平台专用分隔符。 -`normalizeSessionSnapshot` 在规范化路径并擦除系统提示文本与工具 schema 后,会保留完整 Session header 与事件 payload,但从已提交 fixture 中省略顶层 `seq`/`time` envelope;它还会规范化嵌入式 stream clock 与历史 packed-row 的 `seq0`/`time0` envelope。Replay 只在内存中合成顶层 envelope,而运行时持久化仍写入完整日志。多 Session 比较会先通过严格的构建期静态 Session 格式目录校验预期日志与收集日志,再进行身份脱敏与规范化;来源文件名不能改变格式校验。保留的历史 replay 输入不是原生 V3 writer 输出的比较基准:结构迁移保留请求含义,但可以产生不同的事件布局。归一化保留意外的 request-header 字段(包括 `system`),使回归保持可见。无版本的协议适配器单元测试 fixture 不属于已发布 Session 格式语料。当前 v3 fixture 每个事件占一行;保留的 v0/v1 fixture 可以使用规范 packed row。[临时仓库迁移器](../../../scripts/migrate-packed-session-fixtures.ts)(`pnpm run migrate:packed-session-fixtures`)会改写更旧的历史布局,由其[移除提案](../../../.agents/notes/proposed/process/2026-07-26-remove-packed-session-fixture-migrator.zh.md)负责删除该迁移器。 +`normalizeSessionSnapshot` 在规范化路径并擦除系统提示文本与工具 schema 后,会保留完整 Session header 与事件 payload,但从已提交 fixture 中省略顶层 `seq`/`time` envelope;它还会规范化嵌入式 stream clock 与历史 packed-row 的 `seq0`/`time0` envelope 与 catalog child 创建时钟。事件顺序与来源事件引用保持不变。Replay 只在内存中合成顶层 envelope,而运行时持久化仍写入完整日志。多 Session 比较会先通过严格的构建期静态 Session 格式目录校验预期日志与收集日志,再进行身份脱敏与规范化;来源文件名不能改变格式校验。保留的历史 replay 输入不是原生 V3 writer 输出的比较基准:结构迁移保留请求含义,但可以产生不同的事件布局。归一化保留意外的 request-header 字段(包括 `system`),使回归保持可见。无版本的协议适配器单元测试 fixture 不属于已发布 Session 格式语料。当前 v3 fixture 每个事件占一行;保留的 v0/v1 fixture 可以使用规范 packed row。[临时仓库迁移器](../../../scripts/migrate-packed-session-fixtures.ts)(`pnpm run migrate:packed-session-fixtures`)会改写更旧的历史布局,由其[移除提案](../../../.agents/notes/proposed/process/2026-07-26-remove-packed-session-fixture-migrator.zh.md)负责删除该迁移器。 Spill 场景通过真实本地 provider 保存到私有临时根目录。夹具适配器提供固定长度的逻辑定位符,并仅将本次运行已保存的定位符映射回实际文件以供检索,在不写入共享逻辑路径的情况下保留预览预算。已知的快照 spill 路径会规范化为稳定的定位信息 token,包括 JSON 省略通知中带引号、使用 JSON 转义 Windows 分隔符的路径。刷新提取会保留匹配路径的序列化写法,以便进行字面替换。规范化只改变定位信息:保存字节数与省略计数仍作为比较证据。 diff --git a/packages/test-support/session-snapshot/src/normalize.ts b/packages/test-support/session-snapshot/src/normalize.ts index c0624918f3..bb1a507453 100644 --- a/packages/test-support/session-snapshot/src/normalize.ts +++ b/packages/test-support/session-snapshot/src/normalize.ts @@ -325,8 +325,9 @@ export function normalizeStdout( /** * Normalize a session JSONL log into a stable expected output: the header line's * volatile fields (`createdAt`, `id`, `cwd`) are zeroed/scrubbed; event, - * historical packed-row, embedded Assistant-stream, and goal lifecycle clocks - * are zeroed; and all volatile strings are scrubbed. Projected inputs remain + * historical packed-row, embedded Assistant-stream, goal lifecycle, and + * catalog child-creation clocks are zeroed; and all volatile strings are + * scrubbed. Projected inputs remain * projected. Packed `data.dt` gaps are normalized even when the projected row * omits its `time0` anchor. * Output is JSONL in the same shape as the input — one compact record per @@ -381,6 +382,10 @@ export function normalizeSessionLog( if ('createdAt' in data) data.createdAt = 0 if ('updatedAt' in data) data.updatedAt = 0 } + if (record.type === 'subagent/catalog' && record.data !== null && typeof record.data === 'object') { + const data = record.data as Record + if ('childCreatedAt' in data) data.childCreatedAt = 0 + } if (Object.hasOwn(record, 'sourceEventSeqs')) { record.sourceEventSeqs = decodeSeqRanges(record.sourceEventSeqs) } @@ -399,9 +404,8 @@ function projectSessionSnapshot(rawLog: string): string { const body = lines.map((line) => { const record = JSON.parse(line) as Record - const projected = { ...record } - omitFixtureEnvelope(projected) - return JSON.stringify(projected) + omitFixtureEnvelope(record) + return JSON.stringify(record) }) return [header, ...body, ''].join('\n') } @@ -410,7 +414,8 @@ function projectSessionSnapshot(rawLog: string): string { * Normalize and project persisted session JSONL for a committed fixture. * This composes ordinary log normalization with request-header scrubbing and * persistence-envelope projection, then writes the v3 logical event stream as - * one record per event, independent of persistence flush boundaries. + * one record per event, independent of persistence flush boundaries. Event order + * and source-event references are preserved. * * @param rawLog - persisted or already-projected session JSONL. * @param ctx - the run's volatile values to scrub. diff --git a/packages/test-support/session-snapshot/tests/normalize.spec.ts b/packages/test-support/session-snapshot/tests/normalize.spec.ts index ffd6d2f24e..62321e5759 100644 --- a/packages/test-support/session-snapshot/tests/normalize.spec.ts +++ b/packages/test-support/session-snapshot/tests/normalize.spec.ts @@ -523,12 +523,35 @@ describe('normalizeSessionLog', () => { expect(out).toContain('"operation":"resume"') }) + it('normalizes subagent catalog child creation clocks', () => { + const catalog = JSON.stringify({ + type: 'subagent/catalog', + seq: 2, + time: 5, + data: { + version: 0, + childId: 'child', + childCreatedAt: 123, + mode: 'one-shot', + }, + }) + const out = normalizeSessionLog(`${header({})}\n${catalog}\n`, ctx) + expect(out).toContain('"childCreatedAt":0') + }) + it('handles complete envelopes when optional normalized fields are absent', () => { const bareHeader = JSON.stringify({ type: 'session', id: 's' }) const bareHook = JSON.stringify({ type: 'hook/result', seq: 2, time: 5, data: { decision: 'allow' } }) const nullDataHook = JSON.stringify({ type: 'hook/result', seq: 3, time: 6, data: null }) - const out = normalizeSessionLog(`${bareHeader}\n${bareHook}\n${nullDataHook}\n`, ctx) + const bareCatalog = JSON.stringify({ + type: 'subagent/catalog', + seq: 4, + time: 7, + data: { version: 0 }, + }) + const out = normalizeSessionLog(`${bareHeader}\n${bareHook}\n${nullDataHook}\n${bareCatalog}\n`, ctx) expect(out).toContain('"decision":"allow"') + expect(out).toContain('"version":0') expect(out).not.toContain('durationMs') }) }) @@ -597,6 +620,64 @@ describe('normalizeSessionSnapshot', () => { ].join('\n')) }) + it('preserves adjacent catalog facts in parent event order', () => { + const raw = [ + JSON.stringify({ type: 'session', version: 0 }), + JSON.stringify({ type: 'tool/call', data: { callId: 'parallel' } }), + JSON.stringify({ + type: 'subagent/catalog', + data: { version: 0, childId: '{{session:3}}', childCreatedAt: 123, mode: 'one-shot' }, + }), + JSON.stringify({ + type: 'subagent/catalog', + data: { version: 0, childId: '{{session:2}}', childCreatedAt: 124, mode: 'one-shot' }, + }), + JSON.stringify({ type: 'tool/result', data: { callId: 'parallel' } }), + ].join('\n') + '\n' + const normalized = normalizeSessionSnapshot(raw, ctx) + expect(normalized.indexOf('{{session:3}}')).toBeLessThan(normalized.indexOf('{{session:2}}')) + expect(normalized).toContain('"childCreatedAt":0') + }) + + it('preserves malformed catalog payloads', () => { + const raw = [ + JSON.stringify({ type: 'session', version: 0 }), + JSON.stringify({ + type: 'subagent/catalog', + data: { version: 0, childId: '{{session:2}}', childCreatedAt: 1, mode: 'one-shot' }, + }), + JSON.stringify({ + type: 'subagent/catalog', + data: { version: 0, childId: 3, childCreatedAt: 3, mode: 'one-shot' }, + }), + ].join('\n') + '\n' + const normalized = normalizeSessionSnapshot(raw, ctx) + expect(normalized).toContain('{{session:2}}') + expect(normalized).toContain('"childId":3') + }) + + it.each([ + { sources: [0, 1] }, + { sources: [0, 2] }, + ])('preserves source references and catalog order: $sources', ({ sources }) => { + const records = [ + { type: 'session', version: 2 }, + { type: 'tool/call', data: { callId: 'parallel' } }, + { type: 'subagent/catalog', data: { childId: 'child-z', childCreatedAt: 1, version: 0, mode: 'one-shot' } }, + { type: 'subagent/catalog', data: { childId: 'child-a', childCreatedAt: 2, version: 0, mode: 'one-shot' } }, + { type: 'tool/result', data: { callId: 'parallel' }, sourceEventSeqs: sources, surfaceOp: 'append' }, + ] + const normalized = normalizeSessionSnapshot(records.map(record => JSON.stringify(record)).join('\n'), ctx) + expect(normalized).toBe([ + records[0], + records[1], + { ...records[2], data: { ...records[2]?.data, childCreatedAt: 0 } }, + { ...records[3], data: { ...records[3]?.data, childCreatedAt: 0 } }, + records[4], + ].map(record => JSON.stringify(record)).join('\n') + '\n') + expect(normalizeSessionSnapshot(normalized, ctx)).toBe(normalized) + }) + it('migrates and re-packs multi-session fixtures after relationship-preserving id redaction', () => { const raw = [ JSON.stringify({ type: 'session', version: 0, id: '{{session:1}}', createdAt: 0, delegationDepth: 0 }), diff --git a/packages/util/README.i18n.yaml b/packages/util/README.i18n.yaml index 8453741048..f472989bc0 100644 --- a/packages/util/README.i18n.yaml +++ b/packages/util/README.i18n.yaml @@ -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/util/README.md -README.md: 2e46d9d4573b31afa14d7f1380af106c391d19d6 -README.zh.md: c7d8b63dc3ee7e748e8a07a3d65315ff81d5476f +README.md: c6fd72b85fe302392c41818263396c316f987422 +README.zh.md: 879671b642e4872084926e510a84ee7dc1a3b39b diff --git a/packages/util/README.md b/packages/util/README.md index 2e46d9d457..c6fd72b85f 100644 --- a/packages/util/README.md +++ b/packages/util/README.md @@ -30,6 +30,7 @@ Each package provides one primitive; open a package page for how to use it. | [`package-manifest/`](package-manifest/README.md) | Shared TypeScript declarations for package manifests | | [`crypto/`](crypto/README.md) | Mints RFC 9562 v4 UUIDs from the cross-runtime `crypto.getRandomValues` primitive | | [`deque/`](deque/README.md) | Provides amortized constant-time queue operations with bounded vacant storage | +| [`chunked-list/`](chunked-list/README.md) | Retains immutable list versions with bounded append copying and checkpoint validation | | [`values/`](values/README.md) | Validates, snapshots, compares, and freezes lossless JSON-compatible values | | [`home-paths/`](home-paths/README.md) | Resolves the single Harness home and joins shared user-data paths | | [`http-proxy/`](http-proxy/README.md) | Resolves one outbound proxy policy and installs it for `fetch`, SDK agents, and spawned children | diff --git a/packages/util/README.zh.md b/packages/util/README.zh.md index c7d8b63dc3..879671b642 100644 --- a/packages/util/README.zh.md +++ b/packages/util/README.zh.md @@ -30,6 +30,7 @@ kind: "package-group" | [`package-manifest/`](package-manifest/README.zh.md) | Package manifest 的共享 TypeScript 声明 | | [`crypto/`](crypto/README.zh.md) | 基于跨运行时 `crypto.getRandomValues` 原语生成 RFC 9562 v4 UUID | | [`deque/`](deque/README.zh.md) | 提供摊销常数时间的队列操作和有界空闲存储 | +| [`chunked-list/`](chunked-list/README.zh.md) | 通过有界追加复制和检查点校验保留不可变列表版本 | | [`values/`](values/README.zh.md) | 校验、创建快照、比较和冻结无损 JSON 兼容值 | | [`home-paths/`](home-paths/README.zh.md) | 解析统一的 Harness 主目录并拼接共享的用户数据路径 | | [`http-proxy/`](http-proxy/README.zh.md) | 解析出唯一的出站代理策略,并为 `fetch`、SDK agent 与派生子进程安装它 | diff --git a/packages/util/chunked-list/README.i18n.yaml b/packages/util/chunked-list/README.i18n.yaml new file mode 100644 index 0000000000..981bf95bdf --- /dev/null +++ b/packages/util/chunked-list/README.i18n.yaml @@ -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 packages/util/chunked-list/README.md +README.md: f0af89e5dd6cfdc6388fe90aaeaf2fb70b149827 +README.zh.md: 9f28884ad7414ceeae1d7e624cb20efca9033b25 diff --git a/packages/util/chunked-list/README.md b/packages/util/chunked-list/README.md new file mode 100644 index 0000000000..f0af89e5dd --- /dev/null +++ b/packages/util/chunked-list/README.md @@ -0,0 +1,93 @@ +--- +description: "Immutable append-only lists for projection state, with bounded append copying, insertion-order iteration, and Zod checkpoint validation." +kind: "package-library" +--- + +# @deepseek-ai/dsh-chunked-list + +English | [中文](README.zh.md) + +## Summary + +`dsh-chunked-list` lets callers append values while retaining earlier list versions without copying the whole collection. Callers can iterate every value in insertion order and validate JSON checkpoints with their own value schema. The subagent catalog uses it for immutable projection state. + +## Table of Contents + +- [Use this package](#use-this-package) +- [Understand the implementation](#understand-the-implementation) +- [Further Exploration](#further-exploration) +- [Model Experience](#model-experience) +- [Known Limitations and Deferred Work](#known-limitations-and-deferred-work) +- [Dev Note](#dev-note) + +----- + + +## Use this package + +Use this list when an append-only collection needs immutable versions and JSON-compatible storage. An empty list is `undefined`; appending returns a new head without modifying existing nodes. The list shares stored values by reference, so callers must treat them as immutable. + +```ts +import { appendChunkedList, iterateChunkedList } from '@deepseek-ai/dsh-chunked-list' + +const first = appendChunkedList(undefined, 'first') +const second = appendChunkedList(first, 'second') +console.log([...iterateChunkedList(second)]) +``` + +The example produces `['first', 'second']`; `first` still contains only its original value. `chunkedListSchema(valueSchema)` validates JSON checkpoints and rejects unknown fields, invalid values, and empty or oversized chunks. Use `.optional()` on the schema when the containing field also permits an empty list. See the [source contracts](src/index.ts) for the operations. + +----- + + +## Understand the implementation + +
+Implementation internals — click to expand + +The newest chunk stores up to 64 values. Appends copy at most that chunk and share older nodes, taking bounded O(1) work. The capacity controls storage layout, not total list length. Iteration visits all N values in O(N) time and uses O(N / 64) scratch space to visit chunks from oldest to newest. A single capacity constant governs append rollover and recursive Zod validation. + +| File | Role | +|---|---| +| [`src/index.ts`](src/index.ts) | Persistent list operations and checkpoint validation | +| [`tests/chunked-list.spec.ts`](tests/chunked-list.spec.ts) | Version isolation, ordering, structural sharing, and checkpoint acceptance | + +No runtime invariant companion is published because this library has no independently changing observations; its operations return caller-owned immutable values. + +
+ +----- + + +## Further Exploration + +- [Utility package map](../README.md) — shared primitives. +- [Subagent catalog decision](../../../.agents/notes/implemented/architecture/2026-09-01-parent-owned-subagent-catalog.md) — why projection state uses chunks. + +----- + + +## Model Experience + +None, as this collection registers nothing model-facing. + +#### KV Cache effect + +Nothing here enters a model request, so provider cache reuse is unaffected. + +## Known Limitations and Deferred Work + + + +- **Append-only access** — callers needing removal or random access need another collection. +- **Recursive checkpoints** — JSON serialization and schema validation remain subject to runtime nesting limits. Stored values must themselves support the caller's serialization format. + + +### Dev Note + +
+Working context for maintainers — click to expand + +None. + +
diff --git a/packages/util/chunked-list/README.zh.md b/packages/util/chunked-list/README.zh.md new file mode 100644 index 0000000000..9f28884ad7 --- /dev/null +++ b/packages/util/chunked-list/README.zh.md @@ -0,0 +1,93 @@ +--- +description: "用于 projection state 的不可变追加列表,提供有界追加复制、按插入顺序迭代和 Zod 检查点校验。" +kind: "package-library" +--- + +# @deepseek-ai/dsh-chunked-list + +[English](README.md) | 中文 + +## 概述 + +`dsh-chunked-list` 让调用方追加值并保留早期列表版本,无需复制整个集合。调用方可以按插入顺序迭代所有值,并使用自己的值 schema 校验 JSON 检查点。subagent 目录用它保存不可变的 projection state。 + +## 目录 + +- [使用此包](#use-this-package) +- [理解实现](#understand-the-implementation) +- [进一步探索](#further-exploration) +- [模型体验](#model-experience) +- [已知限制与延后工作](#known-limitations-and-deferred-work) +- [开发备注](#dev-note) + +----- + + +## 使用此包 + +当仅追加集合需要不可变版本和兼容 JSON 的存储时,使用此列表。空列表用 `undefined` 表示;追加返回新的头节点,不修改已有节点。列表按引用共享所存的值,因此调用方必须将这些值视为不可变。 + +```ts +import { appendChunkedList, iterateChunkedList } from '@deepseek-ai/dsh-chunked-list' + +const first = appendChunkedList(undefined, 'first') +const second = appendChunkedList(first, 'second') +console.log([...iterateChunkedList(second)]) +``` + +示例输出 `['first', 'second']`;`first` 仍只包含原来的值。`chunkedListSchema(valueSchema)` 校验 JSON 检查点并拒绝未知字段、无效值和空块或超大块。当外层字段也允许空列表时,在 schema 上使用 `.optional()`。各操作详见[源码约定](src/index.ts)。 + +----- + + +## 理解实现 + +
+实现内部机制——点击展开 + +最新的块最多存储 64 个值。追加最多复制该块并共享较旧的节点,工作量为有界 O(1)。容量控制存储布局,不限制列表总长度。迭代以 O(N) 时间访问全部 N 个值,并使用 O(N / 64) 临时空间按从旧到新的顺序访问各块。追加换块与递归 Zod 校验共用一个容量常量。 + +| 文件 | 职责 | +|---|---| +| [`src/index.ts`](src/index.ts) | 持久化列表操作与检查点校验 | +| [`tests/chunked-list.spec.ts`](tests/chunked-list.spec.ts) | 版本隔离、排序、结构共享与检查点接受条件 | + +此库没有独立变化的观测值,因此不发布运行时不变式伴随模块;其操作返回调用方拥有的不可变值。 + +
+ +----- + + +## 进一步探索 + +- [工具包映射](../README.zh.md)——共享原语。 +- [Subagent 目录决策](../../../.agents/notes/implemented/architecture/2026-09-01-parent-owned-subagent-catalog.zh.md)——projection state 使用分块的原因。 + +----- + + +## 模型体验 + +无,因为此集合不注册任何面向模型的内容。 + +#### KV Cache 影响 + +本包没有内容进入模型请求,因此不影响提供方缓存复用。 + +## 已知限制与延后工作 + + + +- **仅追加访问**——需要删除或随机访问的调用方应使用其他集合。 +- **递归检查点**——JSON 序列化与 schema 校验仍受运行时嵌套深度限制。所存的值本身必须支持调用方的序列化格式。 + + +### 开发备注 + +
+维护者的工作上下文——点击展开 + +无。 + +
diff --git a/packages/util/chunked-list/package.json b/packages/util/chunked-list/package.json new file mode 100644 index 0000000000..2a3fc316da --- /dev/null +++ b/packages/util/chunked-list/package.json @@ -0,0 +1,38 @@ +{ + "name": "@deepseek-ai/dsh-chunked-list", + "description": "Persistent append-only chunked lists with bounded copying and JSON checkpoint validation", + "version": "0.1.5-alpha.1", + "publishConfig": { + "access": "public" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/deepseek-ai/deepseek-harness.git", + "directory": "packages/util/chunked-list" + }, + "type": "module", + "main": "lib/index.js", + "types": "lib/types/index.d.ts", + "exports": { + ".": { + "types": "./lib/types/index.d.ts", + "default": "./lib/index.js" + }, + "./src/*": "./src/*", + "./package.json": "./package.json" + }, + "files": [ + "lib/index.js", + "lib/types/**/*.d.ts" + ], + "license": "MIT", + "peerDependencies": { + "@deepseek-ai/cordis": "workspace:^" + }, + "devDependencies": { + "@deepseek-ai/cordis": "workspace:^" + }, + "dependencies": { + "zod": "^4.4.3" + } +} diff --git a/packages/util/chunked-list/src/index.ts b/packages/util/chunked-list/src/index.ts new file mode 100644 index 0000000000..98078079fe --- /dev/null +++ b/packages/util/chunked-list/src/index.ts @@ -0,0 +1,58 @@ +/** + * Persistent append-only lists with bounded copying and JSON checkpoint validation. + * @module @deepseek-ai/dsh-chunked-list + */ + +import { z } from 'zod' + +const CHUNK_CAPACITY = 64 + +/** + * Newest chunk of an immutable list; `undefined` represents the empty list. + * Values within each chunk follow insertion order. Callers treat nodes, arrays, + * and stored values as immutable; operations share values and older chunks. + */ +export interface ChunkedList { + readonly values: readonly T[] + readonly previous?: ChunkedList | undefined +} + +/** + * Append without modifying the input, copying at most one 64-value chunk. + * @param head - current list, or `undefined` for an empty list. + * @param value - value to retain by reference. + * @returns new list sharing the unchanged older chunks. + */ +export function appendChunkedList(head: ChunkedList | undefined, value: T): ChunkedList { + if (head === undefined || head.values.length === CHUNK_CAPACITY) { + return { values: [value], ...head === undefined ? {} : { previous: head } } + } + return { + values: [...head.values, value], + ...head.previous === undefined ? {} : { previous: head.previous }, + } +} + +/** + * Visit all values in insertion order, with O(N) time and O(N / 64) scratch space. + * @param head - current list, or `undefined` for an empty list. + * @returns iterator yielding the stored values by reference, without truncation. + */ +export function* iterateChunkedList(head: ChunkedList | undefined): Generator { + const chunks: ChunkedList[] = [] + for (let chunk = head; chunk !== undefined; chunk = chunk.previous) chunks.push(chunk) + for (const chunk of chunks.reverse()) yield* chunk.values +} + +/** + * Validate nonempty list checkpoints, including every stored value and chunk size. + * @param valueSchema - caller-owned validation for each stored value. + * @returns recursive Zod schema rejecting empty or oversized chunks and unknown fields. + */ +export function chunkedListSchema(valueSchema: z.ZodType): z.ZodType> { + const schema: z.ZodType> = z.lazy(() => z.object({ + values: z.array(valueSchema).min(1).max(CHUNK_CAPACITY), + previous: schema.optional(), + }).strict()) + return schema +} diff --git a/packages/util/chunked-list/tests/chunked-list.spec.ts b/packages/util/chunked-list/tests/chunked-list.spec.ts new file mode 100644 index 0000000000..24946e3ad5 --- /dev/null +++ b/packages/util/chunked-list/tests/chunked-list.spec.ts @@ -0,0 +1,59 @@ +import { describe, expect, it } from 'vitest' +import { z } from 'zod' +import { appendChunkedList, chunkedListSchema, iterateChunkedList } from '../src/index.ts' +import type { ChunkedList } from '../src/index.ts' + +describe('persistent chunked list', () => { + it('iterates an empty list and retains undefined values', () => { + expect([...iterateChunkedList(undefined)]).toEqual([]) + const head = appendChunkedList(undefined, undefined) + expect([...iterateChunkedList(head)]).toEqual([undefined]) + }) + + it('keeps every prefix unchanged across chunk rollovers and divergent appends', () => { + let head: ChunkedList | undefined + const prefixes: ChunkedList[] = [] + for (let value = 0; value < 200; value += 1) { + head = appendChunkedList(head, value) + Object.freeze(head.values) + Object.freeze(head) + prefixes.push(head) + } + for (const [index, prefix] of prefixes.entries()) { + expect([...iterateChunkedList(prefix)]).toEqual(Array.from({ length: index + 1 }, (_, i) => i)) + } + expect(prefixes[64]?.previous).toBe(prefixes[63]) + expect(prefixes[65]?.previous).toBe(prefixes[63]) + expect([...iterateChunkedList(appendChunkedList(prefixes[64], -1))]) + .toEqual([...Array.from({ length: 65 }, (_, i) => i), -1]) + expect([...iterateChunkedList(prefixes[65])]).toEqual(Array.from({ length: 66 }, (_, i) => i)) + }) + + it('retains stored objects by reference', () => { + const value = Object.freeze({ id: 'first' }) + expect([...iterateChunkedList(appendChunkedList(undefined, value))][0]).toBe(value) + }) + + it('restores JSON checkpoints and appends at and after rollover', () => { + const schema = chunkedListSchema(z.number()) + let head: ChunkedList | undefined + for (let value = 0; value < 130; value += 1) { + head = appendChunkedList(head, value) + head = schema.parse(JSON.parse(JSON.stringify(head))) + } + expect([...iterateChunkedList(head)]).toEqual(Array.from({ length: 130 }, (_, i) => i)) + }) + + it.each([ + { values: [] }, + { values: Array.from({ length: 65 }, () => 0) }, + { values: ['invalid'] }, + { values: [0], extra: true }, + { values: [0], previous: { values: [] } }, + { values: [0], previous: { values: Array.from({ length: 65 }, () => 0) } }, + { values: [0], previous: { values: ['invalid'] } }, + { values: [0], previous: { values: [1], extra: true } }, + ])('rejects an invalid checkpoint: %j', (checkpoint) => { + expect(() => chunkedListSchema(z.number()).parse(checkpoint)).toThrow(z.ZodError) + }) +}) diff --git a/packages/util/chunked-list/tsconfig.json b/packages/util/chunked-list/tsconfig.json new file mode 100644 index 0000000000..749cb0208e --- /dev/null +++ b/packages/util/chunked-list/tsconfig.json @@ -0,0 +1,11 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types" + }, + "include": [ + "src" + ], + "references": [] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c25d391a81..dbf7683743 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2186,17 +2186,16 @@ importers: version: link:../../typert/protocol packages/client/store: - dependencies: + devDependencies: + '@deepseek-ai/cordis': + specifier: workspace:^ + version: link:../../../vendor/cordis immer: specifier: ^10.1.1 version: 10.2.0 zustand: specifier: ~4.4.7 version: 4.4.7(@types/react@18.3.31)(immer@10.2.0)(react@18.3.1) - devDependencies: - '@deepseek-ai/cordis': - specifier: workspace:^ - version: link:../../../vendor/cordis packages/client/ui-agent-preset: devDependencies: @@ -2301,10 +2300,6 @@ importers: version: 18.3.1 packages/client/ui-attachment: - dependencies: - clsx: - specifier: ^2.0.0 - version: 2.1.1 devDependencies: '@deepseek-ai/cordis': specifier: workspace:^ @@ -2339,6 +2334,9 @@ importers: '@types/react-dom': specifier: ~18.3.0 version: 18.3.7(@types/react@18.3.31) + clsx: + specifier: ^2.0.0 + version: 2.1.1 react: specifier: ^18.2.0 version: 18.3.1 @@ -2486,10 +2484,6 @@ importers: version: 18.3.1(react@18.3.1) packages/client/ui-commands: - dependencies: - clsx: - specifier: ^2.0.0 - version: 2.1.1 devDependencies: '@deepseek-ai/cordis': specifier: workspace:^ @@ -2539,6 +2533,9 @@ importers: '@types/react': specifier: ~18.3.1 version: 18.3.31 + clsx: + specifier: ^2.0.0 + version: 2.1.1 react: specifier: ^18.2.0 version: 18.3.1 @@ -2548,24 +2545,6 @@ importers: '@deepseek-ai/schemastery': specifier: link:../../../vendor/schemastery version: link:../../../vendor/schemastery - '@lexical/history': - specifier: ^0.49.0 - version: 0.49.0(typescript@6.0.3) - '@lexical/plain-text': - specifier: ^0.49.0 - version: 0.49.0(typescript@6.0.3) - '@lexical/text': - specifier: ^0.49.0 - version: 0.49.0(typescript@6.0.3) - '@lexical/utils': - specifier: ^0.49.0 - version: 0.49.0(typescript@6.0.3) - clsx: - specifier: ^2.0.0 - version: 2.1.1 - lexical: - specifier: ^0.49.0 - version: 0.49.0(typescript@6.0.3) devDependencies: '@deepseek-ai/cordis': specifier: workspace:^ @@ -2660,12 +2639,30 @@ importers: '@lexical/headless': specifier: ^0.49.0 version: 0.49.0(typescript@6.0.3) + '@lexical/history': + specifier: ^0.49.0 + version: 0.49.0(typescript@6.0.3) + '@lexical/plain-text': + specifier: ^0.49.0 + version: 0.49.0(typescript@6.0.3) + '@lexical/text': + specifier: ^0.49.0 + version: 0.49.0(typescript@6.0.3) + '@lexical/utils': + specifier: ^0.49.0 + version: 0.49.0(typescript@6.0.3) '@types/react': specifier: ~18.3.1 version: 18.3.31 '@types/react-dom': specifier: ~18.3.0 version: 18.3.7(@types/react@18.3.31) + clsx: + specifier: ^2.0.0 + version: 2.1.1 + lexical: + specifier: ^0.49.0 + version: 0.49.0(typescript@6.0.3) react: specifier: ^18.2.0 version: 18.3.1 @@ -2725,10 +2722,6 @@ importers: version: 18.3.1 packages/client/ui-directory-picker-browse: - dependencies: - clsx: - specifier: ^2.0.0 - version: 2.1.1 devDependencies: '@deepseek-ai/cordis': specifier: workspace:^ @@ -2760,6 +2753,9 @@ importers: '@types/react': specifier: ~18.3.1 version: 18.3.31 + clsx: + specifier: ^2.0.0 + version: 2.1.1 react: specifier: ^18.2.0 version: 18.3.1 @@ -2792,16 +2788,6 @@ importers: version: 18.3.1(react@18.3.1) packages/client/ui-dockkit: - dependencies: - clsx: - specifier: ^2.0.0 - version: 2.1.1 - react: - specifier: ^18.2.0 - version: 18.3.1 - react-dom: - specifier: ^18.2.0 - version: 18.3.1(react@18.3.1) devDependencies: '@deepseek-ai/cordis': specifier: workspace:^ @@ -2818,6 +2804,15 @@ importers: '@types/react-dom': specifier: ~18.3.0 version: 18.3.7(@types/react@18.3.31) + clsx: + specifier: ^2.0.0 + version: 2.1.1 + react: + specifier: ^18.2.0 + version: 18.3.1 + react-dom: + specifier: ^18.2.0 + version: 18.3.1(react@18.3.1) packages/client/ui-goal: devDependencies: @@ -2880,10 +2875,6 @@ importers: version: 18.3.1(react@18.3.1) packages/client/ui-input-trigger: - dependencies: - clsx: - specifier: ^2.0.0 - version: 2.1.1 devDependencies: '@deepseek-ai/cordis': specifier: workspace:^ @@ -2924,6 +2915,9 @@ importers: '@types/react': specifier: ~18.3.1 version: 18.3.31 + clsx: + specifier: ^2.0.0 + version: 2.1.1 react: specifier: ^18.2.0 version: 18.3.1 @@ -2969,6 +2963,9 @@ importers: '@deepseek-ai/cordis': specifier: workspace:^ version: link:../../../vendor/cordis + '@deepseek-ai/dsh-brand': + specifier: workspace:^ + version: link:../../util/brand '@deepseek-ai/dsh-client-locale': specifier: workspace:^ version: link:../locale @@ -3055,10 +3052,6 @@ importers: version: 18.3.1(react@18.3.1) packages/client/ui-model-selection: - dependencies: - clsx: - specifier: ^2.1.1 - version: 2.1.1 devDependencies: '@deepseek-ai/cordis': specifier: workspace:^ @@ -3111,6 +3104,9 @@ importers: '@types/react-dom': specifier: ~18.3.0 version: 18.3.7(@types/react@18.3.31) + clsx: + specifier: ^2.1.1 + version: 2.1.1 react: specifier: ^18.2.0 version: 18.3.1 @@ -3257,13 +3253,22 @@ importers: version: 18.3.1 packages/client/ui-primitives: - dependencies: + devDependencies: + '@deepseek-ai/cordis': + specifier: workspace:^ + version: link:../../../vendor/cordis '@shikijs/langs': specifier: ^4.3.1 version: 4.3.1 '@types/mdast': specifier: ^4.0.4 version: 4.0.4 + '@types/react': + specifier: ~18.3.1 + version: 18.3.31 + '@types/react-dom': + specifier: ~18.3.0 + version: 18.3.7(@types/react@18.3.31) anser: specifier: ^2.3.5 version: 2.3.5 @@ -3318,16 +3323,6 @@ importers: shiki: specifier: ^4.3.1 version: 4.3.1 - devDependencies: - '@deepseek-ai/cordis': - specifier: workspace:^ - version: link:../../../vendor/cordis - '@types/react': - specifier: ~18.3.1 - version: 18.3.31 - '@types/react-dom': - specifier: ~18.3.0 - version: 18.3.7(@types/react@18.3.31) packages/client/ui-reference: devDependencies: @@ -3372,10 +3367,6 @@ importers: version: link:../../util/workspace-path packages/client/ui-renderer: - dependencies: - use-sync-external-store: - specifier: 1.2.0 - version: 1.2.0(react@18.3.1) devDependencies: '@deepseek-ai/cordis': specifier: workspace:^ @@ -3404,6 +3395,9 @@ importers: react-dom: specifier: ^18.2.0 version: 18.3.1(react@18.3.1) + use-sync-external-store: + specifier: 1.2.0 + version: 1.2.0(react@18.3.1) packages/client/ui-schedule: devDependencies: @@ -3521,9 +3515,6 @@ importers: '@deepseek-ai/schemastery': specifier: link:../../../vendor/schemastery version: link:../../../vendor/schemastery - clsx: - specifier: ^2.0.0 - version: 2.1.1 devDependencies: '@deepseek-ai/cordis': specifier: workspace:^ @@ -3567,6 +3558,9 @@ importers: '@types/react': specifier: ~18.3.1 version: 18.3.31 + clsx: + specifier: ^2.0.0 + version: 2.1.1 react: specifier: ^18.2.0 version: 18.3.1 @@ -3656,10 +3650,6 @@ importers: version: 18.3.1(react@18.3.1) packages/client/ui-settings-plugins: - dependencies: - clsx: - specifier: ^2.0.0 - version: 2.1.1 devDependencies: '@deepseek-ai/cordis': specifier: workspace:^ @@ -3691,15 +3681,14 @@ importers: '@types/react': specifier: ~18.3.1 version: 18.3.31 + clsx: + specifier: ^2.0.0 + version: 2.1.1 react: specifier: ^18.2.0 version: 18.3.1 packages/client/ui-sidebar: - dependencies: - clsx: - specifier: ^2.0.0 - version: 2.1.1 devDependencies: '@deepseek-ai/cordis': specifier: workspace:^ @@ -3710,6 +3699,9 @@ importers: '@deepseek-ai/dsh-client-locale': specifier: workspace:^ version: link:../locale + '@deepseek-ai/dsh-client-store': + specifier: workspace:^ + version: link:../store '@deepseek-ai/dsh-client-test-runtime': specifier: workspace:^ version: link:../../test-support/client-runtime @@ -3734,21 +3726,14 @@ importers: '@types/react': specifier: ~18.3.1 version: 18.3.31 - react: - specifier: ^18.2.0 - version: 18.3.1 - - packages/client/ui-sidebar-files: - dependencies: clsx: specifier: ^2.0.0 version: 2.1.1 react: specifier: ^18.2.0 version: 18.3.1 - react-dom: - specifier: ^18.2.0 - version: 18.3.1(react@18.3.1) + + packages/client/ui-sidebar-files: devDependencies: '@deepseek-ai/cordis': specifier: workspace:^ @@ -3801,21 +3786,17 @@ importers: '@types/react-dom': specifier: ~18.3.0 version: 18.3.7(@types/react@18.3.31) - - packages/client/ui-sidebar-right: - dependencies: clsx: specifier: ^2.0.0 version: 2.1.1 - picomatch: - specifier: ^4.0.4 - version: 4.0.4 react: specifier: ^18.2.0 version: 18.3.1 react-dom: specifier: ^18.2.0 version: 18.3.1(react@18.3.1) + + packages/client/ui-sidebar-right: devDependencies: '@deepseek-ai/cordis': specifier: workspace:^ @@ -3871,18 +3852,20 @@ importers: '@types/react-dom': specifier: ~18.3.0 version: 18.3.7(@types/react@18.3.31) - - packages/client/ui-sidebar-textpreview: - dependencies: clsx: specifier: ^2.0.0 version: 2.1.1 + picomatch: + specifier: ^4.0.4 + version: 4.0.4 react: specifier: ^18.2.0 version: 18.3.1 react-dom: specifier: ^18.2.0 version: 18.3.1(react@18.3.1) + + packages/client/ui-sidebar-textpreview: devDependencies: '@deepseek-ai/cordis': specifier: workspace:^ @@ -3938,6 +3921,15 @@ importers: '@types/react-dom': specifier: ~18.3.0 version: 18.3.7(@types/react@18.3.31) + clsx: + specifier: ^2.0.0 + version: 2.1.1 + react: + specifier: ^18.2.0 + version: 18.3.1 + react-dom: + specifier: ^18.2.0 + version: 18.3.1(react@18.3.1) packages/client/ui-skill: devDependencies: @@ -4064,9 +4056,6 @@ importers: '@deepseek-ai/schemastery': specifier: link:../../../vendor/schemastery version: link:../../../vendor/schemastery - clsx: - specifier: ^2.0.0 - version: 2.1.1 devDependencies: '@deepseek-ai/cordis': specifier: workspace:^ @@ -4107,15 +4096,14 @@ importers: '@types/react': specifier: ~18.3.1 version: 18.3.31 + clsx: + specifier: ^2.0.0 + version: 2.1.1 react: specifier: ^18.2.0 version: 18.3.1 packages/client/ui-tool: - dependencies: - clsx: - specifier: ^2.0.0 - version: 2.1.1 devDependencies: '@deepseek-ai/cordis': specifier: workspace:^ @@ -4189,6 +4177,9 @@ importers: '@types/react': specifier: ~18.3.1 version: 18.3.31 + clsx: + specifier: ^2.0.0 + version: 2.1.1 react: specifier: ^18.2.0 version: 18.3.1 @@ -4197,13 +4188,6 @@ importers: version: 18.3.1(react@18.3.1) packages/client/ui-trajectory: - dependencies: - '@tanstack/react-virtual': - specifier: ^3.14.9 - version: 3.14.9(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - diff: - specifier: ^9.0.0 - version: 9.0.0 devDependencies: '@deepseek-ai/cordis': specifier: workspace:^ @@ -4256,12 +4240,18 @@ importers: '@deepseek-ai/dsh-tools': specifier: workspace:^ version: link:../../core/tools + '@tanstack/react-virtual': + specifier: ^3.14.9 + version: 3.14.9(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@types/react': specifier: ~18.3.1 version: 18.3.31 '@types/react-dom': specifier: ~18.3.0 version: 18.3.7(@types/react@18.3.31) + diff: + specifier: ^9.0.0 + version: 9.0.0 react: specifier: ^18.2.0 version: 18.3.1 @@ -4270,10 +4260,6 @@ importers: version: 18.3.1(react@18.3.1) packages/client/ui-user-questions: - dependencies: - clsx: - specifier: ^2.0.0 - version: 2.1.1 devDependencies: '@deepseek-ai/cordis': specifier: workspace:^ @@ -4329,6 +4315,9 @@ importers: '@types/react': specifier: ~18.3.1 version: 18.3.31 + clsx: + specifier: ^2.0.0 + version: 2.1.1 react: specifier: ^18.2.0 version: 18.3.1 @@ -4385,10 +4374,6 @@ importers: version: 18.3.1 packages/client/ui-workspace: - dependencies: - clsx: - specifier: ^2.0.0 - version: 2.1.1 devDependencies: '@deepseek-ai/cordis': specifier: workspace:^ @@ -4417,6 +4402,9 @@ importers: '@deepseek-ai/dsh-client-ui-conversation': specifier: workspace:^ version: link:../ui-conversation + '@deepseek-ai/dsh-client-ui-layout': + specifier: workspace:^ + version: link:../ui-layout '@deepseek-ai/dsh-client-ui-primitives': specifier: workspace:^ version: link:../ui-primitives @@ -4447,6 +4435,9 @@ importers: '@types/react': specifier: ~18.3.1 version: 18.3.31 + clsx: + specifier: ^2.0.0 + version: 2.1.1 react: specifier: ^18.2.0 version: 18.3.1 @@ -9030,6 +9021,9 @@ importers: '@deepseek-ai/dsh-brand': specifier: workspace:^ version: link:../../util/brand + '@deepseek-ai/dsh-chunked-list': + specifier: workspace:^ + version: link:../../util/chunked-list '@deepseek-ai/dsh-util-values': specifier: workspace:^ version: link:../../util/values @@ -9885,6 +9879,9 @@ importers: '@deepseek-ai/dsh-client-ui-conversation': specifier: workspace:^ version: link:../../client/ui-conversation + '@deepseek-ai/dsh-client-ui-layout': + specifier: workspace:^ + version: link:../../client/ui-layout '@deepseek-ai/dsh-client-ui-renderer': specifier: workspace:^ version: link:../../client/ui-renderer @@ -10163,6 +10160,16 @@ importers: specifier: workspace:^ version: link:../../../vendor/cordis + packages/util/chunked-list: + dependencies: + zod: + specifier: ^4.4.3 + version: 4.4.3 + devDependencies: + '@deepseek-ai/cordis': + specifier: workspace:^ + version: link:../../../vendor/cordis + packages/util/crypto: devDependencies: '@deepseek-ai/cordis': diff --git a/python/sdk/tests/test_smoke_model.py b/python/sdk/tests/test_smoke_model.py index 37d584f76e..5b950f98e3 100644 --- a/python/sdk/tests/test_smoke_model.py +++ b/python/sdk/tests/test_smoke_model.py @@ -223,6 +223,27 @@ def test_mcp_smoke_accepts_the_external_server_result() -> None: ) +def test_advanced_snapshot_normalizes_catalog_child_creation_time() -> None: + value = { + "type": "subagent/catalog", + "data": { + "childCreatedAt": 1788246207176, + }, + } + + assert SMOKE["normalize_snapshot_value"](value, []) == { + "type": "subagent/catalog", + "data": { + "childCreatedAt": 0, + }, + } + + assert SMOKE["normalize_snapshot_value"]( + {"type": "fixture/event", "data": {"childCreatedAt": 1788246207176}}, + [], + ) == {"type": "fixture/event", "data": {"childCreatedAt": 1788246207176}} + + def test_snapshot_comparison_preserves_opaque_generation_provenance() -> None: normalize = SMOKE["normalize_session_format_comparison"] expected = { diff --git a/scripts/browser-bundled-externals.spec.ts b/scripts/browser-bundled-externals.spec.ts new file mode 100644 index 0000000000..b4d95cf96d --- /dev/null +++ b/scripts/browser-bundled-externals.spec.ts @@ -0,0 +1,127 @@ +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, symlinkSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { dirname, join, resolve } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { browserBundledExternals, browserPackageOfFile, browserSourceAliases } from './browser-bundled-externals.ts' + +const roots: string[] = [] +const repositoryRoot = resolve(import.meta.dirname, '..') + +afterEach(() => { + for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }) +}) + +function fixture(): string { + const root = mkdtempSync(join(tmpdir(), 'dsh-browser-notices-')) + roots.push(root) + write(root, 'package.json', '{"type":"module"}') + write(root, 'tsconfig.base.json', JSON.stringify({ + compilerOptions: { + module: 'esnext', target: 'es2022', jsx: 'react-jsx', + paths: { '@fixture/static': ['./packages/client/static/src/index.ts'] }, + }, + })) + write(root, 'tsconfig.base.client.json', '{"extends":"./tsconfig.base.json"}') + return root +} + +function write(root: string, path: string, text: string): void { + const target = join(root, path) + mkdirSync(dirname(target), { recursive: true }) + writeFileSync(target, text) +} + +function library(root: string, name: string, source = 'export const value = 1'): void { + write(root, `node_modules/${name}/package.json`, JSON.stringify({ name, type: 'module', exports: './index.js' })) + write(root, `node_modules/${name}/index.js`, source) +} + +function dynamicPlugin(root: string, source: string): void { + write(root, 'packages/client/dynamic/package.json', JSON.stringify({ + name: '@fixture/dynamic', dsh: { client: { platform: 'web' } }, + })) + write(root, 'packages/client/dynamic/tsdown.config.ts', [ + 'export default {', + ' name: "@fixture/dynamic/client",', + ' entry: { client: "src/client/index.ts" },', + ' inputOptions: { resolve: { conditionNames: ["browser", "import", "default"] } },', + '}', + ].join('\n')) + write(root, 'packages/client/dynamic/src/client/index.ts', source) +} + +describe('browser dependency discovery', () => { + it('records runtime imports through the client config without following upstream dependencies or erased types', async () => { + const root = fixture() + library(root, 'browser-lib', 'export { value } from "transitive-lib"') + library(root, 'transitive-lib') + dynamicPlugin(root, [ + 'import type { MissingType } from "type-only-lib"', + 'import { value } from "browser-lib"', + 'export const output: MissingType = value', + ].join('\n')) + + expect(await browserBundledExternals(root)).toEqual(new Set(['browser-lib'])) + expect(existsSync(join(root, 'packages/client/dynamic/lib'))).toBe(false) + }) + + it('rejects an unresolved runtime library', async () => { + const root = fixture() + dynamicPlugin(root, 'export { value } from "missing-browser-lib"') + await expect(browserBundledExternals(root)).rejects.toThrow('cannot resolve missing-browser-lib') + }) + + it('rejects a declared client without its browser config', async () => { + const root = fixture() + dynamicPlugin(root, 'export const value = 1') + write(root, 'packages/client/dynamic/tsdown.config.ts', 'export default { name: "@fixture/dynamic", entry: "src/index.ts" }') + await expect(browserBundledExternals(root)).rejects.toThrow('has no browser build config') + }) + + it('follows shell workspace aliases, CSS assets and lazy imports without writing output', async () => { + const root = fixture() + library(root, 'shell-lib') + library(root, 'lazy-lib') + library(root, 'asset-lib') + write(root, 'node_modules/asset-lib/package.json', JSON.stringify({ + name: 'asset-lib', exports: { './theme.css': './theme.css' }, + })) + write(root, 'node_modules/asset-lib/theme.css', '.fixture { color: red }') + write(root, 'packages/client/static/package.json', '{"name":"@fixture/static"}') + write(root, 'packages/client/static/src/index.ts', [ + 'import { value } from "shell-lib"', + 'import "asset-lib/theme.css"', + 'export const output = value', + 'export const lazy = () => import("lazy-lib")', + ].join('\n')) + const app = join(root, 'apps/web') + write(root, 'apps/web/package.json', '{"name":"@fixture/web","type":"module","exports":{"./dist/*":"./dist/*"}}') + symlinkSync(resolve(repositoryRoot, 'apps/web/node_modules'), join(app, 'node_modules'), 'junction') + write(root, 'apps/web/index.html', '') + write(root, 'apps/web/main.ts', 'import { output, lazy } from "@fixture/static"; console.log(output); void lazy()') + write(root, 'apps/web/vite.config.ts', `export default { + build: { rollupOptions: { input: { index: ${JSON.stringify(join(app, 'index.html'))}, preview: "missing-preview.ts" } } } + }`) + write(root, 'apps/web/dist/sentinel.txt', 'untouched') + + expect(await browserBundledExternals(root)).toEqual(new Set(['shell-lib', 'lazy-lib', 'asset-lib'])) + expect(readFileSync(join(app, 'dist/sentinel.txt'), 'utf8')).toBe('untouched') + expect(existsSync(join(app, 'dist/index.html'))).toBe(false) + expect(existsSync(join(root, 'packages/client/static/lib'))).toBe(false) + }) + + it('normalizes installed module ids and excludes virtual/workspace modules', () => { + expect(browserPackageOfFile('/repo/node_modules/.pnpm/pkg@1/node_modules/pkg/a.js')).toBe('pkg') + expect(browserPackageOfFile('C:\\repo\\node_modules\\@scope\\pkg\\a.css?url')).toBe('@scope/pkg') + expect(browserPackageOfFile('/repo/packages/client/a.ts')).toBeUndefined() + expect(browserPackageOfFile('\0vite/modulepreload-polyfill')).toBeUndefined() + }) + + it('maps exact names and subpaths from the source facade', () => { + const root = fixture() + const aliases = browserSourceAliases(root) + expect('@fixture/static'.replace(aliases[0]!.find, aliases[0]!.replacement)) + .toBe(join(root, 'packages/client/static/src/index.ts')) + expect(aliases[0]!.find.test('@fixture/static-extra')).toBe(false) + }) +}) diff --git a/scripts/browser-bundled-externals.ts b/scripts/browser-bundled-externals.ts new file mode 100644 index 0000000000..85053be30f --- /dev/null +++ b/scripts/browser-bundled-externals.ts @@ -0,0 +1,178 @@ +/** Resolve direct third-party browser inputs through the shipping build configurations, without emitting files. */ + +import { globSync, readFileSync } from 'node:fs' +import { createRequire } from 'node:module' +import { dirname, resolve } from 'node:path' +import { pathToFileURL } from 'node:url' +import { Rolldown, type UserConfigExport } from 'tsdown' +import ts from 'typescript' + +interface Manifest { + name: string + private?: boolean + dsh?: { client?: unknown } + exports?: Record +} + +interface ResolveContext { + resolve(source: string, importer: string, options: { skipSelf: boolean }): Promise<{ id: string } | null> +} + +/** + * Name of the installed package owning a bundler-resolved file. + * @param file - Resolved module or asset id, including any loader query. + * @returns Package name, or undefined for workspace and virtual modules. + */ +export function browserPackageOfFile(file: string): string | undefined { + const normalized = file.replaceAll('\\', '/') + const marker = normalized.lastIndexOf('/node_modules/') + if (marker < 0) return undefined + const parts = normalized.slice(marker + '/node_modules/'.length).split('/') + return parts[0]?.startsWith('@') ? parts.slice(0, 2).join('/') : parts[0] +} + +function recorder(seen: Set, workspaceNames: ReadonlySet, followWorkspace = false) { + return { + name: 'dsh-browser-direct-dependencies', + enforce: 'pre' as const, + resolveId: { + order: 'pre' as const, + async handler(this: ResolveContext, source: string, importer: string | undefined) { + if (importer === undefined || source.startsWith('.') || source.startsWith('/') + || source.startsWith('\0') || source.startsWith('node:')) return null + const parts = source.split('/') + const name = source.startsWith('@') ? parts.slice(0, 2).join('/') : parts[0] + if (name !== undefined && workspaceNames.has(name)) { + return followWorkspace ? null : { id: source, external: true } + } + const resolved = await this.resolve(source, importer, { skipSelf: true }) + if (resolved === null) throw new Error(`browser notices: cannot resolve ${source} from ${importer}`) + const owner = browserPackageOfFile(resolved.id) + if (owner === undefined) return resolved + if (browserPackageOfFile(importer) === undefined) seen.add(owner) + // Notices disclose direct dependencies; upstream implementation imports stay in the lockfile. + return { id: source, external: true } + }, + }, + } +} + +function readManifest(path: string): Manifest { + return JSON.parse(readFileSync(path, 'utf8')) as Manifest +} + +/** + * Source aliases shared with the repository's source-plane TypeScript programs. + * @param root - Repository root containing tsconfig.base.json. + * @returns Exact and wildcard aliases for the Vite dependency walk. + */ +export function browserSourceAliases(root: string): { find: RegExp; replacement: string }[] { + const path = resolve(root, 'tsconfig.base.json') + const config = ts.readConfigFile(path, file => ts.sys.readFile(file)) + if (config.error !== undefined) throw new Error(ts.flattenDiagnosticMessageText(config.error.messageText, '\n')) + const parsed = ts.parseJsonConfigFileContent(config.config, ts.sys, root) + return Object.entries(parsed.options.paths ?? {}).map(([name, targets]) => { + const target = targets[0] + if (target === undefined) throw new Error(`browser notices: ${name} has no source target in ${path}`) + const escaped = name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&').replace('\\*', '(.*)') + return { find: new RegExp(`^${escaped}$`), replacement: resolve(root, target).replace('*', '$1') } + }) +} + +async function collectClientBundles( + root: string, + manifests: ReadonlyMap, + workspaceNames: ReadonlySet, + seen: Set, +): Promise { + for (const [manifestPath, manifest] of manifests) { + if (manifest.private === true || manifest.dsh?.client === undefined) continue + const dir = dirname(manifestPath) + const loaded = await import(pathToFileURL(resolve(dir, 'tsdown.config.ts')).href) as { default: UserConfigExport } + const factory = await loaded.default + const configured = typeof factory === 'function' ? await factory({ env: {} }, { ci: false }) : factory + const configs = Array.isArray(configured) ? configured : [configured] + const client = configs.find(config => config.name === `${manifest.name}/client`) + if (client === undefined) throw new Error(`browser notices: ${manifest.name} has no browser build config`) + if (typeof client.inputOptions === 'function') throw new Error(`browser notices: ${manifest.name} needs resolved input options`) + const bundle = await Rolldown.rolldown({ + ...client.inputOptions, + cwd: dir, + input: client.entry as Rolldown.InputOption, + platform: 'browser', + transform: client.define === undefined ? {} : { define: client.define }, + plugins: [recorder(seen, workspaceNames), client.plugins ?? []] as NonNullable, + tsconfig: resolve(root, 'tsconfig.base.client.json'), + }) + try { + await bundle.generate({ format: 'cjs', sourcemap: false }) + } finally { + await bundle.close() + } + } +} + +interface ShellConfig { + build: { rollupOptions?: { input?: string | string[] | Record } } +} + +interface ViteApi { + resolveConfig(config: Record, command: 'build'): Promise + build(config: Record): Promise +} + +async function collectShell( + root: string, + workspaceNames: ReadonlySet, + seen: Set, +): Promise { + for (const path of globSync('apps/*/vite.config.ts', { cwd: root }).sort()) { + const dir = dirname(resolve(root, path)) + const manifest = readManifest(resolve(dir, 'package.json')) + if (manifest.private === true || manifest.exports?.['./dist/*'] === undefined) continue + const vitePath = createRequire(resolve(dir, 'package.json')).resolve('vite') + const vite = await import(pathToFileURL(vitePath).href) as ViteApi + const config = await vite.resolveConfig({ root: dir, logLevel: 'error' }, 'build') + const input = config.build.rollupOptions?.input + const entries = typeof input === 'string' ? [input] : Object.values(input ?? {}) + const pages = entries.filter(entry => entry.endsWith('.html')) + if (pages.length === 0) throw new Error(`browser notices: ${manifest.name} has no HTML build entry`) + await vite.build({ + root: dir, + logLevel: 'error', + plugins: [recorder(seen, workspaceNames, true)], + resolve: { alias: browserSourceAliases(root) }, + build: { + write: false, + minify: false, + sourcemap: false, + reportCompressedSize: false, + rollupOptions: { + input: pages.length === 1 ? pages[0] : pages, + // Chunk coloring expects full third-party bodies; the disclosure walk stops at their imports. + output: { manualChunks: () => undefined }, + }, + }, + }) + } +} + +/** + * Direct third-party packages resolved by published browser builds. + * @param root - Repository root with installed build dependencies; lib/ is not required. + * @returns Names of distributed browser inputs, excluding workspace packages and erased types. + */ +export async function browserBundledExternals(root: string): Promise> { + const manifests = new Map() + for (const glob of ['packages/*/*/package.json', 'vendor/*/package.json']) { + for (const path of globSync(glob, { cwd: root }).sort()) { + const absolute = resolve(root, path) + manifests.set(absolute, readManifest(absolute)) + } + } + const names = new Set([...manifests.values()].map(manifest => manifest.name)) + const seen = new Set() + await collectClientBundles(root, manifests, names, seen) + await collectShell(root, names, seen) + return seen +} diff --git a/scripts/doc-standard.spec.ts b/scripts/doc-standard.spec.ts index 0faeb049dc..bfb90703e0 100644 --- a/scripts/doc-standard.spec.ts +++ b/scripts/doc-standard.spec.ts @@ -82,6 +82,7 @@ const PACKAGE_LIBRARIES: Readonly> = { 'packages/util/brand': 'Stateless nominal-string and canonical-key constructors.', 'packages/util/crypto': 'Zero-dependency identifier minting utility.', 'packages/util/deque': 'Zero-dependency circular deque utility.', + 'packages/util/chunked-list': 'Persistent collection operations and checkpoint validation without a plugin surface.', 'packages/util/home-paths': 'Zero-dependency harness-home path resolver.', 'packages/util/launch-environment': 'Zero-dependency environment resolver.', 'packages/util/native-command': 'Host-side subprocess runner utility.', diff --git a/scripts/gen-cordis-inspect-catalog.ts b/scripts/gen-cordis-inspect-catalog.ts index 552ac07733..4d0b46661f 100644 --- a/scripts/gen-cordis-inspect-catalog.ts +++ b/scripts/gen-cordis-inspect-catalog.ts @@ -10,13 +10,13 @@ const root = resolve(import.meta.dirname, '..') const CLIENT_OUT = 'packages/extensions/cordis-client-runner/src/client/api-catalog.ts' const CLIENT_SERVICES: Readonly> = { - layout: ['toggleSidebar', 'openRightbar', 'closeRightbar'], + layout: ['selectPanel', 'beginNavigation', 'toggleSidebar', 'openRightbar', 'closeRightbar'], locale: ['getLocale', 'getSnapshot', 'subscribe', 'setLocale', 'addLanguage', 'register', 'bind'], sessions: ['open', 'openSubagent', 'setSubagentCatalogOpen', 'refreshSubagents', 'search', 'fork', 'scope', 'binding'], slots: ['register', 'inject'], theme: ['getTheme', 'setTheme', 'setFontSize', 'register', 'overrideTokens'], uiWorkspace: [ - 'connectWorkspace', 'startSession', 'archiveSession', 'pickDirectory', 'listDirectory', + 'openSession', 'openWorkspace', 'forkSession', 'connectWorkspace', 'startSession', 'archiveSession', 'pickDirectory', 'listDirectory', 'createDirectory', ], workspaces: ['create', 'rename', 'delete', 'insertSessionBefore', 'archiveSession'], diff --git a/scripts/gen-third-party-notices.spec.ts b/scripts/gen-third-party-notices.spec.ts index 7bf3f88b18..fabce6e8c9 100644 --- a/scripts/gen-third-party-notices.spec.ts +++ b/scripts/gen-third-party-notices.spec.ts @@ -4,6 +4,7 @@ import { tmpdir } from 'node:os' import { describe, expect, it } from 'vitest' import { CLAUDE_AGENT_SDK_PACKAGE, + assertRuntimeLicenses, claudeDistributionFromManifest, collectPythonDependencies, isOwnerAuthorizedRuntime, @@ -24,8 +25,11 @@ describe('THIRD_PARTY_NOTICES.md', () => { // already runs in the test lane, so the check costs no extra CI process. // Pre-commit regenerates the file whenever a manifest is staged, so reaching // this assertion means the notices were committed without that hook. - it('matches what the generator produces from the current manifests', () => { - const generated = render() + // This case resolves all browser build graphs as well as installed license metadata. + it('matches what the generator produces from the current manifests', { + timeout: 120_000, + }, async () => { + const generated = await render() expect(generated).toContain('It depends on the third-party software listed below.') expect(readFileSync(resolve(root, 'THIRD_PARTY_NOTICES.md'), 'utf8'), 'stale notices — run `pnpm run gen-third-party-notices`').toBe(generated) }) @@ -42,6 +46,37 @@ function workspace(entries: Record): { manifests: Map { + it('keeps license rejection active when a browser library is declared for development', () => { + const { manifests, names } = workspace({ + 'packages/client/ui/package.json': { devDependencies: { 'browser-lib': '^1', 'test-tool': '^1' } }, + }) + const tiers = tierExternalDeps(manifests, names, new Set(['browser-lib'])) + const dependencies = [{ name: 'browser-lib', license: 'GPL-3.0-only' }, { name: 'test-tool', license: 'GPL-3.0-only' }] + .filter(dep => tiers.get(dep.name)) + expect(dependencies.map(dep => dep.name)).toEqual(['browser-lib']) + expect(() => { assertRuntimeLicenses(dependencies) }).toThrow('browser-lib (GPL-3.0-only)') + expect(() => { assertRuntimeLicenses([{ name: 'browser-lib', license: 'MIT' }]) }).not.toThrow() + expect(() => { assertRuntimeLicenses([{ name: CLAUDE_AGENT_SDK_PACKAGE, license: 'SEE LICENSE IN README.md' }]) }) + .not.toThrow() + }) + + it('keeps browser-bundled development dependencies in runtime disclosures', () => { + const { manifests, names } = workspace({ + 'packages/client/ui/package.json': { + name: '@fixture/ui', devDependencies: { react: '^18', 'browser-lib': '^1', 'type-only': '^1' }, + }, + }) + expect(tierExternalDeps(manifests, names, new Set(['react', 'browser-lib']))).toEqual(new Map([ + ['tsx', true], ['react', true], ['browser-lib', true], ['type-only', false], + ])) + }) + + it('rejects a browser library missing from the disclosed declarations', () => { + const { manifests, names } = workspace({}) + expect(() => tierExternalDeps(manifests, names, new Set(['missing-lib']))) + .toThrow('browser package missing-lib has no workspace dependency declaration') + }) + it('tiers by declaring area, not by the declaring section name', () => { const { manifests, names } = workspace({ // Root tooling and test infrastructure never ship, whichever section declares them. diff --git a/scripts/gen-third-party-notices.ts b/scripts/gen-third-party-notices.ts index b9bf11e182..9e83956e5e 100644 --- a/scripts/gen-third-party-notices.ts +++ b/scripts/gen-third-party-notices.ts @@ -13,6 +13,7 @@ import { dirname, resolve } from 'node:path' import * as yaml from 'js-yaml' import { parse as parseToml, type TomlTableWithoutBigInt, type TomlValueWithoutBigInt } from 'smol-toml' import parseSpdx from 'spdx-expression-parse' +import { browserBundledExternals } from './browser-bundled-externals.ts' const root = resolve(import.meta.dirname, '..') const OUT = 'THIRD_PARTY_NOTICES.md' @@ -371,14 +372,11 @@ function normalizeRepo(raw: string | undefined): string | undefined { } /** - * External npm dependencies, tiered by which workspace area declares them at - * runtime: a package is runtime when any manifest outside `DEV_ONLY_AREAS` - * names it in `dependencies`/`optionalDependencies`. A package declared only - * by tooling, test infrastructure, the website, or the demo leaves — whatever - * the declaring section is called — is development-only. + * Direct npm dependencies distributed through installed runtime libraries or + * browser builds. Tooling declarations alone do not imply distribution. */ -function collectNpmDeps(manifests: Map, names: Set): ExternalDep[] { - return [...tierExternalDeps(manifests, names)] +function collectNpmDeps(manifests: Map, names: Set, browser: ReadonlySet): ExternalDep[] { + return [...tierExternalDeps(manifests, names, browser)] .filter(([name]) => !FIRST_PARTY.has(name)) .sort(([a], [b]) => a.localeCompare(b)) .map(([name, runtime]) => ({ name, ...installedMetadata(name, manifests), runtime })) @@ -388,9 +386,12 @@ function collectNpmDeps(manifests: Map, names: Set): E * Tier every external dependency the workspace declares. * @param manifests - workspace manifests keyed by repository-relative path. * @param names - every workspace package name, which never counts as external. + * @param browser - Direct third-party packages resolved by the browser builds. * @returns each external package mapped to whether it is a runtime dependency. */ -export function tierExternalDeps(manifests: Map, names: Set): Map { +export function tierExternalDeps( + manifests: Map, names: Set, browser: ReadonlySet = new Set(), +): Map { const tiers = new Map() // `tsx` is runtime by fiat: the root source-run scripts execute through its ESM hook. tiers.set('tsx', true) @@ -399,11 +400,14 @@ export function tierExternalDeps(manifests: Map, names: Set !isPermissive(dep.license) && !isOwnerAuthorizedRuntime(dep.name)) + if (rejected.length > 0) { + throw new Error(`gen-third-party-notices: runtime ${rejected.map(dep => `${dep.name} (${dep.license})`).join(', ')} is not a permissive license; review the distribution terms and record the decision before regenerating.`) + } +} + /** * Render the sentence that isolates non-permissive development tooling, or * nothing at all when every development dependency is permissive. @@ -671,14 +687,15 @@ ${rows.join('\n')} /** * Render the complete notices document. - * @returns the exact bytes `THIRD_PARTY_NOTICES.md` must hold. + * @returns The exact bytes THIRD_PARTY_NOTICES.md must hold after resolving browser inputs. */ -export function render(): string { +export async function render(): Promise { + const browser = await browserBundledExternals(root) // The linked-manifest cache is keyed by name only, so it must not outlive // the manifests map it was resolved from; render() owns that single load. workspaceLinkedManifestCache.clear() const { manifests, names } = loadWorkspaceManifests() - const npm = collectNpmDeps(manifests, names) + const npm = collectNpmDeps(manifests, names, browser) const runtimeDeps = npm.filter(dep => dep.runtime) const devDeps = npm.filter(dep => !dep.runtime) const vendored = collectVendored() @@ -690,15 +707,7 @@ export function render(): string { ? collectClaudeDistribution(manifests) : undefined const nonPermissiveDev = devDeps.filter(dep => !isPermissive(dep.license)) - // A copyleft license reaching a shipped surface is a distribution decision, - // not a rendering detail; the notices cannot quietly absorb it. - const nonPermissiveRuntime = runtimeDeps.filter(dep => - !isPermissive(dep.license) - && !isOwnerAuthorizedRuntime(dep.name), - ) - if (nonPermissiveRuntime.length > 0) { - throw new Error(`gen-third-party-notices: runtime ${nonPermissiveRuntime.map(dep => `${dep.name} (${dep.license})`).join(', ')} is not a permissive license; review the distribution terms and record the decision before regenerating.`) - } + assertRuntimeLicenses(runtimeDeps) const patchedLines = patched.map(({ spec, patch }) => `- \`${spec}\` — [\`${patch}\`](${patch})`) return `