diff --git a/.agents/notes/implemented/architecture/2026-09-18-agent-teams-single-bundle.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-18-agent-teams-single-bundle.i18n.yaml index a005e6d029..ecf9b0571e 100644 --- a/.agents/notes/implemented/architecture/2026-09-18-agent-teams-single-bundle.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-09-18-agent-teams-single-bundle.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-09-18-agent-teams-single-bundle.md -2026-09-18-agent-teams-single-bundle.md: 21878a8e481871f5b0eeebd8f1bea707411ff3b7 -2026-09-18-agent-teams-single-bundle.zh.md: 39c6bfb7b365a45ff98d29f91a4d7905b26db008 +2026-09-18-agent-teams-single-bundle.md: b417061d24928877d8d31739e1e3bde6b8512d51 +2026-09-18-agent-teams-single-bundle.zh.md: ed20dac79f39f6648767d57556ed97b42d0e1c65 diff --git a/.agents/notes/implemented/architecture/2026-09-18-agent-teams-single-bundle.md b/.agents/notes/implemented/architecture/2026-09-18-agent-teams-single-bundle.md index 21878a8e48..b417061d24 100644 --- a/.agents/notes/implemented/architecture/2026-09-18-agent-teams-single-bundle.md +++ b/.agents/notes/implemented/architecture/2026-09-18-agent-teams-single-bundle.md @@ -30,4 +30,4 @@ Existing profiles that select the removed Web bundle have an upgrade compatibili ## Verification -The plugin-manager browser test observes the Team controls appear after one switch, reads the roster and task board through Remote, and observes their removal after disabling the bundle. The Team-panel browser test covers task creation; the built headless CLI test covers delegation without a Web server. These scenarios do not cover upgrading a profile that still selects the removed Web bundle. +The plugin-manager browser test observes the Team controls appear after one switch, reads the roster and task board through Remote, and observes their removal after disabling the bundle. The Team-panel browser test covers the read-only task board; the built headless CLI test covers delegation without a Web server. These scenarios do not cover upgrading a profile that still selects the removed Web bundle. diff --git a/.agents/notes/implemented/architecture/2026-09-18-agent-teams-single-bundle.zh.md b/.agents/notes/implemented/architecture/2026-09-18-agent-teams-single-bundle.zh.md index 39c6bfb7b3..ed20dac79f 100644 --- a/.agents/notes/implemented/architecture/2026-09-18-agent-teams-single-bundle.zh.md +++ b/.agents/notes/implemented/architecture/2026-09-18-agent-teams-single-bundle.zh.md @@ -30,4 +30,4 @@ Status: implemented ## 验证 -插件管理器浏览器测试观察一次开关后 Team 控件出现,通过 Remote 读取成员列表与任务看板,并观察关闭组合包后控件移除。Team 面板浏览器测试覆盖任务创建;构建后的 headless CLI 测试覆盖无需 Web 服务的委派。这些场景不覆盖仍选中已移除 Web 组合包的 profile 升级。 +插件管理器浏览器测试观察一次开关后 Team 控件出现,通过 Remote 读取成员列表与任务看板,并观察关闭组合包后控件移除。Team 面板浏览器测试覆盖只读任务看板;构建后的 headless CLI 测试覆盖无需 Web 服务的委派。这些场景不覆盖仍选中已移除 Web 组合包的 profile 升级。 diff --git a/.agents/notes/implemented/architecture/2026-09-21-conversation-build-groups.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-21-conversation-build-groups.i18n.yaml new file mode 100644 index 0000000000..d8c8fb934a --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-21-conversation-build-groups.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-21-conversation-build-groups.md +2026-09-21-conversation-build-groups.md: 6591ebea61d8edd06edbaf5f54f2bebd1bd00602 +2026-09-21-conversation-build-groups.zh.md: 70657ded8e04f0badb987ada965aef1184cfc7b0 diff --git a/.agents/notes/implemented/architecture/2026-09-21-conversation-build-groups.md b/.agents/notes/implemented/architecture/2026-09-21-conversation-build-groups.md new file mode 100644 index 0000000000..6591ebea61 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-21-conversation-build-groups.md @@ -0,0 +1,155 @@ +# Agent Note: Definition-owned grouping with generic Node/Group assembly + +Status: implemented + +English | [中文](2026-09-21-conversation-build-groups.zh.md) + +## Problem + +Chat needs expandable groups for continuous process content while keeping replies, user input, and Turn controls independent. Compact, Detailed, and Expanded must share grouping results; changing modes must not rebuild React instances by removing group containers, moving member parents, or changing keys. + +A Chat Builder aggregates one target's Nodes and indexes. Hardcoding a Chat process projector there makes the Builder own business segmentation, even if some caching moves into generic infrastructure. Business Definitions are the registration point for these rules. + +Process groups are orthogonal to Steps: one Assistant Node can contribute reasoning inside a group and a reply outside it; tools after that reply enter a later group. Step-number ranges cannot describe membership. Existing event Definitions interpret one event at a time, whereas grouping needs the Nodes those Definitions have already interpreted. + +## Decision + +The grouping foundation provides a separate, Node-input `ConversationGroupDefinition`, its registry and Session-local context, generic Builder input/publication, keyed Group storage, and the two React branches. Production Chat segmentation and PR #4565 presentation are not registered by this foundation; the [business migration proposal](../../proposed/feature/2026-09-20-chat-work-details-migration.md) retains their requirements. The [subsystem reference](../../../../docs/subsystems/conversation.md#group-definitions) and [source types](../../../../packages/client/ui-conversation/src/client/contract/groups.ts) own the current API details. + +### Responsibilities + +| Requirement | Consequence | +|---|---| +| Business grouping belongs to a registered Definition | Membership, segmentation, replies, steering, retries, summaries, and cache invalidation stay together. | +| Builder and assembler do generic work | Supply inputs, dispatch Definitions, validate references, reuse identities, and publish; never construct a concrete grouping class. | +| PR #4565 defines product behavior | Preserve its behavior without copying its invasive implementation. | +| React has two root kinds | The ordered root contains NodeReference and GroupReference, not another layout object model. | +| All modes share grouping | Mode is absent from Definition input, keys, and parent selection. | +| No buildLayout | ConversationViewDefinition keeps its existing responsibilities. | +| Foundation precedes business | No production Chat grouping, summaries, notifications, or footer changes in the foundation PR. | + +### Related decisions + +| Record | Relationship | +|---|---| +| [Business Node assembly](2026-08-09-client-conversation-node-assembly.md) | Retains event matching, Contexts, Locations, one business Node per Context, and target Builders; grouping adds another input category. | +| [Presentation policy and grouping](../../proposed/architecture/2026-09-20-chat-presentation-policy-and-step-groups.md) | Retains Definition ownership, local subscriptions, and mode independence; replaces event folding and Step-range membership as the grouping mechanism. | +| [Work-details migration](../../proposed/feature/2026-09-20-chat-work-details-migration.md) | Retains product scope and migration tracking; infrastructure alone does not complete those features. | + +These records retain independent rationale and remain active. Cross-View navigation and `toolCallFocus` are outside this change; no View handles, Label Slots, or resource-navigation architecture is introduced. + +## Definition input, state, and registration + +`ConversationNodeDefinition` keeps `match/start/update/publication/buildLocationData/buildViewNode`. `ConversationViewDefinition` keeps `target/create/isActive/toolCallFocus`. Group business is registered through `ctx.uiConversation.groups.register(processGroupDefinition)`, not a callback on either existing Definition. + +- One Group Definition per target owns the complete grouping semantics. Duplicate target registration fails rather than selecting a winner or stacking strategies. +- Registration requires an existing View target, but never constructs a Builder. First activation checks the constructed Builder for `groupInput()`; missing input fails. Ungrouped targets do not require it or allocate Group state. +- Each Session owns one derived context per registered Definition. `create()` initializes its State; it is not a `turn/start` event or an event Definition's unique start Match. Definition objects do not hold mutable cross-Session state. +- `update(context, input)` returns the adopted State. `buildGroups(context)` materializes pending output without interpreting events again or advancing counts on repeated reads. Replacement input requires a complete root and group replacement so removed Nodes cannot leave dangling references; `null` on apply retains output. +- `replace` supplies target order, timeline, and synchronous `readNode`, `readTurn`, and `readPosition` readers; `apply` also supplies projected previous/current Nodes, `changedTurns`, and `changedTurnOrders`. Readers are valid during the synchronous call only and must not be retained in State. +- Node data stays in the target Node Store. Content-only updates preserve the order array. Previous values are captured before the Builder installs projected updates, including additional Nodes changed by target projections. +- `ConversationLocationIndex` accumulates changed Turns from boundaries and Location data writes. Assembler passes one drained batch to every updated target, including a Turn ending without Node upserts. Direct ungrouped Builder callers may omit this field. +- The target position index supplies `changedTurnOrders` for visible-key, owning-Turn, or immediate-neighbour changes, including both owners of a moved Node and Turns affected by adjacent unscoped Nodes. `readTurn` returns one Turn's visible keys; `readPosition` returns `GroupNodePosition` with the owning Turn and immediate previous/next keys. Turn-only reads omit interruptions by unscoped Nodes; neighbour facts let business segmentation retain them. The index provides positions, not grouping decisions. +- The generic GroupDataMap associates target and Data across registration, storage, and reading. Undeclared targets have no group payload type; business consumers do not reconstruct summary types with unknown assertions. + +## Node/Group references and updates + +`NodeKey` names an existing Context Node; `GroupKey` identifies a group within a Session, target, and Definition. `NodeReference` and `GroupReference` have equal standing in `RenderEntry`. `GroupSnapshot` stores a group's immutable data and ordered NodeReference members separately from root order. Group creation adds neither Session events nor an execution lifecycle. + +`groupPart` is a renderer-owned part such as reasoning or response; omission selects the whole Node. A part may also appear at the root. Part completeness and content ownership belong to the business renderer, not the framework. Group data describes status, counts, or summaries, without copying Node bodies, tool results, or mutable business State. A group has no renderer field, CSS, coordinates, React elements, navigation, or nested groups. + +| Update | Meaning | +|---|---| +| Omit `entries` on apply input | Keep root order; replacement input requires the complete sequence. | +| Supply `entries`, including `[]` | Replace the complete root sequence; an empty array clears it. | +| `groups: { kind: 'replace', snapshots }` | Replace complete group records, removing omitted groups. | +| `groups: { kind: 'apply', upserts, removes }` | Upsert complete snapshots and remove named GroupKeys only. | +| Data-only change | Upsert the group with new data, reuse members, omit entries. | +| Member-only change | Upsert that group with its complete new member array; no all-group replacement. | +| Root-only change | Supply entries with empty apply upserts/removes. | +| Dissolve a group | Replace root entries with its members and remove the GroupKey atomically. Source Nodes remain. | + +`entries` and `groups` are one installation batch, not independent publication channels. Removing a member reference does not delete a Node. Upserts need snapshot contents; removals need only identity. React may remount a member after a real membership change, but mode switches must not dissolve groups. + +The store validates the submitted final result before mutation: every root Group has exactly one record and root position; Node references exist; duplicate upserts/removals and simultaneous upsert/removal fail. Across all roots and members, each `(NodeKey, groupPart)` appears at most once. A whole Node cannot coexist with any of its parts, but different parts may occupy different positions. Atomic moves between positions are allowed. + +GroupStore indexes records and sources by GroupKey, not repeated array searches. Equivalent root/member arrays and unchanged group snapshots retain identity. Data-only upserts inspect only submitted snapshots, without scanning roots, unchanged members, or Nodes. Full replacement revalidates references and still aligns results by key. Definition caches own business calculation; GroupStore owns installed results and notifications. + +## Assembly order and lifecycle + +1. BoundConversation receives append, prepend, replace, or Assistant settlement from the existing feed. +2. The assembler runs existing Node Definition matching, state updates, and required Context replay. +3. Existing immediate/animation-frame/none cadence schedules publication; grouping adds no timer or event subscription. +4. Flush materializes Step then Turn Location data and target Nodes. +5. Builder.replace/apply installs projected Nodes, target order, and indexes, records position changes, and retains GroupInput with its indexed readers without notifying independent sources. +6. The assembler obtains the target's registered Group Definition context, calls update with builder.groupInput(), and adopts the returned State. +7. It calls buildGroups, validates and installs the result, and installs the target snapshot. +8. After every affected target is installed, it calls Builder.publish, publishes Group sources, and publishes Location data. +9. BoundConversation publishes the existing root Conversation source. + +First activation's replaceView uses the same target update function as ordinary flush. Inactive targets create neither Builder nor Group context. Registration rebuild takes the View and Group Definitions together, discards replaced/removed Group contexts, and clears old observed output. Surviving Definitions reuse keyed stores. React receives the Node Store identity so replacement rebinds keyed hooks without replacing keys or DOM instances. + +Removing a registered View Definition pauses its grouping and clears derived output, while retaining the Group Definition registration. Restoring the View uses the existing replacement flow to rebuild from the current loaded timeline. Initial Group registration still rejects a missing View target; switching tabs does not unregister a View. + +Grouping never replays raw events. Node Definitions handle replay first; grouping consumes the resulting target replacement or changes. Prepend may repair boundaries and legitimately move members. Definition handles old/new Locations and changed Turns instead of treating pagination or every content chunk as a complete-history regroup. + +## Concrete implementation locations + +| Layer/file | Responsibility | +|---|---| +| [groups.ts](../../../../packages/client/ui-conversation/src/client/contract/groups.ts) | Group Definition, input, references, updates, typed data map, and readers. | +| [conversation.ts](../../../../packages/client/ui-conversation/src/client/contract/conversation.ts) | Optional Builder groupInput/publish and snapshot-store grouped reader; existing Node/View Definitions stay unchanged. | +| [group-registry.ts](../../../../packages/client/ui-conversation/src/client/conversation/group-registry.ts) | Existing registry base, target uniqueness, typed registration, effect/disposer lifecycle. | +| [assembly.ts](../../../../packages/client/ui-conversation/src/client/conversation/assembly.ts) | UiConversation.groups and existing binding rebuild notifications. | +| [assembler.ts](../../../../packages/client/ui-conversation/src/client/conversation/assembler.ts) | Common first-activation/flush dispatch, context ownership, installation, and publication. | +| [location-index.ts](../../../../packages/client/ui-conversation/src/client/conversation/location-index.ts) | Accumulate changed Turns, including lifecycle-only and Location-data changes. | +| [group-store.ts](../../../../packages/client/ui-conversation/src/client/conversation/group-store.ts) | Atomic reference validation, keyed sources, array reuse, and local publication. | +| [chat-snapshot-builder.ts](../../../../packages/client/ui-chat/src/client/conversation-nodes/chat-snapshot-builder.ts) | Record projected Node deltas, target positions, and changed Turn orders; provide indexed readers and defer source notification. No process grouping class. | +| [ChatView.tsx](../../../../packages/client/ui-chat/src/client/chat/ChatView.tsx) | Read optional root entries and switch between node/group; fallback to existing Node order. | +| [ChatGroupSeat.tsx](../../../../packages/client/ui-chat/src/client/chat/ChatGroupSeat.tsx) | Stable group parent, member-only subscription, nested Node seats. | +| [ChatNodeSeat.tsx](../../../../packages/client/ui-chat/src/client/chat/ChatNodeSeat.tsx) | Existing Node sources/renderers, groupPart forwarding, distinct part anchors, Store-replacement rebind. | +| [slots.ts](../../../../packages/client/ui-chat/src/client/contract/slots.ts) and [apply.ts](../../../../packages/client/ui-chat/src/client/apply.ts) | Existing keyed-hook injection for Group sources; no Slot-engine change. | + +## React reading and presentation + +The root selects `views.grouped('chat')?.entries` through the existing useConversation source. Keyed Group hooks use existing injection; each Group seat selects members. Members use existing keyed Node sources. Removed Group sources can read undefined before the root removes their components, so selectors tolerate absence and keep Hook order stable. + +React keys derive from reference kind, NodeKey/groupPart or GroupKey, using unambiguous tuples. No separate RenderKey type or renderer dispatch field is needed. Compact, Detailed, and Expanded keep the same group container and member parents; presentation changes do not rerun the Group Definition or select a different root branch. + +The stable Group parent is a `div` with `display: contents`, without its own layout box. CSS inheritance remains available; business styles adapt child/sibling selectors and own measurable body containers. Neither CSS variables nor geometry enter the Definition. Reading-position capture measures member Nodes and retains part-specific anchors; existing Turn navigation resolves the original NodeKey to its first visible part. + +## Alternatives considered + +**Builder-owned buildGroups or process projector.** Rejected: the target aggregator would still own business rules. Moving caches into infrastructure does not change that ownership. + +**Add buildGroups to the existing event Definition without new input.** Rejected: the callback name does not provide cross-Node input, lifecycle changes, or reconstruction semantics. + +**Replay raw events in the Group Definition.** Rejected: it duplicates Assistant/Tool/Message interpretation and can diverge from the Builder's final visible order. + +**ConversationViewDefinition.buildLayout.** Rejected: it broadens a target factory into a business layout callback rather than registering a distinct business Definition. + +**One event Context per Group.** Rejected: boundaries depend on preceding visible content, not a standalone event carrying the group's start identity. A target-local derived context also admits independent Nodes outside Turns. + +**Dependency graphs, ViewItem classes, factories, nested groups, or strategy stacking.** Rejected for this phase: one target input and one non-nested grouping output cover the stated need. + +**Separate data-only mutation operation.** Rejected: an immutable snapshot upsert with reused members already updates summaries without replacing other groups. + +**Let Group Definition add/remove both Node and Group entities.** Rejected: Node data already has an owner. Symmetrical references do not require a second Node owner. + +**Remove the group wrapper in Expanded.** Rejected: changing the parent remounts members even when their keys survive. + +## Verification + +- [Group store tests](../../../../packages/client/ui-conversation/tests/conversation-group-store.client.spec.ts) cover atomic reference validation, root/member identity reuse, local publication, and removal without deleting source Nodes. +- [Grouping dispatch tests](../../../../packages/client/ui-conversation/tests/conversation-groups.client.spec.ts) cover first activation, lifecycle-only input, registry replacement, View removal/recovery, and complete replacement output. [Assembler tests](../../../../packages/client/ui-conversation/tests/conversation-assembler.client.spec.ts) cover changed-Turn reporting and Location data sources. +- [Node source tests](../../../../packages/client/ui-chat/tests/chat-node-source.client.spec.ts) cover projected grouping inputs, indexed readers, and empty change batches. +- [Chat rendering tests](../../../../packages/client/ui-chat/tests/chat-view.client.spec.tsx) retain component state across modes, rebind replacement Node stores, pass independent parts, and omit unreferenced Nodes. [Viewport tests](../../../../packages/client/ui-chat/tests/chat-viewport.client.spec.ts) cover grouped reading anchors, history prepend, and part-aware Turn navigation. + +## Consequences + +- This is an explicit framework extension: a new input protocol, registry, context, publication phase, and reader. It is not merely another callback. +- Target-local State does not automatically make business updates local. Node changes and changed Turn orders let a Definition choose affected groups and ranges. Structural changes still rebuild the target position index over visible order; content-only updates do not rebuild it. Structural grouping output still replaces the complete root-reference array. +- Node order and visibility have one owner: the Builder. Grouping must not independently sort raw events or infer membership again in infrastructure. +- Stable mounting does not eliminate layout, paint, or retained-memory costs. No measured latency or optimal-performance claim is made. +- Real group splits, merges, first-member changes, and pagination repairs can change identity. The mode-switch guarantee does not prohibit those legitimate changes. +- Revealing hidden parts, selection/copy, interruption notices, group disclosure, and outer Turn interaction require business adaptation. Foundation tests exercise mixed references without enabling production groups. diff --git a/.agents/notes/implemented/architecture/2026-09-21-conversation-build-groups.zh.md b/.agents/notes/implemented/architecture/2026-09-21-conversation-build-groups.zh.md new file mode 100644 index 0000000000..70657ded8e --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-21-conversation-build-groups.zh.md @@ -0,0 +1,155 @@ +# Agent Note: Definition 拥有分组业务,框架统一调度 Node/Group 输出 + +Status: implemented + +[English](2026-09-21-conversation-build-groups.md) | 中文 + +## Problem + +Chat 需要把连续过程内容收进可展开的组,同时保留独立回复、用户输入和轮次控件。Compact、Detailed、Expanded 必须共用分组结果;切换模式不能通过删除组容器、移动成员父级或更换 key 重建 React 实例。 + +Chat Builder 汇总整个目标的节点及索引。在其中固定创建 Chat 过程投影器,即使把部分缓存移到通用层,仍然让 Builder 拥有业务分段。业务 Definition 才是这些规则的注册点。 + +过程组与 Step 正交:同一个 Assistant Node 可以把推理放在组内、回复放在组外,回复后的工具进入后面的组。Step 编号范围无法描述这种成员归属。现有事件 Definition 逐个解释事件,分组则需要这些 Definition 已经解释完成的节点。 + +## Decision + +分组基础机制提供独立的节点输入 `ConversationGroupDefinition`、注册表及 Session 内上下文、Builder 通用输入与发布、按键 Group 存储及两种 React 分支。基础机制不注册真实 Chat 分段及 PR #4565 展示,[业务迁移提案](../../proposed/feature/2026-09-20-chat-work-details-migration.zh.md)保留这些需求。[子系统参考](../../../../docs/subsystems/conversation.zh.md#group-definitions)与[源码类型](../../../../packages/client/ui-conversation/src/client/contract/groups.ts)拥有当前 API 细节。 + +### 职责 + +| 要求 | 含义 | +|---|---| +| 分组业务属于已注册 Definition | 成员、分段、回复、steering、重试、摘要和缓存失效放在一起。 | +| Builder 与 assembler 做统一工作 | 提供输入、调度 Definition、校验引用、复用身份并发布,不创建具体分组类。 | +| 产品行为以 PR #4565 为准 | 保留行为,不复制其侵入性实现。 | +| React 根循环只有两种 kind | 根顺序包含 NodeReference 和 GroupReference,不引入另一套布局对象模型。 | +| 所有模式共用分组 | 模式不进入 Definition 输入、key 或父级选择。 | +| 不采用 buildLayout | ConversationViewDefinition 保留现有职责。 | +| 先基础、后业务 | 基础 PR 不包含真实 Chat 分组、摘要、通知或页脚变化。 | + +### 与已有记录的关系 + +| 记录 | 关系 | +|---|---| +| [业务节点组装](2026-08-09-client-conversation-node-assembly.zh.md) | 保留事件匹配、Context、Location、每个 Context 一个业务 Node 及目标 Builder,分组增加另一种输入类别。 | +| [展示策略与过程分组](../../proposed/architecture/2026-09-20-chat-presentation-policy-and-step-groups.zh.md) | 保留 Definition 归属、局部订阅和模式无关要求,以新分组机制替代事件 fold 及 Step 范围成员推断。 | +| [工作过程展示迁移](../../proposed/feature/2026-09-20-chat-work-details-migration.zh.md) | 保留产品范围及迁移跟踪,基础机制本身不代表这些功能完成。 | + +这些记录仍有独立理由,保留有效状态。跨 View 导航与 `toolCallFocus` 不属于本次改动;不引入 View 句柄、Label Slot 或资源导航架构。 + +## Definition 输入、状态与注册 + +`ConversationNodeDefinition` 保留 `match/start/update/publication/buildLocationData/buildViewNode`,`ConversationViewDefinition` 保留 `target/create/isActive/toolCallFocus`。分组业务通过 `ctx.uiConversation.groups.register(processGroupDefinition)` 注册,不加在上述已有 Definition 的回调中。 + +- 每个目标只有一个 Group Definition,拥有完整分组语义。重复目标注册报错,不选择胜者,也不叠加策略。 +- 注册要求已有 View 目标,但不构造 Builder。首次激活取得 Builder 后检查 `groupInput()`,缺少输入方法时报错。未分组目标不要求该方法,也不分配 Group 状态。 +- 每个 Session 为已注册 Definition 拥有一个派生上下文。`create()` 初始化 State,不是 `turn/start` 事件,也不是事件 Definition 的唯一 start Match。Definition 对象不保存跨 Session 的可变状态。 +- `update(context, input)` 返回框架采纳的 State。`buildGroups(context)` 物化待输出结果,不重新解释事件,不因重复读取增加计数。替换输入要求输出完整根序列和组替换,避免已移除节点留下悬挂引用;apply 的 `null` 保留结果。 +- `replace` 提供目标顺序、时间线及同步的 `readNode`、`readTurn`、`readPosition` 读取器;`apply` 还提供投影后的节点前后值、`changedTurns` 和 `changedTurnOrders`。读取器仅在当前同步调用中有效,不保留到 State。 +- 节点数据仍在目标 Node Store。仅正文更新保留顺序数组;旧值在 Builder 安装投影结果前记录,也覆盖目标投影额外改变的节点。 +- `ConversationLocationIndex` 从边界事件和 Location 数据写入累计变化轮次。assembler 将同一批变化交给全部被更新目标,覆盖没有节点 upsert 的 Turn 结束。直接调用未分组 Builder 的调用方可省略该字段。 +- 目标位置索引通过 `changedTurnOrders` 提供可见键、所属 Turn 或紧邻关系的变化,包含移动 Node 的新旧所属轮次,以及受相邻轮次外 Node 影响的轮次。`readTurn` 返回某个 Turn 的可见键,`readPosition` 返回包含所属 Turn 和前后紧邻键的 `GroupNodePosition`。仅按 Turn 读取会遗漏轮次外 Node 造成的分隔,业务分段可以通过相邻事实保留这些分隔。索引提供位置,不决定分组。 +- 通用 GroupDataMap 在注册、存储及读取之间关联目标和 Data。未声明的目标没有组载荷类型,业务消费方不通过 unknown 断言恢复摘要类型。 + +## Node/Group 引用与更新 + +`NodeKey` 表示既有 Context Node,`GroupKey` 在 Session、目标及 Definition 内标识组。`NodeReference` 与 `GroupReference` 在 `RenderEntry` 中地位相同。`GroupSnapshot` 单独保存组的不可变数据与有序 NodeReference 成员,不混入根顺序。创建 Group 不增加 Session 事件或执行生命周期。 + +`groupPart` 是渲染器拥有的节点部分,例如 reasoning 或 response,省略表示整个 Node。部分引用也可以独立位于根层。部分内容的完备性和归属由业务渲染器负责,不由框架判断。组数据描述状态、计数或摘要,不复制 Node 正文、工具结果或可变业务 State。组不携带 renderer 字段、CSS、坐标、React 元素、导航或嵌套组。 + +| 更新 | 含义 | +|---|---| +| apply 输入省略 `entries` | 保留根顺序;替换输入必须提供完整序列。 | +| 提供 `entries`,包括 `[]` | 替换完整根序列,空数组清空根序列。 | +| `groups: { kind: 'replace', snapshots }` | 替换完整组记录,移除未包含的旧组。 | +| `groups: { kind: 'apply', upserts, removes }` | upsert 完整快照,只按具名 GroupKey 删除组。 | +| 仅数据变化 | upsert 新 data,复用 members,省略 entries。 | +| 仅成员变化 | upsert 该组完整的新成员数组,不替换全部组。 | +| 仅根顺序变化 | 提供 entries,apply 的 upserts/removes 为空。 | +| 解散一个组 | 原子地用成员替换根引用并删除 GroupKey,保留原 Node。 | + +`entries` 与 `groups` 属于同一安装批次,不是独立发布通道。删除成员引用不删除 Node。upsert 需要快照内容,删除只需要身份。真实成员归属变化可以让 React 重挂载成员,但模式切换不能解散组。 + +存储在修改前校验最终提交结果:每个根 Group 恰有一份记录和一个根位置;Node 引用存在;重复 upsert、重复删除及同时 upsert/删除均报错。全部根和成员中,每个 `(NodeKey, groupPart)` 最多出现一次。整 Node 不能与自身任一部分共存,不同部分可以占据不同位置。允许引用在一次原子更新中移动位置。 + +GroupStore 按 GroupKey 索引记录和来源,不反复查找数组。等价根数组、成员数组及未变化组快照保留身份。仅数据 upsert 只检查提交的快照,不扫描根、未变化成员或 Node。完整替换重新校验引用,仍按 key 对齐结果。Definition 缓存拥有业务计算,GroupStore 拥有已安装结果及通知。 + +## 组装时序与生命周期 + +1. BoundConversation 从现有 feed 接收 append、prepend、replace 或 Assistant settlement。 +2. assembler 运行既有 Node Definition 匹配、状态更新及必要的 Context 重放。 +3. 既有 immediate/animation-frame/none 节奏调度发布,分组不增加计时器或事件订阅。 +4. flush 依次物化 Step、Turn Location 数据及目标 Node。 +5. Builder.replace/apply 安装投影后 Node、目标顺序及索引,记录位置变化,保留包含索引读取器的 GroupInput,不通知独立来源。 +6. assembler 取得目标已注册的 Group Definition 上下文,以 builder.groupInput() 调用 update,采纳返回 State。 +7. 调用 buildGroups,校验并安装结果,安装目标快照。 +8. 全部受影响目标安装完成后,调用 Builder.publish,发布 Group 来源和 Location 数据。 +9. BoundConversation 发布既有根 Conversation 来源。 + +首次激活的 replaceView 与普通 flush 使用同一目标更新函数。未激活目标不创建 Builder 或 Group 上下文。注册重建一并采纳 View 和 Group Definition,丢弃被替换或删除的 Group 上下文并清空旧观察结果。未替换的 Definition 复用按键存储。React 接收 Node Store 身份,让存储替换时重新绑定按键钩子,不替换 key 或 DOM 实例。 + +已注册的 View Definition 被移除后,分组计算暂停并清空派生结果,Group Definition 注册仍保留。View 恢复时沿现有替换流程,从当前已加载时间线重建。首次注册 Group 时仍拒绝缺失的 View 目标;切换页签不会注销 View。 + +分组不重放原始事件。Node Definition 先处理重放,分组消费其最终目标替换或变化。prepend 可以修正边界并真实移动成员。Definition 处理新旧 Location 和变化轮次,不把分页或每个正文分片都当作完整历史重新分组。 + +## 具体实现落点 + +| 层/文件 | 职责 | +|---|---| +| [groups.ts](../../../../packages/client/ui-conversation/src/client/contract/groups.ts) | Group Definition、输入、引用、更新、类型数据映射及读取器。 | +| [conversation.ts](../../../../packages/client/ui-conversation/src/client/contract/conversation.ts) | Builder 可选 groupInput/publish 和快照存储 grouped 读取,已有 Node/View Definition 不变。 | +| [group-registry.ts](../../../../packages/client/ui-conversation/src/client/conversation/group-registry.ts) | 复用注册表基类,目标唯一性、类型化注册及 effect/disposer 生命周期。 | +| [assembly.ts](../../../../packages/client/ui-conversation/src/client/conversation/assembly.ts) | UiConversation.groups 与现有 binding 重建通知。 | +| [assembler.ts](../../../../packages/client/ui-conversation/src/client/conversation/assembler.ts) | 首次激活和 flush 共用调度,上下文归属、安装及发布。 | +| [location-index.ts](../../../../packages/client/ui-conversation/src/client/conversation/location-index.ts) | 累积变化轮次,覆盖仅生命周期和 Location 数据变化。 | +| [group-store.ts](../../../../packages/client/ui-conversation/src/client/conversation/group-store.ts) | 原子引用校验、按键来源、数组复用及局部发布。 | +| [chat-snapshot-builder.ts](../../../../packages/client/ui-chat/src/client/conversation-nodes/chat-snapshot-builder.ts) | 记录投影后节点增量、目标位置及变化轮次顺序,提供索引读取器并推迟来源通知,不持有过程分组类。 | +| [ChatView.tsx](../../../../packages/client/ui-chat/src/client/chat/ChatView.tsx) | 读取可选根引用并切换 node/group,无分组时使用既有 Node 顺序。 | +| [ChatGroupSeat.tsx](../../../../packages/client/ui-chat/src/client/chat/ChatGroupSeat.tsx) | 稳定组父级、仅成员订阅及嵌套 Node 容器。 | +| [ChatNodeSeat.tsx](../../../../packages/client/ui-chat/src/client/chat/ChatNodeSeat.tsx) | 既有 Node 来源及渲染器、groupPart 传递、独立部分锚点及 Store 替换时重绑定。 | +| [slots.ts](../../../../packages/client/ui-chat/src/client/contract/slots.ts) 与 [apply.ts](../../../../packages/client/ui-chat/src/client/apply.ts) | 使用现有按键钩子注入 Group 来源,不改 Slot 引擎。 | + +## React 读取与展示 + +根层通过既有 useConversation 来源选择 `views.grouped('chat')?.entries`。按键 Group 钩子沿用现有注入,Group 容器选择 members。成员使用既有按键 Node 来源。根列表移除组件前,已删除 Group 来源可能先返回 undefined,因此选择器允许不存在,并保持钩子顺序稳定。 + +React key 由引用 kind、NodeKey/groupPart 或 GroupKey 的无歧义元组派生,不另设 RenderKey 类型或 renderer 分发字段。Compact、Detailed、Expanded 保留同一组容器及成员父级,展示变化不重新运行 Group Definition,也不选择不同的根分支。 + +稳定 Group 父级使用 `div` 与 `display: contents`,自身不产生布局盒子。CSS 继承仍然可用,业务样式适配子级/兄弟选择器并拥有可测量的正文容器。CSS 变量和几何信息都不进入 Definition。阅读位置采样测量成员 Node,保留部分专属锚点;既有轮次导航将原 NodeKey 解析到它的第一个可见部分。 + +## Alternatives considered + +**Builder 持有 buildGroups 或过程投影器。** 不采用,目标汇总器仍然拥有业务规则,把缓存移到通用层不能改变归属。 + +**在原事件 Definition 加 buildGroups,不补输入。** 不采用,回调名称不能提供跨节点输入、生命周期变化或重建语义。 + +**Group Definition 重放原始事件。** 不采用,它重复 Assistant、Tool、Message 解释,并可能偏离 Builder 最终可见顺序。 + +**ConversationViewDefinition.buildLayout。** 不采用,它把目标工厂扩展成业务布局回调,没有独立注册业务 Definition。 + +**每个 Group 一个事件 Context。** 不采用,边界取决于前方可见内容,不由一个携带组起点身份的事件决定。目标内派生上下文也能处理 Turn 外独立节点。 + +**依赖图、ViewItem 类、工厂、嵌套组或策略叠加。** 本阶段不采用,一个目标输入和一份非嵌套分组输出覆盖当前诉求。 + +**独立的数据更新操作。** 不采用,复用 members 的不可变快照 upsert 已经能更新摘要,不替换其他组。 + +**Group Definition 同时增删 Node 和 Group 实体。** 不采用,Node 数据已有所有者,引用对称不需要第二个 Node 所有者。 + +**Expanded 移除组容器。** 不采用,即使 key 不变,父级改变仍会重挂载成员。 + +## Verification + +- [Group 存储测试](../../../../packages/client/ui-conversation/tests/conversation-group-store.client.spec.ts)覆盖原子引用校验、根与成员身份复用、局部发布,以及删除组但保留原 Node。 +- [分组调度测试](../../../../packages/client/ui-conversation/tests/conversation-groups.client.spec.ts)覆盖首次激活、仅生命周期输入、注册替换、View 移除与恢复,以及完整替换输出。[Assembler 测试](../../../../packages/client/ui-conversation/tests/conversation-assembler.client.spec.ts)覆盖变化轮次报告和 Location 数据来源。 +- [Node 来源测试](../../../../packages/client/ui-chat/tests/chat-node-source.client.spec.ts)覆盖投影后的分组输入、索引读取器和空变化批次。 +- [Chat 渲染测试](../../../../packages/client/ui-chat/tests/chat-view.client.spec.tsx)保留模式切换时的组件状态,重绑定替换后的 Node 存储,传递独立部分,并省略未引用 Node。[视口测试](../../../../packages/client/ui-chat/tests/chat-viewport.client.spec.ts)覆盖组内阅读锚点、历史前插及部分感知的轮次导航。 + +## Consequences + +- 这是明确的框架扩展,包含新输入协议、注册表、上下文、发布阶段及读取器,不只是增加一个回调。 +- 目标内 State 不会自动让业务更新局部化,Definition 可以根据节点变化和轮次顺序变化选择受影响的组及范围。结构变化仍按可见顺序重建目标位置索引,仅正文更新不重建该索引。结构性分组输出仍替换完整根引用数组。 +- Node 顺序及可见性只有 Builder 一个所有者,分组不独立排序原始事件,也不在通用层再次推断成员。 +- 稳定挂载不消除布局、绘制或保留内存的成本,不宣称实测延迟或最优性能。 +- 真实组拆并、首成员变化及分页修正可以改变身份,模式切换保证不禁止这些正常变化。 +- 隐藏部分的揭示、选择复制、中断提示、组开合与外层 Turn 联动需要业务适配。基础测试使用混合引用,不启用真实分组。 diff --git a/.agents/notes/implemented/feature/2026-07-06-sandbox.i18n.yaml b/.agents/notes/implemented/feature/2026-07-06-sandbox.i18n.yaml index 0b2036ca86..0c0d28aa76 100644 --- a/.agents/notes/implemented/feature/2026-07-06-sandbox.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-06-sandbox.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/feature/2026-07-06-sandbox.md -2026-07-06-sandbox.md: 45a1c9c028f127c67e56f4436088852f59e10140 -2026-07-06-sandbox.zh.md: 6ad51a039583949849c853fab2f7c2a72d6d9db7 +2026-07-06-sandbox.md: f2c703ba67bc16529605250d35ed181412f05494 +2026-07-06-sandbox.zh.md: ff4d4ed592dc4235903555528a029b4b295d972e diff --git a/.agents/notes/implemented/feature/2026-07-06-sandbox.md b/.agents/notes/implemented/feature/2026-07-06-sandbox.md index 45a1c9c028..f2c703ba67 100644 --- a/.agents/notes/implemented/feature/2026-07-06-sandbox.md +++ b/.agents/notes/implemented/feature/2026-07-06-sandbox.md @@ -176,7 +176,7 @@ Costs and accepted limits: - **The one-wrapper illusion is given up knowingly.** A `tools/pre-execute` wrapper plus prompt conventions does not solve sandbox approval — the correct design costs structured denials, native runner probes, per-call policy carriage, and consistent cross-family enforcement, and this design pays it. - **`read-only` became a cross-family boundary through a follow-up.** This RFC shipped bash-only enforcement; the [cross-family fs sandbox RFC](2026-07-14-cross-family-fs-sandbox.md) extends the same mode vocabulary to the filesystem tools through a sandboxed `ctx.fs` provider and relocates the mode/root config and the `sandbox/mode` override to `ctx.sandboxPolicy` (§ In-process tools). -- **Windows is a partial backend.** This RFC originally reserved an empty, fail-closed win32 chain; the later [Windows ACL sandbox decision](2026-08-08-windows-acl-restricted-token-sandbox.md) filled it with the restricted-token runner. Its Everyone and hard-link gaps are reported as `enforcement: 'partial'`, never promoted to the full promise. +- **Windows is a partial backend.** This RFC originally reserved an empty, fail-closed win32 chain; the later [Windows ACL sandbox decision](2026-08-08-windows-acl-restricted-token-sandbox.md) filled it with the restricted-token runner. Its hard-link, unconfined-read, and AppContainer-ACL gaps are reported as `enforcement: 'partial'`, never promoted to the full promise. - **The Seatbelt rung leans on Apple's deprecated-but-shipped `sandbox-exec` CLI.** As darwin's sole candidate it is selected without probing, so a future removal under a usable workdir surfaces as a runner-attributable spawn failure and an executable refusal through its fatal signature — both become `SANDBOX_UNAVAILABLE`, and the command never runs; fail closed, never open. - **Landlock confinement is only as complete as the running kernel's ABI.** Reported as `enforcement: 'partial'` rather than refused — the deliberate trade that keeps the fallback available on older-kernel hosts. - **Runner attribution uses an in-band protocol.** Exit status plus stderr cannot cryptographically identify the writer, so a confined child can mimic a fatal runner line and status to cause an availability/diagnostic false attribution. The conjunction and exact notice exclusion reduce accidental matches; this is not a sandbox bypass because the child is already confined. diff --git a/.agents/notes/implemented/feature/2026-07-06-sandbox.zh.md b/.agents/notes/implemented/feature/2026-07-06-sandbox.zh.md index 6ad51a0395..ff4d4ed592 100644 --- a/.agents/notes/implemented/feature/2026-07-06-sandbox.zh.md +++ b/.agents/notes/implemented/feature/2026-07-06-sandbox.zh.md @@ -176,7 +176,7 @@ fs/web/todo 在进程内执行,因此它们的沙箱语义是各自能力边 - **单一包装层的幻觉被有意放弃。**`tools/pre-execute` 包装层加提示词约定无法解决沙箱批准——正确的设计需要结构化拒绝、原生 runner 探测、按调用策略承载和一致的跨工具族强制,本设计为此付出了代价。 - **`read-only` 通过后续设计成为跨工具族边界。** 本 Agent Note 最初只交付 bash 强制;[跨工具族 fs 沙箱 RFC](2026-07-14-cross-family-fs-sandbox.zh.md) 通过沙箱化的 `ctx.fs` 提供方把同一模式词汇扩展到文件系统工具,并将 mode/root 配置和 `sandbox/mode` 覆盖迁移到 `ctx.sandboxPolicy`(§ 进程内工具)。 -- **Windows 后端只提供部分强制执行。** 本 RFC 最初预留了一条空的、失败关闭的 win32 链;后续的 [Windows ACL 沙箱决策](2026-08-08-windows-acl-restricted-token-sandbox.zh.md)以受限令牌 runner 填充了它。其 Everyone 与硬链接缺口报告为 `enforcement: 'partial'`,绝不提升为完整承诺。 +- **Windows 后端只提供部分强制执行。** 本 RFC 最初预留了一条空的、失败关闭的 win32 链;后续的 [Windows ACL 沙箱决策](2026-08-08-windows-acl-restricted-token-sandbox.zh.md)以受限令牌 runner 填充了它。其硬链接、读取不受限与 AppContainer ACL 缺口报告为 `enforcement: 'partial'`,绝不提升为完整承诺。 - **Seatbelt 层级依赖 Apple 已弃用但仍交付的 `sandbox-exec` CLI。** 作为 darwin 的唯一候选,它无需探测即被选中,因此在 workdir 可用时,未来移除会表现为可归因于 runner 的 spawn 失败,可执行文件拒绝则通过其致命签名体现——两者都会变为 `SANDBOX_UNAVAILABLE`,且命令绝不会运行;失败关闭,绝不开放。 - **Landlock 约束的完整度取决于运行内核的 ABI。** 报告为 `enforcement: 'partial'` 而非拒绝——这是有意的权衡,使备选在旧内核主机上仍可用。 - **Runner 归因使用带内协议。** 退出状态与 stderr 无法以密码学方式识别写入者,因此受限子进程可以模仿 runner 的致命诊断行和状态,造成可用性或诊断误归因。多项证据的合取与精确通知排除减少了意外匹配;这不是沙箱绕过,因为子进程已经受到限制。 diff --git a/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.i18n.yaml b/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.i18n.yaml index 596033c717..65bc426f49 100644 --- a/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.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/feature/2026-08-08-windows-acl-restricted-token-sandbox.md -2026-08-08-windows-acl-restricted-token-sandbox.md: 9834e9b559f0a8045b0d36dd2dcfba864238996b -2026-08-08-windows-acl-restricted-token-sandbox.zh.md: d349b504ca6a1054237d5ca6aa61547c2a0dcdad +2026-08-08-windows-acl-restricted-token-sandbox.md: 65780b9bba31605db02dc9be4c1b2454c74eb0f4 +2026-08-08-windows-acl-restricted-token-sandbox.zh.md: fe9aadb97b172d77d65ca6b0a843f05751075502 diff --git a/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.md b/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.md index 9834e9b559..65780b9bba 100644 --- a/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.md +++ b/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.md @@ -10,7 +10,7 @@ The original [sandbox decision](2026-07-06-sandbox.md) left `PLATFORM_CHAINS.win ## Decision -Implement the rung directly on the raw ACL mechanism: duplicate the caller's token into a `WRITE_RESTRICTED` token (`CreateRestrictedToken` with `WRITE_RESTRICTED` + `DISABLE_MAX_PRIVILEGE` + `LUA_TOKEN`) whose restricting SIDs carry distinct workspace and private-temp capabilities. `WRITE_RESTRICTED` intersects write accesses only, so reads keep the caller's ambient access while a write must also match one of these capability ACEs. The mechanism is the one huoyaoyuan/windows-acl-restrict-poc (`10e4dfb`) demonstrates; this port checks every API call and fails closed (the POC fail-opened on ignored failures). The per-workspace SID is derived deterministically from the canonical workspace path (`workspaceWriteSid` — sha256 → `S-1-4-x-y`); its standing workspace ACE is the cross-session reuse cache, and an exact-ACE skip prevents repeated eager tree propagation. Each live session/workspace pair instead gets a random private temp directory and a domain-separated SID derived from that path (`tempWriteSid`); its ACE is revocable, TMP/TEMP point at that directory, and the token default DACL names the temp SID so newly created temp objects do not acquire the shared workspace capability. A fork therefore cannot write its sibling's temp tree. A fresh provider chooses a new path and SID even for the same resumed session, so crash residue is inert litter rather than a collision or inherited capability; agentless calls create and remove the same shape per invocation. The ambient temp root is never an implicit grant. A workspace equal to or containing the temp root fails before any ACL mutation because its inheritable standing ACE would otherwise reach every private child; the direct API rejects overlap in either direction between a writable root and the actual private temp directory. PowerShell can complete its startup AppLocker probe through this private-temp capability, so `workspace-write` remains FullLanguage absent a host-wide policy; `read-only` cannot create the probe files and conservatively enters ConstrainedLanguage. That split is PowerShell startup behavior, not part of the ACL boundary. The token lists are read-only = [logon SID, Everyone] and workspace-write = [logon SID, Everyone, workspace SID, optional temp SID]. Logon SID + Everyone are keep-alive invariants (early DLL init dies with 0xC0000142 and CNG crashes pwsh with 0xE0434352 without them). Because Everyone remains, an external object granting Everyone write access clears both checks; because NTFS ACLs belong to file objects, a granted workspace hard link also grants an external alias. Rejecting all hard links would reject ordinary pnpm workspaces, so the provider reports `enforcement: 'partial'` and the native suite pins both gaps. Read-only carries no capability SID, so standing workspace ACEs remain inert across a mode downgrade. Authenticated Users is absent from both lists — CIM is unavailable, closing the C:\-root tree-creation escape — and INTERACTIVE/LOCAL are absent, denying Public-tree writes. New anonymous pipes and sync objects inherit the temp SID (or workspace SID when temp is disabled, Everyone under read-only) through `SetTokenInformation(TokenDefaultDacl)`; named pipes keep the Win32 layer's owner/SYSTEM/Admins-full, Everyone/ANONYMOUS-read-only template, so piped grandchild stdio remains denied. It ships as [`@deepseek-ai/dsh-sandbox-windows-acl`](../../../../packages/sandbox/sandbox-windows-acl/README.md), the `win32` rung of [`dsh-sandbox-local`](../../../../packages/sandbox/sandbox-local/README.md), and [`@deepseek-ai/dsh-pwsh-sandbox`](../../../../packages/shell/pwsh-sandbox/README.md) as the confining executor. +Implement the rung directly on the raw ACL mechanism: duplicate the caller's token into a `WRITE_RESTRICTED` token (`CreateRestrictedToken` with `WRITE_RESTRICTED` + `DISABLE_MAX_PRIVILEGE` + `LUA_TOKEN`) whose restricting SIDs carry distinct workspace and private-temp capabilities. `WRITE_RESTRICTED` intersects write accesses only, so reads keep the caller's ambient access while a write must also match one of these capability ACEs. That intersection covers only the object's own access check, and Windows also authorizes a write or a delete from the PARENT directory's `FILE_DELETE_CHILD` right — which no restricting SID has to co-sign, so `cmd /c del` deleted files outside the workspace wherever the caller's ambient SIDs hold Modify. The token is therefore also lowered to Low integrity and every granted directory receives a Low no-write-up mandatory label in the same call that adds its ACE; the kernel applies that policy inside the access check, whichever right supplied the authority, so the granted roots stay writable and everything else becomes delete-proof as well as write-proof. Because every granted root is labeled Low, the integrity check alone would still let one confined child delete inside ANOTHER granted root (another workspace, a live sibling temp directory), so each grant also DENIES `FILE_DELETE_CHILD` to the world SID: the capability ACE's DELETE bit becomes the only delete authority inside a granted root, for the confined child and for the user's own processes, whose ordinary deletes keep working because the same DACL grants them DELETE directly. The mechanism is the one huoyaoyuan/windows-acl-restrict-poc (`10e4dfb`) demonstrates; this port checks every API call and fails closed (the POC fail-opened on ignored failures). The per-workspace SID is derived deterministically from the canonical workspace path (`workspaceWriteSid` — sha256 → `S-1-4-x-y`); its standing workspace ACE is the cross-session reuse cache, and an exact-ACE skip prevents repeated eager tree propagation. Each live session/workspace pair instead gets a random private temp directory and a domain-separated SID derived from that path (`tempWriteSid`); its ACE is revocable, TMP/TEMP point at that directory, and the token default DACL names the temp SID so newly created temp objects do not acquire the shared workspace capability. A fork therefore cannot write its sibling's temp tree. A fresh provider chooses a new path and SID even for the same resumed session, so crash residue is inert litter rather than a collision or inherited capability; agentless calls create and remove the same shape per invocation. The ambient temp root is never an implicit grant. A workspace equal to or containing the temp root fails before any ACL mutation because its inheritable standing ACE would otherwise reach every private child; the direct API rejects overlap in either direction between a writable root and the actual private temp directory. PowerShell can complete its startup AppLocker probe through this private-temp capability, so `workspace-write` remains FullLanguage absent a host-wide policy; `read-only` cannot create the probe files and conservatively enters ConstrainedLanguage. That split is PowerShell startup behavior, not part of the ACL boundary. The token lists are read-only = [logon SID, Everyone] and workspace-write = [logon SID, Everyone, workspace SID, optional temp SID]. Logon SID + Everyone are keep-alive invariants (early DLL init dies with 0xC0000142 and CNG crashes pwsh with 0xE0434352 without them). Everyone remains in both lists but no longer confers write authority, because the label layer denies it; because NTFS ACLs belong to file objects, a granted workspace hard link still grants an external alias; and because a Low-integrity token cannot open an object whose DACL names another sandbox's AppContainer package SID, a tree stamped by such a tool becomes unreadable to the confined child. Rejecting all hard links would reject ordinary pnpm workspaces, so the provider reports `enforcement: 'partial'` and the native suite pins both gaps. Read-only carries no capability SID, so standing workspace ACEs remain inert across a mode downgrade. Authenticated Users is absent from both lists — CIM is unavailable, closing the C:\-root tree-creation escape — and INTERACTIVE/LOCAL are absent, denying Public-tree writes. New anonymous pipes and sync objects inherit the temp SID (or workspace SID when temp is disabled, Everyone under read-only) through `SetTokenInformation(TokenDefaultDacl)`; named pipes keep the Win32 layer's owner/SYSTEM/Admins-full, Everyone/ANONYMOUS-read-only template, so piped grandchild stdio remains denied. It ships as [`@deepseek-ai/dsh-sandbox-windows-acl`](../../../../packages/sandbox/sandbox-windows-acl/README.md), the `win32` rung of [`dsh-sandbox-local`](../../../../packages/sandbox/sandbox-local/README.md), and [`@deepseek-ai/dsh-pwsh-sandbox`](../../../../packages/shell/pwsh-sandbox/README.md) as the confining executor. ## How the restriction works (why no new identity) @@ -32,12 +32,14 @@ The landstrip evaluation was rejected before implementation (not battle-tested; ## Consequences -Bought: write-only confinement with no new OS floor (`CreateRestrictedToken` predates the mxc releases by two decades), reads/network/process visibility untouched exactly as the mode vocabulary requires, and fail-closed errors carrying the API name and exact Win32 code. Sessions share the intentionally standing workspace capability but not their revocable temp capabilities; restart residue cannot block or authorize a resumed session. Cost: enforcement is structurally partial because Everyone-granted writes and NTFS hard-link aliases cannot be path-confined by this token shape; no read-side or network isolation; console isolation unavailable (children created with `CREATE_NO_WINDOW` or `CREATE_NEW_CONSOLE` die with `STATUS_DLL_INIT_FAILED`; [startup visibility](../bug-fix/2026-09-16-windows-subprocess-console-visibility.md) preserves console inheritance); standing workspace ACE mutations (the reuse cache, plus inert residue when a workspace is renamed) and random temp litter after an unclean shutdown until OS hygiene reclaims it; EAGER full-tree workspace propagation (`SetNamedSecurityInfoW` walks every descendant immediately — tens of seconds on large workspaces), paid once per workspace per machine; CIM unavailable in both confined modes (Authenticated Users is absent, closing the C:\-root tree-creation escape); FAT-class non-ACL targets still writable; NULL-DACL directories not identity-preserving under a grant/revoke round trip; `whoami` and token-inspection cmdlets failing under the restricted token; read-only pwsh entering ConstrainedLanguage while workspace-write remains FullLanguage absent host policy; and named-pipe opens remaining denied, so libuv piped-stdio grandchildren fail with EPERM while inherited/ignored stdio and anonymous pipes work. The package README owns these operational limits. +Bought: write-only confinement with no new OS floor (`CreateRestrictedToken` predates the mxc releases by two decades; mandatory integrity control predates it as well), reads/network/process visibility untouched exactly as the mode vocabulary requires, deletes confined through every authority Windows accepts and confined to the granted root that owns them, and fail-closed errors carrying the API name and exact Win32 code. Sessions share the intentionally standing workspace capability but not their revocable temp capabilities; restart residue cannot block or authorize a resumed session. Cost: enforcement is structurally partial because NTFS hard-link aliases cannot be path-confined by this token shape and because a Low-integrity token cannot open a file whose DACL carries another sandbox's AppContainer package SID, which makes a tree stamped by such a tool unreadable to the confined child; no read-side or network isolation; console isolation unavailable (children created with `CREATE_NO_WINDOW` or `CREATE_NEW_CONSOLE` die with `STATUS_DLL_INIT_FAILED`; [startup visibility](../bug-fix/2026-09-16-windows-subprocess-console-visibility.md) preserves console inheritance); standing workspace security-descriptor mutations (the reuse cache, plus inert residue when a workspace is renamed) and random temp litter after an unclean shutdown until OS hygiene reclaims it; EAGER full-tree workspace propagation (`SetNamedSecurityInfoW` walks every descendant immediately — tens of seconds on large workspaces), paid once per workspace per machine; CIM unavailable in both confined modes (Authenticated Users is absent, closing the C:\-root tree-creation escape); FAT-class non-ACL targets unverified rather than writable; NULL-DACL directories not identity-preserving under a grant/revoke round trip; read-only pwsh entering ConstrainedLanguage while workspace-write remains FullLanguage absent host policy; and named-pipe opens remaining denied, so libuv piped-stdio grandchildren fail with EPERM while inherited/ignored stdio and anonymous pipes work. The package README owns these operational limits. ## Testing -The product-visible Windows roster flip is win32-only, so keyless snapshots that must replay on macOS/Linux cannot cover it; bundle composition specs plus the win32 real-runner suites are the substitute evidence, and the CI Windows lane owns the assembled signal. `sandbox-local/tests/acl-grants.spec.ts` pins random temp allocation, per-session/workspace reuse, fork/workspace separation, crash-resume non-collision, paired argv SIDs, failure cleanup, and standing-versus-revocable lifecycle with Win32 mocked. On Windows, `workspace-sid.spec.ts` pins workspace/temp derivation and domain separation; `acl.spec.ts` pins real DACL lifecycle; and `runner.spec.ts` pins paired-SID validation, sibling temp denial under a shared workspace SID, per-call agentless temp creation/removal, TMP/TEMP rewriting, mode downgrade, Public denial, Everyone/hard-link partial boundaries, mode-specific PowerShell language behavior, and grandchild stdio. ARM64 and emulated x64 native runs own the architecture-specific acceptance evidence. +The product-visible Windows roster flip is win32-only, so keyless snapshots that must replay on macOS/Linux cannot cover it; bundle composition specs plus the win32 real-runner suites are the substitute evidence, and the CI Windows lane owns the assembled signal. `sandbox-local/tests/acl-grants.spec.ts` pins random temp allocation, per-session/workspace reuse, fork/workspace separation, crash-resume non-collision, paired argv SIDs, failure cleanup, and standing-versus-revocable lifecycle with Win32 mocked. On Windows, `workspace-sid.spec.ts` pins workspace/temp derivation and domain separation; `acl.spec.ts` pins the real DACL, ambient-delete deny, and mandatory-label lifecycle; and `runner.spec.ts` pins the Low integrity level, paired-SID validation, sibling temp denial under a shared workspace SID, the outside-delete regression through `cmd`, .NET, `Remove-Item`, and libuv, the cross-root delete regression against another workspace and another live temp tree, per-call agentless temp creation/removal, TMP/TEMP rewriting, mode downgrade, Public and Everyone denial, the hard-link partial boundary, mode-specific PowerShell language behavior, and grandchild stdio. ARM64 and emulated x64 native runs own the architecture-specific acceptance evidence. ## Related The [pwsh executor decision](../../archived/feature/2026-08-01-pwsh-tool-and-executor.md) owns the pwsh-sandbox/tool-pwsh dialect split this rung consumes. + +The [delete-confinement decision](2026-09-19-windows-acl-mandatory-integrity-confinement.md) extends this rung with the Low integrity layer and the ambient-delete deny; it supersedes only the delete route documented here. diff --git a/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.zh.md b/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.zh.md index d349b504ca..fe9aadb97b 100644 --- a/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.zh.md +++ b/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.zh.md @@ -10,7 +10,7 @@ Status: implemented ## 决策 -直接基于原始 ACL 机制实现该档:把调用者令牌复制为 `WRITE_RESTRICTED` 受限令牌(`CreateRestrictedToken`,`WRITE_RESTRICTED` + `DISABLE_MAX_PRIVILEGE` + `LUA_TOKEN`),其 restricting SIDs 携带彼此独立的工作区能力与私有临时目录能力。`WRITE_RESTRICTED` 只对写访问做交集检查,因此读取保留调用者的环境访问,而写入还必须匹配这些能力 ACE 之一。该机制来自 huoyaoyuan/windows-acl-restrict-poc(`10e4dfb`)的演示;本移植检查每个 API 调用并 fail-closed(POC 因忽略失败而 fail-open)。工作区 SID 由规范工作区路径确定性派生(`workspaceWriteSid`——sha256 → `S-1-4-x-y`);其常驻工作区 ACE 是跨会话复用缓存,精确 ACE 跳过可避免重复的急切全树传播。每个活跃的会话/工作区对则获得一个随机私有临时目录,以及一个从该路径派生的、经过域分离的 SID(`tempWriteSid`);其 ACE 可回收,TMP/TEMP 指向该目录,令牌默认 DACL 列入该临时 SID,因此新建的临时对象不会获得共享的工作区能力。fork 因此无法写入同级会话的临时目录树。即使恢复的是同一会话,新的提供方也会选择新的路径和 SID,因此崩溃残留只是失效垃圾,而非冲突或继承的能力;无 agent(智能体)的调用会逐调用创建并移除同样的形态。环境临时根目录绝不会被隐式授权。如果工作区等于或包含临时根目录,调用会在任何 ACL 改动发生前失败,因为否则其可继承的常驻 ACE 会向每个私有子目录授权;直接 API 会拒绝可写根目录与实际私有临时目录在任一方向上的重叠。PowerShell 可借助这项私有临时目录能力完成启动时的 AppLocker 探针,因此在没有主机范围策略时,`workspace-write` 会保持 FullLanguage;`read-only` 无法创建探针文件,会保守地进入 ConstrainedLanguage。这一区别属于 PowerShell 启动行为,不是 ACL 边界的一部分。令牌列表为 read-only = [登录 SID、Everyone],workspace-write = [登录 SID、Everyone、工作区 SID、可选临时 SID]。登录 SID + Everyone 是保活不变式(没有它们,早期 DLL 初始化会以 0xC0000142 死亡,CNG 会让 pwsh 以 0xE0434352 崩溃)。由于 Everyone 仍在列表中,向 Everyone 授予写访问的外部对象会通过两次检查;由于 NTFS ACL 属于文件对象,工作区内获授权的硬链接也会使同一对象的外部别名获得授权。拒绝所有硬链接会让普通 pnpm 工作区不可用,因此提供方报告 `enforcement: 'partial'`,原生套件则钉住这两个缺口。Read-only 不含任何能力 SID,因此常驻工作区 ACE 在模式降级后保持失效。Authenticated Users 在两种列表中都不存在——CIM 不可用,从而关闭 C:\-root 建树逃逸——INTERACTIVE/LOCAL 也不存在,因此 Public 树写入被拒绝。新建匿名管道和同步对象通过 `SetTokenInformation(TokenDefaultDacl)` 继承临时 SID(禁用临时目录时继承工作区 SID,read-only 下继承 Everyone);named pipe 保持 Win32 层 owner/SYSTEM/Admins 全权、Everyone/ANONYMOUS 只读的模板,因此受限孙进程的管道 stdio 仍被拒绝。它以 [`@deepseek-ai/dsh-sandbox-windows-acl`](../../../../packages/sandbox/sandbox-windows-acl/README.zh.md)、[`dsh-sandbox-local`](../../../../packages/sandbox/sandbox-local/README.zh.md) 的 `win32` 档,以及作为隔离执行器的 [`@deepseek-ai/dsh-pwsh-sandbox`](../../../../packages/shell/pwsh-sandbox/README.zh.md) 交付。 +直接基于原始 ACL 机制实现该档:把调用者令牌复制为 `WRITE_RESTRICTED` 受限令牌(`CreateRestrictedToken`,`WRITE_RESTRICTED` + `DISABLE_MAX_PRIVILEGE` + `LUA_TOKEN`),其 restricting SIDs 携带彼此独立的工作区能力与私有临时目录能力。`WRITE_RESTRICTED` 只对写访问做交集检查,因此读取保留调用者的环境访问,而写入还必须匹配这些能力 ACE 之一。该交集只覆盖对象自身的那次访问检查,而 Windows 也会依据父目录的 `FILE_DELETE_CHILD` 权限批准写入或删除——这项权限不需要任何 restricting SID 副署,因此只要调用者的环境 SID 持有 Modify,`cmd /c del` 就能删除工作区之外的文件。所以令牌还会被降级为 Low 完整性,每个被授权目录都在添加 ACE 的同一次调用中收到 Low 禁止上调强制标签;无论权限来自哪一项,内核都会在访问检查内部执行该策略,于是被授权根目录保持可写,而其他一切不仅防写、也防删。由于每个授权根目录都被标记为 Low,仅靠完整性检查仍会让一个受限子进程删除**另一个授权根目录**(别的工作区、同级的活跃临时目录)内的文件,因此每次授权还会向 world SID 拒绝 `FILE_DELETE_CHILD`:能力 ACE 的 DELETE 位由此成为授权根目录内唯一的删除授权来源——对受限子进程如此,对用户自身进程亦如此,而后者的日常删除仍然可用,因为同一份 DACL 直接向其授予 DELETE。该机制来自 huoyaoyuan/windows-acl-restrict-poc(`10e4dfb`)的演示;本移植检查每个 API 调用并 fail-closed(POC 因忽略失败而 fail-open)。工作区 SID 由规范工作区路径确定性派生(`workspaceWriteSid`——sha256 → `S-1-4-x-y`);其常驻工作区 ACE 是跨会话复用缓存,精确 ACE 跳过可避免重复的急切全树传播。每个活跃的会话/工作区对则获得一个随机私有临时目录,以及一个从该路径派生的、经过域分离的 SID(`tempWriteSid`);其 ACE 可回收,TMP/TEMP 指向该目录,令牌默认 DACL 列入该临时 SID,因此新建的临时对象不会获得共享的工作区能力。fork 因此无法写入同级会话的临时目录树。即使恢复的是同一会话,新的提供方也会选择新的路径和 SID,因此崩溃残留只是失效垃圾,而非冲突或继承的能力;无 agent(智能体)的调用会逐调用创建并移除同样的形态。环境临时根目录绝不会被隐式授权。如果工作区等于或包含临时根目录,调用会在任何 ACL 改动发生前失败,因为否则其可继承的常驻 ACE 会向每个私有子目录授权;直接 API 会拒绝可写根目录与实际私有临时目录在任一方向上的重叠。PowerShell 可借助这项私有临时目录能力完成启动时的 AppLocker 探针,因此在没有主机范围策略时,`workspace-write` 会保持 FullLanguage;`read-only` 无法创建探针文件,会保守地进入 ConstrainedLanguage。这一区别属于 PowerShell 启动行为,不是 ACL 边界的一部分。令牌列表为 read-only = [登录 SID、Everyone],workspace-write = [登录 SID、Everyone、工作区 SID、可选临时 SID]。登录 SID + Everyone 是保活不变式(没有它们,早期 DLL 初始化会以 0xC0000142 死亡,CNG 会让 pwsh 以 0xE0434352 崩溃)。Everyone 仍留在列表中,但它带来的写权限已被标签层否定;由于 NTFS ACL 属于文件对象,工作区内获授权的硬链接仍会使同一对象的外部别名获得授权,而 DACL 中携带其他沙箱 AppContainer 包 SID 的文件对 Low 完整性子进程不可读。拒绝所有硬链接会让普通 pnpm 工作区不可用,因此提供方报告 `enforcement: 'partial'`,原生套件则钉住这些缺口。Read-only 不含任何能力 SID,因此常驻工作区 ACE 在模式降级后保持失效。Authenticated Users 在两种列表中都不存在——CIM 不可用,从而关闭 C:\-root 建树逃逸——INTERACTIVE/LOCAL 也不存在,因此 Public 树写入被拒绝。新建匿名管道和同步对象通过 `SetTokenInformation(TokenDefaultDacl)` 继承临时 SID(禁用临时目录时继承工作区 SID,read-only 下继承 Everyone);named pipe 保持 Win32 层 owner/SYSTEM/Admins 全权、Everyone/ANONYMOUS 只读的模板,因此受限孙进程的管道 stdio 仍被拒绝。它以 [`@deepseek-ai/dsh-sandbox-windows-acl`](../../../../packages/sandbox/sandbox-windows-acl/README.zh.md)、[`dsh-sandbox-local`](../../../../packages/sandbox/sandbox-local/README.zh.md) 的 `win32` 档,以及作为隔离执行器的 [`@deepseek-ai/dsh-pwsh-sandbox`](../../../../packages/shell/pwsh-sandbox/README.zh.md) 交付。 ## How the restriction works (why no new identity) @@ -32,12 +32,14 @@ landstrip 评估在实现前已被否决(未经实战检验;自建 launcher ## 后果 -所得:仅写隔离、不引入新的 OS 版本下限(`CreateRestrictedToken` 比 mxc 的版本早二十年)、读/网络/进程可见性完全不受影响(与模式词汇表一致),且 fail-closed 错误携带 API 名与精确 Win32 错误码。会话共享有意常驻的工作区能力,但不共享各自可回收的临时能力;重启残留既不能阻塞恢复的会话,也不能向其授权。所失:强制执行在结构上只能是部分的,因为此令牌形态无法把 Everyone 授予的写入与 NTFS 硬链接别名限制在路径边界内;无读侧或网络隔离;控制台隔离不可用(通过 `CREATE_NO_WINDOW` 或 `CREATE_NEW_CONSOLE` 创建的子进程以 `STATUS_DLL_INIT_FAILED` 死亡;[启动可见性](../bug-fix/2026-09-16-windows-subprocess-console-visibility.zh.md)保留控制台继承);工作区常驻 ACE 改动(复用缓存,以及工作区改名后的失效残留)与异常关闭后遗留的随机临时目录垃圾,直到 OS 卫生机制将其回收;工作区授权采用急切的全树传播(`SetNamedSecurityInfoW` 立即遍历每个后代——大型工作区上耗时数十秒),每台机器每个工作区只付一次;CIM 在两种受限模式下均不可用(Authenticated Users 不存在,从而关闭 C:\-root 建树逃逸);FAT 类无 ACL 目标仍可写;NULL-DACL 目录在 grant/revoke 往返下不保持身份;`whoami` 与令牌检查 cmdlet 在受限令牌下失败;read-only pwsh 会进入 ConstrainedLanguage,而在没有主机策略时 workspace-write 保持 FullLanguage;named pipe 打开仍被拒绝,因此 libuv 管道 stdio 的孙进程以 EPERM 失败,而继承/忽略的 stdio 与匿名管道可用。包 README 负责记录这些运行限制。 +所得:仅写隔离、不引入新的 OS 版本下限(`CreateRestrictedToken` 比 mxc 的版本早二十年;强制完整性控制出现得更早)、读/网络/进程可见性完全不受影响(与模式词汇表一致)、删除在 Windows 接受的每条授权路径上都被约束且被约束在各自的授权根目录内,且 fail-closed 错误携带 API 名与精确 Win32 错误码。会话共享有意常驻的工作区能力,但不共享各自可回收的临时能力;重启残留既不能阻塞恢复的会话,也不能向其授权。所失:强制执行在结构上只能是部分的,因为此令牌形态无法把 NTFS 硬链接别名限制在路径边界内,而 Low 完整性的令牌也无法打开 DACL 中携带其他沙箱 AppContainer 包 SID 的文件——被这类工具标记过的目录树对受限子进程将不可读;无读侧或网络隔离;控制台隔离不可用(通过 `CREATE_NO_WINDOW` 或 `CREATE_NEW_CONSOLE` 创建的子进程以 `STATUS_DLL_INIT_FAILED` 死亡;[启动可见性](../bug-fix/2026-09-16-windows-subprocess-console-visibility.zh.md)保留控制台继承);工作区常驻安全描述符改动(复用缓存,以及工作区改名后的失效残留)与异常关闭后遗留的随机临时目录垃圾,直到 OS 卫生机制将其回收;工作区授权采用急切的全树传播(`SetNamedSecurityInfoW` 立即遍历每个后代——大型工作区上耗时数十秒),每台机器每个工作区只付一次;CIM 在两种受限模式下均不可用(Authenticated Users 不存在,从而关闭 C:\-root 建树逃逸);FAT 类无 ACL 目标的行为未经验证而非「仍可写」;NULL-DACL 目录在 grant/revoke 往返下不保持身份;read-only pwsh 会进入 ConstrainedLanguage,而在没有主机策略时 workspace-write 保持 FullLanguage;named pipe 打开仍被拒绝,因此 libuv 管道 stdio 的孙进程以 EPERM 失败,而继承/忽略的 stdio 与匿名管道可用。包 README 负责记录这些运行限制。 ## 测试 -产品可见的 Windows 阵容切换仅存在于 win32,而必须在 macOS/Linux 上可重放的 keyless 快照无法覆盖它;替代证据是 bundle 组合 spec 加上 win32 真实 runner 套件,组装态信号由 CI 的 Windows lane 负责。`sandbox-local/tests/acl-grants.spec.ts` 在 mock Win32 的情况下钉住随机临时目录分配、按会话/工作区复用、fork/工作区分离、崩溃后恢复不冲突、成对 argv SID、失败清理,以及常驻/可回收生命周期。在 Windows 上,`workspace-sid.spec.ts` 钉住工作区/临时目录派生与域分离;`acl.spec.ts` 钉住真实 DACL 生命周期;`runner.spec.ts` 钉住成对 SID 验证、共享工作区 SID 时对同级会话临时目录的拒绝、无 agent 调用的逐调用临时目录创建/移除、TMP/TEMP 重写、模式降级、Public 拒绝、Everyone/硬链接部分边界、按模式区分的 PowerShell 语言行为与孙进程 stdio。ARM64 与模拟 x64 原生运行负责提供架构特定的验收证据。 +产品可见的 Windows 阵容切换仅存在于 win32,而必须在 macOS/Linux 上可重放的 keyless 快照无法覆盖它;替代证据是 bundle 组合 spec 加上 win32 真实 runner 套件,组装态信号由 CI 的 Windows lane 负责。`sandbox-local/tests/acl-grants.spec.ts` 在 mock Win32 的情况下钉住随机临时目录分配、按会话/工作区复用、fork/工作区分离、崩溃后恢复不冲突、成对 argv SID、失败清理,以及常驻/可回收生命周期。在 Windows 上,`workspace-sid.spec.ts` 钉住工作区/临时目录派生与域分离;`acl.spec.ts` 钉住真实 DACL、环境性删除拒绝与强制标签生命周期;`runner.spec.ts` 钉住 Low 完整性级别、成对 SID 验证、共享工作区 SID 时对同级会话临时目录的拒绝、经 `cmd`、.NET、`Remove-Item` 与 libuv 触发的越界删除回归、针对另一个工作区与另一棵活跃临时目录树的跨根删除回归、无 agent 调用的逐调用临时目录创建/移除、TMP/TEMP 重写、模式降级、Public 与 Everyone 拒绝、硬链接部分边界、按模式区分的 PowerShell 语言行为与孙进程 stdio。ARM64 与模拟 x64 原生运行负责提供架构特定的验收证据。 ## Related [pwsh 执行器决策](../../archived/feature/2026-08-01-pwsh-tool-and-executor.md)拥有本档所消费的 pwsh-sandbox/tool-pwsh 方言划分。 + +[删除约束决策](2026-09-19-windows-acl-mandatory-integrity-confinement.zh.md)以 Low 完整性层与环境性删除拒绝扩展本档;它只取代本文档所述的删除路径。 diff --git a/.agents/notes/implemented/feature/2026-09-14-docs-mermaid-viewer.i18n.yaml b/.agents/notes/implemented/feature/2026-09-14-docs-mermaid-viewer.i18n.yaml index c91b5f6407..a2eba8f020 100644 --- a/.agents/notes/implemented/feature/2026-09-14-docs-mermaid-viewer.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-09-14-docs-mermaid-viewer.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/feature/2026-09-14-docs-mermaid-viewer.md -2026-09-14-docs-mermaid-viewer.md: 03aecb7b4df1c79d2eb254375f6b2c68145079ac -2026-09-14-docs-mermaid-viewer.zh.md: b66f427a4820eef0d6fa25b163cfc8e3548ec0c6 +2026-09-14-docs-mermaid-viewer.md: 5cb4bae84fcafb49c559b7678e9a9dc1aa961ab0 +2026-09-14-docs-mermaid-viewer.zh.md: 665ab65e48e15651ebb9323e96e6a950c0b48deb diff --git a/.agents/notes/implemented/feature/2026-09-14-docs-mermaid-viewer.md b/.agents/notes/implemented/feature/2026-09-14-docs-mermaid-viewer.md index 03aecb7b4d..5cb4bae84f 100644 --- a/.agents/notes/implemented/feature/2026-09-14-docs-mermaid-viewer.md +++ b/.agents/notes/implemented/feature/2026-09-14-docs-mermaid-viewer.md @@ -1,4 +1,4 @@ -# Agent Note: Documentation Mermaid viewer +# Agent Note: Documentation diagram and image viewer Status: implemented @@ -6,15 +6,17 @@ English | [中文](2026-09-14-docs-mermaid-viewer.zh.md) ## Problem -Complex Mermaid diagrams lose readable detail when scaled to the documentation column. Long sequence diagrams need both magnification and movement to inspect interactions while retaining an overview. +Complex Mermaid diagrams and interface screenshots lose readable detail when scaled to the documentation column. Readers need both magnification and movement to inspect interactions while retaining an overview. ## Decision The [VitePress theme](../../../../website/.vitepress/theme/index.ts) adds a corner fullscreen icon to each rendered Mermaid SVG. A native modal dialog provides an inert background and Escape dismissal. Its visible title also names it for assistive technology; a missing or blank page heading uses the localized viewer title. A floating toolbar groups zoom controls, the current scale, and fit; close stays in the top corner, and help opens on demand. Keyboard focus cycles through all five buttons. Panzoom supplies pointer, wheel, and pinch interaction. Arrow keys pan in fixed screen distances. Closing restores the entry's focus without scrolling and restores the page's previous overflow setting. +Ordinary images share one modal and zoom controller with Mermaid. Loaded images with nonempty alt text that occupy their own paragraph open through an image click or corner button; linked, inline, decorative, and `data-no-zoom` images retain their existing interaction. Image alt text names the modal, and the scale button returns to centered natural size (100%) while its accessible name also includes the current scale; keyboard focus cycles through six buttons in image views. + The viewer copies the SVG into a shadow root. Mermaid's embedded selectors and fragment IDs stay local to the copy, so its markers and styles cannot resolve against the original diagram. Panzoom transforms a viewport-sized canvas containing the SVG at its natural viewBox dimensions, so pointer coordinates and the transform origin share the same center; the initial scale and every resize fit the entire diagram without enlarging it beyond its natural size. This preserves vector detail while keeping fit independent of the narrow document column. The canvas has no visible frame; reserved space keeps controls clear of the fitted diagram. -Viewer resources belong to the mounted theme. Route, language, theme, and source-SVG replacement close the active view; asynchronous Mermaid renders receive a fresh entry. The body overflow lock relies on the default theme retaining visible overflow on the HTML element. It avoids mutating HTML attributes, which the Mermaid plugin observes and rerenders in response. The [implementation](../../../../website/.vitepress/theme/mermaid-viewer.ts) leaves Markdown, raw page copies, and `llms.txt` generation with their existing owners. +Viewer resources belong to the mounted theme. Route, language, theme, and source-content replacement close the active view; asynchronous Mermaid renders and loaded images receive fresh entries. The body overflow lock relies on the default theme retaining visible overflow on the HTML element. It avoids mutating HTML attributes, which the Mermaid plugin observes and rerenders in response. The [shared modal](../../../../website/.vitepress/theme/media-viewer.ts), [Mermaid adapter](../../../../website/.vitepress/theme/mermaid-viewer.ts), and [image adapter](../../../../website/.vitepress/theme/image-viewer.ts) leave Markdown, raw page copies, and `llms.txt` generation with their existing owners. ## Alternatives considered @@ -26,8 +28,8 @@ Viewer resources belong to the mounted theme. Route, language, theme, and source ## Consequences -The website gains Panzoom as a direct dependency and uses native dialog, shadow-root, and resize-observer support. Viewer zoom and position are transient: resizing refits the diagram, and navigating or changing the theme closes it. Existing page diagrams remain the reading and link-navigation source. +The website gains Panzoom as a direct dependency and uses native dialog, shadow-root, and resize-observer support. Viewer zoom and position are transient: resizing refits the content, and navigating or changing the theme closes it. Existing page images and diagrams remain the reading and link-navigation source. -The [focused tests](../../../../website/tests/mermaid-viewer.spec.ts) run in the root unit-test suite; `docs:check` and `doc-sync` also select them so documentation-only validation exercises the viewer. They cover late rendering, accessible titles, initial and resized fit, control wiring, keyboard cycling, replacement, and resource release. +The [Mermaid tests](../../../../website/tests/mermaid-viewer.spec.ts) and [image tests](../../../../website/tests/image-viewer.spec.ts) run in the root unit-test suite; `docs:check` and `doc-sync` also select them so documentation-only validation exercises the viewer. They cover late rendering and loading, accessible names, initial and resized fit, the 100% action, keyboard cycling, switching between images and diagrams, source replacement, and resource release. **CI coverage gap.** The DOM tests mock Panzoom and do not execute browser layout. Native modal behavior, SVG markers, pointer-anchored wheel zoom, canvas dragging, theme colors, and narrow-screen geometry require real-browser verification. The recorded browser demonstration supplies evidence for the current implementation, but it is not an automated regression check. diff --git a/.agents/notes/implemented/feature/2026-09-14-docs-mermaid-viewer.zh.md b/.agents/notes/implemented/feature/2026-09-14-docs-mermaid-viewer.zh.md index b66f427a48..665ab65e48 100644 --- a/.agents/notes/implemented/feature/2026-09-14-docs-mermaid-viewer.zh.md +++ b/.agents/notes/implemented/feature/2026-09-14-docs-mermaid-viewer.zh.md @@ -1,4 +1,4 @@ -# Agent Note: 文档站 Mermaid 查看器 +# Agent Note: 文档站图表与图片查看器 Status: implemented @@ -6,15 +6,17 @@ Status: implemented ## 问题 -复杂 Mermaid 图表缩放到文档正文列宽后,细节难以辨认。阅读长时序图需要在保留全图概览的同时放大和平移,以检查交互细节。 +复杂 Mermaid 图表和界面截图缩放到文档正文列宽后,细节难以辨认。阅读时需要在保留全图概览的同时放大和平移,以检查交互细节。 ## 决策 [VitePress 主题](../../../../website/.vitepress/theme/index.ts) 在每张已渲染的 Mermaid SVG 角落增加全屏图标。原生模态对话框使背景不可交互,并支持 Escape 退出。可见标题也用于辅助技术识别对话框;页面标题缺失或为空白时,使用本地化的查看器标题。浮动工具栏集中显示缩放控件、当前比例和适应窗口操作;关闭按钮位于顶部角落,帮助按需展开。键盘焦点在五个按钮之间循环。Panzoom 提供指针、滚轮和双指交互。方向键按固定的屏幕距离平移。关闭时恢复入口焦点且不滚动页面,并恢复页面原有的 overflow 设置。 +普通图片与 Mermaid 共用一个弹层和缩放控制器。已加载、具有非空 alt 文本且独占段落的图片支持点击图片或角落按钮打开;链接图片、行内图片、装饰图片和 `data-no-zoom` 图片保留原有交互。图片的 alt 文本作为弹层标题,比例按钮返回并居中显示自然尺寸(100%),其辅助名称同时包含当前比例;图片视图的键盘焦点在六个按钮之间循环。 + 查看器将 SVG 复制到 shadow root 中。Mermaid 内嵌的选择器和片段 ID 限定在副本内部,因此副本的标记和样式不会解析到原始图表上。Panzoom 变换与视口等大的画布,其中的 SVG 使用 viewBox 的自然尺寸,使指针坐标与变换原点共享同一个中心;初始缩放以及每次窗口尺寸变化都会适配整张图表,且不会放大到超出自然尺寸。这样既保留矢量细节,也使适配不受文档窄列宽度的影响。画布没有可见边框;预留空间使控件不会遮挡适配后的图表。 -查看器资源由已挂载的主题持有。路由、语言、主题和源 SVG 替换都会关闭当前视图;异步渲染的 Mermaid 图表会获得新的入口。body 的 overflow 滚动锁依赖默认主题让 HTML 元素保持 visible overflow。它避免修改 HTML 属性,因为 Mermaid 插件会观察这些属性并重新渲染。[实现](../../../../website/.vitepress/theme/mermaid-viewer.ts) 将 Markdown、原始页面副本和 `llms.txt` 的生成保留在原有归属处。 +查看器资源由已挂载的主题持有。路由、语言、主题和源内容替换都会关闭当前视图;异步渲染的 Mermaid 图表和加载完成的图片会获得新的入口。body 的 overflow 滚动锁依赖默认主题让 HTML 元素保持 visible overflow。它避免修改 HTML 属性,因为 Mermaid 插件会观察这些属性并重新渲染。[共享弹层](../../../../website/.vitepress/theme/media-viewer.ts)、[Mermaid 适配器](../../../../website/.vitepress/theme/mermaid-viewer.ts) 与[图片适配器](../../../../website/.vitepress/theme/image-viewer.ts) 将 Markdown、原始页面副本和 `llms.txt` 的生成保留在原有归属处。 ## 考虑过的替代方案 @@ -26,8 +28,8 @@ Status: implemented ## 影响 -文档站增加了 Panzoom 直接依赖,并使用原生 dialog、shadow root 和 ResizeObserver 支持。查看器的缩放和平移状态是临时的:窗口尺寸变化会重新适配图表,导航或主题变化会关闭视图。原有页面图表仍然是阅读和链接导航的来源。 +文档站增加了 Panzoom 直接依赖,并使用原生 dialog、shadow root 和 ResizeObserver 支持。查看器的缩放和平移状态是临时的:窗口尺寸变化会重新适配内容,导航或主题变化会关闭视图。原有页面图片和图表仍然是阅读和链接导航的来源。 -[定向测试](../../../../website/tests/mermaid-viewer.spec.ts) 属于根单元测试集;`docs:check` 和 `doc-sync`(文档同步门禁)也会选中这些测试,使仅运行文档验证时同样覆盖查看器。它们验证延迟渲染、无障碍标题、初始及窗口变化后的适配、控件连接、键盘焦点循环、图表替换和资源释放。 +[Mermaid 测试](../../../../website/tests/mermaid-viewer.spec.ts) 与[图片测试](../../../../website/tests/image-viewer.spec.ts) 属于根单元测试集;`docs:check` 和 `doc-sync`(文档同步门禁)也会选中这些测试,使仅运行文档验证时同样覆盖查看器。它们验证延迟渲染和加载、无障碍名称、初始及窗口变化后的适配、100% 操作、键盘焦点循环、图片与图表互相切换、源内容替换和资源释放。 **CI 覆盖缺口。** DOM 测试模拟了 Panzoom,不执行浏览器布局。原生模态行为、SVG 标记、以指针为锚点的滚轮缩放、画布拖动、主题颜色和窄屏几何布局需要真实浏览器验证。浏览器演示记录提供了当前实现的证据,但不属于自动回归检查。 diff --git a/.agents/notes/implemented/feature/2026-09-19-windows-acl-mandatory-integrity-confinement.i18n.yaml b/.agents/notes/implemented/feature/2026-09-19-windows-acl-mandatory-integrity-confinement.i18n.yaml new file mode 100644 index 0000000000..4ab68b411e --- /dev/null +++ b/.agents/notes/implemented/feature/2026-09-19-windows-acl-mandatory-integrity-confinement.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/feature/2026-09-19-windows-acl-mandatory-integrity-confinement.md +2026-09-19-windows-acl-mandatory-integrity-confinement.md: f53765f34a7d6d9bcae8b06839c30731a054165d +2026-09-19-windows-acl-mandatory-integrity-confinement.zh.md: a963fd7f549ca2e995f74893c95f1620034664eb diff --git a/.agents/notes/implemented/feature/2026-09-19-windows-acl-mandatory-integrity-confinement.md b/.agents/notes/implemented/feature/2026-09-19-windows-acl-mandatory-integrity-confinement.md new file mode 100644 index 0000000000..f53765f34a --- /dev/null +++ b/.agents/notes/implemented/feature/2026-09-19-windows-acl-mandatory-integrity-confinement.md @@ -0,0 +1,56 @@ +# Agent Note: Windows sandbox delete confinement: mandatory integrity plus an ambient-delete deny + +Status: implemented + +English | [中文](2026-09-19-windows-acl-mandatory-integrity-confinement.zh.md) + +## Problem + +The [restricted-token rung](2026-08-08-windows-acl-restricted-token-sandbox.md) confines writes by intersecting the requested access mask with restricting SIDs. That intersection covers only the access check against the object's OWN security descriptor: Windows also authorizes a write or a delete from the PARENT directory's `FILE_DELETE_CHILD` right, which no restricting SID has to co-sign. `cmd /c del` and `[System.IO.File]::Delete` — anything reaching `DeleteFileW` — therefore deleted files outside the workspace in BOTH confined modes (issue #4581), while plain writes from the same child were denied. + +## Decision + +Every grant now applies three edits in one `SetNamedSecurityInfoW` call, and the restricted token is lowered to match: + +1. the capability-SID allow ACE (`GRANT_MASK`, `OI|CI`) — the write authority, unchanged; +2. a DENY ACE for the world SID on `FILE_DELETE_CHILD` with `CONTAINER_INHERIT_ACE` — the capability ACE's DELETE bit becomes the only delete authority inside a granted root; +3. a Low integrity mandatory label (`S-1-16-4096`, `SYSTEM_MANDATORY_LABEL_NO_WRITE_UP`, `OI|CI`) — and `SetTokenInformation(TokenIntegrityLevel)` lowers the confined token to the same level. + +Each piece is load-bearing. The label is what reaches the parent-`FILE_DELETE_CHILD` route at all: the kernel evaluates the mandatory policy inside the access check whichever right supplied the authority, so a Medium object outside the granted roots is delete-proof as well as write-proof. The deny is what keeps one granted root out of ANOTHER's reach: every granted root carries the Low label, so the integrity check alone would still pass between them. The container-only inheritance on the deny is what keeps the ambient ACL usable: `0x40` is a member of `FILE_ALL_ACCESS`, so a file-level copy would refuse every `GENERIC_ALL`/`FullControl` open inside the root — measured against a real runner before the flag was narrowed. + +The label is STANDING on workspace roots, like the capability ACE and for the same reason: revoking it per session would re-propagate the whole tree on every provision and would race with concurrent sessions. `revokeWrite` clears it only when no other capability grant remains on the directory, so two grants on one directory stay independently revocable. The idempotent skip requires the exact ACE, the exact deny, AND the exact label, so a root granted by an earlier build picks the deny up on its next provision. + +## Alternatives considered + +### Why not make the label revocable per session? + +It removes the externality below, but pays the eager full-tree propagation on every provision (tens of seconds on large workspaces) that the deterministic per-workspace SID exists to avoid, and a concurrent session or a crashed one leaves the label oscillating. + +### Why not Untrusted integrity for read-only mode? + +Measured on this host: an Untrusted (`S-1-16-0`) token cannot start `pwsh` at all — DLL initialization fails (`0x8007045A`, `BCrypt.dll`) — so the read-only mode would have no usable shell. + +### Why not deny `FILE_DELETE_CHILD` with `OI|CI`? + +It also lands the deny on every FILE inside the granted root (observed through `icacls`: `Everyone:(I)(DENY)(DC)`), and `0x40` is part of `FILE_ALL_ACCESS`, so `CreateFileW(GENERIC_ALL)` on those files returns `ERROR_ACCESS_DENIED` for the user, Administrators, SYSTEM, and the DSH host alike. Narrowing the flag removes that class; a FullControl open of a DIRECTORY inside a granted root stays denied, which is the irreducible cost of denying a right that is a member of the full-access mask. + +### Why not drop the confinement layer and document the escape? + +The escape is the report: `cmd /c del` deleting outside the workspace is exactly what the sandbox promises not to allow, and no read-side or ACL-only change closes it — the restricting-SID intersection cannot reach the parent route by construction. + +### Why not give each workspace its own integrity level? + +Mandatory integrity control has five fixed levels (Untrusted, Low, Medium, High, System); they cannot be derived per workspace the way `S-1-4-x-y` capability SIDs are. + +## Consequences + +Bought: deletes confined through every authority Windows accepts AND confined to the granted root that owns them; the previously documented Everyone-write boundary closed; writes, reads, and process visibility otherwise unchanged; fail-closed errors on every Win32 call. Cost, all recorded in the package README: the standing Low label widens the workspace tree for ANY other process running at Low integrity as the same user and outlives DSH (the price of having a write boundary at all at this integrity level); a granted directory must now also grant `WRITE_OWNER` for its label (a Full-control workspace has it, a Modify-only one fails loudly); a FullControl open of a directory inside a granted root is denied; a tree another AppContainer tool has ACL'd with a package SID is unreadable to the Low child; FAT-class targets stay unverified. The [restricted-token rung note](2026-08-08-windows-acl-restricted-token-sandbox.md) remains the owner of the rung's token lists, runner contract, and grant lifecycle; this note owns only the delete route and the integrity layer that closes it. + +## Testing + +`runner.spec.ts` pins the behavior against a real runner and a real restricted token: the delete escape is denied in both modes through `cmd`, .NET, `Remove-Item`, and libuv while the file survives on disk; one session cannot delete inside another granted root; NUL stays writable in both modes; a `GENERIC_ALL` open of a file inside a granted root succeeds while the same open of a directory is denied; revoking one of two grants on a directory leaves the surviving grant usable. `acl.spec.ts` pins the real DACL/label lifecycle, that the deny inherits to containers only, and the shared-label revocation rule; `token-failure-paths.spec.ts` pins the exact `TokenIntegrityLevel` payload; the failure-path suites cover every new allocation and early exit, including the label ACL and descriptor releases. + +## Related + +- [Windows sandbox rung: raw ACL restricted tokens](2026-08-08-windows-acl-restricted-token-sandbox.md) — the rung this change extends (kept active; this note supersedes only its delete-route boundary). +- [Sandbox decision](2026-07-06-sandbox.md) — the platform chains and the `partial` enforcement vocabulary. diff --git a/.agents/notes/implemented/feature/2026-09-19-windows-acl-mandatory-integrity-confinement.zh.md b/.agents/notes/implemented/feature/2026-09-19-windows-acl-mandatory-integrity-confinement.zh.md new file mode 100644 index 0000000000..a963fd7f54 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-09-19-windows-acl-mandatory-integrity-confinement.zh.md @@ -0,0 +1,56 @@ +# Agent Note: Windows 沙箱删除约束:强制完整性 + 环境性删除拒绝 + +Status: implemented + +[English](2026-09-19-windows-acl-mandatory-integrity-confinement.md) | 中文 + +## 问题 + +[受限令牌档](2026-08-08-windows-acl-restricted-token-sandbox.zh.md)通过把请求的访问掩码与 restricting SIDs 求交集来约束写入。该交集只覆盖针对对象**自身**安全描述符的那次访问检查:Windows 也会依据**父目录**的 `FILE_DELETE_CHILD` 权限批准写入或删除,而这项权限不需要任何 restricting SID 副署。因此 `cmd /c del` 与 `[System.IO.File]::Delete`——凡走 `DeleteFileW` 的路径——在两种受限模式下都能删除工作区之外的文件(issue #4581),而同一子进程的普通写入却被拒绝。 + +## 决策 + +每次授权现在都在一次 `SetNamedSecurityInfoW` 调用中施加三项编辑,并把受限令牌降到同一级别: + +1. 能力 SID 的允许 ACE(`GRANT_MASK`,`OI|CI`)——写权限本身,未变; +2. 对 world SID 的 `FILE_DELETE_CHILD` 拒绝 ACE,带 `CONTAINER_INHERIT_ACE`——能力 ACE 的 DELETE 位成为授权根内唯一的删除授权来源; +3. Low 完整性强制标签(`S-1-16-4096`、`SYSTEM_MANDATORY_LABEL_NO_WRITE_UP`、`OI|CI`)——并用 `SetTokenInformation(TokenIntegrityLevel)` 把受限令牌降到同一级别。 + +三者都不可省。标签是唯一能触及父目录 `FILE_DELETE_CHILD` 路径的手段:无论权限来自哪一项,内核都在访问检查内部执行强制策略,因此授权根之外的 Medium 对象不仅防写、也防删。拒绝项则负责隔开**各个**授权根:所有授权根都带 Low 标签,仅靠完整性检查它们之间仍会放行。而拒绝项只做容器继承,是为了不破坏环境 ACL 的可用性:`0x40` 是 `FILE_ALL_ACCESS` 的成员,若落到文件上会让授权根内每次 `GENERIC_ALL`/`FullControl` 打开都被拒绝——收窄标志之前已在真实 runner 上实测确认。 + +标签在工作区根上**常驻**,与能力 ACE 同理:按会话回收会导致每次供给都重新传播整棵树,并与并发会话竞争。`revokeWrite` 只在该目录上不再留有其他能力授权时才清除标签,因此同一目录上的两份授权仍可独立撤销。幂等跳过要求精确 ACE、精确拒绝与精确标签三者齐备,因此早前版本已授权的工作区会在下次供给时补上拒绝项。 + +## 考虑过的替代方案 + +### 为什么不让标签按会话回收? + +那样可以消除下面的外部性,但每次供给都要付出确定性工作区 SID 本要避免的急切全树传播(大型工作区上以数十秒计),而且并发会话或崩溃会话会让标签反复消失与重建。 + +### 为什么 read-only 模式不用 Untrusted 完整性? + +本机实测:Untrusted(`S-1-16-0`)令牌根本无法启动 `pwsh`——DLL 初始化失败(`0x8007045A`,`BCrypt.dll`)——该模式将没有可用的 shell。 + +### 为什么不用 `OI|CI` 施加 `FILE_DELETE_CHILD` 拒绝? + +那样拒绝项也会落到授权根内的每个**文件**上(用 `icacls` 观察到的正是 `Everyone:(I)(DENY)(DC)`),而 `0x40` 属于 `FILE_ALL_ACCESS`,于是这些文件上的 `CreateFileW(GENERIC_ALL)` 会对用户、Administrators、SYSTEM 与 DSH host 一并返回 `ERROR_ACCESS_DENIED`。收窄标志消除了这一类影响;授权根内**目录**的 FullControl 打开仍被拒绝,这是拒绝一项属于完全访问掩码的权限所无法避免的代价。 + +### 为什么不干脆去掉这层约束、只把逃逸写进文档? + +逃逸本身就是报告内容:`cmd /c del` 删除工作区之外的文件,恰恰是沙箱承诺不允许的事;而仅靠读侧或纯 ACL 的改动关不掉它——restricting SID 交集在构造上就触及不到父目录路径。 + +### 为什么不让每个工作区拥有自己的完整性级别? + +强制完整性控制只有五个固定级别(Untrusted、Low、Medium、High、System),无法像 `S-1-4-x-y` 能力 SID 那样按工作区派生。 + +## 后果 + +所得:删除在 Windows 接受的每条授权路径上都被约束,且被约束在各自的授权根内;此前记录的 Everyone 写边界被关闭;写入、读取与进程可见性其余部分不变;每次 Win32 调用失败都 fail-closed。所失(全部记录在包 README 中):常驻 Low 标签会向**任何**以同一用户身份运行在 Low 完整性的进程放宽该工作区树,并比 DSH 生命期更长(这是在该完整性级别上拥有写边界本身的代价);被授权目录现在还必须授予 `WRITE_OWNER` 才能写入标签(完全控制的工作区具备,仅授予 Modify 的会大声失败);授权根内目录的 FullControl 打开被拒绝;被其他 AppContainer 工具以包 SID 标记过的目录树对 Low 子进程不可读;FAT 类目标仍未验证。[受限令牌档笔记](2026-08-08-windows-acl-restricted-token-sandbox.zh.md)仍是该档令牌列表、runner 约定与授权生命周期的归属者;本笔记只负责删除路径与封闭它的完整性层。 + +## 测试 + +`runner.spec.ts` 以真实 runner 与真实受限令牌钉住行为:两种模式下经 `cmd`、.NET、`Remove-Item` 与 libuv 的越界删除全部被拒且**宿主侧文件仍存在**;一个会话无法删除另一个授权根内的文件;NUL 在两种模式下仍可写;授权根内**文件**的 `GENERIC_ALL` 打开成功,而**目录**的同一打开被拒绝;同一目录上两份授权之一被撤销后,存留的授权仍可用。`acl.spec.ts` 钉住真实 DACL/标签生命周期、拒绝项只继承到容器、以及共享标签的撤销规则;`token-failure-paths.spec.ts` 钉住 `TokenIntegrityLevel` 的精确载荷;失败路径套件覆盖每处新增分配与提前退出,包括标签 ACL 与描述符的释放。 + +## 相关 + +- [Windows sandbox 档:原始 ACL 受限令牌](2026-08-08-windows-acl-restricted-token-sandbox.zh.md)——本次改动所扩展的档(保持 active;本笔记只取代其删除路径边界)。 +- [沙箱决策](2026-07-06-sandbox.zh.md)——平台链与 `partial` 强制执行词汇。 diff --git a/.agents/notes/proposed/architecture/2026-09-20-chat-presentation-policy-and-step-groups.i18n.yaml b/.agents/notes/proposed/architecture/2026-09-20-chat-presentation-policy-and-step-groups.i18n.yaml index bb3a7b1cbe..2c52ae61da 100644 --- a/.agents/notes/proposed/architecture/2026-09-20-chat-presentation-policy-and-step-groups.i18n.yaml +++ b/.agents/notes/proposed/architecture/2026-09-20-chat-presentation-policy-and-step-groups.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/proposed/architecture/2026-09-20-chat-presentation-policy-and-step-groups.md -2026-09-20-chat-presentation-policy-and-step-groups.md: df0397a0568967c920994192b370e9fe1d063229 -2026-09-20-chat-presentation-policy-and-step-groups.zh.md: 1bf9cf95174a1de0c7d0576bdd9fbb842d980ae7 +2026-09-20-chat-presentation-policy-and-step-groups.md: 65fbb313d57a34a792ed2ec08d0adfb2fdd5f03d +2026-09-20-chat-presentation-policy-and-step-groups.zh.md: 7ac3442476ea34d5127376cfc40d42892403ce47 diff --git a/.agents/notes/proposed/architecture/2026-09-20-chat-presentation-policy-and-step-groups.md b/.agents/notes/proposed/architecture/2026-09-20-chat-presentation-policy-and-step-groups.md index df0397a056..65fbb313d5 100644 --- a/.agents/notes/proposed/architecture/2026-09-20-chat-presentation-policy-and-step-groups.md +++ b/.agents/notes/proposed/architecture/2026-09-20-chat-presentation-policy-and-step-groups.md @@ -25,6 +25,8 @@ The three settings, presentation policy channel, and per-Turn folding eligibilit ## Goals and constraints +The [implemented Definition-owned Build Group foundation](../../implemented/architecture/2026-09-21-conversation-build-groups.md) supersedes this note's event fold, Builder-materialized group headers, Step-range membership, and changedLocationData proposal. This note retains the presentation-policy and per-Turn eligibility rationale; the grouping implementation below records the superseded route, not the current implementation plan. + The following decisions were confirmed during design review and are inputs to this proposal. | Number | Decision | Meaning | diff --git a/.agents/notes/proposed/architecture/2026-09-20-chat-presentation-policy-and-step-groups.zh.md b/.agents/notes/proposed/architecture/2026-09-20-chat-presentation-policy-and-step-groups.zh.md index 1bf9cf9517..7ac3442476 100644 --- a/.agents/notes/proposed/architecture/2026-09-20-chat-presentation-policy-and-step-groups.zh.md +++ b/.agents/notes/proposed/architecture/2026-09-20-chat-presentation-policy-and-step-groups.zh.md @@ -25,6 +25,8 @@ Chat 的「对话显示」设置在本提案开始时只有两档:`normal` 不 ## 目标与约束 +[已实现的 Definition 分组基础机制](../../implemented/architecture/2026-09-21-conversation-build-groups.zh.md)取代本记录中的事件 fold、Builder 物化组头、Step 范围成员推断及 changedLocationData 提案。本记录保留展示策略与按轮折叠资格的理由;下文分组实现记录已被取代的路线,不是当前实施方案。 + 下表是评审阶段已经确认的裁决,本方案不再重新讨论它们。 | 编号 | 裁决 | 含义 | diff --git a/.agents/notes/proposed/feature/2026-09-20-chat-work-details-migration.i18n.yaml b/.agents/notes/proposed/feature/2026-09-20-chat-work-details-migration.i18n.yaml index 1f41262d74..25c2c8df38 100644 --- a/.agents/notes/proposed/feature/2026-09-20-chat-work-details-migration.i18n.yaml +++ b/.agents/notes/proposed/feature/2026-09-20-chat-work-details-migration.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/proposed/feature/2026-09-20-chat-work-details-migration.md -2026-09-20-chat-work-details-migration.md: cd5dbe723425d5bed719bbb579305e3f917d0cc5 -2026-09-20-chat-work-details-migration.zh.md: 4055b027fc7e5c710358886361badb88a4e9b13e +2026-09-20-chat-work-details-migration.md: 126b61d32d4a4dbf27cb5997149abc9820d7f881 +2026-09-20-chat-work-details-migration.zh.md: 20727c299307881b67d0662092d269afa9af176c diff --git a/.agents/notes/proposed/feature/2026-09-20-chat-work-details-migration.md b/.agents/notes/proposed/feature/2026-09-20-chat-work-details-migration.md index cd5dbe7234..126b61d32d 100644 --- a/.agents/notes/proposed/feature/2026-09-20-chat-work-details-migration.md +++ b/.agents/notes/proposed/feature/2026-09-20-chat-work-details-migration.md @@ -45,7 +45,7 @@ Verify staging against the feature's diff hunks, not whole files. Inspect the wo | Local reasoning-preview subscription | `ReasoningRow` selects its own visibility | Same | Same | Complete | Existing capability | Committed | Pending | | Stable disclosure-row references | Callbacks, icons, and unchanged summaries retain references; full Markdown mounts only on expansion | Same | Same | Complete | Existing capability | Committed | Pending | -All three modes retain whole-Turn folding. `expanded` means removing secondary process groups, not disabling whole-Turn folding or automatically expanding reasoning and tool-card bodies. Until grouping is connected, Chat presents `detailed` and `expanded` identically. +All three modes retain whole-Turn folding. `expanded` removes group-level folding, headers, and height limits while retaining group identity and member parents; it does not disable whole-Turn folding or automatically expand reasoning and tool-card bodies. Until grouping is connected, Chat presents `detailed` and `expanded` identically. ### Migration inventory without grouping dependencies @@ -82,11 +82,11 @@ Review batches are independent of implementation and staging status. Review rema ### Requirements retained for the grouping phase -All items below remain incomplete and retain their full requirements for the turn/step and grouping phase. Neither this channel nor the transferred drafts introduce step-group. Experimental code in `feat/chat-step-groups` does not establish completion of these product behaviors or performance requirements. +All items below remain incomplete and retain their full requirements for the turn/step and grouping phase. The [Definition-owned grouping foundation](../../implemented/architecture/2026-09-21-conversation-build-groups.md) is implemented; that does not establish completion of these product behaviors or performance requirements. | Feature | compact | detailed | expanded | Current state | |---|---|---|---|---| -| Consecutive process groups between replies | Group tools and reasoning, initially collapsed | Same | Show process rows directly without secondary wrappers | Incomplete (grouping phase) | +| Consecutive process groups between replies | Group tools and reasoning, initially collapsed | Same | Show process rows without group-level folding; retain member parents | Incomplete (grouping phase) | | Independent reasoning/reply visibility within one Assistant | Reasoning may join the preceding group; reply stays visible | Same | No secondary group hiding | Incomplete; do not directly copy the original split into two render nodes | | Header category summary | Current category while running; aggregate main categories after completion | Same | No header | Incomplete (grouping phase) | | Running header details | Hidden | Show command, path, query, etc.; reasoning summary when no tool is running | No header | Incomplete (grouping phase) | @@ -97,6 +97,38 @@ All items below remain incomplete and retain their full requirements for the tur Grouping details to verify: completed summaries aggregate at most three categories and use “etc.” beyond three; call counts determine category ordering rather than appearing as header numbers. Detailed-mode live details collapse whitespace and are capped at 160 characters. Titles remain for at least 150ms; retain only the latest update without replaying queued updates. Expanded height is at most the smaller of 400px and half the viewport. Scrollable edges use a 24px fade and preserve interaction with outer scrolling. Retry rows are outside secondary groups. These values describe the original PR's reference behavior, not completed grouping experiments. +### Definition-owned segmentation and presentation + +Business State accepts each input, clears pending changes, and returns repeatable output. Replace builds the loaded window; apply separates structural changes from content updates and maintains affected segments or Turn-local indexes. It owns group opening/closing, stable keys, summaries, and invalidation; generic infrastructure never corrects those decisions by Node kind or Step number. + +| Ordered visible input | Business action | +|---|---| +| Owning Turn changes | Close the pending group. | +| No Turn ownership | Close the group and emit an independent Node; never merge across it. | +| turn-process control | Emit independently without closing the pending process. | +| User, steering, trigger, retry, error, max-tokens, or tail | Close the group and emit independently. | +| Assistant reasoning | Append the reasoning part to the pending group. | +| Assistant visible reply | Close the group and emit the response part independently. Later tools enter a later group. | +| Other process Nodes | Append the Node to the pending group; an ordinary Step boundary does not close it. | +| Turn end | Close that Turn's pending groups even without Node upserts. | + +Content growth with unchanged classification does not resegment history; it may update only the current summary. A first reply, new tool, steering, or retry can change structure. Visibility/location repair handles both old and new ownership. Real structural changes may linearly merge root references; the interface does not promise all operations are constant-time. + +Group headers select data independently from member arrays. All modes retain the same Group container and member parents: + +| Mode | Group header | Body | +|---|---|---| +| Compact | Short activity summary | Group open/closed state controls visibility. | +| Detailed | Summary plus live command/path/query details | Same group open/closed state. | +| Expanded | Hidden | Visible without group-level collapse or height cap; wrapper remains. | + +This assumes the outer Turn process is open. Expanded retains whole-Turn folding and individual tool/reasoning disclosure; it must not eagerly mount all full Markdown. Turn-close resets of internal state are explicit business actions, not key changes. Group headers, scrolling/fades, and Turn interaction belong to this business integration. + +| Business implementation | Responsibility | +|---|---| +| process-groups.ts and register.ts | Definition-owned segmentation, membership, caches, summaries, and registration. | +| AssistantNodeView, Group seat, stores, navigation | Interpret parts; group header/body presentation; local open state; Turn/group reveal, selection/copy and interruption ownership. | + ## Performance constraints | Scenario | Property to preserve | Required evidence | diff --git a/.agents/notes/proposed/feature/2026-09-20-chat-work-details-migration.zh.md b/.agents/notes/proposed/feature/2026-09-20-chat-work-details-migration.zh.md index 4055b027fc..20727c2993 100644 --- a/.agents/notes/proposed/feature/2026-09-20-chat-work-details-migration.zh.md +++ b/.agents/notes/proposed/feature/2026-09-20-chat-work-details-migration.zh.md @@ -45,7 +45,7 @@ PR #4565 同时包含三档展示设置、过程分组、整轮状态、触发 | 推理预览的局部订阅 | `ReasoningRow` 选择自己的可见性 | 相同 | 相同 | 已完成 | 当前代码已有 | 已提交 | 待验收 | | 折叠行引用稳定 | 回调、图标及未变化的摘要保持引用;完整 Markdown 仅在展开时挂载 | 相同 | 相同 | 已完成 | 当前代码已有 | 已提交 | 待验收 | -三档都保留整轮折叠。`expanded` 的产品含义是取消二级过程分组,并非禁止整轮折叠,也不是自动展开每个思考或工具卡的正文。分组尚未接入时,`detailed` 与 `expanded` 的 Chat 展示相同。 +三档都保留整轮折叠。`expanded` 取消组级折叠、组头和限高,保留组身份与成员父级;并非禁止整轮折叠,也不是自动展开每个思考或工具卡的正文。分组尚未接入时,`detailed` 与 `expanded` 的 Chat 展示相同。 ### 不依赖分组的迁移清单 @@ -82,11 +82,11 @@ PR #4565 同时包含三档展示设置、过程分组、整轮状态、触发 ### 分组阶段保留的需求 -这些项目均未完成,继续保留全部需求,后续在 turn/step 与分组阶段处理。当前通道和本次搬迁草稿均未引入 step-group;`feat/chat-step-groups` 的实验代码不代表这些产品行为或性能要求已经完成。 +这些项目均未完成,继续保留全部需求,后续在 turn/step 与分组阶段处理。[Definition 拥有分组的基础机制](../../implemented/architecture/2026-09-21-conversation-build-groups.zh.md)已经实现,但不代表这些产品行为或性能要求已经完成。 | 功能 | compact | detailed | expanded | 当前状态 | |---|---|---|---|---| -| 回复之间的连续过程分组 | 工具与推理归组,默认收起 | 相同 | 过程行直接展示,不使用二级分组包装 | 未完成(分组阶段) | +| 回复之间的连续过程分组 | 工具与推理归组,默认收起 | 相同 | 过程行不做组级折叠,保留成员父级 | 未完成(分组阶段) | | 同一 Assistant 中推理与回复的分别显隐 | 推理可归入前组,回复外露 | 相同 | 不做二级分组隐藏 | 未完成;不直接复制原版拆成两个渲染节点的实现 | | 组头类别摘要 | 运行时显示当前类别,完成后聚合主要类别 | 相同 | 无组头 | 未完成(分组阶段) | | 运行中组头详情 | 不显示 | 显示命令、路径、查询等详情,无运行工具时显示推理摘要 | 无组头 | 未完成(分组阶段) | @@ -97,6 +97,38 @@ PR #4565 同时包含三档展示设置、过程分组、整轮状态、触发 分组产品细节的核对项:完成摘要最多聚合前三类,超过三类使用「等」;调用计数用于分类排序,不直接显示为组头数字。详细模式的实时详情合并空白并限制为 160 个字符;标题最短展示时间为 150ms,只保留最新更新,不补播积压项。展开区高度不超过 400px 与半屏中的较小值,可滚动方向显示 24px 渐隐,并保留与外层滚动的衔接。重试行不属于二级过程组。这些数值是原 PR 的行为参考,不代表当前分组实验已经实现。 +### Definition 拥有的分段与展示 + +业务 State 接受输入、清空待输出变化并返回可重复读取的结果。replace 构建已加载窗口;apply 区分结构变化和正文更新,维护受影响段或轮次内索引。它拥有开组、关组、稳定 key、摘要和失效规则;通用层不按 Node kind 或 Step 编号再次修正业务结论。 + +| 有序可见输入 | 业务动作 | +|---|---| +| 所属 Turn 改变 | 关闭待完成组。 | +| 没有 Turn 归属 | 关闭组并输出独立 Node,不跨越它合并。 | +| turn-process 控件 | 独立输出,不关闭待完成过程。 | +| 用户、steering、触发、重试、错误、最大 token 或尾部 | 关闭组并独立输出。 | +| Assistant 推理 | 把 reasoning 部分加入待完成组。 | +| Assistant 可见回复 | 关闭组,独立输出 response 部分;后续工具进入后面的组。 | +| 其他过程 Node | 加入待完成组,普通 Step 边界不关闭组。 | +| Turn 结束 | 即使没有节点 upsert,也关闭该轮次待完成的组。 | + +分类未变的正文增长不重新分段历史,必要时只更新当前摘要。首次回复、新工具、steering 或重试可以改变结构。可见性或位置修正同时处理旧、新归属。真实结构变化允许线性合并根引用,接口不承诺全部操作常数时间。 + +组头独立于成员数组选择 data,所有模式保留同一 Group 容器及成员父级: + +| 模式 | 组头 | 正文 | +|---|---|---| +| Compact | 简短活动摘要 | 按组开合状态显隐。 | +| Detailed | 摘要及实时命令、路径、查询详情 | 使用同一组开合状态。 | +| Expanded | 隐藏 | 显示,取消组级折叠及限高,容器保留。 | + +以上以外层 Turn 过程已展开为前提。Expanded 保留整轮折叠和单个工具、推理行开合,不能提前挂载全部完整 Markdown。Turn 收起时的内部状态重置是明确业务动作,不通过更换 key 实现。组头、滚动渐隐及整轮联动属于本业务接入。 + +| 业务实现 | 职责 | +|---|---| +| process-groups.ts 与 register.ts | Definition 拥有分段、成员、缓存、摘要及注册。 | +| AssistantNodeView、Group 容器、stores、navigation | 解释部分内容,组头和正文展示,本地开合状态,Turn/Group 揭示、选择复制及中断提示归属。 | + ## 性能约束 | 场景 | 要保留的性质 | 所需证据 | diff --git a/apps/desktop-host/src/index.ts b/apps/desktop-host/src/index.ts index 3964a53291..53a22cb6f9 100644 --- a/apps/desktop-host/src/index.ts +++ b/apps/desktop-host/src/index.ts @@ -9,10 +9,12 @@ import { resolveDshHome } from '@deepseek-ai/dsh-home-paths' import * as desktopOffice from './office.ts' import { installDesktopUpdateTaskControl } from './update-tasks.ts' +import { installOfficeEngineResolution } from './office-engine.ts' async function main(): Promise { const runtimeDir = process.argv[2] as string const projectDir = process.argv[3] as string + installOfficeEngineResolution(runtimeDir) const installAnchor = join(runtimeDir, 'node_modules', '@deepseek-ai', 'dsh', 'package.json') const profile = loadProfileDirectory('dsh', projectDir, installAnchor) const application = runProfile({ diff --git a/apps/desktop-host/src/office-engine.ts b/apps/desktop-host/src/office-engine.ts new file mode 100644 index 0000000000..d8f0bd1d5f --- /dev/null +++ b/apps/desktop-host/src/office-engine.ts @@ -0,0 +1,44 @@ +/** Resolve packaged Office engine manifests from their complete, unpacked resource directories. */ +import { registerHooks, type ModuleHooks } from 'node:module' +import { realpathSync } from 'node:fs' +import { basename, dirname, join, relative } from 'node:path' +import { fileURLToPath, pathToFileURL } from 'node:url' + +/** + * Locate the archive containing a packaged runtime. + * @param runtimeDir - Prepared or ASAR-contained runtime directory. + * @returns Parent archive path, or undefined for a prepared directory. + */ +export function runtimeArchivePath(runtimeDir: string): string | undefined { + const parent = dirname(runtimeDir) + return basename(parent) === 'app.asar' ? parent : undefined +} + +/** + * Keep engine executable and resource paths usable by native child processes outside Electron. + * Hooks apply only to this thread; worker threads must install their own resolver. + * @param runtimeDir - Prepared or ASAR-contained dsh runtime directory. + * @returns Installed resolver for the Host lifetime, or undefined for a non-ASAR runtime. + */ +export function installOfficeEngineResolution(runtimeDir: string): ModuleHooks | undefined { + if (runtimeArchivePath(runtimeDir) === undefined) return undefined + const root = realpathSync(runtimeDir) + const archive = dirname(root) + const source = pathToFileURL(join(root, 'node_modules', '@deepseek-ai', 'libreoffice-kit-')).href + const destination = pathToFileURL(join(`${archive}.unpacked`, relative(archive, root), 'node_modules', '@deepseek-ai', 'libreoffice-kit-')).href + return registerHooks({ + resolve(specifier, context, nextResolve) { + const resolved = nextResolve(specifier, context) + if (!/^@deepseek-ai\/libreoffice-kit-(?:darwin|win32|linux)-/u.test(specifier)) return resolved + const canonical = pathToFileURL(realpathSync(fileURLToPath(resolved.url))).href + if (!canonical.startsWith(source)) { + if (canonical.startsWith(pathToFileURL(archive + '/').href)) { + throw new Error(`desktop Office engine resolved outside the runtime package directory: ${resolved.url}`) + } + return resolved + } + const physical = realpathSync(fileURLToPath(destination + canonical.slice(source.length))) + return { ...resolved, url: pathToFileURL(physical).href } + }, + }) +} diff --git a/apps/desktop-host/tests/office-engine.spec.ts b/apps/desktop-host/tests/office-engine.spec.ts new file mode 100644 index 0000000000..716aef756d --- /dev/null +++ b/apps/desktop-host/tests/office-engine.spec.ts @@ -0,0 +1,93 @@ +import { mkdirSync, mkdtempSync, realpathSync, rmSync, symlinkSync, writeFileSync } from 'node:fs' +import { createRequire, type ModuleHooks } from 'node:module' +import { tmpdir } from 'node:os' +import { dirname, join } from 'node:path' +import { afterEach, expect, it } from 'vitest' +import { installOfficeEngineResolution } from '../src/office-engine.ts' + +const roots: string[] = [] +const hooks: ModuleHooks[] = [] +afterEach(() => { + for (const hook of hooks.splice(0)) hook.deregister() + for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }) +}) + +function fixture(runtimeName = 'dsh') { + const root = mkdtempSync(join(tmpdir(), 'desktop-office-resolution-')) + roots.push(root) + const runtime = join(root, 'app.asar', runtimeName) + const manifest = 'node_modules/@deepseek-ai/libreoffice-kit-darwin-arm64/package.json' + for (const base of [runtime, join(root, 'app.asar.unpacked', runtimeName)]) { + const path = join(base, manifest) + mkdirSync(dirname(path), { recursive: true }) + writeFileSync(path, JSON.stringify({ name: '@deepseek-ai/libreoffice-kit-darwin-arm64', path: realpathSync(dirname(path)) })) + } + const api = join(runtime, 'node_modules/@deepseek-ai/libreoffice-kit/package.json') + mkdirSync(dirname(api), { recursive: true }) + writeFileSync(api, '{"name":"@deepseek-ai/libreoffice-kit"}') + const require: (specifier: string) => unknown = createRequire(join(runtime, 'package.json')) + const hook = installOfficeEngineResolution(runtime)! + hooks.push(hook) + return { root, runtime, manifest, require } +} + +it('resolves engine manifests to physical directories and leaves unrelated modules alone', () => { + const f = fixture() + // Node 24.13 require.resolve bypasses hooks; Electron's require.resolve is covered by packaged Office smoke. + expect(f.require('@deepseek-ai/libreoffice-kit-darwin-arm64/package.json')) + .toMatchObject({ path: realpathSync(dirname(join(f.root, 'app.asar.unpacked', 'dsh', f.manifest))) }) + expect((f.require('node:fs') as typeof import('node:fs')).realpathSync).toBe(realpathSync) + expect(f.require('@deepseek-ai/libreoffice-kit/package.json')).toEqual({ name: '@deepseek-ai/libreoffice-kit' }) +}) + +it('rejects an engine missing from the unpacked tree instead of using its archived copy', () => { + const f = fixture() + rmSync(join(f.root, 'app.asar.unpacked'), { recursive: true }) + expect(() => { f.require('@deepseek-ai/libreoffice-kit-darwin-arm64/package.json') }).toThrow() +}) + +it('leaves a prepared runtime without an archive unchanged', () => { + const root = mkdtempSync(join(tmpdir(), 'desktop-office-prepared-')) + roots.push(root) + expect(installOfficeEngineResolution(join(root, 'dsh'))).toBeUndefined() +}) + +it('preserves a renamed runtime directory when locating the unpacked engine', () => { + const f = fixture('alternate-runtime') + expect(f.require('@deepseek-ai/libreoffice-kit-darwin-arm64/package.json')) + .toMatchObject({ path: realpathSync(dirname(join(f.root, 'app.asar.unpacked', 'alternate-runtime', f.manifest))) }) +}) + +it('resolves an engine through a directory alias', () => { + const f = fixture() + const alias = join(f.root, 'alias') + symlinkSync(join(f.root, 'app.asar'), alias, 'junction') + const require: (specifier: string) => unknown = createRequire(join(alias, 'dsh', 'package.json')) + expect(require('@deepseek-ai/libreoffice-kit-darwin-arm64/package.json')) + .toMatchObject({ path: realpathSync(dirname(join(f.root, 'app.asar.unpacked', 'dsh', f.manifest))) }) +}) + +it('rejects an engine resolved elsewhere inside the archive', () => { + const f = fixture() + const other = join(f.root, 'app.asar', 'other', f.manifest) + mkdirSync(dirname(other), { recursive: true }) + writeFileSync(other, '{}') + const require = createRequire(join(f.root, 'app.asar', 'other', 'package.json')) + expect(() => { require('@deepseek-ai/libreoffice-kit-darwin-arm64/package.json') }) + .toThrow('outside the runtime package directory') +}) + +it('leaves external engines and the archived WASM engine at their own locations', () => { + const f = fixture() + const external = join(f.root, 'external', f.manifest) + const wasm = join(f.runtime, 'node_modules/@deepseek-ai/libreoffice-kit-wasm/package.json') + for (const path of [external, wasm]) { + mkdirSync(dirname(path), { recursive: true }) + writeFileSync(path, JSON.stringify({ path: realpathSync(dirname(path)) })) + } + const require: (specifier: string) => unknown = createRequire(join(f.root, 'external', 'package.json')) + expect(require('@deepseek-ai/libreoffice-kit-darwin-arm64/package.json')) + .toMatchObject({ path: realpathSync(dirname(external)) }) + expect(f.require('@deepseek-ai/libreoffice-kit-wasm/package.json')) + .toMatchObject({ path: realpathSync(dirname(wasm)) }) +}) diff --git a/apps/desktop/README.i18n.yaml b/apps/desktop/README.i18n.yaml index 81f1df65f7..6898608574 100644 --- a/apps/desktop/README.i18n.yaml +++ b/apps/desktop/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 apps/desktop/README.md -README.md: 43cde4f41f2e82bf2d3ccce0a7e93d81234010c5 -README.zh.md: 9e300996cd7ed0428c7199e9f9bd43b6f9077e0a +README.md: e99f771653c62175655f12d6982c04e5f91055d1 +README.zh.md: 9c7f86e5ab1ad0e42fc0fe2ea643c6ca4bed392f diff --git a/apps/desktop/README.md b/apps/desktop/README.md index 43cde4f41f..e99f771653 100644 --- a/apps/desktop/README.md +++ b/apps/desktop/README.md @@ -150,9 +150,9 @@ Each target owns its packed package inputs, prepared runtime, package set, dsh t Desktop packs workspace packages locally and installs external dependencies through the target's bundled Node and pnpm. [Desktop's file policy](scripts/runtime-file-policy.ts) then filters the immutable `resources/app.asar/dsh/node_modules` copy before signing and integrity sealing. It omits TypeScript declarations, recognized JavaScript/CSS/TypeScript source maps, TypeScript build caches, Domino's test directory, selected native compiler outputs, and node-pty prebuilds for other platforms. It preserves runtime JavaScript, native modules and their DLL/EXE helpers, WASM, unknown assets, licenses, and notices. Dependency manifests pass through electron-builder's metadata cleanup before integrity sealing, so archiving preserves their recorded bytes. The policy does not alter npm tarballs, the bundled package manager, or user-installed plugin files. -The [Office conversion provider](../../packages/document/office-to-pdf/README.md) carries the target’s declared native engine, or WASM when the kit declares no matching native target. Preparation rejects a missing target engine before packaging. Engine assets, licenses, and notices remain in the runtime dependency tree. +The [Office conversion provider](../../packages/document/office-to-pdf/README.md) carries the target’s declared native engine, or WASM when the kit declares no matching native target. Preparation rejects a missing target engine before packaging. Complete native engine packages, including executables, libraries, data, licenses, and notices, are unpacked under `resources/app.asar.unpacked/dsh/node_modules/@deepseek-ai/`. The Desktop Host resolves engine manifests to these physical directories so native subprocesses can read their resources; the JavaScript API and WASM engine remain in ASAR. On macOS, the native helper receives the JIT entitlement required by [LibreOffice’s UNO bridge](https://github.com/LibreOffice/core/blob/master/sysui/desktop/macosx/hardened_runtime.xcent.in). -The packaged application runs compiled JavaScript and pre-generated Typert metadata; it does not compile TypeScript plugins. Source-level debugger navigation and editor declarations remain available in development packages. [Copy-policy tests](tests/runtime-file-policy.spec.ts) cover exclusions and retained assets; The [payload smoke](tests/fixtures/runtime-payload-smoke.mjs) runs under Electron RunAsNode before the Host smoke and final inventory verification. Signed Windows builds run these checks after dependency signing; other builds run them during `prepare:dsh`. +The packaged application runs compiled JavaScript and pre-generated Typert metadata; it does not compile TypeScript plugins. Source-level debugger navigation and editor declarations remain available in development packages. [Copy-policy tests](tests/runtime-file-policy.spec.ts) cover exclusions and retained assets; The [payload smoke](tests/fixtures/runtime-payload-smoke.mjs) runs under Electron RunAsNode before the Host smoke and final inventory verification. Signed Windows builds run these checks after dependency signing; other builds run them during `prepare:dsh`. The [Host smoke](scripts/smoke-runtime.ts) creates DOCX, XLSX, and PPTX inputs with bundled Python, converts each through the real Office provider, and checks PDF output. Every assembled application, including directory and unsigned Windows builds, repeats the payload and Host checks against ASAR. Archive integrity checks compare the complete archived descriptor with preparation, file bytes and membership in both archive and unpacked trees, recorded executable flags for archived files, and physical permissions for unpacked files. Conversion failures stop packaging before a release record is written; macOS DMG/ZIP builds run these checks before notarization. Windows release qualification also runs [directory and replacement checks](scripts/smoke-windows.ps1) manually after the Desktop build. Set `$Makensis`, `$SevenZip`, and `$PluginDir` to the pinned builder’s NSIS compiler, 7-Zip executable, and x86-unicode NSIS plugin directory. From the repository root, run the command below. It verifies directory replacement and rollback, and both locked-file replacement modes; it is not part of the unit-test lane. diff --git a/apps/desktop/README.zh.md b/apps/desktop/README.zh.md index 9e300996cd..9c7f86e5ab 100644 --- a/apps/desktop/README.zh.md +++ b/apps/desktop/README.zh.md @@ -150,9 +150,9 @@ macOS arm64 命令要求 Apple Silicon。macOS x64 命令可以在 Intel macOS Desktop 在本地打包工作区包,并通过目标捆绑的 Node 和 pnpm 安装外部依赖。[Desktop 文件策略](scripts/runtime-file-policy.ts)随后在签名和完整性封装前过滤不可变的 `resources/app.asar/dsh/node_modules` 副本。它排除 TypeScript 声明、已识别的 JavaScript/CSS/TypeScript source map、TypeScript 构建缓存、Domino 测试目录、选定的原生编译器输出和其他平台的 node-pty 预构建文件。它保留运行时 JavaScript、原生模块及其 DLL/EXE 辅助文件、WASM、未知资源、许可证和 notices。依赖清单在完整性封装前经过 electron-builder 的元数据清理,确保归档保持已记录的字节。该策略不修改 npm tarball、捆绑的包管理器或用户安装的插件文件。 -[Office 转换提供方](../../packages/document/office-to-pdf/README.zh.md)携带目标已声明的原生引擎;kit 未声明匹配原生目标时携带 WASM 引擎。准备阶段在打包前拒绝缺少目标引擎的情况。引擎资源、许可证和 notices 保留在运行时依赖树中。 +[Office 转换提供方](../../packages/document/office-to-pdf/README.zh.md)携带目标已声明的原生引擎;kit 未声明匹配原生目标时携带 WASM 引擎。准备阶段在打包前拒绝缺少目标引擎的情况。完整原生引擎包(包括可执行文件、库、数据、许可证和 notices)解包到 `resources/app.asar.unpacked/dsh/node_modules/@deepseek-ai/` 下。Desktop Host 将引擎清单解析到这些物理目录,使原生子进程能读取资源;JavaScript API 和 WASM 引擎保留在 ASAR 中。macOS 上的原生辅助程序获得 [LibreOffice UNO 桥](https://github.com/LibreOffice/core/blob/master/sysui/desktop/macosx/hardened_runtime.xcent.in)所需的 JIT entitlement。 -打包应用运行编译后的 JavaScript 和预生成的 Typert 元数据,不编译 TypeScript 插件。源码级调试导航和编辑器声明仍可从开发包中获取。[复制规则测试](tests/runtime-file-policy.spec.ts)覆盖排除项和保留资源;[产物 smoke](tests/fixtures/runtime-payload-smoke.mjs) 在 Host smoke 和最终清单验证之前,使用 Electron RunAsNode 执行。Windows 签名构建在依赖签名后运行这些检查;其他构建在 `prepare:dsh` 中运行。 +打包应用运行编译后的 JavaScript 和预生成的 Typert 元数据,不编译 TypeScript 插件。源码级调试导航和编辑器声明仍可从开发包中获取。[复制规则测试](tests/runtime-file-policy.spec.ts)覆盖排除项和保留资源;[产物 smoke](tests/fixtures/runtime-payload-smoke.mjs) 在 Host smoke 和最终清单验证之前,使用 Electron RunAsNode 执行。Windows 签名构建在依赖签名后运行这些检查;其他构建在 `prepare:dsh` 中运行。[Host smoke](scripts/smoke-runtime.ts) 使用捆绑的 Python 创建 DOCX、XLSX 和 PPTX 输入,通过真实 Office 提供方逐一转换并检查 PDF 输出。每个组装后的应用(包括目录包和 Windows 未签名构建)都会针对 ASAR 重复产物和 Host 检查。归档完整性检查将归档内完整描述符与准备结果比对,并核对归档和解包目录中的文件内容与清单、归档内文件记录的执行标志,以及解包文件的物理权限。转换失败会在写入发布记录前终止打包;macOS DMG/ZIP 构建在公证前执行这些检查。 Windows 发布验收还需在 Desktop 构建后手动运行[目录和替换检查](scripts/smoke-windows.ps1)。将 `$Makensis`、`$SevenZip` 和 `$PluginDir` 分别设为锁定版本构建器的 NSIS 编译器、7-Zip 可执行文件和 x86-unicode NSIS 插件目录。从仓库根目录运行以下命令。它验证 目录替换与回滚和两种文件占用替换方式;不属于单元测试通道。 diff --git a/apps/desktop/scripts/desktop-build-paths.d.mts b/apps/desktop/scripts/desktop-build-paths.d.mts index f20d60da48..b26c633d9c 100644 --- a/apps/desktop/scripts/desktop-build-paths.d.mts +++ b/apps/desktop/scripts/desktop-build-paths.d.mts @@ -4,6 +4,7 @@ import type { DesktopAutoUpdateTarget } from './desktop-auto-update-environment. export interface DesktopTargetBuildPaths { readonly root: string readonly artifacts: string + readonly unsignedArtifacts: string readonly runtime: string readonly packageSet: string readonly dsh: string diff --git a/apps/desktop/scripts/desktop-build-paths.mjs b/apps/desktop/scripts/desktop-build-paths.mjs index 375e61b542..841da2c4ba 100644 --- a/apps/desktop/scripts/desktop-build-paths.mjs +++ b/apps/desktop/scripts/desktop-build-paths.mjs @@ -32,7 +32,7 @@ export function resolveDesktopBuildTarget( /** * Return the mutable preparation and artifact directories owned by one release target. * @param {'mac-arm64' | 'mac-x64' | 'win-x64'} target - Supported Desktop target name. - * @returns {{ root: string, artifacts: string, runtime: string, packageSet: string, dsh: string, dshPnpm: string, electron: string, packedDsh: string, packedVendor: string, packedLandlock: string, downloads: string }} Target paths plus the shared immutable download cache. + * @returns {{ root: string, artifacts: string, unsignedArtifacts: string, runtime: string, packageSet: string, dsh: string, dshPnpm: string, electron: string, packedDsh: string, packedVendor: string, packedLandlock: string, downloads: string }} Target paths plus the shared immutable download cache. */ export function desktopTargetBuildPaths(target) { if (!SUPPORTED_TARGETS.has(target)) { @@ -43,6 +43,7 @@ export function desktopTargetBuildPaths(target) { return { root, artifacts: join(root, 'artifacts'), + unsignedArtifacts: join(root, 'unsigned-artifacts'), runtime: join(root, 'runtime'), packageSet: join(root, 'package-set'), dsh: join(root, 'dsh'), diff --git a/apps/desktop/scripts/electron-builder-config.mjs b/apps/desktop/scripts/electron-builder-config.mjs index 3f469c3be2..3ce50573d1 100644 --- a/apps/desktop/scripts/electron-builder-config.mjs +++ b/apps/desktop/scripts/electron-builder-config.mjs @@ -63,7 +63,8 @@ export function createElectronBuilderConfig( let primaryRuntimeDestination let dshDestination let windowsCode = [] - const unpack = ['**/*.{node,dylib,dll,so,exe}', '**/*.so.*', '**/spawn-helper', '**/@vscode/ripgrep/bin/rg'] + const unpack = ['**/*.{node,dylib,dll,so,exe}', '**/*.so.*', '**/spawn-helper', '**/@vscode/ripgrep/bin/rg', + `**/node_modules/@deepseek-ai/libreoffice-kit-${resolvedPlatform}-${resolvedArch}/**/*`] const windowsSigner = packagesWindows && !unsigned ? createWindowsTokenSigner({ certificateFile: env.DSH_DESKTOP_WINDOWS_CER_FILE, @@ -90,7 +91,7 @@ export function createElectronBuilderConfig( extraMetadata: { dshDesktopAppId: appId, dshMandatoryUpdatePolicy: policy }, productName: 'DeepSeek Harness', artifactName: 'deepseek-harness-${version}-${os}-${arch}.${ext}', - directories: { output: unsigned ? join(buildPaths.root, 'unsigned-artifacts') : buildPaths.artifacts }, + directories: { output: unsigned ? buildPaths.unsignedArtifacts : buildPaths.artifacts }, asar: true, electronDist: buildPaths.electron, electronFuses: { runAsNode: true }, diff --git a/apps/desktop/scripts/node-entitlements.plist b/apps/desktop/scripts/jit-entitlements.plist similarity index 100% rename from apps/desktop/scripts/node-entitlements.plist rename to apps/desktop/scripts/jit-entitlements.plist diff --git a/apps/desktop/scripts/macos-runtime.ts b/apps/desktop/scripts/macos-runtime.ts index 4a180838ee..b5d35f1672 100644 --- a/apps/desktop/scripts/macos-runtime.ts +++ b/apps/desktop/scripts/macos-runtime.ts @@ -40,8 +40,9 @@ export async function signMacOSRuntime( const path = files[next++] if (path === undefined) return const identifier = `${appId}.runtime.${createHash('sha256').update(path).digest('hex')}` - const entitlements = path === 'dependencies/node/bin/node' - ? join(import.meta.dirname, 'node-entitlements.plist') : undefined + const needsJit = path === 'dependencies/node/bin/node' + || /^node_modules\/@deepseek-ai\/libreoffice-kit-darwin-(?:arm64|x64)\/bin\/libreoffice-kit$/u.test(path) + const entitlements = needsJit ? join(import.meta.dirname, 'jit-entitlements.plist') : undefined const file = join(root, path) const thin = ['cefaedfe', 'cffaedfe', 'feedface', 'feedfacf'].includes(magic(file)) if (cacheDirectory !== undefined && policy !== undefined && thin) { diff --git a/apps/desktop/scripts/package-target.ts b/apps/desktop/scripts/package-target.ts index 688d3d9671..6909fc4371 100644 --- a/apps/desktop/scripts/package-target.ts +++ b/apps/desktop/scripts/package-target.ts @@ -424,6 +424,7 @@ export async function packageTarget( ...desktopElectronBuilderArguments(target, true), '--config.mac.notarize=false', ], electronBuilderEnv) + await execute(['exec', 'tsx', 'scripts/smoke-packaged-runtime.ts'], targetEnv) await withMacOSNotarizationProxy(mac?.notarizationProxy, () => packageMacOSArtifacts({ arch: target.arch, version: packageVersion(join(APP_ROOT, 'package.json'), 'desktop package'), @@ -432,13 +433,14 @@ export async function packageTarget( }, artifact => execute(desktopElectronBuilderArguments(target, false, artifact), electronBuilderEnv)), undefined, undefined, proxyEvent) } else if (target.platform === 'darwin') { await execute([...desktopElectronBuilderArguments(target, true), '--config.mac.notarize=false'], electronBuilderEnv) + await execute(['exec', 'tsx', 'scripts/smoke-packaged-runtime.ts'], targetEnv) const appPath = join(buildPaths.artifacts, target.arch === 'arm64' ? 'mac-arm64' : 'mac', 'DeepSeek Harness.app') await withMacOSNotarizationProxy(mac?.notarizationProxy, () => notarizeMacOS({ appPath, ...resolveMacOSNotarizationEnvironment(environment) }), undefined, undefined, proxyEvent) } else { await signedStage('artifacts', () => execute(desktopElectronBuilderArguments(target, invocation.directory), electronBuilderEnv)) + await execute(['exec', 'tsx', 'scripts/smoke-packaged-runtime.ts', ...(invocation.unsigned ? ['--unsigned'] : [])], targetEnv) } - if (signPrimaryRuntime) await execute(['exec', 'tsx', 'scripts/smoke-packaged-runtime.ts'], targetEnv) if (!invocation.directory && !invocation.unsigned) writeReleaseRecord(target, electronBuilderEnv, buildPaths.artifacts) if (journal) recordPackagingEvent(journal, { type: 'artifacts', directory: buildPaths.artifacts }) } diff --git a/apps/desktop/scripts/smoke-packaged-runtime.ts b/apps/desktop/scripts/smoke-packaged-runtime.ts index 5fc993f25d..106830b5ad 100644 --- a/apps/desktop/scripts/smoke-packaged-runtime.ts +++ b/apps/desktop/scripts/smoke-packaged-runtime.ts @@ -1,13 +1,23 @@ -/** Validate the assembled Windows application before recording release completion. */ +/** Validate the assembled application, including native Office conversion outside ASAR. */ import { join } from 'node:path' -import { resolveDesktopTargetBuildPaths } from './desktop-build-paths.mjs' +import { parseArgs } from 'node:util' +import { resolveDesktopBuildTarget, resolveDesktopTargetBuildPaths } from './desktop-build-paths.mjs' import { readDesktopRuntime, verifyDesktopRuntime } from '../src/runtime-tree.ts' import { verifyWindowsCode } from './windows-runtime-signature.mjs' import { smokePreparedRuntime } from './smoke-prepared-runtime.ts' +import { resolveDesktopPackageTarget } from './package-target.ts' const paths = resolveDesktopTargetBuildPaths() -const application = join(paths.artifacts, 'win-unpacked') -const descriptor = await verifyDesktopRuntime(paths.dsh, readDesktopRuntime(paths.dsh).release.version) -await verifyWindowsCode(application) -await smokePreparedRuntime(join(application, 'resources', 'app.asar', 'dsh'), - join(application, 'DeepSeek Harness.exe'), join(application, 'resources', 'runtime'), descriptor) +const { values } = parseArgs({ options: { unsigned: { type: 'boolean', default: false } }, allowPositionals: false }) +const target = resolveDesktopBuildTarget() +const windows = target === 'win-x64' +if (values.unsigned && !windows) throw new Error('desktop smoke: unsigned artifacts require Windows') +const artifacts = values.unsigned ? paths.unsignedArtifacts : paths.artifacts +const application = windows ? join(artifacts, 'win-unpacked') + : join(artifacts, target === 'mac-arm64' ? 'mac-arm64' : 'mac', 'DeepSeek Harness.app', 'Contents') +const resources = join(application, windows ? 'resources' : 'Resources') +const executable = windows ? join(application, 'DeepSeek Harness.exe') : join(application, 'MacOS', 'DeepSeek Harness') +const descriptor = await verifyDesktopRuntime(paths.dsh, readDesktopRuntime(paths.dsh).release.version, + resolveDesktopPackageTarget(target)) +if (windows && !values.unsigned) await verifyWindowsCode(application) +await smokePreparedRuntime(join(resources, 'app.asar', 'dsh'), executable, join(resources, 'runtime'), descriptor) diff --git a/apps/desktop/scripts/smoke-prepared-runtime.ts b/apps/desktop/scripts/smoke-prepared-runtime.ts index 94f0c1e55e..697f52708e 100644 --- a/apps/desktop/scripts/smoke-prepared-runtime.ts +++ b/apps/desktop/scripts/smoke-prepared-runtime.ts @@ -4,10 +4,12 @@ import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join, resolve } from 'node:path' import { promisify } from 'node:util' +import { runtimeArchivePath } from '../../desktop-host/src/office-engine.ts' import { desktopNodeEnvironment } from '../src/node-environment.ts' import type { DesktopRuntimeDescriptor } from '../src/runtime-tree.ts' import { scrubWindowsSigningEnvironment } from './windows-sign.mjs' import { smokeDesktopRuntime } from './smoke-runtime.ts' +import { verifyRuntimeArchive } from './verify-runtime-archive.ts' /** * Exercise real runtime files and Host composition, including an ASAR root when packaged. @@ -15,7 +17,7 @@ import { smokeDesktopRuntime } from './smoke-runtime.ts' * @param node Target Electron executable. * @param resourcesRuntime External runtime directory beside the archive. * @param descriptor Runtime descriptor already verified against the selected target, which may differ from the build host. - * @returns Resolves after payload checks, Host readiness and teardown. + * @returns Resolves after archive integrity, payload checks, Host startup, Office conversion and teardown. */ export async function smokePreparedRuntime( root: string, node: string, resourcesRuntime: string, descriptor: DesktopRuntimeDescriptor, @@ -24,6 +26,8 @@ export async function smokePreparedRuntime( const environment = { ...scrubWindowsSigningEnvironment(process.env), NODE_OPTIONS: '', NARB_NATIVE_CACHE_DIR: cache, NARB_DISABLE_NATIVE_CACHE: '0' } try { + const archive = runtimeArchivePath(root) + if (archive !== undefined) await verifyRuntimeArchive(archive, descriptor) const { stdout } = await promisify(execFile)(node, [ '--expose-internals', resolve(import.meta.dirname, '../tests/fixtures/runtime-payload-smoke.mjs'), root, resourcesRuntime, ], { timeout: 120_000, windowsHide: true, diff --git a/apps/desktop/scripts/smoke-runtime.ts b/apps/desktop/scripts/smoke-runtime.ts index 06768be372..19b26fac1a 100644 --- a/apps/desktop/scripts/smoke-runtime.ts +++ b/apps/desktop/scripts/smoke-runtime.ts @@ -3,17 +3,22 @@ import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' +import { execFile } from 'node:child_process' +import { promisify } from 'node:util' +import { fileURLToPath } from 'node:url' +import { readPrimaryRuntime, workspaceDependencyPaths } from '../../../packages/skill/tool-workspace-dependencies/src/index.ts' import { DesktopHostProcess } from '../src/host-process.ts' import { createPluginProfile } from '../src/project-manager.ts' import type { DesktopRuntimeDescriptor } from '../src/runtime-tree.ts' /** - * Prove the final resource tree boots and serves its matching Web frontend. + * Check Host startup, its matching frontend, external plugins and real Office-to-PDF conversion. * @param root - Materialized dsh resources. * @param node - Prepared target Electron executable. * @param runtime - Verified resource descriptor. * @param environment - Credential-scrubbed build environment and private native cache. * @param resourcesRuntime - Bundled interpreters outside the application archive. + * @returns Resolves after checks and teardown; rejects on a check or teardown failure. */ export async function smokeDesktopRuntime( root: string, node: string, runtime: DesktopRuntimeDescriptor, environment: NodeJS.ProcessEnv, resourcesRuntime: string, @@ -29,6 +34,13 @@ export async function smokeDesktopRuntime( const pluginName = 'desktop-runtime-smoke-plugin' const plugin = join(profile, 'node_modules', pluginName) mkdirSync(plugin, { recursive: true }) + const primary = join(resourcesRuntime, 'primary-runtime') + const dependencies = workspaceDependencyPaths(primary, await readPrimaryRuntime(primary)) + await promisify(execFile)(dependencies.python, ['-I', '-B', + fileURLToPath(new URL('../tests/fixtures/office-conversion-inputs.py', import.meta.url)), home], + { env: environment, timeout: 120_000, windowsHide: true }) + const inputs = ['docx', 'xlsx', 'pptx'].map(extension => ({ extension, + bytes: readFileSync(join(home, `input.${extension}`)).toString('base64') })) const cordis = runtime.sharedPackages.find(entry => entry.name === '@deepseek-ai/cordis') if (cordis === undefined) throw new Error('desktop runtime: missing shared Cordis package') writeFileSync(join(plugin, 'package.json'), JSON.stringify({ @@ -37,13 +49,29 @@ export async function smokeDesktopRuntime( })) writeFileSync(join(plugin, 'index.js'), ` import { Context } from '@deepseek-ai/cordis' +import { inspect } from 'node:util' export function apply(ctx) { if (!(ctx instanceof Context)) throw new Error('desktop runtime: external plugin loaded another Cordis instance') ctx.effect(() => ctx.webServer.register({ kind: 'exact', path: '/desktop-smoke', handler(_request, response) { response.end('plugin route ready') } })) + for (const input of ${JSON.stringify(inputs)}) { + ctx.effect(() => ctx.webServer.register({ kind: 'exact', path: '/desktop-smoke-office/' + input.extension, + async handler(_request, response) { + try { + const bytes = Buffer.from(input.bytes, 'base64') + const result = await ctx.officeToPdf.convert({ extension: input.extension, priority: 'foreground', + source: { key: 'desktop-smoke-' + input.extension, version: 'fixture', bytes: bytes.length, + async read() { return { bytes, version: 'fixture' } } } }) + response.end(Buffer.from(result.pdf)) + } catch (error) { + response.statusCode = 500 + response.end(inspect(error, { depth: 5 })) + } + } })) + } } `) - writeFileSync(join(plugin, 'bundle.yml'), '- insert:\n - id: desktop-runtime-smoke-plugin\n name: desktop-runtime-smoke-plugin\n inject: [webServer]\n') + writeFileSync(join(plugin, 'bundle.yml'), '- insert:\n - id: desktop-runtime-smoke-plugin\n name: desktop-runtime-smoke-plugin\n inject: [webServer, officeToPdf]\n') const manifest = JSON.parse(readFileSync(join(profile, 'package.json'), 'utf8')) as { dependencies: Record dsh: { profile: { bundles: string[] } } @@ -64,6 +92,18 @@ export function apply(ctx) { } const pluginResponse = await fetch(new URL('/desktop-smoke', ready.url), { headers: { cookie } }) if (await pluginResponse.text() !== 'plugin route ready') throw new Error('desktop runtime: plugin HTTP route failed') + for (const { extension } of inputs) { + const converted = await fetch(new URL(`/desktop-smoke-office/${extension}`, ready.url), { + headers: { cookie }, signal: AbortSignal.timeout(120_000), + }) + if (!converted.ok) throw new Error(`desktop runtime: ${extension} conversion failed: ${await converted.text()}`) + const pdf = Buffer.from(await converted.arrayBuffer()) + if (!/^%PDF-\d\.\d/u.test(pdf.subarray(0, 8).toString()) + || !pdf.subarray(-1024).toString().trimEnd().endsWith('%%EOF')) { + throw new Error(`desktop runtime: invalid ${extension} PDF output`) + } + } + console.log('desktop runtime: DOCX, XLSX, PPTX to PDF passed') } finally { clearTimeout(timer) await host.stop() diff --git a/apps/desktop/scripts/verify-runtime-archive.ts b/apps/desktop/scripts/verify-runtime-archive.ts new file mode 100644 index 0000000000..9ee06a49ed --- /dev/null +++ b/apps/desktop/scripts/verify-runtime-archive.ts @@ -0,0 +1,53 @@ +/** Verify runtime bytes and executable permissions using ASAR records and physical unpacked files. */ +import { createHash } from 'node:crypto' +import { lstat, readdir } from 'node:fs/promises' +import { join } from 'node:path' +import { readAsar, type Node } from 'app-builder-lib/out/asar/asar.js' +import type { DesktopRuntimeDescriptor, DesktopRuntimeFile } from '../src/runtime-tree.ts' + +/** + * Compare the complete archived dsh tree with the sealed preparation inventory. + * @param archivePath - Application ASAR file beside its unpacked directory. + * @param expected - Verified preparation descriptor, including its complete file inventory. + * @returns Resolves when bytes, file membership and meaningful executable permissions match. + */ +export async function verifyRuntimeArchive(archivePath: string, expected: DesktopRuntimeDescriptor): Promise { + const archive = await readAsar(archivePath) + const descriptor = await archive.readFile(join('dsh', 'desktop-runtime.json')) + if (!descriptor.equals(Buffer.from(`${JSON.stringify(expected, undefined, 2)}\n`))) { + throw new Error('desktop runtime: archived descriptor differs from preparation') + } + const files: DesktopRuntimeFile[] = [] + const unpacked = new Set() + async function visit(node: Node, path: string): Promise { + if (node.link !== undefined) throw new Error(`desktop runtime: unexpected ASAR link ${path}`) + if (node.files !== undefined) { + for (const [name, child] of Object.entries(node.files)) await visit(child, path === '' ? name : `${path}/${name}`) + return + } + const name = join('dsh', ...path.split('/')) + const physical = node.unpacked === true ? await lstat(join(`${archivePath}.unpacked`, name)) : undefined + if (physical !== undefined && !physical.isFile()) throw new Error(`desktop runtime: unexpected unpacked entry ${path}`) + if (node.unpacked === true) unpacked.add(join(`${archivePath}.unpacked`, name)) + if (path === 'desktop-runtime.json') return + const body = await archive.readFile(name) + // ASAR stores only owner-execute; group/other-only executable files fail the inventory comparison. + const executable = process.platform !== 'win32' && (physical !== undefined + ? (physical.mode & 0o111) !== 0 + : node.executable === true) + files.push({ path, bytes: body.byteLength, sha256: createHash('sha256').update(body).digest('hex'), executable }) + } + await visit(archive.getFile('dsh', false), '') + const entries = await readdir(join(`${archivePath}.unpacked`, 'dsh'), { recursive: true, withFileTypes: true }) + .catch((error: NodeJS.ErrnoException) => { + if (error.code === 'ENOENT' && unpacked.size === 0) return [] + throw error + }) + for (const entry of entries) { + if (entry.isDirectory()) continue + const path = join(entry.parentPath, entry.name) + if (!entry.isFile() || !unpacked.has(path)) throw new Error(`desktop runtime: unexpected unpacked entry ${path}`) + } + files.sort((a, b) => a.path < b.path ? -1 : a.path > b.path ? 1 : 0) + if (JSON.stringify(files) !== JSON.stringify(expected.files)) throw new Error('desktop runtime: ASAR integrity verification failed') +} diff --git a/apps/desktop/tests/desktop-build-paths.spec.ts b/apps/desktop/tests/desktop-build-paths.spec.ts index fdbab81b60..a163885f6d 100644 --- a/apps/desktop/tests/desktop-build-paths.spec.ts +++ b/apps/desktop/tests/desktop-build-paths.spec.ts @@ -13,6 +13,7 @@ describe('desktop build paths', () => { const mutableKeys = [ 'root', 'artifacts', + 'unsignedArtifacts', 'runtime', 'packageSet', 'dsh', diff --git a/apps/desktop/tests/fixtures/office-conversion-inputs.py b/apps/desktop/tests/fixtures/office-conversion-inputs.py new file mode 100644 index 0000000000..c1b86ac10e --- /dev/null +++ b/apps/desktop/tests/fixtures/office-conversion-inputs.py @@ -0,0 +1,20 @@ +"""Create one-page Office inputs with the packaged authoring runtime.""" +from pathlib import Path +import sys +from docx import Document +from openpyxl import Workbook +from pptx import Presentation +from pptx.util import Inches + +root = Path(sys.argv[1]) +document = Document() +document.add_paragraph("Desktop Office conversion 42") +document.save(root / "input.docx") +workbook = Workbook() +workbook.active.append(["Desktop Office conversion", 42]) +workbook.save(root / "input.xlsx") +workbook.close() +presentation = Presentation() +slide = presentation.slides.add_slide(presentation.slide_layouts[6]) +slide.shapes.add_textbox(Inches(1), Inches(1), Inches(6), Inches(1)).text = "Desktop Office conversion 42" +presentation.save(root / "input.pptx") diff --git a/apps/desktop/tests/fixtures/runtime-payload-smoke.mjs b/apps/desktop/tests/fixtures/runtime-payload-smoke.mjs index 41f6b4223b..bb015d3dff 100644 --- a/apps/desktop/tests/fixtures/runtime-payload-smoke.mjs +++ b/apps/desktop/tests/fixtures/runtime-payload-smoke.mjs @@ -16,8 +16,6 @@ assert.equal(process.versions.node, descriptor.release.nodeVersion, 'Run with th assert.equal(process.platform, descriptor.platform) assert.equal(process.arch, descriptor.arch) const resourcesRuntime = process.argv[3] ?? join(dirname(root), 'runtime') -const { verifyDesktopRuntime } = await import('../../lib/types/runtime-tree.js') -await verifyDesktopRuntime(root, descriptor.release.version, { platform: process.platform, arch: process.arch }) const requireRuntime = createRequire(join(root, 'package.json')) const scratch = mkdtempSync(join(tmpdir(), 'dsh-runtime-payload-')) @@ -38,9 +36,11 @@ console.log('desktop-node-script-ok') `) const environment = Object.fromEntries(Object.entries(process.env).filter(([name]) => /^(?:systemroot|windir|comspec)$/iu.test(name))) const systemBin = process.platform === 'win32' ? join(process.env.SystemRoot, 'System32') : '/usr/bin:/bin' + // This dependency-free fixture checks script launch, without pnpm's implicit install and update-network check. const output = execFileSync(process.execPath, ['--expose-internals', pnpm, 'run', 'check'], { cwd: scratch, encoding: 'utf8', timeout: 45_000, - env: { ...environment, ELECTRON_RUN_AS_NODE: '1', DSH_DESKTOP_NODE_EXECUTABLE: process.execPath, + env: { ...environment, pnpm_config_verify_deps_before_run: 'false', + ELECTRON_RUN_AS_NODE: '1', DSH_DESKTOP_NODE_EXECUTABLE: process.execPath, PATH: `${bin}${delimiter}${systemBin}`, HOME: scratch, USERPROFILE: scratch, TMP: scratch, TEMP: scratch, TMPDIR: scratch }, }) assert.match(output, /desktop-node-script-ok/u) diff --git a/apps/desktop/tests/macos-cache-policy.spec.ts b/apps/desktop/tests/macos-cache-policy.spec.ts index 7bf9beaff0..cca57085e2 100644 --- a/apps/desktop/tests/macos-cache-policy.spec.ts +++ b/apps/desktop/tests/macos-cache-policy.spec.ts @@ -51,7 +51,7 @@ it('includes certificate, identifier and entitlement contents in the cache polic const create = macOSCachePolicy('probe') const original = create('com.example.file', identity).policy expect(create('com.other.file', identity).policy).not.toBe(original) - expect(create('com.example.file', identity, join(import.meta.dirname, '../scripts/node-entitlements.plist')).policy).not.toBe(original) + expect(create('com.example.file', identity, join(import.meta.dirname, '../scripts/jit-entitlements.plist')).policy).not.toBe(original) certificate = 'renewed-certificate' expect(macOSCachePolicy('probe')('com.example.file', identity).policy).not.toBe(original) }) diff --git a/apps/desktop/tests/macos-runtime.spec.ts b/apps/desktop/tests/macos-runtime.spec.ts index 7d4025f530..5ca9908c28 100644 --- a/apps/desktop/tests/macos-runtime.spec.ts +++ b/apps/desktop/tests/macos-runtime.spec.ts @@ -47,14 +47,18 @@ it('awaits other signers before rejecting and permitting output cleanup', async expect(verifyMacOSRuntimeCode).toHaveBeenCalledWith(join(path, 'b.node'), identity) }) -it('grants JIT only to the standalone Node interpreter', async () => { +it('grants JIT only to the standalone Node interpreter and native Office helpers', async () => { const path = root() mkdirSync(join(path, 'dependencies/node/bin'), { recursive: true }) const node = join(path, 'dependencies/node/bin/node') const addon = join(path, 'addon.node') - for (const file of [node, addon]) writeFileSync(file, Buffer.from('cffaedfe00000000', 'hex')) + const helpers = ['arm64', 'x64'].map(arch => join(path, 'node_modules/@deepseek-ai', `libreoffice-kit-darwin-${arch}`, 'bin/libreoffice-kit')) + for (const helper of helpers) mkdirSync(join(helper, '..'), { recursive: true }) + for (const file of [node, addon, ...helpers]) writeFileSync(file, Buffer.from('cffaedfe00000000', 'hex')) await signMacOSRuntime(path, 'com.example.app', identity) - expect(signMacOSRuntimeCode).toHaveBeenCalledWith(node, expect.any(String), identity, - join(import.meta.dirname, '../scripts/node-entitlements.plist')) + for (const file of [node, ...helpers]) { + expect(signMacOSRuntimeCode).toHaveBeenCalledWith(file, expect.any(String), identity, + join(import.meta.dirname, '../scripts/jit-entitlements.plist')) + } expect(signMacOSRuntimeCode).toHaveBeenCalledWith(addon, expect.any(String), identity, undefined) }) diff --git a/apps/desktop/tests/package-target-stages.spec.ts b/apps/desktop/tests/package-target-stages.spec.ts index 176c9c86b5..41e1ee8d4e 100644 --- a/apps/desktop/tests/package-target-stages.spec.ts +++ b/apps/desktop/tests/package-target-stages.spec.ts @@ -99,6 +99,35 @@ it.each(['--unsigned', '--prepare-only'])('keeps %s hardware-free and creates no expect(withWindowsSigningStage).not.toHaveBeenCalled() for (const call of run.run.mock.calls) expect(call[3].env).not.toHaveProperty('DSH_DESKTOP_WINDOWS_TOKEN_PIN') expect(writeFileSync).not.toHaveBeenCalled() + expect(stages.includes('exec tsx scripts/smoke-packaged-runtime.ts --unsigned')).toBe(mode === '--unsigned') +}) + +it('checks the assembled macOS runtime before notarizing and recording the release', async () => { + const { run, stages } = supervisor() + vi.mocked(packageMacOSArtifacts).mockImplementationOnce(async () => { + expect(stages.at(-1)).toBe('exec tsx scripts/smoke-packaged-runtime.ts') + expect(writeFileSync).not.toHaveBeenCalled() + }) + await packageTarget(parseDesktopPackageInvocation(['mac-arm64'], 'darwin', 'arm64'), environment, run) + expect(packageMacOSArtifacts).toHaveBeenCalledOnce() + expect(writeFileSync).toHaveBeenCalledOnce() +}) + +it.each([false, true])('refuses macOS notarization and release records after an assembled-runtime failure (directory=%s)', async (directory) => { + const { run } = supervisor('exec tsx scripts/smoke-packaged-runtime.ts') + await expect(packageTarget(parseDesktopPackageInvocation(['mac-arm64', ...(directory ? ['--dir'] : [])], 'darwin', 'arm64'), environment, run)) + .rejects.toThrow('stage refused') + expect(withMacOSNotarizationProxy).not.toHaveBeenCalled() + expect(packageMacOSArtifacts).not.toHaveBeenCalled() + expect(writeFileSync).not.toHaveBeenCalled() +}) + +it('checks macOS directory packages without writing a release record', async () => { + const { run, stages } = supervisor() + await packageTarget(parseDesktopPackageInvocation(['mac-arm64', '--dir'], 'darwin', 'arm64'), { ...environment, APPLE_KEYCHAIN_PROFILE: 'fixture' }, run) + expect(stages.at(-1)).toBe('exec tsx scripts/smoke-packaged-runtime.ts') + expect(packageMacOSArtifacts).not.toHaveBeenCalled() + expect(writeFileSync).not.toHaveBeenCalled() }) it.each([undefined, '2'])('passes macOS pack concurrency %s only to workspace packing and download routing only to download stages', async (concurrency) => { diff --git a/apps/desktop/tests/windows-asar-unpack.spec.ts b/apps/desktop/tests/windows-asar-unpack.spec.ts index 2d5c79563b..5cbe410105 100644 --- a/apps/desktop/tests/windows-asar-unpack.spec.ts +++ b/apps/desktop/tests/windows-asar-unpack.spec.ts @@ -1,4 +1,4 @@ -import { lstat, mkdir, mkdtemp, readFile, readdir, rm, writeFile } from 'node:fs/promises' +import { chmod, lstat, mkdir, mkdtemp, readFile, readdir, rm, symlink, writeFile } from 'node:fs/promises' import { createRequire } from 'node:module' import { tmpdir } from 'node:os' import { dirname, join } from 'node:path' @@ -10,6 +10,9 @@ import { readAsar } from 'app-builder-lib/out/asar/asar.js' import { afterEach, expect, it, vi } from 'vitest' import { prepareWindowsAsarUnpack, verifyWindowsAsarUnpack } from '../scripts/windows-asar-unpack.mjs' import { createElectronBuilderConfig } from '../scripts/electron-builder-config.mjs' +import { verifyRuntimeArchive } from '../scripts/verify-runtime-archive.ts' +import { DESKTOP_HOST_PROTOCOL_VERSION } from '../src/host-protocol.ts' +import { inventoryDesktopRuntime, type DesktopRuntimeDescriptor } from '../src/runtime-tree.ts' vi.mock('../scripts/windows-sign.mjs', async importOriginal => ({ ...await importOriginal(), @@ -184,3 +187,113 @@ it.each([true, false])('validates the real builder hook for unsigned=%s', async await expect(config.afterSign(input.context)).rejects.toThrow('PE bytes changed') } }) + +it.each([false, true])('keeps the complete Office engine outside ASAR with external source=%s', async (external) => { + const input = await fixture(external) + const engine = join('node_modules', '@deepseek-ai', 'libreoffice-kit-win32-x64') + const files = ['package.json', 'prebuilds.json', 'bin/libreoffice-kit', 'program/registry/main.xcd'] + for (const file of files) { + const path = join(input.source, engine, file) + await mkdir(dirname(path), { recursive: true }) + await writeFile(path, '{}') + } + const wasm = join(input.source, 'node_modules/@deepseek-ai/libreoffice-kit-wasm/package.json') + await mkdir(dirname(wasm), { recursive: true }) + await writeFile(wasm, '{}') + const config = createElectronBuilderConfig({ + DSH_DESKTOP_APP_ID: 'com.example.office', DSH_DESKTOP_MANDATORY_UPDATE_TEST_ORIGIN: 'https://policy.example.com', + DSH_DESKTOP_MANDATORY_UPDATE_CONFIG: JSON.stringify({ allowedAuthOrigins: ['https://login.example.com'] }), + DSH_DESKTOP_UNSIGNED: '1', + }, 'win32', 'x64', input.source) + input.config.asarUnpack = [...config.asarUnpack] + await config.beforePack(input.context) + await packageFixture(input) + const archive = await readAsar(join(input.resources, 'app.asar')) + expect(archive.getFile(join('dsh', 'node_modules', '@deepseek-ai', 'libreoffice-kit-wasm', 'package.json')).unpacked).not.toBe(true) + for (const file of files) { + expect(archive.getFile(join('dsh', engine, file), false).unpacked).toBe(true) + expect(await readFile(join(input.resources, 'app.asar.unpacked', 'dsh', engine, file), 'utf8')).toBe('{}') + } +}) + +async function seal(input: Awaited>): Promise { + const descriptor: DesktopRuntimeDescriptor = { + schemaVersion: 1, + release: { schemaVersion: 1, version: '1.0.0', hostProtocolVersion: DESKTOP_HOST_PROTOCOL_VERSION, + nodeVersion: process.versions.node, pnpmVersion: '11.7.0' }, + platform: process.platform, arch: process.arch, + sharedPackages: [{ name: 'foo', version: '1.0.0', path: 'node_modules/foo' }], + files: inventoryDesktopRuntime(input.source), + } + await writeFile(join(input.source, 'desktop-runtime.json'), `${JSON.stringify(descriptor, undefined, 2)}\n`) + return descriptor +} + +it('verifies archived bytes against the preparation descriptor', async () => { + const input = await fixture(false) + const expected = await seal(input) + await packageFixture(input) + const archive = join(input.resources, 'app.asar') + await expect(verifyRuntimeArchive(archive, expected)).resolves.toBeUndefined() + await writeFile(join(input.resources, 'app.asar.unpacked/dsh/node_modules/foo/native.node'), 'tampered') + await expect(verifyRuntimeArchive(archive, expected)).rejects.toThrow('ASAR integrity') +}) + +it.each(['sharedPackages', 'release', 'files'] as const)('rejects changed archived descriptor %s', async (field) => { + const input = await fixture(false) + const expected = await seal(input) + const changed = { ...expected, [field]: field === 'release' ? { ...expected.release, version: '2.0.0' } : [] } + await writeFile(join(input.source, 'desktop-runtime.json'), `${JSON.stringify(changed, undefined, 2)}\n`) + await packageFixture(input) + await expect(verifyRuntimeArchive(join(input.resources, 'app.asar'), expected)).rejects.toThrow('archived descriptor differs') +}) + +it.each(['missing', 'extra'] as const)('rejects %s archived files', async (state) => { + const input = await fixture(false) + const expected = await seal(input) + if (state === 'missing') await rm(join(input.source, 'node_modules/foo/companion.json')) + else await writeFile(join(input.source, 'extra.json'), '{}') + await packageFixture(input) + await expect(verifyRuntimeArchive(join(input.resources, 'app.asar'), expected)).rejects.toThrow('ASAR integrity') +}) + +it.each(['directory', 'extra'] as const)('rejects an unpacked %s without a matching file record', async (state) => { + const input = await fixture(false) + const expected = await seal(input) + await packageFixture(input) + const root = join(input.resources, 'app.asar.unpacked/dsh') + if (state === 'directory') { + const file = join(root, 'node_modules/foo/native.node') + await rm(file) + await mkdir(file) + } else await writeFile(join(root, 'extra.json'), '{}') + await expect(verifyRuntimeArchive(join(input.resources, 'app.asar'), expected)).rejects.toThrow('unexpected unpacked entry') +}) + +// Windows file symlinks require privileges unavailable on some CI runners. +it.skipIf(process.platform === 'win32')('rejects ASAR links', async () => { + const input = await fixture(false) + const expected = await seal(input) + await symlink('companion.json', join(input.source, 'node_modules/foo/alias.json')) + await packageFixture(input) + await expect(verifyRuntimeArchive(join(input.resources, 'app.asar'), expected)).rejects.toThrow('unexpected ASAR link') +}) + +it.skipIf(process.platform === 'win32')('checks packed executable records and physical unpacked permissions', async () => { + const input = await fixture(false) + for (const name of ['extensionless', 'native.node']) await chmod(join(input.source, 'node_modules/foo', name), 0o755) + const expected = await seal(input) + await packageFixture(input) + const archive = join(input.resources, 'app.asar') + await expect(verifyRuntimeArchive(archive, expected)).resolves.toBeUndefined() + await chmod(join(input.resources, 'app.asar.unpacked/dsh/node_modules/foo/native.node'), 0o644) + await expect(verifyRuntimeArchive(archive, expected)).rejects.toThrow('ASAR integrity') +}) + +it.skipIf(process.platform === 'win32')('rejects group-only executable files whose ASAR record loses that permission', async () => { + const input = await fixture(false) + await chmod(join(input.source, 'node_modules/foo/extensionless'), 0o654) + const expected = await seal(input) + await packageFixture(input) + await expect(verifyRuntimeArchive(join(input.resources, 'app.asar'), expected)).rejects.toThrow('ASAR integrity') +}) diff --git a/apps/web/package.json b/apps/web/package.json index 07823f0a29..75b42478c1 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -40,6 +40,7 @@ "@deepseek-ai/dsh-client-web": "workspace:^", "@deepseek-ai/dsh-cmdline": "workspace:^", "@deepseek-ai/dsh-pwsh-local": "workspace:^", + "@deepseek-ai/dsh-experimental-agent-team": "workspace:^", "@deepseek-ai/dsh-experimental-webworker-packer": "workspace:^", "@deepseek-ai/dsh-experimental-webworker-runtime": "workspace:^", "@deepseek-ai/dsh-remote-mock": "workspace:^", diff --git a/apps/web/tests/agent-team-panel.e2e.ts b/apps/web/tests/agent-team-panel.e2e.ts index ac4e484db0..3e5ad5e0c2 100644 --- a/apps/web/tests/agent-team-panel.e2e.ts +++ b/apps/web/tests/agent-team-panel.e2e.ts @@ -8,6 +8,7 @@ import { chromium } from 'playwright' import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest' import * as yaml from 'js-yaml' import { entryListSchema } from '@deepseek-ai/cordis-plugin-include' +import type {} from '@deepseek-ai/dsh-experimental-agent-team' import { createMessage, createUserMessage } from '@deepseek-ai/dsh-llm' import { assertFixtureInventory, captureStableAria, compareOrRefreshGolden, @@ -79,26 +80,93 @@ describe('web e2e: Agent Teams panel', () => { await scaffold?.close() }) - it('loads the roster and creates one shared task through generated Remote', async () => { + it('displays agent-owned tasks through a read-only board', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-agent-team-panel')) - const action = page.locator('[data-team-action]') - await action.getByRole('button', { name: /Agent Team/iu }).click() + await page.locator('[data-team-action]').getByRole('button', { name: /Agent Team/iu }).click() + const action = page.getByRole('dialog', { name: 'Agent Team', exact: true }) await action.getByText('No shared tasks yet').waitFor() await action.getByText('lead').waitFor() - await action.getByRole('button', { name: 'New task' }).click() - await action.getByPlaceholder('Task subject').fill('Browser task') - await action.getByPlaceholder('Task description').fill('Created through the assembled browser') - await action.getByPlaceholder(/Write scopes/iu).fill('src/web') - await action.getByRole('button', { name: 'Save' }).click() - await action.getByText('Browser task').waitFor() + expect(await action.getByRole('button', { name: 'New task' }).count()).toBe(0) - const snapshot = await captureStableAria(page, '[data-team-action]', scaffold.workspaceCwd) + const agent = scaffold.ctx.agents.list()[0]! + const task = await scaffold.ctx.agentTeams.createTask(agent, { + subject: 'Agent task', description: 'Created by the Team Lead', writeScopes: ['src/web'], + }) + await action.getByRole('button', { name: 'Refresh Team' }).click() + await action.getByText('Agent task', { exact: true }).waitFor() + await action.getByText('Owner: Unowned', { exact: true }).waitFor() + await scaffold.ctx.agentTeams.updateTask(agent, { + taskId: task.id, expectedRevision: task.revision, action: 'claim', + }) + await action.getByRole('button', { name: 'Refresh Team' }).click() + await action.getByText('In progress', { exact: true }).waitFor() + await action.getByText('Owner: lead', { exact: true }).waitFor() + expect(await action.getByRole('button', { name: /^(New task|Edit|Complete|Reopen|Delete)$/u }).count()).toBe(0) + expect(await action.locator('input, select, textarea').count()).toBe(0) + + const snapshot = await captureStableAria(page, '[data-team-panel]', scaffold.workspaceCwd) await compareOrRefreshGolden(PANEL_EXPECTED, snapshot, MODE) expect(tripwire.pageErrors).toEqual([]) expect(tripwire.warnings).toEqual([]) + await action.getByRole('button', { name: 'Close' }).click() }, 60_000) + it('keeps the panel inside the viewport and closes on outside click or Escape', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-agent-team-panel-keyboard')) + const panel = page.getByRole('dialog', { name: 'Agent Team', exact: true }) + const trigger = page.locator('[data-team-action]').getByRole('button', { name: /Agent Team/iu }) + const viewport = page.viewportSize()! + await trigger.click() + await panel.getByText('lead', { exact: true }).waitFor() + try { + for (const size of [{ width: 760, height: 540 }, { width: 600, height: 420 }]) { + await page.setViewportSize(size) + await expect.poll(() => panel.evaluate((element) => { + const rect = element.getBoundingClientRect() + return element.parentElement === document.body + && rect.left >= 16 && rect.top >= 16 + && rect.right <= window.innerWidth - 16 && rect.bottom <= window.innerHeight - 16 + && [[rect.left + 12, rect.top + 12], [rect.right - 12, rect.bottom - 12]] + .every(([x, y]) => element.contains(document.elementFromPoint(x!, y!))) + })).toBe(true) + } + await panel.getByText('Shared tasks', { exact: true }).click() + expect(await panel.count()).toBe(1) + await page.mouse.click(2, 2) + await panel.waitFor({ state: 'detached' }) + await page.setViewportSize(viewport) + await trigger.focus() + await page.keyboard.press('Enter') + await panel.getByRole('heading', { name: 'Shared tasks' }).waitFor() + expect(await panel.evaluate(element => element === document.activeElement)).toBe(true) + await page.keyboard.press('Tab') + expect(await panel.getByRole('button', { name: 'Refresh Team' }) + .evaluate(element => element === document.activeElement)).toBe(true) + await page.keyboard.press('Tab') + expect(await panel.getByRole('button', { name: 'Close', exact: true }) + .evaluate(element => element === document.activeElement)).toBe(true) + await page.keyboard.press('Escape') + await panel.waitFor({ state: 'detached' }) + expect(await trigger.evaluate(element => element === document.activeElement)).toBe(true) + await page.keyboard.press('Enter') + await panel.waitFor() + await page.keyboard.press('Tab') + await page.keyboard.press('Tab') + await page.keyboard.press('Enter') + await panel.waitFor({ state: 'detached' }) + expect(await trigger.evaluate(element => element === document.activeElement)).toBe(true) + await page.keyboard.press('Enter') + await panel.waitFor() + const outside = page.getByRole('button', { name: 'Settings', exact: true }) + await outside.focus() + await panel.waitFor({ state: 'detached' }) + expect(await outside.evaluate(element => element === document.activeElement)).toBe(true) + } finally { + await page.setViewportSize(viewport) + } + }) + it.skipIf(MODE === 'record')('keeps the fixture inventory closed', async () => { await assertFixtureInventory(SNAPSHOT_DIR, ['task.expected.md']) }) diff --git a/apps/web/tests/changed-files-turn.e2e.ts b/apps/web/tests/changed-files-turn.e2e.ts index a5b45a3511..a3691df3c2 100644 --- a/apps/web/tests/changed-files-turn.e2e.ts +++ b/apps/web/tests/changed-files-turn.e2e.ts @@ -32,6 +32,28 @@ async function seedRepository(cwd: string): Promise { git('commit', '-q', '-m', 'seed') } +/** Sample the real opacity transition at its midpoint without depending on wall-clock scheduling. */ +function hoverOpacityTransition(page: Page, closing: boolean): Promise<{ duration: number; opacity: number }> { + return page.evaluate(closing => new Promise((resolve) => { + const observer = new MutationObserver(() => { + const card = document.querySelector('[data-changes-hover-preview]')?.parentElement + if (card === undefined || card === null || card.hasAttribute('data-closing') !== closing) return + const initialOpacity = Number(getComputedStyle(card).opacity) + const animation = card.getAnimations().find(animation => animation instanceof CSSTransition && animation.transitionProperty === 'opacity') + observer.disconnect() + if (animation === undefined) { resolve({ duration: 0, opacity: initialOpacity }); return } + animation.pause() + animation.currentTime = 50 + const opacity = Number(getComputedStyle(card).opacity) + const duration = Number(animation.effect!.getTiming().duration) + animation.finish() + resolve({ duration, opacity }) + }) + observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['data-closing'] }) + if (closing) window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true })) + }), closing) +} + describe('web e2e: a git workspace turn ends with its changed files', () => { let scaffold: WebScaffold let browser: Browser @@ -180,6 +202,76 @@ describe('web e2e: a git workspace turn ends with its changed files', () => { expect(tripwire.warnings).toEqual([]) }) + it('previews a single column with a scrollable path and no file notes or hunk headers', async () => { + const card = page.locator('[data-changed-files]') + const row = card.getByRole('button', { name: '查看 notes.txt 的改动' }) + const preview = page.locator('[data-changes-hover-preview]') + const entrance = hoverOpacityTransition(page, false) + await row.hover() + expect(await entrance).toEqual({ duration: 100, opacity: 0.5 }) + await preview.locator('[data-review-view="unified"]').waitFor({ state: 'visible' }) + expect(await preview.locator('[data-diff-side]').count()).toBe(0) + expect(await preview.locator('[data-diff-hunk-header]').isVisible()).toBe(false) + const geometry = await preview.evaluate((element) => { + const popup = element.parentElement!.getBoundingClientRect() + const card = document.querySelector('[data-changed-files]')!.getBoundingClientRect() + return { inset: popup.left - card.left, width: popup.width, cardWidth: card.width, height: popup.height } + }) + expect(geometry.inset).toBe(24) + expect(geometry.width).toBe(geometry.cardWidth - 48) + expect(geometry.height).toBeLessThanOrEqual(420) + const path = preview.locator('[data-changes-preview-path]') + expect(await row.evaluate(element => document.getElementById(element.getAttribute('aria-describedby')!)?.textContent)).toBe(await path.textContent()) + expect(await path.getAttribute('tabindex')).toBeNull() + const pathStyle = await path.evaluate((element) => { + const style = getComputedStyle(element) + return { family: style.fontFamily, overflow: style.overflowX, whiteSpace: style.whiteSpace, ellipsis: style.textOverflow } + }) + expect(pathStyle.family).toMatch(/mono/i) + expect(pathStyle).toMatchObject({ overflow: 'auto', whiteSpace: 'nowrap', ellipsis: 'clip' }) + await path.evaluate((element) => { element.style.maxWidth = '160px'; element.scrollLeft = 50 }) + expect(await path.evaluate(element => element.scrollLeft)).toBe(50) + await path.evaluate((element) => { element.style.removeProperty('max-width') }) + await compareOrRefreshGolden(join(DIR, 'hover.expected.md'), await preview.locator('[data-review-view]').ariaSnapshot(), MODE) + await preview.hover() + await preview.locator('[data-review-view]').evaluate((element) => { + const line = element.querySelector('[data-diff-line]')! + for (let index = 0; index < 40; index++) element.appendChild(line.cloneNode(true)) + }) + await expect.poll(() => preview.evaluate(element => element.parentElement!.getBoundingClientRect().height)).toBe(420) + const scroll = preview.locator('[data-review-view]') + await scroll.evaluate((element) => { element.scrollTop = 80 }) + await expect.poll(() => scroll.evaluate(element => element.scrollTop)).toBe(80) + await row.click() + await preview.waitFor({ state: 'detached' }) + const review = page.locator('[data-changes-review]') + await review.locator('[data-review-file="notes.txt"]').waitFor({ state: 'visible' }) + await review.locator('[data-review-view="split"]').waitFor({ state: 'visible' }) + await card.getByRole('button', { name: '查看 src/util.ts 的改动' }).hover() + await preview.locator('[data-diff-code]').first().waitFor({ state: 'visible' }) + expect(await preview.locator('[data-review-view="unified"]').count()).toBe(1) + expect(await preview.locator('[data-diff-note]').isVisible()).toBe(false) + // Empty comparisons keep their status; only metadata accompanying code is hidden. + await preview.locator('[data-diff-note]').evaluate((element) => { element.setAttribute('data-diff-note', 'empty') }) + expect(await preview.locator('[data-diff-note]').isVisible()).toBe(true) + await preview.locator('[data-diff-note]').evaluate((element) => { element.setAttribute('data-diff-note', 'metadata') }) + expect(await preview.locator('[data-diff-hunk-header]').isVisible()).toBe(false) + await expect.poll(() => preview.evaluate(element => Number(getComputedStyle(element.parentElement!).opacity))).toBe(1) + expect(await hoverOpacityTransition(page, true)).toEqual({ duration: 100, opacity: 0.5 }) + await preview.waitFor({ state: 'detached' }) + for (const key of ['Enter', 'Space']) { + await page.mouse.move(0, 0) + await row.focus() + await row.hover() + await preview.locator('[data-review-view="unified"]').waitFor({ state: 'visible' }) + await row.press(key) + await preview.waitFor({ state: 'detached' }) + await review.locator('[data-review-file="notes.txt"]').waitFor({ state: 'visible' }) + } + expect(tripwire.pageErrors).toEqual([]) + expect(tripwire.warnings).toEqual([]) + }) + it('reviews a shell-appended file from the snapshots and an ignored file from its captured copies in one tab', async () => { const card = page.locator('[data-changed-files]') const column = page.locator('[data-rightbar-col]') diff --git a/apps/web/tests/plugin-manager.e2e.ts b/apps/web/tests/plugin-manager.e2e.ts index 8ce5950d4c..1e7188510f 100644 --- a/apps/web/tests/plugin-manager.e2e.ts +++ b/apps/web/tests/plugin-manager.e2e.ts @@ -276,8 +276,9 @@ describe('web e2e: plugin manager', () => { await expect.poll(() => toggle.getAttribute('aria-checked')).toBe('true') await action.waitFor({ timeout: 20_000 }) await action.getByRole('button', { name: /Agent Team/iu }).click() - await action.getByText('还没有共享任务').waitFor() - await action.getByText('lead', { exact: true }).waitFor() + const teamPanel = teamPage.getByRole('dialog', { name: 'Agent Team', exact: true }) + await teamPanel.getByText('还没有共享任务').waitFor() + await teamPanel.getByText('lead', { exact: true }).waitFor() const manifest = JSON.parse(await homeFile('profiles', 'scaffold', 'package.json')) as { dsh: { profile: { bundles: string[] } } } diff --git a/apps/web/tests/snapshots/agent-team-panel/task.expected.md b/apps/web/tests/snapshots/agent-team-panel/task.expected.md index 447a1f6858..5184f24d29 100644 --- a/apps/web/tests/snapshots/agent-team-panel/task.expected.md +++ b/apps/web/tests/snapshots/agent-team-panel/task.expected.md @@ -1,4 +1,3 @@ -- button "Agent Team" [expanded] - dialog "Agent Team": - strong: Agent Team - button "Refresh Team" @@ -6,14 +5,8 @@ - heading "Members" [level=3] - 'button "lead Inactive · Model: deepseek-v4-flash" [disabled]' - heading "Shared tasks" [level=3] - - button "New task" - article: - - strong: Browser task - - text: Pending - - paragraph: Created through the assembled browser - - text: "task-1 Ready Write scopes: src/web Owner" - - combobox "Owner": - - option "Unowned" [selected] - - option "lead" - - button "Edit" - - button "Delete" + - strong: Agent task + - text: In progress + - paragraph: Created by the Team Lead + - text: "task-1 Owner: lead Write scopes: src/web" diff --git a/benchmarks/package.json b/benchmarks/package.json index fe74d6f65b..ad685bcb27 100644 --- a/benchmarks/package.json +++ b/benchmarks/package.json @@ -33,7 +33,8 @@ "@deepseek-ai/dsh-subagent": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", "@deepseek-ai/dsh-token-meter": "workspace:^", - "@deepseek-ai/dsh-typert-protocol": "workspace:^" + "@deepseek-ai/dsh-typert-protocol": "workspace:^", + "@deepseek-ai/dsh-util-values": "workspace:^" }, "peerDependencies": { "@deepseek-ai/cordis": "workspace:^" diff --git a/docs/config-catalog.i18n.yaml b/docs/config-catalog.i18n.yaml index c1cf209b2c..63b1766591 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: 1fac327a8a50a09cf694f6fc31a0af1f031f596f -config-catalog.zh.md: fbc043ab62d57d1b7f754e28d3469cc5ebd4fb49 +config-catalog.md: 59b15e7bbb518abdee55308c191b109547b5185e +config-catalog.zh.md: 4e89ea92944f6e1f2ae8ae0b57f0bec9e9f835d9 diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 1fac327a8a..59b15e7bbb 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -2122,7 +2122,7 @@ export interface Config { } ``` -Source: [`packages/sandbox/sandbox-local/src/index.ts:44`](../packages/sandbox/sandbox-local/src/index.ts) +Source: [`packages/sandbox/sandbox-local/src/index.ts:45`](../packages/sandbox/sandbox-local/src/index.ts) diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index fbc043ab62..4e89ea9294 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -2126,7 +2126,7 @@ export interface Config { } ``` -来源:[`packages/sandbox/sandbox-local/src/index.ts:44`](../packages/sandbox/sandbox-local/src/index.ts) +来源:[`packages/sandbox/sandbox-local/src/index.ts:45`](../packages/sandbox/sandbox-local/src/index.ts) diff --git a/docs/persistence-catalog.i18n.yaml b/docs/persistence-catalog.i18n.yaml index 60dba9fa9e..da386eec26 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: dd52352ecff33c833154a5aa933767f0b2544b4a -persistence-catalog.zh.md: 71c0866cc2499ebaf573bc078ecb9f72a32ba128 +persistence-catalog.md: 45fe3d7549b98a1c199e3bba1565fbf2c11b3620 +persistence-catalog.zh.md: f55524fdf2f0c4dc3167a89249be8e703bfca7c3 diff --git a/docs/persistence-catalog.md b/docs/persistence-catalog.md index dd52352ecf..45fe3d7549 100644 --- a/docs/persistence-catalog.md +++ b/docs/persistence-catalog.md @@ -975,7 +975,7 @@ Source: [`packages/core/session/src/types.ts:330`](../packages/core/session/src/ Types: [TeamId](subsystems/agent-team.md) · [TeamMemberSnapshot](subsystems/agent-team.md) -Source: [`packages/experimental/agent-team/src/types.ts:221`](../packages/experimental/agent-team/src/types.ts) +Source: [`packages/experimental/agent-team/src/types.ts:210`](../packages/experimental/agent-team/src/types.ts) @@ -993,7 +993,7 @@ Source: [`packages/experimental/agent-team/src/types.ts:221`](../packages/experi Types: [TeamId](subsystems/agent-team.md) · [TeamMessageId](subsystems/agent-team.md) -Source: [`packages/experimental/agent-team/src/types.ts:227`](../packages/experimental/agent-team/src/types.ts) +Source: [`packages/experimental/agent-team/src/types.ts:216`](../packages/experimental/agent-team/src/types.ts) @@ -1006,7 +1006,7 @@ Source: [`packages/experimental/agent-team/src/types.ts:227`](../packages/experi Types: [TeamId](subsystems/agent-team.md) · [TeamMessageSnapshot](subsystems/agent-team.md) -Source: [`packages/experimental/agent-team/src/types.ts:225`](../packages/experimental/agent-team/src/types.ts) +Source: [`packages/experimental/agent-team/src/types.ts:214`](../packages/experimental/agent-team/src/types.ts) @@ -1019,7 +1019,7 @@ Source: [`packages/experimental/agent-team/src/types.ts:225`](../packages/experi Types: [TeamId](subsystems/agent-team.md) · [TeamTaskSnapshot](subsystems/agent-team.md) -Source: [`packages/experimental/agent-team/src/types.ts:223`](../packages/experimental/agent-team/src/types.ts) +Source: [`packages/experimental/agent-team/src/types.ts:212`](../packages/experimental/agent-team/src/types.ts) ### `todo/*` @@ -7373,7 +7373,7 @@ Sources: [`packages/web/web-search-deepseek/src/provider.ts:61`](../packages/web SHA-256: `b1ebd5cda47d90d980bb05bffc3f970c51537b9aff15b3bc3d639811727741b1` -Sources: [`packages/experimental/agent-team/src/types.ts:221`](../packages/experimental/agent-team/src/types.ts) +Sources: [`packages/experimental/agent-team/src/types.ts:210`](../packages/experimental/agent-team/src/types.ts) | Property | Presence | Type | |---|---|---| @@ -7401,7 +7401,7 @@ Sources: [`packages/core/session/src/types.ts:330`](../packages/core/session/src SHA-256: `ea08ec1df0c33108fd03345b9af199cf04dea450aa045d3c683f216311403403` -Sources: [`packages/experimental/agent-team/src/types.ts:225`](../packages/experimental/agent-team/src/types.ts) +Sources: [`packages/experimental/agent-team/src/types.ts:214`](../packages/experimental/agent-team/src/types.ts) | Property | Presence | Type | |---|---|---| @@ -7415,7 +7415,7 @@ Sources: [`packages/experimental/agent-team/src/types.ts:225`](../packages/exper SHA-256: `0bcfaf5566d271028429ac7b562cb59053c9d78b8ae14708c35fac835108d1b1` -Sources: [`packages/experimental/agent-team/src/types.ts:227`](../packages/experimental/agent-team/src/types.ts) +Sources: [`packages/experimental/agent-team/src/types.ts:216`](../packages/experimental/agent-team/src/types.ts) | Property | Presence | Type | |---|---|---| @@ -7561,7 +7561,7 @@ Sources: [`packages/compaction/compaction-image-offload/src/projection.ts:25`](. SHA-256: `a40d12070f6f4a124f32fb3cc86e7857554702e32f0eda3080a25c4b9ac9b18c` -Sources: [`packages/experimental/agent-team/src/types.ts:223`](../packages/experimental/agent-team/src/types.ts) +Sources: [`packages/experimental/agent-team/src/types.ts:212`](../packages/experimental/agent-team/src/types.ts) | Property | Presence | Type | |---|---|---| diff --git a/docs/persistence-catalog.zh.md b/docs/persistence-catalog.zh.md index 71c0866cc2..f55524fdf2 100644 --- a/docs/persistence-catalog.zh.md +++ b/docs/persistence-catalog.zh.md @@ -977,7 +977,7 @@ export type SessionEvent = { 类型:[TeamId](subsystems/agent-team.zh.md) · [TeamMemberSnapshot](subsystems/agent-team.zh.md) -来源:[`packages/experimental/agent-team/src/types.ts:221`](../packages/experimental/agent-team/src/types.ts) +来源:[`packages/experimental/agent-team/src/types.ts:210`](../packages/experimental/agent-team/src/types.ts) @@ -995,7 +995,7 @@ export type SessionEvent = { 类型:[TeamId](subsystems/agent-team.zh.md) · [TeamMessageId](subsystems/agent-team.zh.md) -来源:[`packages/experimental/agent-team/src/types.ts:227`](../packages/experimental/agent-team/src/types.ts) +来源:[`packages/experimental/agent-team/src/types.ts:216`](../packages/experimental/agent-team/src/types.ts) @@ -1008,7 +1008,7 @@ export type SessionEvent = { 类型:[TeamId](subsystems/agent-team.zh.md) · [TeamMessageSnapshot](subsystems/agent-team.zh.md) -来源:[`packages/experimental/agent-team/src/types.ts:225`](../packages/experimental/agent-team/src/types.ts) +来源:[`packages/experimental/agent-team/src/types.ts:214`](../packages/experimental/agent-team/src/types.ts) @@ -1021,7 +1021,7 @@ export type SessionEvent = { 类型:[TeamId](subsystems/agent-team.zh.md) · [TeamTaskSnapshot](subsystems/agent-team.zh.md) -来源:[`packages/experimental/agent-team/src/types.ts:223`](../packages/experimental/agent-team/src/types.ts) +来源:[`packages/experimental/agent-team/src/types.ts:212`](../packages/experimental/agent-team/src/types.ts) ### `todo/*` @@ -7375,7 +7375,7 @@ SHA-256: `930a6567a10bb62ddd157bd8abdf4b182810c8c3b49d91309b3b009a5fed9731` SHA-256: `b1ebd5cda47d90d980bb05bffc3f970c51537b9aff15b3bc3d639811727741b1` -来源:[`packages/experimental/agent-team/src/types.ts:221`](../packages/experimental/agent-team/src/types.ts) +来源:[`packages/experimental/agent-team/src/types.ts:210`](../packages/experimental/agent-team/src/types.ts) | 属性 | 存在性 | 类型 | |---|---|---| @@ -7403,7 +7403,7 @@ SHA-256: `6bc8e51124d0874bc89e1d0ceff381a9c3cd0fc240667d69f841ce4f60f65fe5` SHA-256: `ea08ec1df0c33108fd03345b9af199cf04dea450aa045d3c683f216311403403` -来源:[`packages/experimental/agent-team/src/types.ts:225`](../packages/experimental/agent-team/src/types.ts) +来源:[`packages/experimental/agent-team/src/types.ts:214`](../packages/experimental/agent-team/src/types.ts) | 属性 | 存在性 | 类型 | |---|---|---| @@ -7417,7 +7417,7 @@ SHA-256: `ea08ec1df0c33108fd03345b9af199cf04dea450aa045d3c683f216311403403` SHA-256: `0bcfaf5566d271028429ac7b562cb59053c9d78b8ae14708c35fac835108d1b1` -来源:[`packages/experimental/agent-team/src/types.ts:227`](../packages/experimental/agent-team/src/types.ts) +来源:[`packages/experimental/agent-team/src/types.ts:216`](../packages/experimental/agent-team/src/types.ts) | 属性 | 存在性 | 类型 | |---|---|---| @@ -7563,7 +7563,7 @@ SHA-256: `477614bca595a9e7adb5a1870be446b9a8684f3ba8950259d6289f2d3b74f028` SHA-256: `a40d12070f6f4a124f32fb3cc86e7857554702e32f0eda3080a25c4b9ac9b18c` -来源:[`packages/experimental/agent-team/src/types.ts:223`](../packages/experimental/agent-team/src/types.ts) +来源:[`packages/experimental/agent-team/src/types.ts:212`](../packages/experimental/agent-team/src/types.ts) | 属性 | 存在性 | 类型 | |---|---|---| diff --git a/docs/persistence-schema.json b/docs/persistence-schema.json index c4e1c3b950..e525fb8391 100644 --- a/docs/persistence-schema.json +++ b/docs/persistence-schema.json @@ -22575,7 +22575,7 @@ }, "names": [], "sources": [ - "packages/experimental/agent-team/src/types.ts:227" + "packages/experimental/agent-team/src/types.ts:216" ] }, { @@ -69181,7 +69181,7 @@ }, "names": [], "sources": [ - "packages/experimental/agent-team/src/types.ts:223" + "packages/experimental/agent-team/src/types.ts:212" ] }, { @@ -71163,7 +71163,7 @@ }, "names": [], "sources": [ - "packages/experimental/agent-team/src/types.ts:221" + "packages/experimental/agent-team/src/types.ts:210" ] }, { @@ -83593,7 +83593,7 @@ }, "names": [], "sources": [ - "packages/experimental/agent-team/src/types.ts:225" + "packages/experimental/agent-team/src/types.ts:214" ] }, { diff --git a/docs/subsystems/agent-team.i18n.yaml b/docs/subsystems/agent-team.i18n.yaml index 6328a02541..6a04e1377d 100644 --- a/docs/subsystems/agent-team.i18n.yaml +++ b/docs/subsystems/agent-team.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/agent-team.md -agent-team.md: 653bbbcad50d723690c013161a0c25eea301ff0b -agent-team.zh.md: e9ef2d5dc824a81d1c666d0ea7a270c0cfe2c92e +agent-team.md: 2f49e164645e6b66bd0da4946c477a09c412ddba +agent-team.zh.md: 24d7d73fdace11ad5e00eb3fb4c90a4abde33810 diff --git a/docs/subsystems/agent-team.md b/docs/subsystems/agent-team.md index 653bbbcad5..2f49e16464 100644 --- a/docs/subsystems/agent-team.md +++ b/docs/subsystems/agent-team.md @@ -183,22 +183,6 @@ tryMembership(agent: Agent): TeamMembership | undefined * @returns detached current roster and task views. */ @Remote('view') remoteView(agent: Agent): TeamView - -/** - * Create one shared task through the generated Remote API. - * @param agent - exact live Team member creating the task. - * @param request - task text, blockers, and advisory write scopes. - * @returns the revision-one task or a typed Team rejection. - */ -@Remote('createTask') remoteCreateTask(agent: Agent, request: CreateTeamTaskRequest): Promise - -/** - * Apply one task mutation and preserve Team rejections as business results. - * @param agent - exact live Team member authorizing the mutation. - * @param request - task identity, expected revision, action, and action fields. - * @returns the committed task or a typed Team rejection. - */ -@Remote('updateTask') remoteUpdateTask(agent: Agent, request: UpdateTeamTaskRequest): Promise ``` Types: [Agent](core.md) diff --git a/docs/subsystems/agent-team.zh.md b/docs/subsystems/agent-team.zh.md index e9ef2d5dc8..24d7d73fda 100644 --- a/docs/subsystems/agent-team.zh.md +++ b/docs/subsystems/agent-team.zh.md @@ -183,22 +183,6 @@ tryMembership(agent: Agent): TeamMembership | undefined * @returns detached current roster and task views. */ @Remote('view') remoteView(agent: Agent): TeamView - -/** - * Create one shared task through the generated Remote API. - * @param agent - exact live Team member creating the task. - * @param request - task text, blockers, and advisory write scopes. - * @returns the revision-one task or a typed Team rejection. - */ -@Remote('createTask') remoteCreateTask(agent: Agent, request: CreateTeamTaskRequest): Promise - -/** - * Apply one task mutation and preserve Team rejections as business results. - * @param agent - exact live Team member authorizing the mutation. - * @param request - task identity, expected revision, action, and action fields. - * @returns the committed task or a typed Team rejection. - */ -@Remote('updateTask') remoteUpdateTask(agent: Agent, request: UpdateTeamTaskRequest): Promise ``` Types: [Agent](core.zh.md) diff --git a/docs/subsystems/conversation.i18n.yaml b/docs/subsystems/conversation.i18n.yaml index 5ea67b1086..56327598b7 100644 --- a/docs/subsystems/conversation.i18n.yaml +++ b/docs/subsystems/conversation.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/conversation.md -conversation.md: 4d6324e71b4383dfc640c1873dd9ef31023a1098 -conversation.zh.md: 49f2d7dbdc67a926f88ca72ed8ae6a0622c54fb3 +conversation.md: 9455858acac16584b4d300dc8cd2634eb2a20310 +conversation.zh.md: 2e7c48ab5f65935c2fcb4de05c4484c965622746 diff --git a/docs/subsystems/conversation.md b/docs/subsystems/conversation.md index 4d6324e71b..9455858aca 100644 --- a/docs/subsystems/conversation.md +++ b/docs/subsystems/conversation.md @@ -16,6 +16,7 @@ The Session Controller owns the contiguous loaded logical-event window. Each `Se | Context | The engine-owned ordered Matches and current State for one `(kind, id)`. A transient event occupies one update Match; update-only evidence may remain pending until pagination supplies its unique durable start. | | Location | The engine-owned Session, Turn, or Step coordinates derived from durable boundary events. Definitions may publish typed data onto one Turn or Step. | | View Definition | A target package creates one incremental builder per Session and owns the final snapshot type for that target. | +| Group Definition | A business package derives one target's root references and group snapshots from materialized Nodes; it owns membership, segmentation, summaries, and incremental caches. | | View | A Slot entry such as Chat or Trajectory reads only its target snapshot and renders target-owned nodes. | Chat and Trajectory may recognize the same durable event family, but each keeps its own Definition State and final node payload. Shared target-neutral machinery is limited to identity routing, ordered replay, Location data, predecessor dependencies, and publication cadence. @@ -26,6 +27,32 @@ Each Session keeps a monotonic set of active targets. Creating or reading a targ The shell owns View selection and resolves the registered preferred View or Chat fallback before rendering when a binding is created or selected as current, and after View-roster changes. The assembler receives only the resolved target id and does not select Chat or another default target. A third-party View participates through the same selection and activation operations. A View Definition may expose `toolCallFocus(callId)`; the shell supplies Inspect only for a visible target that declares this capability, and the target maps the call id to its own focus identity. + +## Group Definitions + +The optional `ctx.uiConversation.groups.register(definition)` contribution uses the same effect-owned registry lifecycle as event and view registrations. It requires an existing View target and rejects duplicate target registrations without constructing a Builder. Each Session creates its own Group context only when that target activates. Registration replacement discards the old context and clears its observed groups during the existing registry rebuild. + +The [group types](../../packages/client/ui-conversation/src/client/contract/groups.ts) define the complete protocol: + +| Type | Meaning | +|---|---| +| `ConversationGroupDefinition` | `create()` initializes Session-local State; `update(context, input)` returns its next State; `buildGroups(context)` returns pending output or `null`. Replacement input requires entries and a complete group replacement. | +| `ConversationGroupInput` | `replace` supplies target order, timeline, and synchronous `readNode`, `readTurn`, and `readPosition` readers. `apply` adds projected `previous/current` Node changes, lifecycle `changedTurns`, and `changedTurnOrders`. Do not retain readers in State. | +| `GroupNodePosition` | Owning Turn, when present, and immediate previous/next visible Node keys. Neighbours preserve interruptions that a Turn-only key list omits. | +| `NodeReference` / `GroupReference` | Branded `NodeKey` or `GroupKey`, distinguished by `kind`. A Node reference may select a renderer-owned `groupPart`; omission selects the whole Node. | +| `GroupSnapshot` | Immutable group key, business data, and ordered Node references. Groups cannot contain other groups or own source Node data. | +| `GroupUpdate` | `entries` replaces the complete root sequence; only apply input may omit it to preserve the sequence. Replacement input requires both `entries` and `groups.replace`. `groups.replace.snapshots` replaces all groups; `groups.apply.upserts/removes` updates only named group records. Removal does not delete Nodes. | +| `ConversationGroupDataMap` | Declaration-merged target-to-data association shared by registration and `views.grouped(target)`. Undeclared targets have no group payload type. | +| `ConversationGroupedView` | Stable root `entries` and keyed `groupSource(key)` readers; a removed group reads as `undefined`. | + +The Builder exposes `groupInput()` when grouping is registered; missing input fails at first activation. It records the Node values after its own projections and preserves content-only order identity. The assembler supplies changed Turns with every update; direct ungrouped Builder callers may omit them. Builders with independent sources defer their notifications to `publish()`. First activation and ordinary flush share the same sequence: materialize Location data and Nodes, update the Builder, call the Group Definition, validate and install grouping, then publish every affected target and Location source. The group phase reuses the existing publication cadence. + +Target position indexes supply the readers and identify Turns whose visible keys or neighbours changed. `changedTurnOrders` includes both owners of a moved Node and Turns adjacent to inserted or removed unscoped Nodes; lifecycle-only changes remain in `changedTurns`. A business Definition can resegment those Turns and refresh only groups containing changed content. Structural output still replaces the complete root-reference array, without requiring unchanged Node contents to be reread. + +The Group store validates the whole submitted result before installation: root Group references and records correspond one-to-one, all referenced Nodes exist, and each `(NodeKey, groupPart)` occupies at most one root or member position. A whole Node cannot coexist with one of its parts. Duplicate upserts, duplicate removals, and simultaneous upsert/removal of a group fail. Data-only upserts retain root and member arrays, do not read Nodes, and notify only changed group sources; full replacement revalidates every reference. + +Renderers switch on the two reference kinds and choose components in the owning View, not through a renderer field in group data. Root Node references and referenced group members form the complete render list; unreferenced target Nodes remain stored but do not render. The group body reads members separately from summary data. Presentation modes must retain component types, keys, and member parents; changing data membership may legitimately remount a moved member. The framework does not interpret part completeness, segmentation, or presentation policies. + ## Replayable event families Choose one stable business id before writing the Definition. Every event that contributes to the same Node must carry that id or derive it independently from its own payload; the client must never assign an update to “the latest unfinished” Context. diff --git a/docs/subsystems/conversation.zh.md b/docs/subsystems/conversation.zh.md index 49f2d7dbdc..2e7c48ab5f 100644 --- a/docs/subsystems/conversation.zh.md +++ b/docs/subsystems/conversation.zh.md @@ -16,6 +16,7 @@ Session Controller 拥有连续的已加载逻辑 event window。每个 `Session | Context | Engine 为一个 `(kind, id)` 拥有的有序 Match 与当前 State。一个瞬态 event 只占一个 update Match;只有 update 的证据可以保持 pending,直到分页补齐其唯一持久 start。 | | Location | Engine 根据持久 boundary event 推导的 Session、Turn 或 Step 坐标。Definition 可以向一个 Turn 或 Step 发布类型化数据。 | | View Definition | Target 包为每个 Session 创建一个增量 builder,并拥有该 target 的最终 snapshot 类型。 | +| Group Definition | 业务包从物化后的 Node 派生一个目标的根引用和组快照,拥有成员归属、分段、摘要及增量缓存。 | | View | Chat 或 Trajectory 等 Slot entry 只读取自身 target snapshot,并渲染 target 自有 node。 | Chat 与 Trajectory 可以识别同一个持久 event family,但各自保留自己的 Definition State 与最终 node payload。共享的 target-neutral 机制只包括 identity routing、有序 replay、Location data、predecessor dependency 与 publication cadence。 @@ -26,6 +27,32 @@ Chat 与 Trajectory 可以识别同一个持久 event family,但各自保留 shell 拥有 View 选择,并在 binding 创建、被选为 current 或 View roster 变化时,于渲染前解析已注册的偏好 View 或 Chat fallback。assembler 只接收解析后的 target id,不自行选择 Chat 或其他默认 target。第三方 View 使用相同的选择与激活操作。View Definition 可以提供 `toolCallFocus(callId)`;shell 仅为声明了此能力且可见的目标提供 Inspect,由目标将调用 id 映射为自己的焦点标识。 + +## Group Definition + +可选的 `ctx.uiConversation.groups.register(definition)` 贡献沿用事件及视图注册的 effect 生命周期。它要求已有 View 目标,拒绝重复目标注册,不构造 Builder。每个 Session 仅在目标激活时创建自己的 Group 上下文。注册替换通过既有注册重建流程丢弃旧上下文,并清空其被观察的组。 + +[分组类型](../../packages/client/ui-conversation/src/client/contract/groups.ts)定义完整协议: + +| 类型 | 含义 | +|---|---| +| `ConversationGroupDefinition` | `create()` 初始化 Session 内的 State;`update(context, input)` 返回下一份 State;`buildGroups(context)` 返回待发布输出或 `null`。替换输入要求输出 entries 与完整组替换。 | +| `ConversationGroupInput` | `replace` 提供目标顺序、时间线及同步的 `readNode`、`readTurn`、`readPosition` 读取器。`apply` 增加投影后的 `previous/current` 节点变化、生命周期 `changedTurns` 和 `changedTurnOrders`。不要把读取器留在 State 中。 | +| `GroupNodePosition` | 所属 Turn(如有)及前后紧邻的可见 Node 键。相邻关系保留仅按 Turn 取键时会遗漏的分隔。 | +| `NodeReference` / `GroupReference` | 以 `kind` 区分的品牌类型 `NodeKey` 或 `GroupKey`。Node 引用可选择渲染器拥有的 `groupPart`,省略表示整个 Node。 | +| `GroupSnapshot` | 不可变的组键、业务数据和有序 Node 引用。Group 不包含其他 Group,也不拥有原 Node 数据。 | +| `GroupUpdate` | `entries` 替换完整根序列,仅 apply 输入允许省略它以保留原序列。替换输入必须同时提供 `entries` 与 `groups.replace`。`groups.replace.snapshots` 替换全部组;`groups.apply.upserts/removes` 只更新具名组记录。删除组不删除 Node。 | +| `ConversationGroupDataMap` | 通过声明合并关联目标与数据类型,注册及 `views.grouped(target)` 共用。未声明的目标没有组载荷类型。 | +| `ConversationGroupedView` | 稳定的根 `entries` 与按键 `groupSource(key)` 读取器;已删除的组返回 `undefined`。 | + +注册分组时 Builder 必须提供 `groupInput()`,缺少输入方法在首次激活时报错。它记录自身投影后的节点值,并在仅正文更新时保留顺序数组身份。assembler 每次更新都提供变化轮次,直接调用未分组 Builder 的调用方可以省略。拥有独立来源的 Builder 把通知推迟到 `publish()`。首次激活与普通 flush 共用同一顺序:物化 Location 数据和 Node,更新 Builder,调用 Group Definition,校验并安装分组,再发布全部受影响目标及 Location 来源。分组阶段复用既有发布节奏。 + +目标位置索引提供这些读取器,并标识可见键或相邻关系发生变化的 Turn。`changedTurnOrders` 包含移动 Node 的新旧所属轮次,以及相邻的轮次外 Node 被插入或移除所影响的轮次;仅生命周期变化仍由 `changedTurns` 提供。业务 Definition 可只重分这些轮次,并只刷新包含变化内容的组。结构输出仍替换完整根引用数组,但不要求重读未变化的 Node 内容。 + +Group 存储在安装前校验完整提交结果:根 Group 引用与记录一一对应,引用的 Node 全部存在,每个 `(NodeKey, groupPart)` 在根和成员位置中最多占位一次。整 Node 不能与自身任一部分共存。重复 upsert、重复删除以及同时 upsert 和删除一个组都会报错。仅数据 upsert 保留根及成员数组、不读取 Node,并只通知变化组的来源;完整替换重新校验全部引用。 + +渲染器按两种引用 kind 切换,由所属 View 选择组件,不通过组数据中的 renderer 字段选择。根 Node 引用与被引用组的成员构成完整渲染列表;未被引用的目标 Node 仍保留在存储中,但不渲染。组 body 将成员与摘要数据分开读取。展示模式必须保留组件类型、key 和成员父级;业务数据改变成员归属时,移动成员可以正常重挂载。框架不解释部分内容完备性、分段或展示策略。 + ## 可回放 event family 编写 Definition 前先选定稳定的业务 id。构成同一个 Node 的每条事件都必须携带该 id,或只凭自身 payload 独立推导出该 id;Client 绝不能把 update 猜测为属于“最近一个未完成”的 Context。 diff --git a/docs/subsystems/sandbox.i18n.yaml b/docs/subsystems/sandbox.i18n.yaml index 7bb8ec7c19..c8eeb0c0bb 100644 --- a/docs/subsystems/sandbox.i18n.yaml +++ b/docs/subsystems/sandbox.i18n.yaml @@ -1,6 +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 docs/subsystems/sandbox.md -sandbox.md: c29450d83c54d48a69f74f0b2fa38fe7c4f35ef2 -sandbox.zh.md: 71b55fcb8d46144e45daef717999d8905a78eec4 +# pnpm run verify-translation-pairing --write docs//subsystems//sandbox.md +sandbox.md: 05be79018f60e1e6af964449756322d283048c0d +sandbox.zh.md: 843be48f65ebe47b6c3307f5914ea011191e0bcb diff --git a/docs/subsystems/sandbox.md b/docs/subsystems/sandbox.md index c29450d83c..05be79018f 100644 --- a/docs/subsystems/sandbox.md +++ b/docs/subsystems/sandbox.md @@ -27,7 +27,7 @@ Only the first two modes can be sent to a provider. A `danger-full-access` consu type ConfinedSandboxMode = Exclude ``` -Enforcement is a reported fact. `full` means the backend governs every file effect promised by the mode; `partial` means an active backend or older kernel ABI governs only a subset, so consumers that require the absolute promise must reject or surface that distinction. Older Landlock ABIs and the Windows ACL runner's Everyone/hard-link boundaries are current partial cases. +Enforcement is a reported fact. `full` means the backend governs every file effect promised by the mode; `partial` means an active backend or older kernel ABI governs only a subset, so consumers that require the absolute promise must reject or surface that distinction. Older Landlock ABIs and the Windows ACL runner's hard-link, unconfined-read, and AppContainer-ACL boundaries are current partial cases. ```ts type-equiv /** diff --git a/docs/subsystems/sandbox.zh.md b/docs/subsystems/sandbox.zh.md index 71b55fcb8d..843be48f65 100644 --- a/docs/subsystems/sandbox.zh.md +++ b/docs/subsystems/sandbox.zh.md @@ -27,7 +27,7 @@ type SandboxMode = 'read-only' | 'workspace-write' | 'danger-full-access' type ConfinedSandboxMode = Exclude ``` -强制执行完整性是后端报告的事实。`full` 表示后端管控了该模式承诺的所有文件效果;`partial` 表示活跃后端或较旧的内核 ABI 仅管控其中一个子集,因此要求绝对保证的消费方必须拒绝或向上暴露这一区别。当前的部分强制执行情形包括较旧的 Landlock ABI,以及 Windows ACL runner 的 Everyone 与硬链接边界。 +强制执行完整性是后端报告的事实。`full` 表示后端管控了该模式承诺的所有文件效果;`partial` 表示活跃后端或较旧的内核 ABI 仅管控其中一个子集,因此要求绝对保证的消费方必须拒绝或向上暴露这一区别。当前的部分强制执行情形包括较旧的 Landlock ABI,以及 Windows ACL runner 的硬链接、读取不受限与 AppContainer ACL 边界。 ```ts type-equiv /** diff --git a/package.json b/package.json index 827b4000dd..9a5c8329d3 100644 --- a/package.json +++ b/package.json @@ -127,7 +127,7 @@ "docs:build": "tsx website/build.ts && pnpm run verify-doc-site-fragments", "docs:build:mpa": "tsx website/build.ts --mpa && pnpm run verify-doc-site-fragments", "docs:preview": "pnpm --filter @deepseek-ai/website run preview", - "docs:check": "pnpm exec vitest run scripts/project-doc-site.spec.ts scripts/verify-doc-site-fragments.spec.ts website/tests/mermaid-viewer.spec.ts website/tests/code-groups.spec.ts website/tests/page-markdown-actions.spec.ts website/tests/raw-markdown.spec.ts && pnpm run docs:build", + "docs:check": "pnpm exec vitest run scripts/project-doc-site.spec.ts scripts/verify-doc-site-fragments.spec.ts website/tests/mermaid-viewer.spec.ts website/tests/image-viewer.spec.ts website/tests/code-groups.spec.ts website/tests/page-markdown-actions.spec.ts website/tests/raw-markdown.spec.ts && pnpm run docs:build", "website:dev": "pnpm run docs:dev", "website:build": "pnpm run docs:build", "verify-package-readme-limitations": "tsx scripts/verify-package-readme-limitations.ts", diff --git a/packages/client/ui-chat/README.i18n.yaml b/packages/client/ui-chat/README.i18n.yaml index 1eefd3ba94..ee8dc73bfc 100644 --- a/packages/client/ui-chat/README.i18n.yaml +++ b/packages/client/ui-chat/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-chat/README.md -README.md: 2beb4dfadddec0c834c3ef6f4b56826be120baa6 -README.zh.md: 5d643f4e90b77d2b79ab1bb1c003b399c202e004 +README.md: 0fd407f33b1a7cba5a1f2612411d9ec333679c9c +README.zh.md: e9d5005851f57d632ca4ab7835d32ce14105d3a6 diff --git a/packages/client/ui-chat/README.md b/packages/client/ui-chat/README.md index 2beb4dfadd..0fd407f33b 100644 --- a/packages/client/ui-chat/README.md +++ b/packages/client/ui-chat/README.md @@ -20,6 +20,7 @@ File-mention providers receive the viewed Session ID with the closing-turn owner - [Turn token usage](#turn-token-usage) - [Completed-turn footer](#completed-turn-footer) - [Turn Process Folding](#turn-process-folding) +- [Grouped rendering](#grouped-rendering) - [Scroll ownership](#scroll-ownership) - [Model Experience](#model-experience) - [Known Limitations and Deferred Work](#known-limitations-and-deferred-work) @@ -85,6 +86,15 @@ Stable Chat Node Seats keep every renderer mounted, hidden members add no flow s ----- + +## Grouped rendering + +Chat can render optional Conversation Group Definition output as a mixed `node`/`group` root list. A group seat subscribes only to its member array; each member retains the existing keyed Node source and renderer. `groupPart` reaches that renderer as a business-owned part selector, with distinct DOM anchors for reading-position restoration; Turn navigation can still address the original Node key and land on its first visible part. Presentation modes do not select the root branch or alter member parents. No process Group Definition is registered by this package yet; the default transcript remains ungrouped. + +The group seat uses `div` with `display: contents`: it retains the DOM parent without creating a layout box. CSS inheritance remains available, but child and sibling selectors still follow the DOM tree. The existing direct-child spacing selectors do not reach group members. Business styles must adapt spacing within and across groups, including hidden or empty members and the answer-spacing exception, and own any measurable body or scroll container. CSS variables do not belong in the Group Definition. + +----- + ## Scroll ownership diff --git a/packages/client/ui-chat/README.zh.md b/packages/client/ui-chat/README.zh.md index 5d643f4e90..e9d5005851 100644 --- a/packages/client/ui-chat/README.zh.md +++ b/packages/client/ui-chat/README.zh.md @@ -20,6 +20,7 @@ kind: "package-reference" - [轮次 token 用量](#turn-token-usage) - [已完成轮次的页脚](#completed-turn-footer) - [轮次过程折叠](#turn-process-folding) +- [分组渲染](#grouped-rendering) - [滚动归属](#scroll-ownership) - [模型体验](#model-experience) - [已知限制与暂缓事项](#known-limitations-and-deferred-work) @@ -85,6 +86,15 @@ Chat 在所有工作过程展示模式下都不显示系统提示词行和 `perm ----- + +## 分组渲染 + +Chat 可以把可选的 Conversation Group Definition 输出渲染为混合 `node`/`group` 根列表。组容器只订阅成员数组,每个成员保留既有的按键 Node 来源与渲染器。`groupPart` 作为业务拥有的部分选择器传给渲染器,不同部分具有独立的 DOM 锚点用于恢复阅读位置;轮次导航仍可使用原 Node key,落到它的第一个可见部分。展示模式不选择根分支,也不改变成员父级。本包尚未注册过程 Group Definition,默认文本记录仍未分组。 + +组容器使用 `div` 与 `display: contents`,保留 DOM 父级但不产生布局盒子。CSS 继承仍然可用,子级和兄弟选择器仍遵循 DOM 树。原有直接子级间距选择器不会匹配组内成员。业务样式必须适配组内及组边界间距,处理隐藏或空成员以及回答前的间距特例,并拥有需要测量的正文或滚动容器。CSS 变量不属于 Group Definition。 + +----- + ## 滚动归属 diff --git a/packages/client/ui-chat/package.json b/packages/client/ui-chat/package.json index f966be3c21..28fa4da05d 100644 --- a/packages/client/ui-chat/package.json +++ b/packages/client/ui-chat/package.json @@ -86,6 +86,7 @@ "@deepseek-ai/dsh-token-meter": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", "@deepseek-ai/dsh-util-workspace-path": "workspace:^", + "@deepseek-ai/dsh-util-values": "workspace:^", "@tanstack/react-virtual": "^3.14.9", "@types/react": "~18.3.1", "@types/react-dom": "~18.3.0", diff --git a/packages/client/ui-chat/src/client/apply.ts b/packages/client/ui-chat/src/client/apply.ts index 70390ec889..da926ba05a 100644 --- a/packages/client/ui-chat/src/client/apply.ts +++ b/packages/client/ui-chat/src/client/apply.ts @@ -3,6 +3,7 @@ import type { Context } from '@deepseek-ai/cordis' import type { ImageAttachmentRef } from '@deepseek-ai/dsh-attachment' import type {} from '@deepseek-ai/dsh-api-remotes/client' import type { SessionBinding } from '@deepseek-ai/dsh-api-session-controller/client' +import type { GroupKey } from '@deepseek-ai/dsh-client-ui-conversation/client' import { createSnapshotStore, type ObservableSnapshot } from '@deepseek-ai/dsh-client-store' import type { SessionId } from '@deepseek-ai/dsh-session/types' import type {} from '@deepseek-ai/dsh-client-ui-sidebar-right/client' @@ -156,11 +157,13 @@ export function apply(ctx: Context): void { if (binding === undefined) throw new Error(`ui-chat: unknown session "${sessionId}"`) const session = binding.session const chat = chatSource(binding) + const conversation = ctx.uiConversation.binding(binding) return { hooks: { presentation }, keyedHooks: { chatNode: key => chat.getSnapshot().nodes.source(key), chatNodeProcess: key => chat.getSnapshot().nodes.processSource(key), + chatGroup: key => conversation.snapshot.getSnapshot().views.grouped('chat')?.groupSource(key as GroupKey), }, fileMentions: (owner: TurnTailOwnerProps) => ctx.get('chatFileMentions')?.forClosing(owner, sessionId), // Files open in the right Sidebar, not in a desktop application: the diff --git a/packages/client/ui-chat/src/client/chat/ChatGroupSeat.module.css b/packages/client/ui-chat/src/client/chat/ChatGroupSeat.module.css new file mode 100644 index 0000000000..94ce0b8455 --- /dev/null +++ b/packages/client/ui-chat/src/client/chat/ChatGroupSeat.module.css @@ -0,0 +1,3 @@ +.root { + display: contents; +} diff --git a/packages/client/ui-chat/src/client/chat/ChatGroupSeat.tsx b/packages/client/ui-chat/src/client/chat/ChatGroupSeat.tsx new file mode 100644 index 0000000000..a5af681ef5 --- /dev/null +++ b/packages/client/ui-chat/src/client/chat/ChatGroupSeat.tsx @@ -0,0 +1,30 @@ +/** Stable parent for one group's Node references, independent of display mode. */ +import { memo, type ComponentProps } from 'react' +import type { GroupKey } from '@deepseek-ai/dsh-client-ui-conversation/client' +import type { ChatViewSlotProps } from '../contract/slots.ts' +import { ChatNodeSeat } from './ChatNodeSeat.tsx' +import { chatRenderKey } from './render-entry.ts' +import css from './ChatGroupSeat.module.css' + +type ChatGroupSeatProps = Omit, 'nodeKey' | 'groupPart'> & { + readonly groupKey: GroupKey + readonly useChatGroup: ChatViewSlotProps['useChatGroup'] +} + +/** Render the group's members without moving them between React parents on mode changes. */ +export const ChatGroupSeat = memo(function ChatGroupSeat({ groupKey, useChatGroup, ...props }: ChatGroupSeatProps) { + const members = useChatGroup(groupKey, group => group?.members) + if (members === undefined) return null + return ( +
+ {members.map(member => ( + + ))} +
+ ) +}) diff --git a/packages/client/ui-chat/src/client/chat/ChatNodeSeat.tsx b/packages/client/ui-chat/src/client/chat/ChatNodeSeat.tsx index 3ed6f7b8d8..bb62567364 100644 --- a/packages/client/ui-chat/src/client/chat/ChatNodeSeat.tsx +++ b/packages/client/ui-chat/src/client/chat/ChatNodeSeat.tsx @@ -3,6 +3,7 @@ import { JsonBlock } from '@deepseek-ai/dsh-client-ui-primitives' import type { ConversationLocationDataStore, ConversationTurnDataMap } from '@deepseek-ai/dsh-client-ui-conversation/client' import type { ChatNodeOwnerProps, ChatViewSlotProps, UsePresentation } from '../contract/slots.ts' import type { ChatNode } from '../contract/chat-nodes.ts' +import type { ChatNodeStore } from '../contract/snapshot.ts' import { TURN_PROCESS_INDEPENDENT_KINDS } from '../contract/turn-process.ts' import { storedTurnProcessEntry } from '../stores.ts' import { useSearchableHidden } from './searchable-hidden.ts' @@ -10,6 +11,8 @@ import css from './ChatView.module.css' interface ChatNodeSeatProps extends ChatNodeOwnerProps { readonly nodeKey: string + /** A replaced Builder must rebind keyed hooks even when references and keys survive. */ + readonly nodeStore: ChatNodeStore readonly useChatNode: ChatViewSlotProps['useChatNode'] readonly useChatNodeProcess: ChatViewSlotProps['useChatNodeProcess'] readonly usePresentation: UsePresentation @@ -39,7 +42,7 @@ function turnOf(node: ChatNode | undefined): number | undefined { * only seats whose visibility actually changes. */ export const ChatNodeSeat = memo(function ChatNodeSeat({ - nodeKey, useChatNode, useChatNodeProcess, usePresentation, + nodeKey, groupPart, useChatNode, useChatNodeProcess, usePresentation, cwd, openFile, openSkill, inspectCall, forkAt, loadImage, renderMessageImages, fileMentions, useStore, actions, renderSlot, t, }: ChatNodeSeatProps) { @@ -108,6 +111,7 @@ export const ChatNodeSeat = memo(function ChatNodeSeat({ const owner = useMemo(() => node === undefined ? null : { + ...groupPart === undefined ? {} : { groupPart }, cwd, openFile, openSkill, @@ -118,7 +122,7 @@ export const ChatNodeSeat = memo(function ChatNodeSeat({ fileMentions, turnProcess, }, [ - node, cwd, openFile, openSkill, inspectCall, forkAt, + node, groupPart, cwd, openFile, openSkill, inspectCall, forkAt, loadImage, renderMessageImages, fileMentions, turnProcess, ]) if (routedNode === undefined || owner === null) return null @@ -127,12 +131,15 @@ export const ChatNodeSeat = memo(function ChatNodeSeat({ // keyed-slot entry passed alongside that same Node. TypeScript does not // distribute an object containing a union into a union of objects itself. const routedOwner = { ...owner, node: routedNode } as RoutedChatNodeOwner + const flowKey = groupPart === undefined ? routedNode.key : JSON.stringify([routedNode.key, groupPart]) return (
, 'nodeKey'> & { - readonly order: readonly string[] +type ChatNodeListProps = Omit, 'nodeKey' | 'groupPart'> & { + readonly entries: readonly RenderEntry[] + readonly useChatGroup: ChatViewSlotProps['useChatGroup'] } -const ChatNodeList = memo(function ChatNodeList({ order, ...seatProps }: ChatNodeListProps) { - return order.map(nodeKey => ( - - )) +const ChatNodeList = memo(function ChatNodeList({ entries, useChatGroup, ...seatProps }: ChatNodeListProps) { + return entries.map((entry) => { + switch (entry.kind) { + case 'node': + return + case 'group': + return + default: + return assertNever(entry) + } + }) }) /** @@ -111,11 +123,14 @@ const ChatNodeList = memo(function ChatNodeList({ order, ...seatProps }: ChatNod * ordered business Node crosses the keyed renderer seat. */ export function ChatView({ - useSession, useChat, useChatNode, useChatNodeProcess, useSessions, useStore, actions, renderSlot, + useSession, useChat, useChatNode, useChatNodeProcess, useChatGroup, useConversation, useSessions, useStore, actions, renderSlot, sessionId, openFile, openSkill, openExternalLink, loadOlder, loadThrough, loadImage, inspectCall, chatScroll, forkAt, fileMentions, usePresentation, useProjection, t, }: ChatViewSlotProps) { const order = useChat(s => s.order) + const groupedEntries = useConversation(snapshot => snapshot.views.grouped('chat')?.entries) + const entries = useMemo(() => groupedEntries + ?? order.map(key => ({ kind: 'node', key: key as NodeKey })), [groupedEntries, order]) const nodeStore = useChat(s => s.nodes) // The rail's items are accumulated in the Chat snapshot, so this selector is // both the data and its change signal: the array identity moves only when a @@ -233,7 +248,9 @@ export function ChatView({ )} ('[data-chat-anchor-key]:not([hidden])')) { + // Reading anchors name exact parts; Turn navigation names the original Node. + let nodePart: HTMLElement | null = null + for (const row of this.elements.list.querySelectorAll('[data-chat-anchor-key]:not([hidden]):not([hidden] *)')) { if (row.dataset.chatAnchorKey === key) return row + if (nodePart === null && row.dataset.chatNodeKey === key) nodePart = row } - return null + return nodePart } /** @@ -169,7 +172,8 @@ export class ChatViewport { } if (anchor === null) { const rows = list.querySelectorAll( - '[data-chat-flow] > [data-chat-flow-key]:not(:empty):not([hidden])', + ':is([data-chat-flow], [data-chat-flow] > [data-chat-group-key])' + + ' > [data-chat-flow-key]:not([data-chat-group-key]):not(:empty):not([hidden]):not([hidden] *)', ) let low = 0 let high = rows.length @@ -229,6 +233,7 @@ export class ChatViewport { /** * Align a known loaded turn and return its actual clamped position. + * A split Node anchor selects its first visible part. * @param turn - loaded turn to align below the scrollport's top edge. * @returns the actual landing, or null when its anchor is unavailable. */ @@ -246,7 +251,7 @@ export class ChatViewport { */ scrollToTurnAtOrAfter(turn: number): ViewportLanding | null { if (this.elements === null) return null - for (const row of this.elements.list.querySelectorAll('[data-chat-turn]:not([hidden])')) { + for (const row of this.elements.list.querySelectorAll('[data-chat-turn]:not([hidden]):not([hidden] *)')) { const candidate = Number(row.dataset.chatTurn) if (Number.isSafeInteger(candidate) && candidate >= turn) return this.align(row, 24, candidate) } diff --git a/packages/client/ui-chat/src/client/contract/slots.ts b/packages/client/ui-chat/src/client/contract/slots.ts index 4086eb4d5b..312dcc2a76 100644 --- a/packages/client/ui-chat/src/client/contract/slots.ts +++ b/packages/client/ui-chat/src/client/contract/slots.ts @@ -3,6 +3,7 @@ import type { MessageId } from '@deepseek-ai/dsh-llm/brand' import type { SessionId, SessionSeq } from '@deepseek-ai/dsh-session/types' import type { CommandNode, CompactionSummaryNode, ConversationLocationDataStore, ConversationTurnDataMap, + ConversationGroupData, GroupSnapshot, MessageImageLoader, MessageImagesOwnerProps, RenderMessageImages, TurnLocation, } from '@deepseek-ai/dsh-client-ui-conversation/client' import type { @@ -86,6 +87,8 @@ export interface ChatNodeTurnDataInjected { /** Stable owner currency delivered to a keyed Chat renderer. */ export interface ChatNodeOwnerProps { + /** Renderer-owned Node portion selected by the grouping Definition. */ + groupPart?: string cwd?: string | undefined /** Open the current source file of a skill referenced by a sent message. */ openSkill: (name: string) => void @@ -163,6 +166,8 @@ export interface ChatViewInjected { chatNode: (key: string) => ChatNodeSource /** Resolve the stable Turn-process source for one Chat Node key. */ chatNodeProcess: (key: string) => ChatNodeProcessSource + /** Resolve one optional group without subscribing the root View to its data. */ + chatGroup: (key: string) => ObservableSnapshot> | undefined> | undefined } /** Open the current source file of a skill referenced by a sent message. */ openSkill: (name: string) => void diff --git a/packages/client/ui-chat/src/client/conversation-nodes/chat-snapshot-builder.ts b/packages/client/ui-chat/src/client/conversation-nodes/chat-snapshot-builder.ts index 8c805e6107..85b565a178 100644 --- a/packages/client/ui-chat/src/client/conversation-nodes/chat-snapshot-builder.ts +++ b/packages/client/ui-chat/src/client/conversation-nodes/chat-snapshot-builder.ts @@ -2,7 +2,7 @@ import type { Context } from '@deepseek-ai/cordis' import { notifySubscribers } from '@deepseek-ai/dsh-client-store' import type { ConversationLocation, ConversationNode, ConversationTimelineSnapshot, ConversationViewBuilder, - ConversationViewDefinition, PartialAssistant, RunningToolCall, + ConversationViewDefinition, ConversationGroupInput, GroupNodePosition, NodeChange, NodeKey, PartialAssistant, RunningToolCall, } from '@deepseek-ai/dsh-client-ui-conversation/client' import type { ChatConversationViewNode, ChatNode } from '../contract/chat-nodes.ts' import { isRunningTool } from '../contract/chat-nodes.ts' @@ -164,6 +164,7 @@ class MutableChatNodeStore implements ChatNodeStore { class MutableChatLocationIndex implements ChatLocationNodeIndex { private turns = new Map() private steps = new Map() + private positions = new Map() getTurn(turn: number): readonly string[] { return this.turns.get(turn) ?? EMPTY_KEYS @@ -173,13 +174,32 @@ class MutableChatLocationIndex implements ChatLocationNodeIndex { return this.steps.get(stepKey(turn, step)) ?? EMPTY_KEYS } - rebuild(order: readonly string[], store: ChatNodeStore): void { + getPosition(key: string): GroupNodePosition | undefined { + return this.positions.get(key) + } + + rebuild(order: readonly string[], store: ChatNodeStore): readonly number[] { const turns = new Map() const steps = new Map() - for (const key of order) { + const positions = new Map() + const changedTurns = new Set() + for (const [index, key] of order.entries()) { const location = store.get(key)?.location if (location === undefined) continue const coordinates = locationCoordinates(location) + const previous = this.positions.get(key) + const position: GroupNodePosition = { + turn: coordinates.turn, + previous: order[index - 1] as NodeKey | undefined, + next: order[index + 1] as NodeKey | undefined, + } + const unchanged = previous !== undefined && previous.turn === position.turn + && previous.previous === position.previous && previous.next === position.next + positions.set(key, unchanged ? previous : position) + if (!unchanged) { + if (previous?.turn !== undefined) changedTurns.add(previous.turn) + if (position.turn !== undefined) changedTurns.add(position.turn) + } if (coordinates.turn === undefined) continue const turnKeys = turns.get(coordinates.turn) ?? [] turnKeys.push(key) @@ -190,8 +210,13 @@ class MutableChatLocationIndex implements ChatLocationNodeIndex { stepKeys.push(key) steps.set(step, stepKeys) } + for (const [key, position] of this.positions) { + if (!positions.has(key) && position.turn !== undefined) changedTurns.add(position.turn) + } + this.positions = positions this.turns = updateIndex(this.turns, turns) this.steps = updateIndex(this.steps, steps) + return [...changedTurns] } /** Invalidate aggregate readers when member data changes without moving. */ @@ -966,12 +991,24 @@ export class ChatSnapshotBuilder implements ConversationViewBuilder + private readonly readGroupNode = (key: NodeKey): ChatConversationViewNode | undefined => this.store.get(key) + private readonly readGroupTurn = (turn: number): readonly NodeKey[] => this.locations.getTurn(turn) as readonly NodeKey[] + private readonly readGroupPosition = (key: NodeKey): GroupNodePosition | undefined => this.locations.getPosition(key) /** Last published timeline: a Turn boundary can land without a new node. */ private timeline: ConversationTimelineSnapshot | null = null readonly empty: ChatSnapshot constructor() { this.empty = this.snapshot({ turnOrder: EMPTY_TURNS, turns: new Map() }) + this.latestGroupInput = { + kind: 'replace', + order: this.order as readonly NodeKey[], + readNode: this.readGroupNode, + readTurn: this.readGroupTurn, + readPosition: this.readGroupPosition, + timeline: this.empty.timeline, + } } replace(input: { @@ -985,21 +1022,31 @@ export class ChatSnapshotBuilder implements ConversationViewBuilder() let structural = false const contentOnly: ChatConversationViewNode[] = [] + const changes: NodeChange[] = [] for (const node of upserts) { const previous = this.store.get(node.key) + if (previous !== node) changes.push({ previous, current: node }) const nodeStructural = previous === undefined || previous.kind !== node.kind || previous.anchorSeq !== node.anchorSeq @@ -1015,10 +1062,11 @@ export class ChatSnapshotBuilder implements ConversationViewBuilder node.key) this.order = sameReferences(this.order, next) ? this.order : next - this.locations.rebuild(this.order, this.store) + changedTurnOrders = this.locations.rebuild(this.order, this.store) } this.locations.touch(contentOnly) this.store.updateProcesses(processTurns, this.locations) @@ -1028,11 +1076,29 @@ export class ChatSnapshotBuilder implements ConversationViewBuilder { + return this.latestGroupInput + } + + publish(): void { + this.store.publish() + } + private snapshot( timeline: ConversationTimelineSnapshot, legacy = this.legacy.replace(EMPTY_LIST, timeline), 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 dff3bbe376..9728a0b409 100644 --- a/packages/client/ui-chat/tests/apply-inject.client.spec.tsx +++ b/packages/client/ui-chat/tests/apply-inject.client.spec.tsx @@ -12,6 +12,7 @@ import type { SessionBehaviorOverrides } from '@deepseek-ai/dsh-client-test-runt import type { ClientRemote } from '@deepseek-ai/dsh-api-remotes/client' import { apply as applyConversation, inject as injectConversation, + type GroupKey, } from '@deepseek-ai/dsh-client-ui-conversation/client' import { apply as applyChat, inject as injectChat, type ChatViewInjected, @@ -22,6 +23,12 @@ import { createChatStore } from '../src/client/stores.ts' import { CHAT_SETTINGS_NAMESPACE, type ChatSettings } from '../src/chat-settings.ts' import type { LinkOpeningRowInjected } from '../src/client/settings/LinkOpeningRow.tsx' +declare module '@deepseek-ai/dsh-client-ui-conversation/client' { + interface ConversationGroupDataMap { + chat: number + } +} + usePinnedBrowserLanguages('zh-CN') const ROOT = 'root-1' as SessionId @@ -127,6 +134,36 @@ async function bench(initialSettings?: ChatSettings, withBrowserRegistry = true) } describe('Chat inject API', () => { + it('resolves keyed Group sources across registration, activation, and removal', async () => { + const b = await bench() + try { + const { injected } = b.chatViewApi(b.rootReference) + const key = 'injected-group' as GroupKey + expect(injected.keyedHooks.chatGroup(key)).toBeUndefined() + const conversation = b.runtime.ctx.uiConversation + const remove = conversation.groups.register({ + kind: 'test-group', target: 'chat', + create: () => null, + update: () => null, + buildGroups: () => ({ + entries: [{ kind: 'group', key }], + groups: { kind: 'replace', snapshots: [{ key, data: 1, members: [] }] }, + }), + }) + await Promise.resolve() + conversation.binding(b.rootReference.binding).activate('chat') + const source = injected.keyedHooks.chatGroup(key) + expect(source?.getSnapshot()?.data).toBe(1) + expect(injected.keyedHooks.chatGroup(key)).toBe(source) + remove() + await Promise.resolve() + expect(source?.getSnapshot()).toBeUndefined() + expect(injected.keyedHooks.chatGroup(key)).toBeUndefined() + } finally { + await b.runtime.dispose() + } + }) + it('loads older history and forks through the Session Controller', async () => { const b = await bench() const { injected } = b.chatViewApi(b.rootReference) diff --git a/packages/client/ui-chat/tests/chat-node-source.client.spec.ts b/packages/client/ui-chat/tests/chat-node-source.client.spec.ts index 1209380799..17cd7942b6 100644 --- a/packages/client/ui-chat/tests/chat-node-source.client.spec.ts +++ b/packages/client/ui-chat/tests/chat-node-source.client.spec.ts @@ -28,6 +28,44 @@ function userNode(index: number, text = `message ${String(index)}`): ChatConvers } describe('Chat Node keyed sources', () => { + it('provides ordered projected Nodes and previous values to grouping', () => { + const builder = new ChatSnapshotBuilder() + const user = userNode(1) + const snapshot = builder.replace({ nodes: [user], timeline }) + const initial = builder.groupInput() + expect(initial.kind).toBe('replace') + expect(initial.order).toBe(snapshot.order) + expect(initial.readNode(initial.order[0]!)).toBe(user) + expect(initial.readPosition(initial.order[0]!)).toEqual({ turn: undefined, previous: undefined, next: undefined }) + expect(initial.readTurn(7)).toEqual([]) + const context: ChatConversationViewNode = { + key: 'context:2', kind: 'context', id: '2', target: 'chat', anchorSeq: 2, + location: { kind: 'session' }, visibility: 'visible', + data: { + kind: 'context', seq: 2, time: 2, content: [], + source: { kind: 'skill-invocation', name: 'demo', form: 'instructions' }, + }, + } + const next = builder.apply({ upserts: [context], timeline, changedTurns: [7] }) + const input = builder.groupInput() + if (input.kind !== 'apply') throw new Error('expected apply') + expect(input.order).toBe(next.order) + expect(input.changedTurns).toEqual([7]) + expect(input.changedTurnOrders).toEqual([]) + expect(input.readPosition(context.key as typeof input.order[number])).toEqual({ + turn: undefined, previous: user.key, next: undefined, + }) + expect(input.readPosition('missing' as typeof input.order[number])).toBeUndefined() + expect(input.changes.find(change => change.current.key === user.key)).toEqual({ + previous: user, current: next.nodes.get(user.key), + }) + expect(next.nodes.get(user.key)?.data).toMatchObject({ skillNames: ['demo'] }) + expect(input.changes.find(change => change.current.key === context.key)).toEqual({ previous: undefined, current: context }) + builder.apply({ upserts: [], timeline }) + expect(builder.groupInput()).toMatchObject({ kind: 'apply', changes: [], changedTurns: [], changedTurnOrders: [] }) + expect(builder.groupInput().order).toBe(next.order) + }) + it('notifies only the updated key among 4,000 mounted sources', () => { const builder = new ChatSnapshotBuilder() const nodes = Array.from({ length: 4_000 }, (_, index) => userNode(index + 1)) @@ -43,6 +81,9 @@ describe('Chat Node keyed sources', () => { const changed = userNode(target + 1, 'streamed update') const next = builder.apply({ upserts: [changed], timeline }) + expect(listeners[target]).not.toHaveBeenCalled() + builder.publish() + expect(listeners[target]).toHaveBeenCalledOnce() expect(listeners.reduce((count, listener) => count + listener.mock.calls.length, 0)).toBe(1) expect(next.nodes.source(changed.key)).toBe(sources[target]) 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 db60f029a9..9ff18e1ab8 100644 --- a/packages/client/ui-chat/tests/chat-view.client.spec.tsx +++ b/packages/client/ui-chat/tests/chat-view.client.spec.tsx @@ -16,7 +16,8 @@ import type { SessionListState, SessionSnapshot, } from '@deepseek-ai/dsh-api-session-controller/client' import type { - ConversationLocationDataStore, ConversationTurnDataMap, + ConversationLocationDataStore, ConversationTurnDataMap, ConversationGroupedView, + ConversationSnapshot, ConversationViewSnapshotStore, GroupKey, GroupSnapshot, NodeKey, } from '@deepseek-ai/dsh-client-ui-conversation/client' import type { WorkspaceSnapshot } from '@deepseek-ai/dsh-api-workspace-controller/client' import type { SessionId } from '@deepseek-ai/dsh-session/types' @@ -47,6 +48,13 @@ import { ChatSnapshotBuilder } from '../src/client/conversation-nodes/chat-snaps import type { TurnProcessSpec } from '../src/client/contract/turn-process.ts' import { chatSnapshotFixture } from './chat-snapshot-fixture.client.ts' import { installTurnNavigatorObserver } from './turn-navigator-fixture.ts' +import { ConversationGroupStore } from '../../ui-conversation/src/client/conversation/group-store.ts' + +declare module '@deepseek-ai/dsh-client-ui-conversation/client' { + interface ConversationGroupDataMap { + chat: number + } +} // Every session-scope fixture carries the resource hook the resources plugin merges into GlobalStandardProps. const useResource = (() => ({ status: 'none' as const, value: undefined, failure: undefined })) as GlobalStandardProps['useResource'] @@ -259,6 +267,18 @@ function makeHarness( const useChatNodeProcess = bindKeyedSnapshotSelector( key => chatSource.source.getSnapshot().nodes.processSource(key), ) + let grouped: ConversationGroupedView | undefined + const absentGroup = createSnapshotStore | undefined>(undefined) + const conversation = createSnapshotStore({ + ...EMPTY_CONVERSATION_SNAPSHOT, + views: { + ...EMPTY_CONVERSATION_SNAPSHOT.views, + grouped: (() => grouped) as ConversationViewSnapshotStore['grouped'], + }, + }) + const useChatGroup = bindKeyedSnapshotSelector( + key => grouped?.groupSource(key as GroupKey) ?? absentGroup, + ) const openFile = vi.fn<(path: string) => Promise>().mockResolvedValue(undefined) const openSkill = vi.fn<(name: string) => void>() const loadOlder = vi.fn() @@ -380,7 +400,8 @@ function makeHarness( useChat: bindSnapshotSelector(chatSource.source), useChatNode, useChatNodeProcess, - useConversation: bindSnapshotSelector(createSnapshotStore(EMPTY_CONVERSATION_SNAPSHOT)), + useChatGroup, + useConversation: bindSnapshotSelector(conversation), useTrajectory: (() => { throw new Error('unused') }), useSessions: emptySessions(), useSessionRetainInfo: () => undefined, @@ -448,6 +469,10 @@ function makeHarness( setOutline: (value: unknown) => { outlineValue = value }, chatScroll, forkAt, toolOwners, setPerformanceUsage: (mode: 'compact' | 'detailed') => { performanceUsage.set(mode) }, + setGrouped: (value: ConversationGroupedView | undefined) => { + grouped = value + conversation.set({ ...conversation.getSnapshot() }) + }, setTranscriptView: (mode: TranscriptViewMode) => { transcriptView.set(mode) }, setNodeRenderer: (renderer: React.ComponentProps['renderSlot']) => { nodeSlotOverride = renderer @@ -484,10 +509,12 @@ function withSystemPrompt( visibility: 'visible', data: { text }, } - return builder.replace({ + const next = builder.replace({ nodes: [prompt, ...snapshot.nodes.values()], timeline: snapshot.timeline, }) + builder.publish() + return next } function renderedFlowKinds(container: HTMLElement): Array { @@ -582,6 +609,125 @@ describe('Chat node rendering', () => { }) describe('ChatView', () => { + it('renders only referenced Nodes without deleting unreferenced Node data', () => { + const snapshot = chatSnapshotFixture({ nodes: [user(1, 'included'), user(2, 'omitted')] }) + const h = makeHarness({}, {}, snapshot) + const [included, omitted] = snapshot.order + if (included === undefined || omitted === undefined) throw new Error('expected two Nodes') + const groupStore = new ConversationGroupStore() + groupStore.prepareAndInstall({ + entries: [{ kind: 'node', key: included as NodeKey }], + groups: { kind: 'replace', snapshots: [] }, + }, id => snapshot.nodes.get(id)) + h.setGrouped(groupStore) + + const view = render() + expect(view.getByText('included')).toBeTruthy() + expect(view.queryByText('omitted')).toBeNull() + expect(snapshot.nodes.get(omitted)).toBeDefined() + }) + + it('keeps grouped Node instances mounted across presentation modes and group data updates', () => { + const snapshot = chatSnapshotFixture({ nodes: [user(1, 'outside'), user(2, 'inside')] }) + const h = makeHarness({}, {}, snapshot) + const [outside, inside] = snapshot.order.map(key => ({ kind: 'node' as const, key: key as NodeKey })) + if (outside === undefined || inside === undefined) throw new Error('expected two Nodes') + const key = 'process' as GroupKey + const groupStore = new ConversationGroupStore() + const record = { key, data: 0, members: [inside] } + groupStore.prepareAndInstall({ + entries: [outside, { kind: 'group', key }], + groups: { kind: 'replace', snapshots: [record] }, + }, id => snapshot.nodes.get(id)) + groupStore.publish() + h.setGrouped(groupStore) + h.setNodeRenderer(((slot: string, owner: object) => { + if (slot !== 'conversation.chat.node' || !('node' in owner)) return null + const node = (owner as RoutedChatNodeOwner).node + return + }) as ChatViewSlotProps['renderSlot']) + const view = render() + const input = view.getByRole('textbox', { name: inside.key }) as HTMLInputElement + const parent = input.closest('[data-chat-group-key]') + expect(parent).not.toBeNull() + expect(parent?.tagName).toBe('DIV') + fireEvent.change(input, { target: { value: 'retained local input' } }) + for (const mode of ['detailed', 'expanded', 'compact'] as const) { + act(() => { h.setTranscriptView(mode) }) + expect(view.getByRole('textbox', { name: inside.key })).toBe(input) + expect(input.value).toBe('retained local input') + expect(input.closest('[data-chat-group-key]')).toBe(parent) + } + act(() => { + groupStore.prepareAndInstall({ groups: { kind: 'apply', upserts: [{ ...record, data: 1 }], removes: [] } }, id => snapshot.nodes.get(id)) + groupStore.publish() + }) + expect(view.getByRole('textbox', { name: inside.key })).toBe(input) + expect(view.getByRole('textbox', { name: outside.key }).closest('[data-chat-group-key]')).toBeNull() + act(() => { + groupStore.clear() + groupStore.publish() + }) + expect(view.queryByRole('textbox', { name: inside.key })).toBeNull() + act(() => { h.setGrouped(groupStore) }) + expect(view.queryByRole('textbox', { name: outside.key })).toBeNull() + }) + + it('passes independently keyed group parts to business Node renderers', () => { + const snapshot = chatSnapshotFixture({ nodes: [assistant(1, 'answer')] }) + const h = makeHarness({}, {}, snapshot) + const nodeKey = snapshot.order.find(key => snapshot.nodes.get(key)?.kind === 'assistant-step') as NodeKey + const key = 'parts' as GroupKey + const groupStore = new ConversationGroupStore() + groupStore.prepareAndInstall({ + entries: [{ kind: 'group', key }, { kind: 'node', key: nodeKey, groupPart: 'response' }], + groups: { kind: 'replace', snapshots: [{ key, data: 0, members: [{ kind: 'node', key: nodeKey, groupPart: 'reasoning' }] }] }, + }, id => snapshot.nodes.get(id)) + h.setGrouped(groupStore) + h.setNodeRenderer(((slot: string, owner: object) => { + if (slot !== 'conversation.chat.node' || !('node' in owner)) return null + return {(owner as RoutedChatNodeOwner).groupPart} + }) as ChatViewSlotProps['renderSlot']) + const view = render() + expect(view.getByText('reasoning').closest('[data-chat-group-key]')).not.toBeNull() + expect(view.getByText('response').closest('[data-chat-group-key]')).toBeNull() + const anchors = [...view.container.querySelectorAll('[data-chat-group-part]')] + .map(element => element.getAttribute('data-chat-flow-key')) + expect(new Set(anchors).size).toBe(2) + expect([...view.container.querySelectorAll('[data-chat-group-part]')] + .map(element => element.getAttribute('data-chat-node-key'))).toEqual([nodeKey, nodeKey]) + }) + + it('rebinds grouped members when registry rebuilding replaces the Node store', () => { + const snapshot = chatSnapshotFixture({ nodes: [user(1, 'before rebuild')] }) + const h = makeHarness({}, {}, snapshot) + const key = 'retained-group' as GroupKey + const members = snapshot.order.map(id => ({ kind: 'node' as const, key: id as NodeKey })) + const groups = new ConversationGroupStore() + groups.prepareAndInstall({ + entries: [{ kind: 'group', key }], + groups: { kind: 'replace', snapshots: [{ key, data: 0, members }] }, + }, id => snapshot.nodes.get(id)) + groups.publish() + h.setGrouped(groups) + const view = render() + const original = view.getByText('before rebuild').closest('[data-chat-anchor-key]') + const builder = new ChatSnapshotBuilder() + const replacement = builder.replace({ nodes: snapshot.nodes.values(), timeline: snapshot.timeline }) + act(() => { h.set({ chat: replacement }) }) + const current = replacement.nodes.get(members[0]!.key) as ChatNode<'user'> + act(() => { + builder.apply({ + upserts: [{ ...current, data: { ...current.data, content: [{ type: 'text', text: 'after rebuild' }] } }], + timeline: snapshot.timeline, + }) + builder.publish() + }) + expect(view.queryByText('before rebuild')).toBeNull() + expect(view.getByText('after rebuild').closest('[data-chat-anchor-key]')).toBe(original) + expect(groups.groupSource(key).getSnapshot()?.members).toBe(members) + }) + it('leaves the turn rail unrendered when an unrelated Chat update commits', () => { const snapshot = chatSnapshotFixture({ nodes: [ @@ -1875,6 +2021,7 @@ describe('ChatView', () => { act(() => { turnData.publish() + builder.publish() h.set({ chat: complete, hasMore: false }) }) expect(turnProcessControl(view.container)?.getAttribute('aria-expanded')).toBe('false') diff --git a/packages/client/ui-chat/tests/chat-viewport.client.spec.ts b/packages/client/ui-chat/tests/chat-viewport.client.spec.ts new file mode 100644 index 0000000000..09303fdde9 --- /dev/null +++ b/packages/client/ui-chat/tests/chat-viewport.client.spec.ts @@ -0,0 +1,114 @@ +// @vitest-environment jsdom + +import { expect, it, onTestFinished, vi } from 'vitest' +import { ChatViewport } from '../src/client/chat/use-chat-viewport.ts' + +function fixture() { + const column = document.createElement('div') + column.dataset.chatFlow = '' + document.body.append(column) + const viewport = new ChatViewport() + const hitTest = Object.getOwnPropertyDescriptor(document, 'elementsFromPoint') + Object.defineProperty(document, 'elementsFromPoint', { configurable: true, value: undefined }) + onTestFinished(() => { + viewport.detach() + column.remove() + if (hitTest === undefined) Reflect.deleteProperty(document, 'elementsFromPoint') + else Object.defineProperty(document, 'elementsFromPoint', hitTest) + vi.restoreAllMocks() + }) + Object.defineProperties(column, { + clientHeight: { configurable: true, value: 300 }, + scrollHeight: { configurable: true, value: 2_000 }, + }) + vi.spyOn(column, 'getBoundingClientRect').mockReturnValue(new DOMRect(0, 0, 500, 300)) + viewport.attach(column, column) + let prependedHeight = 0 + const group = () => { + const element = document.createElement('div') + element.dataset.chatGroupKey = 'group' + element.style.display = 'contents' + column.append(element) + return element + } + const row = (parent: HTMLElement, nodeKey: string, top: number, part?: string) => { + const element = document.createElement('div') + element.textContent = nodeKey + element.dataset.chatNodeKey = nodeKey + element.dataset.chatAnchorKey = part === undefined ? nodeKey : JSON.stringify([nodeKey, part]) + element.dataset.chatFlowKey = element.dataset.chatAnchorKey + element.dataset.chatTurn = '1' + parent.append(element) + vi.spyOn(element, 'getBoundingClientRect').mockImplementation(() => + new DOMRect(0, top + prependedHeight - column.scrollTop, 500, 60)) + return element + } + return { column, viewport, group, row, prepend: (height: number) => { prependedHeight += height } } +} + +it('captures a grouped member and preserves its reading position after history grows above it', () => { + const h = fixture() + const group = h.group() + h.row(group, 'first', 20) + h.row(group, 'second', 130) + h.column.scrollTop = 100 + + const position = h.viewport.capturePosition() + expect(position).toEqual({ anchorKey: 'second', anchorTop: 30, scrollTop: 100 }) + if (position === null) throw new Error('expected a grouped reading anchor') + h.prepend(200) + const landing = h.viewport.preserve(position) + expect(landing?.metrics.top).toBe(300) + expect(landing?.position).toEqual({ anchorKey: 'second', anchorTop: 30, scrollTop: 300 }) +}) + +it('navigates by Node identity while retaining a distinct visible part anchor', () => { + const h = fixture() + const group = h.group() + const reasoning = h.row(group, 'assistant', 100, 'reasoning') + const response = h.row(group, 'assistant', 200, 'response') + h.viewport.updateTurns([{ turn: 1, anchorKey: 'assistant', prompt: '', response: '' }]) + + expect(h.viewport.scrollToTurn(1)?.position?.anchorKey).toBe(reasoning.dataset.chatAnchorKey) + reasoning.setAttribute('hidden', 'until-found') + const landing = h.viewport.scrollToTurn(1) + expect(landing?.metrics.top).toBe(176) + expect(landing?.position?.anchorKey).toBe(response.dataset.chatAnchorKey) + expect(h.viewport.restore({ anchorKey: response.dataset.chatAnchorKey!, anchorTop: 8, scrollTop: 0 })?.metrics.top) + .toBe(192) +}) + +it('excludes empty rows, hidden ancestors, and transparent group shells from fallback sampling', () => { + const h = fixture() + const hidden = h.group() + hidden.setAttribute('hidden', 'until-found') + h.row(hidden, 'hidden', 10) + const group = h.group() + group.dataset.chatFlowKey = 'group' + group.dataset.chatAnchorKey = 'group' + const empty = h.row(group, 'empty', 30) + empty.textContent = '' + h.row(group, 'visible', 70) + + expect(h.viewport.capturePosition()?.anchorKey).toBe('visible') +}) + +it('skips hidden group members when navigating to a fallback Turn row', () => { + const h = fixture() + const hidden = h.group() + hidden.setAttribute('hidden', 'until-found') + h.row(hidden, 'hidden', 10, 'reasoning') + const visible = h.row(h.group(), 'visible', 150, 'response') + + const landing = h.viewport.scrollToTurnAtOrAfter(1) + expect(landing?.metrics.top).toBe(126) + expect(landing?.position?.anchorKey).toBe(visible.dataset.chatAnchorKey) +}) + +it('retains the original capture and Turn navigation path for ungrouped Nodes', () => { + const h = fixture() + h.row(h.column, 'whole', 90) + h.viewport.updateTurns([{ turn: 1, anchorKey: 'whole', prompt: '', response: '' }]) + expect(h.viewport.capturePosition()?.anchorKey).toBe('whole') + expect(h.viewport.scrollToTurn(1)?.metrics.top).toBe(66) +}) diff --git a/packages/client/ui-chat/tsconfig.json b/packages/client/ui-chat/tsconfig.json index 92dffe85b0..ab6915a8a6 100644 --- a/packages/client/ui-chat/tsconfig.json +++ b/packages/client/ui-chat/tsconfig.json @@ -8,6 +8,9 @@ "src" ], "references": [ + { + "path": "../../util/values" + }, { "path": "../../../vendor/cordis" }, diff --git a/packages/client/ui-conversation/README.i18n.yaml b/packages/client/ui-conversation/README.i18n.yaml index 2f52a3e505..60fd5ca07b 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: 31b35ef7460c9fecd119e32ae5f696f55585a726 -README.zh.md: 1fb648bf9539cebc307e094c2fe46b1d1749edab +README.md: 6f115521a090ca32686af417a7ceea0634324c7d +README.zh.md: babf6f5cfb0760b471988cf27017e20b46a85868 diff --git a/packages/client/ui-conversation/README.md b/packages/client/ui-conversation/README.md index 31b35ef746..6f115521a0 100644 --- a/packages/client/ui-conversation/README.md +++ b/packages/client/ui-conversation/README.md @@ -31,6 +31,10 @@ The adapter passes each `SessionEventLikeEntry` directly to the assembler. Its o A target becomes active when shell selection resolves it or when its source receives a first subscriber. The assembler replaces that target from current Contexts once and keeps it active for later incremental flushes; creating a source does not activate it and unsubscription does not deactivate it. +`UiConversation.groups` registers one optional business Group Definition per existing target. It consumes projected Node changes, changed Turns, and indexed target positions after Node materialization, including first activation, and owns all segmentation and group data. Indexed Turn reads preserve interruptions from neighbouring Nodes, allowing business updates to stay within affected Turns and groups. The assembler validates and installs root references and keyed group snapshots before publishing Node, Group, and Location data sources. Ungrouped targets retain their original path. [Grouping](../../../docs/subsystems/conversation.md#group-definitions) defines input lifetime, typed registration, atomic updates, and renderer responsibilities. + +Group registration rejects a missing View target. Removing an already registered View Definition pauses its grouping and clears its published results without removing the Group Definition; registering that View again rebuilds from the current loaded timeline through the existing replacement flow. Switching View tabs does not remove a View Definition. + Target packages declaration-merge their snapshot and Location data maps, then register with `ctx.uiConversation.events.register(...)` and `ctx.uiConversation.views.register(...)`. A target reads its Session-owned source with `ctx.uiConversation.binding(binding).target(targetId)`. Registrations are Cordis effects and their returned disposers remove the contribution from the same registry. The shared request inspection serves every target: `ctx.uiConversation.inspectSystemPrompt(previous, event)` interprets system messages and positional replacements as immutable loaded-surface state. It selects the last nonempty surviving system node in surface order, retains only surviving replacement positions for chained rewrites, and withholds the prompt after an unindexed older endpoint until prepend replay supplies its order. Target-owned Definitions retain historical cards independently. `ctx.uiConversation.inspectRequestPrompt(previous, header, system)` classifies request changes against that effective prompt; ordinary messages and stream chunks require no system-state work. diff --git a/packages/client/ui-conversation/README.zh.md b/packages/client/ui-conversation/README.zh.md index 1fb648bf95..babf6f5cfb 100644 --- a/packages/client/ui-conversation/README.zh.md +++ b/packages/client/ui-conversation/README.zh.md @@ -31,6 +31,10 @@ kind: "package-reference" shell 选择解析出 target 或 target source 收到首个 subscriber 时,该 target 进入 active 状态。assembler 从当前 Context 对它执行一次 replace,并使它参与后续增量 flush;创建 source 不会激活 target,取消订阅也不会停用 target。 +`UiConversation.groups` 为每个已注册目标注册一个可选的业务 Group Definition。它在节点物化后消费投影后的节点变化、变化轮次及已索引的目标位置,覆盖首次激活,并拥有全部分段规则与组数据。按索引读取 Turn 时保留相邻 Node 造成的分隔,使业务更新可以限制在受影响轮次和组内。assembler 先校验并安装根引用和按键索引的组快照,再发布 Node、Group 和 Location 数据来源。未分组目标保持原有路径。[分组](../../../docs/subsystems/conversation.zh.md#group-definitions)定义输入有效期、类型化注册、原子更新及渲染器职责。 + +Group 注册时缺少 View 目标会报错。已注册的 View Definition 被移除后,其分组计算暂停并清空已发布结果,但保留 Group Definition;重新注册该 View 后,沿现有替换流程从当前已加载时间线重建。切换 View 页签不会移除 View Definition。 + target package 通过 declaration merge 扩展 snapshot 与 Location data map,再调用 `ctx.uiConversation.events.register(...)` 和 `ctx.uiConversation.views.register(...)`。target 通过 `ctx.uiConversation.binding(binding).target(targetId)` 读取其 Session-owned source。注册属于 Cordis effect,返回的 disposer 从同一个 registry 移除 contribution。共享的请求检查服务于每个 target:`ctx.uiConversation.inspectSystemPrompt(previous, event)` 将系统消息与位置替换解释为不可变的已加载 surface 状态。它按 surface 顺序选择最后一个非空的存活系统节点,为连续重写只保留存活的替换位置;遇到未建立索引的更早端点后,提示词保持不可用,直到向前补页回放提供其顺序。target 自有的 Definition 独立保留历史卡片。`ctx.uiConversation.inspectRequestPrompt(previous, header, system)` 根据该有效提示词分类请求变更;普通消息与流式分片无需处理系统状态。 diff --git a/packages/client/ui-conversation/src/client/contract/conversation.ts b/packages/client/ui-conversation/src/client/contract/conversation.ts index d5cbe27108..333dd1e5ae 100644 --- a/packages/client/ui-conversation/src/client/contract/conversation.ts +++ b/packages/client/ui-conversation/src/client/contract/conversation.ts @@ -1,5 +1,8 @@ import type { SessionEventLike } from '@deepseek-ai/dsh-api-session-controller/client' import type { SessionEvent } from '@deepseek-ai/dsh-session/types' +import type { + ConversationGroupData, ConversationGroupedView, ConversationGroupInput, +} from './groups.ts' /** Definition-local identity and lifecycle role extracted from one event. */ export interface ConversationMatchResult { @@ -141,6 +144,14 @@ export interface ConversationViewSnapshotStore { get>( target: Target, ): ConversationViewSnapshotMap[Target] | undefined + /** + * Read grouping for an activated target without activating another target. + * @param target - registered View target. + * @returns its grouped reader, when a Group Definition is active. + */ + grouped( + target: Target, + ): ConversationGroupedView> | undefined } /** Immutable public view of an assembled business Context. */ @@ -261,6 +272,7 @@ export interface ConversationViewBuilder + /** Publish local sources after all target snapshots and grouping results have been installed. */ + publish?(): void } /** Registry contribution that creates an isolated builder when a Session first uses this target. */ diff --git a/packages/client/ui-conversation/src/client/contract/groups.ts b/packages/client/ui-conversation/src/client/contract/groups.ts new file mode 100644 index 0000000000..e0fa2735e1 --- /dev/null +++ b/packages/client/ui-conversation/src/client/contract/groups.ts @@ -0,0 +1,134 @@ +/** Definition-owned grouping over a target's already materialized Nodes. */ +import type { Branded } from '@deepseek-ai/dsh-brand' +import type { ObservableSnapshot } from '@deepseek-ai/dsh-client-store' +import type { ConversationTimelineSnapshot, ConversationViewNode } from './conversation.ts' + +/** Existing Node identity, without allocating another execution Node. */ +export type NodeKey = Branded<'ConversationNodeKey'> + +/** Definition-local group identity within one Session and target. */ +export type GroupKey = Branded<'ConversationGroupKey'> + +/** One whole Node or a renderer-owned part of that Node. */ +export interface NodeReference { + readonly kind: 'node' + readonly key: NodeKey + readonly groupPart?: string +} + +/** Root-list reference to a separately observed group. */ +export interface GroupReference { + readonly kind: 'group' + readonly key: GroupKey +} + +/** The two supported root rendering positions. */ +export type RenderEntry = NodeReference | GroupReference + +/** Immutable group data and its ordered, non-nested Node references. */ +export interface GroupSnapshot { + readonly key: GroupKey + readonly data: Data + readonly members: readonly NodeReference[] +} + +/** + * One atomic grouping update. Replacement input requires entries and a complete + * group replacement; apply input may omit entries to preserve the root list. + * Group removal never deletes source Nodes. + */ +export interface GroupUpdate { + /** Complete root sequence; only its Node references and referenced group members render. */ + readonly entries?: readonly RenderEntry[] + readonly groups: + | { readonly kind: 'replace'; readonly snapshots: readonly GroupSnapshot[] } + | { + readonly kind: 'apply' + readonly upserts: readonly GroupSnapshot[] + readonly removes: readonly GroupKey[] + } +} + +/** Target-processed Node values before and after one update. */ +export interface NodeChange { + readonly previous: Node | undefined + readonly current: Node +} + +/** Owning Turn and immediate neighbours in the target's visible Node order. */ +export interface GroupNodePosition { + readonly turn: number | undefined + readonly previous: NodeKey | undefined + readonly next: NodeKey | undefined +} + +interface GroupInputNodes { + readonly order: readonly NodeKey[] + /** @param key - target Node identity. @returns its current value during this synchronous update. */ + readonly readNode: (key: NodeKey) => Node | undefined + /** @param turn - owning Turn. @returns its visible keys in target order; empty when absent. */ + readonly readTurn: (turn: number) => readonly NodeKey[] + /** @param key - target Node identity. @returns its visible position, or absence outside the target order. */ + readonly readPosition: (key: NodeKey) => GroupNodePosition | undefined + readonly timeline: ConversationTimelineSnapshot +} + +/** Target-local inputs; apply includes lifecycle changes without Node upserts. */ +export type ConversationGroupInput = GroupInputNodes & ( + | { readonly kind: 'replace' } + | { + readonly kind: 'apply' + readonly changes: readonly NodeChange[] + readonly changedTurns: readonly number[] + /** Turns whose visible keys or immediate neighbours changed, including removal and cross-Turn moves. */ + readonly changedTurnOrders: readonly number[] + } +) + +/** Framework-owned State for one Session's registered Group Definition. */ +export interface ConversationGroupContext { + readonly state: State +} + +/** Business grouping rules, driven after the target Builder has installed Nodes. */ +export interface ConversationGroupDefinition< + Node extends ConversationViewNode = ConversationViewNode, + State = unknown, + Data = unknown, +> { + readonly kind: string + readonly target: string + /** @returns initial Session-local grouping State. */ + create(): State + /** + * Consume target Node changes, not raw Session events or presentation modes. + * @param context - current Session-local State. + * @param input - current target inputs; its readers are valid only during this call. + * @returns State adopted by the framework. + */ + update(context: ConversationGroupContext, input: ConversationGroupInput): State + /** + * Materialize pending grouping changes without changing the business State. + * @param context - State adopted after update. + * @returns complete output for replace input; an update or null when unchanged for apply input. + */ + buildGroups(context: ConversationGroupContext): GroupUpdate | null +} + +/** Merge-extensible group data associated with each registered target. */ +export interface ConversationGroupDataMap {} + +/** Group data declared by a target; undeclared targets have no group payload. */ +export type ConversationGroupData = + Target extends keyof ConversationGroupDataMap ? ConversationGroupDataMap[Target] : never + +/** Stable grouped reader; data-only updates preserve entries and member arrays. */ +export interface ConversationGroupedView { + readonly entries: readonly RenderEntry[] + /** + * Observe a group without subscribing to the root sequence. + * @param key - group identity within this target. + * @returns an identity-stable source, whose value is absent after removal. + */ + groupSource(key: GroupKey): ObservableSnapshot | undefined> +} diff --git a/packages/client/ui-conversation/src/client/contract/snapshot.ts b/packages/client/ui-conversation/src/client/contract/snapshot.ts index 4b3fa13a9f..9982999e30 100644 --- a/packages/client/ui-conversation/src/client/contract/snapshot.ts +++ b/packages/client/ui-conversation/src/client/contract/snapshot.ts @@ -10,7 +10,7 @@ export interface ConversationSnapshot { /** Empty Conversation value used before a Session binding is available. */ export const EMPTY_CONVERSATION_SNAPSHOT: ConversationSnapshot = { - views: { get: () => undefined }, + views: { get: () => undefined, grouped: () => undefined }, activeTargets: new Set(), } diff --git a/packages/client/ui-conversation/src/client/conversation/assembler.ts b/packages/client/ui-conversation/src/client/conversation/assembler.ts index 5259d1af0f..d51216de32 100644 --- a/packages/client/ui-conversation/src/client/conversation/assembler.ts +++ b/packages/client/ui-conversation/src/client/conversation/assembler.ts @@ -11,6 +11,10 @@ import type { ConversationViewSnapshotStore, } from '../contract/conversation.ts' import { conversationContextKey } from '../contract/conversation.ts' +import type { + ConversationGroupData, ConversationGroupDefinition, ConversationGroupedView, +} from '../contract/groups.ts' +import { ConversationGroupStore } from './group-store.ts' import { ConversationLocationIndex, type ConversationLocationDataChange, } from './location-index.ts' @@ -46,11 +50,18 @@ interface PendingMatch { interface ViewState { readonly target: string readonly definition: ConversationViewDefinition + readonly groupDefinition: ConversationGroupDefinition | undefined readonly isActive: ((snapshot: unknown) => boolean) | undefined builder: ConversationViewBuilder | undefined snapshot: unknown } +interface GroupState { + readonly definition: ConversationGroupDefinition + state: unknown + readonly store: ConversationGroupStore +} + const PUBLICATION_RANK: Record = { none: 0, 'animation-frame': 1, @@ -152,6 +163,16 @@ export interface ConversationViewDefinitions { entries(): readonly ConversationViewDefinition[] } +/** Registered grouping rules consumed by the target-neutral assembler. */ +export interface ConversationGroupDefinitions { + /** @returns grouping Definitions in registration order. */ + entries(): readonly ConversationGroupDefinition[] + /** @param target - View target. @returns its optional Group Definition. */ + forTarget(target: string): ConversationGroupDefinition | undefined +} + +const NO_GROUPS: ConversationGroupDefinitions = { entries: () => [], forTarget: () => undefined } + /** * Session-owned incremental engine that assembles business Contexts from a * contiguous Event window and materializes registered view snapshots. @@ -168,6 +189,8 @@ export class ConversationNodeAssembler implements ConversationViewSnapshotStore private readonly revised = new Set() private readonly dependents = new Map>() private readonly views = new Map() + private readonly groups = new Map() + private readonly pendingGroupStores = new Set>() private readonly activeTargets = new Set() private hasMore = false private replacePending = true @@ -176,10 +199,12 @@ export class ConversationNodeAssembler implements ConversationViewSnapshotStore /** * @param eventDefinitions - live Event Definition registry. * @param viewDefinitions - live view builder registry. + * @param groupDefinitions - optional registered grouping rules, independent of presentation modes. */ constructor( private readonly eventDefinitions: ConversationEventDefinitions, private readonly viewDefinitions: ConversationViewDefinitions, + private readonly groupDefinitions: ConversationGroupDefinitions = NO_GROUPS, ) { this.resetViewBuilders() } @@ -333,28 +358,24 @@ export class ConversationNodeAssembler implements ConversationViewSnapshotStore if (!this.replacePending && this.dirty.size === 0 && !this.timelineDirty) return false if (this.replacePending) { this.replaceLocationData() - let published = false + const updated: ViewState[] = [] + const changedTurns = this.locationIndex.takeChangedTurns() for (const target of this.activeTargets) { const view = this.views.get(target) if (view === undefined) continue - const builder = view.builder ?? view.definition.create() - view.builder = builder - view.snapshot = builder.replace({ - nodes: this.buildTargetNodes(target, this.contextsByTarget.get(target)), - timeline: this.locationIndex.snapshot(), - }) - published = true + this.updateView(view, true, this.buildTargetNodes(target, this.contextsByTarget.get(target)), changedTurns) + updated.push(view) } - this.locationIndex.publishData() this.replacePending = false this.dirty.clear() this.dirtyByTarget.clear() this.timelineDirty = false - return published + return this.publishViews(updated) } - let published = false + const updated: ViewState[] = [] if (this.applyDirtyLocationData()) this.timelineDirty = true + const changedTurns = this.locationIndex.takeChangedTurns() const timelineDirty = this.timelineDirty for (const target of this.activeTargets) { const view = this.views.get(target) @@ -363,17 +384,13 @@ export class ConversationNodeAssembler implements ConversationViewSnapshotStore if (builder === undefined) continue const upserts = this.buildTargetUpserts(target, this.dirtyByTarget.get(target)) if (upserts.length === 0 && !timelineDirty) continue - view.snapshot = builder.apply({ - upserts, - timeline: this.locationIndex.snapshot(), - }) - published = true + this.updateView(view, false, upserts, changedTurns) + updated.push(view) } - this.locationIndex.publishData() this.dirty.clear() this.dirtyByTarget.clear() this.timelineDirty = false - return published + return this.publishViews(updated) } /** @@ -389,6 +406,7 @@ export class ConversationNodeAssembler implements ConversationViewSnapshotStore this.activeTargets.add(target) if (view === undefined) return published this.replaceView(view) + this.publishViews([view]) return true } @@ -407,6 +425,12 @@ export class ConversationNodeAssembler implements ConversationViewSnapshotStore return this.snapshot(target) as ConversationViewSnapshotMap[Target] | undefined } + grouped( + target: Target, + ): ConversationGroupedView> | undefined { + return this.groups.get(target)?.store as ConversationGroupedView> | undefined + } + /** * Read targets whose owners classify their latest snapshot as visible activity. * @returns target ids contributing visible activity. @@ -827,12 +851,55 @@ export class ConversationNodeAssembler implements ConversationViewSnapshotStore } private replaceView(view: ViewState): void { + this.updateView(view, true, this.buildTargetNodes(view.target, this.contextsByTarget.get(view.target)), []) + } + + private updateView( + view: ViewState, + replacing: boolean, + nodes: readonly ConversationViewNode[], + changedTurns: readonly number[], + ): void { const builder = view.builder ?? view.definition.create() + const definition = view.groupDefinition + if (definition !== undefined && builder.groupInput === undefined) { + throw new Error(`conversation group target "${view.target}" requires builder.groupInput()`) + } view.builder = builder - view.snapshot = builder.replace({ - nodes: this.buildTargetNodes(view.target, this.contextsByTarget.get(view.target)), - timeline: this.locationIndex.snapshot(), - }) + const timeline = this.locationIndex.snapshot() + const snapshot = replacing + ? builder.replace({ nodes, timeline, changedTurns }) + : builder.apply({ upserts: nodes, timeline, changedTurns }) + if (definition !== undefined && builder.groupInput !== undefined) { + let context = this.groups.get(view.target) + const initial = context === undefined + if (context === undefined) { + context = { definition, state: definition.create(), store: new ConversationGroupStore() } + } + const input = builder.groupInput() + context.state = definition.update(context, input) + const change = definition.buildGroups(context) + if ((initial || input.kind === 'replace') + && (change === null || change.entries === undefined || change.groups.kind !== 'replace')) { + throw new Error(`conversation group target "${view.target}" requires complete grouping for replacement input`) + } + if (change !== null) { + context.store.prepareAndInstall(change, input.readNode) + this.pendingGroupStores.add(context.store) + } + this.groups.set(view.target, context) + } + view.snapshot = snapshot + } + + private publishViews(updated: readonly ViewState[]): boolean { + const changed = updated.length > 0 || this.pendingGroupStores.size > 0 + const stores = [...this.pendingGroupStores] + this.pendingGroupStores.clear() + for (const view of updated) view.builder?.publish?.() + for (const store of stores) store.publish() + this.locationIndex.publishData() + return changed } private buildTargetNodes( @@ -925,11 +992,21 @@ export class ConversationNodeAssembler implements ConversationViewSnapshotStore } private resetViewBuilders(): void { + const definitions = this.viewDefinitions.entries() + const targets = new Set(definitions.map(definition => definition.target)) + for (const [target, group] of this.groups) { + if (!targets.has(target) || this.groupDefinitions.forTarget(target) !== group.definition) { + group.store.clear() + this.pendingGroupStores.add(group.store) + this.groups.delete(target) + } + } this.views.clear() - for (const definition of this.viewDefinitions.entries()) { + for (const definition of definitions) { const view: ViewState = { target: definition.target, definition, + groupDefinition: this.groupDefinitions.forTarget(definition.target), isActive: definition.isActive === undefined ? undefined : snapshot => definition.isActive?.(snapshot) === true, diff --git a/packages/client/ui-conversation/src/client/conversation/assembly.ts b/packages/client/ui-conversation/src/client/conversation/assembly.ts index b305c9e817..fe4d49505c 100644 --- a/packages/client/ui-conversation/src/client/conversation/assembly.ts +++ b/packages/client/ui-conversation/src/client/conversation/assembly.ts @@ -23,6 +23,7 @@ import { ConversationNodeAssembler } from './assembler.ts' import { ConversationEventRegistry } from './event-registry.ts' import { HistoricalImageCache } from './historical-images.ts' import { ConversationViewRegistry } from './view-registry.ts' +import { ConversationGroupRegistry } from './group-registry.ts' /** Observable faces published for one Session's Conversation assembly. */ export interface ConversationBinding { @@ -178,6 +179,8 @@ export class UiConversation extends Service { readonly events: ConversationEventRegistry /** Registry of target View definitions. */ readonly views: ConversationViewRegistry + /** Business grouping rules over already materialized target Nodes. */ + readonly groups: ConversationGroupRegistry private readonly bindings = new WeakMapWithValues() private readonly images: HistoricalImageCache @@ -189,6 +192,7 @@ export class UiConversation extends Service { super(ctx, 'uiConversation') this.events = new ConversationEventRegistry(ctx) this.views = new ConversationViewRegistry(ctx) + this.groups = new ConversationGroupRegistry(ctx, this.views) this.images = new HistoricalImageCache(ctx, sessions) const rebuild = (): void => { for (const record of this.bindings.values) record.binding.rebuild() @@ -205,7 +209,9 @@ export class UiConversation extends Service { ctx.effect(() => { const disposeEvents = this.events.subscribe(scheduleRebuild) const disposeViews = this.views.subscribe(scheduleRebuild) + const disposeGroups = this.groups.subscribe(scheduleRebuild) return () => { + disposeGroups() disposeViews() disposeEvents() for (const record of [...this.bindings.values]) this.drop(record, true) @@ -229,7 +235,7 @@ export class UiConversation extends Service { if (current !== undefined) return current.binding const binding = new BoundConversation( owner.eventSource, - new ConversationNodeAssembler(this.events, this.views), + new ConversationNodeAssembler(this.events, this.views, this.groups), ) const record: BindingRecord = { source: owner, binding, disposeScope: () => {} } this.bindings.set(owner, record) diff --git a/packages/client/ui-conversation/src/client/conversation/group-registry.ts b/packages/client/ui-conversation/src/client/conversation/group-registry.ts new file mode 100644 index 0000000000..1e52ef8b96 --- /dev/null +++ b/packages/client/ui-conversation/src/client/conversation/group-registry.ts @@ -0,0 +1,48 @@ +/** Target-indexed business grouping registrations with the existing effect lifecycle. */ +import type { Context } from '@deepseek-ai/cordis' +import type { ConversationViewNode } from '../contract/conversation.ts' +import type { + ConversationGroupData, ConversationGroupDefinition, +} from '../contract/groups.ts' +import { ConversationDefinitionRegistry } from './definition-registry.ts' +import type { ConversationViewRegistry } from './view-registry.ts' + +/** One Group Definition per target; registration never instantiates its Builder. */ +export class ConversationGroupRegistry extends ConversationDefinitionRegistry { + /** + * @param ctx - owning plugin context. + * @param views - registered target Builder definitions. + */ + constructor(ctx: Context, private readonly views: ConversationViewRegistry) { + super(ctx) + } + + /** + * Register grouping rules for an existing target. + * @param definition - business State and grouping output for the declared target data. + * @returns the effect-owned, idempotent registration disposer. + */ + register( + definition: ConversationGroupDefinition> + & { readonly target: Target }, + ): () => void { + if (!this.views.entries().some(view => view.target === definition.target)) { + throw new Error(`conversation group target "${definition.target}" is not registered`) + } + return this.registerDefinition( + definition.target, + definition, + `conversation group target "${definition.target}" is already registered`, + `uiConversation.groups.register(${JSON.stringify(definition.target)})`, + ) + } + + /** + * Find the grouping rules registered for one target. + * @param target - View target. + * @returns its grouping Definition, when registered. + */ + forTarget(target: string): ConversationGroupDefinition | undefined { + return this.definitions.get(target) + } +} diff --git a/packages/client/ui-conversation/src/client/conversation/group-store.ts b/packages/client/ui-conversation/src/client/conversation/group-store.ts new file mode 100644 index 0000000000..a56e769bc7 --- /dev/null +++ b/packages/client/ui-conversation/src/client/conversation/group-store.ts @@ -0,0 +1,229 @@ +/** Keyed group publication and incremental validation of rendering positions. */ +import { createSnapshotStore, type ObservableSnapshot, type SnapshotStore } from '@deepseek-ai/dsh-client-store' +import { assertNever } from '@deepseek-ai/dsh-util-values' +import type { ConversationViewNode } from '../contract/conversation.ts' +import type { + ConversationGroupedView, GroupKey, GroupSnapshot, GroupUpdate, NodeKey, NodeReference, RenderEntry, +} from '../contract/groups.ts' + +type Parts = Set + +interface GroupSource { + readonly observable: ObservableSnapshot | undefined> + readonly publication: SnapshotStore | undefined> +} + +interface GroupChanges { + readonly upserts: Map> + readonly removes: Set +} + +function sameNodeReference(left: NodeReference, right: NodeReference): boolean { + return left.key === right.key && left.groupPart === right.groupPart +} + +function sameEntry(left: RenderEntry, right: RenderEntry): boolean { + return left.kind === right.kind && left.key === right.key + && (left.kind === 'group' || (right.kind === 'node' && left.groupPart === right.groupPart)) +} + +function reuseReferences( + previous: readonly Value[], + next: readonly Value[], + equal: (left: Value, right: Value) => boolean, +): readonly Value[] { + return previous === next || (previous.length === next.length + && previous.every((value, index) => equal(value, next[index] as Value))) + ? previous + : next +} + +/** Validates a complete batch before installing its group and root-list changes. */ +export class ConversationGroupStore implements ConversationGroupedView { + private root: readonly RenderEntry[] = [] + private rootGroups = new Set() + private readonly groups = new Map>() + private readonly placements = new Map() + private readonly sources = new Map>() + private readonly dirty = new Set() + + /** @returns the identity-stable ordered root references. */ + get entries(): readonly RenderEntry[] { + return this.root + } + + groupSource(key: GroupKey): ObservableSnapshot | undefined> { + let source = this.sources.get(key) + if (source === undefined) { + const publication = createSnapshotStore(this.groups.get(key)) + source = { + publication, + observable: { + getSnapshot: () => this.groups.get(key), + subscribe: listener => publication.subscribe(listener), + }, + } + this.sources.set(key, source) + } + return source.observable + } + + /** + * Install one validated update without notifying readers. + * @param update - root replacement and complete or incremental group records. + * @param readNode - synchronous reader of the current target Nodes. + */ + prepareAndInstall( + update: GroupUpdate, + readNode: (key: NodeKey) => ConversationViewNode | undefined, + ): void { + const nextRoot = update.entries === undefined + ? this.root + : reuseReferences(this.root, update.entries, sameEntry) + const nextRootGroups = nextRoot === this.root ? this.rootGroups : this.collectRootGroups(nextRoot) + const { upserts, removes } = this.collectChanges(update) + const replaceReferences = update.groups.kind === 'replace' + const size = this.groups.size - removes.size + + [...upserts.keys()].filter(key => !this.groups.has(key)).length + if (size !== nextRootGroups.size) { + throw new Error('conversation group records and root references must correspond one-to-one') + } + for (const key of removes) { + if (nextRootGroups.has(key)) throw new Error(`conversation group "${key}" is still referenced`) + } + for (const key of upserts.keys()) { + if (!nextRootGroups.has(key)) throw new Error(`conversation group "${key}" has no root reference`) + } + if (nextRootGroups !== this.rootGroups) { + for (const key of nextRootGroups) { + if (!upserts.has(key) && !this.groups.has(key)) { + throw new Error(`conversation root references missing group "${key}"`) + } + } + } + + const affectedParts = new Map() + const partsOf = (key: NodeKey): Parts => { + let parts = affectedParts.get(key) + if (parts === undefined) { + parts = new Set(this.placements.get(key)) + affectedParts.set(key, parts) + } + return parts + } + const removeReference = (reference: NodeReference): void => { + partsOf(reference.key).delete(reference.groupPart) + } + const addReference = (reference: NodeReference): void => { + if (readNode(reference.key) === undefined) { + throw new Error(`conversation group references missing Node "${reference.key}"`) + } + const parts = partsOf(reference.key) + if (parts.has(reference.groupPart) + || (reference.groupPart === undefined && parts.size > 0) + || parts.has(undefined)) { + throw new Error(`conversation Node "${reference.key}" has overlapping rendering positions`) + } + parts.add(reference.groupPart) + } + + if (replaceReferences || nextRoot !== this.root) { + for (const entry of this.root) if (entry.kind === 'node') removeReference(entry) + } + for (const key of removes) { + for (const member of (this.groups.get(key) as GroupSnapshot).members) removeReference(member) + } + for (const [key, next] of upserts) { + const previous = this.groups.get(key) + if (previous !== undefined && (replaceReferences || previous.members !== next.members)) { + for (const member of previous.members) removeReference(member) + } + } + if (replaceReferences || nextRoot !== this.root) { + for (const entry of nextRoot) if (entry.kind === 'node') addReference(entry) + } + for (const [key, next] of upserts) { + if (replaceReferences || this.groups.get(key)?.members !== next.members) { + for (const member of next.members) addReference(member) + } + } + + for (const [key, parts] of affectedParts) { + if (parts.size === 0) this.placements.delete(key) + else this.placements.set(key, parts) + } + for (const key of removes) { + this.groups.delete(key) + this.dirty.add(key) + } + for (const [key, next] of upserts) { + if (this.groups.get(key) === next) continue + this.groups.set(key, next) + this.dirty.add(key) + } + this.root = nextRoot + this.rootGroups = nextRootGroups + } + + /** Publish changed group sources after all related target data has been installed. */ + publish(): void { + const keys = [...this.dirty] + this.dirty.clear() + for (const key of keys) this.sources.get(key)?.publication.set(this.groups.get(key)) + } + + /** Remove grouping without deleting its source Nodes; publication remains deferred. */ + clear(): void { + this.prepareAndInstall( + { entries: [], groups: { kind: 'replace', snapshots: [] } }, + /* v8 ignore next -- an empty replacement never reads a Node reference. */ + () => undefined, + ) + } + + private collectRootGroups(entries: readonly RenderEntry[]): Set { + const keys = new Set() + for (const entry of entries) { + if (entry.kind !== 'group') continue + if (keys.has(entry.key)) throw new Error(`conversation group "${entry.key}" has duplicate root references`) + keys.add(entry.key) + } + return keys + } + + private collectChanges(update: GroupUpdate): GroupChanges { + const upserts = new Map>() + const removes = new Set() + const add = (snapshot: GroupSnapshot): void => { + if (upserts.has(snapshot.key)) throw new Error(`conversation group "${snapshot.key}" has duplicate upserts`) + const previous = this.groups.get(snapshot.key) + if (previous === undefined) { + upserts.set(snapshot.key, snapshot) + return + } + const members = reuseReferences(previous.members, snapshot.members, sameNodeReference) + upserts.set(snapshot.key, previous.data === snapshot.data && previous.members === members + ? previous + : { ...snapshot, members }) + } + switch (update.groups.kind) { + case 'replace': + for (const snapshot of update.groups.snapshots) add(snapshot) + for (const key of this.groups.keys()) if (!upserts.has(key)) removes.add(key) + break + case 'apply': + for (const snapshot of update.groups.upserts) add(snapshot) + for (const key of update.groups.removes) { + if (removes.has(key)) throw new Error(`conversation group "${key}" has duplicate removals`) + if (!this.groups.has(key)) throw new Error(`conversation group "${key}" cannot be removed because it is absent`) + if (upserts.has(key)) throw new Error(`conversation group "${key}" cannot be upserted and removed together`) + removes.add(key) + } + break + /* v8 ignore next 2 -- closed update union; TypeScript rejects other operation tags. */ + default: + assertNever(update.groups) + } + return { upserts, removes } + } +} diff --git a/packages/client/ui-conversation/src/client/conversation/location-index.ts b/packages/client/ui-conversation/src/client/conversation/location-index.ts index f7bac8eb1c..839beedeaf 100644 --- a/packages/client/ui-conversation/src/client/conversation/location-index.ts +++ b/packages/client/ui-conversation/src/client/conversation/location-index.ts @@ -183,6 +183,7 @@ export class ConversationLocationIndex { private readonly turnDataStores = new Map() private readonly stepDataStores = new Map() private readonly dirtyDataStores = new Set() + private readonly changedTurns = new Set() private currentTurn: number | undefined private currentStep: number | undefined @@ -194,6 +195,16 @@ export class ConversationLocationIndex { return this.timeline } + /** + * Drain the Turn changes accumulated for one assembly flush. + * @returns Turn identities changed since the preceding drain. + */ + takeChangedTurns(): readonly number[] { + const turns = [...this.changedTurns] + this.changedTurns.clear() + return turns + } + /** * Replace all Definition-owned Location values while preserving reader identities. * @param entries - complete current set of Definition-owned Location values. @@ -386,6 +397,9 @@ export class ConversationLocationIndex { } } } + for (const turn of new Set([...previousTurns.keys(), ...nextTurns.keys()])) { + if (previousTurns.get(turn) !== nextTurns.get(turn)) this.changedTurns.add(turn) + } this.timeline = sameMap && turnOrder === this.timeline.turnOrder ? this.timeline : { turnOrder, turns: nextTurns } @@ -480,6 +494,7 @@ export class ConversationLocationIndex { ? [...this.timeline.turnOrder, turnNumber] : this.timeline.turnOrder this.timeline = { turnOrder, turns } + if (turn !== previousTurn) this.changedTurns.add(turnNumber) const changed = new Set() for (const seq of this.seqsByTurn.get(turnNumber) ?? []) { @@ -572,19 +587,22 @@ export class ConversationLocationIndex { } private mutableTurnData(turn: number): MutableLocationDataStore { - const current = this.turnDataStores.get(turn) ?? this.createDataStore() + const current = this.turnDataStores.get(turn) ?? this.createDataStore(turn) this.turnDataStores.set(turn, current) return current } private mutableStepData(key: string): MutableLocationDataStore { - const current = this.stepDataStores.get(key) ?? this.createDataStore() + const current = this.stepDataStores.get(key) ?? this.createDataStore(Number(key.slice(0, key.indexOf(':')))) this.stepDataStores.set(key, current) return current } - private createDataStore(): MutableLocationDataStore { - return new MutableLocationDataStore(store => this.dirtyDataStores.add(store)) + private createDataStore(turn: number): MutableLocationDataStore { + return new MutableLocationDataStore((store) => { + this.dirtyDataStores.add(store) + this.changedTurns.add(turn) + }) } private storeFor(data: ConversationLocationData): MutableLocationDataStore { diff --git a/packages/client/ui-conversation/src/client/index.ts b/packages/client/ui-conversation/src/client/index.ts index 73ddc6d7d3..e776311040 100644 --- a/packages/client/ui-conversation/src/client/index.ts +++ b/packages/client/ui-conversation/src/client/index.ts @@ -3,6 +3,13 @@ export { apply, Config, inject } from './apply.ts' export type { Config as ConversationConfig } from './apply.ts' export { UiConversation } from './conversation/assembly.ts' export type { ConversationBinding } from './conversation/assembly.ts' +export type { ConversationGroupRegistry } from './conversation/group-registry.ts' +export type { + ConversationGroupContext, ConversationGroupData, ConversationGroupDataMap, + ConversationGroupDefinition, ConversationGroupedView, ConversationGroupInput, + GroupKey, GroupNodePosition, GroupReference, GroupSnapshot, GroupUpdate, NodeChange, NodeKey, + NodeReference, RenderEntry, +} from './contract/groups.ts' export { ConversationController, UnsupportedImageMediaTypeError } from './service.ts' export type { IConversation } from './service.ts' export type { @@ -40,7 +47,7 @@ export type { ConversationStoreState, ConversationViewRequest, ViewTab } from '. export { ConversationNodeAssembler } from './conversation/assembler.ts' export type { - ConversationEventDefinitions, ConversationViewDefinitions, + ConversationEventDefinitions, ConversationGroupDefinitions, ConversationViewDefinitions, } from './conversation/assembler.ts' export { ConversationDefinitionRegistry } from './conversation/definition-registry.ts' export { ConversationEventRegistry } from './conversation/event-registry.ts' diff --git a/packages/client/ui-conversation/tests/conversation-assembler.client.spec.ts b/packages/client/ui-conversation/tests/conversation-assembler.client.spec.ts index 1b12d7f1a0..340263e65d 100644 --- a/packages/client/ui-conversation/tests/conversation-assembler.client.spec.ts +++ b/packages/client/ui-conversation/tests/conversation-assembler.client.spec.ts @@ -172,11 +172,31 @@ function fallbackDefinition(start: () => string): ConversationNodeDefinition { + it('reports boundary changes only when the owning Turn location changes', () => { + const index = new ConversationLocationIndex() + const boundaries = [ + at(SessionSeq(1), 'turn/start', { turn: 1 }), + at(SessionSeq(2), 'step/start', { turn: 1, step: 1 }), + at(SessionSeq(3), 'step/end', { turn: 1, step: 1 }), + at(SessionSeq(4), 'turn/end', { turn: 1 }), + ] + for (const boundary of boundaries) { + index.appendBoundary(boundary) + expect(index.takeChangedTurns()).toEqual([1]) + const turn = index.snapshot().turns.get(1) + index.appendBoundary(boundary) + expect(index.snapshot().turns.get(1)).toBe(turn) + expect(index.takeChangedTurns()).toEqual([]) + } + }) + it('publishes Location data through stable per-key sources', () => { const index = new ConversationLocationIndex() const turnStart = at(SessionSeq(1), 'turn/start', { turn: 1 }) const stepStart = at(SessionSeq(2), 'step/start', { turn: 1, step: 1 }) index.rebuild([input(turnStart), input(stepStart)]) + expect(index.takeChangedTurns()).toEqual([1]) + expect(index.takeChangedTurns()).toEqual([]) const location = index.locationOf(stepStart) if (location.kind !== 'step') throw new Error('scope probe requires a Step Location') const source = location.step.data.source('scope-probe') @@ -192,6 +212,7 @@ describe('ConversationNodeAssembler', () => { }])).toBe(true) expect(source.getSnapshot()).toBe(initial) expect(listener).not.toHaveBeenCalled() + expect(index.takeChangedTurns()).toEqual([1]) index.publishData() expect(listener).toHaveBeenCalledOnce() @@ -204,6 +225,7 @@ describe('ConversationNodeAssembler', () => { }]) index.publishData() expect(listener).not.toHaveBeenCalled() + expect(index.takeChangedTurns()).toEqual([1]) const changed = { value: 2 } index.replaceData([{ diff --git a/packages/client/ui-conversation/tests/conversation-group-store.client.spec.ts b/packages/client/ui-conversation/tests/conversation-group-store.client.spec.ts new file mode 100644 index 0000000000..a437f25b27 --- /dev/null +++ b/packages/client/ui-conversation/tests/conversation-group-store.client.spec.ts @@ -0,0 +1,170 @@ +import { describe, expect, it, vi } from 'vitest' +import { brandString } from '@deepseek-ai/dsh-brand' +import type { ConversationViewNode } from '../src/client/contract/conversation.ts' +import type { GroupKey, GroupSnapshot, GroupUpdate, NodeKey, NodeReference, RenderEntry } from '../src/client/contract/groups.ts' +import { ConversationGroupStore } from '../src/client/conversation/group-store.ts' + +const key = (value: string): GroupKey => brandString(value) +const node = (value: string, groupPart?: string): NodeReference => ({ + kind: 'node', key: brandString(value), ...groupPart === undefined ? {} : { groupPart }, +}) +const reference = (value: string): RenderEntry => ({ kind: 'group', key: key(value) }) +const readNode = (id: NodeKey): ConversationViewNode | undefined => id === 'missing' + ? undefined + : { key: id, kind: 'test', id, target: 'test', data: id } +const group = (id: string, members: readonly NodeReference[], data = 0): GroupSnapshot => ({ key: key(id), members, data }) + +function replace(store: ConversationGroupStore, entries: readonly RenderEntry[], groups: readonly GroupSnapshot[]) { + store.prepareAndInstall({ entries, groups: { kind: 'replace', snapshots: groups } }, readNode) +} + +describe('ConversationGroupStore', () => { + it('publishes one changed group without changing root or member identities', () => { + const store = new ConversationGroupStore() + const a = group('a', [node('a')]) + const b = group('b', [node('b')]) + replace(store, [reference('a'), node('reply'), reference('b')], [a, b]) + store.publish() + const root = store.entries + const source = store.groupSource(key('a')) + const other = store.groupSource(key('b')) + expect(store.groupSource(key('a'))).toBe(source) + const changed = vi.fn() + const otherChanged = vi.fn() + const unsubscribe = source.subscribe(changed) + const unsubscribeOther = other.subscribe(otherChanged) + const reads = vi.fn(readNode) + store.prepareAndInstall({ groups: { kind: 'apply', upserts: [{ ...a, data: 1 }], removes: [] } }, reads) + expect(source.getSnapshot()).toEqual({ ...a, data: 1 }) + expect(source.getSnapshot()?.members).toBe(a.members) + expect(other.getSnapshot()).toBe(b) + expect(store.entries).toBe(root) + expect(reads).not.toHaveBeenCalled() + expect(changed).not.toHaveBeenCalled() + store.publish() + expect(changed).toHaveBeenCalledOnce() + expect(otherChanged).not.toHaveBeenCalled() + unsubscribe() + unsubscribeOther() + store.prepareAndInstall({ groups: { kind: 'apply', upserts: [{ ...a, data: 2 }], removes: [] } }, readNode) + store.publish() + expect(changed).toHaveBeenCalledOnce() + }) + + it('reuses equivalent snapshots and accepts parts in separate positions', () => { + const store = new ConversationGroupStore() + const reasoning = node('assistant', 'reasoning') + const reply = node('assistant', 'response') + const original = group('a', [reasoning]) + replace(store, [reference('a'), reply], [original]) + const root = store.entries + const source = store.groupSource(key('a')) + const changed = vi.fn() + source.subscribe(changed) + store.publish() + replace(store, [reference('a'), node('assistant', 'response')], [group('a', [node('assistant', 'reasoning')])]) + store.publish() + expect(store.entries).toBe(root) + expect(source.getSnapshot()).toBe(original) + expect(changed).not.toHaveBeenCalled() + store.prepareAndInstall({ groups: { kind: 'apply', upserts: [group('a', [node('assistant', 'reasoning')], 1)], removes: [] } }, readNode) + expect(source.getSnapshot()?.members).toBe(original.members) + store.publish() + expect(changed).toHaveBeenCalledOnce() + }) + + it('moves references atomically and preserves surviving group sources', () => { + const store = new ConversationGroupStore() + const a = node('a') + const b = node('b') + replace(store, [reference('old')], [group('old', [a, b])]) + store.publish() + const oldSource = store.groupSource(key('old')) + const nextSource = store.groupSource(key('next')) + const oldChanged = vi.fn() + oldSource.subscribe(oldChanged) + store.prepareAndInstall({ + entries: [a, reference('next')], + groups: { kind: 'apply', removes: [key('old')], upserts: [group('next', [b])] }, + }, readNode) + expect(oldSource.getSnapshot()).toBeUndefined() + expect(nextSource.getSnapshot()?.members).toEqual([b]) + expect(oldChanged).not.toHaveBeenCalled() + store.publish() + expect(oldChanged).toHaveBeenCalledOnce() + const root = store.entries + store.prepareAndInstall({ groups: { kind: 'apply', removes: [], upserts: [group('next', [node('c')])] } }, readNode) + expect(store.entries).toBe(root) + expect(store.groupSource(key('next'))).toBe(nextSource) + store.prepareAndInstall({ entries: [node('c'), a], groups: { kind: 'apply', removes: [key('next')], upserts: [] } }, readNode) + store.publish() + expect(store.entries).toEqual([node('c'), a]) + store.clear() + store.publish() + expect(store.entries).toEqual([]) + expect(nextSource.getSnapshot()).toBeUndefined() + store.clear() + store.publish() + }) + + it('keeps omitted entries and allows an empty complete replacement', () => { + const store = new ConversationGroupStore() + replace(store, [node('a'), node('b')], []) + const root = store.entries + store.prepareAndInstall({ groups: { kind: 'apply', upserts: [], removes: [] } }, readNode) + expect(store.entries).toBe(root) + store.prepareAndInstall({ entries: [node('b'), node('a')], groups: { kind: 'apply', upserts: [], removes: [] } }, readNode) + expect(store.entries).toEqual([node('b'), node('a')]) + replace(store, [], []) + expect(store.entries).toEqual([]) + }) + + it('validates references again on complete replacement, even when arrays are reused', () => { + const store = new ConversationGroupStore() + const initial = group('a', [node('a')]) + const entries = [reference('a'), node('b')] + replace(store, entries, [initial]) + expect(() => { store.prepareAndInstall({ entries, groups: { kind: 'replace', snapshots: [initial] } }, () => undefined) }) + .toThrow('missing Node') + expect(store.entries).toBe(entries) + expect(store.groupSource(key('a')).getSnapshot()).toBe(initial) + }) + + it.each([ + [node('a'), node('a')], + [node('a', 'reasoning'), node('a', 'reasoning')], + [node('a'), node('a', 'reasoning')], + [node('a', 'reasoning'), node('a')], + ])('rejects overlapping Node positions without changing installed data: %j', (first, second) => { + const store = new ConversationGroupStore() + replace(store, [node('before')], []) + const root = store.entries + expect(() => { replace(store, [first, reference('a')], [group('a', [second])]) }).toThrow('overlapping') + expect(store.entries).toBe(root) + replace(store, [reference('a')], [group('a', [first])]) + expect(store.groupSource(key('a')).getSnapshot()?.members).toEqual([first]) + }) + + it.each<{ name: string; update: GroupUpdate; error: string }>([ + { name: 'duplicate group roots', update: { entries: [reference('a'), reference('a')], groups: { kind: 'apply', upserts: [], removes: [] } }, error: 'duplicate root' }, + { name: 'unreferenced group', update: { entries: [reference('a'), reference('c')], groups: { kind: 'apply', upserts: [group('b', [])], removes: [] } }, error: 'no root reference' }, + { name: 'missing group', update: { entries: [reference('missing')], groups: { kind: 'apply', upserts: [], removes: [] } }, error: 'missing group' }, + { name: 'removed referenced group', update: { groups: { kind: 'apply', upserts: [group('b', [])], removes: [key('a')] } }, error: 'still referenced' }, + { name: 'record count mismatch', update: { entries: [], groups: { kind: 'apply', upserts: [], removes: [] } }, error: 'one-to-one' }, + { name: 'duplicate upserts', update: { groups: { kind: 'apply', upserts: [group('a', []), group('a', [])], removes: [] } }, error: 'duplicate upserts' }, + { name: 'duplicate removals', update: { entries: [], groups: { kind: 'apply', upserts: [], removes: [key('a'), key('a')] } }, error: 'duplicate removals' }, + { name: 'absent removal', update: { groups: { kind: 'apply', upserts: [], removes: [key('missing')] } }, error: 'absent' }, + { name: 'upsert and remove', update: { groups: { kind: 'apply', upserts: [group('a', [])], removes: [key('a')] } }, error: 'upserted and removed' }, + { name: 'missing Node', update: { groups: { kind: 'apply', upserts: [group('a', [node('missing')])], removes: [] } }, error: 'missing Node' }, + ])('rejects $name atomically', ({ update, error }) => { + const store = new ConversationGroupStore() + const initial = group('a', [node('a')]) + replace(store, [reference('a')], [initial]) + store.publish() + const root = store.entries + const source = store.groupSource(key('a')) + expect(() => { store.prepareAndInstall(update, readNode) }).toThrow(error) + expect(store.entries).toBe(root) + expect(source.getSnapshot()).toBe(initial) + }) +}) diff --git a/packages/client/ui-conversation/tests/conversation-groups.client.spec.ts b/packages/client/ui-conversation/tests/conversation-groups.client.spec.ts new file mode 100644 index 0000000000..03be14383b --- /dev/null +++ b/packages/client/ui-conversation/tests/conversation-groups.client.spec.ts @@ -0,0 +1,311 @@ +import { Context } from '@deepseek-ai/cordis' +import { brandString } from '@deepseek-ai/dsh-brand' +import { SessionSeq, type SessionEvent } from '@deepseek-ai/dsh-session/types' +import { describe, expect, it, onTestFinished, vi } from 'vitest' +import type { + ConversationTimelineSnapshot, ConversationViewBuilder, ConversationViewDefinition, ConversationViewNode, +} from '../src/client/contract/conversation.ts' +import type { + ConversationGroupDefinition, ConversationGroupInput, GroupKey, GroupUpdate, NodeKey, +} from '../src/client/contract/groups.ts' +import { ConversationNodeAssembler } from '../src/client/conversation/assembler.ts' +import { ConversationGroupRegistry } from '../src/client/conversation/group-registry.ts' +import { ConversationViewRegistry } from '../src/client/conversation/view-registry.ts' + +declare module '@deepseek-ai/dsh-client-ui-conversation/client' { + interface ConversationGroupDataMap { + 'group-test': number + } +} + +const groupKey = brandString('group') +const noEvents = { entries: () => [], fallbackEntry: () => undefined } + +class GroupTestBuilder implements ConversationViewBuilder> { + readonly empty = new Map() + readonly nodes = new Map() + readonly publish = vi.fn() + private readonly readers = { + readNode: (key: NodeKey) => this.nodes.get(key), + readTurn: (_turn: number): readonly NodeKey[] => [], + readPosition: (key: NodeKey) => { + const index = this.input.order.indexOf(key) + if (index < 0) return undefined + return { + turn: undefined, + previous: this.input.order[index - 1], + next: this.input.order[index + 1], + } + }, + } + private input: ConversationGroupInput = { + kind: 'replace', order: [], ...this.readers, timeline: { turnOrder: [], turns: new Map() }, + } + + replace({ nodes, timeline }: { nodes: readonly ConversationViewNode[]; timeline: ConversationTimelineSnapshot }) { + this.nodes.clear() + for (const node of nodes) this.nodes.set(node.key, node) + this.input = { kind: 'replace', order: [...this.nodes.keys()] as NodeKey[], ...this.readers, timeline } + return this.nodes + } + + apply({ upserts, timeline, changedTurns = [] }: { + upserts: readonly ConversationViewNode[] + timeline: ConversationTimelineSnapshot + changedTurns?: readonly number[] + }) { + const changes = upserts.map(current => ({ previous: this.nodes.get(current.key), current })) + for (const node of upserts) this.nodes.set(node.key, node) + this.input = { + kind: 'apply', changes, changedTurns, changedTurnOrders: [], + order: [...this.nodes.keys()] as NodeKey[], ...this.readers, timeline, + } + return this.nodes + } + + groupInput() { return this.input } +} + +function grouping(seen: ConversationGroupInput[] = []) { + return { + kind: 'test-group', + target: 'group-test' as const, + create: (): ConversationGroupInput | null => null, + update: (_context, input) => { seen.push(input); return input }, + buildGroups: ({ state }) => state === null ? null : { + entries: [{ kind: 'group', key: groupKey }], + groups: { + kind: 'replace', + snapshots: [{ + key: groupKey, + data: state.timeline.turns.get(1)?.status === 'closed' ? 1 : 0, + members: state.order.map(key => ({ kind: 'node', key })), + }], + }, + }, + } satisfies ConversationGroupDefinition | null, number> +} + +function start(seq: number, turn = 1): SessionEvent<'turn/start'> { + return { seq: SessionSeq(seq), time: seq, type: 'turn/start', data: { turn } } +} + +function setup(definition: ConversationGroupDefinition = grouping()) { + const builder = new GroupTestBuilder() + const create = vi.fn(() => builder) + let groupDefinitions = [definition] + const views = [{ target: 'group-test', create }] + const groups = { + entries: () => groupDefinitions, + forTarget: (target: string) => groupDefinitions.find(candidate => candidate.target === target), + } + const assembler = new ConversationNodeAssembler(noEvents, { entries: () => views }, groups) + return { + assembler, builder, create, views, + removeGroups: () => { groupDefinitions = [] }, + replaceGroup: (next: ConversationGroupDefinition) => { groupDefinitions = [next] }, + } +} + +describe('group Definition dispatch', () => { + it('creates grouping on first activation without eagerly constructing a Builder', () => { + const seen: ConversationGroupInput[] = [] + const { assembler, builder, create } = setup(grouping(seen)) + assembler.replaceWindow([{ type: 'event', event: start(1) }], false) + expect(assembler.flush()).toBe(false) + expect(create).not.toHaveBeenCalled() + expect(assembler.grouped('group-test')).toBeUndefined() + builder.publish.mockImplementation(() => { + expect(assembler.snapshot('group-test')).toBe(builder.nodes) + expect(assembler.grouped('group-test')?.groupSource(groupKey).getSnapshot()?.data).toBe(0) + }) + expect(assembler.activateTarget('group-test')).toBe(true) + expect(create).toHaveBeenCalledOnce() + expect(seen.map(input => input.kind)).toEqual(['replace']) + expect(builder.publish).toHaveBeenCalledOnce() + expect(assembler.activateTarget('group-test')).toBe(false) + expect(seen).toHaveLength(1) + }) + + it('delivers lifecycle changes without Node upserts and installs groups before publication', () => { + const seen: ConversationGroupInput[] = [] + const { assembler, builder } = setup(grouping(seen)) + assembler.replaceWindow([{ type: 'event', event: start(1) }], false) + assembler.activateTarget('group-test') + const grouped = assembler.grouped('group-test')! + const entries = grouped.entries + const source = grouped.groupSource(groupKey) + const changed = vi.fn() + source.subscribe(changed) + const end: SessionEvent<'turn/end'> = { type: 'turn/end', seq: SessionSeq(2), time: 2, data: { turn: 1, reason: { kind: 'completed' } } } + assembler.append({ type: 'event', event: end }) + builder.publish.mockImplementation(() => { expect(source.getSnapshot()?.data).toBe(1) }) + assembler.flush() + const input = seen.at(-1)! + expect(input.kind).toBe('apply') + if (input.kind !== 'apply') throw new Error('expected apply') + expect(input.changes).toEqual([]) + expect(input.changedTurns).toEqual([1]) + expect(grouped.entries).toBe(entries) + expect(changed).toHaveBeenCalledOnce() + expect(assembler.flush()).toBe(false) + }) + + it('rebuilds through the same path and invalidates sources when grouping is removed', () => { + const seen: ConversationGroupInput[] = [] + const { assembler, removeGroups } = setup(grouping(seen)) + assembler.activateTarget('group-test') + const source = assembler.grouped('group-test')!.groupSource(groupKey) + const changed = vi.fn() + source.subscribe(changed) + assembler.rebuildRegistry() + assembler.flush() + expect(seen.map(input => input.kind)).toEqual(['replace', 'replace']) + expect(assembler.grouped('group-test')!.groupSource(groupKey)).toBe(source) + removeGroups() + assembler.rebuildRegistry() + assembler.flush() + expect(source.getSnapshot()).toBeUndefined() + expect(assembler.grouped('group-test')).toBeUndefined() + expect(changed).toHaveBeenCalledOnce() + }) + + it('does not ask ungrouped Builders for grouping input', () => { + const builder = new GroupTestBuilder() + const input = vi.spyOn(builder, 'groupInput') + const assembler = new ConversationNodeAssembler(noEvents, { entries: () => [{ target: 'group-test', create: () => builder }] }) + assembler.activateTarget('group-test') + expect(input).not.toHaveBeenCalled() + }) + + it('keeps the installed group context until the registry rebuild replaces it', () => { + const first = grouping() + const firstUpdate = vi.spyOn(first, 'update') + const next = grouping() + const nextCreate = vi.spyOn(next, 'create') + const { assembler, replaceGroup } = setup(first) + assembler.activateTarget('group-test') + const source = assembler.grouped('group-test')!.groupSource(groupKey) + const changed = vi.fn() + source.subscribe(changed) + replaceGroup(next) + assembler.append({ type: 'event', event: start(1) }) + assembler.flush() + expect(firstUpdate).toHaveBeenCalledTimes(2) + expect(nextCreate).not.toHaveBeenCalled() + assembler.rebuildRegistry() + assembler.flush() + expect(nextCreate).toHaveBeenCalledOnce() + expect(source.getSnapshot()).toBeUndefined() + expect(changed).toHaveBeenCalledOnce() + expect(assembler.grouped('group-test')!.groupSource(groupKey)).not.toBe(source) + }) + + it('keeps published grouping when a later buildGroups returns null', () => { + const definition = grouping() + const build = vi.spyOn(definition, 'buildGroups') + const { assembler } = setup(definition) + assembler.activateTarget('group-test') + const grouped = assembler.grouped('group-test')! + const root = grouped.entries + const source = grouped.groupSource(groupKey) + const value = source.getSnapshot() + const changed = vi.fn() + source.subscribe(changed) + build.mockReturnValueOnce(null) + assembler.append({ type: 'event', event: start(1) }) + assembler.flush() + expect(grouped.entries).toBe(root) + expect(source.getSnapshot()).toBe(value) + expect(changed).not.toHaveBeenCalled() + }) + + it('rejects a missing Builder input method only after target activation', () => { + const create = vi.fn(() => ({ empty: null, replace: () => null, apply: () => null })) + const definition = grouping() + const assembler = new ConversationNodeAssembler(noEvents, { entries: () => [{ target: 'group-test', create }] }, { + entries: () => [definition], forTarget: () => definition, + }) + expect(create).not.toHaveBeenCalled() + expect(() => assembler.activateTarget('group-test')).toThrow('requires builder.groupInput()') + }) + + it('requires complete grouping for replacement input', () => { + const definition = grouping() + const missing = setup({ ...definition, buildGroups: () => null }) + expect(() => missing.assembler.activateTarget('group-test')).toThrow('complete grouping for replacement input') + const retained = grouping() + const build = vi.spyOn(retained, 'buildGroups') + const { assembler } = setup(retained) + assembler.activateTarget('group-test') + build.mockReturnValueOnce(null) + assembler.replaceWindow([], false) + expect(() => assembler.flush()).toThrow('complete grouping for replacement input') + }) + + it.each>([ + { groups: { kind: 'replace', snapshots: [] } }, + { entries: [], groups: { kind: 'apply', upserts: [], removes: [] } }, + ])('rejects incomplete grouping for replacement input: %j', (update) => { + const { assembler } = setup({ ...grouping(), buildGroups: () => update }) + expect(() => assembler.activateTarget('group-test')).toThrow('complete grouping for replacement input') + }) + + it('pauses grouping when its View disappears and rebuilds when the View returns', () => { + const seen: ConversationGroupInput[] = [] + const definition = grouping(seen) + const createGroup = vi.spyOn(definition, 'create') + const { assembler, views, create } = setup(definition) + assembler.activateTarget('group-test') + const source = assembler.grouped('group-test')!.groupSource(groupKey) + const changed = vi.fn() + source.subscribe(changed) + + const view = views.pop()! + assembler.rebuildRegistry() + expect(assembler.flush()).toBe(true) + expect(assembler.snapshot('group-test')).toBeUndefined() + expect(assembler.grouped('group-test')).toBeUndefined() + expect(source.getSnapshot()).toBeUndefined() + expect(changed).toHaveBeenCalledOnce() + + assembler.append({ type: 'event', event: start(1) }) + assembler.flush() + expect(seen).toHaveLength(1) + expect(createGroup).toHaveBeenCalledOnce() + views.push(view) + assembler.rebuildRegistry() + assembler.flush() + expect(create).toHaveBeenCalledTimes(2) + expect(createGroup).toHaveBeenCalledTimes(2) + expect(seen.map(input => input.kind)).toEqual(['replace', 'replace']) + expect(seen.at(-1)?.timeline.turns.has(1)).toBe(true) + expect(assembler.grouped('group-test')!.groupSource(groupKey).getSnapshot()?.data).toBe(0) + }) + + it('registers grouping without creating its Builder and disposes it with its effect', async () => { + const ctx = new Context() + onTestFinished(async () => { await ctx.fiber.dispose() }) + const views = new ConversationViewRegistry(ctx) + const groups = new ConversationGroupRegistry(ctx, views) + const definition = grouping() + expect(() => groups.register(definition)).toThrow('is not registered') + const create = vi.fn(() => new GroupTestBuilder()) + views.register({ target: 'group-test', create } satisfies ConversationViewDefinition) + const changed = vi.fn() + const unsubscribe = groups.subscribe(changed) + const dispose = groups.register(definition) + expect(create).not.toHaveBeenCalled() + expect(groups.forTarget('group-test')).toBe(definition) + expect(() => groups.register(definition)).toThrow('already registered') + dispose() + dispose() + expect(groups.forTarget('group-test')).toBeUndefined() + expect(changed).toHaveBeenCalledTimes(2) + unsubscribe() + groups.register(definition) + await ctx.fiber.dispose() + expect(groups.entries()).toEqual([]) + expect(changed).toHaveBeenCalledTimes(2) + }) +}) diff --git a/packages/client/ui-deliverables/README.i18n.yaml b/packages/client/ui-deliverables/README.i18n.yaml index c3c62898a1..84e8bd43ae 100644 --- a/packages/client/ui-deliverables/README.i18n.yaml +++ b/packages/client/ui-deliverables/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-deliverables/README.md -README.md: ce006783310c257957bc8cfddcfd18226972d18f -README.zh.md: 82239ba169f4ada188e8a5ba8e1889c61b336f96 +README.md: 7bf55c474250b8e389a1767092be0f82b4073222 +README.zh.md: d8b1fc368487d96091b400c6034d159989352192 diff --git a/packages/client/ui-deliverables/README.md b/packages/client/ui-deliverables/README.md index ce00678331..7bf55c4742 100644 --- a/packages/client/ui-deliverables/README.md +++ b/packages/client/ui-deliverables/README.md @@ -38,7 +38,7 @@ The `present` tool row retains its document icon and recorded result across prog Developer tools must be enabled to show this card or request its summary. Turning the preference off removes the card immediately while explicit delivery cards, inline file links, and recorded workspace changes remain available. -The card renders the summary the Host serves for the turn's latest `workspace/changes` announcement, read once per announcement through the authenticated summary route; while the read is pending, once the Host answers that the summary is gone, or when it lists no file, there is no card. Its header names the complete changed-file count and summed added and deleted lines; hovering or focusing it replaces the counts with “Preview in sidebar”. Each row shows one file's display path with its own counts, “binary” for a binary file, or “too large” for a file the Host did not capture. Rows appear in the recorded display order, so repository files above the working directory and files outside it sort first. Four rows show before a fold; the control reveals every recorded file and, once expanded, collapses the list again from the bottom. Each row opens the turn's review in the right Sidebar on that file, and the header opens it on the first file. +The card renders the summary the Host serves for the turn's latest `workspace/changes` announcement, read once per announcement through the authenticated summary route; while the read is pending, once the Host answers that the summary is gone, or when it lists no file, there is no card. Its header names the complete changed-file count and summed added and deleted lines; hovering or focusing it replaces the counts with “Preview in sidebar”. Each row shows one file's display path with its own counts, “binary” for a binary file, or “too large” for a file the Host did not capture. Rows appear in the recorded display order, so repository files above the working directory and files outside it sort first. Four rows show before a fold; the control reveals every recorded file and, once expanded, collapses the list again from the bottom. Each row opens the turn's review in the right Sidebar on that file, and the header opens it on the first file. Hovering a file row for 500ms opens a scrollable, single-column preview of the comparison, independent of the Sidebar’s view. Its full path uses muted monospace text and scrolls horizontally when needed. File-status notes accompanying code and hunk headers are hidden in the preview; comparisons without hunks retain their status text. Each row exposes the full path as its accessible description. The preview is 48px narrower than the card, inset 24px on each side, and at most 420px high, further constrained by the viewport. The whole preview fades in and out over 100ms. Moving into the preview keeps it open; leaving it, pressing Escape, or activating the row by pointer or keyboard dismisses it. Comparisons load only when the preview opens and share the review tab’s cache and retry states. ### The review tab diff --git a/packages/client/ui-deliverables/README.zh.md b/packages/client/ui-deliverables/README.zh.md index 82239ba169..d8b1fc3684 100644 --- a/packages/client/ui-deliverables/README.zh.md +++ b/packages/client/ui-deliverables/README.zh.md @@ -38,7 +38,7 @@ Web 的 `standard`、`ptc` 与 `cordis` preset 提供 `present`,用于声明 只有开启开发者工具才展示此卡片并请求其摘要。关闭该偏好会立即隐藏卡片,同时保留显式交付卡片、行内文件链接和已记录的工作区改动。 -卡片渲染 Host 为本轮最新一条 `workspace/changes` 宣告提供的摘要,每条宣告通过经过认证的摘要路由读取一次;读取尚未完成、Host 答复摘要已不存在,或摘要没有列出任何文件时,没有卡片。标题给出改动文件总数与增删行数合计;鼠标悬停或键盘聚焦时,统计信息替换为“在侧边栏预览”。每一行显示一个文件的展示路径及其增删行数,二进制文件显示“二进制”,Host 没有捕获的文件显示“过大”。行按记录的展示顺序排列,因此仓库内位于工作目录之上的文件与工作区外的文件排在最前。折叠前显示四行;控件展开全部记录文件,展开后从底部收起列表。每一行在右侧 Sidebar 中打开本轮的 review 并选中该文件,标题则在第一个文件上打开它。 +卡片渲染 Host 为本轮最新一条 `workspace/changes` 宣告提供的摘要,每条宣告通过经过认证的摘要路由读取一次;读取尚未完成、Host 答复摘要已不存在,或摘要没有列出任何文件时,没有卡片。标题给出改动文件总数与增删行数合计;鼠标悬停或键盘聚焦时,统计信息替换为“在侧边栏预览”。每一行显示一个文件的展示路径及其增删行数,二进制文件显示“二进制”,Host 没有捕获的文件显示“过大”。行按记录的展示顺序排列,因此仓库内位于工作目录之上的文件与工作区外的文件排在最前。折叠前显示四行;控件展开全部记录文件,展开后从底部收起列表。每一行在右侧 Sidebar 中打开本轮的 review 并选中该文件,标题则在第一个文件上打开它。 在文件行上悬停 500ms 后,会出现可滚动的单列改动浮层,不受 Sidebar 视图设置影响。顶部完整路径使用较浅的等宽字体,超出宽度时可横向滚动。浮层不显示代码旁的文件状态说明和 hunk 头;没有 hunk 的对比仍显示状态文字。每行通过无障碍描述提供完整路径。浮层比卡片窄 48px,左右各内缩 24px,高度最多 420px,并受视口可用空间限制。整个浮层的淡入和淡出各持续 100ms。鼠标移入浮层后保持打开;移出、按 Escape 或通过鼠标和键盘激活文件行会关闭浮层。对比只在浮层打开时加载,与 review tab 共用缓存及重试状态。 ### review tab diff --git a/packages/client/ui-deliverables/src/client/ChangedFiles.module.css b/packages/client/ui-deliverables/src/client/ChangedFiles.module.css index 536bed4f6d..0aa5f6558f 100644 --- a/packages/client/ui-deliverables/src/client/ChangedFiles.module.css +++ b/packages/client/ui-deliverables/src/client/ChangedFiles.module.css @@ -29,3 +29,8 @@ button.header:disabled { cursor: progress; } .toggle:hover { background: var(--dsw-alias-interactive-bg-hover); } .toggle svg { flex: none; width: 14px; height: 14px; } @media (pointer: coarse) { .row, .toggle { min-height: 44px; } } + +.preview { max-height: 100%; overflow: hidden; } + +.previewPath { flex: 1 1 auto; min-width: 0; overflow-x: auto; overflow-y: hidden; color: var(--dsw-alias-label-tertiary); font-family: var(--ds-font-family-code); font-size: 12px; line-height: 20px; white-space: nowrap; } +.preview [data-diff-note='metadata'], .preview [data-diff-hunk-header] { display: none; } diff --git a/packages/client/ui-deliverables/src/client/ChangedFiles.tsx b/packages/client/ui-deliverables/src/client/ChangedFiles.tsx index 2db82abd1f..5e06f5111c 100644 --- a/packages/client/ui-deliverables/src/client/ChangedFiles.tsx +++ b/packages/client/ui-deliverables/src/client/ChangedFiles.tsx @@ -1,13 +1,19 @@ /** The changed-files card: a header and per-file rows that open the turn's review, and a four-row fold. */ -import { useState } from 'react' +import { useEffect, useId, useRef, useState } from 'react' import { resolveWorkspacePath } from '@deepseek-ai/dsh-util-workspace-path' -import { IconChevronDownOutlineRegular, IconChevronUpOutlineRegular } from '@deepseek-ai/dsh-client-ui-primitives' -import type { PropsLocale } from '@deepseek-ai/dsh-client-ui-slots' -import type { ChangesSummary } from '../changes.ts' +import { HoverCard, IconChevronDownOutlineRegular, IconChevronUpOutlineRegular } from '@deepseek-ai/dsh-client-ui-primitives' +import type { InjectFace, PropsLocale, SessionStandardProps } from '@deepseek-ai/dsh-client-ui-slots' +import { changesDiffUrl, type ChangesSummary } from '../changes.ts' +import type { DeliverablesInjected } from './Deliverables.tsx' +import { FileDiff } from './FileDiff.tsx' +import diffCss from './FileDiff.module.css' import { IconCodeBracketsOutline16 } from './icons.tsx' import type { NS } from './locales.ts' import css from './ChangedFiles.module.css' +type PreviewProps = Pick, 'useChangesDiff' | 'loadChangesDiff'> + & Pick + /** Rows shown before the fold; the design's summary height for a closing message. */ const COLLAPSED_ROWS = 4 @@ -23,21 +29,24 @@ function Counts({ added, deleted, t }: { added: number; deleted: number } & Prop /** * Render one turn's changed files. The header opens the turn's review in the - * right Sidebar on its first file; each row opens it on that row's file. + * right Sidebar on its first file; each row previews its comparison after a + * 500ms hover and opens the review on click. * @param props - the recorded summary, the review opener, and localized copy. * @returns the card. */ -export function ChangedFiles({ changes, cwd, openReview, t }: { +export function ChangedFiles({ changes, cwd, openReview, t, sessionId, useChangesDiff, loadChangesDiff }: { /** The served summary with the sequence of the event that announced it. */ changes: Pick & { seq: number } cwd: string | undefined /** Open the turn's review on the file at an original summary index. */ openReview: (index: number) => void -} & PropsLocale) { +} & PropsLocale & PreviewProps) { + const cardRef = useRef(null) + const pathDescriptionId = useId() const [expanded, setExpanded] = useState(false) const foldable = changes.files.length > COLLAPSED_ROWS const rows = foldable && !expanded ? changes.files.slice(0, COLLAPSED_ROWS) : changes.files - return
+ return
+ } + anchor={} /> + ))} @@ -75,3 +89,19 @@ export function ChangedFiles({ changes, cwd, openReview, t }: { }
} + +/** Mounted only while its hover card is open, so passing over a row does not read a comparison. */ +function ChangedFilePreview({ sessionId, seq, index, display, useChangesDiff, loadChangesDiff, t }: PreviewProps & { + seq: number + index: number + display: string +} & PropsLocale) { + const state = useChangesDiff(value => value[changesDiffUrl(sessionId, seq, index)]) + useEffect(() => { + if (state === undefined) void loadChangesDiff(sessionId, seq, index) + }, [state, sessionId, seq, index, loadChangesDiff]) + return
+
{display}
+ { void loadChangesDiff(sessionId, seq, index) }} /> +
+} diff --git a/packages/client/ui-deliverables/src/client/Deliverables.tsx b/packages/client/ui-deliverables/src/client/Deliverables.tsx index 3fae39bd01..544e7a82b2 100644 --- a/packages/client/ui-deliverables/src/client/Deliverables.tsx +++ b/packages/client/ui-deliverables/src/client/Deliverables.tsx @@ -5,6 +5,7 @@ import { Button, IconChevronDownOutlineRegular, IconChevronUpOutlineRegular } fr import type { GlobalStandardProps, InjectFace, PropsLocale, PropsRenderSlots, PropsRuntime, SessionStandardProps } from '@deepseek-ai/dsh-client-ui-slots' import type { ObservableSnapshot } from '@deepseek-ai/dsh-client-store' import type { PresentedOpenController } from './present-open.ts' +import type { ChangesDiffStore } from './changes-diff.ts' import type { ChangesSummaryStore } from './changes-summary.ts' import { ChangedFiles } from './ChangedFiles.tsx' import { changesForClosing, presentedForClosing, type ChangesTurnData, type PresentedPath } from './turn-deliverables.ts' @@ -21,12 +22,14 @@ const COLLAPSED_PRESENTED_COUNT = 4 /** Summary reads, native-open callbacks, and shared gesture status supplied by the plugin. */ export interface DeliverablesInjected { hooks: { + changesDiff: ObservableSnapshot> showCodeDiff: ObservableSnapshot presentedOpen: ObservableSnapshot> presentedHost: ObservableSnapshot> changesSummary: ObservableSnapshot> } reloadPresentedHost: PresentedOpenController['loadHost'] + loadChangesDiff: ChangesDiffStore['load'] loadChangesSummary: ChangesSummaryStore['load'] openPresented: PresentedOpenController['open'] openChanged: PresentedOpenController['openChanged'] @@ -64,7 +67,7 @@ export function DeliverablesTail(props: PropsRuntime<'conversation.chat.turnTail */ export function Deliverables({ matched, openFile, t, sessionId, useSessions, openPresented, openChangesReview, usePresentedOpen, usePresentedHost, - useChangesSummary, reloadPresentedHost, loadChangesSummary, useShowCodeDiff, renderSlot, + useChangesDiff, loadChangesDiff, useChangesSummary, reloadPresentedHost, loadChangesSummary, useShowCodeDiff, renderSlot, }: Pick & { matched: DeliverablesMatch } & PropsLocale & Pick & Pick & InjectFace & PropsRenderSlots<'deliverables.file.actions'>) { @@ -90,6 +93,7 @@ export function Deliverables({ }, [host, reloadPresentedHost]) return <> {changes !== null && { openChangesReview({ sessionId, seq: changes.seq, turn: changes.turn }, index) }} />} {matched.presented.length > 0 &&
| undefined + new: ReadonlyMap | undefined +} + +function highlightedSide(rows: readonly DiffRow[], side: 'old' | 'new', highlighter: CodeHighlighter): HunkHighlights['old'] { + const source = rows.flatMap((row) => { + const no = row[side] + return no === undefined ? [] : [{ no, text: row.text }] + }) + if (source.length === 0) return new Map() + const highlighted = highlighter(source.map(line => line.text).join('\n')) + if (highlighted === undefined) return undefined + return new Map(source.map((line, index) => { + // Shiki omits one terminal empty token row; retain an aligned empty run for that source line. + return [line.no, highlighted[index] ?? []] + })) +} + +function hunkHighlights(hunk: WorkspaceDiffHunk, highlighter: CodeHighlighter): HunkHighlights { + const rows = hunkRows(hunk) + return { old: highlightedSide(rows, 'old', highlighter), new: highlightedSide(rows, 'new', highlighter) } +} + +function DiffText({ text, spans }: { text: string; spans: readonly HighlightSpan[] | undefined }): ReactNode { + return + {spans === undefined ? text : spans.map((span, index) => {span.text})} + +} + +/** + * Number a hunk's lines: context lines count on both sides, deletions on the + * old side, additions on the new side. + * @param hunk - a served hunk. + * @returns the rows in order. + */ +export function hunkRows(hunk: WorkspaceDiffHunk): DiffRow[] { + let oldNo = hunk.oldStart + let newNo = hunk.newStart + return hunk.lines.map((line) => { + const text = line.slice(1) + switch (line[0]) { + case '+': return { kind: 'add', old: undefined, new: newNo++, text } + case '-': return { kind: 'del', old: oldNo++, new: undefined, text } + default: return { kind: 'context', old: oldNo++, new: newNo++, text } + } + }) +} + +/** + * Pair a hunk's lines for the side-by-side view: each run of deletions is + * aligned with the run of additions that follows it, row by row, and context + * lines sit on both sides. + * @param hunk - a served hunk. + * @returns the rows in order. + */ +export function splitRows(hunk: WorkspaceDiffHunk): SplitRow[] { + const rows: SplitRow[] = [] + let dels: NonNullable[] = [] + let adds: NonNullable[] = [] + const flush = (): void => { + for (let at = 0; at < Math.max(dels.length, adds.length); at += 1) { + const left = dels[at] + const right = adds[at] + rows.push({ ...left === undefined ? {} : { left }, ...right === undefined ? {} : { right } }) + } + dels = [] + adds = [] + } + for (const row of hunkRows(hunk)) { + if (row.kind === 'del') dels.push({ no: row.old as number, text: row.text, kind: 'del' }) + else if (row.kind === 'add') adds.push({ no: row.new as number, text: row.text, kind: 'add' }) + else { + flush() + rows.push({ left: { no: row.old as number, text: row.text, kind: 'context' }, right: { no: row.new as number, text: row.text, kind: 'context' } }) + } + } + flush() + return rows +} + +/** + * The hunks to draw, cut at {@link MAX_RENDERED_LINES} lines in total. + * @param hunks - served hunks. + * @returns the hunks with the last one shortened as needed, and whether anything was cut. + */ +export function renderedHunks(hunks: readonly WorkspaceDiffHunk[]): { hunks: WorkspaceDiffHunk[]; truncated: boolean } { + let budget = MAX_RENDERED_LINES + const kept: WorkspaceDiffHunk[] = [] + for (const hunk of hunks) { + if (budget === 0) return { hunks: kept, truncated: true } + kept.push(hunk.lines.length <= budget ? hunk : { ...hunk, lines: hunk.lines.slice(0, budget) }) + budget -= Math.min(budget, hunk.lines.length) + } + return { hunks: kept, truncated: hunks.some((hunk, at) => kept[at] !== hunk) } +} + +/** The one-line fact about a text comparison worth stating above its hunks, if any. */ +function noteOf(diff: Extract): 'diff.created' | 'diff.deleted' | 'diff.unchanged' | undefined { + if (!diff.before) return 'diff.created' + if (!diff.after) return 'diff.deleted' + if (diff.hunks.length === 0) return 'diff.unchanged' + return undefined +} + +/** + * Render a file comparison with the same states and highlighting in previews and review tabs. + * @param props - comparison state, layout choices, retry action, and localized copy. + * @returns the comparison or its loading, unavailable, or error state. + */ +export function FileDiff({ state, split, wrap, retry, t }: { + state: ChangesDiffState | undefined + split: boolean + wrap: boolean + retry: () => void +} & PropsLocale): ReactNode { + if (state === undefined || state === 'loading') return

{t('diff.loading')}

+ if (state === 'missing') return

{t('diff.missing')}

+ if (state === 'error') { + return
{t('diff.error')}
+ } + if (state.kind === 'binary') return

{t('diff.binary')}

+ if (state.kind === 'oversized') return

{t('diff.oversized')}

+ return +} + +/** The kind a paired row carries: a deletion or addition on either side, otherwise context. */ +function splitRowKind(row: SplitRow): DiffRow['kind'] { + return row.left?.kind === 'del' ? 'del' : row.right?.kind === 'add' ? 'add' : 'context' +} + +function hunkHeader(hunk: WorkspaceDiffHunk): string { + return `@@ -${hunk.oldStart},${hunk.oldLines} +${hunk.newStart},${hunk.newLines} @@` +} + +/** + * The side-by-side view without wrapping: two columns that clip their long + * lines and scroll together on both axes, so a long line on one side never + * runs under the other and both sides show the same rows and columns of text. + * Every line is one fixed-height row, which keeps the sides aligned. + */ +function SplitColumns({ hunks, highlights }: { hunks: readonly WorkspaceDiffHunk[]; highlights: readonly HunkHighlights[] }): ReactNode { + const paired = useMemo(() => hunks.map(hunk => ({ header: hunkHeader(hunk), rows: splitRows(hunk) })), [hunks]) + const columns = useRef>({ left: null, right: null }) + const offsets = useRef({ left: { scrollLeft: 0, scrollTop: 0 }, right: { scrollLeft: 0, scrollTop: 0 } }) + const follow = (side: 'left' | 'right') => (event: UIEvent): void => { + const peer = side === 'left' ? 'right' : 'left' + const other = columns.current[peer] + /* v8 ignore next -- Both column refs are attached before browser scroll events can run. */ + if (other === null) return + for (const axis of ['scrollLeft', 'scrollTop'] as const) { + const value = event.currentTarget[axis] + if (offsets.current[side][axis] === value) continue + offsets.current[side][axis] = value + other[axis] = value + // Record the browser-clamped offset so its scroll event cannot pull the source back. + offsets.current[peer][axis] = other[axis] + } + } + return ( +
+ {(['left', 'right'] as const).map(side => ( +
{ columns.current[side] = element }} onScroll={follow(side)}> + {paired.map((hunk, position) => ( +
+
{hunk.header}
+ {hunk.rows.map((row, at) => { + const cell = row[side] + const spans = cell === undefined ? undefined : highlights[position]?.[side === 'left' ? 'old' : 'new']?.get(cell.no) + return ( +
+ {cell?.no ?? ''} + +
+ ) + })} +
+ ))} +
+ ))} +
+ ) +} + +/** The hunks of a text comparison with their line numbers, unified or side by side. */ +function TextDiff({ diff, split, wrap, t }: { diff: Extract; split: boolean; wrap: boolean } & PropsLocale): ReactNode { + const note = noteOf(diff) + const { hunks, truncated } = useMemo(() => renderedHunks(diff.hunks), [diff.hunks]) + const highlighter = useCodeHighlighter(languageForPath(diff.path)) + const highlights = useMemo(() => hunks.map(hunk => hunkHighlights(hunk, highlighter)), [hunks, highlighter]) + return ( +
+ {note !== undefined &&

{t(note)}

} + {diff.coarse &&

{t('diff.coarse')}

} + {truncated &&

{t('diff.truncated', { count: String(MAX_RENDERED_LINES) })}

} + {split && !wrap ? : hunks.map((hunk, position) => { + const highlighted = highlights[position] + return
+
{hunkHeader(hunk)}
+ {split ? splitRows(hunk).map((row, at) => ( +
+ + {row.left?.no ?? ''} + + + + {row.right?.no ?? ''} + + +
+ )) : hunkRows(hunk).map((row, at) => ( +
+ {row.old ?? ''} + {row.new ?? ''} + {row.kind === 'add' ? '+' : row.kind === 'del' ? '-' : ' '} + +
+ ))} +
+ })} +
+ ) +} diff --git a/packages/client/ui-deliverables/src/client/ReviewTab.module.css b/packages/client/ui-deliverables/src/client/ReviewTab.module.css index 8d9fd9ede4..28fb9b62f4 100644 --- a/packages/client/ui-deliverables/src/client/ReviewTab.module.css +++ b/packages/client/ui-deliverables/src/client/ReviewTab.module.css @@ -1,7 +1,5 @@ -/** Review tab: a file selector and icon tools in the document preview's header row, then numbered diff lines in the code font. Unwrapped split columns clip their lines and scroll together on both axes; wrapped ones share rows so the sides stay aligned. */ -.root { --diff-add-fill: color-mix(in srgb, var(--dsw-alias-state-success-primary) 8%, transparent); --diff-del-fill: color-mix(in srgb, var(--dsw-alias-state-error-primary) 8%, transparent); --diff-empty-fill: color-mix(in srgb, var(--dsw-alias-interactive-bg-hover) 50%, transparent); display: flex; flex-direction: column; box-sizing: border-box; width: 100%; height: 100%; min-height: 0; color: var(--dsw-alias-label-primary); } -:global(body[data-ds-dark-theme]) .root { --diff-add-fill: color-mix(in srgb, var(--dsw-alias-state-success-primary) 12%, transparent); --diff-del-fill: color-mix(in srgb, var(--dsw-alias-state-error-primary) 12%, transparent); } -.header { display: flex; flex: 0 0 auto; gap: 6px; align-items: center; box-sizing: border-box; height: 38px; padding: 0 6px 0 8px; border-bottom: 0.5px solid var(--dsw-alias-border-l3); } +/** Review tab sizing, file selector, and file action controls. */ +.root { height: 100%; } .selector { flex: 0 1 auto; min-width: 0; } .selectorLabel { flex: 1 1 auto; min-width: 0; overflow: hidden; font-size: 12px; text-overflow: ellipsis; white-space: nowrap; } .selectorButton { display: inline-flex; gap: 4px; align-items: center; box-sizing: border-box; max-width: 100%; height: 28px; padding: 0 6px 0 8px; border: 0; border-radius: 8px; background: transparent; color: var(--dsw-alias-label-primary); cursor: pointer; font: inherit; font-size: 12px; line-height: 20px; } @@ -22,27 +20,3 @@ .tool[aria-pressed='true'] .compareIcon { transform: rotate(90deg); } .tool:disabled { cursor: progress; } .tool[data-error] { color: var(--dsw-alias-state-error-primary); } -.status { display: flex; gap: 12px; align-items: center; margin: 0; padding: 16px; font-size: 13px; color: var(--dsw-alias-label-secondary); } -.body { display: flex; flex: 1 1 auto; flex-direction: column; min-height: 0; overflow: auto; padding: 8px 0 16px; font: var(--dsw-font-markdown-code-block); } -.columns { display: grid; flex: 1 1 auto; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); min-height: 0; } -.column { min-width: 0; overflow-x: scroll; } -.column + .column { border-left: 0.5px solid var(--dsw-alias-border-l3); } -.sideLine { display: grid; grid-template-columns: 3.5em max-content; box-sizing: border-box; min-width: 100%; width: max-content; min-height: 22px; line-height: 22px; white-space: pre; } -.note { margin: 0; padding: 4px 16px 8px; font: var(--dsw-font-xs-13); color: var(--dsw-alias-label-tertiary); } -.hunk { margin-bottom: 8px; } -.hunkHeader { padding: 4px 16px; color: var(--dsw-alias-label-tertiary); white-space: pre; } -.line { display: grid; grid-template-columns: 3.5em 3.5em 1.2em minmax(0, 1fr); box-sizing: border-box; min-height: 22px; line-height: 22px; white-space: pre; } -.splitLine { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); min-height: 22px; line-height: 22px; white-space: pre; } -.cell { display: grid; grid-template-columns: 3.5em minmax(0, 1fr); box-sizing: border-box; min-width: 0; } -.cell + .cell { border-left: 0.5px solid var(--dsw-alias-border-l3); } -.number { padding-right: 8px; color: var(--dsw-alias-label-tertiary); text-align: right; user-select: none; } -.sign { text-align: center; user-select: none; } -.text { padding-right: 16px; } -.body[data-review-wrap] .line, .body[data-review-wrap] .splitLine { white-space: pre-wrap; } -.body[data-review-wrap] .text { overflow-wrap: anywhere; } -.add { background: var(--diff-add-fill); box-shadow: inset 3px 0 0 var(--dsw-alias-state-success-primary); } -.add .sign, .add .text { color: var(--dsw-alias-state-success-primary); } -.del { background: var(--diff-del-fill); box-shadow: inset 3px 0 0 var(--dsw-alias-state-error-primary); } -.del .sign, .del .text { color: var(--dsw-alias-state-error-primary); } -.context .text { color: var(--dsw-alias-label-secondary); } -.empty { background: var(--diff-empty-fill); } diff --git a/packages/client/ui-deliverables/src/client/ReviewTab.tsx b/packages/client/ui-deliverables/src/client/ReviewTab.tsx index 362f88ec9d..9574dd7bdf 100644 --- a/packages/client/ui-deliverables/src/client/ReviewTab.tsx +++ b/packages/client/ui-deliverables/src/client/ReviewTab.tsx @@ -3,30 +3,27 @@ * selected file's turn-start and turn-end comparison drawn unified or side by * side, wrapped or scrolling, and controls to open the file itself. */ -import { useEffect, useMemo, useRef, useState } from 'react' -import type { ReactNode, UIEvent } from 'react' +import { useEffect, useMemo, useState } from 'react' +import type { ReactNode } from 'react' import { - Button, IconChevronDownOutlineRegular, IconCompareSplitOutlineRegular, IconInspectOutlineRegular, + IconChevronDownOutlineRegular, IconCompareSplitOutlineRegular, IconInspectOutlineRegular, IconNowrapFillRegular, IconWrapFillRegular, Menu, Tooltip, - languageForPath, useCodeHighlighter, } from '@deepseek-ai/dsh-client-ui-primitives' -import type { CodeHighlighter, HighlightSpan } from '@deepseek-ai/dsh-client-ui-primitives' import type { InjectFace, PropsLocale, PropsRenderSlots, PropsRuntime, PropsStore } from '@deepseek-ai/dsh-client-ui-slots' import type { ObservableSnapshot } from '@deepseek-ai/dsh-client-store' import { fileAddressFor } from '@deepseek-ai/dsh-util-workspace-path' -import type { WorkspaceChangedFile, WorkspaceDiffHunk } from '@deepseek-ai/dsh-workspace-changes/types' -import { changedFileUrl, changesDiffUrl, changesSummaryUrl, parseChangesReviewAddress, type ChangesDiff } from '../changes.ts' +import type { WorkspaceChangedFile } from '@deepseek-ai/dsh-workspace-changes/types' +import { changedFileUrl, changesDiffUrl, changesSummaryUrl, parseChangesReviewAddress } from '../changes.ts' import type { ChangesDiffStore } from './changes-diff.ts' import type { ChangesSummaryStore } from './changes-summary.ts' import type { PresentedOpenController } from './present-open.ts' import type { ChangesReviewParams } from './review-definition.ts' import type { createReviewStore } from './review-store.ts' import type { NS } from './locales.ts' +import { FileDiff } from './FileDiff.tsx' +import diffCss from './FileDiff.module.css' import css from './ReviewTab.module.css' -/** Lines drawn before the tab stops; a coarse comparison of a file near the byte cap would otherwise draw every line. */ -export const MAX_RENDERED_LINES = 5000 - const GROUPED = new Intl.NumberFormat('en-US') /** Summary and comparison reads, desktop metadata, and the native open supplied by the plugin. */ @@ -47,125 +44,6 @@ export interface ReviewInjected { export type ReviewTabProps = PropsRuntime<'sidebar.right.pane.tab'> & PropsStore> & InjectFace & PropsLocale & PropsRenderSlots<'deliverables.review.file.actions'> -/** One drawn line of a hunk with its line numbers on each side. */ -export interface DiffRow { - kind: 'add' | 'del' | 'context' - old: number | undefined - new: number | undefined - text: string -} - -/** One side-by-side row: the old side, the new side, or both. */ -export interface SplitRow { - left?: { no: number; text: string; kind: 'del' | 'context' } - right?: { no: number; text: string; kind: 'add' | 'context' } -} - -interface HunkHighlights { - old: ReadonlyMap | undefined - new: ReadonlyMap | undefined -} - -function highlightedSide(rows: readonly DiffRow[], side: 'old' | 'new', highlighter: CodeHighlighter): HunkHighlights['old'] { - const source = rows.flatMap((row) => { - const no = row[side] - return no === undefined ? [] : [{ no, text: row.text }] - }) - if (source.length === 0) return new Map() - const highlighted = highlighter(source.map(line => line.text).join('\n')) - if (highlighted === undefined) return undefined - return new Map(source.map((line, index) => { - // Shiki omits one terminal empty token row; retain an aligned empty run for that source line. - return [line.no, highlighted[index] ?? []] - })) -} - -function hunkHighlights(hunk: WorkspaceDiffHunk, highlighter: CodeHighlighter): HunkHighlights { - const rows = hunkRows(hunk) - return { old: highlightedSide(rows, 'old', highlighter), new: highlightedSide(rows, 'new', highlighter) } -} - -function DiffText({ text, spans }: { text: string; spans: readonly HighlightSpan[] | undefined }): ReactNode { - return - {spans === undefined ? text : spans.map((span, index) => {span.text})} - -} - -/** - * Number a hunk's lines: context lines count on both sides, deletions on the - * old side, additions on the new side. - * @param hunk - a served hunk. - * @returns the rows in order. - */ -export function hunkRows(hunk: WorkspaceDiffHunk): DiffRow[] { - let oldNo = hunk.oldStart - let newNo = hunk.newStart - return hunk.lines.map((line) => { - const text = line.slice(1) - switch (line[0]) { - case '+': return { kind: 'add', old: undefined, new: newNo++, text } - case '-': return { kind: 'del', old: oldNo++, new: undefined, text } - default: return { kind: 'context', old: oldNo++, new: newNo++, text } - } - }) -} - -/** - * Pair a hunk's lines for the side-by-side view: each run of deletions is - * aligned with the run of additions that follows it, row by row, and context - * lines sit on both sides. - * @param hunk - a served hunk. - * @returns the rows in order. - */ -export function splitRows(hunk: WorkspaceDiffHunk): SplitRow[] { - const rows: SplitRow[] = [] - let dels: NonNullable[] = [] - let adds: NonNullable[] = [] - const flush = (): void => { - for (let at = 0; at < Math.max(dels.length, adds.length); at += 1) { - const left = dels[at] - const right = adds[at] - rows.push({ ...left === undefined ? {} : { left }, ...right === undefined ? {} : { right } }) - } - dels = [] - adds = [] - } - for (const row of hunkRows(hunk)) { - if (row.kind === 'del') dels.push({ no: row.old as number, text: row.text, kind: 'del' }) - else if (row.kind === 'add') adds.push({ no: row.new as number, text: row.text, kind: 'add' }) - else { - flush() - rows.push({ left: { no: row.old as number, text: row.text, kind: 'context' }, right: { no: row.new as number, text: row.text, kind: 'context' } }) - } - } - flush() - return rows -} - -/** - * The hunks to draw, cut at {@link MAX_RENDERED_LINES} lines in total. - * @param hunks - served hunks. - * @returns the hunks with the last one shortened as needed, and whether anything was cut. - */ -export function renderedHunks(hunks: readonly WorkspaceDiffHunk[]): { hunks: WorkspaceDiffHunk[]; truncated: boolean } { - let budget = MAX_RENDERED_LINES - const kept: WorkspaceDiffHunk[] = [] - for (const hunk of hunks) { - if (budget === 0) return { hunks: kept, truncated: true } - kept.push(hunk.lines.length <= budget ? hunk : { ...hunk, lines: hunk.lines.slice(0, budget) }) - budget -= Math.min(budget, hunk.lines.length) - } - return { hunks: kept, truncated: hunks.some((hunk, at) => kept[at] !== hunk) } -} - -/** The one-line fact about a text comparison worth stating above its hunks, if any. */ -function noteOf(diff: Extract): 'diff.created' | 'diff.deleted' | 'diff.unchanged' | undefined { - if (!diff.before) return 'diff.created' - if (!diff.after) return 'diff.deleted' - if (diff.hunks.length === 0) return 'diff.unchanged' - return undefined -} - /** The file index a navigation names, when it names one. */ function navigatedIndex(params: unknown): number | undefined { const index = (params as ChangesReviewParams | undefined)?.index @@ -231,8 +109,8 @@ export function ReviewTab({ const native = host !== null && host !== 'error' && host.available && phase !== 'nativeUnavailable' const summaryState = summary === undefined || summary === 'loading' ? 'loading' : summary === 'missing' ? 'missing' : 'ready' return ( -
-
+
+
{file === undefined ? {t('review.title', { turn: String(coordinates.turn) })} : { setMenuOpen(false) }} @@ -273,126 +151,10 @@ export function ReviewTab({ })}
- {summaryState === 'loading' &&

{t('diff.loading')}

} - {summaryState === 'missing' &&

{t('diff.missing')}

} - {file !== undefined && {t('diff.loading')}

} + {summaryState === 'missing' &&

{t('diff.missing')}

} + {file !== undefined && { void loadChangesDiff(sessionId, seq, index) }} />}
) } - -/** The selected file's comparison, or the state that stands in for it. */ -function FileBody({ state, split, wrap, retry, t }: { - state: ChangesDiffStore['state'] extends { getSnapshot(): infer S } ? (S extends Record ? V : never) : never - split: boolean - wrap: boolean - retry: () => void -} & PropsLocale): ReactNode { - if (state === undefined || state === 'loading') return

{t('diff.loading')}

- if (state === 'missing') return

{t('diff.missing')}

- if (state === 'error') { - return
{t('diff.error')}
- } - if (state.kind === 'binary') return

{t('diff.binary')}

- if (state.kind === 'oversized') return

{t('diff.oversized')}

- return -} - -/** The kind a paired row carries: a deletion or addition on either side, otherwise context. */ -function splitRowKind(row: SplitRow): DiffRow['kind'] { - return row.left?.kind === 'del' ? 'del' : row.right?.kind === 'add' ? 'add' : 'context' -} - -function hunkHeader(hunk: WorkspaceDiffHunk): string { - return `@@ -${hunk.oldStart},${hunk.oldLines} +${hunk.newStart},${hunk.newLines} @@` -} - -/** - * The side-by-side view without wrapping: two columns that clip their long - * lines and scroll together on both axes, so a long line on one side never - * runs under the other and both sides show the same rows and columns of text. - * Every line is one fixed-height row, which keeps the sides aligned. - */ -function SplitColumns({ hunks, highlights }: { hunks: readonly WorkspaceDiffHunk[]; highlights: readonly HunkHighlights[] }): ReactNode { - const paired = useMemo(() => hunks.map(hunk => ({ header: hunkHeader(hunk), rows: splitRows(hunk) })), [hunks]) - const columns = useRef>({ left: null, right: null }) - const offsets = useRef({ left: { scrollLeft: 0, scrollTop: 0 }, right: { scrollLeft: 0, scrollTop: 0 } }) - const follow = (side: 'left' | 'right') => (event: UIEvent): void => { - const peer = side === 'left' ? 'right' : 'left' - const other = columns.current[peer] - /* v8 ignore next -- Both column refs are attached before browser scroll events can run. */ - if (other === null) return - for (const axis of ['scrollLeft', 'scrollTop'] as const) { - const value = event.currentTarget[axis] - if (offsets.current[side][axis] === value) continue - offsets.current[side][axis] = value - other[axis] = value - // Record the browser-clamped offset so its scroll event cannot pull the source back. - offsets.current[peer][axis] = other[axis] - } - } - return ( -
- {(['left', 'right'] as const).map(side => ( -
{ columns.current[side] = element }} onScroll={follow(side)}> - {paired.map((hunk, position) => ( -
-
{hunk.header}
- {hunk.rows.map((row, at) => { - const cell = row[side] - const spans = cell === undefined ? undefined : highlights[position]?.[side === 'left' ? 'old' : 'new']?.get(cell.no) - return ( -
- {cell?.no ?? ''} - -
- ) - })} -
- ))} -
- ))} -
- ) -} - -/** The hunks of a text comparison with their line numbers, unified or side by side. */ -function TextDiff({ diff, split, wrap, t }: { diff: Extract; split: boolean; wrap: boolean } & PropsLocale): ReactNode { - const note = noteOf(diff) - const { hunks, truncated } = useMemo(() => renderedHunks(diff.hunks), [diff.hunks]) - const highlighter = useCodeHighlighter(languageForPath(diff.path)) - const highlights = useMemo(() => hunks.map(hunk => hunkHighlights(hunk, highlighter)), [hunks, highlighter]) - return ( -
- {note !== undefined &&

{t(note)}

} - {diff.coarse &&

{t('diff.coarse')}

} - {truncated &&

{t('diff.truncated', { count: String(MAX_RENDERED_LINES) })}

} - {split && !wrap ? : hunks.map((hunk, position) => { - const highlighted = highlights[position] - return
-
{hunkHeader(hunk)}
- {split ? splitRows(hunk).map((row, at) => ( -
- - {row.left?.no ?? ''} - - - - {row.right?.no ?? ''} - - -
- )) : hunkRows(hunk).map((row, at) => ( -
- {row.old ?? ''} - {row.new ?? ''} - {row.kind === 'add' ? '+' : row.kind === 'del' ? '-' : ' '} - -
- ))} -
- })} -
- ) -} diff --git a/packages/client/ui-deliverables/src/client/index.ts b/packages/client/ui-deliverables/src/client/index.ts index f000547756..886456e328 100644 --- a/packages/client/ui-deliverables/src/client/index.ts +++ b/packages/client/ui-deliverables/src/client/index.ts @@ -66,8 +66,9 @@ export function apply(ctx: ClientContext): void { locale: NS, children: { 'deliverables.file.actions': { kind: 'list', scope: 'session' } }, inject: (): DeliverablesInjected => ({ - hooks: { presentedOpen: opener.state, presentedHost: opener.host, changesSummary: summaries.state, + hooks: { changesDiff: diffs.state, presentedOpen: opener.state, presentedHost: opener.host, changesSummary: summaries.state, showCodeDiff: ctx.settingsScope.developerTools.enabled }, + loadChangesDiff: (sessionId, seq, index) => diffs.load(sessionId, seq, index), reloadPresentedHost: () => opener.loadHost(), loadChangesSummary: (sessionId, seq) => summaries.load(sessionId, seq), openPresented: (sessionId, seq, index, action, application) => opener.open(sessionId, seq, index, action, application), diff --git a/packages/client/ui-deliverables/tests/changed-files-hover.client.spec.tsx b/packages/client/ui-deliverables/tests/changed-files-hover.client.spec.tsx new file mode 100644 index 0000000000..d0fbfe80d9 --- /dev/null +++ b/packages/client/ui-deliverables/tests/changed-files-hover.client.spec.tsx @@ -0,0 +1,106 @@ +// @vitest-environment jsdom +/** Hover dwell, lazy comparison reads, and Sidebar navigation from turn-tail rows. */ +import { useSyncExternalStore } from 'react' +import { act, cleanup, fireEvent, render, screen } from '@testing-library/react' +import { afterEach, beforeEach, expect, it, vi } from 'vitest' +import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' +import { SessionId } from '@deepseek-ai/dsh-session/types' +import { ChangedFiles } from '../src/client/ChangedFiles.tsx' +import { ChangesDiffStore } from '../src/client/changes-diff.ts' +import { changesDiffUrl } from '../src/changes.ts' +import { en } from '../src/client/locales.ts' + +beforeEach(() => { vi.useFakeTimers() }) +afterEach(() => { cleanup(); vi.useRealTimers() }) + +const sessionId = SessionId('hover-session') +const files = ['main.ts', 'second.ts'].map(path => ({ path, display: path, added: 1, deleted: 1 })) + +function mount() { + const diffs = new ChangesDiffStore() + const loadChangesDiff = vi.fn().mockResolvedValue(undefined) + const openReview = vi.fn() + const subscribe = diffs.state.subscribe.bind(diffs.state) + const getSnapshot = diffs.state.getSnapshot.bind(diffs.state) + const view = render( select(useSyncExternalStore(subscribe, getSnapshot))} />) + return { view, diffs, loadChangesDiff, openReview, row: screen.getByRole('button', { name: 'View changes to main.ts' }) } +} + +it('reads only after 500ms and renders the selected comparison in a single column', () => { + const { row, diffs, loadChangesDiff, openReview } = mount() + fireEvent.pointerEnter(row) + act(() => { vi.advanceTimersByTime(499) }) + expect(loadChangesDiff).not.toHaveBeenCalled() + expect(document.querySelector('[data-changes-hover-preview]')).toBeNull() + act(() => { vi.advanceTimersByTime(1) }) + expect(loadChangesDiff).toHaveBeenCalledExactlyOnceWith(sessionId, 5, 0) + expect(screen.getByText('Reading changes…')).toBeTruthy() + act(() => { diffs.state.set({ [changesDiffUrl(sessionId, 5, 0)]: { + kind: 'text', path: 'main.ts', display: 'main.ts', before: true, after: true, coarse: false, + hunks: [{ oldStart: 1, oldLines: 1, newStart: 1, newLines: 1, lines: ['-const value = 1', '+const value = 2'] }], + } }) }) + const preview = document.querySelector('[data-changes-hover-preview]')! + expect(preview.querySelector('[data-review-view="unified"]')).not.toBeNull() + expect(preview.querySelector('[data-diff-side]')).toBeNull() + expect(preview.querySelector('[data-diff-line="del"]')?.textContent).toContain('const value = 1') + expect(preview.querySelector('[data-diff-line="add"]')?.textContent).toContain('const value = 2') + expect(preview.textContent).toContain('/workspace/main.ts') + expect(screen.getByRole('button', { name: 'View changes to main.ts', description: '/workspace/main.ts' })).toBe(row) + expect(preview.querySelector('[data-changes-preview-path]')?.getAttribute('tabindex')).toBeNull() + fireEvent.pointerLeave(row) + fireEvent.pointerEnter(preview) + act(() => { vi.advanceTimersByTime(500) }) + expect(document.querySelector('[data-changes-hover-preview]')).toBe(preview) + fireEvent.pointerDown(row) + fireEvent.click(row) + expect(preview.parentElement?.hasAttribute('data-closing')).toBe(true) + act(() => { vi.advanceTimersByTime(99) }) + expect(document.querySelector('[data-changes-hover-preview]')).toBe(preview) + act(() => { vi.advanceTimersByTime(1) }) + expect(document.querySelector('[data-changes-hover-preview]')).toBeNull() + expect(openReview).toHaveBeenCalledExactlyOnceWith(0) +}) + +it('keeps a status note for a comparison with no hunks', () => { + const { row, diffs } = mount() + diffs.state.set({ [changesDiffUrl(sessionId, 5, 0)]: { + kind: 'text', path: 'main.ts', display: 'main.ts', before: true, after: true, coarse: false, hunks: [], + } }) + fireEvent.pointerEnter(row) + act(() => { vi.advanceTimersByTime(500) }) + expect(document.querySelector('[data-changes-hover-preview] [data-diff-note="empty"]')?.textContent).toBe('Both sides hold the same lines') +}) + +it('cancels a short hover and the pending dwell when unmounted', () => { + const { row, view, loadChangesDiff } = mount() + fireEvent.pointerEnter(row) + act(() => { vi.advanceTimersByTime(499) }) + fireEvent.pointerLeave(row) + act(() => { vi.advanceTimersByTime(500) }) + expect(loadChangesDiff).not.toHaveBeenCalled() + fireEvent.pointerEnter(row) + view.unmount() + act(() => { vi.advanceTimersByTime(500) }) + expect(loadChangesDiff).not.toHaveBeenCalled() +}) + +it('previews each row independently, retries failed reads, and closes on Escape', () => { + const { row, diffs, loadChangesDiff } = mount() + diffs.state.set({ [changesDiffUrl(sessionId, 5, 0)]: 'error', [changesDiffUrl(sessionId, 5, 1)]: 'missing' }) + fireEvent.pointerEnter(row) + act(() => { vi.advanceTimersByTime(500) }) + fireEvent.click(screen.getByRole('button', { name: 'Retry' })) + expect(loadChangesDiff).toHaveBeenCalledExactlyOnceWith(sessionId, 5, 0) + fireEvent.pointerLeave(row) + fireEvent.pointerEnter(screen.getByRole('button', { name: 'View changes to second.ts' })) + act(() => { vi.advanceTimersByTime(200) }) + act(() => { vi.advanceTimersByTime(300) }) + expect(document.querySelectorAll('[data-changes-hover-preview]')).toHaveLength(1) + expect(document.querySelector('[data-changes-preview-path]')?.textContent).toBe('/workspace/second.ts') + fireEvent.keyDown(screen.getByRole('button', { name: 'View changes to second.ts' }), { key: 'Escape' }) + act(() => { vi.advanceTimersByTime(100) }) + expect(document.querySelector('[data-changes-hover-preview]')).toBeNull() +}) diff --git a/packages/client/ui-deliverables/tests/deliverables.client.spec.tsx b/packages/client/ui-deliverables/tests/deliverables.client.spec.tsx index 831c32a74d..2231effb3d 100644 --- a/packages/client/ui-deliverables/tests/deliverables.client.spec.tsx +++ b/packages/client/ui-deliverables/tests/deliverables.client.spec.tsx @@ -26,6 +26,7 @@ import type { ChatFileMentions, TurnTailOwnerProps } from '@deepseek-ai/dsh-clie import { makeTranslate, stubSettingsScope } from '@deepseek-ai/dsh-client-test-runtime' import { Deliverables, DeliverablesTail, selectDeliverables, type DeliverablesInjected } from '../src/client/Deliverables.tsx' import type { ReviewInjected } from '../src/client/ReviewTab.tsx' +import { ChangesDiffStore } from '../src/client/changes-diff.ts' import { ChangesSummaryStore } from '../src/client/changes-summary.ts' import { changesSummaryUrl, type ChangesSummary } from '../src/changes.ts' import { PresentedOpenController } from '../src/client/present-open.ts' @@ -40,10 +41,13 @@ import type { SessionEvent } from '@deepseek-ai/dsh-session/types' function openProps(controller = new PresentedOpenController(), summaries = new ChangesSummaryStore()) { controller.host.set({ name: 'desktop', available: true, fileManager: 'finder' }) + const diffs = new ChangesDiffStore() const sessions: SessionListState = { ids: [], byId: {}, phase: 'ready', projectionsBySession: {} } return { SessionProvider: ({ children }: { children?: import('react').ReactNode }) => <>{children}, renderSlot: renderFileActions, + useChangesDiff: (select: (state: ReturnType) => T): T => select(diffs.state.getSnapshot()), + loadChangesDiff: vi.fn((...args: Parameters) => diffs.load(...args)), useShowCodeDiff: (select: (value: boolean) => T): T => select(true), useSessions: (select: (state: SessionListState) => T): T => select(sessions), reloadPresentedHost: vi.fn(() => controller.loadHost()), @@ -598,9 +602,9 @@ describe('ChangedFiles card', () => { expect(within(card).getByText('-326')).toBeTruthy() expect(within(card).getByText('Preview in sidebar')).toBeTruthy() expect(within(card).getAllByRole('listitem')).toHaveLength(4) - expect(within(card).getByText('config/design-token')).toBeTruthy() + expect(within(within(card).getByRole('button', { name: 'View changes to config/design-token' })).getByText('config/design-token')).toBeTruthy() expect(within(card).getByText('+42')).toBeTruthy() - expect(within(card).getByText('src/index.ts')).toBeTruthy() + expect(within(within(card).getByRole('button', { name: 'View changes to src/index.ts' })).getByText('src/index.ts')).toBeTruthy() expect(within(card).queryByText('~/.zshrc')).toBeNull() expect(within(card).getByRole('button', { name: 'Review this turn’s changes in the sidebar' }) .querySelector('svg')?.getAttribute('width')).toBe('10') @@ -616,7 +620,8 @@ describe('ChangedFiles card', () => { fireEvent.click(expand) expect(within(card).getAllByRole('listitem')).toHaveLength(5) expect(within(card).getByText('binary')).toBeTruthy() - expect(within(card).getByRole('button', { name: 'View changes to ~/.zshrc' }).getAttribute('title')).toBe('/home/u/.zshrc') + expect(within(card).getByRole('button', { name: 'View changes to ~/.zshrc' }).getAttribute('title')).toBeNull() + expect(within(card).getByRole('button', { name: 'View changes to ~/.zshrc', description: '/home/u/.zshrc' })).toBeTruthy() fireEvent.click(within(card).getByRole('button', { name: 'View changes to ~/.zshrc' })) expect(props.openChangesReview).toHaveBeenLastCalledWith({ sessionId: 'child-session', seq: 5, turn: 1 }, 4) const collapse = within(card).getByRole('button', { name: 'Collapse changed files' }) @@ -644,6 +649,19 @@ describe('ChangedFiles card', () => { expect(view.getByRole('button', { name: '展开全部 5 个改动文件' }).textContent).toContain('全部 5 个文件') }) + it('preserves expanded rows when the served announcement changes', () => { + const summaries = servedStore() + summaries.state.set({ ...summaries.state.getSnapshot(), [changesSummaryUrl(SessionId('child-session'), 6)]: served }) + const { props, openFile, view } = renderCard(new PresentedOpenController(), en, { changes, presented: [] }, summaries) + const card = view.container.querySelector('[data-changed-files]') + fireEvent.click(view.getByRole('button', { name: 'Show all 5 changed files' })) + view.rerender() + expect(view.container.querySelector('[data-changed-files]')).toBe(card) + expect(view.getAllByRole('listitem')).toHaveLength(5) + expect(view.getByRole('button', { name: 'Collapse changed files' }).getAttribute('aria-expanded')).toBe('true') + }) + it('keeps every count in place whatever the native-open gestures of the review tab are doing', () => { const controller = new PresentedOpenController() controller.state.set({ @@ -790,7 +808,12 @@ describe('plugin registration', () => { await tabFace.loadChangesSummary(SessionId('child-session'), 6) expect(tabFace.hooks.changesSummary.getSnapshot()['api/changes.summary?sessionId=child-session&seq=6']).toEqual({ turn: 3, files: [], total: 0, added: 0, deleted: 0 }) fetcher.mockResolvedValueOnce(Response.json({ kind: 'binary', path: 'src/a.ts', display: 'src/a.ts' })) + await face.loadChangesDiff(SessionId('child-session'), 5, 1) + expect(face.hooks.changesDiff).toBe(tabFace.hooks.changesDiff) + expect(face.hooks.changesDiff.getSnapshot()['api/changes.diff?sessionId=child-session&seq=5&index=1']).toEqual({ kind: 'binary', path: 'src/a.ts', display: 'src/a.ts' }) + const readsAfterHover = fetcher.mock.calls.length await tabFace.loadChangesDiff(SessionId('child-session'), 5, 1) + expect(fetcher).toHaveBeenCalledTimes(readsAfterHover) expect(tabFace.hooks.changesDiff.getSnapshot()['api/changes.diff?sessionId=child-session&seq=5&index=1']).toEqual({ kind: 'binary', path: 'src/a.ts', display: 'src/a.ts' }) expect(tabFace.hooks.presentedHost).toBe(face.hooks.presentedHost) fetcher.mockResolvedValueOnce(Response.json({ name: 'desktop', available: true, fileManager: 'finder' })) diff --git a/packages/client/ui-deliverables/tests/review-tab.client.spec.tsx b/packages/client/ui-deliverables/tests/review-tab.client.spec.tsx index 2af2a12b4a..de97d56dac 100644 --- a/packages/client/ui-deliverables/tests/review-tab.client.spec.tsx +++ b/packages/client/ui-deliverables/tests/review-tab.client.spec.tsx @@ -18,8 +18,9 @@ import { ChangesDiffStore } from '../src/client/changes-diff.ts' import { ChangesSummaryStore } from '../src/client/changes-summary.ts' import { PresentedOpenController } from '../src/client/present-open.ts' import { - hunkRows, MAX_RENDERED_LINES, renderedHunks, ReviewTab, splitRows, type ReviewInjected, type ReviewTabProps, + ReviewTab, type ReviewInjected, type ReviewTabProps, } from '../src/client/ReviewTab.tsx' +import { hunkRows, MAX_RENDERED_LINES, renderedHunks, splitRows } from '../src/client/FileDiff.tsx' import { changesReviewDefinition } from '../src/client/review-definition.ts' import { createReviewStore } from '../src/client/review-store.ts' import { en, zh } from '../src/client/locales.ts' diff --git a/packages/client/ui-primitives/README.i18n.yaml b/packages/client/ui-primitives/README.i18n.yaml index 0c55515d7e..4edb544f84 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: 292f1cc817d30837e36ced9adeee3d226852f4a5 -README.zh.md: c61ecb0cba1237d8fbf0e4d22333f5e21724c9fa +README.md: 1c1b1312bb0b12d504f11ea2c82414a719b08e51 +README.zh.md: 82581fb267da82945182a4c662d070b235ddc88a diff --git a/packages/client/ui-primitives/README.md b/packages/client/ui-primitives/README.md index 292f1cc817..1c1b1312bb 100644 --- a/packages/client/ui-primitives/README.md +++ b/packages/client/ui-primitives/README.md @@ -74,7 +74,7 @@ Writing your own component in your own package is fine when the need is genuinel ### Controls and icons -The catalog above lists what each export is for; this section covers the behavior that props alone do not show. Product icon names omit artboard sizes and end in `Regular` for the supplied one-pixel artwork or `Medium` for the same geometry at a 1.3px stroke; the `size` prop controls rendered dimensions ([decision](../../../.agents/notes/implemented/architecture/2026-09-16-size-neutral-product-icon-weights.md)). Each product, reference, link, and permission glyph intentionally keeps both weight exports even when the current product uses only one, so callers can choose emphasis without adding another API later; fill-only pairs render identically. `IconWarningOutlineRegular`/`Medium` use a circle; `IconWarningTriangleOutlineRegular`/`Medium` use a rounded triangle. `FishLogo` and `BrandWordmark` fill brand slots. `FileTypeIcon` renders the traditional 28px spreadsheet, folder, HTML, image, Markdown, generic, PDF, PPT, video, and Word glyphs, and uses the imported square technology artwork for the established 48 code and configuration categories. The import replaces artwork only: archive-only categories do not extend `CodeFileType`. `classifyFileType` applies exact filename, prefix, suffix, optional project-context, and extension rules in that order; React names win over TypeScript/JavaScript, Angular suffixes win over their base extension, and a Dart file becomes Flutter only when the supplied project files contain a `pubspec.yaml` with `flutter:`. Markdown and SVG remain traditional Markdown and image files. Spreadsheet mappings include CSV, TSV, Excel workbooks and templates, OpenDocument spreadsheets, and Numbers; KEY maps to slides, while RTF/ODT/Pages map to documents. `fileExtension` exposes the same basename and final-dot parsing for adjacent metadata labels. Traditional glyphs use a solid category-colored sheet with a white mark and translucent white corner; the generic file uses a grey sheet and darker grey corner. Callers may override the sheet color through `--dsh-file-type-icon-color`. The full-color technology artwork is the deliberate exception and retains its embedded palette. All glyphs are decorative and carry no label. `LinkIconMedium` is the leading glyph for clickable artifact links — globe, folder, code, image, document, or plain paper, and for a `url` link the mark of a well-known site named by its `href` — the developer sites a transcript usually cites (GitHub, GitLab, npm, PyPI, Stack Overflow, MDN, Wikipedia, Hacker News, YouTube, X, Bilibili, Zhihu, Juejin, CSDN) and mainstream search, video, social, shopping, and reference sites (Google, Baidu, DuckDuckGo, TikTok, Netflix, Spotify, Facebook, Instagram, Reddit, Telegram, WhatsApp, WeChat, QQ, Weibo, Taobao, AliExpress, eBay, Quora, V2EX, Apple) — while `classifyLinkPath` folds the shared file types into that existing six-category vocabulary. `ConnectionIndicator` renders a warning-colored disconnected action whose permanent retry glyph marks the retry action beside the owner-supplied outage label, the shared ongoing loader beside a label whose one-to-three dots advance every 500ms independently of retry timing, or a success-colored recovered status. Clicking either warning state requests an immediate reconnect; no hover interaction changes the copy. The pill fades in on appearance, fades out for 150ms before unmounting, and sizes to its current label. Its owner supplies visibility, the recovery hold, localized labels, and the immediate-reconnect callback; the primitive uses no native title tooltip. `useAnchoredPosition` and `useAnchoredMaxHeight` keep floating panels and bottom-anchored overlays clamped to the viewport and following their anchor; `useAnchoredMaxHeight` and portalled `Menu` widen their 12px top viewport margin to the frame's published `--dsh-frame-top-clearance`. `HoverCard` keeps its portaled preview reachable across the anchor gap and can expose a copy button through the `copyText` prop. `Toast` uses the owner's `holdMs` for both the fade delay and its hold-and-fade lifetime. Rerenders with unchanged `holdMs` do not restart that lifetime; completion uses the latest callback, and fully faded actions cannot receive input. A new component key restarts the banner. `rankByName` is the `/` menu's shared candidate ranker for the command and skill sources: the query must be a case-insensitive ordered subsequence of the name; prefix hits rank first, then alignment score, then source order. `Menu.autoFocus` focuses its first enabled item, supports Arrow Up/Down and Home/End navigation, and focuses the first button in the anchor on Escape; it is opt-in for action menus. +The catalog above lists what each export is for; this section covers the behavior that props alone do not show. Product icon names omit artboard sizes and end in `Regular` for the supplied one-pixel artwork or `Medium` for the same geometry at a 1.3px stroke; the `size` prop controls rendered dimensions ([decision](../../../.agents/notes/implemented/architecture/2026-09-16-size-neutral-product-icon-weights.md)). Each product, reference, link, and permission glyph intentionally keeps both weight exports even when the current product uses only one, so callers can choose emphasis without adding another API later; fill-only pairs render identically. `IconWarningOutlineRegular`/`Medium` use a circle; `IconWarningTriangleOutlineRegular`/`Medium` use a rounded triangle. `FishLogo` and `BrandWordmark` fill brand slots. `FileTypeIcon` renders the traditional 28px spreadsheet, folder, HTML, image, Markdown, generic, PDF, PPT, video, and Word glyphs, and uses the imported square technology artwork for the established 48 code and configuration categories. The import replaces artwork only: archive-only categories do not extend `CodeFileType`. `classifyFileType` applies exact filename, prefix, suffix, optional project-context, and extension rules in that order; React names win over TypeScript/JavaScript, Angular suffixes win over their base extension, and a Dart file becomes Flutter only when the supplied project files contain a `pubspec.yaml` with `flutter:`. Markdown and SVG remain traditional Markdown and image files. Spreadsheet mappings include CSV, TSV, Excel workbooks and templates, OpenDocument spreadsheets, and Numbers; KEY maps to slides, while RTF/ODT/Pages map to documents. `fileExtension` exposes the same basename and final-dot parsing for adjacent metadata labels. Traditional glyphs use a solid category-colored sheet with a white mark and translucent white corner; the generic file uses a grey sheet and darker grey corner. Callers may override the sheet color through `--dsh-file-type-icon-color`. The full-color technology artwork is the deliberate exception and retains its embedded palette. All glyphs are decorative and carry no label. `LinkIconMedium` is the leading glyph for clickable artifact links — globe, folder, code, image, document, or plain paper, and for a `url` link the mark of a well-known site named by its `href` — the developer sites a transcript usually cites (GitHub, GitLab, npm, PyPI, Stack Overflow, MDN, Wikipedia, Hacker News, YouTube, X, Bilibili, Zhihu, Juejin, CSDN) and mainstream search, video, social, shopping, and reference sites (Google, Baidu, DuckDuckGo, TikTok, Netflix, Spotify, Facebook, Instagram, Reddit, Telegram, WhatsApp, WeChat, QQ, Weibo, Taobao, AliExpress, eBay, Quora, V2EX, Apple) — while `classifyLinkPath` folds the shared file types into that existing six-category vocabulary. `ConnectionIndicator` renders a warning-colored disconnected action whose permanent retry glyph marks the retry action beside the owner-supplied outage label, the shared ongoing loader beside a label whose one-to-three dots advance every 500ms independently of retry timing, or a success-colored recovered status. Clicking either warning state requests an immediate reconnect; no hover interaction changes the copy. The pill fades in on appearance, fades out for 150ms before unmounting, and sizes to its current label. Its owner supplies visibility, the recovery hold, localized labels, and the immediate-reconnect callback; the primitive uses no native title tooltip. `useAnchoredPosition` and `useAnchoredMaxHeight` keep floating panels and bottom-anchored overlays clamped to the viewport and following their anchor; `useAnchoredMaxHeight` and portalled `Menu` widen their 12px top viewport margin to the frame's published `--dsh-frame-top-clearance`. `HoverCard` keeps its portaled preview reachable across the anchor gap and can expose a copy button through the `copyText` prop. Its `preview` variant uses the anchor or `widthAnchorRef` element’s width minus 48px, centers the panel with 24px side insets, and places it above or below the row within the viewport. The panel keeps the frame’s top clearance, caps its height at 420px, and follows content and anchor resizing; Escape or pointer and keyboard activation of the anchor dismisses it. The whole preview fades in and out over 100ms; a closing preview stops receiving pointer input and unmounts after the fade. Re-entering the anchor during the fade restores it. Reduced-motion preferences suppress the transition. Copy labels are required only when copying is enabled. `Toast` uses the owner's `holdMs` for both the fade delay and its hold-and-fade lifetime. Rerenders with unchanged `holdMs` do not restart that lifetime; completion uses the latest callback, and fully faded actions cannot receive input. A new component key restarts the banner. `rankByName` is the `/` menu's shared candidate ranker for the command and skill sources: the query must be a case-insensitive ordered subsequence of the name; prefix hits rank first, then alignment score, then source order. `Menu.autoFocus` focuses its first enabled item, supports Arrow Up/Down and Home/End navigation, and focuses the first button in the anchor on Escape; it is opt-in for action menus. ### Rendering agent output diff --git a/packages/client/ui-primitives/README.zh.md b/packages/client/ui-primitives/README.zh.md index c61ecb0cba..82581fb267 100644 --- a/packages/client/ui-primitives/README.zh.md +++ b/packages/client/ui-primitives/README.zh.md @@ -74,7 +74,7 @@ kind: "package-library" ### 控件与图标 -上面的目录说明每个导出的用途;本节讲 props 本身看不出来的行为。产品图标名称不含画板尺寸,以 `Regular` 表示原始 1px 图形,以 `Medium` 表示同一几何的 1.3px 描边;`size` prop 控制渲染尺寸([决定](../../../.agents/notes/implemented/architecture/2026-09-16-size-neutral-product-icon-weights.zh.md))。每个产品、引用、链接与权限图形都会有意保留两种线重导出,即使当前产品只使用其中一种,也让调用方无需再次扩展 API 就能选择强调程度;仅填充的成对图形外观相同。`IconWarningOutlineRegular`/`Medium` 使用圆形;`IconWarningTriangleOutlineRegular`/`Medium` 使用圆角三角形。`FishLogo` 与 `BrandWordmark` 填充品牌 slot。`FileTypeIcon` 渲染传统的 28px spreadsheet、folder、HTML、image、Markdown、generic、PDF、PPT、video 与 Word 图形,并为现有 48 个代码和配置类别使用导入的方形技术图形。该导入只替换图形:资源包中额外的类别不会扩展 `CodeFileType`。`classifyFileType` 按完整文件名、前缀、后缀、可选项目上下文、扩展名的顺序匹配;React 文件名优先于 TypeScript/JavaScript,Angular 后缀优先于基础扩展名,只有传入的项目文件包含带 `flutter:` 的 `pubspec.yaml` 时 Dart 文件才使用 Flutter。Markdown 与 SVG 仍分别使用传统 Markdown 与图片图形。表格映射包括 CSV、TSV、Excel 工作簿与模板、OpenDocument 表格和 Numbers;KEY 映射为幻灯片,RTF/ODT/Pages 映射为文档。`fileExtension` 为相邻元数据 label 暴露同一套 basename 与最终点号解析。传统图形使用实色分类底板、白色标记和半透明白色折角;通用文件使用灰色底板与较深灰色折角。调用方可通过 `--dsh-file-type-icon-color` 覆盖底板颜色。全彩技术图形是明确例外,会保留其内嵌调色板。所有图形都是装饰性的,不自带 label。`LinkIconMedium` 是可点击产物链接的前置图形——地球、文件夹、代码、图片、文档或纸张,`url` 链接的 `href` 指向已知站点时则改用该站点自己的标记——转写内容常引用的开发者站点(GitHub、GitLab、npm、PyPI、Stack Overflow、MDN、Wikipedia、Hacker News、YouTube、X、Bilibili、知乎、掘金、CSDN),以及主流搜索、视频、社交、购物与参考资料站点(Google、百度、DuckDuckGo、TikTok、Netflix、Spotify、Facebook、Instagram、Reddit、Telegram、WhatsApp、微信、QQ、微博、淘宝、速卖通、eBay、Quora、V2EX、Apple)——`classifyLinkPath` 把共享文件类型折叠进原有六类词汇。`ConnectionIndicator` 可渲染警告色的断联操作(常驻重试图形指明重试动作,断联文案由持有方提供)、共享 ongoing loading 加一至三个点以独立于 retry 时序的 500ms 节奏推进的连接中状态,或成功色的恢复状态。点击任一警告状态都会请求立即重连;没有任何悬停交互会改变文案。药丸出现时淡入、卸载前淡出 150ms,宽度随当前 label 自适应。它的持有方提供可见性、恢复驻留时间、本地化 label 与立即重连回调;该原语不使用原生 title tooltip。`useAnchoredPosition` 与 `useAnchoredMaxHeight` 让浮动面板与底部锚定浮层始终钳制在视口内并跟随锚点;`useAnchoredMaxHeight` 与 portal 模式的 `Menu` 会把 12px 的视口顶部边距加宽到框架发布的 `--dsh-frame-top-clearance`。`HoverCard` 通过指针离开宽限期让采用 portal 的预览在跨过锚点间隙时仍可触及,并可通过 `copyText` prop 提供复制按钮。`Toast` 使用调用方的 `holdMs` 同时控制淡出延迟与停留加淡出的总时长。`holdMs` 未变时,父组件重渲染不会重启该生命周期;完成时调用最新回调,完全淡出的操作不能接收输入。新的组件 key 会重新开始横幅周期。`rankByName` 是 `/` 菜单命令源与 skill(技能)源共享的候选排序器:查询必须是名字的不区分大小写的有序子序列;前缀命中排最前,其次按对齐分数,再按来源顺序。 `Menu.autoFocus` 聚焦首个启用项,支持上下方向键与 Home/End 导航,并在 Escape 时聚焦 anchor 内的第一个按钮;操作菜单可显式启用。 +上面的目录说明每个导出的用途;本节讲 props 本身看不出来的行为。产品图标名称不含画板尺寸,以 `Regular` 表示原始 1px 图形,以 `Medium` 表示同一几何的 1.3px 描边;`size` prop 控制渲染尺寸([决定](../../../.agents/notes/implemented/architecture/2026-09-16-size-neutral-product-icon-weights.zh.md))。每个产品、引用、链接与权限图形都会有意保留两种线重导出,即使当前产品只使用其中一种,也让调用方无需再次扩展 API 就能选择强调程度;仅填充的成对图形外观相同。`IconWarningOutlineRegular`/`Medium` 使用圆形;`IconWarningTriangleOutlineRegular`/`Medium` 使用圆角三角形。`FishLogo` 与 `BrandWordmark` 填充品牌 slot。`FileTypeIcon` 渲染传统的 28px spreadsheet、folder、HTML、image、Markdown、generic、PDF、PPT、video 与 Word 图形,并为现有 48 个代码和配置类别使用导入的方形技术图形。该导入只替换图形:资源包中额外的类别不会扩展 `CodeFileType`。`classifyFileType` 按完整文件名、前缀、后缀、可选项目上下文、扩展名的顺序匹配;React 文件名优先于 TypeScript/JavaScript,Angular 后缀优先于基础扩展名,只有传入的项目文件包含带 `flutter:` 的 `pubspec.yaml` 时 Dart 文件才使用 Flutter。Markdown 与 SVG 仍分别使用传统 Markdown 与图片图形。表格映射包括 CSV、TSV、Excel 工作簿与模板、OpenDocument 表格和 Numbers;KEY 映射为幻灯片,RTF/ODT/Pages 映射为文档。`fileExtension` 为相邻元数据 label 暴露同一套 basename 与最终点号解析。传统图形使用实色分类底板、白色标记和半透明白色折角;通用文件使用灰色底板与较深灰色折角。调用方可通过 `--dsh-file-type-icon-color` 覆盖底板颜色。全彩技术图形是明确例外,会保留其内嵌调色板。所有图形都是装饰性的,不自带 label。`LinkIconMedium` 是可点击产物链接的前置图形——地球、文件夹、代码、图片、文档或纸张,`url` 链接的 `href` 指向已知站点时则改用该站点自己的标记——转写内容常引用的开发者站点(GitHub、GitLab、npm、PyPI、Stack Overflow、MDN、Wikipedia、Hacker News、YouTube、X、Bilibili、知乎、掘金、CSDN),以及主流搜索、视频、社交、购物与参考资料站点(Google、百度、DuckDuckGo、TikTok、Netflix、Spotify、Facebook、Instagram、Reddit、Telegram、WhatsApp、微信、QQ、微博、淘宝、速卖通、eBay、Quora、V2EX、Apple)——`classifyLinkPath` 把共享文件类型折叠进原有六类词汇。`ConnectionIndicator` 可渲染警告色的断联操作(常驻重试图形指明重试动作,断联文案由持有方提供)、共享 ongoing loading 加一至三个点以独立于 retry 时序的 500ms 节奏推进的连接中状态,或成功色的恢复状态。点击任一警告状态都会请求立即重连;没有任何悬停交互会改变文案。药丸出现时淡入、卸载前淡出 150ms,宽度随当前 label 自适应。它的持有方提供可见性、恢复驻留时间、本地化 label 与立即重连回调;该原语不使用原生 title tooltip。`useAnchoredPosition` 与 `useAnchoredMaxHeight` 让浮动面板与底部锚定浮层始终钳制在视口内并跟随锚点;`useAnchoredMaxHeight` 与 portal 模式的 `Menu` 会把 12px 的视口顶部边距加宽到框架发布的 `--dsh-frame-top-clearance`。`HoverCard` 通过指针离开宽限期让采用 portal 的预览在跨过锚点间隙时仍可触及,并可通过 `copyText` prop 提供复制按钮。其 `preview` 变体使用 anchor 或 `widthAnchorRef` 元素的宽度减去 48px,左右各内缩 24px,并在视口内放置于行的上方或下方。浮层避开框架顶部保留区,高度最多 420px,会跟随内容及 anchor 尺寸变化;Escape 或通过鼠标和键盘激活锚点可将其关闭。整个浮层的淡入和淡出各持续 100ms;关闭中的浮层停止接收指针输入,淡出后卸载。在淡出期间移回锚点可恢复显示。减少动态效果偏好会禁用过渡动画。只有启用复制时才必须提供复制标签。`Toast` 使用调用方的 `holdMs` 同时控制淡出延迟与停留加淡出的总时长。`holdMs` 未变时,父组件重渲染不会重启该生命周期;完成时调用最新回调,完全淡出的操作不能接收输入。新的组件 key 会重新开始横幅周期。`rankByName` 是 `/` 菜单命令源与 skill(技能)源共享的候选排序器:查询必须是名字的不区分大小写的有序子序列;前缀命中排最前,其次按对齐分数,再按来源顺序。 `Menu.autoFocus` 聚焦首个启用项,支持上下方向键与 Home/End 导航,并在 Escape 时聚焦 anchor 内的第一个按钮;操作菜单可显式启用。 ### 渲染 agent 输出 diff --git a/packages/client/ui-primitives/src/HoverCard.module.css b/packages/client/ui-primitives/src/HoverCard.module.css index 8d425ca85c..e23a27e11e 100644 --- a/packages/client/ui-primitives/src/HoverCard.module.css +++ b/packages/client/ui-primitives/src/HoverCard.module.css @@ -52,3 +52,30 @@ clip: rect(0 0 0 0); white-space: nowrap; } + +.preview { + display: flex; + flex-direction: column; + padding: 0; + overflow: hidden; + border: 0; + border-radius: 12px; + background: var(--dsw-alias-bg-layer-1); + color: var(--dsw-alias-label-primary); + box-shadow: var(--dsw-elevation-panel); + opacity: 1; + transition: opacity var(--dsh-hover-preview-fade) linear; +} + +@starting-style { + .preview { opacity: 0; } +} + +.preview[data-closing] { + opacity: 0; + pointer-events: none; +} + +@media (prefers-reduced-motion: reduce) { + .preview { transition: none; } +} diff --git a/packages/client/ui-primitives/src/HoverCard.tsx b/packages/client/ui-primitives/src/HoverCard.tsx index 216df4c5d5..b0c8ae86f8 100644 --- a/packages/client/ui-primitives/src/HoverCard.tsx +++ b/packages/client/ui-primitives/src/HoverCard.tsx @@ -1,17 +1,29 @@ import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react' -import type { ReactNode } from 'react' +import type { CSSProperties, ReactNode, RefObject, SyntheticEvent } from 'react' +import clsx from 'clsx' import { createPortal } from 'react-dom' import { writeClipboard } from './clipboard.ts' import { usePointerGrace } from './pointer-grace.ts' +import { overlayTopMargin } from './overlay-top-margin.ts' import css from './HoverCard.module.css' +/** Preview opacity transition and retained lifetime during dismissal. */ +const PREVIEW_FADE_MS = 100 +const PREVIEW_MAX_HEIGHT = 420 +const PREVIEW_INSET = 24 +const ANCHOR_GAP = 8 +const VIEWPORT_MARGIN = 8 + /** * Render an anchor with a hover-triggered preview card. * @param props.anchor - the hover target (rendered in place inside a wrapper span). * @param props.content - card content; the pointer may rest on it, so it is * readable and selectable, but it carries no dismissal affordance of its own. * @param props.openDelayMs - hover dwell before the card shows (default 500). - * @param props.disabled - suppress opening; turning true closes an open card. + * @param props.variant - compact card beside the anchor, or a preview above/below it + * with 24px side insets, a 420px height cap, frame-top clearance, and 100ms opacity transitions. + * @param props.widthAnchorRef - optional element whose width and horizontal position size the preview. + * @param props.disabled - suppress opening; turning true dismisses an open card. * @param props.copyText - optional primary value copied by activation and * included in the card's accessible name. * @param props.copyLabel - localized accessible activation-label prefix. @@ -20,16 +32,19 @@ import css from './HoverCard.module.css' */ export function HoverCard({ anchor, content, openDelayMs = 500, disabled = false, - copyText, copyLabel, copiedLabel, + copyText, copyLabel, copiedLabel, variant = 'compact', widthAnchorRef, }: { anchor: ReactNode content: ReactNode openDelayMs?: number disabled?: boolean - copyText?: string | undefined - copyLabel: string - copiedLabel: string -}) { + variant?: 'compact' | 'preview' + widthAnchorRef?: RefObject +} & ({ copyText?: string | undefined; copyLabel: string; copiedLabel: string } | { + copyText?: undefined + copyLabel?: string + copiedLabel?: string +})) { const rootRef = useRef(null) const cardRef = useRef(null) const timerRef = useRef | null>(null) @@ -38,8 +53,11 @@ export function HoverCard({ const copyEpochRef = useRef(0) const copyingRef = useRef(false) const mountedRef = useRef(true) - const [open, setOpen] = useState(false) - const [pos, setPos] = useState<{ left: number; top: number } | null>(null) + const [phase, setPhase] = useState<'closed' | 'open' | 'closing'>('closed') + const open = phase !== 'closed' + const closing = phase === 'closing' + const [pos, setPos] = useState<{ left: number; top: number; width?: number; maxHeight?: number } | null>(null) + const positioned = pos !== null const [copied, setCopied] = useState(false) const clearCopied = useCallback(() => { @@ -54,8 +72,8 @@ export function HoverCard({ const close = useCallback(() => { copyEpochRef.current += 1 clearCopied() - setOpen(false) - }, [clearCopied]) + setPhase(current => variant === 'preview' && current !== 'closed' ? 'closing' : 'closed') + }, [clearCopied, variant]) const { arm: armClose, cancel: cancelClose } = usePointerGrace(close) @@ -66,7 +84,13 @@ export function HoverCard({ } } - // Owner disabling mid-hover (menu opened, drag started) closes immediately. + useEffect(() => { + if (!closing) return + const timer = setTimeout(() => { setPhase('closed') }, PREVIEW_FADE_MS) + return () => { clearTimeout(timer) } + }, [closing]) + + // Owner disabling mid-hover (menu opened, drag started) starts dismissal. useEffect(() => { if (!disabled) return clearTimer() @@ -87,6 +111,17 @@ export function HoverCard({ } }, []) + useEffect(() => { + if (!open || variant !== 'preview') return + const dismiss = (event: KeyboardEvent): void => { + if (event.key !== 'Escape') return + cancelClose() + close() + } + window.addEventListener('keydown', dismiss) + return () => { window.removeEventListener('keydown', dismiss) } + }, [open, variant, cancelClose, close]) + // Fixed-position from the anchor rect before paint; track the anchor while // open (capture-phase scroll catches nested panes), as in Menu portal mode. useLayoutEffect(() => { @@ -97,29 +132,50 @@ export function HoverCard({ if (wrapper === null) return const r = wrapper.getBoundingClientRect() const h = cardRef.current?.offsetHeight ?? 0 - const top = r.top + h > window.innerHeight - 8 ? window.innerHeight - h - 8 : r.top - setPos({ left: r.right + 8, top }) + if (variant === 'preview') { + const bounds = widthAnchorRef?.current?.getBoundingClientRect() ?? r + const width = Math.max(0, Math.min(bounds.width - PREVIEW_INSET * 2, window.innerWidth - VIEWPORT_MARGIN * 2)) + const topMargin = overlayTopMargin(VIEWPORT_MARGIN) + const belowTop = Math.max(topMargin, r.bottom + ANCHOR_GAP) + const above = Math.max(0, r.top - ANCHOR_GAP - topMargin) + const below = Math.max(0, window.innerHeight - belowTop - VIEWPORT_MARGIN) + const onTop = above >= Math.min(PREVIEW_MAX_HEIGHT, below) + const maxHeight = Math.min(PREVIEW_MAX_HEIGHT, onTop ? above : below) + setPos({ + left: Math.max(VIEWPORT_MARGIN, Math.min(bounds.left + PREVIEW_INSET, window.innerWidth - width - VIEWPORT_MARGIN)), + top: onTop ? Math.max(topMargin, r.top - Math.min(h, maxHeight) - ANCHOR_GAP) : belowTop, + width, maxHeight, + }) + return + } + const top = r.top + h > window.innerHeight - VIEWPORT_MARGIN ? window.innerHeight - h - VIEWPORT_MARGIN : r.top + setPos({ left: r.right + ANCHOR_GAP, top }) } place() + const observer = variant === 'preview' && typeof ResizeObserver !== 'undefined' ? new ResizeObserver(place) : null + for (const element of [cardRef.current, rootRef.current, widthAnchorRef?.current]) { + if (element !== null && element !== undefined) observer?.observe(element) + } window.addEventListener('scroll', place, true) window.addEventListener('resize', place) return () => { + observer?.disconnect() window.removeEventListener('scroll', place, true) window.removeEventListener('resize', place) } - }, [open]) + }, [open, variant, widthAnchorRef, positioned]) // The first placement ran before the card mounted (height read 0): once the // card's real height is measurable, correct the bottom-edge clamp. The // correction converges — a clamped top satisfies the guard, so it runs once. useLayoutEffect(() => { - if (!open || pos === null) return + if (!open || pos === null || variant === 'preview') return /* v8 ignore next -- the card is mounted whenever pos is set, so the ref is attached here. */ const h = cardRef.current?.offsetHeight ?? 0 - if (pos.top + h > window.innerHeight - 8) { - setPos({ left: pos.left, top: window.innerHeight - h - 8 }) + if (pos.top + h > window.innerHeight - VIEWPORT_MARGIN) { + setPos({ left: pos.left, top: window.innerHeight - h - VIEWPORT_MARGIN }) } - }, [open, pos]) + }, [open, pos, variant]) const copy = async (text: string): Promise => { if (copied || copyingRef.current) return @@ -136,11 +192,22 @@ export function HoverCard({ } const copyable = copyText !== undefined + const dismissFromAnchor = (event: SyntheticEvent): void => { + // Portal content is a React child, but its clicks and text selection do not activate the anchor. + if (cardRef.current?.contains(event.target as Node)) return + clearTimer() + cancelClose() + close() + } const card = open && pos !== null && (
{ setOpen(true) }, openDelayMs) + timerRef.current = setTimeout(() => { setPhase('open') }, openDelayMs) }} onPointerLeave={() => { clearTimer() @@ -186,17 +253,8 @@ export function HoverCard({ // open, matching Menu's shape. if (open) armClose() }} - // A press inside the anchor (row click, menu trigger) dismisses the - // card immediately, without waiting for the owner to flip `disabled`. - // Capture presses reach this handler from the card too — it is a React - // child of the wrapper — but a press there starts a selection, so the - // card must stay mounted under it (and the browser's click with it). - onPointerDownCapture={(e) => { - if (cardRef.current?.contains(e.target as Node)) return - clearTimer() - cancelClose() - close() - }} + onPointerDownCapture={dismissFromAnchor} + onClickCapture={dismissFromAnchor} > {anchor} {open && copyable && {copied ? copiedLabel : ''}} diff --git a/packages/client/ui-primitives/tests/hover-card.client.spec.tsx b/packages/client/ui-primitives/tests/hover-card.client.spec.tsx index 5bb6f5786e..a71922c828 100644 --- a/packages/client/ui-primitives/tests/hover-card.client.spec.tsx +++ b/packages/client/ui-primitives/tests/hover-card.client.spec.tsx @@ -6,7 +6,7 @@ import { POINTER_GRACE_MS } from '../src/pointer-grace.ts' afterEach(cleanup) beforeEach(() => { vi.useFakeTimers() }) -afterEach(() => { vi.useRealTimers() }) +afterEach(() => { vi.useRealTimers(); vi.unstubAllGlobals() }) /** Anchor wrapper rect: the card positions from this (jsdom rects are all-zero by default). */ function stubAnchorRect(anchor: HTMLElement, rect: { top: number; right: number }): void { @@ -18,6 +18,8 @@ function stubAnchorRect(anchor: HTMLElement, rect: { top: number; right: number } function mount(props: { + widthAnchorRef?: { current: HTMLElement | null } + variant?: 'compact' | 'preview' openDelayMs?: number disabled?: boolean copyText?: string @@ -65,6 +67,90 @@ describe('HoverCard', () => { expect(card.style.top).toBe('40px') }) + it('insets a preview by 24px on each side and follows anchor resizing', () => { + const { wrapper } = mount({ variant: 'preview' }) + wrapper.getBoundingClientRect = () => DOMRect.fromRect({ x: 100, y: 600, width: 600, height: 32 }) + fireEvent.pointerEnter(wrapper) + act(() => { vi.advanceTimersByTime(500) }) + const card = screen.getByText('card body').parentElement as HTMLElement + expect(card.style.width).toBe('552px') + expect(card.style.left).toBe('124px') + expect(card.style.maxHeight).toBe('420px') + wrapper.getBoundingClientRect = () => DOMRect.fromRect({ x: 100, y: 600, width: 400, height: 32 }) + fireEvent(window, new Event('resize')) + expect(card.style.width).toBe('352px') + fireEvent.keyDown(wrapper, { key: 'Escape' }) + act(() => { vi.advanceTimersByTime(100) }) + expect(screen.queryByText('card body')).toBeNull() + }) + + it('places previews below rows near the top and dismisses without anchor focus', () => { + const { wrapper } = mount({ variant: 'preview' }) + fireEvent.pointerEnter(wrapper) + act(() => { vi.advanceTimersByTime(500) }) + const card = screen.getByText('card body').parentElement as HTMLElement + expect(card.style.top).toBe('82px') + expect(card.style.maxHeight).toBe('420px') + fireEvent.keyDown(document.body, { key: 'a' }) + expect(screen.queryByText('card body')).not.toBeNull() + fireEvent.keyDown(document.body, { key: 'Escape' }) + act(() => { vi.advanceTimersByTime(100) }) + expect(screen.queryByText('card body')).toBeNull() + }) + + it('observes preview and container sizes until dismissal', () => { + let resize = () => {} + const observe = vi.fn() + const disconnect = vi.fn() + vi.stubGlobal('ResizeObserver', class { + constructor(callback: () => void) { resize = callback } + observe = observe + disconnect = disconnect + }) + const container = document.createElement('div') + container.getBoundingClientRect = () => DOMRect.fromRect({ x: 10, width: 700 }) + const { wrapper, view } = mount({ variant: 'preview', widthAnchorRef: { current: container } }) + fireEvent.pointerEnter(wrapper) + act(() => { vi.advanceTimersByTime(500) }) + const card = screen.getByText('card body').parentElement as HTMLElement + expect(observe).toHaveBeenCalledWith(card) + expect(observe).toHaveBeenCalledWith(container) + expect(card.style.width).toBe('652px') + expect(card.style.left).toBe('34px') + container.getBoundingClientRect = () => DOMRect.fromRect({ x: 10, width: 500 }) + act(() => { resize() }) + expect(card.style.width).toBe('452px') + const previousDisconnects = disconnect.mock.calls.length + view.unmount() + expect(disconnect.mock.calls.length).toBe(previousDisconnects + 1) + }) + + it('retains a dismissing preview for 100ms and cancels dismissal on re-entry', () => { + const { wrapper, view } = mount({ variant: 'preview' }) + fireEvent.pointerEnter(wrapper) + act(() => { vi.advanceTimersByTime(500) }) + const card = screen.getByText('card body').parentElement as HTMLElement + fireEvent.pointerLeave(wrapper) + act(() => { vi.advanceTimersByTime(POINTER_GRACE_MS) }) + expect(card.hasAttribute('data-closing')).toBe(true) + act(() => { vi.advanceTimersByTime(99) }) + expect(screen.queryByText('card body')).not.toBeNull() + fireEvent.pointerEnter(wrapper) + expect(card.hasAttribute('data-closing')).toBe(false) + act(() => { vi.advanceTimersByTime(100) }) + expect(screen.queryByText('card body')).not.toBeNull() + fireEvent.keyDown(document.body, { key: 'Escape' }) + act(() => { vi.advanceTimersByTime(100) }) + expect(screen.queryByText('card body')).toBeNull() + fireEvent.pointerDown(wrapper) + expect(vi.getTimerCount()).toBe(0) + fireEvent.pointerEnter(wrapper) + act(() => { vi.advanceTimersByTime(500) }) + fireEvent.keyDown(document.body, { key: 'Escape' }) + view.unmount() + expect(vi.getTimerCount()).toBe(0) + }) + it('honors a custom openDelayMs', () => { const { wrapper } = mount({ openDelayMs: 50 }) fireEvent.pointerEnter(wrapper) @@ -72,6 +158,45 @@ describe('HoverCard', () => { expect(screen.getByText('card body')).toBeTruthy() }) + it('dismisses on keyboard clicks and cancels a pending hover', () => { + const { anchor, wrapper } = mount({ variant: 'preview' }) + fireEvent.pointerEnter(wrapper) + act(() => { vi.advanceTimersByTime(499) }) + fireEvent.click(anchor, { detail: 0 }) + act(() => { vi.advanceTimersByTime(1) }) + expect(screen.queryByText('card body')).toBeNull() + fireEvent.pointerEnter(wrapper) + act(() => { vi.advanceTimersByTime(500) }) + fireEvent.click(screen.getByText('card body')) + expect(screen.getByText('card body').parentElement?.hasAttribute('data-closing')).toBe(false) + fireEvent.click(anchor, { detail: 0 }) + expect(screen.getByText('card body').parentElement?.hasAttribute('data-closing')).toBe(true) + act(() => { vi.advanceTimersByTime(100) }) + expect(screen.queryByText('card body')).toBeNull() + expect(vi.getTimerCount()).toBe(0) + }) + + it('keeps previews below the desktop frame clearance', () => { + const prior = document.documentElement.style.getPropertyValue('--dsh-frame-top-clearance') + const priority = document.documentElement.style.getPropertyPriority('--dsh-frame-top-clearance') + document.documentElement.style.setProperty('--dsh-frame-top-clearance', '120px') + try { + const { wrapper } = mount({ variant: 'preview' }) + wrapper.getBoundingClientRect = () => DOMRect.fromRect({ x: 100, y: 600, width: 600, height: 32 }) + fireEvent.pointerEnter(wrapper) + act(() => { vi.advanceTimersByTime(500) }) + const card = screen.getByText('card body').parentElement as HTMLElement + Object.defineProperty(card, 'offsetHeight', { configurable: true, value: 420 }) + wrapper.getBoundingClientRect = () => DOMRect.fromRect({ x: 100, y: 450, width: 600, height: 32 }) + fireEvent(window, new Event('resize')) + expect(card.style.top).toBe('120px') + expect(card.style.maxHeight).toBe('322px') + } finally { + if (prior === '') document.documentElement.style.removeProperty('--dsh-frame-top-clearance') + else document.documentElement.style.setProperty('--dsh-frame-top-clearance', prior, priority) + } + }) + it('pointerleave before the delay cancels the pending open', () => { const { wrapper } = mount() fireEvent.pointerEnter(wrapper) diff --git a/packages/client/ui-tool/tests/chat-ptc-subcalls.client.spec.tsx b/packages/client/ui-tool/tests/chat-ptc-subcalls.client.spec.tsx index 54212fa981..973aceae7f 100644 --- a/packages/client/ui-tool/tests/chat-ptc-subcalls.client.spec.tsx +++ b/packages/client/ui-tool/tests/chat-ptc-subcalls.client.spec.tsx @@ -11,7 +11,7 @@ import { SlotTestRuntime, stubSettingsScope } from '@deepseek-ai/dsh-client-test import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client' import type { PropsRenderSlots } from '@deepseek-ai/dsh-client-ui-slots' import { - ConversationEventRegistry, ConversationViewRegistry, type ConvViewOwnerProps, + ConversationEventRegistry, ConversationViewRegistry, EMPTY_CONVERSATION_SNAPSHOT, type ConvViewOwnerProps, } from '@deepseek-ai/dsh-client-ui-conversation/client' import { en as conversationEn, NS as CONVERSATION_NS, zh as conversationZh } from '@deepseek-ai/dsh-client-ui-conversation/src/client/locales.ts' import { apply as applyChat, inject as injectChat } from '@deepseek-ai/dsh-client-ui-chat/client' @@ -102,14 +102,19 @@ async function bench(snapshot: ChatSnapshot) { runtimes.push(runtime) const ctx = runtime.ctx const chat = createSnapshotStore(snapshot) + const conversation = createSnapshotStore(EMPTY_CONVERSATION_SNAPSHOT) const events = new ConversationEventRegistry(ctx) const views = new ConversationViewRegistry(ctx) ctx.provide('settingsScope', { developerTools: { enabled: createSnapshotStore(true) }, bind: () => stubSettingsScope().scope } as never) ctx.provide('uiConversation', { events, views, - binding: () => ({ target: () => chat }), + binding: () => ({ target: () => chat, snapshot: conversation }), } as never) + ctx.uiSession.provide({ + hooks: ['conversation'], + resolve: () => ({ hooks: { conversation } }), + }) await runtime.sessions.add({ id: SID, 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 fff2d81a6b..c645d276d8 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 @@ -64,7 +64,7 @@ const workspaceState = { error: null, } const conversationState: ConversationState = { - views: { get: () => undefined }, + views: { get: () => undefined, grouped: () => undefined }, activeTargets: new Set(), } const emptyKeys: readonly string[] = [] 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 ac9fe34d22..ef55a45abb 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 @@ -62,7 +62,7 @@ const workspaceState = { error: null, } const conversationState: ConversationState = { - views: { get: () => undefined }, + views: { get: () => undefined, grouped: () => undefined }, activeTargets: new Set(), } const emptyKeys: readonly string[] = [] diff --git a/packages/experimental/agent-team/README.i18n.yaml b/packages/experimental/agent-team/README.i18n.yaml index 30fa8a2866..52019ce198 100644 --- a/packages/experimental/agent-team/README.i18n.yaml +++ b/packages/experimental/agent-team/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/experimental/agent-team/README.md -README.md: 3829a2cae60aa4e666f20a35de7298b5703373b0 -README.zh.md: 9e2fe888fefd3b40a2780848630657df40dde37b +README.md: 6ae7d82214ca9e041324e353057ba6850675c043 +README.zh.md: 89176c285a8bd9d5a1875618dffb0b2eb4890a3e diff --git a/packages/experimental/agent-team/README.md b/packages/experimental/agent-team/README.md index 3829a2cae6..6ae7d82214 100644 --- a/packages/experimental/agent-team/README.md +++ b/packages/experimental/agent-team/README.md @@ -173,7 +173,7 @@ Read these pages when the package-level contract is not enough. They move from t ### Browser Remote -`TeamService` owns the generated `agentTeams/view`, `agentTeams/createTask`, and `agentTeams/updateTask` Remote methods beside the roster, mailbox, task, and lifecycle operations. The `./remote` export supplies the Client contribution mounted by the Web UI, while `./client` re-exports the request, view, and task-mutation result types that are safe in a browser compilation face. Typert retains transport failures in its outer `RemoteResult`; create and update rejections remain explicit domain results inside a successful transport response, with stale update revisions distinguished as task conflicts. +`TeamService` exposes the read-only `agentTeams/view` Remote method for browser clients. Task creation and updates belong to Team agents through the service and model tools. The `./remote` export supplies the Client contribution mounted by the Web UI, while `./client` exports browser-safe roster and task views. ## Model Experience diff --git a/packages/experimental/agent-team/README.zh.md b/packages/experimental/agent-team/README.zh.md index 9e2fe888fe..89176c285a 100644 --- a/packages/experimental/agent-team/README.zh.md +++ b/packages/experimental/agent-team/README.zh.md @@ -173,7 +173,7 @@ dispose 会关闭准入、中止并等待已获准的创建与 mailbox dispatch ### 浏览器 Remote -`TeamService` 除了 roster、mailbox、task 与 lifecycle operation,还拥有生成的 `agentTeams/view`、`agentTeams/createTask` 与 `agentTeams/updateTask` Remote method。`./remote` 导出由 Web UI 挂载的 Client contribution,`./client` 则重新导出可在浏览器 compilation face 中安全使用的 request、view 与 task mutation result type。Typert 在外层 `RemoteResult` 中保留 transport failure;create 与 update rejection 则作为 transport 成功响应中的显式 domain result,其中过期的 update revision 会区分为 task conflict。 +`TeamService` 向浏览器客户端公开只读的 `agentTeams/view` Remote method。任务创建与更新由 Team agent 通过服务和模型工具执行。`./remote` 导出由 Web UI 挂载的 Client contribution,`./client` 导出可供浏览器使用的成员与任务视图。 ## 模型体验 diff --git a/packages/experimental/agent-team/src/client.ts b/packages/experimental/agent-team/src/client.ts index 122af45a0b..b1fd173d10 100644 --- a/packages/experimental/agent-team/src/client.ts +++ b/packages/experimental/agent-team/src/client.ts @@ -1,13 +1,9 @@ -/** Client-safe Agent Teams request, result, and view vocabulary. */ +/** Client-safe Agent Teams roster and task views. */ export type { - CreateTeamTaskRequest, TeamMemberView, - TeamTaskAction, TeamTaskId, - TeamTaskMutationResult, TeamTaskStatus, TeamTaskView, TeamView, - UpdateTeamTaskRequest, } from './types.ts' diff --git a/packages/experimental/agent-team/src/index.ts b/packages/experimental/agent-team/src/index.ts index abdedc6d63..03ddbc7a48 100644 --- a/packages/experimental/agent-team/src/index.ts +++ b/packages/experimental/agent-team/src/index.ts @@ -23,7 +23,6 @@ import type { SpawnTeammateRequest, SpawnTeammateResult, TeamMemberView, - TeamTaskMutationResult, TeamTaskView, TeamView, TeamWaitResult, @@ -247,44 +246,6 @@ export class TeamService extends TypertRemoteService { } } - /** - * Create one shared task through the generated Remote API. - * @param agent - exact live Team member creating the task. - * @param request - task text, blockers, and advisory write scopes. - * @returns the revision-one task or a typed Team rejection. - */ - @Remote('createTask') - remoteCreateTask(agent: Agent, request: CreateTeamTaskRequest): Promise { - return this.taskMutationResult(this.createTask(agent, request)) - } - - /** - * Apply one task mutation and preserve Team rejections as business results. - * @param agent - exact live Team member authorizing the mutation. - * @param request - task identity, expected revision, action, and action fields. - * @returns the committed task or a typed Team rejection. - */ - @Remote('updateTask') - remoteUpdateTask(agent: Agent, request: UpdateTeamTaskRequest): Promise { - return this.taskMutationResult(this.updateTask(agent, request)) - } - - /** Preserve Team task rejections while allowing unexpected failures to reject the Remote call. */ - private async taskMutationResult(operation: Promise): Promise { - try { - return { ok: true, value: await operation } - } catch (error) { - if (!(error instanceof TeamError)) throw error - return { - ok: false, - error: { - code: error.code === 'TEAM_TASK_STALE_REVISION' ? 'team-task-conflict' : 'team-rejected', - message: error.message, - }, - } - } - } - /** Queue one contained recovery pass after publication has unwound. */ private scheduleRecovery(agent: Agent): void { queueMicrotask(() => { diff --git a/packages/experimental/agent-team/src/types.ts b/packages/experimental/agent-team/src/types.ts index cf659ce9d0..77aa6fe620 100644 --- a/packages/experimental/agent-team/src/types.ts +++ b/packages/experimental/agent-team/src/types.ts @@ -199,17 +199,6 @@ export interface UpdateTeamTaskRequest { readonly owner?: string } -/** Browser task mutation result with stale revisions kept distinct from other Team rejections. */ -export type TeamTaskMutationResult = - | { readonly ok: true; readonly value: TeamTaskView } - | { - readonly ok: false - readonly error: { - readonly code: 'team-task-conflict' | 'team-rejected' - readonly message: string - } - } - /** Result of waiting for Team activity. */ export interface TeamWaitResult { readonly timedOut: boolean diff --git a/packages/experimental/agent-team/tests/built-lib.e2e.ts b/packages/experimental/agent-team/tests/built-lib.e2e.ts index 5ae0619172..4e2d762fd6 100644 --- a/packages/experimental/agent-team/tests/built-lib.e2e.ts +++ b/packages/experimental/agent-team/tests/built-lib.e2e.ts @@ -40,8 +40,6 @@ describe.skipIf(!requiredArtifacts)('Agent Teams built LIB service', () => { expect(output).toEqual({ className: 'TeamService', methods: [ - '@deepseek-ai/dsh-experimental-agent-team#agentTeams/createTask', - '@deepseek-ai/dsh-experimental-agent-team#agentTeams/updateTask', '@deepseek-ai/dsh-experimental-agent-team#agentTeams/view', ], }) diff --git a/packages/experimental/agent-team/tests/team.spec.ts b/packages/experimental/agent-team/tests/team.spec.ts index 416df1fd8f..87d63353ed 100644 --- a/packages/experimental/agent-team/tests/team.spec.ts +++ b/packages/experimental/agent-team/tests/team.spec.ts @@ -874,63 +874,20 @@ describe('Team shared task DAG', () => { }) describe('Team Remote API', () => { - it('exports Team views and task mutations from the owning service', async () => { + it('reads tasks created and updated by Team agents', async () => { const { ctx, lead } = await setup([]) expect(ctx.agentTeams.typertRemote).toMatchObject({ serviceKey: 'agentTeams', namespace: 'agentTeams' }) expect(ctx.agentTeams.remoteView(lead)).toEqual({ members: [expect.objectContaining({ name: 'lead', role: 'lead', status: 'inactive' })], tasks: [], }) - - const createdResult = await ctx.agentTeams.remoteCreateTask(lead, { - subject: 'Remote task', - description: 'Created through the generated API', - blockedBy: [], - writeScopes: ['packages/experimental/agent-team'], + const created = await ctx.agentTeams.createTask(lead, { + subject: 'Agent task', description: 'Created by the Team Lead', }) - expect(createdResult).toMatchObject({ ok: true, value: { revision: 1 } }) - if (!createdResult.ok) throw new Error('Remote task creation did not succeed') - const created = createdResult.value - await expect(ctx.agentTeams.remoteUpdateTask(lead, { - taskId: created.id, - expectedRevision: created.revision, - action: 'claim', - })).resolves.toMatchObject({ - ok: true, - value: { id: created.id, revision: 2, ownerName: 'lead' }, + const updated = await ctx.agentTeams.updateTask(lead, { + taskId: created.id, expectedRevision: 1, action: 'claim', }) - expect(ctx.agentTeams.remoteView(lead).tasks).toHaveLength(1) - }) - - it('preserves Team task rejections and propagates unexpected failures', async () => { - const { ctx, lead } = await setup([]) - const createRequest = { - subject: 'Remote task', description: 'Rejected task', blockedBy: [], writeScopes: [], - } - const request = { taskId: TeamTaskId('task-1'), expectedRevision: 1, action: 'delete' as const } - vi.spyOn(ctx.agentTeams, 'createTask') - .mockRejectedValueOnce(new TeamError('invalid task', 'TEAM_TASK_INVALID')) - .mockRejectedValueOnce(new Error('unexpected creation failure')) - vi.spyOn(ctx.agentTeams, 'updateTask') - .mockRejectedValueOnce(new TeamError('stale', 'TEAM_TASK_STALE_REVISION')) - .mockRejectedValueOnce(new TeamError('denied', 'TEAM_TASK_FORBIDDEN')) - .mockRejectedValueOnce(new Error('unexpected mutation failure')) - - await expect(ctx.agentTeams.remoteCreateTask(lead, createRequest)).resolves.toEqual({ - ok: false, - error: { code: 'team-rejected', message: 'invalid task' }, - }) - await expect(ctx.agentTeams.remoteCreateTask(lead, createRequest)) - .rejects.toThrow('unexpected creation failure') - await expect(ctx.agentTeams.remoteUpdateTask(lead, request)).resolves.toEqual({ - ok: false, - error: { code: 'team-task-conflict', message: 'stale' }, - }) - await expect(ctx.agentTeams.remoteUpdateTask(lead, request)).resolves.toEqual({ - ok: false, - error: { code: 'team-rejected', message: 'denied' }, - }) - await expect(ctx.agentTeams.remoteUpdateTask(lead, request)).rejects.toThrow('unexpected mutation failure') + expect(ctx.agentTeams.remoteView(lead).tasks).toEqual([updated]) }) }) diff --git a/packages/experimental/client-ui-agent-team/README.i18n.yaml b/packages/experimental/client-ui-agent-team/README.i18n.yaml index 914a388b83..5965f254f5 100644 --- a/packages/experimental/client-ui-agent-team/README.i18n.yaml +++ b/packages/experimental/client-ui-agent-team/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/experimental/client-ui-agent-team/README.md -README.md: 6d45e618d937cfadcec238c6d0d7dcf3e8747501 -README.zh.md: 879b1784bbe3d2ef6f3f66ab66b9dda7c820a0ed +README.md: 253c243a680a989988ea65e553c14eff9a699155 +README.zh.md: 143c855b8b8a45a7d3777700aefa6a378401f20d diff --git a/packages/experimental/client-ui-agent-team/README.md b/packages/experimental/client-ui-agent-team/README.md index 6d45e618d9..253c243a68 100644 --- a/packages/experimental/client-ui-agent-team/README.md +++ b/packages/experimental/client-ui-agent-team/README.md @@ -9,7 +9,7 @@ English | [中文](README.zh.md) ## Summary -This package adds an Agent Teams action to the Web conversation header, where a user can inspect the current roster, manage the shared task board, and navigate into a teammate's conversation. It reads authoritative Team state through the generated `ctx.remote.agentTeams` contribution and keeps ordinary child-history navigation on the stable addressed-subagent path. Choose it through the published experimental Agent Teams bundle. The browser projection does not extend the stable API Proxy, store Team state, or register model-facing input. +This package adds an Agent Teams action to the Web conversation header, where a user can inspect the current roster, inspect the shared task board, and navigate into a teammate's conversation. It reads authoritative Team state through the generated `ctx.remote.agentTeams` contribution and keeps ordinary child-history navigation on the stable addressed-subagent path. Choose it through the published experimental Agent Teams bundle. The browser projection does not extend the stable API Proxy, store Team state, or register model-facing input. ## Table of Contents @@ -31,11 +31,11 @@ Enable this package through [`@deepseek-ai/dsh-experimental-agent-team-profile`] Opening the panel calls `agentTeams/view`. Roster rows show durable names, turn availability, model, and diagnostics. Provisioning and running members use the shared ongoing loader, inactive members use idle, and failed members use error. Selecting a healthy teammate opens the ordinary `{ parentSessionId, childSessionId, mode: 'continuable' }` address from its Lead and roster identity without refreshing or checking the parent catalog. The Host validates the parent, child, and mode when history opens. History and later human prompts continue through the stable addressed-subagent conversation path; this package adds no Team-specific address field. -### Manage the task board +### Inspect the task board Ready pending tasks use idle, blocked pending tasks use warning, in-progress tasks use ongoing, and completed tasks use done. -The task board shows task identity, owner, blockers, readiness, advisory write scopes, and overlap warnings. A user can create, edit, assign or unassign, complete, reopen, and delete tasks through `agentTeams/createTask` and `agentTeams/updateTask`. Every update sends the displayed revision, and create or update rejections remain explicit business results. +The read-only task board shows task identity, owner, blockers, readiness, advisory write scopes, and overlap warnings. Team agents create and update tasks through their tools; the panel provides no task mutation controls. ----- @@ -47,7 +47,7 @@ The task board shows task identity, owner, blockers, readiness, advisory write s The Client export mounts the generated `ctx.remote.agentTeams` contribution from [`@deepseek-ai/dsh-experimental-agent-team/remote`](../agent-team/README.md), then registers its locale dictionaries and one conversation-header slot through Cordis effects. Disposing the plugin fiber removes both registrations. -Starting a create or update invalidates older refreshes. Success reloads the complete Team view so every task's derived fields stay current. A `team-task-conflict` result displays a stale-state notice only after that reload succeeds; a reload failure remains visible instead. Editing task text or scopes and changing dependencies use two sequential compare-and-set mutations because the Team service exposes them as separate actions. +The panel renders outside the conversation container and stays within the viewport. Opening moves focus into the panel; Escape or Close returns focus to its trigger. Clicking outside or moving focus outside the panel and trigger closes it without moving focus back. Opening or refreshing the panel reads the complete Team view. Overlapping refreshes keep the newest response, and responses for a previous conversation are ignored. | File | Role | |---|---| @@ -73,7 +73,7 @@ Starting a create or update invalidates older refreshes. Success reloads the com ## Model Experience -None, as this browser projection and task control surface registers no model-facing input. +None, as this browser projection registers no model-facing input. #### KV Cache effect @@ -83,7 +83,7 @@ No direct effect; the Team tools and ordinary conversation submission own any la -- **Snapshot refresh** — the panel refreshes on open, explicit refresh, and mutations; it has no live event subscription or mailbox timeline. +- **Snapshot refresh** — the panel refreshes on open and explicit refresh; it has no live event subscription or mailbox timeline. - **Ordinary child continuation** — a human message sent after navigation uses the stable addressed-subagent prompt path, not the Team peer mailbox. - **No lifecycle or workspace controls** — the panel cannot spawn, rename, delete, or interrupt teammates, and write scopes remain advisory metadata. diff --git a/packages/experimental/client-ui-agent-team/README.zh.md b/packages/experimental/client-ui-agent-team/README.zh.md index 879b1784bb..143c855b8b 100644 --- a/packages/experimental/client-ui-agent-team/README.zh.md +++ b/packages/experimental/client-ui-agent-team/README.zh.md @@ -9,7 +9,7 @@ kind: "package-reference" ## 概述 -本包向 Web 会话页头添加 Agent Teams action,让用户检查当前 roster、管理共享任务板并导航到 teammate 会话。它通过生成的 `ctx.remote.agentTeams` contribution 读取权威 Team 状态,并让普通 child history 导航继续使用稳定的 addressed-subagent 路径。通过公开发布的实验性 Agent Teams bundle 选择本包。这个浏览器 projection 不扩展稳定 API Proxy、不存储 Team 状态,也不注册面向模型的输入。 +本包向 Web 会话页头添加 Agent Teams action,让用户检查当前 roster、查看共享任务板并导航到 teammate 会话。它通过生成的 `ctx.remote.agentTeams` contribution 读取权威 Team 状态,并让普通 child history 导航继续使用稳定的 addressed-subagent 路径。通过公开发布的实验性 Agent Teams bundle 选择本包。这个浏览器 projection 不扩展稳定 API Proxy、不存储 Team 状态,也不注册面向模型的输入。 ## 目录 @@ -31,11 +31,11 @@ kind: "package-reference" 打开 panel 会调用 `agentTeams/view`。Roster row 展示持久 name、轮次可用状态、model 与 diagnostics。provisioning 和 running 成员使用共享 ongoing loading,inactive 成员使用 idle 灰点,failed 成员使用 error 红点。选择健康 teammate 时,系统直接根据其 Lead 与 roster 身份打开普通的 `{ parentSessionId, childSessionId, mode: 'continuable' }` address,不刷新或检查 parent catalog。Host 在打开历史时校验 parent、child 与 mode。History 与后续人类提示词继续使用稳定 addressed-subagent 会话路径;本包不会添加 Team 专用 address 字段。 -### 管理任务板 +### 查看任务板 可开始的 pending 任务使用 idle 灰点,被依赖阻塞的 pending 任务使用 warning 橙点,in-progress 任务使用 ongoing loading,completed 任务使用 done 绿点。 -任务板展示 task identity、owner、blocker、readiness、提示性 write scope 与重叠 warning。用户可以通过 `agentTeams/createTask` 与 `agentTeams/updateTask` 创建、编辑、分配或取消分配、完成、重开和删除任务。每次 update 都发送当前显示的 revision,create 或 update rejection 都保留为显式 business result。 +只读任务板展示任务标识、负责人、依赖、就绪状态、提示性写入范围与重叠警告。Team agent 通过工具创建和更新任务;面板不提供任务修改控件。 ----- @@ -47,7 +47,7 @@ kind: "package-reference" Client export 挂载来自 [`@deepseek-ai/dsh-experimental-agent-team/remote`](../agent-team/README.zh.md) 的生成的 `ctx.remote.agentTeams` contribution,然后通过 Cordis effect 注册 locale dictionary 与一个 conversation-header slot。Dispose plugin fiber 会移除这两项 registration。 -开始 create 或 update 会让更早的 refresh 失效。成功后会重新读取完整 Team view,使每个 task 的派生字段保持最新。`team-task-conflict` 结果仅在重新读取成功后显示状态陈旧提示;如果重新读取失败,则改为显示重新读取错误。由于 Team 服务把任务文本或 scope 编辑与 dependency 修改公开为独立 action,两者使用两个连续的 compare-and-set mutation。 +面板渲染在会话容器外,并保持在视口范围内。打开时焦点移入面板;按 Escape 或选择关闭按钮时,焦点返回触发按钮。点击外部或将焦点移出面板与触发按钮时,面板关闭,但不会将焦点移回。打开或刷新面板会读取完整 Team view。并行刷新只保留最新响应,属于上一个会话的响应会被忽略。 | 文件 | 职责 | |---|---| @@ -73,7 +73,7 @@ Client export 挂载来自 [`@deepseek-ai/dsh-experimental-agent-team/remote`](. ## 模型体验 -无直接影响,因为该浏览器 projection 与任务控制界面不注册面向模型的输入。 +无直接影响,因为该浏览器 projection 不注册面向模型的输入。 #### KV Cache 影响 @@ -83,7 +83,7 @@ Client export 挂载来自 [`@deepseek-ai/dsh-experimental-agent-team/remote`](. -- **Snapshot refresh**——panel 会在打开、显式 refresh 与 mutation 后刷新;它没有实时事件订阅或 mailbox timeline。 +- **Snapshot refresh**——panel 会在打开和显式 refresh 时刷新;它没有实时事件订阅或 mailbox timeline。 - **普通 child continuation**——导航后发送的人类消息使用稳定 addressed-subagent 提示词路径,而不是 Team peer mailbox。 - **没有 lifecycle 或 workspace control**——panel 不能 spawn、rename、delete 或 interrupt teammate,write scope 仍只是提示性 metadata。 diff --git a/packages/experimental/client-ui-agent-team/package.json b/packages/experimental/client-ui-agent-team/package.json index c980e32c16..cbde881879 100644 --- a/packages/experimental/client-ui-agent-team/package.json +++ b/packages/experimental/client-ui-agent-team/package.json @@ -75,6 +75,7 @@ "@deepseek-ai/dsh-typert-protocol": "workspace:^", "@testing-library/react": "^16.1.0", "@types/react": "~18.3.1", + "@types/react-dom": "~18.3.0", "@deepseek-ai/cordis": "workspace:^", "react": "^18.2.0", "react-dom": "^18.2.0" diff --git a/packages/experimental/client-ui-agent-team/src/client/TeamAction.module.css b/packages/experimental/client-ui-agent-team/src/client/TeamAction.module.css index e8051dc387..29d35ccaf0 100644 --- a/packages/experimental/client-ui-agent-team/src/client/TeamAction.module.css +++ b/packages/experimental/client-ui-agent-team/src/client/TeamAction.module.css @@ -3,10 +3,7 @@ } .trigger, -.iconButton, -.smallButton, -.taskActions button, -.formActions button { +.iconButton { border: 0; border-radius: 6px; background: transparent; @@ -24,10 +21,7 @@ } .trigger:hover, -.iconButton:hover, -.smallButton:hover, -.taskActions button:hover, -.formActions button:hover { +.iconButton:hover { background: var(--dsw-alias-interactive-bg-hover); } @@ -43,13 +37,11 @@ --dsh-scrollbar-thumb: var(--dsw-alias-scrollbar-bg-l2); --dsh-scrollbar-thumb-hover: var(--dsw-alias-scrollbar-hover-l2); - position: absolute; - top: calc(100% + 5px); - left: 0; + position: fixed; z-index: 110; box-sizing: border-box; width: min(560px, calc(100vw - 32px)); - max-height: min(680px, calc(100vh - 120px)); + max-height: min(680px, calc(100vh - 32px)); padding: 10px; overflow: auto; border: 0; @@ -61,10 +53,7 @@ } .toolbar, -.sectionTitle, -.taskTitle, -.taskActions, -.formActions { +.taskTitle { display: flex; align-items: center; gap: 8px; @@ -135,23 +124,13 @@ color: var(--dsw-alias-state-error-primary); } -.smallButton { - display: inline-flex; - align-items: center; - gap: 4px; - margin-left: auto; - padding: 4px 7px; - font-size: 11px; -} - .tasks { display: flex; flex-direction: column; gap: 7px; } -.task, -.form { +.task { padding: 9px; border: 0.5px solid var(--dsw-alias-border-l1); border-radius: 9px; @@ -180,63 +159,6 @@ gap: 4px 10px; } -.taskActions { - flex-wrap: wrap; - margin-top: 8px; -} - -.taskActions label { - display: inline-flex; - align-items: center; - gap: 4px; - color: var(--dsw-alias-label-tertiary); - font-size: 11px; -} - -.taskActions select, -.form input, -.form textarea { - box-sizing: border-box; - border: 0.5px solid var(--dsw-alias-border-l4); - border-radius: 6px; - background: var(--dsw-alias-bg-base); - color: var(--dsw-alias-label-primary); - font: inherit; -} - -.taskActions button, -.formActions button { - display: inline-flex; - align-items: center; - gap: 3px; - padding: 4px 6px; - font-size: 11px; -} - -.taskActions button:disabled, -.formActions button:disabled { - opacity: 0.45; - cursor: default; -} - -.form { - display: grid; - gap: 6px; - margin-bottom: 7px; -} - -.form input, -.form textarea { - width: 100%; - padding: 6px 8px; - font-size: 12px; -} - -.form textarea { - min-height: 58px; - resize: vertical; -} - .notice, .error { display: flex; diff --git a/packages/experimental/client-ui-agent-team/src/client/TeamAction.tsx b/packages/experimental/client-ui-agent-team/src/client/TeamAction.tsx index a32e2dc24e..bce5aa56e0 100644 --- a/packages/experimental/client-ui-agent-team/src/client/TeamAction.tsx +++ b/packages/experimental/client-ui-agent-team/src/client/TeamAction.tsx @@ -1,18 +1,15 @@ -import { useCallback, useEffect, useRef, useState, type ChangeEvent } from 'react' +import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react' +import { createPortal } from 'react-dom' import type { SessionId } from '@deepseek-ai/dsh-session/types' import type { TeamMemberView as TeamRosterMember, - TeamTaskAction, - TeamTaskId, - TeamTaskMutationResult, TeamTaskView as TeamTask, TeamView, } from '@deepseek-ai/dsh-experimental-agent-team/client' import type { RemoteResult } from '@deepseek-ai/dsh-api-remotes/client' import { - IconCheckOutlineRegular, IconCloseOutlineRegular, IconEditOutlineRegular, IconPlusOutlineRegular, - IconRefreshOutlineRegular, IconTrashOutlineRegular, IconUserOutlineRegular, StateDot, - type StateDotState, + IconCloseOutlineRegular, IconRefreshOutlineRegular, IconUserOutlineRegular, StateDot, + useAnchoredPosition, useDismissOnOutsidePointer, type StateDotState, } from '@deepseek-ai/dsh-client-ui-primitives' import type { PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' import type {} from '@deepseek-ai/dsh-client-ui-conversation/client' @@ -22,28 +19,9 @@ import css from './TeamAction.module.css' /** Generated Remote result consumed directly by the Team UI. */ export type TeamActionResult = RemoteResult -/** Generated Remote result whose business value preserves Team task rejections. */ -export type TeamTaskActionResult = RemoteResult - /** Business actions injected by the browser plugin. */ export interface TeamActionInjected { load: (sessionId: SessionId) => Promise> - createTask: (sessionId: SessionId, input: { - subject: string - description: string - blockedBy: TeamTaskId[] - writeScopes: string[] - }) => Promise - updateTask: (sessionId: SessionId, input: { - taskId: TeamTaskId - expectedRevision: number - action: TeamTaskAction - subject?: string - description?: string - blockedBy?: TeamTaskId[] - writeScopes?: string[] - owner?: string - }) => Promise openTeammate: (sessionId: SessionId, member: TeamRosterMember) => void } @@ -51,27 +29,6 @@ export interface TeamActionInjected { export type TeamActionProps = PropsRuntime<'conversation.session.header.actions'> & TeamActionInjected & PropsLocale -interface Draft { - subject: string - description: string - blockers: string - scopes: string -} - -const EMPTY_DRAFT: Draft = { subject: '', description: '', blockers: '', scopes: '' } - -function items(value: string): string[] { - return [...new Set(value.split(',').map(item => item.trim()).filter(Boolean))] -} - -function taskIds(value: string): TeamTaskId[] { - return items(value) as TeamTaskId[] -} - -/** - * One failure line for either carrier: a Remote failure, or a Team business - * rejection whose codes stay local to this seam and never ride the wire. - */ function failureText(error: { readonly code: string; readonly message: string }): string { return `${error.message} (${error.code})` } @@ -114,19 +71,22 @@ function taskDotState(task: TeamTask): StateDotState { } } -/** Render the live Team roster and compare-and-set task board. */ +/** Render the Team roster and read-only task board. */ export function TeamAction({ - sessionId, load, createTask, updateTask, openTeammate, t, + sessionId, load, openTeammate, t, }: TeamActionProps) { const [open, setOpen] = useState(false) const [loading, setLoading] = useState(false) const [view, setView] = useState(null) const [error, setError] = useState(null) - const [creating, setCreating] = useState(false) - const [createDraft, setCreateDraft] = useState(EMPTY_DRAFT) - const [editing, setEditing] = useState(null) - const [editDraft, setEditDraft] = useState(EMPTY_DRAFT) - const [pendingTasks, setPendingTasks] = useState>(() => new Set()) + const rootRef = useRef(null) + const triggerRef = useRef(null) + const panelRef = useRef(null) + const position = useAnchoredPosition({ + open, anchorRef: triggerRef, panelRef, gap: 5, margin: 16, + }) + const positioned = position !== null + useDismissOnOutsidePointer(rootRef, open, setOpen, panelRef) const sessionRef = useRef(sessionId) const refreshGeneration = useRef(0) sessionRef.current = sessionId @@ -137,136 +97,50 @@ export function TeamAction({ setLoading(false) setView(null) setError(null) - setCreating(false) - setCreateDraft(EMPTY_DRAFT) - setEditing(null) - setEditDraft(EMPTY_DRAFT) - setPendingTasks(new Set()) }, [sessionId]) - const refresh = useCallback(async (): Promise => { + useLayoutEffect(() => { + if (open && positioned) panelRef.current?.focus() + }, [open, positioned]) + + const close = (): void => { + setOpen(false) + triggerRef.current?.focus() + } + + const refresh = useCallback(async (): Promise => { const requestedSession = sessionId const generation = ++refreshGeneration.current setLoading(true) const result = await load(requestedSession) - if (sessionRef.current !== requestedSession || refreshGeneration.current !== generation) return false + if (sessionRef.current !== requestedSession || refreshGeneration.current !== generation) return setLoading(false) if (result.ok) { setView(result.value) setError(null) - return true } else { setError(failureText(result.error)) - return false } }, [load, sessionId]) - const invalidateRefresh = useCallback((): void => { - refreshGeneration.current += 1 - setLoading(false) - }, []) - - const settleTask = useCallback(async ( - taskId: string, - operation: () => Promise, - ): Promise => { - const requestedSession = sessionId - invalidateRefresh() - setPendingTasks(current => new Set(current).add(taskId)) - try { - const result = await operation() - if (sessionRef.current !== requestedSession) return undefined - if (!result.ok) { - setError(failureText(result.error)) - return undefined - } - if (!result.value.ok) { - if (result.value.error.code === 'team-task-conflict') { - const reloaded = await refresh() - if (sessionRef.current !== requestedSession) return undefined - if (reloaded) setError(t('conflict')) - } else { - setError(failureText(result.value.error)) - } - return undefined - } - const task = result.value.value - setError(null) - await refresh() - if (sessionRef.current !== requestedSession) return undefined - return task - } finally { - if (sessionRef.current === requestedSession) { - setPendingTasks((current) => { - const next = new Set(current) - next.delete(taskId) - return next - }) - } - } - }, [invalidateRefresh, refresh, sessionId, t]) - - const submitCreate = async (): Promise => { - const subject = createDraft.subject.trim() - const description = createDraft.description.trim() - /* v8 ignore next -- TaskForm disables Save while either normalized field is empty. */ - if (subject === '' || description === '') return - const created = await settleTask('create', () => createTask(sessionId, { - subject, - description, - blockedBy: taskIds(createDraft.blockers), - writeScopes: items(createDraft.scopes), - })) - if (created === undefined) return - setCreateDraft(EMPTY_DRAFT) - setCreating(false) - } - - const startEdit = (task: TeamTask): void => { - setEditing(task.id) - setEditDraft({ - subject: task.subject, - description: task.description, - blockers: task.blockedBy.join(', '), - scopes: task.writeScopes.join(', '), - }) - } - - const submitEdit = async (task: TeamTask): Promise => { - const requestedSession = sessionId - const edited = await settleTask(task.id, () => updateTask(requestedSession, { - taskId: task.id, - expectedRevision: task.revision, - action: 'edit', - subject: editDraft.subject.trim(), - description: editDraft.description.trim(), - writeScopes: items(editDraft.scopes), - })) - if (edited === undefined) return - const blockedBy = taskIds(editDraft.blockers) - if (blockedBy.length === edited.blockedBy.length - && blockedBy.every((blocker, index) => blocker === edited.blockedBy[index])) { - setEditing(null) - return - } - const dependencyTask = await settleTask(task.id, () => updateTask(requestedSession, { - taskId: task.id, - expectedRevision: edited.revision, - action: 'set_dependencies', - blockedBy, - })) - if (dependencyTask === undefined) return - setEditing(null) - } - const teammates = view?.members.filter(member => member.role === 'teammate') ?? [] - const assignable = view?.members.filter(member => member.status !== 'failed' && member.status !== 'provisioning') ?? [] return ( -
+
{ + if (event.key !== 'Escape' || !open) return + event.preventDefault() + close() + }} onBlur={(event) => { + const target = event.relatedTarget + if (target instanceof Node && !event.currentTarget.contains(target) && !panelRef.current?.contains(target)) { + setOpen(false) + } + }}> - {open && ( -
+ {open && createPortal( +
{t('trigger')} @@ -289,7 +171,7 @@ export function TeamAction({ -
@@ -330,129 +212,36 @@ export function TeamAction({
-
-

{t('tasks')}

- -
- {creating && ( - { void submitCreate() }} - onCancel={() => { setCreating(false) }} - t={t} - /> - )} - {view.tasks.length === 0 && !creating &&
{t('empty')}
} +

{t('tasks')}

+ {view.tasks.length === 0 &&
{t('empty')}
}
- {view.tasks.map(task => editing === task.id - ? ( - { void submitEdit(task) }} - onCancel={() => { setEditing(null) }} - t={t} - /> - ) - : ( -
-
- {task.subject} - - - {t(statusKey(task.status))} - -
-

{task.description}

-
- {task.id} - {task.status === 'pending' && {task.ready ? t('ready') : t('blocked')}} - {task.blockedBy.length > 0 && {t('blockedBy')}: {task.blockedBy.join(', ')}} - {task.writeScopes.length > 0 && {t('writeScopes')}: {task.writeScopes.join(', ')}} - {task.writeScopeWarnings.map(warning => {warning})} -
-
- - - {task.status === 'in_progress' && ( - - )} - {task.status === 'completed' && ( - - )} - -
-
- ))} + {view.tasks.map(task => ( +
+
+ {task.subject} + + + {t(statusKey(task.status))} + +
+

{task.description}

+
+ {task.id} + {t('owner')}: {task.ownerName ?? t('unowned')} + {task.status === 'pending' && {task.ready ? t('ready') : t('blocked')}} + {task.blockedBy.length > 0 && {t('blockedBy')}: {task.blockedBy.join(', ')}} + {task.writeScopes.length > 0 && {t('writeScopes')}: {task.writeScopes.join(', ')}} + {task.writeScopeWarnings.map(warning => {warning})} +
+
+ ))}
)} -
+
, + document.body, )}
) } - -interface TaskFormProps { - draft: Draft - setDraft: (draft: Draft) => void - pending: boolean - onSave: () => void - onCancel: () => void - t: TeamActionProps['t'] -} - -function TaskForm({ draft, setDraft, pending, onSave, onCancel, t }: TaskFormProps) { - const field = (key: keyof Draft, value: string): void => { setDraft({ ...draft, [key]: value }) } - return ( -
- ) => { field('subject', event.target.value) }} /> -