diff --git a/.agents/notes/README.i18n.yaml b/.agents/notes/README.i18n.yaml index c07b56468c..f6289cf96f 100644 --- a/.agents/notes/README.i18n.yaml +++ b/.agents/notes/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 .agents/notes/README.md -README.md: 9fdb4039b525bbc4051ab3c8058b117e4de5f2f4 -README.zh.md: 2d7e07b963d3c4d77da9148b011e1478fa2ae5da +README.md: 6872de0a1390fa5a1707b9f464c9e0edeb2da4be +README.zh.md: ced81f77fa904ad0b5cb5219383ef651d87392b8 diff --git a/.agents/notes/README.md b/.agents/notes/README.md index 9fdb4039b5..6872de0a13 100644 --- a/.agents/notes/README.md +++ b/.agents/notes/README.md @@ -16,7 +16,7 @@ Every Agent Note has two axes, both encoded in its **path** — `{lifecycle}/{cl The date in the filename is when the topic was **first proposed** (per git history). Cross-references between Agent Notes use relative markdown links (`[topic](../../implemented/architecture/2026-…-….md)`) — never bare prose or numbers — so they are mechanically checkable and survive moves between folders. -The active lifecycle tree is the working inventory: browse its lifecycle/class folders or search the repository. Do not add a centralized `INDEX.md`; the [no-index Agent Note](implemented/process/2026-07-19-remove-generated-agent-note-index.md) owns the rationale. Low-future-value implemented records move to the separate frozen [`archived/`](archived/AGENTS.md) tree described below. +The active lifecycle tree is the working inventory: browse its lifecycle/class folders or search the repository. Do not add a centralized `INDEX.md`; the [no-index Agent Note](implemented/process/2026-07-19-remove-generated-agent-note-index.md) owns the rationale. Implemented records with historical decision value but little future guidance move to the separate frozen [`archived/`](archived/AGENTS.md) tree described below. ## Classification @@ -35,9 +35,9 @@ The `architecture` / `process` line: **architecture** is about the source we shi ## Archiving and deletion -Archive an implemented Agent Note when the shipped decision is complete and its rationale is unlikely to guide future work. Keep it active when its alternatives, ownership boundary, negative guarantee, durable or wire semantics, security rule, or reintroduction condition remains useful. Never archive a proposed note: reject an obsolete proposal. Keep a rejected note only while it prevents a plausible mistake; otherwise delete its English, Chinese, and sidecar files together. Use the calibrated [`dsh-archive-agent-notes`](../skills/dsh-archive-agent-notes/SKILL.md) workflow rather than word count, age, or a target quota. +Delete an implemented Agent Note that only describes small UI adjustments or purely mechanical changes; delete its English, Chinese, and sidecar files together and repair or remove inbound links. A local bug fix, performance change, new capability, or substantive behavior decision does not qualify merely because its implementation is small. Archive other implemented notes only when the shipped decision is complete, its rationale is unlikely to guide future work, and the record still has historical decision value. Keep it active when its alternatives, ownership boundary, negative guarantee, durable or wire semantics, security rule, or reintroduction condition remains useful. Never archive a proposed note: reject an obsolete proposal. Keep a rejected note only while it prevents a plausible mistake; otherwise delete its English, Chinese, and sidecar files together. Use the calibrated [`dsh-archive-agent-notes`](../skills/dsh-archive-agent-notes/SKILL.md) workflow rather than word count, age, or a target quota. -The archive is path-encoded as `archived/{class}/yyyy-mm-dd-topic-title.md`; `implemented` is deliberately absent because only implemented notes can enter it. An archival change moves the complete English/Chinese/sidecar triplet, retains `Status: implemented`, inserts the same `Archived: YYYY-MM-DD` line immediately below that status in both language files, re-records the sidecar, and repairs or deletes inbound links. These are the only permitted content changes during archival. +The archive is path-encoded as `archived/{class}/yyyy-mm-dd-topic-title.md`; `implemented` is deliberately absent because only implemented notes can enter it. An archival change moves the complete English/Chinese/sidecar triplet, retains `Status: implemented`, inserts the same `Archived: YYYY-MM-DD` line immediately below that status in both language files, re-records the sidecar, and repairs or deletes inbound links. These are the only permitted content changes during archival. Existing title punctuation, blank-line layout, and language-switcher wording do not block archival; preserve them with the body. Once sealed, every archived triplet is permanently frozen. Do not edit, translate, reformat, update, move, or delete it, and do not treat it as authority for current behavior. Documentation gates skip archived sources, including their outbound links; active prose may still link into an archived note when it intentionally cites history. [`verify-archived-agent-notes`](../../scripts/verify-archived-agent-notes.ts) enforces the closed class tree, complete triplets, archive metadata, sidecar hashes, and the append-only frozen-content manifest. The [archive-policy Agent Note](implemented/process/2026-07-26-frozen-agent-note-archive.md) owns the rationale. diff --git a/.agents/notes/README.zh.md b/.agents/notes/README.zh.md index 2d7e07b963..ced81f77fa 100644 --- a/.agents/notes/README.zh.md +++ b/.agents/notes/README.zh.md @@ -16,7 +16,7 @@ 文件名中的日期是该主题**首次提出**的时间(以 git 历史为准)。Agent Note 之间的交叉引用使用相对 Markdown 链接(`[topic](../../implemented/architecture/2026-…-….md)`),从不使用纯文字或编号,这样既可机械检查,也能在文件夹间移动时保持有效。 -活跃生命周期目录树就是工作清单:浏览其生命周期/类别文件夹,或搜索仓库即可。请勿添加集中式 `INDEX.md`;设计理由见[不设索引的 Agent Note](implemented/process/2026-07-19-remove-generated-agent-note-index.zh.md)。未来指导价值较低的已实施记录会移至下文所述、单独冻结的 [`archived/`](archived/AGENTS.md) 目录树。 +活跃生命周期目录树就是工作清单:浏览其生命周期/类别文件夹,或搜索仓库即可。请勿添加集中式 `INDEX.md`;设计理由见[不设索引的 Agent Note](implemented/process/2026-07-19-remove-generated-agent-note-index.zh.md)。具有历史决策价值但未来指导价值较低的已实施记录会移至下文所述、单独冻结的 [`archived/`](archived/AGENTS.md) 目录树。 @@ -37,9 +37,9 @@ ## 归档与删除 -当一份 implemented Agent Note 记录的交付决策已经完整落地,且其决策依据不太可能再指导未来工作时,将其归档。如果其中的备选方案、归属边界、否定性保证、持久化语义或协议语义、安全规则,或者重新引入条件仍有价值,则继续作为活跃记录保留。绝不归档 proposed Agent Note:过时的提案应转为 rejected。仅当 rejected Agent Note 仍能避免一种可能发生的错误时保留;否则一并删除其英文、中文和伴随记录文件。请使用经过校准的 [`dsh-archive-agent-notes`](../skills/dsh-archive-agent-notes/SKILL.md) 工作流,不要根据字数、存续时间或目标配额来判断。 +仅描述小型 UI 调整或纯机械变更的 implemented Agent Note 直接删除;一并删除英文、中文和伴随记录文件,并修复或移除入站链接。局部 bug 修复、性能变更、新能力或实质行为决策,不会仅因实现规模小就符合删除条件。其他已实施记录只有在交付决策完整落地、其依据不太可能再指导未来工作,但仍具有历史决策价值时才归档。如果其中的备选方案、归属边界、否定性保证、持久化语义或协议语义、安全规则,或者重新引入条件仍有价值,则继续作为活跃记录保留。绝不归档 proposed Agent Note:过时的提案应转为 rejected。仅当 rejected Agent Note 仍能避免一种可能发生的错误时保留;否则一并删除其英文、中文和伴随记录文件。请使用经过校准的 [`dsh-archive-agent-notes`](../skills/dsh-archive-agent-notes/SKILL.md) 工作流,不要根据字数、存续时间或目标配额来判断。 -归档路径编码为 `archived/{class}/yyyy-mm-dd-topic-title.md`;其中有意省略 `implemented`,因为只有 implemented Agent Note 可以进入归档。归档变更会移动完整的英文、中文和伴随记录三个文件,保留 `Status: implemented`,在两种语言的文件中紧接该状态行插入相同的 `Archived: YYYY-MM-DD` 行,重新记录伴随记录,并修复或删除入站链接。归档时只允许对内容做这些更改。 +归档路径编码为 `archived/{class}/yyyy-mm-dd-topic-title.md`;其中有意省略 `implemented`,因为只有 implemented Agent Note 可以进入归档。归档变更会移动完整的英文、中文和伴随记录三个文件,保留 `Status: implemented`,在两种语言的文件中紧接该状态行插入相同的 `Archived: YYYY-MM-DD` 行,重新记录伴随记录,并修复或删除入站链接。归档时只允许对内容做这些更改。 既有标题标点、空行布局和语言切换栏措辞不阻止归档;与正文一起保留。 封存后,每组归档文件都永久冻结。禁止编辑、翻译、重新格式化、更新、移动或删除,也不得将其视为当前行为的权威依据。文档门禁会跳过归档源文件,包括其中的出站链接;当活跃文档有意引用历史时,仍可链接到归档 Agent Note。[`verify-archived-agent-notes`](../../scripts/verify-archived-agent-notes.ts) 强制执行封闭的类别目录树、完整的三文件配对、归档元数据、伴随记录 hash,以及仅追加的冻结内容 manifest(元数据清单)。[归档政策 Agent Note](implemented/process/2026-07-26-frozen-agent-note-archive.zh.md) 记录了设计依据。 diff --git a/.agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.i18n.yaml index aad1a96be8..bb7be001de 100644 --- a/.agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.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-08-09-client-conversation-node-assembly.md -2026-08-09-client-conversation-node-assembly.md: 43f9f0359822f7d7c5b52c09646fd3ffbf80f783 -2026-08-09-client-conversation-node-assembly.zh.md: d7475f5ff8218419743448d455d5d5ffe3cc4091 +2026-08-09-client-conversation-node-assembly.md: bb1d68cc045710d46a44ffb295c2307628338521 +2026-08-09-client-conversation-node-assembly.zh.md: 6291211d589b3389e7dc9a42d15fe6ac0d9e7ab9 diff --git a/.agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.md b/.agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.md index 43f9f03598..bb1d68cc04 100644 --- a/.agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.md +++ b/.agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.md @@ -291,7 +291,7 @@ Page size, record packing, the number of history loads, and RAF coalescing affec Inbox demonstrates that every Event can be a start-only instantaneous-state Context; not every business requires a start/update pair. Reader links each next-step state to the prior same-kind Context instead of inventing a lifecycle ID for the entire Inbox. The state itself shares immutable pending splice nodes and one current claimed-batch Set, while unconsumed next-turn input remains outside Conversation because no Chat or Trajectory classification reads it. -Request Prompt demonstrates shared pure interpretation without shared target State: Chat and Trajectory call `inspectRequestPrompt()` from their own Definitions. The function canonicalizes the full header and classifies model-visible system/tool differences; each target then chooses its own output. Chat materializes a non-empty initial system field, a real system change, and each `series` snapshot that explicitly begins a message series or follows a surface replacement. An unchanged `resume` remains in Trajectory and reconstruction state but does not repeat the visible Chat row once its predecessor is loaded. Ordinary append-only later Turns do not write another unchanged header. The first header in a Step follows the provider envelope rather than the header Event position: step one uses the owning Turn start and later steps use their Step start, placing the system field before the request's user-role messages; a later header in the same Step stays at its own Event after the surface rewrite that began the new series. When the preceding header is outside a partial window, a non-`initial` header stays at its own Event and renders conservatively. Prepending an identical predecessor hides an unchanged resume without withdrawing its stable Node key; a real change remains visible. Every header is a full snapshot, so a first loaded `resume`, `change`, or `series` header can render its system field without fabricating a comparison to unloaded history ([resume presentation decision](../bug-fix/2026-09-03-resume-headers-do-not-repeat-system-prompts.md)). +Request Prompt demonstrates shared pure interpretation without shared target State: Chat and Trajectory call `inspectRequestPrompt()` from their own Definitions. The function canonicalizes the full header and classifies model-visible system/tool differences; each target then chooses its own output. Chat materializes a non-empty initial system field, a real system change, and each `series` snapshot that explicitly begins a message series or follows a surface replacement. Resume headers retain a Chat prompt card even when the system text is unchanged. Ordinary append-only later Turns do not write another unchanged header. The first header in a Step follows the provider envelope rather than the header Event position: step one uses the owning Turn start and later steps use their Step start, placing the system field before the request's user-role messages; a later header in the same Step stays at its own Event after the surface rewrite that began the new series. When the preceding header is outside a partial window, a non-`initial` header stays at its own Event and renders conservatively. Prepending an identical predecessor can hide an unchanged `change` header without withdrawing its stable Node key; resume and explicit series-start cards remain visible. Every header is a full snapshot, so a first loaded `resume`, `change`, or `series` header can render its system field without fabricating a comparison to unloaded history ([request-prompt Definition](../../../../packages/client/ui-chat/src/client/conversation-nodes/request-prompt.ts)). Retry, Assistant, and Turn Tail demonstrate independent claims on one Event. Each Definition updates only its own State and produces its own atomic Chat Node. diff --git a/.agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.zh.md b/.agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.zh.md index d7475f5ff8..6291211d58 100644 --- a/.agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.zh.md @@ -291,7 +291,7 @@ Chat `order` 的结构性变化仍可能重排当前可见 key;纯 data 更新 Inbox 展示了“每条 Event 都是一个 start-only 瞬间态 Context”,不是所有业务都需要 start/update 配对。每个 next-step state 通过 Reader 与前一个同 kind Context 形成连续 fold,而非给整个 Inbox 人工制造生命周期 ID。state 自身共享不可变 pending splice 节点和一个当前 claimed-batch Set;未消费的 next-turn input 不进入 Conversation,因为 Chat 与 Trajectory 都不读取它来分类。 -Request Prompt 展示了如何在不共享 target State 的前提下共用纯解释逻辑:Chat 与 Trajectory 各自在自己的 Definition 中调用 `inspectRequestPrompt()`。该函数规范化完整 header,并判定面向模型的 system/tool 差异;随后每个 target 自行选择产物。Chat 会物化非空的初始 system 字段、真实 system 变化,以及每个显式开启消息序列或紧随表层替换的 `series` 快照。未变化的 `resume` 会留在 Trajectory 和重建状态中,但前序 header 已加载时不会重复可见的 Chat 行。普通的仅追加后续 Turn 不会再次写入未变 header。一个 Step 中的首条 header 遵循提供方信封,而不是 header Event 位置:step one 使用所属 Turn start,后续 step 使用各自的 Step start,把 system 字段放到该请求的 user-role 消息之前;同一 Step 的后续 header 保留在开启新序列的表层改写之后。部分窗口未包含前序 header 时,非 `initial` header 会保留在自身 Event 并保守渲染。prepend 补入相同的前序 header 后,内容未变的 resume 会隐藏但不撤回其稳定 Node key;真实变化仍然可见。每条 header 都是完整快照,因此已加载窗口中的首条 `resume`、`change` 或 `series` header 无需凭空构造与未加载历史的比较,也能渲染其 system 字段([resume 展示决策](../bug-fix/2026-09-03-resume-headers-do-not-repeat-system-prompts.zh.md))。 +Request Prompt 展示了如何在不共享 target State 的前提下共用纯解释逻辑:Chat 与 Trajectory 各自在自己的 Definition 中调用 `inspectRequestPrompt()`。该函数规范化完整 header,并判定面向模型的 system/tool 差异;随后每个 target 自行选择产物。Chat 会物化非空的初始 system 字段、真实 system 变化,以及每个显式开启消息序列或紧随表层替换的 `series` 快照。即使 system 文本未变化,resume header 仍保留 Chat prompt 卡片。普通的仅追加后续 Turn 不会再次写入未变 header。一个 Step 中的首条 header 遵循提供方信封,而不是 header Event 位置:step one 使用所属 Turn start,后续 step 使用各自的 Step start,把 system 字段放到该请求的 user-role 消息之前;同一 Step 的后续 header 保留在开启新序列的表层改写之后。部分窗口未包含前序 header 时,非 `initial` header 会保留在自身 Event 并保守渲染。prepend 补入相同的前序 header 后,内容未变的 `change` header 可以隐藏,但不撤回其稳定 Node key;resume 和显式序列起点的卡片仍然可见。每条 header 都是完整快照,因此已加载窗口中的首条 `resume`、`change` 或 `series` header 无需凭空构造与未加载历史的比较,也能渲染其 system 字段([request-prompt Definition](../../../../packages/client/ui-chat/src/client/conversation-nodes/request-prompt.ts))。 Retry、Assistant 和 Turn Tail 展示了同一 Event 被多个 Definition 独立认领。每个 Definition 只更新自己的 State,最终分别生成原子 Chat Node。 diff --git a/.agents/notes/implemented/architecture/2026-08-18-experimental-agent-teams-packages.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-18-experimental-agent-teams-packages.i18n.yaml index 0ac7d7109d..b3d68505df 100644 --- a/.agents/notes/implemented/architecture/2026-08-18-experimental-agent-teams-packages.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-18-experimental-agent-teams-packages.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-08-18-experimental-agent-teams-packages.md -2026-08-18-experimental-agent-teams-packages.md: 4a78a60c2ad7463c06b670e6ec664579ffd767b9 -2026-08-18-experimental-agent-teams-packages.zh.md: 8056cd8194695e8ce41acb359e51c040d4ed3aec +2026-08-18-experimental-agent-teams-packages.md: a1d29b5e96b185a5539d664e19f2f5cc342a6aba +2026-08-18-experimental-agent-teams-packages.zh.md: f4c4eb481ae6ad6319ddac50eda82fa9b9feebf7 diff --git a/.agents/notes/implemented/architecture/2026-08-18-experimental-agent-teams-packages.md b/.agents/notes/implemented/architecture/2026-08-18-experimental-agent-teams-packages.md index 4a78a60c2a..a1d29b5e96 100644 --- a/.agents/notes/implemented/architecture/2026-08-18-experimental-agent-teams-packages.md +++ b/.agents/notes/implemented/architecture/2026-08-18-experimental-agent-teams-packages.md @@ -12,17 +12,17 @@ Moving the packages into product-role groups would remove their experimental nam ## Decision -`packages/experimental/agent-team`, `packages/experimental/tool-agent-team`, `packages/experimental/agent-team-profile`, `packages/experimental/client-ui-agent-team`, and `packages/experimental/agent-team-web-profile` are public workspace packages. They retain their existing `@deepseek-ai/dsh-experimental-*` names and join the dsh release family. The [publication denylist decision](../process/2026-09-12-experimental-publication-denylist.md) owns the public default and private exceptions; the [experimental package rules](../../../../packages/experimental/AGENTS.md) own dependency isolation and later promotion. +`packages/experimental/agent-team`, `packages/experimental/tool-agent-team`, `packages/experimental/agent-team-profile`, and `packages/experimental/client-ui-agent-team` are public workspace packages. They retain their existing `@deepseek-ai/dsh-experimental-*` names and join the dsh release family. The [publication denylist decision](../process/2026-09-12-experimental-publication-denylist.md) owns the public default and private exceptions; the [experimental package rules](../../../../packages/experimental/AGENTS.md) own dependency isolation and later promotion. -The dsh pack and publish set and the local baseline publisher include these five Agent Teams directories and the [Cua Driver providers](2026-09-12-computer-use-provider-registration.md). Workspace constraints require them to omit `private`, set `publishConfig.access` to `public`, and keep the experimental npm prefix. Release packages and apps outside the experimental group, plus the Python runtime, cannot name experimental packages in `dependencies`, `optionalDependencies`, or `peerDependencies`; experimental packages may depend on release packages and each other. +The dsh pack and publish set and the local baseline publisher include these four Agent Teams directories and the [Cua Driver providers](2026-09-12-computer-use-provider-registration.md). Workspace constraints require them to omit `private`, set `publishConfig.access` to `public`, and keep the experimental npm prefix. Release packages and apps outside the experimental group, plus the Python runtime, cannot name experimental packages in `dependencies`, `optionalDependencies`, or `peerDependencies`; experimental packages may depend on release packages and each other. The generic caller-reserved continuable child identity and selective direct-child drain remain in the stable Subagent service. They own Subagent identity and Activation lifecycle without importing or naming Agent Teams; the experimental Team service consumes them in the permitted direction. -The published Host-side Agent Teams profile bundle depends on the Team packages and applies after `dsh-base`. It inserts the Team rows and disables the global continuable-child controls whose model-visible names overlap the Team tools. The separate published Web profile applies after `dsh-web-app` and the Host profile; it inserts the Team UI, which mounts the Remote contribution generated by the Team package. Both layers remain opt-in and leave the shipped base, CLI, Web, and Python runtime dependency graphs unchanged. +The [single-bundle decision](2026-09-18-agent-teams-single-bundle.md) supersedes the separate Host and Web bundle composition. This note retains publication, dependency isolation, and promotion rationale. The current Team profile remains opt-in and disables the global continuable-child controls whose model-visible names overlap the Team tools; the [optional-bundle decision](../process/2026-09-15-shipped-optional-bundles.md) owns its inclusion in the installation. Profile startup resolves selected bundles before computing the [immutable profile resolution generation](2026-09-09-profile-resolution-generations.md). The generation retains installation-first precedence, traverses each explicit bundle root completely in profile order, and keeps pnpm-managed profile packages authoritative. Runtime mode enforces the result in memory; retained link and dual modes materialize the same result as shared and profile-owned projections. A private profile layer can therefore carry experimental plugin rows without adding those plugins to a release app, requiring profile users to install transitive packages directly, weakening packaged-runtime module identity, or changing another profile's resolution. -Experimental status changes compatibility and support expectations, not publication for these five packages. They retain the repository's ordinary documentation, invariant, lifecycle, security, unit, real-composition, and snapshot requirements. Promotion still requires review of the public contracts, limitations, test evidence, runtime dependents, and a named owner accepting stable-package obligations. +Experimental status changes compatibility and support expectations, not publication for these four packages. They retain the repository's ordinary documentation, invariant, lifecycle, security, unit, real-composition, and snapshot requirements. Promotion still requires review of the public contracts, limitations, test evidence, runtime dependents, and a named owner accepting stable-package obligations. ## Alternatives considered @@ -36,6 +36,6 @@ Experimental status changes compatibility and support expectations, not publicat ## Consequences -Agent Teams publishes as five installable tarballs in the dsh release family without changing package names or enabling Team in a shipped profile. Public availability does not make the packages stable or supported by default, and stable release packages cannot take runtime dependencies on them. +Agent Teams publishes as four installable tarballs in the dsh release family without changing package names or enabling Team in a shipped profile. Public availability does not make the packages stable or supported by default, and stable release packages cannot take runtime dependencies on them. The release family carries the experimental npm names. Promotion still creates path and npm-name churn as specified by the experimental package rules. diff --git a/.agents/notes/implemented/architecture/2026-08-18-experimental-agent-teams-packages.zh.md b/.agents/notes/implemented/architecture/2026-08-18-experimental-agent-teams-packages.zh.md index 8056cd8194..f4c4eb481a 100644 --- a/.agents/notes/implemented/architecture/2026-08-18-experimental-agent-teams-packages.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-18-experimental-agent-teams-packages.zh.md @@ -12,17 +12,17 @@ Agent Teams 的服务与工具约定仍在变化,但它需要使用真实 Sess ## 决策 -`packages/experimental/agent-team`、`packages/experimental/tool-agent-team`、`packages/experimental/agent-team-profile`、`packages/experimental/client-ui-agent-team` 与 `packages/experimental/agent-team-web-profile` 是公开 workspace 包。它们保留现有 `@deepseek-ai/dsh-experimental-*` 名称并加入 dsh 发布系列。[发布拒绝列表决策](../process/2026-09-12-experimental-publication-denylist.zh.md)负责默认公开与私有例外;[实验性包规则](../../../../packages/experimental/AGENTS.md)负责依赖隔离与后续 promotion。 +`packages/experimental/agent-team`、`packages/experimental/tool-agent-team`、`packages/experimental/agent-team-profile` 与 `packages/experimental/client-ui-agent-team` 是公开 workspace 包。它们保留现有 `@deepseek-ai/dsh-experimental-*` 名称并加入 dsh 发布系列。[发布拒绝列表决策](../process/2026-09-12-experimental-publication-denylist.zh.md)负责默认公开与私有例外;[实验性包规则](../../../../packages/experimental/AGENTS.md)负责依赖隔离与后续 promotion。 -dsh 打包与发布集合以及本地基线发布器包含这五个 Agent Teams 目录和 [Cua Driver 提供方](2026-09-12-computer-use-provider-registration.zh.md)。workspace 约束要求它们省略 `private`、将 `publishConfig.access` 设为 `public`,并保留实验性 npm 前缀。实验组之外的发布包、应用和 Python 运行时不能在 `dependencies`、`optionalDependencies` 或 `peerDependencies` 中引用实验包;实验包可以依赖发布包和彼此。 +dsh 打包与发布集合以及本地基线发布器包含这四个 Agent Teams 目录和 [Cua Driver 提供方](2026-09-12-computer-use-provider-registration.zh.md)。workspace 约束要求它们省略 `private`、将 `publishConfig.access` 设为 `public`,并保留实验性 npm 前缀。实验组之外的发布包、应用和 Python 运行时不能在 `dependencies`、`optionalDependencies` 或 `peerDependencies` 中引用实验包;实验包可以依赖发布包和彼此。 通用的调用方预留 continuable child 身份和精确 direct-child drain 仍属于稳定 Subagent 服务。它们负责 Subagent 身份与 Activation 生命周期,不 import 或命名 Agent Teams;实验性 Team 服务沿允许的方向消费这些能力。 -公开发布的 Host 侧 Agent Teams profile bundle 依赖 Team 包,并在 `dsh-base` 之后应用。它会插入 Team 配置行,并禁用模型可见名称与 Team 工具重叠的全局 continuable-child control。独立公开发布的 Web profile 在 `dsh-web-app` 与 Host profile 之后应用;它会插入 Team UI,后者挂载 Team package 生成的 Remote contribution。两个层都保持显式启用,不改变随附 base、CLI、Web 与 Python runtime 的依赖图。 +[单组合包决策](2026-09-18-agent-teams-single-bundle.zh.md)取代了独立 Host 与 Web 组合包的组合方式。本记录保留发布、依赖隔离与 promotion 的理由。当前 Team profile 仍须显式启用,并禁用模型可见名称与 Team 工具重叠的全局 continuable-child control;[可选组合包决策](../process/2026-09-15-shipped-optional-bundles.zh.md)负责其随安装交付的规则。 profile 启动会先解析所选 bundle,再计算[不可变 profile resolution generation](2026-09-09-profile-resolution-generations.zh.md)。generation 保留安装优先顺序,按 profile 顺序完整遍历每个显式 bundle 根,并让 pnpm 管理的 profile 包保持优先。runtime 模式在内存中强制该结果;保留的 link 与 dual 模式把同一结果物化为共享和 profile 自有投影。因此,私有 profile 层可以携带实验性 plugin 配置行,而无需把这些 plugin 加入发布 app、要求 profile 用户直接安装传递依赖、破坏 packaged-runtime 的模块身份,或改变其他 profile 的解析结果。 -对这五个包而言,实验性状态改变兼容性与支持预期,而不阻止发布。这些包仍须满足仓库的一般文档、不变式、生命周期、安全、单元测试、真实组合测试和快照要求。promotion 前仍须评审公开约定、限制、测试证据、运行时依赖方,并由一名具名 owner 接受稳定包义务。 +对这四个包而言,实验性状态改变兼容性与支持预期,而不阻止发布。这些包仍须满足仓库的一般文档、不变式、生命周期、安全、单元测试、真实组合测试和快照要求。promotion 前仍须评审公开约定、限制、测试证据、运行时依赖方,并由一名具名 owner 接受稳定包义务。 ## 曾考虑的替代方案 @@ -36,6 +36,6 @@ profile 启动会先解析所选 bundle,再计算[不可变 profile resolution ## 后果 -Agent Teams 会作为 dsh 发布系列中的五个可安装 tarball 发布,同时保持包名不变,也不会在随附 profile 中启用 Team。公开可用不代表这些包稳定或默认受支持,稳定发布包也不能对其建立运行时依赖。 +Agent Teams 会作为 dsh 发布系列中的四个可安装 tarball 发布,同时保持包名不变,也不会在随附 profile 中启用 Team。公开可用不代表这些包稳定或默认受支持,稳定发布包也不能对其建立运行时依赖。 发布系列保留实验性 npm 名称。promotion 仍会按照实验性包规则产生路径和 npm 名改动。 diff --git a/.agents/notes/implemented/architecture/2026-08-23-client-derived-tool-presentation.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-23-client-derived-tool-presentation.i18n.yaml index cdf124f72a..dcadb91f94 100644 --- a/.agents/notes/implemented/architecture/2026-08-23-client-derived-tool-presentation.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-23-client-derived-tool-presentation.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-08-23-client-derived-tool-presentation.md -2026-08-23-client-derived-tool-presentation.md: 8befe840b08006a7d2ee0fa1b7875c2c989d4779 -2026-08-23-client-derived-tool-presentation.zh.md: 249366f10e6eb06941b344977169609358786413 +2026-08-23-client-derived-tool-presentation.md: 931276fa6d4c0590746b6c59eeae9f998f05629c +2026-08-23-client-derived-tool-presentation.zh.md: b930f98da17a1cb6ecbf605e3a9f37255ba75fc9 diff --git a/.agents/notes/implemented/architecture/2026-08-23-client-derived-tool-presentation.md b/.agents/notes/implemented/architecture/2026-08-23-client-derived-tool-presentation.md index 8befe840b0..931276fa6d 100644 --- a/.agents/notes/implemented/architecture/2026-08-23-client-derived-tool-presentation.md +++ b/.agents/notes/implemented/architecture/2026-08-23-client-derived-tool-presentation.md @@ -22,7 +22,7 @@ The required result is one raw Session journal and one Client presentation owner ## Decision -The visual-equivalence requirements below exclude the separately approved [nested terminal-card fix](../bug-fix/2026-09-05-nested-terminal-cards.md); all other presentation and ownership constraints remain. +The visual-equivalence requirements below exclude the separately approved [nested terminal-card fix](../bug-fix/2026-09-05-nested-terminal-cards.md) and [compact tool details](2026-09-10-compact-tool-details.md); all other presentation and ownership constraints remain. The Session Remote journal sends only raw, validated, persistable Session events. `session.page` and `session.follow` do not parse tool arguments, query the Tools registry, restore a presenter scope, execute `presentCall` or `presentResult`, or construct or clone any tool view. @@ -51,7 +51,7 @@ The Host `ToolDefinition.presentCall`, `ToolDefinition.presentResult`, `ToolCall | Retained | the Session log format, Remote journal lifecycle, and Conversation identity/topology | | Retained | the existing keyed slot, Generic fallback, and Chat, Details, and Trajectory structure | | Forbidden | a new Client presenter service, parallel registry, or wire renderer id | -| Forbidden | new cards, visual redesign, interaction redesign, or PTC dispatch rich-card enhancements except the [nested terminal-card exception](../bug-fix/2026-09-05-nested-terminal-cards.md) | +| Forbidden | new cards, visual redesign, interaction redesign, or PTC dispatch rich-card enhancements except the [nested terminal-card exception](../bug-fix/2026-09-05-nested-terminal-cards.md) and [compact tool details](2026-09-10-compact-tool-details.md) | | Forbidden | compatibility dual-writing, version negotiation, or retention of the old `view` field | ## Terminology diff --git a/.agents/notes/implemented/architecture/2026-08-23-client-derived-tool-presentation.zh.md b/.agents/notes/implemented/architecture/2026-08-23-client-derived-tool-presentation.zh.md index 249366f10e..b930f98da1 100644 --- a/.agents/notes/implemented/architecture/2026-08-23-client-derived-tool-presentation.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-23-client-derived-tool-presentation.zh.md @@ -22,7 +22,7 @@ Host presenter 与 Client keyed renderer 分担展示会形成对同一事件的 ## Decision -下述展示对等要求不包含已独立批准的[嵌套 terminal 卡片修复](../bug-fix/2026-09-05-nested-terminal-cards.zh.md);其他展示与所有权约束全部保留。 +下述展示对等要求不包含已独立批准的[嵌套 terminal 卡片修复](../bug-fix/2026-09-05-nested-terminal-cards.zh.md)和[紧凑工具详情](2026-09-10-compact-tool-details.zh.md);其他展示与所有权约束全部保留。 Session Remote journal 只下发原始、已验证、可持久化的 Session event。`session.page` 和 `session.follow` 不解析工具参数,不查询 Tools registry,不恢复 presenter scope,不执行 `presentCall`/`presentResult`,也不构造或克隆任何 tool view。 @@ -51,7 +51,7 @@ Host 的 `ToolDefinition.presentCall`、`ToolDefinition.presentResult`、`ToolCa | 保留 | Session 日志格式、Remote journal 生命周期与 Conversation identity/topology | | 保留 | 现有 keyed slot、Generic fallback、Chat、Details 与 Trajectory 结构 | | 禁止 | 新 Client presenter service、平行 registry 或 wire renderer id | -| 禁止 | 新卡片、视觉改版、交互改版或 PTC dispatch rich-card 增强,[嵌套 terminal 卡片例外](../bug-fix/2026-09-05-nested-terminal-cards.zh.md)除外 | +| 禁止 | 新卡片、视觉改版、交互改版或 PTC dispatch rich-card 增强,[嵌套 terminal 卡片例外](../bug-fix/2026-09-05-nested-terminal-cards.zh.md)和[紧凑工具详情](2026-09-10-compact-tool-details.zh.md)除外 | | 禁止 | 为兼容保留双写、版本协商或旧 `view` 字段 | ## 术语 diff --git a/.agents/notes/implemented/architecture/2026-08-25-session-observations-and-projection-owned-client-state.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-25-session-observations-and-projection-owned-client-state.i18n.yaml index 3c5fbdae42..2c2fc1b908 100644 --- a/.agents/notes/implemented/architecture/2026-08-25-session-observations-and-projection-owned-client-state.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-25-session-observations-and-projection-owned-client-state.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-08-25-session-observations-and-projection-owned-client-state.md -2026-08-25-session-observations-and-projection-owned-client-state.md: ff5628c3051086159d532b71ca3e98c1b41a51f8 -2026-08-25-session-observations-and-projection-owned-client-state.zh.md: e433df28fec86564fe1c1e9e88686a5abe6b9549 +2026-08-25-session-observations-and-projection-owned-client-state.md: 0ff8cbf50c18285092432850ce5222430f5acf0d +2026-08-25-session-observations-and-projection-owned-client-state.zh.md: f3fca078d089a9ee1e329fdbb661653053053a91 diff --git a/.agents/notes/implemented/architecture/2026-08-25-session-observations-and-projection-owned-client-state.md b/.agents/notes/implemented/architecture/2026-08-25-session-observations-and-projection-owned-client-state.md index ff5628c305..0ff8cbf50c 100644 --- a/.agents/notes/implemented/architecture/2026-08-25-session-observations-and-projection-owned-client-state.md +++ b/.agents/notes/implemented/architecture/2026-08-25-session-observations-and-projection-owned-client-state.md @@ -120,7 +120,7 @@ Client-local interaction state also remains local: loading and error status, an ### Domain applications -- **Title and list metadata.** Cached projection hints may render an existing title and determine blankness or recency. Missing hints leave those facts unknown; only the bounded small-log policy may resolve them during listing. +- **Title and list metadata.** Cached projection hints may render an existing title and determine blankness or recency. Missing hints leave those facts unknown; only the bounded small-log policy may resolve them during listing. The Client reconciles list rows with the current metadata projection: nonblank evidence excludes a Session from blank reuse, and the later prompt timestamp supplies recency. Projection stores outlive lazy Client Session instances, so instantiation reads retained metadata even before a list row is available. A stale list response cannot override a newer history or control projection. - **Model selection.** `model/selection` records a complete provider, model, and optional reasoning effort. `modelSelection` distinguishes the last request's route from a later selection pending consumption by a request header. - **Agent preset.** The projection initializes from immutable Session metadata and advances on preset-selection events. A missing or `null` value is not replaced with the deployment default for an existing Session. - **Subagent identity.** The `subagent` unit remains the sole descriptor interpreter. Listing obtains candidates from the shared corpus and resolves values through live state, projection cache, or an observation rather than scanning events itself. diff --git a/.agents/notes/implemented/architecture/2026-08-25-session-observations-and-projection-owned-client-state.zh.md b/.agents/notes/implemented/architecture/2026-08-25-session-observations-and-projection-owned-client-state.zh.md index e433df28fe..f3fca078d0 100644 --- a/.agents/notes/implemented/architecture/2026-08-25-session-observations-and-projection-owned-client-state.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-25-session-observations-and-projection-owned-client-state.zh.md @@ -120,7 +120,7 @@ Client 本地交互状态也继续留在本地:loading 和 error 状态、打 ### 领域应用 -- **Title 与 list metadata。** Cached projection hints 可以渲染已有 title,并判断 blankness 或 recency。Hints 缺失时这些事实保持未知;listing 期间只有有界小日志策略可以解析它们。 +- **Title 与 list metadata。** Cached projection hints 可以渲染已有 title,并判断 blankness 或 recency。Hints 缺失时这些事实保持未知;listing 期间只有有界小日志策略可以解析它们。Client 使用当前 metadata projection 对账列表行:非空白事实会将 Session 排除在空白复用之外,较晚的 prompt 时间用于最近活动排序。Projection store 的生命周期长于惰性创建的 Client Session 实例,因此实例化时即使尚无列表行,也会读取已保留的元数据。过期列表响应不能覆盖较新的 history 或 control projection。 - **Model selection。** `model/selection` 记录完整 provider、model 和可选 reasoning effort。`modelSelection` 区分上一请求使用的 route,以及等待 request header 消费的较晚 selection。 - **Agent preset。** Projection 从不可变 Session metadata 初始化,并随 preset-selection event 推进。对于现有 Session,缺失或 `null` 值不会替换成部署默认值。 - **Subagent identity。** `subagent` unit 仍是唯一 descriptor interpreter。Listing 从共享 corpus 获得 candidate,并通过 live state、projection cache 或 observation 解析值,不自行扫描 event。 diff --git a/.agents/notes/implemented/architecture/2026-09-05-sidebar-tab-types-and-navigation.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-05-sidebar-tab-types-and-navigation.i18n.yaml index 2ccfc07ac8..577fbe18d0 100644 --- a/.agents/notes/implemented/architecture/2026-09-05-sidebar-tab-types-and-navigation.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-09-05-sidebar-tab-types-and-navigation.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-05-sidebar-tab-types-and-navigation.md -2026-09-05-sidebar-tab-types-and-navigation.md: 834a26e55f87e5db54d57f06fec592e4fdb9f61c -2026-09-05-sidebar-tab-types-and-navigation.zh.md: 9884db9eef49162e8883c390dd71bdbf9d45489f +2026-09-05-sidebar-tab-types-and-navigation.md: 599ce36ed4bb1dfc2cce063a461903e1a4aeb40e +2026-09-05-sidebar-tab-types-and-navigation.zh.md: e4e8de5a389fea77aae705d63cc4fb16f88af647 diff --git a/.agents/notes/implemented/architecture/2026-09-05-sidebar-tab-types-and-navigation.md b/.agents/notes/implemented/architecture/2026-09-05-sidebar-tab-types-and-navigation.md index 834a26e55f..599ce36ed4 100644 --- a/.agents/notes/implemented/architecture/2026-09-05-sidebar-tab-types-and-navigation.md +++ b/.agents/notes/implemented/architecture/2026-09-05-sidebar-tab-types-and-navigation.md @@ -36,7 +36,7 @@ interface SidebarRightTabDefinition { `priority` is one of three literal bands, spelled as strings so that a type from another package needs no runtime import: `extension` is the band of a type from outside the product and the highest, so a type that declares nothing outranks every viewer shipped here; `builtin` is the ordinary band for shipped types; `fallback` is the plain-content position that anything more specific should beat, which VS Code's text editor holds implicitly and our text preview holds explicitly. `candidates(address)` returns every type whose globs match and whose `canOpen` does not veto, ranked by band, then by the length of the longest pattern that matched, then by registration order. `claim(address, kind?)` takes the best candidate, or the named kind's type in force when the caller overrides (its globs are not consulted; naming the type is the decision), and throws for an address nothing will open — a wiring mistake, not a user error. `get(kind)` returns the type in force; `entries()` and `guide()` list the types and their guide boxes in force; `subscribe` observes changes. -`title(address)`, `guide[].title()`, and optional `guide[].description()` are thunks read on every use, so a language change needs no re-registration. [Guide start page and stat pill refinements](../feature/2026-09-10-guide-start-page-and-stat-pill-refinements.md) owns the current description visibility and fallback-glyph rules. The registry itself is a plain object provided at `apply`'s top level **without** `Service.tracker`: a tracker would rebind `this.ctx` to the caller's context, and a cross-package `register()` would then add its effect to the caller's fiber while that fiber is the active scope, stalling the browser boot with no error. +`title(address)`, `guide[].title()`, and optional `guide[].description()` are thunks read on every use, so a language change needs no re-registration. The [Sidebar README](../../../../packages/client/ui-sidebar-right/README.md#the-guide) defines description visibility and fallback-glyph rules. The registry itself is a plain object provided at `apply`'s top level **without** `Service.tracker`: a tracker would rebind `this.ctx` to the caller's context, and a cross-package `register()` would then add its effect to the caller's fiber while that fiber is the active scope, stalling the browser boot with no error. ### Bodies and titles: keyed Slot seats under the definition's `id` diff --git a/.agents/notes/implemented/architecture/2026-09-05-sidebar-tab-types-and-navigation.zh.md b/.agents/notes/implemented/architecture/2026-09-05-sidebar-tab-types-and-navigation.zh.md index 9884db9eef..e4e8de5a38 100644 --- a/.agents/notes/implemented/architecture/2026-09-05-sidebar-tab-types-and-navigation.zh.md +++ b/.agents/notes/implemented/architecture/2026-09-05-sidebar-tab-types-and-navigation.zh.md @@ -36,7 +36,7 @@ interface SidebarRightTabDefinition { `priority` 是三个字面量档位之一,写成字符串,好让别的包的类型不需要任何运行时引入:`extension` 是来自产品之外的类型的档位也是最高档,所以什么都不声明的类型压过这里随包交付的每个查看器;`builtin` 是随包类型的常规档;`fallback` 是任何更具体的东西都应压过的纯内容位置,VS Code 的文本编辑器隐含地占据它,我们的文本预览明确地占据它。`candidates(address)` 返回 glob 命中且 `canOpen` 未否决的每个类型,按档位、再按命中的最长 pattern 长度、再按注册顺序排序。`claim(address, kind?)` 取最佳候选,或在调用方指定时取该 kind 生效的类型(不查它的 glob;点名即决定),对无人愿开的地址抛错——这是接线错误,不是用户错误。`get(kind)` 返回生效类型;`entries()` 与 `guide()` 列出生效类型及其引导入口;`subscribe` 观察变化。 -`title(address)`、`guide[].title()` 与可选的 `guide[].description()` 都是每次使用时重读的 thunk,语言切换无需重新注册。[引导起始页与统计 pill 的细化](../feature/2026-09-10-guide-start-page-and-stat-pill-refinements.zh.md)负责当前的描述显示与兜底图标规则。注册表本身是 `apply` 顶层提供的普通对象,**不带** `Service.tracker`:tracker 会把 `this.ctx` 重绑到调用方上下文,跨包 `register()` 就会在调用方 fiber 仍是活动作用域时往它上加 effect,浏览器启动会无声卡死。 +`title(address)`、`guide[].title()` 与可选的 `guide[].description()` 都是每次使用时重读的 thunk,语言切换无需重新注册。[Sidebar README](../../../../packages/client/ui-sidebar-right/README.zh.md#the-guide)定义描述显示与兜底图标规则。注册表本身是 `apply` 顶层提供的普通对象,**不带** `Service.tracker`:tracker 会把 `this.ctx` 重绑到调用方上下文,跨包 `register()` 就会在调用方 fiber 仍是活动作用域时往它上加 effect,浏览器启动会无声卡死。 ### 正文与标题:按定义 `id` keyed 的 Slot 坑位 diff --git a/.agents/notes/implemented/feature/2026-09-16-known-site-link-marks.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-10-compact-tool-details.i18n.yaml similarity index 58% rename from .agents/notes/implemented/feature/2026-09-16-known-site-link-marks.i18n.yaml rename to .agents/notes/implemented/architecture/2026-09-10-compact-tool-details.i18n.yaml index d6e17b52fa..484c0c2a3b 100644 --- a/.agents/notes/implemented/feature/2026-09-16-known-site-link-marks.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-09-10-compact-tool-details.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 .agents/notes/implemented/feature/2026-09-16-known-site-link-marks.md -2026-09-16-known-site-link-marks.md: 5e4bae0349f28d823c78854a435061de7872b358 -2026-09-16-known-site-link-marks.zh.md: 7b57ab0bbc4491d0db597a31f5077db07c4eb142 +# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-10-compact-tool-details.md +2026-09-10-compact-tool-details.md: 6c1f29c5554c74b01fc2a86c2dee3750a529a6b5 +2026-09-10-compact-tool-details.zh.md: d0822a48f100f9f21ef4706204087fcd1e627da4 diff --git a/.agents/notes/implemented/architecture/2026-09-10-compact-tool-details.md b/.agents/notes/implemented/architecture/2026-09-10-compact-tool-details.md new file mode 100644 index 0000000000..6c1f29c555 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-10-compact-tool-details.md @@ -0,0 +1,29 @@ +# Agent Note: Compact Tool Details from Recorded Results + +Status: implemented + +English | [中文](2026-09-10-compact-tool-details.zh.md) + +## Problem + +Goal, todo, and schedule results contain a small set of user-facing facts that take more effort to read as JSON. Dedicated expanded bodies need to fit the existing conversation rows without adding a second presentation registry or live state to historical calls. + +## Decision + +The `ui-tool` package registers goal and schedule toolviews through the existing keyed slot and shares one package-internal fields/list component with the todo view. Successful, recognized results display recorded values; incomplete calls, errors, malformed values, and unsupported result formats retain generic input/output. Schedule domain errors, including uncertain persistence and an unknown deletion target, retain their original result. + +Tool rows keep their disclosure and Inspect interaction. Checklist marks are static and have accessible status labels. Schedule times use absolute dates with the viewer's time zone; states come from the recorded result. Goal details distinguish an active goal awaiting continuation from an armed goal. These display values do not subscribe to the current goal, todo, or schedule projection. + +Todo changes compare each root or nested call with its preceding durable `todo/write`, so later writes cannot alter an earlier card. The conversation assembler repairs predecessors when older history loads. Until the predecessor is known, the card shows the submitted list without inferred additions or removals. + +This scoped visual change is an exception to the visual-equivalence requirement in [Client-derived tool presentation](2026-08-23-client-derived-tool-presentation.md). Its raw-event ownership, generic fallback, and single keyed registry remain authoritative. + +## Alternatives considered + +**A shared presenter framework:** the existing keyed slot already dispatches tools. Reusing only the detail body keeps the abstraction local until another package needs it. + +**A dashboard inside each card:** interactive controls, progress graphics, and live states add visual weight and can misrepresent what a historical call returned. Compact read-only fields and lists preserve the conversation's hierarchy. + +## Consequences + +Registered tools gain readable details without changing Host tools, Session events, or public Client exports. Tool-specific adapters interpret recorded JSON and text; unrecognized formats retain generic output. The authored tool-details Session replay covers their assembled rendering. diff --git a/.agents/notes/implemented/architecture/2026-09-10-compact-tool-details.zh.md b/.agents/notes/implemented/architecture/2026-09-10-compact-tool-details.zh.md new file mode 100644 index 0000000000..d0822a48f1 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-10-compact-tool-details.zh.md @@ -0,0 +1,29 @@ +# Agent Note: Compact Tool Details from Recorded Results + +Status: implemented + +[English](2026-09-10-compact-tool-details.md) | 中文 + +## 问题 + +目标、任务清单和定时任务的结果包含少量面向用户的事实,以 JSON 阅读时较费力。专用展开内容需要融入现有对话行,不能为历史调用新增一套呈现注册表或实时状态。 + +## 决策 + +`ui-tool` 包通过现有按键分发的插槽注册目标和定时任务视图,并与任务清单视图共用一个包内字段/列表组件。成功且可识别的结果显示记录值;未完成调用、错误、格式异常和不支持的结果格式保留通用输入/输出。定时任务领域错误(包括持久化不确定和删除目标不存在)保留原始结果。 + +工具行保留展开和 Inspect 交互。任务清单标记是静态的,并带有可访问的状态标签。定时任务使用查看者时区的绝对日期,状态取自记录的结果。目标详情区分等待继续的活动目标和已启动的目标。这些显示值不订阅当前目标、任务清单或定时任务投影。 + +任务清单的变化将每个根调用或嵌套调用与之前的持久化 `todo/write` 比较,因此后续写入不会改变较早的卡片。对话组装器在加载更早的历史时修复前序记录。前序记录尚不明确时,卡片展示提交的列表,不推断新增或删除。 + +这项有限的视觉调整是[客户端派生工具呈现](2026-08-23-client-derived-tool-presentation.zh.md)中视觉等价要求的例外。该决策中的原始事件所有权、通用回退和唯一按键注册表仍然有效。 + +## 考虑过的替代方案 + +**共享呈现框架:**现有按键插槽已负责工具分发。仅复用详情内容,可以在其他包出现需求前将抽象限制在包内。 + +**在每张卡片中嵌入仪表盘:**交互控件、进度图形和实时状态会增加视觉负担,也可能歪曲历史调用返回的事实。紧凑的只读字段和列表保留了对话的层级。 + +## 影响 + +已注册的工具获得可读详情,无需改变 Host 工具、Session 事件或公开的 Client 导出。工具专用适配器解读记录中的 JSON 和文本;未识别的格式保留通用输出。手写的 tool-details Session 回放覆盖这些工具组装后的渲染。 diff --git a/.agents/notes/implemented/architecture/2026-09-15-bounded-office-conversion.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-15-bounded-office-conversion.i18n.yaml index 8ddfd2d49e..2f64b37f55 100644 --- a/.agents/notes/implemented/architecture/2026-09-15-bounded-office-conversion.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-09-15-bounded-office-conversion.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-15-bounded-office-conversion.md -2026-09-15-bounded-office-conversion.md: 78a2fc46d97500575262d2263bf5549037872264 -2026-09-15-bounded-office-conversion.zh.md: 88cb15c8cbf2200a9365b9a42cb5802c1bfb65d8 +2026-09-15-bounded-office-conversion.md: b15aa5421c575451ddb4a7bf91169bcb2fa38d3c +2026-09-15-bounded-office-conversion.zh.md: f23c38096764cf32207125cc0c3ebbdf1c04d009 diff --git a/.agents/notes/implemented/architecture/2026-09-15-bounded-office-conversion.md b/.agents/notes/implemented/architecture/2026-09-15-bounded-office-conversion.md index 78a2fc46d9..b15aa5421c 100644 --- a/.agents/notes/implemented/architecture/2026-09-15-bounded-office-conversion.md +++ b/.agents/notes/implemented/architecture/2026-09-15-bounded-office-conversion.md @@ -37,3 +37,7 @@ The cache is transient and cannot bypass source authorization. Oversized PDFs ca Office preview checks source authorization and versions through Workspace Files, then reads raw input with `fs.readBytes` within its conversion reservation. Office input limits govern that read. The Host conversion path avoids base64 source allocation; PDF responses encode only the converted output. Controlled source and engine completions verify pre-read admission, content joining, priority, cancellation isolation, delayed resource release, LRU/alias limits, stale versions, and converter replacement. Loader composition and native conversion checks exercise the shared provider independently of presentation consumers. + +## Related + +The [background scheduling proposal](../../proposed/simplification/2026-09-17-defer-office-background-scheduling.md) questions the need for speculative priority without a production caller; this note remains the authority for the implemented behavior. diff --git a/.agents/notes/implemented/architecture/2026-09-15-bounded-office-conversion.zh.md b/.agents/notes/implemented/architecture/2026-09-15-bounded-office-conversion.zh.md index 88cb15c8cb..f23c380967 100644 --- a/.agents/notes/implemented/architecture/2026-09-15-bounded-office-conversion.zh.md +++ b/.agents/notes/implemented/architecture/2026-09-15-bounded-office-conversion.zh.md @@ -37,3 +37,7 @@ Office 预览和显式文档检查可能请求相同转换。仅缓存已完成 Office 预览通过 Workspace Files 检查源文件授权与版本,再使用 `fs.readBytes` 在转换预留容量内读取原始输入。该读取受 Office 输入上限约束。Host 转换路径不分配 base64 源数据;PDF 响应只编码转换后的输出。 受控的源读取和引擎完成验证读取前准入、内容合并、优先级、取消隔离、延迟资源释放、LRU 与别名限额、过期版本及转换器替换。Loader 组合与原生转换检查独立于展示消费者验证共享提供方。 + +## 相关记录 + +[后台调度提案](../../proposed/simplification/2026-09-17-defer-office-background-scheduling.zh.md) 质疑没有生产调用方时保留推测性优先级的必要性;本记录仍定义已实现的行为。 diff --git a/.agents/notes/implemented/architecture/2026-09-16-desktop-policy-login-loading.md b/.agents/notes/implemented/architecture/2026-09-16-desktop-policy-login-loading.md deleted file mode 100644 index 26fd635c6c..0000000000 --- a/.agents/notes/implemented/architecture/2026-09-16-desktop-policy-login-loading.md +++ /dev/null @@ -1,37 +0,0 @@ -# Agent Note: Show a local document while the policy login page loads - -Status: implemented - -English | [中文](2026-09-16-desktop-policy-login-loading.zh.md) - -## Problem - -The test-deployment login window ([mandatory-update client](../feature/2026-09-11-desktop-mandatory-update-client.md)) navigated straight to the policy origin. Until that document committed and painted, the window was blank, and the remote page can take seconds to appear. A user pressed "Sign in with Feishu" and saw an empty window with no sign that anything was happening — while the window had to stay closable and must never cover a page that had become interactive. - -## Decision - -[policy-test-auth.ts](../../../../apps/desktop/src/policy-test-auth.ts) loads [renderer/policy-login-loading.html](../../../../apps/desktop/renderer/policy-login-loading.html) as the window's first document and requests the policy origin only after that document has committed. The placeholder is a packaged, self-contained file whose only text is the label the main process passes through `loadFile`'s query, so the copy stays in the shell locale dictionary (`policyLoginLoading`). The first committed remote document replaces it. - -The placeholder never coexists with the remote page, so there is no removal step and no timer: Chromium keeps the placeholder frame until the remote document's first frame is ready, and from then on the third-party page owns the window. A redirect chain keeps the placeholder until the last document commits. The window is created visible and closable as before. - -Two places recognize the placeholder's own load by file name and nothing else: the Session's `onBeforeRequest` filter, which cancels document requests outside the policy and Feishu origins, and the `did-fail-load` handler, which fails the login on a main-frame error. The `will-navigate` and `will-redirect` guards still require an allowed HTTPS origin, so the remote page cannot steer the window back to a local file. A placeholder that cannot load is not a login failure: the login request starts anyway and the window is blank exactly as it was before this change. - -## Alternatives considered - -**Overlay the window with a `WebContentsView` (or `BrowserView`) and remove it when the page is ready.** It would show feedback even earlier and can be removed at any chosen signal, but it needs a second renderer, a position that follows window resizes, and an explicit removal point. Every available removal signal is either late (`did-finish-load` still waits for subresources) or a guess (`dom-ready` can precede the first paint), and a view left in place covers an interactive page. - -**Inject an overlay element into the login page with a preload script.** The one approach the decision has to keep out: it puts a shell-owned DOM layer inside a third-party page, and it would give that page a preload bridge the isolated Session deliberately withholds (`webPreferences.preload` is unset). - -**Keep the window hidden until the remote page finishes loading.** It removes the blank flash by removing the feedback, and it delays the user's first view of the page until every subresource completes. - -**Show a native splash window, or the loading text in the window title.** A second window adds a focus and lifetime problem for a two-second wait, and a title is not visible feedback inside the window. - -**Reveal the page with a fixed delay.** A timer cannot distinguish a slow origin from a fast one, and it either covers a ready page or uncovers a blank one. - -## Consequences - -The login window gives immediate local feedback that cannot depend on the network: the placeholder's CSP sets `default-src 'none'`, so the document has no origin, font, image, or connection to reach. Because the placeholder is the window's document rather than a layer, nothing about it can survive into the third-party page, and a slow subresource on the login page cannot extend it. - -The placeholder shares the login Session, so its request passes through the same document filter; the filter's exemption is name-scoped and does not widen the allowed-origin check for navigation. The window title remains the shell-owned login title (the renderer prevents `page-title-updated`), so the placeholder contributes no copy of its own. - -[policy-test-auth.spec.ts](../../../../apps/desktop/tests/policy-test-auth.spec.ts) covers the order (placeholder first, remote page only after it settles), the filter's acceptance of the placeholder, a failed placeholder load that must not fail the login, and a window closed while the placeholder is loading that never starts the remote page. [policy-login-loading.spec.ts](../../../../apps/desktop/tests/policy-login-loading.spec.ts) loads the packaged document under jsdom and checks that it renders the label it is given, leaves the label empty without one, and forbids every network source. The placeholder's rendered frames and the first-paint timing of the substituted login page remain unverified against a real login window. diff --git a/.agents/notes/implemented/architecture/2026-09-16-desktop-policy-login-loading.zh.md b/.agents/notes/implemented/architecture/2026-09-16-desktop-policy-login-loading.zh.md deleted file mode 100644 index 16de25323a..0000000000 --- a/.agents/notes/implemented/architecture/2026-09-16-desktop-policy-login-loading.zh.md +++ /dev/null @@ -1,37 +0,0 @@ -# Agent Note: 策略登录页加载期间显示本地文档 - -Status: implemented - -[English](2026-09-16-desktop-policy-login-loading.md) | 中文 - -## 问题 - -测试部署的登录窗口([强制更新客户端](../feature/2026-09-11-desktop-mandatory-update-client.zh.md))直接导航到策略 origin。在该文档提交并绘制之前,窗口是空白的,而远端页面可能需要数秒才出现。用户点击“通过飞书登录”后只看到一个空窗口,没有任何正在进行的迹象——同时窗口必须保持可关闭,也绝不能遮挡已经可交互的页面。 - -## 决策 - -[policy-test-auth.ts](../../../../apps/desktop/src/policy-test-auth.ts)把 [renderer/policy-login-loading.html](../../../../apps/desktop/renderer/policy-login-loading.html) 作为窗口的首个文档载入,并在该文档提交之后才请求策略 origin。占位页是随包发布的、自包含的文件,其中唯一的文本是主进程通过 `loadFile` 的 query 传入的文案,因此文案仍由 shell 本地化字典(`policyLoginLoading`)拥有。首个提交的远端文档会替换它。 - -占位页从不与远端页面共存,因此没有移除步骤,也没有计时器:Chromium 会一直保留占位帧,直到远端文档的首帧就绪;此后窗口归第三方页面所有。重定向链会把占位页保持到最后一个文档提交。窗口仍与之前一样在创建时即显示且可关闭。 - -只有两处按文件名识别占位页自身的加载,不识别其他任何东西:Session 的 `onBeforeRequest` 过滤器(它会取消策略与飞书 origin 之外的文档请求)以及 `did-fail-load` 处理器(主框架出错时让登录失败)。`will-navigate` 与 `will-redirect` 守卫仍然要求允许的 HTTPS origin,因此远端页面无法把窗口引回本地文件。占位页无法载入不算登录失败:登录请求照常开始,窗口与本次改动之前一样保持空白。 - -## 考虑过的替代方案 - -**用 `WebContentsView`(或 `BrowserView`)覆盖窗口,并在页面就绪时移除。** 它能更早显示反馈,也可以在任选信号处移除,但需要第二个渲染器、一个随窗口尺寸变化的位置,以及一个明确的移除时机。可用的移除信号要么偏晚(`did-finish-load` 仍会等待子资源),要么只是猜测(`dom-ready` 可能早于首帧绘制);而一旦忘记移除,视图就会遮挡可交互的页面。 - -**通过 preload 脚本向登录页注入覆盖元素。** 这是决策必须排除的做法:它把 shell 拥有的 DOM 层放进第三方页面,并且会给该页面一个隔离 Session 刻意不提供的 preload 桥(`webPreferences.preload` 未设置)。 - -**在远端页面加载完成前保持窗口隐藏。** 它通过取消反馈来消除空白闪烁,并把用户第一次看到页面的时间推迟到所有子资源完成之后。 - -**使用原生启动窗口,或把加载文本放进窗口标题。** 为一个两秒的等待增加第二个窗口会带来焦点与生命周期问题,而标题不是窗口内的可见反馈。 - -**用固定延时揭示页面。** 计时器无法区分慢 origin 与快 origin,结果要么遮挡已就绪的页面,要么在空白页面上取消遮挡。 - -## 结果 - -登录窗口给出不依赖网络的即时本地反馈:占位页的 CSP 设置了 `default-src 'none'`,因此该文档没有任何 origin、字体、图片或连接可用。由于占位页是窗口的文档而不是覆盖层,它的任何部分都不会残留到第三方页面中,登录页上缓慢的子资源也无法延长它的存在。 - -占位页共享登录 Session,因此它的请求会经过同一个文档过滤器;该过滤器的豁免按文件名限定,不会放宽导航所用的允许 origin 检查。窗口标题仍是 shell 拥有的登录标题(渲染器阻止了 `page-title-updated`),因此占位页不贡献自己的文案。 - -[policy-test-auth.spec.ts](../../../../apps/desktop/tests/policy-test-auth.spec.ts)覆盖顺序(先占位页,只有它稳定后才请求远端页面)、过滤器对占位页的接受、占位页载入失败不得导致登录失败,以及占位页加载期间关闭窗口后绝不启动远端页面。[policy-login-loading.spec.ts](../../../../apps/desktop/tests/policy-login-loading.spec.ts)在 jsdom 下载入随包发布的文档,检查它渲染传入的文案、在没有文案时保持为空,并禁止一切网络来源。占位页的实际渲染帧与替代登录页的首帧时机仍未在真实登录窗口上验证。 diff --git a/.agents/notes/implemented/architecture/2026-09-16-nested-tooltip-suppression.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-16-nested-tooltip-suppression.i18n.yaml deleted file mode 100644 index 7079d4ec79..0000000000 --- a/.agents/notes/implemented/architecture/2026-09-16-nested-tooltip-suppression.i18n.yaml +++ /dev/null @@ -1,6 +0,0 @@ -# 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-16-nested-tooltip-suppression.md -2026-09-16-nested-tooltip-suppression.md: 1b06a3a1a4f754aa78e821ee8a1479dab19873ff -2026-09-16-nested-tooltip-suppression.zh.md: 96063f78b281deb3561045bb73fcffa64a2c1ef8 diff --git a/.agents/notes/implemented/architecture/2026-09-16-nested-tooltip-suppression.md b/.agents/notes/implemented/architecture/2026-09-16-nested-tooltip-suppression.md deleted file mode 100644 index 1b06a3a1a4..0000000000 --- a/.agents/notes/implemented/architecture/2026-09-16-nested-tooltip-suppression.md +++ /dev/null @@ -1,35 +0,0 @@ -# Agent Note: Suppress an enclosing tooltip while a nested one is shown - -Status: implemented - -English | [中文](2026-09-16-nested-tooltip-suppression.zh.md) - -## Problem - -The collapsed sidebar renders its update badge through the `sidebar.toggle.badge` slot inside the expand button, and both the button and the badge attach a `Tooltip`. Hovering the badge showed two bubbles at once — the version notice and "Open sidebar" — because the badge is a DOM descendant of the button: the button's hover state stays true for as long as the pointer rests on the badge, and each tooltip correctly follows its own anchor. - -## Decision - -[Tooltip](../../../../packages/client/ui-primitives/src/Tooltip.tsx) provides a suppression setter through an internal `TooltipSuppression` context wrapped around its cloned anchor and its bubble. A nested tooltip consumes the nearest setter and announces whenever its own bubble becomes visible. An enclosing tooltip that receives a suppression claim keeps its hover/focus triggers and its measured position but does not render its bubble, so the enclosing bubble returns in the same commit that the nested one disappears. - -The nested tooltip announces synchronously inside `show()` and through a `visible` effect. The synchronous call keeps a nested pair that becomes visible in one React commit from painting both bubbles for a frame; the effect releases the claim on every other path that hides a bubble, including unmount and a `disabled` flip. - -No component in `ui-sidebar` or `ui-settings-general` changed: the badge already renders inside the toggle's anchor, so the shared primitive resolves the overlap for any future nested pair as well. - -## Alternatives considered - -**Disable the toggle tooltip whenever an update exists.** The simplest guard, and wrong: it removes "Open sidebar" from ordinary rail hovers for the whole time an update is available, including hovers that never touch the badge. - -**Disable the toggle tooltip while the pointer is over the badge.** Hover-scoped, but `Tooltip` clears its hover and focus triggers when it is disabled, and no new `mouseenter` fires on the button while the pointer moves from the badge onto the button's own area. The enclosing bubble would stay gone until the pointer left and re-entered the button. - -**Stop the badge's `mouseenter` from reaching the button.** It only helps a pointer that enters the badge directly; a pointer that enters the button first and then moves onto the badge already has the enclosing bubble visible, because the enclosing hover never ended. - -**Render the badge outside the toggle's anchor.** The slot is declared inside the toggle button, and the badge must sit on the button's corner; moving the anchor out of the button would either detach the bubble from the control or change the slot's declared placement. - -## Consequences - -The rule is a property of tooltip nesting, not of the update badge: any tooltip whose anchor contains another tooltip now yields the bubble to the innermost visible one. A tooltip with no nested tooltip is unaffected — it consumes a null context and never announces. - -The enclosing tooltip's bubble is withdrawn only while a descendant is visible; its trigger state survives, so no re-entry is needed to restore it. Suppression does not change what the anchors do: the button still toggles the sidebar and the badge remains a non-interactive marker. - -[Tooltip tests](../../../../packages/client/ui-primitives/tests/tooltip.client.spec.tsx) cover withdrawal, restoration when the nested anchor is left for the enclosing one with a real `relatedTarget`, and release when a shown nested tooltip unmounts. The [sidebar shell test](../../../../packages/client/ui-sidebar/tests/sidebar-root.client.spec.tsx) covers the product wiring: the rail badge replaces the toggle bubble after the toggle's own hover delay has already elapsed. diff --git a/.agents/notes/implemented/architecture/2026-09-16-nested-tooltip-suppression.zh.md b/.agents/notes/implemented/architecture/2026-09-16-nested-tooltip-suppression.zh.md deleted file mode 100644 index 96063f78b2..0000000000 --- a/.agents/notes/implemented/architecture/2026-09-16-nested-tooltip-suppression.zh.md +++ /dev/null @@ -1,35 +0,0 @@ -# Agent Note: 嵌套提示显示时抑制外层提示 - -Status: implemented - -[English](2026-09-16-nested-tooltip-suppression.md) | 中文 - -## 问题 - -侧边栏收起时,更新蓝点通过 `sidebar.toggle.badge` 插槽渲染在展开按钮内部,而按钮和蓝点各自挂了一个 `Tooltip`。hover 蓝点会同时出现两个气泡——版本提示和“打开侧边栏”——因为蓝点是按钮的 DOM 后代:只要指针停在蓝点上,按钮的 hover 状态就保持为真,而两个 tooltip 都在正确地跟随各自的 anchor。 - -## 决策 - -[Tooltip](../../../../packages/client/ui-primitives/src/Tooltip.tsx)通过内部 `TooltipSuppression` context 提供一个抑制 setter,并把该 context 包在克隆后的 anchor 与气泡外层。嵌套的 tooltip 消费最近的 setter,并在自己的气泡可见时上报。收到抑制声明后,外层 tooltip 保留 hover/focus 触发状态与已测量的位置,但不渲染自己的气泡,因此嵌套气泡消失的同一提交里外层气泡就会回来。 - -嵌套 tooltip 既在 `show()` 中同步上报,也通过 `visible` effect 上报。同步调用避免在同一 React 提交中同时可见的一对工具提示多绘制一帧的两个气泡;effect 则在其他所有隐藏气泡的路径上释放抑制,包括卸载和 `disabled` 翻转。 - -`ui-sidebar` 与 `ui-settings-general` 中的组件无需改动:蓝点本就渲染在 toggle 的 anchor 内,因此共享 primitive 也顺带解决了未来任何嵌套组合。 - -## 考虑过的替代方案 - -**只要存在更新就禁用 toggle 的 tooltip。** 这是最简单的防护,但也是错的:它会在更新可用的整个期间移除普通 rail hover 的“打开侧边栏”,包括根本不会碰到蓝点的 hover。 - -**指针位于蓝点上时禁用 toggle 的 tooltip。** 作用域正确,但 `Tooltip` 被禁用时会清空自己的 hover 与 focus 触发状态,而指针从蓝点移到按钮自身区域时按钮不会再触发新的 `mouseenter`。外层气泡会一直消失,直到指针离开按钮并重新进入。 - -**阻止蓝点的 `mouseenter` 传播到按钮。** 这只能帮助直接从蓝点进入的指针;先进入按钮再移到蓝点上的指针,其外层气泡早已可见,因为外层的 hover 从未结束过。 - -**把蓝点渲染到 toggle 的 anchor 之外。** 该插槽声明在 toggle 按钮内部,蓝点必须落在按钮角上;把 anchor 移出按钮要么让气泡脱离控件,要么改变插槽声明的放置位置。 - -## 结果 - -这条规则是 tooltip 嵌套的性质,而不是更新蓝点的性质:任何 anchor 内含另一个 tooltip 的 tooltip 现在都会把气泡让给最内层可见的那个。没有嵌套 tooltip 的 tooltip 不受影响——它消费到的是 null context,永远不会上报。 - -外层 tooltip 仅在子级可见期间收回气泡;它的触发状态得以保留,因此不需要重新进入即可恢复。抑制不改变 anchor 的行为:按钮仍然切换侧边栏,蓝点仍然是非交互标记。 - -[Tooltip 测试](../../../../packages/client/ui-primitives/tests/tooltip.client.spec.tsx)覆盖收回、用真实 `relatedTarget` 从嵌套 anchor 移到外层 anchor 时的恢复,以及已显示的嵌套 tooltip 卸载时的释放。[侧边栏 shell 测试](../../../../packages/client/ui-sidebar/tests/sidebar-root.client.spec.tsx)覆盖产品接线:在 toggle 自身的 hover 延迟已经过去之后,rail 蓝点气泡仍替换掉 toggle 气泡。 diff --git a/.agents/notes/implemented/feature/2026-09-04-web-clickable-link-styles.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-18-agent-teams-single-bundle.i18n.yaml similarity index 57% rename from .agents/notes/implemented/feature/2026-09-04-web-clickable-link-styles.i18n.yaml rename to .agents/notes/implemented/architecture/2026-09-18-agent-teams-single-bundle.i18n.yaml index 11220477c5..a005e6d029 100644 --- a/.agents/notes/implemented/feature/2026-09-04-web-clickable-link-styles.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-09-18-agent-teams-single-bundle.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 .agents/notes/implemented/feature/2026-09-04-web-clickable-link-styles.md -2026-09-04-web-clickable-link-styles.md: eddceb8af8b39b24ebf85870b893ecd4d7939c96 -2026-09-04-web-clickable-link-styles.zh.md: dbb2c9ac34605e45ef0f404628ac710384983b57 +# 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 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 new file mode 100644 index 0000000000..21878a8e48 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-18-agent-teams-single-bundle.md @@ -0,0 +1,33 @@ +# Agent Note: Enable Agent Teams with one bundle + +Status: implemented + +English | [中文](2026-09-18-agent-teams-single-bundle.zh.md) + +## Problem + +Separate Agent Teams and Agent Teams Web switches require users to discover that tools and their browser controls need both selections. The package split exposes composition details in the plugin page without helping users choose a different Team capability. + +## Decision + +`@deepseek-ai/dsh-experimental-agent-team-profile` carries the Team service, tools, and browser UI in one optional bundle. Its patch retains the `agent-team`, `tool-agent-team`, and `ui-agent-team` row ids, so profile patches can still configure individual rows. The UI package has an inert Host entry; its browser entry mounts only in a Web Client, and headless does not start a Web server. + +The separate `@deepseek-ai/dsh-experimental-agent-team-web-profile` package is absent from the workspace and release family. The plugin page exposes one Team selection, disabled by default. + +This decision supersedes the separate Host/Web composition in the [package publication note](2026-08-18-experimental-agent-teams-packages.md) and the two Team selections in the [optional-bundle note](../process/2026-09-15-shipped-optional-bundles.md). Both remain active for publication, dependency isolation, promotion, and installation ownership. The [archived Web-controls note](../../archived/feature/2026-08-06-agent-teams-web.md) records the original split; its historical text remains frozen. + +## Alternatives considered + +**Keep two switches and explain their dependency.** Users still need two selections for one feature, and either selection alone leaves an incomplete browser experience. + +**Combine the entries in the generic profile loader or plugin manager.** Recognizing Team package names there would make shared loading or presentation code own feature-specific composition. The bundle patch already declares the required rows and dependencies. + +## Consequences + +Users enable tools and browser controls together. Separate installation-level selection of a UI-only Team bundle is unavailable; custom profiles still control individual patch rows. Headless installations include the UI dependency graph even though they do not mount its browser entry. + +Existing profiles that select the removed Web bundle have an upgrade compatibility gap: startup fails when that package cannot be resolved. Bundle composition provides no automatic rewrite of those saved selections. Compatibility handling remains separate from this composition decision. + +## 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. 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 new file mode 100644 index 0000000000..39c6bfb7b3 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-18-agent-teams-single-bundle.zh.md @@ -0,0 +1,33 @@ +# Agent Note: 通过一个组合包启用 Agent Teams + +Status: implemented + +[English](2026-09-18-agent-teams-single-bundle.md) | 中文 + +## 问题 + +独立的 Agent Teams 与 Agent Teams Web 开关要求用户自行发现:工具与浏览器控件需要同时选中两项。包的拆分把组合细节暴露在插件页上,却没有帮助用户选择不同的 Team 能力。 + +## 决策 + +`@deepseek-ai/dsh-experimental-agent-team-profile` 在一个可选组合包中携带 Team 服务、工具与浏览器 UI。其 patch 保留 `agent-team`、`tool-agent-team` 与 `ui-agent-team` 配置行 id,因此 profile patch 仍可单独配置各行。UI 包的 Host 入口不执行逻辑;其浏览器入口只在 Web 客户端中挂载,headless 不会启动 Web 服务。 + +独立的 `@deepseek-ai/dsh-experimental-agent-team-web-profile` 包不在 workspace 与发布系列中。插件页提供一个 Team 选项,默认关闭。 + +本决策取代[包发布记录](2026-08-18-experimental-agent-teams-packages.zh.md)中的独立 Host/Web 组合方式,以及[可选组合包记录](../process/2026-09-15-shipped-optional-bundles.zh.md)中的两个 Team 选项。两条记录仍负责发布、依赖隔离、promotion 与安装方所有权。[已归档的 Web 控件记录](../../archived/feature/2026-08-06-agent-teams-web.md)记载原始拆分方式,其历史文本保持冻结。 + +## 曾考虑的替代方案 + +**保留两个开关并解释依赖关系。** 用户仍须为一个功能选中两项,单独选中任意一项都不能提供完整的浏览器体验。 + +**在通用 profile loader 或插件管理器中合并入口。** 在这些位置识别 Team 包名会让共用的加载或展示代码负责功能特定的组合。组合包 patch 已能声明所需配置行与依赖。 + +## 后果 + +用户同时启用工具与浏览器控件。安装层面不再提供仅选择 Team UI 组合包的入口;自定义 profile 仍能单独控制 patch 配置行。headless 安装包含 UI 依赖图,但不会挂载其浏览器入口。 + +选中了已移除 Web 组合包的已有 profile 存在升级兼容性缺口:无法解析该包时,启动会失败。组合包本身不会自动改写这些已保存的选择。兼容性处理与本组合决策分开。 + +## 验证 + +插件管理器浏览器测试观察一次开关后 Team 控件出现,通过 Remote 读取成员列表与任务看板,并观察关闭组合包后控件移除。Team 面板浏览器测试覆盖任务创建;构建后的 headless CLI 测试覆盖无需 Web 服务的委派。这些场景不覆盖仍选中已移除 Web 组合包的 profile 升级。 diff --git a/.agents/notes/implemented/feature/2026-09-09-turn-duration-hour-unit.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-18-chat-navigation-performance.i18n.yaml similarity index 56% rename from .agents/notes/implemented/feature/2026-09-09-turn-duration-hour-unit.i18n.yaml rename to .agents/notes/implemented/architecture/2026-09-18-chat-navigation-performance.i18n.yaml index 209fc7e492..143d9ccffe 100644 --- a/.agents/notes/implemented/feature/2026-09-09-turn-duration-hour-unit.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-09-18-chat-navigation-performance.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 .agents/notes/implemented/feature/2026-09-09-turn-duration-hour-unit.md -2026-09-09-turn-duration-hour-unit.md: f0f5cb478dafa7938614bdeeec3a6de9f58aed8d -2026-09-09-turn-duration-hour-unit.zh.md: c9c94eef2a88a0f58d5f5a05332b4b5034ef977a +# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-18-chat-navigation-performance.md +2026-09-18-chat-navigation-performance.md: d990c0cb9a351ea3b3224ce5feec912c176a5fc9 +2026-09-18-chat-navigation-performance.zh.md: 1a330d3b6bf6357199eb09ffc1cdb434cda1c1af diff --git a/.agents/notes/implemented/architecture/2026-09-18-chat-navigation-performance.md b/.agents/notes/implemented/architecture/2026-09-18-chat-navigation-performance.md new file mode 100644 index 0000000000..d990c0cb9a --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-18-chat-navigation-performance.md @@ -0,0 +1,59 @@ +# Agent Note: Bound turn navigation work and batch historical jumps + +Status: implemented + +English | [中文](2026-09-18-chat-navigation-performance.zh.md) + +## Problem + +A long Session makes three independent costs visible: a turn rail with one DOM mark per known turn, transcript navigation that rediscovers an already known landing, and historical jumps that publish every fetched page. Each intermediate prepend rebuilds growing conversation indexes and exposes another transcript update before the requested turn is available. Combining these concerns into one controller obscures which operations require DOM geometry and which only apply reading or paging policy. + +## Decision + +### Fixed-pitch virtual rail + +[TurnNavigator](../../../../packages/client/ui-chat/src/client/chat/TurnNavigator.tsx) uses TanStack React Virtual with a fixed 10px pitch. Only the visible range, overscan, and the focused mark with its neighbors remain mounted. The library owns the size container and absolute mark transforms. The full turn metadata remains resident; this is rail virtualization, not transcript virtualization. + +The rail uses ResizeObserver-delivered viewport dimensions and fixed item sizes. Its offset calculation clamps against cached geometry and invokes the supported `scrollBy` operation, avoiding the library's DOM-based maximum-offset calculation used by `scrollToIndex`. The first usable size places the latest committed active turn directly after the body has restored its reading state. A ref exposes independent activation and rail-only scrolling. Native pointer targets replace coordinate hit testing, and mark length changes use transforms. Layout containment belongs to the floating rail frame; it does not promise that browser geometry reads flush only that subtree. + +### Two-layer transcript navigation + +[ChatViewport](../../../../packages/client/ui-chat/src/client/chat/use-chat-viewport.ts) owns DOM reads, clamped writes, native events, and turn anchors. It returns the actual landing with available turn and semantic-position facts. Reading policy consumes those facts without another hit test; the viewport ignores its own unchanged scroll echoes and events from the nested rail. + +[ChatReading](../../../../packages/client/ui-chat/src/client/chat/use-chat-reading.ts) owns follow-tail policy, semantic restoration, and sampled reader movement. [ChatNavigation](../../../../packages/client/ui-chat/src/client/chat/use-chat-navigation.ts) owns loaded or unloaded jumps, paging anchors, replacement tasks, and fallback. [useChatScroll](../../../../packages/client/ui-chat/src/client/chat/use-chat-scroll.ts) supplies committed inputs and coordinates these owners. Timer and animation-frame completion invoke the owners directly instead of incrementing React tick state. The [pinned-delivery rule](../../../../packages/client/ui-chat/README.md#scroll-ownership) remains independently necessary: small reader gestures cannot be erased by intervening layout growth. + +### One publication per historical jump + +[Session.loadThrough](../../../../packages/api/session-controller/src/client/sessions/session.ts) retains accepted pages and a private paging cursor until its shared target is covered or loading ends. It reverses the page list and flattens it once, then publishes one ordered prepend. Live events continue through their normal publication path. A later page failure publishes the successful prefix once; stream or window replacement discards obsolete buffered pages. Single-page `loadOlder` remains immediate, and repeated jump requests retain the existing lowest-target policy. + +### Theoretical work model + +Let `T` be known turns, `V` mounted virtual marks, `P` fetched pages, `B` entries per page, and `W0` entries already loaded. These are work-count estimates, not timing measurements. + +| Operation | Previous work | Current work | +|---|---|---| +| Rail DOM and React mark traversal | `O(T)` | `O(V)`; turn metadata and initial index/cache construction remain `O(T)` | +| Active-turn index lookup with unchanged items | Linear search, `O(T)` | Map lookup, expected `O(1)` | +| Rail target-offset calculation | Synchronous DOM extent/viewport reads | Fixed arithmetic and cached sizes; no DOM extent read on this path | +| Known transcript jump | Landing followed by position rediscovery | Reuse the landing; no hit test, but existing anchor-selector and turn lookup costs remain | +| History-driven linear index passes | `O(P * W0 + B * P²)` | `O(W0 + B * P)` for one final pass, plus `O(B * P)` buffer flattening | +| History-driven sorting | Repeated sorting of each growing window | One final-window sort, `O((W0 + B * P) log(W0 + B * P))` | +| Historical prepend publications | `P` | One; live-event and loading-state updates remain independent | + +For illustration, 10,000 turns and about 50 mounted marks imply roughly 99.5% fewer rail marks, not a 200× end-to-end speedup. With 20 equal pages and a negligible initial window, a full linear pass after each page visits `210B` entries in aggregate; one final pass visits `20B`, about 10.5× less work for that pass. Page requests remain 20, and the final transcript still needs assembly, rendering, and layout. + +## Alternatives considered + +**Containment or React batching alone.** Containment does not remove repeated JavaScript work. Suppressing React renders after each published page still leaves conversation assembly and index rebuilds upstream. + +**Patch the virtualizer.** The supported callbacks and relative-scroll operation cover fixed-height placement without maintaining a dependency fork. The native scroll container still needs a total extent; removing that extent would remove native scrolling, not virtualize it. + +**One large scrolling controller.** It centralizes state but couples DOM mechanics to follow and paging policy. Separate owners allow business changes without copying geometry operations. + +**Virtualize the whole transcript.** That introduces renderer lifetime and semantic-anchor constraints outside this change. Batching the requested historical jump removes intermediate publications without changing transcript residency. + +## Consequences + +Loading a distant turn retains the current transcript until the batch lands, instead of revealing intermediate pages. Buffered pages add references proportional to newly fetched entries. Main-transcript geometry reads, semantic-anchor queries, ordinary native-scroll metrics, final layout, and center-width reflow during Sidebar animation remain; none is claimed eliminated by this change. Transform and color changes may still repaint, and assigning `scrollTop` may still deliver a native scroll event. + +[Rail tests](../../../../packages/client/ui-chat/tests/turn-navigator.client.spec.tsx) cover bounded mounting, first placement, fixed-size controls, and focus retention. [Chat tests](../../../../packages/client/ui-chat/tests/chat-view.client.spec.tsx) cover semantic restoration, follow attribution, own-scroll echoes, and navigation without hit testing. [Session tests](../../../../packages/api/session-controller/tests/session.client.spec.ts) cover atomic prepend publication with live delivery, failure, and replacement. [Browser navigation](../../../../apps/web/tests/chat-scroll-contract.e2e.ts) covers real scrolling and unloaded-turn landing. Functional evidence is separate from the theoretical estimates above. diff --git a/.agents/notes/implemented/architecture/2026-09-18-chat-navigation-performance.zh.md b/.agents/notes/implemented/architecture/2026-09-18-chat-navigation-performance.zh.md new file mode 100644 index 0000000000..1a330d3b6b --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-18-chat-navigation-performance.zh.md @@ -0,0 +1,59 @@ +# Agent Note: 限制轮次导航工作量并聚合历史跳转 + +Status: implemented + +[English](2026-09-18-chat-navigation-performance.md) | 中文 + +## Problem + +长会话会放大三类独立成本:轮次轨道为每个已知轮次挂载一个 DOM 刻度,正文导航重新查找已知落点,以及历史跳转逐页发布数据。每次中间前插都会重建不断增大的对话索引,并在目标轮次可用前再次更新正文。把这些职责放进一个控制器,会使需要 DOM 几何的操作与只负责阅读、分页策略的操作难以区分。 + +## Decision + +### 固定间距虚拟轨道 + +[TurnNavigator](../../../../packages/client/ui-chat/src/client/chat/TurnNavigator.tsx) 使用 TanStack React Virtual,间距固定为 10px。只保留可见范围、预加载项,以及键盘焦点刻度及其相邻项。库负责尺寸容器和绝对定位刻度的变换。全部轮次元数据仍驻留内存;这里虚拟化的是轨道,不是正文。 + +轨道采用 ResizeObserver 提供的视口尺寸和固定条目高度。偏移计算根据缓存几何限制范围,再调用库支持的 `scrollBy`,避开 `scrollToIndex` 使用的基于 DOM 的最大偏移计算。正文恢复阅读状态后,轨道在首次获得可用尺寸时直接定位到最新已提交的活跃轮次。ref 分别提供激活轮次和仅滚动轨道的操作。原生指针目标替代坐标命中测试,刻度长度通过变换改变。布局包含作用于悬浮轨道外框,但不承诺浏览器几何读取只刷新该子树。 + +### 两层正文导航 + +[ChatViewport](../../../../packages/client/ui-chat/src/client/chat/use-chat-viewport.ts) 负责 DOM 读取、限制范围后的写入、原生事件和轮次锚点。它返回实际落点及可用的轮次、语义位置信息。阅读策略直接消费这些信息,无需再次命中测试;视口会忽略自身位置未变的滚动回声,以及内嵌轨道发出的事件。 + +[ChatReading](../../../../packages/client/ui-chat/src/client/chat/use-chat-reading.ts) 负责跟随底部、语义恢复和读者移动采样。[ChatNavigation](../../../../packages/client/ui-chat/src/client/chat/use-chat-navigation.ts) 负责已加载或未加载轮次跳转、分页锚点、任务替换和回退。[useChatScroll](../../../../packages/client/ui-chat/src/client/chat/use-chat-scroll.ts) 提供已提交输入并协调各对象。定时器与动画帧完成时直接调用负责对象,不再递增 React tick 状态。[贴底事件规则](../../../../packages/client/ui-chat/README.zh.md#scroll-ownership) 仍有独立必要性:期间发生的布局增长不能抵消读者的小幅操作。 + +### 每次历史跳转统一发布 + +[Session.loadThrough](../../../../packages/api/session-controller/src/client/sessions/session.ts) 在共享目标被覆盖或加载结束前,保留已接受的页面和私有分页游标。它只反转一次页面列表并展开一次,然后发布一次有序前插。实时事件继续走正常发布路径。后续页面失败时,已成功取得的部分统一发布一次;流或窗口被替换时丢弃失效的暂存页面。单页 `loadOlder` 仍立即发布,重复跳转请求保留既有的最低目标策略。 + +### 理论工作量模型 + +设 `T` 为已知轮次数,`V` 为已挂载虚拟刻度数,`P` 为获取页数,`B` 为每页条目数,`W0` 为原先已加载条目数。以下估算工作量,不代表耗时测量。 + +| 操作 | 原有工作量 | 当前工作量 | +|---|---|---| +| 轨道 DOM 与 React 刻度遍历 | `O(T)` | `O(V)`;轮次元数据与初始索引、缓存构建仍为 `O(T)` | +| 条目未变时查找活跃轮次索引 | 线性搜索,`O(T)` | Map 查找,期望 `O(1)` | +| 轨道目标偏移计算 | 同步读取 DOM 总高度、视口尺寸 | 固定算术与缓存尺寸;此路径不读取 DOM 滚动总高度 | +| 已知正文轮次跳转 | 定位后重新查找位置 | 复用落点;不做命中测试,但既有锚点选择器与轮次查找成本仍在 | +| 历史驱动的线性索引遍历 | `O(P * W0 + B * P²)` | 最终一次遍历为 `O(W0 + B * P)`,另有 `O(B * P)` 暂存展开 | +| 历史驱动的排序 | 对每个不断增大的窗口重复排序 | 对最终窗口排序一次,`O((W0 + B * P) log(W0 + B * P))` | +| 历史前插发布次数 | `P` | 一次;实时事件与加载状态更新仍独立发生 | + +例如,10,000 个轮次只挂载约 50 个刻度,意味着轨道刻度约减少 99.5%,不代表端到端加速 200 倍。若获取 20 个等大页面且初始窗口可忽略,每页到达后做一次全量线性遍历,累计访问 `210B` 个条目;最终只遍历一次访问 `20B`,该遍历工作量约减少到原来的 1/10.5。分页请求仍为 20 次,最终正文仍需装配、渲染和布局。 + +## Alternatives considered + +**只加布局包含或 React 批处理。** 布局包含不会删除重复的 JavaScript 工作。仅抑制每页发布后的 React 渲染,也会保留上游对话装配与索引重建。 + +**修改虚拟化库。** 现有回调和相对滚动操作已能完成固定高度定位,无需维护依赖分支。原生滚动容器仍需总高度;删除总高度会使原生滚动失效,而不是实现虚拟化。 + +**单一大滚动控制器。** 它能集中状态,却把 DOM 操作与跟随、分页策略耦合。拆分负责对象后,业务变更无需复制几何操作。 + +**虚拟化整个正文。** 这会引入本次范围之外的 renderer 生命周期与语义锚点约束。聚合目标历史跳转即可删除中间发布,无需改变正文驻留方式。 + +## Consequences + +跳转远处轮次时,当前正文会保持到整批数据落地,不再逐页展示中间结果。暂存页面增加与新获取条目数成正比的引用。正文几何读取、语义锚点查询、普通原生滚动指标、最终布局,以及侧栏动画改变中间区域宽度时的重排仍然存在;本次不声称消除了这些成本。变换与颜色变化仍可能重绘,赋值 `scrollTop` 仍可能产生原生滚动事件。 + +[轨道测试](../../../../packages/client/ui-chat/tests/turn-navigator.client.spec.tsx) 覆盖有界挂载、首次定位、固定尺寸控制与焦点保留。[Chat 测试](../../../../packages/client/ui-chat/tests/chat-view.client.spec.tsx) 覆盖语义恢复、跟随归因、自身滚动回声与不做命中测试的导航。[Session 测试](../../../../packages/api/session-controller/tests/session.client.spec.ts) 覆盖实时事件并发下的原子前插发布、失败与替换。[浏览器导航](../../../../apps/web/tests/chat-scroll-contract.e2e.ts) 覆盖真实滚动与未加载轮次落点。功能证据与上述理论估算相互独立。 diff --git a/.agents/notes/implemented/bug-fix/2026-09-03-resume-headers-do-not-repeat-system-prompts.md b/.agents/notes/implemented/bug-fix/2026-09-03-resume-headers-do-not-repeat-system-prompts.md deleted file mode 100644 index de5e509c9e..0000000000 --- a/.agents/notes/implemented/bug-fix/2026-09-03-resume-headers-do-not-repeat-system-prompts.md +++ /dev/null @@ -1,31 +0,0 @@ -# Agent Note: Resume headers do not repeat system prompts - -Status: implemented - -English | [中文](2026-09-03-resume-headers-do-not-repeat-system-prompts.zh.md) - -## Problem - -Forking a Session copies the source history into the child. The child's first model request then records a `request/header` with reason `resume`, even when its system field is identical to the preceding copied header. Chat treated every resume header as a new display point, so continuing the fork showed a second `System prompt` row and suggested that the system prompt had been injected twice. The provider request still carried the system field once; the duplicate existed only in Chat presentation. - -## Decision - -The durable resume header records the request boundary needed for exact Session reconstruction. Chat compares that full header with the preceding loaded Request Prompt and displays a non-empty system prompt only for the initial request, an explicit message-series start, or a real system-field change. An unchanged resume does not create a visible repetition. - -A partial history window may begin with a non-initial header and lack the predecessor needed for comparison. Chat renders that system prompt conservatively. If prepend later supplies an identical predecessor, the existing request-prompt Node becomes hidden instead of being withdrawn; its key and page-lifetime anchor stay stable. A different system field remains visible. - -Trajectory exposes every request header and its classified changes. Chat presentation does not alter provider requests, Session events, or reconstruction. - -## Alternatives considered - -**Omit unchanged resume headers from the Session log.** Rejected: resume is a real request boundary, and removing it would make exact reconstruction depend on process history that the durable log does not contain. - -**Special-case only forked Sessions.** Rejected: an ordinary process resume has the same presentation semantics, and the request headers already contain the system fields needed for a direct comparison. - -**Keep the duplicate row as a lifecycle marker.** Rejected: `System prompt` describes model-visible request content, so using it to mark a loop restart incorrectly implies another prompt injection. Request lifecycle evidence remains available in Trajectory. - -## Consequences - -Continuing a fork or resuming a process with an unchanged system field leaves one visible `System prompt` row for the current message series. Explicit series starts and real system changes still repeat the row. A partial window can initially show a conservative row and hide it after older history loads, while retaining the same materialized Node. - -The unit regression covers initial, series, unchanged resume, system-change, and prepend cases. The Web recorded-session scenario contains an unchanged resume header and asserts that the settled Chat renders exactly one `System prompt` control. diff --git a/.agents/notes/implemented/bug-fix/2026-09-03-resume-headers-do-not-repeat-system-prompts.zh.md b/.agents/notes/implemented/bug-fix/2026-09-03-resume-headers-do-not-repeat-system-prompts.zh.md deleted file mode 100644 index 008e25d8d5..0000000000 --- a/.agents/notes/implemented/bug-fix/2026-09-03-resume-headers-do-not-repeat-system-prompts.zh.md +++ /dev/null @@ -1,31 +0,0 @@ -# Agent Note: Resume header 不重复系统提示词 - -Status: implemented - -[English](2026-09-03-resume-headers-do-not-repeat-system-prompts.md) | 中文 - -## 问题 - -fork Session 会把源会话历史复制到子会话。即使 system 字段与前一条被复制的 header 完全相同,子会话的第一个模型请求仍会记录一条 reason 为 `resume` 的 `request/header`。Chat 把每条 resume header 都视作新的展示点,因此继续 fork 会显示第二行`系统提示词`,让人误以为系统提示词被注入了两次。提供方请求实际仍只携带一次 system 字段;重复仅存在于 Chat 展示中。 - -## 决策 - -持久化 resume header 记录精确重建 Session 所需的请求边界。Chat 会把完整 header 与前一条已加载 Request Prompt 比较,只在初始请求、显式消息序列起点或真实 system 字段变化时显示非空系统提示词。内容未变的 resume 不创建可见的重复行。 - -部分历史窗口可能以非初始 header 开头,因缺少前序 header 而无法比较。Chat 会保守渲染该系统提示词。如果 prepend 随后补入相同的前序 header,既有 request-prompt Node 会转为隐藏而不是被撤回;其 key 和页面生命周期内的 anchor 保持稳定。不同的 system 字段仍然可见。 - -Trajectory 会展示每一条请求 header 及其变化分类。Chat 展示不会改变提供方请求、Session event 或重建行为。 - -## 考虑过的替代方案 - -**从 Session log 省略未变化的 resume header。** 否决:resume 是真实的请求边界,移除后精确重建将依赖持久日志未记录的进程历史。 - -**只对 fork Session 做特殊处理。** 否决:普通进程恢复具有相同的展示语义,请求 header 已经包含可直接比较的 system 字段。 - -**把重复行保留为生命周期标记。** 否决:`系统提示词`描述模型可见的请求内容,用它标记 loop 重启会错误暗示再次注入提示词。请求生命周期证据仍可在 Trajectory 中查看。 - -## 后果 - -继续 fork 或在 system 字段未变时恢复进程,当前消息序列只保留一行可见的`系统提示词`。显式序列起点与真实 system 变化仍会重复该行。部分窗口起初可以显示保守行,并在更早历史加载后将其隐藏,同时保留同一个已物化 Node。 - -单元回归覆盖初始请求、显式序列、未变化 resume、system 变化与 prepend 场景。Web 录制会话场景包含一条未变化的 resume header,并断言稳定后的 Chat 只渲染一个`系统提示词`控件。 diff --git a/.agents/notes/implemented/bug-fix/2026-09-07-pinned-scroll-delivery-before-layout.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-09-07-pinned-scroll-delivery-before-layout.i18n.yaml deleted file mode 100644 index 5df3dbdf86..0000000000 --- a/.agents/notes/implemented/bug-fix/2026-09-07-pinned-scroll-delivery-before-layout.i18n.yaml +++ /dev/null @@ -1,6 +0,0 @@ -# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each -# side as of the last confirmed-consistent state. Both languages carry equal authority; -# after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-09-07-pinned-scroll-delivery-before-layout.md -2026-09-07-pinned-scroll-delivery-before-layout.md: f3b428dbe0d2fdc7cbaedf0383d2ab80ea1bfd45 -2026-09-07-pinned-scroll-delivery-before-layout.zh.md: 9b200f45535c67cfbbb75feaf3025c5a3b5c1fa2 diff --git a/.agents/notes/implemented/bug-fix/2026-09-07-pinned-scroll-delivery-before-layout.md b/.agents/notes/implemented/bug-fix/2026-09-07-pinned-scroll-delivery-before-layout.md deleted file mode 100644 index f3b428dbe0..0000000000 --- a/.agents/notes/implemented/bug-fix/2026-09-07-pinned-scroll-delivery-before-layout.md +++ /dev/null @@ -1,23 +0,0 @@ -# Agent Note: Settle pinned scroll deliveries before layout changes - -Status: implemented - -English | [中文](2026-09-07-pinned-scroll-delivery-before-layout.zh.md) - -## Problem - -A delayed scroll sample compares positions from different layouts. While Chat is pinned, a composer or transcript shrink can move the browser floor; subsequent growth can move the browser position again before `scrollend` or the sampling timer. Deferring follow during that interval leaves the observed-top ledger stale and can classify browser layout movement as reader input, disabling follow without a reader gesture. - -## Decision - -[ChatView](../../../../packages/client/ui-chat/src/client/chat/ChatView.tsx) uses the existing observed-top comparison to sample non-reader pinned scroll deliveries synchronously through the same sample operation that clears pending work. This releases layout follow before further growth. Genuine reader movement remains pending until the existing interval or `scrollend`, even within the follow threshold: growth must not erase small gestures before they accumulate into a scroll-away. Immediate pinned samples use scroll metrics, not semantic-row geometry. - -## Alternatives considered - -**Defer every delivery.** Coalescing reduces geometry work while reading history, but a pinned browser position and its floor must be attributed in the same layout. A longer timeout or retry cannot recover ownership once the stale comparison disarms it. - -**Sample every delivery synchronously.** This restores attribution but also repeats semantic-anchor and reading-line measurements throughout an away-reader scroll burst. Only pinned ownership needs the immediate path. - -## Consequences - -Pinned deliveries incur immediate scroll-metric reads. History reading retains its bounded sampling cadence, and explicit return-to-bottom deliveries clear any pending away sample. [Focused tests](../../../../packages/client/ui-chat/tests/chat-view.client.spec.tsx) cover shrink/regrowth before scrollend, observer growth without row measurements, repinning with a pending sample, timer and scrollend sampling, and unmount cancellation. The [keyless browser scenario](../../../../apps/web/tests/chat-scroll-contract.e2e.ts) covers pinned Send, real scroll-away input, streaming, and tool disclosure across the long transcript. diff --git a/.agents/notes/implemented/bug-fix/2026-09-07-pinned-scroll-delivery-before-layout.zh.md b/.agents/notes/implemented/bug-fix/2026-09-07-pinned-scroll-delivery-before-layout.zh.md deleted file mode 100644 index 9b200f4553..0000000000 --- a/.agents/notes/implemented/bug-fix/2026-09-07-pinned-scroll-delivery-before-layout.zh.md +++ /dev/null @@ -1,23 +0,0 @@ -# Agent Note: 在布局变化前处理贴底滚动事件 - -Status: implemented - -[English](2026-09-07-pinned-scroll-delivery-before-layout.md) | 中文 - -## Problem - -延迟的滚动采样会比较来自不同布局的位置。Chat 贴底时,输入框或 transcript(文本记录)收缩可能改变浏览器底部位置;随后的增长又可能在 `scrollend` 或采样定时器触发前改变浏览器位置。在此期间推迟跟随会使已观察顶部位置记录过期,把浏览器布局移动误判为读者输入,在没有读者操作时关闭跟随。 - -## Decision - -[ChatView](../../../../packages/client/ui-chat/src/client/chat/ChatView.tsx) 使用现有的已观察顶部位置比较,通过同一个清除待处理工作的采样操作,同步采样非读者引起的贴底滚动事件。这会在后续增长前恢复布局跟随。真实读者移动即使位于跟随阈值内,也保持待处理直到现有周期或 `scrollend`:增长不能在小幅操作累积为离底滚动前将其抵消。立即执行的贴底采样只读取滚动指标,不读取语义行几何。 - -## Alternatives considered - -**延迟所有事件。** 合并采样减少阅读历史时的几何计算,但贴底浏览器位置及其底部必须在同一布局中完成归因。过期比较关闭跟随后,延长超时或重试都无法恢复归属。 - -**同步采样所有事件。** 这能恢复归因,却也会在离底读者连续滚动时重复测量语义锚点和阅读线。只有贴底归属需要立即处理。 - -## Consequences - -贴底事件会立即读取滚动指标。历史阅读保留有界采样节奏,显式回到底部的滚动事件会清除任何待处理的离底采样。[聚焦测试](../../../../packages/client/ui-chat/tests/chat-view.client.spec.tsx) 覆盖 scrollend 前的收缩与增长、无需行测量的观察器增长、存在待处理采样时重新贴底、定时器与 scrollend 采样,以及卸载取消。[无密钥浏览器场景](../../../../apps/web/tests/chat-scroll-contract.e2e.ts) 覆盖长 transcript 中贴底发送、真实离底输入、流式输出与工具详情展开。 diff --git a/.agents/notes/implemented/bug-fix/2026-09-08-stable-room-reading-under-hidden-split-controls.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-09-08-stable-room-reading-under-hidden-split-controls.i18n.yaml deleted file mode 100644 index f6a8425e96..0000000000 --- a/.agents/notes/implemented/bug-fix/2026-09-08-stable-room-reading-under-hidden-split-controls.i18n.yaml +++ /dev/null @@ -1,6 +0,0 @@ -# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each -# side as of the last confirmed-consistent state. Both languages carry equal authority; -# after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-09-08-stable-room-reading-under-hidden-split-controls.md -2026-09-08-stable-room-reading-under-hidden-split-controls.md: c89c5328ee896e23ed24454c50223193c2d587ba -2026-09-08-stable-room-reading-under-hidden-split-controls.zh.md: edf9948e547b79b33165b9c7168238e6cf1d2685 diff --git a/.agents/notes/implemented/bug-fix/2026-09-08-stable-room-reading-under-hidden-split-controls.md b/.agents/notes/implemented/bug-fix/2026-09-08-stable-room-reading-under-hidden-split-controls.md deleted file mode 100644 index c89c5328ee..0000000000 --- a/.agents/notes/implemented/bug-fix/2026-09-08-stable-room-reading-under-hidden-split-controls.md +++ /dev/null @@ -1,25 +0,0 @@ -# Agent Note: Keep the room reading independent of hidden split controls - -Status: implemented - -English | [中文](2026-09-08-stable-room-reading-under-hidden-split-controls.zh.md) - -## Problem - -The dockkit room rule measures each pane's tab strip after every commit to decide whether an equal split leaves two working halves. With `hideSplitWhenBlocked`, a width-blocked pane unmounts its split control — and the unmount changes the very strip the rule measured: the strip sheds the control's 28px box plus its 4px gap, the fixed part shrinks, and the same pane reads as fitting again. Remounting the control reverses the reading. Across a roughly 32px band of pane widths the two states alternate inside nested layout effects until React stops the update loop (error #185); the slot runtime catches the crash and unmounts the Sidebar's entry while the column still records itself expanded, so neither the panel nor the header's collapsed-only expand button renders. A grip drag on a squeezed viewport sweeps the panel through that band, which presented as the whole sidebar vanishing with no way back in. - -## Decision - -When the embedder hides blocked split controls, the room rule leaves the split control's footprint out of the strip's fixed part unconditionally, so the reading is the same whether the control is currently mounted or not. [`measurePaneFits`](../../../../packages/client/ui-dockkit/src/components/measure.ts) takes the embedder's `hideSplitWhenBlocked` choice, measures the rendered control's box plus the strip's column gap (`splitControlFootprint`), and passes it as [`PaneMeasure.splitControlWidth`](../../../../packages/client/ui-dockkit/src/engine/geometry.ts), which `halvesFit` subtracts from the fixed part. Excluding the footprint is also correct on its own terms: a half too narrow to split would hide its own control, so the footprint is not part of what a half must carry. Embedders that render blocked controls disabled pass nothing and keep the control in the fixed part, as before. - -## Alternatives considered - -**Hide only budget-blocked controls, render width-blocked ones disabled.** This is what the code did before `hideSplitAtCapacity` widened into `hideSplitWhenBlocked`: the budget is state-driven and cannot feed back through the measurement. It avoids the loop but forfeits the Sidebar's requested presentation — no disabled split control on panes that cannot split. - -**Debounce or freeze re-measurement during oscillation.** Damping hides the instability instead of removing it: the reading would still depend on the control's visibility, settle on an arbitrary one of the two states, and flip on the next resize. - -**Measure the control's footprint from a constant.** A hardcoded 32px drifts from the stylesheet; measuring the rendered control and the strip's real `column-gap` keeps the subtraction equal to what the strip actually sheds, which is the exact condition for a stable reading. - -## Consequences - -The room reading is a fixed point under control visibility, so `hideSplitWhenBlocked` embedders get hidden controls without feedback. Panes near the boundary now read as splittable slightly earlier than a disabled-control embedder would report, because the half being asked about would not carry the control. A [dockkit regression test](../../../../packages/client/ui-dockkit/tests/components.client.spec.tsx) emulates the strip shedding the control's footprint and fails with React's update-depth error on the unfixed code; a [Sidebar browser case](../../../../apps/web/tests/sidebar-right.e2e.ts) drags the panel grip past both clamps on a squeezed viewport and asserts the panel, its grip, and a clean console survive, because the crash surfaces only as a console error the scaffold tripwire does not watch. diff --git a/.agents/notes/implemented/bug-fix/2026-09-08-stable-room-reading-under-hidden-split-controls.zh.md b/.agents/notes/implemented/bug-fix/2026-09-08-stable-room-reading-under-hidden-split-controls.zh.md deleted file mode 100644 index edf9948e54..0000000000 --- a/.agents/notes/implemented/bug-fix/2026-09-08-stable-room-reading-under-hidden-split-controls.zh.md +++ /dev/null @@ -1,25 +0,0 @@ -# Agent Note: Keep the room reading independent of hidden split controls - -Status: implemented - -[English](2026-09-08-stable-room-reading-under-hidden-split-controls.md) | 中文 - -## Problem - -dockkit 的空间规则在每次 commit 后测量各 pane 的标签条,判断等分后的两半是否仍可用。开启 `hideSplitWhenBlocked` 时,宽度不足的 pane 会卸载自己的分屏控件——而这次卸载恰恰改变了规则所测量的标签条:标签条少了控件的 28px 盒子加 4px 间距,固定部分随之变小,同一个 pane 又被读成"够宽"。控件重新挂载后读数再次反转。在约 32px 的 pane 宽度区间内,两种状态在嵌套 layout effect 中来回切换,直到 React 中止更新循环(错误 #185);slot 运行时捕获崩溃后卸载 Sidebar 的条目,而列状态仍记录为展开,于是面板和 header 上仅折叠时显示的展开按钮都不再渲染。在收窄的视口上拖动把手会让面板扫过该区间,表现为整个侧栏消失且无法再打开。 - -## Decision - -当嵌入方选择隐藏被阻止的分屏控件时,空间规则无条件将分屏控件的占位排除在标签条固定部分之外,使读数与控件当前是否挂载无关。[`measurePaneFits`](../../../../packages/client/ui-dockkit/src/components/measure.ts) 接收嵌入方的 `hideSplitWhenBlocked` 选择,测量已渲染控件的盒子加标签条的列间距(`splitControlFootprint`),并作为 [`PaneMeasure.splitControlWidth`](../../../../packages/client/ui-dockkit/src/engine/geometry.ts) 传入,由 `halvesFit` 从固定部分中减去。排除该占位本身也是正确的:窄到无法分屏的一半会隐藏自己的控件,所以这份占位并不属于一半必须承载的内容。将被阻止控件渲染为禁用态的嵌入方不传该值,控件照旧计入固定部分。 - -## Alternatives considered - -**只隐藏预算受限的控件,宽度受限的渲染为禁用态。** 这是 `hideSplitAtCapacity` 扩展为 `hideSplitWhenBlocked` 之前的做法:预算由状态驱动,不会经测量反馈回来。它避免了循环,但放弃了 Sidebar 想要的呈现——无法分屏的 pane 上不出现禁用的分屏控件。 - -**在振荡期间对重新测量做防抖或冻结。** 阻尼只是掩盖不稳定而非消除它:读数仍依赖控件的可见性,会任意停在两种状态之一,并在下次 resize 时再次翻转。 - -**用常量表示控件占位。** 硬编码的 32px 会与样式表漂移;测量实际渲染的控件和标签条真实的 `column-gap`,才能保证减去的量恰好等于标签条实际卸下的量,这正是读数稳定的确切条件。 - -## Consequences - -空间读数在控件可见性变化下是不动点,`hideSplitWhenBlocked` 的嵌入方获得隐藏控件的呈现且无反馈循环。临界宽度附近的 pane 会比禁用态嵌入方的报告稍早读成可分屏,因为被询问的那一半不会承载控件。[dockkit 回归测试](../../../../packages/client/ui-dockkit/tests/components.client.spec.tsx) 模拟标签条卸下控件占位的反馈,在未修复的代码上以 React 更新深度错误失败;[Sidebar 浏览器用例](../../../../apps/web/tests/sidebar-right.e2e.ts) 在收窄视口上把面板把手拖过两侧钳位,断言面板、把手与干净的 console 均存活——崩溃只以 console error 形式出现,而脚手架的 tripwire 不监听它。 diff --git a/.agents/notes/implemented/bug-fix/2026-09-09-composer-placeholder-whitespace.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-09-09-composer-placeholder-whitespace.i18n.yaml deleted file mode 100644 index c971fb1bfd..0000000000 --- a/.agents/notes/implemented/bug-fix/2026-09-09-composer-placeholder-whitespace.i18n.yaml +++ /dev/null @@ -1,6 +0,0 @@ -# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each -# side as of the last confirmed-consistent state. Both languages carry equal authority; -# after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-09-09-composer-placeholder-whitespace.md -2026-09-09-composer-placeholder-whitespace.md: c5d778f8fb1fdfc742636819a9ff27ddeeb0234e -2026-09-09-composer-placeholder-whitespace.zh.md: b5f82bcd86f8567976bba96095d4de9d42bae9b1 diff --git a/.agents/notes/implemented/bug-fix/2026-09-09-composer-placeholder-whitespace.md b/.agents/notes/implemented/bug-fix/2026-09-09-composer-placeholder-whitespace.md deleted file mode 100644 index c5d778f8fb..0000000000 --- a/.agents/notes/implemented/bug-fix/2026-09-09-composer-placeholder-whitespace.md +++ /dev/null @@ -1,21 +0,0 @@ -# Agent Note: Composer placeholder emptiness - -Status: implemented - -English | [中文](2026-09-09-composer-placeholder-whitespace.zh.md) - -## Problem - -Sharing the whitespace-trimmed submission check with placeholder rendering leaves guidance drawn over a draft containing spaces. - -## Decision - -The Composer hides its placeholder whenever the raw draft is nonempty. Submission keeps its trimmed-content check. Attachments and claimed commands retain their existing placeholder suppression. - -## Alternatives considered - -**Reuse the submission check.** Whitespace has no sendable message content, but it occupies the editor and moves its caret. A shared check conflates these two states. - -## Consequences - -All placeholder variants, including queued-message steering guidance, disappear after whitespace input and return after deletion. A whitespace-only draft without attachments remains unsendable. [Component tests](../../../../packages/client/ui-conversation/tests/input-bar.client.spec.tsx) cover visibility, composition, rerendering and submission; the [browser regression](../../../../apps/web/tests/composer-placeholder.e2e.ts) checks keyboard and clipboard gestures against built UI. diff --git a/.agents/notes/implemented/bug-fix/2026-09-09-composer-placeholder-whitespace.zh.md b/.agents/notes/implemented/bug-fix/2026-09-09-composer-placeholder-whitespace.zh.md deleted file mode 100644 index b5f82bcd86..0000000000 --- a/.agents/notes/implemented/bug-fix/2026-09-09-composer-placeholder-whitespace.zh.md +++ /dev/null @@ -1,21 +0,0 @@ -# Agent Note: Composer 占位提示的判空规则 - -Status: implemented - -[English](2026-09-09-composer-placeholder-whitespace.md) | 中文 - -## Problem - -占位提示复用去除首尾空白后的提交判断,会让提示文字覆盖已经包含空格的草稿。 - -## Decision - -原始草稿非空时,Composer 隐藏占位提示。提交仍检查去除首尾空白后的内容。附件和已认领指令沿用现有的占位提示隐藏规则。 - -## Alternatives considered - -**复用提交判断。** 空白字符没有可发送的消息内容,但会占据编辑器并移动光标。共用判断会混淆这两种状态。 - -## Consequences - -所有占位提示,包括排队消息的插话提示,都会在输入空白字符后隐藏,删除后恢复。没有附件的纯空白草稿仍无法发送。[组件测试](../../../../packages/client/ui-conversation/tests/input-bar.client.spec.tsx) 覆盖显示、输入法组合、重新渲染和提交;[浏览器回归](../../../../apps/web/tests/composer-placeholder.e2e.ts) 使用构建后的界面检查键盘和剪贴板操作。 diff --git a/.agents/notes/implemented/bug-fix/2026-09-14-transcript-width-handle-layering.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-09-14-transcript-width-handle-layering.i18n.yaml deleted file mode 100644 index 003ab9ba65..0000000000 --- a/.agents/notes/implemented/bug-fix/2026-09-14-transcript-width-handle-layering.i18n.yaml +++ /dev/null @@ -1,6 +0,0 @@ -# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each -# side as of the last confirmed-consistent state. Both languages carry equal authority; -# after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-09-14-transcript-width-handle-layering.md -2026-09-14-transcript-width-handle-layering.md: 01d70e646e874d8266633597f1b510ad73fc2287 -2026-09-14-transcript-width-handle-layering.zh.md: c6e03ec5775256f5aac87d2f7c1ed66a12cf0c2a diff --git a/.agents/notes/implemented/bug-fix/2026-09-14-transcript-width-handle-layering.md b/.agents/notes/implemented/bug-fix/2026-09-14-transcript-width-handle-layering.md deleted file mode 100644 index 01d70e646e..0000000000 --- a/.agents/notes/implemented/bug-fix/2026-09-14-transcript-width-handle-layering.md +++ /dev/null @@ -1,33 +0,0 @@ -# Agent Note: Transcript width handles stay behind chat content - -Status: implemented - -English | [中文](2026-09-14-transcript-width-handle-layering.zh.md) - -## Problem - -The Conversation shell renders each content-width handle as a full-height absolute strip beside the reading column. A high stacking level placed that strip above wide Markdown tables that legitimately overflow the column. The handle glow covered their content and its pointer target could replace their interaction target. - -## Decision - -The Conversation shell keeps width handles at level zero without creating a body-wide stacking context. Chat raises concrete table elements to level one, while the reading column, column-bounded tool cards, and table breakout wrapper create no new stacking level. A table therefore owns the gutter only where its painted box actually reaches it; a fitting four-column table leaves the adjacent gutter draggable. Fixed message tooltips remain outside the table stacking context and can still paint over the sticky composer. - -This rule relies on the supported Chromium engine not turning ChatView's inline-size query container into an intermediate stacking context. The same engine behavior already lets the back-to-bottom control outrank the sticky composer. The Chromium hit-test scenario pins that assumption; raising the whole Chat root would instead make its transparent full-width box reclaim the gutter. - -A width handle starts resizing only for the primary pointer button. Ordinary wheel input over the handle is normalized from pixel, computed line-height, or page units and forwarded to the direct sibling transcript scrollport, so hovering the gutter does not suspend reading. Ctrl+wheel is not forwarded because it represents browser zoom or a trackpad pinch gesture. - -Each side's hit strip is at most 10px wide. Its hover indicator uses the lower-contrast resting scrollbar tint and is a 2px line with a 16px solid center and 28px fades, for a 72px total visible length. - -Composer chrome retains its higher layer, so its full sticky footer band intentionally does not start a width resize. A handle whose pointer capture began above that band temporarily rises to level eight, keeping the indicator visible until release. A full-view composer takeover continues to hide the handles entirely. - -## Alternatives considered - -**Move the handles farther from the reading column.** Wide tables can use the available transcript width, so a fixed larger inset would only reduce the overlap for some window sizes and would make the handles harder to reach. - -**Disable the handles whenever a transcript contains wide content.** One wide row would remove resizing from the entire Session, including empty gutter beside unrelated rows. - -**Inspect the elements under the pointer in JavaScript.** Dynamic hit testing would duplicate browser stacking and pointer dispatch rules and could still disagree with new plugin renderers. - -## Consequences - -Visible Chat content owns pointer input only where its painted element extends into a width-handle strip, while bare gutter keeps a quieter resize affordance, primary-button drag, and transcript wheel scrolling. Unit tests pin the declared handle geometry, pointer-button behavior, direct scrollport lookup, zoom exclusion, and every DOM delta mode. Chromium `elementFromPoint` coverage proves an overflowing table wins the hit while a fitting `md-table-wide` wrapper does not, and a message-action test proves fixed tooltips still paint above the sticky composer. diff --git a/.agents/notes/implemented/bug-fix/2026-09-14-transcript-width-handle-layering.zh.md b/.agents/notes/implemented/bug-fix/2026-09-14-transcript-width-handle-layering.zh.md deleted file mode 100644 index c6e03ec577..0000000000 --- a/.agents/notes/implemented/bug-fix/2026-09-14-transcript-width-handle-layering.zh.md +++ /dev/null @@ -1,33 +0,0 @@ -# Agent Note: 正文宽度拖拽条位于 Chat 内容下方 - -Status: implemented - -[English](2026-09-14-transcript-width-handle-layering.md) | 中文 - -## 问题 - -Conversation shell 将每个正文宽度拖拽条渲染为阅读列旁的全高绝对定位区域。较高的堆叠层级使该区域盖在会合理溢出阅读列的宽 Markdown 表格之上。拖拽条的高亮会遮住表格内容,其指针目标也可能取代表格自身的交互目标。 - -## 决策 - -Conversation shell 将宽度拖拽条保持在第零层,不创建覆盖整个 body 的堆叠上下文。Chat 只将具体的表格元素提到第一层,阅读列、限定在列内的工具卡片与表格 breakout 包装层都不创建新的堆叠层级。因此只有表格的可见盒子实际延伸进沟槽时才会占有指针;能容纳在正文中的四列表格不会阻断旁边的沟槽拖拽。消息的固定定位 Tooltip 保持在表格堆叠上下文之外,仍可绘制在粘滞 composer 上方。 - -该规则依赖交付的 Chromium 引擎不会把 ChatView 的 inline-size query container 变成中间堆叠上下文。back-to-bottom 控件能高于粘滞 composer 也已依赖相同的引擎行为。Chromium 命中测试固定这一假设;如果改为提高整个 Chat root,其透明全宽盒子反而会重新占用沟槽。 - -宽度拖拽条仅使用主指针按键开始调整。拖拽条上的普通滚轮输入会从像素、计算后的行高或页面单位规整为像素,再转发给直接相邻的 transcript scrollport,因此指针悬停在沟槽时不会中断阅读滚动。Ctrl+滚轮表示浏览器缩放或触控板捏合手势,不会被转发。 - -每侧的命中区域最宽为 10px。悬停指示线使用对比度更低的静止滚动条颜色,线宽 2px,中心实色段长 16px,两端各渐隐 28px,可见总长度为 72px。 - -Composer chrome 保留更高层级,因此完整的粘滞底部区带刻意不会开始宽度调整。如果拖拽条在进入该区带之前已获得指针捕获,它会临时提到第八层,使指示线在松开前保持可见。占据完整 View 的 composer takeover 仍会完全隐藏拖拽条。 - -## 考虑过的替代方案 - -**将拖拽条移到离阅读列更远的位置。** 宽表格可以使用 transcript 的可用宽度,因此固定增加间距只能减少部分窗口尺寸下的重叠,还会让拖拽条更难触达。 - -**只要 transcript 包含宽内容就禁用拖拽条。** 一行宽内容会让整个 Session 都无法调整宽度,包括与其他行相邻的空白沟槽。 - -**在 JavaScript 中检查指针下方的元素。** 动态命中测试会重复浏览器的堆叠与指针分发规则,并且仍可能与新的插件 renderer 不一致。 - -## 后果 - -可见 Chat 内容只在其可见元素实际延伸进宽度拖拽区域时拥有指针输入,裸露沟槽则保留更轻的宽度调整提示、主按键拖拽和 transcript 滚轮滚动。单元测试固定声明的拖拽条尺寸、指针按键行为、直接 scrollport 查找、缩放排除和每种 DOM delta mode。Chromium `elementFromPoint` 覆盖证明溢出表格获得指针命中,而可容纳的 `md-table-wide` 包装层不会;消息操作测试证明固定定位 Tooltip 仍绘制在粘滞 composer 上方。 diff --git a/.agents/notes/implemented/bug-fix/2026-09-17-thinking-markdown.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-09-17-thinking-markdown.i18n.yaml index f0a89cb78d..6377a233cb 100644 --- a/.agents/notes/implemented/bug-fix/2026-09-17-thinking-markdown.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-09-17-thinking-markdown.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/bug-fix/2026-09-17-thinking-markdown.md -2026-09-17-thinking-markdown.md: de49cb42ba537700f6584c9f4cef9a57fb2e3605 -2026-09-17-thinking-markdown.zh.md: e403630138f2f39aec0834e550d4d4a8860a67fe +2026-09-17-thinking-markdown.md: 6990fc6277d7d1c0a088a50cfb88694b2d9c55c7 +2026-09-17-thinking-markdown.zh.md: 963626088e50a44bcf76c7fc7ec3653e93fcd2c9 diff --git a/.agents/notes/implemented/bug-fix/2026-09-17-thinking-markdown.md b/.agents/notes/implemented/bug-fix/2026-09-17-thinking-markdown.md index de49cb42ba..6990fc6277 100644 --- a/.agents/notes/implemented/bug-fix/2026-09-17-thinking-markdown.md +++ b/.agents/notes/implemented/bug-fix/2026-09-17-thinking-markdown.md @@ -10,9 +10,9 @@ Chat Thinking contains model-authored Markdown, but literal rendering exposes he ## Decision -[MarkdownText](../../../../packages/client/ui-primitives/src/markdown/MarkdownText.tsx) owns a compact presentation variant used by Chat Thinking and Trajectory thinking details. It keeps the secondary content font size, line height, and tertiary color throughout. Heading levels retain semantic elements but share the same 600 weight and size; paragraphs, lists, quotes, and code use compact spacing. Links keep a resting dotted underline with tertiary color as a secondary-content exception to the [shared link styling](../feature/2026-09-04-web-clickable-link-styles.md); code retains its monospace font and background. +[MarkdownText](../../../../packages/client/ui-primitives/src/markdown/MarkdownText.tsx) owns a compact presentation variant used by Chat Thinking and Trajectory thinking details. It keeps the secondary content font size, line height, and tertiary color throughout. Heading levels retain semantic elements but share the same 600 weight and size; paragraphs, lists, quotes, and code use compact spacing. Links keep a resting dotted underline with tertiary color as a secondary-content exception to the [shared link styling](../../../../docs/web-styling.md); code retains its monospace font and background. -Tables and formulas remain enabled through the existing parser. Their containers constrain horizontal overflow, and formula text inherits the secondary font size. Inline formulas retain native KaTeX baselines; enclosing text blocks own overflow so short formulas do not create scrollbars. Compact code banners stay in normal flow, keeping the [Thinking disclosure header](../feature/2026-08-03-web-sticky-collapsible-headers.md) above the scrolling content without a second sticky band. +Tables and formulas remain enabled through the existing parser. Their containers constrain horizontal overflow, and formula text inherits the secondary font size. Inline formulas retain native KaTeX baselines; enclosing text blocks own overflow so short formulas do not create scrollbars. Compact code banners stay in normal flow, keeping the [Thinking disclosure header](../../../../packages/client/ui-chat/src/client/chat/ReasoningRow.module.css) above the scrolling content without a second sticky band. Chat passes its running state to the existing incremental Markdown renderer. The collapsed summary remains a separate single-line text projection. The parser, frozen-block cache, stored reasoning, and Session format do not change. Trajectory pins Thinking to its inspector’s fixed 13px/20px tier, independent of Chat’s content-size setting. Its answer output retains its existing typography and its [Thinking disclosure behavior](../feature/2026-09-09-ptc-trajectory-code-inspection.md). diff --git a/.agents/notes/implemented/bug-fix/2026-09-17-thinking-markdown.zh.md b/.agents/notes/implemented/bug-fix/2026-09-17-thinking-markdown.zh.md index e403630138..963626088e 100644 --- a/.agents/notes/implemented/bug-fix/2026-09-17-thinking-markdown.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-09-17-thinking-markdown.zh.md @@ -10,9 +10,9 @@ Chat Thinking 包含模型编写的 Markdown,但纯文本渲染直接显示标 ## 决策 -[MarkdownText](../../../../packages/client/ui-primitives/src/markdown/MarkdownText.tsx) 拥有紧凑展示变体,供 Chat Thinking 和 Trajectory 思考详情使用。内容沿用次级字号、行高和 tertiary 颜色。各级标题保留语义元素,但统一使用 600 字重和相同字号;段落、列表、引用和代码采用紧凑间距。链接保留默认点状下划线与 tertiary 颜色,作为[共享链接样式](../feature/2026-09-04-web-clickable-link-styles.zh.md)的次级内容例外;代码保留等宽字体和底色。 +[MarkdownText](../../../../packages/client/ui-primitives/src/markdown/MarkdownText.tsx) 拥有紧凑展示变体,供 Chat Thinking 和 Trajectory 思考详情使用。内容沿用次级字号、行高和 tertiary 颜色。各级标题保留语义元素,但统一使用 600 字重和相同字号;段落、列表、引用和代码采用紧凑间距。链接保留默认点状下划线与 tertiary 颜色,作为[共享链接样式](../../../../docs/web-styling.zh.md)的次级内容例外;代码保留等宽字体和底色。 -表格和公式继续通过既有解析器启用。其容器限制横向溢出,公式文本继承次级字号。行内公式保留 KaTeX 原生基线,由外层文本块承担溢出处理,短公式不会产生滚动条。紧凑代码栏保持正常文档流,让 [Thinking 折叠标题](../feature/2026-08-03-web-sticky-collapsible-headers.zh.md) 位于滚动内容上方,无需第二条 sticky 栏。 +表格和公式继续通过既有解析器启用。其容器限制横向溢出,公式文本继承次级字号。行内公式保留 KaTeX 原生基线,由外层文本块承担溢出处理,短公式不会产生滚动条。紧凑代码栏保持正常文档流,让 [Thinking 折叠标题](../../../../packages/client/ui-chat/src/client/chat/ReasoningRow.module.css) 位于滚动内容上方,无需第二条 sticky 栏。 Chat 将运行状态传给既有增量 Markdown 渲染器。折叠摘要仍是独立的单行文本投影。解析器、冻结块缓存、存储的推理内容和 Session 格式均保持不变。Trajectory 将 Thinking 固定在检查器的 13px/20px 层级,与 Chat 的内容字号设置无关。其回答输出保留既有排版及其 [Thinking 折叠行为](../feature/2026-09-09-ptc-trajectory-code-inspection.zh.md)。 diff --git a/.agents/notes/implemented/architecture/2026-09-16-desktop-policy-login-loading.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-09-18-messages-input-history-compatibility.i18n.yaml similarity index 54% rename from .agents/notes/implemented/architecture/2026-09-16-desktop-policy-login-loading.i18n.yaml rename to .agents/notes/implemented/bug-fix/2026-09-18-messages-input-history-compatibility.i18n.yaml index b25a4b72b4..0898823b35 100644 --- a/.agents/notes/implemented/architecture/2026-09-16-desktop-policy-login-loading.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-09-18-messages-input-history-compatibility.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 .agents/notes/implemented/architecture/2026-09-16-desktop-policy-login-loading.md -2026-09-16-desktop-policy-login-loading.md: 26fd635c6cc5b92cad8011919382a11497d2e835 -2026-09-16-desktop-policy-login-loading.zh.md: 16de25323afc971b3142d5359c769df50c08800d +# pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-09-18-messages-input-history-compatibility.md +2026-09-18-messages-input-history-compatibility.md: 1775276deec6180f1fd186b11cb7a757a3328a54 +2026-09-18-messages-input-history-compatibility.zh.md: 50367e33c29df0679a44aca1fe680d2bedc8c263 diff --git a/.agents/notes/implemented/bug-fix/2026-09-18-messages-input-history-compatibility.md b/.agents/notes/implemented/bug-fix/2026-09-18-messages-input-history-compatibility.md new file mode 100644 index 0000000000..1775276dee --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-09-18-messages-input-history-compatibility.md @@ -0,0 +1,31 @@ +# Agent Note: Replay assistant blocks in Messages user input + +Status: implemented + +English | [中文](2026-09-18-messages-input-history-compatibility.zh.md) + +## Problem + +Saved subagent settlement notices can contain child reasoning and tool calls in parent user messages. Chat Completions and pi-ai omit these blocks from user input, while rejecting them in Messages prevents every later request containing the same history. The [text-only settlement producer](../feature/2026-08-06-manager-owned-subagent-settlement-delivery.md) prevents new notices from carrying these blocks but cannot repair already recorded messages. + +## Decision + +The [Messages serializer](../../../../packages/llm/llm-deepseek/src/protocols/messages/serialize.ts) omits `reasoning` and `tool-call` blocks from user and tool-result input. This matches Chat Completions and pi-ai for these two block types, without changing Session records or converting child reasoning into parent user text. The [provider README](../../../../packages/llm/llm-deepseek/README.md#model-experience) owns the input rules, including empty user messages, empty tool results, and rejection of other unsupported blocks. + +This partially supersedes the settlement decision's rejection of serializer tolerance and the [Messages adapter decision](../feature/2026-09-07-deepseek-messages-adapter.md)'s input rejection. Notice construction still projects nonempty child text for every parent provider. Canonical child output and ordinary assistant reasoning and tool calls remain available to their existing consumers. The omission is based on input role and block type, not the notice source or creation date, so it also applies to newly supplied user and tool-result content. + +## Alternatives considered + +**Fix only the producer.** New notices are representable, but saved notices still block protocol switching and continuation. + +**Rewrite Session history or flatten reasoning into user text.** Rewriting removes original evidence; flattening sends child reasoning as parent input. Request-only omission preserves the durable record and existing input semantics. + +**Ignore every unsupported block.** Unknown plugin content has no Messages representation defined here. Compatibility for two known assistant block types does not establish one. + +**Move a system update to an earlier retained user turn.** When the corresponding user input is omitted, moving the update before an earlier assistant changes its position in the conversation and rewrites a previously sent prefix. The [documented in-history limitation](../../../../packages/llm/llm-deepseek/README.md#known-limitations-and-deferred-work) remains explicit rather than inventing a user message or relocating the update. + +## Consequences + +Saved notices with retained text can continue through Messages. The provider no longer diagnoses these two block types as user-input producer errors; the settlement producer remains responsible for text-only notices. This does not promise lossless provider input or compatibility for every historical message sequence. + +Verification covers user and tool-result filtering, unchanged durable content, HTTP continuation, and a [headless Session replay](../../../../snapshots/session/deepseek-messages-input-history/snapshot.yml). Serialization tests pin rejection of an in-history update when its user input is entirely omitted, both before another assistant and at request end, and acceptance when text or a tool result retains the corresponding turn. diff --git a/.agents/notes/implemented/bug-fix/2026-09-18-messages-input-history-compatibility.zh.md b/.agents/notes/implemented/bug-fix/2026-09-18-messages-input-history-compatibility.zh.md new file mode 100644 index 0000000000..50367e33c2 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-09-18-messages-input-history-compatibility.zh.md @@ -0,0 +1,31 @@ +# Agent Note: 回放 Messages 用户输入中的助手块 + +Status: implemented + +[English](2026-09-18-messages-input-history-compatibility.md) | 中文 + +## 问题 + +已保存的子 Agent 结算通知可能在父级用户消息中包含子级推理和工具调用。Chat Completions 与 pi-ai 会在用户输入中省略这些块,而 Messages 拒绝它们会阻断包含相同历史的所有后续请求。[仅生成文本的结算通知生产方](../feature/2026-08-06-manager-owned-subagent-settlement-delivery.zh.md)可以避免新通知携带这些块,但无法修复已经记录的消息。 + +## 决策 + +[Messages 序列化器](../../../../packages/llm/llm-deepseek/src/protocols/messages/serialize.ts)省略用户与工具结果输入中的 `reasoning` 和 `tool-call` 块。这两类块的处理与 Chat Completions、pi-ai 一致,不改变 Session 记录,也不把子级推理转换成父级用户文本。[提供方 README](../../../../packages/llm/llm-deepseek/README.zh.md#model-experience)统一说明输入规则,包括空用户消息、空工具结果,以及对其他不支持块的拒绝。 + +这部分取代了结算决策对序列化器容错的拒绝,以及 [Messages 适配器决策](../feature/2026-09-07-deepseek-messages-adapter.zh.md)中的输入拒绝规则。通知构建仍为每种父级提供方投影子级的非空文本。子级的规范输出以及普通助手推理和工具调用仍可供现有消费者使用。省略依据是输入角色和块类型,而不是通知来源或创建日期,因此也适用于新提供的用户和工具结果内容。 + +## 考虑过的替代方案 + +**只修复生产方。** 新通知可以表示,但已保存的通知仍会阻断协议切换和会话续接。 + +**改写 Session 历史或把推理压平为用户文本。** 改写会移除原始证据;压平会把子级推理作为父级输入发送。只在请求中省略可以保留持久化记录和现有输入语义。 + +**忽略所有不支持的块。** 此处没有定义未知插件内容的 Messages 表示方式。兼容两种已知助手块类型并不意味着其他类型也有对应表示。 + +**把 system 更新移到更早保留的用户轮次。** 对应用户输入被省略时,将更新移到更早的 assistant 之前,会改变它在对话中的位置,并改写已发送的前缀。保留明确记录的[历史内更新限制](../../../../packages/llm/llm-deepseek/README.zh.md#known-limitations-and-deferred-work),不编造用户消息或移动更新位置。 + +## 后果 + +保留文本的已保存通知可以通过 Messages 继续会话。提供方不再将这两种块类型诊断为用户输入生产方错误;结算通知生产方仍负责仅生成文本通知。这不保证提供方输入无损,也不保证兼容所有历史消息序列。 + +验证覆盖用户与工具结果过滤、持久化内容不变、HTTP 续接,以及 [headless Session 回放](../../../../snapshots/session/deepseek-messages-input-history/snapshot.yml)。序列化测试固定了历史内更新在对应用户输入被完全省略时的拒绝行为,覆盖下一个 assistant 之前和请求结束两种位置;文本或工具结果保留对应轮次时则可以通过。 diff --git a/.agents/notes/implemented/feature/2026-08-03-web-sticky-collapsible-headers.i18n.yaml b/.agents/notes/implemented/feature/2026-08-03-web-sticky-collapsible-headers.i18n.yaml deleted file mode 100644 index 9ae953b44a..0000000000 --- a/.agents/notes/implemented/feature/2026-08-03-web-sticky-collapsible-headers.i18n.yaml +++ /dev/null @@ -1,6 +0,0 @@ -# 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-08-03-web-sticky-collapsible-headers.md -2026-08-03-web-sticky-collapsible-headers.md: 5bbe91681e656a8278b9d65f12e20a4d9dc779d0 -2026-08-03-web-sticky-collapsible-headers.zh.md: c75846fa7f5ca1088dab530b63243553e50f9579 diff --git a/.agents/notes/implemented/feature/2026-08-03-web-sticky-collapsible-headers.md b/.agents/notes/implemented/feature/2026-08-03-web-sticky-collapsible-headers.md deleted file mode 100644 index 5bbe91681e..0000000000 --- a/.agents/notes/implemented/feature/2026-08-03-web-sticky-collapsible-headers.md +++ /dev/null @@ -1,41 +0,0 @@ -# Agent Note: Web sticky collapsible headers — Think and compaction toggles pin while scrolling - -Status: implemented - -English | [中文](2026-08-03-web-sticky-collapsible-headers.zh.md) - -## Problem - -Two conversation blocks render their expanded body uncapped, flowing with the page instead of scrolling inside a bounded surface: the Web Think row (`.thinkBody`) and the compaction marker (`.compactionBody`). Every other tool row caps its body and scrolls it inside its own card, so its disclosure header stays visible. The two uncapped blocks do not. A long chain of thought or a long compaction summary carries its own disclosure header off the top of the viewport, so a reader who wants to collapse the block again must scroll the full body back up to reach the toggle. - -## Decision - -The disclosure header of each uncapped block sticks to the conversation scroll container's top while the block is open. The header remains in normal flow when collapsed, so a collapsed block scrolls away like any other row. - -Both blocks already scroll against the shared conversation scroll container (`[data-conversation-scroll]`), not an inner box, so `position: sticky; top: 0` on the header pins it against that container. A base-token background masks the prose that scrolls under the pinned header. - -The pinned header's stacking rank differs by block, because their bodies differ. The Think body uses [compact Markdown](../bug-fix/2026-09-17-thinking-markdown.md) with code banners in normal flow, so `z-index: 1` suffices. The compaction body renders markdown, and a fenced code block in the summary pins its own banner at `z-index: 6` (`packages/client/ui-primitives/src/markdown/CodeBlock.module.css`) with a Copy control inside it; the compaction header therefore uses `z-index: 7` and holds that banner below its own band, so the header never covers the Copy control and the banner never covers the toggle. The band's height is one component-local measurement (`--dsh-compaction-header-height` on `.compactionRow`) shared by the toggle's `height` and the banner's `top`; the banner rule wins over CodeBlock's `top: 0` by specificity, because the two sheets load in their own packages. The pinned header also overrides its hover fill to the opaque `--dsw-alias-interactive-bg-hover-solid` token and squares its corners for as long as the row is open: the default translucent hover token would let the scrolling prose show through the moment the pointer lands on the toggle, and the base 6px radius would leave the same prose visible at the corners. `:hover` raises the rule's specificity above the later base hover rule, so declaration order does not decide the winner. Two other elements take `z-index: 7` to clear the same code banners: the composer seat and the turn-navigation rail slot (`TurnNavigator.module.css`, ui-chat). Their order among equal ranks is stated once, in the composer seat's comment (`packages/client/ui-conversation/src/client/skeleton/ConversationRoot.module.css`). - -The rules are scoped so only the two uncapped blocks are affected; the capped tool rows keep their existing behavior, since stacking sticky headers across a run of tool rows would pile them at the top. The Think rule is `packages/client/ui-chat/src/client/chat/ReasoningRow.module.css` `.root[data-expanded] [data-open] [data-disclosure-row]` — gated on `DisclosureRow`'s `data-open` so a collapsed Think row never sticks, and scoped under the Think row's own root so no tool-call variant is touched. The compaction rule is `packages/client/ui-chat/src/client/chat/MessageItem.module.css` `.compactionRow:has(.compactionBody) .compactionButton` — the body sibling exists in the DOM only while open, so `:has()` gates the stick on the open state. - -No session, wire, durable event, or model-visible contract changes; this is a presentation-only CSS change owned by the existing components. - -## Alternatives considered - -**Cap the two bodies with `max-height` + internal scroll, matching the tool cards.** Rejected: the Think body is deliberately uncapped so reasoning reads as ordinary message prose ([web-thinking-tail-scroll](../../archived/feature/2026-08-02-web-thinking-tail-scroll.md) and the `.thinkBody` comment own that intent), and the compaction summary is a reading surface. An inner scrollport introduces nested scrolling — the wheel switches from page to box under the cursor — and compresses long technical prose into a small window that is worse to read. Sticky headers keep the flowing-prose reading model and still keep the toggle reachable. - -**Add sticky headers to every collapsible row for consistency.** Rejected: the capped tool rows already keep their header visible because their body scrolls internally, so they have no problem to solve. Making their headers sticky against the page would stack one pinned header per open row at the top of the viewport during a scroll through a run of tool calls, which is visual noise, not consistency. - -**Offset the summary's code banner from `CodeBlock` rather than from the compaction rule.** Rejected: `CodeBlock` could read a property such as `--dsl-code-block-banner-top` that this consumer sets, which would leave the code block the owner of its own geometry and avoid a `:has()` selector reaching into another package's DOM. It also routes every consumer's layout through a property only this block sets, and the offset is this block's presentation concern rather than the code block's: the local rule states the offset beside the toggle that creates it, and `[data-code-block-banner]` is the hook `CodeBlock` already publishes for owner styling. - -**A shared sticky rule on the `DisclosureRow` primitive.** Rejected: `DisclosureRow` backs Think, every tool-call variant, and the context-injection row; a rule there would hit the capped rows too. The behavior belongs only to the uncapped consumers, so each scopes the rule to its own block. - -## Consequences - -The collapse toggle for a long Think block or compaction summary stays reachable without scrolling the body back to its start, while both bodies keep flowing as page prose. The change is CSS-only: no timer, subscription, durable state, DOM structure change, or transport traffic. The compaction rule uses the CSS `:has()` selector, supported across the browsers the Web UI targets. - -## Testing - -The unit specs pin the DOM anchors the selectors key on: `packages/client/ui-chat/tests/reasoning-row.client.spec.tsx` asserts an open Think row nests `[data-disclosure-row]` under `[data-variant='think'][data-expanded] [data-open]` and that a collapsed row has no `[data-open]`; `packages/client/ui-chat/tests/chat-branch-tails.client.spec.tsx` asserts the compaction body appears under `.compactionRow` only while open. `packages/client/ui-chat/tests/sticky-header-styles.client.spec.ts` reads the rules as CSS text and pins the declarations the pinning depends on: `position: sticky`, `top: 0`, the square corners, the `z-index` rank of each side, the single measurement shared by the toggle's height and the code banner's offset, and the opaque hover token, because jsdom computes no sticky layout and the render specs cannot fail on a changed declaration. - -The real-browser evidence is two keyless Chromium e2e paths. `apps/web/tests/lifecycle-chrome.e2e.ts` expands the settled turn's process row, opens the Think row, and asserts its header computes `position: sticky`, `top: 0px` (and is not sticky while collapsed); that fixture's recorded reasoning is a single line, too short to overflow, so it proves only that the CSS resolves onto the Think header. `apps/web/tests/seeded-history.e2e.ts` carries the pinned-while-scrolling evidence: it seeds a compaction whose summary length this suite controls (a fenced code block plus 40 list items), shrinks the viewport to force overflow, scrolls the marker into its pinned state, and asserts the header computes `position: sticky`, `top: 0px`, a `z-index` greater than the code block banner's, that it holds at the scrollport top after the scroll, that its own center is the topmost hit-tested element (the toggle stays clickable), and that its hover fill stays fully opaque (alpha 1). The same case scrolls the summary's code banner into its own stuck position and asserts the banner stops below the toggle's band and that the banner's Copy control owns its center, so the offset cannot regress into a covered control. The case also writes a keyless geometry golden (`snapshots/web/seeded-history/sticky-geometry.expected.md`) fixing these platform-independent semantic facts, since this is a user-visible CSS behavior that changes no DOM and no accessible name, so the aria goldens cannot capture it. The PR demo GIF, recorded against a real server and a real model round, carries the visual evidence that the pinned header stays at the top while the body scrolls. diff --git a/.agents/notes/implemented/feature/2026-08-03-web-sticky-collapsible-headers.zh.md b/.agents/notes/implemented/feature/2026-08-03-web-sticky-collapsible-headers.zh.md deleted file mode 100644 index c75846fa7f..0000000000 --- a/.agents/notes/implemented/feature/2026-08-03-web-sticky-collapsible-headers.zh.md +++ /dev/null @@ -1,41 +0,0 @@ -# Agent Note:Web 可折叠块的钉住标题 —— Think 与压缩标记的折叠按钮在滚动时钉住 - -Status: implemented - -[English](2026-08-03-web-sticky-collapsible-headers.md) | 中文 - -## 问题 - -会话里有两个块的展开正文不封顶,随整页滚动,而不是在有界的表面内部滚动:Web Think 行(`.thinkBody`)和压缩标记(`.compactionBody`)。其他每个工具行都给正文封顶并在自己的卡片内部滚动,所以折叠标题始终可见。这两个不封顶的块做不到。一段很长的思维链或很长的压缩摘要会把自己的折叠标题顶出视口上方,想再次折叠该块的读者必须把整段正文滚回顶部才能够到折叠按钮。 - -## 决策 - -每个不封顶块的折叠标题在块展开时钉在会话滚动容器的顶部。折叠时标题保持在正常文档流中,所以折叠的块会像其他行一样滚走。 - -这两个块本来就是相对共享的会话滚动容器(`[data-conversation-scroll]`)滚动,而非某个内层框,所以在标题上加 `position: sticky; top: 0` 就把它钉在该容器上。一个 base token 背景遮住在钉住的标题下方滚过的正文。 - -钉住的标题的层叠级别按块而异,因为两者正文不同。Think 正文使用[紧凑 Markdown](../bug-fix/2026-09-17-thinking-markdown.zh.md),代码栏处于正常文档流中,`z-index: 1` 就够。压缩正文渲染 markdown,摘要里的围栏代码块会把自己的 banner 钉在 `z-index: 6`(`packages/client/ui-primitives/src/markdown/CodeBlock.module.css`),banner 里带一个 Copy 控件;因此压缩标题用 `z-index: 7`,并让那个 banner 停在自己的标题带下方,这样标题永远不会盖住 Copy 控件,banner 也不会盖住折叠按钮。标题带高度是一个组件局部量(`.compactionRow` 上的 `--dsh-compaction-header-height`),由折叠按钮的 `height` 与 banner 的 `top` 共用;banner 规则靠特异性压过 CodeBlock 的 `top: 0`,因为两张样式表分属不同的包。钉住的标题还把 hover 底覆盖为不透明的 `--dsw-alias-interactive-bg-hover-solid` token,并在整行展开期间都保持直角:默认的半透明 hover token 会在指针落到折叠按钮准备折叠的瞬间让滚动的正文透出,而基础的 6px 圆角会让同样的正文在四角露出来。`:hover` 把该规则的特异性抬到文件更靠后的 hover 基础规则之上,所以胜负不由声明顺序决定。另外还有两个元素为了避开同一批代码 banner 取 `z-index: 7`:输入框座与轮次导航轨道槽(`TurnNavigator.module.css`,ui-chat)。同级之间的先后顺序只写在一处:`packages/client/ui-conversation/src/client/skeleton/ConversationRoot.module.css` 中输入框座的注释。 - -规则被限定作用域,只影响这两个不封顶的块;封顶的工具行保持原有行为,因为让一连串工具行的 sticky 标题层层堆叠会把它们全挤在顶部。Think 规则是 `packages/client/ui-chat/src/client/chat/ReasoningRow.module.css` 的 `.root[data-expanded] [data-open] [data-disclosure-row]`,用 `DisclosureRow` 的 `data-open` 门控,折叠的 Think 行绝不钉住,并限定在 Think 行自己的 root 之下,不触及任何工具调用 variant。压缩规则是 `packages/client/ui-chat/src/client/chat/MessageItem.module.css` 的 `.compactionRow:has(.compactionBody) .compactionButton`,正文兄弟节点只在展开时存在于 DOM,所以 `:has()` 就以展开状态门控钉住。 - -不改动任何 session、wire、durable event 或 model-visible 契约;这是一处纯展示层的 CSS 改动,由既有组件拥有。 - -## 曾考虑的替代方案 - -**用 `max-height` 加内部滚动给这两个正文封顶,与工具卡片一致。** 否决:Think 正文是刻意不封顶的,好让推理读起来像普通消息正文([web-thinking-tail-scroll](../../archived/feature/2026-08-02-web-thinking-tail-scroll.md) 和 `.thinkBody` 注释拥有这一意图),压缩摘要是一个阅读表面。内层滚动框会引入嵌套滚动,滚轮在光标下从整页切换到框内,并把很长的技术性正文压进一个更难读的小窗口。钉住标题保留了流式正文的阅读模型,同时让折叠按钮依然够得着。 - -**为一致性给每个可折叠行都加钉住标题。** 否决:封顶的工具行因为正文在内部滚动,标题本来就一直可见,没有需要解决的问题。让它们的标题相对整页钉住,会在滚过一连串工具调用时把每个展开行各自钉住的标题堆叠在视口顶部,这是视觉噪音,不是一致性。 - -**把摘要内代码栏的偏移交给 `CodeBlock` 承担。** 否决:可以让 `CodeBlock` 读取使用方设置的 `--dsl-code-block-banner-top` 之类的属性,这样代码块继续拥有自己的几何,也避免用 `:has()` 选择器伸进另一个包的 DOM。代价是把所有使用方的布局都接到一个只有这个块会设置的属性上,而这段偏移是这个块的呈现问题、不是代码块的:局部规则把偏移写在造成它的折叠按钮旁边,而 `[data-code-block-banner]` 本来就是 `CodeBlock` 为使用者样式发布的钩子。 - -**在 `DisclosureRow` 基元上加一条共享的 sticky 规则。** 否决:`DisclosureRow` 支撑 Think、每个工具调用 variant 以及 context-injection 行;在那里加规则会一并命中封顶行。该行为只属于不封顶的消费者,所以各自把规则限定在自己的块上。 - -## 后果 - -很长的 Think 块或压缩摘要的折叠按钮无需把正文滚回起点就能够到,同时两个正文都保持作为整页正文流动。改动是纯 CSS:没有计时器、订阅、durable state、DOM 结构改动或传输流量。压缩规则使用 CSS `:has()` 选择器,Web UI 所面向的各浏览器均支持。 - -## 测试 - -单元测试钉住选择器所依赖的 DOM 锚点:`packages/client/ui-chat/tests/reasoning-row.client.spec.tsx` 断言展开的 Think 行在 `[data-variant='think'][data-expanded] [data-open]` 之下嵌套了 `[data-disclosure-row]`,且折叠行没有 `[data-open]`;`packages/client/ui-chat/tests/chat-branch-tails.client.spec.tsx` 断言压缩正文只在展开时出现在 `.compactionRow` 之下。`packages/client/ui-chat/tests/sticky-header-styles.client.spec.ts` 把这两条规则当作 CSS 文本读取,逐条固定钉住所依赖的声明:`position: sticky`、`top: 0`、直角圆角、两侧各自的 `z-index` 级别、折叠按钮高度与代码 banner 偏移共用的那一个量,以及不透明的 hover token——因为 jsdom 不计算 sticky 布局,渲染类测试也无法在某条声明被改动时变红。 - -真实浏览器证据由两条 keyless Chromium e2e 路径承载。`apps/web/tests/lifecycle-chrome.e2e.ts` 展开已结束轮次的 process 行、展开 Think 行,并断言其标题计算出 `position: sticky`、`top: 0px`(折叠时非 sticky);该 fixture 录制的 reasoning 只有一行,太短不足以溢出,所以它只证明 CSS 解析到了 Think 标题。`apps/web/tests/seeded-history.e2e.ts` 承载「钉住态随滚动」的证据:它种入一个摘要长度由本套件控制的压缩(一个围栏代码块加 40 个列表项),把视口压小以强制溢出,滚动到 marker 的钉住态,断言标题计算出 `position: sticky`、`top: 0px`、`z-index` 大于代码块 banner、滚动后仍停在滚动口顶边、其自身中心是命中测试命中的最上层元素(折叠按钮保持可点击),以及其 hover 底保持完全不透明(alpha 1)。同一用例还把摘要里的代码 banner 滚到它自己的钉住位置,断言 banner 停在标题带下方、且 banner 的 Copy 控件在它的中心点命中自身,使这条偏移不会退化成控件被盖住。同一用例还写出一份 keyless 几何 golden(`snapshots/web/seeded-history/sticky-geometry.expected.md`),把这些与平台无关的语义事实固定下来,因为这是一处用户可见、但不改动 DOM 与无障碍名称的 CSS 行为,无障碍 golden 捕获不到它。PR demo GIF 用真实服务器加真实模型轮次录制,承载视觉证据:钉住的标题在正文滚动时停留在顶部。 diff --git a/.agents/notes/implemented/feature/2026-08-05-agent-teams.i18n.yaml b/.agents/notes/implemented/feature/2026-08-05-agent-teams.i18n.yaml index ab88dedfc2..b0104c7e52 100644 --- a/.agents/notes/implemented/feature/2026-08-05-agent-teams.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-05-agent-teams.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-05-agent-teams.md -2026-08-05-agent-teams.md: bdf646db9b1617ded7f50f4145887f34a52ae83c -2026-08-05-agent-teams.zh.md: 5386044edb57dad9fabf114d565808c735846464 +2026-08-05-agent-teams.md: d108d4f9833aca2a24c84d18d895e7ec63c0bfc2 +2026-08-05-agent-teams.zh.md: abf2ce3716e44effc8e34c0911cd9561d815aa08 diff --git a/.agents/notes/implemented/feature/2026-08-05-agent-teams.md b/.agents/notes/implemented/feature/2026-08-05-agent-teams.md index bdf646db9b..d108d4f983 100644 --- a/.agents/notes/implemented/feature/2026-08-05-agent-teams.md +++ b/.agents/notes/implemented/feature/2026-08-05-agent-teams.md @@ -40,7 +40,7 @@ Fresh children have no inherited conversation. Fork children capture the Lead's Peer communication is a Lead-log mailbox. `team/message/queued` is appended and flushed before delivery. The target message carries the stable message id and sender identity in both durable source metadata and a short model-visible prefix. A target receipt is acknowledged with `team/message/delivered` only after its pending inbox item or recorded user message is flushed. Immediate admission is serialized per target in queued-log order, recovery retries queued-minus-delivered in the same order, and delivery folds live or persisted target inbox/history state before cold resume. Every current-version Team payload is runtime-validated before entering replay state. The Team runtime tracks dispatch and asynchronous acknowledgement work from synchronous admission until settlement; disposal closes admission and awaits both before removing the service. Current waiters wake only after the owning Team event flush succeeds. -`send_message` always attempts Steer delivery. A running target receives the message at the nearest step boundary, an idle target starts a turn, and an inactive teammate cold-resumes. Success means the message is already durable even when temporary delivery failure leaves it queued. The mechanism provides process-local retry and target-Session de-duplication, not a cross-process exactly-once claim. The [Team Steer messaging decision](../../archived/simplification/2026-08-30-team-send-message-steer.md) owns the single-tool scheduling rationale. +`send_message` always attempts Steer delivery. A running target receives the message at the nearest step boundary; an inactive target starts a turn if loaded or cold-resumes otherwise. Success means the message is already durable even when temporary delivery failure leaves it queued. The mechanism provides process-local retry and target-Session de-duplication, not a cross-process exactly-once claim. The [Team Steer messaging decision](../../archived/simplification/2026-08-30-team-send-message-steer.md) owns the single-tool scheduling rationale. Shared tasks are complete snapshots with Team-local ids and monotonic revisions. Every mutation carries `expectedRevision`. Any member creates, reads, or claims a ready unowned task; the owner or Lead edits and transitions it, while only the Lead assigns another member. Numeric task ids remain within the safe-integer allocation range, and exhaustion fails without reusing an id. Dependencies must name non-deleted tasks and form a complete DAG. Deleted tasks are retained tombstones. `writeScopes` are normalized path prefixes that produce overlap diagnostics but never block claim or authorize a write. @@ -72,6 +72,8 @@ Worktree isolation is not a harness runtime behavior. A deployment or prompt may Package tests cover identity, name and authority checks, provider selection, reserved-id persistence collisions, child-before-Lead flush ordering, durable provisioning failure and pending-inbox JSONL reconciliation, concurrent target-local ordering, pending/history de-duplication, mailbox limits, post-flush notification, bounded disposal with in-flight creation and dispatch cancellation, failed-member cleanup, task CAS and DAG validation, write-scope warnings, wait cancellation/timeout, inbox-preserving interruption, ordinary-fork isolation, legacy-control shadowing, compact declared-schema result rendering, and scoped registration HMR at per-file 100% coverage. A keyless product snapshot loads the Agent Teams profile bundle through `dsh --profile headless` and pins its complete model-visible tool list, Team policy, and durable workflow projection for two teammates, dependent tasks, peer delivery, waiting, completion, and aggregation. A CLI e2e reuses the same deterministic adapter and verifies normal process exit with persisted Team and child logs. +Model-facing member identities and availability follow the [tool projection decision](../simplification/2026-09-15-model-agent-availability-and-team-targets.md); service residency and durable identity remain distinct. + ## Consequences The Lead Session grows with whole task/member snapshots and mailbox acknowledgements. This favors independently inspectable recovery over compact deltas; configured task and pending-mail bounds cap active state, while deleted and delivered history remains append-only until broader Session retention applies. diff --git a/.agents/notes/implemented/feature/2026-08-05-agent-teams.zh.md b/.agents/notes/implemented/feature/2026-08-05-agent-teams.zh.md index 5386044edb..abf2ce3716 100644 --- a/.agents/notes/implemented/feature/2026-08-05-agent-teams.zh.md +++ b/.agents/notes/implemented/feature/2026-08-05-agent-teams.zh.md @@ -40,7 +40,7 @@ fresh child 不继承对话。fork child 只捕获一次 Lead 已完成 turn 前 Peer 通讯使用 Lead 日志 mailbox。投递前先追加并 flush `team/message/queued`。target message 会在持久 source metadata 与短模型可见前缀中同时携带稳定 message id 和 sender identity。只有 pending inbox 条目或已记录用户消息完成 flush,Lead 日志才写入 `team/message/delivered` acknowledgement。即时准入按 target 和 queued 日志顺序串行化,恢复按同一顺序重试 queued-minus-delivered,并在冷恢复前折叠 live 或 persisted target 的 inbox/历史状态。每个当前版本 Team payload 都会经过运行时验证后才进入 replay state。Team runtime 从同步准入到 settlement 全程跟踪 dispatch 与异步 acknowledgement 工作;dispose 会关闭准入,并在移除服务前等待两者。当前 waiter 只在所属 Team event flush 成功后被唤醒。 -`send_message` 始终尝试 Steer 投递。running target 在最近的步骤边界收到消息,idle target 启动一个轮次,inactive teammate 则冷恢复。即使临时投递失败让消息保持 queued,成功也表示消息已经持久化。该机制提供进程内重试与 target Session 去重,不宣称跨进程 exactly-once。[Team Steer 消息决策](../../archived/simplification/2026-08-30-team-send-message-steer.md)负责单工具调度的理由。 +`send_message` 始终尝试 Steer 投递。running target 在最近的步骤边界收到消息,inactive target 在已加载时启动一个轮次,否则冷恢复。即使临时投递失败让消息保持 queued,成功也表示消息已经持久化。该机制提供进程内重试与 target Session 去重,不宣称跨进程 exactly-once。[Team Steer 消息决策](../../archived/simplification/2026-08-30-team-send-message-steer.md)负责单工具调度的理由。 共享 task 是带 Team-local id 与单调 revision 的完整快照。每次变更都携带 `expectedRevision`。任意 member 可以创建、读取或 claim ready 且无 owner 的任务;Owner 或 Lead 可以编辑和转换;只有 Lead 可以分配给另一个 member。数字 task id 保持在安全整数分配范围内;该范围耗尽时会失败,不会复用 id。依赖必须指向未删除任务,并形成完整 DAG。删除任务保留为 tombstone。`writeScopes` 是规范化路径前缀,只产生重叠诊断,绝不会阻止 claim 或授予写权限。 @@ -72,6 +72,8 @@ Worktree isolation 不是 harness runtime 行为。deployment 或 prompt 可以 Package test 以逐文件 100% coverage 覆盖身份、名字与权限检查、provider 选择、预留 id 持久化冲突、child-before-Lead flush 顺序、持久 provisioning 失败与 pending-inbox JSONL 对账、target-local 并发顺序、pending/history 去重、mailbox 限额、flush 后 notification、取消在途创建与 dispatch 的有界 dispose、failed member cleanup、task CAS 与 DAG 校验、write-scope warning、wait cancel/timeout、保留 inbox 的 interrupt、普通 fork 隔离、旧 control shadowing、声明 schema 的紧凑结果渲染与 scoped registration HMR。一条 keyless 产品快照会通过 `dsh --profile headless` 加载 Agent Teams profile bundle,并为两个 teammate、依赖任务、peer 投递、等待、完成和汇总固定完整的面向模型工具列表、Team policy 与持久 workflow 投影。CLI e2e 会复用同一个确定性 adapter,并验证带持久 Team 与 child 日志的正常退出。 +模型可见的成员身份与可用状态遵循[工具投影决策](../simplification/2026-09-15-model-agent-availability-and-team-targets.zh.md);服务驻留状态和持久身份仍保持区分。 + ## Consequences Lead Session 会随着完整 task/member 快照与 mailbox acknowledgement 增长。该设计用可独立检查的恢复能力换取更紧凑的 delta;配置的 task 与 pending-mail 限额限制 active state,而 deleted 与 delivered 历史会保持 append-only,直到更广泛的 Session retention 生效。 diff --git a/.agents/notes/implemented/feature/2026-08-06-manager-owned-subagent-settlement-delivery.i18n.yaml b/.agents/notes/implemented/feature/2026-08-06-manager-owned-subagent-settlement-delivery.i18n.yaml index c79578f47b..a8b84e3d55 100644 --- a/.agents/notes/implemented/feature/2026-08-06-manager-owned-subagent-settlement-delivery.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-06-manager-owned-subagent-settlement-delivery.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-06-manager-owned-subagent-settlement-delivery.md -2026-08-06-manager-owned-subagent-settlement-delivery.md: 5553d4c06a6776f0ef025e19ae7f4f67fe277a56 -2026-08-06-manager-owned-subagent-settlement-delivery.zh.md: 6d7ee756ad531e123508e8667adc4e5f99c2b9ea +2026-08-06-manager-owned-subagent-settlement-delivery.md: 7f6cfc90862b5bb32368b4b0e1fbe068ba0cbf66 +2026-08-06-manager-owned-subagent-settlement-delivery.zh.md: 23c5506f79d4d95308b0533e8d507ac856c4d85e diff --git a/.agents/notes/implemented/feature/2026-08-06-manager-owned-subagent-settlement-delivery.md b/.agents/notes/implemented/feature/2026-08-06-manager-owned-subagent-settlement-delivery.md index 5553d4c06a..7f6cfc9086 100644 --- a/.agents/notes/implemented/feature/2026-08-06-manager-owned-subagent-settlement-delivery.md +++ b/.agents/notes/implemented/feature/2026-08-06-manager-owned-subagent-settlement-delivery.md @@ -90,7 +90,7 @@ The refusal and interruption wordings are pinned verbatim in unit tests rather t **Filter the canonical child output.** Removing nontext blocks in `AssistantOutputFold` would discard assistant content needed by SDK and UI consumers. Only the parent notice requires a text projection. -**Relax the Messages serializer.** Accepting or silently discarding invalid user-role blocks would conceal the producer's role conversion error. Notice construction supplies content that every parent provider can represent while protocol validation remains strict. +**Rely on serializer tolerance for notice construction.** Notice construction supplies text that every parent provider can represent; adapter filtering cannot replace that producer responsibility. The [saved-input compatibility decision](../bug-fix/2026-09-18-messages-input-history-compatibility.md) supersedes the rejection of Messages input filtering for already recorded notices, which a producer fix cannot change. ## Consequences diff --git a/.agents/notes/implemented/feature/2026-08-06-manager-owned-subagent-settlement-delivery.zh.md b/.agents/notes/implemented/feature/2026-08-06-manager-owned-subagent-settlement-delivery.zh.md index 6d7ee756ad..23c5506f79 100644 --- a/.agents/notes/implemented/feature/2026-08-06-manager-owned-subagent-settlement-delivery.zh.md +++ b/.agents/notes/implemented/feature/2026-08-06-manager-owned-subagent-settlement-delivery.zh.md @@ -90,7 +90,7 @@ Status: implemented **过滤规范的子级输出。** 在 `AssistantOutputFold` 中删除非文本块,会丢弃 SDK 和 UI 消费方需要的 assistant 内容。只有父级通知需要文本投影。 -**放宽 Messages 序列化器。** 接受或静默丢弃用户角色中的无效块,会掩盖生产方的角色转换错误。通知构建提供每种父级提供方都能表示的内容,协议校验仍保持严格。 +**依赖序列化器容错来构建通知。** 通知构建提供每种父级提供方都能表示的文本;适配器过滤不能替代生产方的责任。[已保存输入兼容决策](../bug-fix/2026-09-18-messages-input-history-compatibility.zh.md)取代了对已记录通知进行 Messages 输入过滤的拒绝,因为生产方修复无法改变这些通知。 ## 后果 diff --git a/.agents/notes/implemented/feature/2026-09-04-web-clickable-link-styles.md b/.agents/notes/implemented/feature/2026-09-04-web-clickable-link-styles.md deleted file mode 100644 index eddceb8af8..0000000000 --- a/.agents/notes/implemented/feature/2026-09-04-web-clickable-link-styles.md +++ /dev/null @@ -1,35 +0,0 @@ -# Agent Note: Web clickable-link language — link alias, dotted hover underline, category glyphs - -Status: implemented - -English | [中文](2026-09-04-web-clickable-link-styles.zh.md) - -## Problem - -Clickable artifact links in the chat transcript wore four different costumes: markdown anchors and prose file mentions were business-primary blue with a solid hover underline, web search/fetch links matched that pair, produced-file chips were grey pills (label-secondary text on interactive-bg-hover, 96px max width), and workflow member links carried a resting solid underline. Nothing marked what a link opens (browser, host app, Finder, in-app view), and link color was coupled to `--dsw-alias-state-business-primary`, which also drives focus rings and state dots, so tuning link color risked unrelated surfaces. - -## Decision - -One link language across the transcript's clickable-link surfaces — markdown anchors (including reference links, mailto, and URL-promoted inline code), prose file mentions, web search source links and the fetch URL, produced-file chips, and workflow member links: - -- Color comes through a dedicated `--dsw-alias-link` alias in `design-platform.css` (light `deepseek-500`, dark `deepseek-400`), decoupled from `state-business-primary`; links render at `font-weight: 500` with no underline at rest and `underline dotted` at 3px offset on hover/focus. -- A leading category glyph — `LinkIconMedium` in ui-primitives with kinds `url`, `folder`, `code`, `image`, `document`, and `other` (paper) — renders `currentColor` only; the `url` glyph is the globe, or the destination site's own mark when its host is a known site ([known-site link marks](2026-09-16-known-site-link-marks.md)); `classifyLinkPath` folds the [shared detailed file-type classification](2026-09-08-shared-file-type-icons.md) into those six link categories, code and web extensions share the code glyph, and spreadsheet extensions use the document glyph. Two anchor shapes carry no glyph: workflow member links (an in-app member view fits no file or URL category) and anchors wrapping only images (a badge or thumbnail — a dangling globe beside the picture leads no text). Inline glyphs sit at 1.1em with a −0.25em baseline offset; the flex-centered produced-file glyphs instead nudge 1.2px down because the 22px text box carries its glyphs below box center. -- Produced-file chips drop the grey pill and the 96px cap: plain link-blue text at natural width that shrinks with ellipsis only when the row overflows; the container-query bands still budget 96px per chip when choosing how many chips to show. -- [Compact Thinking Markdown](../bug-fix/2026-09-17-thinking-markdown.md) retains tertiary text color and a resting dotted underline to preserve secondary emphasis. -- Deliberately untouched: ToolRow's grey dotted file links, and the grey "Show in folder" action (it gains the folder glyph but keeps its grey style). -- In the same pass, the inline-code chip tint moved from `neutral-bluish-100` to `neutral-50` (dark: `neutral-800`) and gained a 0.5px l1 border. - -Coverage: a link-icon unit spec (one distinct glyph per kind, both stroke weights, known-site marks, and the classification table), refreshed markdown-dom fixtures, and the `clickable-links-gallery` web e2e — one settled keyless turn rendering every clickable link form — registered in the host compiler face (`tsconfig.host.json`) like its other scaffold-importing siblings. - -## Alternatives considered - -- **Colored Word/Excel/PPT/PDF link glyphs.** Rejected: fixed brand fills break the icon set's currentColor-only rule, so those extensions fold into the single outline `document` glyph. The larger file-card primitive uses distinct current-color silhouettes instead. -- **Per-extension link icons.** Collapsed to six categories: more glyphs than the eye can parse at 14px adds noise. The 28px `FileTypeIcon` owns the more detailed file identities, and per-site marks live behind the same `url` category ([known-site link marks](2026-09-16-known-site-link-marks.md)). -- **Keeping links on `state-business-primary`.** Darker link blues (blue-600/650/700 were auditioned and reverted) would have dragged focus rings and state dots along; the dedicated alias localizes any future tuning to one line. -- **Glyphs on ToolRow path links.** Rejected: tool rows keep their quieter grey dotted affordance, and leading glyphs there would stack icons in already dense rows. - -## Consequences - -- A new clickable artifact surface should consume `--dsw-alias-link` and the `LinkIconMedium` vocabulary rather than introduce another color or underline form; the rule lives in [docs/web-styling.md](../../../../docs/web-styling.md). -- Long produced-file names take their natural width; when a row overflows, flex shrinks all chips proportionally, so several long names shrink together instead of the last one yielding first. -- mailto links currently share the `url` globe; a distinct mail category is a one-line addition if ever wanted. diff --git a/.agents/notes/implemented/feature/2026-09-04-web-clickable-link-styles.zh.md b/.agents/notes/implemented/feature/2026-09-04-web-clickable-link-styles.zh.md deleted file mode 100644 index dbb2c9ac34..0000000000 --- a/.agents/notes/implemented/feature/2026-09-04-web-clickable-link-styles.zh.md +++ /dev/null @@ -1,35 +0,0 @@ -# Agent Note: Web 可点击链接语言——链接别名、hover 点状下划线、分类图标 - -Status: implemented - -[English](2026-09-04-web-clickable-link-styles.md) | 中文 - -## 问题 - -会话记录里的可点击产物链接有四套互不一致的样式:Markdown 锚点和正文文件引用是 business-primary 蓝加 hover 实线下划线,网页搜索/抓取链接与之相同,产物 chips 是灰色药丸(interactive-bg-hover 底上的 label-secondary 文字,96px 最大宽度),workflow 成员链接则带常驻实线下划线。没有任何标记说明链接点开后去哪儿(浏览器、宿主应用、Finder、应用内视图),而且链接颜色耦合在 `--dsw-alias-state-business-primary` 上——它同时驱动焦点环和状态点,调链接色会波及无关表面。 - -## 决策 - -会话记录的可点击链接表面——Markdown 锚点(含引用式链接、mailto、被提升为链接的 inline code)、正文文件引用、网页搜索来源链接与抓取 URL、产物 chips、workflow 成员链接——统一为一套链接语言: - -- 颜色经由 `design-platform.css` 中专用的 `--dsw-alias-link` 别名(亮色 `deepseek-500`,暗色 `deepseek-400`),与 `state-business-primary` 解耦;链接以 `font-weight: 500` 呈现,默认无下划线,hover/focus 时为 3px offset 的 `underline dotted`。 -- 前置分类图标——ui-primitives 的 `LinkIconMedium`,kind 为 `url`、`folder`、`code`、`image`、`document`、`other`(纸张)——只渲染 `currentColor`;`url` 图形为地球,目的地主机属于已知站点时则为该站点自己的标记([已知站点链接标记](2026-09-16-known-site-link-marks.zh.md));`classifyLinkPath` 把[共享精细文件类型分类](2026-09-08-shared-file-type-icons.zh.md)折叠进这六种链接类别,代码和网页扩展名共用 code 图形,表格扩展名使用 document 图形。两类锚点不带图标:workflow 成员链接(应用内成员视图不属于任何文件或 URL 类别)和只包图片的锚点(徽章或缩略图——图片旁悬着的地球没有可引导的文字)。行内图标为 1.1em、基线偏移 −0.25em;flex 居中的产物图标则下移 1.2px,因为 22px 文字盒的字形低于盒中心。 -- 产物 chips 去掉灰色药丸和 96px 上限:纯链接蓝文字按自然宽度展示,仅当整行溢出时才收缩出省略号;容器查询档位在决定展示几个 chip 时仍按每个 96px 预算。 -- [紧凑 Thinking Markdown](../bug-fix/2026-09-17-thinking-markdown.zh.md) 保留 tertiary 文字色和默认点状下划线,以保持次级强调。 -- 刻意不动:ToolRow 的灰色点线文件链接,以及灰色的「在文件夹中显示」操作(它获得文件夹图标但保持灰色样式)。 -- 同一批次中,inline code 底色从 `neutral-bluish-100` 换到 `neutral-50`(暗色:`neutral-800`),并新增 0.5px l1 描边。 - -覆盖:link-icon 单测(每个 kind 一个独立图形、两种描边字重、已知站点标记和分类表)、刷新后的 markdown-dom 夹具,以及 `clickable-links-gallery` web e2e——一个 settled 的 keyless 回合渲染全部可点击链接形态——像其他引 scaffold 的同类一样注册进 host 编译面(`tsconfig.host.json`)。 - -## 备选方案 - -- **彩色 Word/Excel/PPT/PDF 链接图形。** 否决:固定品牌填充违反图标集 currentColor-only 规则,因此这些扩展名并入单一的 outline `document` 图形。较大的文件卡片 primitive 改用各自不同的 current-color 轮廓。 -- **每个扩展名一个链接图标。** 收敛为六个类别:14px 下超出肉眼可分辨数量的图形只会增加噪音。28px 的 `FileTypeIcon` 拥有更精细的文件身份;按站点标记位于同一 `url` 类别之下([已知站点链接标记](2026-09-16-known-site-link-marks.zh.md))。 -- **链接继续用 `state-business-primary`。** 更深的链接蓝(试过 blue-600/650/700 又回退)会连带焦点环和状态点;专用别名把未来的调色收敛到一行。 -- **给 ToolRow 路径链接加图形。** 否决:工具行保持更安静的灰色点线示能,在已经很密的行里加前置图形会造成图标堆叠。 - -## 后果 - -- 新的可点击产物表面应消费 `--dsw-alias-link` 和 `LinkIconMedium` 词汇,而不是引入另一种颜色或下划线形态;规则记录在 [docs/web-styling.md](../../../../docs/web-styling.zh.md)。 -- 长产物文件名按自然宽度展示;整行溢出时 flex 按比例收缩所有 chip,几个长名字一起收缩,而不是最后一个先让位。 -- mailto 链接目前共用 `url` 地球图形;若将来需要独立的邮件类别,一行即可加上。 diff --git a/.agents/notes/implemented/feature/2026-09-05-sidebar-text-preview-and-file-tree.i18n.yaml b/.agents/notes/implemented/feature/2026-09-05-sidebar-text-preview-and-file-tree.i18n.yaml index a2f18c11cb..702e3bf1fc 100644 --- a/.agents/notes/implemented/feature/2026-09-05-sidebar-text-preview-and-file-tree.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-09-05-sidebar-text-preview-and-file-tree.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-05-sidebar-text-preview-and-file-tree.md -2026-09-05-sidebar-text-preview-and-file-tree.md: 0142b325004a1fc018e944ed908d600e75f0ab0d -2026-09-05-sidebar-text-preview-and-file-tree.zh.md: d40ca8c790c9a7208a2243422c0973fc293a5915 +2026-09-05-sidebar-text-preview-and-file-tree.md: 8ddb1a336307011d8f6ce9bbe927f00642a83e60 +2026-09-05-sidebar-text-preview-and-file-tree.zh.md: 294a2b456008837882f7f82e55e8e9e435254554 diff --git a/.agents/notes/implemented/feature/2026-09-05-sidebar-text-preview-and-file-tree.md b/.agents/notes/implemented/feature/2026-09-05-sidebar-text-preview-and-file-tree.md index 0142b32500..8ddb1a3363 100644 --- a/.agents/notes/implemented/feature/2026-09-05-sidebar-text-preview-and-file-tree.md +++ b/.agents/notes/implemented/feature/2026-09-05-sidebar-text-preview-and-file-tree.md @@ -20,7 +20,7 @@ Three tab types ship with the Sidebar: the **guide** (`ui-sidebar-right`), the * The guide is what a pane shows before it holds content. Its registration is `{ id: '@deepseek-ai/dsh-client-ui-sidebar-right/guide', kind: 'guide', priority: 'builtin', title }` with no `patterns`: a guide views nothing, so it is opened by kind through `openTab` and recorded under the page address `sidebar://guide`, which is the registry's bookkeeping and never composed by a caller. The tab's title is `开始` / `Start`, captured into the layout record when the pane is seeded, so a later language change relabels the type and not tabs already open. -The body projects every registered type's `guide[]` in `order` through the registry's observable `guide()` list, so a type registering later appears without the guide knowing it. [Guide start page and stat pill refinements](2026-09-10-guide-start-page-and-stat-pill-refinements.md) owns the compass, optional descriptions, fallback glyph, and current capsule layout. Picking a capsule calls `tabActions.openTab(entry.kind, { replaceTab: true })`: the picked type opens in the guide's own tab, and the guide is gone. The guide is a doorway, not a page that stays open beside what it opened. +The body projects every registered type's `guide[]` in `order` through the registry's observable `guide()` list, so a type registering later appears without the guide knowing it. The [Sidebar README](../../../../packages/client/ui-sidebar-right/README.md#the-guide) describes the compass, optional descriptions, fallback glyph, and current capsule layout. Picking a capsule calls `tabActions.openTab(entry.kind, { replaceTab: true })`: the picked type opens in the guide's own tab, and the guide is gone. The guide is a doorway, not a page that stays open beside what it opened. The body is also the replacement seam. It renders the `sidebar.right.tab.guide` chain with the shipped guide as the chain's fallback, so a product that registers its own entry takes the whole body, and with no entry, or every entry declining, the shipped guide draws. Because the shipped guide is the fallback and not a chain entry, there is always exactly one body and it cannot be outvoted by accident. @@ -48,7 +48,7 @@ A failed read keeps content already shown and adds a localized failure with a re ### The file tree -`files` is a page type, not a viewer: it claims no address. Its registration is `{ kind: 'files', id: '@deepseek-ai/dsh-client-ui-sidebar-files', priority: 'builtin', title, guide: [{ order: 10, title, description, icon: FolderSheetGlyph }] }` — no `patterns`, because nothing navigates *to* a file tree by address; the guide's entry box opens the type itself. `FolderSheetGlyph` adapts the shared coloured folder sheet to the guide's requested glyph size; the [guide refinement](2026-09-10-guide-start-page-and-stat-pill-refinements.md) owns that presentation choice. `id` is the implementation's identity in the Tab system and doubles as the `key` of the body seat `sidebar.right.pane.tab`, so the same string names the type and the component that draws it. `register()` returns a disposer and goes through `ctx.effect`, as every registration does. +`files` is a page type, not a viewer: it claims no address. Its registration is `{ kind: 'files', id: '@deepseek-ai/dsh-client-ui-sidebar-files', priority: 'builtin', title, guide: [{ order: 10, title, description, icon: FolderSheetGlyph }] }` — no `patterns`, because nothing navigates *to* a file tree by address; the guide's entry box opens the type itself. `FolderSheetGlyph` adapts the shared coloured folder sheet to the guide's requested glyph size; the [Sidebar README](../../../../packages/client/ui-sidebar-right/README.md#the-guide) describes that presentation rule. `id` is the implementation's identity in the Tab system and doubles as the `key` of the body seat `sidebar.right.pane.tab`, so the same string names the type and the component that draws it. `register()` returns a disposer and goes through `ctx.effect`, as every registration does. The root is the session's working directory as the Host reports it in the session list (`useSessions().byId[sessionId].cwd`), labelled by `workspaceTitleOf` from `dsh-util-workspace-path` — the final non-empty path segment — with the root string itself as the label when the path is separator-only. A session without a working directory shows one line (`noWorkspace`) and issues no request. There is no root chooser and no way to browse upward: the Host's `list` refuses paths outside the Session's workspace root, so the one directory the client can list is the one it shows. diff --git a/.agents/notes/implemented/feature/2026-09-05-sidebar-text-preview-and-file-tree.zh.md b/.agents/notes/implemented/feature/2026-09-05-sidebar-text-preview-and-file-tree.zh.md index d40ca8c790..294a2b4560 100644 --- a/.agents/notes/implemented/feature/2026-09-05-sidebar-text-preview-and-file-tree.zh.md +++ b/.agents/notes/implemented/feature/2026-09-05-sidebar-text-preview-and-file-tree.zh.md @@ -20,7 +20,7 @@ Sidebar 随包交付三个 tab 类型:**引导页**(`ui-sidebar-right`)、 引导页是 pane 承载内容之前显示的东西。它的注册定义是 `{ id: '@deepseek-ai/dsh-client-ui-sidebar-right/guide', kind: 'guide', priority: 'builtin', title }`,没有 `patterns`:引导页不查看任何东西,所以经 `openTab` 按 kind 打开,并记在页地址 `sidebar://guide` 之下——那是注册表自己的记账,调用方从不拼它。tab 标题是 `开始` / `Start`,在 pane 播种时捕获进布局记录,于是之后切换语言只重标类型,不改已开着的 tab。 -正文按 `order` 从每个已注册类型的 `guide[]` 投影入口,并通过注册表可观察的 `guide()` 列表更新,因此后注册的类型无需引导页感知即可出现。[引导起始页与统计 pill 的细化](2026-09-10-guide-start-page-and-stat-pill-refinements.zh.md)负责罗盘、可选描述、兜底图标和当前胶囊布局。点选胶囊会调用 `tabActions.openTab(entry.kind, { replaceTab: true })`:被选的类型在引导页自己的 tab 里打开,引导页随之消失。引导页是一扇门,不是留在被打开者旁边的一页。 +正文按 `order` 从每个已注册类型的 `guide[]` 投影入口,并通过注册表可观察的 `guide()` 列表更新,因此后注册的类型无需引导页感知即可出现。[Sidebar README](../../../../packages/client/ui-sidebar-right/README.zh.md#the-guide)说明罗盘、可选描述、兜底图标和当前胶囊布局。点选胶囊会调用 `tabActions.openTab(entry.kind, { replaceTab: true })`:被选的类型在引导页自己的 tab 里打开,引导页随之消失。引导页是一扇门,不是留在被打开者旁边的一页。 体同时也是替换接缝。它渲染 `sidebar.right.tab.guide` 链,并以随包交付的引导页作为链的 fallback,于是注册了自己入口的产品接管整个体,而没有入口、或每个入口都拒绝时,随包交付的引导页照常绘制。因为随包交付的引导页是 fallback 而不是链上的一个入口,所以永远恰有一个体,也不可能被意外投掉。 @@ -48,7 +48,7 @@ store 是 Slot 标准件:每会话一个独占实例,按 tab id 分桶,持 ### 文件树 -`files` 是页类型,不是查看器:它不认领任何地址。注册定义是 `{ kind: 'files', id: '@deepseek-ai/dsh-client-ui-sidebar-files', priority: 'builtin', title, guide: [{ order: 10, title, description, icon: FolderSheetGlyph }] }`——没有 `patterns`,因为没有谁按地址导航*到*一棵文件树;引导页的入口框打开的是类型本身。`FolderSheetGlyph` 把共享的彩色文件夹页适配到引导页请求的图标尺寸;[引导页细化记录](2026-09-10-guide-start-page-and-stat-pill-refinements.zh.md)负责这项展示选择。`id` 是这个实现在 Tab 系统里的唯一键,同时也是体坑位 `sidebar.right.pane.tab` 的 `key`,于是同一个串既命名类型也命名画它的组件。`register()` 返回 disposer 并经 `ctx.effect` 注册,与所有注册一致。 +`files` 是页类型,不是查看器:它不认领任何地址。注册定义是 `{ kind: 'files', id: '@deepseek-ai/dsh-client-ui-sidebar-files', priority: 'builtin', title, guide: [{ order: 10, title, description, icon: FolderSheetGlyph }] }`——没有 `patterns`,因为没有谁按地址导航*到*一棵文件树;引导页的入口框打开的是类型本身。`FolderSheetGlyph` 把共享的彩色文件夹页适配到引导页请求的图标尺寸;[Sidebar README](../../../../packages/client/ui-sidebar-right/README.zh.md#the-guide)说明这项展示规则。`id` 是这个实现在 Tab 系统里的唯一键,同时也是体坑位 `sidebar.right.pane.tab` 的 `key`,于是同一个串既命名类型也命名画它的组件。`register()` 返回 disposer 并经 `ctx.effect` 注册,与所有注册一致。 根是 Host 在会话列表里上报的会话工作目录(`useSessions().byId[sessionId].cwd`),标签由 `dsh-util-workspace-path` 的 `workspaceTitleOf` 给出——路径最后一个非空段——路径只有分隔符时用根串本身作标签。没有工作目录的会话只显示一行(`noWorkspace`),不发请求。没有根选择器,也不能往上浏览:Host 的 `list` 拒绝会话工作区根之外的路径,所以客户端能列的那一个目录就是它显示的目录。 diff --git a/.agents/notes/implemented/feature/2026-09-07-composer-session-stats-pills.i18n.yaml b/.agents/notes/implemented/feature/2026-09-07-composer-session-stats-pills.i18n.yaml index 44e214ff4c..0c2c513623 100644 --- a/.agents/notes/implemented/feature/2026-09-07-composer-session-stats-pills.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-09-07-composer-session-stats-pills.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-07-composer-session-stats-pills.md -2026-09-07-composer-session-stats-pills.md: 49976439e6c30603423278a86dba80c3c48eb39b -2026-09-07-composer-session-stats-pills.zh.md: 09b956c6607ebebb10a525161090f434b735d233 +2026-09-07-composer-session-stats-pills.md: d165aab471bc5958cb7a0b287d278a0ae48485ee +2026-09-07-composer-session-stats-pills.zh.md: dac136d4e60e50b026ed082eb7a006a80059c328 diff --git a/.agents/notes/implemented/feature/2026-09-07-composer-session-stats-pills.md b/.agents/notes/implemented/feature/2026-09-07-composer-session-stats-pills.md index 49976439e6..d165aab471 100644 --- a/.agents/notes/implemented/feature/2026-09-07-composer-session-stats-pills.md +++ b/.agents/notes/implemented/feature/2026-09-07-composer-session-stats-pills.md @@ -12,7 +12,7 @@ The session stats strip under the composer (`StatsLine`, ui-chat, mounted on `co `StatsPills` (packages/client/ui-chat/src/client/chat/StatsPills.tsx) replaces `StatsLine` on the same `conversation.composer.dock` slot; the losing variant is deleted, its shared helpers (`deriveStats`, `formatDuration`, `cacheHitPercent`, `billedInputTokens`) absorbed into the new module, and the dead `stats.llm`, `stats.toolCall`, `stats.ttftAverage`, `stats.tokensPerSecond`, and `stats.tokens` locale keys removed. -- **Two icon pills, two dialogs.** A gauge pill (new `IconGaugeOutlineRegular`, dial center optically dropped to y=8.75 because the bottom-open arc reads high) shows `{turns} 轮 {steps} 步` plus output TPS and click-opens the 会话统计 dialog (LLM time, tool time, average TTFT, TPS); a log with no timed figure would open an empty dialog, so that pill renders as a static reading instead of a button. A database pill (`IconDatabaseOutlineRegular`) shows the compact billed total plus cache-hit share and click-opens the Token 用量 dialog (cache hit, uncached input, cache read, output, and cache write when non-zero — exact counts). Both dialogs wear the shared `stat-dialog` module (portal panel, anchored placement, outside-dismiss, optionally externally owned open state) extracted for exactly this two-consumer split; the pills row owns one exclusive open slot, so opening either dialog closes the other, and each button carries an explicit `aria-label` that separates with ` · ` the segments the aria-hidden sep glyph joins visually. [Guide start page and stat pill refinements](2026-09-10-guide-start-page-and-stat-pill-refinements.md) owns the zero cache-write omission. +- **Two icon pills, two dialogs.** A gauge pill (new `IconGaugeOutlineRegular`, dial center optically dropped to y=8.75 because the bottom-open arc reads high) shows `{turns} 轮 {steps} 步` plus output TPS and click-opens the 会话统计 dialog (LLM time, tool time, average TTFT, TPS); a log with no timed figure would open an empty dialog, so that pill renders as a static reading instead of a button. A database pill (`IconDatabaseOutlineRegular`) shows the compact billed total plus cache-hit share and click-opens the Token 用量 dialog (cache hit, uncached input, cache read, output, and cache write when non-zero — exact counts). Both dialogs wear the shared `stat-dialog` module (portal panel, anchored placement, outside-dismiss, optionally externally owned open state) extracted for exactly this two-consumer split; the pills row owns one exclusive open slot, so opening either dialog closes the other, and each button carries an explicit `aria-label` that separates with ` · ` the segments the aria-hidden sep glyph joins visually. [StatsPills](../../../../packages/client/ui-chat/src/client/chat/StatsPills.tsx) owns the zero cache-write omission. - **Shared information tier.** Composer stats and per-turn tail metadata use the secondary font size minus one pixel. Assistant-tail metadata uses the action icons' tertiary label color and starts after an extra 8px separation from the action cluster. - **Data sourcing is unchanged in architecture.** Counts and times prefer the durable `sessionStats` projection with the window fold as the assembly-without-the-unit fallback ([whole-session counts](../../archived/bug-fix/2026-08-12-full-session-turn-step-counts.md)); token figures ride `tokenUsage` only, so an absent projection drops the usage pill rather than showing window-derived billing. Cache writes stay in the billed total and the cache-hit denominator ([projection decision](../architecture/2026-07-29-projected-token-usage-and-request-context.md)). Context occupancy remains owned by ui-conversation's `ContextMeter`, with its ring and percentage after the two stats pills below the input card. The common dock groups statistics together and leaves the toolbar for input actions; ui-chat does not import the context component. The context panel renders through a portal and uses ui-primitives for viewport-clamped positioning and outside-pointer dismissal, including when the statistics contribution is absent. - **Render discipline.** The row folds settled nodes only (`chat.legacy.nodes` identity), so streaming chunk frames cause zero rerenders — pinned by a render-count unit test. A session with no closed step and no billed tokens renders nothing. diff --git a/.agents/notes/implemented/feature/2026-09-07-composer-session-stats-pills.zh.md b/.agents/notes/implemented/feature/2026-09-07-composer-session-stats-pills.zh.md index 09b956c660..dac136d4e6 100644 --- a/.agents/notes/implemented/feature/2026-09-07-composer-session-stats-pills.zh.md +++ b/.agents/notes/implemented/feature/2026-09-07-composer-session-stats-pills.zh.md @@ -12,7 +12,7 @@ Status: implemented `StatsPills`(packages/client/ui-chat/src/client/chat/StatsPills.tsx)在同一 `conversation.composer.dock` 插槽上取代 `StatsLine`;落选变体已删除,其共享工具函数(`deriveStats`、`formatDuration`、`cacheHitPercent`、`billedInputTokens`)并入新模块,废弃的 `stats.llm`、`stats.toolCall`、`stats.ttftAverage`、`stats.tokensPerSecond`、`stats.tokens` 文案键一并移除。 -- **两个图标 pill、两个弹层。** 仪表盘 pill(新增 `IconGaugeOutlineRegular`,因下开口圆弧视觉偏高而把表盘中心光学下移到 y=8.75)展示 `{turns} 轮 {steps} 步` 加输出 TPS,点击打开「会话统计」弹层(模型用时、工具调用用时、首 token 平均、输出速度);日志里没有任何计时数字时弹层会是空的,此时该 pill 渲染为静态读数而非按钮。数据库 pill(`IconDatabaseOutlineRegular`)展示紧凑计费总量加缓存命中率,点击打开「Token 用量」弹层(缓存命中、未缓存输入、缓存读取、输出,以及非零时的缓存写入——精确计数)。两个弹层共用为这两处消费者抽出的 `stat-dialog` 模块(portal 面板、锚定定位、点击外部关闭、可选的外部持有开合状态);pill 行持有唯一的互斥开合槽位,打开任一弹层即关闭另一个,且每个按钮携带显式 `aria-label`,用 ` · ` 分隔 aria-hidden 分隔符在视觉上连接的两段文本。[引导起始页与统计 pill 的细化](2026-09-10-guide-start-page-and-stat-pill-refinements.zh.md)负责缓存写入为零时省略该行的规则。 +- **两个图标 pill、两个弹层。** 仪表盘 pill(新增 `IconGaugeOutlineRegular`,因下开口圆弧视觉偏高而把表盘中心光学下移到 y=8.75)展示 `{turns} 轮 {steps} 步` 加输出 TPS,点击打开「会话统计」弹层(模型用时、工具调用用时、首 token 平均、输出速度);日志里没有任何计时数字时弹层会是空的,此时该 pill 渲染为静态读数而非按钮。数据库 pill(`IconDatabaseOutlineRegular`)展示紧凑计费总量加缓存命中率,点击打开「Token 用量」弹层(缓存命中、未缓存输入、缓存读取、输出,以及非零时的缓存写入——精确计数)。两个弹层共用为这两处消费者抽出的 `stat-dialog` 模块(portal 面板、锚定定位、点击外部关闭、可选的外部持有开合状态);pill 行持有唯一的互斥开合槽位,打开任一弹层即关闭另一个,且每个按钮携带显式 `aria-label`,用 ` · ` 分隔 aria-hidden 分隔符在视觉上连接的两段文本。[StatsPills](../../../../packages/client/ui-chat/src/client/chat/StatsPills.tsx)负责缓存写入为零时省略该行的规则。 - **统一信息层级。** 输入框底部统计与单轮回答末尾信息都使用次级字号减 1px。回答末尾信息使用与操作图标一致的 tertiary 文字颜色,并与操作图标组额外拉开 8px。 - **数据来源架构不变。** 计数与用时优先读取持久的 `sessionStats` 投影,窗口折叠仅作无该单元装配时的回退([全会话计数](../../archived/bug-fix/2026-08-12-full-session-turn-step-counts.md));token 数字只走 `tokenUsage`,投影缺席时直接不渲染用量 pill,而非展示窗口推算的计费。缓存写入仍计入计费总量与缓存命中分母([投影决定](../architecture/2026-07-29-projected-token-usage-and-request-context.zh.md))。上下文占用仍归 ui-conversation 的 `ContextMeter` 所有,在输入卡片下方、两个统计 pill 之后显示圆环和百分比。共用 dock 将统计信息放在一起,工具栏保留给输入操作;ui-chat 不导入上下文组件。上下文面板通过 portal 渲染,复用 ui-primitives 的视口内定位与外部指针关闭工具,统计贡献项缺席时也不会越界。 - **渲染纪律。** 该行只折叠已定稿节点(`chat.legacy.nodes` 身份),流式 chunk 帧零重渲染——由渲染计数单测钉住。无已完成步且无计费 token 的会话什么都不渲染。 diff --git a/.agents/notes/implemented/feature/2026-09-07-deepseek-messages-adapter.i18n.yaml b/.agents/notes/implemented/feature/2026-09-07-deepseek-messages-adapter.i18n.yaml index ddba9ebf1e..961abe5984 100644 --- a/.agents/notes/implemented/feature/2026-09-07-deepseek-messages-adapter.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-09-07-deepseek-messages-adapter.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-07-deepseek-messages-adapter.md -2026-09-07-deepseek-messages-adapter.md: c667ec3afdd9956282a532e748ad2e2488d26f5b -2026-09-07-deepseek-messages-adapter.zh.md: 0e3425e41d8a9585a1638eedeefcf6ead76a5a78 +2026-09-07-deepseek-messages-adapter.md: caec59feb3a84140427bd3ae8b738274c3bc5c47 +2026-09-07-deepseek-messages-adapter.zh.md: 030c91cb9a5f566ce09892bb054b63b1d243f6bb diff --git a/.agents/notes/implemented/feature/2026-09-07-deepseek-messages-adapter.md b/.agents/notes/implemented/feature/2026-09-07-deepseek-messages-adapter.md index c667ec3afd..caec59feb3 100644 --- a/.agents/notes/implemented/feature/2026-09-07-deepseek-messages-adapter.md +++ b/.agents/notes/implemented/feature/2026-09-07-deepseek-messages-adapter.md @@ -36,6 +36,6 @@ Both transports use the existing [request-extension registry](../architecture/20 ## Consequences -The package owns wire validation, stop-reason mapping, cancellation, and error classification, so protocol changes require adapter maintenance. Unsupported content and incomplete streams fail explicitly. The existing retry consumer owns retries; the existing assembler drops incomplete tool calls at the output limit. Messages defaults cover the shared base, Web, and standalone first-party compositions. Protocol changes preserve the provider id and saved model selections, but an explicit endpoint override must support the selected protocol. +The package owns wire validation, stop-reason mapping, cancellation, and error classification, so protocol changes require adapter maintenance. User and tool-result input follows the [saved-input compatibility rule](../bug-fix/2026-09-18-messages-input-history-compatibility.md); other unsupported content and incomplete streams fail explicitly. The existing retry consumer owns retries; the existing assembler drops incomplete tool calls at the output limit. Messages defaults cover the shared base, Web, and standalone first-party compositions. Protocol changes preserve the provider id and saved model selections, but an explicit endpoint override must support the selected protocol. Verification covers wire fixtures, real Loader composition, per-file unit coverage, [recorded Session replay](../../../../snapshots/session/deepseek-messages-replay/snapshot.yml) with [unknown replay versions](../../../../snapshots/session/deepseek-messages-degraded-replay/snapshot.yml), a Web Messages Session replay, and credential-gated text, thinking, tool continuation, image, and cancellation requests. Live gateway checks establish compatibility with the configured gateway; they do not establish compatibility with every Anthropic proxy. diff --git a/.agents/notes/implemented/feature/2026-09-07-deepseek-messages-adapter.zh.md b/.agents/notes/implemented/feature/2026-09-07-deepseek-messages-adapter.zh.md index 0e3425e41d..030c91cb9a 100644 --- a/.agents/notes/implemented/feature/2026-09-07-deepseek-messages-adapter.zh.md +++ b/.agents/notes/implemented/feature/2026-09-07-deepseek-messages-adapter.zh.md @@ -36,6 +36,6 @@ Web 始终显示 DeepSeek,不提供协议选择器。两个协议共用 `baseU ## 结果 -该包负责协议校验、停止原因映射、取消和错误分类,因此协议变化需要维护适配器。不支持的内容和不完整的流会明确报错。现有重试消费者负责重试;现有装配器在输出达到上限时丢弃未完成的工具调用。Messages 默认配置覆盖共享 base、Web 和独立的官方组合。协议变化保留 provider id 与已保存的模型选择,但显式端点覆盖必须支持所选协议。 +该包负责协议校验、停止原因映射、取消和错误分类,因此协议变化需要维护适配器。用户与工具结果输入遵循[已保存输入兼容规则](../bug-fix/2026-09-18-messages-input-history-compatibility.zh.md);其他不支持的内容和不完整的流会明确报错。现有重试消费者负责重试;现有装配器在输出达到上限时丢弃未完成的工具调用。Messages 默认配置覆盖共享 base、Web 和独立的官方组合。协议变化保留 provider id 与已保存的模型选择,但显式端点覆盖必须支持所选协议。 验证覆盖协议夹具、真实 Loader 组合、逐文件单元覆盖率、[已记录 Session 回放](../../../../snapshots/session/deepseek-messages-replay/snapshot.yml)与[未知回放版本](../../../../snapshots/session/deepseek-messages-degraded-replay/snapshot.yml),Web Messages Session 回放,以及凭证控制的文本、思考、工具续接、图片和取消请求。真实网关检查证明与已配置网关的兼容性,不能证明与所有 Anthropic 代理兼容。 diff --git a/.agents/notes/implemented/feature/2026-09-08-shared-file-type-icons.i18n.yaml b/.agents/notes/implemented/feature/2026-09-08-shared-file-type-icons.i18n.yaml index 8b9255eec8..35f8f4dc56 100644 --- a/.agents/notes/implemented/feature/2026-09-08-shared-file-type-icons.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-09-08-shared-file-type-icons.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-08-shared-file-type-icons.md -2026-09-08-shared-file-type-icons.md: f0d3fbdb3a86b5f2c67a438de96348b30b27319f -2026-09-08-shared-file-type-icons.zh.md: d7542592804bc384e4ae5d7359145e70f4d98527 +2026-09-08-shared-file-type-icons.md: 5dbe0ade14c15e1d509f56eba6e0266c61089c46 +2026-09-08-shared-file-type-icons.zh.md: 9dbccdf10f188b30dcbbbe11d45b238af42ca112 diff --git a/.agents/notes/implemented/feature/2026-09-08-shared-file-type-icons.md b/.agents/notes/implemented/feature/2026-09-08-shared-file-type-icons.md index f0d3fbdb3a..5dbe0ade14 100644 --- a/.agents/notes/implemented/feature/2026-09-08-shared-file-type-icons.md +++ b/.agents/notes/implemented/feature/2026-09-08-shared-file-type-icons.md @@ -16,7 +16,7 @@ Client feature plugins can share React components only through `@deepseek-ai/dsh Recognized code and configuration files render the corresponding 20px square artwork scaled to the requested icon size. The embedded static table contains exactly the 48 established `CodeFileType` entries; archive-only artwork does not add a category, and an adjacent manifest records the responsible design owner and source digests. Tests reject scripts, event attributes, external references, and duplicate ids in that table. `CodeFileIcon` replaces local SVG ids with a per-component prefix before insertion so repeated gradients and clip paths remain independent. These technology marks retain their embedded multicolor fills and are the explicit exception to the ordinary current-color icon rule. The map selects React before TypeScript/JavaScript, Angular filename suffixes before their base extension, Docker/Node/Git/Make/CMake by filename rules, and Flutter only when the optional project snapshot contains a `pubspec.yaml` whose text includes `flutter:`. Markdown and SVG remain owned by the traditional Markdown and image categories. CSV, TSV, Excel workbooks and templates, OpenDocument spreadsheets, and Numbers use the spreadsheet glyph in file cards, rows, and preview titles; their clickable links use the document category. Both `.env` and names ending in `.env` use the environment glyph. Every traditional and technology SVG is `aria-hidden`, and the card, row, or button that owns the file identity supplies the accessible name. -`LinkIconMedium` delegates extension classification to `classifyFileType` and folds the detailed result into its existing link vocabulary: code and HTML use `code`, images use `image`, PDF/Word/Excel/PPT use `document`, and Markdown/video/unknown files use `other`. Extensionless names remain `other` in link contexts, so the 14px clickable-link appearance defined by the [clickable-link decision](2026-09-04-web-clickable-link-styles.md) does not change. +`LinkIconMedium` delegates extension classification to `classifyFileType` and folds the detailed result into its existing link vocabulary: code and HTML use `code`, images use `image`, PDF/Word/Excel/PPT use `document`, and Markdown/video/unknown files use `other`. Extensionless names remain `other` in link contexts, so the 14px clickable-link appearance defined by the [link styling rules](../../../../docs/web-styling.md) does not change. Attachment upload cards, sent-message file cards, queued-file rows, and workspace file rows render `FileTypeIcon`. The Files tab title renders its explicit `folder` kind at 16px. Explicit delivery cards use `FileTypeIcon` at 20px and `fileExtension` for their fallback metadata. The two metadata rows use `fileExtension` rather than local parsers; a leading-dot basename such as `.env` therefore displays `ENV`, while an absent or trailing suffix displays no extension label. Image content continues to render as a preview rather than a file-type glyph, and produced-file links and Markdown file mentions continue to use `LinkIconMedium` because they are link surfaces. diff --git a/.agents/notes/implemented/feature/2026-09-08-shared-file-type-icons.zh.md b/.agents/notes/implemented/feature/2026-09-08-shared-file-type-icons.zh.md index d754259280..9dbccdf10f 100644 --- a/.agents/notes/implemented/feature/2026-09-08-shared-file-type-icons.zh.md +++ b/.agents/notes/implemented/feature/2026-09-08-shared-file-type-icons.zh.md @@ -16,7 +16,7 @@ Status: implemented 已识别的代码与配置文件把对应的 20px 方形图稿缩放到请求的图标尺寸。内嵌静态表只包含现有 48 个 `CodeFileType` 条目;资源包中额外的图稿不会新增类别,相邻 manifest 记录负责的设计归属方与来源摘要。测试会拒绝该表中的脚本、事件属性、外部引用与重复 id。`CodeFileIcon` 在插入前为本地 SVG id 加上组件实例前缀,使重复渐变与裁剪路径互不干扰。这些技术标记保留自身内嵌的多色填充,是普通 current-color 图标规则的明确例外。映射让 React 优先于 TypeScript/JavaScript、Angular 文件名后缀优先于基础扩展名,并按文件名识别 Docker/Node/Git/Make/CMake;只有可选项目快照包含内容带 `flutter:` 的 `pubspec.yaml` 时才选择 Flutter。Markdown 与 SVG 仍由传统 Markdown 和图片类别拥有。CSV、TSV、Excel 工作簿与模板、OpenDocument 表格和 Numbers 在文件卡片、文件行及预览标题中使用表格图标,其可点击链接使用文档类别。`.env` 和以 `.env` 结尾的文件名均使用环境配置图标。所有传统与技术 SVG 都是 `aria-hidden` 的,拥有文件身份的卡片、行或按钮提供无障碍名称。 -`LinkIconMedium` 委托 `classifyFileType` 做扩展名分类,再把精细结果折叠进原有链接词汇:code 与 HTML 使用 `code`,图片使用 `image`,PDF/Word/Excel/PPT 使用 `document`,Markdown、video 与未知文件使用 `other`。无扩展名文件在链接语境中仍是 `other`,因此[可点击链接决策](2026-09-04-web-clickable-link-styles.zh.md)定义的 14px 外观不变。 +`LinkIconMedium` 委托 `classifyFileType` 做扩展名分类,再把精细结果折叠进原有链接词汇:code 与 HTML 使用 `code`,图片使用 `image`,PDF/Word/Excel/PPT 使用 `document`,Markdown、video 与未知文件使用 `other`。无扩展名文件在链接语境中仍是 `other`,因此[链接样式规则](../../../../docs/web-styling.zh.md)定义的 14px 外观不变。 附件上传卡片、已发送消息文件卡片、排队文件行和工作区文件行渲染 `FileTypeIcon`。Files 标签页标题使用显式的 `folder` 类别,尺寸为 16px。显式交付卡片使用 20px 的 `FileTypeIcon`,并通过 `fileExtension` 提供默认元数据。两处元数据行统一使用 `fileExtension`;`.env` 这样的前导点 basename 会显示 `ENV`,无后缀或末尾点号则不显示扩展名 label。图片内容继续渲染为预览而不是文件类型图形,产物文件链接与 Markdown 文件提及继续使用 `LinkIconMedium`,因为它们属于链接表面。 diff --git a/.agents/notes/implemented/feature/2026-09-09-turn-duration-hour-unit.md b/.agents/notes/implemented/feature/2026-09-09-turn-duration-hour-unit.md deleted file mode 100644 index f0f5cb478d..0000000000 --- a/.agents/notes/implemented/feature/2026-09-09-turn-duration-hour-unit.md +++ /dev/null @@ -1,29 +0,0 @@ -# Agent Note: Turn duration labels gain an hour unit - -Status: implemented - -English | [中文](2026-09-09-turn-duration-hour-unit.zh.md) - -## Problem - -The Web chat's turn duration labels counted minutes without bound. `formatRunDuration` in [message-chrome.ts](../../../../packages/client/ui-chat/src/client/chat/message-chrome.ts) split elapsed milliseconds into seconds and minutes only, so a turn that ran for 90 minutes read `90分05秒` / `90m 05s` in all three places sharing the formatter: the `Deep diving...` running clock, the settled `Ran for {duration}` footer, and the turn-time dialog's total. The archived [turn run time decision](../../archived/feature/2026-08-03-web-turn-run-time.md) fixed the clock's anchor and the shared whole-second floor; it left the formatter at two units, which stops reading correctly once a turn crosses an hour. - -## Decision - -`formatRunDuration` carries an hour branch: elapsed time at or above 3600 seconds renders through `duration.hours` with zero-padded minutes and seconds — `1小时05分03秒` / `1h 05m 03s` — while everything below an hour keeps the existing second and minute branches unchanged. Hours appear only at or above 3600 seconds, so 3599 seconds still reads `59分59秒` and `60分00秒` never appears. Seconds are retained rather than dropped once hours appear, because the running clock ticks every second and a `1小时05分` label would sit still for a minute at a time. Negatives still clamp to zero and partial seconds still floor. `duration.hours` joins both dictionaries in [locale.ts](../../../../packages/client/ui-chat/src/client/locale.ts), and `RunDurationTranslate` widens to three keys. - -The change is confined to the turn formatter. `StatsPills.formatDuration` — the session-wide aggregate pill reading `45.2s` / `2m42s` — keeps its own two-unit format. - -## Alternatives considered - -**Pair hours with minutes only.** Dropping seconds at the hour boundary matches the `ui-jobs` job-duration format and keeps the label short. It loses the second-level figure from the settled footer, and it makes the live clock look frozen: the label would change once a minute while the turn is still running. - -**Three units while running, two once settled.** Rejected because both readings come from one function by design — the archived decision pins that — so the same turn would report different precision before and after it settles. - -**Change the aggregate pill in the same change.** `StatsPills.formatDuration` measures session-wide aggregates — LLM time, tool time, and average TTFT — from projections rather than one turn's boundaries. It can exceed an hour too, but folding it in would mix two independent formatters and their tests into one change. - -**Leave minutes unbounded.** `90分05秒` is technically correct and costs nothing to keep, but it is the reading that prompted this change and grows harder to parse the longer a turn runs. - -## Consequences - -Long turns now read in hours without new session events or new timing state; the labels remain derived from the logged `turn/start` and `turn/end` boundaries and keep the 15-second clock delay. The unit spec covers the 3599-second and 3600-second boundary plus the English template. No recorded-session snapshot changes: every shipped `Deep diving...` expectation is captured before the clock appears. diff --git a/.agents/notes/implemented/feature/2026-09-09-turn-duration-hour-unit.zh.md b/.agents/notes/implemented/feature/2026-09-09-turn-duration-hour-unit.zh.md deleted file mode 100644 index c9c94eef2a..0000000000 --- a/.agents/notes/implemented/feature/2026-09-09-turn-duration-hour-unit.zh.md +++ /dev/null @@ -1,29 +0,0 @@ -# Agent Note: 回合耗时标签增加小时单位 - -Status: implemented - -[English](2026-09-09-turn-duration-hour-unit.md) | 中文 - -## 问题 - -Web 聊天的回合耗时标签分钟数无界增长。[message-chrome.ts](../../../../packages/client/ui-chat/src/client/chat/message-chrome.ts) 里的 `formatRunDuration` 只把毫秒拆成秒和分两级,于是一个跑了 90 分钟的回合在共用一个格式化器的三处都读作 `90分05秒` / `90m 05s`:`Deep diving...` 后的实时计时、收尾的 `Ran for {duration}` 页脚,以及耗时详情弹窗里的总用时。已归档的[回合运行时长决策](../../archived/feature/2026-08-03-web-turn-run-time.md)确定了计时锚点和共用的整秒向下取整;它把格式化器留在两级单位,一旦回合跨过一小时就不再正确。 - -## 决定 - -`formatRunDuration` 增加小时分支:总时长达到或超过 3600 秒时经 `duration.hours` 渲染,分和秒补零——`1小时05分03秒` / `1h 05m 03s`——不足一小时的时长保持原有的秒、分两级不变。小时只在达到或超过 3600 秒时出现,因此 3599 秒仍读作 `59分59秒`,不会出现 `60分00秒`。出现小时后保留秒而不丢弃,因为实时计时每秒都在走,`1小时05分` 这样的标签会整整一分钟静止不动。负值仍然钳到 0,不足一秒仍然向下取整。[locale.ts](../../../../packages/client/ui-chat/src/client/locale.ts) 的两套字典都新增 `duration.hours`,`RunDurationTranslate` 扩为三个键。 - -改动只限于回合计时器。`StatsPills.formatDuration`——会话级聚合胶囊,读作 `45.2s` / `2m42s`——保留自己的两级格式。 - -## 考虑过的替代方案 - -**小时只与分配对。** 在小时边界丢掉秒与 `ui-jobs` 的任务时长格式一致,标签也更短。代价是收尾页脚失去秒级数字,并且实时计时看起来像卡住了:回合还在跑,标签却每分钟才变一次。 - -**运行中三单位、收尾两单位。** 拒绝,因为两处读数按设计来自同一个函数——归档决策锁定了这一点——同一个回合会在结束前后报告不同的精度。 - -**在同一次改动里改聚合胶囊。** `StatsPills.formatDuration` 统计的是来自投影的会话级聚合值——LLM 耗时、工具耗时与平均 TTFT——而不是单个回合的边界。它同样可能超过一小时,但把它折进来会让两个独立的格式化器及其测试混进同一次改动。 - -**保留分钟无界。** `90分05秒` 在技术上没错,保留也不花成本,但它正是引发这次改动的读数,而且回合跑得越久越难解析。 - -## 后果 - -长回合现在按小时展示,没有新增会话事件或计时状态;标签仍由日志中的 `turn/start` 与 `turn/end` 边界派生,并保留 15 秒后才出现计时的延迟。单元 spec 覆盖 3599 秒与 3600 秒的边界以及英文模板。录制会话快照没有变化:所有已发布的 `Deep diving...` 期望都在计时出现之前就被捕获。 diff --git a/.agents/notes/implemented/feature/2026-09-10-connection-indicator-refinements.i18n.yaml b/.agents/notes/implemented/feature/2026-09-10-connection-indicator-refinements.i18n.yaml deleted file mode 100644 index 992a5d55e9..0000000000 --- a/.agents/notes/implemented/feature/2026-09-10-connection-indicator-refinements.i18n.yaml +++ /dev/null @@ -1,6 +0,0 @@ -# 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-10-connection-indicator-refinements.md -2026-09-10-connection-indicator-refinements.md: 7c918b6f9ee65612c627e0406230a99e20cde2af -2026-09-10-connection-indicator-refinements.zh.md: e6a3fcdec50bdd209c73679b47d4b05fde88a9a4 diff --git a/.agents/notes/implemented/feature/2026-09-10-connection-indicator-refinements.md b/.agents/notes/implemented/feature/2026-09-10-connection-indicator-refinements.md deleted file mode 100644 index 7c918b6f9e..0000000000 --- a/.agents/notes/implemented/feature/2026-09-10-connection-indicator-refinements.md +++ /dev/null @@ -1,29 +0,0 @@ -# Agent Note: Connection indicator state and interaction refinements - -Status: implemented - -English | [中文](2026-09-10-connection-indicator-refinements.zh.md) - -## Problem - -The sidebar connection pill hid its affordance behind a hover swap: outage and retry-attempt states replaced their label with **Reconnect now** on hover or focus, so every state had to reserve the widest supplied label to keep the control from resizing. A retry that resolved in under a second flickered the connecting pill in and out, and state changes and unmounts jumped with no transition. - -## Decision - -**The disconnected pill shows its action statically.** [ConnectionIndicator.tsx](../../../../packages/client/ui-primitives/src/ConnectionIndicator.tsx) renders a permanent retry glyph (`IconRefreshOutlineRegular`) beside the outage copy (`连接异常,刷新重试` / `Disconnected`; the Chinese copy also names the retry action); clicking the pill still reconnects immediately. The hover label swap and the hidden widest-label size-reservation spans are gone, so the pill sizes to its current label. The connecting state shows a rotating-arc spinner instead of the exclamation glyph. Appearance and removal fade over 150ms — swaps between visible states replace content in place: `EXIT_MS` delays unmount to match the stylesheet's `.leaving` transition, and `prefers-reduced-motion` disables every animation and transition. Chrome settles at 28px height, 8px horizontal padding, 4px icon gap, 13px radius, and a 1px border of the label color at 20% alpha. - -**The shell owns attempt pacing.** [SettingsRoot.tsx](../../../../packages/client/ui-settings-general/src/client/SettingsRoot.tsx) keeps the connecting pill visible for at least `CONNECTING_MIN_VISIBLE_MS` (800ms) so sub-second retries do not flicker; every attempt, manual or automatic, reads the one label `重新连接中` (`connection.connecting`). The two-second recovery confirmation (`RECOVERY_CONFIRMATION_MS`) starts when the recovered pill becomes visible, so a hold that delays its appearance never shortens the confirmation. Both timings are built-in presentation constants of their owners, not configuration. - -## Alternatives considered - -**Animating width changes.** A FLIP-style measured pixel transition (remember the old width, pin it, transition to the new measurement) needs a layout effect and imperative style writes; the fade-only change reads calm enough without them. - -**Swapping to the retry glyph only on hover.** Showing the retry glyph permanently states the affordance without requiring any pointer interaction, matching the static label; a hover cross-fade adds interaction-dependent state and conveys nothing extra. - -**Scaling on enter/exit.** A 0.98 scale beside the opacity fades reads as jitter at 12px text, so only opacity animates. - -**Naming manual and automatic attempts differently.** `ConnectionController.emitState` deduplicates repeated `connecting` states across backoff attempts, so the shell cannot observe attempt boundaries: a shell-held manual-retry flag either flips the label mid-hold or sticks across later automatic attempts. Distinguishing the copy correctly requires the connection layer to expose the attempt origin, which this change does not need — both attempt kinds read the same label. - -## Consequences - -`ConnectionIndicator`'s `reconnectLabel` prop and its size-reservation spans are removed from the pre-stable API; the sole consumer (`ui-settings-general`) is updated in the same change. `settings-root.client.spec.tsx` pins the 800ms hold, the single attempt label held steady through the hold, and the visibility-based confirmation window; `atoms.client.spec.tsx` pins the exit-duration unmount; `lifecycle-chrome.e2e.ts` and its ARIA golden replay the recovery flow in a real browser. Both packages' READMEs restate the interaction. diff --git a/.agents/notes/implemented/feature/2026-09-10-connection-indicator-refinements.zh.md b/.agents/notes/implemented/feature/2026-09-10-connection-indicator-refinements.zh.md deleted file mode 100644 index e6a3fcdec5..0000000000 --- a/.agents/notes/implemented/feature/2026-09-10-connection-indicator-refinements.zh.md +++ /dev/null @@ -1,29 +0,0 @@ -# Agent Note: 连接指示器状态与交互细化 - -Status: implemented - -[English](2026-09-10-connection-indicator-refinements.md) | 中文 - -## Problem - -侧边栏连接药丸把操作提示藏在悬停切换里:断连与重试状态在悬停或聚焦时把文案替换为**立即重连**,因此每个状态都要为最宽的 label 预留空间以避免控件变形。一次不到一秒就恢复的重试会让连接中药丸闪现闪没,状态切换和消失也没有任何过渡、十分突兀。 - -## Decision - -**断连药丸静态地展示其动作。** [ConnectionIndicator.tsx](../../../../packages/client/ui-primitives/src/ConnectionIndicator.tsx) 在断连文案旁常驻渲染重试图形(`IconRefreshOutlineRegular`),文案为 `连接异常,刷新重试` / `Disconnected`(中文文案同时点明重试动作);点击药丸仍会立即重连。悬停换文案和隐藏的最宽 label 占位 span 全部移除,药丸宽度随当前 label 自适应。连接中状态改用旋转圆弧 spinner 取代感叹号图形。出现与移除以 150ms 淡入淡出——可见状态之间的切换则原地替换内容:`EXIT_MS` 延迟卸载以匹配样式表的 `.leaving` 过渡,`prefers-reduced-motion` 会禁用全部动画与过渡。外观定为高 28px、水平内边距 8px、图标间距 4px、圆角 13px,以及 label 颜色 20% 透明度的 1px 边框。 - -**外壳拥有尝试节奏。** [SettingsRoot.tsx](../../../../packages/client/ui-settings-general/src/client/SettingsRoot.tsx) 让连接中药丸至少可见 `CONNECTING_MIN_VISIBLE_MS`(800ms),亚秒级重试不再闪动;无论手动还是自动,每次尝试都显示同一个文案`重新连接中`(`connection.connecting`)。2 秒恢复确认(`RECOVERY_CONFIRMATION_MS`)从恢复药丸实际可见时起算,驻留推迟其出现也不会缩短确认时长。两个时长是各自持有方的内置展示常量,不是配置。 - -## Alternatives considered - -**给宽度变化加动画。** FLIP 式的像素测量过渡(记住旧宽度、钉住、过渡到新测量值)需要一个 layout effect 和命令式样式写入;纯淡入淡出已足够平静,无需这些。 - -**仅在悬停时切换为重试图形。** 常驻显示重试图形无需任何指针交互就说明了操作,与静态文案一致;悬停交叉渐变引入依赖交互的状态却不传达更多信息。 - -**进出场缩放。** 0.98 的缩放叠加在透明度淡入淡出上,在 12px 文字上读起来像抖动,因此只保留透明度。 - -**手动与自动尝试使用不同命名。** `ConnectionController.emitState` 会去重退避尝试之间重复的 `connecting` 状态,外壳观察不到尝试边界:外壳自持的手动重试标志要么在驻留中途翻转文案,要么在后续自动尝试中一直滞留。要正确区分文案需要连接层暴露尝试来源,而本次变更并不需要——两种尝试显示同一文案。 - -## Consequences - -`ConnectionIndicator` 的 `reconnectLabel` prop 及其占位 span 从 pre-stable API 中移除;唯一消费者(`ui-settings-general`)在同一变更中更新。`settings-root.client.spec.tsx` 固定 800ms 驻留、驻留期间保持不变的单一尝试文案,以及按可见时刻起算的确认窗口;`atoms.client.spec.tsx` 固定退出时长后的卸载;`lifecycle-chrome.e2e.ts` 及其 ARIA golden 在真实浏览器中回放恢复流程。两个包的 README 重述了该交互。 diff --git a/.agents/notes/implemented/feature/2026-09-10-guide-start-page-and-stat-pill-refinements.i18n.yaml b/.agents/notes/implemented/feature/2026-09-10-guide-start-page-and-stat-pill-refinements.i18n.yaml deleted file mode 100644 index dd2b16d82c..0000000000 --- a/.agents/notes/implemented/feature/2026-09-10-guide-start-page-and-stat-pill-refinements.i18n.yaml +++ /dev/null @@ -1,6 +0,0 @@ -# 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-10-guide-start-page-and-stat-pill-refinements.md -2026-09-10-guide-start-page-and-stat-pill-refinements.md: 987e46fd597b11046843a943c60187fdb1a769d0 -2026-09-10-guide-start-page-and-stat-pill-refinements.zh.md: 590f18a2963507a8918a0aca30997aac23c6e477 diff --git a/.agents/notes/implemented/feature/2026-09-10-guide-start-page-and-stat-pill-refinements.md b/.agents/notes/implemented/feature/2026-09-10-guide-start-page-and-stat-pill-refinements.md deleted file mode 100644 index 987e46fd59..0000000000 --- a/.agents/notes/implemented/feature/2026-09-10-guide-start-page-and-stat-pill-refinements.md +++ /dev/null @@ -1,29 +0,0 @@ -# Agent Note: Guide start page and stat pill refinements - -Status: implemented - -English | [中文](2026-09-10-guide-start-page-and-stat-pill-refinements.zh.md) - -## Problem - -The right Sidebar's guide tab was a bare list of entry capsules: no visual anchor above them, a capsule could only say its title, and an entry whose type registered no glyph rendered with no icon at all, so a mixed list read as broken rather than sparse. Separately, the session token-usage dialog under the composer printed a `Cache write 0 tok` row for sessions that never wrote cache. - -## Decision - -**The guide is a compass over self-describing capsules.** This refines the guide contracts in [Right Sidebar tab types and navigation](../architecture/2026-09-05-sidebar-tab-types-and-navigation.md) and [Sidebar text preview and file tree](2026-09-05-sidebar-text-preview-and-file-tree.md). [GuideBody.tsx](../../../../packages/client/ui-sidebar-right/src/client/tabs/guide/GuideBody.tsx) draws a muted 56px compass hero over the entry capsules and no heading, as a browser start page shows its doors without a caption. [`SidebarRightGuideEntry`](../../../../packages/client/ui-sidebar-right/src/client/tab-registry.ts) gains an optional thunked `description` — read fresh on every render like `title`, so language changes need no re-registration. A capsule shows its description under the title only while the guide lists at most `MAX_DESCRIBED_ENTRIES` (4) entries; a longer list drops every description to stay light, so a type must stand on its title. The glyph rides the capsule's height: 22px beside a bare title, 26px beside two lines. - -**Icon-less entries fall back to a shipped cube placeholder.** The fallback is decided at the render site (`entry.icon ?? CubeGlyph`), not at registration, so every contributor — builtin or extension — gets it uniformly and a chain replacement of the body replaces the rule with it. `CubeGlyph` lives beside `CompassGlyph` in [GuideTitle.tsx](../../../../packages/client/ui-sidebar-right/src/client/tabs/guide/GuideTitle.tsx): an isometric box in 1.1px straight strokes with rounded joins on `currentColor`, drawn on `--dsw-alias-label-tertiary` — one step quieter than a registered glyph's ink — to mark the slot as unclaimed. The files type registers a description and the shared folder glyph in [definition.tsx](../../../../packages/client/ui-sidebar-files/src/client/definition.tsx). - -**The session usage dialog drops the zero cache-write row.** This refines [Composer session stats](2026-09-07-composer-session-stats-pills.md). [StatsPills.tsx](../../../../packages/client/ui-chat/src/client/chat/StatsPills.tsx) renders the `Cache write` row only when `cacheWriteTokens !== 0`, as the per-turn panel already drops its absent optional fields; the always-present buckets (input, cache read, output) keep their rows. - -## Alternatives considered - -**Register the cube in `ui-primitives`.** Its `icons/index.tsx` is the imported figma `ic_ds_*` set, and the cube has a single consumer; `CompassGlyph` set the precedent of package-local guide glyphs. - -**Default the icon at registration time.** A `?? default` inside the registry would hide the fallback from the body and make "registered no glyph" undetectable, losing the quieter placeholder ink; explicit render-site fallback keeps registrations honest. - -**Show `Cache write 0`.** A session on a provider that never writes cache would carry the row forever; zero here means "not a thing", not a measurement. - -## Consequences - -`description` is new pre-stable registry API; every consumer was updated (the files entry registers one). The 4-entry threshold is a shipped constant of the guide body, not configuration. Guide-body specs cover the placeholder (size and ink), the description threshold on both sides, and the registered-glyph path; chat-stats specs cover the dropped and present cache-write row. The `ui-sidebar-right` and `ui-sidebar-files` READMEs restate the guide rules. diff --git a/.agents/notes/implemented/feature/2026-09-10-guide-start-page-and-stat-pill-refinements.zh.md b/.agents/notes/implemented/feature/2026-09-10-guide-start-page-and-stat-pill-refinements.zh.md deleted file mode 100644 index 590f18a296..0000000000 --- a/.agents/notes/implemented/feature/2026-09-10-guide-start-page-and-stat-pill-refinements.zh.md +++ /dev/null @@ -1,29 +0,0 @@ -# Agent Note: 引导起始页与统计 pill 的细化 - -Status: implemented - -[English](2026-09-10-guide-start-page-and-stat-pill-refinements.md) | 中文 - -## Problem - -右侧边栏的引导 tab 只是一列光秃秃的入口胶囊:上方没有视觉锚点,胶囊只能显示标题,而类型没注册图标的入口干脆不画图标,混合列表看起来像坏了而不是稀疏。另外,输入框下方的会话 token 用量弹窗会给从未写入缓存的会话打印一行 `Cache write 0 tok`。 - -## Decision - -**引导页是罗盘压着能自我说明的胶囊。** 本决议细化[右侧 Sidebar tab 类型与导航](../architecture/2026-09-05-sidebar-tab-types-and-navigation.zh.md)和 [Sidebar 文本预览与文件树](2026-09-05-sidebar-text-preview-and-file-tree.zh.md)中的引导页约定。[GuideBody.tsx](../../../../packages/client/ui-sidebar-right/src/client/tabs/guide/GuideBody.tsx) 在入口胶囊上方画一枚弱化的 56px 罗盘,不加标题,如同浏览器起始页不给它的入口配说明文字。[`SidebarRightGuideEntry`](../../../../packages/client/ui-sidebar-right/src/client/tab-registry.ts) 新增可选的 thunk 化 `description`——与 `title` 一样每次渲染重新读取,语言切换无需重新注册。仅当引导页列出的入口不超过 `MAX_DESCRIBED_ENTRIES`(4)个时,胶囊才在标题下显示描述;更长的列表去掉所有描述以保持轻盈,所以类型必须靠标题立得住。图标随胶囊高度变化:单行标题旁 22px,两行旁 26px。 - -**没有图标的入口回退到内置的立方体占位符。** 回退在渲染点决定(`entry.icon ?? CubeGlyph`)而不在注册时,因此每个贡献者——内置或扩展——得到统一的占位符,链式替换 body 时规则随之整体替换。`CubeGlyph` 与 `CompassGlyph` 一起放在 [GuideTitle.tsx](../../../../packages/client/ui-sidebar-right/src/client/tabs/guide/GuideTitle.tsx):一只等距视角的盒子,1.1px 直线描边、圆角拼接、走 `currentColor`,用 `--dsw-alias-label-tertiary` 着色——比注册图标的墨色浅一档——标记这个槽位无人认领。files 类型在 [definition.tsx](../../../../packages/client/ui-sidebar-files/src/client/definition.tsx) 注册了描述和共享的文件夹图标。 - -**会话用量弹窗去掉为零的缓存写入行。** 本决议细化[输入框下的会话统计](2026-09-07-composer-session-stats-pills.zh.md)。[StatsPills.tsx](../../../../packages/client/ui-chat/src/client/chat/StatsPills.tsx) 仅当 `cacheWriteTokens !== 0` 时渲染 `Cache write` 行,与 per-turn 面板去掉缺失可选字段的做法一致;始终存在的桶(输入、缓存读取、输出)保留各自的行。 - -## Alternatives considered - -**把立方体注册进 `ui-primitives`。** 其 `icons/index.tsx` 是导入的 figma `ic_ds_*` 集合,而立方体只有一个消费者;`CompassGlyph` 已开了引导图标包内自持的先例。 - -**在注册时默认图标。** 注册表内部的 `?? default` 会对 body 隐藏回退,让"没注册图标"无法辨认,浅色占位墨色随之丢失;显式的渲染点回退让注册保持诚实。 - -**显示 `Cache write 0`。** 用不写缓存的 provider 的会话会永远挂着这一行;这里的零意味着"没有这回事",不是一次测量。 - -## Consequences - -`description` 是新的 pre-stable 注册表 API;所有消费者已同步更新(files 入口注册了一个)。4 个入口的阈值是引导 body 的内置常量,不是配置。guide-body 用例覆盖占位符(尺寸与墨色)、描述阈值两侧和已注册图标路径;chat-stats 用例覆盖缓存写入行的去除与保留。`ui-sidebar-right` 与 `ui-sidebar-files` 的 README 重述了引导页规则。 diff --git a/.agents/notes/implemented/feature/2026-09-13-macos-hidden-titlebar-vibrancy.i18n.yaml b/.agents/notes/implemented/feature/2026-09-13-macos-hidden-titlebar-vibrancy.i18n.yaml index 46cf21a20b..f24e9aa057 100644 --- a/.agents/notes/implemented/feature/2026-09-13-macos-hidden-titlebar-vibrancy.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-09-13-macos-hidden-titlebar-vibrancy.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-13-macos-hidden-titlebar-vibrancy.md -2026-09-13-macos-hidden-titlebar-vibrancy.md: 1bc531953cedac8633c4baa6da45aa9bcd77a728 -2026-09-13-macos-hidden-titlebar-vibrancy.zh.md: bbc593badbabdccaa037cca836a9554929d614bc +2026-09-13-macos-hidden-titlebar-vibrancy.md: 99ac240698fb229f9a0d66a8000251058b52491e +2026-09-13-macos-hidden-titlebar-vibrancy.zh.md: 26bbb616485e49de2f70a3d142ea84dfd5bd7cb9 diff --git a/.agents/notes/implemented/feature/2026-09-13-macos-hidden-titlebar-vibrancy.md b/.agents/notes/implemented/feature/2026-09-13-macos-hidden-titlebar-vibrancy.md index 1bc531953c..99ac240698 100644 --- a/.agents/notes/implemented/feature/2026-09-13-macos-hidden-titlebar-vibrancy.md +++ b/.agents/notes/implemented/feature/2026-09-13-macos-hidden-titlebar-vibrancy.md @@ -18,7 +18,7 @@ Every macOS web-side adjustment keys off `html[data-platform='darwin']`, which o **Native theme sync.** The vibrancy material follows `nativeTheme.themeSource`, which otherwise tracks the OS appearance and diverges from the app's own theme preference. The ui-theme boot script and ui-layout `ThemePresenter` publish `html[data-ds-theme-source]` (`light`, `dark`, or `system`; a fixed preference, including registered theme ids, publishes its resolved scheme). The app preload observes the attribute and forwards it over `dsh-desktop:native-theme-set`; main validates the value and the sender (the main window's WebContents, including its local static Web document) and assigns `nativeTheme.themeSource`. Publishing the preference rather than the resolved scheme preserves OS-follow while the preference is `system`. -**Sidebar top strip and full hide.** On darwin the sidebar opens with a 52px top strip that clears the traffic lights, carries the collapse toggle, and is a window drag region (`-webkit-app-region: drag`; the toggle opts out). Collapsing the sidebar hides the column entirely — `computeColumns` takes an explicit `collapsedWidth` and the AppFrame passes 0 on darwin desktop — instead of the 56px rail the other platforms keep. The reopen affordances move into the conversation header: a new single, session-scoped slot `conversation.session.header.leading` sits before the breadcrumbs, and ui-sidebar registers `HeaderLeadingControls` (open sidebar + New Session, reusing the shell's inject face and locale) into it. Visibility is pure CSS against the AppFrame-published `data-sidebar-collapsed` attribute; no collapse-state pipe is added. The blank-session header keeps the leading seat mounted on darwin so a hidden sidebar always has a reopen control on screen. +**Sidebar top strip and full hide.** On darwin the sidebar opens with a 52px top strip that clears the traffic lights, carries the collapse toggle, and is a window drag region (`-webkit-app-region: drag`; the toggle opts out). Collapsing the sidebar hides the column entirely — `computeColumns` takes an explicit `collapsedWidth` and the AppFrame passes 0 on darwin desktop — instead of the 56px rail the other platforms keep. The reopen affordances move into the conversation header: a single, root-scoped slot `conversation.header.leading` sits before the breadcrumbs, and ui-sidebar registers `HeaderLeadingControls` (open sidebar + New Session, reusing the shell's inject face and locale) into it. Visibility is pure CSS against the AppFrame-published `data-sidebar-collapsed` attribute; no collapse-state pipe is added. The resident header keeps the leading seat mounted on darwin with or without a selected Session so a hidden sidebar always has a reopen control on screen. **Drag regions.** The conversation title row is a drag region on darwin with every interactive descendant opted out. Electron computes drag regions from window geometry in DOM order, not stacking: an overlay covering the title band must subtract itself or the band underneath keeps taking the pointer. The right sidebar's fullscreen panel therefore sets `-webkit-app-region: no-drag` on its whole box and restores drag on its tab strips' blank runs. @@ -40,10 +40,10 @@ Every macOS web-side adjustment keys off `html[data-platform='darwin']`, which o - The macOS window gains a translucent sidebar and hidden titlebar at zero cost to other platforms: every rule is scoped to `[data-platform='darwin']`, which only the Electron preload sets. - The vibrancy material follows the app theme, including third-party registered themes (their resolved scheme). Screenshots and screen recordings differ from the flat web rendering. -- `conversation.session.header.leading` is a public slot in the client catalog; any package can occupy the seat, and ui-sidebar's occupant assumes it may render into it whenever the platform matches. +- `conversation.header.leading` is a public slot in the client catalog; any package can occupy the seat, and ui-sidebar's occupant assumes it may render into it whenever the platform matches. - Drag-region geometry is a window-global invariant: any future overlay that covers the title band on darwin must subtract itself with `-webkit-app-region: no-drag` or its controls become unclickable. - The transparent window plus vibrancy is accepted to look different in screen sharing and may flash on startup; the transparent `backgroundColor` mitigates the flash. ## Testing -ui-theme boot and ui-layout presenter specs pin the `data-ds-theme-source` publication and disposal. The ui-sidebar apply spec pins the leading-seat registration (component, locale, shared inject face) and its removal on teardown. The ui-conversation skeleton spec pins the leading slot's render call in the active-phase header. The ui-theme corner-shape and full-round style gates cover the new stylesheet. +ui-theme boot and ui-layout presenter specs pin the `data-ds-theme-source` publication and disposal. The ui-sidebar apply spec pins the leading-seat registration (component, locale, shared inject face) and its removal on teardown. The ui-conversation skeleton spec pins the leading slot's render call with and without a selected Session. The ui-theme corner-shape and full-round style gates cover the new stylesheet. diff --git a/.agents/notes/implemented/feature/2026-09-13-macos-hidden-titlebar-vibrancy.zh.md b/.agents/notes/implemented/feature/2026-09-13-macos-hidden-titlebar-vibrancy.zh.md index bbc593badb..26bbb61648 100644 --- a/.agents/notes/implemented/feature/2026-09-13-macos-hidden-titlebar-vibrancy.zh.md +++ b/.agents/notes/implemented/feature/2026-09-13-macos-hidden-titlebar-vibrancy.zh.md @@ -18,7 +18,7 @@ Electron 主进程在 darwin 上以 `titleBarStyle: 'hiddenInset'`、`trafficLig **原生主题同步。** 毛玻璃材质跟随 `nativeTheme.themeSource`,后者默认跟踪系统外观,会与应用自身的主题偏好背离。ui-theme 引导脚本与 ui-layout 的 `ThemePresenter` 发布 `html[data-ds-theme-source]`(`light`、`dark` 或 `system`;固定偏好——包括注册主题 id——发布其解析后的配色)。应用 preload 观察该属性并经 `dsh-desktop:native-theme-set` 转发;主进程校验取值与发送者(主窗口的 WebContents,包括其本地静态 Web 文档)后赋给 `nativeTheme.themeSource`。发布偏好而非解析值,可在偏好为 `system` 时保留跟随系统。 -**侧边栏顶部条与完全隐藏。** darwin 上侧边栏展开时有一条 52px 的顶部条,避开红绿灯、承载收起按钮,并作为窗口拖拽区(`-webkit-app-region: drag`;按钮退出拖拽)。收起侧边栏时整列隐藏——`computeColumns` 接受显式 `collapsedWidth`,AppFrame 在 darwin 桌面传 0——而非其他平台保留的 56px rail。重新打开的入口移入会话头部:新增 single、session 作用域的 slot `conversation.session.header.leading` 位于面包屑之前,ui-sidebar 向其注册 `HeaderLeadingControls`(打开侧边栏 + 新会话,复用 shell 的 inject face 与 locale)。显隐纯由 CSS 依据 AppFrame 发布的 `data-sidebar-collapsed` 属性控制;不新增收起状态管道。darwin 上空白会话的头部保持 leading 座挂载,确保侧边栏隐藏时屏幕上始终有重新打开的控件。 +**侧边栏顶部条与完全隐藏。** darwin 上侧边栏展开时有一条 52px 的顶部条,避开红绿灯、承载收起按钮,并作为窗口拖拽区(`-webkit-app-region: drag`;按钮退出拖拽)。收起侧边栏时整列隐藏——`computeColumns` 接受显式 `collapsedWidth`,AppFrame 在 darwin 桌面传 0——而非其他平台保留的 56px rail。重新打开的入口移入会话头部:single、root 作用域的 slot `conversation.header.leading` 位于面包屑之前,ui-sidebar 向其注册 `HeaderLeadingControls`(打开侧边栏 + 新会话,复用 shell 的 inject face 与 locale)。显隐纯由 CSS 依据 AppFrame 发布的 `data-sidebar-collapsed` 属性控制;不新增收起状态管道。darwin 上常驻头部在选中或未选中 Session 时均保持 leading 座挂载,确保侧边栏隐藏时屏幕上始终有重新打开的控件。 **拖拽区。** darwin 上会话标题行是拖拽区,所有可交互后代退出。Electron 按 DOM 顺序以窗口几何计算拖拽区,不看层叠:覆盖标题带的浮层必须自行减除,否则下层区域仍会截获指针。因此右侧边栏的全屏面板对整个盒子设 `-webkit-app-region: no-drag`,再在其 tab 条空白处恢复拖拽。 @@ -40,10 +40,10 @@ Electron 主进程在 darwin 上以 `titleBarStyle: 'hiddenInset'`、`trafficLig - macOS 窗口获得半透明侧边栏与隐藏标题栏,对其他平台零成本:所有规则限定在 `[data-platform='darwin']` 下,该属性仅由 Electron preload 设置。 - 毛玻璃材质跟随应用主题,含第三方注册主题(取其解析配色)。截图与录屏与纯 Web 的平面渲染不同。 -- `conversation.session.header.leading` 是客户端 catalog 中的公开 slot;任何包都可占用该座位,ui-sidebar 的占用者假定平台匹配时随时可能渲染。 +- `conversation.header.leading` 是客户端 catalog 中的公开 slot;任何包都可占用该座位,ui-sidebar 的占用者假定平台匹配时随时可能渲染。 - 拖拽区几何是窗口级全局不变量:今后任何在 darwin 上覆盖标题带的浮层必须用 `-webkit-app-region: no-drag` 自行减除,否则其控件不可点击。 - 接受透明窗口 + 毛玻璃在屏幕共享中呈现不同、启动可能闪烁;透明 `backgroundColor` 缓解闪烁。 ## Testing -ui-theme 引导与 ui-layout presenter 测试钉住 `data-ds-theme-source` 的发布与清除。ui-sidebar apply 测试钉住 leading 座注册(组件、locale、共享 inject face)及 teardown 移除。ui-conversation skeleton 测试钉住 active 阶段头部对 leading slot 的渲染调用。ui-theme 的 corner-shape 与 full-round 样式门覆盖新样式表。 +ui-theme 引导与 ui-layout presenter 测试钉住 `data-ds-theme-source` 的发布与清除。ui-sidebar apply 测试钉住 leading 座注册(组件、locale、共享 inject face)及 teardown 移除。ui-conversation skeleton 测试钉住 选中或未选中 Session 时对 leading slot 的渲染调用。ui-theme 的 corner-shape 与 full-round 样式门覆盖新样式表。 diff --git a/.agents/notes/implemented/feature/2026-09-16-known-site-link-marks.md b/.agents/notes/implemented/feature/2026-09-16-known-site-link-marks.md deleted file mode 100644 index 5e4bae0349..0000000000 --- a/.agents/notes/implemented/feature/2026-09-16-known-site-link-marks.md +++ /dev/null @@ -1,38 +0,0 @@ -# Agent Note: Known-site link marks - -Status: implemented - -English | [中文](2026-09-16-known-site-link-marks.zh.md) - -## Problem - -Every transcribed anchor led with the same globe, so a transcript full of GitHub, npm, and documentation links gave no signal about where a link goes until the reader parsed its label. The [clickable-link vocabulary](2026-09-04-web-clickable-link-styles.md) reserved that leading seat for one category glyph and left per-site marks as a possible later extension of the `url` category. - -## Decision - -`LinkIconRegular` and `LinkIconMedium` take an optional `href`. For the `url` category, a destination whose host is a well-known site draws that site's own mark; every other `url` destination keeps the globe, and the file categories ignore `href` because their destination is a path, not a site. - -`SiteGlyph.tsx` in ui-primitives owns the mapping: forty host suffixes resolve to thirty-four marks. The developer sites a transcript usually cites: GitHub (`github.com`, `github.io`, `raw.githubusercontent.com`), GitLab, npm, PyPI, Stack Overflow, MDN, Wikipedia, Hacker News (`news.ycombinator.com`), YouTube (`youtube.com`, `youtu.be`), X (`x.com`, `twitter.com`), Bilibili, Zhihu, Juejin, CSDN. The mainstream sites a general audience links: search (Google, Baidu, DuckDuckGo), video and audio (TikTok, Netflix, Spotify), social and messaging (Facebook, Instagram, Reddit, Telegram with its `t.me` short links, WhatsApp with `wa.me`, WeChat via `weixin.qq.com`, QQ, Weibo), shopping (Taobao, AliExpress, eBay), reference and community (Quora, V2EX), and Apple. A host matches a suffix when it equals it or is a subdomain of it, and the longest matching suffix wins, so `gist.github.com` and `en.wikipedia.org` need no entry while `weixin.qq.com` keeps WeChat rather than the QQ mark its `qq.com` suffix would also select. Only absolute `http:` and `https:` destinations can match; anything else falls back to the globe. - -The artwork is the [Simple Icons](https://simpleicons.org) set (CC0-1.0) consumed as a devDependency of ui-primitives, so adding a site is one host-map entry and the artwork version comes from the lockfile rather than a copied path. Each mark renders as that set's single path on its 24-unit viewBox, inset to a 28-unit box so it keeps the ~8% margin the 20-unit `ic_ds_*` glyphs draw inside their own box, and takes the link's `currentColor` rather than the brand fill the set records. Every mark is `aria-hidden`; the anchor's own text remains the accessible name. - -Taking `currentColor` is deliberate for a functional link glyph rather than brand presentation: YouTube, X, Instagram, and other brands publish guidelines against recoloring their marks, and the first alternative below weighs the fixed-brand-fill option. CC0-1.0 covers the path data; every mark remains its owner's trademark, and the generated third-party notices cover the package rather than this artwork. - -Two consumers pass their destination: the markdown renderer's `renderSafeLink`, which covers authored anchors, reference links, and URL-promoted inline code, and the web card's source and fetch links. Both already hold the sanitized destination. - -## Alternatives considered - -- **Fetching each site's favicon** from the site itself or a favicon service. This covers arbitrary sites, but rendering a transcript would issue network requests to every linked host, which discloses reading activity and turns a text render into a network operation; it also fails offline and behind a strict `img-src` policy. Rejected: a fixed local vocabulary keeps rendering deterministic and private, and the globe remains the honest fallback. -- **Filling the mark with the site's brand color.** Rejected: the link glyphs are `currentColor`-only so they follow the link alias, dark mode, and hover; a fixed fill would be the first exception and would fight the link's own hover color. -- **Copying the path data into this module.** Rejected: a third-party artwork set that changes upstream is exactly what a pinned dependency tracks; fourteen embedded copies would leave no update path. -- **Adding site values to `LinkIconKind`.** Rejected: the kind is the category the consumer states, while the site is derived from the destination; folding both into one union would make every consumer spell out a site it does not know. -- **A monogram tile for every unmapped host.** Rejected as noise: a generated letter capsule claims a site identity without carrying one, and 14px leaves no room for readable initials. - -## Consequences - -- Adding a site needs a host-map entry in `SiteGlyph.tsx` and, to be checked, a URL in the `link-icon` spec: that spec lists a representative URL per mapped site, requires thirty-four distinct marks across them, and pins the GitHub, npm, YouTube, Wikipedia, X, Telegram, and WhatsApp aliases. A dropped or duplicated entry fails the count, while a site added to the map alone stays uncovered by the spec. -- The vocabulary stays bounded by category rather than by demand: each site costs one map entry, one upstream mark, and one spec URL, and the thirty-four marks add roughly 23 KB of path data to the source, which the shell build reduces to a few KB in the entry chunk after tree-shaking, minification, and compression. -- `simple-icons` enters the browser build graph as a development dependency of ui-primitives; the shell build tree-shakes the imported marks instead of the complete set. -- Unknown hosts, non-http schemes, unparseable destinations, and file categories keep the existing globe or category glyph; the `mailto` link in a transcript still shows the globe. -- The vocabulary is deliberately finite. Sites such as `example.com` never get a mark from this mechanism; recognizing them would require the network fetching the alternatives reject. -- Coverage: the LinkIcon spec covers the aliases, the fallback branches, and the sizing seat; the markdown spec pins one known and one unknown anchor; the web-card spec pins a source and a fetch link against the same marks. diff --git a/.agents/notes/implemented/feature/2026-09-16-known-site-link-marks.zh.md b/.agents/notes/implemented/feature/2026-09-16-known-site-link-marks.zh.md deleted file mode 100644 index 7b57ab0bbc..0000000000 --- a/.agents/notes/implemented/feature/2026-09-16-known-site-link-marks.zh.md +++ /dev/null @@ -1,38 +0,0 @@ -# Agent Note: 已知站点链接标记 - -Status: implemented - -[English](2026-09-16-known-site-link-marks.md) | 中文 - -## Problem - -转写内容中的每个锚点都以同一个地球图形开头,因此满是 GitHub、npm 与文档链接的对话在读者解析标签之前,无法从图形看出链接去向。[可点击链接词汇](2026-09-04-web-clickable-link-styles.zh.md) 把该前置位置留给一个分类图形,并把按站点区分的标记留作 `url` 类别日后可能的扩展。 - -## Decision - -`LinkIconRegular` 与 `LinkIconMedium` 接受可选的 `href`。对于 `url` 类别,目的地主机属于已知站点时改画该站点自己的标记;其余 `url` 目的地仍使用地球,文件类别忽略 `href`,因为它们的目的地是路径而不是站点。 - -ui-primitives 的 `SiteGlyph.tsx` 持有该映射:四十个主机后缀解析为三十四个标记。转写内容常引用的开发者站点:GitHub(`github.com`、`github.io`、`raw.githubusercontent.com`)、GitLab、npm、PyPI、Stack Overflow、MDN、Wikipedia、Hacker News(`news.ycombinator.com`)、YouTube(`youtube.com`、`youtu.be`)、X(`x.com`、`twitter.com`)、Bilibili、知乎、掘金与 CSDN。普通用户常访问的主流站点:搜索(Google、百度、DuckDuckGo)、视频与音频(TikTok、Netflix、Spotify)、社交与通讯(Facebook、Instagram、Reddit、Telegram 及 `t.me` 短链、WhatsApp 及 `wa.me`、`weixin.qq.com` 上的微信、QQ、微博)、购物(淘宝、速卖通、eBay)、参考资料与社区(Quora、V2EX)以及 Apple。主机等于某后缀或为其子域即匹配,且最长匹配后缀优先,因此 `gist.github.com` 与 `en.wikipedia.org` 无需单独登记,而 `weixin.qq.com` 得到微信标记而不是其 `qq.com` 后缀同样会选中的 QQ 标记。只有绝对 `http:` 与 `https:` 目的地能够匹配,其余一律回退到地球。 - -图形取自 [Simple Icons](https://simpleicons.org) 图标集(CC0-1.0),以 ui-primitives 的 devDependency 形式引入,因此新增站点只是主机映射中的一条记录,图形版本由 lockfile 固定,而不是复制路径数据。每个标记以该图标集的单条 path 绘制在其 24 单位 viewBox 上,并内缩到 28 单位盒中,以保留 20 单位 `ic_ds_*` 图形在自身盒内约 8% 的留白;填充使用链接的 `currentColor`,而不是图标集记录的品牌填充色。所有标记均为 `aria-hidden`,锚点自身文本仍是可访问名称。 - -对功能性链接图形采用 `currentColor` 是刻意的取舍,而非品牌呈现:YouTube、X、Instagram 等品牌公布的规范禁止为其标记改色,下方第一个备选方案比较了固定品牌填充的做法。CC0-1.0 覆盖路径数据;每个标记仍属其所有者的商标,生成的第三方声明覆盖该 npm 包本身,而不是这份图形。 - -两处消费者传入自己的目的地:markdown 渲染器的 `renderSafeLink`(覆盖作者书写的锚点、引用式链接与提升为链接的行内代码)以及 web 卡片的来源与抓取链接。两者本已持有经安全校验的目的地。 - -## Alternatives considered - -- **抓取各站点的 favicon**,无论取自站点本身还是 favicon 服务。这能覆盖任意站点,但渲染一份转写内容会向每个被链接的主机发起网络请求,泄露阅读行为并把文本渲染变成网络操作;它在离线与严格 `img-src` 策略下同样失败。拒绝:固定的本地词汇表让渲染保持确定与私密,地球仍是诚实的回退。 -- **以站点品牌色填充标记。** 拒绝:链接图形只使用 `currentColor`,以跟随链接 alias、暗色模式与 hover;固定填充会成为首个例外,并与链接自身的 hover 颜色冲突。 -- **把路径数据复制进本模块。** 拒绝:上游会持续更新的第三方图形集正是需要固定版本依赖来跟踪的内容;十四个内嵌副本没有更新路径。 -- **把站点值加进 `LinkIconKind`。** 拒绝:kind 是消费者陈述的分类,而站点由目的地推导;把两者折进同一联合类型会迫使每个消费者写出自己并不知晓的站点。 -- **为每个未映射主机生成首字母方块。** 以噪音为由拒绝:自动生成的字母胶囊宣称了一种站点身份却没有承载它,14px 也没有留下可读字母的空间。 - -## Consequences - -- 新增站点需要在 `SiteGlyph.tsx` 的主机映射中加一条记录,并在 `link-icon` spec 中补一个 URL 才会被检查:该 spec 为每个已映射站点列出一个代表 URL、要求它们共产生三十四个互不相同的标记,并固定 GitHub、npm、YouTube、Wikipedia、X、Telegram 与 WhatsApp 的别名。记录丢失或重复会让数量断言失败,而只改主机映射、不补 spec URL 的新站点仍未被 spec 覆盖。 -- 词汇表按类别而非按需求增长:每个站点需要一条主机映射、一个上游标记与一个 spec URL;这三十四个标记在源码中增加约 23 KB 路径数据,经 shell 构建的 tree-shake、压缩与 gzip 后进入入口 chunk 的只有几 KB。 -- `simple-icons` 作为 ui-primitives 的开发依赖进入浏览器构建图;shell 构建会 tree-shake 所导入的标记,而不是整套图标。 -- 未收录主机、非 http scheme、无法解析的目的地与文件类别都保留原有的地球或分类图形;转写内容中的 `mailto` 链接仍显示地球。 -- 该词汇表刻意有限。像 `example.com` 这样的站点不会由此机制获得标记;要识别它们就需要被上述备选方案拒绝的网络抓取。 -- 覆盖:LinkIcon spec 覆盖别名、各回退分支与尺寸座位;markdown spec 固定一个已知锚点与一个未知锚点;web 卡片 spec 用同一批标记固定一个来源链接与一个抓取链接。 diff --git a/.agents/notes/implemented/feature/2026-09-17-web-sidebar-session-title-hover-scroll.i18n.yaml b/.agents/notes/implemented/feature/2026-09-17-web-sidebar-session-title-hover-scroll.i18n.yaml deleted file mode 100644 index 896278e3d6..0000000000 --- a/.agents/notes/implemented/feature/2026-09-17-web-sidebar-session-title-hover-scroll.i18n.yaml +++ /dev/null @@ -1,6 +0,0 @@ -# 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-17-web-sidebar-session-title-hover-scroll.md -2026-09-17-web-sidebar-session-title-hover-scroll.md: 07f802f2c64aa40bb13ba679cda2007112b58d69 -2026-09-17-web-sidebar-session-title-hover-scroll.zh.md: aa08bac5018304a0e25e93062f7cc45ca415799e diff --git a/.agents/notes/implemented/feature/2026-09-17-web-sidebar-session-title-hover-scroll.md b/.agents/notes/implemented/feature/2026-09-17-web-sidebar-session-title-hover-scroll.md deleted file mode 100644 index 07f802f2c6..0000000000 --- a/.agents/notes/implemented/feature/2026-09-17-web-sidebar-session-title-hover-scroll.md +++ /dev/null @@ -1,41 +0,0 @@ -# Agent Note: Web sidebar Session title hover scroll - -Status: implemented - -English | [中文](2026-09-17-web-sidebar-session-title-hover-scroll.zh.md) - -## Problem - -A Session title wider than its sidebar row is clipped with an ellipsis, so the part that distinguishes two related Sessions is the part that disappears. Fork makes it visible: the child's persisted title is the parent's plus an increment, so a long parent title leaves the `(1)` off the row's right edge. Opening the hover card shows the full title, but the card needs a dwell, covers neighboring rows, and is not the comparison the user is trying to make. - -## Decision - -A Session row reveals its clipped title in place while the pointer rests on the row. `SessionNodeItem` writes `title.scrollLeft = scrollWidth - clientWidth` on pointer enter and returns it with `scrollTo({ left: 0, behavior: 'instant' })` on pointer leave; the title element keeps `overflow: hidden`, so nothing reflows, no scrollbar appears, and the page never scrolls. Entering glides because the title carries `scroll-behavior: smooth` (the file's reduced-motion block restores `auto`), so the handler owns no timer or animation loop. Returning is deliberately one step: a smooth return would travel under the resting ellipsis and the row's narrowing cell for its whole duration. - -While the pointer is on the row, `text-overflow` switches from `ellipsis` to `clip`: a scrolled `overflow: hidden` box still paints the ellipsis at its right edge, which would cover exactly the characters the scroll just revealed (verified in Chromium before the rule was written). That rule sits behind `@media (hover: hover)`, because a touch tap latches `:hover` on the row after the title has already returned, and an unguarded rule would leave the tapped row hard-clipped at its start. - -The reveal belongs to the Session row, not to a single cell: entering anywhere on the row starts it, and it ends only when the pointer leaves the row. Workspace rows and search results keep their ellipsis behavior — their titles distinguish nothing by a trailing token — and only the Session title carries the scroll behavior. - -The hovered row is also the wider cell: its trailing relative-time label yields to the row menu, so the hovered `clientWidth` is larger than the resting one and the hovered scroll maximum is smaller. A handler that read the resting width would compute a larger delta that the browser clamps to the hovered maximum, reaching the same far edge; the browser scenario therefore polls the live extent instead of the value the handler read. - -## Alternatives considered - -**A moving inner span or a duplicated-copy marquee.** Rejected: `text-overflow: ellipsis` ellipsizes text directly inside the clipping block, so moving the text requires a nested element whose own clipping (or the duplicate copy) moves with it; the resting ellipsis, the existing DOM, and the accessible text would all change. Scrolling the existing element keeps every one of them. - -**A rAF or CSS-loop marquee.** Rejected: a continuous animation keeps moving while the user reads and needs per-row lifetime management. One scroll to the far edge and one back is the whole reveal. - -**`scrollTo({ behavior: 'smooth' })` for both moves.** Rejected: it needs a `matchMedia` probe for reduced motion plus a capability branch, and it would keep the return animating under the resting ellipsis. The stylesheet supplies the entering glide, and the leaving move asks for `instant` explicitly. - -**A handler-owned clip state cleared on `scrollend`.** Considered for the return transient; rejected because returning in one step removes the transient without a second state, an event listener, or a browser capability the jsdom lane lacks. - -**`direction: rtl` on hover.** Rejected: it jumps instead of scrolling and reorders punctuation inside mixed-direction titles. - -**Leaving the reveal to the hover card.** Rejected: the card opens only after its dwell and covers neighboring rows, so it cannot support comparing two adjacent rows. - -## Consequences - -A long title still occupies exactly one row line; the scroll range is measured from the element's own box, so a width change (the sidebar resizing, or the hovered row's own cell change) is picked up on the next pointer enter, and the instant return leaves the resting ellipsis and the text consistent whenever the pointer leaves. Revealing depends on a pointer resting on the row, so touch and keyboard users still rely on the hover card and the Rename dialog for the full title; removing the reveal leaves an ordinary ellipsized row. - -## Testing - -`packages/client/ui-workspace/tests/rows.client.spec.tsx` pins the scroll position written on pointer enter, the explicit instant return, and that a title that fits stays at its start. `packages/client/ui-workspace/tests/browser-styles.client.spec.ts` pins the stylesheet contract for the hovered row. `apps/web/tests/sidebar-title-hover-scroll.e2e.ts` hovers a seeded clipped row in the assembled application and asserts that the title really overflows, reaches its own live extent under the pointer, stops ellipsizing, returns to its start within one step, and that the computed scroll behavior is `smooth` in the default media state and `auto` under reduced motion. diff --git a/.agents/notes/implemented/feature/2026-09-17-web-sidebar-session-title-hover-scroll.zh.md b/.agents/notes/implemented/feature/2026-09-17-web-sidebar-session-title-hover-scroll.zh.md deleted file mode 100644 index aa08bac501..0000000000 --- a/.agents/notes/implemented/feature/2026-09-17-web-sidebar-session-title-hover-scroll.zh.md +++ /dev/null @@ -1,41 +0,0 @@ -# Agent Note: Web 侧栏 Session 标题悬停滚动 - -Status: implemented - -[English](2026-09-17-web-sidebar-session-title-hover-scroll.md) | 中文 - -## 问题 - -标题宽于侧栏行时会被省略号裁切,于是区分两个相关 Session 的那一部分恰好消失。Fork 会让这件事暴露出来:子会话持久化标题是父标题加一个递增序号,父标题一长,`(1)` 就落到行的右边界之外。悬停卡片能显示完整标题,但它需要停留等待、会盖住相邻行,而且用户想做的比较并不是看卡片。 - -## 决策 - -指针停在 Session 行上时,该行就地显示被裁切的标题。`SessionNodeItem` 在指针进入时写入 `title.scrollLeft = scrollWidth - clientWidth`,在指针离开时用 `scrollTo({ left: 0, behavior: 'instant' })` 一次性回到起点;标题元素保持 `overflow: hidden`,因此不会重排、不会出现滚动条,页面本身也不滚动。进入时之所以平滑,是因为标题带有 `scroll-behavior: smooth`(本文件的减少动效块恢复为 `auto`),处理函数因此不持有定时器或动画循环。返回被有意做成一步到位:平滑返回会让静止态省略号和收窄后的格子在整个返回过程中一直覆盖着文字。 - -指针停留在行上期间,`text-overflow` 由 `ellipsis` 切换为 `clip`:已滚动的 `overflow: hidden` 盒子仍会在右边界绘制省略号,而那正是刚刚被滚动显示出来的字符(写这条规则前已在 Chromium 中验证)。这条规则位于 `@media (hover: hover)` 之内,因为触屏点按会在标题已经返回之后仍让该行的 `:hover` 保持钉住,不设保护就会让被点按的行停在起点且硬裁切、失去省略号。 - -该行为属于 Session 行,而不是某一格:指针进入行的任意位置都会开始,只有离开该行才结束。Workspace 行与搜索结果保留省略号行为——它们的标题没有靠尾部记号区分的含义——只有 Session 标题带有滚动行为。 - -悬停中的行同时也是更宽的格子:行尾的相对时间标签会让位给行操作菜单,因此悬停时的 `clientWidth` 比静止时更大,可滚动上限更小。若处理函数读到的是静止宽度,它会算出一个更大的位移,浏览器会把它夹到悬停状态的上限,最终同样到达远端;因此浏览器场景断言的是实时到达范围,而不是处理函数读到的数值。 - -## 备选方案 - -**移动的内层元素或复制副本的跑马灯。** 否决:`text-overflow: ellipsis` 只对裁切块内直接包含的文本生效,因此移动文本需要嵌套元素,而它自身的裁切(或复制副本)会随之移动;静止态的省略号、现有 DOM 和可访问文本都会改变。滚动现有元素则三者都保留。 - -**rAF 或 CSS 循环跑马灯。** 否决:持续动画会在用户阅读时继续移动,并且需要逐行管理生命周期。滚到远端再滚回一次,就是这个揭示所需的全部动作。 - -**两个方向都用 `scrollTo({ behavior: 'smooth' })`。** 否决:它需要一个 `matchMedia` 探测来处理减少动效,还需要一个能力分支,并且会让返回过程在静止态省略号之下持续动画。进入的平滑由样式表提供,离开则显式要求 `instant`。 - -**由处理函数持有裁切状态并在 `scrollend` 时清除。** 曾用于消除返回过程的瞬态;否决,因为一步返回直接消除该瞬态,无需第二个状态、事件监听或 jsdom 车道缺少的浏览器能力。 - -**悬停时使用 `direction: rtl`。** 否决:它是跳变而非滚动,并且会重排混合方向标题中的标点。 - -**把揭示交给悬停卡片。** 否决:卡片要等停留时间结束才出现,而且会盖住相邻行,无法支持并排比较两行。 - -## 后果 - -长标题仍然只占一行;滚动范围按元素自身盒宽测量,因此宽度变化(侧栏缩放,或悬停行自身的格子变化)会在下一次指针进入时被重新读取,而一步返回保证指针离开时静止态省略号与文字始终一致。揭示依赖指针停在行上,所以触屏与键盘用户仍需通过悬停卡片和重命名对话框获取完整标题;去掉该行为后,这一行只是普通的省略号行。 - -## 测试 - -`packages/client/ui-workspace/tests/rows.client.spec.tsx` 钉住指针进入时写入的滚动位置、显式的一步返回,以及能放下的标题停留在起点。`packages/client/ui-workspace/tests/browser-styles.client.spec.ts` 钉住悬停行的样式表契约。`apps/web/tests/sidebar-title-hover-scroll.e2e.ts` 在组装后的应用中悬停一条被裁切的种子会话行,断言标题确实溢出、到达指针下的实时范围、停止省略、一步回到起点,并断言默认媒体状态下计算出的滚动行为为 `smooth`、减少动效下为 `auto`。 diff --git a/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.i18n.yaml b/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.i18n.yaml index b6d13dd1aa..23d245c8c7 100644 --- a/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.i18n.yaml +++ b/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-07-26-ci-failover-runbook.md -2026-07-26-ci-failover-runbook.md: 10123fe1999c0ad03f977e1cc7c69d788a679c2b -2026-07-26-ci-failover-runbook.zh.md: cff99e6f644bf945f8366c2a723b32222ab902b4 +2026-07-26-ci-failover-runbook.md: c2fd2fa6cf673ec4d202702c16a9a85a3a609b8d +2026-07-26-ci-failover-runbook.zh.md: 7fa5887bb552afd98cebd7502202fb5e9f37b1ee diff --git a/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.md b/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.md index 10123fe199..c2fd2fa6cf 100644 --- a/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.md +++ b/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.md @@ -32,7 +32,7 @@ The two switches are independent: flip only the one whose platform is degraded. 1. Repository **Settings → Secrets and variables → Actions → Variables → New repository variable**: name `DSH_CI_FAILOVER_LINUX` (Linux pool outage) or `DSH_CI_FAILOVER_WINDOWS` (Windows pool outage), value `selfhosted`. 2. Retrigger the required jobs so they re-resolve their pool. Jobs already **queued** for the hosted labels do not retarget and cannot be re-run in place, so for the documented indefinite-queue outage, cancel the stuck run and re-run all jobs, or push a new commit; "Re-run failed jobs" only helps once a job has actually failed rather than queued. -3. That is the entire switch. Under the `selfhosted` Linux failover value the workflow also drops `DSH_SNAPSHOT_MAX_CONCURRENCY` to 12 for the shared VM and skips the hosted-path pnpm cache restores because the VM's persistent store serves warm installs. Coverage uses the same four single-worker instrumented partitions and two exempt workers on both Linux pools. The Windows switch has no concurrency or cache branches; it only retargets the native Windows jobs' pool. +3. That is the entire switch. Under the `selfhosted` Linux failover value the workflow also drops `DSH_SNAPSHOT_MAX_CONCURRENCY` to 12 for the shared VM and skips the hosted-path pnpm cache restores because the VM's persistent store serves warm installs. Self-hosted coverage uses four single-worker instrumented partitions and two exempt workers; isolated hosted runners derive worker defaults from available CPUs. The Windows switch selects the native Windows pool and retains explicit worker limits on shared self-hosted runners. **Dependabot exception.** Both switches' `selfhosted` legs deliberately exclude `dependabot[bot]`: under self-hosted failover, Dependabot PRs stay queued for the hosted pool rather than executing dependency-supplied code on the persistent VMs. A Dependabot PR that remains queued during an outage is expected behavior, not a failed switch; it completes when the hosted pool recovers. The `blacksmith` value's branches carry no such exclusion, because Blacksmith runners are ephemeral (see the [blacksmith failover leg note](2026-09-09-blacksmith-failover-leg.md)). diff --git a/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.zh.md b/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.zh.md index cff99e6f64..7fa5887bb5 100644 --- a/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.zh.md +++ b/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.zh.md @@ -32,7 +32,7 @@ Status: implemented 1. 仓库 **Settings → Secrets and variables → Actions → Variables → New repository variable**:名称 `DSH_CI_FAILOVER_LINUX`(Linux 池故障)或 `DSH_CI_FAILOVER_WINDOWS`(Windows 池故障),值 `selfhosted`。 2. 重新触发必需作业,使其重新解析运行器池。已经为托管标签**排队**的作业不会重定向,也无法原地 re-run,因此对于本手册所述的无限排队故障,应取消卡住的运行并 re-run all jobs,或推送一个新提交;“Re-run failed jobs”只有在作业真正失败(而非仍在排队)时才有用。 -3. 切换到此完成。在 `selfhosted` 的 Linux 故障切换取值下,工作流还会把 `DSH_SNAPSHOT_MAX_CONCURRENCY` 降为 12,以限制共享虚拟机上的争抢,并跳过托管路径的 pnpm 缓存恢复,因为虚拟机的持久 store 会直接提供热安装。覆盖率在两个 Linux 池上都使用 4 个单 worker 插桩分区与 2 个豁免 worker。Windows 开关没有并发或缓存分支;它只重定向原生 Windows 作业的运行器池。 +3. 切换到此完成。在 `selfhosted` 的 Linux 故障切换取值下,工作流还会把 `DSH_SNAPSHOT_MAX_CONCURRENCY` 降为 12,以限制共享虚拟机上的争抢,并跳过托管路径的 pnpm 缓存恢复,因为虚拟机的持久 store 会直接提供热安装。自托管覆盖率使用 4 个单 worker 插桩分区与 2 个豁免 worker;独占的托管运行器根据可用 CPU 计算默认 worker 数。Windows 开关选择原生 Windows 运行器池,并在共享的自托管运行器上保留显式 worker 限制。 **Dependabot 例外。**两个开关的 `selfhosted` 腿都刻意排除 `dependabot[bot]`:自托管故障切换期间,Dependabot 拉取请求继续在托管池排队,而不是把依赖项提供的代码放到持久化虚拟机上执行。故障期间 Dependabot PR 持续排队是预期行为而非切换失败;托管池恢复后它会自行完成。`blacksmith` 取值下的分支不带此类排除,因为 Blacksmith 运行器是临时的(见 [blacksmith 故障切换支路笔记](2026-09-09-blacksmith-failover-leg.zh.md))。 diff --git a/.agents/notes/implemented/process/2026-07-26-frozen-agent-note-archive.i18n.yaml b/.agents/notes/implemented/process/2026-07-26-frozen-agent-note-archive.i18n.yaml index 5b23d66658..c8bfb9d43c 100644 --- a/.agents/notes/implemented/process/2026-07-26-frozen-agent-note-archive.i18n.yaml +++ b/.agents/notes/implemented/process/2026-07-26-frozen-agent-note-archive.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-07-26-frozen-agent-note-archive.md -2026-07-26-frozen-agent-note-archive.md: 0c139c4a5d892de5bdace76b4935edf32586c4c1 -2026-07-26-frozen-agent-note-archive.zh.md: 2c2751c56a9b20423b00eac7a90c02286ce3e8b0 +2026-07-26-frozen-agent-note-archive.md: e9ecfa78f53a5912608201654e2c56e399defccd +2026-07-26-frozen-agent-note-archive.zh.md: 9a3ce2e6e21864f2754f627be6f7576bfc1f9076 diff --git a/.agents/notes/implemented/process/2026-07-26-frozen-agent-note-archive.md b/.agents/notes/implemented/process/2026-07-26-frozen-agent-note-archive.md index 0c139c4a5d..e9ecfa78f5 100644 --- a/.agents/notes/implemented/process/2026-07-26-frozen-agent-note-archive.md +++ b/.agents/notes/implemented/process/2026-07-26-frozen-agent-note-archive.md @@ -10,9 +10,9 @@ Implemented Agent Notes are maintained as current decision records, so every pat ## Decision -Only implemented Agent Notes can be archived. An implemented note moves when its shipped decision is complete and its rationale, alternatives, consequences, negative guarantees, and reintroduction conditions are unlikely to guide future work. Foundational boundaries, durable and wire semantics, security rules, recurring design temptations, and unresolved reintroduction conditions remain active regardless of age or word count. Proposed notes never enter the archive; an obsolete proposal becomes rejected. A rejected note remains only while it prevents a tempting, meaningful mistake and is otherwise deleted as a complete triplet. +Implemented notes that only describe small UI adjustments or purely mechanical changes are deleted as complete triplets, with inbound links repaired or removed. A small implementation can still carry lasting naming, compatibility, ownership, or behavior decisions; those records remain eligible for retention. Only other implemented Agent Notes can be archived: the shipped decision is complete, its rationale is unlikely to guide future work, and it still has historical decision value. Foundational boundaries, durable and wire semantics, security rules, recurring design temptations, and unresolved reintroduction conditions remain active regardless of age or word count. Proposed notes never enter the archive; an obsolete proposal becomes rejected. A rejected note remains only while it prevents a tempting, meaningful mistake and is otherwise deleted as a complete triplet. -The archive uses `.agents/notes/archived/{kind}/yyyy-mm-dd-topic.md`; the redundant `implemented` segment is absent. The archival change moves the complete English, Chinese, and consistency-sidecar triplet, leaves `Status: implemented` intact, and inserts `Archived: YYYY-MM-DD` immediately below it in both language files. Relocation, that metadata line, the corresponding sidecar re-record, and mechanical inbound-link repair are the only permitted archival edits. +The archive uses `.agents/notes/archived/{kind}/yyyy-mm-dd-topic.md`; the redundant `implemented` segment is absent. The archival change moves the complete English, Chinese, and consistency-sidecar triplet, leaves `Status: implemented` intact, and inserts `Archived: YYYY-MM-DD` immediately below it in both language files. Relocation, that metadata line, the corresponding sidecar re-record, and mechanical inbound-link repair are the only permitted archival edits. Archival admission checks lifecycle metadata and completeness, not title punctuation, blank-line layout, or language-switcher wording; requiring those repairs would rewrite the historical record. The root `.rgignore` excludes the archive from searches that traverse a parent directory. Historical queries name the archive directory explicitly, so intentional access remains available without mixing frozen facts into active decision discovery. @@ -42,4 +42,4 @@ Supersession is checked while a new Agent Note is being written, not deferred to ## Consequences -The active corpus becomes a set of decisions expected to influence future work, while low-value implemented history remains explicitly searchable and linkable without consuming maintenance attention or appearing in parent-directory searches. Writing a new note includes a scoped supersession check, so replacement decisions cannot silently leave redundant active records behind. Rejected clutter can disappear when it no longer protects a meaningful choice, and proposed work cannot quietly evade a verdict through archival. The archive adds a manifest, a dedicated verifier, and an explicit one-time metadata step. Archived facts and outbound links can become stale by design, so readers and agents must treat active code and documentation as authority and cite an archived note only as history. +The active corpus becomes a set of decisions expected to influence future work, while substantive historical decisions with little future guidance remain explicitly searchable and linkable without consuming maintenance attention or appearing in parent-directory searches. Writing a new note includes a scoped supersession check, so replacement decisions cannot silently leave redundant active records behind. Rejected clutter can disappear when it no longer protects a meaningful choice, and proposed work cannot quietly evade a verdict through archival. The archive adds a manifest, a dedicated verifier, and an explicit one-time metadata step. Archived facts and outbound links can become stale by design, so readers and agents must treat active code and documentation as authority and cite an archived note only as history. diff --git a/.agents/notes/implemented/process/2026-07-26-frozen-agent-note-archive.zh.md b/.agents/notes/implemented/process/2026-07-26-frozen-agent-note-archive.zh.md index 2c2751c56a..9a3ce2e6e2 100644 --- a/.agents/notes/implemented/process/2026-07-26-frozen-agent-note-archive.zh.md +++ b/.agents/notes/implemented/process/2026-07-26-frozen-agent-note-archive.zh.md @@ -10,9 +10,9 @@ implemented Agent Note 作为当前决策记录持续维护,因此活跃记录 ## 决策 -只有 implemented Agent Note 可以归档。当一份已实施记录的交付决策已经完整落地,且其决策依据、备选方案、后果、否定性保证和重新引入条件不太可能再指导未来工作时,将其移入归档。基础性边界、持久语义与协议语义、安全规则、反复出现且看似诱人的设计选择和尚未解决的重新引入条件,无论记录的存续时间或字数如何,都继续作为活跃记录保留。proposed Agent Note 绝不进入归档;过时的提案应转为 rejected。仅当 rejected Agent Note 仍能避免一种诱人且影响重大的错误时保留,否则将其三个配对文件完整删除。 +仅描述小型 UI 调整或纯机械变更的 implemented Agent Note 会作为完整的三文件配对删除,并修复或移除入站链接。小规模实现仍可能承载长期有效的命名、兼容性、归属或行为决策;此类记录仍可保留。只有其他 implemented Agent Note 可以归档:交付决策已经完整落地,其依据不太可能再指导未来工作,但仍具有历史决策价值。基础性边界、持久语义与协议语义、安全规则、反复出现且看似诱人的设计选择和尚未解决的重新引入条件,无论记录的存续时间或字数如何,都继续作为活跃记录保留。proposed Agent Note 绝不进入归档;过时的提案应转为 rejected。仅当 rejected Agent Note 仍能避免一种诱人且影响重大的错误时保留,否则将其三个配对文件完整删除。 -归档路径为 `.agents/notes/archived/{kind}/yyyy-mm-dd-topic.md`,其中省略了冗余的 `implemented` 层级。归档变更会移动完整的英文、中文和一致性伴随记录三个文件,保留 `Status: implemented`,并在两种语言的文件中紧接该状态行插入 `Archived: YYYY-MM-DD`。归档时只允许做文件迁移、添加该元数据行、相应地重新记录伴随记录,以及机械修复入站链接。 +归档路径为 `.agents/notes/archived/{kind}/yyyy-mm-dd-topic.md`,其中省略了冗余的 `implemented` 层级。归档变更会移动完整的英文、中文和一致性伴随记录三个文件,保留 `Status: implemented`,并在两种语言的文件中紧接该状态行插入 `Archived: YYYY-MM-DD`。归档时只允许做文件迁移、添加该元数据行、相应地重新记录伴随记录,以及机械修复入站链接。 归档准入检查生命周期元数据与完整性,不检查标题标点、空行布局或语言切换栏措辞;要求修复这些格式会改写历史记录。 根目录的 `.rgignore` 会将归档目录排除在从上层目录开始的搜索之外。查找历史内容时会显式指定归档目录,因此仍可按需访问,同时不会把冻结事实混入对活跃决策的检索。 @@ -42,4 +42,4 @@ implemented Agent Note 作为当前决策记录持续维护,因此活跃记录 ## 后果 -活跃记录集合由预计仍会影响未来工作的决策组成;未来指导价值较低的实施历史仍可显式搜索和链接,却不再消耗维护精力,也不会出现在从上层目录开始的搜索中。编写新记录时会包含一项范围明确的取代关系检查,因此取代既有决策的新决策无法悄然留下冗余的活跃记录。当被否决的记录不再保护有意义的选择时,可以清除这类杂项;提案也无法通过归档悄悄逃避明确结论。归档机制增加一份 manifest、一个专用校验器和一个显式的一次性元数据步骤。归档中的事实和出站链接可以按设计逐渐陈旧,因此读者和 agent(智能体)必须以活跃代码与文档为权威依据,并且仅将归档 Agent Note 作为历史引用。 +活跃记录集合由预计仍会影响未来工作的决策组成;未来指导价值较低但具有实质内容的历史决策仍可显式搜索和链接,却不再消耗维护精力,也不会出现在从上层目录开始的搜索中。编写新记录时会包含一项范围明确的取代关系检查,因此取代既有决策的新决策无法悄然留下冗余的活跃记录。当被否决的记录不再保护有意义的选择时,可以清除这类杂项;提案也无法通过归档悄悄逃避明确结论。归档机制增加一份 manifest、一个专用校验器和一个显式的一次性元数据步骤。归档中的事实和出站链接可以按设计逐渐陈旧,因此读者和 agent(智能体)必须以活跃代码与文档为权威依据,并且仅将归档 Agent Note 作为历史引用。 diff --git a/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.i18n.yaml b/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.i18n.yaml index a542599011..7ad3923251 100644 --- a/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.i18n.yaml +++ b/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.md -2026-08-08-native-windows-pull-request-ci.md: e4fc7cab8c274148191632e8cc125ac75f2ec1d5 -2026-08-08-native-windows-pull-request-ci.zh.md: 27ad602c3748f2920e6b63f271a6a38b11c0ab77 +2026-08-08-native-windows-pull-request-ci.md: 64c4366ee8d60dd0137e6e4eb5473c0450204efe +2026-08-08-native-windows-pull-request-ci.zh.md: 48725cdc58ab7b8db22461da64236c6de3bb3027 diff --git a/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.md b/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.md index e4fc7cab8c..64c4366ee8 100644 --- a/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.md +++ b/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.md @@ -14,11 +14,11 @@ A coverage audit found that stale branch state had restored temporary exclusions The master-only `windows` job in [ci-master.yml](../../../../.github/workflows/ci-master.yml) runs `windows node 24 / wine` on `ubuntu-latest`. It retains the checksum-verified Windows Node, Wine apt and pnpm caches, a hoisted install confined to a workspace snapshot, and the [shared Wine gate script](../../../../scripts/wine-windows-gates.sh) that runs the workspace build and production site. Node distribution transfers use bounded retries; when nodejs.org stalls on the large archive, a range-capable transport mirror resumes the same bytes, but nodejs.org remains the version and SHA-256 authority and the archive is never promoted before that checksum passes. Wine is outside the PR aggregate under the [master-only platform policy](2026-09-06-master-only-platform-ci.md). The [archived Wine experiment](../../archived/process/2026-07-27-wine-windows-gates-experiment.md) preserves its measured trade-offs, while this note owns the current dual topology. -Every pull request also starts four independent native jobs on the organization-owned `dsh-windows-2025-16core` runner: `windows-build`, `windows-coverage`, `windows-native-tests`, and `windows-observational`. Each job enables Developer Mode for workspace symlinks, provisions the repository-pinned pnpm through `pnpm/action-setup`, performs an immutable install without a transferred store archive, and runs its inventory under native PowerShell. The Windows failover variable (`DSH_CI_FAILOVER_WINDOWS`) retargets all four jobs to the in-house pool under `selfhosted` and to Blacksmith's Windows runners under `blacksmith` (see the [blacksmith failover leg note](2026-09-09-blacksmith-failover-leg.md)). Per-job deadlines range from 60 to 120 minutes and bound stuck work without treating a performance target as a correctness deadline. +Every pull request starts three native jobs: `windows-build`, `windows-coverage`, and `windows-native-tests`. Each job enables Developer Mode for workspace symlinks, provisions the repository-pinned pnpm through `pnpm/action-setup`, performs an immutable install without a transferred store archive, and runs its inventory under native PowerShell. The Windows failover variable (`DSH_CI_FAILOVER_WINDOWS`) selects the organization-owned `dsh-windows-2025-16core` runner by default, the in-house pool under `selfhosted`, or Blacksmith runners under `blacksmith` (see the [blacksmith failover leg note](2026-09-09-blacksmith-failover-leg.md)). Blacksmith build and coverage jobs request 16 vCPU; file-serial native tests request 2 vCPU. Per-job deadlines range from 60 to 120 minutes. -`windows-build` and `windows-native-tests` are dependencies of `all checks passed`; their workspace-build and targeted native-process results are blocking. `windows-coverage` remains an ordinary job but is absent from aggregate `needs`, so its 100%-per-file result stays red and visible without delaying the required verdict. `windows-observational` is also absent from aggregate `needs` and uses `continue-on-error` because Linux owns the blocking static, documentation, package, and built-artifact verdicts. +`windows-build` and `windows-native-tests` are dependencies of `all checks passed`; their workspace-build and targeted native-process results are blocking. After its blocking build and production-site checks succeed, `windows-build` runs `check:ci:windows-observational-ready` in the same workspace. This step uses `continue-on-error`, disables fail-fast, and has a 15-minute timeout; failures produce a workflow warning because Linux owns the blocking static, documentation, package, and built-artifact verdicts. The required verdict waits for the step to finish but does not require its diagnostics to pass. `windows-coverage` remains outside aggregate `needs`, so its 100%-per-file result stays red and visible without delaying the verdict. -`windows-coverage` runs [in-job partitioned coverage](2026-08-18-in-job-partitioned-coverage.md) without a preceding build, matching the Linux coverage lane: four single-worker instrumented shards beside a two-worker exempt-heavy gate; workspace imports resolve to `src` through the tsconfig paths map, and the lib-consuming suites self-skip on unbuilt checkouts. Both coverage gates raise Vitest's per-test, expect.poll, and hook budgets through `DSH_COVERAGE_TEST_TIMEOUT_MS=90000`. `windows-observational` owns its own workspace build and production-site validation, starts the independent static gates together, and caps `publint` at eight workers. Its built-bin smoke starts only after every other observational gate settles; the smoke's `needs` edge still requires a successful build, while its `after` edges preserve the diagnostic after another gate fails. This keeps bounded real-application startup measurements from competing with tool-catalog, NodeNext, package, and documentation processes. The script-only translation-pairing merge suite runs in the exempt-heavy gate because it imports only `scripts/` sources and child processes; V8 instrumentation contributes no threshold coverage there but magnifies Git-process latency. Lefthook concurrency fixtures retain their outcomes with 30-second case budgets and a 10-second process-ready probe, while the installer allows five seconds for a preempted lock owner to publish its record after exclusive creation. Directory-picker composition gives its debounced config write an explicit 15-second poll budget; workspace-context composition fixtures use a test-owned signal without an unrelated one-second deadline. The LSP sources and the ACL-sandbox sources remain in the Windows denominator: stub-based failure-path suites carry every in-process ACL-sandbox file to 100%, and only the runner entry stays excluded — it executes exclusively as a spawned child outside the instrumented run, its behavior pinned end-to-end by the runner suite. Narrow annotated V8 ignores cover only unreachable branches (peer-platform arms and lifecycle-unreachable guards), with their behavior tests retained on the owning platform. +`windows-coverage` runs [in-job partitioned coverage](2026-08-18-in-job-partitioned-coverage.md) without a preceding build, matching the Linux coverage lane: four single-worker instrumented shards beside a two-worker exempt-heavy gate; workspace imports resolve to `src` through the tsconfig paths map, and the lib-consuming suites self-skip on unbuilt checkouts. Both coverage gates raise Vitest's per-test, expect.poll, and hook budgets through `DSH_COVERAGE_TEST_TIMEOUT_MS=90000`. The observational step reuses the successful build, retains the distinct MPA documentation build, starts independent static gates together, and uses CI worker defaults for `publint`, with an eight-worker override on shared self-hosted runners. Its built-bin smoke starts only after every other observational gate settles, including failed gates. The ready aggregate requires a successful prior workspace build; it removes only that build and its internal dependency edges from the shared internal inventory. This keeps bounded real-application startup measurements from competing with tool-catalog, NodeNext, package, and documentation processes. The script-only translation-pairing merge suite runs in the exempt-heavy gate because it imports only `scripts/` sources and child processes; V8 instrumentation contributes no threshold coverage there but magnifies Git-process latency. Lefthook concurrency fixtures retain their outcomes with 30-second case budgets and a 10-second process-ready probe, while the installer allows five seconds for a preempted lock owner to publish its record after exclusive creation. Directory-picker composition gives its debounced config write an explicit 15-second poll budget; workspace-context composition fixtures use a test-owned signal without an unrelated one-second deadline. The LSP sources and the ACL-sandbox sources remain in the Windows denominator: stub-based failure-path suites carry every in-process ACL-sandbox file to 100%, and only the runner entry stays excluded — it executes exclusively as a spawned child outside the instrumented run, its behavior pinned end-to-end by the runner suite. Narrow annotated V8 ignores cover only unreachable branches (peer-platform arms and lifecycle-unreachable guards), with their behavior tests retained on the owning platform. The 16-core allocation is the measured capacity point for this inventory. Six-worker coverage trials produced complete passes in 6 minutes 27 seconds and 7 minutes 50 seconds, while exact-head trials with four, three, and two concurrent workers inside one instrumented Vitest process exposed unreliable fixtures and worker exits. Separate single-worker child processes retain process isolation. Historical sixteen-shard samples reduced instrumented coverage to 112.66–122.01 seconds. The pull-request coverage job schedules four instrumented children plus two exempt workers without a preceding build, while the self-hosted complete reference runs its unsharded coverage gates serially with one worker. A six-partition pull-request profile creates enough process and type-aware lint contention to violate bounded test deadlines. Sixteen instrumented shards plus two exempt workers would exceed a 16-core allocation before system overhead. A 32-core comparison reduced aggregate gate time by only 1.47 seconds and still triggered the CJS-lexer fatal inside a fork worker, so additional cores did not provide a reliable wall-clock improvement. @@ -40,7 +40,7 @@ Shiki disables lazy TextMate-regex compilation and warms each boot grammar befor **Run only Wine on pull requests.** Wine reaches blocking win32 toolchain branches quickly, but can report green while a real NT, NTFS, PowerShell, process, or addon contract is broken. -**Mark every non-blocking native job `continue-on-error`.** The observational job uses this setting because Linux owns its blocking verdict. Coverage remains an ordinary job outside aggregate `needs`, so a threshold failure stays visibly red without blocking the aggregate. +**Mark every non-blocking native job `continue-on-error`.** Only the observational step uses this setting because Linux owns its blocking verdict. Its containing build job must still fail when the blocking build or production site fails. Coverage remains an ordinary job outside aggregate `needs`, so a threshold failure stays visibly red without blocking the aggregate. **Exclude unsupported-looking files or weaken Windows fixtures.** Rejected because the affected LSP, watcher, persistence, client, and process behavior is supported. Peer-platform branches are marked narrowly; portable outcomes stay in the denominator and are exercised through host-realistic fixtures. @@ -50,8 +50,8 @@ Shiki disables lazy TextMate-regex compilation and warms each boot grammar befor ## Consequences -Wine provides post-merge toolchain evidence. Native coverage and observational results can still be pending or red when `all checks passed` turns green, so branch protection consumes the targeted native build and process checks while reviewers and follow-up automation consume the remaining native results. +Wine provides post-merge toolchain evidence. Native coverage may still be pending or red when `all checks passed` turns green. Observational diagnostics finish inside the required build job. A diagnostic failure leaves the PR check successful; the failed step, workflow warning, and run summary expose the failure. -Every pull request nevertheless receives a real NT kernel, NTFS, PowerShell, Windows process, native addon, and supported-source coverage signal. The native jobs duplicate setup across the build, coverage, and observational workspaces and repeat builds in the build and observational ones, but they lower each job's process count and expose path, watcher, lifecycle, and fixture defects hidden by the compatibility lane. +Every pull request receives a real NT kernel, NTFS, PowerShell, Windows process, native addon, and supported-source coverage signal. Observational checks reuse the successful build workspace, avoiding a second checkout, dependency installation, and workspace build. Their sequential position extends the build job and can delay the required verdict; it also separates diagnostic processes from compilation. The ready and master aggregates share one internal diagnostic inventory; master retains its own workspace build and full checks. Maintainers must preserve two intentional execution topologies: the Wine snapshot uses Linux installation plus a hoisted layout to reach win32 binaries, while the native jobs use separate immutable workspaces on the organization-owned 16-core Windows runner. A failure unique to either topology must be classified against that boundary rather than weakened or silently skipped. diff --git a/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.zh.md b/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.zh.md index 27ad602c37..48725cdc58 100644 --- a/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.zh.md +++ b/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.zh.md @@ -14,11 +14,11 @@ Wine 在 Linux 内核与区分大小写的 ext4 之上采用 hoisted 依赖布 [ci-master.yml](../../../../.github/workflows/ci-master.yml) 中仅 master 触发的 `windows` 作业在 `ubuntu-latest` 上运行 `windows node 24 / wine`。它保留经过校验和验证的 Windows Node、Wine apt 与 pnpm 缓存、仅限工作区快照的 hoisted 安装,以及运行工作区构建与生产网站的[共享 Wine 门禁脚本](../../../../scripts/wine-windows-gates.sh)。Node 分发文件传输采用有界重试;nodejs.org 的大文件传输停滞时,由支持范围请求的传输镜像续传相同字节,但版本和 SHA-256 权威仍属于 nodejs.org,归档通过该校验前绝不会投入使用。根据[仅 master 平台策略](2026-09-06-master-only-platform-ci.zh.md),Wine 不参与 PR 聚合。[已归档的 Wine 实验](../../archived/process/2026-07-27-wine-windows-gates-experiment.md)保留其实测取舍,而本文负责当前双通道拓扑。 -每个拉取请求还会在组织自有的 `dsh-windows-2025-16core` 运行器上启动 4 个相互独立的原生作业:`windows-build`、`windows-coverage`、`windows-native-tests` 与 `windows-observational`。每个作业都会为工作区符号链接启用开发人员模式,通过 `pnpm/action-setup` 提供仓库固定版本的 pnpm,在不传输 store 归档的情况下执行不可变安装,并在原生 PowerShell 下运行自己的清单。Windows 故障切换变量(`DSH_CI_FAILOVER_WINDOWS`)在 `selfhosted` 下把这 4 个作业全部重定向到公司内部运行器池,在 `blacksmith` 下重定向到 Blacksmith 的 Windows 运行器(见 [blacksmith 故障切换支路笔记](2026-09-09-blacksmith-failover-leg.zh.md))。各作业采用 60 至 120 分钟的截止时间,以约束卡住的工作,同时不把性能目标当作正确性截止时间。 +每个拉取请求都会启动三个原生作业:`windows-build`、`windows-coverage` 和 `windows-native-tests`。每个作业都会为 workspace 符号链接启用开发者模式,通过 `pnpm/action-setup` 提供仓库固定版本的 pnpm,在不传输 store 归档的情况下执行不可变安装,并在原生 PowerShell 下运行自己的清单。Windows 故障切换变量(`DSH_CI_FAILOVER_WINDOWS`)默认选择组织自有的 `dsh-windows-2025-16core` 运行器,在 `selfhosted` 下选择公司内部运行器池,在 `blacksmith` 下选择 Blacksmith 运行器(见 [blacksmith 故障切换支路笔记](2026-09-09-blacksmith-failover-leg.zh.md))。Blacksmith 构建和覆盖率作业请求 16 vCPU;逐文件串行的原生测试请求 2 vCPU。各作业的截止时间为 60 至 120 分钟。 -`windows-build` 与 `windows-native-tests` 是 `all checks passed` 的依赖项;其工作区构建和定向原生进程结果具有阻断性。`windows-coverage` 仍是常规作业,但不在聚合流程的 `needs` 中,因此逐文件 100% 覆盖率结果会保持红灯并可见,却不会延迟必需判定。`windows-observational` 同样不在聚合流程的 `needs` 中,并使用 `continue-on-error`,因为静态检查、文档、包与构建产物的阻断性判定由 Linux 负责。 +`windows-build` 与 `windows-native-tests` 是 `all checks passed` 的依赖项;其工作区构建和定向原生进程结果具有阻断性。阻断性构建和生产网站检查成功后,`windows-build` 在同一工作区运行 `check:ci:windows-observational-ready`。该步骤使用 `continue-on-error`、关闭 fail-fast,并设置 15 分钟超时;失败时会产生工作流警告,因为静态检查、文档、包与构建产物的阻断性判定由 Linux 负责。必需判定会等待该步骤结束,但不要求其诊断通过。`windows-coverage` 仍在聚合流程的 `needs` 之外,因此逐文件 100% 覆盖率结果会保持红灯并可见,却不会延迟判定。 -`windows-coverage` 与 Linux 覆盖率通道一致,不先构建工作区就运行[job 内分区覆盖率](2026-08-18-in-job-partitioned-coverage.zh.md):4 个单 worker 插桩分片与一个双 worker 的豁免重型门禁并行运行;工作区导入通过 tsconfig paths 映射解析到 `src`,消费构建产物的套件在未构建的检出上会自跳。两项覆盖率门禁都通过 `DSH_COVERAGE_TEST_TIMEOUT_MS=90000` 提高 Vitest 的单测试、expect.poll 与 hook 预算。`windows-observational` 拥有自己的工作区构建和生产网站验证,会一起启动相互独立的静态门禁,并将 `publint` 限制为最多 8 个 worker。其 built-bin 冒烟测试只在其他所有观测性门禁结算后启动;冒烟测试的 `needs` 边仍要求构建成功,而 `after` 边会在其他门禁失败后保留这项诊断。这可避免有界的真实应用启动测量与 tool-catalog、NodeNext、包及文档进程争抢资源。translation-pairing 合并套件只导入 `scripts/` 源码和子进程,因此放入豁免重型套件门禁;V8 插桩不会为它贡献任何阈值覆盖率,却会放大 Git 进程延迟。Lefthook 并发 fixture 保留原有结果,采用 30 秒单用例预算与 10 秒进程就绪探测;安装器则允许被抢占的 lock 持有者在独占创建后用 5 秒发布记录。directory-picker 组合为防抖配置写入提供显式的 15 秒轮询预算;workspace-context 组合 fixture 使用测试自有、没有无关 1 秒截止时间的信号。LSP 源码与 ACL 沙箱源码仍计入 Windows 分母:基于 stub 的失败路径套件把每个进程内 ACL 沙箱文件都带到 100%,只有 runner 入口保持排除——它只作为 spawn 出的子进程在插桩运行之外执行,其行为由 runner 套件端到端钉住。窄范围且带注释的 V8 ignore 只覆盖不可达分支(另一平台专属分支、生命周期内不可达的防御守卫),其行为测试仍保留在所属平台。 +`windows-coverage` 与 Linux 覆盖率通道一致,不先构建工作区就运行[job 内分区覆盖率](2026-08-18-in-job-partitioned-coverage.zh.md):4 个单 worker 插桩分片与一个双 worker 的豁免重型门禁并行运行;工作区导入通过 tsconfig paths 映射解析到 `src`,消费构建产物的套件在未构建的检出上会自跳。两项覆盖率门禁都通过 `DSH_COVERAGE_TEST_TIMEOUT_MS=90000` 提高 Vitest 的单测试、expect.poll 与 hook 预算。观测步骤复用成功的构建,保留独立的 MPA 文档构建,一起启动相互独立的静态门禁,并让 `publint` 使用 CI worker 默认值,在共享自托管运行器上覆盖为 8 个 worker。其 built-bin 冒烟测试只在其他所有观测性门禁结算后启动,包括失败的门禁。ready 聚合要求此前已成功构建工作区;它仅从内部共用清单中移除该构建及其内部依赖边。这可避免有界的真实应用启动测量与 tool-catalog、NodeNext、包及文档进程争抢资源。translation-pairing 合并套件只导入 `scripts/` 源码和子进程,因此放入豁免重型套件门禁;V8 插桩不会为它贡献任何阈值覆盖率,却会放大 Git 进程延迟。Lefthook 并发 fixture 保留原有结果,采用 30 秒单用例预算与 10 秒进程就绪探测;安装器则允许被抢占的 lock 持有者在独占创建后用 5 秒发布记录。directory-picker 组合为防抖配置写入提供显式的 15 秒轮询预算;workspace-context 组合 fixture 使用测试自有、没有无关 1 秒截止时间的信号。LSP 源码与 ACL 沙箱源码仍计入 Windows 分母:基于 stub 的失败路径套件把每个进程内 ACL 沙箱文件都带到 100%,只有 runner 入口保持排除——它只作为 spawn 出的子进程在插桩运行之外执行,其行为由 runner 套件端到端钉住。窄范围且带注释的 V8 ignore 只覆盖不可达分支(另一平台专属分支、生命周期内不可达的防御守卫),其行为测试仍保留在所属平台。 16 核配置是这项清单经实测选定的容量规格。使用 6 个 coverage worker 的试验分别以 6 分 27 秒和 7 分 50 秒跑出完整通过结果,而在单个插桩 Vitest 进程内使用 4 个、3 个和 2 个并发 worker 的分支头精确试验暴露出不稳定的 fixture 与 worker 退出。相互独立的单 worker 子进程保留进程隔离。历史上的 16 分片样本把插桩覆盖率缩短到 112.66–122.01 秒。拉取请求覆盖率作业会在没有前置构建的情况下调度 4 个插桩子进程和 2 个豁免 worker,而自托管完整参考流程会用 1 个 worker 串行运行未分片的覆盖率门禁。拉取请求若采用 6 分片配置,就会产生足以违反有界测试截止时间的进程与类型感知 lint 争用。16 个插桩分片加 2 个豁免 worker 会在计入系统开销前就超过 16 核分配。32 核对比仅将聚合门禁时间缩短 1.47 秒,且仍在 fork worker 内触发 CJS lexer 致命故障,因此增加核心数没有带来可靠的墙钟时间改善。 @@ -40,7 +40,7 @@ Shiki 会禁用 TextMate 正则的延迟编译,并在用户内容进入保持 **只在拉取请求上运行 Wine。** Wine 能快速触达阻断性 win32 工具链分支,但即使真实 NT、NTFS、PowerShell、进程或原生插件约定已经损坏,也可能报告绿灯。 -**将每个非阻断原生作业都标记为 `continue-on-error`。** 观测性作业采用该设置,因为它的阻断性判定由 Linux 负责。覆盖率仍是聚合流程 `needs` 之外的常规作业,因此阈值失败会保持明显红灯,却不会阻断聚合流程。 +**将每个非阻断原生作业都标记为 `continue-on-error`。** 只有观测步骤采用该设置,因为它的阻断性判定由 Linux 负责。包含该步骤的构建作业仍必须在阻断性构建或生产网站失败时失败。覆盖率仍是聚合流程 `needs` 之外的常规作业,因此阈值失败会保持明显红灯,却不会阻断聚合流程。 **排除看似不受支持的文件或削弱 Windows fixture。** 不予采纳,因为受影响的 LSP、watcher、持久化、客户端与进程行为均受支持。仅适用于另一平台的分支采用窄范围标注;可移植结果继续计入分母,并通过符合真实宿主行为的 fixture 验证。 @@ -50,8 +50,8 @@ Shiki 会禁用 TextMate 正则的延迟编译,并在用户内容进入保持 ## 后果 -Wine 提供合并后的工具链证据。`all checks passed` 变绿时,原生覆盖率与观测性结果仍可能处于待处理或红灯状态,因此分支保护采用定向原生构建和进程检查,而评审者和后续自动化采用其余原生结果。 +Wine 提供合并后的工具链证据。`all checks passed` 变绿时,原生覆盖率仍可能处于待处理或红灯状态。观测诊断在必需的构建作业内完成。诊断失败时,PR 检查仍显示成功;失败步骤、工作流警告和运行摘要会展示该失败。 -尽管如此,每个拉取请求都会获得真实 NT 内核、NTFS、PowerShell、Windows 进程、原生插件和受支持源码覆盖率信号。原生作业会在构建、覆盖率与观测性工作区中重复设置流程,并在构建与观测性工作区中重复构建,但它们会降低每个作业的进程数,并暴露兼容性通道掩盖的路径、watcher、生命周期与 fixture 缺陷。 +每个拉取请求都会获得真实 NT 内核、NTFS、PowerShell、Windows 进程、原生插件和受支持源码覆盖率信号。观测检查复用成功构建的工作区,省去第二次检出、依赖安装和工作区构建。顺序执行会延长构建作业,并可能延迟必需判定;它也让诊断进程与编译分开运行。ready 与 master 聚合共用一份内部诊断清单;master 保留自己的工作区构建和完整检查。 维护者必须保留两种有意设计的执行拓扑:Wine 快照使用 Linux 安装加 hoisted 布局来触达 win32 二进制文件,而原生作业在组织自有的 16 核 Windows 运行器上使用相互独立的不可变工作区。任一拓扑独有的失败都必须依据该边界分类,不得削弱或静默跳过。 diff --git a/.agents/notes/implemented/process/2026-09-07-issue-policy-module-ownership.i18n.yaml b/.agents/notes/implemented/process/2026-09-07-issue-policy-module-ownership.i18n.yaml deleted file mode 100644 index 37bf7d8e0c..0000000000 --- a/.agents/notes/implemented/process/2026-09-07-issue-policy-module-ownership.i18n.yaml +++ /dev/null @@ -1,6 +0,0 @@ -# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each -# side as of the last confirmed-consistent state. Both languages carry equal authority; -# after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-09-07-issue-policy-module-ownership.md -2026-09-07-issue-policy-module-ownership.md: 696343ddffa3f6a6e411330d038bb4a470ce78d8 -2026-09-07-issue-policy-module-ownership.zh.md: abe539a5baed35cf2f356194eab8e53d61b67314 diff --git a/.agents/notes/implemented/process/2026-09-07-issue-policy-module-ownership.md b/.agents/notes/implemented/process/2026-09-07-issue-policy-module-ownership.md deleted file mode 100644 index 696343ddff..0000000000 --- a/.agents/notes/implemented/process/2026-09-07-issue-policy-module-ownership.md +++ /dev/null @@ -1,29 +0,0 @@ -# Agent Note: Issue policy module ownership - -Status: implemented - -English | [中文](2026-09-07-issue-policy-module-ownership.zh.md) - -## Problem - -Issue validation and Project lifecycle processing use the same reference parser, metadata rules, and GitHub reads, but they make different decisions and perform different writes. Keeping those responsibilities in the command entry makes it harder to reuse policy rules without also depending on event dispatch and output handling. - -## Decision - -Issue management separates pure decisions, GitHub access, pull-request evaluation, lifecycle mutations, and command dispatch into owner modules. [The owner reference](../../../../.github/issue-management/README.md#module-ownership) maps those responsibilities to source files. Callers and tests import the module that owns the operation rather than using the command entry as an export collection. - -The separation preserves policy results, diagnostics, credentials, API requests, lifecycle mutations, and workflow entry commands. [Selective evaluation](2026-09-07-selective-issue-policy-evaluation.md) remains the independent owner of eligibility, Project-read selection, and event scheduling; this decision does not redefine those behaviors. - -## Alternatives considered - -**Keep one command module.** A single file avoids imports between local owners, but ties reusable decisions and GitHub access to command handling. Separate owners let PR validation and lifecycle processing share their existing rules and reads without sharing command dispatch. - -## Consequences - -Maintainers can locate a rule, network operation, or event handler by responsibility. Reuse occurs through ordinary local ESM imports, not a new package or plugin API. The added modules introduce imports that must stay coordinated when a shared function changes. - -Module separation does not grant stronger credentials, change check authority, serialize Project mutations, or prevent a future behavior regression. Existing lifecycle races and field-management limitations remain documented by their behavior owners. - -## Verification - -[Policy tests](../../../../.github/issue-management/policy.test.mjs) exercise the owner modules and check observable policy and lifecycle behavior. [Workflow tests](../../../../scripts/ci-workflow.spec.ts) cover the command wiring and workflow declarations. Their evidence concerns the checked implementation, not a guarantee that later edits preserve behavior. diff --git a/.agents/notes/implemented/process/2026-09-07-issue-policy-module-ownership.zh.md b/.agents/notes/implemented/process/2026-09-07-issue-policy-module-ownership.zh.md deleted file mode 100644 index abe539a5ba..0000000000 --- a/.agents/notes/implemented/process/2026-09-07-issue-policy-module-ownership.zh.md +++ /dev/null @@ -1,29 +0,0 @@ -# Agent Note: Issue 策略模块归属 - -Status: implemented - -[English](2026-09-07-issue-policy-module-ownership.md) | 中文 - -## 问题 - -Issue 校验与 Project 生命周期处理使用相同的引用解析器、元数据规则和 GitHub 读取,但各自作出不同决策、执行不同写入。将这些职责放在命令入口中,会使复用策略规则时更难摆脱对事件分派与输出处理的依赖。 - -## 决策 - -Issue 管理将纯决策、GitHub 访问、PR(Pull Request)求值、生命周期 mutation 和命令分派分配给各自的所属模块。[所属参考文档](../../../../.github/issue-management/README.zh.md#module-ownership)将这些职责对应到源文件。调用方和测试直接导入拥有该操作的模块,不把命令入口用作导出集合。 - -此职责分离保留策略结果、诊断、凭据、API 请求、生命周期 mutation 和工作流入口命令。[选择性求值](2026-09-07-selective-issue-policy-evaluation.zh.md)仍独立拥有强制范围、Project 读取选择和事件调度规则;本决策不重新定义这些行为。 - -## 考虑过的替代方案 - -**保留单一命令模块。** 单文件可以避免本地所属模块之间的导入,但会把可复用的决策与 GitHub 访问绑定到命令处理。独立的所属模块让 PR 校验和生命周期处理可以共享既有规则与读取,而无需共享命令分派。 - -## 影响 - -维护者可以按职责定位规则、网络操作或事件处理器。复用通过普通的本地 ESM 导入完成,而不是新增包或插件 API。新增模块引入的导入关系需要在共享函数变更时同步维护。 - -模块分离不会授予更强的凭据、更改检查权威来源、串行化 Project mutation,也不能防止未来的行为回归。既有生命周期竞态与字段管理限制仍由各自的行为文档记录。 - -## 验证 - -[策略测试](../../../../.github/issue-management/policy.test.mjs)运行所属模块,并检查可观察的策略与生命周期行为。[工作流测试](../../../../scripts/ci-workflow.spec.ts)覆盖命令接线与工作流声明。这些证据针对被检查的实现,不保证后续编辑仍保留行为。 diff --git a/.agents/notes/implemented/process/2026-09-09-blacksmith-failover-leg.i18n.yaml b/.agents/notes/implemented/process/2026-09-09-blacksmith-failover-leg.i18n.yaml index 384d675f4c..bfd50afb64 100644 --- a/.agents/notes/implemented/process/2026-09-09-blacksmith-failover-leg.i18n.yaml +++ b/.agents/notes/implemented/process/2026-09-09-blacksmith-failover-leg.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-09-09-blacksmith-failover-leg.md -2026-09-09-blacksmith-failover-leg.md: d4be1ad25c6c5b66e295439d4609726c8918e137 -2026-09-09-blacksmith-failover-leg.zh.md: cd6e74aefba2cba9481fa1ae9510bb1ce020325d +2026-09-09-blacksmith-failover-leg.md: 4fb4ed4ccb500887622200e01310230393b3de12 +2026-09-09-blacksmith-failover-leg.zh.md: c2c4ee76a3fcd8c9f16c635ac81bc987d40a04e2 diff --git a/.agents/notes/implemented/process/2026-09-09-blacksmith-failover-leg.md b/.agents/notes/implemented/process/2026-09-09-blacksmith-failover-leg.md index d4be1ad25c..4fb4ed4ccb 100644 --- a/.agents/notes/implemented/process/2026-09-09-blacksmith-failover-leg.md +++ b/.agents/notes/implemented/process/2026-09-09-blacksmith-failover-leg.md @@ -10,9 +10,9 @@ The [failover runbook](2026-07-26-ci-failover-runbook.md) routes degraded enterp ## Decision -`DSH_CI_FAILOVER_LINUX` and `DSH_CI_FAILOVER_WINDOWS` additionally accept the value `blacksmith`. When any repository writer sets it, the platform's participating jobs retarget onto Blacksmith's hosted runners at the vCPU size matching the pool each job replaces; any other value (or unset) keeps today's runner, and `selfhosted` keeps routing to the in-house pools. Like the existing values this is writer-manageable repository state — no merge, re-run the affected jobs — and it costs money only while set, which is why it is an explicit outage or experiment choice, never the default. +`DSH_CI_FAILOVER_LINUX` and `DSH_CI_FAILOVER_WINDOWS` additionally accept the value `blacksmith`. When any repository writer sets it, the platform's participating jobs retarget onto Blacksmith's hosted runners at each job's configured vCPU size; any other value (or unset) keeps today's runner, and `selfhosted` keeps routing to the in-house pools. Like the existing values this is writer-manageable repository state — no merge, re-run the affected jobs — and it costs money only while set, which is why it is an explicit outage or experiment choice, never the default. -Participating jobs under `blacksmith`: the three enterprise Linux workers, the `node-compat` legs, the `all-checks-passed` verdict, and the four Windows lanes in [ci.yml](../../../../.github/workflows/ci.yml) (16-core pools map to `blacksmith-16vcpu-ubuntu-2404` and `blacksmith-16vcpu-windows-2025`, standard-hosted legs to `blacksmith-4vcpu-ubuntu-2404`); `expected-filenames`; the sandbox bwrap leg; and the two manual benchmark matrices, whose 4-32 vCPU tiers map to Blacksmith equivalents while the 64/96-core rows keep the fleet labels because Blacksmith offers no such tier. The blacksmith branches carry no Dependabot exclusion: Blacksmith runners are ephemeral, so the persistent-VM rationale that keeps Dependabot on the hosted pool does not apply. The python-runtime builder stays hosted in full: its PR and master callers (`ci.yml`'s and `ci-master.yml`'s `python-runtime` jobs) pass real-API credentials (`DEEPSEEK_API_KEY_EXTERNAL`) into the reusable workflow, its macOS and arm64 single-exe legs need GitHub's images, and its wheel output feeds the PyPI publication chain. The docs-pages workflow is a tag-dispatched release publication, so it stays hosted too. +Participating jobs under `blacksmith`: the three enterprise Linux workers, the `node-compat` legs, the `all-checks-passed` verdict, and the three Windows jobs in [ci.yml](../../../../.github/workflows/ci.yml), using the per-job runner sizes declared there; `expected-filenames`; the sandbox bwrap leg; and the two manual benchmark matrices, whose 4-32 vCPU tiers map to Blacksmith equivalents while the 64/96-core rows keep the fleet labels because Blacksmith offers no such tier. The blacksmith branches carry no Dependabot exclusion: Blacksmith runners are ephemeral, so the persistent-VM rationale that keeps Dependabot on the hosted pool does not apply. The python-runtime builder stays hosted in full: its PR and master callers (`ci.yml`'s and `ci-master.yml`'s `python-runtime` jobs) pass real-API credentials (`DEEPSEEK_API_KEY_EXTERNAL`) into the reusable workflow, its macOS and arm64 single-exe legs need GitHub's images, and its wheel output feeds the PyPI publication chain. The docs-pages workflow is a tag-dispatched release publication, so it stays hosted too. Everything else keeps its own runner under every value: jobs pinned to standard hosted runners by workflow spec (`node-24-bench`, `python-sdk`, the master Wine gate, `release-publish` and `release-vendor-publish`, the Cloudflare preview, the weighted-approval pair); jobs that hold credentials (the real-API e2e lanes, the issue App-token jobs, the npm and PyPI publishers); jobs that need a host capability Blacksmith lacks (the Landlock legs and their pack verification, the Seatbelt leg, the linux-arm64 single-exe leg); and the release-shaped chains — the npm publishers, the PyPI publishers, the node-addon-system release chain, and the Pages deployment workflow — which stay entirely hosted because their artifacts feed published registry output. The release rehearsal jobs in `release.yml` and `release-vendor.yml` (`dependencies`, `pack`) keep their existing pre-change routing unchanged: they carry only the pre-existing `selfhosted` branch, so under `blacksmith` they fall back to their hosted `ubuntu-24.04` default exactly as under an unset variable, and they gain no `blacksmith` branch. diff --git a/.agents/notes/implemented/process/2026-09-09-blacksmith-failover-leg.zh.md b/.agents/notes/implemented/process/2026-09-09-blacksmith-failover-leg.zh.md index cd6e74aefb..c2c4ee76a3 100644 --- a/.agents/notes/implemented/process/2026-09-09-blacksmith-failover-leg.zh.md +++ b/.agents/notes/implemented/process/2026-09-09-blacksmith-failover-leg.zh.md @@ -10,9 +10,9 @@ Status: implemented ## 决策 -`DSH_CI_FAILOVER_LINUX` 与 `DSH_CI_FAILOVER_WINDOWS` 额外接受值 `blacksmith`。任何仓库写者设置它时,该平台参与切换的作业按与被替换池匹配的 vCPU 档位重定向到 Blacksmith 托管运行器;任何其它值(或未设置)保持今天的运行器,`selfhosted` 继续路由到自有池。与既有取值一样,这是写者可管理的仓库状态——无需合并,重跑受影响的作业即可——并且只在设置期间产生费用,这正是它作为明确的故障或实验选择、而非默认的原因。 +`DSH_CI_FAILOVER_LINUX` 与 `DSH_CI_FAILOVER_WINDOWS` 额外接受值 `blacksmith`。任何仓库写者设置它时,该平台参与切换的作业按各作业配置的 vCPU 档位重定向到 Blacksmith 托管运行器;任何其它值(或未设置)保持今天的运行器,`selfhosted` 继续路由到自有池。与既有取值一样,这是写者可管理的仓库状态——无需合并,重跑受影响的作业即可——并且只在设置期间产生费用,这正是它作为明确的故障或实验选择、而非默认的原因。 -`blacksmith` 下参与切换的作业:[ci.yml](../../../../.github/workflows/ci.yml) 中三个企业级 Linux 工作作业、`node-compat` 各腿、`all-checks-passed` 判定作业与四条 Windows 通道(16 核池映射到 `blacksmith-16vcpu-ubuntu-2404` 与 `blacksmith-16vcpu-windows-2025`,标准托管腿映射到 `blacksmith-4vcpu-ubuntu-2404`);`expected-filenames`;sandbox 的 bwrap 腿;以及两个手动基准矩阵——其 4-32 vCPU 档位映射到 Blacksmith 等价档,64/96 核行保留自有池标签,因为 Blacksmith 没有对应档位。blacksmith 分支不带 Dependabot 排除:Blacksmith 运行器是临时的,因此把 Dependabot 留在托管池的持久虚拟机理由不适用。python-runtime 构建器整体保持托管:其 PR 与 master 调用方(`ci.yml` 与 `ci-master.yml` 的 `python-runtime` 作业)会把真实 API 凭据(`DEEPSEEK_API_KEY_EXTERNAL`)传入该可复用工作流,其 macOS 与 arm64 单文件分支需要 GitHub 镜像,且其 wheel 产物供给 PyPI 发布链。docs-pages 工作流是 tag 派发的发布,因此同样保持托管。 +`blacksmith` 下参与切换的作业:[ci.yml](../../../../.github/workflows/ci.yml) 中三个企业级 Linux 工作作业、`node-compat` 各腿、`all-checks-passed` 判定作业与三个 Windows 作业,使用该工作流为各作业声明的运行器规格;`expected-filenames`;sandbox 的 bwrap 腿;以及两个手动基准矩阵——其 4-32 vCPU 档位映射到 Blacksmith 等价档,64/96 核行保留自有池标签,因为 Blacksmith 没有对应档位。blacksmith 分支不带 Dependabot 排除:Blacksmith 运行器是临时的,因此把 Dependabot 留在托管池的持久虚拟机理由不适用。python-runtime 构建器整体保持托管:其 PR 与 master 调用方(`ci.yml` 与 `ci-master.yml` 的 `python-runtime` 作业)会把真实 API 凭据(`DEEPSEEK_API_KEY_EXTERNAL`)传入该可复用工作流,其 macOS 与 arm64 单文件分支需要 GitHub 镜像,且其 wheel 产物供给 PyPI 发布链。docs-pages 工作流是 tag 派发的发布,因此同样保持托管。 其余作业在任何取值下都保留自己的运行器:被 workflow spec 钉死在标准托管运行器上的作业(`node-24-bench`、`python-sdk`、master 的 Wine 门禁、`release-publish` 与 `release-vendor-publish`、Cloudflare preview、weighted-approval 一对);持有凭据的作业(真实 API 的 e2e 通道、issue App token 作业、npm 与 PyPI 发布作业);需要 Blacksmith 缺失的宿主能力的作业(Landlock 各腿及其打包校验、Seatbelt 腿、linux-arm64 单文件分支);以及发布形态的链——npm 发布、PyPI 发布、node-addon-system 发布链与 Pages 部署工作流——它们整体保持托管,因为其产物供给已发布的 registry 输出。`release.yml` 与 `release-vendor.yml` 中的发布演练作业(`dependencies`、`pack`)保持改动前既有的路由不变:它们只带既有的 `selfhosted` 分支,因此在 `blacksmith` 取值下与变量未设置时一样回落到托管的 `ubuntu-24.04` 默认,并不新增 `blacksmith` 分支。 diff --git a/.agents/notes/implemented/process/2026-09-15-shipped-optional-bundles.i18n.yaml b/.agents/notes/implemented/process/2026-09-15-shipped-optional-bundles.i18n.yaml index 2b32ebfee1..2c5a3f8554 100644 --- a/.agents/notes/implemented/process/2026-09-15-shipped-optional-bundles.i18n.yaml +++ b/.agents/notes/implemented/process/2026-09-15-shipped-optional-bundles.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-09-15-shipped-optional-bundles.md -2026-09-15-shipped-optional-bundles.md: 40ef69d3880f8d1c925c1e3c423e59a628f22e09 -2026-09-15-shipped-optional-bundles.zh.md: adf2b979b9d23b7510c077f3633e2d030732a7b4 +2026-09-15-shipped-optional-bundles.md: c9f560f5c07ec6fb18030b77a0d1f53bd27dc045 +2026-09-15-shipped-optional-bundles.zh.md: e91b6580e29cd7ae142c9c61ed6aac7e5e1476eb diff --git a/.agents/notes/implemented/process/2026-09-15-shipped-optional-bundles.md b/.agents/notes/implemented/process/2026-09-15-shipped-optional-bundles.md index 40ef69d388..c9f560f5c0 100644 --- a/.agents/notes/implemented/process/2026-09-15-shipped-optional-bundles.md +++ b/.agents/notes/implemented/process/2026-09-15-shipped-optional-bundles.md @@ -14,7 +14,7 @@ The launcher names in `OPTIONAL_BUNDLES` (`packages/boot/app-boot/src/profile.ts Default-product isolation keeps its rules with one declared exception: an optional bundle's dependency graph is outside the default product. The static gate skips the `dependencies` edge from `@deepseek-ai/dsh` to a listed bundle and still rejects a runtime import, a shipped composition, a preset, or a default template that names it, an experimental dependency the list does not name, and a listed name that is not a runtime dependency or not a bundle. The workspace-constraints check accepts the same `dependencies` edges and no other runtime section, and the packed-install release check skips them from the installed entry package while requiring each listed bundle to be installed. -Agent Teams ships this way, as `@deepseek-ai/dsh-experimental-agent-team-profile` and `@deepseek-ai/dsh-experimental-agent-team-web-profile`. Auto review is a published experimental package the page's install guide names as its example, not an optional bundle. +The [single-bundle decision](../architecture/2026-09-18-agent-teams-single-bundle.md) supersedes the two Agent Teams selections with one `@deepseek-ai/dsh-experimental-agent-team-profile` selection. This note retains the installation-owned optional-bundle policy. Auto review is a published experimental package the page's install guide names as its example, not an optional bundle. ## Alternatives considered @@ -26,4 +26,4 @@ Agent Teams ships this way, as `@deepseek-ai/dsh-experimental-agent-team-profile ## Consequences -Optional bundles are downloaded with the product and stay inactive until selected; the runtime isolation smokes still observe no experimental module in a default composition. Switching on an optional Web layer loads its client plugin through the live client module graph. A bundle that needs a companion layer, such as the Agent Teams Web layer over its Host layer, says so in its description; the manager does not select companions automatically. +Optional bundles are downloaded with the product and stay inactive until selected; the runtime isolation smokes still observe no experimental module in a default composition. Switching on an optional Web layer loads its client plugin through the live client module graph. diff --git a/.agents/notes/implemented/process/2026-09-15-shipped-optional-bundles.zh.md b/.agents/notes/implemented/process/2026-09-15-shipped-optional-bundles.zh.md index adf2b979b9..e91b6580e2 100644 --- a/.agents/notes/implemented/process/2026-09-15-shipped-optional-bundles.zh.md +++ b/.agents/notes/implemented/process/2026-09-15-shipped-optional-bundles.zh.md @@ -14,7 +14,7 @@ Web 插件页只管理用户装进 profile 的组合包。像 Agent Teams 这样 默认产品隔离的规则保持不变,只声明一个例外:可选组合包的依赖图在默认产品之外。静态门禁跳过从 `@deepseek-ai/dsh` 到列表中组合包的 `dependencies` 边,仍然拒绝运行时 import、随附组合、preset 或默认模板对它的引用,拒绝列表没有点名的实验依赖,也拒绝不是运行时依赖或不是组合包的列表项。workspace 约束检查接受同样的 `dependencies` 边而不接受其他运行时依赖段;发布时的 packed-install 检查对已安装入口包跳过这些边,并要求列表中的每个组合包都已安装。 -Agent Teams 以这种方式交付,即 `@deepseek-ai/dsh-experimental-agent-team-profile` 与 `@deepseek-ai/dsh-experimental-agent-team-web-profile`。Auto review 是一个已发布的实验包,插件页的安装引导拿它作示例,它不是可选组合包。 +[单组合包决策](../architecture/2026-09-18-agent-teams-single-bundle.zh.md)将两个 Agent Teams 选项替换为一个 `@deepseek-ai/dsh-experimental-agent-team-profile` 选项。本记录保留由安装方决定可选组合包的策略。Auto review 是一个已发布的实验包,插件页的安装引导拿它作示例,它不是可选组合包。 ## 考虑过的替代方案 @@ -26,4 +26,4 @@ Agent Teams 以这种方式交付,即 `@deepseek-ai/dsh-experimental-agent-tea ## 影响 -可选组合包随产品一起下载,选中前保持不活动;运行时隔离 smoke 在默认组合中仍观察不到任何实验模块。开启一个可选 Web 层会通过实时客户端模块图加载它的客户端插件。需要配套层的组合包,例如 Agent Teams Web 层依赖其 Host 层,会在描述里说明;管理器不会自动选中配套层。 +可选组合包随产品一起下载,选中前保持不活动;运行时隔离 smoke 在默认组合中仍观察不到任何实验模块。开启一个可选 Web 层会通过实时客户端模块图加载它的客户端插件。 diff --git a/.agents/notes/implemented/bug-fix/2026-09-03-resume-headers-do-not-repeat-system-prompts.i18n.yaml b/.agents/notes/implemented/simplification/2026-09-15-model-agent-availability-and-team-targets.i18n.yaml similarity index 52% rename from .agents/notes/implemented/bug-fix/2026-09-03-resume-headers-do-not-repeat-system-prompts.i18n.yaml rename to .agents/notes/implemented/simplification/2026-09-15-model-agent-availability-and-team-targets.i18n.yaml index d7871098ba..165022b7dc 100644 --- a/.agents/notes/implemented/bug-fix/2026-09-03-resume-headers-do-not-repeat-system-prompts.i18n.yaml +++ b/.agents/notes/implemented/simplification/2026-09-15-model-agent-availability-and-team-targets.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 .agents/notes/implemented/bug-fix/2026-09-03-resume-headers-do-not-repeat-system-prompts.md -2026-09-03-resume-headers-do-not-repeat-system-prompts.md: de5e509c9e57af35bc36c24004457f02b63fd99d -2026-09-03-resume-headers-do-not-repeat-system-prompts.zh.md: 008e25d8d507aac8fbaec7230bad433732f0efd0 +# pnpm run verify-translation-pairing --write .agents/notes/implemented/simplification/2026-09-15-model-agent-availability-and-team-targets.md +2026-09-15-model-agent-availability-and-team-targets.md: e1f7f95cfa333be6d05677b4b19775c744f2817c +2026-09-15-model-agent-availability-and-team-targets.zh.md: 0447d7d4059eb75d1fb9169389cd83a54292aadf diff --git a/.agents/notes/implemented/simplification/2026-09-15-model-agent-availability-and-team-targets.md b/.agents/notes/implemented/simplification/2026-09-15-model-agent-availability-and-team-targets.md new file mode 100644 index 0000000000..e1f7f95cfa --- /dev/null +++ b/.agents/notes/implemented/simplification/2026-09-15-model-agent-availability-and-team-targets.md @@ -0,0 +1,29 @@ +# Agent Note: Model agent availability and Team targets + +Status: implemented + +English | [中文](2026-09-15-model-agent-availability-and-team-targets.zh.md) + +## Problem + +Team operations accept member names, but returning both names and Session UUIDs invites the model to choose an unusable address. Loaded and stored agents also have distinct internal statuses despite requiring the same model action to continue work. + +## Decision + +The Team tool adapter returns `target` from the existing member name and omits member Session IDs from creation and listing results. Messaging, interruption, and task assignment consume that same name. Task `ownerName`, task IDs, and message IDs retain their existing meanings. Internal services, Web views, and persisted Team records retain Session identity. + +Normal subagent tool results and Team service views project loaded-between-turns and stored agents as `inactive`. Only an executing turn is `running`; Team retains `provisioning` and `failed` for member creation, and ordinary subagent diagnostic rows retain their read-failure reasons. Interruption projects its previous status by the same rule. Availability does not report task outcomes or waiting dependencies. + +The Team service owns roster and interruption availability for both tools and Web. The ordinary subagent projection belongs to its list tool. Runtime loading, cold resume, interruption authority, and mailbox delivery retain their existing behavior. Ordinary subagents continue to use `agent_id`; stable subagent names and historical-data handling are deferred. No persistence type or Session format changes. + +## Alternatives considered + +**Add stable names to ordinary subagents now.** This requires a durable naming policy and explicit handling of historical children without names. It is independent of removing misleading Team identities and model status distinctions. + +**Merge internal runtime states.** Residency determines whether delivery starts a loaded agent or restores one from storage. Underlying Agent status and registry presence retain that distinction; Team views expose only turn availability. + +## Consequences + +Models can copy a Team result's target directly into applicable operations. They lose member UUIDs and residency details from these tool results; neither is needed for Team addressing. Ordinary subagent and Team addressing remain different until a separate naming decision. + +Focused service and tool tests cover every status projection, returned-target operations, diagnostic rows, and live versus stored children. Recorded headless Team and SDK subagent scenarios pin the assembled model output and schemas. The [Agent Teams decision](../feature/2026-08-05-agent-teams.md) continues to own roster durability, scoping, and authority. diff --git a/.agents/notes/implemented/simplification/2026-09-15-model-agent-availability-and-team-targets.zh.md b/.agents/notes/implemented/simplification/2026-09-15-model-agent-availability-and-team-targets.zh.md new file mode 100644 index 0000000000..0447d7d405 --- /dev/null +++ b/.agents/notes/implemented/simplification/2026-09-15-model-agent-availability-and-team-targets.zh.md @@ -0,0 +1,29 @@ +# Agent Note: 模型可见的 Agent 可用状态与 Team 目标 + +Status: implemented + +[English](2026-09-15-model-agent-availability-and-team-targets.md) | 中文 + +## 问题 + +Team 操作接受成员名称,但同时返回名称和 Session UUID 容易使模型选择不可用的地址。已加载与仅存储的 agent 也有不同的内部状态,尽管模型继续其工作时采取相同操作。 + +## 决策 + +Team 工具适配器从现有成员名称生成 `target`,并从创建和列表结果中省略成员 Session ID。消息、中断和任务分配使用同一名称。任务的 `ownerName`、任务 ID 和消息 ID 保持现有含义。内部服务、Web 视图和持久化 Team 记录保留 Session 身份。 + +普通 subagent 工具结果与 Team 服务视图把已加载但处于轮次之间的 agent 和仅存储的 agent 都投影为 `inactive`。只有正在执行轮次时才是 `running`;Team 保留表示成员创建状态的 `provisioning` 与 `failed`,普通 subagent 的诊断行保留读取失败原因。中断结果按同一规则投影之前的状态。可用状态不报告任务结果或等待依赖。 + +Team 服务为工具和 Web 统一提供 roster 与中断的可用状态。普通 subagent 的投影归其列表工具所有。运行时加载、冷恢复、中断权限和邮箱投递保持现有行为。普通 subagent 继续使用 `agent_id`;稳定的 subagent 名称和历史数据处理暂缓。不修改持久化类型或 Session 格式。 + +## 考虑过的替代方案 + +**立即为普通 subagent 增加稳定名称。** 这需要持久化命名策略,以及对历史无名称子级的明确处理。它与移除误导性的 Team 身份和模型状态区别相互独立。 + +**合并内部运行状态。** 驻留状态决定投递是启动已加载的 agent,还是从存储恢复。底层 Agent 状态与注册表中的存在性保留该区别;Team 视图仅暴露轮次可用状态。 + +## 影响 + +模型可以把 Team 结果的 target 直接复制到适用操作。这些工具结果不再提供成员 UUID 和驻留细节;Team 寻址不需要它们。在另行决定命名方案之前,普通 subagent 与 Team 的寻址方式仍不同。 + +定向服务与工具测试覆盖所有状态投影、返回目标的操作、诊断行和在线与仅存储子级。录制的 headless Team 与 SDK subagent 场景固定组装后的模型输出和 schema。[Agent Teams 决策](../feature/2026-08-05-agent-teams.zh.md)继续拥有 roster 持久化、作用域和权限规则。 diff --git a/.agents/notes/implemented/simplification/2026-09-17-plugin-inventory-phase-dot.i18n.yaml b/.agents/notes/implemented/simplification/2026-09-17-plugin-inventory-phase-dot.i18n.yaml deleted file mode 100644 index 6f8e8d20c4..0000000000 --- a/.agents/notes/implemented/simplification/2026-09-17-plugin-inventory-phase-dot.i18n.yaml +++ /dev/null @@ -1,6 +0,0 @@ -# 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/simplification/2026-09-17-plugin-inventory-phase-dot.md -2026-09-17-plugin-inventory-phase-dot.md: 5ba614c61a390bbd39a49c17239d0410012b325d -2026-09-17-plugin-inventory-phase-dot.zh.md: a01d172c6b6f3cb461b79ef456b37a17b9c324c8 diff --git a/.agents/notes/implemented/simplification/2026-09-17-plugin-inventory-phase-dot.md b/.agents/notes/implemented/simplification/2026-09-17-plugin-inventory-phase-dot.md deleted file mode 100644 index 5ba614c61a..0000000000 --- a/.agents/notes/implemented/simplification/2026-09-17-plugin-inventory-phase-dot.md +++ /dev/null @@ -1,35 +0,0 @@ -# Agent Note: The plugin inventory reserves its status dot for phases the tag cannot state - -Status: implemented - -English | [中文](2026-09-17-plugin-inventory-phase-dot.zh.md) - -## Problem - -The Settings plugin list marks every Loader entry with an enablement tag (`已启用`, `已停用`, `条件启用`, `预设中启用`, `启动失败`). It also drew a colored root-fiber status dot on enabled rows whose fiber was live. For the common `active` phase the dot and the tag stated the same thing twice on one collapsed card, while `pending`, `loading` and `unloading` — the phases the tag has no words for — were where the dot carried its own information. - -## Decision - -`PluginInventorySettingsTab` renders the status dot only for `pending`, `loading` and `unloading`. `active` and `failed` render no dot: a settled enabled row shows its enablement tag alone, and a failed row has the failure tag and card treatment. `PHASE_DOT_STATES` lists exactly the dotted phases, and `showsPhaseDot` narrows a phase to that union. - -Row enablement, tag tones, grouping, search and list behavior are unchanged, as are the Loader lifecycle and persistence. The expanded card still lists the phase under 运行状态 / Status, so the runtime phase stays readable without the collapsed dot. - -## Alternatives considered - -**Remove the dot for every phase.** Simpler to state, but it drops the only collapsed-state signal that a plugin is still loading or unloading — the one state the tag cannot express. - -**Keep the `active` dot on preset rows only.** The preset group is where the duplication was first noticed, but the global group renders the same tag-and-dot pair. A per-group exception would make one rendering rule depend on which group a row sits in, and would leave the duplicate in the global group. - -**Fold the phase into the enablement tag.** The tag reports the config fact the Host publishes as `enabled`; adding a runtime phase would make one control carry two lifecycles with different owners. - -## Consequences - -- A collapsed card no longer shows that an enabled entry's fiber is currently `active`. A reader who needs the phase expands the row, whose Status fact keeps it. That is the information the change deliberately gives up. -- Rows mid-transition keep their dot, its animation and its `role="img"` name, so the accessible name still says `等待依赖`, `加载中` or `卸载中`. -- Known gap, pre-existing and unchanged: the dot is gated on `row.enabled`, so a row a user just disabled while its fiber is still `unloading` shows `已停用` without a dot. - -## Testing - -The package spec pins the rule in both scopes: `loading` and `pending` (global) and `unloading` (preset) keep a dot, while `active`, `failed` and a row without a live fiber render none. `apps/web/tests/settings-chrome.e2e.ts` asserts the assembled global group contains enabled rows and no `role="img"` element named `运行中`. - -The remaining dots' palette and animation are owned by [the StateDot visual language decision](../feature/2026-09-17-state-dot-visual-language.md); the tag capsule remains owned by [the shared client control decision](../architecture/2026-09-05-shared-client-control-primitives.md). diff --git a/.agents/notes/implemented/simplification/2026-09-17-plugin-inventory-phase-dot.zh.md b/.agents/notes/implemented/simplification/2026-09-17-plugin-inventory-phase-dot.zh.md deleted file mode 100644 index a01d172c6b..0000000000 --- a/.agents/notes/implemented/simplification/2026-09-17-plugin-inventory-phase-dot.zh.md +++ /dev/null @@ -1,35 +0,0 @@ -# Agent Note: 插件列表只为主题标签无法表达的阶段保留状态圆点 - -Status: implemented - -[English](2026-09-17-plugin-inventory-phase-dot.md) | 中文 - -## 问题 - -设置里的插件列表给每个 Loader 条目配一个启停标签(`已启用`、`已停用`、`条件启用`、`预设中启用`、`启动失败`),同时给 fiber 存活的已启用条目画一个彩色根 fiber 状态圆点。对最常见的 `active` 阶段,圆点与标签在同一张收起卡片上把同一件事说了两遍;而标签没有词可用的 `pending`、`loading`、`unloading`,才是圆点真正携带独立信息的阶段。 - -## 决策 - -`PluginInventorySettingsTab` 只为 `pending`、`loading`、`unloading` 渲染状态圆点。`active` 与 `failed` 不渲染:已稳定启用的条目只显示启停标签,失败条目由失败标签和卡片样式说明。`PHASE_DOT_STATES` 只列出有圆点的阶段,`showsPhaseDot` 把阶段收窄到该联合。 - -条目启停、标签色调、分组、搜索与列表行为不变,Loader 生命周期与持久化也不变。展开卡片仍在「运行状态」下给出阶段名,因此不画收起态圆点也能读到运行时阶段。 - -## 否决的备选方案 - -**所有阶段都不画圆点。** 规则更简单,但会丢掉收起态下唯一能说明插件正在加载或卸载的信号——恰恰是标签无法表达的那个状态。 - -**只在预设分组保留 `active` 圆点。** 重复最先是在预设分组被注意到的,但全局分组渲染的是同一套标签加圆点。按分组开例外会让同一条渲染规则取决于条目落在哪个分组,而且全局分组的重复仍然存在。 - -**把阶段并进启停标签。** 标签报告的是 Host 以 `enabled` 发布的配置事实;再塞一个运行时阶段会让一个控件承载两条归属不同的生命周期。 - -## 后果 - -- 收起卡片不再显示已启用条目的 fiber 当前处于 `active`。需要该阶段的读者展开条目,其「运行状态」事实仍然给出。这是本次变更刻意放弃的信息。 -- 过渡中的条目保留圆点、动画与 `role="img"` 名称,无障碍名称仍会读出 `等待依赖`、`加载中` 或 `卸载中`。 -- 既有且未改变的缺口:圆点以 `row.enabled` 为前提,用户刚停用而 fiber 仍在 `unloading` 的条目只显示 `已停用`,没有圆点。 - -## 测试 - -包级用例在两个作用域钉住规则:`loading` 与 `pending`(全局)、`unloading`(预设)保留圆点,`active`、`failed` 与没有存活 fiber 的条目都不渲染。`apps/web/tests/settings-chrome.e2e.ts` 断言装配后的全局分组含有已启用条目,且没有名为 `运行中` 的 `role="img"` 元素。 - -其余圆点的配色与动画由 [StateDot 视觉语言决策](../feature/2026-09-17-state-dot-visual-language.zh.md)负责;标签胶囊仍由[共享客户端控件决策](../architecture/2026-09-05-shared-client-control-primitives.zh.md)负责。 diff --git a/.agents/notes/proposed/simplification/2026-09-17-defer-office-background-scheduling.i18n.yaml b/.agents/notes/proposed/simplification/2026-09-17-defer-office-background-scheduling.i18n.yaml new file mode 100644 index 0000000000..35fe5b54d9 --- /dev/null +++ b/.agents/notes/proposed/simplification/2026-09-17-defer-office-background-scheduling.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/proposed/simplification/2026-09-17-defer-office-background-scheduling.md +2026-09-17-defer-office-background-scheduling.md: 82311022a015671b9fb4cf3a0f3eba2714f325cd +2026-09-17-defer-office-background-scheduling.zh.md: ada0146f204fa40e71d48251c231c1b68c5fa5a1 diff --git a/.agents/notes/proposed/simplification/2026-09-17-defer-office-background-scheduling.md b/.agents/notes/proposed/simplification/2026-09-17-defer-office-background-scheduling.md new file mode 100644 index 0000000000..82311022a0 --- /dev/null +++ b/.agents/notes/proposed/simplification/2026-09-17-defer-office-background-scheduling.md @@ -0,0 +1,40 @@ +# Agent Note: Defer Office background scheduling until a consumer needs it + +Status: proposed + +English | [中文](2026-09-17-defer-office-background-scheduling.zh.md) + +## Problem + +The [conversion queue](../../../../packages/document/office-to-pdf/src/queue.ts) implements foreground promotion, demotion after reader cancellation, eviction of queued background work, a reserved foreground reader allowance, and separate background concurrency accounting. These mechanisms protect foreground conversion from speculative prewarming; ordinary conversion still needs the queue's general resource limits. + +The [Office preview](../../../../packages/client/ui-sidebar-documentpreview/src/client/office/index.ts) calls its cache without a priority argument, so the [Client cache](../../../../packages/client/ui-sidebar-documentpreview/src/client/office/cache.ts) forwards the default foreground priority to the Host. Repository searches for `officeToPdf`, `OfficeToPdfRequest`, `OfficeToPdfPriority`, and `maxBackgroundConversions` find no production background request. The [conversion smoke](../../../../packages/bundle/web-app/tests/document-conversion.e2e.ts) also submits foreground requests. Background requests appear in queue, Remote, and Client-cache tests. The exposed API and Client priority plumbing alone do not establish which scheduling policy prewarming requires. The open delivery prewarming PR #4064 already provides a concrete prewarming consumer, but its implementation calls the older `documentRender` API without priority. Removing scheduling must first be reconciled with that consumer; absence from master is insufficient evidence to proceed. + +The [bounded conversion decision](../../implemented/architecture/2026-09-15-bounded-office-conversion.md) deliberately reserves capacity for foreground consumers. That remains the correct rule if speculation exists, but the current caller inventory does not justify maintaining the speculative request class and its lifecycle interactions. + +## Proposal + +Remove `OfficeToPdfPriority`, request `priority`, and configuration `maxBackgroundConversions`. Remove reader/job priority fields, promotion and demotion, speculative eviction, foreground reader reservation, and the queue's background counter. Remove Client-cache priority plumbing and update the Remote signature together. Admit the oldest queued job whose source reservation fits whenever conversion capacity is available; reject a new distinct job when the metadata queue is full. Continue joining an existing source job subject to the shared reader limit. + +Keep the provider, reusable converters, deferred authorized reads, all remaining resource limits, source-version checks, content sharing, bounded result cache, independent reader cancellation, and teardown that waits for actual work to settle. This proposal changes admission policy, not conversion ownership or engine selection. + +Update the request/config definitions, package README pair, subsystem and generated catalogs, and conversion fixtures together. Delete priority-only assertions; retain resource and cancellation regressions embedded in mixed priority tests by expressing them with ordinary requests. The existing bounded conversion note remains active because its cache, authorization, and lifetime rationale still applies; acceptance would replace only its scheduling policy. + +## Alternatives considered + +**Keep priority for a future prewarm consumer.** This avoids a later API change, but retains scheduling states and configuration before a product consumer can define their required behavior. Reintroduce priority with a concrete prewarming caller and measured foreground contention. + +**Disable background conversions by default.** This avoids speculative execution but retains the public request class, configuration, and all scheduling branches. + +**Remove the whole shared provider or cache.** That exceeds the evidence: independent conversion and bounded sharing remain coherent capabilities, and the Loader smoke exercises them without presentation plugins. + +## Acceptance criteria + +- Resolve the scheduling needs of delivery prewarming PR #4064 before implementation, then repeat the caller search. Retain priority if that consumer needs foreground protection; do not remove it merely because the consumer has not merged. +- Priority types, fields, configuration, and scheduling branches disappear from maintained source, tests, documentation, and generated catalogs. Decision records may describe the removed policy. +- Focused queue/provider tests preserve bounded admission, source/digest sharing, cancellation isolation, output ownership, and delayed capacity release. The real conversion Loader smoke still passes. +- Verify the foreground Office preview path; update its recorded-session snapshot if affected. Run the relevant type checks, generated-reference checks, documentation checks, and lint. + +## Risks + +Out-of-tree callers can use the exposed pre-stable API even though repository searches find no background consumer. Removing priority and its config field requires callers to update; this proposal offers no compatibility alias. Speculative callers would lose foreground protection and must stop prewarming until an explicit scheduling requirement is implemented. No runtime behavior changes until this proposal is accepted and implemented. diff --git a/.agents/notes/proposed/simplification/2026-09-17-defer-office-background-scheduling.zh.md b/.agents/notes/proposed/simplification/2026-09-17-defer-office-background-scheduling.zh.md new file mode 100644 index 0000000000..ada0146f20 --- /dev/null +++ b/.agents/notes/proposed/simplification/2026-09-17-defer-office-background-scheduling.zh.md @@ -0,0 +1,40 @@ +# Agent Note: 待调用方需要时再引入 Office 后台调度 + +Status: proposed + +[English](2026-09-17-defer-office-background-scheduling.md) | 中文 + +## 问题 + +[转换队列](../../../../packages/document/office-to-pdf/src/queue.ts) 实现了前台优先级提升、读取方取消后的降级、排队后台任务的驱逐、预留前台读取名额,以及独立的后台并发计数。这些机制保护前台转换不受推测性预热影响;普通转换仍需要队列的通用资源限制。 + +[Office 预览](../../../../packages/client/ui-sidebar-documentpreview/src/client/office/index.ts) 调用缓存时未传优先级,因此[Client 缓存](../../../../packages/client/ui-sidebar-documentpreview/src/client/office/cache.ts) 向 Host 转发默认前台优先级。在仓库中搜索 `officeToPdf`、`OfficeToPdfRequest`、`OfficeToPdfPriority` 和 `maxBackgroundConversions`,未发现生产后台请求。[转换冒烟测试](../../../../packages/bundle/web-app/tests/document-conversion.e2e.ts) 也提交前台请求。后台请求出现在队列、Remote 和 Client 缓存测试中。仅凭暴露 API 和 Client 优先级传递逻辑,无法确定预热需要哪种调度策略。开放的交付文件预热 PR #4064已经提供具体预热消费者,但其实现仍调用不含优先级的旧 `documentRender` API。删除调度前必须先与该消费者协调;尚未进入 master 不足以支持直接实施。 + +[有界转换决策](../../implemented/architecture/2026-09-15-bounded-office-conversion.zh.md) 有意为前台消费者预留容量。存在推测性任务时,这条规则仍然正确,但当前调用方清单不足以支持维护推测性请求类别及其生命周期交互。 + +## 提案 + +移除 `OfficeToPdfPriority`、请求中的 `priority` 和配置 `maxBackgroundConversions`。删除读取方及任务的优先级字段、升降级、推测性任务驱逐、前台读取名额预留,以及队列的后台计数器。同时删除 Client 缓存的优先级传递逻辑并更新 Remote 签名。在转换容量可用时,接纳源预留容量足够的最早排队任务;元数据队列满时拒绝新的独立任务。仍允许在共享读取方数量限制内加入已有源任务。 + +保留提供者、可复用转换器、延迟授权读取、其余所有资源限制、源版本校验、内容共享、有界结果缓存、读取方独立取消,以及等待实际工作结束的清理。这项提案改变接纳策略,不改变转换所有权或引擎选择。 + +同步更新请求及配置定义、包 README 双语文档、子系统与生成目录,以及转换 fixture(测试前置数据)。删除仅针对优先级的断言;将混合优先级测试中的资源与取消回归改写为普通请求,并保留覆盖。现有有界转换记录继续有效,因为其缓存、授权和生命周期理由仍然成立;采纳本提案只会替换其中的调度策略。 + +## 考虑过的替代方案 + +**为未来的预热调用方保留优先级。** 这可以避免后续 API 变更,但在产品调用方能够定义所需行为之前,就保留了调度状态与配置。应在有具体预热调用方和实测前台资源争用时重新引入优先级。 + +**默认禁用后台转换。** 这可以避免推测性执行,但仍保留公开请求类别、配置及全部调度分支。 + +**删除整个共享提供者或缓存。** 这超出了证据支持的范围:独立转换和有界共享仍是完整的能力,Loader 冒烟测试也在不依赖展示插件的情况下验证它们。 + +## 验收标准 + +- 实施前先明确交付文件预热 PR #4064 的调度需求,再重复调用方搜索。若该消费者需要前台保护,则保留优先级;不能仅因消费者尚未合入而删除。 +- 从维护中的源码、测试、文档和生成目录移除优先级类型、字段、配置及调度分支。决策记录可以描述被移除的策略。 +- 聚焦队列及提供者的测试保留有界接纳、源及摘要共享、取消隔离、输出所有权和延迟容量释放覆盖。真实转换的 Loader 冒烟测试仍然通过。 +- 验证前台 Office 预览路径;若受影响,同步更新其记录会话快照。运行相关类型检查、生成引用检查、文档检查及 lint。 + +## 风险 + +仓库外调用方可能使用已暴露的稳定前 API,即使仓库搜索未发现后台消费者。移除优先级及其配置字段要求调用方更新;本提案不提供兼容别名。推测性调用方会失去前台保护,必须停止预热,直到明确的调度需求得到实现。本提案被接受并实现之前,运行行为不变。 diff --git a/.agents/skills/dsh-archive-agent-notes/SKILL.md b/.agents/skills/dsh-archive-agent-notes/SKILL.md index a08e4b5179..6052cbf1fc 100644 --- a/.agents/skills/dsh-archive-agent-notes/SKILL.md +++ b/.agents/skills/dsh-archive-agent-notes/SKILL.md @@ -1,6 +1,6 @@ --- name: dsh-archive-agent-notes -description: Use when adding, auditing, pruning, archiving, restoring, or reviewing Agent Notes in deepseek-harness; checks every new note for superseded active records, classifies implemented notes by future decision value, deletes rejected notes that no longer prevent a tempting fallacy, and applies the frozen archived/{kind} triplet and manifest rules. +description: Use when adding, auditing, pruning, archiving, restoring, or reviewing Agent Notes in deepseek-harness; checks every new note for superseded active records, deletes small UI and purely mechanical records, classifies other implemented notes by future decision value, deletes rejected notes that no longer prevent a tempting fallacy, and applies the frozen archived/{kind} triplet and manifest rules. --- # Archive DeepSeek Harness Agent Notes @@ -19,8 +19,9 @@ Before drafting, apply the [creation criteria](../../notes/README.md#when-to-wri Apply these lifecycle-specific outcomes: -- **Implemented — keep active:** retain a note when its rationale, alternatives, negative guarantees, durable/wire semantics, ownership boundary, security rule, or reintroduction condition is likely to guide a future change. Length does not matter. -- **Implemented — archive:** archive a note when the shipped decision is complete and its body is unlikely to guide future work, such as one-off UI chrome, a narrow adapter, a minor closed bug, superseded implementation detail, or process history whose current behavior is obvious elsewhere. +- **Implemented — delete:** delete complete triplets that only describe small UI adjustments or purely mechanical changes, and repair or remove inbound links. Local bug fixes, performance changes, new capabilities, and substantive behavior or ownership decisions do not qualify merely because they are implemented. Apply this criterion before keep/archive classification. +- **Implemented — keep active:** retain a note when its rationale, alternatives, negative guarantees, durable/wire semantics, ownership boundary, security rule, or reintroduction condition is likely to guide a future change. Classify the decision, not the mechanics of its implementation: a mechanical rename or type extraction can still record lasting naming, compatibility, or ownership rules. Length does not matter. +- **Implemented — archive:** archive a substantive historical decision when it is complete and unlikely to guide future work, but its historical rationale still warrants preservation. Do not archive records that meet the direct-deletion criterion. - **Proposed — never archive:** keep a live proposal active; if it is no longer worth pursuing, reject it with an honest reason and satisfy the rejected lifecycle format. - **Rejected — keep only as a guardrail:** retain a rejection only when the losing proposal remains a tempting, meaningful mistake and the note explains why it loses. - **Rejected — delete:** delete the whole triplet when the rejected idea is obsolete, superseded, no longer plausible, or unlikely to prevent re-litigation. Repair or delete inbound links. @@ -31,11 +32,10 @@ Do not archive toward a quota. Inspect every note in scope, classify analogous g These examples set the bar; the word counts demonstrate that size is not the test. -Archive implemented notes such as: +Delete implemented notes such as: - collapsed sidebar control rail — 533 words: closed, minor UI behavior; -- Commander argument adapter — 1,498 words: substantial implementation detail with little future design leverage; -- documentation graph atlas — 920 words: completed documentation machinery whose current generators are authoritative. +- issue-policy module ownership — completed function relocation and import rewiring with unchanged behavior. Keep implemented notes such as: @@ -55,7 +55,7 @@ For rejected notes: 1. Move the complete `foo.md`, `foo.zh.md`, and `foo.i18n.yaml` triplet from `implemented//` to `archived//`; `implemented` is deliberately absent from the archive path. 2. Make no body edits. Insert only `Archived: YYYY-MM-DD` immediately below `Status: implemented` in both language files, using the archival date and the same value on both sides. -3. Re-record the sidecar hashes mechanically for the two metadata-only edits. Do not translate, reformat, update facts, or repair links inside the note. +3. Re-record the sidecar hashes mechanically for the two metadata-only edits. Do not translate, reformat, update facts, or repair links inside the note. Existing title punctuation, blank-line layout, and language-switcher wording are preserved, not prerequisites for archival. 4. Search for inbound links from active prose. Redirect them to current authority, retarget them to the archived path only when the historical snapshot is intentionally cited, or delete them. Never verify or repair links out of the archived note. 5. Run `pnpm run verify-archived-agent-notes --write`. Its append-only mode first proves every existing seal still matches, then adds only the new triplet hashes. Run the normal verifier afterward. @@ -65,4 +65,4 @@ After the triplet is sealed, never edit, move, translate, reformat, or delete it Run the archive verifier's focused test, `pnpm run verify-archived-agent-notes`, `pnpm run doc-sync`, `pnpm run lint`, and `git diff --check`; select any additional evidence through [dsh-pre-push-checks](../dsh-pre-push-checks/SKILL.md). -Report active implemented notes kept, implemented notes archived, rejected notes kept/deleted, proposed notes rejected if any, and every genuinely borderline case with its word count and chosen outcome. Do not claim archived outbound links are valid: the archive verifier intentionally never checks them. +Report active implemented notes kept, implemented notes deleted or archived, rejected notes kept/deleted, proposed notes rejected if any, and every genuinely borderline case with its word count and chosen outcome. Do not claim archived outbound links are valid: the archive verifier intentionally never checks them. diff --git a/.agents/skills/dsh-find-simplifications/SKILL.md b/.agents/skills/dsh-find-simplifications/SKILL.md index 6ee736796c..bce148ad18 100644 --- a/.agents/skills/dsh-find-simplifications/SKILL.md +++ b/.agents/skills/dsh-find-simplifications/SKILL.md @@ -93,7 +93,7 @@ Reject or downgrade a candidate when: Audit the Agent Note tree when the user asks to reduce or coalesce it, or when the simplification being implemented makes an owning note obsolete. Do not expand every code-simplification survey into a repository-wide note audit. -Use [`dsh-archive-agent-notes`](../dsh-archive-agent-notes/SKILL.md) for retention judgment and archive mechanics. Low-future-value implemented notes move as frozen triplets to `archived/{kind}`; proposed notes are never archived; rejected notes that no longer prevent a tempting mistake are deleted. Do not edit an archived note while simplifying current prose or code. +Use [`dsh-archive-agent-notes`](../dsh-archive-agent-notes/SKILL.md) for retention judgment and archive mechanics. Implemented notes that only describe small UI adjustments or purely mechanical changes are deleted directly; other completed notes with historical decision value but little future guidance move as frozen triplets to `archived/{kind}`; proposed notes are never archived; rejected notes that no longer prevent a tempting mistake are deleted. Do not edit an archived note while simplifying current prose or code. Follow the deletion rule in the [Agent Note rules](../../notes/README.md#when-to-write-one); do not duplicate or weaken it here. For each candidate chain: diff --git a/.github/issue-management/README.i18n.yaml b/.github/issue-management/README.i18n.yaml index 7d31f8418d..beaf888252 100644 --- a/.github/issue-management/README.i18n.yaml +++ b/.github/issue-management/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 .github/issue-management/README.md -README.md: a936b9358810fd3a8b8104609a5523e305e1aaa5 -README.zh.md: df743cac581c2125df9ac9e76f04d2528204eb74 +README.md: 49e91859f4b22fda13d0e93c6e6ea492272f6951 +README.zh.md: 8424f90cd2e3a59ba1212f64f3f1081a984b55f8 diff --git a/.github/issue-management/README.md b/.github/issue-management/README.md index a936b93588..49e91859f4 100644 --- a/.github/issue-management/README.md +++ b/.github/issue-management/README.md @@ -61,7 +61,7 @@ Lifecycle processing is event-driven, not a reconciler. Omitted events do not re ## Module ownership -Maintainers reuse the owning module directly; [policy.mjs](policy.mjs) only reads the event file, dispatches commands, and reports command failures. The [module-ownership decision](../../.agents/notes/implemented/process/2026-09-07-issue-policy-module-ownership.md) explains this separation. +Maintainers reuse the owning module directly; [policy.mjs](policy.mjs) only reads the event file, dispatches commands, and reports command failures.
Implementation owners diff --git a/.github/issue-management/README.zh.md b/.github/issue-management/README.zh.md index df743cac58..8424f90cd2 100644 --- a/.github/issue-management/README.zh.md +++ b/.github/issue-management/README.zh.md @@ -61,7 +61,7 @@ PR 打开时,工作流按配置时区中的 PR 创建日期,为每个被引 ## 模块归属 -维护者直接复用所属模块;[policy.mjs](policy.mjs)仅负责读取事件文件、分派命令并报告命令失败。[模块归属决策](../../.agents/notes/implemented/process/2026-09-07-issue-policy-module-ownership.zh.md)解释此职责分离。 +维护者直接复用所属模块;[policy.mjs](policy.mjs)仅负责读取事件文件、分派命令并报告命令失败。
实现职责 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e0da3c3125..32b58e9bc1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -42,14 +42,15 @@ jobs: node-24: if: github.event_name == 'pull_request' runs-on: >- - ${{ vars.DSH_CI_FAILOVER_LINUX == 'blacksmith' && 'blacksmith-16vcpu-ubuntu-2404' + ${{ vars.DSH_CI_FAILOVER_LINUX == 'blacksmith' && 'blacksmith-8vcpu-ubuntu-2404' || vars.DSH_CI_FAILOVER_LINUX == 'selfhosted' && github.event.pull_request.user.login != 'dependabot[bot]' && fromJSON('["self-hosted", "linux", "x64", "vm-backup"]') || 'dsh-ubuntu-24-04-16core' }} name: node 24 / static env: - DSH_GATE_CONCURRENCY: '8' + # Shared hosts expose host CPUs, not an isolated allocation. + DSH_GATE_CONCURRENCY: ${{ vars.DSH_CI_FAILOVER_LINUX == 'selfhosted' && github.event.pull_request.user.login != 'dependabot[bot]' && '8' || '' }} # Stop the aggregate at the first blocking gate failure so a red run # does not keep burning enterprise runner time on the remaining gates. DSH_GATE_FAIL_FAST: '1' @@ -116,11 +117,10 @@ jobs: || 'dsh-ubuntu-24-04-16core' }} name: node 24 / coverage env: - # Partitioning replaces the instrumented share; this budget gives the - # exempt-heavy gate two workers on both hosted and failover runners. - DSH_COVERAGE_MAX_WORKERS: '6' - DSH_COVERAGE_PARTITIONS: '4' - DSH_GATE_CONCURRENCY: '3' + # Shared hosts expose host CPUs, not an isolated allocation. + DSH_COVERAGE_MAX_WORKERS: ${{ vars.DSH_CI_FAILOVER_LINUX == 'selfhosted' && github.event.pull_request.user.login != 'dependabot[bot]' && '6' || '' }} + DSH_COVERAGE_PARTITIONS: ${{ vars.DSH_CI_FAILOVER_LINUX == 'selfhosted' && github.event.pull_request.user.login != 'dependabot[bot]' && '4' || '' }} + DSH_GATE_CONCURRENCY: ${{ vars.DSH_CI_FAILOVER_LINUX == 'selfhosted' && github.event.pull_request.user.login != 'dependabot[bot]' && '3' || '' }} # Managed-scope teardown cases run past the default per-test budget when # this lane's partitions, workers, and sibling gates share one host: the # disposal cases in subprocess-local and bash-sandbox exceeded 5000ms on @@ -246,17 +246,17 @@ jobs: || 'dsh-ubuntu-24-04-16core' }} name: node 24 / snapshots and artifacts env: - DSH_GATE_CONCURRENCY: '10' + # Shared hosts expose host CPUs, not an isolated allocation. + DSH_GATE_CONCURRENCY: ${{ vars.DSH_CI_FAILOVER_LINUX == 'selfhosted' && github.event.pull_request.user.login != 'dependabot[bot]' && '10' || '' }} + DSH_OXLINT_THREADS: ${{ vars.DSH_CI_FAILOVER_LINUX == 'selfhosted' && github.event.pull_request.user.login != 'dependabot[bot]' && '8' || '' }} + DSH_PUBLINT_CONCURRENCY: ${{ vars.DSH_CI_FAILOVER_LINUX == 'selfhosted' && github.event.pull_request.user.login != 'dependabot[bot]' && '8' || '' }} + DSH_WEB_SNAPSHOT_WORKERS: ${{ vars.DSH_CI_FAILOVER_LINUX == 'selfhosted' && github.event.pull_request.user.login != 'dependabot[bot]' && '6' || '' }} + DSH_SNAPSHOT_MAX_CONCURRENCY: ${{ vars.DSH_CI_FAILOVER_LINUX == 'selfhosted' && github.event.pull_request.user.login != 'dependabot[bot]' && '12' || '' }} DSH_NODE_COMPAT_SKIP_TYPECHECK: '1' - DSH_OXLINT_THREADS: '8' - DSH_PUBLINT_CONCURRENCY: '8' - DSH_WEB_SNAPSHOT_WORKERS: '6' # A failing gate aborts its running siblings: a failing build stops the # independent Node compatibility smoke, and a failing reader (e.g. # publint) stops the remaining artifact consumers. DSH_GATE_FAIL_FAST: '1' - # Failover halves snapshot concurrency for the shared 64-core VM. - DSH_SNAPSHOT_MAX_CONCURRENCY: ${{ vars.DSH_CI_FAILOVER_LINUX == 'selfhosted' && github.event.pull_request.user.login != 'dependabot[bot]' && '12' || '32' }} steps: # Runner cleanup owns tool caches and fixtures, including cancelled runs. - name: Use runner-owned temporary storage @@ -491,14 +491,12 @@ jobs: secrets: DEEPSEEK_API_KEY_EXTERNAL: ${{ secrets.DEEPSEEK_API_KEY_EXTERNAL }} - # Every pull request also gets real Windows-kernel signals. The former - # monolithic windows-native job is split into smaller jobs so one slow - # coverage gate does not hold up build/static results, while the total - # per-job process count stays lower. Observational checks are non-blocking. + # Native builds and process tests block pull requests independently of coverage. + # Observational checks reuse the successful build and remain non-blocking. # Dependabot PRs are excluded from the self-hosted pool and stay queued for # the hosted runner. - # Blacksmith uses 2 vCPU for file-serial native tests; builds, coverage, - # and observational gates retain 16 for their concurrent work. + # Blacksmith uses 2 vCPU for file-serial native tests and 16 for coverage + # and the shared build/observational workspace. windows-build: if: github.event_name == 'pull_request' runs-on: >- @@ -507,7 +505,7 @@ jobs: && github.event.pull_request.user.login != 'dependabot[bot]' && fromJSON('["self-hosted", "dsh-win-ci", "windows"]') || 'dsh-windows-2025-16core' }} - name: windows node 24 / build + name: windows node 24 / build & observational timeout-minutes: 60 env: # A failing build or site aborts the sibling gate on the same runner. @@ -543,6 +541,23 @@ jobs: - name: Run blocking Windows builds shell: pwsh run: pnpm run check:ci:windows-blocking + - name: Run Windows observational gates on built artifacts + id: observational + continue-on-error: true + timeout-minutes: 15 + shell: pwsh + env: + DSH_GATE_FAIL_FAST: '' + DSH_PUBLINT_CONCURRENCY: ${{ vars.DSH_CI_FAILOVER_WINDOWS == 'selfhosted' && github.event.pull_request.user.login != 'dependabot[bot]' && '8' || '' }} + run: pnpm run check:ci:windows-observational-ready + - name: Report Windows observational failures + continue-on-error: true + if: steps.observational.outcome == 'failure' + shell: pwsh + run: | + $message = 'Windows observational checks failed; see the observational step for diagnostics.' + Write-Output "::warning::$message" + $message | Out-File -FilePath $env:GITHUB_STEP_SUMMARY -Encoding utf8 -Append windows-coverage: if: github.event_name == 'pull_request' @@ -555,10 +570,11 @@ jobs: name: windows node 24 / coverage timeout-minutes: 120 env: - DSH_COVERAGE_MAX_WORKERS: '6' - DSH_COVERAGE_PARTITIONS: '4' + # Shared hosts expose host CPUs, not an isolated allocation. + DSH_COVERAGE_MAX_WORKERS: ${{ vars.DSH_CI_FAILOVER_WINDOWS == 'selfhosted' && github.event.pull_request.user.login != 'dependabot[bot]' && '6' || '' }} + DSH_COVERAGE_PARTITIONS: ${{ vars.DSH_CI_FAILOVER_WINDOWS == 'selfhosted' && github.event.pull_request.user.login != 'dependabot[bot]' && '4' || '' }} + DSH_GATE_CONCURRENCY: ${{ vars.DSH_CI_FAILOVER_WINDOWS == 'selfhosted' && github.event.pull_request.user.login != 'dependabot[bot]' && '3' || '' }} DSH_COVERAGE_TEST_TIMEOUT_MS: '90000' - DSH_GATE_CONCURRENCY: '3' # A gate failure aborts the sibling gate instead of waiting out its # multi-minute instrumented run. DSH_GATE_FAIL_FAST: '1' @@ -670,51 +686,6 @@ jobs: packages/workflow/tool-ralph/tests/integration.spec.ts packages/subprocess/subprocess-local/tests/process-exit.spec.ts - windows-observational: - if: github.event_name == 'pull_request' - continue-on-error: true - runs-on: >- - ${{ vars.DSH_CI_FAILOVER_WINDOWS == 'blacksmith' && 'blacksmith-16vcpu-windows-2025' - || vars.DSH_CI_FAILOVER_WINDOWS == 'selfhosted' - && github.event.pull_request.user.login != 'dependabot[bot]' - && fromJSON('["self-hosted", "dsh-win-ci", "windows"]') - || 'dsh-windows-2025-16core' }} - name: windows node 24 / observational - timeout-minutes: 60 - env: - DSH_PUBLINT_CONCURRENCY: '8' - steps: - - uses: actions/checkout@v6 - with: - persist-credentials: false - - name: Enable Developer Mode (symlink support) - shell: pwsh - run: >- - reg add "HKLM\SOFTWARE\Microsoft\Windows\CurrentVersion\AppModelUnlock" - /t REG_DWORD /f /v "AllowDevelopmentWithoutDevLicense" /d "1" - - uses: pnpm/action-setup@v4 - with: - dest: ${{ runner.temp }}/setup-pnpm-js-${{ github.run_id }}-${{ github.run_attempt }}-${{ github.job }} - - uses: actions/setup-node@v6 - with: - node-version: ${{ env.PRIMARY_NODE_VERSION }} - - name: Install (immutable) - shell: pwsh - # See 2026-08-30-windows-refs-store-block-clone-install for the - # ReFS block-clone rationale; detect the workspace filesystem and - # pass --package-import-method=clone only on ReFS. - run: >- - $drive = (Split-Path -Qualifier $env:GITHUB_WORKSPACE).TrimEnd(':'); - $fs = (Get-Volume -DriveLetter $drive).FileSystem; - if ($fs -eq 'ReFS') { - corepack pnpm install --frozen-lockfile --package-import-method=clone - } else { - pnpm install --frozen-lockfile - } - - name: Run Windows observational gates - shell: pwsh - run: pnpm run check:ci:windows-observational - # Single stable required check for branch protection: require "all checks # passed" instead of enumerating matrix legs whose names change as lanes and # node versions evolve. Every blocking job in THIS workflow must be listed in diff --git a/apps/cli/package.json b/apps/cli/package.json index 8916642cfe..b3cff4af5d 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -48,7 +48,6 @@ "@deepseek-ai/dsh-compaction-tool-result-pruner": "workspace:^", "@deepseek-ai/dsh-cordis-client-runner": "workspace:^", "@deepseek-ai/dsh-experimental-agent-team-profile": "workspace:^", - "@deepseek-ai/dsh-experimental-agent-team-web-profile": "workspace:^", "@deepseek-ai/dsh-fs-local": "workspace:^", "@deepseek-ai/dsh-goal": "workspace:^", "@deepseek-ai/dsh-goal-round-driver": "workspace:^", diff --git a/apps/desktop/README.i18n.yaml b/apps/desktop/README.i18n.yaml index 909bef683c..0c0db6e288 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: c2a1f4df103d5fe74f4a25aa1bcfc1f8e38d3b1e -README.zh.md: 9dda03b4b40a7b46bf5e0f1404f77d73d397bfa4 +README.md: 531c168c0d3fc3091a330f5350d0a85ac3009f08 +README.zh.md: 6a20f83623a54e8f87d05104280b97ddfb2a796a diff --git a/apps/desktop/README.md b/apps/desktop/README.md index c2a1f4df10..531c168c0d 100644 --- a/apps/desktop/README.md +++ b/apps/desktop/README.md @@ -263,7 +263,7 @@ An unpacked artifact contains Electron, the materialized dsh production tree, pn ## Updates -Packaged applications check fixed Nightly asynchronously at startup. Ordinary polling uses a ten-minute base interval with independently sampled ±20% jitter. Each check failure doubles the base delay up to one hour; success resets it. The randomized delay is bounded by that cap and starts after all joined callers settle. Foreground and system-resume checks respect the same monotonic deadline; the localized Check for Updates menu item, including in the Windows caption's Application menu, runs immediately and joins an in-flight check. A newly received mandatory policy also requests an immediate feed check. Automatic checks never open dialogs or download packages. Manual checks display checking, failure, or no-update feedback with the installed version. +Packaged applications check fixed Nightly asynchronously at startup. Ordinary polling uses a ten-minute base interval with independently sampled ±20% jitter. Each check failure doubles the base delay up to one hour; success resets it. The randomized delay is bounded by that cap and starts after all joined callers settle. Foreground and system-resume checks respect the same monotonic deadline; the localized Check for Updates menu item, including in the Windows caption's Application menu, runs immediately and joins an in-flight check. A newly received mandatory policy also requests an immediate feed check. Automatic checks never open dialogs or download packages. Manual checks display checking, failure, or no-update feedback with the installed version. Ordinary update dialogs fade in and out in place; consecutive prompts replace the card content and reset its scroll position while retaining the backdrop and background blur. `DSH_DESKTOP_UPDATE_CHECK_INTERVAL_MS` configures the ordinary base interval, and `DSH_DESKTOP_UPDATE_CHECK_MAX_BACKOFF_MS` configures the cap; both accept integer milliseconds from 1000 through 2147483647, with the cap at least the interval. An omitted cap defaults to the larger of one hour and the interval. `DSH_DESKTOP_UPDATE_CHECK_JITTER` sets the fractional jitter from 0 through 1, defaulting to `0.2`; the final delay is at least one second and never exceeds the cap. These settings do not change mandatory-policy polling or authorize download retries. @@ -295,7 +295,7 @@ With `feishu-test`, an HTTP 401 JSON response containing `error.code: "UNAUTHENT Login and policy requests share an in-memory Session, separate from product windows and the updater; restarting requires a new login. Closing cancels login, and navigation failure provides localized retry guidance. Returning to the service triggers a fresh policy query; a redirect, cookie, or HTTP 422 is not a valid policy decision. Cancellation, expiry, and invalid responses retain any known mandatory block. Fixed login outcomes appear in process diagnostics and the optional update journal; cookies, OAuth parameters, and remote error text are not recorded by the login controller. Live Harness gateway/API integration and macOS login qualification remain unverified. -A flattened `40005` opens a shell-owned modal and refuses subsequent plugin mutations without stopping existing Host tasks. Server title and detail are optional plain text and fall back to client copy; an absent or unapproved download URL hides the external-page actions without clearing the block. On Windows the mandatory window has a native title bar and can be moved, resized, and maximized. Closing it exits the application after cleanup without clearing the update requirement; Esc does not dismiss it. Once installation is approved, installer-owned quit releases the modal before Electron closes windows. Download, file verification including preparation, task inspection, and installation confirmation share this same modal. Only the second user approval permits task teardown and installation; deferral retains the block and package. Restart feedback mentions task stopping only when affected tasks exist. Policy is not persisted across application restarts, and policy responses never revoke or replace an updater artifact. +A flattened `40005` opens a shell-owned modal and refuses subsequent plugin mutations without stopping existing Host tasks. Server title and detail are optional plain text and fall back to client copy; an absent or unapproved download URL hides the external-page actions without clearing the block. On macOS the mandatory overlay fades in and out in place, keeps its backdrop during update-state changes, and redirects parent-window focus and keyboard input to the overlay. On Windows the mandatory window has a native title bar and can be moved, resized, and maximized. Closing it exits the application after cleanup without clearing the update requirement; Esc does not dismiss it. Once installation is approved, installer-owned quit releases the modal before Electron closes windows. Download, file verification including preparation, task inspection, and installation confirmation share this same modal. Only the second user approval permits task teardown and installation; deferral retains the block and package. Restart feedback mentions task stopping only when affected tasks exist. Policy is not persisted across application restarts, and policy responses never revoke or replace an updater artifact. Failures retain blocking, localized retry guidance, and folded diagnostics inside the modal. The allowed download-page action appears in recovery states, not beside normal download or installation. Requesting the browser immediately exposes a copy alternative even while the OS request is pending; a resolved request does not prove the page opened. Copy failure reveals the complete, read-only address for manual copying. Browser and clipboard outcomes do not overwrite updater errors. Only a fresh valid no-force response clears the block; the top-menu check remains available while blocked. diff --git a/apps/desktop/README.zh.md b/apps/desktop/README.zh.md index 9dda03b4b4..6a20f83623 100644 --- a/apps/desktop/README.zh.md +++ b/apps/desktop/README.zh.md @@ -264,7 +264,7 @@ macOS 打包在组装 App 时、代码签名前写入 `Contents/Resources/app-up ## 更新 -打包应用在启动后异步检查固定 Nightly。常规轮询以十分钟为基础间隔,每次独立采样 ±20% 的随机抖动。每次检查失败将基础延迟翻倍,上限为一小时;成功后重置。随机延迟不超过该上限,并从全部复用调用结算后开始计时。本地化的“检查更新…”菜单项(Windows 可从顶栏的“应用”菜单进入)立即执行,并复用正在进行的检查。回到前台和系统恢复时遵守相同的单调时钟截止时间。新收到的强更策略也会立即请求检查更新清单。自动检查从不弹窗或下载安装包。手动检查显示正在检查、失败或包含已安装版本号的无更新反馈。 +打包应用在启动后异步检查固定 Nightly。常规轮询以十分钟为基础间隔,每次独立采样 ±20% 的随机抖动。每次检查失败将基础延迟翻倍,上限为一小时;成功后重置。随机延迟不超过该上限,并从全部复用调用结算后开始计时。本地化的“检查更新…”菜单项(Windows 可从顶栏的“应用”菜单进入)立即执行,并复用正在进行的检查。回到前台和系统恢复时遵守相同的单调时钟截止时间。新收到的强更策略也会立即请求检查更新清单。自动检查从不弹窗或下载安装包。手动检查显示正在检查、失败或包含已安装版本号的无更新反馈。常规更新弹窗原位渐入渐出;连续弹窗替换卡片内容并重置其滚动位置,保留蒙层与背景模糊。 `DSH_DESKTOP_UPDATE_CHECK_INTERVAL_MS` 配置常规基础间隔,`DSH_DESKTOP_UPDATE_CHECK_MAX_BACKOFF_MS` 配置上限;两者均接受 1000 至 2147483647 的整数毫秒数,且上限不能小于间隔。省略上限时取一小时与间隔中的较大值。`DSH_DESKTOP_UPDATE_CHECK_JITTER` 配置 0 至 1 的抖动比例,默认 `0.2`;最终延迟至少一秒,且不超过上限。这些配置不改变强更策略轮询,也不授权下载重试。 @@ -296,7 +296,7 @@ macOS 打包在组装 App 时、代码签名前写入 `Contents/Resources/app-up 登录和策略请求共用内存 Session,与产品窗口及 updater 隔离;应用重启后需要重新登录。关闭窗口取消登录,导航失败提供本地化重试提示。返回服务后重新查询策略;重定向、Cookie 或 HTTP 422 都不是有效策略决定。取消、登录过期及无效响应均保留已知强更阻塞。固定登录结果写入进程诊断及可选更新日志;登录控制器不记录 Cookie、OAuth 参数或远程错误原文。真实 Harness 网关/API 联调及 macOS 登录验收仍未完成。 -扁平化的 `40005` 打开壳拥有的模态窗口,并拒绝后续插件修改,不停止现有 Host 任务。服务端标题与详情是可选纯文本,缺失时使用客户端兜底文案;缺少下载地址或地址未获批准时隐藏外部页面操作,不解除阻塞。Windows 强更窗口使用原生标题栏,可拖动、调整大小和最大化。关闭窗口会在完成清理后退出应用,不会解除更新要求;Esc 不会关闭窗口。批准安装后,安装器接管的退出流程会在 Electron 关闭窗口前释放模态窗口。下载、含准备步骤的文件校验、任务检查和安装确认共用同一弹窗。只有第二次用户批准才允许任务收尾和安装;稍后更新保留阻塞与安装包。仅存在受影响任务时,重启文案才提示正在停止任务。策略不跨应用重启持久化,策略响应也不作废或替换 updater 产物。 +扁平化的 `40005` 打开壳拥有的模态窗口,并拒绝后续插件修改,不停止现有 Host 任务。服务端标题与详情是可选纯文本,缺失时使用客户端兜底文案;缺少下载地址或地址未获批准时隐藏外部页面操作,不解除阻塞。macOS 强更蒙层原位渐入渐出,在更新状态切换时保留蒙层,并将父窗口焦点和键盘输入重定向到蒙层。Windows 强更窗口使用原生标题栏,可拖动、调整大小和最大化。关闭窗口会在完成清理后退出应用,不会解除更新要求;Esc 不会关闭窗口。批准安装后,安装器接管的退出流程会在 Electron 关闭窗口前释放模态窗口。下载、含准备步骤的文件校验、任务检查和安装确认共用同一弹窗。只有第二次用户批准才允许任务收尾和安装;稍后更新保留阻塞与安装包。仅存在受影响任务时,重启文案才提示正在停止任务。策略不跨应用重启持久化,策略响应也不作废或替换 updater 产物。 失败时在同一弹窗内保留阻塞、本地化重试提示和折叠诊断。白名单下载页面操作只在恢复状态出现,不与正常下载或安装并列。请求打开浏览器后立即提供复制替代入口,即使系统请求尚未返回;请求成功不证明网页已打开。复制失败时展示完整、只读的地址供手动复制。浏览器与剪贴板结果不覆盖 updater 错误。只有新的有效无需强更响应才解除阻塞;阻塞期间仍可使用顶部菜单检查。 diff --git a/apps/desktop/renderer/mandatory-update.html b/apps/desktop/renderer/mandatory-update.html index c0c4a28513..673c5d0953 100644 --- a/apps/desktop/renderer/mandatory-update.html +++ b/apps/desktop/renderer/mandatory-update.html @@ -8,7 +8,7 @@ - +

diff --git a/apps/desktop/renderer/mandatory-update.js b/apps/desktop/renderer/mandatory-update.js index 807209a0cd..24e1f88041 100644 --- a/apps/desktop/renderer/mandatory-update.js +++ b/apps/desktop/renderer/mandatory-update.js @@ -7,6 +7,8 @@ const element = id => document.getElementById(id) const format = (message, values) => message.replaceAll(/\{([^{}]+)\}/gu, (match, key) => values[key] ?? match) function render(view) { + document.body.classList.toggle('visible', view.policy.blocking) + if (!view.policy.blocking) return const wasConfirming = current?.confirmation !== undefined current = view const { locale: { id, messages }, policy, update, confirmation, navigation } = view diff --git a/apps/desktop/renderer/update-dialog.css b/apps/desktop/renderer/update-dialog.css index 043c0d0cbc..98183989a5 100644 --- a/apps/desktop/renderer/update-dialog.css +++ b/apps/desktop/renderer/update-dialog.css @@ -5,7 +5,8 @@ background: transparent; } * { box-sizing: border-box; } -body { margin: 0; height: 100vh; display: grid; place-items: center; background: rgb(0 0 0 / 24%); } +body { margin: 0; height: 100vh; display: grid; place-items: center; isolation: isolate; } +body::before { content: ""; position: fixed; inset: 0; z-index: -1; background: rgb(0 0 0 / 24%); } main { width: min(380px, calc(100vw - 32px)); max-height: calc(100vh - 32px); overflow: auto; padding: 22px 24px 24px; border-radius: 24px; background: #fff; box-shadow: 0 0 1px rgb(0 0 0 / 20%), 0 0 4px rgb(0 0 0 / 2%), 0 12px 32px rgb(0 0 0 / 8%); } header { display: flex; align-items: flex-start; justify-content: space-between; gap: 8px; margin-bottom: 12px; } main:focus { outline: none; } @@ -24,3 +25,7 @@ summary { cursor: pointer; color: #5b6472; } pre { margin: 8px 0 0; padding: 10px; max-height: min(180px, 30vh); overflow: auto; border-radius: 8px; background: #f5f5f5; font: 12px/18px monospace; white-space: pre-wrap; overflow-wrap: anywhere; } button:disabled { opacity: 0.5; cursor: default; } [hidden] { display: none !important; } +body.update-dialog { opacity: 0; transition: opacity 150ms ease; } +body.update-dialog.visible { opacity: 1; } +@starting-style { body.update-dialog.visible { opacity: 0; } } +@media (prefers-reduced-motion: reduce) { body.update-dialog { transition: none; } } diff --git a/apps/desktop/renderer/update-dialog.html b/apps/desktop/renderer/update-dialog.html index 176a0dcf12..85a0f20abd 100644 --- a/apps/desktop/renderer/update-dialog.html +++ b/apps/desktop/renderer/update-dialog.html @@ -7,7 +7,7 @@ - +

diff --git a/apps/desktop/renderer/update-dialog.js b/apps/desktop/renderer/update-dialog.js index baa4ac75f6..d03eadadf7 100644 --- a/apps/desktop/renderer/update-dialog.js +++ b/apps/desktop/renderer/update-dialog.js @@ -5,7 +5,7 @@ let responding = false function respond(index) { if (responding || view === undefined) return responding = true - void api.respond(index).catch(() => { responding = false }) + void api.respond(view.revision, index).catch(() => { responding = false }) } document.getElementById('close').addEventListener('click', () => { respond(view.cancelId) }) document.addEventListener('keydown', event => { @@ -18,7 +18,14 @@ document.addEventListener('keydown', event => { event.preventDefault() controls[next].focus() }) -void api.status().then(state => { +function render(state) { + if (state === null) { + responding = true + document.body.classList.remove('visible') + return + } + if (view !== undefined && state.revision <= view.revision) return + responding = false view = state document.documentElement.lang = state.locale document.title = state.title @@ -29,6 +36,8 @@ void api.status().then(state => { document.getElementById('technical-details').hidden = state.technicalDetails === '' document.getElementById('technical-details-label').textContent = state.technicalDetailsLabel document.getElementById('technical-details-content').textContent = state.technicalDetails + document.getElementById('technical-details').open = false + document.getElementById('actions').replaceChildren() for (const [index, label] of state.buttons.entries()) { const button = document.createElement('button') button.type = 'button' @@ -38,5 +47,11 @@ void api.status().then(state => { document.getElementById('actions').append(button) } document.querySelector('main').hidden = false + document.getElementById('dialog').scrollTop = 0 + document.body.classList.add('visible') document.getElementById('dialog').focus() -}) +} +let received = false +const unsubscribe = api.subscribe(state => { received = true; render(state) }) +window.addEventListener('pagehide', unsubscribe, { once: true }) +void api.status().then(state => { if (!received) render(state) }) diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index 5c21833b0e..f9bfb78409 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -474,43 +474,50 @@ async function main(): Promise { if (manual) await Promise.all([checkPolicyManually(), updateSchedule.check(true)]) return } - let state = updates.state - if (manual || state.phase === 'idle' || (state.phase === 'error' && state.failedOperation === 'check')) { - const controller = new AbortController() - ordinaryDialogs.add(controller) - const progress = mainWindow === undefined ? Promise.resolve() : updateDialog.show(mainWindow, { type: 'info', title: messages.updateCheckTitle, - message: messages.updateChecking, buttons: [messages.later], cancelId: 0, signal: controller.signal }) - try { + let controller: AbortController | undefined + let progress: Promise | undefined + try { + let state = updates.state + if (manual || state.phase === 'idle' || (state.phase === 'error' && state.failedOperation === 'check')) { + controller = new AbortController() + ordinaryDialogs.add(controller) + progress = mainWindow === undefined ? Promise.resolve() : updateDialog.show(mainWindow, { type: 'info', title: messages.updateCheckTitle, + message: messages.updateChecking, buttons: [messages.later], cancelId: 0, signal: controller.signal }) if (!joinedPolicyAuthentication) { void checkPolicyManually('deferred').catch((error: unknown) => { console.error(error) }) } state = await updateSchedule.check(true) - } finally { controller.abort(); ordinaryDialogs.delete(controller); await progress } - } - if (isMandatory()) { mandatoryUI?.focus(); return } - if (state.phase === 'error' && state.failedOperation === 'check') { await showUpdateFailure(state); return } - if (state.phase === 'idle') { - await ordinaryMessageBox({ type: 'info', title: messages.updateCheckTitle, - message: formatDesktopMessage(messages.updateCurrent, { version: app.getVersion() }) }) - return - } - if (state.phase === 'ready' || (state.phase === 'error' && state.failedOperation === 'install')) { - if (state.version !== undefined) { - failedOperation = 'install' - await showUpdateFailure(await updates.install(state.version)) } - return - } - if (state.phase !== 'available' && !(state.phase === 'error' && state.failedOperation === 'download')) return - if (manual) { - const result = await ordinaryMessageBox({ title: messages.updateCheckTitle, message: messages.updateAvailable, - detail: formatDesktopMessage(messages.updateDetail, { version: state.version ?? '' }), - buttons: [messages.updateDownload], cancelId: 1 }) - if (result.response !== 0) return - } - if (!isMandatory() && state.version !== undefined) { - failedOperation = 'download' - await showUpdateFailure(await downloadUpdate(state.version)) + if (isMandatory()) { mandatoryUI?.focus(); return } + if (state.phase === 'error' && state.failedOperation === 'check') { await showUpdateFailure(state); return } + if (state.phase === 'idle') { + await ordinaryMessageBox({ type: 'info', title: messages.updateCheckTitle, + message: formatDesktopMessage(messages.updateCurrent, { version: app.getVersion() }) }) + return + } + if (state.phase === 'ready' || (state.phase === 'error' && state.failedOperation === 'install')) { + if (state.version !== undefined) { + failedOperation = 'install' + await showUpdateFailure(await updates.install(state.version)) + } + return + } + if (state.phase !== 'available' && !(state.phase === 'error' && state.failedOperation === 'download')) return + if (manual) { + const result = await ordinaryMessageBox({ title: messages.updateCheckTitle, message: messages.updateAvailable, + detail: formatDesktopMessage(messages.updateDetail, { version: state.version ?? '' }), + buttons: [messages.updateDownload], cancelId: 1 }) + if (result.response !== 0) return + } + if (!isMandatory() && state.version !== undefined) { + controller?.abort() + failedOperation = 'download' + await showUpdateFailure(await downloadUpdate(state.version)) + } + } finally { + controller?.abort() + if (controller !== undefined) ordinaryDialogs.delete(controller) + await progress } }).catch((error: unknown) => showUpdateFailure({ phase: 'error', failedOperation, message: desktopErrorState(error).message })) diff --git a/apps/desktop/src/mandatory-update-window.ts b/apps/desktop/src/mandatory-update-window.ts index a3ca6bcf8f..17438ab15e 100644 --- a/apps/desktop/src/mandatory-update-window.ts +++ b/apps/desktop/src/mandatory-update-window.ts @@ -49,6 +49,7 @@ const page = 'dsh-app://shell/mandatory-update.html' /** A modal child blocks the product window without cancelling work in the Host. */ export class DesktopMandatoryUpdateWindow { private window: BrowserWindow | undefined + private closing: ReturnType | undefined private disposed = false private error: string | undefined private action: Promise | undefined @@ -142,11 +143,20 @@ export class DesktopMandatoryUpdateWindow { this.clearNavigation() this.restart = undefined this.deferred = false - this.window?.destroy() - this.window = undefined + if (this.window !== undefined && this.closing === undefined) { + const window = this.window + window.webContents.send(MANDATORY_IPC.state, this.view()) + this.closing = setTimeout(() => { + this.closing = undefined + if (this.window === window) this.window = undefined + window.destroy() + }, 150) + } this.error = undefined return } + clearTimeout(this.closing) + this.closing = undefined if (this.navigationUrl !== this.options.policy().page) this.clearNavigation() if (this.options.update().phase === 'error') this.restart = undefined if (this.window === undefined) { @@ -186,6 +196,8 @@ export class DesktopMandatoryUpdateWindow { /** Detach IPC and release the modal during application shutdown. */ dispose(): void { this.disposed = true + clearTimeout(this.closing) + this.closing = undefined this.finishConfirmation(false) this.attention.reset() this.clearNavigation() diff --git a/apps/desktop/src/preload-update-dialog.ts b/apps/desktop/src/preload-update-dialog.ts index 65cf46fcd4..37a5757aac 100644 --- a/apps/desktop/src/preload-update-dialog.ts +++ b/apps/desktop/src/preload-update-dialog.ts @@ -3,7 +3,12 @@ import { contextBridge, ipcRenderer } from 'electron' import { UPDATE_DIALOG_IPC, type UpdateDialogApi, type UpdateDialogView } from './update-dialog.ts' const api: UpdateDialogApi = { - status: () => ipcRenderer.invoke(UPDATE_DIALOG_IPC.status) as Promise, - respond: index => ipcRenderer.invoke(UPDATE_DIALOG_IPC.respond, index) as Promise, + status: () => ipcRenderer.invoke(UPDATE_DIALOG_IPC.status) as Promise, + respond: (revision, index) => ipcRenderer.invoke(UPDATE_DIALOG_IPC.respond, revision, index) as Promise, + subscribe: (listener) => { + const receive = (_event: Electron.IpcRendererEvent, view: UpdateDialogView | null): void => { listener(view) } + ipcRenderer.on(UPDATE_DIALOG_IPC.changed, receive) + return () => { ipcRenderer.removeListener(UPDATE_DIALOG_IPC.changed, receive) } + }, } if (location.href === 'dsh-app://shell/update-dialog.html') contextBridge.exposeInMainWorld('dshUpdateDialog', api) diff --git a/apps/desktop/src/update-dialog.ts b/apps/desktop/src/update-dialog.ts index f48f70d075..c307bda9e4 100644 --- a/apps/desktop/src/update-dialog.ts +++ b/apps/desktop/src/update-dialog.ts @@ -4,10 +4,12 @@ import type { DesktopLocale } from './locale.ts' import { createUpdateOverlay } from './update-overlay.ts' /** Channels available only to the isolated update-dialog document. */ -export const UPDATE_DIALOG_IPC = { status: 'dsh-update-dialog:status', respond: 'dsh-update-dialog:respond' } as const +export const UPDATE_DIALOG_IPC = { status: 'dsh-update-dialog:status', changed: 'dsh-update-dialog:changed', respond: 'dsh-update-dialog:respond' } as const /** Text and choices supplied by the main process, never by product documents. */ export interface UpdateDialogView { + /** Identifies the displayed choices; a response from an older prompt is rejected. */ + readonly revision: number readonly locale: string readonly title: string readonly message: string @@ -26,17 +28,22 @@ export interface UpdateDialogOptions extends MessageBoxOptions { /** The document can select only a displayed response index. */ export interface UpdateDialogApi { - status(): Promise - respond(index: number): Promise + status(): Promise + respond(revision: number, index: number): Promise + subscribe(listener: (view: UpdateDialogView | null) => void): () => void } // main.ts's protocol.handle shell route serves this document and its renderer assets; the modal requires that route. const page = 'dsh-app://shell/update-dialog.html' -/** One replaceable confirmation window; aborted checks and mandatory policy cancel ordinary prompts. */ +/** One fading backdrop with replaceable confirmation content; aborted checks and mandatory policy cancel ordinary prompts. */ export class DesktopUpdateDialog { private disposed = false - private active: { window: BrowserWindow; view: UpdateDialogView; finish: (index: number) => void } | undefined + private revision = 0 + private window: BrowserWindow | undefined + private parent: BrowserWindow | undefined + private closing: ReturnType | undefined + private active: { window: BrowserWindow; view: UpdateDialogView; finish: (index: number, retain?: boolean) => void } | undefined /** Focus the current explanation or confirmation without replacing it or granting permission. */ focus(): void { this.active?.window.focus() } @@ -46,9 +53,11 @@ export class DesktopUpdateDialog { * @param locale - Shell-owned copy. */ constructor(private readonly preload: string, private readonly locale: DesktopLocale) { - ipcMain.handle(UPDATE_DIALOG_IPC.status, event => this.owned(event).view) - ipcMain.handle(UPDATE_DIALOG_IPC.respond, (event, index: unknown) => { - const active = this.owned(event) + ipcMain.handle(UPDATE_DIALOG_IPC.status, (event) => { this.owned(event); return this.active?.view ?? null }) + ipcMain.handle(UPDATE_DIALOG_IPC.respond, (event, revision: unknown, index: unknown) => { + this.owned(event) + const active = this.active + if (active === undefined || revision !== active.view.revision) throw new Error('desktop update: stale dialog response') if (typeof index !== 'number' || !Number.isInteger(index) || (index !== active.view.cancelId && (index < 0 || index >= active.view.buttons.length))) { throw new Error('desktop update: invalid dialog response') @@ -60,34 +69,45 @@ export class DesktopUpdateDialog { /** * @param parent - Window blocked by this confirmation. * @param options - Main-owned localized content, response choices, and optional cancellation signal. - * @returns A displayed response, or the cancel response when closed, replaced, aborted, or unable to load. + * @returns A displayed response, or cancellation on replacement, abort, close, or load failure; backdrop dismissal fades independently. */ show(parent: BrowserWindow, options: UpdateDialogOptions): Promise { - this.cancel() const buttons = options.buttons ?? [this.locale.messages.updateAcknowledge] const cancelId = options.cancelId ?? buttons.length - 1 if (this.disposed || options.signal?.aborted === true || parent.isDestroyed()) { return Promise.resolve({ response: cancelId, checkboxChecked: false }) } - const window = createUpdateOverlay(parent, this.preload, options.title ?? this.locale.messages.updateTitle) - const view: UpdateDialogView = { locale: this.locale.id, title: options.title ?? '', message: options.message, + if (this.parent !== parent) { this.cancel(); this.close() } + this.active?.finish(this.active.view.cancelId, true) + clearTimeout(this.closing) + this.closing = undefined + const existing = this.window + const window = existing ?? createUpdateOverlay(parent, this.preload, options.title ?? this.locale.messages.updateTitle, false) + this.window = window + this.parent = parent + const view: UpdateDialogView = { revision: ++this.revision, locale: this.locale.id, title: options.title ?? '', message: options.message, detail: options.detail ?? '', buttons, cancelId, closeLabel: this.locale.messages.updateClose, technicalDetails: options.technicalDetails ?? '', technicalDetailsLabel: this.locale.messages.updateTechnicalDetails } return new Promise((resolve) => { const abort = (): void => { finish(cancelId) } - const finish = (response: number): void => { - if (this.active?.window !== window) return + const finish = (response: number, retain = false): void => { + if (this.active?.view !== view) return this.active = undefined options.signal?.removeEventListener('abort', abort) - if (!window.isDestroyed()) window.destroy() + if (!retain && !window.isDestroyed()) { + window.webContents.send(UPDATE_DIALOG_IPC.changed, null) + this.closing = setTimeout(() => { this.close() }, 150) + } resolve({ response, checkboxChecked: false }) } this.active = { window, view, finish } options.signal?.addEventListener('abort', abort, { once: true }) - window.once('closed', abort) + if (existing !== undefined) { window.webContents.send(UPDATE_DIALOG_IPC.changed, view); return } + const failed = (): void => { if (this.window === window) { this.cancel(); this.close() } } + window.once('closed', failed) window.webContents.on('will-navigate', (event, url) => { if (url !== page) event.preventDefault() }) - window.webContents.once('render-process-gone', abort) - void window.loadURL(page).catch(abort) + window.webContents.once('render-process-gone', failed) + void window.loadURL(page).catch(failed) }) } @@ -99,16 +119,25 @@ export class DesktopUpdateDialog { if (this.disposed) return this.disposed = true this.cancel() + this.close() ipcMain.removeHandler(UPDATE_DIALOG_IPC.status) ipcMain.removeHandler(UPDATE_DIALOG_IPC.respond) } - private owned(event: IpcMainInvokeEvent): NonNullable { - const active = this.active - if (active === undefined || event.sender !== active.window.webContents - || event.senderFrame !== active.window.webContents.mainFrame || event.senderFrame.url !== page) { + private close(): void { + clearTimeout(this.closing) + this.closing = undefined + const window = this.window + this.window = undefined + this.parent = undefined + if (window !== undefined && !window.isDestroyed()) window.destroy() + } + + private owned(event: IpcMainInvokeEvent): void { + const window = this.window + if (window === undefined || event.sender !== window.webContents + || event.senderFrame !== window.webContents.mainFrame || event.senderFrame.url !== page) { throw new Error('desktop update: rejected unowned dialog renderer') } - return active } } diff --git a/apps/desktop/src/update-overlay.ts b/apps/desktop/src/update-overlay.ts index 39bcde23db..2ff0b8c0d4 100644 --- a/apps/desktop/src/update-overlay.ts +++ b/apps/desktop/src/update-overlay.ts @@ -5,15 +5,27 @@ import { BrowserWindow } from 'electron' * @param parent - Product window whose content is blocked while the overlay is open. * @param preload - Isolated shell-only preload. * @param title - Localized window title. + * @param nativeModal - Use a native modal; false keeps overlays out of macOS sheets. * @returns A transparent child that follows its parent's content bounds and releases its listeners on close. */ -export function createUpdateOverlay(parent: BrowserWindow, preload: string, title: string): BrowserWindow { +export function createUpdateOverlay(parent: BrowserWindow, preload: string, title: string, nativeModal = true): BrowserWindow { const window = new BrowserWindow({ - parent, modal: true, show: false, frame: false, transparent: true, + parent, modal: nativeModal || process.platform !== 'darwin', show: false, frame: false, transparent: true, ...parent.getContentBounds(), resizable: false, minimizable: false, maximizable: false, skipTaskbar: true, hasShadow: false, title, webPreferences: { preload, contextIsolation: true, sandbox: true, nodeIntegration: false, webSecurity: true }, }) + // macOS native modals animate the entire viewport as a sheet. + const focus = (): void => { if (!window.isDestroyed()) window.focus() } + const blockInput = (event: Electron.Event): void => { event.preventDefault(); focus() } + if (!nativeModal && process.platform === 'darwin') { + parent.on('focus', focus) + parent.webContents.on('before-input-event', blockInput) + window.once('closed', () => { + parent.off('focus', focus) + parent.webContents.off('before-input-event', blockInput) + }) + } const follow = (): void => { if (!window.isDestroyed()) window.setBounds(parent.getContentBounds()) } parent.on('move', follow) parent.on('resize', follow) @@ -39,7 +51,7 @@ export function createUpdateOverlay(parent: BrowserWindow, preload: string, titl /** A native Windows modal retains its own title bar while the product window remains blocked. */ export function createMandatoryUpdateWindow(parent: BrowserWindow, preload: string, title: string, platform: NodeJS.Platform = process.platform): BrowserWindow { - if (platform !== 'win32') return createUpdateOverlay(parent, preload, title) + if (platform !== 'win32') return createUpdateOverlay(parent, preload, title, false) const window = new BrowserWindow({ parent, modal: true, show: false, title, width: 640, height: 560, minWidth: 480, minHeight: 360, diff --git a/apps/desktop/tests/fixtures/update-dialogs.mjs b/apps/desktop/tests/fixtures/update-dialogs.mjs index 3626bcfcad..e748f54db7 100644 --- a/apps/desktop/tests/fixtures/update-dialogs.mjs +++ b/apps/desktop/tests/fixtures/update-dialogs.mjs @@ -73,12 +73,14 @@ export async function qualifyUpdateDialogs(root, fixture) { const name = active ? 'update-active-tasks.png' : 'update-ready.png' await writeFile(join(root, name), (await window.webContents.capturePage()).toPNG()) screenshots.push(name) + const closed = once(window, 'closed', { signal: AbortSignal.timeout(10_000) }) if (active) { await window.webContents.executeJavaScript("setTimeout(() => document.querySelector('.primary').click(), 0); undefined") } else { window.webContents.sendInputEvent({ type: 'keyDown', keyCode: 'Escape' }) } const result = await pending + await closed assert.equal(window.isDestroyed(), true) assert.equal(result.phase, active ? 'installing' : 'ready') assert.equal(f.installations.length, active ? 1 : 0) diff --git a/apps/desktop/tests/main-startup.spec.ts b/apps/desktop/tests/main-startup.spec.ts index 3df37cb4ae..d68d076971 100644 --- a/apps/desktop/tests/main-startup.spec.ts +++ b/apps/desktop/tests/main-startup.spec.ts @@ -289,7 +289,7 @@ beforeEach(() => { vi.spyOn(console, 'info').mockImplementation(() => {}) vi.stubEnv('DSH_DESKTOP_PNPM_ENTRY', 'test-pnpm') vi.stubEnv('DSH_DESKTOP_DSH_DIR', 'test-runtime') - vi.stubGlobal('process', { ...process, platform: 'win32', resourcesPath: 'desktop-test-resources' }) + vi.stubGlobal('process', { ...process, platform: 'win32', arch: 'x64', resourcesPath: 'desktop-test-resources' }) vi.stubEnv('DSH_DESKTOP_HOST_INSPECT_PORT', undefined) vi.stubEnv('DSH_DESKTOP_MANDATORY_UPDATE_CONFIG', undefined) vi.stubEnv('DSH_DESKTOP_UPDATE_JOURNAL_DIR', undefined) @@ -812,6 +812,8 @@ describe('desktop main startup', () => { expect(host.stop).not.toHaveBeenCalled() request.mockImplementationOnce(async () => Response.json({ code: 0, data: { biz_code: 0, biz_data: null } })) await vi.advanceTimersByTimeAsync(20_000) + expect(modal.isDestroyed()).toBe(false) + await vi.advanceTimersByTimeAsync(150) expect(modal.isDestroyed()).toBe(true) expect(host.stop).not.toHaveBeenCalled() expect(request.mock.calls[0]![1]!.headers).toMatchObject({ 'x-client-bundle-id': 'com.deepseek.dsh', 'x-client-version': '1.0.0' }) @@ -826,7 +828,10 @@ describe('desktop main startup', () => { harness.dialog.showMessageBox.mockImplementationOnce(({ signal }: { signal: AbortSignal }) => { checking.resolve(signal) return new Promise((resolve) => { signal.addEventListener('abort', () => { resolve({ response: 0 }) }, { once: true }) }) - }).mockResolvedValueOnce({ response: 0 }) + }).mockImplementationOnce(() => { + expect((harness.dialog.showMessageBox.mock.calls[0]![0] as { signal: AbortSignal }).signal.aborted).toBe(false) + return Promise.resolve({ response: 0 }) + }) const submenu = applicationMenuItems() const action = submenu.find(item => item.label === 'Check for Updates…') expect(action?.click).toBeTypeOf('function') diff --git a/apps/desktop/tests/mandatory-update-window.spec.ts b/apps/desktop/tests/mandatory-update-window.spec.ts index 4df1f34c69..84f9195122 100644 --- a/apps/desktop/tests/mandatory-update-window.spec.ts +++ b/apps/desktop/tests/mandatory-update-window.spec.ts @@ -24,7 +24,7 @@ function fakeWindow() { loadURL: vi.fn(async () => {}), destroy: vi.fn(), isDestroyed: () => false, isFocused: () => true, isMinimized: () => false, focus: vi.fn(), show: vi.fn(), restore: vi.fn() }) } -afterEach(() => { ui?.dispose(); vi.clearAllMocks(); native.handlers.clear() }) +afterEach(() => { ui?.dispose(); vi.clearAllMocks(); native.handlers.clear(); vi.useRealTimers() }) function setup() { window = fakeWindow() let policy: DesktopPolicyState = { blocking: true, checking: false, page: 'https://downloads.example.com/desktop' } @@ -130,3 +130,25 @@ it('exits the application when the mandatory window is closed without clearing t expect(native.quit).toHaveBeenCalledOnce() expect(f.view().policy.blocking).toBe(true) }) + +it('fades on clearance, reuses a reblocked window, and cancels teardown on disposal', () => { + vi.useFakeTimers() + const f = setup() + f.policy({ blocking: false, checking: false }) + expect(window.webContents.send).toHaveBeenLastCalledWith(MANDATORY_IPC.state, + f.view()) + vi.advanceTimersByTime(100) + expect(window.destroy).not.toHaveBeenCalled() + f.policy({ blocking: true, checking: false }) + vi.advanceTimersByTime(150) + expect(window.destroy).not.toHaveBeenCalled() + expect(window.loadURL).toHaveBeenCalledOnce() + f.policy({ blocking: false, checking: false }) + vi.advanceTimersByTime(150) + expect(window.destroy).toHaveBeenCalledOnce() + expect(ui!.confirmationWindow).toBeUndefined() + f.policy({ blocking: true, checking: false }) + f.policy({ blocking: false, checking: false }) + ui!.dispose() + expect(vi.getTimerCount()).toBe(0) +}) diff --git a/apps/desktop/tests/update-dialog.spec.ts b/apps/desktop/tests/update-dialog.spec.ts index 3c866c75c7..a163dd6e1a 100644 --- a/apps/desktop/tests/update-dialog.spec.ts +++ b/apps/desktop/tests/update-dialog.spec.ts @@ -10,11 +10,13 @@ const fixture = await vi.hoisted(async () => { class FakeWindow extends EventEmitter { destroyed = false readonly webContents = Object.assign(new EventEmitter(), { + send: vi.fn(), mainFrame: { url: 'dsh-app://shell/update-dialog.html' }, setWindowOpenHandler: vi.fn(), insertCSS: vi.fn(async () => 'blur'), removeInsertedCSS: vi.fn(async () => {}), }) + readonly focus = vi.fn() readonly show = vi.fn() readonly setBounds = vi.fn() readonly loadURL = vi.fn(async () => {}) @@ -33,6 +35,7 @@ vi.mock('electron', () => ({ BrowserWindow: fixture.FakeWindow, ipcMain: { let dialogs: DesktopUpdateDialog | undefined afterEach(() => { + vi.useRealTimers() dialogs?.dispose() dialogs = undefined for (const window of fixture.windows) if (!window.isDestroyed()) window.destroy() @@ -48,7 +51,9 @@ function setup() { }) const invoke = (channel: string, ...args: unknown[]) => { const window = fixture.windows.at(-1)! - return fixture.handlers.get(channel)!({ sender: window.webContents, senderFrame: window.webContents.mainFrame }, ...args) + const event = { sender: window.webContents, senderFrame: window.webContents.mainFrame } + const view = fixture.handlers.get(UPDATE_DIALOG_IPC.status)!(event) as { revision: number } + return fixture.handlers.get(channel)!(event, ...(channel === UPDATE_DIALOG_IPC.respond ? [view.revision, ...args] : args)) } return { parent, show, invoke } } @@ -73,7 +78,7 @@ it('follows the parent geometry and removes listeners when closed or replaced', const f = setup() const first = f.show() const window = fixture.windows.at(-1)! - expect(window.options).toMatchObject({ frame: false, transparent: true, modal: true, width: 900, height: 650 }) + expect(window.options).toMatchObject({ frame: false, transparent: true, modal: process.platform !== 'darwin', width: 900, height: 650 }) window.emit('ready-to-show') expect(window.show).toHaveBeenCalledOnce() f.parent.emit('resize') @@ -85,7 +90,7 @@ it('follows the parent geometry and removes listeners when closed or replaced', expect((await next).response).toBe(1) expect(f.parent.listenerCount('resize')).toBe(0) expect(f.parent.listenerCount('move')).toBe(0) - expect(f.parent.webContents.removeInsertedCSS).toHaveBeenCalledTimes(2) + expect(f.parent.webContents.removeInsertedCSS).toHaveBeenCalledTimes(1) }) it('cancels on abort, renderer failure, disposal, or an already-closed parent', async () => { @@ -132,3 +137,57 @@ it('supplies localized disclosure copy without putting diagnostics in the ordina dialogs!.cancel() await pending }) + +it('replaces content without releasing the backdrop and rejects a response from the previous prompt', async () => { + const f = setup() + const abort = new AbortController() + const first = f.show(abort.signal) + const window = fixture.windows.at(-1)! + const old = f.invoke(UPDATE_DIALOG_IPC.status) as { revision: number } + const next = f.show() + expect((await first).response).toBe(1) + expect(fixture.windows).toHaveLength(2) + expect(window.loadURL).toHaveBeenCalledOnce() + expect(window.webContents.send.mock.calls).toEqual([[UPDATE_DIALOG_IPC.changed, f.invoke(UPDATE_DIALOG_IPC.status)]]) + expect(f.parent.webContents.removeInsertedCSS).not.toHaveBeenCalled() + abort.abort() + expect(() => fixture.handlers.get(UPDATE_DIALOG_IPC.respond)!({ + sender: window.webContents, senderFrame: window.webContents.mainFrame, + }, old.revision, 0)).toThrow(/stale/) + f.invoke(UPDATE_DIALOG_IPC.respond, 0) + expect((await next).response).toBe(0) +}) + +it('fades out before releasing the backdrop and cancels pending removal when another prompt opens', async () => { + vi.useFakeTimers() + const f = setup() + const first = f.show() + const window = fixture.windows.at(-1)! + dialogs!.cancel() + await first + expect(window.isDestroyed()).toBe(false) + expect(window.webContents.send).toHaveBeenLastCalledWith(UPDATE_DIALOG_IPC.changed, null) + const next = f.show() + await vi.advanceTimersByTimeAsync(150) + expect(window.isDestroyed()).toBe(false) + dialogs!.cancel() + await next + await vi.advanceTimersByTimeAsync(150) + expect(window.isDestroyed()).toBe(true) + expect(f.parent.listenerCount('focus')).toBe(0) + expect(f.parent.webContents.listenerCount('before-input-event')).toBe(0) +}) + +it.runIf(process.platform === 'darwin')('blocks parent keyboard input and redirects focus without a native sheet', async () => { + const f = setup() + const pending = f.show() + const window = fixture.windows.at(-1)! + const event = { preventDefault: vi.fn() } + f.parent.webContents.emit('before-input-event', event) + expect(event.preventDefault).toHaveBeenCalledOnce() + f.parent.emit('focus') + expect(window.focus).toHaveBeenCalledTimes(2) + dialogs!.dispose() + await pending + expect(f.parent.webContents.listenerCount('before-input-event')).toBe(0) +}) diff --git a/apps/desktop/tests/update-error-renderer.spec.ts b/apps/desktop/tests/update-error-renderer.spec.ts index e8b925cf2f..ad92f2df8f 100644 --- a/apps/desktop/tests/update-error-renderer.spec.ts +++ b/apps/desktop/tests/update-error-renderer.spec.ts @@ -25,12 +25,13 @@ function page(name: string) { it.each(['en', 'zh-CN'])('keeps ordinary diagnostics folded, text-only, and keyboard-accessible: %s', async (language) => { const p = page('update-dialog') const locale = resolveDesktopLocale(language) - const state: UpdateDialogView = { locale: locale.id, title: locale.messages.updateFailedTitle, + const state: UpdateDialogView = { revision: 1, locale: locale.id, title: locale.messages.updateFailedTitle, message: locale.messages.updateStopFailed, detail: '', buttons: [locale.messages.updateAcknowledge], cancelId: 0, closeLabel: locale.messages.updateClose, technicalDetailsLabel: locale.messages.updateTechnicalDetails, technicalDetails: '\nexit 0; shutdown acknowledged false' } const respond = vi.fn(async () => {}) - const api: UpdateDialogApi = { status: async () => state, respond } + let publish!: (view: UpdateDialogView | null) => void + const api: UpdateDialogApi = { status: async () => state, respond, subscribe: (listener) => { publish = listener; return () => {} } } Object.defineProperty(p.dom.window, 'dshUpdateDialog', { value: api }) p.run() await expect.poll(() => p.element('dialog').hidden).toBe(false) @@ -54,7 +55,24 @@ it.each(['en', 'zh-CN'])('keeps ordinary diagnostics folded, text-only, and keyb expect(disclosure.open).toBe(false) expect(respond).not.toHaveBeenCalled() p.document.dispatchEvent(new p.dom.window.KeyboardEvent('keydown', { key: 'Escape', cancelable: true })) - expect(respond).toHaveBeenCalledWith(0) + expect(respond).toHaveBeenCalledWith(1, 0) + const backdrop = p.document.body + const dialog = p.element('dialog') + dialog.scrollTop = 170 + publish({ ...state, revision: 2, message: locale.messages.updateChecking, technicalDetails: '', buttons: ['OK'] }) + expect(p.document.body).toBe(backdrop) + expect(p.element('dialog')).toBe(dialog) + expect(dialog.scrollTop).toBe(0) + expect(backdrop.classList.contains('visible')).toBe(true) + expect(p.element('actions').childElementCount).toBe(1) + expect(disclosure.open).toBe(false) + expect(p.document.activeElement).toBe(dialog) + dialog.scrollTop = 80 + publish(state) + expect(dialog.scrollTop).toBe(80) + expect(p.element('title').textContent).toBe(locale.messages.updateChecking) + publish(null) + expect(backdrop.classList.contains('visible')).toBe(false) }) it('keeps mandatory diagnostics expandable without clearing the block or authorizing installation', async () => { @@ -100,6 +118,11 @@ it('keeps mandatory diagnostics expandable without clearing the block or authori expect(disclosure.hidden).toBe(true) expect(p.element('error').hidden).toBe(true) expect(p.document.getElementById('quit')).toBeNull() + expect(p.document.body.classList.contains('visible')).toBe(true) + publish({ ...initial, policy: { blocking: false, checking: false } }) + expect(p.document.body.classList.contains('visible')).toBe(false) + publish(initial) + expect(p.document.body.classList.contains('visible')).toBe(true) p.dom.window.dispatchEvent(new p.dom.window.Event('pagehide')) expect(unsubscribe).toHaveBeenCalledOnce() }) diff --git a/apps/desktop/tests/update-overlay.spec.ts b/apps/desktop/tests/update-overlay.spec.ts index 712163265b..97bb5cc266 100644 --- a/apps/desktop/tests/update-overlay.spec.ts +++ b/apps/desktop/tests/update-overlay.spec.ts @@ -25,3 +25,28 @@ it('gives the Windows mandatory modal native move, resize, and maximize controls expect(window.show).toHaveBeenCalledOnce() expect(window.webContents.setWindowOpenHandler).toHaveBeenCalledOnce() }) + +it('keeps the macOS mandatory overlay stationary and blocks parent keyboard input until close', () => { + const platform = vi.spyOn(process, 'platform', 'get').mockReturnValue('darwin') + const parent = Object.assign(new EventEmitter(), { + getContentBounds: () => ({ x: 0, y: 0, width: 1000, height: 700 }), + webContents: Object.assign(new EventEmitter(), { insertCSS: vi.fn(async () => 'blur'), removeInsertedCSS: vi.fn(async () => {}) }), + isDestroyed: () => false, + }) + const window = Object.assign(new EventEmitter(), { + webContents: { setWindowOpenHandler: vi.fn() }, show: vi.fn(), focus: vi.fn(), + setMenu: vi.fn(), setBounds: vi.fn(), isDestroyed: () => false, + }) + native.create.mockReturnValue(window) + try { + createMandatoryUpdateWindow(parent as unknown as BrowserWindow, 'owned', 'Update required', 'darwin') + expect(native.create).toHaveBeenLastCalledWith(expect.objectContaining({ modal: false, transparent: true, frame: false })) + const event = { preventDefault: vi.fn() } + parent.webContents.emit('before-input-event', event) + expect(event.preventDefault).toHaveBeenCalledOnce() + expect(window.focus).toHaveBeenCalledOnce() + window.emit('closed') + expect(parent.listenerCount('focus')).toBe(0) + expect(parent.webContents.listenerCount('before-input-event')).toBe(0) + } finally { platform.mockRestore() } +}) diff --git a/apps/web/tests/agent-team-panel.e2e.ts b/apps/web/tests/agent-team-panel.e2e.ts index b3192c3655..ac4e484db0 100644 --- a/apps/web/tests/agent-team-panel.e2e.ts +++ b/apps/web/tests/agent-team-panel.e2e.ts @@ -1,4 +1,4 @@ -// Keyless assembled-browser coverage for the opt-in Agent Teams Web profiles +// Keyless assembled-browser coverage for the opt-in Agent Teams bundle // over the real Host Typert Remote flow. import { fileURLToPath } from 'node:url' import { join } from 'node:path' @@ -18,11 +18,9 @@ import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './suppor const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/agent-team-panel', import.meta.url)) const PANEL_EXPECTED = join(SNAPSHOT_DIR, 'task.expected.md') const OVERLAY = fileURLToPath(new URL('./agent-team-panel.overlay.yml', import.meta.url)) -const HOST_PATCH = fileURLToPath(new URL('../../../packages/experimental/agent-team-profile/cordis.patch.yml', import.meta.url)) -const WEB_PATCH = fileURLToPath(new URL('../../../packages/experimental/agent-team-web-profile/cordis.patch.yml', import.meta.url)) +const TEAM_PATCH = fileURLToPath(new URL('../../../packages/experimental/agent-team-profile/cordis.patch.yml', import.meta.url)) const INSTALL_ANCHORS = [ fileURLToPath(new URL('../../../packages/experimental/agent-team-profile/package.json', import.meta.url)), - fileURLToPath(new URL('../../../packages/experimental/agent-team-web-profile/package.json', import.meta.url)), ] const MODE = webSnapshotMode() @@ -33,11 +31,8 @@ function profileEntries(path: string): unknown[] { } describe('Agent Teams panel overlay', () => { - it('matches the shipped Host and Web profile layers', () => { - expect(profileEntries(OVERLAY)).toEqual([ - ...profileEntries(HOST_PATCH), - ...profileEntries(WEB_PATCH), - ]) + it('matches the shipped Agent Teams bundle', () => { + expect(profileEntries(OVERLAY)).toEqual(profileEntries(TEAM_PATCH)) }) }) diff --git a/apps/web/tests/agent-team-panel.overlay.yml b/apps/web/tests/agent-team-panel.overlay.yml index cb6dd2decd..20c49c23a1 100644 --- a/apps/web/tests/agent-team-panel.overlay.yml +++ b/apps/web/tests/agent-team-panel.overlay.yml @@ -1,5 +1,4 @@ -# Equivalent to the two explicit Agent Teams profile layers over the scaffold's -# stable base and Web bundles. +# Equivalent to the Agent Teams bundle over the stable base and Web bundles. - id: tool-subagent-control disabled: true @@ -28,6 +27,5 @@ freshProvider: spawn forkProvider: fork -- insert: - id: ui-agent-team name: '@deepseek-ai/dsh-experimental-client-ui-agent-team' diff --git a/apps/web/tests/chat-long-interactions.e2e.ts b/apps/web/tests/chat-long-interactions.e2e.ts index 17e639814c..33c8046779 100644 --- a/apps/web/tests/chat-long-interactions.e2e.ts +++ b/apps/web/tests/chat-long-interactions.e2e.ts @@ -198,35 +198,37 @@ describe('web e2e: long Chat interaction contract', () => { const turnNavigation = page.getByRole('navigation', { name: 'Turn navigation' }) await turnNavigation.waitFor({ state: 'visible', timeout: 15_000 }) - // The whole-log outline offers every fixture turn before any paging, with - // the live tail mark current. const marks = turnNavigation.getByRole('button') - await expect.poll(() => marks.count(), { timeout: 15_000 }).toBe(FIXTURE_TURNS) - expect(await marks.last().getAttribute('aria-current')).toBe('true') + await expect.poll(() => marks.last().getAttribute('aria-current'), { timeout: 15_000 }).toBe('true') + expect(await marks.count()).toBeLessThan(FIXTURE_TURNS) // The oldest turn is an unloaded mark whose outline preview already // carries both the prompt and the settled response. const firstTurnButton = turnNavigation .getByRole('button', { name: 'Load and jump to turn 1', exact: true }) + const railScroller = turnNavigation.locator('[class*="scroller"]') + await railScroller.hover() + await page.mouse.wheel(0, -FIXTURE_TURNS * 10) + await expect.poll(() => railScroller.evaluate(element => element.scrollTop)).toBe(0) await firstTurnButton.focus() const preview = page.getByRole('tooltip') await preview.waitFor({ state: 'visible', timeout: 5_000 }) - expect(await preview.textContent()).toContain(FIXTURE.markers.user(1)) + await expect.poll(() => preview.textContent(), { timeout: 5_000 }).toContain(FIXTURE.markers.user(1)) expect(await preview.textContent()).toContain(FIXTURE.markers.assistant(1)) - const firstTurnPosition = await firstTurnButton.evaluate(button => ( - button.parentElement?.style.getPropertyValue('--turn-natural-position') ?? '' + const markPitch = () => turnNavigation.getByRole('button').evaluateAll(buttons => ( + buttons[1]!.getBoundingClientRect().top - buttons[0]!.getBoundingClientRect().top )) - expect(firstTurnPosition).toBe('0px') + expect(await markPitch()).toBe(10) const loadEarlier = page.getByRole('button', { name: 'Load earlier', exact: true }) - const loadedMarks = turnNavigation.getByRole('button', { name: /^Jump to turn / }) - const loadedBefore = await loadedMarks.count() + const loadedRows = page.locator('[data-chat-flow-key]') + const loadedBefore = await loadedRows.count() await loadEarlier.click() - // Paging converts marks to their loaded form without moving the - // fixed-pitch ladder. - await expect.poll(() => loadedMarks.count(), { timeout: 15_000 }).toBeGreaterThan(loadedBefore) - expect(await firstTurnButton.evaluate(button => ( - button.parentElement?.style.getPropertyValue('--turn-natural-position') ?? '' - ))).toBe(firstTurnPosition) + await expect.poll(() => loadedRows.count(), { timeout: 15_000 }).toBeGreaterThan(loadedBefore) + await railScroller.hover() + await page.mouse.wheel(0, -FIXTURE_TURNS * 10) + await expect.poll(() => railScroller.evaluate(element => element.scrollTop)).toBe(0) + await firstTurnButton.waitFor({ state: 'visible' }) + expect(await markPitch()).toBe(10) // Activating the still-unloaded oldest mark pages the rest in and lands // on the turn's own row. await firstTurnButton.focus() diff --git a/apps/web/tests/chat-scroll-contract.e2e.ts b/apps/web/tests/chat-scroll-contract.e2e.ts index 5c4eb17d93..af9ebc875f 100644 --- a/apps/web/tests/chat-scroll-contract.e2e.ts +++ b/apps/web/tests/chat-scroll-contract.e2e.ts @@ -587,7 +587,7 @@ describe('web e2e: long Chat scroll contract', () => { }) }, 180_000) - it.skipIf(MODE === 'record')('offers every outline turn on the rail and jumps to an unloaded one', async () => { + it.skipIf(MODE === 'record')('virtualizes the outline rail and jumps to an unloaded turn', async () => { await withScrollWorld({ failureShot: 'web-e2e-turn-rail-jump', seeds: [{ fixture: HISTORY_FIXTURE, id: RAIL_SESSION_ID }], @@ -595,31 +595,41 @@ describe('web e2e: long Chat scroll contract', () => { await openSeed(world.page, HISTORY_FIXTURE, HISTORY_FIXTURE.markers.assistant(HISTORY_FIXTURE.turns)) await expectBottom(world.page) - // The whole-log outline reaches the rail before any paging: one mark - // per fixture turn, the oldest still in its load-and-jump form. const rail = world.page.getByRole('navigation', { name: 'Turn navigation' }) - await expect.poll(() => rail.getByRole('button').count(), { timeout: 15_000 }) - .toBe(HISTORY_FIXTURE.turns) + const latest = rail.getByRole('button', { name: `Jump to turn ${String(HISTORY_FIXTURE.turns)}`, exact: true }) + await expect.poll(() => latest.getAttribute('aria-current'), { timeout: 15_000 }).toBe('true') + expect(await rail.getByRole('button').count()).toBeLessThan(HISTORY_FIXTURE.turns) const firstUnloaded = rail.getByRole('button', { name: 'Load and jump to turn 1', exact: true }) - expect(await firstUnloaded.count()).toBe(1) - // Fixed pitch: the ladder keeps its natural height, scrolls inside the - // frame, and (following the active tail mark) fades its upper end. - expect(await rail.evaluate(nav => nav.style.getPropertyValue('--turn-natural-height'))) - .toBe(`${String((HISTORY_FIXTURE.turns - 1) * 10 + 12)}px`) + expect(await firstUnloaded.count()).toBe(0) const railScroller = rail.locator('[class*="scroller"]') await expect.poll(() => railScroller.evaluate(el => el.scrollHeight > el.clientHeight)).toBe(true) await expect.poll(() => rail.locator('[class*="fadeTop"]').count(), { timeout: 15_000 }).toBe(1) + const bodyBeforeRailScroll = await scrollGeometry(world.page) + await railScroller.hover() + await world.page.mouse.wheel(0, -HISTORY_FIXTURE.turns * 10) + await expect.poll(() => railScroller.evaluate(element => element.scrollTop)).toBe(0) + await firstUnloaded.waitFor({ state: 'visible' }) + expect((await scrollGeometry(world.page)).scrollTop).toBe(bodyBeforeRailScroll.scrollTop) - // Activate the unloaded mark by keyboard: pointer input belongs to the - // rail frame, while each mark is the keyboard/AT destination. Focus - // first shows the outline-backed preview: prompt and settled response - // both travel ahead of the events. const beforeRows = await loadedFlowRows(world.page) await firstUnloaded.focus() const tooltip = world.page.getByRole('tooltip') await expect.poll(() => tooltip.count(), { timeout: 15_000 }).toBe(1) - expect(await tooltip.textContent()).toContain(HISTORY_FIXTURE.markers.user(1)) + await expect.poll(() => tooltip.textContent(), { timeout: 5_000 }).toContain(HISTORY_FIXTURE.markers.user(1)) expect(await tooltip.textContent()).toContain(HISTORY_FIXTURE.markers.assistant(1)) + const previewId = await tooltip.getAttribute('id') + const hoveredMark = () => rail.evaluate(element => element.querySelector('button:hover')?.dataset.index ?? null) + await expect.poll(hoveredMark).not.toBeNull() + const hoveredBefore = await hoveredMark() + await world.page.mouse.wheel(0, 80) + await expect.poll(() => railScroller.evaluate(element => element.scrollTop)).toBe(80) + await expect.poll(hoveredMark).toBe(String(Number(hoveredBefore) + 8)) + await nextPaint(world.page) + expect(await firstUnloaded.evaluate(element => document.activeElement === element)).toBe(true) + expect(await firstUnloaded.getAttribute('aria-describedby')).toBe(previewId) + expect(await tooltip.textContent()).toContain(HISTORY_FIXTURE.markers.user(1)) + await world.page.mouse.wheel(0, -80) + await expect.poll(() => railScroller.evaluate(element => element.scrollTop)).toBe(0) const transcriptLayers = await tooltip.evaluate((preview) => { const railSlot = preview.closest('nav')?.parentElement const codeBanner = document.querySelector('.md-code-block > :first-child') diff --git a/apps/web/tests/expected/cordis-history/ui.expected.md b/apps/web/tests/expected/cordis-history/ui.expected.md index 6e35dfef96..16ea029e39 100644 --- a/apps/web/tests/expected/cordis-history/ui.expected.md +++ b/apps/web/tests/expected/cordis-history/ui.expected.md @@ -16,7 +16,7 @@ - button "3 tool calls" [expanded] - button "Context injection @deepseek-ai/dsh-system-prompt" - button "Think I will inspect the current Session's dynamic Cordis Plugins before defining the snapshot Package." -- 'button "Tool call cordis_inspect_self · {}"' +- button "Inspect plugins 0 dynamic plugins" - button "Think No dynamic Plugins are present, so I will define the requested Host and Client Package." - button "Register Cordis Plugin snapshot noop does nothing, for the snapshot Ready" [expanded] - tablist "Plugin source": diff --git a/apps/web/tests/expected/plugin-config/official.expected.md b/apps/web/tests/expected/plugin-config/official.expected.md index bd0a2a97e9..78528ab7d5 100644 --- a/apps/web/tests/expected/plugin-config/official.expected.md +++ b/apps/web/tests/expected/plugin-config/official.expected.md @@ -3,16 +3,12 @@ - button "刷新" - button "添加插件" - heading "官方" [level=3] -- text: "6" +- text: "5" - list: - listitem: - button "查看 智能体团队": 智能体团队 - - text: Beta 启用智能体团队协作与团队工具。 + - text: Beta 启用团队协作、团队工具、成员列表和共享任务看板。 - switch "启用 智能体团队" - - listitem: - - button "查看 智能体团队 Web 界面": 智能体团队 Web 界面 - - text: Beta 在浏览器中查看团队成员、任务看板和成员会话。 - - switch "启用 智能体团队 Web 界面" - listitem: - button "查看 终端": 终端 - text: 限制 agent 运行的每一条命令。 diff --git a/apps/web/tests/expected/plugin-manager/live-enabled.expected.md b/apps/web/tests/expected/plugin-manager/live-enabled.expected.md index 16fe2ceeab..d0f1b2a24e 100644 --- a/apps/web/tests/expected/plugin-manager/live-enabled.expected.md +++ b/apps/web/tests/expected/plugin-manager/live-enabled.expected.md @@ -3,16 +3,12 @@ - button "刷新" - button "添加插件" - heading "官方" [level=3] -- text: "6" +- text: "5" - list: - listitem: - button "查看 智能体团队": 智能体团队 - - text: Beta 启用智能体团队协作与团队工具。 + - text: Beta 启用团队协作、团队工具、成员列表和共享任务看板。 - switch "启用 智能体团队" - - listitem: - - button "查看 智能体团队 Web 界面": 智能体团队 Web 界面 - - text: Beta 在浏览器中查看团队成员、任务看板和成员会话。 - - switch "启用 智能体团队 Web 界面" - listitem: - button "查看 终端": 终端 - text: 限制 agent 运行的每一条命令。 diff --git a/apps/web/tests/expected/plugin-manager/manager.expected.md b/apps/web/tests/expected/plugin-manager/manager.expected.md index 5c7e0abece..7be62ce234 100644 --- a/apps/web/tests/expected/plugin-manager/manager.expected.md +++ b/apps/web/tests/expected/plugin-manager/manager.expected.md @@ -3,16 +3,12 @@ - button "刷新" - button "添加插件" - heading "官方" [level=3] -- text: "6" +- text: "5" - list: - listitem: - button "查看 智能体团队": 智能体团队 - - text: Beta 启用智能体团队协作与团队工具。 + - text: Beta 启用团队协作、团队工具、成员列表和共享任务看板。 - switch "启用 智能体团队" - - listitem: - - button "查看 智能体团队 Web 界面": 智能体团队 Web 界面 - - text: Beta 在浏览器中查看团队成员、任务看板和成员会话。 - - switch "启用 智能体团队 Web 界面" - listitem: - button "查看 终端": 终端 - text: 限制 agent 运行的每一条命令。 diff --git a/apps/web/tests/lifecycle-chrome.e2e.ts b/apps/web/tests/lifecycle-chrome.e2e.ts index 818aa80ba2..2948072fd0 100644 --- a/apps/web/tests/lifecycle-chrome.e2e.ts +++ b/apps/web/tests/lifecycle-chrome.e2e.ts @@ -23,7 +23,8 @@ import { launchWebScaffold, recordFixture, watchConsole, webSnapshotMode, type WebScaffold, } from './scaffold.ts' import { - connectFreshWorkspace, expandOwningTurnProcess, newEnglishPage, saveFailureShot, writeComposerDraft, ZH_BROWSER_LOCALE, + connectFreshWorkspace, connectFreshWorkspaceZh, expandOwningTurnProcess, newEnglishPage, saveFailureShot, + writeComposerDraft, ZH_BROWSER_LOCALE, } from './support.ts' const SNAPSHOT_DIR = fileURLToPath(new URL('../../../snapshots/web/lifecycle-chrome', import.meta.url)) @@ -34,6 +35,7 @@ const COMMAND_MENU_EXPECTED = join(SNAPSHOT_DIR, 'command-menu.expected.md') const COMMAND_MENU_ZH_EXPECTED = join(SNAPSHOT_DIR, 'command-menu-zh.expected.md') const FUZZY_COMMAND_MENU_EXPECTED = join(SNAPSHOT_DIR, 'command-menu-fuzzy.expected.md') const PLAN_ACTIVE_EXPECTED = join(SNAPSHOT_DIR, 'plan-active.expected.md') +const PLAN_ACTIVE_ZH_EXPECTED = join(SNAPSHOT_DIR, 'plan-active-zh.expected.md') const CONNECTION_ERROR_EXPECTED = join(SNAPSHOT_DIR, 'connection-error.expected.md') // Post-reload golden: the same settled conversation rebuilt purely from // persistence + history — byte-equal rendering is exactly the recovery claim. @@ -153,7 +155,7 @@ describe('web e2e: lifecycle & chrome (workspace flow / reload / dark mode)', () await inputPage.keyboard.insertText('这是任务') await expect.poll(() => input.textContent()).toBe(`${token} 这是任务`) for (let i = 0; i < 5; i++) await input.press('Backspace') - const tokenText = () => input.locator('[data-lexical-text][style*="warn-label"]').textContent() + const tokenText = () => input.locator('[data-lexical-text][style*="business-primary"]').textContent() await expect.poll(() => input.textContent()).toBe(token) await expect.poll(() => input.getAttribute('data-phase')).toBe('claimed') await expect.poll(tokenText).toBe(token) @@ -195,7 +197,7 @@ describe('web e2e: lifecycle & chrome (workspace flow / reload / dark mode)', () } }) - it.skipIf(MODE === 'record')('shows active Plan as the warn-state status action', async () => { + it.skipIf(MODE === 'record')('shows active Plan as the business-state status action', async () => { const activeScaffold = await launchWebScaffold() const activePage = await newEnglishPage(browser) const activeTripwire = watchConsole(activePage) @@ -220,14 +222,15 @@ describe('web e2e: lifecycle & chrome (workspace flow / reload / dark mode)', () await compareOrRefreshGolden(PLAN_ACTIVE_EXPECTED, planSnapshot, MODE) const planStyle = await planButton.evaluate((element) => { const probe = document.createElement('span') - probe.style.color = 'var(--dsw-alias-state-warn-label)' - probe.style.backgroundColor = 'var(--dsw-alias-state-warn-tertiary)' + probe.style.color = 'var(--dsw-alias-state-business-primary)' + probe.style.backgroundColor = 'var(--dsw-alias-state-business-tertiary)' document.body.append(probe) const actual = getComputedStyle(element) const reference = getComputedStyle(probe) const result = { color: actual.color, backgroundColor: actual.backgroundColor, + height: actual.height, borderRadius: actual.borderRadius, fontSize: actual.fontSize, referenceColor: reference.color, @@ -238,6 +241,7 @@ describe('web e2e: lifecycle & chrome (workspace flow / reload / dark mode)', () }) expect(planStyle.color).toBe(planStyle.referenceColor) expect(planStyle.backgroundColor).toBe(planStyle.referenceBackgroundColor) + expect(planStyle.height).toBe('28px') expect(planStyle.borderRadius).toBe('999px') expect(planStyle.fontSize).toBe('13px') await planButton.click() @@ -253,6 +257,39 @@ describe('web e2e: lifecycle & chrome (workspace flow / reload / dark mode)', () } }) + it.skipIf(MODE === 'record')('shows the active Plan chip with the Chinese copy', async () => { + const zhScaffold = await launchWebScaffold() + const zhPage = await browser.newPage({ viewport: { width: 1680, height: 1000 }, locale: ZH_BROWSER_LOCALE }) + const zhTripwire = watchConsole(zhPage) + try { + await zhPage.goto(zhScaffold.authenticatedUrl, { waitUntil: 'load' }) + await zhPage.waitForSelector('[class*="frame"]', { timeout: 30_000 }) + await connectFreshWorkspaceZh(zhPage, zhScaffold.workspaceCwd) + const input = zhPage.locator('[data-composer-input]').first() + await zhPage.getByRole('button', { name: '添加文件或调用指令' }).click() + const menu = zhPage.getByRole('listbox', { name: '触发候选建议' }) + await menu.waitFor({ timeout: 10_000 }) + await menu.getByRole('option', { name: '计划 plan 进入或退出计划模式', exact: true }).click() + await expect.poll(() => input.textContent()).toBe('/计划 ') + await input.press('Enter') + const planButton = zhPage.getByRole('button', { name: '计划模式已开启,按下关闭' }) + await planButton.waitFor({ timeout: 10_000 }) + await expect.poll(() => input.textContent(), { timeout: 10_000 }).toBe('') + const planSnapshot = await captureStableAria(zhPage, '[class*="frame"]', zhScaffold.workspaceCwd) + await compareOrRefreshGolden(PLAN_ACTIVE_ZH_EXPECTED, planSnapshot, MODE) + await planButton.click() + await expect.poll(() => planButton.count()).toBe(0) + expect(zhTripwire.pageErrors).toEqual([]) + expect(zhTripwire.warnings).toEqual([]) + } catch (error) { + await saveFailureShot(zhPage, 'web-e2e-plan-active-zh').catch(() => undefined) + throw error + } finally { + await zhPage.close() + await zhScaffold.close() + } + }) + it('sends the first prompt from the empty-state hero (all modes)', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-lifecycle-send')) if (MODE !== 'record') { @@ -564,7 +601,7 @@ describe('web e2e: lifecycle & chrome (workspace flow / reload / dark mode)', () await assertFixtureInventory(SNAPSHOT_DIR, [ 'session.v3.jsonl', 'replay.override.json', 'command-menu.expected.md', 'command-menu-fuzzy.expected.md', 'command-menu-zh.expected.md', 'connection-error.expected.md', - 'hero.expected.md', 'plan-active.expected.md', + 'hero.expected.md', 'plan-active.expected.md', 'plan-active-zh.expected.md', 'reloaded.expected.md', 'reloaded-expanded.expected.md', ]) }) diff --git a/apps/web/tests/plugin-config.e2e.ts b/apps/web/tests/plugin-config.e2e.ts index db20eed409..6327a0f68f 100644 --- a/apps/web/tests/plugin-config.e2e.ts +++ b/apps/web/tests/plugin-config.e2e.ts @@ -86,10 +86,10 @@ describe('web e2e: plugin configuration pages', () => { // Every page the shipped web composition exposes: the shell executor, the // agent loop, subagent selection, and the DeepSeek search provider, after - // the two official bundles the installation ships switched off. + // the official Team bundle the installation ships switched off. await panel.getByRole('button', { name: '查看 网页搜索', exact: true }).waitFor({ timeout: 20_000 }) const official = panel.locator('[data-plugin-group="official"]') - expect(await official.locator('[data-plugin-package]').count()).toBe(2) + expect(await official.locator('[data-plugin-package]').count()).toBe(1) expect(await official.locator('[data-plugin-item]').count()).toBe(4) for (const title of ['终端', 'Agent 循环', 'Subagent', '网页搜索']) { expect(await official.getByRole('button', { name: `查看 ${title}`, exact: true }).count()).toBe(1) @@ -177,16 +177,16 @@ describe('web e2e: plugin configuration pages', () => { const save = panel.getByRole('button', { name: '保存', exact: true }) await save.click() - await expect.poll(async () => (await settingsDocument()).includes('subagent-model-selection:'), { timeout: 10_000 }) - .toBe(true) - expect(await settingsDocument()).toContain('maxDepth: 2') - expect(await settingsDocument()).toContain('enabled: true') - expect(await settingsDocument()).toContain('allowedModels:') - expect(await settingsDocument()).toContain('provider:') - expect(await settingsDocument()).toContain('model:') - // The page stays open once the save landed; a settled form offers no save to repeat. + // The Save label returns only after both namespace controllers settle. + await expect.poll(() => save.isDisabled(), { timeout: 10_000 }).toBe(true) + const saved = await settingsDocument() + expect(saved).toContain('subagent-model-selection:') + expect(saved).toContain('maxDepth: 2') + expect(saved).toContain('enabled: true') + expect(saved).toContain('allowedModels:') + expect(saved).toContain('provider:') + expect(saved).toContain('model:') await expect.poll(() => toggle.getAttribute('aria-checked'), { timeout: 5_000 }).toBe('true') - await expect.poll(() => save.isDisabled(), { timeout: 5_000 }).toBe(true) await toggle.click() await save.click() diff --git a/apps/web/tests/plugin-manager.e2e.ts b/apps/web/tests/plugin-manager.e2e.ts index c8a07c1d79..9fa3c09feb 100644 --- a/apps/web/tests/plugin-manager.e2e.ts +++ b/apps/web/tests/plugin-manager.e2e.ts @@ -1,18 +1,20 @@ // Web e2e scenario: the plugin manager page behind the sidebar's Plugins entry over a // managed scaffold profile: installed bundles, their rows, and bundle enablement. Zero -// model calls: everything is client state plus the profile files and the settings +// model calls: everything is client state, seeded Session state, profile files, and the settings // document, so there is no fixture and a stray stream would fail loud on the open llm seam. import { readFile } from 'node:fs/promises' import { fileURLToPath } from 'node:url' import type { Browser, Page } from 'playwright' import { chromium } from 'playwright' +import { FiberState } from '@deepseek-ai/cordis' +import { createUserMessage } from '@deepseek-ai/dsh-llm' import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest' import { join } from 'node:path' import { assertFixtureInventory, captureStableAria, compareOrRefreshGolden, launchWebScaffold, watchConsole, webSnapshotMode, type WebScaffold, } from './scaffold.ts' -import { ZH_BROWSER_LOCALE, saveFailureShot } from './support.ts' +import { ZH_BROWSER_LOCALE, connectFreshWorkspaceZh, saveFailureShot } from './support.ts' const SNAPSHOT_DIR = fileURLToPath(new URL('./expected/plugin-manager', import.meta.url)) const MANAGER_EXPECTED = join(SNAPSHOT_DIR, 'manager.expected.md') @@ -75,9 +77,9 @@ describe('web e2e: plugin manager', () => { // group, followed by the official plugins that registered their configuration, and its other bundles // stay off the page. expect(await panel.locator('[data-plugin-group="bundles"] [data-plugin-package]').count()).toBe(1) - expect(await panel.locator('[data-plugin-group="official"] [data-plugin-package]').count()).toBe(2) + expect(await panel.locator('[data-plugin-group="official"] [data-plugin-package]').count()).toBe(1) expect(await panel.locator('[data-plugin-group="official"] [data-plugin-item]').count()).toBe(4) - expect(await panel.getByText('Beta', { exact: true }).count()).toBe(2) + expect(await panel.getByText('Beta', { exact: true }).count()).toBe(1) // A bundle that is off still shows the rows its patch declares, without switches. await panel.getByRole('button', { name: '查看 bundle' }).click() await panel.locator('[data-plugin-row]', { hasText: 'fixture-row' }).waitFor({ timeout: 10_000 }) @@ -97,7 +99,7 @@ describe('web e2e: plugin manager', () => { await panel.getByRole('button', { name: '查看 智能体团队', exact: true }).click() const packageName = panel.locator('[data-plugin-name]') expect(await packageName.textContent()).toBe('@deepseek-ai/dsh-experimental-agent-team-profile') - expect(await panel.getByText('启用智能体团队协作与团队工具。').count()).toBe(1) + expect(await panel.getByText('启用团队协作、团队工具、成员列表和共享任务看板。').count()).toBe(1) try { await page.getByRole('button', { name: '设置', exact: true }).click() await page.getByRole('dialog', { name: '设置' }).getByRole('button', { name: '中文' }).click() @@ -106,13 +108,10 @@ describe('web e2e: plugin manager', () => { await page.keyboard.press('Escape') await panel.getByRole('heading', { name: 'Agent Teams', exact: true }).waitFor() expect(await packageName.textContent()).toBe('@deepseek-ai/dsh-experimental-agent-team-profile') - expect(await panel.getByText('Enable agent team collaboration and team tools.').count()).toBe(1) + expect(await panel.getByText('Enable team collaboration, team tools, the member roster, and the shared task board.').count()).toBe(1) await panel.getByRole('button', { name: 'Back to plugins' }).click() - for (const title of ['Agent Teams', 'Agent Teams Web UI']) { - await panel.getByRole('button', { name: `View ${title}`, exact: true }).waitFor() - expect(await panel.getByRole('switch', { name: `Enable ${title}`, exact: true }).count()).toBe(1) - } - expect(await panel.getByText('View team members, the task board, and teammate sessions in the browser.').count()).toBe(1) + await panel.getByRole('button', { name: 'View Agent Teams', exact: true }).waitFor() + expect(await panel.getByRole('switch', { name: 'Enable Agent Teams', exact: true }).count()).toBe(1) // The official configuration pages follow the language too, from their own dictionary. for (const title of ['Shell', 'Agent loop', 'Subagent', 'Web search']) { await panel.getByRole('button', { name: `View ${title}`, exact: true }).waitFor() @@ -132,6 +131,63 @@ describe('web e2e: plugin manager', () => { expect(tripwire.pageErrors).toEqual([]) }, 60_000) + it('enables the Team tools and browser plugin with one bundle switch', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-plugin-manager-team')) + const panel = await openPluginsPanel() + const toggle = panel.getByRole('switch', { name: '启用 智能体团队', exact: true }) + const teamRows = () => [...scaffold.ctx.loader.entries()] + .filter(entry => ['agent-team', 'tool-agent-team', 'ui-agent-team'].includes(entry.options.id)) + const teamPage = await browser.newPage({ viewport: { width: 1680, height: 1000 }, locale: ZH_BROWSER_LOCALE }) + const teamTripwire = watchConsole(teamPage) + try { + await teamPage.goto(scaffold.authenticatedUrl, { waitUntil: 'load' }) + await connectFreshWorkspaceZh(teamPage, scaffold.workspaceCwd) + const agent = scaffold.ctx.agents.list()[0] + if (agent === undefined) throw new Error('connected Team workspace did not create an Agent') + // Session actions render only after the conversation leaves its blank state. + agent.session.append('turn/start', { turn: 1 }) + agent.session.append('user/message', createUserMessage({ + content: [{ type: 'text', text: 'Team UI lifecycle' }], source: { kind: 'user' }, + }), { surfaceOp: 'append' }) + agent.session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) + await scaffold.ctx.sessions.flush(agent.session) + await teamPage.getByRole('button', { name: 'Team UI lifecycle', exact: true }).waitFor() + const action = teamPage.locator('[data-team-action]') + expect(await action.count()).toBe(0) + await toggle.click() + try { + await expect.poll(() => teamRows().filter(entry => entry.fiber?.state === FiberState.ACTIVE).length, { timeout: 20_000 }).toBe(3) + 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 manifest = JSON.parse(await homeFile('profiles', 'scaffold', 'package.json')) as { + dsh: { profile: { bundles: string[] } } + } + expect(manifest.dsh.profile.bundles).toEqual([ + '@deepseek-ai/dsh-base', '@deepseek-ai/dsh-web-app', '@deepseek-ai/dsh-experimental-agent-team-profile', + ]) + await panel.getByRole('button', { name: '查看 智能体团队', exact: true }).click() + for (const id of ['agent-team', 'tool-agent-team', 'ui-agent-team']) { + await panel.locator('[data-plugin-row]', { hasText: id }).first().waitFor() + } + await panel.getByRole('button', { name: '返回插件列表' }).click() + } finally { + const back = panel.getByRole('button', { name: '返回插件列表' }) + if (await back.count() > 0) await back.click() + if (await toggle.getAttribute('aria-checked') === 'true') await toggle.click() + await expect.poll(() => teamRows().filter(entry => entry.fiber?.state === FiberState.ACTIVE).length, { timeout: 20_000 }).toBe(0) + await expect.poll(() => action.count(), { timeout: 20_000 }).toBe(0) + } + expect(teamTripwire.pageErrors).toEqual([]) + expect(teamTripwire.warnings).toEqual([]) + } finally { + await teamPage.close() + } + expect(tripwire.pageErrors).toEqual([]) + }, 60_000) + it('checks a spec before installing it and words what the check refused', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-plugin-manager-install')) const panel = await openPluginsPanel() diff --git a/apps/web/tests/seeded-history.e2e.ts b/apps/web/tests/seeded-history.e2e.ts index 77d25398b5..b49bcd3c25 100644 --- a/apps/web/tests/seeded-history.e2e.ts +++ b/apps/web/tests/seeded-history.e2e.ts @@ -458,6 +458,23 @@ describe('web e2e: seeded history renders through cold resume', () => { await expect.poll(() => disclosure.getAttribute('aria-expanded')).toBe('false') }) + it.skipIf(MODE === 'record')('restores the active turn rail mark across Chat and Trajectory', async () => { + const rail = page.getByRole('navigation', { name: 'Turn navigation' }) + const current = rail.locator('[aria-current="true"]') + await current.waitFor({ state: 'visible' }) + const active = await current.getAttribute('aria-label') + const scroller = page.locator('[data-conversation-scroll]') + const top = await scroller.evaluate(element => element.scrollTop) + + await page.getByRole('tab', { name: 'Trajectory', exact: true }).click() + await page.getByLabel('Trajectory timeline', { exact: true }).waitFor({ state: 'visible' }) + await page.getByRole('tab', { name: 'Chat', exact: true }).click() + + await expect.poll(() => current.getAttribute('aria-label')).toBe(active) + await expect.poll(async () => Math.abs(await scroller.evaluate(element => element.scrollTop) - top)).toBeLessThanOrEqual(2) + expect(tripwire.pageErrors).toEqual([]) + }) + it.skipIf(MODE === 'record')('file-path tool rows rebuilt from the cold log open the right Sidebar', async () => { onTestFailed(async () => { await mkdir(fileURLToPath(new URL('../../../.artifacts/screenshots/0907-2205-sidebar', import.meta.url)), { recursive: true }) diff --git a/apps/web/tests/sessionless-header.e2e.ts b/apps/web/tests/sessionless-header.e2e.ts new file mode 100644 index 0000000000..f0ea28f32a --- /dev/null +++ b/apps/web/tests/sessionless-header.e2e.ts @@ -0,0 +1,60 @@ +/** Global navigation before the first Session, through the shipped Web composition. */ +import { chromium, type Browser } from 'playwright' +import { afterAll, beforeAll, describe, expect, it } from 'vitest' +import { launchWebScaffold, watchConsole, type WebScaffold } from './scaffold.ts' +import { newEnglishPage, saveFailureShot } from './support.ts' + +let scaffold: WebScaffold +let browser: Browser + +beforeAll(async () => { + scaffold = await launchWebScaffold() + browser = await chromium.launch() +}) + +afterAll(async () => { + try { await browser?.close() } + finally { await scaffold?.close() } +}) + +describe('navigation without a selected Session', () => { + it.each(['web', 'win32', 'linux', 'darwin'] as const)('%s preserves its empty-header geometry and sidebar access', async (platform) => { + const page = await newEnglishPage(browser) + try { + if (platform !== 'web') { + await page.addInitScript((value) => { + const mark = () => { document.documentElement.setAttribute('data-platform', value) } + if (document.documentElement === null) document.addEventListener('DOMContentLoaded', mark, { once: true }) + else mark() + }, platform) + } + const tripwire = watchConsole(page) + await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' }) + await page.getByRole('textbox', { name: 'Choose workspace', exact: true }).waitFor() + const sessionHeader = page.locator('[data-slot="conversation.session.header"]') + expect(await sessionHeader.count()).toBe(0) + const header = page.locator('[data-slot="conversation.header"] > header') + expect(await header.evaluate(element => element.getBoundingClientRect().height)).toBe(platform === 'darwin' ? 40 : 0) + await page.getByRole('button', { name: 'Collapse sidebar', exact: true }).click() + const reopen = platform === 'darwin' + ? page.locator('[data-conversation-header-leading]').getByRole('button', { name: 'Open sidebar', exact: true }) + : page.getByRole('button', { name: 'Open sidebar', exact: true }) + await reopen.waitFor({ state: 'visible' }) + expect(await sessionHeader.count()).toBe(0) + expect(await header.evaluate(element => element.getBoundingClientRect().height)).toBe(platform === 'darwin' ? 40 : 0) + if (platform === 'darwin') { + expect(await page.locator('[data-conversation-header-leading]').getByRole('button', { name: 'New session', exact: true }).isVisible()).toBe(true) + } + await reopen.click() + await page.getByRole('button', { name: 'Collapse sidebar', exact: true }).waitFor({ state: 'visible' }) + expect(await sessionHeader.count()).toBe(0) + expect(tripwire.pageErrors).toEqual([]) + expect(tripwire.warnings).toEqual([]) + } catch (error) { + await saveFailureShot(page, `sessionless-header-${platform}`) + throw error + } finally { + await page.close() + } + }) +}) 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 691a99c8e4..447a1f6858 100644 --- a/apps/web/tests/snapshots/agent-team-panel/task.expected.md +++ b/apps/web/tests/snapshots/agent-team-panel/task.expected.md @@ -4,7 +4,7 @@ - button "Refresh Team" - button "Close" - heading "Members" [level=3] - - 'button "lead Idle · Model: deepseek-v4-flash" [disabled]' + - 'button "lead Inactive · Model: deepseek-v4-flash" [disabled]' - heading "Shared tasks" [level=3] - button "New task" - article: diff --git a/apps/web/tests/tool-details.e2e.ts b/apps/web/tests/tool-details.e2e.ts new file mode 100644 index 0000000000..a1a84780e9 --- /dev/null +++ b/apps/web/tests/tool-details.e2e.ts @@ -0,0 +1,101 @@ +/** Authored Session rendering of compact recorded Tool results. */ +import { readFile } from 'node:fs/promises' +import { fileURLToPath } from 'node:url' +import type { Browser, Page } from 'playwright' +import { chromium } from 'playwright' +import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest' +import { + assertFixtureInventory, captureStableAria, compareOrRefreshGolden, fixtureUserPrompts, + launchWebScaffold, seedSession, watchConsole, webSnapshotMode, type WebScaffold, +} from './scaffold.ts' +import { expandOwningTurnProcess, newEnglishPage, saveFailureShot } from './support.ts' + +const SNAPSHOT_DIR = fileURLToPath(new URL('../../../snapshots/web/tool-details', import.meta.url)) +const FIXTURE = fileURLToPath(new URL('../../../snapshots/web/tool-details/session.v3.jsonl', import.meta.url)) +const UI_EXPECTED = fileURLToPath(new URL('../../../snapshots/web/tool-details/ui.expected.md', import.meta.url)) +const MODE = webSnapshotMode() +const SEED_ID = 'tool-details-web-e2e' +const PROMPT = 'Show a goal, a to-do list, and reminders.' + +describe.skipIf(MODE === 'record')('web e2e: compact Tool details', () => { + let scaffold: WebScaffold + let browser: Browser + let page: Page + let tripwire: ReturnType + + beforeAll(async () => { + const fixture = await readFile(FIXTURE, 'utf8') + expect(fixtureUserPrompts(fixture)).toEqual([PROMPT]) + scaffold = await launchWebScaffold({}) + await seedSession(scaffold, fixture, SEED_ID) + browser = await chromium.launch() + page = await newEnglishPage(browser) + tripwire = watchConsole(page) + await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' }) + await page.waitForSelector('[class*="frame"]', { timeout: 30_000 }) + + const groupRow = page.locator('[role="treeitem"]').first() + await groupRow.waitFor({ timeout: 15_000 }) + await groupRow.click() + const sessionRow = page.locator('[role="treeitem"]').nth(1) + await sessionRow.waitFor({ timeout: 10_000 }) + await sessionRow.click() + const skillRow = page.locator('[data-tool="create_goal"]') + await expandOwningTurnProcess(page, skillRow) + await skillRow.waitFor({ timeout: 15_000 }) + }, 120_000) + + afterAll(async () => { + try { await browser?.close() } finally { await scaffold?.close() } + }) + + it('expands every recorded tool through its keyed renderer', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-tool-details')) + const rows = page.locator('[data-tool]') + expect(await rows.count()).toBe(44) + for (const row of await rows.all()) { + await row.getByRole('button', { expanded: false }).first().click() + expect(await row.getByRole('listitem').count()).toBeGreaterThan(0) + expect(await row.locator('[class*="ioCard"]').count()).toBe(0) + expect(await row.getByRole('button', { name: 'Inspect', exact: true }).count()).toBe(1) + } + const changedTodos = page.locator('[data-tool="todo_write"]').nth(1) + await changedTodos.getByText('1 added · 1 updated · 1 removed', { exact: true }).waitFor() + for (const summary of await page.locator('[data-tool] details > summary').all()) await summary.click() + await page.locator('[data-tool="create_goal"]').getByText('Awaiting continuation', { exact: true }).waitFor() + const schema = page.locator('[data-tool="cordis_inspect_list"] pre').first() + await expect.poll(() => schema.textContent()).toContain('"properties"') + const snapshots = await Promise.all(Array.from({ length: 44 }, (_, index) => + captureStableAria(page, `[data-chat-call-id="details-call-${index + 1}"] [data-tool]`, scaffold.workspaceCwd))) + const snapshot = snapshots.join('\n') + await compareOrRefreshGolden(UI_EXPECTED, snapshot, MODE) + await page.setViewportSize({ width: 360, height: 800 }) + const card = page.locator('[data-tool="schedule_create"]') + expect(await card.evaluate(element => element.scrollWidth <= element.clientWidth)).toBe(true) + const reminders = page.locator('[data-tool="schedule_list"]') + await reminders.scrollIntoViewIfNeeded() + const list = reminders.getByRole('list').first() + await list.evaluate((element) => { + const second = element.children[1]! + element.scrollTop += second.getBoundingClientRect().top - element.getBoundingClientRect().top + }) + expect(await list.evaluate(element => element.scrollTop)).toBeGreaterThan(0) + const geometry = await reminders.evaluate((element) => { + const title = element.querySelector('ul')!.children[1]!.querySelector('[class*="heading"] > span')!.getBoundingClientRect() + const inspect = element.querySelector('[class*="inspectButton"]')!.getBoundingClientRect() + return { + verticalOverlap: Math.min(title.bottom, inspect.bottom) - Math.max(title.top, inspect.top), + horizontalGap: inspect.left - title.right, + } + }) + expect(geometry.verticalOverlap).toBeGreaterThan(0) + expect(geometry.horizontalGap).toBeGreaterThanOrEqual(0) + expect(tripwire.pageErrors).toEqual([]) + expect(tripwire.warnings).toEqual([]) + }, 60_000) + + it('keeps the snapshot inventory closed', async () => { + await assertFixtureInventory(SNAPSHOT_DIR, ['session.v3.jsonl', 'ui.expected.md']) + }) + +}) diff --git a/apps/web/tests/workflow-run.e2e.ts b/apps/web/tests/workflow-run.e2e.ts index 3f782c206f..fff5eab5d4 100644 --- a/apps/web/tests/workflow-run.e2e.ts +++ b/apps/web/tests/workflow-run.e2e.ts @@ -187,7 +187,7 @@ describe.skipIf(MODE === 'record')('web e2e: durable workflow run in Chat', () = expect(await terminalWorkflow.getAttribute('aria-expanded')).toBe('false') expect(await terminalWorkflow.evaluate(element => getComputedStyle(element).cursor)).toBe('pointer') await terminalWorkflow.click() - const terminalPhase = page.getByRole('button', { name: /^Run/ }) + const terminalPhase = page.locator('[data-workflow-run][data-run-status="completed"] [data-disclosure-row]').nth(1) await terminalPhase.waitFor() expect(await terminalPhase.getAttribute('aria-expanded')).toBe('false') expect(await terminalPhase.evaluate(element => getComputedStyle(element).cursor)).toBe('pointer') @@ -210,7 +210,7 @@ describe.skipIf(MODE === 'record')('web e2e: durable workflow run in Chat', () = const snapshot = await captureStableAria(page, '[data-chat-flow]', scaffold.workspaceCwd) await compareOrRefreshGolden(UI_EXPECTED, snapshot, MODE) await workflow.click() - const phase = page.getByRole('button', { name: /^Run/ }) + const phase = page.locator('[data-workflow-run][data-run-status="completed"] [data-disclosure-row]').nth(1) await phase.waitFor() expect(await phase.getAttribute('aria-expanded')).toBe('false') await phase.click() diff --git a/apps/web/tests/workspace-management.e2e.ts b/apps/web/tests/workspace-management.e2e.ts index bec77985d3..6f1a7f99db 100644 --- a/apps/web/tests/workspace-management.e2e.ts +++ b/apps/web/tests/workspace-management.e2e.ts @@ -17,7 +17,7 @@ import { join, sep } from 'node:path' import type { Browser, Locator, Page } from 'playwright' import { chromium } from 'playwright' import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest' -import { SessionId } from '@deepseek-ai/dsh-session' +import { SessionId, SessionLogOffset } from '@deepseek-ai/dsh-session' import { logPath } from '../../../packages/session/session-persistence-jsonl/src/format.ts' import { acknowledgeReloadConnectionLoss, assertFixtureInventory, captureStableAria, compareOrRefreshGolden, @@ -754,8 +754,57 @@ describe('web e2e: workspace management (create / rename / grouping / hover affo it.skipIf(MODE === 'record')('issued zero model calls and stayed clean', async () => { expect(tripwire.warnings).toEqual([]) - // The directory-browser aria golden is this spec's one owned artifact; - // the seed it reuses is owned (and inventory-guarded) by seeded-history. - await assertFixtureInventory(SNAPSHOT_DIR, ['.gitkeep', 'directory-browser.expected.md']) + await assertFixtureInventory(SNAPSHOT_DIR, [ + '.gitkeep', 'directory-browser.expected.md', 'new-session.expected.md', + ]) + }) +}) + +describe('web e2e: New Session after an outdated blank cache', () => { + it('opens a fresh conversation instead of reusing the recorded conversation', async () => { + const scaffold = await launchWebScaffold({}) + let browser: Browser | undefined + try { + const now = Date.UTC(2026, 8, 16) + const id = await seedSession(scaffold, await readFile(SEED, 'utf8'), 'new-session-stale-blank', + undefined, { createdAt: now - 60_000 }) + const stored = await scaffold.ctx.sessionPersistence.stat(id) + if (stored === undefined) throw new Error('seeded Session is missing') + // A durable log may advance after its last blank projection checkpoint. + // An unregistered Session writes only the checkpoint, leaving the persisted log intact. + const blank = scaffold.ctx.sessions.prepare(id, { + eventState: 'detached', seed: [], meta: stored.header, inheritedEventCount: SessionLogOffset(0), + }) + await scaffold.ctx.sessionProjectionCache.write(blank) + const workspace = await scaffold.ctx.workspaceRegistry.create(scaffold.workspaceCwd, 'New session regression') + await workspace.attachSession(id) + browser = await chromium.launch() + const page = await newEnglishPage(browser) + await page.clock.setFixedTime(now) + const tripwire = watchConsole(page) + onTestFailed(() => saveFailureShot(page, 'web-e2e-new-session-stale-blank')) + + await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' }) + await page.getByRole('tab', { name: 'Chat', exact: true }).waitFor() + const selected = page.locator('[role="treeitem"][aria-selected="true"]') + await expect.poll(() => selected.innerText()).not.toBe('New Session') + const previous = (await selected.innerText()).split('\n')[0]! + const before = await captureStableAria(page, '[role="tree"]', scaffold.workspaceCwd) + + await page.getByRole('button', { name: 'New session', exact: true }).last().click() + await expect.poll(() => selected.innerText()).toBe('New Session') + await page.getByRole('tab', { name: 'Chat', exact: true }).waitFor({ state: 'hidden' }) + await expect.poll(() => workspace.sessionIds.length).toBe(2) + expect(workspace.sessionIds).toContain(id) + await page.getByRole('treeitem').filter({ hasText: previous }).waitFor() + const after = await captureStableAria(page, '[role="tree"]', scaffold.workspaceCwd) + await compareOrRefreshGolden(join(SNAPSHOT_DIR, 'new-session.expected.md'), + `Before New Session\n${before}\nAfter New Session\n${after}`, MODE) + expect(tripwire.pageErrors).toEqual([]) + expect(tripwire.warnings).toEqual([]) + } finally { + await browser?.close() + await scaffold.close() + } }) }) diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json index d523d409fc..c0fffc637c 100644 --- a/apps/web/tsconfig.json +++ b/apps/web/tsconfig.json @@ -44,6 +44,7 @@ "tests/trajectory-virtualization.e2e.ts", "tests/lifecycle-chrome.e2e.ts", "tests/details-session-lifecycle.e2e.ts", + "tests/sessionless-header.e2e.ts", "tests/document-preview.e2e.ts", "tests/office-fixture.ts", "tests/sidebar-browser.e2e.ts", @@ -130,6 +131,7 @@ "tests/auto-review-denial.e2e.ts", "tests/bash-abort-row.e2e.ts", "tests/skill-tool-row.e2e.ts", + "tests/tool-details.e2e.ts", "tests/turn-tail-actions.e2e.ts", "tests/goal-multi-turn-actions.e2e.ts", "tests/chat-scroll-fixture.ts", diff --git a/docs/config-catalog.i18n.yaml b/docs/config-catalog.i18n.yaml index 3f58c2317d..b0b9fe31ca 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: d18d2b2d49f5096779dfabd74585ff7416e040f1 -config-catalog.zh.md: 84c18d71354c19d3ad841585af603fd58310f5c3 +config-catalog.md: 2310e11c352645d55bc8bc1bef62461bb6f255dd +config-catalog.zh.md: e2db885409146c70d2d92409d561f498881c3319 diff --git a/docs/config-catalog.md b/docs/config-catalog.md index d18d2b2d49..2310e11c35 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -3912,7 +3912,6 @@ Imported as libraries by other packages; a `cordis.yml` cannot load them. - `@deepseek-ai/dsh-cmdline` ([`packages/boot/cmdline/src/index.ts`](../packages/boot/cmdline/src/index.ts)) - `@deepseek-ai/dsh-deque` ([`packages/util/deque/src/index.ts`](../packages/util/deque/src/index.ts)) - `@deepseek-ai/dsh-experimental-agent-team-profile` ([`packages/experimental/agent-team-profile/src/index.ts`](../packages/experimental/agent-team-profile/src/index.ts)) -- `@deepseek-ai/dsh-experimental-agent-team-web-profile` ([`packages/experimental/agent-team-web-profile/src/index.ts`](../packages/experimental/agent-team-web-profile/src/index.ts)) - `@deepseek-ai/dsh-experimental-browser-use-runtime` ([`packages/experimental/browser-use-runtime/src/index.ts`](../packages/experimental/browser-use-runtime/src/index.ts)) - `@deepseek-ai/dsh-experimental-webworker-packer` ([`packages/experimental/webworker-packer/src/index.ts`](../packages/experimental/webworker-packer/src/index.ts)) - `@deepseek-ai/dsh-experimental-webworker-runtime` ([`packages/experimental/webworker-runtime/src/index.ts`](../packages/experimental/webworker-runtime/src/index.ts)) diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index 84c18d7135..e2db885409 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -3913,7 +3913,6 @@ export interface Config { - `@deepseek-ai/dsh-cmdline`([`packages/boot/cmdline/src/index.ts`](../packages/boot/cmdline/src/index.ts)) - `@deepseek-ai/dsh-deque`([`packages/util/deque/src/index.ts`](../packages/util/deque/src/index.ts)) - `@deepseek-ai/dsh-experimental-agent-team-profile`([`packages/experimental/agent-team-profile/src/index.ts`](../packages/experimental/agent-team-profile/src/index.ts)) -- `@deepseek-ai/dsh-experimental-agent-team-web-profile`([`packages/experimental/agent-team-web-profile/src/index.ts`](../packages/experimental/agent-team-web-profile/src/index.ts)) - `@deepseek-ai/dsh-experimental-browser-use-runtime` ([`packages/experimental/browser-use-runtime/src/index.ts`](../packages/experimental/browser-use-runtime/src/index.ts)) - `@deepseek-ai/dsh-experimental-webworker-packer`([`packages/experimental/webworker-packer/src/index.ts`](../packages/experimental/webworker-packer/src/index.ts)) - `@deepseek-ai/dsh-experimental-webworker-runtime`([`packages/experimental/webworker-runtime/src/index.ts`](../packages/experimental/webworker-runtime/src/index.ts)) diff --git a/docs/module-graph.i18n.yaml b/docs/module-graph.i18n.yaml index ff54d13bd5..78f2897d67 100644 --- a/docs/module-graph.i18n.yaml +++ b/docs/module-graph.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/module-graph.md -module-graph.md: a886619fa5cba78b2c243c525b8f2a173c1e8038 -module-graph.zh.md: 9c40a086df916cd638599176a95c649d6a2adc0d +module-graph.md: 2e2899c3a74e1d2c5fe50ab137408e7ed3beca82 +module-graph.zh.md: 49291ba55842462e5d532cce1de4f3803610d908 diff --git a/docs/module-graph.md b/docs/module-graph.md index a886619fa5..2e2899c3a7 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -231,7 +231,6 @@ flowchart TD subgraph group_experimental["packages/experimental"] pkg_experimental_agent_team["experimental-agent-team"] pkg_experimental_agent_team_profile["experimental-agent-team-profile"] - pkg_experimental_agent_team_web_profile["experimental-agent-team-web-profile"] pkg_experimental_auto_review["experimental-auto-review"] pkg_experimental_browser_use_chrome_devtools_mcp["experimental-browser-use-chrome-devtools-mcp"] pkg_experimental_browser_use_playwright_mcp["experimental-browser-use-playwright-mcp"] @@ -1382,7 +1381,6 @@ flowchart TD | [`client-web`](../packages/client/web) | `client` | — | | [`office-to-pdf`](../packages/document/office-to-pdf) | `document` | — | | [`experimental-agent-team-profile`](../packages/experimental/agent-team-profile) | `experimental` | — | -| [`experimental-agent-team-web-profile`](../packages/experimental/agent-team-web-profile) | `experimental` | — | | [`experimental-webworker-packer`](../packages/experimental/webworker-packer) | `experimental` | — | | [`client-ui-cordis`](../packages/extensions/ui-cordis) | `extensions` | — | | [`cordis-client-runner`](../packages/extensions/cordis-client-runner) | `extensions` | — | diff --git a/docs/module-graph.zh.md b/docs/module-graph.zh.md index 9c40a086df..49291ba558 100644 --- a/docs/module-graph.zh.md +++ b/docs/module-graph.zh.md @@ -233,7 +233,6 @@ flowchart TD subgraph group_experimental["packages/experimental"] pkg_experimental_agent_team["experimental-agent-team"] pkg_experimental_agent_team_profile["experimental-agent-team-profile"] - pkg_experimental_agent_team_web_profile["experimental-agent-team-web-profile"] pkg_experimental_auto_review["experimental-auto-review"] pkg_experimental_browser_use_chrome_devtools_mcp["experimental-browser-use-chrome-devtools-mcp"] pkg_experimental_browser_use_playwright_mcp["experimental-browser-use-playwright-mcp"] @@ -1384,7 +1383,6 @@ flowchart TD | [`client-web`](../packages/client/web) | `client` | — | | [`office-to-pdf`](../packages/document/office-to-pdf) | `document` | — | | [`experimental-agent-team-profile`](../packages/experimental/agent-team-profile) | `experimental` | — | -| [`experimental-agent-team-web-profile`](../packages/experimental/agent-team-web-profile) | `experimental` | — | | [`experimental-webworker-packer`](../packages/experimental/webworker-packer) | `experimental` | — | | [`client-ui-cordis`](../packages/extensions/ui-cordis) | `extensions` | — | | [`cordis-client-runner`](../packages/extensions/cordis-client-runner) | `extensions` | — | diff --git a/docs/subsystems/agent-team.i18n.yaml b/docs/subsystems/agent-team.i18n.yaml index 60b0f39970..6328a02541 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: 6f20ee11cbaf66028a5a3123d1682a0e9a87b014 -agent-team.zh.md: 5f2cfae582f275f25380cbe996f5bad711c0f917 +agent-team.md: 653bbbcad50d723690c013161a0c25eea301ff0b +agent-team.zh.md: e9ef2d5dc824a81d1c666d0ea7a270c0cfe2c92e diff --git a/docs/subsystems/agent-team.md b/docs/subsystems/agent-team.md index 6f20ee11cb..653bbbcad5 100644 --- a/docs/subsystems/agent-team.md +++ b/docs/subsystems/agent-team.md @@ -21,7 +21,7 @@ interface TeamMemberSnapshot { } ``` -Every member starts in `provisioning` and reaches exactly one terminal roster phase, `active` or `failed`. Runtime `running`/`idle`/`inactive` status is derived separately and never rewrites this record. +Every member starts in `provisioning` and reaches exactly one terminal roster phase, `active` or `failed`. Roster `running`/`inactive` status is derived separately and never rewrites this record. ## Durable mailbox @@ -38,7 +38,7 @@ interface TeamMessageSnapshot { } ``` -Every message attempts Steer delivery. A running target receives it at the nearest step boundary, an idle target starts a turn, and an inactive teammate cold-resumes. Scheduling is not stored in the durable record because callers cannot select another mode. +Every message attempts Steer delivery. A running target receives it at the nearest step boundary; an inactive target starts a turn if loaded or cold-resumes otherwise. Scheduling is not stored in the durable record because callers cannot select another mode. The target Session keeps message identity and sender attribution on both the pending inbox item and the eventual user message. Folding that source across inbox and history is the target-side de-duplication key; the model-visible framing repeats the id and sender. @@ -168,7 +168,7 @@ async waitForChange(caller: Agent, timeoutMs: number, signal: AbortSignal): Prom * @param targetName - durable teammate name. * @returns the target status sampled before cancellation. */ -interrupt(caller: Agent, targetName: string): { previousStatus: 'running' | 'idle' | 'inactive' } +interrupt(caller: Agent, targetName: string): { previousStatus: 'running' | 'inactive' } /** * Resolve a caller without throwing, used by scoped-tool installation and observers. diff --git a/docs/subsystems/agent-team.zh.md b/docs/subsystems/agent-team.zh.md index 5f2cfae582..e9ef2d5dc8 100644 --- a/docs/subsystems/agent-team.zh.md +++ b/docs/subsystems/agent-team.zh.md @@ -21,7 +21,7 @@ interface TeamMemberSnapshot { } ``` -每个 member 都从 `provisioning` 开始,并且只到达一个终态 roster phase:`active` 或 `failed`。运行时 `running`/`idle`/`inactive` 状态单独派生,绝不会重写该记录。 +每个 member 都从 `provisioning` 开始,并且只到达一个终态 roster phase:`active` 或 `failed`。roster 的 `running`/`inactive` 状态单独派生,绝不会重写该记录。 ## 持久 mailbox @@ -38,7 +38,7 @@ interface TeamMessageSnapshot { } ``` -每条消息都会尝试 Steer 投递。running target 在最近的步骤边界收到消息,idle target 启动一个轮次,inactive teammate 则冷恢复。调用方不能选择其他模式,因此持久记录不存储调度方式。 +每条消息都会尝试 Steer 投递。running target 在最近的步骤边界收到消息,inactive target 在已加载时启动一个轮次,否则冷恢复。调用方不能选择其他模式,因此持久记录不存储调度方式。 target Session 会在 pending inbox 条目和最终用户消息上保留消息身份与发送者归因。跨 inbox 与历史折叠该 source 构成 target 侧去重键;模型可见的 framing 会重复 id 和发送者。 @@ -168,7 +168,7 @@ async waitForChange(caller: Agent, timeoutMs: number, signal: AbortSignal): Prom * @param targetName - durable teammate name. * @returns the target status sampled before cancellation. */ -interrupt(caller: Agent, targetName: string): { previousStatus: 'running' | 'idle' | 'inactive' } +interrupt(caller: Agent, targetName: string): { previousStatus: 'running' | 'inactive' } /** * Resolve a caller without throwing, used by scoped-tool installation and observers. diff --git a/docs/subsystems/slots.i18n.yaml b/docs/subsystems/slots.i18n.yaml index 3851d2aeea..485e7af213 100644 --- a/docs/subsystems/slots.i18n.yaml +++ b/docs/subsystems/slots.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/subsystems/slots.md -slots.md: a9f0ee6ab2ef37065ab965a7989f5989350bca8b -slots.zh.md: da6a38eab6aac1c2670b131fa49a832ce41f711e +slots.md: 1b7dbd9e8ce81d298094df8a2adf7da39178541d +slots.zh.md: b852e3f204c0acea49f94cffcedf2eebad0e432e diff --git a/docs/subsystems/slots.md b/docs/subsystems/slots.md index a9f0ee6ab2..1b7dbd9e8c 100644 --- a/docs/subsystems/slots.md +++ b/docs/subsystems/slots.md @@ -144,12 +144,13 @@ root │ │ │ └─ tool.view.cordis │ │ ├─ conversation.message.images │ │ └─ conversation.trajectory.images -│ ├─ conversation.session.header -│ │ ├─ conversation.session.header.lineage -│ │ ├─ conversation.session.header.leading -│ │ ├─ conversation.session.header.actions -│ │ ├─ conversation.session.header.utilities -│ │ └─ conversation.session.header.corner +│ ├─ conversation.header +│ │ ├─ conversation.header.leading +│ │ └─ conversation.session.header +│ │ ├─ conversation.session.header.lineage +│ │ ├─ conversation.session.header.actions +│ │ ├─ conversation.session.header.utilities +│ │ └─ conversation.session.header.corner │ ├─ conversation.composer │ │ ├─ conversation.approval.detail │ │ └─ conversation.plan-review.actions diff --git a/docs/subsystems/slots.zh.md b/docs/subsystems/slots.zh.md index da6a38eab6..b852e3f204 100644 --- a/docs/subsystems/slots.zh.md +++ b/docs/subsystems/slots.zh.md @@ -144,12 +144,13 @@ root │ │ │ └─ tool.view.cordis │ │ ├─ conversation.message.images │ │ └─ conversation.trajectory.images -│ ├─ conversation.session.header -│ │ ├─ conversation.session.header.lineage -│ │ ├─ conversation.session.header.leading -│ │ ├─ conversation.session.header.actions -│ │ ├─ conversation.session.header.utilities -│ │ └─ conversation.session.header.corner +│ ├─ conversation.header +│ │ ├─ conversation.header.leading +│ │ └─ conversation.session.header +│ │ ├─ conversation.session.header.lineage +│ │ ├─ conversation.session.header.actions +│ │ ├─ conversation.session.header.utilities +│ │ └─ conversation.session.header.corner │ ├─ conversation.composer │ │ ├─ conversation.approval.detail │ │ └─ conversation.plan-review.actions diff --git a/docs/subsystems/subagent.i18n.yaml b/docs/subsystems/subagent.i18n.yaml index d998951e48..0cea3b7d31 100644 --- a/docs/subsystems/subagent.i18n.yaml +++ b/docs/subsystems/subagent.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/subsystems/subagent.md -subagent.md: 2bdb618f2196e5593fcdd4448504165a641fd23e -subagent.zh.md: 0fbde9283617cf04907f830cc8486aef284eab68 +subagent.md: 32c9d47c288a352ef234a0e555484d3826f6adf5 +subagent.zh.md: 2e39072f820e3e4c5afc2884babbde63e23af7df diff --git a/docs/subsystems/subagent.md b/docs/subsystems/subagent.md index 2bdb618f21..32c9d47c28 100644 --- a/docs/subsystems/subagent.md +++ b/docs/subsystems/subagent.md @@ -266,7 +266,7 @@ A local one-shot provider appends the descriptor inside the child's initial turn ## Durable enumeration: `listChildren()`, `listDescendants()`, and their entries -`SubagentRuntime.listChildren(parentSessionId)` enumerates the parent's direct session-backed subagents from the live-preferred merge of `ctx.sessions` and the session-query engine's `listSessions()` — no Agent is loaded or resumed. Candidates are the direct children whose durable header carries `origin: 'subagent'`; the marker classifies enumeration and coarse generic-route denial but cannot establish a valid descriptor, resumability, or authorization — the projection fold owns identity, and the Activation contract owns resume. Each row's `mode`/`label` is the registered `subagent` projection unit's value, served through a three-rung ladder: the registry's watermark cache for a live child (zero log reads); the optional projection checkpoint cache for a cold one (`cachedSnapshot` — an identity passing the own-suffix seq gate is final, because an own descriptor is immutable once appended); otherwise one `query.observeSession()` cold observation folded through the registry (bounded concurrency, recomputed per listing). The cache is a pure optional accelerator: absent, serving the `null` sentinel or missing the key, failing the seq gate, or faulting, it falls silently through to the authoritative refold. The fold is `subagent/descriptor` last-wins with no failure channel: the child's own descriptor overrides a fork-seeded ancestor's, and a malformed or unknown-version payload folds to a serializable `null` sentinel, treated as no value. The result is one `SubagentListEntry[]` in `createdAt`-then-id order: a served identity yields a `child` entry with `mode: 'one-shot' | 'continuable'` and `activity: 'running' | 'inactive'`; continuable entries always carry `label`, while one-shot entries carry it only when the start caller supplied presentation metadata. A settled candidate whose fold served no identity yields a `corrupt` diagnostic — missing, malformed, and unknown-version descriptors deliberately undistinguished (`unsupported` remains in the type but is never produced); a running candidate without an identity is omitted (the creation window before its descriptor lands); a failed cold inspection yields one `unavailable` diagnostic retried on the next listing, so one damaged sibling cannot hide healthy children. `hasChildren` marks a direct descendant with durable subagent origin, read from the same merged material. Activity snapshots only whether the logical record is live in `ctx.sessions`, not outcome or resumability. Absent persistence, enumeration is live-only rather than an error — a cold child cannot be resumed then either. `listChildren()` throws `SubagentError` with code `SUBAGENT_CONTROL_PROJECTIONS_UNAVAILABLE` when the `ctx.sessionProjections` registry is absent and `SUBAGENT_CONTROL_SESSION_STORE_UNAVAILABLE` when the session store is, both checked before any read so a deployment with zero children still fails deterministically; the list tool requires `ctx.subagents` and `ctx.agents` at plugin load. A service consumer such as a UI can display both modes and choose an unlabeled one-shot fallback, while the model-facing `list_agents` adapter (the separately loadable `/list-agents` plugin of [dsh-tool-subagent-control](../../packages/subagent/tool-subagent-control)) keeps only continuable entries and refines status through the live Agent registry into its own `running`/`idle`/`ready` vocabulary, whose `ready` names a storage-only child as resumable rather than terminal. Listing does not consult the continuation manager's Activation map, Agent registry, or provider availability; `send_message` remains the authoritative delivery-time operation, and a listed running continuable child may still reject delivery as an ownership conflict. [The archived list-identity-projection record](../../.agents/notes/archived/architecture/2026-08-06-subagent-list-identity-projection.md) documents the original read-path decision. +`SubagentRuntime.listChildren(parentSessionId)` enumerates the parent's direct session-backed subagents from the live-preferred merge of `ctx.sessions` and the session-query engine's `listSessions()` — no Agent is loaded or resumed. Candidates are the direct children whose durable header carries `origin: 'subagent'`; the marker classifies enumeration and coarse generic-route denial but cannot establish a valid descriptor, resumability, or authorization — the projection fold owns identity, and the Activation contract owns resume. Each row's `mode`/`label` is the registered `subagent` projection unit's value, served through a three-rung ladder: the registry's watermark cache for a live child (zero log reads); the optional projection checkpoint cache for a cold one (`cachedSnapshot` — an identity passing the own-suffix seq gate is final, because an own descriptor is immutable once appended); otherwise one `query.observeSession()` cold observation folded through the registry (bounded concurrency, recomputed per listing). The cache is a pure optional accelerator: absent, serving the `null` sentinel or missing the key, failing the seq gate, or faulting, it falls silently through to the authoritative refold. The fold is `subagent/descriptor` last-wins with no failure channel: the child's own descriptor overrides a fork-seeded ancestor's, and a malformed or unknown-version payload folds to a serializable `null` sentinel, treated as no value. The result is one `SubagentListEntry[]` in `createdAt`-then-id order: a served identity yields a `child` entry with `mode: 'one-shot' | 'continuable'` and `activity: 'running' | 'inactive'`; continuable entries always carry `label`, while one-shot entries carry it only when the start caller supplied presentation metadata. A settled candidate whose fold served no identity yields a `corrupt` diagnostic — missing, malformed, and unknown-version descriptors deliberately undistinguished (`unsupported` remains in the type but is never produced); a running candidate without an identity is omitted (the creation window before its descriptor lands); a failed cold inspection yields one `unavailable` diagnostic retried on the next listing, so one damaged sibling cannot hide healthy children. `hasChildren` marks a direct descendant with durable subagent origin, read from the same merged material. Activity snapshots only whether the logical record is live in `ctx.sessions`, not outcome or resumability. Absent persistence, enumeration is live-only rather than an error — a cold child cannot be resumed then either. `listChildren()` throws `SubagentError` with code `SUBAGENT_CONTROL_PROJECTIONS_UNAVAILABLE` when the `ctx.sessionProjections` registry is absent and `SUBAGENT_CONTROL_SESSION_STORE_UNAVAILABLE` when the session store is, both checked before any read so a deployment with zero children still fails deterministically; the list tool requires `ctx.subagents` and `ctx.agents` at plugin load. A service consumer such as a UI can display both modes and choose an unlabeled one-shot fallback, while the model-facing `list_agents` adapter (the separately loadable `/list-agents` plugin of [dsh-tool-subagent-control](../../packages/subagent/tool-subagent-control)) keeps only continuable entries and refines status through the live Agent registry into `running`/`inactive`; `inactive` includes loaded and storage-only children without describing task completion or outcome. Listing does not consult the continuation manager's Activation map, Agent registry, or provider availability; `send_message` remains the authoritative delivery-time operation, and a listed running continuable child may still reject delivery as an ownership conflict. [The archived list-identity-projection record](../../.agents/notes/archived/architecture/2026-08-06-subagent-list-identity-projection.md) documents the original read-path decision. `SubagentRuntime.listDescendants(rootSessionId)` applies the same live-preferred corpus and projection-backed interpretation to the root's complete descendant tree in stable pre-order. Ordinary sessions and one-shot children remain traversal nodes, so continuable descendants below them are discovered; only `origin: 'subagent'` candidates produce rows. Each returned child or diagnostic adds its position from the enumerated durable header, while a cold inspection revalidates that complete lifecycle before serving identity: diff --git a/docs/subsystems/subagent.zh.md b/docs/subsystems/subagent.zh.md index 0fbde92836..2e39072f82 100644 --- a/docs/subsystems/subagent.zh.md +++ b/docs/subsystems/subagent.zh.md @@ -266,7 +266,7 @@ interface ContinuableCreateSpec { ## 持久化枚举:`listChildren()`、`listDescendants()` 与其条目 -`SubagentRuntime.listChildren(parentSessionId)` 从 `ctx.sessions` 与会话查询引擎 `listSessions()` 的实时优先合并中枚举 parent 直接且由会话支撑的 subagent——不会加载或恢复任何 Agent。候选是持久 header 携带 `origin: 'subagent'` 的直接 child;该标记只负责枚举分类与粗粒度的通用路由拒绝,不能证明描述符有效、child 可恢复或操作已获授权——身份由投影折叠负责,恢复由 Activation 约定负责。每行的 `mode`/`label` 是已注册 `subagent` projection unit 的值,经三级阶梯供值:存活 child 由注册表水位缓存供值(零日志读取);冷 child 先读可选的投影 checkpoint 缓存(`cachedSnapshot`——过 own-suffix seq 门的身份即定值,own descriptor 一经追加不可变);否则在一次 `query.observeSession()` 冷观察上经注册表折叠(有界并发,每次列表重新计算)。该缓存是纯可选加速层:服务缺席、行里是 `null` 哨兵或 key 缺席、seq 门不过、读取出错,都静默落到权威重折。折叠规则是 `subagent/descriptor` last-wins 且没有失败通道:子 agent 自己的描述符覆盖 fork seed 中祖先的描述符,格式错误或版本不认识的载荷折叠为可序列化的 `null` 哨兵,视同无值。结果是按 `createdAt`、再按 id 排序的 `SubagentListEntry[]`:取到身份即生成带有 `mode: 'one-shot' | 'continuable'` 和 `activity: 'running' | 'inactive'` 的 `child` 条目;可继续条目始终携带 `label`,一次性条目则只在启动调用方提供展示元数据时携带该字段。已定局而折叠无身份的候选生成 `corrupt` diagnostic——缺失、格式错误与版本不认识的描述符有意不再细分(`unsupported` 仍保留在类型中但从不产出);运行中而无身份的候选被省略(描述符落盘前的创建窗口);冷检查失败生成一条 `unavailable` diagnostic 并在下次列表自然重试,因此一个损坏的 sibling 不会隐藏健康 child。`hasChildren` 标记存在持久 subagent origin 的直接后代,读取自同一份合并材料。活动状态只表示逻辑记录是否在 `ctx.sessions` 中存活,而不表示结果或可恢复性。缺少持久化时,枚举退化为仅存活枚举而不是报错——此时冷 child 本就无法恢复。缺少 `ctx.sessionProjections` 注册表时,`listChildren()` 抛出携带错误码 `SUBAGENT_CONTROL_PROJECTIONS_UNAVAILABLE` 的 `SubagentError`,缺少会话存储时则抛出 `SUBAGENT_CONTROL_SESSION_STORE_UNAVAILABLE`,两者都在任何读取之前检查,因此零 child 的部署同样确定失败;列表工具在插件加载时要求 `ctx.subagents` 与 `ctx.agents`。UI 等服务消费方可以展示两种模式,并为无标签的一次性 child 选择回退展示;面向模型的 `list_agents` 适配器([dsh-tool-subagent-control](../../packages/subagent/tool-subagent-control) 中可单独加载的 `/list-agents` 插件)则只保留可继续条目,并通过在线 Agent 注册表将状态细化为自己的 `running`/`idle`/`ready` 词汇,其中 `ready` 把仅存于存储的 child 命名为可恢复而非终态。枚举不会查询继续执行管理器的 Activation map、Agent 注册表或提供方可用性;`send_message` 仍是消息送达时的权威操作,列表中的运行中可继续 child 仍可能因所有权冲突而拒绝投递。[已归档的列表身份投影记录](../../.agents/notes/archived/architecture/2026-08-06-subagent-list-identity-projection.md)记录了最初的读路径决策。 +`SubagentRuntime.listChildren(parentSessionId)` 从 `ctx.sessions` 与会话查询引擎 `listSessions()` 的实时优先合并中枚举 parent 直接且由会话支撑的 subagent——不会加载或恢复任何 Agent。候选是持久 header 携带 `origin: 'subagent'` 的直接 child;该标记只负责枚举分类与粗粒度的通用路由拒绝,不能证明描述符有效、child 可恢复或操作已获授权——身份由投影折叠负责,恢复由 Activation 约定负责。每行的 `mode`/`label` 是已注册 `subagent` projection unit 的值,经三级阶梯供值:存活 child 由注册表水位缓存供值(零日志读取);冷 child 先读可选的投影 checkpoint 缓存(`cachedSnapshot`——过 own-suffix seq 门的身份即定值,own descriptor 一经追加不可变);否则在一次 `query.observeSession()` 冷观察上经注册表折叠(有界并发,每次列表重新计算)。该缓存是纯可选加速层:服务缺席、行里是 `null` 哨兵或 key 缺席、seq 门不过、读取出错,都静默落到权威重折。折叠规则是 `subagent/descriptor` last-wins 且没有失败通道:子 agent 自己的描述符覆盖 fork seed 中祖先的描述符,格式错误或版本不认识的载荷折叠为可序列化的 `null` 哨兵,视同无值。结果是按 `createdAt`、再按 id 排序的 `SubagentListEntry[]`:取到身份即生成带有 `mode: 'one-shot' | 'continuable'` 和 `activity: 'running' | 'inactive'` 的 `child` 条目;可继续条目始终携带 `label`,一次性条目则只在启动调用方提供展示元数据时携带该字段。已定局而折叠无身份的候选生成 `corrupt` diagnostic——缺失、格式错误与版本不认识的描述符有意不再细分(`unsupported` 仍保留在类型中但从不产出);运行中而无身份的候选被省略(描述符落盘前的创建窗口);冷检查失败生成一条 `unavailable` diagnostic 并在下次列表自然重试,因此一个损坏的 sibling 不会隐藏健康 child。`hasChildren` 标记存在持久 subagent origin 的直接后代,读取自同一份合并材料。活动状态只表示逻辑记录是否在 `ctx.sessions` 中存活,而不表示结果或可恢复性。缺少持久化时,枚举退化为仅存活枚举而不是报错——此时冷 child 本就无法恢复。缺少 `ctx.sessionProjections` 注册表时,`listChildren()` 抛出携带错误码 `SUBAGENT_CONTROL_PROJECTIONS_UNAVAILABLE` 的 `SubagentError`,缺少会话存储时则抛出 `SUBAGENT_CONTROL_SESSION_STORE_UNAVAILABLE`,两者都在任何读取之前检查,因此零 child 的部署同样确定失败;列表工具在插件加载时要求 `ctx.subagents` 与 `ctx.agents`。UI 等服务消费方可以展示两种模式,并为无标签的一次性 child 选择回退展示;面向模型的 `list_agents` 适配器([dsh-tool-subagent-control](../../packages/subagent/tool-subagent-control) 中可单独加载的 `/list-agents` 插件)则只保留可继续条目,并通过在线 Agent 注册表将状态细化为 `running`/`inactive`;`inactive` 包括已加载和仅存于存储的 child,不表示任务完成或结果。枚举不会查询继续执行管理器的 Activation map、Agent 注册表或提供方可用性;`send_message` 仍是消息送达时的权威操作,列表中的运行中可继续 child 仍可能因所有权冲突而拒绝投递。[已归档的列表身份投影记录](../../.agents/notes/archived/architecture/2026-08-06-subagent-list-identity-projection.md)记录了最初的读路径决策。 `SubagentRuntime.listDescendants(rootSessionId)` 将同一份实时优先语料与基于投影的解释应用到根的完整后代树,并按稳定 pre-order 输出。普通会话和一次性 child 仍作为遍历节点,因此其下的可继续后代仍可发现;只有 `origin: 'subagent'` 的候选会生成条目。每个返回的 child 或 diagnostic 都从枚举所得的持久 header 附加树位置;冷检查在提供身份前还会重新校验完整生命周期: diff --git a/docs/tool-catalog.i18n.yaml b/docs/tool-catalog.i18n.yaml index 32f4026c80..f68b88dc20 100644 --- a/docs/tool-catalog.i18n.yaml +++ b/docs/tool-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/tool-catalog.md -tool-catalog.md: 02df667038b2126ca25254e4e15ff2db8e16b504 -tool-catalog.zh.md: d27cf4ef12c6c34c108e5e3c31d6f704800b7b43 +tool-catalog.md: 11d6e958e6f9c2157cf06bcbb18c3543c8d3f598 +tool-catalog.zh.md: 34838f60b2cd4e4e0e4e1376269babb0efb4d2f4 diff --git a/docs/tool-catalog.md b/docs/tool-catalog.md index 02df667038..11d6e958e6 100644 --- a/docs/tool-catalog.md +++ b/docs/tool-catalog.md @@ -1886,7 +1886,7 @@ Source: [`packages/subagent/tool-subagent-control/src/index.ts`](../packages/sub ### `list_agents` -List your continuable background subagents by durable id and label. Use it to recall which ones you started, not to poll for completion — you are told when one finishes. Status comes from the live registry: running means the agent is working right now, idle means it is loaded but between turns (it may be waiting on agents it started), and ready means it exists only in storage — resumable, not terminal, and not a result waiting to be collected; a `send_message` steers a running child at its nearest step boundary or starts a turn for an idle or ready child, and a direct child remains a `send_message` candidate in every status. The snapshot is not a delivery promise — `send_message` performs the authoritative check and may still fail. Children that could not be read are reported as diagnostics instead of being silently dropped. Scope `descendants` walks the whole tree below you in stable pre-order, annotating each entry with its durable direct-parent session id and depth. You may use `send_message` only for depth-1 entries; deeper entries are candidates for `interrupt_agent` only. +List your continuable background subagents by durable id and label. Use it to recall which ones you started, not to poll for completion — you are told when one finishes. Status comes from the live registry: running means the agent is working right now; inactive means no turn is executing, whether the child is loaded or must be resumed. inactive does not describe task completion, success, failure, or waiting for other agents. A `send_message` steers a running child at its nearest step boundary or starts or resumes a turn for an inactive child, and a direct child remains a `send_message` candidate in every status. The snapshot is not a delivery promise — `send_message` performs the authoritative check and may still fail. Children that could not be read are reported as diagnostics instead of being silently dropped. Scope `descendants` walks the whole tree below you in stable pre-order, annotating each entry with its durable direct-parent session id and depth. You may use `send_message` only for depth-1 entries; deeper entries are candidates for `interrupt_agent` only. ```json { @@ -1908,7 +1908,7 @@ Source: [`packages/subagent/tool-subagent-control/src/list-agents.ts`](../packag ### `send_message` -Send a message to a direct continuable child by its agent id. If you are a resident continuable child, you may also target your direct parent. If the target is still working, the message steers its nearest step; if it is idle, the message starts a turn. This call returns no answer from the agent — only confirmation that the message was delivered. A failure means the message was NOT delivered. +Send a message to a direct continuable child by its agent id. If you are a resident continuable child, you may also target your direct parent. If the target is still working, the message steers its nearest step; if it is inactive, the message starts or resumes a turn. This call returns no answer from the agent — only confirmation that the message was delivered. A failure means the message was NOT delivered. ```json { @@ -2021,7 +2021,7 @@ Interrupt one teammate's current turn while preserving its pending inbox. Team L "properties": { "target": { "type": "string", - "description": "Teammate name." + "description": "Teammate target returned by spawn_teammate or list_agents." } }, "required": [ @@ -2034,7 +2034,7 @@ Source: [`packages/experimental/tool-agent-team/src/index.ts`](../packages/exper ### `list_agents` -List the Lead and every durable teammate with current runtime status. +List the Lead and every durable teammate with an addressable target and current availability. inactive means no turn is executing, not a task result. provisioning and failed describe member creation. ```json { @@ -2047,7 +2047,7 @@ Source: [`packages/experimental/tool-agent-team/src/index.ts`](../packages/exper ### `send_message` -Send one durable message to another Team member. A running target receives it at the nearest step boundary; an idle target starts a turn; an inactive teammate cold-resumes. +Send one durable message to another Team member. A running target receives it at the nearest step boundary; an inactive target starts or resumes a turn. ```json { @@ -2055,7 +2055,7 @@ Send one durable message to another Team member. A running target receives it at "properties": { "target": { "type": "string", - "description": "Team member name, or lead." + "description": "Member target returned by spawn_teammate or list_agents, including lead." }, "message": { "type": "string", @@ -2190,7 +2190,7 @@ List shared tasks, including readiness, owner, revision, blockers, and write-sco }, "owner": { "type": "string", - "description": "Optional member-name filter; use unowned for tasks without an owner." + "description": "Optional member target from spawn_teammate or list_agents, matching ownerName; use unowned for tasks without an owner." }, "ready": { "type": "boolean", @@ -2264,7 +2264,7 @@ Compare-and-set a shared task action using the latest revision from team_task_ge }, "owner": { "type": "string", - "description": "Member name for Lead-only reassign; omit to unassign." + "description": "Member target from spawn_teammate or list_agents for Lead-only reassign; omit to unassign." } }, "required": [ diff --git a/docs/tool-catalog.zh.md b/docs/tool-catalog.zh.md index d27cf4ef12..34838f60b2 100644 --- a/docs/tool-catalog.zh.md +++ b/docs/tool-catalog.zh.md @@ -1892,7 +1892,7 @@ lsp 工具将提供方选择和语言服务器子进程置于 ctx.lsp 之后, ### `list_agents` -按持久 id 和标签列出你的可继续后台 subagent。用它回忆你启动过哪些 subagent,而不是轮询完成情况——subagent 完成时你会被告知。状态来自实时注册表:running 表示 agent 此刻正在工作;idle 表示已加载但处于轮次之间,可能正在等待它启动的 agent;ready 表示它只存在于存储中——可恢复而非终态,也不表示有结果等待收集;`send_message` 会在运行中 child 的最近 step 边界 steer 消息,或为 idle、ready child 启动轮次,且无论处于哪种状态,直接子级都仍可作为 `send_message` 的目标。该快照并非投递承诺;`send_message` 会执行权威检查,仍可能失败。无法读取的子级会作为诊断信息报告,而不会被静默丢弃。`descendants` 作用域会按稳定的前序顺序遍历你下方的整棵树,并为每个条目标注其持久的直接父会话 id 和深度。只有深度为 1 的条目可以使用 `send_message`;更深的条目只能作为 `interrupt_agent` 的候选目标。 +按持久 id 和标签列出你的可继续后台 subagent。用它回忆你启动过哪些 subagent,而不是轮询完成情况——subagent 完成时你会被告知。状态来自实时注册表:running 表示 agent 此刻正在工作;inactive 表示没有轮次在执行,包括已加载和需要恢复的 child。inactive 不表示任务完成、成功、失败或等待其他 agent。`send_message` 会在运行中 child 的最近 step 边界 steer 消息,或为 inactive child 启动或恢复轮次,且无论处于哪种状态,直接子级都仍可作为 `send_message` 的目标。该快照并非投递承诺;`send_message` 会执行权威检查,仍可能失败。无法读取的子级会作为诊断信息报告,而不会被静默丢弃。`descendants` 作用域会按稳定的前序顺序遍历你下方的整棵树,并为每个条目标注其持久的直接父会话 id 和深度。只有深度为 1 的条目可以使用 `send_message`;更深的条目只能作为 `interrupt_agent` 的候选目标。 ```json { @@ -1914,7 +1914,7 @@ lsp 工具将提供方选择和语言服务器子进程置于 ctx.lsp 之后, ### `send_message` -根据 agent id 向直接可继续 child 发送消息。如果你是驻留的可继续 child,也可以把自己的直接 parent 作为目标。如果目标仍在工作,消息会 steer 其最近的 step;如果目标处于 idle,消息会启动一个轮次。此调用不会返回该 agent 的答案,只会确认消息已投递。调用失败表示消息**未**投递。 +根据 agent id 向直接可继续 child 发送消息。如果你是驻留的可继续 child,也可以把自己的直接 parent 作为目标。如果目标仍在工作,消息会 steer 其最近的 step;如果目标处于 inactive,消息会启动或恢复一个轮次。此调用不会返回该 agent 的答案,只会确认消息已投递。调用失败表示消息**未**投递。 ```json { @@ -2027,7 +2027,7 @@ lsp 工具将提供方选择和语言服务器子进程置于 ctx.lsp 之后, "properties": { "target": { "type": "string", - "description": "Teammate name." + "description": "Teammate target returned by spawn_teammate or list_agents." } }, "required": [ @@ -2040,7 +2040,7 @@ lsp 工具将提供方选择和语言服务器子进程置于 ctx.lsp 之后, ### `list_agents` -列出 Lead 与所有持久 teammate,以及各自当前的运行时状态。 +列出 Lead 与所有持久 teammate,以及可用于寻址的 target 和当前可用状态。inactive 表示没有轮次在执行,不表示任务结果。provisioning 与 failed 描述成员创建状态。 ```json { @@ -2053,7 +2053,7 @@ lsp 工具将提供方选择和语言服务器子进程置于 ctx.lsp 之后, ### `send_message` -向另一名 Team member 发送一条持久消息。running target 会在最近的步骤边界收到消息;idle target 会启动一个 turn;inactive teammate 会冷恢复。 +向另一名 Team member 发送一条持久消息。running target 会在最近的步骤边界收到消息;inactive target 会启动或恢复一个 turn。 ```json { @@ -2061,7 +2061,7 @@ lsp 工具将提供方选择和语言服务器子进程置于 ctx.lsp 之后, "properties": { "target": { "type": "string", - "description": "Team member name, or lead." + "description": "Member target returned by spawn_teammate or list_agents, including lead." }, "message": { "type": "string", @@ -2196,7 +2196,7 @@ lsp 工具将提供方选择和语言服务器子进程置于 ctx.lsp 之后, }, "owner": { "type": "string", - "description": "Optional member-name filter; use unowned for tasks without an owner." + "description": "Optional member target from spawn_teammate or list_agents, matching ownerName; use unowned for tasks without an owner." }, "ready": { "type": "boolean", @@ -2270,7 +2270,7 @@ lsp 工具将提供方选择和语言服务器子进程置于 ctx.lsp 之后, }, "owner": { "type": "string", - "description": "Member name for Lead-only reassign; omit to unassign." + "description": "Member target from spawn_teammate or list_agents for Lead-only reassign; omit to unassign." } }, "required": [ diff --git a/docs/web-styling.i18n.yaml b/docs/web-styling.i18n.yaml index 874380a78c..2b7ec7cafd 100644 --- a/docs/web-styling.i18n.yaml +++ b/docs/web-styling.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/web-styling.md -web-styling.md: 2185abef4e2c0bad6e3ce1de7feed0316b7bc821 -web-styling.zh.md: fb836b1e434ff4bca2cf2c38146b902ef752d376 +web-styling.md: 1275dd7cc6f2441618ab571d180a9ccd97ca2d93 +web-styling.zh.md: 9f43829b3e2fed846584474b0fccac0d8c0e94b7 diff --git a/docs/web-styling.md b/docs/web-styling.md index 2185abef4e..1275dd7cc6 100644 --- a/docs/web-styling.md +++ b/docs/web-styling.md @@ -24,7 +24,7 @@ Global style sheets belong in `ui-theme/src/styles/`. Component styles live besi - Elevated surfaces (menus, popovers, modals, panels, floating buttons, the composer) set `border: 0` and take `box-shadow: var(--dsw-elevation-panel)`, `var(--dsw-elevation-prominent)`, or the composer's `var(--dsw-elevation-soft)` (larger blur at lower alpha): the 0.5px hairline stroke is the first shadow layer, and `--dsw-elevation-stroke-color` rebinds or suppresses it per surface or state. Never pair a `--dsw-alias-border-*` border with an lv/elevation shadow — the ui-theme elevation spec rejects the pairing; state-colored borders (warn panels) stay real borders. - An elevated surface using the translucent `--dsw-specific-menu` fill applies `backdrop-filter: var(--dsw-menu-backdrop-filter)` in the same rule. A surface containing fixed-position overlays paints both values on an isolated background pseudo-element so the filter does not change those overlays' containing block. Shared dropdowns use the compact `Menu` geometry; feature-owned menu content may vary without recreating the material or scrollbar implementation ([decision](../.agents/notes/implemented/feature/2026-09-17-compact-translucent-menu-surfaces.md)). - Flat borders and separators that use a neutral `--dsw-alias-border-*` token draw at `0.5px` — buttons, inputs, cards, row dividers, and separators drawn as filled boxes (menu separators, the conversation header seam, markdown `hr`, vertical rails) share the hairline weight, which Chromium paints as one device pixel. Dashed affordances and state-colored borders keep 1px; spinner ring tracks keep their width through the spec's explicit allowlist. The ui-theme elevation spec rejects wider neutral solid borders. -- Clickable artifact links (markdown anchors, prose file mentions, web source and fetch links, produced-file chips, workflow member links) color through `--dsw-alias-link` at `font-weight: 500`, with no underline at rest and a dotted 3px-offset underline on hover/focus. Compact Thinking Markdown keeps tertiary text color and a resting dotted underline ([compact presentation](../.agents/notes/implemented/bug-fix/2026-09-17-thinking-markdown.md)). Text-leading anchors also lead with the ui-primitives `LinkIconMedium` category glyph riding `currentColor`, which for a well-known external host is that site's own mark instead of the globe; workflow member links and image-only anchors carry no glyph, and tool-row file links keep their grey dotted affordance ([clickable-link Agent Note](../.agents/notes/implemented/feature/2026-09-04-web-clickable-link-styles.md), [known-site mark Agent Note](../.agents/notes/implemented/feature/2026-09-16-known-site-link-marks.md)). +- Clickable artifact links (markdown anchors, prose file mentions, web source and fetch links, produced-file chips, workflow member links) color through `--dsw-alias-link` at `font-weight: 500`, with no underline at rest and a dotted 3px-offset underline on hover/focus. Compact Thinking Markdown keeps tertiary text color and a resting dotted underline ([compact presentation](../.agents/notes/implemented/bug-fix/2026-09-17-thinking-markdown.md)). Text-leading anchors also lead with the ui-primitives `LinkIconMedium` category glyph riding `currentColor`, which for a well-known external host is that site's own mark instead of the globe; workflow member links and image-only anchors carry no glyph, and tool-row file links keep their grey dotted affordance. ## Changing the system diff --git a/docs/web-styling.zh.md b/docs/web-styling.zh.md index fb836b1e43..9f43829b3e 100644 --- a/docs/web-styling.zh.md +++ b/docs/web-styling.zh.md @@ -24,7 +24,7 @@ - 高层级表面(菜单、浮层、对话框、面板、悬浮按钮、输入框)设 `border: 0` 并使用 `box-shadow: var(--dsw-elevation-panel)`、`var(--dsw-elevation-prominent)` 或输入框专用的 `var(--dsw-elevation-soft)`(更大模糊、更低透明度):0.5px 发丝描边是第一层投影,`--dsw-elevation-stroke-color` 可按表面或状态重绑或抑制描边。不得将 `--dsw-alias-border-*` border 与 lv/elevation 投影配对——ui-theme 的 elevation spec 会拒绝;状态色 border(warn 面板)保持真 border。 - 使用半透明 `--dsw-specific-menu` 填充的高层级表面,必须在同一规则中应用 `backdrop-filter: var(--dsw-menu-backdrop-filter)`。包含 fixed 定位浮层的表面把两个属性绘制在隔离的背景伪元素上,避免滤镜改变这些浮层的包含块。共享下拉菜单采用紧凑 `Menu` 几何;功能包自有菜单内容可以不同,但不得重新实现材质或滚动条([决定](../.agents/notes/implemented/feature/2026-09-17-compact-translucent-menu-surfaces.zh.md))。 - 使用中性 `--dsw-alias-border-*` token 的平面边框与分割线一律 `0.5px`——按钮、输入框、卡片、行分割线,以及以填充盒绘制的分隔线(菜单分隔、对话标题栏接缝、markdown `hr`、竖向轨道线)共用发丝线粗细,Chromium 将其绘制为一个设备像素。dashed 记号与状态色 border 保持 1px;spinner 圆环经 spec 的显式豁免保留原宽度。更宽的中性 solid border 会被 ui-theme elevation spec 拒绝。 -- 可点击产物链接(Markdown 锚点、正文文件引用、网页来源与抓取链接、产物 chips、workflow 成员链接)经 `--dsw-alias-link` 着色、`font-weight: 500`,默认无下划线,hover/focus 时为 3px offset 的点状下划线。紧凑 Thinking Markdown 保持 tertiary 文字色和默认点状下划线([紧凑展示](../.agents/notes/implemented/bug-fix/2026-09-17-thinking-markdown.zh.md))。带文字的锚点另以 ui-primitives 的 `LinkIconMedium` 分类图形(随 `currentColor`)作前置;目的地是已知外部站点时改用该站点自己的标记而非地球;workflow 成员链接与只包图片的锚点不带图形,工具行文件链接保持其灰色点线示能([可点击链接 Agent Note](../.agents/notes/implemented/feature/2026-09-04-web-clickable-link-styles.zh.md)、[已知站点标记 Agent Note](../.agents/notes/implemented/feature/2026-09-16-known-site-link-marks.zh.md))。 +- 可点击产物链接(Markdown 锚点、正文文件引用、网页来源与抓取链接、产物 chips、workflow 成员链接)经 `--dsw-alias-link` 着色、`font-weight: 500`,默认无下划线,hover/focus 时为 3px offset 的点状下划线。紧凑 Thinking Markdown 保持 tertiary 文字色和默认点状下划线([紧凑展示](../.agents/notes/implemented/bug-fix/2026-09-17-thinking-markdown.zh.md))。带文字的锚点另以 ui-primitives 的 `LinkIconMedium` 分类图形(随 `currentColor`)作前置;目的地是已知外部站点时改用该站点自己的标记而非地球;workflow 成员链接与只包图片的锚点不带图形,工具行文件链接保持其灰色点线示能。 ## 变更系统 diff --git a/package.json b/package.json index 5e7ce892f0..ee34bc050a 100644 --- a/package.json +++ b/package.json @@ -87,7 +87,7 @@ "check:windows-wine": "bash scripts/wine-windows-gates.sh", "check:ci:windows-blocking": "tsx scripts/run-gates.ts ci-windows-blocking", "check:ci:windows-complete": "tsx scripts/run-gates.ts ci-windows-complete", - "check:ci:windows-observational": "tsx scripts/run-gates.ts ci-windows-observational", + "check:ci:windows-observational-ready": "tsx scripts/run-gates.ts ci-windows-observational-ready", "check:node-compat": "tsx scripts/run-gates.ts node-compat", "publint": "tsx scripts/publint-all.ts", "doc-typecheck": "npm run build:lib:host && npm run doc-typecheck:contracts-ready", diff --git a/packages/api/session-controller/README.i18n.yaml b/packages/api/session-controller/README.i18n.yaml index 544c5f0228..c5db6355b9 100644 --- a/packages/api/session-controller/README.i18n.yaml +++ b/packages/api/session-controller/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/api/session-controller/README.md -README.md: f67355be0cb9737ebc67bf589528878bccc26e6d -README.zh.md: e52501c145c3789213aec517b0801e5e7ee2a4cb +README.md: 0ab2a452587da8e818e2744e59232fe67f15f79f +README.zh.md: 6819f07ae0a495896937a7097c5be2dcb0fbefc9 diff --git a/packages/api/session-controller/README.md b/packages/api/session-controller/README.md index f67355be0c..0ab2a45258 100644 --- a/packages/api/session-controller/README.md +++ b/packages/api/session-controller/README.md @@ -31,6 +31,8 @@ The Client journal validates exact V3 event envelopes before publishing follow s Each endpoint states its activation policy. List reads only stored headers and projection-cache rows: it never calls per-session stat or opens a cold Session body. A current-format cache identity may supply every list hint; a lifecycle-matching predecessor cache may supply only its version-compatible title as a stale display fact, never as an authoritative fold seed. Search, attachment, history pages, log following, skill discovery, and workspace-path opening can inspect persistence without activating an Agent; `canOpenWorkspacePath()` reports native-opening availability without addressing a Session. Cancellation requires live state; queue mutation, model, rename, prompt, and file-reference operations may resolve or resume an ordinary Session. Prompt rejects content with neither non-whitespace text nor an attachment before resolving the Agent or appending Session events; queue edits accept only non-empty text content. Prompt admission consumes opaque receipts from the injected [`fileUploads`](../../client/file-upload/README.md) Host service and resolves every same-Agent receipt before sending the complete ordered content list through `ctx.attachments`. Prompt retries whose `requestId` is already queued or logged return the original acceptance without inserting another message. Create and fork are the only operations that create a new Agent directly. The service applies one preset-aware resume policy and subagent ownership fence to its own methods and to the Typert Agent and Session lookups used by other Remote namespaces. Queue mutation has one narrow exception: a live child whose current projected identity is continuable and comes from its own non-seed suffix accepts the ordinary Edit, Remove, and QueueDock Steer actions across both inbox destinations. One-shot, missing, unknown, corrupt, seed-only, or cold children remain rejected without resume. The skill catalog uses a live Agent when present or the recorded preset's standing scope when cold, so listing never starts an Agent. The authenticated delivery routes use `workspaceDesktop()` for the serving Host name and file-manager behavior. `openWorkspacePath({ path, action: "reveal" })` delegates file-manager navigation to the native adapter; omitting `action` opens the default application. +Client list rows and resident Sessions use the current `sessionListMetadata` projection to reject stale blank-session hints; recency is the later of the summary timestamp and the projected last user prompt. When SessionManager creates an instance before its list row arrives, it reconciles blankness with metadata already retained for that Session. An opened conversation is therefore not reused by New Session even when an older list response still marks it blank. + Client list refreshes retain unchanged row objects and reuse the items array when order and values match. Each row's `retainedBy` contains positive local reference-source counts; Host metadata refreshes cannot overwrite them. Cache membership checks use a per-refresh ID set, so reconciliation grows linearly with the current list and retained cache sizes. The Client adapter exposes `SessionEventStream`, a Gateway `RemoteJournalStream` bound to one ordinary or direct-subagent address. It opens follow before the initial page, publishes only contiguous `replace`, `prepend`, `append`, and `settle-assistant` changes, and repairs reconnect or sequence gaps through a tail page. Backwards paging has two verbs: `loadOlder()` pulls one 50-message page, and `loadThrough(seq)` — the turn-jump loader — loops 200-message pages until the window covers the target seq, lowering a shared target on repeated calls, stopping on a page that makes no progress, and reporting busy through the same `loadingOlder` snapshot bit. The Web adapter explicitly opts into cursorless Assistant frames: each opening carries the active attempt's `startedAfterSeq`, `nextIndex`, and compact stream, and every stream member becomes a Client-only `assistant/live-chunk` entry ordered between durable cursors. The Host captures a follower-local arrival ordinal with that baseline and suppresses buffered frames at or before the cut; a replacement Agent may restart frame revision at one. A durable `assistant/message` or `assistant/attempt` arriving after an active opening stays staged only when its seq follows `startedAfterSeq` and its Turn and Step match; the matching end type, seq, and index publishes one named settlement delta that retires the attempt's transient rows and adds the durable entry while earlier same-step retries remain visible. Revision, dense-index, or settlement gaps for a known attempt reopen follow, while a controller that missed the start ignores unknown-attempt frames and publishes their durable settlement normally. An abandoned end publishes a settlement delta without a durable entry so its transient rows retire immediately. A durable gap-repair page has no Assistant baseline, so its held notification reopens follow once for a paired page and baseline. Every history record covers exactly its event seq. A business, persistence, or unresolved continuity failure terminates the stream, while only physical carrier loss selects automatic resumption. `SessionControlStream` is a Gateway `RemoteSnapshotStream`; every generation opens with a complete process-local baseline, so reconnect replaces jobs and projection state instead of treating transient values as durable events. At each ready Host generation, a synchronous Client subscription clears retained projection values and watermarks before refreshing queries and restarting the control stream, including Sessions absent from the control baseline. The first control stream waits for generation readiness, so its opening values cannot precede invalidation. Outstanding list responses from the previous generation cannot republish those values. Within a generation, a delayed control baseline cannot overwrite or clear newer list, history, or live values. The durable `inbox` projection carries both pending lists through the same cold-read and reconnect path as other projections. Client Agent contexts provide the identity used by the independent [`fileUpload`](../../client/file-upload/README.md) service; Session objects expose lifecycle, prompt, queue, and history operations rather than file transfer. @@ -44,6 +46,8 @@ Fork copies history through the selected completed turn, including its `turn/end A resume blocked by an existing write handle returns `session/writer-held` with the Session id; other resume failures retain `gateway/internal`. +`loadThrough(seq)` retains older pages privately until its shared target is covered or loading ends, then publishes the successful pages as one ordered prepend. Live events remain visible while history loads. A later page failure retains the successful prefix; replacing the history window discards buffered pages from the replaced window. Ordinary `loadOlder()` still publishes its single page directly. + ## Client references diff --git a/packages/api/session-controller/README.zh.md b/packages/api/session-controller/README.zh.md index e52501c145..6819f07ae0 100644 --- a/packages/api/session-controller/README.zh.md +++ b/packages/api/session-controller/README.zh.md @@ -31,6 +31,8 @@ Client journal 在发布 follow 快照、live entry 或历史页之前验证精 每个 endpoint 都声明自己的激活策略。列表只读取持久化 header 与 projection cache row,绝不调用逐 Session stat 或打开冷 Session body。当前格式 cache identity 可以提供全部列表 hint;生命周期匹配的 predecessor cache 只能提供版本兼容的 title,作为可能过时的展示事实,绝不能作为权威 fold seed。搜索、附件、历史页、日志跟随、skill 发现和工作区路径打开可以在不激活 Agent 的情况下检查 persistence;`canOpenWorkspacePath()` 无需指定 Session 即可报告原生打开能力。取消要求 live 状态;queue 变更、模型、重命名、prompt 和文件引用操作可以解析或恢复普通 Session。提示词会在解析 Agent 或追加 Session 事件前,拒绝既没有非空白文本也没有附件的 content;queue edit 只接受非空文本 content。prompt 准入从注入的 [`fileUploads`](../../client/file-upload/README.zh.md) Host 服务取得不透明凭证,在把完整有序内容列表交给 `ctx.attachments` 前解析每个属于同一 Agent 的凭证。`requestId` 已进入 queue 或日志时,prompt 重试直接返回原来的接受结果,不会重复插入消息。只有 create 与 fork 会直接创建新 Agent。该服务把同一套感知 preset 的恢复策略和 subagent ownership fence 同时用于自身方法,以及其他 Remote namespace 使用的 Typert Agent 与 Session lookup。Queue 变更只有一个狭窄例外:当前 projection identity 为 continuable 且来自自身非 seed suffix 的在线 child,可以在两个 inbox 目标上使用普通 Edit、Remove 与 QueueDock Steer action。One-shot、缺失、未知、损坏、仅含 seed identity 或冷 child 继续被拒绝,且不会恢复。skill 目录优先使用已有 live Agent,否则使用所记录 preset 的常驻 scope,因此列表查询绝不会启动 Agent。经过鉴权的文件交付路由通过 `workspaceDesktop()` 获取提供服务的 Host 名称和文件管理器行为。`openWorkspacePath({ path, action: "reveal" })` 将文件管理器导航委托给原生适配器;省略 `action` 时打开默认应用。 +Client 列表行和驻留 Session 使用当前 `sessionListMetadata` 投影纠正过期的空白会话提示;最近活动时间取摘要时间戳与投影中最后一次用户提示词时间的较晚值。当 SessionManager 在列表行到达前创建实例时,会使用已保留的该 Session 元数据对账空白状态。因此,即使旧列表响应仍将已有对话标为空白,新会话操作也不会复用已经打开过的对话。 + Client 列表刷新保留未变化的行对象,并在顺序和值均相同时复用条目数组。每行的 `retainedBy` 包含本地引用来源的正计数;Host 元数据刷新不能覆盖它们。缓存成员检查使用每次刷新构建的 ID 集合,因此对账成本随当前列表和保留缓存的规模线性增长。 Client 适配器提供 `SessionEventStream`,即绑定到一个普通 Session 或 direct subagent address 的 Gateway `RemoteJournalStream`。它在读取首个 page 前打开 follow,只发布连续的 `replace`、`prepend`、`append` 与 `settle-assistant` 变更,并通过 tail page 修复重连或 seq 缺口。向后分页有两个动词:`loadOlder()` 拉一页 50 条消息,而 `loadThrough(seq)`——轮次跳转加载器——按每页 200 条消息循环拉取直到窗口覆盖目标 seq,重复调用会下调共享目标,遇到无进展的页即停止,忙碌状态复用同一个 `loadingOlder` 快照位。Web 适配器显式选择接收无 cursor 的 Assistant frame:每个 opening 携带活跃 attempt 的 `startedAfterSeq`、`nextIndex` 与紧凑 stream,每个 stream member 都成为排在持久 cursor 之间的 Client-only `assistant/live-chunk` 条目。Host 会随该 baseline 捕获 follower 本地到达序号,并抑制该 cut 及之前的 buffered frame;replacement Agent 可以从 revision 一重新开始。活跃 opening 之后到达的持久 `assistant/message` 或 `assistant/attempt` 只有在其 seq 晚于 `startedAfterSeq` 且轮次与步骤匹配时才会保持暂存;匹配的 end type、seq 与 index 会发布一个具名 settlement delta,删除该 attempt 的瞬态 row、加入持久条目,并保留同一步骤中更早的 retry。已知 attempt 的 revision、密集 index 或 settlement 缺口会重新打开 follow;若 controller 错过 start,则忽略 unknown-attempt frame,并正常发布其持久 settlement。Abandoned end 会发布不含持久条目的 settlement delta,使瞬态 row 立即退出。持久缺口修复 page 不携带 Assistant baseline,因此 held notification 会重新打开 follow 一次,以取得配对的 page 与 baseline。每条历史 record 只覆盖自身的事件 seq。业务、persistence 或无法恢复的连续性错误会终止 stream,只有物理载体断开才触发自动恢复。`SessionControlStream` 是 Gateway `RemoteSnapshotStream`;每代都以完整的进程本地 baseline 开始,因此重连会替换 jobs 和 projection 状态,而不会把瞬态值当作 durable event。每次 Host generation 就绪时,同步的 Client 订阅会先清除保留的投影值及其水位,再刷新查询并重新打开 control stream,其中也包括 control baseline 中没有列出的 Session。首次 control stream 会等待 generation 就绪,确保其 opening 值不会先于旧状态清理到达。上一代尚未完成的 list 响应无法重新发布这些值。同一 generation 内,延迟到达的 control baseline 不能覆盖或清除较新的 list、history 或 live 值。持久 `inbox` 投影通过与其他投影相同的冷读取和重连路径传输两份待处理列表。Client Agent 上下文提供独立 [`fileUpload`](../../client/file-upload/README.zh.md) 服务使用的身份;Session 对象提供生命周期、prompt、queue 与历史操作,不提供文件传输。 @@ -44,6 +46,8 @@ Session 对象还承载本地提交回显:`session.beginSubmission` 在调用 恢复会话时若已有写句柄占用,返回 `session/writer-held`,并携带会话 id;其他恢复失败仍返回 `gateway/internal`。 +`loadThrough(seq)` 在共享目标被覆盖或加载结束前私下保留较早页面,随后把成功取得的页面按顺序作为一次前插发布。历史加载期间实时事件仍然可见。后续页面失败时保留已成功取得的部分;历史窗口被替换时丢弃被替换窗口的暂存页面。普通 `loadOlder()` 仍直接发布其单页结果。 + ## Client 引用 diff --git a/packages/api/session-controller/src/client/contract/session.ts b/packages/api/session-controller/src/client/contract/session.ts index 20c2985c80..246d2c8a92 100644 --- a/packages/api/session-controller/src/client/contract/session.ts +++ b/packages/api/session-controller/src/client/contract/session.ts @@ -127,6 +127,8 @@ export interface ISession { * turn-jump loader. Repeated calls while a jump is paging lower its shared * target and return the in-flight completion; `snapshot.loadingOlder` is * the busy signal for the whole jump. + * Older pages publish together at completion, including successful pages + * before a later failure; live events remain independently visible. * @param seq - durable event seq the window must reach (a turn's `turn/start` seq). * @returns completion once covered, exhausted, superseded, or failed soft. */ diff --git a/packages/api/session-controller/src/client/sessions/lineage.ts b/packages/api/session-controller/src/client/sessions/lineage.ts index 65e1501bd4..496087f6ad 100644 --- a/packages/api/session-controller/src/client/sessions/lineage.ts +++ b/packages/api/session-controller/src/client/sessions/lineage.ts @@ -19,7 +19,7 @@ export interface SessionListEntry { title?: string updatedAt: number running: boolean - /** Empty-log bit mirrored from the summary; lists hide blank sessions (filtering stays with the consumer). */ + /** Summary blankness reconciled with `sessionListMetadata`; lists hide blank sessions (filtering stays with the consumer). */ blank: boolean parentSessionId?: SessionId /** Coarse durable origin for navigation filtering; not a continuation capability. */ diff --git a/packages/api/session-controller/src/client/sessions/manager.ts b/packages/api/session-controller/src/client/sessions/manager.ts index 79474ae2cf..c0c0990cf2 100644 --- a/packages/api/session-controller/src/client/sessions/manager.ts +++ b/packages/api/session-controller/src/client/sessions/manager.ts @@ -233,6 +233,7 @@ export class SessionManager { /** * Lazy build: return the existing instance or construct one (no auto-open — * the reference allocator opens history after binding the scope). + * New instances reconcile retained metadata before returning. * @param sessionId - the session to get. * @returns the resident instance. */ @@ -256,6 +257,9 @@ export class SessionManager { // durable history, even though child rows do not carry `blank`. session.handleBlank(false) session.handleRunning(child.activity === 'running') + } else { + // Retained metadata may have notified before this Session existed. + session.handleBlank(true) } } } @@ -286,9 +290,14 @@ export class SessionManager { let store = this.projectionStores.get(sessionId) if (store === undefined) { store = new ProjectionValueStore() - // List rows project off store keys (title); any-key changes re-enter - // the manager's own batched rebuild channel. - store.subscribeAny(() => { this.notifier.markDirty() }) + const projections = store + store.subscribeAny(() => { + // Newer history or control metadata corrects a resident Session's stale list hint. + if (projections.values().sessionListMetadata?.blank === false) { + this.sessions.get(sessionId)?.handleBlank(false) + } + this.notifier.markDirty() + }) this.projectionStores.set(sessionId, store) } return store @@ -415,7 +424,7 @@ export class SessionManager { this.summaries = mutations.reduce(applyMutation, baseline) this.listState = 'idle' this.listPhase = 'ready' - // Push running/blank bits down to instantiated Sessions (the list is the authoritative summary source). + // Sessions reconcile list blank hints with their current metadata projection. for (const s of this.summaries) { const session = this.sessions.get(s.sessionId) if (session === undefined) continue @@ -815,8 +824,12 @@ export class SessionManager { const projectionStore = this.projectionStores.get(summary.sessionId) const title = projectionStore?.get('title') const projectionValues = projectionStore?.values() + const metadata = projectionValues?.sessionListMetadata return { ...summary, + // Cached list hints can precede a history opening or control update. + blank: summary.blank && metadata?.blank !== false, + updatedAt: Math.max(summary.updatedAt, metadata?.lastPromptAt ?? 0), ...(typeof title === 'string' && title !== '' ? { title } : {}), ...(projectionValues === undefined ? {} : { projectionValues }), } diff --git a/packages/api/session-controller/src/client/sessions/service.ts b/packages/api/session-controller/src/client/sessions/service.ts index 8d0dd75fc7..1c7196a272 100644 --- a/packages/api/session-controller/src/client/sessions/service.ts +++ b/packages/api/session-controller/src/client/sessions/service.ts @@ -38,10 +38,10 @@ export interface SessionSummary { /** Local ownership counts; Host metadata refreshes cannot overwrite them. */ readonly retainedBy: SessionRetainInfo['retainedBy'] /** - * Empty-log bit (host summary derivation mirror). New Session reuses a blank - * one targeting the same workspace. Filtering stays with the consumer: the - * store carries every row, while the Workspace browser shows only the - * selected blank entry. + * Blankness from the Host summary reconciled with `sessionListMetadata`. + * New Session reuses a blank one targeting the same workspace. Filtering + * stays with the consumer: the store carries every row, while the Workspace + * browser shows only the selected blank entry. */ blank: boolean updatedAt: number diff --git a/packages/api/session-controller/src/client/sessions/session.ts b/packages/api/session-controller/src/client/sessions/session.ts index 3c401cc3e7..67c7b2f164 100644 --- a/packages/api/session-controller/src/client/sessions/session.ts +++ b/packages/api/session-controller/src/client/sessions/session.ts @@ -53,6 +53,12 @@ export const PAGE_MESSAGES = 50 /** Messages requested per page while a turn jump loops backwards (fewer, larger round trips). */ export const JUMP_PAGE_MESSAGES = 200 +interface PendingHistory { + beforeSeq: SessionLogOffset + hasMore: boolean + readonly pages: (readonly SessionEventLikeEntry[])[] +} + /** Manager-owned observers of a Session object's local state edges. */ export interface SessionOptions { /** Catalog-discovered address selecting non-activating subagent transport. */ @@ -97,6 +103,7 @@ export class Session implements SessionFace { private jumpTargetSeq: SessionSeq | null = null /** The running jump loop's completion, shared by retargeting callers. */ private jumpPromise: Promise | null = null + private pendingHistory: PendingHistory | null = null private readonly stopObservingInbox: () => void private readonly assistantStream = new ClientAssistantStream() private running = false @@ -419,6 +426,14 @@ export class Session implements SessionFace { // target behind — only the loop's finally clears that field, and no // loop starts here. if (this.loadingOlder) return Promise.resolve() + const events = this.events + if (events === undefined) return Promise.resolve() + const pending: PendingHistory = { + beforeSeq: this.baseSeq, + hasMore: this.hasMore, + pages: [], + } + this.pendingHistory = pending this.jumpTargetSeq = seq this.loadingOlder = true this.notifier.markDirty() @@ -428,15 +443,13 @@ export class Session implements SessionFace { const generation = this.openGeneration this.jumpPromise = (async () => { try { - while (this.hasMore && this.jumpTargetSeq !== null && this.baseSeq > this.jumpTargetSeq) { + while (pending.hasMore && this.jumpTargetSeq !== null && pending.beforeSeq > this.jumpTargetSeq) { if (generation !== this.openGeneration) return - const events = this.events - if (events === undefined) return - const before = this.baseSeq - await events.prepend({ beforeSeq: this.baseSeq, maxMessages: JUMP_PAGE_MESSAGES }) + const before = pending.beforeSeq + await events.prepend({ beforeSeq: before, maxMessages: JUMP_PAGE_MESSAGES }) // No-progress guard: an empty or dropped page that still claims more // history must end the loop, not spin it. - if (this.baseSeq >= before) return + if (pending.beforeSeq >= before) return } } catch (error) { if (!isRemoteFailure(error)) { @@ -445,7 +458,11 @@ export class Session implements SessionFace { } finally { this.jumpTargetSeq = null this.jumpPromise = null + this.pendingHistory = null this.loadingOlder = false + if (generation === this.openGeneration && pending.pages.length > 0) { + this.prependWindow(pending.pages.reverse().flat(), pending.hasMore) + } this.notifier.markDirty() } })() @@ -535,13 +552,12 @@ export class Session implements SessionFace { } /** - * Blank-bit relay from the authoritative summary source (`session.list` and - * `api-session/added`). Monotone: once any signal (local first send, - * running flip, an earlier summary) cleared it, a stale true never - * re-blanks. - * @param blank - the summary's derived empty-log bit. + * Relay list blankness without overriding a started conversation established + * by the current projection, a local prompt, or running state. + * @param blank - whether the list or projection reports an unstarted conversation. */ handleBlank(blank: boolean): void { + blank = blank && this.projections.values().sessionListMetadata?.blank !== false if (blank === this.blankBit) return if (blank && (this.promptAttempted || this.running)) return this.blankBit = blank @@ -648,6 +664,11 @@ export class Session implements SessionFace { const visible = this.assistantStream.replace(entries, assistantStream) this.baseSeq = SessionLogOffset(entries[0]?.event.seq ?? 0) this.hasMore = hasMore + if (this.pendingHistory !== null) { + this.pendingHistory.beforeSeq = this.baseSeq + this.pendingHistory.hasMore = hasMore + this.pendingHistory.pages.length = 0 + } if (visible.some(entry => entry.event.type === 'turn/start')) this.firstPromptPendingTurn = false if (projections !== undefined) this.projections.seed(projections) this.eventSource.replace(visible, hasMore) @@ -684,6 +705,13 @@ export class Session implements SessionFace { /** Prepend one stream-validated history page. */ private prependWindow(entries: readonly SessionEventLikeEntry[], hasMore: boolean): void { + if (this.pendingHistory !== null) { + const pending = this.pendingHistory + pending.beforeSeq = entries[0] === undefined ? pending.beforeSeq : SessionLogOffset(entries[0].event.seq) + pending.hasMore = hasMore + pending.pages.push(entries) + return + } this.baseSeq = entries[0] === undefined ? this.baseSeq : SessionLogOffset(entries[0].event.seq) this.hasMore = hasMore this.eventSource.prepend(entries, hasMore) diff --git a/packages/api/session-controller/tests/manager.client.spec.ts b/packages/api/session-controller/tests/manager.client.spec.ts index 5cc6145b34..2bbf54cc8e 100644 --- a/packages/api/session-controller/tests/manager.client.spec.ts +++ b/packages/api/session-controller/tests/manager.client.spec.ts @@ -58,6 +58,28 @@ describe('SessionManager instances', () => { expect(session.getSnapshot().running).toBe(true) // list preceded instantiation }) + it('initializes blankness from metadata published before the Session and list exist', async ({ mock, remote }) => { + const manager = makeManager(mock, remote) + const published = Promise.withResolvers() + const stopObserving = manager.subscribe(() => { published.resolve(undefined) }) + try { + manager.handleControlFrame({ type: 'projection', sessionId: S1, + key: 'sessionListMetadata', value: { blank: false, lastPromptAt: 1200 }, seq: 8 }) + manager.handleControlFrame({ type: 'projection', sessionId: S2, + key: 'sessionListMetadata', value: { blank: true, lastPromptAt: null }, seq: 2 }) + // The initial notification must finish while neither Session is resident. + await published.promise + + expect(manager.getListSnapshot().items).toEqual([]) + expect(manager.get(S1).getSnapshot()).toMatchObject({ blank: false, running: false }) + expect(manager.get(S2).getSnapshot()).toMatchObject({ blank: true, running: false }) + expect(manager.get('fk-unobserved' as SessionId).getSnapshot().blank).toBe(true) + } finally { + stopObserving() + await manager.dispose() + } + }) + }) describe('SessionManager query lifetime', () => { @@ -125,6 +147,28 @@ describe('SessionManager query lifetime', () => { }) describe('list lifecycle', () => { + it('uses metadata received before the list without reblanking started rows', async ({ mock, remote }) => { + const manager = makeManager(mock, remote) + try { + manager.handleControlFrame({ type: 'projection', sessionId: S1, + key: 'sessionListMetadata', value: { blank: false, lastPromptAt: 1200 }, seq: 8 }) + manager.handleControlFrame({ type: 'projection', sessionId: S2, + key: 'sessionListMetadata', value: { blank: true, lastPromptAt: null }, seq: 2 }) + remote.session.list.mockResolvedValue(ok({ items: [ + summary(S1, { blank: true }), summary(S2), + ] as never[] })) + await manager.refreshList() + expect(manager.getListSnapshot().items.map(item => item.blank)).toEqual([false, false]) + expect(manager.get(S1).getSnapshot().blank).toBe(false) + expect(manager.get(S2).getSnapshot().blank).toBe(false) + manager.handleControlFrame({ type: 'projection', sessionId: S1, + key: 'sessionListMetadata', value: { blank: true, lastPromptAt: null }, seq: 7 }) + expect(manager.getListSnapshot().items[0]).toMatchObject({ blank: false, updatedAt: 1200 }) + } finally { + await manager.dispose() + } + }) + it('fills missing durable links without overwriting established rows and projects first-send engagement', async ({ mock, remote }) => { const manager = makeManager(mock, remote) remote.session.list.mockResolvedValueOnce(ok({ items: [ @@ -146,6 +190,39 @@ describe('list lifecycle', () => { } }) + it('keeps an opened conversation out of blank-session reuse after a stale list refresh', async ({ mock, remote }) => { + const stale = { + ...summary(S1, { blank: true, cwd: '/workspace' }), + projections: { asOfSeq: 2, values: { + sessionListMetadata: { blank: true, lastPromptAt: null }, + } }, + } + remote.session.list.mockResolvedValue(ok({ items: [stale] as never[] })) + const manager = makeManager(mock, remote) + try { + await manager.refreshList() + const session = manager.get(S1) + expect(session.getSnapshot().blank).toBe(true) + + // History opening can be newer than the metadata-only list cache. + session.projections.seed({ asOfSeq: SessionSeq(61), values: { + sessionListMetadata: { blank: false, lastPromptAt: 1200 }, + } }) + await vi.waitFor(() => { + expect(manager.getListSnapshot().items[0]).toMatchObject({ blank: false, updatedAt: 1200 }) + expect(session.getSnapshot().blank).toBe(false) + }) + + await manager.refreshList() + expect(manager.getListSnapshot().items[0]).toMatchObject({ blank: false, updatedAt: 1200 }) + expect(session.getSnapshot().blank).toBe(false) + manager.handleSessionAdded(stale) + expect(session.getSnapshot().blank).toBe(false) + } finally { + await manager.dispose() + } + }) + it('projects cold Session additions and an empty-cut control baseline before history exists', async ({ mock, remote }) => { const manager = makeManager(mock, remote) try { diff --git a/packages/api/session-controller/tests/session.client.spec.ts b/packages/api/session-controller/tests/session.client.spec.ts index 42ee4b94ff..fd49737fb1 100644 --- a/packages/api/session-controller/tests/session.client.spec.ts +++ b/packages/api/session-controller/tests/session.client.spec.ts @@ -58,6 +58,15 @@ describe('Session open', () => { expect(session.getSnapshot()).toMatchObject({ blank: false, running: true }) }, COLD_BOOT_TIMEOUT_MS) + it('rejects a blank list hint when current metadata records a started conversation', async ({ mock, start }) => { + const session = await sessionBench(mock, start, SID) + session.projections.apply('sessionListMetadata', { blank: false, lastPromptAt: 1200 }, SessionSeq(8)) + + session.handleBlank(true) + + expect(session.getSnapshot()).toMatchObject({ blank: false, promptAttempted: false, running: false }) + }) + it('installs the tail page: cold → loading → open with window and nodes in place', async ({ mock, start }) => { const session = await sessionBench(mock, start, SID) const page = plainTurn(SessionSeq(10), 3, '问', '答') @@ -262,6 +271,88 @@ describe('paging', () => { expect(mock.log.requests()).toHaveLength(sent) }) + it('publishes jump pages together while live events remain visible', async ({ mock, start }) => { + const oldest = plainTurn(SessionSeq(0), 0, 'old', 'answer') + const middle = plainTurn(SessionSeq(6), 1, 'middle', 'answer') + const newest = plainTurn(SessionSeq(12), 2, 'new', 'answer') + const session = await sessionBench(mock, start, SID) + mock.stream(FOLLOW, followScript(history(newest, true))) + await session.open() + const secondRequested = Promise.withResolvers() + const lastPage = Promise.withResolvers>() + mock.remote.session.page.mockImplementation(pageRule((request) => { + if (request.beforeSeq === 12) return history(middle, true) + secondRequested.resolve(undefined) + return lastPage.promise + })) + const changes: string[] = [] + onTestFinished(session.eventSource.subscribe(() => { changes.push(session.eventSource.getSnapshot().change.kind) })) + const jump = session.loadThrough(SessionSeq(0)) + await secondRequested.promise + expect(changes).toEqual([]) + expect(eventSeqs(session)).toEqual(newest.map(event => event.seq)) + expect(session.getSnapshot().loadingOlder).toBe(true) + + await pushEvent(mock, ev.turnStart(SessionSeq(18), 3)) + expect(changes).toEqual(['append']) + expect(eventSeqs(session)).toEqual([...newest.map(event => event.seq), 18]) + const nearerJump = session.loadThrough(SessionSeq(6)) + expect(nearerJump).toBe(jump) + + lastPage.resolve(history(oldest)) + await jump + expect(changes).toEqual(['append', 'prepend']) + expect(eventSeqs(session)).toEqual(Array.from({ length: 19 }, (_, index) => index)) + expect(session.eventSource.getSnapshot().change).toMatchObject({ + kind: 'prepend', entries: entries([...oldest, ...middle]), + }) + expect(session.getSnapshot()).toMatchObject({ loadingOlder: false, hasMore: false }) + }) + + it('publishes the successful jump prefix once when a later page fails', async ({ mock, start }) => { + const middle = plainTurn(SessionSeq(6), 1, 'middle', 'answer') + const newest = plainTurn(SessionSeq(12), 2, 'new', 'answer') + const session = await sessionBench(mock, start, SID) + mock.stream(FOLLOW, followScript(history(newest, true))) + await session.open() + const changed = vi.fn() + onTestFinished(session.eventSource.subscribe(changed)) + mock.remote.session.page.mockImplementation(pageRule(request => request.beforeSeq === 12 + ? history(middle, true) + : err(new RemoteError('gateway/internal', 'page unavailable', {})))) + + await session.loadThrough(SessionSeq(0)) + + expect(changed).toHaveBeenCalledOnce() + expect(eventSeqs(session)).toEqual([...middle, ...newest].map(event => event.seq)) + expect(session.getSnapshot()).toMatchObject({ loadingOlder: false, hasMore: true }) + }) + + it('discards buffered jump pages when the Session replaces its stream', async ({ mock, start }) => { + const newest = plainTurn(SessionSeq(12), 2, 'new', 'answer') + const session = await sessionBench(mock, start, SID) + mock.stream(FOLLOW, followScript(history(newest, true))) + await session.open() + const secondRequested = Promise.withResolvers() + const lastPage = Promise.withResolvers>() + mock.remote.session.page.mockImplementation(pageRule((request) => { + if (request.beforeSeq === 12) return history(plainTurn(SessionSeq(6), 1, 'middle', 'answer'), true) + secondRequested.resolve(undefined) + return lastPage.promise + })) + const changes: string[] = [] + onTestFinished(session.eventSource.subscribe(() => { changes.push(session.eventSource.getSnapshot().change.kind) })) + const jump = session.loadThrough(SessionSeq(0)) + await secondRequested.promise + const replacement = session.resync() + lastPage.resolve(history(plainTurn(SessionSeq(0), 0, 'old', 'answer'))) + await Promise.all([jump, replacement]) + + expect(changes).toEqual(['replace']) + expect(eventSeqs(session)).toEqual(newest.map(event => event.seq)) + expect(session.getSnapshot().loadingOlder).toBe(false) + }) + it('loadThrough retargets a running jump to the lowest requested seq and shares its completion', async ({ mock, start }) => { const oldest = plainTurn(SessionSeq(0), 0, 'a', 'b') const middle = plainTurn(SessionSeq(6), 1, 'c', 'd') diff --git a/packages/boot/app-boot/src/profile.ts b/packages/boot/app-boot/src/profile.ts index d187f3c10a..d5c0a32ff7 100644 --- a/packages/boot/app-boot/src/profile.ts +++ b/packages/boot/app-boot/src/profile.ts @@ -166,7 +166,6 @@ export const DEFAULT_PROFILE_BUNDLES: readonly string[] = ['@deepseek-ai/dsh-bas */ export const OPTIONAL_BUNDLES: readonly string[] = [ '@deepseek-ai/dsh-experimental-agent-team-profile', - '@deepseek-ai/dsh-experimental-agent-team-web-profile', ] const PROFILE_PATCH_TEMPLATE = `# Your patch layer for this dsh profile, applied after every bundle layer: diff --git a/packages/client/ui-chat/README.i18n.yaml b/packages/client/ui-chat/README.i18n.yaml index 78519947ae..c9bc9c511d 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: 92c71663e9193e24657a31e57204a9bf076e19c3 -README.zh.md: 702042384b81d028b7db40275e5ee705dc949fb2 +README.md: 95066b18f125df32a63d4ba6336bd0e83e0518a2 +README.zh.md: f4697aac4f8ef9c19fe05449affb142ac4e84060 diff --git a/packages/client/ui-chat/README.md b/packages/client/ui-chat/README.md index 92c71663e9..95066b18f1 100644 --- a/packages/client/ui-chat/README.md +++ b/packages/client/ui-chat/README.md @@ -72,7 +72,18 @@ Settings → General exposes a persisted, localized `Normal` / `Compact` convers ## Scroll ownership -Chat restores semantic anchors across history prepend and renderer remounts. Pinned scroll deliveries without reader movement update follow ownership immediately, before subsequent layout changes can invalidate their floor. Reader movement remains pending until the sampling interval or `scrollend`, even inside the follow threshold, so layout growth cannot erase small scroll gestures. While the reader is pinned to the floor, `ResizeObserver` follows the new floor and selects the latest loaded Turn without reading row geometry. Once the reader moves away, flow-height changes preserve the top position and the reading-line geometry selects the active Turn. Turn-rail previews paint above sticky Markdown code-block banners, while the rail frame remains inside the transcript band above the composer. +Chat restores semantic anchors across history prepend and renderer remounts. Pinned scroll deliveries without reader movement update follow ownership immediately, before subsequent layout changes can invalidate their floor. Reader movement remains pending until the sampling interval or `scrollend`, even inside the follow threshold, so layout growth cannot erase small scroll gestures. Submitting transcript input or steering immediately restores tail following and clears an older pending reader sample. While the reader is pinned to the floor, `ResizeObserver` follows the new floor and selects the latest loaded Turn without reading row geometry. Once the reader moves away, flow-height changes preserve the top position and the reading-line geometry selects the active Turn. Turn-rail previews paint above sticky Markdown code-block banners, while the rail frame remains inside the transcript band above the composer. + +The turn rail mounts only visible marks, overscan, and the focused mark's neighbors. Its fixed pitch and observed viewport size determine scroll offsets without reading the DOM scroll extent. Initial placement waits for the body's restored active Turn and the rail's first usable viewport size. The ref controls activate a Turn or scroll the rail independently; the transcript itself remains fully mounted. + +While the pointer is outside the rail, automatic follow keeps the rail still when the active mark's center is inside the fade-free band and centers it after it leaves that band. Previews follow pointer movement or focus; marks scrolling under a stationary pointer do not select another preview. + +
+Scroll implementation — click to expand + +`useChatViewport` owns turn-aware DOM reads, clamped writes, and native events. `useChatReading` owns follow policy, sampled reader input, and semantic memory; `useChatNavigation` owns turn jumps and history-prepend preservation. `useChatScroll` coordinates their committed inputs. Explicit navigation carries its measured landing into reading policy, so it does not rediscover the known target with a hit test. + +
----- diff --git a/packages/client/ui-chat/README.zh.md b/packages/client/ui-chat/README.zh.md index 702042384b..f4697aac4f 100644 --- a/packages/client/ui-chat/README.zh.md +++ b/packages/client/ui-chat/README.zh.md @@ -72,7 +72,18 @@ Assistant 回复结算后,已完成轮次的计时对话框不显示 TTFT 和 ## 滚动归属 -Chat 会在历史前插与 renderer 重新挂载时恢复语义锚点。没有读者移动的贴底滚动事件会立即更新跟随归属,避免后续布局变化使其底部位置失效。读者移动即使位于跟随阈值内,也保持待处理直到采样周期或 `scrollend`,防止布局增长抵消小幅滚动操作。读者跟随底部时,`ResizeObserver` 追随新的底部,并且无需读取行几何就选中最后一个已加载轮次;读者离开底部后,高度变化会保持顶部位置,再由阅读线几何选择活跃轮次。轮次导航预览位于 Markdown 代码块粘性头栏上方,而导航外框始终处于 composer 上方的 transcript 区域内。 +Chat 会在历史前插与 renderer 重新挂载时恢复语义锚点。没有读者移动的贴底滚动事件会立即更新跟随归属,避免后续布局变化使其底部位置失效。读者移动即使位于跟随阈值内,也保持待处理直到采样周期或 `scrollend`,防止布局增长抵消小幅滚动操作。提交正文输入或 steering 时立即恢复跟随底部,并清除先前待处理的读者滚动采样。读者跟随底部时,`ResizeObserver` 追随新的底部,并且无需读取行几何就选中最后一个已加载轮次;读者离开底部后,高度变化会保持顶部位置,再由阅读线几何选择活跃轮次。轮次导航预览位于 Markdown 代码块粘性头栏上方,而导航外框始终处于 composer 上方的 transcript 区域内。 + +轮次轨道只挂载可见刻度、预加载范围及键盘焦点刻度的相邻项。固定间距和观察到的视口尺寸决定滚动偏移,不读取 DOM 滚动总高度。初始定位等待正文恢复活跃轮次,以及轨道首次获得可用视口尺寸。ref 控制接口分别支持激活轮次和仅滚动轨道;正文自身仍完整挂载。 + +指针位于轨道外时,自动跟随在活跃刻度中心处于非渐隐区域内时保持轨道不动,越界后才将其居中。预览随指针移动或焦点切换;刻度在静止指针下滚动不会选中另一个预览。 + +
+滚动实现——点击展开 + +`useChatViewport` 负责识别轮次的 DOM 读取、限制范围后的写入与原生事件。`useChatReading` 负责跟随策略、读者输入采样和语义位置记忆;`useChatNavigation` 负责轮次跳转与历史前插时的位置保持。`useChatScroll` 协调已提交的输入。显式导航把测得的落点交给阅读策略,因此无需通过命中测试重新寻找已知目标。 + +
----- diff --git a/packages/client/ui-chat/package.json b/packages/client/ui-chat/package.json index 46c3422a78..f966be3c21 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:^", + "@tanstack/react-virtual": "^3.14.9", "@types/react": "~18.3.1", "@types/react-dom": "~18.3.0", "react": "^18.2.0", diff --git a/packages/client/ui-chat/src/client/chat/ChatView.tsx b/packages/client/ui-chat/src/client/chat/ChatView.tsx index 19529a727b..83facf3576 100644 --- a/packages/client/ui-chat/src/client/chat/ChatView.tsx +++ b/packages/client/ui-chat/src/client/chat/ChatView.tsx @@ -1,11 +1,10 @@ // An enclosing `[data-conversation-scroll]` owns scrolling when present; // otherwise this view owns it. Each row subscribes to one stable node key. -import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, type ComponentProps } from 'react' +import { memo, useCallback, useEffect, useMemo, useRef, useState, type ComponentProps } from 'react' import type { ConversationTimelineSnapshot, RenderMessageImages, } from '@deepseek-ai/dsh-client-ui-conversation/client' -import type { SessionSeq } from '@deepseek-ai/dsh-session/types' import type { InboxState } from '@deepseek-ai/dsh-agent/types' import { Button, IconChevronDownOutlineRegular, MarkdownDelegateProvider, Modal, @@ -15,117 +14,11 @@ import type { ChatSnapshot } from '../contract/snapshot.ts' import { PendingSteeringBubble, PendingSubmissionBubble } from './MessageItem.tsx' import { ChatNodeSeat } from './ChatNodeSeat.tsx' import { TurnNavigator } from './TurnNavigator.tsx' -import { mergeTurnRailItems, type TurnRailItem } from './turn-rail-items.ts' +import { mergeTurnRailItems } from './turn-rail-items.ts' +import { useChatScroll } from './use-chat-scroll.ts' import { formatRunDuration } from './message-chrome.ts' import css from './ChatView.module.css' -const FOLLOW_THRESHOLD = 24 -const SCROLL_SAMPLE_INTERVAL_MS = 500 - -/** Active column host when present; otherwise the view-local scroller. */ -function scrollerOf(from: HTMLElement): HTMLElement { - return (from.closest('[data-conversation-scroll]')) ?? from -} - -/** Browser shrink clamps and recorded writes do not transfer scroll ownership. */ -function readerMovedScroll(top: number, floor: number, observedTop: number): boolean { - return Math.abs(top - Math.min(observedTop, floor)) > 0.5 -} - -interface PagingAnchor { - /** Stable node/call identity, independent of boundary-spanning group keys. */ - key: string - /** Row top relative to the scrollport after the latest user scroll. */ - top: number -} - -/** Find an already-rendered row without interpolating a selector. */ -function anchorElement(list: HTMLElement, key: string): HTMLElement | null { - for (const row of list.querySelectorAll('[data-chat-anchor-key]:not([hidden])')) { - if (row.dataset.chatAnchorKey === key) return row - } - return null -} - -/** - * Turn owning the row at a scrollport line. Scroll frames are hot, so this - * hit-tests the line first and falls back to one row scan when layout cannot - * answer (jsdom, pre-paint); neither path queries per navigation item. - * @param list - the ChatView list element. - * @param line - viewport y of the reading line. - * @returns the Turn number, or null when no loaded row covers the line. - */ -function turnAtLine(list: HTMLElement, line: number): number | null { - const content = list.getBoundingClientRect() - if (typeof document.elementsFromPoint === 'function' && content.width > 0) { - for (const element of document.elementsFromPoint(content.left + content.width / 2, line)) { - const row = element instanceof HTMLElement ? element.closest('[data-chat-turn]') : null - const turn = Number(row?.dataset.chatTurn) - if (row !== null && list.contains(row) && Number.isSafeInteger(turn)) return turn - } - } - let found: number | null = null - for (const row of list.querySelectorAll('[data-chat-turn]')) { - if (row.getBoundingClientRect().top > line) break - const turn = Number(row.dataset.chatTurn) - if (Number.isSafeInteger(turn)) found = turn - } - return found -} - -/** Row position in scrollport coordinates (viewport-independent). */ -function flowTop(row: HTMLElement, scrollport: HTMLElement): number { - return row.getBoundingClientRect().top - scrollport.getBoundingClientRect().top -} - -/** Select a visible stable node/call identity, falling back only when layout - * has not exposed a visible box yet. */ -function pagingAnchor(list: HTMLElement, scrollport: HTMLElement): HTMLElement | null { - const viewport = scrollport.getBoundingClientRect() - const composer = scrollport.querySelector('[data-composer-seat]') - const visibleBottom = composer?.getBoundingClientRect().top ?? viewport.bottom - // The leading edge preserves nested call identity when it hits a row. - // Chrome/gap misses use logarithmic layout reads over the ordered flex rows. - if (typeof document.elementsFromPoint === 'function' && visibleBottom > viewport.top) { - const content = list.getBoundingClientRect() - const left = Math.max(viewport.left, content.left) - const right = Math.min(viewport.right, content.right) - const x = left + Math.max(0, right - left) / 2 - for (const element of document.elementsFromPoint(x, viewport.top + 1)) { - const row = element instanceof HTMLElement - ? element.closest('[data-chat-anchor-key]') - : null - if (row !== null && list.contains(row)) return row - } - } - const rows = list.querySelectorAll( - '[data-chat-flow] > [data-chat-flow-key]:not(:empty):not([hidden])', - ) - let low = 0 - let high = rows.length - while (low < high) { - const middle = (low + high) >>> 1 - if (rows.item(middle).getBoundingClientRect().bottom > viewport.top) high = middle - else low = middle + 1 - } - const row = rows[low] - return row !== undefined && row.getBoundingClientRect().top < visibleBottom ? row : rows[0] ?? null -} - -type ChatScrollPosition = NonNullable> - -/** Capture a reflow-resistant reader position from the current rendered window. */ -function scrollPosition(list: HTMLElement, scrollport: HTMLElement): ChatScrollPosition | null { - const row = pagingAnchor(list, scrollport) - const anchorKey = row?.dataset.chatAnchorKey - if (row === null || anchorKey === undefined) return null - return { - anchorKey, - anchorTop: flowTop(row, scrollport), - scrollTop: scrollport.scrollTop, - } -} - /** Host/OS refusal text for the file-open dialog; empty throws keep a locale fallback. */ function openFailureMessage(error: unknown, fallback: string): string { const message = error instanceof Error ? error.message : String(error) @@ -301,473 +194,31 @@ export function ChatView({ ) const runningTurnStart = useMemo(() => runningTurnStartTime(timeline), [timeline]) - const listRef = useRef(null) - const columnRef = useRef(null) - // A saved position starts disarmed; the first layout effect synchronously - // restores it and normalizes a floor-clamped position back to following. - const [atBottom, setAtBottom] = useState(() => chatScroll.read() === null) - const atBottomRef = useRef(atBottom) - const scrollSamplePendingRef = useRef(false) - const [, setScrollSampleTick] = useState(0) - const [activeTurn, setActiveTurn] = useState( - () => turnNavigationItems.at(-1)?.turn ?? null, - ) - /** Last position delivered or written on the main thread. */ - const observedTopRef = useRef(0) - /** Paging anchor: semantic row/position at click, updated by reader scrolls - * while the request is pending and restored after the prepend lands. */ - const anchorRef = useRef(null) - /** Unloaded-turn jump in flight: target turn plus its load-through seq. */ - const pendingJumpRef = useRef<{ turn: number; seq: SessionSeq } | null>(null) - /** Whether the in-flight jump already landed mid-paging (settle then only corrects an untouched landing). */ - const jumpLandedRef = useRef(false) - const [busyJumpTurn, setBusyJumpTurn] = useState(null) - /** Bumped when a loadThrough completion settles, after its last page's commit. */ - const [jumpSettleTick, setJumpSettleTick] = useState(0) - /** Window head at the last settle-time repage; an unmoved head falls back instead of repaging forever. */ - const jumpRepageHeadRef = useRef(null) - const firstSeqRef = useRef(null) - const openedRef = useRef(false) - const lastKeyRef = useRef(null) - const lastSteeringIdRef = useRef(null) - const lastSubmissionIdRef = useRef(null) - /** Flow tip signature — follow-scroll only when this moves, never on a - * scroll-driven at-bottom chrome re-render (which would snap inertial - * scrolls the rest of the way to the floor). */ - const followSigRef = useRef(null) - const firstKey = order[0] const firstSeq = firstKey === undefined ? null : nodeStore.get(firstKey)?.anchorSeq ?? null const lastKey = order.at(-1) ?? null - const lastNode = lastKey === null ? undefined : nodeStore.get(lastKey) - const lastSteeringId = pendingSteering[pendingSteering.length - 1]?.id ?? null - const lastSubmissionId = visibleSubmissions[visibleSubmissions.length - 1]?.requestId ?? null - const followSig = `${openState}:${firstSeq}:${lastKey}:${order.length}:${running ? 1 : 0}:${lastSteeringId ?? ''}:${lastSubmissionId ?? ''}` - - const syncActiveTurn = useCallback((): void => { - if (scrollSamplePendingRef.current) return - const local = listRef.current - const first = turnNavigationItems[0] - if (local === null || first === undefined) { - setActiveTurn(null) - return - } - const el = scrollerOf(local) - if (el.scrollHeight - el.scrollTop - el.clientHeight <= FOLLOW_THRESHOLD + 1) { - const latest = turnNavigationItems.at(-1)?.turn ?? first.turn - setActiveTurn(current => current === latest ? current : latest) - return - } - const readingLine = el.getBoundingClientRect().top + Math.min(96, el.clientHeight * 0.2) - const reading = turnAtLine(local, readingLine) - // No row reaches the line yet: the flow head still owns the mark. Otherwise - // the row's Turn may be one the rail does not offer (all its nodes hidden), - // so the newest offered Turn at or above it owns the mark. - let next = first.turn - if (reading !== null) { - for (const item of turnNavigationItems) { - if (item.turn > reading) break - next = item.turn - } - } - setActiveTurn(current => current === next ? current : next) - }, [turnNavigationItems]) - - const activeTurnRef = useRef<(() => void) | null>(null) - const activeFrameRef = useRef(null) - const scheduleActiveTurn = useCallback((): void => { - if (activeFrameRef.current !== null) return - if (typeof requestAnimationFrame === 'undefined') { - syncActiveTurn() - return - } - activeFrameRef.current = requestAnimationFrame(() => { - activeFrameRef.current = null - syncActiveTurn() - }) - }, [syncActiveTurn]) - - useEffect(() => () => { - if (activeFrameRef.current !== null && typeof cancelAnimationFrame !== 'undefined') { - cancelAnimationFrame(activeFrameRef.current) - } - }, []) - - activeTurnRef.current = scheduleActiveTurn - - useLayoutEffect(() => { - scheduleActiveTurn() - }, [scheduleActiveTurn]) - - const toBottom = (el: HTMLElement): void => { - anchorRef.current = null - // Returning to the live tail supersedes a jump still landing. - pendingJumpRef.current = null - setBusyJumpTurn(current => current === null ? current : null) - el.scrollTop = el.scrollHeight - observedTopRef.current = el.scrollTop - atBottomRef.current = true - setAtBottom(true) - chatScroll.save(null) - setActiveTurn(turnNavigationItems.at(-1)?.turn ?? null) - } - - // Land a row at the reading line and republish scroll-derived state. A - // latest-ref, so navigateToTurn's identity stays stable for the memoized rail. - const landOnRowRef = useRef<(local: HTMLElement, el: HTMLElement, row: HTMLElement, turn: number) => void>( - () => {}, - ) - landOnRowRef.current = (local, el, row, turn) => { - el.scrollTop += flowTop(row, el) - 24 - observedTopRef.current = el.scrollTop - const isAtBottom = el.scrollHeight - el.scrollTop - el.clientHeight <= FOLLOW_THRESHOLD + 1 - atBottomRef.current = isAtBottom - setAtBottom(isAtBottom) - setActiveTurn(turn) - const position = isAtBottom ? null : scrollPosition(local, el) - if (isAtBottom) chatScroll.save(null) - else if (position !== null) chatScroll.save(position) - } - - /** - * Land the pending jump once its Turn has a rendered anchor row; false - * while it must keep waiting. Mid-jump landings (`settle` false) keep the - * jump armed with the target row as the paging anchor, so later chunks and - * the load-earlier button's unmount re-land on the same row; the settling - * call clears the jump. - */ - const realizePendingJump = (local: HTMLElement, el: HTMLElement, settle: boolean): boolean => { - const pending = pendingJumpRef.current - if (pending === null) return true - const item = railItems.find(candidate => candidate.turn === pending.turn) - if (item === undefined || item.anchor.kind !== 'loaded') return false - const row = anchorElement(local, item.anchor.key) - if (row === null) return false - if (settle) { - pendingJumpRef.current = null - setBusyJumpTurn(null) - const held = anchorRef.current - const landedEarlier = jumpLandedRef.current - jumpLandedRef.current = false - anchorRef.current = null - // A reader who moved off an already-landed target mid-jump keeps their - // place; a first landing, or an untouched one, takes the correction. - if (!landedEarlier || held?.key === item.anchor.key) { - landOnRowRef.current(local, el, row, pending.turn) - } - return true - } - landOnRowRef.current(local, el, row, pending.turn) - jumpLandedRef.current = true - anchorRef.current = { key: item.anchor.key, top: flowTop(row, el) } - return true - } - - useLayoutEffect(() => { - if (scrollSamplePendingRef.current) return - const local = listRef.current - /* v8 ignore next -- ref-null guard: React attaches the ref before layout effects run. */ - if (local === null) return - const el = scrollerOf(local) - // Open completed: jump to the bottom once — unless a scroll position - // survives from a previous mount (view-tab switch away and back), which - // is restored instead of snapping the reader back to the floor. - if (openState === 'open' && !openedRef.current) { - openedRef.current = true - const saved = chatScroll.read() - if (saved === null) { - toBottom(el) - } else { - el.scrollTop = saved.scrollTop - const row = anchorElement(local, saved.anchorKey) - if (row !== null) el.scrollTop += flowTop(row, el) - saved.anchorTop - observedTopRef.current = el.scrollTop - const isAtBottom = el.scrollHeight - el.scrollTop - el.clientHeight <= FOLLOW_THRESHOLD + 1 - atBottomRef.current = isAtBottom - setAtBottom(isAtBottom) - const normalized = isAtBottom ? null : scrollPosition(local, el) - if (isAtBottom) chatScroll.save(null) - else if (normalized !== null) chatScroll.save(normalized) - } - firstSeqRef.current = firstSeq - lastKeyRef.current = lastKey - lastSteeringIdRef.current = lastSteeringId - lastSubmissionIdRef.current = lastSubmissionId - followSigRef.current = followSig - return - } - // Prepend (head seq decreased): preserve the same settled row at the - // position established by the reader's latest scroll. This excludes - // unrelated tail/composer growth while the request was in flight. - if (anchorRef.current !== null && firstSeq !== null && firstSeqRef.current !== null && firstSeq < firstSeqRef.current) { - const anchor = anchorRef.current - anchorRef.current = null - const row = anchorElement(local, anchor.key) - if (row !== null) el.scrollTop += flowTop(row, el) - anchor.top - observedTopRef.current = el.scrollTop - // A jump chunk lands here: scroll to the target once its rows exist; - // until then keep holding the reader's row for the next chunk. - if (!realizePendingJump(local, el, false) && row !== null) { - anchorRef.current = { key: anchor.key, top: flowTop(row, el) } - } - firstSeqRef.current = firstSeq - /* v8 ignore next -- ?? arm: a prepend adds nodes, so the flow list here is never empty. */ - lastKeyRef.current = lastKey - lastSteeringIdRef.current = lastSteeringId - lastSubmissionIdRef.current = lastSubmissionId - followSigRef.current = followSig - return - } - firstSeqRef.current = firstSeq - // Own words must be visible: a new trailing user node force-scrolls - // (send lives in the composer, so arrival is detected here, not armed there). - const appendedUser = lastKey !== lastKeyRef.current && lastNode?.kind === 'user' - const appendedSteering = lastSteeringId !== null && lastSteeringId !== lastSteeringIdRef.current - const appendedSubmission = lastSubmissionId !== null && lastSubmissionId !== lastSubmissionIdRef.current - const tipMoved = followSigRef.current !== followSig - lastKeyRef.current = lastKey - lastSteeringIdRef.current = lastSteeringId - lastSubmissionIdRef.current = lastSubmissionId - followSigRef.current = followSig - // Follow new flow content while pinned; do NOT re-pin on every render - // merely because atBottomRef is true (scroll threshold → setState → snap). - if (appendedUser || appendedSteering || appendedSubmission || (tipMoved && atBottomRef.current)) { - toBottom(el) - return - } - // A jump whose target committed outside the anchored-prepend path (for - // example after a mid-jump toBottom dropped the held anchor) lands here. - if (pendingJumpRef.current !== null) realizePendingJump(local, el, false) + const scroll = useChatScroll({ + ready: openState === 'open', + order, firstSeq, lastKey, running, loadingOlder, hasMore, chatScroll, loadOlder, loadThrough, + lastIsUser: lastKey !== null && nodeStore.get(lastKey)?.kind === 'user', + steeringId: pendingSteering.at(-1)?.id ?? null, + submissionId: visibleSubmissions.at(-1)?.requestId ?? null, + loadedTurns: turnNavigationItems, }) - const onScrollRef = useRef(() => {}) - onScrollRef.current = () => { - const local = listRef.current - /* v8 ignore next -- ref-null guard: the handler only fires while mounted. */ - if (local === null) return - const el = scrollerOf(local) - // Only reader input may make raw scroll geometry change follow ownership: - // a delivered position that deviates from the observed-top ledger (every - // programmatic write records itself there synchronously). This covers - // wheel, touch, scrollbar, and keyboard alike without naming devices. - // Browser shrink-clamps land exactly on the floor min and delayed - // programmatic deliveries land on the ledger itself, so both preserve - // the current ownership state. - const floor = Math.max(0, el.scrollHeight - el.clientHeight) - const movedByReader = readerMovedScroll(el.scrollTop, floor, observedTopRef.current) - const isAtBottom = movedByReader - ? floor - el.scrollTop <= FOLLOW_THRESHOLD + 1 - : atBottomRef.current - if (!movedByReader && isAtBottom) { - toBottom(el) - return - } - atBottomRef.current = isAtBottom - setAtBottom(isAtBottom) - const position = isAtBottom ? null : scrollPosition(local, el) - if (isAtBottom) { - anchorRef.current = null - } else if (anchorRef.current !== null && position !== null) { - anchorRef.current = { key: position.anchorKey, top: position.anchorTop } - } - // Continuous save (unmount happens after ref detach, so saving there is - // too late); pinned-to-bottom clears so a remount keeps following. - if (isAtBottom) chatScroll.save(null) - else if (position !== null) chatScroll.save(position) - observedTopRef.current = el.scrollTop - scheduleActiveTurn() - } - - // Non-reader pinned deliveries must settle before layout growth invalidates - // their floor. Reader movement stays pending even inside the follow threshold, - // so growth cannot erase small gestures before they accumulate off the floor. - useEffect(() => { - const local = listRef.current - /* v8 ignore next -- ref-null guard: effect runs after the list node commits. */ - if (local === null) return - const el = scrollerOf(local) - let sampleTimer: number | undefined - const sample = (): void => { - if (!scrollSamplePendingRef.current) return - scrollSamplePendingRef.current = false - if (sampleTimer !== undefined) window.clearTimeout(sampleTimer) - sampleTimer = undefined - onScrollRef.current() - setScrollSampleTick(tick => tick + 1) - } - const onScroll = (): void => { - scrollSamplePendingRef.current = true - if (atBottomRef.current) { - const floor = Math.max(0, el.scrollHeight - el.clientHeight) - if (!readerMovedScroll(el.scrollTop, floor, observedTopRef.current)) { - sample() - return - } - } - sampleTimer ??= window.setTimeout(sample, SCROLL_SAMPLE_INTERVAL_MS) - } - el.addEventListener('scroll', onScroll, { passive: true }) - el.addEventListener('scrollend', sample, { passive: true }) - return () => { - el.removeEventListener('scroll', onScroll) - el.removeEventListener('scrollend', sample) - if (sampleTimer !== undefined) window.clearTimeout(sampleTimer) - scrollSamplePendingRef.current = false - } - }, []) - - // The ref starts null and is assigned every render, so the placeholder - // initializer a function initial value would need never exists. - const followRef = useRef<(() => void) | null>(null) - followRef.current = () => { - if (scrollSamplePendingRef.current) return - const local = listRef.current - if (local !== null && atBottomRef.current) { - const el = scrollerOf(local) - el.scrollTop = el.scrollHeight - observedTopRef.current = el.scrollTop - chatScroll.save(null) - } - } - // Streaming, tool disclosures, and other flow changes resize the column; - // the sticky composer resizes outside it. This observer owns ChatView's - // dynamic-height follow decisions and writes only while the reader is pinned. - useEffect(() => { - const column = columnRef.current - const local = listRef.current - if (column === null || local === null || typeof ResizeObserver === 'undefined') return - const scrollport = scrollerOf(local) - const composer = scrollport.querySelector('[data-composer-seat]') - // Flow-height changes (image loads, tool disclosures) move rows across the - // reading line without a scroll event, so the active mark resyncs here too. - const observer = new ResizeObserver(() => { - followRef.current?.() - activeTurnRef.current?.() - }) - observer.observe(column) - if (composer !== null) observer.observe(composer) - return () => { observer.disconnect() } - }, []) - - // A failed/empty page leaves the head unchanged. Once the request leaves - // its busy state there is no future prepend for the saved anchor to own. - useEffect(() => { - if (!loadingOlder) anchorRef.current = null - }, [loadingOlder]) - - // Jump settlement: every loadThrough completion bumps the tick after its - // last page's commit, and a plain pull's loadingOlder flip re-settles a - // jump it made wait. A still-pending jump is realized now, held while a - // plain load-earlier pull owns the pager (its completion retries below), - // repaged once per head movement, or landed on the nearest rendered Turn - // at or after the target (failure, exhausted history, or a Turn with no - // visible row). - useEffect(() => { - const pending = pendingJumpRef.current - const local = listRef.current - if (pending === null || local === null) return - const el = scrollerOf(local) - // The settling landing runs after the load-earlier button's unmount - // commit, so the target row cannot drift once the jump clears. - if (realizePendingJump(local, el, true)) return - const uncovered = firstSeq === null || firstSeq > pending.seq - if (uncovered && hasMore) { - // A plain pull owns the pager right now: hold the jump (busy stays) - // instead of degrading to a wrong landing. - if (loadingOlder) return - if (jumpRepageHeadRef.current !== firstSeq) { - jumpRepageHeadRef.current = firstSeq - const held = pagingAnchor(local, el) - if (held !== null && held.dataset.chatAnchorKey !== undefined) { - anchorRef.current = { key: held.dataset.chatAnchorKey, top: flowTop(held, el) } - } - void loadThrough(pending.seq).finally(() => { setJumpSettleTick(tick => tick + 1) }) - return - } - } - for (const row of local.querySelectorAll('[data-chat-turn]:not([hidden])')) { - const turn = Number(row.dataset.chatTurn) - if (!Number.isSafeInteger(turn) || turn < pending.turn) continue - landOnRowRef.current(local, el, row, turn) - break - } - pendingJumpRef.current = null - setBusyJumpTurn(null) - // Snapshot values are read at settle time; the completion tick is the trigger. - }, [jumpSettleTick]) - - // A jump held while a plain pull owned the pager waits in the effect - // above; the pull's completion is its retry signal. - useEffect(() => { - if (!loadingOlder && pendingJumpRef.current !== null) setJumpSettleTick(tick => tick + 1) - }, [loadingOlder]) - - const loadOlderAnchored = (): void => { - const local = listRef.current - /* v8 ignore next -- ref-null guard: the paging button renders inside the list tree. */ - if (local !== null) { - const el = scrollerOf(local) - const row = pagingAnchor(local, el) - if (row !== null && row.dataset.chatAnchorKey !== undefined) { - anchorRef.current = { - key: row.dataset.chatAnchorKey, - top: flowTop(row, el), - } - } - } - loadOlder() - } - - // Identity feeds the memoized rail; a fresh closure per render would defeat it. - const navigateToTurn = useCallback((item: TurnRailItem): void => { - const local = listRef.current - if (local === null) return - const el = scrollerOf(local) - if (item.anchor.kind === 'unloaded') { - // Jumping into history is leaving the live tail: release bottom - // ownership on the click itself, or the pinned-scroll snap (a - // non-reader scroll delivery during the first prepend's compensation) - // would call toBottom and cancel the jump. - atBottomRef.current = false - setAtBottom(false) - // Hold the reader's place through the paging chunks; the layout effect - // lands on the target once its rows commit. - const held = pagingAnchor(local, el) - if (held !== null && held.dataset.chatAnchorKey !== undefined) { - anchorRef.current = { key: held.dataset.chatAnchorKey, top: flowTop(held, el) } - } - pendingJumpRef.current = { turn: item.turn, seq: item.anchor.seq } - jumpRepageHeadRef.current = null - jumpLandedRef.current = false - setBusyJumpTurn(item.turn) - void loadThrough(item.anchor.seq).finally(() => { setJumpSettleTick(tick => tick + 1) }) - return - } - const row = anchorElement(local, item.anchor.key) - if (row === null) return - // A loaded-mark click supersedes any jump still landing. - pendingJumpRef.current = null - setBusyJumpTurn(current => current === null ? current : null) - landOnRowRef.current(local, el, row, item.turn) - // A pending older page still has to compensate the prepended height, so - // navigation moves that anchor to the new position instead of dropping it. - const landed = loadingOlder ? pagingAnchor(local, el) : null - anchorRef.current = landed === null || landed.dataset.chatAnchorKey === undefined - ? null - : { key: landed.dataset.chatAnchorKey, top: flowTop(landed, el) } - }, [loadingOlder, loadThrough]) - return (
-
- -
+
+ {scroll.initialized && ( + + )} +
{openState === 'loading' &&
{t('chat.loadingHistory')}
} {openState === 'error' && openError !== null && (
@@ -776,7 +227,7 @@ export function ChatView({ )} {hasMore && (
-
@@ -825,17 +276,13 @@ export function ChatView({ /> ))}
- {!atBottom && ( + {!scroll.followingTail && (
diff --git a/packages/client/ui-chat/src/client/chat/TurnNavigator.module.css b/packages/client/ui-chat/src/client/chat/TurnNavigator.module.css index 9bf8107b1e..0352ff42d6 100644 --- a/packages/client/ui-chat/src/client/chat/TurnNavigator.module.css +++ b/packages/client/ui-chat/src/client/chat/TurnNavigator.module.css @@ -29,22 +29,21 @@ /* Fixed-pitch marks never compress: a ladder taller than the band scrolls inside this frame instead of pushing marks under the composer or above the scrollport. */ - height: min( - var(--turn-natural-height), + max-height: min( max(0px, calc(var(--turn-rail-band) - 64px)), 420px ); + contain: layout; cursor: pointer; pointer-events: auto; transform: translateY(-50%); - transition: height 220ms cubic-bezier(0.2, 0.8, 0.2, 1); } /* The frame's inner scroller: no visible scrollbar, no scroll chaining into the transcript, and gradient fades over the ends that can still scroll. */ .scroller { - position: absolute; - inset: 0; + position: relative; + max-height: inherit; overflow-y: auto; overscroll-behavior: contain; scrollbar-width: none; @@ -74,57 +73,43 @@ .marks { position: relative; - height: var(--turn-natural-height); } -.markPosition { +.mark { position: absolute; - top: calc(var(--turn-natural-position) + var(--turn-rail-inset)); + top: 0; right: 0; left: 0; height: 10px; - transform: translateY(-50%); - transition: top 220ms cubic-bezier(0.2, 0.8, 0.2, 1); - animation: dsh-turn-mark-enter 150ms ease-out; -} - -/* The rail owns pointer input for the whole column, so a mark is a keyboard - destination that paints one tick — never a mouse target of its own. */ -.mark { - position: absolute; - /* As wide as the longest tick, right-aligned with it: the focus ring below - then wraps the tick instead of the rail's full pointer column. */ - inset: 0 0 0 auto; - width: 20px; padding: 0; border: 0; border-radius: 8px; background: transparent; cursor: pointer; - pointer-events: none; } .mark::before { position: absolute; top: 50%; right: 0; - width: 12px; + width: 20px; height: 2px; border-radius: 2px; background: var(--dsw-alias-border-l4); content: ''; - transform: translateY(-50%); - transition: width 140ms ease, background-color 140ms ease; + transform: translateY(-50%) scaleX(0.6); + transform-origin: right center; + transition: transform 140ms ease, background-color 140ms ease; } /* Ordered before hover/active so those states keep their stronger tick. */ .markUnloaded::before { - width: 8px; + transform: translateY(-50%) scaleX(0.4); opacity: 0.6; } .markPreview::before { - width: 18px; + transform: translateY(-50%) scaleX(0.9); background: var(--dsw-alias-label-tertiary); } @@ -133,7 +118,7 @@ } .markActive::before { - width: 20px; + transform: translateY(-50%) scaleX(1); background: var(--dsw-alias-label-primary); } @@ -141,26 +126,29 @@ the rest of the rail never uses, and a hairline ring keeps it legible against a busy transcript. */ .mark:focus-visible::before { - width: 20px; + transform: translateY(-50%) scaleX(1); background: var(--dsw-alias-state-business-primary); } .mark:focus-visible { + outline: none; +} + +.mark:focus-visible::after { + position: absolute; + inset: 0 0 0 auto; + width: 20px; + border-radius: inherit; outline: 1px solid var(--dsw-alias-state-business-primary); outline-offset: 2px; + content: ''; } .preview { position: absolute; - /* Centered on its mark. Mark positions live in the scrolled ladder, so the - frame-level preview subtracts the scroller's offset, then holds clear of - both frame ends. */ top: clamp( 0px, - calc( - var(--turn-natural-position) + var(--turn-rail-inset) - - var(--turn-scroll-top, 0px) - var(--turn-preview-height) / 2 - ), + calc(var(--turn-preview-center) - var(--turn-preview-height) / 2), calc(100% - var(--turn-preview-height)) ); right: calc(100% + 10px); @@ -198,11 +186,6 @@ -webkit-line-clamp: 3; } -@keyframes dsh-turn-mark-enter { - from { opacity: 0; } - to { opacity: 1; } -} - @keyframes dsh-turn-preview-enter { from { opacity: 0; transform: translateX(4px); } to { opacity: 1; transform: translateX(0); } @@ -223,7 +206,6 @@ @media (prefers-reduced-motion: reduce) { .frame, .scroller, - .markPosition, .mark::before, .markBusy::before, .preview { diff --git a/packages/client/ui-chat/src/client/chat/TurnNavigator.tsx b/packages/client/ui-chat/src/client/chat/TurnNavigator.tsx index f56b3ab3f3..51dfab804f 100644 --- a/packages/client/ui-chat/src/client/chat/TurnNavigator.tsx +++ b/packages/client/ui-chat/src/client/chat/TurnNavigator.tsx @@ -1,7 +1,9 @@ +/** Fixed-pitch virtual turn rail with independent activation and scroll controls. */ import { - memo, useEffect, useId, useRef, useState, - type CSSProperties, type MouseEvent, type PointerEvent, + forwardRef, memo, useCallback, useEffect, useId, useImperativeHandle, useLayoutEffect, useMemo, useRef, useState, + type CSSProperties, type ForwardedRef, } from 'react' +import { defaultRangeExtractor, elementScroll, observeElementOffset, useVirtualizer, type Range } from '@tanstack/react-virtual' import type { ChatViewSlotProps } from '../contract/slots.ts' import type { TurnRailItem } from './turn-rail-items.ts' import css from './TurnNavigator.module.css' @@ -15,6 +17,14 @@ interface TurnNavigatorProps { readonly t: ChatViewSlotProps['t'] } +/** Imperative controls for known turns; unknown turn numbers are ignored. */ +export interface TurnNavigatorHandle { + /** @param turn - turn to activate through the navigation callback. */ + activateTurn(turn: number): void + /** @param turn - turn to center in the rail without navigating the transcript. */ + scrollToTurn(turn: number): void +} + /** Fixed pitch between neighbouring marks; overflow scrolls inside the frame. */ const TURN_SPACING_PX = 10 /** Rail padding above the first mark and below the last one, per end. */ @@ -22,180 +32,247 @@ const RAIL_INSET_PX = 6 /** Fade band the mask reserves at a scrollable end. */ const FADE_PX = 24 -type TurnPositionStyle = CSSProperties & { - readonly '--turn-natural-position': string +function preferredScrollBehavior(): 'auto' | 'smooth' { + return typeof matchMedia === 'function' && matchMedia('(prefers-reduced-motion: reduce)').matches + ? 'auto' + : 'smooth' } -type TurnFrameStyle = CSSProperties & { - readonly '--turn-natural-height': string - readonly '--turn-rail-inset': string - readonly '--turn-scroll-top': string +interface TurnMarkProps extends Pick { + readonly item: TurnRailItem + readonly index: number + readonly active: boolean + readonly busy: boolean + readonly previewId: string | undefined + readonly registerElement: (element: HTMLButtonElement | null) => void + readonly onPreview: (turn: number | null) => void + readonly onFocusChange: (turn: number | null) => void } -function itemPosition(index: number): TurnPositionStyle { - return { '--turn-natural-position': `${String(index * TURN_SPACING_PX)}px` } -} +const TurnMark = memo(function TurnMark({ + item, index, active, busy, previewId, registerElement, onNavigate, onPreview, onFocusChange, t, +}: TurnMarkProps) { + const classes = [css.mark] + if (item.anchor.kind === 'unloaded') classes.push(css.markUnloaded) + if (active) classes.push(css.markActive) + else if (previewId !== undefined) classes.push(css.markPreview) + if (busy) classes.push(css.markBusy) + return ( +
+ ) })}
{preview !== undefined && previewPosition !== undefined && ( -