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-10-compact-tool-details.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-10-compact-tool-details.i18n.yaml new file mode 100644 index 0000000000..484c0c2a3b --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-10-compact-tool-details.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-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-18-agent-teams-single-bundle.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-18-agent-teams-single-bundle.i18n.yaml new file mode 100644 index 0000000000..a005e6d029 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-18-agent-teams-single-bundle.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-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/architecture/2026-09-18-chat-navigation-performance.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-18-chat-navigation-performance.i18n.yaml new file mode 100644 index 0000000000..143d9ccffe --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-18-chat-navigation-performance.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-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-18-messages-input-history-compatibility.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-09-18-messages-input-history-compatibility.i18n.yaml new file mode 100644 index 0000000000..0898823b35 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-09-18-messages-input-history-compatibility.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/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-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-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-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/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-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-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/simplification/2026-09-15-model-agent-availability-and-team-targets.i18n.yaml b/.agents/notes/implemented/simplification/2026-09-15-model-agent-availability-and-team-targets.i18n.yaml new file mode 100644 index 0000000000..165022b7dc --- /dev/null +++ b/.agents/notes/implemented/simplification/2026-09-15-model-agent-availability-and-team-targets.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/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/.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 68345cdb09..a24324072d 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: f7144fb776d114c519de327cbb4c2624a67ab76f -README.zh.md: 1b62ebedfc5cc7e7344f3eea4d690d187bc8db82 +README.md: 72b2d739408569224a42c62719ff6bff0e928ddc +README.zh.md: efe7f4ed8f6304b797f9b2e862083219fe30d814 diff --git a/apps/desktop/README.md b/apps/desktop/README.md index f7144fb776..72b2d73940 100644 --- a/apps/desktop/README.md +++ b/apps/desktop/README.md @@ -265,7 +265,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. @@ -297,7 +297,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 1b62ebedfc..efe7f4ed8f 100644 --- a/apps/desktop/README.zh.md +++ b/apps/desktop/README.zh.md @@ -266,7 +266,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`;最终延迟至少一秒,且不超过上限。这些配置不改变强更策略轮询,也不授权下载重试。 @@ -298,7 +298,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 3910bacd2a..b1c9cebaa9 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) @@ -846,6 +846,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' }) @@ -860,7 +862,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 5b4ae51dff..ad511654fb 100644 --- a/apps/web/tests/chat-scroll-contract.e2e.ts +++ b/apps/web/tests/chat-scroll-contract.e2e.ts @@ -588,7 +588,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 }], @@ -596,31 +596,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 c52f7fd386..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,15 +177,15 @@ 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) - await expect.poll(() => save.isDisabled(), { timeout: 5_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 toggle.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 2be8478dda..87418766f9 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 2956319a5e..636e689ec8 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: e2c9d9bfe22f9a3b86822676f62569b39d3e7d0f -config-catalog.zh.md: d36255cccf920f7d358b4f3dd064c3abdd5dd69d +config-catalog.md: 8033eeb611a33f7634757743417c4bc0e787e016 +config-catalog.zh.md: 706f711e6e2a09b04899c9d5502d6f1130bc6204 diff --git a/docs/config-catalog.md b/docs/config-catalog.md index e2c9d9bfe2..8033eeb611 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -3905,7 +3905,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 d36255cccf..706f711e6e 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -3906,7 +3906,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 ea61de7fe9..71048d044e 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: e6942411a8e342464bce47782f33e3d9e4a06914 -module-graph.zh.md: 992a67df770f5ea83dc5f902e723e876af55cdb4 +module-graph.md: 531e97991ded62406d7e68c7cc3f5286c9764af8 +module-graph.zh.md: fd757d1bdc42c18357a2319619e910ee215d1ea2 diff --git a/docs/module-graph.md b/docs/module-graph.md index e6942411a8..531e97991d 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"] @@ -1383,7 +1382,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 992a67df77..fd757d1bdc 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"] @@ -1385,7 +1384,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 1ce4ba789c..b4d2e147ae 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: 3a95e7ce4d39994d4147c4e5bc1f012fc39343a3 -subagent.zh.md: d10f872a696d65127505aaa725f31c987adb834d +subagent.md: 7a8bafdb5acbd51f19f42ab6906ccf99f1ee715b +subagent.zh.md: b43a9f9fcefcbef0841e2b01a84eb8e4d2846e9c diff --git a/docs/subsystems/subagent.md b/docs/subsystems/subagent.md index 3a95e7ce4d..7a8bafdb5a 100644 --- a/docs/subsystems/subagent.md +++ b/docs/subsystems/subagent.md @@ -266,6 +266,8 @@ A local one-shot provider appends the descriptor inside the child's initial turn ## Durable enumeration: `listChildren()`, `listDescendants()`, and their entries +The model-facing `list_agents` adapter reports current activity as `running` or `inactive`. These values do not describe task completion or guarantee that `send_message` will succeed. + `SubagentRuntime.listChildren(parentSessionId, signal?)` reads the parent's `subagentCatalog` view through a live-preferred Session observation and releases that observation on success or failure. It returns direct-child entries in parent event order without reading child logs or enumerating the Session corpus. Query failures propagate; a missing catalog projection fails explicitly. Browser rows derive membership from the shared projection store and add activity from Session status; the control stream pushes complete catalog updates. `listDescendants()` retains corpus traversal, child-identity diagnostics, and complete `hasChildren` computation. [The parent-catalog Agent Note](../../.agents/notes/implemented/architecture/2026-09-01-parent-owned-subagent-catalog.md) owns creation, fork isolation, ordering, and persistence costs. `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 d10f872a69..b43a9f9fce 100644 --- a/docs/subsystems/subagent.zh.md +++ b/docs/subsystems/subagent.zh.md @@ -266,6 +266,8 @@ interface ContinuableCreateSpec { ## 持久化枚举:`listChildren()`、`listDescendants()` 与其条目 +模型侧的 `list_agents` 适配器将当前活动表示为 `running` 或 `inactive`。这些值不描述任务完成情况,也不保证 `send_message` 会成功。 + `SubagentRuntime.listChildren(parentSessionId, signal?)` 通过优先使用在线 Session 的观察读取父会话的 `subagentCatalog` 视图,并在成功或失败时释放观察。它按父会话事件顺序返回直接子级条目,不读取子级日志,也不枚举 Session 语料库。查询失败直接传播;缺少目录投影时显式失败。浏览器条目从共享 projection store 派生成员关系,并从 Session 状态补充活动状态;control stream 推送完整目录更新。`listDescendants()` 保留语料库遍历、子级身份诊断和完整的 `hasChildren` 计算。[父目录 Agent Note](../../.agents/notes/implemented/architecture/2026-09-01-parent-owned-subagent-catalog.zh.md) 说明创建、fork 隔离、排序和持久化成本。 `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 c957007d84..3be3169100 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: 565e150281335fb10dd6532f3455b81e49605300 -tool-catalog.zh.md: 69c27f4804b371851128827a5df4d73c13fd00c7 +tool-catalog.md: 42ecd052a54f93f4bf1665eed5975cd5ab9b83b3 +tool-catalog.zh.md: 92cf597eb1c124f36fdb4ca7ba336ac6d909103d diff --git a/docs/tool-catalog.md b/docs/tool-catalog.md index 565e150281..42ecd052a5 100644 --- a/docs/tool-catalog.md +++ b/docs/tool-catalog.md @@ -1882,7 +1882,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 only in `descendants` scope. 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 only in `descendants` scope. 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 { @@ -1904,7 +1904,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 { @@ -2017,7 +2017,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": [ @@ -2030,7 +2030,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 { @@ -2043,7 +2043,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 { @@ -2051,7 +2051,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", @@ -2186,7 +2186,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", @@ -2260,7 +2260,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 69c27f4804..92cf597eb1 100644 --- a/docs/tool-catalog.zh.md +++ b/docs/tool-catalog.zh.md @@ -1888,7 +1888,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` 作用域中作为诊断信息报告。`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` 作用域中作为诊断信息报告。`descendants` 作用域会按稳定的前序顺序遍历你下方的整棵树,并为每个条目标注其持久的直接父会话 id 和深度。只有深度为 1 的条目可以使用 `send_message`;更深的条目只能作为 `interrupt_agent` 的候选目标。 ```json { @@ -1910,7 +1910,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 { @@ -2023,7 +2023,7 @@ lsp 工具将提供方选择和语言服务器子进程置于 ctx.lsp 之后, "properties": { "target": { "type": "string", - "description": "Teammate name." + "description": "Teammate target returned by spawn_teammate or list_agents." } }, "required": [ @@ -2036,7 +2036,7 @@ lsp 工具将提供方选择和语言服务器子进程置于 ctx.lsp 之后, ### `list_agents` -列出 Lead 与所有持久 teammate,以及各自当前的运行时状态。 +列出 Lead 与所有持久 teammate,以及可用于寻址的 target 和当前可用状态。inactive 表示没有轮次在执行,不表示任务结果。provisioning 与 failed 描述成员创建状态。 ```json { @@ -2049,7 +2049,7 @@ lsp 工具将提供方选择和语言服务器子进程置于 ctx.lsp 之后, ### `send_message` -向另一名 Team member 发送一条持久消息。running target 会在最近的步骤边界收到消息;idle target 会启动一个 turn;inactive teammate 会冷恢复。 +向另一名 Team member 发送一条持久消息。running target 会在最近的步骤边界收到消息;inactive target 会启动或恢复一个 turn。 ```json { @@ -2057,7 +2057,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", @@ -2192,7 +2192,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", @@ -2266,7 +2266,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/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 d130fc0ee6..554291b103 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: 3899c116b2614a55b0a8ea7f5d5446e91166326c -README.zh.md: 9b404899069fd773dfee9011e8267039376a9445 +README.md: 3575f0ae96b9f5bb831ff7b7facfeac9647cde93 +README.zh.md: 9bc3288078958b1b4bf8373972495ec9b310c394 diff --git a/packages/api/session-controller/README.md b/packages/api/session-controller/README.md index 3899c116b2..3575f0ae96 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. `session.projections` reads one complete baseline through a live-preferred Session observation without activating an Agent. It returns null for a missing Session and serves any registered projection keys. The Client exposes shared values and explicit-read state through `projectionsBySession`; domains select their own keys. Session-list summaries carry `agentAvailable`, updated through existing summary and status events independently of durable projections. Initial reads and live projection frames use the same sequence ordering. +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. Host summary updates replace running and Agent availability; local create/fork responses only fill missing metadata on existing rows. Removed ordinary Sessions retain projection stores only when their catalogs contain children. Projection reads retain loading and failure state independently of their values. Reconnect cancels reads from the previous connection and reloads previously requested projections; live membership updates arrive through the control stream. Parent availability comes from Host summaries and remains unknown until a summary or a successful list baseline establishes it. Address lookup resolves projected children without selecting them or creating scopes. @@ -46,6 +48,8 @@ Fork copies the exact inclusive event prefix selected by `atSeq`, including a cu 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 9b40489906..9bc3288078 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` 时打开默认应用。 `session.projections` 通过一次 live-preferred Session observation 读取完整基线,不激活 Agent。Session 不存在时返回 null,并可提供任意已注册的 projection key。Client 通过 `projectionsBySession` 暴露共享值和显式读取状态,由领域选择自身的 key。Session 列表摘要携带 `agentAvailable`,通过已有摘要与状态事件更新,与持久化 projection 相互独立。初始读取与实时 projection 帧使用相同的序号排序规则。 +Client 列表行和驻留 Session 使用当前 `sessionListMetadata` 投影纠正过期的空白会话提示;最近活动时间取摘要时间戳与投影中最后一次用户提示词时间的较晚值。当 SessionManager 在列表行到达前创建实例时,会使用已保留的该 Session 元数据对账空白状态。因此,即使旧列表响应仍将已有对话标为空白,新会话操作也不会复用已经打开过的对话。 + Client 列表刷新保留未变化的行对象,并在顺序和值均相同时复用条目数组。每行的 `retainedBy` 包含正数的本地引用来源计数,Host 元数据刷新不能覆盖它们。缓存成员检查使用每次刷新构建的 ID 集合,因此对账成本随当前列表和保留缓存的规模线性增长。 Host 摘要更新会替换运行状态与 Agent 可用性;本地 create/fork 响应只补充已有行缺失的元数据。普通 Session 被移除后,仅在目录仍有子项时保留其投影 store。 投影读取独立于其值保留加载与失败状态。重连会取消上一连接的读取,并重新加载已请求的投影;实时成员更新通过 control stream 到达。父 Agent 可用性来自 Host 摘要,在收到对应摘要或成功的列表 baseline 前保持未知。地址查找直接解析投影中的子项,不会选择它们或创建 scope。 @@ -46,6 +48,8 @@ Fork 复制 `atSeq` 所选的精确事件前缀,包含切点事件,允许在 恢复会话时若已有写句柄占用,返回 `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 f16a98a825..56d06d3e43 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 4eeef25ce9..b6061ce2b4 100644 --- a/packages/api/session-controller/src/client/sessions/manager.ts +++ b/packages/api/session-controller/src/client/sessions/manager.ts @@ -219,6 +219,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. */ @@ -242,6 +243,9 @@ export class SessionManager { // durable history, even though child rows do not carry `blank`. session.handleBlank(false) session.handleRunning(false) + } else { + // Retained metadata may have notified before this Session existed. + session.handleBlank(true) } } } @@ -272,9 +276,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 @@ -359,7 +368,7 @@ export class SessionManager { this.listState = 'idle' this.listPhase = 'ready' this.updateParentAvailability() - // 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 @@ -674,8 +683,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 0fb4346bfe..a44c4147d9 100644 --- a/packages/api/session-controller/src/client/sessions/service.ts +++ b/packages/api/session-controller/src/client/sessions/service.ts @@ -39,10 +39,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 1b23423061..cd38a26fcf 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 3484b47ff4..7ee5be5397 100644 --- a/packages/api/session-controller/tests/manager.client.spec.ts +++ b/packages/api/session-controller/tests/manager.client.spec.ts @@ -57,6 +57,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', () => { @@ -105,6 +127,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: [ @@ -126,6 +170,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 && ( -