diff --git a/.agents/notes/implemented/architecture/2026-09-24-schedule-opt-in-optional-bundle.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-24-schedule-opt-in-optional-bundle.i18n.yaml index ae54aff2a9..ca6497cb81 100644 --- a/.agents/notes/implemented/architecture/2026-09-24-schedule-opt-in-optional-bundle.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-09-24-schedule-opt-in-optional-bundle.i18n.yaml @@ -3,17 +3,17 @@ # After editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-24-schedule-opt-in-optional-bundle.md /agent-note-schedule-as-an-opt-in-optional-bundle: - en: d234bf064c5990f1 - zh: c54b941429b4658c + en: 8c2381fd778a9888 + zh: 8877255cb980eea1 /agent-note-schedule-as-an-opt-in-optional-bundle/problem: - en: 2409ea3a006ea5d9 - zh: dbcda58a4c6b5b9f + en: bb23c2f7f93a462b + zh: 9fb600fc8e203323 /agent-note-schedule-as-an-opt-in-optional-bundle/decision: - en: 5aa944749a231645 - zh: c14651be6367ee13 + en: 483d1dd51b9cc073 + zh: b069e2e2385c8132 /agent-note-schedule-as-an-opt-in-optional-bundle/alternatives-considered: - en: 1782c8ecdbe6bf87 - zh: dd6997d067257562 + en: 8e5f4d8fa8c2e78d + zh: 3353c7042c6a1d6b /agent-note-schedule-as-an-opt-in-optional-bundle/consequences: - en: 8ea1d9aaf25f1702 - zh: 32e721347ea55475 + en: 36cd0eb71abeedbe + zh: 7c134095cc19fcfd diff --git a/.agents/notes/implemented/architecture/2026-09-24-schedule-opt-in-optional-bundle.md b/.agents/notes/implemented/architecture/2026-09-24-schedule-opt-in-optional-bundle.md index bd4b47d25a..865f97848d 100644 --- a/.agents/notes/implemented/architecture/2026-09-24-schedule-opt-in-optional-bundle.md +++ b/.agents/notes/implemented/architecture/2026-09-24-schedule-opt-in-optional-bundle.md @@ -4,19 +4,17 @@ Status: implemented English | [中文](2026-09-24-schedule-opt-in-optional-bundle.zh.md) -> Supersedes [Schedule in the shipped Web composition](../../archived/architecture/2026-09-24-web-default-schedule-composition.md): the three rows now ship disabled, and switching them on is this bundle's job. - ## Problem -`packages/bundle/web-app/cordis.patch.yml` inserts `time-context`, `schedule`, and `ui-schedule` with `disabled: true`, so the shipped Web composition exposes no Schedule surface: no `schedule_*` tools, no Session reminder catalog, no Automation tasks page, and no per-step clock reading. The rows have no switch on the product surface; a deployment that wants them edits a profile patch layer or passes a `--patch` overlay, and both are configuration files a person using the shipped Web profile cannot reach. +Schedule adds four tool schemas to every live root Agent request and one durable clock message per eligible step, so the shipped Web composition must not mount it by default. A person using the shipped Web profile still needs a product-surface switch to turn it on; a profile patch layer or a `--patch` overlay is a configuration file that person cannot reach. ## Decision -The rows stay in the composition because an id-targeted patch overrides an existing row: a profile patch or a bundle sets a row's `disabled` to `false` without inserting it again. +`packages/bundle/web-app/cordis.patch.yml` carries none of `time-context`, `schedule`, and `ui-schedule`, so the shipped Web composition exposes no Schedule surface: no `schedule_*` tools, no Session reminder catalog, no Automation tasks page, and no per-step clock reading. -`@deepseek-ai/dsh-experimental-schedule-bundle` (`packages/experimental/schedule-bundle/`) carries three id-targeted patches in its `cordis.patch.yml`, one per row, each setting `disabled: false`; it inserts no row. `OPTIONAL_BUNDLES` in `packages/boot/app-boot/src/profile.ts` names the package, and `apps/cli` declares it as a runtime dependency, so every installation ships it switched off. The [experimental-as-optional-bundles decision](2026-09-21-experimental-capabilities-as-optional-bundles.md) owns the `OPTIONAL_BUNDLES` conventions and the localized `icon` and `meta.title` / `meta.description` metadata the Web Plugins page renders in its Official group. +`@deepseek-ai/dsh-experimental-schedule-bundle` (`packages/experimental/schedule-bundle/`) inserts the three rows in its `cordis.patch.yml` and depends on their packages, as every other optional bundle inserts the rows it ships. `OPTIONAL_BUNDLES` in `packages/boot/app-boot/src/profile.ts` names the package, and `apps/cli` declares it as a runtime dependency, so every installation ships it switched off. The [experimental-as-optional-bundles decision](2026-09-21-experimental-capabilities-as-optional-bundles.md) owns the `OPTIONAL_BUNDLES` conventions and the localized `icon` and `meta.title` / `meta.description` metadata the Web Plugins page renders in its Official group. -Enabling the bundle turns on `time-context` (a per-step clock reading with the sampled instant, the browser zone attached to the open request, and the elapsed time since the preceding model-visible message), `schedule` (durable reminders plus `schedule_create`, `schedule_list`, `schedule_update`, and `schedule_delete` on live root Agents), and `ui-schedule` (the Session reminder catalog and the Automation tasks page). The bundle switches `time-context` on with `schedule` because a reminder request states a wall-clock target: the sampled instant is what lets the model turn a request such as "tomorrow at nine" into an offset-bearing `at` value. The [Schedule subsystem](../../../../docs/subsystems/schedule.md) owns the durable records, delivery, and management operations; the [Web bundle](../../../../packages/bundle/web-app/README.md) owns the composition that carries the disabled rows. Disabling the bundle restores the shipped composition, and the Host stops scheduling while the `schedule` row stays disabled; stored task records remain in the Schedule domain. +Enabling the bundle inserts `time-context` (a per-step clock reading with the sampled instant, the browser zone attached to the open request, and the elapsed time since the preceding model-visible message), `schedule` (durable reminders plus `schedule_create`, `schedule_list`, `schedule_update`, and `schedule_delete` on live root Agents), and `ui-schedule` (the Session reminder catalog and the Automation tasks page). The bundle carries `time-context` with `schedule` because a reminder request states a wall-clock target: the sampled instant is what lets the model turn a request such as "tomorrow at nine" into an offset-bearing `at` value. The [Schedule subsystem](../../../../docs/subsystems/schedule.md) owns the durable records, delivery, and management operations; the [Web bundle](../../../../packages/bundle/web-app/README.md) owns the composition the bundle adds the rows to. Disabling the bundle restores the shipped composition, and the Host stops scheduling while the `schedule` row is absent; stored task records remain in the Schedule domain. ## Alternatives considered @@ -24,11 +22,14 @@ Enabling the bundle turns on `time-context` (a per-step clock reading with the s **Keep a `--patch` overlay file.** An overlay is a launch-time argument rather than a product-surface switch, so a person using the shipped Web profile cannot reach it. Re-declaring the rows through its `insert` list does not override them: `applyEntryPatches` appends that list without de-duplicating ids, and the Loader collapses the composed list to one entry per id with the last declaration winning, so the overlay replaces the Web bundle's rows instead of setting fields on them. -**Extract the three rows out of the Web composition into the bundle.** The bundle would insert the rows instead of overriding them, so a profile that also declares one of those ids would have two declarations for it, of which the Loader keeps only the last. The disabled rows are what an override-style switch targets. +**Keep the rows in the Web composition with `disabled: true` and switch them on with id-targeted patches.** The bundle would then set fields on existing rows, but the plugin manager lists only the rows a bundle inserts, so the bundle's page reported no components and needed a second listing path for overridden rows, while every other optional bundle inserts its rows. A profile that also declares one of the ids resolves the same way as for any other bundle: the Loader keeps the last declaration. + +**Lock the three rows to the bundle switch.** A bundle manifest field that makes the plugin manager refuse to switch the rows one by one adds a plugin-manager concept only this bundle would use. The bundle's page offers a switch per row, as for every other bundle, and the bundle README records that the three rows work only together. ## Consequences - A default `dsh web` session carries no Schedule tool schemas, no Session reminder catalog, no Automation tasks page, and no per-step clock reading. An installation that wants them enables the optional bundle from the Plugins page or lists the package in a profile's `dsh.profile.bundles`. - An enabled installation adds four tool schemas to every live root Agent and one durable clock message per eligible step; the reading is model-visible and durable, so it replays, compacts, and appears in exported Session logs like any other user message. -- A composition that does not carry the three Web rows reports one `patch: entry not found` warning per patch and mounts nothing from this bundle, and its package declares no plugin dependency, so enabling it cannot mount a Host service twice. +- The Plugins page lists the three rows under the bundle with the titles their packages' `locale/*.json` declare, their state, and, while the bundle is on, a switch per row; switching one of them off leaves Schedule without that part. +- A profile patch or `--patch` overlay that targets one of the three ids matches no row while the bundle is not selected, and the loader warns `patch: entry not found` for it; selecting the bundle replaces switching the rows on by id. - The switch is configuration-only: `src/index.ts` is an empty module, the patch carries the runtime content, and the package owns no mutable runtime state, so it publishes no invariant companion. diff --git a/.agents/notes/implemented/architecture/2026-09-24-schedule-opt-in-optional-bundle.zh.md b/.agents/notes/implemented/architecture/2026-09-24-schedule-opt-in-optional-bundle.zh.md index eaa59aa17d..a90516606a 100644 --- a/.agents/notes/implemented/architecture/2026-09-24-schedule-opt-in-optional-bundle.zh.md +++ b/.agents/notes/implemented/architecture/2026-09-24-schedule-opt-in-optional-bundle.zh.md @@ -4,19 +4,17 @@ Status: implemented [English](2026-09-24-schedule-opt-in-optional-bundle.md) | 中文 -> 取代[默认 Web 组合中的 Schedule](../../archived/architecture/2026-09-24-web-default-schedule-composition.md):这三行现在默认禁用,打开它们是此 bundle 的职责。 - ## Problem -`packages/bundle/web-app/cordis.patch.yml` 以 `disabled: true` 插入 `time-context`、`schedule` 与 `ui-schedule`,因此随发行版交付的 Web 组合不提供任何定时能力:没有 `schedule_*` 工具、没有 Session 提醒目录、没有自动化任务页面,也没有逐步时钟读数。这些条目在产品界面上没有开关;想要它们的部署要改 profile patch 层或传 `--patch` overlay,而两者都是使用随发行版 Web profile 的人无法触及的配置文件。 +Schedule 会给每个活跃根 Agent 的请求增加四个工具 schema,并在每个符合条件的步骤追加一条持久时钟消息,因此随发行版交付的 Web 组合不能默认挂载它。使用随发行版 Web profile 的人仍需要产品界面上的开关来打开它;profile patch 层或 `--patch` overlay 都是这个人无法触及的配置文件。 ## Decision -这三行仍留在组合中,因为按 id 定位的 patch 覆盖的是已存在的行:profile patch 或 bundle 把某行的 `disabled` 设为 `false`,无需再次插入它。 +`packages/bundle/web-app/cordis.patch.yml` 不含 `time-context`、`schedule` 与 `ui-schedule` 中的任何一行,因此随发行版交付的 Web 组合不提供任何定时能力:没有 `schedule_*` 工具、没有 Session 提醒目录、没有自动化任务页面,也没有逐步时钟读数。 -`@deepseek-ai/dsh-experimental-schedule-bundle`(`packages/experimental/schedule-bundle/`)在其 `cordis.patch.yml` 中携带三个按 id 定位的 patch,每行一个,各自把 `disabled` 设为 `false`;它不插入任何行。`packages/boot/app-boot/src/profile.ts` 的 `OPTIONAL_BUNDLES` 列出该包,`apps/cli` 将其声明为运行时依赖,因此每次安装都随包携带且默认关闭。[实验性能力作为可选 bundle 的决策](2026-09-21-experimental-capabilities-as-optional-bundles.zh.md)负责 `OPTIONAL_BUNDLES` 的约定,以及 Web 插件管理页在 Official 分组中渲染的本地化 `icon` 与 `meta.title` / `meta.description` 元数据。 +`@deepseek-ai/dsh-experimental-schedule-bundle`(`packages/experimental/schedule-bundle/`)在其 `cordis.patch.yml` 中插入这三行并依赖它们的包,与其他可选 bundle 插入各自随附的行相同。`packages/boot/app-boot/src/profile.ts` 的 `OPTIONAL_BUNDLES` 列出该包,`apps/cli` 将其声明为运行时依赖,因此每次安装都随包携带且默认关闭。[实验性能力作为可选 bundle 的决策](2026-09-21-experimental-capabilities-as-optional-bundles.zh.md)负责 `OPTIONAL_BUNDLES` 的约定,以及 Web 插件管理页在“官方”分组中渲染的本地化 `icon` 与 `meta.title` / `meta.description` 元数据。 -启用该 bundle 会打开 `time-context`(每个步骤的时钟读数,包含采样瞬时、附加到当前开放请求的浏览器时区,以及自前一条模型可见消息以来的经过时长)、`schedule`(持久提醒,以及每个活跃根 Agent 上的 `schedule_create`、`schedule_list`、`schedule_update` 与 `schedule_delete`),以及 `ui-schedule`(Session 提醒目录与自动化任务页面)。该 bundle 把 `time-context` 与 `schedule` 一起打开,因为提醒请求给出的是挂钟时间目标:采样瞬时正是让模型把“明天九点”这类请求转换成带偏移量的 `at` 值的依据。持久记录、投递与管理操作由 [Schedule 子系统](../../../../docs/subsystems/schedule.zh.md)负责;承载这些禁用行的组合由 [Web bundle](../../../../packages/bundle/web-app/README.zh.md)负责。禁用该 bundle 会恢复随发行版交付的组合,`schedule` 行保持禁用期间 Host 不再调度;已存储的任务记录保留在 Schedule domain 中。 +启用该 bundle 会插入 `time-context`(每个步骤的时钟读数,包含采样瞬时、附加到当前开放请求的浏览器时区,以及自前一条模型可见消息以来的经过时长)、`schedule`(持久提醒,以及每个活跃根 Agent 上的 `schedule_create`、`schedule_list`、`schedule_update` 与 `schedule_delete`)与 `ui-schedule`(Session 提醒目录与自动化任务页面)。该 bundle 把 `time-context` 与 `schedule` 一起携带,因为提醒请求给出的是挂钟时间目标:采样瞬时正是让模型把“明天九点”这类请求转换成带偏移量的 `at` 值的依据。持久记录、投递与管理操作由 [Schedule 子系统](../../../../docs/subsystems/schedule.zh.md)负责;该 bundle 向其加入这些行的组合由 [Web bundle](../../../../packages/bundle/web-app/README.zh.md) 负责。禁用该 bundle 会恢复随发行版交付的组合,`schedule` 行不存在期间 Host 不再调度;已存储的任务记录保留在 Schedule domain 中。 ## Alternatives considered @@ -24,11 +22,14 @@ Status: implemented **保留一个 `--patch` overlay 文件。** overlay 是启动时的参数,而不是产品界面上的开关,因此使用随发行版 `web` profile 的人无法触及它。通过它的 `insert` 列表重新声明这些条目并不能覆盖它们:`applyEntryPatches` 追加该列表时不对 id 去重,而 Loader 会把组合出的列表按 id 收敛为一个条目、最后一个声明生效,因此该 overlay 会替换 Web Bundle 的条目,而不是给它们设值。 -**把这三行从 Web 组合中抽出、移入 bundle。** 这样 bundle 会插入这些行而不是覆盖它们;某个 profile 若也声明了这些 id,同一个 id 就会有两份声明,而 Loader 只保留最后一份。按需开关针对的正是这些禁用行。 +**把这三行以 `disabled: true` 留在 Web 组合中,用按 id 定位的 patch 打开。** 这样 bundle 只是给已有的行设值,但插件管理器只列出 bundle 插入的行,因此该 bundle 的页面显示不含任何组件,需要为被覆盖的行另建一条列出路径;而其他可选 bundle 都插入各自的行。某个 profile 若也声明了其中某个 id,处理方式与任何其他 bundle 相同:Loader 保留最后一份声明。 + +**把这三行锁定在 bundle 开关上。** 让插件管理器拒绝逐行开关的 bundle 清单字段,会给插件管理器增加一个只有这个 bundle 使用的概念。该 bundle 的页面像其他所有 bundle 一样为每行提供开关,由 bundle README 说明这三行只能一起工作。 ## Consequences - 默认的 `dsh web` 会话不带 Schedule 工具 schema、Session 提醒目录、自动化任务页面与逐步时钟读数。需要它们的安装可从插件管理页启用该可选 bundle,或把该包列进 profile 的 `dsh.profile.bundles`。 - 启用后的安装会给每个活跃根 Agent 增加四个工具 schema,并在每个符合条件的步骤追加一条持久时钟消息;该读数对模型可见且持久,因此它与其他 user 消息一样参与回放、压缩,并出现在导出的 Session 日志中。 -- 在不携带这三行 Web 行的组合中,该 bundle 的每个补丁都会报告一条 `patch: entry not found` 警告且不挂载任何东西;其包不声明插件依赖,因此启用它不会重复挂载同一个 Host 服务。 +- 插件管理页在该 bundle 下列出这三行,显示各自包的 `locale/*.json` 声明的标题、它们的状态,并在 bundle 打开时为每行提供开关;关掉其中一行会让 Schedule 缺少这一部分。 +- 未选中该 bundle 时,按 id 定位这三行之一的 profile 补丁或 `--patch` overlay 匹配不到任何条目,加载器会为它报告 `patch: entry not found` 警告;选中该 bundle 即可取代按 id 打开这些行。 - 该开关只涉及配置:`src/index.ts` 是空模块,运行时内容由 patch 承载,包本身不拥有可变的运行时状态,因此不发布不变量伴随模块。 diff --git a/.agents/notes/implemented/bug-fix/2026-09-19-failed-step-tool-results.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-09-19-failed-step-tool-results.i18n.yaml new file mode 100644 index 0000000000..727bb883f4 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-09-19-failed-step-tool-results.i18n.yaml @@ -0,0 +1,19 @@ +# Bilingual-pair consistency record for 2026-09-19-failed-step-tool-results.md (docs/i18n/README.md): per heading +# section, a hash of its English and Chinese blocks outside code blocks and generated regions. +# 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-19-failed-step-tool-results.md +/agent-note-record-unanswered-tool-results-before-closing-a-failed-step: + en: 8fd2a50a041b1b18 + zh: 01aed07a6425a322 +/agent-note-record-unanswered-tool-results-before-closing-a-failed-step/problem: + en: 1fa7525f83af9035 + zh: 401ce968e3dd5434 +/agent-note-record-unanswered-tool-results-before-closing-a-failed-step/decision: + en: 3d58367a05183b4a + zh: f9ccc3a7011a6240 +/agent-note-record-unanswered-tool-results-before-closing-a-failed-step/alternatives-considered: + en: 37da4d9c8533b736 + zh: e52a59763ae2b62e +/agent-note-record-unanswered-tool-results-before-closing-a-failed-step/consequences: + en: 1ea72421a98551d8 + zh: c01e02ddac8a3454 diff --git a/.agents/notes/implemented/bug-fix/2026-09-19-failed-step-tool-results.md b/.agents/notes/implemented/bug-fix/2026-09-19-failed-step-tool-results.md new file mode 100644 index 0000000000..80c813598e --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-09-19-failed-step-tool-results.md @@ -0,0 +1,33 @@ +# Agent Note: Record unanswered tool results before closing a failed step + +Status: implemented + +English | [中文](2026-09-19-failed-step-tool-results.zh.md) + +## Problem + +A committed assistant message can request tools whose scheduler fails before all results are recorded. Closing the step and turn with those calls unanswered makes later model requests invalid for providers that require paired tool calls and results. An error turn is already closed, so crash-tail repair cannot recover it on resume. + +## Decision + +The AgentLoop driver records recovery results for unanswered calls before it closes a failed step, then propagates the original failure. Live step recovery and `interruptedTurnClosers` share incremental pending-call tracking and result construction; the live driver still owns its step and turn events. A step-local observer retains pending identities from committed events without copying session history. The scheduler stops replenishment and drains started dispatches before rejecting, so result recovery cannot race a live dispatch. + +Recovery preserves committed results and follows the assistant's call order. A recorded `tool/call` without a result receives `TOOL_OUTCOME_UNKNOWN`; its text warns against blindly retrying an operation with possible side effects. An assistant request with no call record receives `TOOL_NOT_STARTED`. A call record precedes preparation and does not establish that the tool body ran, so unknown outcomes remain conservative. + +This decision supersedes only the missing-result policy in [parallel tool-call execution](../feature/2026-07-10-parallel-tool-call-execution.md). Its classification, concurrency limit, ordering, and quiescence rules remain applicable. + +## Alternatives considered + +**Leave calls unanswered to avoid inventing tool outcomes.** An unanswered call invalidates later provider requests. An explicit unknown-outcome error records the limit of the available evidence without claiming success, cancellation, or absence of side effects. + +**Repair only when resuming or forking.** The ordinary failure path already records `step/end` and `turn/end`; open-tail repair intentionally preserves closed history. Recovery belongs before those records, while the driver owns the failed step. + +**Repair inside the tool scheduler alone.** Failure can occur after the assistant message is recorded but before scheduling starts. The step driver covers that interval and reuses the existing session repair semantics rather than adding a second implementation of call/result matching. + +**Retry automatically.** A dispatch can have side effects even when its result is missing. The error result supplies retry guidance, while the turn retains the original failure and waits for later work. + +## Consequences + +Later requests, resume, and fork retain a usable call/result history after a live step failure. Recovery appends results; it does not remove assistant messages, replace completed results, execute tools, or convert the failed turn into success. The log uses existing event types and recovery codes, with no format change. + +Already-closed inconsistent historical turns remain unchanged. The live recovery path requires the session to accept the missing result records; it cannot promise recovery from a failed log append. diff --git a/.agents/notes/implemented/bug-fix/2026-09-19-failed-step-tool-results.zh.md b/.agents/notes/implemented/bug-fix/2026-09-19-failed-step-tool-results.zh.md new file mode 100644 index 0000000000..7d69b46a9a --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-09-19-failed-step-tool-results.zh.md @@ -0,0 +1,33 @@ +# Agent Note: 在关闭失败步骤前补齐工具结果 + +Status: implemented + +[English](2026-09-19-failed-step-tool-results.md) | 中文 + +## 问题 + +已提交的 assistant 消息可能请求多个工具,而调度器在全部结果记录完成之前失败。如果关闭步骤和轮次时仍有调用尚无结果,后续模型请求就不满足要求工具调用与结果配对的提供方。错误轮次已经关闭,因此恢复会话时的崩溃尾部修复无法恢复它。 + +## 决策 + +AgentLoop 驱动器在关闭失败步骤之前,为尚无结果的调用记录恢复结果,随后传播原始失败。实时步骤恢复和 `interruptedTurnClosers` 共享增量的待处理调用跟踪与结果构造;实时驱动器仍拥有自己的步骤和轮次事件。步骤本地观察器从已提交事件中保留待处理调用标识,不复制会话历史。调度器在拒绝之前停止补充池并排空已启动的派发,因此结果恢复不会与运行中的派发竞争。 + +恢复保留已提交的结果,并遵循 assistant 的调用顺序。已有 `tool/call` 记录但尚无结果的调用获得 `TOOL_OUTCOME_UNKNOWN`;其文本警告不要盲目重试可能存在副作用的操作。没有调用记录的 assistant 请求获得 `TOOL_NOT_STARTED`。调用记录先于准备阶段,不能证明工具主体已经运行,因此未知结果保持保守。 + +本决策仅取代[并行工具调用执行](../feature/2026-07-10-parallel-tool-call-execution.zh.md)中的缺失结果策略。其中的分类、并发上限、顺序和完全停稳规则继续适用。 + +## 备选方案 + +**让调用保持无结果,避免虚构工具执行结果。** 尚无结果的调用会使后续提供方请求无效。显式的未知结果错误记录可用证据的局限,而不声称成功、取消或没有副作用。 + +**仅在恢复或 fork 时修复。** 普通失败路径已经记录 `step/end` 和 `turn/end`;开放尾部修复有意保留已关闭历史。恢复应在这些记录之前执行,此时驱动器仍拥有失败步骤。 + +**仅在工具调度器内部修复。** 失败可能发生在 assistant 消息记录之后、调度开始之前。步骤驱动器覆盖这段区间,并复用已有的会话修复语义,避免另行实现调用/结果匹配。 + +**自动重试。** 即使结果缺失,派发也可能产生副作用。错误结果提供重试指引,而轮次保留原始失败并等待后续工作。 + +## 影响 + +实时步骤失败后,后续请求、恢复和 fork 会保留可用的调用/结果历史。恢复追加结果;它不移除 assistant 消息、不替换已完成结果、不执行工具,也不将失败轮次改为成功。日志使用已有事件类型和恢复代码,无需格式变更。 + +此前已关闭的不一致历史轮次保持不变。实时恢复路径要求会话能够接纳缺失结果记录;它不能保证从日志追加失败中恢复。 diff --git a/.agents/notes/implemented/feature/2026-07-10-parallel-tool-call-execution.i18n.yaml b/.agents/notes/implemented/feature/2026-07-10-parallel-tool-call-execution.i18n.yaml index 4b82a34a31..5867da4d43 100644 --- a/.agents/notes/implemented/feature/2026-07-10-parallel-tool-call-execution.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-10-parallel-tool-call-execution.i18n.yaml @@ -12,8 +12,8 @@ en: 532bb7268d9b93ce zh: ad47162233222ab0 /agent-note-parallel-tool-call-execution-by-per-call-safety/scheduling-and-ordering: - en: f0906d03fef98e7b - zh: f9e43c4c832c9a05 + en: a485c530ea4366de + zh: 6f9915d726de9ac7 /agent-note-parallel-tool-call-execution-by-per-call-safety/safety-contract: en: 16c4ee08b65ead23 zh: df80d6e49982615b @@ -24,8 +24,8 @@ en: eb5a6aa84980f0ab zh: eed2680a160e8ed2 /agent-note-parallel-tool-call-execution-by-per-call-safety/alternatives-considered: - en: 44f8fb5851ba4e39 - zh: 4d75c937ac5277f7 + en: 17becc038bb7df7c + zh: 673d9a9bdfa39edf /agent-note-parallel-tool-call-execution-by-per-call-safety/consequences: - en: 5e5b2a1c7d850f83 - zh: b63311f28eb69366 + en: a5508057bc03f4f6 + zh: 96b46c59402474c3 diff --git a/.agents/notes/implemented/feature/2026-07-10-parallel-tool-call-execution.md b/.agents/notes/implemented/feature/2026-07-10-parallel-tool-call-execution.md index d5d26e03a2..004d725bfd 100644 --- a/.agents/notes/implemented/feature/2026-07-10-parallel-tool-call-execution.md +++ b/.agents/notes/implemented/feature/2026-07-10-parallel-tool-call-execution.md @@ -46,7 +46,7 @@ Only dispatch and the tool body overlap. `tools/pre-execute` and `tools/post-exe Each started call appends `tool/call` immediately before its pre-execute gate. Completed dispatches occupy model-order slots, and a commit cursor appends `tool/result` and collects `additionalContexts` only when the next slot is ready. Live surfaces may show several pending calls, but results and post-tool context remain model-ordered. -An abort before a group starts records no calls from that group. An abort during a group stops replenishment, waits for already-started calls, commits their results in order, drains accepted batch context after those results, and then ends the step through the existing abort path. Calls that never start have no audit event. An unexpected scheduler failure stops new dispatches, waits for every already-started dispatch to settle, and rethrows the first failure. Because that failure is terminal internal state rather than a tool outcome, the loop does not invent tool results for rejected or uncommitted calls. +An abort stops replenishment, waits for already-started calls, commits their results in order, drains accepted batch context after those results, and records synthetic aborted call/result pairs for undispatched calls. An unexpected scheduler failure stops new dispatches, waits for every already-started dispatch to settle, and rethrows the first failure. The driver records missing results before closing the failed step under the [failed-step recovery decision](../bug-fix/2026-09-19-failed-step-tool-results.md). PTC mode remains outside this scheduler because the model emits one native `run_code` call. `run_code` and its internal dispatch queue remain serial; native sibling calls in `mode: 'both'` use the normal scheduler. @@ -84,7 +84,7 @@ Snapshot coverage pins the visible multi-call transcript: pending calls may over **Expose staged methods or a scheduling waterfall.** Public `prepare` / `dispatch` / `finalize` methods or a `tools/execution-mode` event add extension surface before another consumer needs it. The loop uses an internal scheduler view, while `executionMode(exec)` leaves an insertion point for a policy hook. -**Convert scheduler failures into tool results.** AgentLoop cannot determine whether a rejected dispatch invoked the tool body; ToolRuntime owns body-invocation state and typed tool outcomes. Internal scheduler failures therefore remain terminal instead of being reclassified as `ABORTED` results. +**Convert scheduler failures into ordinary tool results.** AgentLoop cannot determine whether a rejected dispatch invoked the tool body; ToolRuntime owns body-invocation state and typed tool outcomes. Internal scheduler failures remain terminal instead of being reclassified as `ABORTED` results. Explicit recovery results preserve this uncertainty without leaving unanswered calls. **Start calls while the model streams.** This may reduce latency further but changes assistant-message authority, replay, and call/result pairing. The scheduler starts only after the assistant message is complete. @@ -96,7 +96,7 @@ Snapshot coverage pins the visible multi-call transcript: pending calls may over The design is fail-closed and simple for tool authors, but it cannot exploit concurrency whose safety depends on comparing siblings. A tool that opts in too broadly can expose latent shared-state races. -Parallel calls may begin in cases where serial execution would have aborted before reaching them. The scheduler therefore records only started calls, drains them on abort, and never starts replacements after cancellation. +Parallel calls may begin in cases where serial execution would have aborted before reaching them. The scheduler drains them on abort and never starts replacements after cancellation; undispatched calls receive synthetic aborted call/result pairs. Ordered commits may hold a fast result behind a slow earlier sibling. This preserves replay and model-history order while live surfaces still show pending progress. @@ -104,4 +104,4 @@ Concurrent external calls can compete for quota or process capacity. Providers o Tool registration is a scheduling boundary. Registry mutations affect not-yet-started calls because the scheduler reclassifies after each barrier and before every pool replenishment. Already-started calls retain the scheduling decision under which they entered the pool. -A terminal scheduler failure may leave recorded calls without results before the failed step closes. Waiting for live dispatches preserves quiescence without misreporting those internal failures as tool outcomes. +A terminal scheduler failure reaches step recovery only after live dispatches settle. Recovery reports uncommitted outcomes as unknown, preserving quiescence and the original failure while keeping later model history usable. diff --git a/.agents/notes/implemented/feature/2026-07-10-parallel-tool-call-execution.zh.md b/.agents/notes/implemented/feature/2026-07-10-parallel-tool-call-execution.zh.md index 707428985c..e78f56e07c 100644 --- a/.agents/notes/implemented/feature/2026-07-10-parallel-tool-call-execution.zh.md +++ b/.agents/notes/implemented/feature/2026-07-10-parallel-tool-call-execution.zh.md @@ -46,7 +46,7 @@ Status: implemented 每个已启动的调用都会在进入 pre-execute 门禁之前立即追加 `tool/call`。已完成的派发占据模型顺序的槽位;提交游标只有在下一个槽位就绪时,才会追加 `tool/result` 并收集 `additionalContexts`。实时界面可以显示多个待处理调用,但结果和工具执行后的上下文仍按模型顺序排列。 -如果在一组启动前中止,系统不会记录该组的任何调用。如果在一组执行期间中止,系统会停止补充池,等待已启动的调用,按顺序提交其结果,在这些结果之后排空已接受的批次上下文,然后通过现有中止路径结束该步骤。从未启动的调用没有审计事件。调度器发生意外故障时,会停止新的派发,等待每项已启动的派发结算,并重新抛出第一个故障。由于该故障是内部终态,而非工具结果,循环不会为被拒绝或未提交的调用虚构工具结果。 +中止会停止补充池,等待已启动的调用,按顺序提交其结果,在这些结果之后排空已接受的批次上下文,并为未派发的调用记录合成的中止调用/结果对。调度器发生意外故障时,会停止新的派发,等待每项已启动的派发结算,并重新抛出第一个故障。驱动器按照[失败步骤恢复决策](../bug-fix/2026-09-19-failed-step-tool-results.zh.md),在关闭失败步骤之前记录缺失结果。 PTC mode 仍不使用此调度器,因为模型只会发出一个原生 `run_code` 调用。`run_code` 及其内部派发队列仍按串行方式执行;`mode: 'both'` 中的原生并列调用使用常规调度器。 @@ -84,7 +84,7 @@ PTC mode 仍不使用此调度器,因为模型只会发出一个原生 `run_co **公开分阶段方法或调度 waterfall(瀑布式事件)。** 公开的 `prepare` / `dispatch` / `finalize` 方法或 `tools/execution-mode` 事件,会在另一个消费方需要它之前扩大扩展接口。循环使用内部调度器视图,而 `executionMode(exec)` 为策略钩子保留了插入点。 -**将调度器故障转换为工具结果。** AgentLoop 无法判断被拒绝的派发是否已调用工具主体;ToolRuntime 负责工具主体调用状态和类型化工具结果。因此,内部调度器故障保持为终态,而不会被重新分类为 `ABORTED` 结果。 +**将调度器故障转换为普通工具结果。** AgentLoop 无法判断被拒绝的派发是否已调用工具主体;ToolRuntime 负责工具主体调用状态和类型化工具结果。内部调度器故障保持为终态,而不会被重新分类为 `ABORTED` 结果。显式恢复结果保留这种不确定性,同时避免留下尚无结果的调用。 **在模型流式输出时启动调用。** 这可能进一步降低延迟,但会改变 assistant 消息的权威性、回放以及调用/结果配对。调度器只在 assistant 消息完成后才启动。 @@ -96,7 +96,7 @@ PTC mode 仍不使用此调度器,因为模型只会发出一个原生 `run_co 该设计遵循安全侧原则,对工具作者而言也很简单,但无法利用必须通过比较并列调用才能确认安全的并发性。工具过于宽泛地选择并行,可能暴露潜在的共享状态竞态。 -在某些情形下,并行调用会先行启动,而串行执行原本会在轮到这些调用之前中止。因此,调度器只记录已启动的调用,在中止时将其排空,且取消后绝不启动替换调用。 +在某些情形下,并行调用会先行启动,而串行执行原本会在轮到这些调用之前中止。调度器在中止时将其排空,且取消后绝不启动替换调用;未派发的调用获得合成的中止调用/结果对。 有序提交可能会让快速结果等待较慢的早期并列调用。这保留了回放和模型历史顺序,同时实时界面仍可显示待处理进度。 @@ -104,4 +104,4 @@ PTC mode 仍不使用此调度器,因为模型只会发出一个原生 `run_co 工具注册是调度边界。调度器会在每个屏障之后以及每次补充池之前重新分类,因此注册表变更会影响尚未启动的调用。已启动的调用保留它们进入池时所依据的调度决策。 -终态调度器故障可能会在故障步骤关闭前留下已记录但没有结果的调用。等待仍在运行的派发可确保完全停稳,而不会将这些内部故障误报为工具结果。 +终态调度器故障只有在运行中的派发结算后才会进入步骤恢复。恢复将未提交的执行结果报告为未知,保留完全停稳与原始失败,同时让后续模型历史保持可用。 diff --git a/.github/workflows/docs-pages.yml b/.github/workflows/docs-pages.yml index 8bac404b2d..6cd25b059d 100644 --- a/.github/workflows/docs-pages.yml +++ b/.github/workflows/docs-pages.yml @@ -35,7 +35,7 @@ env: jobs: build: - runs-on: ubuntu-latest + runs-on: blacksmith-8vcpu-ubuntu-2404 permissions: contents: read pages: read diff --git a/apps/cli/tests/desktop-host.e2e.ts b/apps/cli/tests/desktop-host.e2e.ts index e486354a33..615270aae0 100644 --- a/apps/cli/tests/desktop-host.e2e.ts +++ b/apps/cli/tests/desktop-host.e2e.ts @@ -28,7 +28,11 @@ it.each([false, true])('settles startup after parent IPC disconnect (boot failur } writeFileSync(join(root, 'package.json'), '{"type":"module"}') writeFileSync(join(modules, 'dsh-app-boot', 'package.json'), '{"type":"module","exports":"./index.js"}') - writeFileSync(join(modules, 'dsh-app-boot', 'index.js'), 'export const loadProfileDirectory = () => ({ skippedBundles: [] }); export const reportSkippedBundles = () => {}; export const loadLayeredEnv = () => ({})') + writeFileSync(join(modules, 'dsh-app-boot', 'index.js'), ` + export const loadProfileDirectory = () => ({ skippedBundles: [] }); + export const reportSkippedBundles = () => {}; + export const loadLayeredEnv = () => ({}); + `) writeFileSync(join(modules, 'dsh', 'package.json'), '{"type":"module","exports":{"./profile-boot":"./profile-boot.js"}}') writeFileSync(join(modules, 'dsh', 'profile-boot.js'), ` import { writeFileSync } from 'node:fs'; diff --git a/apps/cli/tests/profiles/web/tests/web-default-isolation.expected.e2e.ts b/apps/cli/tests/profiles/web/tests/web-default-isolation.expected.e2e.ts index a9d9821ec4..eef3fb2908 100644 --- a/apps/cli/tests/profiles/web/tests/web-default-isolation.expected.e2e.ts +++ b/apps/cli/tests/profiles/web/tests/web-default-isolation.expected.e2e.ts @@ -29,15 +29,13 @@ it('boots default Web without experimental modules, scheduling, time context, or expect(roster.plugins.length).toBeGreaterThan(roster.entries.length) expect(roster.modules.some(url => modulePackage(url) === '@deepseek-ai/dsh')).toBe(true) expect(roster.client.entries.length).toBeGreaterThan(0) - for (const name of [ - '@deepseek-ai/dsh-client-ui-sidebar-browser', - '@deepseek-ai/dsh-time-context', - '@deepseek-ai/dsh-schedule', - '@deepseek-ai/dsh-client-ui-schedule', - ]) { - const entry = roster.entries.find(entry => entry.name === name) - expect(entry, name).toBeDefined() - expect(entry!.state, name).toBeUndefined() + const browser = roster.entries.find(entry => entry.name === '@deepseek-ai/dsh-client-ui-sidebar-browser') + expect(browser).toBeDefined() + expect(browser!.state).toBeUndefined() + expect(delivered.entries.some(entry => entry.id === '@deepseek-ai/dsh-client-ui-sidebar-browser')).toBe(false) + // The optional Schedule bundle inserts these rows; the shipped composition carries none of them. + for (const name of ['@deepseek-ai/dsh-time-context', '@deepseek-ai/dsh-schedule', '@deepseek-ai/dsh-client-ui-schedule']) { + expect(roster.entries.some(entry => entry.name === name), name).toBe(false) expect(delivered.entries.some(entry => entry.id === name), name).toBe(false) } expect(experimentalRuntimeReferences(roster)).toEqual([]) diff --git a/apps/desktop-host/tests/platform-session.spec.ts b/apps/desktop-host/tests/platform-session.spec.ts index fbc26da6e1..4abc1b2055 100644 --- a/apps/desktop-host/tests/platform-session.spec.ts +++ b/apps/desktop-host/tests/platform-session.spec.ts @@ -9,6 +9,7 @@ const state: AccountView = { } class Account extends DeepSeekAccount { + getDeviceIdentity = async () => ({ osVersion: 'test-os' }) fail = false ended = Promise.withResolvers() reading = Promise.withResolvers() diff --git a/apps/desktop/README.i18n.yaml b/apps/desktop/README.i18n.yaml index ec85ddebce..93f5641fb1 100644 --- a/apps/desktop/README.i18n.yaml +++ b/apps/desktop/README.i18n.yaml @@ -3,8 +3,8 @@ # After editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write apps/desktop/README.md /deepseek-harness-desktop: - en: 45561fc0dd3a1b20 - zh: f4b03355d4bd0693 + en: b8e4f357cf330721 + zh: cdaaf35a061f41b1 /deepseek-harness-desktop/closing-the-window-and-quitting: en: c323e02e3f0305fe zh: 3c730db632136443 diff --git a/apps/desktop/README.md b/apps/desktop/README.md index 8109ebd577..cf780cddc6 100644 --- a/apps/desktop/README.md +++ b/apps/desktop/README.md @@ -2,6 +2,8 @@ English | [中文](README.zh.md) +Desktop analytics follows the [product collection policy](../../packages/client/product-analytics/README.md), including its live application setting. Web usage is excluded. Update installation waits for its local analytics intake before locking API admission and stopping the Host. Intake has a one-second deadline; failure does not prevent installation, and collector delivery is not awaited. + The desktop application is an Electron shell around the complete dsh Web application. An Electron RunAsNode child starts the shared profile runner, and Electron immediately loads the packaged Web entry at `dsh-app://app/`. Its shared loading page waits for Host boot injections, then starts the client without navigating to another document. Electron forwards application HTTP requests to the authenticated Web Host, dropping connection-level response headers (`transfer-encoding`, `connection`, `keep-alive`) that describe the Node fetch rather than the resource, and marking plugin bundle responses `no-store` because their per-launch revisions would only accumulate in Chromium's disk cache; WebSocket streams connect to that Host with credentials attached only for the owned application window. Node IPC carries boot injections, readiness, and shutdown. Desktop defaults to port `19387`, separate from Web’s `3080`; a `webserver.config.port` patch can override it. The first application-menu command, **About DeepSeek Harness**, opens Electron's native About panel with the application icon, product name, and installed release version. The menu follows the Desktop shell locale. On macOS, Hide, Hide Others, Show All, and Quit use localized labels; Hide and Quit include the DeepSeek Harness product name. These commands retain their native actions and shortcuts. macOS reads the icon from its application bundle, so an unpackaged development launch displays Electron's icon; Windows receives the packaged PNG. diff --git a/apps/desktop/README.zh.md b/apps/desktop/README.zh.md index f6c9667ad7..a64cbd2246 100644 --- a/apps/desktop/README.zh.md +++ b/apps/desktop/README.zh.md @@ -2,6 +2,8 @@ [English](README.md) | 中文 +桌面埋点遵循[产品采集策略](../../packages/client/product-analytics/README.zh.md)及其动态应用配置,不包含 Web 使用情况。安装更新会等待该操作的本地埋点接收请求结束,再锁定 API 准入并停止 Host。接收请求的时限为一秒,失败不会阻止安装,也不等待收集端完成发送。 + 桌面应用是完整 dsh Web 应用外的一层 Electron 壳。Electron RunAsNode 子进程启动共享 profile runner,Electron 立即从 `dsh-app://app/` 加载打包内的 Web 入口。共享加载页等待 Host 启动注入,然后在同一文档中启动客户端。Electron 将应用 HTTP 请求转发给已认证的 Web Host,转发时丢弃描述 Node fetch 连接而非资源本身的响应头(`transfer-encoding`、`connection`、`keep-alive`),并把插件 bundle 响应标记为 `no-store`,因为其每次启动都变化的 revision 只会在 Chromium 磁盘缓存中累积;WebSocket 流连接到该 Host,仅为归属的应用窗口附加凭据。Node IPC 承载启动注入、就绪与关闭。Desktop 默认使用端口 `19387`,与 Web 的 `3080` 分开;可通过 `webserver.config.port` patch 覆盖。 应用菜单第一项“**关于 DeepSeek Harness**”打开 Electron 原生关于面板,展示应用图标、产品名称和当前安装的发布版本。菜单文案跟随桌面壳的语言。macOS 的隐藏、隐藏其他、显示全部和退出条目使用本地化文案,隐藏和退出条目包含 DeepSeek Harness 产品名称。这些条目保留原生动作和快捷键。macOS 从应用包读取图标,因此未打包的开发启动会显示 Electron 图标;Windows 使用随包分发的 PNG。 diff --git a/apps/desktop/package.json b/apps/desktop/package.json index cc0ce4f064..81c8059d55 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -75,6 +75,7 @@ "vite": "^6.0.0", "@deepseek-ai/dsh-client-ui-theme": "workspace:*", "@deepseek-ai/dsh-client-shortcuts": "workspace:*", - "@deepseek-ai/dsh-atomic-write": "workspace:*" + "@deepseek-ai/dsh-atomic-write": "workspace:*", + "@deepseek-ai/dsh-client-product-analytics": "workspace:*" } } diff --git a/apps/desktop/src/account-backend.ts b/apps/desktop/src/account-backend.ts index 889b4371bc..770d3e4e9d 100644 --- a/apps/desktop/src/account-backend.ts +++ b/apps/desktop/src/account-backend.ts @@ -67,9 +67,13 @@ export interface DesktopAccountBackend { * @param listener - state recipient. * @param failed - stream failure recipient. * @param expired - live credential-expiry recipient. + * @param onAnalyticsEnabledChanged - optional Desktop collection-policy recipient; disconnected streams publish false. * @returns stream disposer. */ - watch(listener: (state: AccountView) => void, failed: () => void, expired: () => void): () => void + watch( + listener: (state: AccountView) => void, failed: () => void, expired: () => void, + onAnalyticsEnabledChanged?: (enabled: boolean) => void, + ): () => void } /** @@ -86,19 +90,21 @@ export function desktopAccountBackend(origin: string, invoke: AccountInvoke, coo state: () => call('getState'), start: client => call('startSignIn', { client, callbackOrigin: new URL(origin).origin, loginSource: 'desktop' }), cancel: attemptId => call('cancelSignIn', { attemptId }), signOut: client => call('signOut', { client }), - watch(listener, failed, expired) { + watch(listener, failed, expired, onAnalyticsEnabledChanged) { let closed = false let socket: WebSocket | undefined let retry: ReturnType | undefined const connect = (): void => { const streamId = randomUUID() const expiryStreamId = randomUUID() + const analyticsPolicyStreamId = randomUUID() void cookies().then((cookie) => { if (closed) return const url = new URL(REMOTE_STREAM_MUX_PATH, origin) url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:' socket = new WebSocket(url, { headers: { cookie, origin }, maxPayload: 65_536 }) socket.on('open', () => { + if (onAnalyticsEnabledChanged !== undefined) socket?.send(JSON.stringify({ type: 'open', streamId: analyticsPolicyStreamId, endpoint: 'productAnalytics/watchPolicy', payload: { args: {} } })) socket?.send(JSON.stringify({ type: 'open', streamId: expiryStreamId, endpoint: 'account/watchExpiry', payload: { args: {} } })) socket?.send(JSON.stringify({ type: 'open', streamId, endpoint: 'account/watch', payload: { args: {} } })) }) @@ -106,6 +112,11 @@ export function desktopAccountBackend(origin: string, invoke: AccountInvoke, coo try { const bytes = Array.isArray(data) ? Buffer.concat(data) : Buffer.isBuffer(data) ? data : Buffer.from(data) const frame = parseRemoteStreamServerMessage(bytes.toString('utf8')) + if (frame.streamId === analyticsPolicyStreamId) { + if (frame.type === 'item' && typeof frame.value === 'boolean') onAnalyticsEnabledChanged?.(frame.value) + else onAnalyticsEnabledChanged?.(false) + return + } if (frame.streamId === expiryStreamId && frame.type === 'item' && frame.value === 'session-expired') { expired(); return } if (frame.streamId !== streamId) throw new Error('desktop account: unexpected stream') if (frame.type === 'item') listener(accountView(frame.value)) @@ -113,11 +124,11 @@ export function desktopAccountBackend(origin: string, invoke: AccountInvoke, coo } catch { socket?.close() } }) socket.on('error', () => { socket?.close() }) - socket.on('close', () => { if (!closed) { failed(); retry = setTimeout(connect, 1000) } }) - }).catch(() => { if (!closed) { failed(); retry = setTimeout(connect, 1000) } }) + socket.on('close', () => { if (!closed) { onAnalyticsEnabledChanged?.(false); failed(); retry = setTimeout(connect, 1000) } }) + }).catch(() => { if (!closed) { onAnalyticsEnabledChanged?.(false); failed(); retry = setTimeout(connect, 1000) } }) } connect() - return () => { closed = true; clearTimeout(retry); socket?.close() } + return () => { closed = true; onAnalyticsEnabledChanged?.(false); clearTimeout(retry); socket?.close() } }, } } diff --git a/apps/desktop/src/client/WelcomePage.tsx b/apps/desktop/src/client/WelcomePage.tsx index 8fcde7768a..b367061732 100644 --- a/apps/desktop/src/client/WelcomePage.tsx +++ b/apps/desktop/src/client/WelcomePage.tsx @@ -19,6 +19,7 @@ export function Welcome({ api }: { api: WelcomeApi }) { const [expiryNotice, setExpiryNotice] = useState(false) const [page, setPage] = useState('entry') const pageRef = useRef('entry') + const visiblePage = useRef('entry') const [attempt, setAttempt] = useState(null) const attemptRef = useRef(null) const [starting, setStarting] = useState(false) @@ -69,12 +70,14 @@ export function Welcome({ api }: { api: WelcomeApi }) { }, [api, m.welcomeTitle]) useEffect(() => { + if (page === 'entry' && visiblePage.current !== 'entry') void api.analytics?.('auth_page_view', {}) + visiblePage.current = page if (page === 'key') input.current?.focus() else if (page === 'entry' && focusEntry.current) { focusEntry.current = false keyButton.current?.focus() } - }, [page]) + }, [page, api]) useEffect(() => { if (copyState !== 'copied' && copyState !== 'failed') return @@ -85,6 +88,7 @@ export function Welcome({ api }: { api: WelcomeApi }) { async function saveKey(event: FormEvent) { event.preventDefault() if (busyRef.current) return + void api.analytics?.('api_key_save_click', {}) const value = draft.trim() if (!/^[\x21-\x7e]+$/.test(value) || /^[A-Z][A-Z0-9_]*=[^=]/.test(value) || ((value.startsWith('"') || value.startsWith("'") || value.charCodeAt(0) === 96) && value.at(-1) === value[0])) { @@ -198,15 +202,15 @@ export function Welcome({ api }: { api: WelcomeApi }) { - - + + {inputSentence === null @@ -925,37 +985,70 @@ function InstallDialog({ style={registryPosition ?? { visibility: 'hidden', left: 0, top: 0 }} data-install-registry aria-label={t('registryLegend')} + onKeyDown={(event) => { + if (event.key !== 'Tab' || event.ctrlKey || event.altKey || event.metaKey || event.nativeEvent.isComposing) return + event.preventDefault() + event.stopPropagation() + const radio = event.currentTarget.querySelector('input[type="radio"]:checked') + const field = registryCustomRef.current + if (event.shiftKey && event.target === field) radio?.focus() + else if (!event.shiftKey && event.target !== field) field?.focus() + else { + onToggleRegistry() + registryToggleRef.current?.focus() + } + }} > {offeredRegistries(install.registries).map((registry) => { const checked = choice.kind === 'offered' && choice.registry === registry return ( ) })} -
+
{ + // Keep label activation from moving focus back to the radio. + if (!(event.target instanceof HTMLInputElement)) event.preventDefault() + registryCustomRef.current?.focus() + }}> { + if (pointerModality() && choice.kind !== 'custom') onChooseRegistry({ kind: 'custom', url: customRegistryDraft }) + }} onChange={(event) => { onChooseRegistry({ kind: 'custom', url: event.currentTarget.value }) }} - onKeyDown={(event) => { if (event.key === 'Enter' && !empty) onRun() }} + onCompositionStart={registryComposition.onCompositionStart} + onCompositionEnd={registryComposition.onCompositionEnd} + onBlur={registryComposition.onBlur} + onKeyDown={(event) => { + if (registryComposition.isComposing(event.nativeEvent)) return + if (event.key === 'Enter' && !empty) onRun() + }} /> {install.registryError ? @@ -1168,7 +1261,7 @@ export function PluginManagerPage(props: PluginManagerPageProps): ReactNode { const timer = setTimeout(clearHighlight, HIGHLIGHT_MS) return () => { clearTimeout(timer) } }, [highlight, clearHighlight]) - const noticeLine = state.notice === null ? null : noticeText(state.notice, t) + const noticeLine = state.notice === null || state.notice.kind === 'refresh-failed' ? null : noticeText(state.notice, t) // The page manages what the person installed, what the installation ships for them to switch on, and a // selected name the Host cannot read; the installation's other bundles are inspected in the Settings @@ -1178,6 +1271,7 @@ export function PluginManagerPage(props: PluginManagerPageProps): ReactNode { const mine = listed.filter(pkg => pkg.installed || !pkg.optional) const official = listed.filter(pkg => pkg.optional && !pkg.installed) const loaded = state.status === 'ready' || state.status === 'error' + const refreshing = state.refreshStatus === 'refreshing' const openPkg = view.kind === 'package' || view.kind === 'row' ? listed.find(pkg => pkg.name === view.name) : undefined const openItem = view.kind === 'item' ? ledger.items.find(item => item.id === view.id) : undefined const openRow = view.kind === 'row' && openPkg !== undefined ? openPkg.rows.find(row => row.rowId === view.rowId) : undefined @@ -1224,7 +1318,7 @@ export function PluginManagerPage(props: PluginManagerPageProps): ReactNode { ) return ( -
+
{showsCards ? (
@@ -1233,9 +1327,13 @@ export function PluginManagerPage(props: PluginManagerPageProps): ReactNode {

{t('intro')}

- + + + @@ -1243,11 +1341,7 @@ export function PluginManagerPage(props: PluginManagerPageProps): ReactNode { ) : null} - {showsCards && state.status === 'loading' ? ( -

- {t('loading')} -

- ) : null} + {showsCards && state.status === 'loading' ? : null} {showsCards && state.status === 'unavailable' ? (

{t('unavailable')} @@ -1264,11 +1358,11 @@ export function PluginManagerPage(props: PluginManagerPageProps): ReactNode { onDone={props.dismissNotice} /> )} - {!showsCards && state.status === 'error' + {!showsCards && state.status === 'error' && !refreshing ? (

- {t('error')} + {t(state.refreshStatus === 'failed' ? 'refreshError' : 'error')}

@@ -1317,11 +1411,11 @@ export function PluginManagerPage(props: PluginManagerPageProps): ReactNode { {renderGroup('bundles', t('bundlesTitle'), mine.map(packageCard))} {/* A failed package read trails the groups it left incomplete: right under Official on a first-load failure, and after the kept cards when a refresh fails over stale data. */} - {state.status === 'error' + {state.status === 'error' && !refreshing ? (

- {t('error')} + {t(state.refreshStatus === 'failed' ? 'refreshError' : 'error')}

diff --git a/packages/client/ui-plugin-manager/src/client/PluginRefreshToast.tsx b/packages/client/ui-plugin-manager/src/client/PluginRefreshToast.tsx new file mode 100644 index 0000000000..b573026f52 --- /dev/null +++ b/packages/client/ui-plugin-manager/src/client/PluginRefreshToast.tsx @@ -0,0 +1,32 @@ +/** Refresh failure feedback hosted outside the Plugins panel's lifetime. */ +import type { ReactNode } from 'react' +import { IconWarningOutlineRegular, Toast } from '@deepseek-ai/dsh-client-ui-primitives' +import type { InjectFace, PropsLocale } from '@deepseek-ai/dsh-client-ui-slots' +import type { PluginManagerFace } from './manager-store.ts' +import { noticeText } from './presentation.ts' + +/** The controller's shared notice source and dismissal action. */ +export type PluginRefreshToastFace = Pick & { + hooks: Pick +} + +/** Render inputs bound from the shared controller and plugin dictionary. */ +export type PluginRefreshToastProps = InjectFace & PropsLocale<'pluginManager'> + +/** + * Display refresh failures even after navigation leaves the Plugins panel. + * @param props - shared notice hook, dismissal action, and locale seat. + * @returns the refresh failure toast, or null for other notices. + */ +export function PluginRefreshToast({ usePluginManager, dismissNotice, t }: PluginRefreshToastProps): ReactNode { + const notice = usePluginManager(state => state.notice) + if (notice?.kind !== 'refresh-failed') return null + return ( + } + onDone={dismissNotice} + /> + ) +} diff --git a/packages/client/ui-plugin-manager/src/client/index.ts b/packages/client/ui-plugin-manager/src/client/index.ts index 0bceeabe9d..a568db4666 100644 --- a/packages/client/ui-plugin-manager/src/client/index.ts +++ b/packages/client/ui-plugin-manager/src/client/index.ts @@ -6,7 +6,7 @@ * A plugin that carries its own configuration renders it on this page through * the slots the page declares (`slot-contract.ts`). */ - +import type {} from '@deepseek-ai/dsh-client-product-analytics/client' import type {} from '@deepseek-ai/dsh-client-locale/client' import type { Context as ClientContext } from '@deepseek-ai/cordis' // Type-only: the root `main` keyed slot the page registers into, declared by @@ -21,6 +21,7 @@ import type {} from '@deepseek-ai/dsh-api-remotes/client' // through the owning package's client-safe types subpath). import type {} from '@deepseek-ai/dsh-plugin-manager/types' import { PluginManagerPage } from './PluginManagerPage.tsx' +import { PluginRefreshToast, type PluginRefreshToastFace } from './PluginRefreshToast.tsx' import { PluginsPanelIcon } from './PluginsPanelIcon.tsx' import { configLedgerSource } from './config-ledger.ts' import { PluginManagerController } from './manager-store.ts' @@ -97,6 +98,14 @@ export function apply(ctx: ClientContext): void { // the page's own; a plugin's configuration arrives through the slots the // page declares here, so the page never names a configurable plugin. const configLedger = configLedgerSource(ctx) + const face = controller.inject(configLedger, text => ctx.locale.resolveText(text)) + ctx.slots.inject('shell.overlay', () => ctx.slots.register({ + name: 'shell.overlay', id: 'plugin-manager.refresh-toast', locale: NS, + inject: (): PluginRefreshToastFace => ({ + hooks: { pluginManager: face.hooks.pluginManager }, + dismissNotice: face.dismissNotice, + }), + }, PluginRefreshToast)) ctx.slots.inject('main', function* () { const handle = createNavigationStore(), instance = handle.create() const store: typeof handle = { ...handle, create: () => instance } @@ -105,7 +114,7 @@ export function apply(ctx: ClientContext): void { key: PANEL_ID, locale: NS, store, - inject: () => controller.inject(configLedger, text => ctx.locale.resolveText(text)), + inject: () => face, children: { 'plugins.item': { kind: 'list', scope: 'root' }, 'plugins.bundle.activation': { kind: 'keyed', scope: 'root' }, diff --git a/packages/client/ui-plugin-manager/src/client/locales.ts b/packages/client/ui-plugin-manager/src/client/locales.ts index 3c4fb304f0..e28336d4e6 100644 --- a/packages/client/ui-plugin-manager/src/client/locales.ts +++ b/packages/client/ui-plugin-manager/src/client/locales.ts @@ -10,6 +10,7 @@ export const zh = { unavailable: '本部署没有可管理的 profile,无法安装或启停插件。', retry: '重试', refresh: '刷新', + refreshError: '刷新失败,请重试', empty: '还没有安装任何插件。', addPlugin: '添加插件', restartNotice: '更改将在下次启动生效', @@ -48,11 +49,11 @@ export const zh = { installTitle: '添加插件', installDescription: '输入插件的包名、GitHub 仓库地址或本地目录路径。', installSpecLabel: '包名或地址', - installSpecPlaceholder: '例如 @deepseek-ai/dsh-subagent-codex', + installSpecPlaceholder: '例如 dsh-plugin-whale-pet', installGuideToggle: '插件安装引导和示例', installGuideHide: '收起引导', installGuideIdTitle: '包名', - installGuideIdExample: '@deepseek-ai/dsh-subagent-codex', + installGuideIdExample: 'dsh-plugin-whale-pet', installGuideIdHint: '插件包名即 npm 包名(如 dsh-xxx 或 @作者/插件名),社区插件的 README 安装命令中 dsh plugin add 或 pnpm add 之后的部分。', installGuideGitTitle: 'GitHub 仓库地址', installGuideGitExample: 'https://github.com/author/dsh-plugin', @@ -69,10 +70,9 @@ export const zh = { registryDefault: '默认安装源', registryOfficial: 'npm 官方源', registryNpmmirror: '中国大陆镜像源', - registryWithHost: '{name}({host})', registryCustom: '自定义地址', registryCustomPlaceholder: 'https://npm.example.com/', - registryCustomHint: '公司内网或私有 npm 源;需要登录的源,把凭据放在本机的 ~/.npmrc 里', + registryCustomHint: '填写内网或私有 npm 源地址,以 http:// 或 https:// 开头。若为需要登录的源,请把凭据放在本机的 ~/.npmrc 里。', registryCustomInvalid: '请输入以 http:// 或 https:// 开头的地址', registryListSeparator: '、', installRun: '安装', @@ -203,6 +203,7 @@ export const en = { unavailable: 'This deployment runs without a manageable profile, so plugins cannot be installed or switched here.', retry: 'Retry', refresh: 'Refresh', + refreshError: 'Refresh failed. Please try again.', empty: 'No plugins are installed yet.', addPlugin: 'Add plugin', restartNotice: 'The change takes effect at the next start', @@ -241,11 +242,11 @@ export const en = { installTitle: 'Add plugin', installDescription: 'Enter the plugin\'s package name, GitHub repository address, or local directory path.', installSpecLabel: 'Package name or address', - installSpecPlaceholder: 'for example @deepseek-ai/dsh-subagent-codex', + installSpecPlaceholder: 'for example dsh-plugin-whale-pet', installGuideToggle: 'Install guide and examples', installGuideHide: 'Hide the guide', installGuideIdTitle: 'Package name', - installGuideIdExample: '@deepseek-ai/dsh-subagent-codex', + installGuideIdExample: 'dsh-plugin-whale-pet', installGuideIdHint: 'The plugin package name is the npm package name (like dsh-xxx or @author/plugin): the part after dsh plugin add or pnpm add in a community plugin\'s README install command.', installGuideGitTitle: 'GitHub repository address', installGuideGitExample: 'https://github.com/author/dsh-plugin', @@ -262,10 +263,9 @@ export const en = { registryDefault: 'Default registry', registryOfficial: 'Official npm registry', registryNpmmirror: 'Mainland China mirror', - registryWithHost: '{name} ({host})', registryCustom: 'Custom address', registryCustomPlaceholder: 'https://npm.example.com/', - registryCustomHint: 'A company or private npm registry; for one that needs a login, keep the credentials in ~/.npmrc on this machine', + registryCustomHint: 'Enter an internal or private npm registry address starting with http:// or https://. If it requires a login, store the credentials in ~/.npmrc on this machine.', registryCustomInvalid: 'Enter an address starting with http:// or https://', registryListSeparator: ', ', installRun: 'Install', diff --git a/packages/client/ui-plugin-manager/src/client/manager-store.ts b/packages/client/ui-plugin-manager/src/client/manager-store.ts index ab1c3aa038..2f319d01ea 100644 --- a/packages/client/ui-plugin-manager/src/client/manager-store.ts +++ b/packages/client/ui-plugin-manager/src/client/manager-store.ts @@ -1,3 +1,4 @@ +import { sanitizeInstallInput } from './sanitize-install-input.ts' /** * The plugin manager's state: the Host's bundles joined with its plugin * entries, the action in flight, the install run, and the confirmation an @@ -5,7 +6,7 @@ * after each action and after every `plugin-manager/changed` event, so a * change made on another surface shows here without a manual refresh. */ - +import type {} from '@deepseek-ai/dsh-client-product-analytics/client' import { randomUUID } from '@deepseek-ai/dsh-util-crypto' import type { Context as ClientContext } from '@deepseek-ai/cordis' import type { @@ -41,6 +42,7 @@ export type ManagerNotice = | { readonly kind: 'restart'; readonly packageName: string; readonly seq: number } | { readonly kind: 'overridden'; readonly packageName: string; readonly seq: number } | { readonly kind: 'cancelled'; readonly seq: number } + | { readonly kind: 'refresh-failed'; readonly seq: number } | { readonly kind: 'install'; readonly outcome: 'done' | 'failed' | 'unconfirmed' | 'applying' | 'unknown'; readonly seq: number } | { readonly kind: 'failed' @@ -105,6 +107,9 @@ export type RegistryChoice = /** The choice shown until the Host has said which registry it asks first: the one pnpm's own configuration names. */ const OFFICIAL_REGISTRY: RegistryChoice = { kind: 'offered', registry: null } +/** Hold the manual-refresh spinner at least this long so a fast read does not flash it. */ +const REFRESH_SPINNER_MIN_MS = 400 + /** * The registry a choice asks, as the Host's install plan compares registries: pnpm's own configuration stands for * the URL it names, once the Host has read it. @@ -269,6 +274,12 @@ export interface ConfirmState { export interface PluginManagerState { /** `unavailable` when the Host runs without a managed profile; `error` keeps the last packages. */ readonly status: 'idle' | 'loading' | 'ready' | 'error' | 'unavailable' + /** + * Manual refresh feedback: `refreshing` until reads settle and the 400 ms minimum elapses; + * `failed` after a failed refresh without cached inventory; otherwise `idle`, including + * cached refresh failures reported by toast. Background reads do not start the spinner. + */ + readonly refreshStatus: 'idle' | 'refreshing' | 'failed' readonly packages: readonly PackageView[] /** Package names and row keys with an action crossing the wire. */ readonly busy: readonly string[] @@ -492,11 +503,14 @@ export class PluginManagerController { private rerun = false private generation = 0 private disposed = false + /** A successful managed-profile read remains usable even when it returned no bundles. */ + private hasCachedInventory = false private pendingConfirm: (() => Promise) | undefined /** Cancels the check the dialog has in flight. */ private inspectAbort: AbortController | undefined private request: InstallRequest | undefined private noticeSeq = 0 + private analyticsAttempt: { input: string; started: number } | undefined private registryRead: RegistryRead | undefined /** The registry last used from this browser, kept across dialogs and page loads; null until one was used. */ private readonly registryMemory: SnapshotStore = createSnapshotStore(null, { @@ -510,7 +524,7 @@ export class PluginManagerController { private readonly ctx: ClientContext, ) { this.store = createSnapshotStore({ - status: 'idle', packages: [], busy: [], notice: null, + status: 'idle', refreshStatus: 'idle', packages: [], busy: [], notice: null, install: IDLE_INSTALL, confirm: null, highlight: null, }) } @@ -543,8 +557,9 @@ export class PluginManagerController { hooks: { pluginManager: this.store, configLedger, configurations: this.ctx.configForms.describe() }, configForm: id => this.ctx.configForms.get(id), ensure: () => { if (this.getSnapshot().status === 'idle') void this.load() }, - refresh: () => { void this.load() }, + refresh: () => { void this.refresh() }, openInstall: () => { + this.ctx.get('productAnalytics')?.track('plugin_add_button_click', {}) const install = this.getSnapshot().install if (install.requestId === undefined) { // A new dialog starts from the registry last used here and reads what the Host offers; a hidden install reopens as it is. @@ -563,6 +578,7 @@ export class PluginManagerController { void this.cancelInstall() return } + if (this.getSnapshot().install.phase === 'checking') this.finishAnalytics('cancelled') this.abortInspect() this.registryRead = undefined this.patch({ install: IDLE_INSTALL }) @@ -604,11 +620,14 @@ export class PluginManagerController { clearHighlight: () => { if (this.getSnapshot().highlight !== null) this.patch({ highlight: null }) }, setEnabled: (packageName, enabled) => { void this.run(packageName, { packageName, action: enabled ? 'enable' : 'disable' }, async () => { - this.applied(await this.ctx.remote.pluginManager.setBundleEnabled(packageName, enabled), packageName) + const result = await this.ctx.remote.pluginManager.setBundleEnabled(packageName, enabled) + this.applied(result, packageName) + if (result.ok && (result.value.application === 'applied' || result.value.application === 'restart-required')) this.trackToggle(packageName, enabled) }) }, uninstall: (packageName) => { this.pendingConfirm = () => this.run(packageName, { packageName, action: 'uninstall' }, async () => { + this.ctx.get('productAnalytics')?.track('confirm_uninstall_plugin', { plugin_name: packageName }) this.applied(await this.ctx.remote.pluginManager.removeBundle(packageName), packageName) }) this.patch({ confirm: { action: 'uninstall', packageName } }) @@ -617,7 +636,9 @@ export class PluginManagerController { cancelConfirm: () => { this.pendingConfirm = undefined; this.patch({ confirm: null }) }, setRowEnabled: (entryId, enabled) => { void this.run(rowKey(entryId), { packageName: entryId, action: enabled ? 'rowEnable' : 'rowDisable' }, async () => { - this.applied(await this.ctx.remote.pluginManager.setPluginEnabled(entryId, enabled), entryId) + const result = await this.ctx.remote.pluginManager.setPluginEnabled(entryId, enabled) + this.applied(result, entryId) + if (result.ok && (result.value.application === 'applied' || result.value.application === 'restart-required')) this.trackToggle(entryId, enabled, true) }) }, dismissNotice: () => { this.patch({ notice: null }) }, @@ -710,6 +731,36 @@ export class PluginManagerController { return run } + /** Keep manual refresh feedback until its coalesced reads settle, without clearing cached cards. */ + private async refresh(): Promise { + if (this.disposed || this.getSnapshot().refreshStatus === 'refreshing') return + const startedAt = Date.now() + this.patch({ + refreshStatus: 'refreshing', + ...this.getSnapshot().notice?.kind === 'refresh-failed' ? { notice: null } : {}, + }) + try { + await this.load() + } catch (_error) { + // A rejected transport request leaves the cached cards available for retry. + this.patch({ status: 'error' }) + } finally { + // Hold the spinner to its minimum so a fast read does not flash it, then settle. + const remaining = REFRESH_SPINNER_MIN_MS - (Date.now() - startedAt) + if (remaining > 0) await new Promise((resolve) => { setTimeout(resolve, remaining) }) + this.settleRefresh() + } + } + + /** Publish refresh feedback only before disposal. */ + private settleRefresh(): void { + if (this.disposed) return + const failed = this.getSnapshot().status === 'error' + this.patch(failed && this.hasCachedInventory + ? { status: 'ready', refreshStatus: 'idle', notice: { kind: 'refresh-failed', seq: ++this.noticeSeq } } + : { refreshStatus: failed ? 'failed' : 'idle' }) + } + private async read(): Promise { try { do { @@ -725,6 +776,7 @@ export class PluginManagerController { continue } if (inventory.value.managementAvailable !== true) { + this.hasCachedInventory = false this.patch({ status: 'unavailable', packages: [] }) continue } @@ -737,8 +789,10 @@ export class PluginManagerController { this.patch({ status: 'error' }) continue } + this.hasCachedInventory = true this.patch({ status: 'ready', + refreshStatus: this.getSnapshot().refreshStatus === 'refreshing' ? 'refreshing' : 'idle', packages: sortPackages(bundles.value.map(bundle => packageView(bundle, plugins.value))), }) } while (this.shouldRerun()) @@ -779,15 +833,21 @@ export class PluginManagerController { const install = state.install const spec = install.spec.trim() if (install.phase === 'checking' || isInstallPending(install.phase) || spec === '') return + if (this.ctx.get('productAnalytics')?.enabled) { + this.analyticsAttempt = { input: sanitizeInstallInput(spec), started: Date.now() } + this.ctx.get('productAnalytics')?.track('plugin_install_click', { input_value: sanitizeInstallInput(spec) }) + } // A name the list already shows is refused at once, before the Host is asked. if (state.packages.some(pkg => pkg.name === spec)) { + this.finishAnalytics('failed', 'already-installed') this.patchInstall({ phase: 'idle', inputError: { problem: 'already-installed', reason: spec } }) return } const choice = install.registry const typed = choice.kind === 'custom' ? choice.url.trim() : undefined if (typed !== undefined && !REGISTRY_URL.test(typed)) { - this.patchInstall({ phase: 'idle', registryError: true }) + this.finishAnalytics('failed', 'invalid-registry') + this.patchInstall({ phase: 'idle', registryError: true, registryOpen: true }) return } this.abortInspect() @@ -810,11 +870,13 @@ export class PluginManagerController { if (this.gone(controller.signal)) return this.inspectAbort = undefined if (!inspected.ok) { + this.finishAnalytics('failed', inspected.error.code) this.patchInstall({ phase: 'idle', inputError: { problem: 'unknown', reason: inspected.error.message } }) return } if (inspected.value.status === 'refused') { const { problem, reason, registries } = inspected.value + this.finishAnalytics('failed', problem) this.patchInstall({ phase: 'idle', inputError: { problem, reason, ...registries === undefined ? {} : { registries } } }) return } @@ -865,12 +927,14 @@ export class PluginManagerController { return } this.request = undefined + this.finishAnalytics('unknown') this.patchInstall({ phase: 'unknown', failure: null, runs: settledRuns(this.getSnapshot().install.runs, null) }) this.notifyHiddenInstall('unknown') void this.load() } private settleInstall(result: ChangeResult): void { + this.finishAnalytics(result.application === 'failed' ? 'failed' : result.application === 'cancelled' ? 'cancelled' : 'success', result.application === 'failed' ? result.error?.code : undefined, result.bundle) const { runs, attempts } = this.getSnapshot().install this.request = undefined // The Host's answer names every registry it asked, whether or not each attempt's announcement arrived. @@ -916,6 +980,7 @@ export class PluginManagerController { const install = this.getSnapshot().install const pending = install.failure?.pendingBuilds if (install.phase !== 'failed' || install.subject === null || pending === undefined || pending.length === 0) return + if (this.ctx.get('productAnalytics')?.enabled) this.analyticsAttempt = { input: sanitizeInstallInput(install.subject.spec), started: Date.now() } await this.startInstall(install.subject, pending) } @@ -927,6 +992,7 @@ export class PluginManagerController { private async cancelInstall(): Promise { const install = this.getSnapshot().install if (install.phase === 'checking' || install.phase === 'failed' || install.phase === 'unknown') { + if (install.phase === 'checking') this.finishAnalytics('cancelled') this.abortInspect() this.offerSpecAgain() return @@ -953,6 +1019,7 @@ export class PluginManagerController { return } if (result.value.status === 'cancelled') { + this.finishAnalytics('cancelled') this.offerSpecAgain({ kind: 'cancelled', seq: ++this.noticeSeq }) void this.load() } else if (result.value.status === 'too-late') { @@ -1006,6 +1073,7 @@ export class PluginManagerController { if (this.disposed) return try { this.applied(result, name) + if (result.ok && (result.value.application === 'applied' || result.value.application === 'restart-required')) this.trackToggle(name, true) } catch (error) { this.patch({ notice: failedNotice(error, { packageName: name, action: 'enable' }, ++this.noticeSeq) }) } @@ -1062,6 +1130,27 @@ export class PluginManagerController { } } + private trackToggle(name: string, enabled: boolean, row = false): void { + const bundle = this.getSnapshot().packages.find(pkg => row ? pkg.rows.some(item => item.entryId === name) : pkg.name === name) + const pluginName = row ? bundle?.rows.find(item => item.entryId === name)?.moduleName : name + if (bundle === undefined || pluginName === undefined) return + this.ctx.get('productAnalytics')?.track('plugin_toggle', { + plugin_name: pluginName, plugin_type: row ? 'plugin' : 'bundle', is_enabled: enabled, is_builtin: !bundle.installed, + }) + } + + private finishAnalytics(status: 'success' | 'failed' | 'cancelled' | 'unknown', reason?: string, name?: string): void { + const attempt = this.analyticsAttempt + this.analyticsAttempt = undefined + if (attempt === undefined || this.disposed) return + const errorReason = status === 'cancelled' ? 'user_cancelled' : status === 'unknown' ? 'unknown_result' : reason + this.ctx.get('productAnalytics')?.track('install_plugin_result', { + input_value: attempt.input, is_success: status === 'success', + duration: Math.max(0, Date.now() - attempt.started), + ...errorReason === undefined ? {} : { error_reason: errorReason }, ...name === undefined ? {} : { plugin_name: name }, + }) + } + private patch(next: Partial): void { if (this.disposed) return this.store.set({ ...this.getSnapshot(), ...next }) diff --git a/packages/client/ui-plugin-manager/src/client/presentation.ts b/packages/client/ui-plugin-manager/src/client/presentation.ts index 292d604386..727d893729 100644 --- a/packages/client/ui-plugin-manager/src/client/presentation.ts +++ b/packages/client/ui-plugin-manager/src/client/presentation.ts @@ -143,6 +143,7 @@ export function noticeText(notice: ManagerNotice, t: Translate): string { case 'restart': return t('restartNotice') case 'overridden': return t('overriddenNotice', { name: notice.packageName }) case 'cancelled': return t('installCancelled') + case 'refresh-failed': return t('refreshError') case 'install': return t(({ done: 'installBackgroundDone', failed: 'installBackgroundFailed', unconfirmed: 'installBackgroundUnconfirmed', applying: 'installBackgroundApplying', unknown: 'installBackgroundUnknown', diff --git a/packages/client/ui-plugin-manager/src/client/sanitize-install-input.ts b/packages/client/ui-plugin-manager/src/client/sanitize-install-input.ts new file mode 100644 index 0000000000..c9caed9767 --- /dev/null +++ b/packages/client/ui-plugin-manager/src/client/sanitize-install-input.ts @@ -0,0 +1,13 @@ +/** Privacy-safe installation input classification shared by click and result events. */ + +/** + * Keep registry package names and plain versions; classify other installer inputs without their contents. + * @param spec - user-entered installation spec. + * @returns an identifier safe to send without URL credentials, tokens, or local paths. + */ +export function sanitizeInstallInput(spec: string): string { + if (/^(?:@[a-z0-9._-]+\/)?[a-z0-9][a-z0-9._-]*(?:@[a-z0-9.*^~+<>=| -]+)?$/iu.test(spec)) return spec + if (/^(?:git[+:]|git@|github:|gitlab:|bitbucket:)/iu.test(spec)) return '[git]' + if (/^[a-z][a-z0-9+.-]*:\/\//iu.test(spec)) return '[url]' + return '[path-or-other]' +} diff --git a/packages/client/ui-plugin-manager/tests/browser-plugin.client.spec.tsx b/packages/client/ui-plugin-manager/tests/browser-plugin.client.spec.tsx index f9cff227dc..155a15c69b 100644 --- a/packages/client/ui-plugin-manager/tests/browser-plugin.client.spec.tsx +++ b/packages/client/ui-plugin-manager/tests/browser-plugin.client.spec.tsx @@ -12,6 +12,7 @@ import { TestRemote, usePinnedBrowserLanguages } from '@deepseek-ai/dsh-client-t import * as settings from '@deepseek-ai/dsh-client-ui-settings/client' import { apply, inject, NS, PANEL_ID } from '../src/client/index.ts' import { PluginManagerPage } from '../src/client/PluginManagerPage.tsx' +import { PluginRefreshToast, type PluginRefreshToastFace } from '../src/client/PluginRefreshToast.tsx' import { PluginsPanelIcon } from '../src/client/PluginsPanelIcon.tsx' import type { PluginManagerFace } from '../src/client/manager-store.ts' @@ -54,6 +55,7 @@ function declare(slots: SlotRegistry): () => void { name: 'root', children: { 'main': { kind: 'keyed', scope: 'root' }, + 'shell.overlay': { kind: 'list', scope: 'root' }, 'sidebar.panellist': { kind: 'list', scope: 'root' }, }, } as never, () => null) @@ -80,6 +82,41 @@ describe('ui-plugin-manager browser plugin', () => { expect(navigation.getSnapshot()).toEqual({ view: { kind: 'package', name: 'dsh-navigation-test' } }) }) + it('shares refresh failures with the overlay after navigation and releases both registrations across reloads', async () => { + const b = await bench() + const fiber = b.ctx.plugin({ inject: [...inject], apply }) + await fiber.await() + expect(b.slots.entries('shell.overlay')).toHaveLength(0) + const removeRoot = declare(b.slots) + const mainInjected: object = b.slots.entries('main')[0]!.inject!() + const face = mainInjected as PluginManagerFace + const overlayInjected: object = b.slots.entries('shell.overlay')[0]!.inject!() + const overlay = overlayInjected as PluginRefreshToastFace + expect(overlay.hooks.pluginManager).toBe(face.hooks.pluginManager) + face.ensure() + await vi.waitFor(() => { expect(face.hooks.pluginManager.getSnapshot().status).toBe('ready') }) + b.selectPanel(PANEL_ID) + b.list.mockRejectedValueOnce(new Error('offline')) + face.refresh() + b.selectPanel(null) + await vi.waitFor(() => { expect(overlay.hooks.pluginManager.getSnapshot().notice?.kind).toBe('refresh-failed') }) + overlay.dismissNotice() + expect(face.hooks.pluginManager.getSnapshot().notice).toBeNull() + removeRoot() + expect(b.slots.entries('shell.overlay')).toHaveLength(0) + expect(b.slots.entries('main')).toHaveLength(0) + declare(b.slots) + const remountedInjected: object = b.slots.entries('shell.overlay')[0]!.inject!() + expect((remountedInjected as PluginRefreshToastFace).hooks.pluginManager).toBe(face.hooks.pluginManager) + await fiber.dispose() + expect(b.slots.entries('shell.overlay')).toHaveLength(0) + expect(b.slots.entries('main')).toHaveLength(0) + await b.ctx.plugin({ inject: [...inject], apply }).await() + const reloadedInjected: object = b.slots.entries('shell.overlay')[0]!.inject!() + expect((reloadedInjected as PluginRefreshToastFace).hooks.pluginManager).not.toBe(face.hooks.pluginManager) + expect((reloadedInjected as PluginRefreshToastFace).hooks.pluginManager.getSnapshot().notice).toBeNull() + }) + it('declares only the services the page and its Remote methods use', () => { expect(inject).toEqual(['slots', 'locale', 'remote', 'remote.pluginManager', 'remote.pluginInventory', 'remote.pluginRegistryProbe', 'configForms', 'layout']) }) @@ -116,7 +153,18 @@ describe('ui-plugin-manager browser plugin', () => { for (const name of ['plugins.detail.actions', 'plugins.detail.badge', 'plugins.detail.section'] as const) { expect(b.slots.spec(name)).toMatchObject({ kind: 'list', scope: 'root' }) } - const face = (entry.inject as unknown as () => PluginManagerFace)() + const injected: object = entry.inject!() + const face = injected as PluginManagerFace + const overlay = b.slots.entries('shell.overlay')[0]! + expect(overlay.component).toBe(PluginRefreshToast) + expect(overlay.options).toMatchObject({ id: 'plugin-manager.refresh-toast' }) + expect(overlay.locale).toBe(NS) + const overlayInjected: object = overlay.inject!() + const overlayFace = overlayInjected as PluginRefreshToastFace + expect(overlayFace.hooks.pluginManager).toBe(face.hooks.pluginManager) + expect(overlayFace.dismissNotice).toBe(face.dismissNotice) + expect(Object.keys(overlayFace).sort()).toEqual(['dismissNotice', 'hooks']) + expect(Object.keys(overlayFace.hooks)).toEqual(['pluginManager']) const text = { en: 'Local tools', zh: '本地工具' } expect(face.resolveText(text)).toBe('本地工具') b.locale.setLocale('en') @@ -146,6 +194,7 @@ describe('ui-plugin-manager browser plugin', () => { await fiber.dispose() expect(b.ctx.get('pluginNavigation')).toBeUndefined() expect(b.slots.entries('main')).toHaveLength(0) + expect(b.slots.entries('shell.overlay')).toHaveLength(0) expect(b.slots.entries('sidebar.panellist')).toHaveLength(0) b.remote.emit('plugin-manager/changed', [{ reason: 'install' }]) await Promise.resolve() diff --git a/packages/client/ui-plugin-manager/tests/components.client.spec.tsx b/packages/client/ui-plugin-manager/tests/components.client.spec.tsx index 09303b9e7a..beaa13ac3d 100644 --- a/packages/client/ui-plugin-manager/tests/components.client.spec.tsx +++ b/packages/client/ui-plugin-manager/tests/components.client.spec.tsx @@ -1,7 +1,7 @@ // @vitest-environment jsdom import type { ConfigPageForm } from '../src/client/slot-contract.ts' import { act, cleanup, fireEvent, render, screen, within } from '@testing-library/react' -import { afterEach, describe, expect, it, onTestFinished, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, onTestFinished, vi } from 'vitest' import { Context } from '@deepseek-ai/cordis' import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client' import type { PluginEntryId, PluginInstallRequestId } from '@deepseek-ai/dsh-api-remotes/client' @@ -11,6 +11,7 @@ import { createSnapshotStore } from '@deepseek-ai/dsh-client-store' import { StrictMode, type ReactNode } from 'react' import { createNavigationStore } from '../src/client/navigation-store.ts' import { PluginManagerPage } from '../src/client/PluginManagerPage.tsx' +import { PluginRefreshToast } from '../src/client/PluginRefreshToast.tsx' import type { PluginManagerPageProps } from '../src/client/index.ts' import type { ConfigLedger } from '../src/client/config-ledger.ts' import { rowKey, type InstallState, type PackageRow, type PackageView, type PluginManagerState } from '../src/client/manager-store.ts' @@ -54,9 +55,9 @@ const OFFICIAL = 'https://registry.npmjs.org/' const REGISTRIES = { registry: null, fallbackRegistries: [MIRROR], resolved: OFFICIAL } /** pnpm's own registry as the options label it: by npm's own name once read, the neutral default name until then. */ -const OFFICIAL_OPTION = en.registryWithHost.replace('{name}', en.registryOfficial).replace('{host}', 'registry.npmjs.org') -const UNREAD_OPTION = en.registryWithHost.replace('{name}', en.registryDefault).replace('{host}', 'registry.npmjs.org') -const MIRROR_OPTION = en.registryWithHost.replace('{name}', en.registryNpmmirror).replace('{host}', 'registry.npmmirror.com') +const OFFICIAL_OPTION = `${en.registryOfficial} registry.npmjs.org` +const UNREAD_OPTION = `${en.registryDefault} registry.npmjs.org` +const MIRROR_OPTION = `${en.registryNpmmirror} registry.npmmirror.com` const IDLE_INSTALL: InstallState = { open: false, spec: '', registries: null, registry: { kind: 'offered', registry: null }, registryOpen: false, registryError: false, attempts: null, @@ -66,6 +67,7 @@ const IDLE_INSTALL: InstallState = { const READY: PluginManagerState = { status: 'ready', + refreshStatus: 'idle', packages: [], busy: [], notice: null, @@ -163,9 +165,18 @@ function renderTab( return body(owner.view, owner, 'form' in owner ? owner.form as ConfigPageForm | undefined : undefined) }, } - const { rerender, unmount } = render() + let showPage = true + let currentT = t + const contents = () => ( + <> + {showPage ? : null} + + + ) + const { rerender, unmount } = render(contents()) return { navigation, + hidePage: () => { showPage = false; rerender(contents()) }, props, unmount, store, @@ -173,7 +184,8 @@ function renderTab( set: (next: Partial) => { act(() => { store.set({ ...store.getSnapshot(), ...next }) }) }, setLanguage: (dict: typeof en) => { locale.setLocale(dict === zh ? 'zh' : 'en') - rerender() + currentT = translate(dict) + rerender(contents()) }, } } @@ -203,9 +215,14 @@ describe('PluginManagerPage', () => { it('asks the store once mounted and renders the loading, unavailable, error, and empty states', () => { const { actions, set } = renderTab({ status: 'loading' }) expect(actions.ensure).toHaveBeenCalledTimes(1) - expect(screen.getByText(en.loading).querySelector('[data-state="ongoing"]')).not.toBeNull() + const loading = screen.getByRole('status', { name: en.loading }) + expect(loading.querySelectorAll('li')).toHaveLength(4) + expect(loading.querySelector('ul')?.getAttribute('aria-hidden')).toBe('true') + expect(loading.querySelector('button, input, [data-state="ongoing"]')).toBeNull() + expect(document.querySelector('[data-plugin-panel]')?.getAttribute('aria-busy')).toBe('true') expect(screen.getByRole('button', { name: en.addPlugin })).toHaveProperty('disabled', true) set({ status: 'unavailable' }) + expect(screen.queryByRole('status', { name: en.loading })).toBeNull() expect(screen.getByRole('status').querySelector('[data-state="idle"]')).not.toBeNull() set({ status: 'error' }) expect(screen.getByRole('alert').querySelector('[data-state="error"]')).not.toBeNull() @@ -219,6 +236,134 @@ describe('PluginManagerPage', () => { expect(actions.openInstall).toHaveBeenCalledTimes(1) }) + it('shows skeletons only while loading and keeps ready cards during refresh and switching', () => { + const { actions, set } = renderTab({ status: 'idle' }) + expect(screen.queryByRole('status', { name: en.loading })).toBeNull() + set({ status: 'loading' }) + expect(screen.getByRole('status', { name: en.loading })).toBeTruthy() + set({ status: 'ready', packages: [pkg()] }) + expect(screen.queryByRole('status', { name: en.loading })).toBeNull() + expect(document.querySelector('[data-plugin-panel]')?.getAttribute('aria-busy')).toBe('false') + fireEvent.click(screen.getByRole('button', { name: en.refresh })) + expect(actions.refresh).toHaveBeenCalledTimes(1) + set({ busy: ['dsh-better-sidebar'] }) + expect(screen.queryByRole('status', { name: en.loading })).toBeNull() + expect(screen.getByRole('button', { name: en.openDetail.replace('{name}', 'dsh-better-sidebar') })).toBeTruthy() + expect(screen.getByRole('switch', { name: en.enableToggle.replace('{name}', 'dsh-better-sidebar') })).toHaveProperty('disabled', true) + }) + + it('shows refresh progress on the disabled button while preserving cards, then restores it without a toast', () => { + const { actions, set } = renderTab({ packages: [pkg()] }) + const refresh = screen.getByRole('button', { name: en.refresh }) + const detailName = en.openDetail.replace('{name}', 'dsh-better-sidebar') + const card = screen.getByRole('button', { name: detailName }) + expect(refresh).toHaveProperty('disabled', false) + expect(refresh.getAttribute('title')).toBeNull() + fireEvent.click(refresh) + expect(actions.refresh).toHaveBeenCalledOnce() + set({ refreshStatus: 'refreshing' }) + expect(refresh).toHaveProperty('disabled', true) + expect(refresh.getAttribute('aria-busy')).toBe('true') + expect(refresh.querySelector('[data-state="ongoing"]')).not.toBeNull() + expect(screen.getByRole('button', { name: detailName })).toBe(card) + expect(screen.queryByRole('status', { name: en.loading })).toBeNull() + expect(screen.queryByRole('alert')).toBeNull() + fireEvent.click(refresh) + expect(actions.refresh).toHaveBeenCalledOnce() + set({ refreshStatus: 'idle' }) + expect(refresh).toHaveProperty('disabled', false) + expect(refresh.getAttribute('aria-busy')).toBe('false') + expect(refresh.querySelector('[data-state="ongoing"]')).toBeNull() + expect(screen.getByRole('button', { name: detailName })).toBe(card) + expect(screen.queryByRole('alert')).toBeNull() + expect(actions.dismissNotice).not.toHaveBeenCalled() + }) + + it.each(['list', 'detail'] as const)('keeps cached %s content with one localized refresh toast and no inline retry', (view) => { + const { set, setLanguage } = renderTab({ packages: [pkg()] }) + if (view === 'detail') fireEvent.click(screen.getByRole('button', { name: en.openDetail.replace('{name}', 'dsh-better-sidebar') })) + const content = document.querySelector(view === 'detail' ? '[data-plugin-detail]' : '[data-plugin-package]') + for (const dict of [en, zh]) { + setLanguage(dict) + set({ status: 'ready', refreshStatus: 'idle', notice: { kind: 'refresh-failed', seq: 1 } }) + expect(screen.getAllByRole('alert')).toHaveLength(1) + const toast = screen.getByRole('alert') + expect(toast.textContent).toBe(dict.refreshError) + expect(toast.parentElement).toBe(document.body) + expect(document.querySelector('[data-plugin-panel]')?.contains(toast)).toBe(false) + expect(screen.queryByRole('button', { name: dict.retry })).toBeNull() + expect(content?.isConnected).toBe(true) + set({ refreshStatus: 'refreshing', notice: null }) + expect(screen.queryByRole('alert')).toBeNull() + expect(content?.isConnected).toBe(true) + set({ refreshStatus: 'idle' }) + expect(screen.queryByRole('alert')).toBeNull() + } + }) + + it.each(['list', 'configuration detail'] as const)('keeps an uncached refresh failure inline in %s and hides it during retry', (view) => { + const { actions, set, setLanguage } = renderTab( + { status: 'error', refreshStatus: 'failed' }, + { items: [{ id: 'bash', label: 'Shell' }] }, + ) + if (view === 'configuration detail') fireEvent.click(screen.getByRole('button', { name: en.openDetail.replace('{name}', 'Shell') })) + for (const dict of [en, zh]) { + setLanguage(dict) + set({ status: 'error', refreshStatus: 'failed' }) + const alert = screen.getByRole('alert') + expect(alert.textContent).toBe(dict.refreshError) + expect(document.querySelector('[data-plugin-panel]')?.contains(alert)).toBe(true) + fireEvent.click(screen.getByRole('button', { name: dict.retry })) + set({ refreshStatus: 'refreshing' }) + expect(screen.queryByRole('alert')).toBeNull() + expect(screen.queryByRole('button', { name: dict.retry })).toBeNull() + } + expect(actions.refresh).toHaveBeenCalledTimes(2) + }) + + it.each(['before', 'after'] as const)('keeps the refresh toast when navigation leaves %s the failure arrives', (navigation) => { + vi.useFakeTimers() + try { + const { actions, set, hidePage } = renderTab({ packages: [pkg()] }) + set({ refreshStatus: 'refreshing' }) + if (navigation === 'before') hidePage() + set({ refreshStatus: 'idle', notice: { kind: 'refresh-failed', seq: 1 } }) + const toast = screen.getByRole('alert') + expect(toast.textContent).toBe(en.refreshError) + if (navigation === 'after') hidePage() + expect(document.querySelector('[data-plugin-panel]')).toBeNull() + expect(screen.getByRole('alert')).toBe(toast) + expect(toast.style.getPropertyValue('--dsh-toast-hold')).toBe('3000ms') + act(() => { vi.advanceTimersByTime(3_999) }) + expect(actions.dismissNotice).not.toHaveBeenCalled() + act(() => { vi.advanceTimersByTime(1) }) + expect(actions.dismissNotice).toHaveBeenCalledOnce() + set({ notice: null }) + expect(screen.queryByRole('alert')).toBeNull() + } finally { + cleanup() + vi.useRealTimers() + } + }) + + it('gives a repeated refresh failure its own toast lifetime without extending it for locale changes', () => { + vi.useFakeTimers() + try { + const { actions, set, setLanguage } = renderTab({ notice: { kind: 'refresh-failed', seq: 1 } }) + act(() => { vi.advanceTimersByTime(2_000) }) + set({ notice: { kind: 'refresh-failed', seq: 2 } }) + act(() => { vi.advanceTimersByTime(2_000) }) + expect(actions.dismissNotice).not.toHaveBeenCalled() + setLanguage(zh) + expect(screen.getByRole('alert').textContent).toBe(zh.refreshError) + act(() => { vi.advanceTimersByTime(2_000) }) + expect(actions.dismissNotice).toHaveBeenCalledOnce() + } finally { + cleanup() + vi.useRealTimers() + } + }) + it('keeps the read failure and its retry visible while a detail page is open', () => { const { actions, set } = renderTab({ packages: [pkg()] }) fireEvent.click(screen.getByRole('button', { name: en.openDetail.replace('{name}', 'dsh-better-sidebar') })) @@ -735,6 +880,19 @@ describe('PluginManagerPage', () => { expect(document.querySelector('[data-plugin-name]')?.textContent).toBe(name) }) + it.each([ + { locale: en, placeholder: 'for example dsh-plugin-whale-pet' }, + { locale: zh, placeholder: '例如 dsh-plugin-whale-pet' }, + ])('uses the package example in $placeholder and the install guide', ({ locale, placeholder }) => { + const { actions, setLanguage } = renderTab({ install: { ...IDLE_INSTALL, open: true } }) + setLanguage(locale) + expect(screen.getByPlaceholderText(placeholder)).toBeTruthy() + fireEvent.click(screen.getByRole('button', { name: locale.installGuideToggle })) + expect(screen.getByText('dsh-plugin-whale-pet')).toBeTruthy() + fireEvent.click(screen.getByRole('button', { name: locale.installGuideFillAria.replace('{example}', 'dsh-plugin-whale-pet') })) + expect(actions.editInstallSpec).toHaveBeenCalledExactlyOnceWith('dsh-plugin-whale-pet') + }) + it('opens a guide under the field and drops an example into it', () => { const { actions } = renderTab({ install: { ...IDLE_INSTALL, open: true } }) expect(screen.queryByText(en.installGuideIdHint)).toBeNull() @@ -857,6 +1015,100 @@ describe('PluginManagerPage', () => { expect(within(detail).getByText(en.rowStateIdle)).toBeTruthy() }) + describe.each(['package', 'custom registry'] as const)('%s input IME confirmation', (input) => { + beforeEach(() => { vi.useFakeTimers() }) + afterEach(() => { cleanup(); vi.useRealTimers() }) + const inputName = input === 'package' ? en.installSpecLabel : en.registryCustom + const open: InstallState = { + ...IDLE_INSTALL, open: true, spec: 'dsh-new', registryOpen: true, + registry: { kind: 'custom', url: 'https://npm.corp.example/' }, + } + const enter = (field: HTMLElement) => { + // oxlint-disable-next-line typescript/no-deprecated -- Exercise native Enter's legacy keyCode in the IME regression. + const event = new KeyboardEvent('keydown', { key: 'Enter', isComposing: false, keyCode: 13, bubbles: true, cancelable: true }) + fireEvent(field, event) + expect(event.defaultPrevented).toBe(false) + } + + it('guards active composition and the first 10ms after compositionend without native flags', () => { + const { actions } = renderTab({ install: open }) + const field = screen.getByRole('textbox', { name: inputName }) + fireEvent.compositionStart(field) + enter(field) + expect(actions.runInstall).not.toHaveBeenCalled() + fireEvent.compositionEnd(field) + enter(field) + act(() => { vi.advanceTimersByTime(9) }) + enter(field) + expect(actions.runInstall).not.toHaveBeenCalled() + act(() => { vi.advanceTimersByTime(1) }) + enter(field) + expect(actions.runInstall).toHaveBeenCalledOnce() + }) + + it('keeps composition state independent from the other input', () => { + const { actions } = renderTab({ install: open }) + const field = screen.getByRole('textbox', { name: inputName }) + const other = screen.getByRole('textbox', { name: input === 'package' ? en.registryCustom : en.installSpecLabel }) + fireEvent.compositionStart(field) + enter(other) + expect(actions.runInstall).toHaveBeenCalledOnce() + enter(field) + expect(actions.runInstall).toHaveBeenCalledOnce() + }) + + it.each(['active', 'ended'] as const)('clears %s composition on blur and dialog reopen', (phase) => { + const { actions, set } = renderTab({ install: open }) + const field = screen.getByRole('textbox', { name: inputName }) + fireEvent.compositionStart(field) + if (phase === 'ended') fireEvent.compositionEnd(field) + fireEvent.blur(field) + enter(field) + expect(actions.runInstall).toHaveBeenCalledOnce() + fireEvent.compositionStart(field) + if (phase === 'ended') fireEvent.compositionEnd(field) + set({ install: { ...open, open: false } }) + set({ install: open }) + enter(screen.getByRole('textbox', { name: inputName })) + expect(actions.runInstall).toHaveBeenCalledTimes(2) + }) + + if (input === 'custom registry') { + it('clears composition when the registry menu closes and reopens', () => { + const { actions, set } = renderTab({ install: open }) + fireEvent.compositionStart(screen.getByRole('textbox', { name: inputName })) + set({ install: { ...open, registryOpen: false } }) + set({ install: open }) + enter(screen.getByRole('textbox', { name: inputName })) + expect(actions.runInstall).toHaveBeenCalledOnce() + }) + } + + it.each([ + { signal: 'isComposing', isComposing: true, keyCode: 13 }, + { signal: 'legacy keyCode 229', isComposing: false, keyCode: 229 }, + ])('leaves $signal Enter to the IME and installs on the next ordinary Enter', ({ isComposing, keyCode }) => { + const { actions } = renderTab({ + install: { + ...IDLE_INSTALL, open: true, spec: 'dsh-new', registryOpen: input === 'custom registry', + registry: { kind: 'custom', url: 'https://npm.corp.example/' }, + }, + }) + const field = screen.getByRole('textbox', { name: input === 'package' ? en.installSpecLabel : en.registryCustom }) + fireEvent.compositionStart(field) + if (!isComposing) fireEvent.compositionEnd(field) + // oxlint-disable-next-line typescript/no-deprecated -- Exercise the legacy IME signal when isComposing is false. + const confirm = new KeyboardEvent('keydown', { key: 'Enter', isComposing, keyCode, bubbles: true, cancelable: true }) + fireEvent(field, confirm) + expect(actions.runInstall).not.toHaveBeenCalled() + expect(confirm.defaultPrevented).toBe(false) + if (isComposing) fireEvent.compositionEnd(field) + act(() => { vi.advanceTimersByTime(10) }) + fireEvent.keyDown(field, { key: 'Enter', isComposing: false, keyCode: 13 }) + expect(actions.runInstall).toHaveBeenCalledOnce() + }) + }) + it('takes a spec, checks it, and words what the check refused', () => { const { actions, set } = renderTab({ install: { ...IDLE_INSTALL, open: true } }) expect(screen.getByText(en.installDescription)).toBeTruthy() @@ -1197,6 +1449,86 @@ describe('PluginManagerPage', () => { } }) + it('selects the custom registry from its whole area and focuses the address without clearing it', () => { + const open = { ...IDLE_INSTALL, open: true, registryOpen: true, registries: REGISTRIES } + const { actions, set } = renderTab({ install: open }) + const field = screen.getByRole('textbox', { name: en.registryCustom }) + const label = screen.getByRole('radio', { name: en.registryCustom }).parentElement! + const area = label.parentElement! + expect(field).toHaveProperty('disabled', false) + for (const target of [area, label, screen.getByText(en.registryCustomHint)]) { + field.blur() + set({ install: open }) + actions.chooseRegistry.mockClear() + fireEvent.pointerDown(target) + fireEvent.click(target) + expect(actions.chooseRegistry).toHaveBeenCalledExactlyOnceWith({ kind: 'custom', url: '' }) + expect(document.activeElement).toBe(field) + } + set({ install: { ...open, registry: { kind: 'custom', url: 'https://npm.corp/' } } }) + field.blur() + actions.chooseRegistry.mockClear() + field.focus() + fireEvent.click(field) + fireEvent.click(area) + expect(document.activeElement).toBe(field) + expect(field).toHaveProperty('value', 'https://npm.corp/') + expect(actions.chooseRegistry).not.toHaveBeenCalled() + field.blur() + fireEvent.click(screen.getByRole('radio', { name: OFFICIAL_OPTION })) + set({ install: open }) + expect(field).toHaveProperty('value', 'https://npm.corp/') + fireEvent.click(screen.getByRole('radio', { name: MIRROR_OPTION })) + set({ install: { ...open, registry: { kind: 'offered', registry: MIRROR } } }) + fireEvent.click(area) + expect(actions.chooseRegistry).toHaveBeenLastCalledWith({ kind: 'custom', url: 'https://npm.corp/' }) + expect(document.activeElement).toBe(field) + }) + + it('keeps the selected registry on keyboard focus and selects custom through its radio', () => { + const open = { ...IDLE_INSTALL, open: true, registryOpen: true, registries: REGISTRIES } + const { actions } = renderTab({ install: open }) + const field = screen.getByRole('textbox', { name: en.registryCustom }) + const official = screen.getByRole('radio', { name: OFFICIAL_OPTION }) + official.focus() + fireEvent.keyDown(official, { key: 'Tab' }) + expect(document.activeElement).toBe(field) + expect(official).toHaveProperty('checked', true) + expect(actions.chooseRegistry).not.toHaveBeenCalled() + fireEvent.keyDown(field, { key: 'Tab', shiftKey: true }) + expect(document.activeElement).toBe(official) + fireEvent.keyDown(official, { key: 'Tab', shiftKey: true }) + const toggle = screen.getByRole('button', { name: `${en.registryToggle} ${en.registryOfficial}` }) + expect(document.activeElement).toBe(toggle) + expect(actions.toggleRegistryOptions).toHaveBeenCalledOnce() + actions.toggleRegistryOptions.mockClear() + field.focus() + fireEvent.keyDown(field, { key: 'Tab' }) + expect(document.activeElement).toBe(toggle) + expect(actions.toggleRegistryOptions).toHaveBeenCalledOnce() + const custom = screen.getByRole('radio', { name: en.registryCustom }) + custom.focus() + fireEvent.keyDown(custom, { key: ' ' }) + fireEvent.click(custom) + expect(actions.chooseRegistry).toHaveBeenCalledExactlyOnceWith({ kind: 'custom', url: '' }) + expect(document.activeElement).toBe(field) + }) + + it('focuses an invalid custom address when submission opens its registry menu', () => { + const open = { ...IDLE_INSTALL, open: true, spec: 'dsh-new', registry: { kind: 'custom' as const, url: 'invalid' } } + const { actions, set } = renderTab({ install: open }) + fireEvent.click(screen.getByRole('button', { name: en.installRun })) + expect(actions.runInstall).toHaveBeenCalledOnce() + set({ install: { ...open, registryOpen: true, registryError: true } }) + const field = screen.getByRole('textbox', { name: en.registryCustom }) + expect(document.activeElement).toBe(field) + expect(field.getAttribute('aria-invalid')).toBe('true') + expect(screen.getByRole('alert').textContent).toBe(en.registryCustomInvalid) + fireEvent.keyDown(field, { key: 'Escape' }) + expect(actions.toggleRegistryOptions).toHaveBeenCalledOnce() + expect(actions.closeInstall).not.toHaveBeenCalled() + }) + it('offers the registries under the spec, folded by default, and picks or types one', () => { const open = { ...IDLE_INSTALL, open: true, registries: REGISTRIES } const { actions, set } = renderTab({ install: open }) diff --git a/packages/client/ui-plugin-manager/tests/manager-store.client.spec.ts b/packages/client/ui-plugin-manager/tests/manager-store.client.spec.ts index 0cd5683e0e..895a4feccd 100644 --- a/packages/client/ui-plugin-manager/tests/manager-store.client.spec.ts +++ b/packages/client/ui-plugin-manager/tests/manager-store.client.spec.ts @@ -71,10 +71,12 @@ const NO_CONFIG: HostObservable = { subscribe: () => () => {}, } -function bench(overrides: Partial>> = {}) { +function bench(overrides: Partial>> = {}, enabled = true) { const inventory = { list: overrides.inventory ?? vi.fn(() => Promise.resolve(ok({ entries: [], managementAvailable: true }))) } const plugins = { - listBundles: vi.fn(() => Promise.resolve(ok([BUNDLE]))), + listBundles: vi.fn<() => Promise> | ReturnType>>( + () => Promise.resolve(ok([BUNDLE])), + ), listPlugins: vi.fn(() => Promise.resolve(ok(PLUGINS))), inspect: vi.fn(() => Promise.resolve(ok(INSPECTED))), registries: vi.fn(() => Promise.resolve(ok(REGISTRIES))), @@ -87,7 +89,9 @@ function bench(overrides: Partial>> = {} ...overrides, } const probe = { fastest: overrides.fastest ?? vi.fn(() => Promise.resolve(ok(null))) } + const track = vi.fn() const ctx = { + get: () => ({ enabled, track }), configForms: { describe: () => ({ getSnapshot: () => ({ view: { namespaces: [] } }), subscribe: () => () => {} }), get: vi.fn((id: string) => `form:${id}`) }, remote: { pluginManager: plugins, pluginInventory: inventory, pluginRegistryProbe: probe }, } as never @@ -100,7 +104,7 @@ function bench(overrides: Partial>> = {} await vi.waitFor(() => { expect(state().install.phase).toBe('starting') }) return state().install.requestId as PluginInstallRequestId } - return { plugins, inventory, probe, controller, face, state, started } + return { plugins, inventory, probe, controller, face, state, started, track } } it('hands a custom page the shared configuration form of its entry', () => { @@ -224,7 +228,229 @@ describe('PluginManagerController', () => { expect(plugins.listBundles).toHaveBeenCalledTimes(2) }) + it('keeps cached packages while a manual refresh reads and ignores repeated refreshes without a success notice', async () => { + vi.useFakeTimers() + onTestFinished(() => { vi.useRealTimers() }) + const { plugins, inventory, face, state, controller } = bench() + await controller.load() + const packages = state().packages + const gate = deferred>>() + plugins.listBundles.mockReturnValueOnce(gate.promise) + face.refresh() + expect(state()).toMatchObject({ status: 'ready', refreshStatus: 'refreshing', notice: null }) + expect(state().packages).toBe(packages) + await vi.advanceTimersByTimeAsync(0) + expect(plugins.listBundles).toHaveBeenCalledTimes(2) + face.refresh() + face.refresh() + gate.resolve(ok([{ ...BUNDLE, version: '0.17.0' }])) + await vi.advanceTimersByTimeAsync(399) + expect(state().refreshStatus).toBe('refreshing') + await vi.advanceTimersByTimeAsync(1) + expect(state().refreshStatus).toBe('idle') + expect(state().packages[0]?.version).toBe('0.17.0') + expect(state().status).toBe('ready') + expect(state().notice).toBeNull() + expect(inventory.list).toHaveBeenCalledTimes(2) + expect(plugins.listBundles).toHaveBeenCalledTimes(2) + expect(plugins.listPlugins).toHaveBeenCalledTimes(2) + }) + + it.each(['refused', 'rejected'] as const)('keeps cached packages ready after a %s refresh and clears its toast when retry starts', async (failure) => { + vi.useFakeTimers() + onTestFinished(() => { vi.useRealTimers() }) + const { inventory, plugins, face, state, controller } = bench() + await controller.load() + const packages = state().packages + if (failure === 'refused') inventory.list.mockResolvedValueOnce(refused('gateway/internal', 'offline')) + else inventory.list.mockRejectedValueOnce(new Error('transport down')) + face.refresh() + await vi.advanceTimersByTimeAsync(400) + expect(state().refreshStatus).toBe('idle') + expect(state()).toMatchObject({ status: 'ready', notice: { kind: 'refresh-failed', seq: 1 } }) + expect(state().packages).toBe(packages) + const gate = deferred>>() + plugins.listBundles.mockReturnValueOnce(gate.promise) + face.refresh() + expect(state()).toMatchObject({ status: 'ready', refreshStatus: 'refreshing', notice: null }) + expect(state().packages).toBe(packages) + await vi.advanceTimersByTimeAsync(0) + expect(plugins.listBundles).toHaveBeenCalledTimes(2) + gate.resolve(ok([BUNDLE])) + await vi.advanceTimersByTimeAsync(400) + expect(state().refreshStatus).toBe('idle') + expect(state()).toMatchObject({ status: 'ready', notice: null }) + inventory.list.mockResolvedValueOnce(refused('gateway/internal', 'offline again')) + face.refresh() + await vi.advanceTimersByTimeAsync(400) + expect(state().notice).toEqual({ kind: 'refresh-failed', seq: 2 }) + face.dismissNotice() + expect(state().notice).toBeNull() + }) + + it.each(['refused', 'rejected'] as const)('keeps a %s first refresh failure inline without a toast', async (failure) => { + vi.useFakeTimers() + onTestFinished(() => { vi.useRealTimers() }) + const inventory = failure === 'refused' + ? vi.fn().mockResolvedValueOnce(refused('gateway/internal', 'offline')) + : vi.fn().mockRejectedValueOnce(new Error('transport down')) + const { face, state } = bench({ inventory }) + face.refresh() + await vi.advanceTimersByTimeAsync(400) + expect(state().refreshStatus).toBe('failed') + expect(state()).toMatchObject({ status: 'error', packages: [], notice: null }) + }) + + it('treats an empty successful inventory as cached for refresh failures', async () => { + vi.useFakeTimers() + onTestFinished(() => { vi.useRealTimers() }) + const { inventory, face, state, controller } = bench({ listBundles: vi.fn().mockResolvedValue(ok([])) }) + await controller.load() + expect(state().packages).toEqual([]) + inventory.list.mockResolvedValueOnce(refused('gateway/internal', 'offline')) + face.refresh() + await vi.advanceTimersByTimeAsync(400) + expect(state().refreshStatus).toBe('idle') + expect(state()).toMatchObject({ status: 'ready', packages: [], notice: { kind: 'refresh-failed' } }) + }) + + it('forgets cached inventory when the managed profile becomes unavailable', async () => { + vi.useFakeTimers() + onTestFinished(() => { vi.useRealTimers() }) + const { inventory, face, state, controller } = bench() + await controller.load() + inventory.list.mockResolvedValueOnce(ok({ entries: [], managementAvailable: false })) + await controller.load() + expect(state()).toMatchObject({ status: 'unavailable', packages: [] }) + inventory.list.mockResolvedValueOnce(refused('gateway/internal', 'offline')) + face.refresh() + await vi.advanceTimersByTimeAsync(400) + expect(state().refreshStatus).toBe('failed') + expect(state()).toMatchObject({ status: 'error', packages: [], notice: null }) + }) + + it('preserves unrelated notices when a manual refresh starts and succeeds', async () => { + vi.useFakeTimers() + onTestFinished(() => { vi.useRealTimers() }) + const { face, state, controller } = bench({ + setBundleEnabled: vi.fn().mockResolvedValue(ok({ ...APPLIED, application: 'restart-required' })), + }) + await controller.load() + face.setEnabled(BUNDLE.name, true) + await vi.advanceTimersByTimeAsync(0) + expect(state().notice?.kind).toBe('restart') + const notice = state().notice + face.refresh() + expect(state().notice).toBe(notice) + await vi.advanceTimersByTimeAsync(400) + expect(state().refreshStatus).toBe('idle') + expect(state().notice).toBe(notice) + }) + + it('keeps background reads quiet and clears an uncached refresh failure when they succeed', async () => { + vi.useFakeTimers() + onTestFinished(() => { vi.useRealTimers() }) + const { plugins, face, state, controller } = bench({ + inventory: vi.fn().mockResolvedValueOnce(refused('gateway/internal', 'offline')) + .mockResolvedValue(ok({ entries: [], managementAvailable: true })), + }) + face.refresh() + await vi.advanceTimersByTimeAsync(400) + expect(state().refreshStatus).toBe('failed') + const gate = deferred>>() + plugins.listBundles.mockReturnValueOnce(gate.promise) + const loading = controller.load() + await vi.advanceTimersByTimeAsync(0) + expect(plugins.listBundles).toHaveBeenCalledOnce() + expect(state().refreshStatus).toBe('failed') + gate.resolve(ok([BUNDLE])) + await loading + expect(state()).toMatchObject({ status: 'ready', refreshStatus: 'idle', notice: null }) + }) + + it.each(['background', 'manual'] as const)('holds the refresh indicator through coalesced reads when %s starts first', async (first) => { + vi.useFakeTimers() + onTestFinished(() => { vi.useRealTimers() }) + const { plugins, face, state, controller } = bench() + await controller.load() + const initial = deferred>>() + const rerun = deferred>>() + plugins.listBundles.mockReturnValueOnce(initial.promise).mockReturnValueOnce(rerun.promise) + const background = first === 'background' ? controller.load() : undefined + if (first === 'manual') face.refresh() + await vi.advanceTimersByTimeAsync(0) + expect(plugins.listBundles).toHaveBeenCalledTimes(2) + expect(state().refreshStatus).toBe(first === 'background' ? 'idle' : 'refreshing') + const completion = background ?? controller.load() + if (first === 'background') face.refresh() + initial.resolve(ok([{ ...BUNDLE, version: '0.17.0' }])) + await vi.advanceTimersByTimeAsync(400) + expect(plugins.listBundles).toHaveBeenCalledTimes(3) + expect(state()).toMatchObject({ status: 'ready', refreshStatus: 'refreshing', notice: null }) + expect(state().packages[0]?.version).toBe('0.17.0') + face.refresh() + rerun.resolve(ok([{ ...BUNDLE, version: '0.18.0' }])) + await completion + await vi.advanceTimersByTimeAsync(0) + expect(state().refreshStatus).toBe('idle') + expect(state().packages[0]?.version).toBe('0.18.0') + expect(state().notice).toBeNull() + expect(plugins.listBundles).toHaveBeenCalledTimes(3) + }) + + it.each([false, true])('reports only the final coalesced refresh result (failure: %s)', async (failure) => { + vi.useFakeTimers() + onTestFinished(() => { vi.useRealTimers() }) + const { plugins, face, state, controller } = bench() + await controller.load() + const initial = deferred> | ReturnType>() + const rerun = deferred> | ReturnType>() + plugins.listBundles.mockReturnValueOnce(initial.promise).mockReturnValueOnce(rerun.promise) + face.refresh() + await vi.advanceTimersByTimeAsync(0) + expect(plugins.listBundles).toHaveBeenCalledTimes(2) + const loading = controller.load() + initial.resolve(failure ? ok([BUNDLE]) : refused('gateway/internal', 'offline')) + await vi.advanceTimersByTimeAsync(0) + expect(plugins.listBundles).toHaveBeenCalledTimes(3) + expect(state().refreshStatus).toBe('refreshing') + expect(state().notice).toBeNull() + rerun.resolve(failure ? refused('gateway/internal', 'offline') : ok([BUNDLE])) + await loading + await vi.advanceTimersByTimeAsync(400) + expect(state().refreshStatus).toBe('idle') + expect(state().status).toBe('ready') + expect(state().notice).toEqual(failure ? { kind: 'refresh-failed', seq: 1 } : null) + }) + + it.each([false, true])('does not publish a manual refresh that settles after disposal (failure: %s)', async (failure) => { + vi.useFakeTimers() + onTestFinished(() => { vi.useRealTimers() }) + const { plugins, face, state, controller } = bench() + await controller.load() + const gate = deferred> | ReturnType>() + plugins.listBundles.mockReturnValueOnce(gate.promise) + face.refresh() + await vi.advanceTimersByTimeAsync(0) + expect(plugins.listBundles).toHaveBeenCalledTimes(2) + const completion = controller.load() + const before = state() + const listener = vi.fn() + const unsubscribe = face.hooks.pluginManager.subscribe(listener) + onTestFinished(unsubscribe) + controller.dispose() + gate.resolve(failure ? refused('gateway/internal', 'offline') : ok([{ ...BUNDLE, version: '0.17.0' }])) + await completion + face.refresh() + await vi.advanceTimersByTimeAsync(400) + expect(state()).toBe(before) + expect(listener).not.toHaveBeenCalled() + expect(plugins.listBundles).toHaveBeenCalledTimes(2) + }) + it('reports a Host without a managed profile as unavailable and keeps the last packages across a failed read', async () => { + vi.useFakeTimers() + onTestFinished(() => { vi.useRealTimers() }) const { inventory, plugins, face, state, controller } = bench() await controller.load() expect(state().packages).toHaveLength(1) @@ -239,11 +465,12 @@ describe('PluginManagerController', () => { plugins.listPlugins.mockResolvedValueOnce(refused('gateway/internal', 'offline') as never) await controller.load() expect(state()).toMatchObject({ status: 'error', packages: [packageView(BUNDLE, PLUGINS)] }) - plugins.listBundles.mockResolvedValueOnce(refused('gateway/internal', 'offline') as never) + plugins.listBundles.mockResolvedValueOnce(refused('gateway/internal', 'offline')) await controller.load() expect(state().status).toBe('error') face.refresh() - await vi.waitFor(() => { expect(state().status).toBe('ready') }) + await vi.advanceTimersByTimeAsync(400) + expect(state().status).toBe('ready') }) it('enables a bundle, marks it busy meanwhile, and says when a restart is needed or a layer overrides it', async () => { @@ -311,7 +538,7 @@ describe('PluginManagerController', () => { }) it('always asks before uninstalling, and cancelling runs nothing', async () => { - const { plugins, face, state, controller } = bench() + const { plugins, face, state, controller, track } = bench() await controller.load() face.uninstall(BUNDLE.name) expect(state().confirm).toEqual({ action: 'uninstall', packageName: BUNDLE.name }) @@ -319,10 +546,12 @@ describe('PluginManagerController', () => { expect(state().confirm).toBeNull() face.confirm() expect(plugins.removeBundle).not.toHaveBeenCalled() + expect(track).not.toHaveBeenCalled() face.uninstall(BUNDLE.name) face.confirm() expect(state().confirm).toBeNull() await vi.waitFor(() => { expect(plugins.removeBundle).toHaveBeenCalledExactlyOnceWith(BUNDLE.name) }) + expect(track).toHaveBeenCalledExactlyOnceWith('confirm_uninstall_plugin', { plugin_name: BUNDLE.name }) await vi.waitFor(() => { expect(state().busy).toEqual([]) }) // A refused removal names the action it was. plugins.removeBundle.mockResolvedValueOnce(ok({ ...failed(), stage: 'remove', error: { code: 'not-removable' } }) as never) @@ -857,7 +1086,7 @@ describe('PluginManagerController', () => { expect(state().highlight).toBeNull() }) - it('offers the scripts a blocked run left pending, and retries the same spec with them allowed', async () => { + it.each([true, false])('offers the scripts a blocked run left pending, and retries with analytics enabled=%s', async (enabled) => { const gates: ReturnType> | ReturnType>>>[] = [] const { face, state, controller, plugins, started } = bench({ installBundle: vi.fn(() => { @@ -865,7 +1094,7 @@ describe('PluginManagerController', () => { gates.push(gate) return gate.promise }), - }) + }, enabled) await controller.load() face.openInstall() face.editInstallSpec('x') @@ -1049,6 +1278,52 @@ describe('PluginManagerController', () => { await loading expect(state()).toBe(before) }) + + it.each([false, true])('drops refresh feedback when disposed during the spinner hold (failure: %s)', async (failure) => { + vi.useFakeTimers() + try { + const { plugins, face, state, controller } = bench() + await controller.load() + plugins.listBundles.mockResolvedValueOnce(failure ? refused('gateway/internal', 'offline') : ok([BUNDLE])) + face.refresh() + await vi.advanceTimersByTimeAsync(0) + expect(state().refreshStatus).toBe('refreshing') + expect(vi.getTimerCount()).toBe(1) + const before = state() + const listener = vi.fn() + const unsubscribe = face.hooks.pluginManager.subscribe(listener) + onTestFinished(unsubscribe) + controller.dispose() + await vi.advanceTimersByTimeAsync(400) + expect(state()).toBe(before) + expect(listener).not.toHaveBeenCalled() + expect(vi.getTimerCount()).toBe(0) + } finally { + vi.useRealTimers() + } + }) + + it('settles a manual refresh whose read outlasts the spinner minimum without an extra hold', async () => { + vi.useFakeTimers() + try { + const { plugins, face, state, controller } = bench() + await controller.load() + const gate = deferred>>() + plugins.listBundles.mockReturnValueOnce(gate.promise) + face.refresh() + await vi.advanceTimersByTimeAsync(401) + expect(plugins.listBundles).toHaveBeenCalledTimes(2) + expect(state().refreshStatus).toBe('refreshing') + gate.resolve(ok([{ ...BUNDLE, version: '0.42.0' }])) + await vi.advanceTimersByTimeAsync(0) + expect(state().refreshStatus).toBe('idle') + expect(state().packages[0]?.version).toBe('0.42.0') + expect(state().status).toBe('ready') + expect(vi.getTimerCount()).toBe(0) + } finally { + vi.useRealTimers() + } + }) it('offers the registries the Host configured, starts from its first, and asks the check and the run to start where the person chose', async () => { const gate = deferred>>() const { plugins, face, state, controller, started } = bench({ @@ -1152,15 +1427,18 @@ describe('PluginManagerController', () => { removeItem: (key: string) => { storage.delete(key) }, }) try { - const { plugins, face, state } = bench({ + const { plugins, face, state, track } = bench({ inspect: vi.fn(() => Promise.resolve(ok({ ...INSPECTED, registry: 'https://npm.corp.example/' }))), }) face.openInstall() face.editInstallSpec('dsh-new') face.chooseRegistry({ kind: 'custom', url: ' npm.corp.example ' }) face.runInstall() - expect(state().install).toMatchObject({ phase: 'idle', registryError: true }) + expect(state().install).toMatchObject({ phase: 'idle', registryError: true, registryOpen: true }) expect(plugins.inspect).not.toHaveBeenCalled() + expect(track.mock.calls.filter(([event]) => event === 'install_plugin_result')).toEqual([ + ['install_plugin_result', expect.objectContaining({ input_value: 'dsh-new', is_success: false, error_reason: 'invalid-registry' })], + ]) // Typing again clears the refusal; a URL is asked as typed, trimmed. face.chooseRegistry({ kind: 'custom', url: ' https://npm.corp.example ' }) expect(state().install.registryError).toBe(false) @@ -1168,6 +1446,9 @@ describe('PluginManagerController', () => { expect(plugins.inspect).toHaveBeenCalledWith('dsh-new', { registry: 'https://npm.corp.example' }, expect.any(AbortSignal)) await vi.waitFor(() => { expect(state().install.phase).toBe('done') }) expect(plugins.installBundle).toHaveBeenCalledWith('dsh-new', expect.objectContaining({ registry: 'https://npm.corp.example/' })) + const results = track.mock.calls.filter(([event]) => event === 'install_plugin_result') + expect(results).toHaveLength(2) + expect(results[1]).toEqual(['install_plugin_result', expect.objectContaining({ is_success: true, plugin_name: 'dsh-new' })]) // A dialog opened later, by another controller, starts from the registry last used. const later = bench() later.face.openInstall() @@ -1412,3 +1693,102 @@ it('recognizes the official registry without a trailing slash and with uppercase face.openInstall() await vi.waitFor(() => { expect(state().install.registry).toEqual({ kind: 'offered', registry: MIRROR }) }) }) + + +describe('desktop analytics outcomes', () => { + it('redacts authenticated installer URLs from click and failed-result events', async () => { + const b = bench({ inspect: vi.fn(async () => ok({ status: 'refused', problem: 'not-bundle', reason: 'refused' })) }) + b.face.openInstall() + b.face.editInstallSpec('git+https://user:private-token@example.invalid/repo.git?token=private-token') + b.face.runInstall() + await vi.waitFor(() => { expect(b.track).toHaveBeenCalledWith('install_plugin_result', expect.objectContaining({ input_value: '[git]' })) }) + expect(b.track).toHaveBeenCalledWith('plugin_install_click', { input_value: '[git]' }) + expect(JSON.stringify(b.track.mock.calls)).not.toContain('private-token') + }) + + it('reports input inspection failure separately from cancellation', async () => { + const b = bench({ inspect: vi.fn(async () => ok({ status: 'refused', problem: 'not-bundle', reason: 'not a bundle' })) }) + b.face.openInstall() + b.face.editInstallSpec('dsh-new') + b.face.runInstall() + await vi.waitFor(() => { expect(b.track).toHaveBeenCalledWith('install_plugin_result', expect.objectContaining({ input_value: 'dsh-new', is_success: false, error_reason: 'not-bundle', duration: expect.any(Number) as number })) }) + expect(b.track).toHaveBeenCalledWith('plugin_install_click', { input_value: 'dsh-new' }) + }) + + it('reports unknown only after recovery confirms the result is absent', async () => { + const lost = deferred>>() + const b = bench({ installBundle: vi.fn(async () => refused('gateway/internal', 'offline')), waitForInstall: vi.fn(() => lost.promise) }) + b.face.openInstall() + b.face.editInstallSpec('dsh-new') + b.face.runInstall() + await vi.waitFor(() => { expect(b.plugins.waitForInstall).toHaveBeenCalledTimes(1) }) + expect(b.track.mock.calls.filter(call => call[0] === 'install_plugin_result')).toEqual([]) + lost.resolve(ok(null)) + await vi.waitFor(() => { expect(b.track).toHaveBeenCalledWith('install_plugin_result', expect.objectContaining({ error_reason: 'unknown_result', is_success: false })) }) + b.face.reconcileInstall() + expect(b.track.mock.calls.filter(call => call[0] === 'install_plugin_result')).toHaveLength(1) + }) + + it('closing during inspection reports user_cancelled once', async () => { + const inspecting = deferred>>() + const b = bench({ inspect: vi.fn(() => inspecting.promise) }) + b.face.openInstall() + b.face.editInstallSpec('dsh-new') + b.face.runInstall() + await vi.waitFor(() => { expect(b.plugins.inspect).toHaveBeenCalledTimes(1) }) + b.face.closeInstall() + inspecting.resolve(ok(INSPECTED)) + await Promise.resolve() + const results = b.track.mock.calls.filter(call => call[0] === 'install_plugin_result') + expect(results).toHaveLength(1) + expect(results[0]?.[1]).toMatchObject({ error_reason: 'user_cancelled', is_success: false }) + expect(results[0]?.[1]).not.toHaveProperty('result_status') + expect(results[0]?.[1]).not.toHaveProperty('plugin_type') + expect(b.plugins.installBundle).not.toHaveBeenCalled() + }) + + it('uses bundle and plugin types only after successful changes', async () => { + const b = bench() + await b.controller.load() + b.face.setEnabled(BUNDLE.name, true) + await vi.waitFor(() => { expect(b.track).toHaveBeenCalledWith('plugin_toggle', { plugin_name: BUNDLE.name, plugin_type: 'bundle', is_enabled: true, is_builtin: false }) }) + b.face.setRowEnabled(ROW_ENTRY, false) + await vi.waitFor(() => { expect(b.track).toHaveBeenCalledWith('plugin_toggle', { plugin_name: BUNDLE.name, plugin_type: 'plugin', is_enabled: false, is_builtin: false }) }) + b.plugins.setBundleEnabled.mockResolvedValueOnce(ok({ ...APPLIED, application: 'cancelled' })) + b.face.setEnabled(BUNDLE.name, false) + await vi.waitFor(() => { expect(b.state().busy).toEqual([]) }) + expect(b.track.mock.calls.filter(call => call[0] === 'plugin_toggle')).toHaveLength(2) + }) +}) + + +it('does not retain install identity when analytics is disabled', async () => { + const b = bench({}, false) + b.face.openInstall() + b.face.editInstallSpec('dsh-new') + b.face.runInstall() + await vi.waitFor(() => { expect(b.state().install.phase).toBe('done') }) + expect(b.track.mock.calls.filter(([name]) => name === 'plugin_install_click' || name === 'install_plugin_result')).toEqual([]) +}) + +it('reports restart-required row toggles and tolerates a vanished inventory entry', async () => { + const b = bench({ setPluginEnabled: vi.fn(async () => ok({ ...APPLIED, application: 'restart-required' })) }) + await b.controller.load() + b.face.setRowEnabled(ROW_ENTRY, true) + await vi.waitFor(() => { expect(b.track).toHaveBeenCalledWith('plugin_toggle', expect.objectContaining({ plugin_type: 'plugin' })) }) + b.face.setEnabled('vanished-package', true) + await vi.waitFor(() => { expect(b.state().busy).toEqual([]) }) + expect(b.track.mock.calls.filter(([name]) => name === 'plugin_toggle')).toHaveLength(1) +}) + +it('reports a successful install with only documented result fields', async () => { + const b = bench() + b.face.openInstall() + b.face.editInstallSpec('dsh-new') + b.face.runInstall() + await vi.waitFor(() => { expect(b.state().install.phase).toBe('done') }) + const results = b.track.mock.calls.filter(([name]) => name === 'install_plugin_result') + expect(results).toEqual([['install_plugin_result', { + input_value: 'dsh-new', is_success: true, duration: expect.any(Number) as number, plugin_name: 'dsh-new', + }]]) +}) diff --git a/packages/client/ui-plugin-manager/tests/sanitize-install-input.client.spec.ts b/packages/client/ui-plugin-manager/tests/sanitize-install-input.client.spec.ts new file mode 100644 index 0000000000..d3e3e2bc17 --- /dev/null +++ b/packages/client/ui-plugin-manager/tests/sanitize-install-input.client.spec.ts @@ -0,0 +1,17 @@ +import { expect, it } from 'vitest' +import { sanitizeInstallInput } from '../src/client/sanitize-install-input.ts' + +it.each(['dsh-example', '@scope/plugin', '@scope/plugin@1.2.3', 'plugin@latest'])('retains a registry identifier: %s', (spec) => { + expect(sanitizeInstallInput(spec)).toBe(spec) +}) + +it.each([ + ['git+https://user:secret@example.invalid/org/repo.git?token=secret#secret', '[git]'], + ['https://user:secret@example.invalid/archive.tgz?signature=secret#secret', '[url]'], + ['git@example.invalid:private/repo', '[git]'], + ['/Users/private-name/plugin', '[path-or-other]'], + ['C:\\Users\\private-name\\plugin', '[path-or-other]'], + ['plugin@https://user:secret@example.invalid/archive.tgz', '[path-or-other]'], +])('removes private installer input: %s', (spec, expected) => { + expect(sanitizeInstallInput(spec)).toBe(expected) +}) diff --git a/packages/client/ui-plugin-manager/tsconfig.client.json b/packages/client/ui-plugin-manager/tsconfig.client.json index 4d56e3dee7..5f8c1f5e42 100644 --- a/packages/client/ui-plugin-manager/tsconfig.client.json +++ b/packages/client/ui-plugin-manager/tsconfig.client.json @@ -47,6 +47,9 @@ }, { "path": "../ui-settings/tsconfig.client.json" + }, + { + "path": "../product-analytics/tsconfig.client.json" } ], "include": [ diff --git a/packages/client/ui-primitives/README.i18n.yaml b/packages/client/ui-primitives/README.i18n.yaml index 6203412c5d..9df146dd15 100644 --- a/packages/client/ui-primitives/README.i18n.yaml +++ b/packages/client/ui-primitives/README.i18n.yaml @@ -18,8 +18,8 @@ en: ddec1f6f9b018747 zh: 9555f132daf8901c /deepseek-ai-dsh-client-ui-primitives/use-this-package/component-catalog: - en: 54ced5d959bd06c6 - zh: 5a7fc2504f92a788 + en: 27c3b3715866df73 + zh: 65c07b00516149ad /deepseek-ai-dsh-client-ui-primitives/use-this-package/controls-and-icons: en: 83052e14c3fb34cf zh: 0adb09885723296a diff --git a/packages/client/ui-primitives/README.md b/packages/client/ui-primitives/README.md index c9f0cb4a5a..0da0f9f934 100644 --- a/packages/client/ui-primitives/README.md +++ b/packages/client/ui-primitives/README.md @@ -55,7 +55,7 @@ Check this table before writing a control in a feature package. A plugin cannot | `DisclosureRow` | 24px compact disclosure that lays title and content side by side. Memoized with shallow prop comparison; keep callbacks and React-node props stable when their content is unchanged. | | `Modal` | Centered dialog over a page mask. A nested dialog can intercept keys with `onKeyDownCapture` before document Escape handlers. The tint and dialog fade in while backdrop blur stays fully applied, honoring reduced motion. Set `backdropBlur={false}` when the caller already blurs the source page. | | `RiskConfirmation` | Sensitive action gated behind an explicit checkbox. | -| `Tooltip` | Hover text anchored to a cloned child; optional `portal` rendering escapes clipping containers and ancestor stacking contexts that cap the bubble's z-index. | +| `Tooltip` | Hover and keyboard-focus text anchored to a cloned child; optional `delayMs` controls hover delay and `focusDelayMs` controls keyboard-focus delay, both defaulting to 0 ms. Optional `portal` rendering escapes clipping containers and ancestor stacking contexts that cap the bubble's z-index. | | `HoverCard` | Hover preview the pointer can rest on and select from; optional copy button. | | `ImageLightbox` | Shared image modal with focus restoration and Escape dismissal. | | `Toast` | Transient top-center banner held for the owner's `holdMs`. | diff --git a/packages/client/ui-primitives/README.zh.md b/packages/client/ui-primitives/README.zh.md index de6adf1a2d..f22fa7b540 100644 --- a/packages/client/ui-primitives/README.zh.md +++ b/packages/client/ui-primitives/README.zh.md @@ -55,7 +55,7 @@ kind: "package-library" | `DisclosureRow` | 24px 紧凑折叠行,标题与内容左右排列。使用浅层 prop 比较进行 memo;内容未变时,保持回调与 React 节点 prop 的引用稳定。 | | `Modal` | 页面遮罩之上的居中对话框。嵌套对话框可通过 `onKeyDownCapture` 在文档级 Escape 处理器之前拦截按键。 色层与弹窗淡入,背景模糊始终完整生效,并遵循减少动态效果偏好。调用方已模糊源页面时设置 `backdropBlur={false}`。 | | `RiskConfirmation` | 以显式复选框把关的敏感操作确认。 | -| `Tooltip` | 锚定在克隆子元素上的悬停文本;可通过 `portal` 渲染到外层,避免被容器裁剪,或受祖先层叠上下文限制其 z-index。 | +| `Tooltip` | 锚定在克隆子元素上的悬停与键盘聚焦提示;可选的 `delayMs` 控制悬停延迟,`focusDelayMs` 控制键盘聚焦延迟,均默认为 0 毫秒。可通过 `portal` 渲染到外层,避免被容器裁剪,或受祖先层叠上下文限制其 z-index。 | | `HoverCard` | 指针可停留、可选中的悬停预览;可选带复制按钮。 | | `ImageLightbox` | 共享图片浮层,支持焦点恢复与 Esc 关闭。 | | `Toast` | 顶部居中的瞬时横幅,保持时长由所有者的 `holdMs` 决定。 | diff --git a/packages/client/ui-primitives/src/Tooltip.tsx b/packages/client/ui-primitives/src/Tooltip.tsx index 3d0783c422..b997279f54 100644 --- a/packages/client/ui-primitives/src/Tooltip.tsx +++ b/packages/client/ui-primitives/src/Tooltip.tsx @@ -40,7 +40,9 @@ type TooltipLabel = string | (() => string) * bubble would overlap); default 'center'. Ignored for side 'right'. * @param props.portal - render the bubble under document.body, so an ancestor's clipping or its * stacking context (which confines the bubble's z-index to that context) cannot hide it. - * @param props.delayMs - hover delay in milliseconds; keyboard focus remains immediate. + * @param props.delayMs - hover delay in milliseconds (default 0). + * @param props.focusDelayMs - keyboard focus delay in milliseconds (default 0); blur, click, + * mouse leave, disabling, and unmount cancel a pending show. * @param props.gap - anchor-to-bubble distance in pixels for 'bottom'/'top' bubbles (default 8); * ignored for side 'right'. * @param props.disabled - suppress the bubble while true; the anchor renders identically so @@ -53,7 +55,7 @@ type TooltipLabel = string | (() => string) * anchor dismisses the bubble until the next trigger, and focus arriving after a pointer * interaction (a closing menu refocusing its trigger) never raises it. */ -export function Tooltip({ label, shortcutKeys, side = 'right', align = 'center', delayMs = 0, gap = 8, disabled = false, portal = false, maxWidth, children }: { label: TooltipLabel; shortcutKeys?: readonly string[] | undefined; side?: TooltipSide; align?: 'center' | 'end'; delayMs?: number; gap?: number; disabled?: boolean; portal?: boolean; maxWidth?: number; children: ReactElement }) { +export function Tooltip({ label, shortcutKeys, side = 'right', align = 'center', delayMs = 0, focusDelayMs = 0, gap = 8, disabled = false, portal = false, maxWidth, children }: { label: TooltipLabel; shortcutKeys?: readonly string[] | undefined; side?: TooltipSide; align?: 'center' | 'end'; delayMs?: number; focusDelayMs?: number; gap?: number; disabled?: boolean; portal?: boolean; maxWidth?: number; children: ReactElement }) { const anchor = useRef(null) // React 18 keeps the element's ref outside props; forward it so wrapping an // anchor in Tooltip never silently severs the owner's ref. @@ -157,16 +159,16 @@ export function Tooltip({ label, shortcutKeys, side = 'right', align = 'center', }) announce(true) } - const showAfterHoverDelay = () => { + const showAfterDelay = (delay: number) => { cancelShow() - if (delayMs <= 0) { + if (delay <= 0) { show() return } showTimer.current = setTimeout(() => { showTimer.current = null show() - }, delayMs) + }, delay) } const withdraw = () => { setPos(null) @@ -198,13 +200,18 @@ export function Tooltip({ label, shortcutKeys, side = 'right', align = 'center', {cloneElement(children, { ref: mergedRef, - onMouseEnter: (e) => { children.props.onMouseEnter?.(e); triggers.current.hover = true; showAfterHoverDelay() }, + onMouseEnter: (e) => { children.props.onMouseEnter?.(e); triggers.current.hover = true; showAfterDelay(delayMs) }, onMouseLeave: (e) => { children.props.onMouseLeave?.(e); triggers.current.hover = false; cancelShow(); withdraw() }, // Activating the anchor dismisses the bubble: the action often changes // what the anchor now does (pin → unpin), and the click leaves the // anchor focused, which would otherwise pin the relabelled bubble up. onClick: (e) => { children.props.onClick?.(e); triggers.current.focus = false; cancelShow(); withdraw() }, - onFocus: (e) => { children.props.onFocus?.(e); if (pointerModality()) return; triggers.current.focus = true; cancelShow(); show() }, + onFocus: (e) => { + children.props.onFocus?.(e) + if (pointerModality()) return + triggers.current.focus = true + showAfterDelay(focusDelayMs) + }, onBlur: (e) => { children.props.onBlur?.(e); triggers.current.focus = false; hide() }, })} {portal ? (content !== false && createPortal(content, document.body)) : content} diff --git a/packages/client/ui-primitives/src/index.ts b/packages/client/ui-primitives/src/index.ts index 0e4cc0a527..57511f4dab 100644 --- a/packages/client/ui-primitives/src/index.ts +++ b/packages/client/ui-primitives/src/index.ts @@ -59,6 +59,7 @@ export type { export { projectUserText, type UserTextReferences } from './user-text.tsx' export { Tooltip } from './Tooltip.tsx' export type { TooltipSide } from './Tooltip.tsx' +export { pointerModality } from './input-modality.ts' export { Toast } from './Toast.tsx' export { fileSizeText } from './file-size.ts' export { writeClipboard } from './clipboard.ts' diff --git a/packages/client/ui-primitives/src/plugin-artwork.tsx b/packages/client/ui-primitives/src/plugin-artwork.tsx index 374d99e138..8fdbadb711 100644 --- a/packages/client/ui-primitives/src/plugin-artwork.tsx +++ b/packages/client/ui-primitives/src/plugin-artwork.tsx @@ -70,13 +70,13 @@ export const PluginArtworkSearch = ({ size = 36, className }: IconProps) => { const uid = useArtworkId() return ( - +
{ expect(container.querySelector('foreignObject div')).not.toBeNull() }) + it('pairs the search green with the loop upper-right blue-violet in its ring and handle', () => { + const { container } = render() + const ring = container.querySelector('foreignObject div') + expect(ring?.style.background).toBe('conic-gradient(from 90deg, rgb(65, 225, 172) 0deg, rgb(101, 142, 255) 62.0619deg, rgb(65, 225, 172) 360deg)') + expect(container.querySelector('path[stroke]')?.getAttribute('stroke')).toBe('#658EFF') + }) + + it('uses light blue for both terminal strokes', () => { + const { container } = render() + const strokes = [...container.querySelectorAll('path')].map(path => path.getAttribute('stroke')) + expect(strokes).toEqual(['#679EFE', '#679EFE']) + expect(container.querySelector('linearGradient')).toBeNull() + }) + it.each(Object.entries(artworks))('%s renders at 36 by default and repeats without document-global ids', (_name, Artwork) => { const { container } = render( <> @@ -46,8 +60,8 @@ describe('plugin artwork', () => { for (const svg of container.querySelectorAll('svg')) expect(svg.getAttribute('width')).toBe('36') const ids = [...container.querySelectorAll('[id]')].map(node => node.id) expect(new Set(ids).size).toBe(ids.length) - for (const reference of container.querySelectorAll('[fill^="url("], [clip-path^="url("]')) { - const target = (reference.getAttribute('fill') ?? reference.getAttribute('clip-path') ?? '').slice('url(#'.length, -1) + for (const reference of container.querySelectorAll('[fill^="url("], [stroke^="url("], [clip-path^="url("]')) { + const target = (reference.getAttribute('fill') ?? reference.getAttribute('stroke') ?? reference.getAttribute('clip-path') ?? '').slice('url(#'.length, -1) expect(ids).toContain(target) } }) diff --git a/packages/client/ui-primitives/tests/tooltip.client.spec.tsx b/packages/client/ui-primitives/tests/tooltip.client.spec.tsx index 3d109a91bf..e681afae06 100644 --- a/packages/client/ui-primitives/tests/tooltip.client.spec.tsx +++ b/packages/client/ui-primitives/tests/tooltip.client.spec.tsx @@ -120,6 +120,85 @@ describe('Tooltip', () => { } }) + describe('keyboard focus delay', () => { + beforeEach(() => { + vi.useFakeTimers() + fireEvent.keyDown(document.body, { key: 'Tab' }) + }) + afterEach(() => { + cleanup() + vi.useRealTimers() + fireEvent.keyDown(document.body, { key: 'Tab' }) + }) + + it('shows at 500ms, but not at 499ms', () => { + const label = vi.fn(() => 'Refresh') + render() + fireEvent.focus(screen.getByText('anchor')) + expect(screen.queryByRole('tooltip')).toBeNull() + act(() => { vi.advanceTimersByTime(499) }) + expect(screen.queryByRole('tooltip')).toBeNull() + expect(label).not.toHaveBeenCalled() + act(() => { vi.advanceTimersByTime(1) }) + expect(screen.getByRole('tooltip').textContent).toBe('Refresh') + }) + + it.each(['blur', 'click', 'mouseLeave', 'disabled', 'unmount'] as const)( + 'cancels a pending focus bubble on %s', (cancel) => { + const view = render() + const anchor = screen.getByText('anchor') + const initialTimers = vi.getTimerCount() + fireEvent.focus(anchor) + expect(vi.getTimerCount()).toBe(initialTimers + 1) + act(() => { vi.advanceTimersByTime(499) }) + if (cancel === 'disabled') { + view.rerender() + } else if (cancel === 'unmount') { + view.unmount() + } else { + fireEvent[cancel](anchor) + } + expect(vi.getTimerCount()).toBe(initialTimers) + act(() => { vi.advanceTimersByTime(500) }) + expect(screen.queryByRole('tooltip')).toBeNull() + if (cancel === 'disabled') { + view.rerender() + expect(screen.queryByRole('tooltip')).toBeNull() + } + }, + ) + + it('keeps default focus immediate and cancels a pending hover delay', () => { + render() + const anchor = screen.getByText('anchor') + const initialTimers = vi.getTimerCount() + fireEvent.mouseEnter(anchor) + act(() => { vi.advanceTimersByTime(499) }) + expect(screen.queryByRole('tooltip')).toBeNull() + fireEvent.focus(anchor) + expect(screen.getByRole('tooltip').textContent).toBe('Refresh') + expect(vi.getTimerCount()).toBe(initialTimers) + }) + + it('does not schedule pointer-driven focus and resumes delay after a key press', () => { + render() + const anchor = screen.getByText('anchor') + const initialTimers = vi.getTimerCount() + fireEvent.pointerDown(document.body) + fireEvent.focus(anchor) + expect(vi.getTimerCount()).toBe(initialTimers) + act(() => { vi.advanceTimersByTime(500) }) + expect(screen.queryByRole('tooltip')).toBeNull() + fireEvent.blur(anchor) + fireEvent.keyDown(document.body, { key: 'Tab' }) + fireEvent.focus(anchor) + act(() => { vi.advanceTimersByTime(499) }) + expect(screen.queryByRole('tooltip')).toBeNull() + act(() => { vi.advanceTimersByTime(1) }) + expect(screen.getByRole('tooltip').textContent).toBe('Refresh') + }) + }) + it('shows the bubble to the right on hover and hides it on leave', () => { render( diff --git a/packages/client/ui-schedule/README.i18n.yaml b/packages/client/ui-schedule/README.i18n.yaml index ec785c3dda..8866d6e127 100644 --- a/packages/client/ui-schedule/README.i18n.yaml +++ b/packages/client/ui-schedule/README.i18n.yaml @@ -15,8 +15,8 @@ en: d152484eb41ac6b4 zh: 405b30feaf48c087 /deepseek-ai-dsh-client-ui-schedule/use-this-package: - en: bf8096e6e576ac07 - zh: 1936c0a75dc5cd4b + en: c129ae5d0a150a5d + zh: d81976f2840cb059 /deepseek-ai-dsh-client-ui-schedule/use-this-package/time-zone-data-source: en: 6bd6882dbbbefcb0 zh: 03aa15f1a65d13dc diff --git a/packages/client/ui-schedule/README.md b/packages/client/ui-schedule/README.md index 54b0b172b5..ed9431a6b5 100644 --- a/packages/client/ui-schedule/README.md +++ b/packages/client/ui-schedule/README.md @@ -25,7 +25,7 @@ Manage active and inactive tasks from the Automation tasks page and the right Si ## Use this package -The shipped Web composition carries the `ui-schedule` row disabled; enable the optional experimental bundle `@deepseek-ai/dsh-experimental-schedule-bundle` from the Plugins page (Official group), or list it in a profile's `dsh.profile.bundles`, to mount `ui-schedule` with the Host Schedule service and the clock context that supplies the model's current time. The Automation tasks sidebar entry opens a global page without selecting or activating a Session. The page header's New action starts a new Session, where the reminder is created by asking the model; the page itself has no creation form. Its task list supports text search and one All, Enabled, and Inactive status filter row; search matches the stored task name, the instruction, and the internal Session id, not a resolved Session title, and the empty state keeps the heading's New action beside the notice. Selecting an active or inactive task opens a second detail level with Rules and Delivery records tabs. Every list row shows the task's stored title with its status, frequency, and next run and never the original Session name; the detail's name control shows the stored title. Rules contains the complete reminder, status, next target for active tasks, original recurrence rule, and task id. Daily rule labels show the saved local time and exact stored IANA zone, preserving nonzero seconds and milliseconds rather than substituting the browser's zone. The Linked session entry in the detail tab strip names the linked Session by its catalog title, falling back to the Session id when no title is available. It opens an available, unarchived Session after metadata loads; pending Session or Workspace metadata, an archived Session, missing Session membership, or a Workspace query error keeps it disabled with an explanation. The click rechecks availability; browsing does not unarchive the Session. Only this action requests Session activation. Deletion is available from the More task actions menu in the detail's top strip, separate from navigation. +The shipped Web composition carries no `ui-schedule` row; enable the optional experimental bundle `@deepseek-ai/dsh-experimental-schedule-bundle` from the Plugins page (Official group), or list it in a profile's `dsh.profile.bundles`, to insert and mount `ui-schedule` with the Host Schedule service and the clock context that supplies the model's current time. The Automation tasks sidebar entry opens a global page without selecting or activating a Session. The page header's New action starts a new Session, where the reminder is created by asking the model; the page itself has no creation form. Its task list supports text search and one All, Enabled, and Inactive status filter row; search matches the stored task name, the instruction, and the internal Session id, not a resolved Session title, and the empty state keeps the heading's New action beside the notice. Selecting an active or inactive task opens a second detail level with Rules and Delivery records tabs. Every list row shows the task's stored title with its status, frequency, and next run and never the original Session name; the detail's name control shows the stored title. Rules contains the complete reminder, status, next target for active tasks, original recurrence rule, and task id. Daily rule labels show the saved local time and exact stored IANA zone, preserving nonzero seconds and milliseconds rather than substituting the browser's zone. The Linked session entry in the detail tab strip names the linked Session by its catalog title, falling back to the Session id when no title is available. It opens an available, unarchived Session after metadata loads; pending Session or Workspace metadata, an archived Session, missing Session membership, or a Workspace query error keeps it disabled with an explanation. The click rechecks availability; browsing does not unarchive the Session. Only this action requests Session activation. Deletion is available from the More task actions menu in the detail's top strip, separate from navigation. Selecting a task from the list opens Rules; refreshing that task preserves the selected tab. Rules edits an active task's name, instruction, and run time; the edits stay in a local draft until saved, and inactive tasks remain read-only. Unmodified Left/Right arrows cycle the tabs, Home/End select the first/last tab, and keyboard focus follows the selection. The tabs do not consume these keys with Alt, Ctrl, Meta, or Shift. The linked-Session control in the detail tab strip stays available on the Rules tab, and confirmed deletion is available from either view. diff --git a/packages/client/ui-schedule/README.zh.md b/packages/client/ui-schedule/README.zh.md index 4a4fab6b96..d03aefa658 100644 --- a/packages/client/ui-schedule/README.zh.md +++ b/packages/client/ui-schedule/README.zh.md @@ -25,7 +25,7 @@ kind: "package-reference" ## 使用本包 -随发行版交付的 Web 组合把 `ui-schedule` 行保持禁用;在插件管理页(Official 分组)启用可选实验性 bundle `@deepseek-ai/dsh-experimental-schedule-bundle`,或把它列进 profile 的 `dsh.profile.bundles`,即可将 `ui-schedule` 与 Host Schedule 服务、以及为模型提供当前时间的时钟上下文一起挂载。侧栏的自动化任务入口打开全局页面,不选择或激活会话。页面页头的“新建”操作会打开一个新会话,提醒由模型在该会话中创建;页面本身没有创建表单。列表支持文本搜索和一行“全部/已开启/未运行”状态筛选;搜索匹配任务已存储的名称、指令和内部会话 id,不匹配解析出的会话标题,空状态在提示旁保留页头的“新建”操作。选择活动或未运行任务后,进入含“规则”和“任务运行记录”页签的第二层详情。每条列表行显示任务已存储的标题及其状态、频率和下次运行,不显示原会话名称;详情中的名称输入框显示任务已存储的标题。“规则”显示完整提醒、状态、活动任务的下一目标时间、原始重复规则和任务 id。每天的规则标签显示已保存的本地时间与精确的存储 IANA 时区,保留非零秒与毫秒,而不替换为浏览器时区。详情页签条内的“关联会话”入口按会话标题显示,取不到标题时回退显示会话 id。元数据加载后,它可打开可用且未归档的会话;会话或 Workspace 元数据尚未就绪、会话已归档、会话不在列表中或 Workspace 查询失败时,入口保持禁用并显示原因。点击时会重新检查可用性;浏览不会取消会话归档。仅此操作请求激活会话。删除位于详情顶部条带的“更多任务操作”菜单。 +随发行版交付的 Web 组合不含 `ui-schedule` 行;在插件管理页的“官方”分组中启用可选实验性 bundle `@deepseek-ai/dsh-experimental-schedule-bundle`,或把它列进 profile 的 `dsh.profile.bundles`,即可插入 `ui-schedule` 并将它与 Host Schedule 服务、以及为模型提供当前时间的时钟上下文一起挂载。侧栏的自动化任务入口打开全局页面,不选择或激活会话。页面页头的“新建”操作会打开一个新会话,提醒由模型在该会话中创建;页面本身没有创建表单。列表支持文本搜索和一行“全部/已开启/未运行”状态筛选;搜索匹配任务已存储的名称、指令和内部会话 id,不匹配解析出的会话标题,空状态在提示旁保留页头的“新建”操作。选择活动或未运行任务后,进入含“规则”和“任务运行记录”页签的第二层详情。每条列表行显示任务已存储的标题及其状态、频率和下次运行,不显示原会话名称;详情中的名称输入框显示任务已存储的标题。“规则”显示完整提醒、状态、活动任务的下一目标时间、原始重复规则和任务 id。每天的规则标签显示已保存的本地时间与精确的存储 IANA 时区,保留非零秒与毫秒,而不替换为浏览器时区。详情页签条内的“关联会话”入口按会话标题显示,取不到标题时回退显示会话 id。元数据加载后,它可打开可用且未归档的会话;会话或 Workspace 元数据尚未就绪、会话已归档、会话不在列表中或 Workspace 查询失败时,入口保持禁用并显示原因。点击时会重新检查可用性;浏览不会取消会话归档。仅此操作请求激活会话。删除位于详情顶部条带的“更多任务操作”菜单。 从列表选择任务时默认打开“规则”;刷新同一任务会保留当前页签。活动任务的“规则”修改名称、指令和运行时间;这些修改保留在本地草稿中直到保存,未运行任务保持只读。不带修饰键的左右方向键循环切换页签,Home/End 选择第一个/最后一个页签,键盘焦点随选择移动。页签不会拦截这些键与 Alt、Ctrl、Meta 或 Shift 的组合。共用的会话入口位于详情页签条内,在“规则”页签可见;确认删除操作在两种页签下都可用。 diff --git a/packages/client/ui-schedule/locale/en.json b/packages/client/ui-schedule/locale/en.json new file mode 100644 index 0000000000..7564e0fff7 --- /dev/null +++ b/packages/client/ui-schedule/locale/en.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "Task interface", + "description": "The Automation tasks page in the sidebar, plus task cards and reminder marks in sessions." + } +} diff --git a/packages/client/ui-schedule/locale/zh.json b/packages/client/ui-schedule/locale/zh.json new file mode 100644 index 0000000000..b9b7495eaa --- /dev/null +++ b/packages/client/ui-schedule/locale/zh.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "任务界面", + "description": "侧栏的自动化任务页面,以及会话里的任务卡片和提醒标记。" + } +} diff --git a/packages/client/ui-schedule/package.json b/packages/client/ui-schedule/package.json index f4979f5921..665d1bca90 100644 --- a/packages/client/ui-schedule/package.json +++ b/packages/client/ui-schedule/package.json @@ -15,7 +15,8 @@ "default": "./lib/client.js" }, "./src/*": "./src/*", - "./package.json": "./package.json" + "./package.json": "./package.json", + "./locale/*.json": "./locale/*.json" }, "dsh": { "client": { @@ -81,6 +82,7 @@ "react-dom": "^18.2.0" }, "files": [ + "locale/*.json", "lib/index.js", "lib/client.js", "lib/types/**/*.d.ts" diff --git a/packages/client/ui-settings-account/README.i18n.yaml b/packages/client/ui-settings-account/README.i18n.yaml index 75f9483e09..cb0531a5d8 100644 --- a/packages/client/ui-settings-account/README.i18n.yaml +++ b/packages/client/ui-settings-account/README.i18n.yaml @@ -6,8 +6,8 @@ en: 1fcba8f273b661fb zh: 14e1236f84ae39d7 /deepseek-ai-dsh-client-ui-settings-account: - en: 76e18729c08888be - zh: 170a2032838071b4 + en: 9ff7ac2d70f70b59 + zh: ded522c4cfd6e040 /deepseek-ai-dsh-client-ui-settings-account/summary: en: 9ff357621753be69 zh: 3f7ede82acc9fa31 diff --git a/packages/client/ui-settings-account/README.md b/packages/client/ui-settings-account/README.md index e93210f5fe..a417443c7b 100644 --- a/packages/client/ui-settings-account/README.md +++ b/packages/client/ui-settings-account/README.md @@ -7,6 +7,8 @@ kind: "package-reference" English | [中文](README.zh.md) +Desktop product events use the optional [product analytics service](../product-analytics/README.md); ordinary Web interactions are excluded. Workspace login dialogs emit no authentication events; native welcome owns authentication analytics. + Server-expired account credentials clear the displayed account details and emit a localized sign-in reminder toast once; account snapshot replay does not repeat it. ## Summary diff --git a/packages/client/ui-settings-account/README.zh.md b/packages/client/ui-settings-account/README.zh.md index 44c442f7be..2d357ee54c 100644 --- a/packages/client/ui-settings-account/README.zh.md +++ b/packages/client/ui-settings-account/README.zh.md @@ -7,6 +7,8 @@ kind: "package-reference" [English](README.md) | 中文 +桌面端产品事件使用可选的[产品埋点服务](../product-analytics/README.zh.md),不包含普通 Web 交互。工作区内的登录弹窗不发送认证事件;认证埋点由原生欢迎页负责。 + 账号凭据被服务端判定失效后,清空显示的账号详情,并弹出一次本地化的重新登录提示 toast;账号快照重放不会重复提示。 ## 概述 diff --git a/packages/client/ui-settings-account/package.json b/packages/client/ui-settings-account/package.json index 479ec0f0a6..c895148b8a 100644 --- a/packages/client/ui-settings-account/package.json +++ b/packages/client/ui-settings-account/package.json @@ -69,7 +69,8 @@ "@deepseek-ai/dsh-client-ui-settings-models": "workspace:*", "@types/react-dom": "~18.3.0", "@deepseek-ai/dsh-host-webserver": "workspace:*", - "big.js": "^7.0.1" + "big.js": "^7.0.1", + "@deepseek-ai/dsh-client-product-analytics": "workspace:*" }, "files": [ "lib/index.js", diff --git a/packages/client/ui-settings-account/src/client/DesktopOnboarding.tsx b/packages/client/ui-settings-account/src/client/DesktopOnboarding.tsx index 5bfd37dfd2..99c1bc4d2a 100644 --- a/packages/client/ui-settings-account/src/client/DesktopOnboarding.tsx +++ b/packages/client/ui-settings-account/src/client/DesktopOnboarding.tsx @@ -1,4 +1,5 @@ /** Coordinates onboarding navigation, transitions and the shared native recharge page. */ +import type { ProductEventMap } from '@deepseek-ai/dsh-client-product-analytics/client' import { useEffect, useRef, useState } from 'react' import { Button } from '@deepseek-ai/dsh-client-ui-primitives' import { hasOnboardingCredit } from './onboarding-balance.ts' @@ -12,12 +13,20 @@ import { OnboardingConfirmation, type OnboardingConfirmationKind } from './Onboa import backIcon from './assets/onboarding-back.svg' import css from './DesktopOnboarding.module.css' +const onboardingPages = { + welcome: 'onboarding_welcome', + credit: 'onboarding_recharge', + purpose: 'onboarding_use_case', + process: 'onboarding_process', + done: undefined, +} as const + /** * @param props - durable choices, account state, localized copy and the shared recharge page channel. * @returns the active first-run page. */ export function DesktopOnboarding({ - state, account, openPlatformPage, update, complete, retry, t, locale, exiting = false, + state, account, openPlatformPage, update, complete, retry, t, locale, track, exiting = false, }: DesktopOnboardingProps) { const [dialog, setDialog] = useState(null) const heading = useRef(null) @@ -44,10 +53,37 @@ export function DesktopOnboarding({ return () => { animation.cancel() } }, [targetStep, step, state.visible]) useEffect(() => { heading.current?.focus() }, [step]) + const pageName = onboardingPages[step] + const shownPage = useRef(null) + useEffect(() => { + if (!state.visible || pageName === undefined || exiting) { + shownPage.current = null + return + } + if (state.status === 'loading') return + if (shownPage.current === pageName) return + shownPage.current = pageName + track?.('onboarding_page_view', { page_name: pageName }) + }, [state.visible, state.status, step, pageName, exiting, track]) + const shownPopup = useRef(null) + useEffect(() => { + if (dialog === null || !state.visible || state.status === 'loading' || step === 'done') { shownPopup.current = null; return } + if (shownPopup.current === dialog) return + shownPopup.current = dialog + track?.('onboarding_popup_view', { popup_name: dialog === 'skip' ? 'skip_setting' : 'skip_charge' }) + }, [dialog, track, state.visible, state.status, step]) + const popupClick = (button_name: ProductEventMap['onboarding_popup_click']['button_name']) => { + track?.('onboarding_popup_click', { popup_name: dialog === 'skip' ? 'skip_setting' : 'skip_charge', button_name }) + } if (state.visible && state.status === 'loading') return
{t('onboardingLoading')}
- if (!state.visible || step === 'done') return null + if (!state.visible || pageName === undefined) return null + const click = (button_name: ProductEventMap['onboarding_page_click']['button_name']) => { + const selected_content = step === 'purpose' && progress.purpose !== null ? ({ office: 'office', development: 'code', both: 'code_office' } as const)[progress.purpose] + : step === 'process' && progress.process !== null ? ({ compact: 'focus_result', standard: 'key_detail', detailed: 'full_process' } as const)[progress.process] : undefined + track?.('onboarding_page_click', { page_name: pageName, button_name, ...selected_content === undefined ? {} : { selected_content } }) + } const go = (next: 'welcome' | 'credit' | 'purpose' | 'process') => { void update({ step: next }) } const canRecharge = openPlatformPage !== undefined const recharge = () => { @@ -68,22 +104,22 @@ export function DesktopOnboarding({ return <>
- {step === 'welcome' && { go('credit') }} />} + {step === 'welcome' && { click('next'); go('credit') }} />} {step === 'credit' && { go('purpose') }} onRecharge={recharge} onLater={later} />} + funded={state.creditFunded} canRecharge={canRecharge} onContinue={() => { click('continue'); go('purpose') }} onRecharge={() => { click('charge'); recharge() }} onLater={() => { click('later'); later() }} />} {step === 'purpose' && { void update({ purpose }) }} onContinue={purposeNext} />} + onSelect={(purpose) => { void update({ purpose }) }} onContinue={() => { click('next'); purposeNext() }} />} {step === 'process' && { void update({ process }) }} onComplete={() => { void complete('completed') }} />} + onSelect={(process) => { void update({ process }) }} onComplete={() => { click('next'); void complete('completed') }} />} {state.error !== null &&
{t('onboardingSaveFailed')}
} {step !== 'welcome' &&
- - + +
}
{dialog !== null && { setDialog(null) }} onContinue={() => { setDialog(null); go('purpose') }} onRecharge={recharge} - onSkip={() => { void complete('skipped').then(() => { setDialog(null) }) }} />} + onClose={() => { popupClick('close'); setDialog(null) }} onContinueSetup={() => { popupClick('setting'); setDialog(null) }} onContinue={() => { popupClick('know'); setDialog(null); go('purpose') }} onRecharge={() => { popupClick('charge'); recharge() }} + onSkip={() => { popupClick(dialog === 'skip' ? 'enter' : 'know'); void complete('skipped').then(() => { setDialog(null) }) }} />} } diff --git a/packages/client/ui-settings-account/src/client/OnboardingConfirmation.tsx b/packages/client/ui-settings-account/src/client/OnboardingConfirmation.tsx index 6793520a6e..6899334678 100644 --- a/packages/client/ui-settings-account/src/client/OnboardingConfirmation.tsx +++ b/packages/client/ui-settings-account/src/client/OnboardingConfirmation.tsx @@ -7,12 +7,13 @@ import css from './DesktopOnboarding.module.css' export type OnboardingConfirmationKind = 'credit' | 'credit-skip' | 'skip' /** @param props - confirmation action, localized copy and navigation callbacks. @returns a focus-contained dialog. */ -export function OnboardingConfirmation({ kind, t, busy, canRecharge, onClose, onContinue, onRecharge, onSkip }: +export function OnboardingConfirmation({ kind, t, busy, canRecharge, onClose, onContinueSetup, onContinue, onRecharge, onSkip }: Pick & { kind: OnboardingConfirmationKind busy: boolean canRecharge: boolean onClose: () => void + onContinueSetup: () => void onContinue: () => void onRecharge: () => void onSkip: () => void @@ -26,7 +27,7 @@ export function OnboardingConfirmation({ kind, t, busy, canRecharge, onClose, on

{t(credit ? 'onboardingNoCreditDescription' : 'onboardingSkipDescription')}

- +
diff --git a/packages/client/ui-settings-account/src/client/index.ts b/packages/client/ui-settings-account/src/client/index.ts index 9e4eef835c..e165c063b7 100644 --- a/packages/client/ui-settings-account/src/client/index.ts +++ b/packages/client/ui-settings-account/src/client/index.ts @@ -1,4 +1,5 @@ /** Desktop account settings registration and reconnecting Remote subscription. */ +import type {} from '@deepseek-ai/dsh-client-product-analytics/client' import type { TranscriptViewMode } from '@deepseek-ai/dsh-client-ui-chat/client' import type { Context } from '@deepseek-ai/cordis' import type {} from '@deepseek-ai/dsh-client-locale/client' @@ -24,7 +25,7 @@ import { en, zh, type AccountKey } from './locales.ts' import { AccountQuotaNotice, type AccountQuotaNoticeInjected } from './AccountQuotaNotice.tsx' import { DESKTOP_ONBOARDING_NAMESPACE, type OnboardingSettings } from '../onboarding-settings.ts' import { DesktopOnboardingController } from './onboarding-state.ts' -import { DesktopOnboardingEntry } from './DesktopOnboardingEntry.tsx' +import { DesktopOnboardingEntry, type DesktopOnboardingInjected } from './DesktopOnboardingEntry.tsx' import { readOnboardingApiKeyPresence } from './onboarding-credentials.ts' import { refreshAfterReturn } from './account-refresh.ts' export type { AccountSectionInjected, AccountSectionProps } from './AccountSection.tsx' @@ -211,7 +212,7 @@ export function apply(ctx: Context): void { if ('dshDesktop' in globalThis) { const controller = new DesktopOnboardingController( ctx.configForms.get(DESKTOP_ONBOARDING_NAMESPACE), - ctx.configForms.get<{ transcriptView: TranscriptViewMode; performanceUsage: 'compact' | 'detailed' }>('ui-chat'), + ctx.configForms.get<{ transcriptView?: TranscriptViewMode | null; performanceUsage: 'compact' | 'detailed' }>('ui-chat'), enabled => ctx.configForms.developerTools.setEnabled(enabled), operations.hooks.account, readOnboardingApiKeyPresence, @@ -229,13 +230,14 @@ export function apply(ctx: Context): void { }, 'account: desktop credential readiness') ctx.slots.inject('shell.overlay', () => ctx.slots.register({ name: 'shell.overlay', id: 'desktop-onboarding', locale: 'settings.account', - inject: () => ({ + inject: (): DesktopOnboardingInjected => ({ hooks: { account: operations.hooks.account, onboarding: controller.state }, // Onboarding's recharge return re-reads profile and balance only. ...nativePlatform === undefined ? {} : { openPlatformPage: platformPageOpener(refresh) }, update: (change: OnboardingChange) => controller.update(change), complete: (reason: 'completed' | 'skipped') => controller.complete(reason), retry: () => controller.retry(), + track: (name, attributes) => ctx.get('productAnalytics')?.track(name, attributes), }), }, DesktopOnboardingEntry)) } diff --git a/packages/client/ui-settings-account/src/client/onboarding-contract.ts b/packages/client/ui-settings-account/src/client/onboarding-contract.ts index 5c1a82081c..4d3815d90e 100644 --- a/packages/client/ui-settings-account/src/client/onboarding-contract.ts +++ b/packages/client/ui-settings-account/src/client/onboarding-contract.ts @@ -1,4 +1,5 @@ /** Pure view inputs for the desktop onboarding flow. */ +import type { TrackProductEvent } from '@deepseek-ai/dsh-client-product-analytics/client' import type { OnboardingProgress } from '../onboarding-settings.ts' import type { AccountSnapshot } from './AccountSection.tsx' import type { PlatformPages } from './platform-pages.ts' @@ -21,6 +22,8 @@ export type OnboardingChange = Partial, - private readonly chat: ConfigForm<{ transcriptView: TranscriptViewMode; performanceUsage: 'compact' | 'detailed' }>, + private readonly chat: ConfigForm<{ transcriptView?: TranscriptViewMode | null; performanceUsage: 'compact' | 'detailed' }>, private readonly setDeveloperTools: (enabled: boolean) => Promise, private readonly account: HostObservable, private readonly readApiKeyPresence: () => Promise, diff --git a/packages/client/ui-settings-account/tests/apply.client.spec.ts b/packages/client/ui-settings-account/tests/apply.client.spec.ts index 20a430fec6..393d5232ec 100644 --- a/packages/client/ui-settings-account/tests/apply.client.spec.ts +++ b/packages/client/ui-settings-account/tests/apply.client.spec.ts @@ -271,8 +271,13 @@ it('re-reads profile and balance but no bonus for the onboarding recharge return vi.stubGlobal('dshPlatform', { open: vi.fn(), setBounds: vi.fn(), close: vi.fn() }) await c.reload(SELF) const actions = operations(c) + const track = vi.fn() + c.ctx.provide('productAnalytics', { track } as never) + expect(actions).not.toHaveProperty('track') const onboarding = injectedOf(c.ctx.slots.entries('shell.overlay') .find(entry => entry.options.id === 'desktop-onboarding')!) as DesktopOnboardingInjected + onboarding.track?.('onboarding_page_view', { page_name: 'onboarding_recharge' }) + expect(track).toHaveBeenCalledWith('onboarding_page_view', { page_name: 'onboarding_recharge' }) const host = injectedOf(platformHostEntry(c)!) as AccountPlatformHostInjected c.mock.remote.account.getProfile.mockResolvedValue(ok(profile)) c.mock.remote.account.getBalance.mockResolvedValue(ok(null)) diff --git a/packages/client/ui-settings-account/tests/desktop-onboarding.client.spec.tsx b/packages/client/ui-settings-account/tests/desktop-onboarding.client.spec.tsx index 80f0d38930..8152510505 100644 --- a/packages/client/ui-settings-account/tests/desktop-onboarding.client.spec.tsx +++ b/packages/client/ui-settings-account/tests/desktop-onboarding.client.spec.tsx @@ -10,6 +10,7 @@ afterEach(() => { cleanup(); vi.unstubAllGlobals() }) function mount(step: DesktopOnboardingState['progress']['step'] = 'welcome', balance: 'zero' | 'positive' | 'bonus' | 'failed' | 'loading' = 'positive', status: DesktopOnboardingState['status'] = 'ready', copy: typeof zh = zh, creditFunded = false) { vi.stubGlobal('ResizeObserver', class { observe() {} disconnect() {} }) + const track = vi.fn() const complete = vi.fn(async () => true) const retry = vi.fn(async () => true) // The shared host owns the native view; this flow only requests a page from it. @@ -19,13 +20,15 @@ function mount(step: DesktopOnboardingState['progress']['step'] = 'welcome', bal returnFromPage = onClose return release }) + let changeStatus: (value: DesktopOnboardingState['status']) => void = () => {} const update = vi.fn(async (_change: Parameters[0]) => true) function App() { const [state, setState] = useState({ status, visible: true, error: status === 'error' ? 'settings' : null, creditFunded, progress: { version: 1, step, purpose: null, process: null, completion: null, usage: 'compact', developerTools: false }, }) - return copy[key]} complete={complete} retry={retry} + changeStatus = (value) => { setState(current => ({ ...current, status: value })) } + return copy[key]} complete={complete} retry={retry} update={async (change) => { setState(current => ({ ...current, status: 'saving' })) const saved = await update(change) @@ -38,7 +41,7 @@ function mount(step: DesktopOnboardingState['progress']['step'] = 'welcome', bal } render() return { - complete, openPlatformPage, release, update, retry, + complete, openPlatformPage, release, update, retry, track, changeStatus: (value: DesktopOnboardingState['status']) => { changeStatus(value) }, /** The viewer returning through the shared host's Back action. */ back: () => { returnFromPage?.() }, } @@ -330,3 +333,39 @@ it('returns from purpose to credit without dropping the selected purpose', async await act(async () => { fireEvent.click(screen.getByRole('button', { name: zh.onboardingBack })) }) expect(h.update).toHaveBeenCalledWith({ step: 'credit' }) }) + + +it('reports visible pages once, funded Continue, and popup close', async () => { + const b = mount('credit', 'positive', 'ready', zh, true) + expect(b.track).toHaveBeenCalledExactlyOnceWith('onboarding_page_view', { page_name: 'onboarding_recharge' }) + await act(async () => { fireEvent.click(screen.getByRole('button', { name: zh.onboardingSkip })) }) + expect(b.track).toHaveBeenCalledWith('onboarding_popup_view', { popup_name: 'skip_charge' }) + b.track.mockClear() + await act(async () => { fireEvent.click(screen.getByRole('button', { name: zh.close })) }) + expect(b.track).toHaveBeenCalledExactlyOnceWith('onboarding_popup_click', { popup_name: 'skip_charge', button_name: 'close' }) + await act(async () => { fireEvent.click(screen.getByRole('button', { name: zh.onboardingContinue })) }) + expect(b.track).toHaveBeenCalledWith('onboarding_page_click', { page_name: 'onboarding_recharge', button_name: 'continue' }) + expect(b.track).toHaveBeenCalledWith('onboarding_page_view', { page_name: 'onboarding_use_case' }) +}) + +it('does not report the loading surface as a page', () => { + expect(mount('welcome', 'loading', 'loading').track).not.toHaveBeenCalled() +}) + + +it('does not repeat page or popup exposure during save and refresh', () => { + const b = mount('credit') + fireEvent.click(screen.getByRole('button', { name: zh.onboardingSkip })) + act(() => { b.changeStatus('saving') }) + act(() => { b.changeStatus('loading') }) + act(() => { b.changeStatus('ready') }) + expect(b.track.mock.calls.filter(([name]) => name === 'onboarding_page_view')).toHaveLength(1) +}) + +it('reports closing the skip-settings popup', () => { + const b = mount('purpose') + fireEvent.click(screen.getByRole('button', { name: zh.onboardingSkip })) + b.track.mockClear() + fireEvent.click(screen.getByRole('button', { name: zh.close })) + expect(b.track).toHaveBeenCalledExactlyOnceWith('onboarding_popup_click', { popup_name: 'skip_setting', button_name: 'close' }) +}) diff --git a/packages/client/ui-settings-account/tests/sign-in-dialog.client.spec.tsx b/packages/client/ui-settings-account/tests/sign-in-dialog.client.spec.tsx index 0cf569c39a..c03b3613d8 100644 --- a/packages/client/ui-settings-account/tests/sign-in-dialog.client.spec.tsx +++ b/packages/client/ui-settings-account/tests/sign-in-dialog.client.spec.tsx @@ -178,3 +178,16 @@ it('keeps an outstanding start open until the request settles', async () => { await act(async () => { pending.resolve(undefined); await pending.promise }) expect(screen.getByRole('button', { name: en.signIn }).hasAttribute('disabled')).toBe(false) }) + +// Workspace login remains outside native welcome analytics even if a caller forwards a sender. +it('does not report workspace login views or login choices', async () => { + const track = vi.fn() + const props = { ...dialogProps(null), track } + const view = render() + view.rerender() + await act(async () => { fireEvent.click(screen.getByRole('button', { name: en.signIn })) }) + expect(props.start).toHaveBeenCalledOnce() + await act(async () => { fireEvent.click(screen.getByRole('button', { name: en.addApiKey })) }) + expect(props.useApiKey).toHaveBeenCalledOnce() + expect(track).not.toHaveBeenCalled() +}) diff --git a/packages/client/ui-settings-account/tsconfig.json b/packages/client/ui-settings-account/tsconfig.json index a2c29cb37a..ac170f07db 100644 --- a/packages/client/ui-settings-account/tsconfig.json +++ b/packages/client/ui-settings-account/tsconfig.json @@ -55,6 +55,9 @@ }, { "path": "../../settings/settings" + }, + { + "path": "../product-analytics/tsconfig.client.json" } ] } diff --git a/packages/client/ui-settings-models/README.i18n.yaml b/packages/client/ui-settings-models/README.i18n.yaml index 3e3c096cab..dbd67087e4 100644 --- a/packages/client/ui-settings-models/README.i18n.yaml +++ b/packages/client/ui-settings-models/README.i18n.yaml @@ -6,8 +6,8 @@ en: d1bc4d9ef67bfb19 zh: d095e4dc76f1c5e0 /deepseek-ai-dsh-client-ui-settings-models: - en: 65e1e8a17a23e576 - zh: 01de4e98a002d376 + en: 0db6fa836e1ff511 + zh: 0e2968763e1fb7a5 /deepseek-ai-dsh-client-ui-settings-models/summary: en: 53301a5d29936f69 zh: 9ac408a33571dbdd diff --git a/packages/client/ui-settings-models/README.md b/packages/client/ui-settings-models/README.md index ead2111e2b..c974239fed 100644 --- a/packages/client/ui-settings-models/README.md +++ b/packages/client/ui-settings-models/README.md @@ -7,6 +7,8 @@ kind: "package-reference" English | [中文](README.zh.md) +Desktop product events use the optional [product analytics service](../product-analytics/README.md); ordinary Web interactions are excluded. + ## Summary `dsh-client-ui-settings-models` is the Models settings page of the dsh web client: users configure API keys (stored write-only under the profile's credential reference), edit each provider's model list, and hand-declare custom pi-ai routes, with provider rows and one editor card at a time. The page joins the provider directory, the settings document, and the credential descriptions into one shared snapshot, so a row's state stays consistent across all three. It also walks first-run users through two ordered dialogs — a versioned internal-testing notice and the conditional official-DeepSeek credential step. diff --git a/packages/client/ui-settings-models/README.zh.md b/packages/client/ui-settings-models/README.zh.md index 8f8e0720fb..3ac2bb0480 100644 --- a/packages/client/ui-settings-models/README.zh.md +++ b/packages/client/ui-settings-models/README.zh.md @@ -7,6 +7,8 @@ kind: "package-reference" [English](README.md) | 中文 +桌面端产品事件使用可选的[产品埋点服务](../product-analytics/README.zh.md),不包含普通 Web 交互。 + ## 概述 `dsh-client-ui-settings-models` 是 dsh Web 客户端的 Models 设置页面:用户可以配置 API 密钥(以只写方式存入 profile 的凭据引用之下)、编辑每个提供商的模型列表,并手工声明自定义 pi-ai 路由;页面以提供商行展示,一次只展开一张编辑卡片。该页面把提供商目录、设置文档与凭据描述合并为一个共享快照,因此行的状态在三个方面始终一致。它还会带首次运行的用户走两个有序弹窗——版本化内测声明,以及按条件显示的官方 DeepSeek 凭据步骤。 diff --git a/packages/client/ui-settings-models/package.json b/packages/client/ui-settings-models/package.json index 3d39cc682d..169aac59b9 100644 --- a/packages/client/ui-settings-models/package.json +++ b/packages/client/ui-settings-models/package.json @@ -57,7 +57,8 @@ "react": "^18.2.0", "@deepseek-ai/dsh-client-ui-renderer": "workspace:*", "@deepseek-ai/dsh-util-values": "workspace:*", - "@deepseek-ai/dsh-remote-mock": "workspace:*" + "@deepseek-ai/dsh-remote-mock": "workspace:*", + "@deepseek-ai/dsh-client-product-analytics": "workspace:*" }, "dependencies": { "@deepseek-ai/schemastery": "workspace:~" diff --git a/packages/client/ui-settings-models/src/client/DeepSeekOnboardingDialog.tsx b/packages/client/ui-settings-models/src/client/DeepSeekOnboardingDialog.tsx index fdc32c4045..63c090b3d5 100644 --- a/packages/client/ui-settings-models/src/client/DeepSeekOnboardingDialog.tsx +++ b/packages/client/ui-settings-models/src/client/DeepSeekOnboardingDialog.tsx @@ -5,7 +5,7 @@ * official DeepSeek route. The step reuses that page's credential editor in * the onboarding plugin's shared modal, so the key is entered once. */ - +import type { TrackProductEvent } from '@deepseek-ai/dsh-client-product-analytics/client' import { useEffect, useState } from 'react' import type { ReactNode } from 'react' import type { SnapshotStore } from '@deepseek-ai/dsh-client-store' @@ -22,6 +22,8 @@ import styles from './DeepSeekOnboardingDialog.module.css' /** Registration-side dependencies of {@link DeepSeekOnboardingDialog}. */ export interface DeepSeekOnboardingInjected { + /** Desktop-only event collection. */ + track?: TrackProductEvent /** Whether first-run setup should show automatically. Explicit requests remain available. */ automatic: boolean hooks: { @@ -120,6 +122,7 @@ export function DeepSeekOnboardingDialog(props: DeepSeekOnboardingDialogProps): readOnly={false} hideTitle credentialOnly + onSubmitCredential={() => { props.track?.('api_key_save_click', {}) }} credentialRequired autoFocusCredential cancelLabelKey="onboardingLater" diff --git a/packages/client/ui-settings-models/src/client/ProviderEditor.tsx b/packages/client/ui-settings-models/src/client/ProviderEditor.tsx index 41dac376d1..cd6656fdd5 100644 --- a/packages/client/ui-settings-models/src/client/ProviderEditor.tsx +++ b/packages/client/ui-settings-models/src/client/ProviderEditor.tsx @@ -87,6 +87,8 @@ export interface ProviderEditorProps { submitBusyLabelKey?: keyof typeof en /** Close the editor; `changed` reports whether an Apply committed. */ onClose: (changed: boolean) => void + /** Called on a credential form submission before the save starts. */ + onSubmitCredential?: () => void /** * Called once per change with whether the apply or the model list's * endpoint interrogation is in flight, so the owner can hold its surface @@ -530,7 +532,7 @@ export function ProviderEditor(props: ProviderEditorProps): ReactNode { submitBusyLabelKey={props.submitBusyLabelKey ?? 'applying'} {...props.cancelLabelKey === undefined ? {} : { cancelLabelKey: props.cancelLabelKey }} onCancel={() => { props.onClose(false) }} - onSubmit={() => { void apply() }} + onSubmit={() => { props.onSubmitCredential?.(); void apply() }} /> ) diff --git a/packages/client/ui-settings-models/src/client/index.ts b/packages/client/ui-settings-models/src/client/index.ts index a96665b11f..c80634a2b6 100644 --- a/packages/client/ui-settings-models/src/client/index.ts +++ b/packages/client/ui-settings-models/src/client/index.ts @@ -6,6 +6,7 @@ * Export discipline: * packages/client/AGENTS.md. */ +import type {} from '@deepseek-ai/dsh-client-product-analytics/client' import type { Context as ClientContext } from '@deepseek-ai/cordis' // Type-only: pulls the shell's SlotMap merge (the 'settings.section' entry). import type {} from '@deepseek-ai/dsh-client-ui-settings/client' @@ -98,6 +99,7 @@ export function apply(ctx: ClientContext): void { }) const deepSeekOnboardingInjected = (): DeepSeekOnboardingInjected => ({ automatic: credentialOnboarding, + track: (name, attributes) => ctx.get('productAnalytics')?.track(name, attributes), controller, hooks: { models: controller.store }, operations, diff --git a/packages/client/ui-settings-models/tests/apply.client.spec.ts b/packages/client/ui-settings-models/tests/apply.client.spec.ts index 62196478f7..2372690ba2 100644 --- a/packages/client/ui-settings-models/tests/apply.client.spec.ts +++ b/packages/client/ui-settings-models/tests/apply.client.spec.ts @@ -141,6 +141,12 @@ describe('ui-settings-models apply', () => { }) const deepSeek = onboarding.find(entry => entry.options.id === 'deepseek-official')! expect(deepSeek.component).toBe(DeepSeekOnboardingDialog) + const analytics = (deepSeek.inject!() as object) as import('../src/client/DeepSeekOnboardingDialog.tsx').DeepSeekOnboardingInjected + analytics.track?.('api_key_save_click', {}) + const track = vi.fn() + before.ctx.provide('productAnalytics', { track } as never) + analytics.track?.('api_key_save_click', {}) + expect(track).toHaveBeenCalledWith('api_key_save_click', {}) expect(deepSeek.options).toMatchObject({ id: 'deepseek-official', order: 0 }) const deepSeekInjected = ( deepSeek.inject as unknown as () => import('../src/client/DeepSeekOnboardingDialog.tsx').DeepSeekOnboardingInjected diff --git a/packages/client/ui-settings-models/tsconfig.json b/packages/client/ui-settings-models/tsconfig.json index 13bf556904..121f44cdde 100644 --- a/packages/client/ui-settings-models/tsconfig.json +++ b/packages/client/ui-settings-models/tsconfig.json @@ -8,8 +8,12 @@ "src" ], "references": [ - { "path": "../../host/webserver" }, - { "path": "../../../vendor/schemastery" }, + { + "path": "../../host/webserver" + }, + { + "path": "../../../vendor/schemastery" + }, { "path": "../../../vendor/cordis" }, @@ -33,6 +37,9 @@ }, { "path": "../../api/remotes/tsconfig.client.json" + }, + { + "path": "../product-analytics/tsconfig.client.json" } ] } diff --git a/packages/client/ui-settings-plugin-inventory/README.i18n.yaml b/packages/client/ui-settings-plugin-inventory/README.i18n.yaml index 6b7ff6a2bf..b30b1c29f8 100644 --- a/packages/client/ui-settings-plugin-inventory/README.i18n.yaml +++ b/packages/client/ui-settings-plugin-inventory/README.i18n.yaml @@ -9,8 +9,8 @@ en: 463eecf002ec0f54 zh: b8dc6003ddccd14a /deepseek-ai-dsh-client-ui-settings-plugin-inventory/summary: - en: 56f5349b6e502525 - zh: 691a0fe90b739e9d + en: f52353cf99816eb8 + zh: e172c1751289882f /deepseek-ai-dsh-client-ui-settings-plugin-inventory/table-of-contents: en: d152484eb41ac6b4 zh: 09388d293f9be9cb @@ -18,14 +18,14 @@ en: 67637808e7940157 zh: d4d0716ea4d33e4a /deepseek-ai-dsh-client-ui-settings-plugin-inventory/use-this-package/reading-a-card: - en: e615aaa891b5055f - zh: 38769e763c8e54ca + en: 141d6a73e99fee51 + zh: 2aa28b174867c967 /deepseek-ai-dsh-client-ui-settings-plugin-inventory/use-this-package/the-preset-switcher: en: 84e5938329199a22 zh: 6f5cfaac56433e8b /deepseek-ai-dsh-client-ui-settings-plugin-inventory/use-this-package/retrying-a-failed-read: - en: 2859449aaae5ab59 - zh: 06380059ae429026 + en: 673c7574e14078be + zh: b093e897f0341973 /deepseek-ai-dsh-client-ui-settings-plugin-inventory/understand-the-implementation: en: cab883d26642c932 zh: 54e7321bd781b400 @@ -33,8 +33,8 @@ en: e4792880c5916ce0 zh: 4c8171a446dd153f /deepseek-ai-dsh-client-ui-settings-plugin-inventory/understand-the-implementation/rendering: - en: b9aa850e5f101b68 - zh: 8a29025f19f6ce37 + en: 779146e3d3732803 + zh: 0dfafa328dfeda75 /deepseek-ai-dsh-client-ui-settings-plugin-inventory/further-exploration: en: ae3819f0c5da95bd zh: 40972ab96d5c95e2 diff --git a/packages/client/ui-settings-plugin-inventory/README.md b/packages/client/ui-settings-plugin-inventory/README.md index 7353f14d11..f6aa2312b8 100644 --- a/packages/client/ui-settings-plugin-inventory/README.md +++ b/packages/client/ui-settings-plugin-inventory/README.md @@ -9,7 +9,7 @@ English | [中文](README.zh.md) ## Summary -The **Plugin list** tab lets Web users inspect plugins without changing their configuration. It lists agent presets, then the global inventory; both start collapsed and open on demand or during a search. Cards show localized titles and descriptions when available, identify instances by stable entry id, and expose enablement, source details, runtime status, disabled conditions, and discovery failures; preset-provided global entries name their presets. Search covers both groups and points to matches in other presets. The tab handles loading, empty, no-match, failure, and retry states without exposing transport details, and still shows the global inventory without a preset roster. +The **Plugin list** tab lets Web users inspect plugins without changing their configuration. It lists agent presets, open by default, then the collapsed global inventory; a search opens both. Cards show localized titles and descriptions when available, identify instances by stable entry id, and expose enablement, source details, runtime status, disabled conditions, and discovery failures; preset-provided global entries name their presets. Search covers both groups and points to matches in other presets. The tab handles loading, empty, no-match, failure, and retry states without exposing transport details, and still shows the global inventory without a preset roster. ## Table of Contents @@ -31,9 +31,9 @@ A failed installation with pending pnpm build permissions offers **Allow these s ### Reading a card -Each collapsed card uses the plugin's localized title and description when available. Settings shortens literal package-name or module-name fallbacks by removing the npm scope and the `cordis:`, `cordis-plugin-`, or `dsh-`/`dsh-host-`/`dsh-client-` prefix; locale titles remain unchanged. The Host supplies locale and package-field fallbacks in `meta`; the Client selects the current language. Full module specifiers remain available in details and search. Missing descriptions are omitted, and metadata diagnostics appear with the card. This compact naming applies only to Settings, not the sidebar's Plugins page. +Each collapsed card uses the plugin's localized title and description when available; a description shows at most two lines until its card is expanded. Settings shortens literal package-name or module-name fallbacks by removing the npm scope and the `cordis:`, `cordis-plugin-`, or `dsh-`/`dsh-host-`/`dsh-client-` prefix; locale titles remain unchanged. The Host supplies locale and package-field fallbacks in `meta`; the Client selects the current language. Full module specifiers remain available in details and search. Missing descriptions are omitted, and metadata diagnostics appear with the card. This compact naming applies only to Settings, not the sidebar's Plugins page. -The stable entry id appears underneath the title, alongside a small enablement tag; a colored root-fiber status dot marks only the phases the tag cannot state: `pending` maps to idle, while `loading` and `unloading` map to ongoing. An `active` or `failed` fiber shows its tag without a dot. A composition-generated subtitle omits its leading `include:` marker, while hover, search, the accessible name, and expanded details retain the complete id. Long entry ids truncate in the row and remain available on hover. Expanding one card reveals the declared entry id, the full module specifier, and the state facts: a preset row names the preset it comes from, its runtime status when the composition is live, and its disable condition when it carries one; a preset-provided global row explains that agent presets provide it per session, names the presets that enable it, and offers a jump into the preset group. Preset names resolve through the shared `presetDisplayText` fold (`dsh-agent-preset-registry/display`) over [`ui-agent-preset`](../ui-agent-preset/README.md)'s dictionaries: shipped presets follow the active locale while user-authored ones keep their own metadata, so an English surface never echoes the preset declarations' Chinese names. Search filters both groups by localized title and description, module name, and entry id. +A small enablement tag marks only the states that differ from plainly enabled: disabled, conditional, provided by presets, and failed; a plainly enabled row carries no tag. A colored root-fiber status dot marks the live phases no tag states: `pending` maps to idle, while `loading` and `unloading` map to ongoing; an `active` or `failed` fiber shows no dot. The stable entry id appears in a code chip at the card's bottom-left, in line with the title, only when it differs from the title: a composition-generated id drops its leading `include:` marker there, and an id that then repeats the title is omitted. Whenever the chip shows, its hover text and the card's accessible name carry the complete id; search and expanded details always retain it. Long entry ids truncate in the chip and remain available on hover. Expanding one card reveals its full description, the declared entry id, the full module specifier, and the state facts: a preset row names the preset it comes from, its runtime status when the composition is live, and its disable condition when it carries one; a preset-provided global row explains that agent presets provide it per session, names the presets that enable it, and offers a jump into the preset group. Preset names resolve through the shared `presetDisplayText` fold (`dsh-agent-preset-registry/display`) over [`ui-agent-preset`](../ui-agent-preset/README.md)'s dictionaries: shipped presets follow the active locale while user-authored ones keep their own metadata, so an English surface never echoes the preset declarations' Chinese names. Search filters both groups by localized title and description, module name, and entry id. ### The preset switcher @@ -41,7 +41,7 @@ The switcher is the same selector-pill-plus-menu control the General settings ro ### Retrying a failed read -A failed read renders the shared error marker inside the tab; loading and current-page synchronization use the shared ongoing loader. Retrying re-runs the lazy `list()` call without exposing transport details. +A failed read renders the shared error marker inside the tab. Loading shows skeleton cards in the card grid, and current-page synchronization uses the shared ongoing loader. Retrying re-runs the lazy `list()` call without exposing transport details. The Plugin list also shows synchronization failures on the current page. Its retry reapplies the latest client graph without changing Host enablement or refreshing the page. @@ -61,7 +61,7 @@ The browser plugin registers one localized `settings.plugins.tab` contribution w ### Rendering -Row keys are scope-qualified (`global:`, `preset::`), so one module appearing in both scopes keeps distinct disclosure state; a declared entry id appears in expanded details and supplies the collapsed subtitle after removal of a leading composition `include:` marker, while a row without one stays unlabeled. The preset-provided marking is derived client-side: a global entry carries it when it is disabled there while at least one preset row for the same module specifier is actually enabled, so a module every preset gates off (or declares only conditionally) stays plainly disabled rather than over-claiming provision. +Row keys are scope-qualified (`global:`, `preset::`), so one module appearing in both scopes keeps distinct disclosure state; a declared entry id appears in expanded details and, after removal of a leading composition `include:` marker, supplies the collapsed id chip when the result differs from the title, while a row without one stays unlabeled. The preset-provided marking is derived client-side: a global entry carries it when it is disabled there while at least one preset row for the same module specifier is actually enabled, so a module every preset gates off (or declares only conditionally) stays plainly disabled rather than over-claiming provision. diff --git a/packages/client/ui-settings-plugin-inventory/README.zh.md b/packages/client/ui-settings-plugin-inventory/README.zh.md index 9078f8074a..cb0c2ce723 100644 --- a/packages/client/ui-settings-plugin-inventory/README.zh.md +++ b/packages/client/ui-settings-plugin-inventory/README.zh.md @@ -9,7 +9,7 @@ kind: "package-reference" ## 概述 -**插件列表**标签页让 Web 用户查看插件,而不改变其配置。它先列 agent(智能体)预设分组,再列全局清单;两个分组默认都收起,按需展开,搜索期间保持展开。卡片显示可用的本地化标题与描述,以稳定的条目 id 标识实例,并展示启停状态、出处、运行状态、禁用条件与发现失败;由预设提供的全局条目会列出对应预设。搜索覆盖两个分组,并指出其他预设中的匹配。标签页处理加载、空结果、无匹配、失败与重试状态,且不暴露传输细节;没有预设 roster 时仍会展示全局清单。 +**插件列表**标签页让 Web 用户查看插件,而不改变其配置。它先列 agent(智能体)预设分组,再列全局清单;预设分组默认展开,全局分组默认收起,搜索期间两组都展开。卡片显示可用的本地化标题与描述,以稳定的条目 id 标识实例,并展示启停状态、出处、运行状态、禁用条件与发现失败;由预设提供的全局条目会列出对应预设。搜索覆盖两个分组,并指出其他预设中的匹配。标签页处理加载、空结果、无匹配、失败与重试状态,且不暴露传输细节;没有预设 roster 时仍会展示全局清单。 ## 目录 @@ -31,9 +31,9 @@ kind: "package-reference" ### 阅读卡片 -每张收起的卡片使用插件可用的本地化标题与描述。设置页会缩短字面的包名或模块名回退值,移除 npm scope 以及 `cordis:`、`cordis-plugin-` 或 `dsh-`/`dsh-host-`/`dsh-client-` 前缀;locale 标题保持原样。Host 在 `meta` 中提供 locale 与包字段的回退结果,Client 选择当前语言。详情与搜索仍保留完整模块标识。缺失的描述不显示,元信息诊断随卡片展示。这种短名显示仅用于设置页,不影响侧栏的插件页。 +每张收起的卡片使用插件可用的本地化标题与描述;描述最多显示两行,展开卡片后显示全文。设置页会缩短字面的包名或模块名回退值,移除 npm scope 以及 `cordis:`、`cordis-plugin-` 或 `dsh-`/`dsh-host-`/`dsh-client-` 前缀;locale 标题保持原样。Host 在 `meta` 中提供 locale 与包字段的回退结果,Client 选择当前语言。详情与搜索仍保留完整模块标识。缺失的描述不显示,元信息诊断随卡片展示。这种短名显示仅用于设置页,不影响侧栏的插件页。 -稳定的条目 id 显示在标题下方,卡片带有表示启停状态的小标签;彩色根 fiber 状态圆点只标记标签无法表达的阶段:`pending` 映射为 idle,`loading` 与 `unloading` 映射为 ongoing。`active` 或 `failed` fiber 只显示标签,不显示圆点。组合生成的次标题省略开头的 `include:` 标记;悬停、搜索、无障碍名称与展开详情仍保留完整 id。长条目 id 会在行内截断,悬停时仍可查看完整值。展开卡片后会显示声明的条目 id、完整模块标识与状态事实:预设行说明它来自哪个预设、组合存活时的运行状态,以及它携带的禁用条件;被预设提供的全局行说明它由 Agent 预设按会话提供、列出启用它的预设,并提供跳转到预设组的入口。预设名经共享的 `presetDisplayText` 纯函数(`dsh-agent-preset-registry/display`)叠在 [`ui-agent-preset`](../ui-agent-preset/README.zh.md) 的字典上解析:内置预设走当前语言,用户自建预设保留自己的元数据,因此英文界面不会回显预设声明里的中文名。搜索按本地化标题与描述、模块名称及条目 id 过滤两组。 +表示启停状态的小标签只标记与普通启用不同的状态:已停用、条件启用、由预设提供与启动失败;普通启用的行不带标签。彩色根 fiber 状态圆点标记没有标签表达的存活阶段:`pending` 映射为 idle,`loading` 与 `unloading` 映射为 ongoing;`active` 或 `failed` fiber 不显示圆点。稳定的条目 id 仅在与标题不同时显示,位于卡片左下角的代码标签中,与标题左对齐:组合生成的 id 在此省略开头的 `include:` 标记,省略后与标题相同的 id 不再重复显示。显示代码标签时,其悬停提示与卡片的无障碍名称带完整 id;搜索与展开详情始终保留完整 id。长条目 id 会在代码标签内截断,悬停时仍可查看完整值。展开卡片后会显示完整描述、声明的条目 id、完整模块标识与状态事实:预设行说明它来自哪个预设、组合存活时的运行状态,以及它携带的禁用条件;被预设提供的全局行说明它由 Agent 预设按会话提供、列出启用它的预设,并提供跳转到预设组的入口。预设名经共享的 `presetDisplayText` 纯函数(`dsh-agent-preset-registry/display`)叠在 [`ui-agent-preset`](../ui-agent-preset/README.zh.md) 的字典上解析:内置预设走当前语言,用户自建预设保留自己的元数据,因此英文界面不会回显预设声明里的中文名。搜索按本地化标题与描述、模块名称及条目 id 过滤两组。 ### 预设切换器 @@ -41,7 +41,7 @@ kind: "package-reference" ### 重试失败的读取 -读取失败会在标签页内渲染共享 error 标记;加载与当前页面同步使用共享 ongoing loading。重试会重新执行懒 `list()` 调用,且不会暴露传输细节。 +读取失败会在标签页内渲染共享 error 标记。加载时在卡片网格中显示骨架卡片,当前页面同步使用共享 ongoing loading。重试会重新执行懒 `list()` 调用,且不会暴露传输细节。 插件列表还显示当前页面的同步失败。重试会重新应用最新客户端图,不改变 Host 启用状态,也不刷新页面。 @@ -61,7 +61,7 @@ kind: "package-reference" ### 渲染 -行 key 按作用域限定(`global:`、`preset::`),因此同一模块出现在两个作用域时保持各自的展开状态;声明的条目 id 出现在展开详情中,并在去掉开头的组合 `include:` 标记后作为收起次标题,没有 id 的行不显示次级标签。预设提供标记在客户端推导:一个全局条目在全局被停用、且至少一个预设行对同一模块标识实际启用时才携带它,因此被所有预设关掉(或仅条件声明)的模块保持单纯的已停用,而不是夸大提供关系。 +行 key 按作用域限定(`global:`、`preset::`),因此同一模块出现在两个作用域时保持各自的展开状态;声明的条目 id 出现在展开详情中;去掉开头的组合 `include:` 标记后若与标题不同,则作为收起卡片的 id 代码标签,没有 id 的行不显示次级标签。预设提供标记在客户端推导:一个全局条目在全局被停用、且至少一个预设行对同一模块标识实际启用时才携带它,因此被所有预设关掉(或仅条件声明)的模块保持单纯的已停用,而不是夸大提供关系。 diff --git a/packages/client/ui-settings-plugin-inventory/src/client/PluginInventorySettingsTab.module.css b/packages/client/ui-settings-plugin-inventory/src/client/PluginInventorySettingsTab.module.css index 87b29d442f..be3aa42773 100644 --- a/packages/client/ui-settings-plugin-inventory/src/client/PluginInventorySettingsTab.module.css +++ b/packages/client/ui-settings-plugin-inventory/src/client/PluginInventorySettingsTab.module.css @@ -1,4 +1,6 @@ +/* The tab's own width, not the window's, decides the card columns below. */ .section { + container: plugin-inventory / inline-size; display: flex; flex-direction: column; gap: 14px; @@ -7,7 +9,6 @@ color: var(--dsw-alias-label-primary); } -.catalogHeading h3, .status, .failure p { margin: 0; @@ -85,37 +86,21 @@ box-shadow: 0 0 0 2px color-mix(in srgb, var(--dsw-focus-ring-color, var(--dsw-alias-state-business-primary)) 18%, transparent); } -.catalogHeading { - display: flex; - align-items: baseline; - gap: 7px; - padding: 0 2px; -} - -.catalogHeading h3 { - font-size: 13px; - line-height: 20px; - font-weight: 600; -} - -.catalogHeading span { - font-size: 12px; - line-height: 18px; - color: var(--dsw-alias-label-tertiary); - font-variant-numeric: tabular-nums; -} - .cards { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); - align-items: start; gap: 10px; margin: 0; padding: 0; list-style: none; } +/* Cards stretch to their grid row's height. The button fills a stretched card: + the title row and description stay at the top, in line with the other card's, + and the spare height sits above the entry id chip or at the bottom. */ .card { + display: flex; + flex-direction: column; min-width: 0; overflow: hidden; border: 0.5px solid var(--dsw-alias-settings-card-stroke); @@ -127,11 +112,22 @@ border-color: var(--dsw-alias-border-l3); } +/* The other card in an expanded card's row keeps its own height instead of + stretching alongside the details. The selectors assume the two columns + `.cards` declares, where each row starts at an odd position, and must change + with that count; in one column the matched card sits alone in its row. */ +.card:nth-child(odd)[data-open='true'] + .card, +.card:nth-child(odd):has(+ .card[data-open='true']) { + align-self: start; +} + .cardContent { box-sizing: border-box; display: flex; + flex: 1 1 auto; flex-direction: column; align-items: stretch; + gap: 2px; width: 100%; min-height: 52px; border: 0; @@ -173,11 +169,35 @@ min-width: 0; font-size: 14px; line-height: 20px; - font-weight: 600; + font-weight: 500; } -/* The Loader entry id keeps one size in the card subtitle and the row it expands into; - the collapsed subtitle stays on the quieter label tier, while the expanded row carries +/* Two lines at most while collapsed; the expanded card shows the full text. */ +.cardDescription { + display: -webkit-box; + overflow: hidden; + color: var(--dsw-alias-label-tertiary); + font-size: 12px; + line-height: 18px; + text-wrap: pretty; + -webkit-box-orient: vertical; + -webkit-line-clamp: 2; +} + +.card[data-open='true'] .cardDescription { + display: block; +} + +/* The entry id is metadata, not prose: a code chip in the card's bottom-left + corner whose left edge lines up with the title. */ +.cardMeta { + display: flex; + margin-top: auto; + padding-top: 6px; +} + +/* The Loader entry id keeps one size in the card chip and the row it expands into; + the collapsed chip stays on the quieter label tier, while the expanded row carries the card's primary value. */ .cardIdentity, .entryValue { @@ -188,15 +208,19 @@ .cardIdentity { display: block; - width: 100%; - color: var(--dsw-alias-label-tertiary); + box-sizing: border-box; + max-width: 100%; + border-radius: var(--dsw-radius-xs); + padding: 1px 6px; + background: var(--dsw-alias-bg-module-platform); + color: var(--dsw-alias-label-secondary); } .cardTrailing { display: inline-flex; flex: none; align-items: center; - gap: 7px; + gap: 8px; color: var(--dsw-alias-label-tertiary); } @@ -213,11 +237,13 @@ gap: 10px; } +/* Every group header takes the preset switcher's 36px height, so each subtitle + sits the same distance below its title. */ .groupTitleRow { display: flex; align-items: center; gap: 8px; - min-height: 32px; + min-height: 36px; } .group + .group { @@ -266,7 +292,7 @@ .groupSub { display: flex; flex-wrap: wrap; - gap: 4px 8px; + row-gap: 4px; margin: -6px 0 0 20px; color: var(--dsw-alias-label-tertiary); font-size: 12px; @@ -274,6 +300,13 @@ font-variant-numeric: tabular-nums; } +/* A middle dot with equal space on both sides separates the subtitle's parts. */ +.groupSub > * + *::before { + content: '·'; + padding: 0 6px; + color: var(--dsw-alias-label-tertiary); +} + .failedCount { color: var(--dsw-alias-state-error-primary); } @@ -330,7 +363,7 @@ padding: 8px 10px; background: color-mix(in srgb, var(--dsw-alias-state-error-primary) 8%, transparent); color: var(--dsw-alias-state-error-primary); - font-size: 12.5px; + font-size: 12px; line-height: 18px; overflow-wrap: anywhere; white-space: pre-line; @@ -343,7 +376,7 @@ gap: 4px 8px; margin: 0; color: var(--dsw-alias-label-tertiary); - font-size: 12.5px; + font-size: 12px; line-height: 18px; } @@ -353,10 +386,34 @@ background: transparent; color: var(--dsw-alias-state-business-primary); font: inherit; - font-size: 12.5px; + font-size: 12px; cursor: pointer; } +/* Loading placeholder in the cards grid, sized like a card with one description + line; its bars pulse through the shared skeleton token as the input-trigger + menu's pending rows do. */ +.skeletonCard { + display: flex; + flex-direction: column; + gap: 8px; + border: 0.5px solid var(--dsw-alias-settings-card-stroke); + border-radius: var(--dsw-radius-xl); + padding: 15px 14px; + background: var(--dsw-alias-settings-card-fill); +} + +.skeletonBar { + width: 40%; + height: 14px; + border-radius: var(--dsw-radius-xs); + background: var(--dsw-alias-bg-skeleton); +} + +.skeletonBar + .skeletonBar { + width: 80%; + height: 12px; +} .enabledIn { display: flex; @@ -430,9 +487,19 @@ .chevron { transition: transform 140ms var(--ds-ease-in-out); } + + .skeletonBar { + animation: plugin-inventory-skeleton 2s cubic-bezier(0.36, 0, 0.64, 1) infinite; + } } -@media (max-width: 680px) { +@keyframes plugin-inventory-skeleton { + 0% { opacity: 1; } + 40% { opacity: 0.6; } + 80%, 100% { opacity: 1; } +} + +@container plugin-inventory (max-width: 520px) { .cards { grid-template-columns: minmax(0, 1fr); } diff --git a/packages/client/ui-settings-plugin-inventory/src/client/PluginInventorySettingsTab.tsx b/packages/client/ui-settings-plugin-inventory/src/client/PluginInventorySettingsTab.tsx index c5064e5441..57810b993a 100644 --- a/packages/client/ui-settings-plugin-inventory/src/client/PluginInventorySettingsTab.tsx +++ b/packages/client/ui-settings-plugin-inventory/src/client/PluginInventorySettingsTab.tsx @@ -58,6 +58,9 @@ const PHASE_KEYS = { unloading: 'unloading', } satisfies Record, PluginInventoryLocaleKey> +/** Placeholder cards the loading skeleton lays out in the cards grid. */ +const SKELETON_CARDS = [0, 1, 2, 3] as const + /** Localized accessible label for one root Fiber phase. */ function phaseLabel(phase: PluginFiberPhase, t: Translate): string { return phase === null ? t('unobserved') : t(PHASE_KEYS[phase]) @@ -77,6 +80,16 @@ function entrySubtitle(entryId: string): string { return entryId.replace(/^include:/, '') } +/** Whether a card shows its entry id: the id exists and, without its `include:` marker, differs from the title. */ +function idAddsToTitle(entryId: string | null, title: string): entryId is string { + return entryId !== null && entrySubtitle(entryId) !== title +} + +/** Accessible card name: the title, the complete entry id when the card shows one, then the enablement state. */ +function cardLabel(title: string, entryId: string | null, state: string): string { + return idAddsToTitle(entryId, title) ? `${title}, ${entryId}, ${state}` : `${title}, ${state}` +} + /** Preserve translated titles and shorten literal package or module name fallbacks in Settings. */ function pluginText(row: PluginInventoryEntry | AgentPresetRow, resolveText: PluginInventorySettingsTabInjected['resolveText']) { const title = row.meta?.title @@ -151,8 +164,12 @@ function PluginCard({