diff --git a/.agents/notes/implemented/architecture/2026-07-19-cooperative-tool-cancellation.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-19-cooperative-tool-cancellation.i18n.yaml index 4573f0a33f..e4b4714af1 100644 --- a/.agents/notes/implemented/architecture/2026-07-19-cooperative-tool-cancellation.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-19-cooperative-tool-cancellation.i18n.yaml @@ -24,8 +24,8 @@ en: 26e4b71366bebd1b zh: 10598e12b28c586e /agent-note-cooperative-tool-cancellation-at-the-registry-boundary/verification: - en: fbd101d4576afa92 - zh: 35f5d3c2f6cad6a8 + en: 983da6d0c09f5c10 + zh: ca7c86117a1a4d5f /agent-note-cooperative-tool-cancellation-at-the-registry-boundary/alternatives-considered: en: c4d86feb135d2ac4 zh: 67c56c0bc2213949 diff --git a/.agents/notes/implemented/architecture/2026-07-19-cooperative-tool-cancellation.md b/.agents/notes/implemented/architecture/2026-07-19-cooperative-tool-cancellation.md index 967c7285df..8ad0af1d21 100644 --- a/.agents/notes/implemented/architecture/2026-07-19-cooperative-tool-cancellation.md +++ b/.agents/notes/implemented/architecture/2026-07-19-cooperative-tool-cancellation.md @@ -46,7 +46,7 @@ This decision requires cancellation at the tool invocation boundary only. Making ## Verification -[`execution-signal-types.spec.ts`](../../../../packages/core/tools/tests/execution-signal-types.spec.ts) proves the required exact signal types, readonly observer and tool views, mutable-but-required around-dispatch view, and `defineTool()` inference. [`tools.spec.ts`](../../../../packages/core/tools/tests/tools.spec.ts) covers pre-aborted materialization, phase skipping, policy and wrapper races, body invocation classification, caller-signal fusion, error precedence, context retention, and quiescent drainage. [`tool-calls.spec.ts`](../../../../packages/core/agent-loop/tests/tool-calls.spec.ts) and [`contract-regressions.spec.ts`](../../../../packages/core/agent-loop/tests/contract-regressions.spec.ts) cover balanced durable results for undispatched siblings. [`ptc.spec.ts`](../../../../packages/core/tools/tests/ptc.spec.ts) and first-party integration suites cover explicit forwarding, while [`timeout-policy.spec.ts`](../../../../packages/guard/timeout-policy/tests/timeout-policy.spec.ts) preserves timeout ownership. +[`execution-signal-types.spec.ts`](../../../../packages/core/tools/tests/execution-signal-types.spec.ts) proves the required exact signal types, readonly observer and tool views, mutable-but-required around-dispatch view, and `defineTool()` inference. [`tools.spec.ts`](../../../../packages/core/tools/tests/tools.spec.ts) covers pre-aborted materialization, phase skipping, policy and wrapper races, body invocation classification, caller-signal fusion, error precedence, context retention, and quiescent drainage. [`tool-calls.spec.ts`](../../../../packages/core/agent-loop/tests/tool-calls.spec.ts) and [`contract-regressions.spec.ts`](../../../../packages/core/agent-loop/tests/contract-regressions.spec.ts) cover balanced durable results for undispatched siblings. [`ptc.spec.ts`](../../../../packages/core/tool-executor-ptc/tests/ptc.spec.ts) and first-party integration suites cover explicit forwarding, while [`timeout-policy.spec.ts`](../../../../packages/guard/timeout-policy/tests/timeout-policy.spec.ts) preserves timeout ownership. No registry test can prove that arbitrary third-party same-process code observes the signal or stops in bounded time. Capability tests continue to prove cancellation and quiescence at the boundary that owns each side effect. diff --git a/.agents/notes/implemented/architecture/2026-07-19-cooperative-tool-cancellation.zh.md b/.agents/notes/implemented/architecture/2026-07-19-cooperative-tool-cancellation.zh.md index 87318c3316..613db6dacd 100644 --- a/.agents/notes/implemented/architecture/2026-07-19-cooperative-tool-cancellation.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-19-cooperative-tool-cancellation.zh.md @@ -46,7 +46,7 @@ Status: implemented ## 验证 -[`execution-signal-types.spec.ts`](../../../../packages/core/tools/tests/execution-signal-types.spec.ts) 证明必填的精确信号类型、观察者与工具的只读视图、环绕调度可替换但不可删除的视图,以及 `defineTool()` 推断。[`tools.spec.ts`](../../../../packages/core/tools/tests/tools.spec.ts) 覆盖进入时已中止的物化与阶段跳过、策略和包装层竞态、工具主体调用分类、调用方信号融合、错误优先级、上下文保留和完全停稳。[`tool-calls.spec.ts`](../../../../packages/core/agent-loop/tests/tool-calls.spec.ts) 与 [`contract-regressions.spec.ts`](../../../../packages/core/agent-loop/tests/contract-regressions.spec.ts) 覆盖为未调度的同批调用补齐持久化结果。[`ptc.spec.ts`](../../../../packages/core/tools/tests/ptc.spec.ts) 和第一方集成测试覆盖显式转发,[`timeout-policy.spec.ts`](../../../../packages/guard/timeout-policy/tests/timeout-policy.spec.ts) 保持超时归属。 +[`execution-signal-types.spec.ts`](../../../../packages/core/tools/tests/execution-signal-types.spec.ts) 证明必填的精确信号类型、观察者与工具的只读视图、环绕调度可替换但不可删除的视图,以及 `defineTool()` 推断。[`tools.spec.ts`](../../../../packages/core/tools/tests/tools.spec.ts) 覆盖进入时已中止的物化与阶段跳过、策略和包装层竞态、工具主体调用分类、调用方信号融合、错误优先级、上下文保留和完全停稳。[`tool-calls.spec.ts`](../../../../packages/core/agent-loop/tests/tool-calls.spec.ts) 与 [`contract-regressions.spec.ts`](../../../../packages/core/agent-loop/tests/contract-regressions.spec.ts) 覆盖为未调度的同批调用补齐持久化结果。[`ptc.spec.ts`](../../../../packages/core/tool-executor-ptc/tests/ptc.spec.ts) 和第一方集成测试覆盖显式转发,[`timeout-policy.spec.ts`](../../../../packages/guard/timeout-policy/tests/timeout-policy.spec.ts) 保持超时归属。 任何注册表测试都无法证明任意第三方同进程代码会观察信号或在有界时间内停止。各能力的测试仍需在拥有相应副作用的边界证明取消与完全停稳。 diff --git a/.agents/notes/implemented/architecture/2026-09-14-current-profile-plugin-management.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-14-current-profile-plugin-management.i18n.yaml index e9aab02a20..79f9416898 100644 --- a/.agents/notes/implemented/architecture/2026-09-14-current-profile-plugin-management.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-09-14-current-profile-plugin-management.i18n.yaml @@ -9,8 +9,8 @@ en: 6bb727a659ba1503 zh: d2ebf5c8164a807e /agent-note-current-profile-plugin-management-shares-cli-transactions/decision: - en: 863b4369c39f3933 - zh: 7dd44a91ccaaef8f + en: d2e13562b5aef903 + zh: 07face30189c07fc /agent-note-current-profile-plugin-management-shares-cli-transactions/alternatives-considered: en: 30c609de89084351 zh: 27f2747ad9d9b752 diff --git a/.agents/notes/implemented/architecture/2026-09-14-current-profile-plugin-management.md b/.agents/notes/implemented/architecture/2026-09-14-current-profile-plugin-management.md index 8d4d4c40f0..c9d522d4e7 100644 --- a/.agents/notes/implemented/architecture/2026-09-14-current-profile-plugin-management.md +++ b/.agents/notes/implemented/architecture/2026-09-14-current-profile-plugin-management.md @@ -16,7 +16,7 @@ Configuration watches use Chokidar write stabilization by default. Its ordinary Profile files remain the persisted state: entry toggles edit only `disabled` in the last override matching the entry id and any module-name assertion, appending when none matches, and bundle toggles edit the ordered string list. Dependency updates do not reactivate retained disabled bundles. A service removal first applies the composition without the bundle and waits for old fibers to finish before deleting the dependency. Saved configuration, pnpm completion and runtime activation have separate outcomes; a failed removal preserves the actual partial state and a diagnostic path, while a failed or cancelled installation restores the profile files it snapshotted. -This extends the [profile bundle composition decision](2026-08-05-profile-plugin-bundles.md). Profiles without HMR keep their process composition, and Desktop package management remains shell-owned. Web controls and explicitly enabled agent tools call the same service. Management operations return results to callers without adding messages to live Agents. The agent tool is enabled in Creator mode and disabled by default in the base bundle and other shipped presets. The browser-only worker preview has no host package installer; its module-proxy table refuses `execa` calls explicitly while retaining the management module for inventory discovery. +This extends the [profile bundle composition decision](2026-08-05-profile-plugin-bundles.md). Profiles without HMR keep their process composition, and Desktop package management remains shell-owned. Web controls and explicitly enabled agent tools call the same service. Management operations return results to callers without adding messages to live Agents. The agent tool is enabled in Standard and Creator modes and disabled by default in the base bundle and other shipped presets. The browser-only worker preview has no host package installer; its module-proxy table refuses `execa` calls explicitly while retaining the management module for inventory discovery. CLI calls inherit the terminal and authentication environment; service calls retain the subprocess credential scrub and bounded diagnostics. Management records carry error codes and parameters for locale-owned Web presentation. Reconciliation compares entry identity, fiber identity, configuration and diagnostics before and after updating: unchanged inactive entries remain warnings, while newly affected failures reject the operation. Explicit enablement targets must activate. diff --git a/.agents/notes/implemented/architecture/2026-09-14-current-profile-plugin-management.zh.md b/.agents/notes/implemented/architecture/2026-09-14-current-profile-plugin-management.zh.md index a8dce606d0..de2b995047 100644 --- a/.agents/notes/implemented/architecture/2026-09-14-current-profile-plugin-management.zh.md +++ b/.agents/notes/implemented/architecture/2026-09-14-current-profile-plugin-management.zh.md @@ -16,7 +16,7 @@ Web 和 Agent 控件需要修改运行中的 profile,同时避免另建包安 profile 文件保持为持久状态:条目开关只修改最后一条符合条目 id 及模块名称断言的覆盖项中的 `disabled`,没有匹配项时追加,组合包开关修改有序字符串列表。更新依赖不会重新激活保留的已停用组合包。service 删除组合包时,先应用去掉该组合包的配置,等待旧 fiber 完成卸载后再删除依赖。已保存配置、pnpm 完成状态与运行时激活分别报告;失败的删除保留实际的部分状态与诊断路径,失败或被取消的安装则恢复它快照的 profile 文件。 -这扩展了[profile 组合包决策](2026-08-05-profile-plugin-bundles.zh.md)。startup profile 保留进程组合,Desktop 包管理仍由 shell 持有。Web 控件与显式启用的 Agent 工具调用同一 service。管理操作向调用方返回结果,不向存活 Agent 添加消息。创造模式启用该 Agent 工具;base 组合包和其他内置预设默认禁用。纯浏览器 worker 预览没有宿主包安装器;其模块代理表明确拒绝 `execa` 调用,同时保留管理模块用于清单发现。 +这扩展了[profile 组合包决策](2026-08-05-profile-plugin-bundles.zh.md)。startup profile 保留进程组合,Desktop 包管理仍由 shell 持有。Web 控件与显式启用的 Agent 工具调用同一 service。管理操作向调用方返回结果,不向存活 Agent 添加消息。标准模式和创造模式启用该 Agent 工具;base 组合包和其他内置预设默认禁用。纯浏览器 worker 预览没有宿主包安装器;其模块代理表明确拒绝 `execa` 调用,同时保留管理模块用于清单发现。 CLI 调用继承终端和认证环境;service 调用保留子进程凭据清理与有界诊断。管理结果提供错误码和参数,由 Web 词典呈现文案。重载前后比较 entry、fiber、配置与诊断:未变化的已有故障保留为警告,本次影响到的新故障使操作失败。显式启用的目标必须成功激活。 diff --git a/.agents/notes/implemented/architecture/2026-09-16-creator-persistent-plugin-management.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-16-creator-persistent-plugin-management.i18n.yaml index e2da7be945..8d8498f8bb 100644 --- a/.agents/notes/implemented/architecture/2026-09-16-creator-persistent-plugin-management.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-09-16-creator-persistent-plugin-management.i18n.yaml @@ -9,8 +9,8 @@ en: 44615a7a0939054e zh: b8efa721321ba0d7 /agent-note-creator-mode-installs-persistent-plugin-bundles/decision: - en: c0d74db1b2b16576 - zh: b8713a6aadf007f9 + en: 38ecabc170c418c7 + zh: d380f53fa508328e /agent-note-creator-mode-installs-persistent-plugin-bundles/alternatives-considered: en: af527d8aabdfd59c zh: 34388da992d2ab56 diff --git a/.agents/notes/implemented/architecture/2026-09-16-creator-persistent-plugin-management.md b/.agents/notes/implemented/architecture/2026-09-16-creator-persistent-plugin-management.md index 5723eba63e..8fa2b574f6 100644 --- a/.agents/notes/implemented/architecture/2026-09-16-creator-persistent-plugin-management.md +++ b/.agents/notes/implemented/architecture/2026-09-16-creator-persistent-plugin-management.md @@ -12,6 +12,8 @@ Agents need to install capabilities and use them during the same conversation. G Creator mode enables the existing `plugin_manager` tool. Agents author packages and Loader YAML patches as workspace files, then install them through `install_bundle`. An MCP connection is a configuration-only bundle inserting the installed `dsh-mcp-client`; a UI bundle includes a Host entry and a Client artifact. Profile locking, package installation, enablement and HMR remain owned by the existing manager. The entire management tool requires `danger-full-access` or approval for one call: profile changes can load code with host permissions and affect other sessions. Each execution, including inventory reads, uses the shared sandbox escalation helper and approval service before accessing the manager. Under lower sandbox modes, `ask` requests approval and `never` denies; a full-access session needs no additional approval. A grant does not change session permissions, but its profile changes persist. This keeps shell-equivalent Host access explicit without requiring a permanent session-wide elevation. Human-operated Web and CLI controls retain their existing behavior. +The default Standard mode enables the same tool under the same profile condition and approval gate. Without it, Standard sessions did not install plugins directly and spent extra turns and tokens working around the missing tool; the change also moves Standard and Creator toward one capability set. A full-access Standard session can therefore install Host code without a separate prompt, as its shell already can. PTC and Minimal keep the tool disabled. + The model sees two read-only Cordis inspection tools. Generated-code define/run/stop/undefine and dynamic self-inspection tool APIs are absent. Existing runtime and Client consumers retain their services; historical session cards remain readable. This supersedes only the model-facing mutation workflow in the [self-referential toolset reference](../../../../packages/extensions/cordis-host-runner/README.md); its runtime ownership and sandbox rationale remain independently relevant. The [profile transaction decision](2026-09-14-current-profile-plugin-management.md) continues to govern locking, package installation, and partial failures. Visual creation requests default to an installed Client plugin rendered in the current Web page unless the user names another destination. The development skill supplies a minimal package and effect-owned Client registration. Discovery ends when the required APIs are known; a working first version is installed before optional visual refinement. Verification uses the connected page where available. Browser authentication or operating-system setup is not a prerequisite for plugin installation, and a mock preview cannot establish an in-app result. diff --git a/.agents/notes/implemented/architecture/2026-09-16-creator-persistent-plugin-management.zh.md b/.agents/notes/implemented/architecture/2026-09-16-creator-persistent-plugin-management.zh.md index 33c1fd867f..b5c0ad0cb2 100644 --- a/.agents/notes/implemented/architecture/2026-09-16-creator-persistent-plugin-management.zh.md +++ b/.agents/notes/implemented/architecture/2026-09-16-creator-persistent-plugin-management.zh.md @@ -12,6 +12,8 @@ agent 需要安装能力并在同一段对话中使用。生成代码工具在 创造模式启用现有的 `plugin_manager` 工具。agent 将包和 Loader YAML patch 写入工作区文件,再通过 `install_bundle` 安装。MCP 连接是插入已安装 `dsh-mcp-client` 的纯配置组合包;UI 组合包包含 Host 入口和 Client 产物。profile 锁、包安装、启停和 HMR 继续由现有管理器负责。 整个管理工具要求 `danger-full-access` 或单次调用的批准:profile 变更能以宿主权限加载代码,并影响其他会话。每次执行(包括查询列表)都会先使用共享沙箱提权函数和审批服务,再访问管理器。在较低沙箱模式下,`ask` 请求审批,`never` 拒绝;完整权限会话无需额外审批。批准不改变会话权限,但本次操作的 profile 变更会持久化。这让与 shell 等价的宿主访问需要明确授权,同时不必永久提升整个会话的权限。由用户直接操作的 Web 和 CLI 控件保持原有行为。 +默认的标准模式以相同的 profile 条件和审批门槛启用同一工具。缺少该工具时,标准模式会话不会直接安装插件,而要额外花费轮次和 token 绕过;此改动也让标准模式和创造模式朝同一能力集收敛。因此,完整权限的标准模式会话无需单独审批即可安装 Host 代码,与其 shell 已有的能力一致。PTC 和 Minimal 仍禁用该工具。 + 模型可见两个只读 Cordis 检查工具,不再提供生成代码的 define/run/stop/undefine 和动态自省工具 API。现有运行时和 Client 消费者保留其服务;历史会话卡片仍然可读。这仅取代[自引用工具集参考](../../../../packages/extensions/cordis-host-runner/README.zh.md)中的模型侧变更流程;其运行时所有权和沙箱依据仍有独立价值。[profile 事务决策](2026-09-14-current-profile-plugin-management.zh.md)继续规定锁、包安装和部分失败行为。 除非用户指定其他目标,视觉创建请求默认通过已安装的 Client 插件显示在当前 Web 页面。开发 skill 提供最小包和由 effect 管理的 Client 注册示例。已知所需 API 后结束探查,在可选视觉优化之前先安装能工作的初版。有条件时使用已连接页面验证。浏览器认证或操作系统设置不是安装插件的前提,mock 预览不能证明应用内结果。 diff --git a/.agents/notes/implemented/architecture/2026-09-21-experimental-capabilities-as-optional-bundles.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-21-experimental-capabilities-as-optional-bundles.i18n.yaml index 5d8a536a53..4a705f767e 100644 --- a/.agents/notes/implemented/architecture/2026-09-21-experimental-capabilities-as-optional-bundles.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-09-21-experimental-capabilities-as-optional-bundles.i18n.yaml @@ -9,8 +9,8 @@ en: 2ba6c43b20a3d347 zh: 5647179ddaa38ad9 /agent-note-ship-experimental-capabilities-as-optional-bundles/decision: - en: d6328ba2b218f2ac - zh: 3731561313b1ad5e + en: 191ca8bffb101db2 + zh: 1365bef5cd830a5c /agent-note-ship-experimental-capabilities-as-optional-bundles/alternatives-considered: en: d6f40bf02df37ab7 zh: e5b85ce9d41e8754 diff --git a/.agents/notes/implemented/architecture/2026-09-21-experimental-capabilities-as-optional-bundles.md b/.agents/notes/implemented/architecture/2026-09-21-experimental-capabilities-as-optional-bundles.md index a2d39b0ffb..2c5e6a73b5 100644 --- a/.agents/notes/implemented/architecture/2026-09-21-experimental-capabilities-as-optional-bundles.md +++ b/.agents/notes/implemented/architecture/2026-09-21-experimental-capabilities-as-optional-bundles.md @@ -10,7 +10,7 @@ The Web plugin page offered two optional bundles, Agent Teams and voice input. A ## Decision -`OPTIONAL_BUNDLES` owns the installation's default-off selections, including maintained optional capabilities and experimental capabilities. Every bundle declares `icon` and exports `./locale/*.json` with `meta.title` and `meta.description`; the Official group displays that metadata. A bundle's presentation does not determine its [experimental status](../../../../packages/experimental/README.md#status). The isolation check validates admission and still rejects experimental dependencies of nonexperimental bundles. +`OPTIONAL_BUNDLES` owns the installation's default-off selections, including maintained optional capabilities and experimental capabilities. Every bundle declares `icon` and exports `./locale/*.json` with `meta.title` and `meta.description`; the [plugin manager page](../../../../packages/client/ui-plugin-manager/README.md#use-this-package) displays that metadata for eligible bundles and retains explicit activation for default-hidden ones. A bundle's presentation does not determine its [experimental status](../../../../packages/experimental/README.md#status). The isolation check validates admission and still rejects experimental dependencies of nonexperimental bundles. An optional bundle's dependencies are downloaded with every `dsh` installation. [Reasoning translation](2026-10-05-anonymous-reasoning-translation.md) uses three experimental packages without adding third-party runtime dependencies. Admission therefore considers installation cost and whether a switch supplies a usable composition without an additional configuration form. External browser and Cua Driver providers remain explicit compositions because they add substantial runtimes or require an external executable. Stagehand requires model credentials; Python PTC replaces a core provider and conflicts with TypeScript workflow consumers. Demonstrative hooks, webhooks, and mods stay outside the list. [Session Inspector](../feature/2026-09-24-session-inspector.md) owns the Inspector's distinct activation behavior. diff --git a/.agents/notes/implemented/architecture/2026-09-21-experimental-capabilities-as-optional-bundles.zh.md b/.agents/notes/implemented/architecture/2026-09-21-experimental-capabilities-as-optional-bundles.zh.md index 15bc65b56f..31afdf2f3f 100644 --- a/.agents/notes/implemented/architecture/2026-09-21-experimental-capabilities-as-optional-bundles.zh.md +++ b/.agents/notes/implemented/architecture/2026-09-21-experimental-capabilities-as-optional-bundles.zh.md @@ -10,7 +10,7 @@ Web 插件页只提供两个可选 bundle:Agent Teams 与语音输入。Auto r ## 决策 -`OPTIONAL_BUNDLES` 管理安装随附且默认关闭的选项,包括维护中的可选能力与实验性能力。每个 bundle 声明 `icon`,并导出带 `meta.title` 与 `meta.description` 的 `./locale/*.json`;官方分组显示这些元数据。Bundle 的展示方式不决定其[实验性状态](../../../../packages/experimental/README.zh.md#status)。隔离检查校验准入,并继续拒绝非实验 bundle 的实验性依赖。 +`OPTIONAL_BUNDLES` 管理安装随附且默认关闭的选项,包括维护中的可选能力与实验性能力。每个 bundle 声明 `icon`,并导出带 `meta.title` 与 `meta.description` 的 `./locale/*.json`;[插件管理页](../../../../packages/client/ui-plugin-manager/README.zh.md#use-this-package)为符合展示条件的组合包显示这些元数据,并保留默认隐藏项的显式启用入口。Bundle 的展示方式不决定其[实验性状态](../../../../packages/experimental/README.zh.md#status)。隔离检查校验准入,并继续拒绝非实验 bundle 的实验性依赖。 可选 bundle 的依赖随每次 `dsh` 安装下载。[思考过程翻译](2026-10-05-anonymous-reasoning-translation.zh.md)使用三个实验包,不增加第三方运行时依赖。因此准入需要考虑安装成本,以及开关能否在不增加配置表单的情况下提供可用组合。外部浏览器与 Cua Driver 提供方仍使用显式组合,因为它们会增加较大的运行时或要求外部可执行文件。Stagehand 需要模型凭据;Python PTC 替换核心提供方,并与 TypeScript workflow 消费方冲突。演示性钩子、webhook 与模组不进入名单。[Session Inspector](../feature/2026-09-24-session-inspector.zh.md) 管理 Inspector 特有的激活行为。 diff --git a/.agents/notes/implemented/architecture/2026-10-12-peer-principal-and-remote-access.i18n.yaml b/.agents/notes/implemented/architecture/2026-10-12-peer-principal-and-remote-access.i18n.yaml index fbf95020c0..9378bc4f0b 100644 --- a/.agents/notes/implemented/architecture/2026-10-12-peer-principal-and-remote-access.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-10-12-peer-principal-and-remote-access.i18n.yaml @@ -54,8 +54,8 @@ en: 7865e159528e2d30 zh: 1399d54632f4d926 /agent-note-peer-principals-and-method-level-remote-access-control/decision/method-level-access-control/access-snapshot-stream: - en: 41d7ac1faf2448d7 - zh: 6d523895a9abae68 + en: a928a63e8c36177c + zh: c943c1fdc8c4bb45 /agent-note-peer-principals-and-method-level-remote-access-control/decision/method-level-access-control/client-face: en: 7be28cd7e19e5377 zh: 30b5aee957271d6c @@ -81,8 +81,8 @@ en: 89452c3c3f042e75 zh: 9bcad00a60c65303 /agent-note-peer-principals-and-method-level-remote-access-control/consequences/verification: - en: 048067eb02e6bf8a - zh: 12508b59f6b206b8 + en: 2279a8c153cc5b89 + zh: 63c0b6462722f0a3 /agent-note-peer-principals-and-method-level-remote-access-control/consequences/costs-and-limits: en: 1e254cec680b1a37 zh: 06a31e057ba965aa diff --git a/.agents/notes/implemented/architecture/2026-10-12-peer-principal-and-remote-access.md b/.agents/notes/implemented/architecture/2026-10-12-peer-principal-and-remote-access.md index 324606f3ab..c9894ae409 100644 --- a/.agents/notes/implemented/architecture/2026-10-12-peer-principal-and-remote-access.md +++ b/.agents/notes/implemented/architecture/2026-10-12-peer-principal-and-remote-access.md @@ -446,7 +446,7 @@ export const REMOTE_ACCESS_SNAPSHOT_KEY = '__DSH_REMOTE_ACCESS__' | Behavior | Detail | |---|---| -| First snapshot | `snapshotFor(principal)` computes a page's opening baseline. remote-access adds the global `REMOTE_ACCESS_SNAPSHOT_KEY`, set to the owner baseline `{ type: 'baseline', owner: true }`, to every index the webServer renders, through `webserver/index-inject`. A business package that renders a non-owner page (such as liveshare on the owner Host) places an assignment of that global to `snapshotFor(principal)` after every index injection row; the later assignment wins, and the Client reads the global when it mounts | +| First snapshot | `snapshotFor(principal)` computes a page's opening baseline. The webServer renders each index for the Peer the page speaks for, and `webserver/index-inject` passes that Peer to listeners; remote-access adds the global `REMOTE_ACCESS_SNAPSHOT_KEY`, set to `snapshotFor(peer.principal)`, to every index, so the owner's page receives the owner baseline `{ type: 'baseline', owner: true }` and a page rendered for a non-owner Peer (such as a liveshare viewer page on the owner Host) receives that principal's grants. The Client reads the global when it mounts | | Subscription | Each Peer subscribes to one access snapshot stream, whose baseline replaces the injected one | | Computation | All bindings of the policy for the principal's kind are merged by equal resource; each resource's `actions` are the endpoint actions of those bindings' roles that the same decision as a call allows (one resource, without args, conditions called without args); only a resource with at least one allowed endpoint action gets a grant. Decisions and snapshots share one source: for the same principal, action, and resource (without arguments), the snapshot answer equals `decide()` | | Endpoint actions only | `event:` actions do not enter the snapshot; the frontend decides entry visibility from endpoint actions only | @@ -638,7 +638,7 @@ The review rule's home is the "`@Remote` marks capability methods" section of [R | Resource resolution | A default field (top-level or first object argument) yields its resource; a lookup parameter that declares that kind as its `resource` yields its wire identity; a Context method gets its Session resource from the Agent Context identity in args without activating the Agent, and may declare other resources through `resources`; a `resources` reader overrides its kind, an array yields several resources, and `undefined` none; with nothing the call is `[global]`; a throw, an empty array, or a value that is not a non-empty string (including a Context identity) is invalid, refused by the Gateway with `resource-invalid` without asking `decide()`; a Context that declares no `resource` resolves by the ordinary rules; resources and `args` read the validated arguments, so an undeclared nested field changes no resource and no condition sees it, and a JSON argument failing validation returns `gateway/input-invalid` before the access check | Gateway host spec (gateway-access) | | Resource registry | `typert.resources` registers one default field per kind, rejects a second registration and `global`, and withdraws the registration with its fiber; a configured lookup resolver keeps the provider's `resource`; a Host Context adapter keeps its `resource` | typert-registry spec | | Forwarded events | A non-owner `$events` stream receives an event, whole, only when `event:` is granted on its resources; an event that declares no resources is decided on `global`; an event whose reader returns `undefined` does not reach non-owners, and `decide()` is not asked; the owner receives all | Gateway host spec (gateway-access) | -| First snapshot | `snapshotFor()` returns the owner baseline or the principal's grants; remote-access injects the owner baseline into every rendered index; the Client starts from the injected baseline and rejects a malformed one | remote-access host and client specs | +| First snapshot | `snapshotFor()` returns the owner baseline or the principal's grants; remote-access injects the snapshot of the Peer each index speaks for; the Client starts from the injected baseline and rejects a malformed one | remote-access host and client specs | | Type inference | The parameter types of `resources` readers are inferred from the method; lookup positions have wire types; a missing property, more parameters than the method has, and a `global` key fail compilation; a binding whose resource kind differs from its role's fails compilation | typert-protocol spec, remote-access spec (type tests) | | List filtering | `session/list`, `session/search`, `session/control`, and `workspace/follow` return non-owners only items for which `allows()` is true; `workspace/follow` sends a `replace` increment when a delivered Workspace loses visibility, and the Workspace reappears once access returns; owner results are unchanged | session-controller / workspace-controller host spec | | Access snapshots | The allowed set in a snapshot equals the Host decision for the same principal, action, and resource without arguments; it contains endpoint actions only; a resource with no allowed action gets no grant, and one whose allowed set becomes empty is removed; `invalidate()` and policy registration or removal push increments; a wait that a change ends leaves no abort listener behind; unloading the service ends every open snapshot stream; the owner receives only `{ owner: true }` | remote-access host spec | diff --git a/.agents/notes/implemented/architecture/2026-10-12-peer-principal-and-remote-access.zh.md b/.agents/notes/implemented/architecture/2026-10-12-peer-principal-and-remote-access.zh.md index afbd49de7f..ed124e8159 100644 --- a/.agents/notes/implemented/architecture/2026-10-12-peer-principal-and-remote-access.zh.md +++ b/.agents/notes/implemented/architecture/2026-10-12-peer-principal-and-remote-access.zh.md @@ -446,7 +446,7 @@ export const REMOTE_ACCESS_SNAPSHOT_KEY = '__DSH_REMOTE_ACCESS__' | 行为 | 说明 | |---|---| -| 首份快照 | `snapshotFor(principal)` 计算页面打开时的 baseline。remote-access 经 `webserver/index-inject` 为 webServer 渲染的每个 index 加入全局变量 `REMOTE_ACCESS_SNAPSHOT_KEY`,值为 owner baseline `{ type: 'baseline', owner: true }`。渲染非 owner 页面的业务包(例如 owner Host 上的 liveshare)在所有 index 注入行之后放置一条把该全局变量赋值为 `snapshotFor(principal)` 的语句;后写的赋值生效,Client 在挂载时读取该全局变量 | +| 首份快照 | `snapshotFor(principal)` 计算页面打开时的 baseline。webServer 按页面所代表的 Peer 渲染 index,`webserver/index-inject` 把该 Peer 传给监听方;remote-access 为每个 index 加入全局变量 `REMOTE_ACCESS_SNAPSHOT_KEY`,值为 `snapshotFor(peer.principal)`,owner 页面因此得到 owner baseline `{ type: 'baseline', owner: true }`,为非 owner Peer 渲染的页面(例如 owner Host 上的 liveshare 读者页)得到该 principal 的 grant。Client 在挂载时读取该全局变量 | | 订阅 | 每个 Peer 订阅一条访问快照流,其 baseline 取代注入的快照 | | 计算 | 取该 principal kind 的 policy 的全部 binding,按资源相等归并;每个资源的 `actions` 为这些 binding 的 role 中、经与调用相同的判定(单个资源、不带 args,condition 以无 args 调用)放行的端点 action;只为至少放行一个端点 action 的资源给出 grant。判定与快照同源:同一 principal、action 与资源(不带参数)上,快照答案与 `decide()` 一致 | | 只含端点 action | `event:` action 不进入快照;前端只据端点 action 决定入口显隐 | @@ -638,7 +638,7 @@ GlobalStandardProps.useWorkspaceAccess: (workspaceId: WorkspaceId, action: Remot | 资源解析 | 默认字段(顶层或首个对象参数)得到对应资源;声明了该种类 `resource` 的 lookup 参数得到其 wire 身份;Context 方法从 args 中的 Agent Context 身份得到其 session 资源且不激活 Agent,另可经 `resources` 声明其他资源;`resources` 读取函数覆盖该种类,数组为多个资源,`undefined` 为无;都没有为 `[global]`;抛错、空数组或非非空字符串(含 Context 身份)为非法,Gateway 以 `resource-invalid` 拒绝且不询问 `decide()`;未声明 `resource` 的 Context 按普通规则解析;资源与 `args` 读取校验后的参数,未声明的嵌套字段不改变资源、condition 也读不到它,JSON 参数校验失败在访问检查前返回 `gateway/input-invalid` | Gateway host spec(gateway-access) | | 资源注册表 | `typert.resources` 每个种类登记一个默认字段,拒绝重复登记与 `global`,并随 fiber 撤回;配置的 lookup resolver 保留 provider 的 `resource`;Host Context adapter 保留其 `resource` | typert-registry spec | | 转发事件 | 非 owner 的 `$events` 流只在 `event:` 于其资源上被授权时整条收到事件;未声明资源的事件按 `global` 判定;读取函数返回 `undefined` 的事件不送达非 owner,且不询问 `decide()`;owner 全部收到 | Gateway host spec(gateway-access) | -| 首份快照 | `snapshotFor()` 返回 owner baseline 或该 principal 的 grant;remote-access 为渲染的每个 index 注入 owner baseline;Client 从注入的 baseline 起步并拒绝格式错误的快照 | remote-access host 与 client spec | +| 首份快照 | `snapshotFor()` 返回 owner baseline 或该 principal 的 grant;remote-access 为每个 index 注入其所代表 Peer 的快照;Client 从注入的 baseline 起步并拒绝格式错误的快照 | remote-access host 与 client spec | | 类型推断 | `resources` 读取函数的参数类型从方法推断;lookup 位为 wire 类型;访问不存在的属性、参数多于方法参数与 `global` 键编译失败;binding 的资源种类与 role 不符编译失败 | typert-protocol spec、remote-access spec(类型测试) | | 列表过滤 | `session/list`、`session/search`、`session/control`、`workspace/follow` 对非 owner 只返回 `allows()` 为 true 的项;已下发工作区失去可见性时 `workspace/follow` 发送 `replace` 增量帧,访问恢复后该工作区重新出现;owner 结果不变 | session-controller / workspace-controller host spec | | 访问快照 | 快照中的允许集合与 Host 对同一 principal、action、资源(不带参数)的判定一致;只含端点 action;没有放行 action 的资源不出 grant,放行集合变空时推送 `remove`;`invalidate()` 与 policy 注册、撤回后推送增量;被变化结束的等待不残留 abort 监听器;卸载服务即结束所有打开的快照流;owner 只收 `{ owner: true }` | remote-access host spec | diff --git a/.agents/notes/implemented/architecture/2026-10-13-cua-hover-routes-and-coordinates.i18n.yaml b/.agents/notes/implemented/architecture/2026-10-13-cua-hover-routes-and-coordinates.i18n.yaml index 62f41f9e6f..85cccf7afa 100644 --- a/.agents/notes/implemented/architecture/2026-10-13-cua-hover-routes-and-coordinates.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-10-13-cua-hover-routes-and-coordinates.i18n.yaml @@ -9,8 +9,8 @@ en: f96bf1ebc0719fb0 zh: e9a1144f0e76572d /agent-note-background-hover-and-screenshot-coordinates/decision: - en: 0cdd295d5f1872ed - zh: fe03ec9f7df94a4a + en: 0f45528ea6c5988c + zh: 9bde9df417ddb1ce /agent-note-background-hover-and-screenshot-coordinates/alternatives-considered: en: 5fc4beaf79761c50 zh: 4948333b0020be28 diff --git a/.agents/notes/implemented/architecture/2026-10-13-cua-hover-routes-and-coordinates.md b/.agents/notes/implemented/architecture/2026-10-13-cua-hover-routes-and-coordinates.md index e43121c8c8..d81d964f8a 100644 --- a/.agents/notes/implemented/architecture/2026-10-13-cua-hover-routes-and-coordinates.md +++ b/.agents/notes/implemented/architecture/2026-10-13-cua-hover-routes-and-coordinates.md @@ -12,7 +12,7 @@ Hover-only controls can sometimes be revealed without moving the user's pointer. Native hover first uses a same-window element or ancestor advertising both `AXShowAlternateUI` and `AXShowDefaultUI`. Entering its revealed descendants preserves the parent UI; leaving restores it. An unavailable route falls back to the real system pointer. An AX action failure propagates because received effects may be uncertain. The result records the route and whether the system pointer moved. System movement requires the selected foreground app and AppKit's mouse hit test, which excludes input-transparent overlays. No route grants [cross-call ownership](2026-10-13-cua-continuous-hover.md). -Model-facing coordinate input defaults to pixels in the returned screenshot. `relative: true` accepts integer thousandths from 0 to 1000 for click, hover, scroll and drag; it does not apply to AX indices. The Host owns the mapping to native window points. An application-wide observation refreshes its inventory; a scoped observation preserves other windows' image mappings. Window resizing invalidates the old image, while movement preserves local coordinates. Relative endpoints remain inside the window and do not depend on screenshot resolution. +Model-facing coordinate input defaults to pixels in the returned screenshot. `relative: true` accepts integer thousandths from 0 to 999 for click, hover, scroll and drag; it does not apply to AX indices. The Host owns the mapping to native window points. An application-wide observation refreshes its inventory; a scoped observation preserves other windows' image mappings. Window resizing invalidates the old image, while movement preserves local coordinates. Relative endpoints remain inside the window and do not depend on screenshot resolution. `LlmAdapter.imageRequestTarget` exposes the same dimensions and byte target used by image serialization. DeepSeek and pi-ai share their existing projection functions with this method. Other adapters preserve normalized images unless they declare resizing. Screenshot bytes returned to programs match the request image; its durable source is admitted and stored once. The native PNG metadata is checked against decoded source dimensions before any input mapping is published. diff --git a/.agents/notes/implemented/architecture/2026-10-13-cua-hover-routes-and-coordinates.zh.md b/.agents/notes/implemented/architecture/2026-10-13-cua-hover-routes-and-coordinates.zh.md index 51beb3967f..2ef7ac9e44 100644 --- a/.agents/notes/implemented/architecture/2026-10-13-cua-hover-routes-and-coordinates.zh.md +++ b/.agents/notes/implemented/architecture/2026-10-13-cua-hover-routes-and-coordinates.zh.md @@ -12,7 +12,7 @@ Status: implemented 原生悬停优先寻找同一窗口内同时声明 `AXShowAlternateUI` 和 `AXShowDefaultUI` 的元素或祖先。进入其展开区域的子控件时保持父控件展开,离开后恢复。没有可用路径时回退到真实系统鼠标。AX 动作失败直接返回,因为已产生的效果可能不确定。结果记录实际路径及是否移动系统鼠标。系统移动要求所选应用处于前台,并使用 AppKit 的鼠标命中查询排除不接收输入的覆盖窗口。所有路径都遵守[跨调用不分配所有权](2026-10-13-cua-continuous-hover.zh.md)的决定。 -面向模型的坐标默认使用返回截图中的像素。click、hover、scroll 和 drag 传入 `relative: true` 时接受 0~1000 的千分比整数,不适用于 AX 索引。Host 负责换算原生窗口 point。应用级观察刷新窗口清单,单窗口观察保留其他窗口的图片映射。窗口尺寸变化使旧图片失效,移动窗口保留局部坐标。相对坐标的端点保持在窗口内,不随截图分辨率改变。 +面向模型的坐标默认使用返回截图中的像素。click、hover、scroll 和 drag 传入 `relative: true` 时接受 0~999 的千分比整数,不适用于 AX 索引。Host 负责换算原生窗口 point。应用级观察刷新窗口清单,单窗口观察保留其他窗口的图片映射。窗口尺寸变化使旧图片失效,移动窗口保留局部坐标。相对坐标的端点保持在窗口内,不随截图分辨率改变。 `LlmAdapter.imageRequestTarget` 提供与图片序列化相同的尺寸和字节目标。DeepSeek 和 pi-ai 在该方法中复用已有投影函数。其他适配器除非声明缩放,否则保留归一化图片。返回程序的截图字节与模型请求图片一致,持久原图只接入并保存一次。发布输入映射前,原生 PNG 元数据必须与解码后的原图尺寸一致。 diff --git a/.agents/notes/implemented/architecture/2026-10-19-file-drag-plugin-ownership.i18n.yaml b/.agents/notes/implemented/architecture/2026-10-19-file-drag-plugin-ownership.i18n.yaml new file mode 100644 index 0000000000..3ebb632746 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-10-19-file-drag-plugin-ownership.i18n.yaml @@ -0,0 +1,19 @@ +# Bilingual-pair consistency record for 2026-10-19-file-drag-plugin-ownership.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/architecture/2026-10-19-file-drag-plugin-ownership.md +/agent-note-file-drag-ownership-outside-the-application-layout: + en: 951575b08912a949 + zh: f33e8e6bcb9240bd +/agent-note-file-drag-ownership-outside-the-application-layout/problem: + en: 557edc1db9bee8d8 + zh: 20f47c78c8b0078b +/agent-note-file-drag-ownership-outside-the-application-layout/decision: + en: 1dfea60901d6e6ae + zh: e4fc534dbe01b0d5 +/agent-note-file-drag-ownership-outside-the-application-layout/alternatives-considered: + en: 571eabab132ce4c1 + zh: b5f8207015aeb3be +/agent-note-file-drag-ownership-outside-the-application-layout/consequences: + en: 56ebb7c25911fd61 + zh: 88120d2af74785ef diff --git a/.agents/notes/implemented/architecture/2026-10-19-file-drag-plugin-ownership.md b/.agents/notes/implemented/architecture/2026-10-19-file-drag-plugin-ownership.md new file mode 100644 index 0000000000..5d99b5815a --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-10-19-file-drag-plugin-ownership.md @@ -0,0 +1,25 @@ +# Agent Note: File drag ownership outside the application layout + +Status: implemented + +English | [中文](2026-10-19-file-drag-plugin-ownership.zh.md) + +## Problem + +Image dragging spans conversation images, sidebar previews, and composer drop targets. Original files must survive source unmount during a drag, and failures must remain visible after the originating panel closes. An application-root image provider puts feature state in layout and makes independent image entries depend on an undeclared React ancestor. + +## Decision + +The [file-drag plugin](../../../../packages/client/ui-file-drag/README.md) owns generic file preparation, browser transfer tokens, retained original files, desktop bridge selection, and transient failure feedback. Consumers receive preparation and error callbacks through their slot or Factory injection. The plugin registers its feedback through `shell.overlay`; layout declares the generic slot and has no image dependency. The shared image primitive owns gestures and cancellation. Images are the first consumer, while MIME naming and transport accept other files. + +## Alternatives considered + +**Root React provider.** It preserves transfer lifetime but adds image-specific assembly to the layout package and hides dependencies from slot registration. Moving the same wrapper into the renderer would move the feature coupling into another generic owner. + +**State on each image.** It avoids shared assembly but cannot deliver the retained file after the source unmounts or keep a late failure visible after the panel closes. + +**Direct browser File only.** Manual macOS Chrome 154.0.8037.93 drags of PNG and PDF fixtures produced a populated `dataTransfer.files` in trusted `dragstart`, but empty files in trusted same-page and cross-window drops. Finder received filename-only text clippings. These measured failures require the application-local token path; they do not establish behavior in untested browsers or cross-tab delivery. Desktop retains Electron native file dragging. + +## Consequences + +One plugin lifetime governs transfer state, a native source-node end listener, and drop completion. The source listener remains attached after React unmount. Independent render roots can share a transfer without sharing a React ancestor. Custom compositions must load the service and declare the feedback slot; custom image Factory registrations supply the required callbacks. Application-local browser tokens are accepted only over the composer; native Desktop files retain the full-page drop overlay. Plugin disposal removes feedback and clears retained files. Loader-backed browser scenarios cover source removal and delivery, while focused tests cover local resource authorization and cleanup failures. diff --git a/.agents/notes/implemented/architecture/2026-10-19-file-drag-plugin-ownership.zh.md b/.agents/notes/implemented/architecture/2026-10-19-file-drag-plugin-ownership.zh.md new file mode 100644 index 0000000000..0e361717a4 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-10-19-file-drag-plugin-ownership.zh.md @@ -0,0 +1,25 @@ +# Agent Note: 应用布局之外的文件拖放归属 + +Status: implemented + +[English](2026-10-19-file-drag-plugin-ownership.md) | 中文 + +## Problem + +图片拖放跨越会话图片、侧栏预览和输入框接收区。拖动期间原始文件必须在源组件卸载后继续保留,发起操作的面板关闭后失败提示也必须可见。应用根部的图片 Provider 把功能状态放进布局,并使独立图片入口依赖未声明的 React 祖先组件。 + +## Decision + +[文件拖放插件](../../../../packages/client/ui-file-drag/README.zh.md) 持有通用文件准备、浏览器传递令牌、原始文件、桌面桥接选择和临时失败提示。消费方通过 slot 或 Factory 注入接收准备及错误回调。插件通过 `shell.overlay` 注册提示,布局只声明通用插槽,不依赖图片功能。共享图片原语负责手势与取消。图片是首个调用方,MIME 命名与传输也接受其他文件。 + +## Alternatives considered + +**根部 React Provider。** 它能保留传递生命周期,但会把图片专属组合放进布局包,并对 slot 注册隐藏依赖。把同一个包裹组件移到渲染器,只会把功能耦合转移到另一个通用模块。 + +**每张图片独立持有状态。** 这能避免共享组合,却不能在源组件卸载后交付保留文件,也不能在面板关闭后继续显示延迟到达的失败提示。 + +**仅直接传递浏览器 File。** 在 macOS Chrome 154.0.8037.93 中手动拖放 PNG 与 PDF 时,可信 `dragstart` 中 `dataTransfer.files` 有文件,同页与跨窗口的可信 drop 却收到空文件列表。Finder 收到仅含文件名的文本摘录。这些实测失败要求保留应用内令牌路径,但不能据此判定未测试浏览器或跨标签页交付的行为。Desktop 保留 Electron 原生文件拖放。 + +## Consequences + +一个插件生命周期管理传递状态、源节点上的原生结束监听器与投放完成。React 卸载后,监听器仍绑定源节点。独立渲染根可以共享传递,而不共享 React 祖先组件。自定义组合必须加载服务并声明反馈插槽,自定义图片 Factory 注册提供所需回调。应用内浏览器令牌仅在输入框范围接受;Desktop 原生文件保留全页投放遮罩。插件卸载会移除提示并清除保留文件。通过 Loader 启动的浏览器场景覆盖源图移除与交付,定向测试覆盖本地资源授权和清理失败。 diff --git a/.agents/notes/implemented/architecture/2026-10-19-pi-ai-system-reminder-fallback.i18n.yaml b/.agents/notes/implemented/architecture/2026-10-19-pi-ai-system-reminder-fallback.i18n.yaml new file mode 100644 index 0000000000..3e9350f2b3 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-10-19-pi-ai-system-reminder-fallback.i18n.yaml @@ -0,0 +1,19 @@ +# Bilingual-pair consistency record for 2026-10-19-pi-ai-system-reminder-fallback.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/architecture/2026-10-19-pi-ai-system-reminder-fallback.md +/agent-note-pi-ai-system-reminder-fallback: + en: f43b5c6974289180 + zh: abc0a86b1d137189 +/agent-note-pi-ai-system-reminder-fallback/problem: + en: 5247278483ca4e7a + zh: ca711671a43fae76 +/agent-note-pi-ai-system-reminder-fallback/decision: + en: 604eb72704c73328 + zh: 6661d1065f65ecf9 +/agent-note-pi-ai-system-reminder-fallback/alternatives-considered: + en: 0e2aaf897587ebb0 + zh: 0a7f9a9cb7ddffaf +/agent-note-pi-ai-system-reminder-fallback/consequences: + en: 8a0c53289a04c64a + zh: 591e602eb2036a1a diff --git a/.agents/notes/implemented/architecture/2026-10-19-pi-ai-system-reminder-fallback.md b/.agents/notes/implemented/architecture/2026-10-19-pi-ai-system-reminder-fallback.md new file mode 100644 index 0000000000..458fdb83ca --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-10-19-pi-ai-system-reminder-fallback.md @@ -0,0 +1,25 @@ +# Agent Note: Pi-ai system reminder fallback + +Status: implemented + +English | [中文](2026-10-19-pi-ai-system-reminder-fallback.zh.md) + +## Problem + +Pi-ai concatenates later system messages into the initial prompt for models without native mid-conversation system support. Later system messages supplied to the adapter therefore invalidate the preceding conversation cache prefix. + +## Decision + +The pi-ai adapter uses the prepared model's existing `supportsMidConvoSystemMessages` capability mapping. It preserves native system messages on capable models and converts each non-empty later system message into a user message wrapped in `` otherwise, before pi-ai receives the context. Leading system text remains in the initial prompt. Message order, repeated updates, and existing text-block concatenation remain intact. Durable messages retain their original roles. + +The fallback belongs to the adapter because it depends on the selected protocol capability. Prompt admission remains owned by the agent loop, which consolidates system-prompt updates on routes without native support; this fallback only handles later system messages present in the supplied request. + +## Alternatives considered + +**Let pi-ai concatenate updates.** Retains system-role priority but changes the initial prompt whenever later system text changes, defeating prefix reuse. + +**Advertise native support or send developer messages.** Misstates model capability or relies on another unsupported instruction role. The fallback uses ordinary user messages and does not change capability metadata. + +## Consequences + +The wrapper does not confer system-role authority, so reminders have user-role priority. Cache hits remain provider-dependent; tool changes and compaction can still invalidate prefixes. Both text-only and attachment-aware conversion apply the same fallback, and wire tests inspect the real pi-ai serializer output. diff --git a/.agents/notes/implemented/architecture/2026-10-19-pi-ai-system-reminder-fallback.zh.md b/.agents/notes/implemented/architecture/2026-10-19-pi-ai-system-reminder-fallback.zh.md new file mode 100644 index 0000000000..02e9ca648f --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-10-19-pi-ai-system-reminder-fallback.zh.md @@ -0,0 +1,25 @@ +# Agent Note: Pi-ai 系统提醒回退 + +Status: implemented + +[English](2026-10-19-pi-ai-system-reminder-fallback.md) | 中文 + +## 问题 + +对于不原生支持对话中系统消息的模型,pi-ai 会把后续系统消息拼接进初始提示词。因此,传给适配器的后续系统消息会使此前的对话缓存前缀失效。 + +## 决策 + +pi-ai 适配器使用已准备模型的现有 `supportsMidConvoSystemMessages` 能力映射。对于具备该能力的模型,适配器保留原生系统消息;否则,在 pi-ai 接收上下文之前,将每条非空的后续系统消息转换为以 `` 包裹的用户消息。开头的系统文本仍位于初始提示词中。消息顺序、重复更新和现有文本块拼接方式均保持不变。持久消息保留原始角色。 + +回退由适配器负责,因为它取决于所选协议的能力。提示词纳入仍由 Agent loop 负责,它在不支持原生更新的路由上合并系统提示词更新;此回退仅处理传入请求中已有的后续系统消息。 + +## 考虑过的替代方案 + +**让 pi-ai 拼接更新。** 保留系统角色优先级,但后续系统文本每次变化都会改变初始提示词,破坏前缀复用。 + +**宣称原生支持或发送 developer 消息。** 错报模型能力,或依赖另一种不受支持的指令角色。回退使用普通用户消息,不改变能力元数据。 + +## 后果 + +包裹文本不会赋予系统角色权限,因此提醒具有用户角色优先级。缓存命中仍取决于提供方;工具变化和压缩仍可能使前缀失效。纯文本与附件感知转换使用相同的回退,线协议测试检查真实 pi-ai 序列化器的输出。 diff --git a/.agents/notes/implemented/architecture/2026-10-21-when-access.i18n.yaml b/.agents/notes/implemented/architecture/2026-10-21-when-access.i18n.yaml new file mode 100644 index 0000000000..2a74d80a3d --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-10-21-when-access.i18n.yaml @@ -0,0 +1,28 @@ +# Bilingual-pair consistency record for 2026-10-21-when-access.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/architecture/2026-10-21-when-access.md +/agent-note-whenaccess-registration-that-follows-access: + en: 2ef7cfef514c138a + zh: 064e3aecbfafcd9c +/agent-note-whenaccess-registration-that-follows-access/problem: + en: 4f88d3ee7868eaa5 + zh: 72a4ce2dde13bef7 +/agent-note-whenaccess-registration-that-follows-access/decision: + en: a15c7c13f167e5ac + zh: 63bcc2ef60c2f344 +/agent-note-whenaccess-registration-that-follows-access/decision/whenaccess: + en: c168de20989cf9a4 + zh: bf3b0361beea083d +/agent-note-whenaccess-registration-that-follows-access/decision/plugin-migration: + en: 73577ed282c656f3 + zh: 2f60a6824218e693 +/agent-note-whenaccess-registration-that-follows-access/alternatives-considered: + en: ddc432b693608163 + zh: 620294d8812b0a31 +/agent-note-whenaccess-registration-that-follows-access/consequences: + en: de44fa92c49bc335 + zh: 34436fe971ab013f +/agent-note-whenaccess-registration-that-follows-access/related: + en: 89073ffbfcff0641 + zh: 736a180d13a08981 diff --git a/.agents/notes/implemented/architecture/2026-10-21-when-access.md b/.agents/notes/implemented/architecture/2026-10-21-when-access.md new file mode 100644 index 0000000000..5936accdf5 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-10-21-when-access.md @@ -0,0 +1,88 @@ +# Agent Note: whenAccess registration that follows access + +Status: implemented + +English | [中文](2026-10-21-when-access.zh.md) + +## Problem + +Client plugins need to switch their own registrations on and off with the current Peer's access: sidebar entries, main panels, overlays, and data streams. The existing Client access surface covers only rendering: + +| Where | Existing capability | Gap | +|---|---|---| +| Components | `useRemoteAccess(action)` (global), `useSessionAccess(sessionId, action)`, `useWorkspaceAccess(workspaceId, action)`, and the Session-scope `useAccess(action)` | Readable only during render; a changed answer re-renders automatically | +| JS (`apply`, services) | `ctx.remoteAccess.source(kind, id, action)` returns a `HostObservable` | A plugin must read `getSnapshot` and subscribe to changes itself, then register and dispose by hand | + +Without a JS entry point, a plugin can only decide its registrations once by identity at `apply`, and they do not follow an access change. + +## Decision + +### whenAccess + +The JS side has a `whenAccess` for each of the three access Hooks, from the package that publishes that Hook: + +| Interface | Provider | Matching Hook | +|---|---|---| +| `ctx.remoteAccess.whenAccess(action, callback)` | remote-access | `useRemoteAccess(action)` | +| `ctx.uiSession.whenAccess(sessionId, action, callback)` | ui-session | `useSessionAccess(sessionId, action)` | +| `ctx.uiWorkspace.whenAccess(workspaceId, action, callback)` | ui-workspace | `useWorkspaceAccess(workspaceId, action)` | + +```ts ignore-check +import type { AccessEffect, RemoteEndpoint } from '@deepseek-ai/dsh-api-remote-access/client' + +/** The global-resource member; the Session and Workspace versions take the resource id first. */ +export interface WhenAccess { + /** + * Keep `callback`'s effects installed while the current Peer may call `action` + * on the resource: run it when the answer becomes true, dispose its effects in + * reverse order when it becomes false, and run it again on the next true. + * @returns disposer for the observation and any installed effects. + */ + whenAccess(action: RemoteEndpoint, callback: () => AccessEffect): () => void +} +``` + +`AccessEffect` is `(() => void) | Iterable<() => void, void, void>`. + +| Rule | Content | +|---|---| +| Semantics | Same as `ctx.slots.inject`: the callback returns one disposer or an iterable of disposers; an iterable installs as one transaction and disposes in reverse order; the controller belongs to the caller's fiber and disposes when the plugin unloads | +| Synchronous run | When the answer is already true, the callback runs synchronously; the owner's answer is always true, so registration order matches direct registration | +| Stream failure | A non-owner's answer turns false and the installed effects dispose | +| Callback failure | A setup failure at the call throws synchronously; a setup failure on a later grant stops the controller and is rethrown in a microtask | +| Layering | remote-access knows only `global`; the Session and Workspace versions are built over remote-access's lower-level `whenResourceAccess(kind, id, action, callback)`, as their Hooks are built over `source(kind, id, action)`. Only the package that owns a resource kind calls `source` and `whenResourceAccess`; other plugins use only the access Hooks and `whenAccess` and pass no kind. All four entry points share [`when-allowed.ts`](../../../../packages/api/remote-access/src/client/when-allowed.ts) | + +### Plugin migration + +| Plugin | Change | Action | +|---|---|---| +| ui-schedule | The task catalog source, delete toast overlay, right-sidebar task tab and title, main panel, sidebar entry, turn-tail card, Session-header reminder catalog, and Session row mark and hover list move into `remoteAccess.whenAccess`; the Session-header catalog reads the per-Session `schedule.list`, so its component also decides through the Session-scope `useAccess` whether to render; the task tab type and the turn definition stay registered outside | `schedule/catalog`, `schedule/list` | +| ui-plugin-manager | Host change subscriptions, refresh toast overlay, main panel (including `pluginNavigation`), and sidebar entry move into `remoteAccess.whenAccess`, and each grant refreshes a list already loaded; the controller and `pluginChanges` stay outside, and `pluginChanges` receives no updates while access is withdrawn. ui-entry-customization and voice-input use `pluginNavigation` optionally: without the service they hide the browse-plugins and open/details buttons and still start | `pluginManager/listBundles` | +| ui-settings-general | The settings shell `sidebar.settings` and the `settings.open` shortcut move into `remoteAccess.whenAccess` | `settings/describe` | +| ui-settings-account | The account state stream (including default model initialization), quota notice, settings launcher, account settings section, and `settings.models.sign-in` move into `remoteAccess.whenAccess`. On revocation the stream's disposer synchronously invalidates the account reads this grant started, closes the pending authorization tab, clears the account view, details, picture, and sign-in dialog state, ends this generation's bonus notices, which a frame of that stream begins, and then closes the stream; a frame delivered before the revocation but not yet handled is dropped. The notice controller stays outside and begins again under the next grant; the account section appears once the new stream's first frame arrives | `account/watch` | +| ui-directory-picker-native | Both directory flows move into `remoteAccess.whenAccess` | `directoryPicker/pick` | +| ui-message-feedback | The message feedback buttons stay registered and the component decides whether to render through the Session-scope `useAccess`; `messageFeedback/*` requests carry `sessionId`, which the Gateway resolves to that Session through the `session` resource's default field, so they declare nothing. The `/feedback` decoration has no Client check: the decoration registry is global while `sessionFeedback/record` is decided per Session, so without access the dialog still opens and the Host refuses the submission, which the dialog reports with its failure code | `messageFeedback/put` | +| ui-deliverables, ui-open-in-app | Unchanged: an owner-only HTTP route that cannot be read falls back as before | — | + +## Alternatives considered + +**Return after an owner check at the start of `apply` (the #6486 approach)**: fixed on identity; does not follow an access change. + +**Trim the plugin list per connection**: a plugin cannot reload at once when access changes. + +**Declare access in the plugin package manifest**: defines an access-control concept in the package manifest. + +**One generic method with a resource parameter, or all three versions on remote-access**: business code would pass a kind, and remote-access would name the Session and Workspace resource kinds. + +## Consequences + +| Consequence | Note | +|---|---| +| Registrations follow access changes | Without a grant, a migrated plugin registers none of the corresponding entries for a non-owner; they appear after a grant and disappear after a revocation; the owner's answer is always true, so its registrations match direct registration | +| The callback must hand back disposers | An effect registered through the plugin's `ctx` inside the callback also hangs on the plugin fiber; a registration whose disposer is not yielded does not dispose on access loss, only when the plugin unloads | +| Readers receive all plugin code | Plugins load as before; the Gateway decides each call, and `whenAccess` and the Hooks control the UI | +| Test stand-ins | [`dsh-client-test-runtime`](../../../../packages/test-support/client-runtime/README.md) replaces remote-access with fixed answers through `StaticRemoteAccess`, `staticAccessPlugin`, and `TestClientOptions.access`, so plugin specs cover the refused path | + +## Related + +- [Peer, principal, and remote access](2026-10-12-peer-principal-and-remote-access.md): this Note extends its Client access face. diff --git a/.agents/notes/implemented/architecture/2026-10-21-when-access.zh.md b/.agents/notes/implemented/architecture/2026-10-21-when-access.zh.md new file mode 100644 index 0000000000..242be55f65 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-10-21-when-access.zh.md @@ -0,0 +1,88 @@ +# Agent Note: 按访问状态注册的 whenAccess + +Status: implemented + +[English](2026-10-21-when-access.md) | 中文 + +## 问题 + +Client 插件需要按当前 Peer 的访问状态开关自己的注册:侧栏入口、主面板、浮层、数据流。现有 Client 访问面只覆盖渲染: + +| 位置 | 现有能力 | 缺口 | +|---|---|---| +| 组件 | `useRemoteAccess(action)`(global)、`useSessionAccess(sessionId, action)`、`useWorkspaceAccess(workspaceId, action)`、会话作用域的 `useAccess(action)` | 只能在渲染时读,答案变化时自动重渲染 | +| JS(`apply`、服务) | `ctx.remoteAccess.source(kind, id, action)` 返回 `HostObservable` | 插件要自己读 `getSnapshot`、订阅变化,再手动注册与撤销 | + +没有 JS 侧入口时,插件只能在 `apply` 时按身份一次性决定注册与否,授权变化后注册不跟随。 + +## 决策 + +### whenAccess + +JS 侧有与三个访问 Hook 一一对应的 `whenAccess`,由各自 Hook 的发布者提供: + +| 接口 | 提供者 | 对应 Hook | +|---|---|---| +| `ctx.remoteAccess.whenAccess(action, callback)` | remote-access | `useRemoteAccess(action)` | +| `ctx.uiSession.whenAccess(sessionId, action, callback)` | ui-session | `useSessionAccess(sessionId, action)` | +| `ctx.uiWorkspace.whenAccess(workspaceId, action, callback)` | ui-workspace | `useWorkspaceAccess(workspaceId, action)` | + +```ts ignore-check +import type { AccessEffect, RemoteEndpoint } from '@deepseek-ai/dsh-api-remote-access/client' + +/** The global-resource member; the Session and Workspace versions take the resource id first. */ +export interface WhenAccess { + /** + * Keep `callback`'s effects installed while the current Peer may call `action` + * on the resource: run it when the answer becomes true, dispose its effects in + * reverse order when it becomes false, and run it again on the next true. + * @returns disposer for the observation and any installed effects. + */ + whenAccess(action: RemoteEndpoint, callback: () => AccessEffect): () => void +} +``` + +`AccessEffect` 为 `(() => void) | Iterable<() => void, void, void>`。 + +| 规则 | 内容 | +|---|---| +| 语义 | 与 `ctx.slots.inject` 相同:回调返回一个 disposer 或一组 disposer;一组时按事务安装、逆序撤销;控制器属于调用方 fiber,插件卸载时撤销 | +| 同步执行 | 调用时答案为 true 则回调同步执行;owner 的答案恒为 true,注册顺序与直接注册相同 | +| 快照流失败 | 非 owner 的答案变为 false,已安装的效果撤销 | +| 回调失败 | 调用时安装失败同步抛出;之后重新授权时安装失败,控制器停止并在微任务中抛出 | +| 分层 | remote-access 只认识 global;会话版与工作区版经 remote-access 的底层方法 `whenResourceAccess(kind, id, action, callback)` 实现,与它们用 `source(kind, id, action)` 构造 Hook 相同。`source` 与 `whenResourceAccess` 只由拥有该资源种类的包调用,其他插件只用访问 Hook 与 `whenAccess`,不传 kind。四个入口共用 [`when-allowed.ts`](../../../../packages/api/remote-access/src/client/when-allowed.ts) | + +### 插件迁移 + +| 插件 | 改法 | action | +|---|---|---| +| ui-schedule | 任务列表数据源、删除提示浮层、右栏任务 tab 与标题、主面板、侧栏入口、turn 尾部卡片、会话头部提醒、会话行标记与悬浮列表包进 `remoteAccess.whenAccess`;会话头部提醒读取按会话判定的 `schedule.list`,组件另在会话作用域里用 `useAccess` 决定是否渲染;任务 tab 类型与 turn 定义的注册留在外面 | `schedule/catalog`、`schedule/list` | +| ui-plugin-manager | Host 变更订阅、刷新提示浮层、主面板(含 `pluginNavigation`)、侧栏入口包进 `remoteAccess.whenAccess`,每次授权时刷新已加载过的列表;controller 与 `pluginChanges` 留在外面,撤权期间 `pluginChanges` 不更新。依赖 `pluginNavigation` 的 ui-entry-customization 与 voice-input 改为可选使用:服务不在时隐藏「浏览插件」与「打开 / 查看详情」按钮,插件照常启动 | `pluginManager/listBundles` | +| ui-settings-general | 设置外壳 `sidebar.settings` 与 `settings.open` 快捷键包进 `remoteAccess.whenAccess` | `settings/describe` | +| ui-settings-account | 账号状态流(含默认模型初始化)、额度提醒、设置启动器、账号设置分区与 `settings.models.sign-in` 包进 `remoteAccess.whenAccess`。撤权时账号流的 disposer 同步作废这一轮发出的账号读取,关闭待授权的浏览器标签页,清空账号视图、详情、头像与登录对话框状态,结束本轮奖励提示(提示由该流的帧开始),再关闭流;撤权前已交付但尚未处理的帧被丢弃。提示控制器留在外面,下次授权时重新开始;账号分区等新流的第一帧到达后才出现 | `account/watch` | +| ui-directory-picker-native | 两个目录选择流程包进 `remoteAccess.whenAccess` | `directoryPicker/pick` | +| ui-message-feedback | 消息反馈按钮照常注册,组件在会话作用域里用 `useAccess` 判断是否渲染;`messageFeedback/*` 的请求带 `sessionId`,Gateway 按 `session` 资源的默认字段自动解析为该 Session,无需声明。`/feedback` 装饰不做前端判断:装饰注册表是全局的,而 `sessionFeedback/record` 按会话判定;没有权限时对话框照常打开,提交被 Host 拒绝并显示失败码 | `messageFeedback/put` | +| ui-deliverables、ui-open-in-app | 不动:owner 专用 HTTP 路由读不到时走已有回退 | — | + +## 考虑过的替代方案 + +**插件 `apply` 开头判断 owner 后 return(#6486 的做法)**:按身份写死,授权变化后不跟随。 + +**按连接裁剪插件列表**:授权变化后插件无法立即重新加载。 + +**插件包清单声明访问性**:在包清单上定义访问控制概念。 + +**一个带资源参数的通用方法,或三个版本都挂在 remote-access 上**:业务代码要传 kind,且 remote-access 会出现会话、工作区的资源种类名。 + +## 后果 + +| 后果 | 说明 | +|---|---| +| 注册跟随授权变化 | 迁移的插件在非 owner 无授权时不注册相应入口,授予后出现,撤销后消失;owner 的答案恒为 true,注册与直接注册相同 | +| 回调必须交还 disposer | 回调里经插件 `ctx` 注册的效果也挂在插件 fiber 上;不 yield 其 disposer 的注册在失权时不会撤销,只在插件卸载时撤销 | +| 读者拿到全部插件代码 | 插件照常加载;访问由 Gateway 逐次判定,界面由 `whenAccess` 与 Hook 控制 | +| 测试替身 | [`dsh-client-test-runtime`](../../../../packages/test-support/client-runtime/README.zh.md) 的 `StaticRemoteAccess`、`staticAccessPlugin` 与 `TestClientOptions.access` 以固定答案替换 remote-access,供插件测试无权限路径 | + +## 相关 + +- [Peer、principal 与 remote-access](2026-10-12-peer-principal-and-remote-access.zh.md):本 Note 扩展其 Client 访问面。 diff --git a/.agents/notes/implemented/bug-fix/2026-09-17-multiline-display-dollar-math.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-09-17-multiline-display-dollar-math.i18n.yaml new file mode 100644 index 0000000000..e17980cb32 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-09-17-multiline-display-dollar-math.i18n.yaml @@ -0,0 +1,22 @@ +# Bilingual-pair consistency record for 2026-09-17-multiline-display-dollar-math.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-17-multiline-display-dollar-math.md +/agent-note-multi-line-display-dollar-math: + en: 8f57c77aa43fbd6c + zh: ef5192184e4923d5 +/agent-note-multi-line-display-dollar-math/problem: + en: e4b73644a982844c + zh: 48f86358242d6ccc +/agent-note-multi-line-display-dollar-math/decision: + en: d6b136b1f62285b7 + zh: aba323bfdb800162 +/agent-note-multi-line-display-dollar-math/alternatives-considered: + en: e0833b5674e648e7 + zh: b0472020b07099b9 +/agent-note-multi-line-display-dollar-math/consequences: + en: 70f16597bd640e93 + zh: 0e0c9005f331e514 +/agent-note-multi-line-display-dollar-math/testing: + en: f17a1453df91ce88 + zh: 4f15be0139e8b694 diff --git a/.agents/notes/implemented/bug-fix/2026-09-17-multiline-display-dollar-math.md b/.agents/notes/implemented/bug-fix/2026-09-17-multiline-display-dollar-math.md new file mode 100644 index 0000000000..0362895314 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-09-17-multiline-display-dollar-math.md @@ -0,0 +1,66 @@ +# Agent Note: Multi-line display-dollar math + +Status: implemented + +English | [中文](2026-09-17-multiline-display-dollar-math.zh.md) + +## Problem + +Authors and models write `$$` display math in three shapes. The settled grammar rendered two of them: `$$math$$` on one line, and an opening `$$` alone on its line closed by `$$` alone on its line. The third shape — math on the opening line, a closing `$$` at the end of the last math line — did not: + +```markdown +$$f(x)=x +\text{continued}$$ +``` + +`micromark-extension-math` treats text after an opening `$$` as fence meta and accepts a closing fence only on a line whose content is exactly `$$` (after optional whitespace). The [markdown compatibility layer](../../archived/feature/2026-07-23-web-assistant-markdown.md) claimed only the same-line shape, and the settled renderer passes `node.value` to KaTeX while ignoring `node.meta`. The block therefore never closed: it consumed the document text that followed, `value` retained the literal `$$`, KaTeX raised `Can't use function '$' in math mode`, and [katex.tsx](../../../../packages/client/ui-primitives/src/markdown/katex.tsx)'s error arm painted the TeX source plus the swallowed prose in red. + +In the session that surfaced this, one chat reply lost lines 22–55 of 55 to a single error span, and six note files lost every line after such a block (up to 500 lines) to one. A second defect shared the mechanism: when a later lone `$$` did close the flow, the opening line had already been assigned to `meta`, so the renderer silently dropped the first line of the formula. + +## Decision + +[mathCompatibility.ts](../../../../packages/client/ui-primitives/src/markdown/mathCompatibility.ts) gains multi-line display-dollar blocks. Its dollar construct keeps the opening line's text in `mathFlowValue` instead of fence meta and closes at a trailing `$$` on any later content line, so `$$a+b` continued by `c+d$$` becomes one math node. + +A `$$` sequence that fails to close ends the candidate: immediately on a later line, and at that line's ending on the opening line (`$$x$$ trailing` fails there). Within the opening line the scan continues, so a later `$$` there still closes (`$$\verb*a$$a$$`). Scanning past either point would consume a later block's closing fence. A solitary `$` is content rather than a failed fence, so a formula that uses one — `$$P = \begin{cases} 1 & \text{if $x>0$} \end{cases}$$` — keeps the block open and closes normally wherever this flow owns the block. A trailing backslash escapes the line ending, not the next line's first character, so the escape run ends at the line ending instead of reaching into the next candidate. An unescaped `%` starts a TeX comment that runs to the line ending, so a `$$` at the end of a later line closes nothing when the comment holds text before it. A `%` whose text before the fence is empty or whitespace (`95%$$`, `95 %$$`) holds no comment text, and a block that opens and closes on one line keeps closing at that line's `$$`, as it did before this flow spanned lines. That state and its gate on the closing check belong to the dollar flow. The characters right after `\verb` or `\verb*` are that command's delimiter rather than a fence, so the delimiter and, when the region is empty, the `$` closing it are content; that tail is cleared at a line ending and after an escaped backslash (`\\verb` is a line break followed by letters), and a comment holds no commands. Otherwise verbatim regions are not parsed, which keeps `\verb`-bearing formulas on the same value path as every other input. + +Its paragraph-interruption check declines instead of consuming the line ending, for the dollar construct only. Consuming it there keeps the paragraph open across the line boundary, so the document layer keeps the container and counts the next line lazy for every construct tried afterwards; that made upstream's `mathFlow`, the construct tried after this one, end empty inside a list item. Declining also leaves the interruption decision to that upstream flow, whose own check short-circuits without consuming and claims only the fences it accepts, so a candidate neither construct accepts (`**before` followed by `$$a\$b`) leaves its paragraph intact. The backslash construct has no competing upstream construct and keeps consuming there. + +[parse.ts](../../../../packages/client/ui-primitives/src/markdown/parse.ts) registers `mathCompatibility()` after `math()`: micromark dispatches the extension registered last first, so the dollar flow above takes precedence over upstream's `mathFlow` while `math()` still owns inline `$…$`. + +## Alternatives considered + +**Keep the compatibility layer at same-line `$$…$$`.** The multi-line shape is what GitHub, KaTeX's auto-render, VS Code, and Typora accept, and the form models emit for multi-case formulas; leaving it unparsed means a red error span in place of the formula plus the rest of the document. + +**Repair the parsed tree afterwards.** The flow consumes the swallowed text into `mathFlowValue`, where it is indistinguishable from math source; `meta` and `value` cannot be reassembled into the intended block, and the following blocks were never parsed at all. + +**Rewrite `$$` fences in the source text before parsing.** The compatibility layer is a grammar extension by design; a text pass would need to locate code spans, fences, and escapes itself, and would change author text rather than parse it. + +**Continue the dollar flow after a failed close.** That is what a plain `multiline` flag produces: `$$x$$ trailing\n\n$$y$$` then scans to the second block's `$$` and renders one math node holding both lines' text, where upstream inline math renders `x`, the prose, and `y` separately. Existing behavior for `$$x$$ trailing` already pinned the fallback, so the flow declines the candidate instead. Declining on any `$` rather than a full `$$` sequence also broke formulas that use a solitary `$` inside `\text{}`. + +**End the attempt as soon as one line resists parsing.** Declining every failed same-line close swallows nothing but turns `$$a$b` lines into a quadratic scan and drops the solitary-`$` formulas above. The condition is the full `$$` sequence, not the character. + +**Claim the paragraph interruption in the check.** Returning success there, as upstream's flow does for the fences it accepts, prevents the lazy bookkeeping that broke the list case, but it also ends the paragraph for candidates that later fail (`**before\n$$a\$b\nafter**` split in two and exposed its `**`). Declining instead lets upstream's own check decide. + +**Apply the interruption guard to the backslash construct too.** The guard exists to protect the construct tried after this one; `\[`/`\(` have no second construct, and an unconditional guard dropped their multi-line blocks (`Prose line` followed by `\[`). + +## Consequences + +The three shapes now produce the same math node, so formulas stop truncating at the opening line and stop swallowing the prose after them. `$$` fence meta is no longer honored: `$$asciimath$$` parses as a formula whose value is `asciimath`, which upstream's flow treats as fence meta and drops. That spelling has no user in this repository, and the renderer never read `meta` anyway. + +Six inputs still reach upstream's flow or stay in the surrounding paragraph, because the compatibility flow declines them. Trailing text after a closing `$$` (`$$x$$ trailing`, or a CJK quote mark closing a line of prose) resolves through upstream inline math, as the existing `markdown.client.spec.tsx` case pins — including when a later `$$` block follows. A `$$` sequence the line does not end after declines the block: immediately on a later line, and at that line's ending on the opening line. A `$$` block left open runs to the next lone `$$` or to EOF exactly as it did before this change, because the upstream flow closes at EOF. + +The fourth is a multi-line block whose opening line carries a solitary `$` and that must interrupt a paragraph, with no blank line before it. Both flows decline the interruption there — this one by design, upstream because its fence meta stops at the `$` — so the block stays inside the paragraph and renders as inline math or not at all. Separating the block from the paragraph with a blank line makes this flow own it and render the display block. + +The fifth follows from the comment gate: a block whose only closing fence sits behind a comment that holds text keeps scanning instead of closing there, so when nothing else closes it the block reaches the upstream flow as the shapes above do. A fence behind a whitespace-only comment closes the block there, which leaves a following lone `$$` as its own empty block where the flow it replaced kept one node. + +The sixth is a multi-line block inside a container whose continuation line falls back to the container's indentation. `- Define:` followed by `$$f(x)=x` at the item's indentation and then a de-indented continuation line gives upstream's flow a fence meta and no value, so the reader sees an empty display block and the continuation as prose — the opening line is gone. Keeping the continuation at the same indentation, or moving the block out of the list, renders it normally. Both flow behaviors match the grammar this replaced; only the multi-line blocks this flow owns are new. + +One further limitation is not a fallback: the flow reads the six characters before a fence and does not parse verbatim regions, so a fence inside an unterminated region (`\verb|$$`) closes the block there, and a region whose text ends in `\verb` (`\verb**\verb*$$`) has its fence read as that text's delimiter. The six characters before either fence are locally identical, so no rule that reads only that tail separates them. + +Repeated unclosed candidates keep linear parser work: an open candidate declines at the next line that starts with a full `$$` it cannot close, rather than scanning each candidate to EOF, which would make 9.6 KB of `$$a$b` lines take ~4 s instead of ~16 ms. KaTeX-level failures in a formula's own source, such as `\arccot`, remain errors by design. + +## Testing + +`packages/client/ui-primitives/tests/markdown.client.spec.tsx` adds the multi-line case — opening-line math closed on a content line, closed by a lone fence, after a paragraph, inside a list item, and at column zero after a list item — and that case fails without the change. The same file also pins a multi-line formula that uses a solitary `$`, which like the case above renders only with this change. Further cases pin one condition each: a trailing-text candidate ahead of a later block (the flow must decline a full `$$` the line does not end after), a solitary `$` inside a single-line display formula (the flow must not decline on a single `$`), an escaped-dollar candidate that leaves its paragraph intact (the interruption check must not claim the paragraph), an unclosed `\[` candidate that leaves its paragraph intact (the guard must not apply to the backslash construct), a candidate ending in an odd backslash (the escape run must end at the line ending), a closing fence inside a TeX comment, a `%` the fence follows immediately or after whitespace, a second `%` inside an open comment, a `%` inside `\verb|…|`, the delimiter right after `\verb` and `\verb*` together with its empty region, an escaped backslash before `verb`, a verb tail that crosses no line ending, a comment ending in `verb`, a later-line trailing fence whose following prose upstream takes, a failed fence inside a comment, and bounded-work cases for repeated candidates with that backslash, without it, and ending in a verb tail. + +The DOM compatibility corpus adds `math-multiline-display` with its `.settled.txt` and `.streaming.txt` fixtures, which also pin that a column-zero `$$` after a list item stays outside the list item. `apps/web/tests/math-rendering.e2e.ts` renders the multi-line shape in the browser lane. diff --git a/.agents/notes/implemented/bug-fix/2026-09-17-multiline-display-dollar-math.zh.md b/.agents/notes/implemented/bug-fix/2026-09-17-multiline-display-dollar-math.zh.md new file mode 100644 index 0000000000..ed0ef3f46a --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-09-17-multiline-display-dollar-math.zh.md @@ -0,0 +1,66 @@ +# Agent Note: Multi-line display-dollar math + +Status: implemented + +[English](2026-09-17-multiline-display-dollar-math.md) | 中文 + +## Problem + +作者和模型写 `$$` 行间公式时有三种写法。定稿语法只渲染其中两种:一行内开闭的 `$$math$$`,以及 `$$` 独占一行、由独占一行的 `$$` 闭合。第三种——开围栏同一行就带公式内容,闭合 `$$` 落在最后一行公式行行尾——不渲染: + +```markdown +$$f(x)=x +\text{continued}$$ +``` + +`micromark-extension-math` 把开围栏 `$$` 之后的同一行文本当作围栏 meta,并且只接受内容恰为 `$$`(后接可选空白)的行作为闭合围栏。[markdown 兼容层](../../archived/feature/2026-07-23-web-assistant-markdown.md)只认同一行开闭这一种形状,而定稿渲染器把 `node.value` 交给 KaTeX,不读 `node.meta`。于是该块始终不闭合:它吞掉后续正文,`value` 里残留字面 `$$`,KaTeX 抛出 `Can't use function '$' in math mode`,[katex.tsx](../../../../packages/client/ui-primitives/src/markdown/katex.tsx) 的错误分支把 TeX 原文连同被吞掉的正文一起显示为红色。 + +在暴露该问题的会话中,一条聊天回复 55 行里有 34 行(第 22–55 行)变成同一个错误块,六个笔记文件从出错点起的所有行(最多约 500 行)同样变成一个错误块。同一机制还带出第二个缺陷:当更靠后的独占行 `$$` 恰好闭合了这个流时,开围栏那一行已经归给 `meta`,渲染器于是静默丢掉公式的第一行。 + +## Decision + +[mathCompatibility.ts](../../../../packages/client/ui-primitives/src/markdown/mathCompatibility.ts) 现在支持跨行的显示美元块。dollar 构造把开围栏同一行的文本留在 `mathFlowValue` 里而不是当作围栏 meta,并在后续任意内容行行尾的 `$$` 处闭合,于是 `$$a+b` 接 `c+d$$` 成为一个 math 节点。 + +闭合失败的 `$$` 序列会结束该候选:出现在后续行时立即结束;出现在开围栏行时在该行行尾结束(`$$x$$ trailing` 即在此放弃)。在开围栏行内扫描继续,因此同一行更靠后的 `$$` 仍能闭合(`$$\verb*a$$a$$`)。越过这两点继续扫描都会吞掉更靠后一个块的闭合围栏。孤立的一个 `$` 属于公式内容而不是失败的围栏,所以凡是本 flow 拥有该块的场合,用到它的公式——`$$P = \begin{cases} 1 & \text{if $x>0$} \end{cases}$$`——保持块打开并正常闭合。行尾反斜杠转义的是换行本身,不是下一行的第一个字符,因此转义状态在换行处结束,不会延续到下一个候选。未转义的 `%` 会开启延续到行尾的 TeX 注释:当注释在围栏之前还有文本时,块后续行行尾的 `$$` 不闭合任何东西;而围栏之前只有空白或什么都没有的 `%`(`95%$$`、`95 %$$`)不含注释文本,且在一行内开闭的块仍在该行的 `$$` 处闭合,与它跨行之前的行为一致。该状态及其对闭合判定的门控属于 dollar flow。`\verb` 或 `\verb*` 之后的那个字符是该命令的分隔符而不是围栏,因此该分隔符、以及区域为空时紧随其后的那个 `$` 都属于内容;这段尾部状态在换行处、以及在被转义的反斜杠之后清空(`\\verb` 是换行加字母,不是该命令),注释里也不识别命令。除此之外不解析原样文本区域,使含 `\verb` 的公式与其它输入走同一条 value 路径。 + +它的段落打断检查改为「不认领」,且只作用于 dollar 构造。在那里消耗换行会让段落跨行边界保持打开,document 层因此保留容器并把下一行对随后尝试的所有构造计为 lazy;这正是上游 `mathFlow`(本构造之后被尝试的构造)在列表项内以空块结束的原因。不认领同时把打断决定交给上游 flow:它自己的检查不消耗换行就短路返回,并且只认领它接受的围栏,因此两个构造都不接受的候选(`**before` 后接 `$$a\$b`)保持原段落。反斜杠构造没有竞争的上游构造,仍然照旧消耗换行。 + +[parse.ts](../../../../packages/client/ui-primitives/src/markdown/parse.ts) 把 `mathCompatibility()` 注册在 `math()` 之后:micromark 先派发最后注册的扩展,上面的 dollar flow 因此优先于上游 `mathFlow`,而行内 `$…$` 仍由 `math()` 负责。 + +## Alternatives considered + +**兼容层维持只处理同一行 `$$…$$`。** 多行写法是 GitHub、KaTeX auto-render、VS Code、Typora 都接受的形状,也是模型写多分支公式时实际采用的形状;不解析它意味着公式位置显示为红色错误块,并连带吞掉后文。 + +**解析后修补语法树。** 被吞掉的正文已经进入 `mathFlowValue`,与数学源码无法区分;`meta` 和 `value` 无法还原成原意的块,而且后面的块根本没有被解析。 + +**解析前用文本替换改写 `$$` 围栏。** 兼容层的设计就是语法扩展;文本替换需要自己定位代码 span、围栏和转义,而且改的是作者原文而不是解析它。 + +**闭合失败后继续扫描。** 这正是单纯打开 `multiline` 会得到的行为:`$$x$$ trailing\n\n$$y$$` 会一路扫到第二个块的 `$$`,渲染成一个同时含着两行文本的 math 节点;而上游行内数学会把 `x`、正文和 `y` 分开渲染。`$$x$$ trailing` 的既有行为已经固定了这条回退,因此该构造放弃这个候选。对任意一个 `$`(而不是完整的 `$$` 序列)就放弃,还会破坏在 `\text{}` 内使用单个 `$` 的公式。 + +**只要有一行解析不下去就结束尝试。** 对每次同一行闭合失败都放弃虽然不再吞文,却会让 `$$a$b` 这类行变成平方级扫描,并丢掉上面那些含单个 `$` 的公式。判据是完整的 `$$` 序列,不是那个字符。 + +**在检查阶段认领段落打断。** 像上游 flow 那样返回成功可以避免破坏列表用例的 lazy 记账,但也会让最终失败的候选提前结束段落(`**before\n$$a\$b\nafter**` 被拆成两段并暴露 `**`)。改为不认领,把决定交给上游自己的检查。 + +**把打断守卫也用在反斜杠构造上。** 这个守卫是为了保护随后被尝试的构造;`\[`/`\(` 没有第二个构造,无条件套用会丢掉它们的跨行块(`Prose line` 后接 `\[`)。 + +## Consequences + +三种写法现在产生同一个 math 节点,公式不再在开围栏那一行被截断,也不再吞掉其后的正文。`$$` 围栏 meta 不再生效:`$$asciimath$$` 会解析成 value 为 `asciimath` 的公式,而上游 flow 会把该段当作 fence meta 丢弃。这种写法在本仓库没有使用者,且渲染器本来就从不读 `meta`。 + +有六种输入仍会走到上游 flow,或留在所属段落里,因为兼容 flow 主动放弃它们。闭合 `$$` 之后还有文本时(`$$x$$ trailing`,或用中文引号收尾的散文行)走上游行内数学,这一点由 `markdown.client.spec.tsx` 中已有用例固定,后面还有别的 `$$` 块时同样如此。「后面不是行尾」的 `$$` 序列会让块放弃:出现在后续行时立即放弃,出现在开围栏行时在该行行尾放弃。未闭合的 `$$` 块仍像本次改动之前那样延伸到下一个独占行 `$$` 或文件结尾,因为上游 flow 在 EOF 处闭合。 + +第四种是「开围栏行含孤立 `$`、且需要打断段落、前面又没有空行」的多行块。两个 flow 都不认领这次打断——本 flow 出于设计,上游则因为它的围栏 meta 在 `$` 处即停止——该块因此留在段落里,渲染成行内数学或根本不渲染。用空行把块与段落分开,本 flow 就会拥有它并渲染 display。 + +第五种来自注释门控:当块唯一的闭合围栏位于「带文本的注释」之后时,块继续扫描而不是在那里闭合,若再没有别的围栏闭合它,就会像上面几种一样走到上游 flow。若围栏之前只有空白,则块在该处闭合,此后单独一行的 `$$` 会成为它自己的空块——而被替换掉的语法那时只给出一个节点。 + +第六种是容器内续行退回容器缩进的多行块。`- Define:` 之后以列表项缩进写 `$$f(x)=x`,再接一行不缩进的续行,上游 flow 会得到有 meta、无 value 的结果:读者看到一个空的 display 块加一行散文,公式首行无声消失。续行保持同样缩进,或把整块移到列表之外,即可正常渲染。这两种 flow 行为都与本次替换掉的语法一致;真正新增的只是「本 flow 拥有的多行块」。 + +另有一项不属于回退的限制:flow 只读取围栏之前的六个字符、不解析原样文本区域,因此未闭合区域内部的围栏(`\verb|$$`)会在那里闭合块,而内容以文本 `\verb` 结尾的区域(`\verb**\verb*$$`)其围栏会被读成该文本的分隔符。两种情况下围栏前的六个字符在局部完全相同,仅凭这段尾部无法区分。 + +重复的未闭合候选保持线性解析开销:未闭合的候选会在下一行遇到无法闭合的完整 `$$` 时放弃,而不是把每个候选都扫到 EOF——否则 9.6 KB 的 `$$a$b` 行会花约 4 秒而不是约 16 毫秒。公式自身源码层面的 KaTeX 失败,例如 `\arccot`,仍按设计报错。 + +## Testing + +`packages/client/ui-primitives/tests/markdown.client.spec.tsx` 新增多行用例——开围栏行内容由内容行行尾 `$$` 闭合、由独占行 `$$` 闭合、出现在段落之后、出现在列表项内,以及列表项之后的零缩进围栏——该用例去掉改动即失败。同一文件还固定了「使用单个 `$` 的多行公式」,它与上面的多行用例一样只有本次改动才会渲染成 display。另有一组用例各自对应一个条件:后面还有块时的尾随文本候选(flow 必须放弃「后面不是行尾」的完整 `$$`)、单行显示公式内的单个 `$`(不得因为一个 `$` 就放弃)、保持段落完整性的转义美元候选(打断检查不得认领段落)、保持段落完整性的未闭合 `\[` 候选(守卫不得作用于反斜杠构造)、以奇数个反斜杠结尾的候选(转义状态必须在换行处结束)、TeX 注释内的闭合围栏、紧邻围栏或只隔空白的 `%`、已开启注释内的第二个 `%`、`\verb|…|` 内的 `%`、`\verb` 与 `\verb*` 之后的分隔符及其空区域、`verb` 之前的转义反斜杠、不跨行的 verb 尾部、以 `verb` 结尾的注释、后续行尾随文本围栏(其后正文由上游接管)、注释内失败的围栏,以及带该反斜杠、不带该反斜杠、以 verb 尾部结尾三种重复候选的有界解析开销。 + +DOM 兼容语料新增 `math-multiline-display` 及其 `.settled.txt`、`.streaming.txt`,同时固定了「列表项之后的零缩进 `$$` 不进入列表项内」。`apps/web/tests/math-rendering.e2e.ts` 在浏览器 lane 渲染多行写法。 diff --git a/.agents/notes/implemented/feature/2026-06-15-ptc.i18n.yaml b/.agents/notes/implemented/feature/2026-06-15-ptc.i18n.yaml index e3bd9b93f5..9c1ddab7b7 100644 --- a/.agents/notes/implemented/feature/2026-06-15-ptc.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-06-15-ptc.i18n.yaml @@ -15,8 +15,8 @@ en: da694641e1d009de zh: 9d7492d39039af66 /agent-note-ptc-mode-the-model-writes-typescript-against-the-tool-registry/decision/the-registry-owns-the-mode: - en: 091b7bc49b027353 - zh: 42dc77a810202694 + en: 4cd76a34f4712e1a + zh: 303ef72a18644d7c /agent-note-ptc-mode-the-model-writes-typescript-against-the-tool-registry/decision/the-run-code-tool-and-the-dispatch-bridge: en: 6a0e2698b082978e zh: fbb03503520b30e9 diff --git a/.agents/notes/implemented/feature/2026-06-15-ptc.md b/.agents/notes/implemented/feature/2026-06-15-ptc.md index 97fc5b04db..e99eccd65b 100644 --- a/.agents/notes/implemented/feature/2026-06-15-ptc.md +++ b/.agents/notes/implemented/feature/2026-06-15-ptc.md @@ -40,7 +40,7 @@ The [Node execution decision](../architecture/2026-09-11-sandboxed-node-ptc-runt **SDK prompt section.** In `'ptc'`, the lazy `tools:sdk` section in the tool-guidance order band renders the loaded runtime's language declarations plus fixed usage instructions for the scope's visible capabilities (TypeScript by default; the [language-dispatch note](../../archived/feature/2026-07-31-ptc-language-dispatch.md) added Python and the `ctx.ptcRuntime.language` renderer table). It shares lookup and execution visibility, excludes `run_code`, and sorts tools lexicographically for byte-stable output. -**Assembly ownership.** `run_code` and `tools:sdk` enter the trusted `system-prompt/assemble` waterfall as normal assembly inputs. A scoped `tools:sdk` section may shadow the global default before dispatch, and a listener may remove or replace either contribution. The waterfall's returned assembly is final, so whoever changes these inputs owns preserving a viable PTC mode protocol when the deployment expects PTC mode to remain usable; no restoration pass overrides deliberate composition. +**Assembly ownership.** `tools:sdk` enters the trusted `system-prompt/assemble` presentation waterfall as a normal section, while `run_code` stays a provider-owned declaration that the waterfall cannot remove or replace. A scoped `tools:sdk` section may shadow the global default before dispatch, and a listener may remove or replace the section. Whoever changes it owns preserving a viable PTC mode protocol when the deployment expects PTC mode to remain usable; no restoration pass overrides deliberate composition. **Codegen.** `jsonSchemaToTs()` maps the `defineTool` JSON-Schema subset to TypeScript, carries schema descriptions into JSDoc, and degrades unsupported constructs to `unknown`. The SDK exposes tools as quoted object keys, supporting arbitrary names without aliases or collisions. Typing is advisory because the runtime strips types before execution. diff --git a/.agents/notes/implemented/feature/2026-06-15-ptc.zh.md b/.agents/notes/implemented/feature/2026-06-15-ptc.zh.md index 381448d354..71efafa7f2 100644 --- a/.agents/notes/implemented/feature/2026-06-15-ptc.zh.md +++ b/.agents/notes/implemented/feature/2026-06-15-ptc.zh.md @@ -40,7 +40,7 @@ Cloudflare 的 [Code Mode](https://blog.cloudflare.com/code-mode/) 提出了一 **SDK 提示词段。** 在 `'ptc'` 下,tool-guidance order band 中的惰性 `tools:sdk` 段为当前 scope 的可见能力渲染所加载运行时语言的声明加固定的使用说明(默认 TypeScript;[语言分发 note](../../archived/feature/2026-07-31-ptc-language-dispatch.md) 加入了 Python 与按 `ctx.ptcRuntime.language` 选择的渲染器表)。它共享查找和执行可见性,排除 `run_code`,并按字典序排列工具以获得字节稳定的输出。 -**组装所有权。** `run_code` 和 `tools:sdk` 作为正常的组装输入进入受信任的 `system-prompt/assemble` waterfall。一个 scoped 的 `tools:sdk` 段可以在分发前遮蔽全局默认值,监听器也可以移除或替换任一贡献。waterfall 返回的组装结果是最终的,因此修改这些输入的人有责任在部署期望 PTC mode 可用时保持协议面的完整性;没有恢复 pass 会覆盖有意的组合。 +**组装所有权。** `tools:sdk` 作为正常段进入受信任的 `system-prompt/assemble` 展示 waterfall,而 `run_code` 仍是提供方持有的声明,waterfall 不能移除或替换它。一个 scoped 的 `tools:sdk` 段可以在分发前遮蔽全局默认值,监听器也可以移除或替换该段。修改它的人有责任在部署期望 PTC mode 可用时保持协议面的完整性;没有恢复 pass 会覆盖有意的组合。 **代码生成。** `jsonSchemaToTs()` 将 `defineTool` 的 JSON Schema 子集映射为 TypeScript,将 schema 描述带入 JSDoc,不支持的构造降级为 `unknown`。SDK 将工具暴露为带引号的对象键,支持任意名称而无需别名或冲突处理。类型是建议性的,因为运行时在执行前会剥离类型。 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 639381d125..ca48f70356 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: 4e85280cc5eb8697 - zh: 8e6b09b1527784fa + en: 47010d626ab15117 + zh: a5b77385d7a3e781 /agent-note-parallel-tool-call-execution-by-per-call-safety/safety-contract: en: 16c4ee08b65ead23 zh: df80d6e49982615b @@ -21,11 +21,11 @@ en: e112957ed8a48039 zh: 37d63f9ea4968fb2 /agent-note-parallel-tool-call-execution-by-per-call-safety/verification: - en: eb5a6aa84980f0ab - zh: eed2680a160e8ed2 + en: 224b6838130c113f + zh: f3d7eb68ab5932fb /agent-note-parallel-tool-call-execution-by-per-call-safety/alternatives-considered: en: 17becc038bb7df7c zh: 673d9a9bdfa39edf /agent-note-parallel-tool-call-execution-by-per-call-safety/consequences: - en: a5508057bc03f4f6 - zh: 96b46c59402474c3 + en: 20a3e29931e01aa0 + zh: acbfc61dd710d960 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 40adc9f9aa..4eb497b1ff 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 @@ -26,7 +26,7 @@ A tagged mode, rather than a public boolean scheduler API, keeps resource-aware ## Scheduling and ordering -The loop waits for the complete assistant message, parses every call once, creates a distinct `ToolExecution` for each call, and scans them in model order. Consecutive parallel calls form one group; every exclusive call forms a singleton group and an ordering barrier. Groups execute sequentially. Classification is lazy: the scheduler resolves the next call after each barrier and reclassifies every later call before replenishing a parallel pool. If a registry mutation makes that call exclusive, the current pool drains before the call starts as the next barrier. +The loop waits for the complete assistant message, parses every call once, creates a distinct `ToolExecution` for each call, and scans them in model order. Consecutive parallel calls form one group; every exclusive call forms a singleton group and an ordering barrier. Groups execute sequentially. Classification is lazy: the scheduler resolves the next call after each barrier and reclassifies every later call before replenishing a parallel pool. Calls resolve through the step's admitted tool set, so replacing or removing a call's registration makes that unstarted call fail as `UNKNOWN_TOOL`; the scheduler classifies it as exclusive, and the current pool drains before it settles as the next barrier. For example: @@ -66,7 +66,7 @@ Filesystem read relies on a narrow recorder exception: its synchronous observati ## Verification -Unit coverage pins fail-closed classification, typed argument validation, grouping, barriers, live reclassification after registry replacement, the rolling cap, distinct execution objects, middleware order, ordered results and context, abort draining, and scheduler-failure quiescence. First-party tests pin each parallel declaration. +Unit coverage pins fail-closed classification, typed argument validation, grouping, barriers, rejection of unstarted calls after registry replacement, the rolling cap, distinct execution objects, middleware order, ordered results and context, abort draining, and scheduler-failure quiescence. First-party tests pin each parallel declaration. Snapshot coverage pins the visible multi-call transcript: pending calls may overlap while completed results remain model-ordered. PTC mode coverage pins its serial boundary. No provider-backed e2e is required because scheduling is deterministic loop behavior. @@ -102,6 +102,6 @@ Ordered commits may hold a fast result behind a slow earlier sibling. This prese Concurrent external calls can compete for quota or process capacity. Providers own their capacity controls; the loop cap only limits calls from one agent step. -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. +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. A call runs only the registration its step declared ([conditional tools](../../../../packages/core/tools/README.md)), so a hot reload, MCP reconnect, or skill reload that re-registers a tool mid-step fails that step's unstarted calls of it; the next step declares and runs the new registration. Already-started calls retain the scheduling decision under which they entered the pool. 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 536541370b..dbfa7cd684 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 @@ -26,7 +26,7 @@ Status: implemented ## 调度与顺序 -循环会等待完整的 assistant 消息,对每个调用只解析一次,为每个调用创建独立的 `ToolExecution`,再按模型顺序扫描。连续的并行调用组成一组;每个独占调用单独组成一组,并构成顺序屏障。各组按顺序执行。分类采用惰性方式:每经过一个屏障,调度器都会解析下一个调用;补充并行池之前,还会重新分类每个后续调用。如果注册表变更使该调用变为独占,当前池会先完全排空,然后该调用才作为下一个屏障启动。 +循环会等待完整的 assistant 消息,对每个调用只解析一次,为每个调用创建独立的 `ToolExecution`,再按模型顺序扫描。连续的并行调用组成一组;每个独占调用单独组成一组,并构成顺序屏障。各组按顺序执行。分类采用惰性方式:每经过一个屏障,调度器都会解析下一个调用;补充并行池之前,还会重新分类每个后续调用。调用通过本步骤的准入工具集合解析,因此替换或移除某个调用的注册后,该尚未开始的调用会以 `UNKNOWN_TOOL` 失败;调度器把它分类为独占,当前池会先完全排空,然后它才作为下一个屏障结束。 例如: @@ -66,7 +66,7 @@ Status: implemented ## 验证 -单元测试固定了按安全侧原则进行的分类、类型化参数验证、分组、屏障、注册表替换后的运行时重新分类、滚动上限、独立执行对象、中间件顺序、有序结果与上下文、中止排空,以及调度器故障后的完全停稳。第一方测试固定了每项并行声明。 +单元测试固定了按安全侧原则进行的分类、类型化参数验证、分组、屏障、注册表替换后拒绝尚未开始的调用、滚动上限、独立执行对象、中间件顺序、有序结果与上下文、中止排空,以及调度器故障后的完全停稳。第一方测试固定了每项并行声明。 快照测试固定了可见的多调用 transcript(文本记录):待处理调用可以重叠执行,已完成结果仍按模型顺序排列。PTC mode 测试固定了其串行边界。此调度属于确定性循环行为,因此无需依赖提供方的 e2e 测试。 @@ -102,6 +102,6 @@ Status: implemented 并发外部调用可能会争用配额或进程容量。提供方负责自身容量控制;循环上限只限制一个 agent 步骤中的调用数量。 -工具注册是调度边界。调度器会在每个屏障之后以及每次补充池之前重新分类,因此注册表变更会影响尚未启动的调用。已启动的调用保留它们进入池时所依据的调度决策。 +工具注册是调度边界。调度器会在每个屏障之后以及每次补充池之前重新分类,因此注册表变更会影响尚未启动的调用。调用只执行其步骤所声明的注册([条件工具](../../../../packages/core/tools/README.zh.md)),因此在步骤中途重新注册工具的热重载、MCP 重连或 skill 重载会让该步骤里这个工具尚未开始的调用失败;下一个步骤声明并执行新的注册。已启动的调用保留它们进入池时所依据的调度决策。 终态调度器故障只有在运行中的派发结算后才会进入步骤恢复。恢复将未提交的执行结果报告为未知,保留完全停稳与原始失败,同时让后续模型历史保持可用。 diff --git a/.agents/notes/implemented/feature/2026-08-26-generic-file-upload.i18n.yaml b/.agents/notes/implemented/feature/2026-08-26-generic-file-upload.i18n.yaml index 496d2e8136..98789701f0 100644 --- a/.agents/notes/implemented/feature/2026-08-26-generic-file-upload.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-26-generic-file-upload.i18n.yaml @@ -9,8 +9,8 @@ en: e7c3b5d7702714fe zh: 268f162b553b37d1 /agent-note-generic-file-upload/decision: - en: 31c7ad96c6ab82c8 - zh: 806922fc8fcd1242 + en: 1769950d73d30dca + zh: bf296b920ea9b997 /agent-note-generic-file-upload/alternatives-considered: en: 23f386b05e4e79ef zh: 0756c4fa30e8d0eb @@ -21,5 +21,5 @@ en: 3a50ac370d51cda9 zh: d490f58922f8795b /agent-note-generic-file-upload/related: - en: 7579c49eefdb8488 - zh: 68ad384fd9d2f8f9 + en: 14e8659b89dc08a8 + zh: dec5baa11f168e91 diff --git a/.agents/notes/implemented/feature/2026-08-26-generic-file-upload.md b/.agents/notes/implemented/feature/2026-08-26-generic-file-upload.md index f60649a083..133312a83c 100644 --- a/.agents/notes/implemented/feature/2026-08-26-generic-file-upload.md +++ b/.agents/notes/implemented/feature/2026-08-26-generic-file-upload.md @@ -14,7 +14,7 @@ The command plane had already established a whole-envelope rule for images: a co Files and images use separate storage schemes and one ordered message attachment list. Images keep their normalized-attachment pipeline unchanged. Other picked files enter one FIFO background-upload queue and are stored byte-for-byte with no type or size limits, then staged in-process for the same Session. Equal bytes have one canonical object under `DSH_HOME/attachments/v1/file-objects//`; each read-only model-facing path at `DSH_HOME/attachments/v1/files///` is a hard link, so different display names do not duplicate content. The configurable queue defaults to two active transfers. The independent dual-face `file-upload` Cordis package owns transfer and staging. Its Client `fileUpload` service accepts the owning Session identity together with a `Blob`, exact bytes, or a one-shot `ReadableStream`; it uses that identity to assemble the raw route request or invoke the generated Remote directly. Its Host `fileUploads` service owns the streaming route, encoded Remote fallback, command receipt resolver, and receipt tables keyed by the receiving Agent's Session object. Encoded admission and attachment-error recognition go through `ctx.attachments`, whose provider owns byte storage. Session Controller registers the resolver for cold ordinary Agents and consumes receipts during prompt admission; Session objects do not expose file transfer. Served Web pages hand each active body to a dedicated upload Worker: Blob bodies use XMLHttpRequest for total-aware byte progress, while streams transfer ownership and feed Fetch incrementally. Worker-hosted pages transfer the body through their page-owned Fetch carrier to the Host Worker, which reads bounded chunks. The page thread never reads, base64-encodes, or JSON-serializes generic file bytes. The authenticated Host route opts into Connection's streaming request mode, so the bridge dispatches before consuming the body and the local store hashes and writes bounded chunks to a private staging object. Completion atomically publishes the content-addressed object; cancellation or failure removes the staging file. Upload operations and draft state belong to the resident conversation root, so navigation to another Session does not stop the transfer; returning to the original Session shows the same card and progress. Removing the file card skips its queued transfer or aborts its active transfer. The sent message carries a structured `FileBlock` (`{ name, bytes, attachmentId }`) inside `user/message`; no new session event type exists. Request assembly in `LlmRuntime` projects every file block, including nested tool-result occurrences, to one deterministic handle naming the file and its saved read-only path. The handle tells a main agent to include that path when delegating file work and to report when the child's execution environment cannot see it. No provider receives file bytes; the model reads the stored copy with existing file tools on demand. -Default mode, active Plan Mode, and an active goal share ordinary queue, follow-up, and steer attachment admission. `/goal` create and edit and `/plan` entry accept the same ordered image and file batches; attachment-bearing control subcommands reject before changing state. Command submissions cite completed staged-file receipts, so generic file bytes are not read or copied again. Session Controller resolves every file receipt to a durable reference before `ctx.attachments.admitPromptContent()` receives the complete ordered content list; attachment admission persists images and passes file references unchanged. The Host rejects upload completion after the original Agent is disposed and deduplicates prompt retries already identified by `rpcId`. Prompt receipts remain available through failure recovery, then retire after the accepted queue or history occurrence is observed; command-only receipts remain until Session disposal. Submission echoes register through `beginSubmission` before serialization, carry mixed attachments through `pendingSubmissions`, correlate by `rpcId`, retire once after queue or history observation, and keep drafts on failure. Before submission, the composer presents the ordered batch in one non-wrapping horizontal rail: images use 64 by 64 pixel thumbnails and generic files use 240 by 64 pixel cards with a 16 pixel radius, blue document glyph, filename plus extension and size, and a hover or keyboard removal action. QueueDock uses the same order with compact thumbnails and file cards. After submission, Chat presents the ordered batch in one right-aligned wrapping attachment flow. A lone image retains the large gallery presentation; a message with multiple attachments uses 64 by 64 pixel image tiles and 240 by 64 pixel file cards, so files and images share each row until the available width forces a wrap. Trajectory keeps the user-message preview compact and adds a localized generic-file count. Paperclip, drag, and paste classify by MIME; send waits for every file upload. Continuable subagent composers disable attachment intake, and the Host rejects any file block received for a child session. +Default mode, active Plan Mode, and an active goal share ordinary queue, follow-up, and steer attachment admission. `/goal` create and edit and `/plan` entry accept the same ordered image and file batches; attachment-bearing control subcommands reject before changing state. Command submissions cite completed staged-file receipts, so generic file bytes are not read or copied again. Session Controller resolves every file receipt to a durable reference before `ctx.attachments.admitPromptContent()` receives the complete ordered content list; attachment admission persists images and passes file references unchanged. The Host rejects upload completion after the original Agent is disposed and deduplicates prompt retries already identified by `rpcId`. Prompt receipts remain available through failure recovery, then retire after the accepted queue or history occurrence is observed; command-only receipts remain until Session disposal. Submission echoes register through `beginSubmission` before serialization, carry mixed attachments through `pendingSubmissions`, correlate by `rpcId`, retire once after queue or history observation, and keep drafts on failure. Before submission, the composer presents the ordered batch in one non-wrapping horizontal rail: images use 64 by 64 pixel thumbnails and generic files use 240 by 64 pixel cards with a 16 pixel radius, blue document glyph, filename plus extension and size, and a hover or keyboard removal action. QueueDock uses the same order with compact thumbnails and file cards. After submission, Chat presents the ordered batch in one right-aligned wrapping attachment flow. A lone image retains the large gallery presentation; a message with multiple attachments uses 64 by 64 pixel image tiles and 240 by 64 pixel file cards, so files and images share each row until the available width forces a wrap. Trajectory keeps the user-message preview compact and adds a localized generic-file count. Paperclip, drag, and paste classify by MIME; send waits for every upload. Continuable subagent composers disable attachment intake, and the Host rejects any file block received for a child session. ## Alternatives considered @@ -54,3 +54,5 @@ Unit and integration coverage pins verbatim and streamed storage, name sanitizat The [Trajectory reference](../../../../packages/client/ui-trajectory/README.md) documents ledger counts and inspector attachment layouts. The [input echo admission reference](../../../../packages/api/session-controller/README.md) owns placement-specific retirement and delayed Inbox suppression for submissions with or without attachments. + +The [composer image upload on add](2026-10-21-composer-image-upload-on-add.md) note extends this background-upload queue to the images of an ordinary Session's draft. diff --git a/.agents/notes/implemented/feature/2026-08-26-generic-file-upload.zh.md b/.agents/notes/implemented/feature/2026-08-26-generic-file-upload.zh.md index 3d1667d181..dfc48509c5 100644 --- a/.agents/notes/implemented/feature/2026-08-26-generic-file-upload.zh.md +++ b/.agents/notes/implemented/feature/2026-08-26-generic-file-upload.zh.md @@ -14,7 +14,7 @@ Status: implemented 文件与图片使用不同存储方案,并进入同一个有序消息附件列表。图片的规范化附件流水线保持不变。其他被选中的文件进入同一个先进先出的后台上传队列,按字节原样保存且不设类型与大小限制,再在本进程内按同一 Session 暂存。相同字节在 `DSH_HOME/attachments/v1/file-objects//` 下只有一个规范对象;每条模型可见的只读路径 `DSH_HOME/attachments/v1/files///` 都是硬链接,因此不同显示名称不会复制内容。该队列可以配置,默认同时运行两项传输。独立的双端 `file-upload` Cordis 包拥有传输与暂存。其 Client `fileUpload` 服务同时接收所属 Session 标识与 `Blob`、精确字节或只能使用一次的 `ReadableStream`;它使用该标识组装原始路由请求,或直接调用生成的 Remote。其 Host `fileUploads` 服务拥有流式路由、编码 Remote 兜底、命令凭证解析器,以及以接收方 Agent 的 Session 对象为键的凭证表。编码准入和附件错误识别通过 `ctx.attachments` 完成,字节存储由附件提供方拥有。Session Controller 注册用于休眠普通 Agent 的解析器,并在 prompt 准入时消费凭证;Session 对象不公开文件传输。普通 Web 页面把每个活动请求体交给专用上传 Worker:Blob 请求体通过 XMLHttpRequest 发送并报告包含总量的字节进度,stream 则转移所有权并增量传入 Fetch。Worker-hosted 页面通过页面自有 Fetch 载体把请求体转交给 Host Worker,后者读取有界分片。页面线程不读取通用文件字节,也不做 base64 编码或 JSON 序列化。经过认证的 Host 路由显式使用 Connection 的流式请求模式,因此 bridge 会在消费请求体前分发,本地存储则把有界分片写入私有暂存对象并同时计算摘要。接收完成后,内容寻址对象以原子方式发布;取消或失败会移除暂存文件。上传任务和草稿状态属于常驻的 conversation 根,因此切到另一条 Session 不会停止传输,返回原 Session 时仍显示同一张文件卡和当前进度。移除文件卡会跳过排队中的传输或中止正在运行的传输。发送的消息在 `user/message` 里携带结构化的 `FileBlock`(`{ name, bytes, attachmentId }`),不新增会话事件类型。`LlmRuntime` 的请求组装把每个文件块,包括嵌套工具结果中的文件块,投影成确定性 handle 文本,指出文件与其只读保存路径。handle 会要求主 agent 委派文件任务时附上该路径,并在子级执行环境看不到路径时明确报告。提供方不会收到文件字节,模型在需要时用现有文件工具读取存储副本。 -默认模式、激活的 Plan Mode 与 active goal 共用普通 queue、follow-up 和 steer 的附件准入。`/goal` create 和 edit 以及 `/plan` 进入接受同一套有序图片与文件批量;携带附件的控制子命令会在状态变更前拒绝。命令提交引用已完成暂存的文件凭证,不会再次读取或复制通用文件字节。Session Controller 先把每个文件凭证解析为持久引用,再由 `ctx.attachments.admitPromptContent()` 接收完整有序内容列表;附件准入会持久化图片,并让文件引用原样通过。原 Agent 已销毁时,Host 会拒绝发布上传完成凭证,并按 `rpcId` 去重已经存在的 prompt 重试。prompt 凭证在失败恢复期间保持可用,已接受的 queue 或历史 occurrence 被观察后才退休;只被命令使用的凭证保留到 Session 销毁。提交回显在序列化前经 `beginSubmission` 注册,通过 `pendingSubmissions` 携带混合附件,以 `rpcId` 关联,在 queue 或历史观察后只退休一次,并在失败时保留草稿。发送前,composer 把有序附件放进同一条不换行的横向附件栏:图片使用 64 乘 64 像素缩略图,通用文件使用 240 乘 64 像素卡片,带 16 像素圆角、蓝色文档图标、文件名加扩展名与大小,以及悬停或键盘操作时可用的移除按钮。QueueDock 使用相同顺序以及紧凑的缩略图和文件卡。发送后,Chat 把有序附件放进同一个右对齐、可换行的附件区域。只有一张图片时保留图片画廊的大图样式;一条消息含有多个附件时,图片使用 64 乘 64 像素缩略图,文件使用 240 乘 64 像素卡片,文件与图片在可用宽度内共用一行,空间不足时再换行。Trajectory 保持紧凑的用户消息预览,并增加本地化的通用文件数量。回形针、拖拽和粘贴按 MIME 分类;发送会等待每个文件上传完成。continuable 子代理 composer 禁用附件入口,Host 会拒绝收到的任何子会话文件块。 +默认模式、激活的 Plan Mode 与 active goal 共用普通 queue、follow-up 和 steer 的附件准入。`/goal` create 和 edit 以及 `/plan` 进入接受同一套有序图片与文件批量;携带附件的控制子命令会在状态变更前拒绝。命令提交引用已完成暂存的文件凭证,不会再次读取或复制通用文件字节。Session Controller 先把每个文件凭证解析为持久引用,再由 `ctx.attachments.admitPromptContent()` 接收完整有序内容列表;附件准入会持久化图片,并让文件引用原样通过。原 Agent 已销毁时,Host 会拒绝发布上传完成凭证,并按 `rpcId` 去重已经存在的 prompt 重试。prompt 凭证在失败恢复期间保持可用,已接受的 queue 或历史 occurrence 被观察后才退休;只被命令使用的凭证保留到 Session 销毁。提交回显在序列化前经 `beginSubmission` 注册,通过 `pendingSubmissions` 携带混合附件,以 `rpcId` 关联,在 queue 或历史观察后只退休一次,并在失败时保留草稿。发送前,composer 把有序附件放进同一条不换行的横向附件栏:图片使用 64 乘 64 像素缩略图,通用文件使用 240 乘 64 像素卡片,带 16 像素圆角、蓝色文档图标、文件名加扩展名与大小,以及悬停或键盘操作时可用的移除按钮。QueueDock 使用相同顺序以及紧凑的缩略图和文件卡。发送后,Chat 把有序附件放进同一个右对齐、可换行的附件区域。只有一张图片时保留图片画廊的大图样式;一条消息含有多个附件时,图片使用 64 乘 64 像素缩略图,文件使用 240 乘 64 像素卡片,文件与图片在可用宽度内共用一行,空间不足时再换行。Trajectory 保持紧凑的用户消息预览,并增加本地化的通用文件数量。回形针、拖拽和粘贴按 MIME 分类;发送会等待每项上传完成。continuable 子代理 composer 禁用附件入口,Host 会拒绝收到的任何子会话文件块。 ## 考虑过的替代方案 @@ -54,3 +54,5 @@ Status: implemented [Trajectory 参考](../../../../packages/client/ui-trajectory/README.zh.md)说明列表计数与详情面板附件布局。 [输入回显入档参考](../../../../packages/api/session-controller/README.zh.md)负责有无附件提交的按展示归属回收和迟到 Inbox 排除。 + +[输入框在加入时上传图片](2026-10-21-composer-image-upload-on-add.zh.md) 记录把这条后台上传队列扩展到普通 Session 草稿里的图片。 diff --git a/.agents/notes/implemented/feature/2026-08-26-web-rich-text-quotes.i18n.yaml b/.agents/notes/implemented/feature/2026-08-26-web-rich-text-quotes.i18n.yaml new file mode 100644 index 0000000000..efb7b7a757 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-26-web-rich-text-quotes.i18n.yaml @@ -0,0 +1,22 @@ +# Bilingual-pair consistency record for 2026-08-26-web-rich-text-quotes.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/feature/2026-08-26-web-rich-text-quotes.md +/agent-note-durable-rich-text-quotes-in-the-web-composer: + en: 49522a0a81db6981 + zh: 92e81872377e96e9 +/agent-note-durable-rich-text-quotes-in-the-web-composer/problem: + en: 7bd295c086ad9905 + zh: 704dc665acfaa5ec +/agent-note-durable-rich-text-quotes-in-the-web-composer/decision: + en: d27134e46ab55ea0 + zh: 3f1da063b661a1b3 +/agent-note-durable-rich-text-quotes-in-the-web-composer/alternatives-considered: + en: 1999da9f900c9be3 + zh: cf6aad990a180851 +/agent-note-durable-rich-text-quotes-in-the-web-composer/verification: + en: b320c262d9c08ee4 + zh: 6047f864213a2b4c +/agent-note-durable-rich-text-quotes-in-the-web-composer/consequences: + en: bb6f4338aad0ec41 + zh: 93a4396b619492b1 diff --git a/.agents/notes/implemented/feature/2026-08-26-web-rich-text-quotes.md b/.agents/notes/implemented/feature/2026-08-26-web-rich-text-quotes.md new file mode 100644 index 0000000000..c122d6c671 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-26-web-rich-text-quotes.md @@ -0,0 +1,37 @@ +# Agent Note: Durable rich-text quotes in the Web composer + +Status: implemented + +English | [中文](2026-08-26-web-rich-text-quotes.zh.md) + +## Problem + +Selected transcript excerpts need atomic editing and reload-safe drafts without depending on the availability of their source messages. + +## Decision + +`ui-conversation` owns an inline `QuoteNode` beside reference nodes in the existing editor runtime. Its clipboard projection is canonical Markdown, while its detect projection is one atomic character. The existing semantic `DraftSnapshot` persists quote text and clipboard-coordinate spans beside references; raw Lexical JSON and runtime node identities remain outside browser storage. Session switching, Workspace transfer, and failed-send restoration use this same snapshot. Quote controls receive runtime-only locale labels. + +`ui-chat` accepts one nonblank browser selection within displayed user content, including pending steering and local submission echoes or finalized Assistant text and reasoning. Streaming and interrupted Assistant output, tools, injected context, and Markdown controls are excluded. Selection extraction follows visible line and block breaks and inserts through the current Session input actions. + +Text interaction belongs to the shared primitive and the Chat-owned list Slot. Quote registers only gesture admission and insertion. Local absolute placement keeps actions in the content coordinate system; outside presses and Escape close the same area-owned interaction for every consumer. Dismissal and selection clearing are separate operations so a failed insertion preserves the excerpt. Context-menu consumers cancel their originating browser event synchronously; rendering a Slot cannot retroactively cancel it. + +The [text-derived blockquote decision](../simplification/2026-10-13-text-derived-message-quotes.md) owns sent-message recognition and presentation. The [UserText evaluator](../architecture/2026-10-10-user-text-json-nodes.md) renders derived quote nodes through the existing matcher registry. + +Quotes display a compact single-line preview; clipped excerpts expose the complete text in a shared inline HoverCard. Their text has no source-navigation action. The editable draft alone exposes whole-node removal. Only selected text survives message persistence: no source Session, message, or selection coordinates are retained. + +## Alternatives considered + +**Persist Lexical JSON or introduce another draft format.** Editor serialization would become a product storage obligation, while a second draft format would duplicate restoration and transfer ownership. The existing semantic snapshot already carries reference spans and extends to quotes. + +**Retain source navigation.** Availability checks would require source coordinates, subscriptions, history pagination, focus routing, and failure states. A future navigation feature must define those durable coordinates and target-owned actions before adding them. + +**Store UserText nodes or callbacks.** Text nodes are derived presentation, and callbacks are runtime state. Both can change independently of the logged input and semantic draft. + +## Verification + +Composer tests cover atomic removal and undo, semantic storage round trips, Markdown serialization, and failed-send restoration. UserText tests cover text recognition, literal excerpt rendering, literal references, and credential spelling. The keyless Web scenario exercises selection, clipped previews, draft switching and reload, persistence, and ordinary Markdown submission through the shipped composition. + +## Consequences + +The draft and Session retain enough data for atomic editing and replay without retaining source-history dependencies. UserText remains a display projection; its JSON nodes never replace authoritative content. Browser-local storage retains unsent selected text under the existing draft policy. diff --git a/.agents/notes/implemented/feature/2026-08-26-web-rich-text-quotes.zh.md b/.agents/notes/implemented/feature/2026-08-26-web-rich-text-quotes.zh.md new file mode 100644 index 0000000000..7f324768e1 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-26-web-rich-text-quotes.zh.md @@ -0,0 +1,37 @@ +# Agent Note:Web composer 中的持久富文本 quote + +Status: implemented + +[English](2026-08-26-web-rich-text-quotes.md) | 中文 + +## 问题 + +所选 transcript 摘录需要原子编辑与可跨刷新恢复的草稿,不依赖来源消息的可用性。 + +## 决策 + +`ui-conversation` 在现有编辑器 runtime 中于 reference 节点旁拥有行内 `QuoteNode`。其剪贴板投影是规范 Markdown,检测投影则是一个原子字符。现有语义 `DraftSnapshot` 在 reference 旁持久化 quote 文本与剪贴板坐标范围;原始 Lexical JSON 与 runtime 节点身份不进入浏览器存储。Session 切换、Workspace 转移和发送失败恢复使用同一个 snapshot。Quote 控件接收仅用于 runtime 的 locale 标签。 + +`ui-chat` 接受已展示的用户内容(包括待接纳 steering 和本地发送回显)、或最终完成的 Assistant 文本与 reasoning 中的一个非空浏览器选区。流式及中断的 Assistant 输出、tool、注入上下文和 Markdown 控件被排除。选区提取遵循可见的行与 block 换行,通过当前 Session 的 input actions 插入。 + +文本交互属于共享基础能力与 Chat 拥有的 list Slot,quote 只注册手势接管与插入操作。本地绝对定位让操作处于内容坐标系;外部按下与 Escape 对所有使用方关闭同一个区域拥有的交互。关闭展示与清除选区是独立操作,因此插入失败时保留摘录。右键菜单使用方同步取消来源浏览器事件;渲染 Slot 无法事后取消事件。 + +[文本派生 blockquote 决策](../simplification/2026-10-13-text-derived-message-quotes.zh.md)拥有发送后的消息识别与展示。[UserText 计算器](../architecture/2026-10-10-user-text-json-nodes.zh.md)通过现有 matcher registry 渲染派生的 quote 节点。 + +Quote 显示紧凑的单行预览;截断的摘录通过共享行内 HoverCard 展示完整文本。文本不提供来源导航操作,只有可编辑草稿提供整节点删除。消息持久化只保留所选文本:不保留来源 Session、消息或选区坐标。 + +## 考虑过的替代方案 + +**持久化 Lexical JSON 或引入另一套草稿格式。** 编辑器序列化会成为产品存储义务,第二套草稿格式则会重复拥有恢复与转移逻辑。现有语义 snapshot 已携带 reference 范围,也可扩展到 quote。 + +**保留来源导航。** 可用性检查需要来源坐标、订阅、历史分页、焦点路由和失败状态。未来若添加导航功能,必须先定义其持久坐标与目标拥有的操作。 + +**存储 UserText 节点或 callback。** 文本节点是派生的展示,callback 是 runtime 状态;二者都可以独立于记录的输入和语义草稿变化。 + +## 验证 + +Composer 测试覆盖原子删除与撤销、语义存储往返、Markdown 序列化和发送失败恢复。UserText 测试覆盖文本识别、字面摘录展示、字面 reference 与凭据原文。无密钥 Web 场景通过已发布组合验证选区、截断预览、草稿切换与刷新、持久化和普通 Markdown 提交。 + +## 后果 + +草稿与 Session 保留足以支持原子编辑和 replay 的数据,不保留对来源历史的依赖。UserText 仍是展示投影;其 JSON 节点不会替代权威 content。浏览器本地存储按现有草稿策略保留未发送的所选文本。 diff --git a/.agents/notes/implemented/feature/2026-10-21-composer-image-upload-on-add.i18n.yaml b/.agents/notes/implemented/feature/2026-10-21-composer-image-upload-on-add.i18n.yaml new file mode 100644 index 0000000000..3d86d265aa --- /dev/null +++ b/.agents/notes/implemented/feature/2026-10-21-composer-image-upload-on-add.i18n.yaml @@ -0,0 +1,25 @@ +# Bilingual-pair consistency record for 2026-10-21-composer-image-upload-on-add.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/feature/2026-10-21-composer-image-upload-on-add.md +/agent-note-composer-image-upload-on-add: + en: 996b39b53086b9ac + zh: c81fade50ac895ec +/agent-note-composer-image-upload-on-add/problem: + en: b184ade2bd353f50 + zh: 00b97c91df54b310 +/agent-note-composer-image-upload-on-add/decision: + en: eada67e3c3d50f47 + zh: 5fb4f3a5e774e47e +/agent-note-composer-image-upload-on-add/alternatives-considered: + en: afa82b9bce9015f6 + zh: d088b92c2cb6c622 +/agent-note-composer-image-upload-on-add/consequences: + en: 08d1bcb970beb40e + zh: f42db4052618c289 +/agent-note-composer-image-upload-on-add/testing: + en: f2d2c59272ffcf70 + zh: 46c933fd2aeb87ea +/agent-note-composer-image-upload-on-add/related: + en: 6eac98f60c3b99f9 + zh: c1e318e74f44fbb4 diff --git a/.agents/notes/implemented/feature/2026-10-21-composer-image-upload-on-add.md b/.agents/notes/implemented/feature/2026-10-21-composer-image-upload-on-add.md new file mode 100644 index 0000000000..ae8c509ac7 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-10-21-composer-image-upload-on-add.md @@ -0,0 +1,36 @@ +# Agent Note: Composer image upload on add + +Status: implemented + +English | [中文](2026-10-21-composer-image-upload-on-add.zh.md) + +## Problem + +The Web composer kept image drafts in the browser and base64-encoded each image into `session/prompt` at send time, so the Host received, validated, normalized, and stored image bytes inside prompt admission. Generic files already uploaded when picked ([generic file upload](2026-08-26-generic-file-upload.md)). An image message therefore carried up to the per-message image byte limit in one RPC body, and its send time grew with image size; a composer that waits for the Host to accept a message before clearing it would hold the draft for that whole transfer. + +## Decision + +An ordinary Session's composer uploads each image draft as soon as it is added, through the same FIFO background-upload queue, Worker carriers, progress, retry, and cancellation that generic files use. The raw upload route takes a `mediaType` query parameter for an image upload; the Host reads at most the smaller of the deployment's per-image and per-message image byte limits, then validates, normalizes, and stores the image with `ctx.attachments.saveImages` exactly as prompt admission would, and stages the normalized image under a receipt for that Session. The prompt cites the receipt in an ordinary file part, so `session/prompt` keeps its wire types; prompt admission resolves the receipt to the stored image, applies the model's image-input check to the prompt, and counts stored images toward `maxImagesPerMessage` and `maxMessageImageBytes` together with encoded ones, each stored image by the bytes of its upload. Send waits until every image draft has a receipt, and a failed image upload turns its thumbnail into a retry control. Commands accept only file receipts, so command submissions keep encoding images inline. A continuable subagent's composer keeps inline images because the Host stages uploads only for ordinary Sessions; SDK and other wire callers may still send encoded images. + +## Alternatives considered + +- **A dedicated image-receipt prompt part.** Rejected: the receipt already names what was staged, so a second part kind would widen the `session/prompt` wire union, its generated catalogs, and every consumer that switches on parts without changing what the Host admits. +- **A separate image upload route or Remote method.** Rejected: one route with an explicit `mediaType` parameter reuses Session addressing, carrier selection, progress, and the failure envelope; the exact-bytes Remote fallback has no image caller. +- **Upload images but normalize them at send.** Rejected: normalization is the expensive part of image admission, so deferring it would keep the send-time cost this change removes. + +## Consequences + +- Sending an image message transfers only references, and the normalization cost moves to the moment the image is added. +- Per-image refusals such as an undecodable or oversized image surface when the image is added: the thumbnail names the refusal instead of offering a retry, and Enter repeats it. The model's image-input check and the per-message image count and byte limits still apply at send. +- Commands accept only file receipts, so a command submission that carries an image waits for the image upload like a message and then encodes the image inline: the image is stored a second time, and its unused receipt lasts until its Session ends. +- Removing an image while it uploads cancels the upload, and a cancelled upload receives no receipt; an image whose storage had already begun, or whose upload had finished, stays under `DSH_HOME`, which never deletes attachments. On a remote Host the bytes reach the server as soon as the user adds the image. +- Staged image receipts share the generic files' in-memory lifetime: a Host restart between adding and sending surfaces `FILE_NOT_STAGED`, and the user adds the image again. +- Image drafts, like file drafts, are not restored after a page reload or after their Session's client scope is released. + +## Testing + +Host coverage pins image staging through the raw route, the per-image byte bound that stops reading, unsupported types, subagent refusal, command refusal of image receipts, receipt resolution into the admitted image, the image-input check for an uploaded image, the per-message byte limit across receipts, and withheld receipts for uploads cancelled after their last chunk or during storage. Attachment admission coverage pins stored-image pass-through and the combined image count and byte limits. Client coverage pins image upload start, retry, cancellation, the send gate, receipt citation without re-encoding, inline images for drafts without an upload, the rail's progress, retry, and refusal states, and the Enter explanation of a held send. + +## Related + +The [generic file upload](2026-08-26-generic-file-upload.md) note owns the background-upload queue, carriers, staging lifetime, and file storage that image uploads reuse. diff --git a/.agents/notes/implemented/feature/2026-10-21-composer-image-upload-on-add.zh.md b/.agents/notes/implemented/feature/2026-10-21-composer-image-upload-on-add.zh.md new file mode 100644 index 0000000000..bfa887c85b --- /dev/null +++ b/.agents/notes/implemented/feature/2026-10-21-composer-image-upload-on-add.zh.md @@ -0,0 +1,36 @@ +# Agent Note: 输入框在加入时上传图片 + +Status: implemented + +[English](2026-10-21-composer-image-upload-on-add.md) | 中文 + +## 问题 + +Web 输入框把图片草稿留在浏览器里,发送时才把每张图片 base64 编码进 `session/prompt`,Host 在 prompt 准入时才接收、校验、规整并存储图片字节。通用文件早已在选中时上传([通用文件上传](2026-08-26-generic-file-upload.zh.md))。因此一条图片消息要在一个 RPC 请求体里携带最多达到单条消息图片字节上限的数据,发送耗时随图片大小增长;如果输入框要等 Host 接纳消息后才清空,草稿就要在整个传输期间一直保留。 + +## 决定 + +普通 Session 的输入框在图片草稿加入时就上传它,复用通用文件的先进先出后台上传队列、Worker 载体、进度、重试与取消。原始上传路由用 `mediaType` 查询参数表示图片上传;Host 最多读取部署配置的单张图片与单条消息图片字节上限中较小的一个,然后按 prompt 准入的同样方式用 `ctx.attachments.saveImages` 校验、规整并存储图片,再以该 Session 的凭证暂存规整后的图片。prompt 在普通文件部分引用该凭证,因此 `session/prompt` 的线上类型不变;prompt 准入把凭证解析为已存储的图片,对这条 prompt 执行模型的图片输入检查,并把已存储图片与编码图片一起计入 `maxImagesPerMessage` 和 `maxMessageImageBytes`,已存储图片按其上传的字节数计入。发送会等到每个图片草稿都取得凭证;图片上传失败时,缩略图变成重试控件。命令只接受文件凭证,所以命令提交仍以内联方式编码图片。continuable 子代理的输入框继续内联图片,因为 Host 只为普通 Session 暂存上传;SDK 和其他线上调用方仍可发送编码图片。 + +## 考虑过的替代方案 + +- **专用的图片凭证 prompt 部分。** 已否决:凭证本身已经指明暂存的内容,新增一种部分类型会扩大 `session/prompt` 线上联合类型、它的生成目录以及每个按部分类型分支的消费方,却不改变 Host 准入的内容。 +- **单独的图片上传路由或 Remote 方法。** 已否决:一条带显式 `mediaType` 参数的路由复用了 Session 寻址、载体选择、进度和失败信封;精确字节的 Remote 兜底没有图片调用方。 +- **加入时上传、发送时再规整。** 已否决:规整是图片准入中开销最大的部分,推迟它会保留这次改动要消除的发送耗时。 + +## 后果 + +- 发送图片消息只传引用,规整开销移到图片加入的时刻。 +- 无法解码、超过单张上限等逐张拒绝在图片加入时出现:缩略图说明拒绝原因而不提供重试,按 Enter 也会提示同样的原因。模型的图片输入检查以及单条消息的图片数量与总字节上限仍在发送时执行。 +- 命令只接受文件凭证,所以带图片的命令提交也要像消息一样等图片上传完成,再以内联方式编码图片:同一张图会再存储一次,未被引用的凭证一直留到 Session 结束。 +- 图片上传中移除会取消上传,取消的上传不会取得凭证;已开始存储或已上传完成的图片仍留在 `DSH_HOME` 下,附件存储从不删除。Host 在远端时,用户一加入图片,字节就到达服务器。 +- 暂存的图片凭证与通用文件同样只存在内存里:在加入与发送之间重启 Host 会得到 `FILE_NOT_STAGED`,用户需要重新加入图片。 +- 与文件草稿一样,图片草稿在页面刷新后或其 Session 的客户端作用域释放后不会恢复。 + +## 测试 + +Host 侧覆盖经原始路由暂存图片、单张字节上限一超出即停止读取、不支持的类型、子代理拒绝、命令拒绝图片凭证、凭证解析为准入后的图片、已上传图片的图片输入检查、多个凭证合计的单条消息字节上限,以及在最后一个分块之后或存储期间取消的上传不签发凭证。附件准入覆盖已存储图片原样通过,以及合并后的图片数量与总字节上限。客户端覆盖图片上传的开始、重试、取消、发送闸门、发送时引用凭证而不重新编码、没有上传的草稿以内联方式发送图片,附件栏的进度、重试与拒绝状态,以及发送被挡住时按 Enter 的说明。 + +## 相关记录 + +[通用文件上传](2026-08-26-generic-file-upload.zh.md) 记录负责图片上传所复用的后台上传队列、载体、暂存生命周期与文件存储。 diff --git a/.agents/notes/implemented/process/2026-07-21-serial-cross-platform-ci-reference.i18n.yaml b/.agents/notes/implemented/process/2026-07-21-serial-cross-platform-ci-reference.i18n.yaml index c1501b5647..c870bf9a78 100644 --- a/.agents/notes/implemented/process/2026-07-21-serial-cross-platform-ci-reference.i18n.yaml +++ b/.agents/notes/implemented/process/2026-07-21-serial-cross-platform-ci-reference.i18n.yaml @@ -9,8 +9,8 @@ en: 4fb1c27a631b32d3 zh: 9dbe9a4b9aadfca9 /agent-note-serial-cross-platform-ci-reference/decision: - en: c88f606c0749bd67 - zh: b2038b0d92fd8890 + en: b78b36281749773b + zh: c4ed34782577e5e7 /agent-note-serial-cross-platform-ci-reference/alternatives-considered: en: 3610152c5c4e93af zh: f4c09d327716f750 diff --git a/.agents/notes/implemented/process/2026-07-21-serial-cross-platform-ci-reference.md b/.agents/notes/implemented/process/2026-07-21-serial-cross-platform-ci-reference.md index 35fa389802..41b6666d6b 100644 --- a/.agents/notes/implemented/process/2026-07-21-serial-cross-platform-ci-reference.md +++ b/.agents/notes/implemented/process/2026-07-21-serial-cross-platform-ci-reference.md @@ -16,7 +16,7 @@ Real-kernel sandbox proofs require specific hosted operating systems and archite ## Decision -[CI](../../../../.github/workflows/ci.yml) (pull-request-only) and [CI master](../../../../.github/workflows/ci-master.yml) (master-push + workflow_dispatch) give pull-request and master-push events complementary responsibilities. Pull requests run Linux, native Windows, [Node compatibility with self-hosted routing under the Linux variable and trust conditions](../../../../.github/workflows/ci.yml), and Python checks; [platform scheduling](../../../../.github/workflows/ci-master.yml) assigns Wine and three Python runtime carriers to master pushes. On a push to `master`, the active references are `serial / linux (self-hosted standby)` on the in-house `vm-backup` pool and `serial / windows (self-hosted standby)` on the in-house `dsh-win-ci` pool — the hot-standby drills that continuously re-prove the failover targets described in the [failover runbook](2026-07-26-ci-failover-runbook.md). There is no standard-hosted `serial / linux` definition; the standard-hosted `serial / macos` remains disabled under `TODO(hosted-serial-ci)` until its portable capacity can be restored. The current `serial / windows` definition is the in-house `dsh-win-ci` standby. The separate job definitions intentionally keep their short checkout, runtime setup, and immutable install sequences visible instead of hiding operating systems behind a matrix or reusable workflow. `workflow_dispatch` is reserved for runner benchmarks. +[CI](../../../../.github/workflows/ci.yml), [CI Windows](../../../../.github/workflows/ci-windows.yml), and [CI Python](../../../../.github/workflows/ci-python.yml) (pull-request-only) and [CI master](../../../../.github/workflows/ci-master.yml) (master-push + workflow_dispatch) give pull-request and master-push events complementary responsibilities. Pull requests run Linux, native Windows, [Node compatibility with self-hosted routing under the Linux variable and trust conditions](../../../../.github/workflows/ci.yml), and Python checks; [platform scheduling](../../../../.github/workflows/ci-master.yml) assigns Wine and three Python runtime carriers to master pushes. On a push to `master`, the active references are `serial / linux (self-hosted standby)` on the in-house `vm-backup` pool and `serial / windows (self-hosted standby)` on the in-house `dsh-win-ci` pool — the hot-standby drills that continuously re-prove the failover targets described in the [failover runbook](2026-07-26-ci-failover-runbook.md). There is no standard-hosted `serial / linux` definition; the standard-hosted `serial / macos` remains disabled under `TODO(hosted-serial-ci)` until its portable capacity can be restored. The current `serial / windows` definition is the in-house `dsh-win-ci` standby. The separate job definitions intentionally keep their short checkout, runtime setup, and immutable install sequences visible instead of hiding operating systems behind a matrix or reusable workflow. `workflow_dispatch` is reserved for runner benchmarks. Each reference job runs `pnpm run check:ci` without any shard selector. `DSH_GATE_CONCURRENCY=1` makes the top-level aggregate execute one ready gate at a time; coverage, snapshot replay, built-bin smoke, and publication validation also receive worker counts of one. The reference jobs may run beside one another, but each host's repository gates are serial and complete. Linux installs bubblewrap before replaying snapshots, and Windows enables Developer Mode before installing the symlinked workspace. diff --git a/.agents/notes/implemented/process/2026-07-21-serial-cross-platform-ci-reference.zh.md b/.agents/notes/implemented/process/2026-07-21-serial-cross-platform-ci-reference.zh.md index 98ab14a3e1..8a725acc3b 100644 --- a/.agents/notes/implemented/process/2026-07-21-serial-cross-platform-ci-reference.zh.md +++ b/.agents/notes/implemented/process/2026-07-21-serial-cross-platform-ci-reference.zh.md @@ -16,7 +16,7 @@ Status: implemented ## 决策 -[CI](../../../../.github/workflows/ci.yml)(仅 pull request)与 [CI master](../../../../.github/workflows/ci-master.yml)(master 推送 + `workflow_dispatch`)为拉取请求事件与 master 推送事件赋予互补的职责。拉取请求运行 Linux、原生 Windows、[仅在 Linux 变量与信任条件满足时使用自托管路由的 Node 兼容性](../../../../.github/workflows/ci.yml)与 Python 检查;[平台调度](../../../../.github/workflows/ci-master.yml)将 Wine 与三个 Python 运行时载体分配给 master 推送。向 `master` 推送时,当前启用的参考作业是公司自有 `vm-backup` 池上的 `serial / linux (self-hosted standby)` 和 `dsh-win-ci` 池上的 `serial / windows (self-hosted standby)`——这些热备演练持续验证[故障切换手册](2026-07-26-ci-failover-runbook.zh.md)所描述的切换目标。不存在标准托管的 `serial / linux` 定义;标准托管的 `serial / macos` 仍处于禁用状态,并由 `TODO(hosted-serial-ci)` 标记,直到其可移植容量恢复。当前 `serial / windows` 定义是公司自有 `dsh-win-ci` 池的 standby。各自独立的作业定义有意显式保留简短的代码检出、运行时设置和依赖锁定的安装步骤,而不是用矩阵或可复用工作流隐藏操作系统差异。`workflow_dispatch` 仅用于运行器基准测试。 +[CI](../../../../.github/workflows/ci.yml)、[CI Windows](../../../../.github/workflows/ci-windows.yml) 与 [CI Python](../../../../.github/workflows/ci-python.yml)(仅 pull request)与 [CI master](../../../../.github/workflows/ci-master.yml)(master 推送 + `workflow_dispatch`)为拉取请求事件与 master 推送事件赋予互补的职责。拉取请求运行 Linux、原生 Windows、[仅在 Linux 变量与信任条件满足时使用自托管路由的 Node 兼容性](../../../../.github/workflows/ci.yml)与 Python 检查;[平台调度](../../../../.github/workflows/ci-master.yml)将 Wine 与三个 Python 运行时载体分配给 master 推送。向 `master` 推送时,当前启用的参考作业是公司自有 `vm-backup` 池上的 `serial / linux (self-hosted standby)` 和 `dsh-win-ci` 池上的 `serial / windows (self-hosted standby)`——这些热备演练持续验证[故障切换手册](2026-07-26-ci-failover-runbook.zh.md)所描述的切换目标。不存在标准托管的 `serial / linux` 定义;标准托管的 `serial / macos` 仍处于禁用状态,并由 `TODO(hosted-serial-ci)` 标记,直到其可移植容量恢复。当前 `serial / windows` 定义是公司自有 `dsh-win-ci` 池的 standby。各自独立的作业定义有意显式保留简短的代码检出、运行时设置和依赖锁定的安装步骤,而不是用矩阵或可复用工作流隐藏操作系统差异。`workflow_dispatch` 仅用于运行器基准测试。 每个参考作业均在不设置任何分片选择器的情况下运行 `pnpm run check:ci`。`DSH_GATE_CONCURRENCY=1` 使顶层聚合每次只执行一个已经就绪的门禁;覆盖率、快照回放、built-bin 冒烟测试和发布验证的 worker 数量也设为 1。各参考作业可以彼此并行,但每台主机上的仓库门禁都串行运行且完整执行。Linux 在回放快照前安装 bubblewrap,Windows 则在安装采用符号链接的工作区前启用开发人员模式。 diff --git a/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.i18n.yaml b/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.i18n.yaml index d13a9fab6d..907c19c6e6 100644 --- a/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.i18n.yaml +++ b/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.i18n.yaml @@ -6,11 +6,11 @@ en: 677b522817e84e2f zh: f8518a7033b0fd54 /agent-note-ci-failover-runbook-hosted-pools-in-house-pool/problem: - en: 3ecedc74cceced3b - zh: f4708f1887d42c61 + en: bcdd24f3c39d593c + zh: 3e576d09c8c3bfd4 /agent-note-ci-failover-runbook-hosted-pools-in-house-pool/decision: - en: 4a76615718dbc8f0 - zh: f46b885a11d5dd1d + en: fed13ca92e488b69 + zh: 9e5c9cdc118ab84e /agent-note-ci-failover-runbook-hosted-pools-in-house-pool/decision/release-rehearsals-share-the-linux-switch: en: 01e74068e0a96375 zh: aa811861ce30cb47 diff --git a/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.md b/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.md index a4be39a582..8c03479ba2 100644 --- a/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.md +++ b/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.md @@ -6,11 +6,11 @@ English | [中文](2026-07-26-ci-failover-runbook.zh.md) ## Problem -The three required Linux worker jobs in [CI](../../../../.github/workflows/ci.yml) (`node 24 / static`, `node 24 / coverage`, `node 24 / snapshots and artifacts`) run on the hosted enterprise 32-core pools; the required verdict job that aggregates them (`all checks passed`) runs on standard `ubuntu-latest`; the [native Windows jobs](2026-08-08-native-windows-pull-request-ci.md) run on the hosted `dsh-windows-2025-16core` larger runner. When the enterprise pools degrade — jobs queue indefinitely or the enterprise labels vanish — every open pull request becomes unmergeable, and the ordinary recovery of merging a fix is itself deadlocked behind the very required checks that cannot run. **Scope: two independent switches, one per platform.** `DSH_CI_FAILOVER_LINUX` recovers an enterprise Linux-pool outage (the three required Linux workers plus the `all checks passed` verdict); `DSH_CI_FAILOVER_WINDOWS` recovers a hosted Windows-pool outage (the native Windows jobs). A Linux-pool outage need not retarget Windows jobs and vice versa. The [Node compatibility jobs](../../../../.github/workflows/ci.yml) also follow the Linux switch with isolated setup; the verdict's `node-24-bench`, `python-sdk`, and `python-runtime` dependencies stay on standard hosted runners; in a broader GitHub-hosted capacity failure that also takes out the standard pools, those dependencies still block `all checks passed`. An outage therefore needs a switch any responder with repository write access can throw without merging anything. +The three required Linux worker jobs in [CI](../../../../.github/workflows/ci.yml) (`node 24 / static`, `node 24 / coverage`, `node 24 / snapshots and artifacts`) run on the hosted enterprise 32-core pools; the required verdict job that aggregates them (`all checks passed`) runs on standard `ubuntu-latest`; the [native Windows jobs](2026-08-08-native-windows-pull-request-ci.md) in [CI Windows](../../../../.github/workflows/ci-windows.yml) run on the hosted `dsh-windows-2025-16core` larger runner. When the enterprise pools degrade — jobs queue indefinitely or the enterprise labels vanish — every open pull request becomes unmergeable, and the ordinary recovery of merging a fix is itself deadlocked behind the very required checks that cannot run. **Scope: two independent switches, one per platform.** `DSH_CI_FAILOVER_LINUX` recovers an enterprise Linux-pool outage (the three required Linux workers plus the `all checks passed`, `windows checks passed`, and `python checks passed` verdicts); `DSH_CI_FAILOVER_WINDOWS` recovers a hosted Windows-pool outage (the native Windows jobs). A Linux-pool outage need not retarget Windows jobs and vice versa. The [Node compatibility jobs](../../../../.github/workflows/ci.yml) also follow the Linux switch with isolated setup; the verdict's `node-24-bench`, `python-sdk`, and `python-runtime` dependencies stay on standard hosted runners, as does CI Python's `python-runtime`; in a broader GitHub-hosted capacity failure that also takes out the standard pools, those dependencies still block the verdicts that require them. An outage therefore needs a switch any responder with repository write access can throw without merging anything. ## Decision -The three primary Linux jobs (`node-24`, `node-24-coverage`, `node-24-consumers`), the two `node-compat` matrix entries, and `all-checks-passed` resolve through `DSH_CI_FAILOVER_LINUX`; the native Windows jobs resolve through `DSH_CI_FAILOVER_WINDOWS`. A platform switch does not redirect the other platform. Set to `selfhosted` by a repository writer, the applicable trusted jobs select `vm-backup` or `dsh-win-ci`; the `blacksmith` value routes the participating jobs per the [blacksmith failover leg note](2026-09-09-blacksmith-failover-leg.md); unset or any other value retains the workflow-defined hosted fallbacks. Node compatibility jobs require a same-repository, non-fork head and a non-Dependabot author, use isolated runtime setup, and retain the `ubuntu-latest` fallback under unset and non-special values; the blacksmith branch carries none of those predicates. Under the `selfhosted` value, Linux failover bounds snapshot concurrency and skips hosted package-cache restores. The verdict follows its workers so it does not remain queued on an unavailable hosted pool. Each switch is writer-manageable repository state, not a merge, so it works while checks are red. The `serial / linux (self-hosted standby)` and `serial / windows (self-hosted standby)` lanes re-prove the complete unsharded aggregates on master pushes. +The three primary Linux jobs (`node-24`, `node-24-coverage`, `node-24-consumers`), the two `node-compat` matrix entries, and the `all-checks-passed`, `windows-checks-passed`, and `python-checks-passed` verdicts resolve through `DSH_CI_FAILOVER_LINUX`; the native Windows jobs resolve through `DSH_CI_FAILOVER_WINDOWS`. A platform switch does not redirect the other platform. Set to `selfhosted` by a repository writer, the applicable trusted jobs select `vm-backup` or `dsh-win-ci`; the `blacksmith` value routes the participating jobs per the [blacksmith failover leg note](2026-09-09-blacksmith-failover-leg.md); unset or any other value retains the workflow-defined hosted fallbacks. Node compatibility jobs require a same-repository, non-fork head and a non-Dependabot author, use isolated runtime setup, and retain the `ubuntu-latest` fallback under unset and non-special values; the blacksmith branch carries none of those predicates. Under the `selfhosted` value, Linux failover bounds snapshot concurrency and skips hosted package-cache restores. The three verdict jobs follow the Linux switch so they do not remain queued on an unavailable hosted pool. Each switch is writer-manageable repository state, not a merge, so it works while checks are red. The `serial / linux (self-hosted standby)` and `serial / windows (self-hosted standby)` lanes re-prove the complete unsharded aggregates on master pushes. The [superseded-CI cancellation policy](../../../../.github/workflows/ci.yml) governs master pushes and manual runs in the same workflow/ref group, including standby drills. Rapid master updates can starve a drill before it reaches a verdict. Use the latest completed standby verdict and check its age and commit before treating it as readiness evidence; a cancelled or merely scheduled run is not proof of readiness. diff --git a/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.zh.md b/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.zh.md index fafeda5b1d..e72908e3fc 100644 --- a/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.zh.md +++ b/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.zh.md @@ -6,11 +6,11 @@ Status: implemented ## 问题 -[CI](../../../../.github/workflows/ci.yml) 中三个必需的 Linux 工作作业(`node 24 / static`、`node 24 / coverage`、`node 24 / snapshots and artifacts`)运行在托管的企业级 32 核池上;聚合它们的必需判定作业(`all checks passed`)运行在标准 `ubuntu-latest` 上;[原生 Windows 作业](2026-08-08-native-windows-pull-request-ci.zh.md)运行在托管的 `dsh-windows-2025-16core` 大型运行器上。当企业池发生故障——作业无限排队或企业标签消失——所有开启的拉取请求都无法合并,而"合并一个修复"这一常规恢复手段本身正被那些无法运行的必需检查死锁。**适用范围:两个独立开关,每个平台一个。**`DSH_CI_FAILOVER_LINUX` 恢复企业级 Linux 池故障(三个必需的 Linux 工作作业加 `all checks passed` 判定作业);`DSH_CI_FAILOVER_WINDOWS` 恢复托管 Windows 池故障(原生 Windows 作业)。Linux 池故障无需重定向 Windows 作业,反之亦然。[Node 兼容性作业](../../../../.github/workflows/ci.yml)也通过隔离设置跟随 Linux 开关;判定作业的 `node-24-bench`、`python-sdk` 和 `python-runtime` 依赖仍留在标准托管运行器上;若更大范围的 GitHub 托管容量故障连标准池一并击倒,这些依赖仍会阻塞 `all checks passed`。因此故障需要一个任何具备仓库写权限的响应者都能在不合并任何代码的情况下触发的开关。 +[CI](../../../../.github/workflows/ci.yml) 中三个必需的 Linux 工作作业(`node 24 / static`、`node 24 / coverage`、`node 24 / snapshots and artifacts`)运行在托管的企业级 32 核池上;聚合它们的必需判定作业(`all checks passed`)运行在标准 `ubuntu-latest` 上;[原生 Windows 作业](2026-08-08-native-windows-pull-request-ci.zh.md)(位于 [CI Windows](../../../../.github/workflows/ci-windows.yml))运行在托管的 `dsh-windows-2025-16core` 大型运行器上。当企业池发生故障——作业无限排队或企业标签消失——所有开启的拉取请求都无法合并,而"合并一个修复"这一常规恢复手段本身正被那些无法运行的必需检查死锁。**适用范围:两个独立开关,每个平台一个。**`DSH_CI_FAILOVER_LINUX` 恢复企业级 Linux 池故障(三个必需的 Linux 工作作业加 `all checks passed`、`windows checks passed` 与 `python checks passed` 判定作业);`DSH_CI_FAILOVER_WINDOWS` 恢复托管 Windows 池故障(原生 Windows 作业)。Linux 池故障无需重定向 Windows 作业,反之亦然。[Node 兼容性作业](../../../../.github/workflows/ci.yml)也通过隔离设置跟随 Linux 开关;判定作业的 `node-24-bench`、`python-sdk` 和 `python-runtime` 依赖仍留在标准托管运行器上,CI Python 的 `python-runtime` 也是如此;若更大范围的 GitHub 托管容量故障连标准池一并击倒,这些依赖仍会阻塞要求它们的判定。因此故障需要一个任何具备仓库写权限的响应者都能在不合并任何代码的情况下触发的开关。 ## 决策 -三个主要 Linux 作业(`node-24`、`node-24-coverage`、`node-24-consumers`)、两个 `node-compat` 矩阵条目和 `all-checks-passed` 通过 `DSH_CI_FAILOVER_LINUX` 解析;原生 Windows 作业通过 `DSH_CI_FAILOVER_WINDOWS` 解析。一个平台的开关不会重定向另一个平台。仓库写者将变量设为 `selfhosted` 时,适用的可信作业选择 `vm-backup` 或 `dsh-win-ci`;`blacksmith` 取值按 [blacksmith 故障切换支路笔记](2026-09-09-blacksmith-failover-leg.zh.md) 路由参与切换的作业;未设置或任何其它值保留工作流定义的托管回退。Node 兼容性作业要求同仓库且非 fork 的头部以及非 Dependabot 作者,使用隔离运行时设置,并在未设置与非特殊值下保留 `ubuntu-latest` 回退;blacksmith 分支不带上述任何条件。在 `selfhosted` 取值下,Linux 故障切换会限制快照并发,并跳过托管软件包缓存恢复。判定作业跟随工作作业,避免继续在不可用的托管池排队。每个开关都是写者可管理的仓库状态而非一次合并,因此在检查失败时仍然有效。`serial / linux (self-hosted standby)` 与 `serial / windows (self-hosted standby)` 通道在 master 推送上重新验证完整的未分片聚合流程。 +三个主要 Linux 作业(`node-24`、`node-24-coverage`、`node-24-consumers`)、两个 `node-compat` 矩阵条目,以及 `all-checks-passed`、`windows-checks-passed`、`python-checks-passed` 三个判定作业通过 `DSH_CI_FAILOVER_LINUX` 解析;原生 Windows 作业通过 `DSH_CI_FAILOVER_WINDOWS` 解析。一个平台的开关不会重定向另一个平台。仓库写者将变量设为 `selfhosted` 时,适用的可信作业选择 `vm-backup` 或 `dsh-win-ci`;`blacksmith` 取值按 [blacksmith 故障切换支路笔记](2026-09-09-blacksmith-failover-leg.zh.md) 路由参与切换的作业;未设置或任何其它值保留工作流定义的托管回退。Node 兼容性作业要求同仓库且非 fork 的头部以及非 Dependabot 作者,使用隔离运行时设置,并在未设置与非特殊值下保留 `ubuntu-latest` 回退;blacksmith 分支不带上述任何条件。在 `selfhosted` 取值下,Linux 故障切换会限制快照并发,并跳过托管软件包缓存恢复。三个判定作业跟随 Linux 开关,避免继续在不可用的托管池排队。每个开关都是写者可管理的仓库状态而非一次合并,因此在检查失败时仍然有效。`serial / linux (self-hosted standby)` 与 `serial / windows (self-hosted standby)` 通道在 master 推送上重新验证完整的未分片聚合流程。 [被取代 CI 的取消策略](../../../../.github/workflows/ci.yml) 管理同一工作流/引用组内的 master 推送和手动运行,包括热备演练。master 快速更新可能让演练因反复被取消而始终无法得出结论。判断就绪状态时,使用最近一次已完成的热备结论,并核对其时间和提交;已取消或仅被调度的运行不构成就绪证据。 diff --git a/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.i18n.yaml b/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.i18n.yaml index 360ed3d315..8c2c549e30 100644 --- a/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.i18n.yaml +++ b/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.i18n.yaml @@ -9,11 +9,11 @@ en: 3e229ecf646af866 zh: d358507b1e1d381f /agent-note-wine-and-native-windows-ci/decision: - en: 5a74ef2d98f6b4cf - zh: 4a539303a3cd4ed8 + en: 1125b44a170f70fb + zh: 37838b9da2003651 /agent-note-wine-and-native-windows-ci/alternatives-considered: - en: f6c5e77fe3f05739 - zh: 8de640d12d3a38c4 + en: 0e9a322cfc38d313 + zh: f7bdeb86ba4fea1a /agent-note-wine-and-native-windows-ci/consequences: - en: 58069228d4e904c6 - zh: 2601a48b82400903 + en: 3dc1ae32e0a74331 + zh: ab0e9d00aad92d62 diff --git a/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.md b/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.md index 478c5a8ba3..cb14645f13 100644 --- a/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.md +++ b/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.md @@ -14,9 +14,9 @@ A coverage audit found that stale branch state had restored temporary exclusions The master-only `windows` job in [ci-master.yml](../../../../.github/workflows/ci-master.yml) runs `windows node 24 / wine` on `ubuntu-latest`. It retains the checksum-verified Windows Node, Wine apt and pnpm caches, a hoisted install confined to a workspace snapshot, and the [shared Wine gate script](../../../../scripts/wine-windows-gates.sh) that runs the workspace build and production site. Node distribution transfers use bounded retries; when nodejs.org stalls on the large archive, a range-capable transport mirror resumes the same bytes, but nodejs.org remains the version and SHA-256 authority and the archive is never promoted before that checksum passes. Wine is outside the PR aggregate under the [master-only platform policy](../../../../.github/workflows/ci-master.yml). The [archived Wine experiment](../../archived/process/2026-07-27-wine-windows-gates-experiment.md) preserves its measured trade-offs, while this note owns the current dual topology. -Every pull request starts three native jobs: `windows-build`, `windows-coverage`, and `windows-native-tests`. Each job enables Developer Mode for workspace symlinks, provisions the repository-pinned pnpm through `pnpm/action-setup`, performs an immutable install without a transferred store archive, and runs its inventory under native PowerShell. The Windows failover variable (`DSH_CI_FAILOVER_WINDOWS`) selects the organization-owned `dsh-windows-2025-16core` runner by default, the in-house pool under `selfhosted`, or Blacksmith runners under `blacksmith` (see the [blacksmith failover leg note](2026-09-09-blacksmith-failover-leg.md)). Blacksmith build and coverage jobs request 16 vCPU; file-serial native tests request 2 vCPU. Per-job deadlines range from 60 to 120 minutes. +Every pull request starts three native jobs in the separate [CI Windows workflow](../../../../.github/workflows/ci-windows.yml): `windows-build`, `windows-coverage`, and `windows-native-tests`. GitHub re-runs failed jobs only after every job of a run finishes, so a separate workflow lets maintainers re-run a failed Linux job while Windows jobs are still running, and the reverse. Each job enables Developer Mode for workspace symlinks, provisions the repository-pinned pnpm through `pnpm/action-setup`, performs an immutable install without a transferred store archive, and runs its inventory under native PowerShell. The Windows failover variable (`DSH_CI_FAILOVER_WINDOWS`) selects the organization-owned `dsh-windows-2025-16core` runner by default, the in-house pool under `selfhosted`, or Blacksmith runners under `blacksmith` (see the [blacksmith failover leg note](2026-09-09-blacksmith-failover-leg.md)). Blacksmith build and coverage jobs request 16 vCPU; file-serial native tests request 2 vCPU. Per-job deadlines range from 60 to 120 minutes. -`windows-build` and `windows-native-tests` are dependencies of `all checks passed`; their workspace-build and targeted native-process results are blocking. After its blocking build and production-site checks succeed, `windows-build` runs `check:ci:windows-observational-ready` in the same workspace. This step uses `continue-on-error`, disables fail-fast, and has a 15-minute timeout; failures produce a workflow warning because Linux owns the blocking static, documentation, package, and built-artifact verdicts. The required verdict waits for the step to finish but does not require its diagnostics to pass. `windows-coverage` remains outside aggregate `needs`, so its 100%-per-file result stays red and visible without delaying the verdict. +`windows-build` and `windows-native-tests` are dependencies of the required `windows checks passed` verdict; their workspace-build and targeted native-process results are blocking. After its blocking build and production-site checks succeed, `windows-build` runs `check:ci:windows-observational-ready` in the same workspace. This step uses `continue-on-error`, disables fail-fast, and has a 15-minute timeout; failures produce a workflow warning because Linux owns the blocking static, documentation, package, and built-artifact verdicts. The required verdict waits for the step to finish but does not require its diagnostics to pass. `windows-coverage` remains outside aggregate `needs`, so its 100%-per-file result stays red and visible without delaying the verdict. `windows-coverage` runs [in-job partitioned coverage](../../../../scripts/coverage-partitions.ts) without a preceding build, matching the Linux coverage lane: four single-worker instrumented shards beside a two-worker exempt-heavy gate; workspace imports resolve to `src` through the tsconfig paths map, and the lib-consuming suites self-skip on unbuilt checkouts. Both coverage gates raise Vitest's per-test, expect.poll, and hook budgets through `DSH_COVERAGE_TEST_TIMEOUT_MS=90000`. The observational step reuses the successful build, retains the distinct MPA documentation build, starts independent static gates together, and uses CI worker defaults for `publint`, with an eight-worker override on shared self-hosted runners. Its built-bin smoke starts only after every other observational gate settles, including failed gates. The ready aggregate requires a successful prior workspace build; it removes only that build and its internal dependency edges from the shared internal inventory. This keeps bounded real-application startup measurements from competing with tool-catalog, NodeNext, package, and documentation processes. The script-only translation-pairing merge suite runs in the exempt-heavy gate because it imports only `scripts/` sources and child processes; V8 instrumentation contributes no threshold coverage there but magnifies Git-process latency. Lefthook concurrency fixtures retain their outcomes with 30-second case budgets and a 10-second process-ready probe, while the installer allows five seconds for a preempted lock owner to publish its record after exclusive creation. Directory-picker composition gives its debounced config write an explicit 15-second poll budget; workspace-context composition fixtures use a test-owned signal without an unrelated one-second deadline. The LSP sources and the ACL-sandbox sources remain in the Windows denominator: stub-based failure-path suites carry every in-process ACL-sandbox file to 100%, and only the runner entry stays excluded — it executes exclusively as a spawned child outside the instrumented run, its behavior pinned end-to-end by the runner suite. Narrow annotated V8 ignores cover only unreachable branches (peer-platform arms and lifecycle-unreachable guards), with their behavior tests retained on the owning platform. @@ -36,7 +36,7 @@ Shiki disables lazy TextMate-regex compilation and warms each boot grammar befor ## Alternatives considered -**Make every native Windows result a dependency of `all checks passed`.** This gives the aggregate the highest-fidelity Windows verdict, but makes every merge wait for coverage and the duplicated observational inventory. Requiring the build and targeted native-process suite retains fast native correctness signals while the other results remain automatic. +**Make every native Windows result a dependency of `windows checks passed`.** This gives the aggregate the highest-fidelity Windows verdict, but makes every merge wait for coverage and the duplicated observational inventory. Requiring the build and targeted native-process suite retains fast native correctness signals while the other results remain automatic. **Run only Wine on pull requests.** Wine reaches blocking win32 toolchain branches quickly, but can report green while a real NT, NTFS, PowerShell, process, or addon contract is broken. @@ -50,7 +50,7 @@ Shiki disables lazy TextMate-regex compilation and warms each boot grammar befor ## Consequences -Wine provides post-merge toolchain evidence. Native coverage may still be pending or red when `all checks passed` turns green. Observational diagnostics finish inside the required build job. A diagnostic failure leaves the PR check successful; the failed step, workflow warning, and run summary expose the failure. +Wine provides post-merge toolchain evidence. Native coverage may still be pending or red when `windows checks passed` turns green. Observational diagnostics finish inside the required build job. A diagnostic failure leaves the PR check successful; the failed step, workflow warning, and run summary expose the failure. Every pull request receives a real NT kernel, NTFS, PowerShell, Windows process, native addon, and supported-source coverage signal. Observational checks reuse the successful build workspace, avoiding a second checkout, dependency installation, and workspace build. Their sequential position extends the build job and can delay the required verdict; it also separates diagnostic processes from compilation. The ready and master aggregates share one internal diagnostic inventory; master retains its own workspace build and full checks. diff --git a/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.zh.md b/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.zh.md index f3c8dc5fff..b999ac8ef8 100644 --- a/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.zh.md +++ b/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.zh.md @@ -14,9 +14,9 @@ Wine 在 Linux 内核与区分大小写的 ext4 之上采用 hoisted 依赖布 [ci-master.yml](../../../../.github/workflows/ci-master.yml) 中仅 master 触发的 `windows` 作业在 `ubuntu-latest` 上运行 `windows node 24 / wine`。它保留经过校验和验证的 Windows Node、Wine apt 与 pnpm 缓存、仅限工作区快照的 hoisted 安装,以及运行工作区构建与生产网站的[共享 Wine 门禁脚本](../../../../scripts/wine-windows-gates.sh)。Node 分发文件传输采用有界重试;nodejs.org 的大文件传输停滞时,由支持范围请求的传输镜像续传相同字节,但版本和 SHA-256 权威仍属于 nodejs.org,归档通过该校验前绝不会投入使用。根据[仅 master 平台策略](../../../../.github/workflows/ci-master.yml),Wine 不参与 PR 聚合。[已归档的 Wine 实验](../../archived/process/2026-07-27-wine-windows-gates-experiment.md)保留其实测取舍,而本文负责当前双通道拓扑。 -每个拉取请求都会启动三个原生作业:`windows-build`、`windows-coverage` 和 `windows-native-tests`。每个作业都会为 workspace 符号链接启用开发者模式,通过 `pnpm/action-setup` 提供仓库固定版本的 pnpm,在不传输 store 归档的情况下执行不可变安装,并在原生 PowerShell 下运行自己的清单。Windows 故障切换变量(`DSH_CI_FAILOVER_WINDOWS`)默认选择组织自有的 `dsh-windows-2025-16core` 运行器,在 `selfhosted` 下选择公司内部运行器池,在 `blacksmith` 下选择 Blacksmith 运行器(见 [blacksmith 故障切换支路笔记](2026-09-09-blacksmith-failover-leg.zh.md))。Blacksmith 构建和覆盖率作业请求 16 vCPU;逐文件串行的原生测试请求 2 vCPU。各作业的截止时间为 60 至 120 分钟。 +每个拉取请求都会在独立的 [CI Windows 工作流](../../../../.github/workflows/ci-windows.yml)中启动三个原生作业:`windows-build`、`windows-coverage` 和 `windows-native-tests`。GitHub 只有在一次运行的所有作业结束后才会重新运行失败的作业,因此独立工作流让维护者能在 Windows 作业仍在运行时重新运行失败的 Linux 作业,反之亦然。每个作业都会为 workspace 符号链接启用开发者模式,通过 `pnpm/action-setup` 提供仓库固定版本的 pnpm,在不传输 store 归档的情况下执行不可变安装,并在原生 PowerShell 下运行自己的清单。Windows 故障切换变量(`DSH_CI_FAILOVER_WINDOWS`)默认选择组织自有的 `dsh-windows-2025-16core` 运行器,在 `selfhosted` 下选择公司内部运行器池,在 `blacksmith` 下选择 Blacksmith 运行器(见 [blacksmith 故障切换支路笔记](2026-09-09-blacksmith-failover-leg.zh.md))。Blacksmith 构建和覆盖率作业请求 16 vCPU;逐文件串行的原生测试请求 2 vCPU。各作业的截止时间为 60 至 120 分钟。 -`windows-build` 与 `windows-native-tests` 是 `all checks passed` 的依赖项;其工作区构建和定向原生进程结果具有阻断性。阻断性构建和生产网站检查成功后,`windows-build` 在同一工作区运行 `check:ci:windows-observational-ready`。该步骤使用 `continue-on-error`、关闭 fail-fast,并设置 15 分钟超时;失败时会产生工作流警告,因为静态检查、文档、包与构建产物的阻断性判定由 Linux 负责。必需判定会等待该步骤结束,但不要求其诊断通过。`windows-coverage` 仍在聚合流程的 `needs` 之外,因此逐文件 100% 覆盖率结果会保持红灯并可见,却不会延迟判定。 +`windows-build` 与 `windows-native-tests` 是必需判定 `windows checks passed` 的依赖项;其工作区构建和定向原生进程结果具有阻断性。阻断性构建和生产网站检查成功后,`windows-build` 在同一工作区运行 `check:ci:windows-observational-ready`。该步骤使用 `continue-on-error`、关闭 fail-fast,并设置 15 分钟超时;失败时会产生工作流警告,因为静态检查、文档、包与构建产物的阻断性判定由 Linux 负责。必需判定会等待该步骤结束,但不要求其诊断通过。`windows-coverage` 仍在聚合流程的 `needs` 之外,因此逐文件 100% 覆盖率结果会保持红灯并可见,却不会延迟判定。 `windows-coverage` 与 Linux 覆盖率通道一致,不先构建工作区就运行[job 内分区覆盖率](../../../../scripts/coverage-partitions.ts):4 个单 worker 插桩分片与一个双 worker 的豁免重型门禁并行运行;工作区导入通过 tsconfig paths 映射解析到 `src`,消费构建产物的套件在未构建的检出上会自跳。两项覆盖率门禁都通过 `DSH_COVERAGE_TEST_TIMEOUT_MS=90000` 提高 Vitest 的单测试、expect.poll 与 hook 预算。观测步骤复用成功的构建,保留独立的 MPA 文档构建,一起启动相互独立的静态门禁,并让 `publint` 使用 CI worker 默认值,在共享自托管运行器上覆盖为 8 个 worker。其 built-bin 冒烟测试只在其他所有观测性门禁结算后启动,包括失败的门禁。ready 聚合要求此前已成功构建工作区;它仅从内部共用清单中移除该构建及其内部依赖边。这可避免有界的真实应用启动测量与 tool-catalog、NodeNext、包及文档进程争抢资源。translation-pairing 合并套件只导入 `scripts/` 源码和子进程,因此放入豁免重型套件门禁;V8 插桩不会为它贡献任何阈值覆盖率,却会放大 Git 进程延迟。Lefthook 并发 fixture 保留原有结果,采用 30 秒单用例预算与 10 秒进程就绪探测;安装器则允许被抢占的 lock 持有者在独占创建后用 5 秒发布记录。directory-picker 组合为防抖配置写入提供显式的 15 秒轮询预算;workspace-context 组合 fixture 使用测试自有、没有无关 1 秒截止时间的信号。LSP 源码与 ACL 沙箱源码仍计入 Windows 分母:基于 stub 的失败路径套件把每个进程内 ACL 沙箱文件都带到 100%,只有 runner 入口保持排除——它只作为 spawn 出的子进程在插桩运行之外执行,其行为由 runner 套件端到端钉住。窄范围且带注释的 V8 ignore 只覆盖不可达分支(另一平台专属分支、生命周期内不可达的防御守卫),其行为测试仍保留在所属平台。 @@ -36,7 +36,7 @@ Shiki 会禁用 TextMate 正则的延迟编译,并在用户内容进入保持 ## 曾考虑的替代方案 -**让每项原生 Windows 结果都成为 `all checks passed` 的依赖项。** 这会为聚合流程提供保真度最高的 Windows 判定,但也会让每次合并等待覆盖率和重复的观测性清单。要求构建与定向原生进程套件通过,可以保留快速的原生正确性信号,同时继续自动产生其他结果。 +**让每项原生 Windows 结果都成为 `windows checks passed` 的依赖项。** 这会为聚合流程提供保真度最高的 Windows 判定,但也会让每次合并等待覆盖率和重复的观测性清单。要求构建与定向原生进程套件通过,可以保留快速的原生正确性信号,同时继续自动产生其他结果。 **只在拉取请求上运行 Wine。** Wine 能快速触达阻断性 win32 工具链分支,但即使真实 NT、NTFS、PowerShell、进程或原生插件约定已经损坏,也可能报告绿灯。 @@ -50,7 +50,7 @@ Shiki 会禁用 TextMate 正则的延迟编译,并在用户内容进入保持 ## 后果 -Wine 提供合并后的工具链证据。`all checks passed` 变绿时,原生覆盖率仍可能处于待处理或红灯状态。观测诊断在必需的构建作业内完成。诊断失败时,PR 检查仍显示成功;失败步骤、工作流警告和运行摘要会展示该失败。 +Wine 提供合并后的工具链证据。`windows checks passed` 变绿时,原生覆盖率仍可能处于待处理或红灯状态。观测诊断在必需的构建作业内完成。诊断失败时,PR 检查仍显示成功;失败步骤、工作流警告和运行摘要会展示该失败。 每个拉取请求都会获得真实 NT 内核、NTFS、PowerShell、Windows 进程、原生插件和受支持源码覆盖率信号。观测检查复用成功构建的工作区,省去第二次检出、依赖安装和工作区构建。顺序执行会延长构建作业,并可能延迟必需判定;它也让诊断进程与编译分开运行。ready 与 master 聚合共用一份内部诊断清单;master 保留自己的工作区构建和完整检查。 diff --git a/.agents/notes/implemented/process/2026-09-06-python-runtime-windows-hosted.i18n.yaml b/.agents/notes/implemented/process/2026-09-06-python-runtime-windows-hosted.i18n.yaml index 52e1d7fc30..a235ab080c 100644 --- a/.agents/notes/implemented/process/2026-09-06-python-runtime-windows-hosted.i18n.yaml +++ b/.agents/notes/implemented/process/2026-09-06-python-runtime-windows-hosted.i18n.yaml @@ -9,8 +9,8 @@ en: 81d36185575e6d77 zh: 19003ab154d93394 /agent-note-windows-python-runtime-ci-stays-on-github-hosted-windows/decision: - en: 1169dd58149ad6b4 - zh: aeebaf9ca8769de6 + en: 68aff432bf30d821 + zh: 6633d254904a39f6 /agent-note-windows-python-runtime-ci-stays-on-github-hosted-windows/alternatives-considered: en: 817e77f011ea35c8 zh: 2087bc653620a60f diff --git a/.agents/notes/implemented/process/2026-09-06-python-runtime-windows-hosted.md b/.agents/notes/implemented/process/2026-09-06-python-runtime-windows-hosted.md index 95b762f224..5f6beafcdc 100644 --- a/.agents/notes/implemented/process/2026-09-06-python-runtime-windows-hosted.md +++ b/.agents/notes/implemented/process/2026-09-06-python-runtime-windows-hosted.md @@ -10,7 +10,7 @@ The Windows x64 target in [build-exe-for-python-sdk.yml](../../../../.github/wor ## Decision -The Windows x64 target always uses its hosted `matrix.runner` — `windows-2025` for pull-request CI — with the standard setup-python toolchain, the pnpm cache restore, and the pkg cache. The failover selector, the job-private Python setup step, the self-hosted dependency install and post-step cleanup, the private setup script, and the routing spec from #3629 are removed. `DSH_CI_FAILOVER_WINDOWS=selfhosted` again retargets only the native Windows jobs in [ci.yml](../../../../.github/workflows/ci.yml); the [failover runbook](2026-07-26-ci-failover-runbook.md) and [python/development.md](../../../../python/development.md) describe hosted-only runtime builds. The migration's UTF-8 mode exports existed because the persistent host used a GBK default code page; hosted images provide the locale the lane previously ran under. +The Windows x64 target always uses its hosted `matrix.runner` — `windows-2025` for pull-request CI — with the standard setup-python toolchain, the pnpm cache restore, and the pkg cache. The failover selector, the job-private Python setup step, the self-hosted dependency install and post-step cleanup, the private setup script, and the routing spec from #3629 are removed. `DSH_CI_FAILOVER_WINDOWS=selfhosted` again retargets only the native Windows jobs in [ci-windows.yml](../../../../.github/workflows/ci-windows.yml); the [failover runbook](2026-07-26-ci-failover-runbook.md) and [python/development.md](../../../../python/development.md) describe hosted-only runtime builds. The migration's UTF-8 mode exports existed because the persistent host used a GBK default code page; hosted images provide the locale the lane previously ran under. ## Alternatives considered diff --git a/.agents/notes/implemented/process/2026-09-06-python-runtime-windows-hosted.zh.md b/.agents/notes/implemented/process/2026-09-06-python-runtime-windows-hosted.zh.md index e3f27788b0..9b06a99150 100644 --- a/.agents/notes/implemented/process/2026-09-06-python-runtime-windows-hosted.zh.md +++ b/.agents/notes/implemented/process/2026-09-06-python-runtime-windows-hosted.zh.md @@ -10,7 +10,7 @@ Status: implemented ## 决策 -Windows x64 目标始终使用托管的 `matrix.runner`——PR CI 为 `windows-2025`——配以标准 setup-python 工具链、pnpm 缓存恢复与 pkg 缓存。来自 #3629 的故障切换选择器、作业私有 Python 准备步骤、自托管依赖安装与后置清理、私有准备脚本及路由测试均被移除。`DSH_CI_FAILOVER_WINDOWS=selfhosted` 再次只重定向 [ci.yml](../../../../.github/workflows/ci.yml) 中的原生 Windows 作业;[故障切换手册](2026-07-26-ci-failover-runbook.zh.md)与 [python/development.zh.md](../../../../python/development.zh.md) 描述仅托管的 runtime 构建。迁移中的 UTF-8 模式导出之所以存在,是因为持久主机使用 GBK 默认代码页;托管镜像提供该通道此前运行的区域设置。 +Windows x64 目标始终使用托管的 `matrix.runner`——PR CI 为 `windows-2025`——配以标准 setup-python 工具链、pnpm 缓存恢复与 pkg 缓存。来自 #3629 的故障切换选择器、作业私有 Python 准备步骤、自托管依赖安装与后置清理、私有准备脚本及路由测试均被移除。`DSH_CI_FAILOVER_WINDOWS=selfhosted` 再次只重定向 [ci-windows.yml](../../../../.github/workflows/ci-windows.yml) 中的原生 Windows 作业;[故障切换手册](2026-07-26-ci-failover-runbook.zh.md)与 [python/development.zh.md](../../../../python/development.zh.md) 描述仅托管的 runtime 构建。迁移中的 UTF-8 模式导出之所以存在,是因为持久主机使用 GBK 默认代码页;托管镜像提供该通道此前运行的区域设置。 ## 已考虑的替代方案 diff --git a/.agents/notes/implemented/process/2026-09-09-blacksmith-failover-leg.i18n.yaml b/.agents/notes/implemented/process/2026-09-09-blacksmith-failover-leg.i18n.yaml index be7e6eb89c..585429e528 100644 --- a/.agents/notes/implemented/process/2026-09-09-blacksmith-failover-leg.i18n.yaml +++ b/.agents/notes/implemented/process/2026-09-09-blacksmith-failover-leg.i18n.yaml @@ -9,8 +9,8 @@ en: 43ecfec1601f933e zh: 3db5365403ffeac5 /agent-note-blacksmith-as-a-failover-leg-for-ci-pools/decision: - en: fac195b5006faa2d - zh: 7c0ea86638c54e95 + en: 667aae1665477840 + zh: ff95c9c5fd9d1793 /agent-note-blacksmith-as-a-failover-leg-for-ci-pools/alternatives-considered: en: a605440fed0ccc2a zh: 621b35ad2850ca1b diff --git a/.agents/notes/implemented/process/2026-09-09-blacksmith-failover-leg.md b/.agents/notes/implemented/process/2026-09-09-blacksmith-failover-leg.md index 4fb4ed4ccb..1749462cef 100644 --- a/.agents/notes/implemented/process/2026-09-09-blacksmith-failover-leg.md +++ b/.agents/notes/implemented/process/2026-09-09-blacksmith-failover-leg.md @@ -12,7 +12,7 @@ The [failover runbook](2026-07-26-ci-failover-runbook.md) routes degraded enterp `DSH_CI_FAILOVER_LINUX` and `DSH_CI_FAILOVER_WINDOWS` additionally accept the value `blacksmith`. When any repository writer sets it, the platform's participating jobs retarget onto Blacksmith's hosted runners at each job's configured vCPU size; any other value (or unset) keeps today's runner, and `selfhosted` keeps routing to the in-house pools. Like the existing values this is writer-manageable repository state — no merge, re-run the affected jobs — and it costs money only while set, which is why it is an explicit outage or experiment choice, never the default. -Participating jobs under `blacksmith`: the three enterprise Linux workers, the `node-compat` legs, the `all-checks-passed` verdict, and the three Windows jobs in [ci.yml](../../../../.github/workflows/ci.yml), using the per-job runner sizes declared there; `expected-filenames`; the sandbox bwrap leg; and the two manual benchmark matrices, whose 4-32 vCPU tiers map to Blacksmith equivalents while the 64/96-core rows keep the fleet labels because Blacksmith offers no such tier. The blacksmith branches carry no Dependabot exclusion: Blacksmith runners are ephemeral, so the persistent-VM rationale that keeps Dependabot on the hosted pool does not apply. The python-runtime builder stays hosted in full: its PR and master callers (`ci.yml`'s and `ci-master.yml`'s `python-runtime` jobs) pass real-API credentials (`DEEPSEEK_API_KEY_EXTERNAL`) into the reusable workflow, its macOS and arm64 single-exe legs need GitHub's images, and its wheel output feeds the PyPI publication chain. The docs-pages workflow is a tag-dispatched release publication, so it stays hosted too. +Participating jobs under `blacksmith`: the three enterprise Linux workers, the `node-compat` legs, and the `all-checks-passed` verdict in [ci.yml](../../../../.github/workflows/ci.yml), plus the three Windows jobs and the `windows-checks-passed` verdict in [ci-windows.yml](../../../../.github/workflows/ci-windows.yml), plus the `python-checks-passed` verdict in [ci-python.yml](../../../../.github/workflows/ci-python.yml), using the per-job runner sizes declared there; `expected-filenames`; the sandbox bwrap leg; and the two manual benchmark matrices, whose 4-32 vCPU tiers map to Blacksmith equivalents while the 64/96-core rows keep the fleet labels because Blacksmith offers no such tier. The blacksmith branches carry no Dependabot exclusion: Blacksmith runners are ephemeral, so the persistent-VM rationale that keeps Dependabot on the hosted pool does not apply. The python-runtime builder stays hosted in full: its PR and master callers (the `python-runtime` jobs in `ci-python.yml` and `ci-master.yml`) pass real-API credentials (`DEEPSEEK_API_KEY_EXTERNAL`) into the reusable workflow, its macOS and arm64 single-exe legs need GitHub's images, and its wheel output feeds the PyPI publication chain. The docs-pages workflow is a tag-dispatched release publication, so it stays hosted too. Everything else keeps its own runner under every value: jobs pinned to standard hosted runners by workflow spec (`node-24-bench`, `python-sdk`, the master Wine gate, `release-publish` and `release-vendor-publish`, the Cloudflare preview, the weighted-approval pair); jobs that hold credentials (the real-API e2e lanes, the issue App-token jobs, the npm and PyPI publishers); jobs that need a host capability Blacksmith lacks (the Landlock legs and their pack verification, the Seatbelt leg, the linux-arm64 single-exe leg); and the release-shaped chains — the npm publishers, the PyPI publishers, the node-addon-system release chain, and the Pages deployment workflow — which stay entirely hosted because their artifacts feed published registry output. The release rehearsal jobs in `release.yml` and `release-vendor.yml` (`dependencies`, `pack`) keep their existing pre-change routing unchanged: they carry only the pre-existing `selfhosted` branch, so under `blacksmith` they fall back to their hosted `ubuntu-24.04` default exactly as under an unset variable, and they gain no `blacksmith` branch. diff --git a/.agents/notes/implemented/process/2026-09-09-blacksmith-failover-leg.zh.md b/.agents/notes/implemented/process/2026-09-09-blacksmith-failover-leg.zh.md index c2c4ee76a3..c110566000 100644 --- a/.agents/notes/implemented/process/2026-09-09-blacksmith-failover-leg.zh.md +++ b/.agents/notes/implemented/process/2026-09-09-blacksmith-failover-leg.zh.md @@ -12,7 +12,7 @@ Status: implemented `DSH_CI_FAILOVER_LINUX` 与 `DSH_CI_FAILOVER_WINDOWS` 额外接受值 `blacksmith`。任何仓库写者设置它时,该平台参与切换的作业按各作业配置的 vCPU 档位重定向到 Blacksmith 托管运行器;任何其它值(或未设置)保持今天的运行器,`selfhosted` 继续路由到自有池。与既有取值一样,这是写者可管理的仓库状态——无需合并,重跑受影响的作业即可——并且只在设置期间产生费用,这正是它作为明确的故障或实验选择、而非默认的原因。 -`blacksmith` 下参与切换的作业:[ci.yml](../../../../.github/workflows/ci.yml) 中三个企业级 Linux 工作作业、`node-compat` 各腿、`all-checks-passed` 判定作业与三个 Windows 作业,使用该工作流为各作业声明的运行器规格;`expected-filenames`;sandbox 的 bwrap 腿;以及两个手动基准矩阵——其 4-32 vCPU 档位映射到 Blacksmith 等价档,64/96 核行保留自有池标签,因为 Blacksmith 没有对应档位。blacksmith 分支不带 Dependabot 排除:Blacksmith 运行器是临时的,因此把 Dependabot 留在托管池的持久虚拟机理由不适用。python-runtime 构建器整体保持托管:其 PR 与 master 调用方(`ci.yml` 与 `ci-master.yml` 的 `python-runtime` 作业)会把真实 API 凭据(`DEEPSEEK_API_KEY_EXTERNAL`)传入该可复用工作流,其 macOS 与 arm64 单文件分支需要 GitHub 镜像,且其 wheel 产物供给 PyPI 发布链。docs-pages 工作流是 tag 派发的发布,因此同样保持托管。 +`blacksmith` 下参与切换的作业:[ci.yml](../../../../.github/workflows/ci.yml) 中三个企业级 Linux 工作作业、`node-compat` 各腿与 `all-checks-passed` 判定作业,以及 [ci-windows.yml](../../../../.github/workflows/ci-windows.yml) 中三个 Windows 作业与 `windows-checks-passed` 判定作业,以及 [ci-python.yml](../../../../.github/workflows/ci-python.yml) 中的 `python-checks-passed` 判定作业,使用这些工作流为各作业声明的运行器规格;`expected-filenames`;sandbox 的 bwrap 腿;以及两个手动基准矩阵——其 4-32 vCPU 档位映射到 Blacksmith 等价档,64/96 核行保留自有池标签,因为 Blacksmith 没有对应档位。blacksmith 分支不带 Dependabot 排除:Blacksmith 运行器是临时的,因此把 Dependabot 留在托管池的持久虚拟机理由不适用。python-runtime 构建器整体保持托管:其 PR 与 master 调用方(`ci-python.yml` 与 `ci-master.yml` 中的 `python-runtime` 作业)会把真实 API 凭据(`DEEPSEEK_API_KEY_EXTERNAL`)传入该可复用工作流,其 macOS 与 arm64 单文件分支需要 GitHub 镜像,且其 wheel 产物供给 PyPI 发布链。docs-pages 工作流是 tag 派发的发布,因此同样保持托管。 其余作业在任何取值下都保留自己的运行器:被 workflow spec 钉死在标准托管运行器上的作业(`node-24-bench`、`python-sdk`、master 的 Wine 门禁、`release-publish` 与 `release-vendor-publish`、Cloudflare preview、weighted-approval 一对);持有凭据的作业(真实 API 的 e2e 通道、issue App token 作业、npm 与 PyPI 发布作业);需要 Blacksmith 缺失的宿主能力的作业(Landlock 各腿及其打包校验、Seatbelt 腿、linux-arm64 单文件分支);以及发布形态的链——npm 发布、PyPI 发布、node-addon-system 发布链与 Pages 部署工作流——它们整体保持托管,因为其产物供给已发布的 registry 输出。`release.yml` 与 `release-vendor.yml` 中的发布演练作业(`dependencies`、`pack`)保持改动前既有的路由不变:它们只带既有的 `selfhosted` 分支,因此在 `blacksmith` 取值下与变量未设置时一样回落到托管的 `ubuntu-24.04` 默认,并不新增 `blacksmith` 分支。 diff --git a/.agents/notes/implemented/process/2026-09-11-persistence-type-history.i18n.yaml b/.agents/notes/implemented/process/2026-09-11-persistence-type-history.i18n.yaml index 246e224b7f..74cacfcfd5 100644 --- a/.agents/notes/implemented/process/2026-09-11-persistence-type-history.i18n.yaml +++ b/.agents/notes/implemented/process/2026-09-11-persistence-type-history.i18n.yaml @@ -9,14 +9,14 @@ en: c8e926bfc65d7195 zh: e0e007960b68fe80 /agent-note-in-tree-persistence-type-history/decision: - en: 94be3d6c7df3063b - zh: 7e8fb08d7fd95f85 + en: 3d4b2935c4db54b2 + zh: eb0da7083e301ef4 /agent-note-in-tree-persistence-type-history/decision/finalized-checkpoints: en: b508035a163340a3 zh: ba5b2b7e8cef4b2f /agent-note-in-tree-persistence-type-history/alternatives-considered: - en: 1cf6d0a5555ae50a - zh: 70ea1ba535e09442 + en: a581d63455788d48 + zh: 0a3781d2f827e35d /agent-note-in-tree-persistence-type-history/consequences: en: 672c8cb59d291dc2 zh: cbec90fbca92320a diff --git a/.agents/notes/implemented/process/2026-09-11-persistence-type-history.md b/.agents/notes/implemented/process/2026-09-11-persistence-type-history.md index add0904f42..5c98588230 100644 --- a/.agents/notes/implemented/process/2026-09-11-persistence-type-history.md +++ b/.agents/notes/implemented/process/2026-09-11-persistence-type-history.md @@ -16,6 +16,8 @@ One normalized type model supplies the readable persistence catalog, the complet The dedicated [persistence-change records](../../../../docs/persistence-changes/README.md) bind acknowledgement to the after digest of each affected root. A generated companion stores complete after schemas. The initial record covers all roots; later records name each root's predecessor. A predecessor's after schema supplies the next before schema. Verification rejects ambiguous history and requires current source to match the terminal recorded state without consulting Git history or remote services. +Every schema file stores one node table keyed by a 16-hexadecimal-digit prefix of each node's canonical subgraph digest; roots and types locate their graphs through their own digests. A type change therefore rewrites only the changed nodes, their ancestors, and the affected digests. Parsers accept only the exact generated text, and all committed files share this layout. + Automatic classification permits optional body additions, required-to-optional body changes, and ordinary event additions within one version. The scoped [source-attribution policy](2026-09-17-persistence-schema-review.md) also permits qualified new producer kinds when both saved schemas opt in. The [event-payload version rule](../../../../docs/persistence-changes/README.md#compatibility-rules) admits higher numeric payload versions when every old alternative remains readable and records the older-reader limitation. Other structural changes require an explicit same-version choice in the recording CLI or an increasing header version. Every structural difference needs an acknowledgement; its Compatibility and Verification sections explain both reader directions and executed checks. CI validates saved decisions without inferring how they were authored. The [format-version procedure](../../../../docs/cookbook/adding-a-session-format-version.md) owns adjacent migration work when a bump is required. The persistence-change document kind preserves compatibility reasoning and evidence for a historical type transition. Agent Notes retain mechanism-level decisions; they do not become a growing inventory of individual acknowledgements. Machine declarations remain identical across the bilingual pair and are parsed once. @@ -44,6 +46,8 @@ Finalization records acceptance independently of publication. The [status record **Infer behavior from types or require proof of human review.** A type graph cannot establish replay semantics, and a recorded choice cannot establish how it was authored. The checker validates declared transitions and header-version constraints. Reviewers inspect actual reader behavior and the acknowledgement’s explanation independently. +**Keep positional node indices or also read the previous layout.** Positional indices renumber every later node when a union gains a variant, and per-root graphs repeat shared types, so a one-variant change rewrote thousands of lines. Digest-keyed nodes keep diffs proportional to the change. Reading two layouts would leave two parsers to maintain and two accepted texts per inventory; migrating every committed file once keeps a single format. + ## Consequences Authors retain one snapshot per affected root per accepted transition and resolve history forks when integrating competing changes to that root. Independent roots can advance without rewriting unrelated acknowledgements. The [cookbook](../../../../docs/cookbook/reviewing-persistence-type-changes.md) provides the local authoring and verification procedure. diff --git a/.agents/notes/implemented/process/2026-09-11-persistence-type-history.zh.md b/.agents/notes/implemented/process/2026-09-11-persistence-type-history.zh.md index fade8ff41b..dae77a7810 100644 --- a/.agents/notes/implemented/process/2026-09-11-persistence-type-history.zh.md +++ b/.agents/notes/implemented/process/2026-09-11-persistence-type-history.zh.md @@ -16,6 +16,8 @@ Status: implemented 专用的[持久化变更记录](../../../../docs/persistence-changes/README.zh.md)将确认绑定到每个受影响根的变更后摘要。生成的伴随文件保存完整的变更后 schema。初始记录覆盖所有根;后续记录为每个根指定前驱。前驱的变更后 schema 提供下一次的变更前 schema。验证拒绝有歧义的历史,并要求当前源码匹配最终记录状态,无需查询 Git 历史或远端服务。 +每个 schema 文件保存一张节点表,以每个节点规范子图摘要的 16 位十六进制前缀为键;根与类型通过各自的摘要定位其图。因此类型变更只改写发生变化的节点、其祖先节点以及受影响的摘要。解析器只接受精确生成的文本,所有已提交文件共用此布局。 + 自动分类允许可选事件体新增、事件体属性从必选改为可选,以及普通事件新增保持同一版本。当双方保存的 schema 均声明支持时,受限的[消息源归属策略](2026-09-17-persistence-schema-review.zh.md)也允许添加符合条件的生产者 kind。[事件载荷版本规则](../../../../docs/persistence-changes/README.zh.md#compatibility-rules)允许在每个旧分支仍可读取时增加更高的数字载荷版本,并记录旧读取器限制。其他结构变更要求在记录 CLI 中显式选择同版本,或递增头版本。每个结构差异都需要确认;其兼容性与验证部分说明两个方向的读取器行为及已执行检查。CI 验证保存的决策,不推断其编写方式。需要升版本时,[格式版本流程](../../../../docs/cookbook/adding-a-session-format-version.zh.md)负责相邻迁移工作。 持久化变更文档类型保留历史类型转换的兼容性说明和证据。Agent Note 保留机制层面的决策,不积累单次确认清单。双语对中的机器声明保持相同,且只解析一次。 @@ -44,6 +46,8 @@ Status: implemented **从类型推断行为,或要求人类评审证明。** 类型图无法证明回放语义,所记录的选择也无法证明其编写方式。检查器验证声明的转换与头版本约束。评审者独立检查实际读取器行为与确认说明。 +**保留按位置编号的节点索引,或同时读取旧布局。** 联合类型新增分支时,按位置编号的索引会为其后的每个节点重新编号,而逐根图会重复共享类型,因此单个分支变更曾改写数千行。以摘要为键的节点使差异规模与变更成正比。同时读取两种布局需要维护两个解析器,且每个清单会有两种可接受文本;将所有已提交文件一次性迁移后只保留单一格式。 + ## 影响 作者为每次已接受转换中的每个受影响根保留一份快照,并在集成该根的竞争变更时解决历史分叉。独立根可以演进而不改写无关确认。[实操手册](../../../../docs/cookbook/reviewing-persistence-type-changes.zh.md)提供本地编写与验证流程。 diff --git a/.agents/notes/implemented/process/2026-09-15-shipped-optional-bundles.i18n.yaml b/.agents/notes/implemented/process/2026-09-15-shipped-optional-bundles.i18n.yaml index e3ffc60e70..13804455ce 100644 --- a/.agents/notes/implemented/process/2026-09-15-shipped-optional-bundles.i18n.yaml +++ b/.agents/notes/implemented/process/2026-09-15-shipped-optional-bundles.i18n.yaml @@ -9,8 +9,8 @@ en: dc43ba84345e1405 zh: dbc03572ab7710d2 /agent-note-ship-optional-bundles-with-the-installation/decision: - en: 5210b3b746cfee78 - zh: f1907f4807ae0098 + en: 1362533f342b31f4 + zh: 90689262acefb43d /agent-note-ship-optional-bundles-with-the-installation/alternatives-considered: en: 95b48b61757f1f13 zh: e9fb2d6ea492a4bc diff --git a/.agents/notes/implemented/process/2026-09-15-shipped-optional-bundles.md b/.agents/notes/implemented/process/2026-09-15-shipped-optional-bundles.md index 151cc8ccad..c55de4e8fd 100644 --- a/.agents/notes/implemented/process/2026-09-15-shipped-optional-bundles.md +++ b/.agents/notes/implemented/process/2026-09-15-shipped-optional-bundles.md @@ -10,7 +10,7 @@ The Web plugin page manages only the bundles a person installed into the profile ## Decision -The launcher names in `OPTIONAL_BUNDLES` (`packages/boot/app-boot/src/profile.ts`, beside the profile templates) the bundles the installation ships for a person to switch on. Each must be a runtime dependency of `apps/cli` that declares `dsh.bundle.patch`, and no shipped profile template selects it. The plugin manager's `listBundles` reports such a bundle as `optional`: switched off until selected, never removable, resolved from the installation like any installation-supplied bundle. The Web plugin page opens its Official group with the optional bundles, tagged experimental when the package is experimental, ahead of the profile's own installed bundles. +The launcher names in `OPTIONAL_BUNDLES` (`packages/boot/app-boot/src/profile.ts`, beside the profile templates) the bundles the installation ships for a person to switch on. Each must be a runtime dependency of `apps/cli` that declares `dsh.bundle.patch`, and no shipped profile template selects it. The plugin manager's `listBundles` reports such a bundle as `optional`: switched off until selected, never removable, resolved from the installation like any installation-supplied bundle. The Web plugin page presents offered optional bundles in its Official group, tagged experimental when the package is experimental, ahead of the profile's own installed bundles. The [persistent-terminal bundle](../../../../packages/experimental/terminal-bundle/README.md) remains available to the Host but has no page switch, even when enabled; its existing profile selection remains intact. Default-product isolation keeps its rules with one declared exception: an optional bundle's dependency graph is outside the default product. The static gate skips the `dependencies` edge from `@deepseek-ai/dsh` to a listed bundle and still rejects a runtime import, a shipped composition, a preset, or a default template that names it, an experimental dependency the list does not name, and a listed name that is not a runtime dependency or not a bundle. The workspace-constraints check accepts the same `dependencies` edges and no other runtime section, and the packed-install release check skips them from the installed entry package while requiring each listed bundle to be installed. diff --git a/.agents/notes/implemented/process/2026-09-15-shipped-optional-bundles.zh.md b/.agents/notes/implemented/process/2026-09-15-shipped-optional-bundles.zh.md index 9ddd54da88..7c82d124ab 100644 --- a/.agents/notes/implemented/process/2026-09-15-shipped-optional-bundles.zh.md +++ b/.agents/notes/implemented/process/2026-09-15-shipped-optional-bundles.zh.md @@ -10,7 +10,7 @@ Web 插件页只管理用户装进 profile 的组合包。像 Agent Teams 这样 ## 决策 -启动器在 `OPTIONAL_BUNDLES`(`packages/boot/app-boot/src/profile.ts`,与 profile 模板并列)里点名安装随附、供用户开启的组合包。每一个都必须是 `apps/cli` 声明了 `dsh.bundle.patch` 的运行时依赖,且不被任何随附 profile 模板选中。插件管理器的 `listBundles` 把这类组合包报告为 `optional`:选中前保持关闭、永不可卸载、像其他安装提供的组合包一样从安装目录解析。Web 插件页以可选组合包开启「官方」分组,属于实验包的带实验性标签,排在 profile 自己已安装的组合包之前。 +启动器在 `OPTIONAL_BUNDLES`(`packages/boot/app-boot/src/profile.ts`,与 profile 模板并列)里点名安装随附、供用户开启的组合包。每一个都必须是 `apps/cli` 声明了 `dsh.bundle.patch` 的运行时依赖,且不被任何随附 profile 模板选中。插件管理器的 `listBundles` 把这类组合包报告为 `optional`:选中前保持关闭、永不可卸载、像其他安装提供的组合包一样从安装目录解析。Web 插件页在「官方」分组展示可供选择的可选组合包,属于实验包的带实验性标签,排在 profile 自己已安装的组合包之前。[持久终端组合包](../../../../packages/experimental/terminal-bundle/README.zh.md)仍可供 Host 使用,但即使已启用也不提供页面开关;已有的 profile 选择保持不变。 默认产品隔离的规则保持不变,只声明一个例外:可选组合包的依赖图在默认产品之外。静态门禁跳过从 `@deepseek-ai/dsh` 到列表中组合包的 `dependencies` 边,仍然拒绝运行时 import、随附组合、preset 或默认模板对它的引用,拒绝列表没有点名的实验依赖,也拒绝不是运行时依赖或不是组合包的列表项。workspace 约束检查接受同样的 `dependencies` 边而不接受其他运行时依赖段;发布时的 packed-install 检查对已安装入口包跳过这些边,并要求列表中的每个组合包都已安装。 diff --git a/.agents/notes/implemented/process/2026-10-05-pr-artifact-typechecks.i18n.yaml b/.agents/notes/implemented/process/2026-10-05-pr-artifact-typechecks.i18n.yaml index 1ab57007ef..121838e838 100644 --- a/.agents/notes/implemented/process/2026-10-05-pr-artifact-typechecks.i18n.yaml +++ b/.agents/notes/implemented/process/2026-10-05-pr-artifact-typechecks.i18n.yaml @@ -9,8 +9,8 @@ en: e5c984fa32cc5e69 zh: 25d5742b2106d830 /agent-note-pr-artifact-builds-and-aggregate-typescript-checks/decision: - en: 26a903fdfeffcea7 - zh: f6ea094ec32a7b14 + en: 8353d57b20ca01c3 + zh: e0b74906846d759c /agent-note-pr-artifact-builds-and-aggregate-typescript-checks/alternatives-considered: en: f61da066f9ddcd62 zh: 816fd6e0063017cd diff --git a/.agents/notes/implemented/process/2026-10-05-pr-artifact-typechecks.md b/.agents/notes/implemented/process/2026-10-05-pr-artifact-typechecks.md index b6f5bc1743..0b403e7892 100644 --- a/.agents/notes/implemented/process/2026-10-05-pr-artifact-typechecks.md +++ b/.agents/notes/implemented/process/2026-10-05-pr-artifact-typechecks.md @@ -20,7 +20,7 @@ The Python executable builder selects `build:artifacts` artifact compilation wit PR artifact builds in CI invoke the root manifest's `build:artifacts`; a PR that edits these root manifest scripts activates the separate native-addon platform matrix, a trade-off [the build split](2026-10-11-build-artifacts-check-split.md) records. The vendor rehearsal and benchmark lanes invoke the internal build helpers directly. -Job inventory, runner selectors, and benchmark sample counts stay fixed; gate and worker ceilings do not increase. The two compatibility cases run one job at a time; the two-target Python runtime matrix starts after the keyless Python SDK job succeeds. Four Linux lanes, the Python SDK, three Windows lanes, and one compatibility job permit at most nine worker jobs initially. Once the SDK job finishes, the remaining seven independent lanes, one compatibility job, and two runtime targets permit at most ten. The runtime plan and wheel stages precede those targets and each use one job. This permits runtime validation to overlap the Node 24.9 build within the original ten-job ceiling. Coverage partitions reuse compact file-cost metadata within their platform and runner pool. Parallel release packing uses pnpm's existing bounded scheduler; serial publication keeps family order. +Job inventory, runner selectors, and benchmark sample counts stay fixed; gate and worker ceilings do not increase. The two compatibility cases run one job at a time; both x64 Python runtime targets start after the keyless Python SDK job succeeds in CI Python. Four Linux lanes, one compatibility job, three Windows lanes, and the Python SDK job permit at most nine worker jobs initially. Once the SDK job finishes, those four Linux lanes, one compatibility job, and three Windows lanes run beside the two runtime targets, permitting at most ten. Each runtime caller's plan and wheel stages precede its target and each use one job. This permits runtime validation to overlap the Node 24.9 build within the original ten-job ceiling. Coverage partitions reuse compact file-cost metadata within their platform and runner pool. Parallel release packing uses pnpm's existing bounded scheduler; serial publication keeps family order. Each Node version runs the same eight source compatibility specs in one serial Vitest invocation. Both file parallelism and the CLI worker limit are fixed to one, and the gate sets `VITEST_MAX_WORKERS=1` because that environment variable overrides Vitest's CLI limit. Files retain fork isolation; the build-backed Node 24.9 lazy-search smoke keeps its separate build dependency. The original seven-spec paired samples preserve all 253 assertion outcomes and reduce startup-only median time from 11.52 to 8.69 seconds, without increasing owned descendant-process peak. diff --git a/.agents/notes/implemented/process/2026-10-05-pr-artifact-typechecks.zh.md b/.agents/notes/implemented/process/2026-10-05-pr-artifact-typechecks.zh.md index 52756c1176..56fa7cbaa5 100644 --- a/.agents/notes/implemented/process/2026-10-05-pr-artifact-typechecks.zh.md +++ b/.agents/notes/implemented/process/2026-10-05-pr-artifact-typechecks.zh.md @@ -20,7 +20,7 @@ Python executable builder 在验证 runtime closure 后,通过 `--build-artifa CI 的 PR 产物构建调用 root manifest 中的 `build:artifacts`;修改 root manifest 中这些 script 的 PR 会激活独立的 native-addon 平台矩阵,[构建拆分](2026-10-11-build-artifacts-check-split.zh.md)记录这一取舍。vendor 演练与 benchmark lane 直接调用内部 build helper。 -job 清单、runner 选择和 benchmark sample 数量保持固定;gate 与 worker 上限不提高。两个 compatibility case 每次运行一个 job;双目标 Python runtime 矩阵在 keyless Python SDK job 成功后启动。四个 Linux lane、Python SDK、三个 Windows lane 和一个 compatibility job,初始最多占用九个 worker job。SDK job 完成后,剩余七个独立 lane、一个 compatibility job 和两个 runtime target,最多占用十个 job。runtime plan 与 wheel 阶段先于这些 target,各占一个 job。这允许 runtime 验证与 Node 24.9 构建重叠,同时保持原有十个 job 的上限。覆盖率分区在各自的平台和 runner pool 内复用精简的文件成本元数据。并行 release 打包使用 pnpm 现有的有界调度器;串行发布保持 family 顺序。 +job 清单、runner 选择和 benchmark sample 数量保持固定;gate 与 worker 上限不提高。两个 compatibility case 每次运行一个 job;两个 x64 Python runtime target 都在 CI Python 中于 keyless Python SDK job 成功后启动。四个 Linux lane、一个 compatibility job、三个 Windows lane 与 Python SDK job,初始最多占用九个 worker job。SDK job 完成后,这四个 Linux lane、一个 compatibility job 与三个 Windows lane 与两个 runtime target 并行,最多占用十个 job。每个 runtime 调用方的 plan 与 wheel 阶段先于其 target,各占一个 job。这允许 runtime 验证与 Node 24.9 构建重叠,同时保持原有十个 job 的上限。覆盖率分区在各自的平台和 runner pool 内复用精简的文件成本元数据。并行 release 打包使用 pnpm 现有的有界调度器;串行发布保持 family 顺序。 每个 Node 版本在一次串行 Vitest 调用中运行相同的八个 source compatibility spec。文件并行和 CLI worker 上限均固定为一,gate 还设置 `VITEST_MAX_WORKERS=1`,因为该环境变量会覆盖 Vitest 的 CLI 上限。文件保持 fork 隔离;依赖构建的 Node 24.9 lazy-search smoke 保留独立的 build 依赖。原来七个 spec 的成对样本保持全部 253 个 assertion outcome,将启动开销比较的 median 从 11.52 秒降至 8.69 秒,且不增加所拥有 descendant process 的峰值。 diff --git a/.agents/notes/implemented/process/2026-10-20-weighted-approval-status-gate-with-check-run-label.i18n.yaml b/.agents/notes/implemented/process/2026-10-20-weighted-approval-status-gate-with-check-run-label.i18n.yaml new file mode 100644 index 0000000000..cc5e19892d --- /dev/null +++ b/.agents/notes/implemented/process/2026-10-20-weighted-approval-status-gate-with-check-run-label.i18n.yaml @@ -0,0 +1,22 @@ +# Bilingual-pair consistency record for 2026-10-20-weighted-approval-status-gate-with-check-run-label.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/process/2026-10-20-weighted-approval-status-gate-with-check-run-label.md +/agent-note-weighted-approval-keeps-its-commit-status-gate-and-adds-a-decision-check-run: + en: a1f0f9fd657c7d35 + zh: e8fa02fb5bde6075 +/agent-note-weighted-approval-keeps-its-commit-status-gate-and-adds-a-decision-check-run/problem: + en: 8e854872edfc0729 + zh: 6f3ed495fcb690a4 +/agent-note-weighted-approval-keeps-its-commit-status-gate-and-adds-a-decision-check-run/decision: + en: 03de94946d7532d7 + zh: cf2f585f715d3295 +/agent-note-weighted-approval-keeps-its-commit-status-gate-and-adds-a-decision-check-run/verification: + en: 8298cff64cce6467 + zh: a121d509c2302c00 +/agent-note-weighted-approval-keeps-its-commit-status-gate-and-adds-a-decision-check-run/alternatives-considered: + en: 197255cd82aabef7 + zh: dc03f4bf77150f22 +/agent-note-weighted-approval-keeps-its-commit-status-gate-and-adds-a-decision-check-run/consequences: + en: 25e1ac1c8cab089a + zh: 84e10cccb1b9eda7 diff --git a/.agents/notes/implemented/process/2026-10-20-weighted-approval-status-gate-with-check-run-label.md b/.agents/notes/implemented/process/2026-10-20-weighted-approval-status-gate-with-check-run-label.md new file mode 100644 index 0000000000..386ff345df --- /dev/null +++ b/.agents/notes/implemented/process/2026-10-20-weighted-approval-status-gate-with-check-run-label.md @@ -0,0 +1,37 @@ +# Agent Note: Weighted approval keeps its commit status gate and adds a decision check run + +Status: implemented + +English | [中文](2026-10-20-weighted-approval-status-gate-with-check-run-label.zh.md) + +## Problem + +The `weighted approval` commit status is the record this repository's branch rules require, and below the approval threshold it stays `pending`. A commit status has no action-required state, so readers cannot tell an approval wait from running work. Moving the gate to a check run does not work: after that change, the check run completed as `success` on the pull-request head and both the REST API and `gh pr checks` listed it, while the merge box kept reporting the same `weighted approval decision` context as `Expected — Waiting for status to be reported`, and two pull requests needed an administrator bypass. That change was reverted. + +## Decision + +The publisher keeps writing the `weighted approval` commit status exactly as before: `pending` while the policy is unmet, `success` when it holds, and `error` when setup or evaluation fails. Branch rules keep requiring that status. That behavior, including keeping an unmet policy `pending`, is what the [archived decision](../../archived/process/2026-09-09-blocked-weighted-approvals-remain-pending.md) records; this change adds the label beside it instead of changing the gate. + +The publisher additionally writes a `weighted approval decision` check run for the same evaluation: `action_required` while the policy is unmet, `success` when it holds, and `failure` when setup or evaluation fails. The check run's title carries the score or the blocker. Branch rules do not require it, so it never blocks a merge; it names the blocked state beside the status that does. + +A head keeps one GitHub Actions run with the check-run name: the publisher updates the head's existing run and creates one only when none exists. The setup phase creates the run as `in_progress` on a head without one and deals with a published success by rewriting its conclusion to `action_required`, because GitHub accepts but does not apply a return to `in_progress`. A failed check-run write is logged and never changes the status, so a token without the checks permission, or a check-run API failure, leaves the gate as the status wrote it. The publisher identifies its own runs by the `external_id` marker it sets on creation, which excludes check runs that workflow jobs produce for a same-named job. It revokes a published `success` before dependency setup, because GitHub accepts but does not apply a return to `in_progress`. + +## Verification + +[Approval policy tests](../../../../.github/review-ownership/check-approval.test.mjs) pin the unchanged status states, the check-run conclusion for a met and an unmet policy, the phase writes, revoking a published success through its conclusion, one run per head, and invalid list responses. [Workflow tests](../../../../scripts/ci-workflow.spec.ts) pin the `checks: write` and `statuses: write` permissions and the setup phase commands. + +The check-run limit was measured on 2026-10-20 with the reverted attempt on this repository: the `weighted approval decision` check run completed as `success` on the pull-request head, the REST API and `gh pr checks` listed it, and the merge box kept reporting the same context as expected. + +## Alternatives considered + +**Require the check run instead of the commit status.** Measured above: the ruleset does not match a check run published through the default-branch workflow's token, so the gate cannot be satisfied. + +**Publish the commit status as `failure` while the policy is unmet.** The status would complete, but this changes the required record's meaning and would report an unmet, reversible condition as a malfunction. + +**Publish only the check run and leave the status untouched.** The status would keep its last state from an earlier evaluation, so the gate and the label could disagree. + +**Publish the required check run from a dedicated GitHub App.** A check run whose app the ruleset names would let the required entry carry the action-required conclusion itself. The repository has no App with the checks permission; with one, the label could move onto the required record and the status could be retired. + +## Consequences + +A job that ends between the `pending` write and the fallback step, such as a lost runner or a cancellation that skips the final step, leaves the status `pending`; that is the original symptom of this status, not a regression from the added label. While reviewers are pending, the merge box reports `weighted approval` as pending (the gate) and the `weighted approval decision` entry as `action_required`, with the score or blocker in its title; `gh pr checks` lists both. A published success is revoked by rewriting the check run's conclusion, and the status follows the unchanged policy states. diff --git a/.agents/notes/implemented/process/2026-10-20-weighted-approval-status-gate-with-check-run-label.zh.md b/.agents/notes/implemented/process/2026-10-20-weighted-approval-status-gate-with-check-run-label.zh.md new file mode 100644 index 0000000000..75089f2f13 --- /dev/null +++ b/.agents/notes/implemented/process/2026-10-20-weighted-approval-status-gate-with-check-run-label.zh.md @@ -0,0 +1,37 @@ +# Agent Note: Weighted approval 保留其 commit status 门禁并新增决策 check run + +Status: implemented + +[English](2026-10-20-weighted-approval-status-gate-with-check-run-label.md) | 中文 + +## 问题 + +`weighted approval` commit status 是本仓库分支规则要求的记录,在批准阈值下方时保持 `pending`。commit status 没有 action-required 状态,读者无法区分等待批准与正在运行的工作。把门禁改成 check run 并不可行:该改动后 check run 在 PR head 上以 `success` 完成,REST API 与 `gh pr checks` 都能列出它,而合并框始终把同一上下文报告为 `Expected — Waiting for status to be reported`,两个 PR 需要管理员绕过才能合并。该改动已回退。 + +## 决定 + +publisher 继续完全按原样写 `weighted approval` commit status:策略未满足时为 `pending`,满足时为 `success`,安装或评估失败时为 `error`。分支规则继续要求该 status。该行为——包括未满足的策略保持 `pending`——正是[已归档决定](../../archived/process/2026-09-09-blocked-weighted-approvals-remain-pending.md)记录的;本次变更在该门禁旁边新增标签,而不改变门禁。 + +publisher 为同一次评估额外写一个 `weighted approval decision` check run:策略未满足时为 `action_required`,满足时为 `success`,安装或评估失败时为 `failure`。check run 的标题携带分数或 blocker。分支规则不要求它,因此它从不阻止合并;它在会阻止合并的那条 status 旁边命名被阻止的状态。 + +每个 head 只保留一个同名 GitHub Actions run:publisher 更新 head 上已有的 run,仅在不存在时创建。setup 阶段对没有 run 的 head 以 `in_progress` 创建,并把已发布的 success 的 conclusion 改写为 `action_required`,因为 GitHub 接受但不执行改回 `in_progress` 的更新。check run 写入失败会被记录,且从不改变 status,因此没有 checks 权限的 token 或 check-run API 失败时,门禁保持 status 写入的结果。publisher 用创建时设置的 `external_id` 标记识别自己的 run,从而排除 workflow job 为同名 job 产生的 check run。它在依赖安装前撤销已发布的 `success`,因为 GitHub 接受但不执行改回 `in_progress` 的更新。 + +## 验证 + +[批准策略测试](../../../../.github/review-ownership/check-approval.test.mjs)锁定未改动的 status 状态、策略满足与未满足两种情况下 check run 的 conclusion、阶段写入、通过 conclusion 撤销已发布的成功、每个 head 只有一个 run,以及无效的列表响应。[工作流测试](../../../../scripts/ci-workflow.spec.ts)锁定 `checks: write` 与 `statuses: write` 权限以及 setup 阶段命令。 + +check run 的限制于 2026-10-20 在本仓库用被回退的尝试实测:`weighted approval decision` check run 在 PR head 上以 `success` 完成,REST API 与 `gh pr checks` 都能列出它,而合并框始终把同一个 `weighted approval decision` 上下文报告为 expected。 + +## 考虑过的替代方案 + +**把 check run 作为必需检查。** 见上面的实测:ruleset 不匹配默认分支 workflow 的 token 发布的 check run,门禁无法满足。 + +**策略未满足时把 commit status 发布为 `failure`。** status 会完成,但改变了必需记录的含义,并把尚未满足且可撤销的条件报告为故障。 + +**只发布 check run 而不动 status。** status 会保留上一次评估的状态,门禁与标签可能不一致。 + +**由专用 GitHub App 发布必需 check run。** 由 ruleset 指定 app 的 check run 可以让必需条目本身携带 action-required 结论。本仓库没有具备 checks 权限的 App;有了之后可以把标签移到必需记录上并停用 status。 + +## 后果 + +job 在 `pending` 写入与兜底步骤之间结束(例如 runner 丢失,或取消导致最后一步未执行)会让 status 保持 `pending`;这是该 status 既有的表现,不是新增标签带来的回归。评审未完成时,合并框把 `weighted approval` 报告为 pending(门禁),并把 `weighted approval decision` 条目报告为 `action_required`,标题里是分数或 blocker;`gh pr checks` 会列出两者。已发布的 success 通过改写 check run 的 conclusion 撤销,status 继续按未改动的策略状态变化。 diff --git a/.agents/notes/implemented/simplification/2026-10-13-text-derived-message-quotes.i18n.yaml b/.agents/notes/implemented/simplification/2026-10-13-text-derived-message-quotes.i18n.yaml new file mode 100644 index 0000000000..0b25c139c2 --- /dev/null +++ b/.agents/notes/implemented/simplification/2026-10-13-text-derived-message-quotes.i18n.yaml @@ -0,0 +1,22 @@ +# Bilingual-pair consistency record for 2026-10-13-text-derived-message-quotes.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/simplification/2026-10-13-text-derived-message-quotes.md +/agent-note-recognize-message-quotes-from-markdown-text: + en: a2abd7f693937d51 + zh: f9e4bb817536dbbe +/agent-note-recognize-message-quotes-from-markdown-text/problem: + en: d71ebbacbd9bd8a3 + zh: 45c1f7fd1d797f61 +/agent-note-recognize-message-quotes-from-markdown-text/decision: + en: 0abb09d5ba396f69 + zh: dd067b0ca5fd376f +/agent-note-recognize-message-quotes-from-markdown-text/alternatives-considered: + en: 05c6009796927583 + zh: 9f08229767c07b31 +/agent-note-recognize-message-quotes-from-markdown-text/verification: + en: 790598fd0f3f0c67 + zh: 0399e0e322ffca40 +/agent-note-recognize-message-quotes-from-markdown-text/consequences: + en: 21f96c04f6edcc1d + zh: 8ea3f42a8e981592 diff --git a/.agents/notes/implemented/simplification/2026-10-13-text-derived-message-quotes.md b/.agents/notes/implemented/simplification/2026-10-13-text-derived-message-quotes.md new file mode 100644 index 0000000000..2a6ecaa833 --- /dev/null +++ b/.agents/notes/implemented/simplification/2026-10-13-text-derived-message-quotes.md @@ -0,0 +1,29 @@ +# Agent Note: Recognize message quotes from Markdown text + +Status: implemented + +English | [中文](2026-10-13-text-derived-message-quotes.zh.md) + +## Problem + +Quote-only content indexes duplicate information in Markdown text and require coordinated prompt, source, echo, and queue metadata. The same visible blockquote can acquire different presentation and direct-input behavior depending on how it was created. + +## Decision + +Messages contain ordinary Markdown text. The shared blockquote matcher recognizes consecutive explicitly marked lines for UserText presentation. Hand-written and composer-created blockquotes have identical sent-message behavior. Quote previews display decoded text without running UserText rules over their contents. Session-reference and skill processing applies normally to quoted text; recognized Session labels retain the same presentation as the surrounding quote text. + +The [rich-text quote feature](../feature/2026-08-26-web-rich-text-quotes.md) retains atomic draft nodes and semantic restoration spans. This decision supersedes only its durable quote-index distinction. UserText nodes and recognition ranges remain derived values. + +## Alternatives considered + +**Keep quote-only indexes.** Indexes preserve a distinction between typed and inserted quotes, but that distinction is unnecessary for sent messages and requires metadata coordination across every producer and renderer. + +**Remove atomic draft spans.** Plain text cannot restore whole-node editing, undo, Session switching, or failed-send restoration. Draft spans remain browser-owned editing state. + +## Verification + +Matcher tests cover original offsets, explicit line markers, blank excerpts, and literal nested quotes. Composer tests assert ordinary text submission. The shipped Web scenario checks text-only persistence and quote display after reload. + +## Consequences + +Messages need no quote fields or content indexes. Typed blockquotes also display as quotes without changing direct-input processing. Recognition deliberately covers explicit quote lines rather than Markdown lazy continuation or fenced-code parsing. Source navigation still requires a separate durable-coordinate design. diff --git a/.agents/notes/implemented/simplification/2026-10-13-text-derived-message-quotes.zh.md b/.agents/notes/implemented/simplification/2026-10-13-text-derived-message-quotes.zh.md new file mode 100644 index 0000000000..c9ee20f906 --- /dev/null +++ b/.agents/notes/implemented/simplification/2026-10-13-text-derived-message-quotes.zh.md @@ -0,0 +1,29 @@ +# Agent Note: 从 Markdown 文本识别消息 quote + +Status: implemented + +[English](2026-10-13-text-derived-message-quotes.md) | 中文 + +## 问题 + +纯 quote content 索引重复了 Markdown 文本中的信息,并要求 prompt、source、回显与队列协同携带元数据。同样可见的 blockquote 会因创建方式不同而获得不同的展示与直接输入行为。 + +## 决策 + +消息包含普通 Markdown 文本。共享 blockquote 匹配器识别连续的显式标记行,用于 UserText 展示。手写与输入框创建的 blockquote 在发送后具有相同行为。Quote 预览直接展示还原后的文本,不对正文再次运行 UserText 规则。Session-reference 与 skill 处理照常作用于引用文本;已识别的 Session 标签与周围引用文字保持相同样式。 + +[富文本 quote 功能](../feature/2026-08-26-web-rich-text-quotes.zh.md)保留原子草稿节点与语义恢复范围。本决策仅取代其持久 quote 索引区分。UserText 节点与识别范围仍是派生值。 + +## 考虑过的替代方案 + +**保留纯 quote 索引。** 索引保留手写与插入 quote 的区别,但发送后的消息无需这一区分,且要求每个生产方与渲染器协同处理元数据。 + +**移除原子草稿范围。** 纯文本无法恢复整节点编辑、撤销、Session 切换或发送失败恢复。草稿范围仍是浏览器拥有的编辑状态。 + +## 验证 + +匹配器测试覆盖原始偏移、显式行标记、空白摘录与字面的嵌套 quote。Composer 测试断言普通文本提交。已发布 Web 场景检查仅文本的持久化与刷新后的 quote 展示。 + +## 后果 + +消息无需 quote 字段或 content 索引。手写 blockquote 也显示为 quote,不改变直接输入处理。识别有意限定为显式引用行,不涵盖 Markdown 懒惰续行或代码围栏解析。来源导航仍需独立设计持久坐标。 diff --git a/.agents/notes/implemented/testing/2026-08-23-installed-python-wheel-black-box-ci.i18n.yaml b/.agents/notes/implemented/testing/2026-08-23-installed-python-wheel-black-box-ci.i18n.yaml index 4f8b351852..7da2ecf5cb 100644 --- a/.agents/notes/implemented/testing/2026-08-23-installed-python-wheel-black-box-ci.i18n.yaml +++ b/.agents/notes/implemented/testing/2026-08-23-installed-python-wheel-black-box-ci.i18n.yaml @@ -21,8 +21,8 @@ en: 0f98f5b5f78b5603 zh: 95ee8bafa66d6b1b /agent-note-installed-wheel-python-runtime-pull-request-validation/decision/required-targets: - en: 7a33cde000508475 - zh: 142fb5ec6b59a280 + en: ff9f43ef5d3480c8 + zh: eb8d94581522f1ec /agent-note-installed-wheel-python-runtime-pull-request-validation/existing-decisions-and-supersession: en: 04aa439d4e607324 zh: f4c1d24b6a418cc4 diff --git a/.agents/notes/implemented/testing/2026-08-23-installed-python-wheel-black-box-ci.md b/.agents/notes/implemented/testing/2026-08-23-installed-python-wheel-black-box-ci.md index dfa71a9787..5ef6fc6e91 100644 --- a/.agents/notes/implemented/testing/2026-08-23-installed-python-wheel-black-box-ci.md +++ b/.agents/notes/implemented/testing/2026-08-23-installed-python-wheel-black-box-ci.md @@ -30,7 +30,7 @@ Fork and Dependabot pull requests never receive the repository secret. Their nat ### Required targets -The pull-request `python-runtime` job calls the reusable builder for Linux x64 and Windows x64; master pushes select Linux arm64 and both macOS architectures under the [master-only platform policy](../../../../.github/workflows/ci-master.yml). Its aggregate result remains a dependency of `all checks passed`, so a failed, cancelled, or missing native carrier blocks the required verdict. The [sdk-runtime README](../../../../python/sdk-runtime/README.md) owns the Windows target and its PowerShell-specific minimal snapshot. +The pull-request `python-runtime` job in CI Python calls the reusable builder for Linux x64 and Windows x64; master pushes select Linux arm64 and both macOS architectures under the [master-only platform policy](../../../../.github/workflows/ci-master.yml). Its aggregate result is a dependency of CI Python's required verdict, `python checks passed`, so a failed, cancelled, or missing native carrier blocks the required verdict. The [sdk-runtime README](../../../../python/sdk-runtime/README.md) owns the Windows target and its PowerShell-specific minimal snapshot. ## Existing decisions and supersession diff --git a/.agents/notes/implemented/testing/2026-08-23-installed-python-wheel-black-box-ci.zh.md b/.agents/notes/implemented/testing/2026-08-23-installed-python-wheel-black-box-ci.zh.md index fe2e6b6ece..03b015e0a4 100644 --- a/.agents/notes/implemented/testing/2026-08-23-installed-python-wheel-black-box-ci.zh.md +++ b/.agents/notes/implemented/testing/2026-08-23-installed-python-wheel-black-box-ci.zh.md @@ -30,7 +30,7 @@ Fork 与 Dependabot 拉取请求永远不会获得仓库密钥。它们的原生 ### 必需目标 -拉取请求的 `python-runtime` job 针对 Linux x64 与 Windows x64 调用可复用构建器;master 推送根据[仅 master 平台策略](../../../../.github/workflows/ci-master.yml)选择 Linux arm64 与两种 macOS 架构。其聚合结果仍是 `all checks passed` 的依赖项,因此任一原生载体失败、取消或缺失都会阻止必需判定通过。[sdk-runtime README](../../../../python/sdk-runtime/README.zh.md) 负责 Windows 目标及其 PowerShell 专属极简快照。 +拉取请求的 `python-runtime` job 在 CI Python 中针对 Linux x64 与 Windows x64 调用可复用构建器;master 推送根据[仅 master 平台策略](../../../../.github/workflows/ci-master.yml)选择 Linux arm64 与两种 macOS 架构。其聚合结果是 CI Python 必需判定 `python checks passed` 的依赖项,因此任一原生载体失败、取消或缺失都会阻止必需判定通过。[sdk-runtime README](../../../../python/sdk-runtime/README.zh.md) 负责 Windows 目标及其 PowerShell 专属极简快照。 ## Existing decisions and supersession diff --git a/.agents/skills/dsh-prose-standard/SKILL.md b/.agents/skills/dsh-prose-standard/SKILL.md index bb8347823b..338ff29a21 100644 --- a/.agents/skills/dsh-prose-standard/SKILL.md +++ b/.agents/skills/dsh-prose-standard/SKILL.md @@ -55,7 +55,7 @@ This is not a one-way shortening pass. Add or restore prose when code, types, an - **Postmortems:** retain the incident sequence, evidence, causal chain, impact, and prevention. Remove repeated persuasion or implementation detail that does not establish causality. - **Skills and agent instructions:** state behavioral guardrails and explicit scope limitations such as “guidance, not a script/checklist.” Keep the workflow concise and link its source of truth. - **Examples and configuration comments:** explain access limits, non-obvious wiring or load order, security stance, replay behavior, exceptions, and likely misuse. Do not narrate entries that the configuration already shows. -- **Prompts and visible strings:** treat wording as behavior. Client UI copy belongs in typed locale dictionaries and reaches Cordis-free primitives as explicit localized props; inspect text, accessibility names, tooltips, placeholders, and format templates together, then run `verify-client-ui-i18n`. Update the owning runnable snapshot for model-visible text and repository-required GUI evidence. If the authorized scope has no owning scenario, leave the wording unchanged and report the deferral; do not silently fold it into a prose-only edit. +- **Prompts and visible strings:** treat wording as behavior. Client UI copy belongs in typed locale dictionaries and reaches Cordis-free primitives as explicit localized props; inspect text, accessibility names, tooltips, placeholders, and format templates together, then run `verify-client-ui-i18n`. English UI copy, including plugin `meta.title` and `meta.description`, uses sentence case: capitalize only the first word, proper nouns, and product names such as `Chrome DevTools`, `Git`, or `MCP` (`Git worktrees`, not `Git Worktrees`). Chinese copy keeps its own conventions. Update the owning runnable snapshot for model-visible text and repository-required GUI evidence. If the authorized scope has no owning scenario, leave the wording unchanged and report the deferral; do not silently fold it into a prose-only edit. - **Diagnostics:** name the failing subject or path, violated rule, and correction when it is non-obvious. Remove internal execution narration. Preserve searchable mechanism names and meaningful modal, temporal, or negative emphasis. Normalize decorative emphasis only. diff --git a/.github/AGENTS.md b/.github/AGENTS.md index 299f6426c7..35f0103169 100644 --- a/.github/AGENTS.md +++ b/.github/AGENTS.md @@ -1,6 +1,6 @@ # AGENTS.md — GitHub Actions -Run jobs on Windows runners (`windows-*` labels) under native `pwsh`. Native Windows build and process checks contribute to the pull-request `all checks passed` verdict; Wine runs Windows Node on hosted Linux only in `ci-master.yml`. Python runtime CI checks Linux/Windows x64 on pull requests and Linux ARM64 plus both macOS architectures on master pushes; releases retain all five targets ([platform policy](workflows/ci-master.yml)). `ci.yml` is pull-request-only. Master-only platform checks, Linux/Windows self-hosted standbys, and manual runner benchmarks live in `ci-master.yml`, which listens to master pushes and `workflow_dispatch`, not `pull_request`; separating workflow triggers keeps master-only jobs out of PR check panels. The master standbys validate the self-hosted failover targets; preserve the existing per-platform switches (values `selfhosted` for the in-house standbys and `blacksmith` for Blacksmith's hosted runners) and the Dependabot hosted fallback under the `selfhosted` values ([failover runbook](../.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.md), [blacksmith failover leg note](../.agents/notes/implemented/process/2026-09-09-blacksmith-failover-leg.md)). +Run jobs on Windows runners (`windows-*` labels) under native `pwsh`. Native Windows pull-request jobs live in `ci-windows.yml` and Python pull-request jobs in `ci-python.yml`, so each PR workflow can be re-run independently; their checks feed the required `windows checks passed` and `python checks passed` verdicts, as Linux jobs in `ci.yml` feed `all checks passed`; Wine runs Windows Node on hosted Linux only in `ci-master.yml`. Python runtime CI checks Linux/Windows x64 on pull requests and Linux ARM64 plus both macOS architectures on master pushes; releases retain all five targets ([platform policy](workflows/ci-master.yml)). `ci.yml`, `ci-windows.yml`, and `ci-python.yml` are pull-request-only. Master-only platform checks, Linux/Windows self-hosted standbys, and manual runner benchmarks live in `ci-master.yml`, which listens to master pushes and `workflow_dispatch`, not `pull_request`; separating workflow triggers keeps master-only jobs out of PR check panels. The master standbys validate the self-hosted failover targets; preserve the existing per-platform switches (values `selfhosted` for the in-house standbys and `blacksmith` for Blacksmith's hosted runners) and the Dependabot hosted fallback under the `selfhosted` values ([failover runbook](../.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.md), [blacksmith failover leg note](../.agents/notes/implemented/process/2026-09-09-blacksmith-failover-leg.md)). The manual [Windows packaging workflow](workflows/windows-package.yml) uses the dedicated self-hosted Windows x64 label `dsh-win-package-trial` under native `pwsh`; preserve this routing separately from the automatic CI lanes. diff --git a/.github/review-ownership/README.md b/.github/review-ownership/README.md index 26ae70e6f6..c115acf172 100644 --- a/.github/review-ownership/README.md +++ b/.github/review-ownership/README.md @@ -16,7 +16,7 @@ The [`weighted-approval` workflow](../workflows/weighted-approval.yml) publishes ## Approval scoring -The weighted approval workflow exposes two pull-request checks. The `weighted approval publisher` Actions job reports whether evaluation and status publication completed, while the `weighted approval` commit status carries the approval decision on the pull request head. Branch rules must require only the commit status with GitHub Actions as its expected source; a context-only requirement can accept a same-named status from another integration. The publisher marks the head pending before Python setup or lexer installation, so failed setup or an interrupted history fetch cannot leave a previous success in place. Setup and evaluation failures publish an error status. A completed evaluation returns `pending` below two approval points, while the pull request is a draft, or while a write-capable reviewer has an effective `CHANGES_REQUESTED` review; the blocker keeps the status pending even when counted approvals reach the threshold. It returns `success` only when the threshold is met, the pull request is ready, and no such blocker exists. If evaluation fails, the publisher writes an `error` status. +The weighted approval workflow exposes two pull-request checks. The `weighted approval publisher` Actions job reports whether evaluation and status publication completed, while the `weighted approval` commit status carries the approval decision on the pull request head. Branch rules must require only the commit status with GitHub Actions as its expected source; a context-only requirement can accept a same-named status from another integration. The publisher marks the head pending before Python setup or lexer installation, so failed setup or an interrupted history fetch cannot leave a previous success in place. Setup and evaluation failures publish an error status. A completed evaluation returns `pending` below two approval points, while the pull request is a draft, or while a write-capable reviewer has an effective `CHANGES_REQUESTED` review; the blocker keeps the status pending even when counted approvals reach the threshold. It returns `success` only when the threshold is met, the pull request is ready, and no such blocker exists. If evaluation fails, the publisher writes an `error` status. The publisher also writes a `weighted approval decision` check run that reports the same decision as a conclusion — `action_required` while the policy is unmet, `success` when it holds, and `failure` on setup or evaluation failures — with the score or blocker in its title. Branch rules do not require that check run, so it never blocks a merge; it names the blocked state beside the status that gates the merge. A failed check-run write is logged and never changes the status. Reviewers whose calculated base repository permission is `write` or `admin` count. The [approval policy](approval-policy.json) gives `@07akioni`, `@imccyu`, `@tianyicui`, `@tianyicui-bot`, `@turtle1999`, and `@turtle2099` two points each; every other write-capable reviewer gets one point. The pull-request author’s own review and reviewers without write permission do not count. diff --git a/.github/review-ownership/check-approval.mjs b/.github/review-ownership/check-approval.mjs index 5792299c46..1f499ff170 100644 --- a/.github/review-ownership/check-approval.mjs +++ b/.github/review-ownership/check-approval.mjs @@ -11,6 +11,8 @@ const API_VERSION = '2026-03-10' const MAX_PULL_REQUEST_RECORDS = 3_000 const PAGE_SIZE = 100 const STATUS_CONTEXT = 'weighted approval' +const CHECK_NAME = 'weighted approval decision' +const CHECK_RUN_MARKER = 'weighted-approval-decision' const WRITABLE_PERMISSIONS = new Set(['admin', 'write']) const REVIEW_STATES = new Set(['APPROVED', 'CHANGES_REQUESTED', 'COMMENTED', 'DISMISSED', 'PENDING']) @@ -332,6 +334,7 @@ export async function runApprovalCheck({ event, policySource, api, runUrl, getOw } } catch (error) { await publishStatus(api, pull, 'error', 'Approval evaluation failed.', runUrl) + await tryPublishCheck(api, pull, 'failure', 'Approval evaluation failed.', runUrl, write) throw error } write(result.authorCredit @@ -343,6 +346,7 @@ export async function runApprovalCheck({ event, policySource, api, runUrl, getOw writeList(write, 'Blocking change requests', result.blockers.map(login => `@${login}`)) writeList(write, 'Ignored reviewers without write access', result.ignoredReviewers.map(login => `@${login}`)) await publishStatus(api, pull, result.state, result.description, runUrl) + await tryPublishCheck(api, pull, result.state === 'success' ? 'success' : 'action_required', result.description, runUrl, write) write(`Published ${JSON.stringify(STATUS_CONTEXT)} status ${JSON.stringify(result.state)}.`) try { await requestDelegatedReviews(result, api, write) @@ -354,13 +358,25 @@ export async function runApprovalCheck({ event, policySource, api, runUrl, getOw /** * Revoke a previous success before dependency installation, or report its failure. - * @param {{event: unknown, api: (path: string, options: object) => Promise, runUrl: string, phase: string}} options Publication inputs. - * @returns {Promise} Completion of the status write. + * @param {{event: unknown, api: (path: string, options: object) => Promise, runUrl: string, phase: string, write?: (line: string) => void}} options Publication inputs. + * @returns {Promise} Completion of the status write and the corresponding check-run write. */ -export async function publishApprovalPhase({ event, api, runUrl, phase }) { - if (!['pending', 'error'].includes(phase)) throw new Error('invalid approval setup phase') - await publishStatus(api, pullRequestFromEvent(event), phase, - phase === 'pending' ? 'Preparing approval evaluation.' : 'Approval setup or evaluation failed.', runUrl) +export async function publishApprovalPhase({ event, api, runUrl, phase, write = line => process.stdout.write(`${line}\n`) }) { + const pull = pullRequestFromEvent(event) + if (phase === 'pending') { + await publishStatus(api, pull, 'pending', 'Preparing approval evaluation.', runUrl) + try { + await revokeApproval(api, pull, runUrl) + } catch (error) { + write(`Decision check run not revoked; the commit status is unchanged: ${JSON.stringify(error instanceof Error ? error.message : String(error))}`) + } + return + } + if (phase === 'error') { + await publishStatus(api, pull, 'error', 'Approval setup or evaluation failed.', runUrl) + return tryPublishCheck(api, pull, 'failure', 'Approval setup or evaluation failed.', runUrl, write) + } + throw new Error('invalid approval setup phase') } /** @@ -467,6 +483,73 @@ async function reviewerPermission(api, repository, login) { return response.permission.toLowerCase() } +// The required gate is the commit status; this check run reports the same decision as a conclusion +// label and is not required. A failed label write is reported and never changes the gate, including +// for a replayed workflow whose token has no checks permission. +async function tryPublishCheck(api, pull, conclusion, title, runUrl, write) { + try { + await publishCheck(api, pull, conclusion, title, runUrl) + } catch (error) { + write(`Decision check run not published; the commit status is unchanged: ${JSON.stringify(error instanceof Error ? error.message : String(error))}`) + } +} + +async function publishCheck(api, pull, conclusion, title, runUrl) { + requireHttpsRunUrl(runUrl) + const body = { status: 'completed', conclusion, details_url: runUrl, output: { title, summary: `${title} [Workflow run](${runUrl})` } } + const runs = await decisionRuns(api, pull) + if (runs.length === 0) { + await api(`/repos/${pull.repository}/check-runs`, { + method: 'POST', + body: { name: CHECK_NAME, head_sha: pull.headSha, external_id: CHECK_RUN_MARKER, ...body }, + }) + return + } + for (const run of runs) { + if (!Number.isSafeInteger(run.id)) throw new Error('check run has no valid id') + await api(`/repos/${pull.repository}/check-runs/${run.id}`, { method: 'PATCH', body }) + } +} + +// A head without a decision starts in progress; a published success becomes action_required, so an +// interrupted evaluation cannot leave a success that its current inputs no longer support. +async function revokeApproval(api, pull, runUrl) { + requireHttpsRunUrl(runUrl) + const title = 'Preparing approval evaluation.' + const output = { title, summary: `${title} [Workflow run](${runUrl})` } + const runs = await decisionRuns(api, pull) + if (runs.length === 0) { + await api(`/repos/${pull.repository}/check-runs`, { + method: 'POST', + body: { name: CHECK_NAME, head_sha: pull.headSha, status: 'in_progress', details_url: runUrl, output }, + }) + return + } + for (const run of runs) { + if (!Number.isSafeInteger(run.id)) throw new Error('check run has no valid id') + // The label is not a gate; only a published success has to stop claiming the policy holds. + if (run.conclusion !== 'success') continue + await api(`/repos/${pull.repository}/check-runs/${run.id}`, { + method: 'PATCH', + body: { status: 'completed', conclusion: 'action_required', details_url: runUrl, output }, + }) + } +} + +// A completed check run never returns to in_progress: GitHub answers 200 and leaves it completed. +async function decisionRuns(api, pull) { + const listed = await api(`/repos/${pull.repository}/commits/${pull.headSha}/check-runs?check_name=${encodeURIComponent(CHECK_NAME)}&filter=all&per_page=100`) + if (!isRecord(listed) || !Array.isArray(listed.check_runs) || listed.total_count !== listed.check_runs.length) { + throw new Error('check-run list response is invalid or incomplete') + } + return listed.check_runs.filter(run => isRecord(run) && isRecord(run.app) && run.app.slug === 'github-actions' + && (run.external_id === CHECK_RUN_MARKER || run.external_id === '')) +} + +function requireHttpsRunUrl(runUrl) { + if (!/^https:\/\/[^\s]+$/u.test(runUrl)) throw new Error('workflow run URL must use HTTPS') +} + async function publishStatus(api, pull, state, description, runUrl) { if (!/^https:\/\/[^\s]+$/u.test(runUrl)) throw new Error('workflow run URL must use HTTPS') if (description.length > 140) throw new Error('commit status description exceeds 140 characters') diff --git a/.github/review-ownership/check-approval.test.mjs b/.github/review-ownership/check-approval.test.mjs index b5d0b6cbb3..979661be10 100644 --- a/.github/review-ownership/check-approval.test.mjs +++ b/.github/review-ownership/check-approval.test.mjs @@ -11,13 +11,18 @@ import { evaluateApproval as evaluateWithHistory, listPullRequestReviews, parseApprovalPolicy, - runApprovalCheck as runWithHistory, + runApprovalCheck as runPublisher, publishApprovalPhase, } from './check-approval.mjs' const policySource = readFileSync(new URL('approval-policy.json', import.meta.url), 'utf8') -const withoutComments = api => (path, options) => path.includes('/comments?') ? [] : api(path, options) +const emptyCheckRuns = { total_count: 0, check_runs: [] } +const isCheckRunsList = path => path.includes('/commits/') && path.includes('/check-runs?') +const withoutChecks = api => (path, options) => + isCheckRunsList(path) ? emptyCheckRuns : path.includes('/check-runs') ? {} : api(path, options) +const withoutComments = api => withoutChecks((path, options) => path.includes('/comments?') ? [] : api(path, options)) const evaluateApproval = options => evaluateWithHistory({ getMergedCount: async () => 0, ...options, api: withoutComments(options.api) }) +const runWithHistory = options => runPublisher({ ...options, api: withoutChecks(options.api) }) const runApprovalCheck = options => runWithHistory({ getMergedCount: async () => 0, ...options, api: withoutComments(options.api) }) const HEAD_SHA = '1234567890abcdef1234567890abcdef12345678' @@ -475,16 +480,94 @@ test('uses policy endpoints and formats only the displayed score', async () => { test('publishes setup phases without evaluating or installing dependencies', async () => { const states = [] + const phases = [] const options = { event: pullRequestEvent(), runUrl: 'https://github.example/run/1', - api: async (path, { body }) => { assert.match(path, /\/statuses\//u); states.push(body.state) }, + api: async (path, options = {}) => { + if (isCheckRunsList(path)) return emptyCheckRuns + if (path.includes('/check-runs')) { phases.push(options.body?.conclusion ?? options.body?.status); return {} } + assert.match(path, /\/statuses\//u) + states.push(options.body.state) + }, } await publishApprovalPhase({ ...options, phase: 'pending' }) await publishApprovalPhase({ ...options, phase: 'error' }) assert.deepEqual(states, ['pending', 'error']) + assert.deepEqual(phases, ['in_progress', 'failure']) await assert.rejects(publishApprovalPhase({ ...options, phase: 'success' }), /invalid approval setup phase/u) }) +test('keeps the status gate when the label write fails', async () => { + const writes = [] + const states = [] + await runPublisher({ + event: pullRequestEvent(), policySource, runUrl: 'https://github.example/run/1', + write: line => writes.push(line), + getOwnership: async () => ({ totalLines: 100, reviewerLines: { turtle2099: 25 } }), + api: async (path, options = {}) => { + if (path.includes('/reviews?')) return [review('turtle2099', 'APPROVED')] + if (path.includes('/collaborators/turtle2099/permission')) return { permission: 'write' } + if (path.includes('/comments?')) return [] + if (path.includes('/statuses/')) { states.push(options.body.state); return {} } + if (path.includes('/check-runs')) throw new Error('check runs unavailable') + throw new Error(`unexpected API path ${path}`) + }, + }) + assert.deepEqual(states, ['pending', 'success']) + assert.ok(writes.some(line => /Decision check run not published/.test(line))) +}) + +test('reports the decision on a non-required check run beside the status', async () => { + const calls = [] + const api = async (path, options = {}) => { + calls.push({ path, method: options.method, body: options.body }) + if (path.includes('/reviews?')) return [review('writer', 'APPROVED')] + if (path.includes('/collaborators/writer/permission')) return { permission: 'write' } + if (path.includes('/comments?')) return [] + if (isCheckRunsList(path)) return emptyCheckRuns + if (path.includes('/check-runs') || path.includes('/statuses/')) return {} + throw new Error(`unexpected API path ${path}`) + } + const result = await runPublisher({ + event: pullRequestEvent(), policySource, runUrl: 'https://github.example/run/1', write: () => {}, + getOwnership: async () => ({ totalLines: 0, reviewerLines: {} }), + getMergedCount: async () => 0, + api, + }) + const status = calls.filter(call => call.path.includes('/statuses/')).at(-1) + const check = calls.filter(call => call.path.endsWith('/check-runs')).at(-1) + assert.equal(result.state, 'pending') + assert.equal(status.body.state, 'pending') + assert.equal(check.body.name, 'weighted approval decision') + assert.equal(check.body.conclusion, 'action_required') +}) + +test('revokes a published success check run and updates one run per head', async () => { + const calls = [] + const checkRuns = [ + { id: 7, app: { slug: 'github-actions' }, conclusion: 'success', external_id: '' }, + { id: 8, app: { slug: 'github-actions' }, conclusion: 'action_required', external_id: 'weighted-approval-decision' }, + { id: 9, app: { slug: 'other-app' }, conclusion: 'success', external_id: '' }, + { id: 10, app: { slug: 'github-actions' }, conclusion: 'success', external_id: 'a-job-check-run' }, + ] + await publishApprovalPhase({ + event: pullRequestEvent(), runUrl: 'https://github.example/run/1', phase: 'pending', + api: async (path, options = {}) => { + calls.push({ path, method: options.method, body: options.body }) + return isCheckRunsList(path) ? { total_count: checkRuns.length, check_runs: checkRuns } : {} + }, + }) + assert.deepEqual(calls.map(call => call.path), [ + `/repos/deepseek-harness/deepseek-harness/statuses/${HEAD_SHA}`, + `/repos/deepseek-harness/deepseek-harness/commits/${HEAD_SHA}/check-runs?check_name=weighted%20approval%20decision&filter=all&per_page=100`, + '/repos/deepseek-harness/deepseek-harness/check-runs/7', + ]) + for (const call of calls.slice(2)) { + assert.equal(call.method, 'PATCH') + assert.equal(call.body.conclusion, 'action_required') + } +}) + for (const [mergedCount, credit] of [[0, 0], [1, 0.011], [50, 0.55], [99, 1.089], [100, 1.1], [101, 1.1], [200, 1.1]]) { test(`author with ${mergedCount} merged PRs contributes ${credit} points but cannot approve alone`, async () => { const result = await evaluateApproval({ diff --git a/.github/workflows/ci-python.yml b/.github/workflows/ci-python.yml new file mode 100644 index 0000000000..baabdd143c --- /dev/null +++ b/.github/workflows/ci-python.yml @@ -0,0 +1,87 @@ +name: CI Python + +# Python pull-request lanes run in their own workflow so a slow runtime build +# does not keep the CI run in progress: GitHub re-runs failed jobs only after +# every job of a run finishes, so each workflow can be re-run without waiting +# for the others. Branch protection requires this workflow's "python checks +# passed" verdict beside CI's "all checks passed" and CI Windows' "windows +# checks passed". +on: + pull_request: +permissions: + contents: read + +env: + PRIMARY_NODE_VERSION: '24' + # CI runs must never report to the production telemetry endpoint baked + # into apps/cli/cordis.yml (AppCLIEntry disables the row when set). + DSH_TELEMETRY_DISABLED: '1' + +# Cancel a superseded pull-request run on a new push, as in ci.yml. +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + python-sdk: + if: github.event_name == 'pull_request' + runs-on: ubuntu-latest + name: python 3.10 / keyless SDK + steps: + - uses: actions/checkout@v7.0.1 + + - uses: actions/setup-python@v6.3.0 + with: + python-version: '3.10' + cache: pip + + - name: Install uv + run: python -m pip install uv==0.11.23 + + - uses: actions/setup-node@v7.0.0 + with: + node-version: 24 + + - name: Test production blame scoring + run: uv run --python 3.10 --with-requirements .github/review-ownership/requirements.txt python -m unittest discover -s .github/review-ownership -p 'test_*.py' + + - name: Run complete keyless Python suite + run: uv run --python 3.10 --group test --project python/sdk pytest + + # The reusable builder owns each published executable, wheel, clean-install, + # keyless black-box, and trusted real-API path. Linux and Windows x64 block + # pull requests; Linux ARM64 and both macOS architectures run in + # ci-master.yml. + python-runtime: + if: github.event_name == 'pull_request' + needs: python-sdk + name: python runtime / release-shaped matrix + uses: ./.github/workflows/build-exe-for-python-sdk.yml + with: + targets: node24-linux-x64,node24-win-x64 + ci: true + secrets: + DEEPSEEK_API_KEY_EXTERNAL: ${{ secrets.DEEPSEEK_API_KEY_EXTERNAL }} + + # Stable required check for the Python lanes, with the same semantics as + # ci.yml's "all checks passed": every blocking job in THIS workflow is + # listed in `needs`. The bookkeeping job follows the Linux failover switch + # like the other verdicts. + python-checks-passed: + name: python checks passed + runs-on: >- + ${{ vars.DSH_CI_FAILOVER_LINUX == 'blacksmith' && 'blacksmith-4vcpu-ubuntu-2404' + || vars.DSH_CI_FAILOVER_LINUX == 'selfhosted' + && github.event.pull_request.user.login != 'dependabot[bot]' + && fromJSON('["self-hosted", "linux", "x64", "vm-backup"]') + || 'ubuntu-latest' }} + needs: [python-sdk, python-runtime] + if: ${{ !cancelled() && github.event_name == 'pull_request' }} + steps: + - name: Fail if any needed job did not succeed + if: contains(needs.*.result, 'failure') || contains(needs.*.result, 'cancelled') || contains(needs.*.result, 'skipped') + run: | + echo "::error::Needed job results: ${{ join(needs.*.result, ', ') }}" + exit 1 + - name: Python checks passed + run: echo "All needed jobs succeeded (${{ join(needs.*.result, ', ') }})" diff --git a/.github/workflows/ci-windows.yml b/.github/workflows/ci-windows.yml new file mode 100644 index 0000000000..fa2473a3a3 --- /dev/null +++ b/.github/workflows/ci-windows.yml @@ -0,0 +1,255 @@ +name: CI Windows + +# Native Windows pull-request lanes run in their own workflow so a slow Windows +# job does not keep the CI run in progress: GitHub re-runs failed jobs only +# after every job of a run finishes, so each workflow can be re-run without +# waiting for the others. Branch protection requires this workflow's +# "windows checks passed" verdict beside CI's "all checks passed" and CI +# Python's "python checks passed". +on: + pull_request: +permissions: + contents: read + +env: + PRIMARY_NODE_VERSION: '24' + # CI runs must never report to the production telemetry endpoint baked + # into apps/cli/cordis.yml (AppCLIEntry disables the row when set). + DSH_TELEMETRY_DISABLED: '1' + +# Cancel a superseded pull-request run on a new push, as in ci.yml. +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + # Native builds and process tests block pull requests independently of coverage. + # Observational checks reuse the successful build and remain non-blocking. + # Dependabot PRs are excluded from the self-hosted pool and stay queued for + # the hosted runner. + # Blacksmith uses 2 vCPU for file-serial native tests and 16 for coverage + # and the shared build/observational workspace. + windows-build: + if: github.event_name == 'pull_request' + runs-on: >- + ${{ vars.DSH_CI_FAILOVER_WINDOWS == 'blacksmith' && 'blacksmith-16vcpu-windows-2025' + || vars.DSH_CI_FAILOVER_WINDOWS == 'selfhosted' + && github.event.pull_request.user.login != 'dependabot[bot]' + && fromJSON('["self-hosted", "dsh-win-ci", "windows"]') + || 'dsh-windows-2025-16core' }} + name: windows node 24 / build & observational + timeout-minutes: 60 + env: + # A failing build or site aborts the sibling gate on the same runner. + DSH_GATE_FAIL_FAST: '1' + steps: + - uses: actions/checkout@v7.0.1 + with: + persist-credentials: false + - name: Enable Developer Mode (symlink support) + shell: pwsh + run: >- + reg add "HKLM\SOFTWARE\Microsoft\Windows\CurrentVersion\AppModelUnlock" + /t REG_DWORD /f /v "AllowDevelopmentWithoutDevLicense" /d "1" + - uses: pnpm/action-setup@v5.0.0 + with: + dest: ${{ runner.temp }}/setup-pnpm-js-${{ github.run_id }}-${{ github.run_attempt }}-${{ github.job }} + - uses: actions/setup-node@v7.0.0 + with: + node-version: ${{ env.PRIMARY_NODE_VERSION }} + - name: Install (immutable) + shell: pwsh + # See 2026-08-30-windows-refs-store-block-clone-install for the + # ReFS block-clone rationale; detect the workspace filesystem and + # pass --package-import-method=clone only on ReFS. + run: >- + $drive = (Split-Path -Qualifier $env:GITHUB_WORKSPACE).TrimEnd(':'); + $fs = (Get-Volume -DriveLetter $drive).FileSystem; + if ($fs -eq 'ReFS') { + corepack pnpm install --frozen-lockfile --package-import-method=clone + } else { + pnpm install --frozen-lockfile + } + - name: Run blocking Windows builds + shell: pwsh + run: pnpm run check:ci:windows-blocking + - name: Parse Windows runner supervision scripts + shell: pwsh + run: | + foreach ($path in @('apps/desktop/scripts/windows-runner-task.ps1', 'apps/desktop/scripts/run-windows-runner-task.ps1', 'apps/desktop/tests/windows-runner-task.ps1', 'apps/desktop/tests/windows-runner-launcher.ps1')) { + $tokens = $null + $parseErrors = $null + $null = [Management.Automation.Language.Parser]::ParseFile((Join-Path $env:GITHUB_WORKSPACE $path), [ref]$tokens, [ref]$parseErrors) + if ($parseErrors.Count) { throw ($parseErrors -join "`n") } + } + - name: Verify windowless Windows runner launcher + shell: pwsh + run: ./apps/desktop/tests/windows-runner-launcher.ps1 + - name: Run Windows observational gates on built artifacts + id: observational + continue-on-error: true + timeout-minutes: 15 + shell: pwsh + env: + DSH_GATE_FAIL_FAST: '' + DSH_PUBLINT_CONCURRENCY: ${{ vars.DSH_CI_FAILOVER_WINDOWS == 'selfhosted' && github.event.pull_request.user.login != 'dependabot[bot]' && '8' || '' }} + run: pnpm run check:ci:windows-observational-ready + - name: Report Windows observational failures + continue-on-error: true + if: steps.observational.outcome == 'failure' + shell: pwsh + run: | + $message = 'Windows observational checks failed; see the observational step for diagnostics.' + Write-Output "::warning::$message" + $message | Out-File -FilePath $env:GITHUB_STEP_SUMMARY -Encoding utf8 -Append + + windows-coverage: + if: github.event_name == 'pull_request' + runs-on: >- + ${{ vars.DSH_CI_FAILOVER_WINDOWS == 'blacksmith' && 'blacksmith-16vcpu-windows-2025' + || vars.DSH_CI_FAILOVER_WINDOWS == 'selfhosted' + && github.event.pull_request.user.login != 'dependabot[bot]' + && fromJSON('["self-hosted", "dsh-win-ci", "windows"]') + || 'dsh-windows-2025-16core' }} + name: windows node 24 / coverage + timeout-minutes: 120 + env: + # Shared hosts expose host CPUs, not an isolated allocation. + DSH_COVERAGE_MAX_WORKERS: ${{ vars.DSH_CI_FAILOVER_WINDOWS == 'selfhosted' && github.event.pull_request.user.login != 'dependabot[bot]' && '6' || '' }} + DSH_COVERAGE_PARTITIONS: ${{ vars.DSH_CI_FAILOVER_WINDOWS == 'selfhosted' && github.event.pull_request.user.login != 'dependabot[bot]' && '4' || '' }} + DSH_GATE_CONCURRENCY: ${{ vars.DSH_CI_FAILOVER_WINDOWS == 'selfhosted' && github.event.pull_request.user.login != 'dependabot[bot]' && '3' || '' }} + DSH_COVERAGE_TEST_TIMEOUT_MS: '90000' + # A gate failure aborts the sibling gate instead of waiting out its + # multi-minute instrumented run. + DSH_GATE_FAIL_FAST: '1' + steps: + - uses: actions/checkout@v7.0.1 + with: + persist-credentials: false + # Checkout removes the gitignored duration history, so restore it from + # the GitHub cache before coverage and save the updated one afterwards. + # The coordinator then weights partitions by measured durations on the + # second run instead of degrading to a file-count split. GitHub cache + # entries are immutable, so the save key is unique per run and the + # restore matches the newest entry through the stable prefix. + - name: Restore coverage duration history + uses: actions/cache/restore@v6.1.0 + with: + path: .coverage-times.json + key: coverage-times-Windows-${{ runner.environment }}-${{ vars.DSH_CI_FAILOVER_WINDOWS || 'enterprise' }}-${{ github.run_id }}-${{ github.run_attempt }} + restore-keys: | + coverage-times-Windows-${{ runner.environment }}-${{ vars.DSH_CI_FAILOVER_WINDOWS || 'enterprise' }}- + - name: Enable Developer Mode (symlink support) + shell: pwsh + run: >- + reg add "HKLM\SOFTWARE\Microsoft\Windows\CurrentVersion\AppModelUnlock" + /t REG_DWORD /f /v "AllowDevelopmentWithoutDevLicense" /d "1" + - uses: pnpm/action-setup@v5.0.0 + with: + dest: ${{ runner.temp }}/setup-pnpm-js-${{ github.run_id }}-${{ github.run_attempt }}-${{ github.job }} + - uses: actions/setup-node@v7.0.0 + with: + node-version: ${{ env.PRIMARY_NODE_VERSION }} + - name: Install (immutable) + shell: pwsh + # See 2026-08-30-windows-refs-store-block-clone-install for the + # ReFS block-clone rationale; detect the workspace filesystem and + # pass --package-import-method=clone only on ReFS. + run: >- + $drive = (Split-Path -Qualifier $env:GITHUB_WORKSPACE).TrimEnd(':'); + $fs = (Get-Volume -DriveLetter $drive).FileSystem; + if ($fs -eq 'ReFS') { + corepack pnpm install --frozen-lockfile --package-import-method=clone + } else { + pnpm install --frozen-lockfile + } + # No build before coverage, matching the Linux lane: workspace imports + # resolve to src through the tsconfig paths map, and the lib-consuming + # suites (webworker-packer image-loadable, webworker-runtime + # transform-corpus, client ui-trajectory client-bundle) self-skip on + # unbuilt checkouts. + - name: Run Windows coverage + shell: pwsh + run: pnpm run check:ci:coverage + - name: Save coverage duration history + # Coverage flakes must not prevent the cache from building; the + # measured durations remain useful even when a partition failed. The + # per-run key keeps every save a fresh immutable cache entry. + if: ${{ !cancelled() }} + uses: actions/cache/save@v6.1.0 + with: + path: .coverage-times.json + key: coverage-times-Windows-${{ runner.environment }}-${{ vars.DSH_CI_FAILOVER_WINDOWS || 'enterprise' }}-${{ github.run_id }}-${{ github.run_attempt }} + + windows-native-tests: + if: github.event_name == 'pull_request' + runs-on: >- + ${{ vars.DSH_CI_FAILOVER_WINDOWS == 'blacksmith' && 'blacksmith-2vcpu-windows-2025' + || vars.DSH_CI_FAILOVER_WINDOWS == 'selfhosted' + && github.event.pull_request.user.login != 'dependabot[bot]' + && fromJSON('["self-hosted", "dsh-win-ci", "windows"]') + || 'dsh-windows-2025-16core' }} + name: windows node 24 / native tests + timeout-minutes: 60 + steps: + - uses: actions/checkout@v7.0.1 + with: + persist-credentials: false + - name: Enable Developer Mode (symlink support) + shell: pwsh + run: >- + reg add "HKLM\SOFTWARE\Microsoft\Windows\CurrentVersion\AppModelUnlock" + /t REG_DWORD /f /v "AllowDevelopmentWithoutDevLicense" /d "1" + - uses: pnpm/action-setup@v5.0.0 + with: + dest: ${{ runner.temp }}/setup-pnpm-js-${{ github.run_id }}-${{ github.run_attempt }}-${{ github.job }} + - uses: actions/setup-node@v7.0.0 + with: + node-version: ${{ env.PRIMARY_NODE_VERSION }} + - name: Install (immutable) + shell: pwsh + # See 2026-08-30-windows-refs-store-block-clone-install for the + # ReFS block-clone rationale; detect the workspace filesystem and + # pass --package-import-method=clone only on ReFS. + run: >- + $drive = (Split-Path -Qualifier $env:GITHUB_WORKSPACE).TrimEnd(':'); + $fs = (Get-Volume -DriveLetter $drive).FileSystem; + if ($fs -eq 'ReFS') { + corepack pnpm install --frozen-lockfile --package-import-method=clone + } else { + pnpm install --frozen-lockfile + } + - name: Run Windows-specific native tests + shell: pwsh + run: >- + pnpm exec vitest run + --no-file-parallelism + --testTimeout 90000 + packages/shell/tool-pwsh/tests/loader.spec.ts + packages/workflow/workflow-ptc/tests/workflow-ptc.spec.ts + packages/experimental/tool-ralph/tests/integration.spec.ts + packages/subprocess/subprocess-local/tests/process-exit.spec.ts + + # Stable required check for the Windows lanes, with the same semantics as + # ci.yml's "all checks passed": every blocking job in THIS workflow is + # listed in `needs`. windows-coverage stays outside the verdict, so its + # per-file result remains visible without blocking. The bookkeeping job + # follows the Linux failover switch like the CI verdict. + windows-checks-passed: + name: windows checks passed + runs-on: >- + ${{ vars.DSH_CI_FAILOVER_LINUX == 'blacksmith' && 'blacksmith-4vcpu-ubuntu-2404' + || vars.DSH_CI_FAILOVER_LINUX == 'selfhosted' + && github.event.pull_request.user.login != 'dependabot[bot]' + && fromJSON('["self-hosted", "linux", "x64", "vm-backup"]') + || 'ubuntu-latest' }} + needs: [windows-build, windows-native-tests] + if: ${{ !cancelled() && github.event_name == 'pull_request' }} + steps: + - name: Fail if any needed job did not succeed + if: contains(needs.*.result, 'failure') || contains(needs.*.result, 'cancelled') || contains(needs.*.result, 'skipped') + run: | + echo "::error::Needed job results: ${{ join(needs.*.result, ', ') }}" + exit 1 + - name: Windows checks passed + run: echo "All needed jobs succeeded (${{ join(needs.*.result, ', ') }})" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 41f28c8e7e..ae9db81e61 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -12,7 +12,7 @@ env: DSH_TELEMETRY_DISABLED: '1' # Cancel a superseded pull-request run on a new push so a fresh head does not -# queue a second full 9-job run behind a stale one (paid enterprise runners +# queue a second full run behind a stale one (paid enterprise runners # would otherwise stack with no auto-cancellation). concurrency: group: ${{ github.workflow }}-${{ github.ref }} @@ -36,9 +36,8 @@ jobs: # Blacksmith's hosted runners sized per workload (see # .agents/notes/implemented/process/2026-09-09-blacksmith-failover-leg.md). The # in-house pool's readiness is re-proven on every master push by the - # serial-linux-selfhosted standby lane in ci-master.yml. The Windows failover - # switch is the separate DSH_CI_FAILOVER_WINDOWS variable on the windows-native - # job below. + # serial-linux-selfhosted standby lane in ci-master.yml. Native Windows jobs + # and their separate DSH_CI_FAILOVER_WINDOWS switch live in ci-windows.yml. node-24: if: github.event_name == 'pull_request' runs-on: >- @@ -478,258 +477,13 @@ jobs: - name: Check Loader internal shape detection run: pnpm exec vitest run packages/boot/app-boot/tests/loader-shape.compat.spec.ts - python-sdk: - if: github.event_name == 'pull_request' - runs-on: ubuntu-latest - name: python 3.10 / keyless SDK - steps: - - uses: actions/checkout@v7.0.1 - - - uses: actions/setup-python@v6.3.0 - with: - python-version: '3.10' - cache: pip - - - name: Install uv - run: python -m pip install uv==0.11.23 - - - uses: actions/setup-node@v7.0.0 - with: - node-version: 24 - - - name: Test production blame scoring - run: uv run --python 3.10 --with-requirements .github/review-ownership/requirements.txt python -m unittest discover -s .github/review-ownership -p 'test_*.py' - - - name: Run complete keyless Python suite - run: uv run --python 3.10 --group test --project python/sdk pytest - - # The reusable builder owns each published executable, wheel, clean-install, - # keyless black-box, and trusted real-API path. Linux/Windows x64 block PRs; - # Linux ARM64 and both macOS architectures run in ci-master.yml. - python-runtime: - if: github.event_name == 'pull_request' - needs: python-sdk - name: python runtime / release-shaped matrix - uses: ./.github/workflows/build-exe-for-python-sdk.yml - with: - targets: node24-linux-x64,node24-win-x64 - ci: true - secrets: - DEEPSEEK_API_KEY_EXTERNAL: ${{ secrets.DEEPSEEK_API_KEY_EXTERNAL }} - - # Native builds and process tests block pull requests independently of coverage. - # Observational checks reuse the successful build and remain non-blocking. - # Dependabot PRs are excluded from the self-hosted pool and stay queued for - # the hosted runner. - # Blacksmith uses 2 vCPU for file-serial native tests and 16 for coverage - # and the shared build/observational workspace. - windows-build: - if: github.event_name == 'pull_request' - runs-on: >- - ${{ vars.DSH_CI_FAILOVER_WINDOWS == 'blacksmith' && 'blacksmith-16vcpu-windows-2025' - || vars.DSH_CI_FAILOVER_WINDOWS == 'selfhosted' - && github.event.pull_request.user.login != 'dependabot[bot]' - && fromJSON('["self-hosted", "dsh-win-ci", "windows"]') - || 'dsh-windows-2025-16core' }} - name: windows node 24 / build & observational - timeout-minutes: 60 - env: - # A failing build or site aborts the sibling gate on the same runner. - DSH_GATE_FAIL_FAST: '1' - steps: - - uses: actions/checkout@v7.0.1 - with: - persist-credentials: false - - name: Enable Developer Mode (symlink support) - shell: pwsh - run: >- - reg add "HKLM\SOFTWARE\Microsoft\Windows\CurrentVersion\AppModelUnlock" - /t REG_DWORD /f /v "AllowDevelopmentWithoutDevLicense" /d "1" - - uses: pnpm/action-setup@v5.0.0 - with: - dest: ${{ runner.temp }}/setup-pnpm-js-${{ github.run_id }}-${{ github.run_attempt }}-${{ github.job }} - - uses: actions/setup-node@v7.0.0 - with: - node-version: ${{ env.PRIMARY_NODE_VERSION }} - - name: Install (immutable) - shell: pwsh - # See 2026-08-30-windows-refs-store-block-clone-install for the - # ReFS block-clone rationale; detect the workspace filesystem and - # pass --package-import-method=clone only on ReFS. - run: >- - $drive = (Split-Path -Qualifier $env:GITHUB_WORKSPACE).TrimEnd(':'); - $fs = (Get-Volume -DriveLetter $drive).FileSystem; - if ($fs -eq 'ReFS') { - corepack pnpm install --frozen-lockfile --package-import-method=clone - } else { - pnpm install --frozen-lockfile - } - - name: Run blocking Windows builds - shell: pwsh - run: pnpm run check:ci:windows-blocking - - name: Parse Windows runner supervision scripts - shell: pwsh - run: | - foreach ($path in @('apps/desktop/scripts/windows-runner-task.ps1', 'apps/desktop/scripts/run-windows-runner-task.ps1', 'apps/desktop/tests/windows-runner-task.ps1', 'apps/desktop/tests/windows-runner-launcher.ps1')) { - $tokens = $null - $parseErrors = $null - $null = [Management.Automation.Language.Parser]::ParseFile((Join-Path $env:GITHUB_WORKSPACE $path), [ref]$tokens, [ref]$parseErrors) - if ($parseErrors.Count) { throw ($parseErrors -join "`n") } - } - - name: Verify windowless Windows runner launcher - shell: pwsh - run: ./apps/desktop/tests/windows-runner-launcher.ps1 - - name: Run Windows observational gates on built artifacts - id: observational - continue-on-error: true - timeout-minutes: 15 - shell: pwsh - env: - DSH_GATE_FAIL_FAST: '' - DSH_PUBLINT_CONCURRENCY: ${{ vars.DSH_CI_FAILOVER_WINDOWS == 'selfhosted' && github.event.pull_request.user.login != 'dependabot[bot]' && '8' || '' }} - run: pnpm run check:ci:windows-observational-ready - - name: Report Windows observational failures - continue-on-error: true - if: steps.observational.outcome == 'failure' - shell: pwsh - run: | - $message = 'Windows observational checks failed; see the observational step for diagnostics.' - Write-Output "::warning::$message" - $message | Out-File -FilePath $env:GITHUB_STEP_SUMMARY -Encoding utf8 -Append - - windows-coverage: - if: github.event_name == 'pull_request' - runs-on: >- - ${{ vars.DSH_CI_FAILOVER_WINDOWS == 'blacksmith' && 'blacksmith-16vcpu-windows-2025' - || vars.DSH_CI_FAILOVER_WINDOWS == 'selfhosted' - && github.event.pull_request.user.login != 'dependabot[bot]' - && fromJSON('["self-hosted", "dsh-win-ci", "windows"]') - || 'dsh-windows-2025-16core' }} - name: windows node 24 / coverage - timeout-minutes: 120 - env: - # Shared hosts expose host CPUs, not an isolated allocation. - DSH_COVERAGE_MAX_WORKERS: ${{ vars.DSH_CI_FAILOVER_WINDOWS == 'selfhosted' && github.event.pull_request.user.login != 'dependabot[bot]' && '6' || '' }} - DSH_COVERAGE_PARTITIONS: ${{ vars.DSH_CI_FAILOVER_WINDOWS == 'selfhosted' && github.event.pull_request.user.login != 'dependabot[bot]' && '4' || '' }} - DSH_GATE_CONCURRENCY: ${{ vars.DSH_CI_FAILOVER_WINDOWS == 'selfhosted' && github.event.pull_request.user.login != 'dependabot[bot]' && '3' || '' }} - DSH_COVERAGE_TEST_TIMEOUT_MS: '90000' - # A gate failure aborts the sibling gate instead of waiting out its - # multi-minute instrumented run. - DSH_GATE_FAIL_FAST: '1' - steps: - - uses: actions/checkout@v7.0.1 - with: - persist-credentials: false - # Checkout removes the gitignored duration history, so restore it from - # the GitHub cache before coverage and save the updated one afterwards. - # The coordinator then weights partitions by measured durations on the - # second run instead of degrading to a file-count split. GitHub cache - # entries are immutable, so the save key is unique per run and the - # restore matches the newest entry through the stable prefix. - - name: Restore coverage duration history - uses: actions/cache/restore@v6.1.0 - with: - path: .coverage-times.json - key: coverage-times-Windows-${{ runner.environment }}-${{ vars.DSH_CI_FAILOVER_WINDOWS || 'enterprise' }}-${{ github.run_id }}-${{ github.run_attempt }} - restore-keys: | - coverage-times-Windows-${{ runner.environment }}-${{ vars.DSH_CI_FAILOVER_WINDOWS || 'enterprise' }}- - - name: Enable Developer Mode (symlink support) - shell: pwsh - run: >- - reg add "HKLM\SOFTWARE\Microsoft\Windows\CurrentVersion\AppModelUnlock" - /t REG_DWORD /f /v "AllowDevelopmentWithoutDevLicense" /d "1" - - uses: pnpm/action-setup@v5.0.0 - with: - dest: ${{ runner.temp }}/setup-pnpm-js-${{ github.run_id }}-${{ github.run_attempt }}-${{ github.job }} - - uses: actions/setup-node@v7.0.0 - with: - node-version: ${{ env.PRIMARY_NODE_VERSION }} - - name: Install (immutable) - shell: pwsh - # See 2026-08-30-windows-refs-store-block-clone-install for the - # ReFS block-clone rationale; detect the workspace filesystem and - # pass --package-import-method=clone only on ReFS. - run: >- - $drive = (Split-Path -Qualifier $env:GITHUB_WORKSPACE).TrimEnd(':'); - $fs = (Get-Volume -DriveLetter $drive).FileSystem; - if ($fs -eq 'ReFS') { - corepack pnpm install --frozen-lockfile --package-import-method=clone - } else { - pnpm install --frozen-lockfile - } - # No build before coverage, matching the Linux lane: workspace imports - # resolve to src through the tsconfig paths map, and the lib-consuming - # suites (webworker-packer image-loadable, webworker-runtime - # transform-corpus, client ui-trajectory client-bundle) self-skip on - # unbuilt checkouts. - - name: Run Windows coverage - shell: pwsh - run: pnpm run check:ci:coverage - - name: Save coverage duration history - # Coverage flakes must not prevent the cache from building; the - # measured durations remain useful even when a partition failed. The - # per-run key keeps every save a fresh immutable cache entry. - if: ${{ !cancelled() }} - uses: actions/cache/save@v6.1.0 - with: - path: .coverage-times.json - key: coverage-times-Windows-${{ runner.environment }}-${{ vars.DSH_CI_FAILOVER_WINDOWS || 'enterprise' }}-${{ github.run_id }}-${{ github.run_attempt }} - - windows-native-tests: - if: github.event_name == 'pull_request' - runs-on: >- - ${{ vars.DSH_CI_FAILOVER_WINDOWS == 'blacksmith' && 'blacksmith-2vcpu-windows-2025' - || vars.DSH_CI_FAILOVER_WINDOWS == 'selfhosted' - && github.event.pull_request.user.login != 'dependabot[bot]' - && fromJSON('["self-hosted", "dsh-win-ci", "windows"]') - || 'dsh-windows-2025-16core' }} - name: windows node 24 / native tests - timeout-minutes: 60 - steps: - - uses: actions/checkout@v7.0.1 - with: - persist-credentials: false - - name: Enable Developer Mode (symlink support) - shell: pwsh - run: >- - reg add "HKLM\SOFTWARE\Microsoft\Windows\CurrentVersion\AppModelUnlock" - /t REG_DWORD /f /v "AllowDevelopmentWithoutDevLicense" /d "1" - - uses: pnpm/action-setup@v5.0.0 - with: - dest: ${{ runner.temp }}/setup-pnpm-js-${{ github.run_id }}-${{ github.run_attempt }}-${{ github.job }} - - uses: actions/setup-node@v7.0.0 - with: - node-version: ${{ env.PRIMARY_NODE_VERSION }} - - name: Install (immutable) - shell: pwsh - # See 2026-08-30-windows-refs-store-block-clone-install for the - # ReFS block-clone rationale; detect the workspace filesystem and - # pass --package-import-method=clone only on ReFS. - run: >- - $drive = (Split-Path -Qualifier $env:GITHUB_WORKSPACE).TrimEnd(':'); - $fs = (Get-Volume -DriveLetter $drive).FileSystem; - if ($fs -eq 'ReFS') { - corepack pnpm install --frozen-lockfile --package-import-method=clone - } else { - pnpm install --frozen-lockfile - } - - name: Run Windows-specific native tests - shell: pwsh - run: >- - pnpm exec vitest run - --no-file-parallelism - --testTimeout 90000 - packages/shell/tool-pwsh/tests/loader.spec.ts - packages/workflow/workflow-ptc/tests/workflow-ptc.spec.ts - packages/experimental/tool-ralph/tests/integration.spec.ts - packages/subprocess/subprocess-local/tests/process-exit.spec.ts - # Single stable required check for branch protection: require "all checks # passed" instead of enumerating matrix legs whose names change as lanes and # node versions evolve. Every blocking job in THIS workflow must be listed in - # `needs`. Native Windows build and process checks are required; Wine and - # the deferred Python runtime targets live in ci-master.yml and do not - # participate in this PR verdict. `needs` cannot cross workflow files. + # `needs`. `needs` cannot cross workflow files: native Windows jobs report + # the separate "windows checks passed" verdict from ci-windows.yml, Python + # jobs report "python checks passed" from ci-python.yml, and Wine plus the + # master-only runtime targets live in ci-master.yml. # An explicit status function runs the verdict after failed/skipped needs # without keeping a cancelled workflow alive for an obsolete verdict. all-checks-passed: @@ -749,7 +503,7 @@ jobs: && github.event.pull_request.user.login != 'dependabot[bot]' && fromJSON('["self-hosted", "linux", "x64", "vm-backup"]') || 'ubuntu-latest' }} - needs: [node-24, node-24-coverage, node-24-bench, node-24-consumers, node-compat, python-sdk, python-runtime, windows-build, windows-native-tests] + needs: [node-24, node-24-coverage, node-24-bench, node-24-consumers, node-compat] if: ${{ !cancelled() && github.event_name == 'pull_request' }} steps: - name: Fail if any needed job did not succeed diff --git a/.github/workflows/weighted-approval.yml b/.github/workflows/weighted-approval.yml index eb6f2fe3ba..2a20d83d5b 100644 --- a/.github/workflows/weighted-approval.yml +++ b/.github/workflows/weighted-approval.yml @@ -10,6 +10,7 @@ on: types: [completed] permissions: + checks: write contents: read pull-requests: write statuses: write diff --git a/AGENTS.md b/AGENTS.md index 1f88aa1293..69dbd7d69e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -155,7 +155,7 @@ Real-API tests/demos read `DEEPSEEK_API_KEY`, optional `DEEPSEEK_BASE_URL`, and - **Tests describe behavior, not correctness.** Change obsolete behavior with its tests; explain why in the PR. - **Create Agent Notes only for durable decision rationale;** mechanical/local edits are exempt, including local UI changes ([scope](.agents/notes/README.md#when-to-write-one)). Archived notes are frozen: never edit or treat them as current authority ([archive policy](.agents/notes/README.md#archiving-and-deletion)). - **Official plugin artwork:** use [dsh-official-plugin-artwork](.agents/skills/dsh-official-plugin-artwork/SKILL.md) for official and official experimental plugin icons; obtain designer approval before landing new glyphs. -- **Client UI copy is locale-owned.** Route product text through typed dictionaries and `t` or localized primitive props; `verify-client-ui-i18n` rejects hardcoded copy ([decision](.agents/notes/implemented/architecture/2026-08-23-locale-owned-client-ui-copy.md)). +- **Client UI copy is locale-owned.** Route product text through typed dictionaries and `t` or localized primitive props; `verify-client-ui-i18n` rejects hardcoded copy ([decision](.agents/notes/implemented/architecture/2026-08-23-locale-owned-client-ui-copy.md)). English copy, including plugin titles, uses sentence case ([rule](.agents/skills/dsh-prose-standard/SKILL.md#required-coverage-by-prose-location)). - **Testing policy** — [docs/testing.md](docs/testing.md). Every non-trivial model- or product-user-visible change updates a keyless recorded-session snapshot; [snapshot ownership](snapshots/AGENTS.md) reserves the top-level tree for session-driven cases and keeps other expected output owner-local. Fixtures replay on macOS/Linux; fix fixtures, not normalizers. - **Design each tool's UI presentation up front.** Host presenters stay pure; Web cards derive from raw events and persisted result metadata ([cookbook](docs/cookbook/adding-a-tool.md)). - **Plan unit, e2e, and snapshot coverage** for capability seams, lifecycle paths, and transcript output; include missing snapshot-harness support in the same change. diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index 43b8d64815..b08c83a529 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -78,6 +78,7 @@ External packages installed for runtime use or embedded in distributed Host or b | [`ajv`](https://github.com/ajv-validator/ajv) | MIT | | [`anser`](https://github.com/IonicaBizau/anser) | MIT | | [`big.js`](https://github.com/MikeMcl/big.js) | MIT | +| [`birpc`](https://github.com/antfu-collective/birpc) | MIT | | [`buffer`](https://github.com/feross/buffer) | MIT | | [`chokidar`](https://github.com/paulmillr/chokidar) | MIT | | [`chrome-devtools-frontend`](https://github.com/ChromeDevTools/devtools-frontend) | BSD-3-Clause | @@ -113,6 +114,7 @@ External packages installed for runtime use or embedded in distributed Host or b | [`micromark-util-classify-character`](https://github.com/micromark/micromark/tree/main/packages/micromark-util-classify-character) | MIT | | [`micromark-util-sanitize-uri`](https://github.com/micromark/micromark/tree/main/packages/micromark-util-sanitize-uri) | MIT | | [`micromark-util-symbol`](https://github.com/micromark/micromark/tree/main/packages/micromark-util-symbol) | MIT | +| [`mime`](https://github.com/broofa/mime) | MIT | | [`mime-types`](https://github.com/jshttp/mime-types) | MIT | | [`minisearch`](https://github.com/lucaong/minisearch) | MIT | | [`negotiator`](https://github.com/jshttp/negotiator) | MIT | diff --git a/apps/cli/composition.md b/apps/cli/composition.md index cda1e0bb96..21150774cc 100644 --- a/apps/cli/composition.md +++ b/apps/cli/composition.md @@ -188,6 +188,10 @@ flowchart LR cfg --> plugin_dsh_base_mcp_resources plugin_dsh_base_tools["tools
@deepseek-ai/dsh-tools"] cfg --> plugin_dsh_base_tools + plugin_dsh_base_tool_executor_native["tool-executor-native
@deepseek-ai/dsh-tool-executor-native"] + cfg --> plugin_dsh_base_tool_executor_native + plugin_dsh_base_tool_executor_ptc["tool-executor-ptc
@deepseek-ai/dsh-tool-executor-ptc"] + cfg --> plugin_dsh_base_tool_executor_ptc plugin_dsh_base_system_prompt["system-prompt
@deepseek-ai/dsh-system-prompt"] cfg --> plugin_dsh_base_system_prompt plugin_dsh_base_agent_loop["agent-loop
@deepseek-ai/dsh-agent-loop"] @@ -292,6 +296,8 @@ flowchart LR | `tool-web` | `@deepseek-ai/dsh-tool-web` | | `mcp-resources` | `@deepseek-ai/dsh-mcp-resources` | | `tools` | `@deepseek-ai/dsh-tools` | +| `tool-executor-native` | `@deepseek-ai/dsh-tool-executor-native` | +| `tool-executor-ptc` | `@deepseek-ai/dsh-tool-executor-ptc` | | `system-prompt` | `@deepseek-ai/dsh-system-prompt` | | `agent-loop` | `@deepseek-ai/dsh-agent-loop` | | `fs-sandbox` | `@deepseek-ai/dsh-fs-sandbox` | diff --git a/apps/cli/config/examples/ptc.yml b/apps/cli/config/examples/ptc.yml new file mode 100644 index 0000000000..d0c2cc888a --- /dev/null +++ b/apps/cli/config/examples/ptc.yml @@ -0,0 +1,5 @@ +- id: tool-executor-native + disabled: true + +- id: tool-executor-ptc + disabled: false diff --git a/apps/cli/package.json b/apps/cli/package.json index d7c3e78f72..65c32f037a 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -155,6 +155,7 @@ "@deepseek-ai/dsh-subagent-spawn-in-process": "workspace:*", "@deepseek-ai/dsh-subprocess-local": "workspace:*", "@deepseek-ai/dsh-system-prompt": "workspace:*", + "@deepseek-ai/dsh-tool-testkit": "workspace:*", "@deepseek-ai/dsh-tools": "workspace:*", "@deepseek-ai/dsh-user-approval": "workspace:*", "@types/js-yaml": "^4.0.9", diff --git a/apps/cli/reference/README.i18n.yaml b/apps/cli/reference/README.i18n.yaml index d7cbbd9ac6..ffd79b8daa 100644 --- a/apps/cli/reference/README.i18n.yaml +++ b/apps/cli/reference/README.i18n.yaml @@ -21,8 +21,8 @@ en: 8d2cfff9bab7327f zh: 21515ab6e6798504 /dsh-cli-behavior-reference/web-profile: - en: 850434e8ca162221 - zh: 4893613f97a21eb8 + en: 149c5571acd51baf + zh: 300e3c2425273a92 /dsh-cli-behavior-reference/shared-deployment-behavior: en: f40edcf903a51f3f zh: 8a9a915451047c8b diff --git a/apps/cli/reference/README.md b/apps/cli/reference/README.md index 98b7197a92..a375450048 100644 --- a/apps/cli/reference/README.md +++ b/apps/cli/reference/README.md @@ -130,7 +130,7 @@ The base-backed modes treat the invoking directory as the default workspace root New sessions in base-backed profiles default to the `workspace-write` permission preset. Bash and filesystem mutations are restricted to the session workspace and platform temporary roots; reads and network access are not confined, while process visibility depends on the selected sandbox backend — bwrap runs commands in a private PID namespace that hides host processes, and Landlock and Seatbelt leave host process visibility unchanged. `DSH_PERMISSION_MODE` changes the process fallback. Stored General-settings permissions affect later Web sessions, not an already-open one. The standalone `sdk-minimal` tree instead pins `danger-full-access` and mounts no approval or permission-settings service. -`DSH_TOOLS_MODE` selects `native` or `ptc` for the process; another value fails at boot. The shipped `minimal` agent preset keeps that deployment presentation, fixes the complete system prompt to `You are a helpful software engineer assistant.`, and provides the platform-selected persistent shell, `read_image`, and `working_directory`. It supports automatic context compaction and the human `/compact` command. Select 极简模式 when creating a Web session; the shared browser, workspace, persistence, sandbox, and permission host remains in place. +Web, headless, and SDK inherit native tool execution from the base bundle; use a [PTC overlay](../config/examples/ptc.yml) to select PTC. The shipped `minimal` agent preset keeps that deployment presentation, fixes the complete system prompt to `You are a helpful software engineer assistant.`, and provides the platform-selected persistent shell, `read_image`, and `working_directory`. It supports automatic context compaction and the human `/compact` command. Select 极简模式 when creating a Web session; the shared browser, workspace, persistence, sandbox, and permission host remains in place. ## Shared deployment behavior diff --git a/apps/cli/reference/README.zh.md b/apps/cli/reference/README.zh.md index 6441309f1c..642fa18bc5 100644 --- a/apps/cli/reference/README.zh.md +++ b/apps/cli/reference/README.zh.md @@ -132,7 +132,7 @@ dsh web --help 基于 base 的 profile 中,新会话默认使用 `workspace-write` 权限预设。Bash 和文件系统修改仅限于会话 workspace 与平台临时根目录;读取和网络访问不受限制,进程可见性则取决于所选沙箱后端——bwrap 在私有 PID 命名空间中运行命令并隐藏宿主进程,Landlock 与 Seatbelt 保持宿主进程可见性不变。`DSH_PERMISSION_MODE` 更改进程后备值。General settings 中存储的权限影响后续 Web 会话,不改变已打开的会话。独立的 `sdk-minimal` 配置树则固定为 `danger-full-access`,且不挂载 approval 或权限 settings 服务。 -`DSH_TOOLS_MODE` 为进程选择 `native` 或 `ptc`;其他值会导致启动失败。随附的 `minimal` agent preset 会保留该部署的呈现方式,将完整系统提示词固定为 `You are a helpful software engineer assistant.`,并提供按平台选择的持久 shell、`read_image` 和 `working_directory`。它支持自动上下文压缩和用户 `/compact` 命令。创建 Web 会话时请选择极简模式;共享的浏览器、workspace、持久化、沙箱与权限宿主保持不变。 +Web、headless 与 SDK 继承 base bundle 的原生工具执行;使用 [PTC overlay](../config/examples/ptc.yml) 可选择 PTC。随附的 `minimal` agent preset 会保留该部署的呈现方式,将完整系统提示词固定为 `You are a helpful software engineer assistant.`,并提供按平台选择的持久 shell、`read_image` 和 `working_directory`。它支持自动上下文压缩和用户 `/compact` 命令。创建 Web 会话时请选择极简模式;共享的浏览器、workspace、持久化、沙箱与权限宿主保持不变。 ## 共享部署行为 diff --git a/apps/cli/tests/built-bin.e2e.ts b/apps/cli/tests/built-bin.e2e.ts index cd8b5aad9c..7a80f1c760 100644 --- a/apps/cli/tests/built-bin.e2e.ts +++ b/apps/cli/tests/built-bin.e2e.ts @@ -1268,6 +1268,7 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)', ['session-title', '@deepseek-ai/dsh-session-title'], ['system-prompt', '@deepseek-ai/dsh-system-prompt'], ['tools', '@deepseek-ai/dsh-tools'], + ['tool-executor-native', '@deepseek-ai/dsh-tool-executor-native'], ['mcp-resources', '@deepseek-ai/dsh-mcp-resources'], ['agent', '@deepseek-ai/dsh-agent'], ['llm-retry', '@deepseek-ai/dsh-llm-retry'], diff --git a/apps/cli/tests/fixtures/dsh-badge/snapshot.ts b/apps/cli/tests/fixtures/dsh-badge/snapshot.ts index c84912a9f1..db8cc56e5d 100644 --- a/apps/cli/tests/fixtures/dsh-badge/snapshot.ts +++ b/apps/cli/tests/fixtures/dsh-badge/snapshot.ts @@ -1,3 +1,4 @@ +import { executeToolFixture } from '@deepseek-ai/dsh-tool-testkit' import { fileURLToPath } from 'node:url' import { agentEvents, type Agent } from '@deepseek-ai/dsh-agent' import { ToolCallId } from '@deepseek-ai/dsh-llm' @@ -5,7 +6,7 @@ import { boot, loadOverlayPatches } from '@deepseek-ai/dsh-app-boot' import { SessionId } from '@deepseek-ai/dsh-session' import type {} from '@deepseek-ai/dsh-skill' import type {} from '@deepseek-ai/dsh-tools' -import { unsupportedInbox } from '@deepseek-ai/dsh-agent-loop-testkit' +import { unsupportedModel, unsupportedInbox } from '@deepseek-ai/dsh-agent-loop-testkit' const overlayPath = process.argv[2] if (overlayPath === undefined) throw new Error('dsh-badge snapshot requires an overlay path') @@ -31,6 +32,7 @@ try { id: agentId, options: {}, session, + model: unsupportedModel(), inbox: unsupportedInbox(), status: 'idle', send: () => {}, @@ -43,7 +45,7 @@ try { } const decision = await agentEvents(ctx, agent).waterfall( 'agent/pre-step', - { messages: [], turn: 1, step: 1, signal: new AbortController().signal }, + { modelSelection: { provider: 'mock', model: 'mock' }, messages: [], turn: 1, step: 1, signal: new AbortController().signal }, () => Promise.resolve({ kind: 'enter' as const, messages: [] }), ) const catalog = decision.kind === 'enter' @@ -51,7 +53,7 @@ try { && message.source.kind === 'skill-catalog')?.content : undefined const summary = (await ctx.skills.list()).find(skill => skill.name === 'dsh-badge') - const result = await ctx.tools.execute({ + const result = await executeToolFixture(ctx, { callId: ToolCallId('dsh-badge-snapshot'), name: 'skill', arguments: { name: 'dsh-badge' }, diff --git a/apps/cli/tests/optional-bundle-transitions.e2e.ts b/apps/cli/tests/optional-bundle-transitions.e2e.ts index f084cb29cb..b5a5eecbaf 100644 --- a/apps/cli/tests/optional-bundle-transitions.e2e.ts +++ b/apps/cli/tests/optional-bundle-transitions.e2e.ts @@ -1,4 +1,5 @@ /** Optional bundles through the shipped Web Loader, live package manager, and real Agent loop. */ +import { executeToolFixture } from '@deepseek-ai/dsh-tool-testkit' import { randomUUID } from 'node:crypto' import { existsSync } from 'node:fs' import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' @@ -232,7 +233,7 @@ describe.skipIf(!existsSync(join(root, 'apps/cli/lib/bin.js')))('Official option it('loads the actual badge asset only while its bundle is selected', async () => { const owner = await agent() - const load = (callId: string) => ctx.tools.execute({ + const load = (callId: string) => executeToolFixture(ctx, { callId: ToolCallId(callId), name: 'skill', arguments: { name: 'dsh-badge' }, agent: owner.agent, signal: new AbortController().signal, }) diff --git a/apps/cli/tests/profiles/headless/tests/expected/headless-profile/session.expected.jsonl b/apps/cli/tests/profiles/headless/tests/expected/headless-profile/session.expected.jsonl index bfcfc3ca75..d45d98dfd0 100644 --- a/apps/cli/tests/profiles/headless/tests/expected/headless-profile/session.expected.jsonl +++ b/apps/cli/tests/profiles/headless/tests/expected/headless-profile/session.expected.jsonl @@ -17,6 +17,7 @@ {"type":"tool/call","data":{"turn":1,"step":1,"callId":"cli-smoke-call","name":"bash","arguments":"{\"command\":\"printf CLI_TOOL_ROUND_TRIP\",\"description\":\"Prove the CLI tool round trip.\"}"}} {"type":"job/registered","data":{"jobId":"{{id:1}}","owner":"{{sessionId}}","kind":"bash","label":"printf CLI_TOOL_ROUND_TRIP","startedAt":0,"toolCall":{"callId":"cli-smoke-call","rootCallId":"cli-smoke-call"}}} {"type":"job/settled","data":{"jobId":"{{id:1}}","owner":"{{sessionId}}","kind":"bash","label":"printf CLI_TOOL_ROUND_TRIP","startedAt":0,"toolCall":{"callId":"cli-smoke-call","rootCallId":"cli-smoke-call"},"finishedAt":0,"status":"completed","cause":"producer","producerReported":true,"detail":"exit code: 0"}} +{"type":"model/selection","data":{"provider":"cli-mock","model":"cli-mock","reasoningEffort":"off"}} {"type":"tool/result","data":{"turn":1,"step":1,"message":{"role":"tool","source":{"kind":"tool","callId":"cli-smoke-call"},"toolCallId":"cli-smoke-call","content":[{"type":"text","text":"CLI_TOOL_ROUND_TRIP"}],"isError":false,"id":"{{sessionId}}"},"meta":{"cwd":"{{cwd}}"}},"sourceEventSeqs":[15],"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":1}} {"type":"step/start","data":{"turn":1,"step":2}} diff --git a/apps/cli/tests/profiles/headless/tests/expected/subagent-inheritance/parent.expected.jsonl b/apps/cli/tests/profiles/headless/tests/expected/subagent-inheritance/parent.expected.jsonl index 1cee326a29..3b3a9e5c7b 100644 --- a/apps/cli/tests/profiles/headless/tests/expected/subagent-inheritance/parent.expected.jsonl +++ b/apps/cli/tests/profiles/headless/tests/expected/subagent-inheritance/parent.expected.jsonl @@ -17,8 +17,8 @@ {"type":"system/message","data":{"turn":2,"step":1,"message":{"role":"system","content":[{"type":"text","text":"{{system}}"}],"source":{"kind":"system-prompt"},"id":"{{sessionId}}"}},"sourceEventSeqs":[2],"surfaceOp":{"op":"replace","startSeq":2,"endSeq":2}} {"type":"user/message","data":{"content":[{"type":"text","text":"Delegate the write probe to a subagent."}],"source":{"kind":"user"},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"} {"type":"user/message","data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent working directory: \"{{cwd}}\".\n\nCurrent DSH file policy: read-only. Any available operation enforced by the DSH file sandbox cannot modify files in the standing mode. Do not refuse a required modification from this policy alone: try an available tool normally and follow any denial and escalation guidance it returns.\n\nApproval policy: ask. Operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."}],"source":{"kind":"runtime-context","form":"snapshot","sections":[{"name":"working-directory:current","text":"Current working directory: \"{{cwd}}\"."},{"name":"sandbox:policy","text":"Current DSH file policy: read-only. Any available operation enforced by the DSH file sandbox cannot modify files in the standing mode. Do not refuse a required modification from this policy alone: try an available tool normally and follow any denial and escalation guidance it returns."},{"name":"approval:policy","text":"Approval policy: ask. Operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."}]},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"} -{"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash","reasoningEffort":"low"},"tools":["bash","create_goal","edit","exit_plan_mode","get_goal","glob","grep","interrupt_agent","job_kill","job_list","job_output","list_agents","read","read_image","send_message","skill","subagent","subagent_fork","todo_write","update_goal","web_fetch","web_search","workflow","working_directory","write"]},"reason":"resume","startsSeries":true}} -{"type":"developer/message","data":{"turn":2,"step":1,"message":{"source":{"kind":"tool-registry"},"content":[{"type":"tool-addition","toolName":"bash"},{"type":"tool-addition","toolName":"create_goal"},{"type":"tool-addition","toolName":"edit"},{"type":"tool-addition","toolName":"exit_plan_mode"},{"type":"tool-addition","toolName":"get_goal"},{"type":"tool-addition","toolName":"glob"},{"type":"tool-addition","toolName":"grep"},{"type":"tool-addition","toolName":"interrupt_agent"},{"type":"tool-addition","toolName":"job_kill"},{"type":"tool-addition","toolName":"job_list"},{"type":"tool-addition","toolName":"job_output"},{"type":"tool-addition","toolName":"list_agents"},{"type":"tool-addition","toolName":"read"},{"type":"tool-addition","toolName":"read_image"},{"type":"tool-addition","toolName":"send_message"},{"type":"tool-addition","toolName":"skill"},{"type":"tool-addition","toolName":"subagent"},{"type":"tool-addition","toolName":"subagent_fork"},{"type":"tool-addition","toolName":"todo_write"},{"type":"tool-addition","toolName":"update_goal"},{"type":"tool-addition","toolName":"web_fetch"},{"type":"tool-addition","toolName":"web_search"},{"type":"tool-addition","toolName":"workflow"},{"type":"tool-addition","toolName":"working_directory"},{"type":"tool-addition","toolName":"write"}],"role":"developer","id":"{{sessionId}}"},"headerSeq":18},"surfaceOp":"append"} +{"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash","reasoningEffort":"low"},"tools":["bash","create_goal","edit","get_goal","glob","grep","interrupt_agent","job_kill","job_list","job_output","list_agents","read","read_image","send_message","skill","subagent","subagent_fork","todo_write","update_goal","web_fetch","web_search","workflow","working_directory","write"]},"reason":"resume","startsSeries":true}} +{"type":"developer/message","data":{"turn":2,"step":1,"message":{"source":{"kind":"tool-registry"},"content":[{"type":"tool-addition","toolName":"bash"},{"type":"tool-addition","toolName":"create_goal"},{"type":"tool-addition","toolName":"edit"},{"type":"tool-addition","toolName":"get_goal"},{"type":"tool-addition","toolName":"glob"},{"type":"tool-addition","toolName":"grep"},{"type":"tool-addition","toolName":"interrupt_agent"},{"type":"tool-addition","toolName":"job_kill"},{"type":"tool-addition","toolName":"job_list"},{"type":"tool-addition","toolName":"job_output"},{"type":"tool-addition","toolName":"list_agents"},{"type":"tool-addition","toolName":"read"},{"type":"tool-addition","toolName":"read_image"},{"type":"tool-addition","toolName":"send_message"},{"type":"tool-addition","toolName":"skill"},{"type":"tool-addition","toolName":"subagent"},{"type":"tool-addition","toolName":"subagent_fork"},{"type":"tool-addition","toolName":"todo_write"},{"type":"tool-addition","toolName":"update_goal"},{"type":"tool-addition","toolName":"web_fetch"},{"type":"tool-addition","toolName":"web_search"},{"type":"tool-addition","toolName":"workflow"},{"type":"tool-addition","toolName":"working_directory"},{"type":"tool-addition","toolName":"write"}],"role":"developer","id":"{{sessionId}}"},"headerSeq":18},"surfaceOp":"append"} {"type":"request/context","data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} {"type":"session/title","data":{"title":"Tighten this session to read-only.","messageSeqs":[3],"source":{"kind":"fallback"}}} {"type":"assistant/message","data":{"turn":2,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"delegate-write","name":"subagent","arguments":"{\"description\": \"Delegated write probe\", \"prompt\": \"Use the write tool exactly once with file_path set to exactly the relative path inherited.txt and content escaped. If the write is denied, reply with the single word CHILD_DENIED and the denial marker line; do not retry and do not request escalation. If it succeeds, reply CHILD_WROTE.\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{sessionId}}"},"usage":{"inputTokens":10,"outputTokens":5},"stream":[{"type":"chunk","time":0,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"tool-call-chunks","time0":0,"index":0,"dt":[],"id":"delegate-write","name":"subagent","args":["{\"description\": \"Delegated write probe\", \"prompt\": \"Use the write tool exactly once with file_path set to exactly the relative path inherited.txt and content escaped. If the write is denied, reply with the single word CHILD_DENIED and the denial marker line; do not retry and do not request escalation. If it succeeds, reply CHILD_WROTE.\"}"]},{"type":"chunk","time":0,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"delegate-write","name":"subagent","arguments":"{\"description\": \"Delegated write probe\", \"prompt\": \"Use the write tool exactly once with file_path set to exactly the relative path inherited.txt and content escaped. If the write is denied, reply with the single word CHILD_DENIED and the denial marker line; do not retry and do not request escalation. If it succeeds, reply CHILD_WROTE.\"}"}}},{"type":"chunk","time":0,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}},{"type":"chunk","time":0,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} diff --git a/apps/cli/tests/profiles/headless/tests/headless.expected.e2e.ts b/apps/cli/tests/profiles/headless/tests/headless.expected.e2e.ts index b71ffc778c..a2ae0c9f19 100644 --- a/apps/cli/tests/profiles/headless/tests/headless.expected.e2e.ts +++ b/apps/cli/tests/profiles/headless/tests/headless.expected.e2e.ts @@ -555,7 +555,7 @@ describe('headless stream-json snapshots', () => { '--permission-mode', 'auto', '--approval-policy', 'never', '--json', 'Run the CLI probe.', ], tsconfigPath, - env: { DSH_AUTO_REVIEW_TEST_MODE: mode, DSH_TOOLS_MODE: 'native', DSH_TELEMETRY_DISABLED: '1' }, + env: { DSH_AUTO_REVIEW_TEST_MODE: mode, DSH_TELEMETRY_DISABLED: '1' }, inspect: async (cwd) => { const logs = await persistedLogs(cwd, join(cwd, '.dsh', 'sessions')) expect(logs).toHaveLength(1) @@ -605,7 +605,7 @@ describe('headless stream-json snapshots', () => { const first = await runLoaderSmoke({ label: 'headless Auto denied wake', cwd, binScript: dshBinScript, configPath: autoReviewOverlayPath, binArgs: [...args, '--permission-mode', 'auto', '--approval-policy', 'never', 'Run the CLI probe.'], tsconfigPath, - env: { DSH_AUTO_REVIEW_TEST_MODE: 'deny', DSH_TOOLS_MODE: 'native', DSH_TELEMETRY_DISABLED: '1' }, + env: { DSH_AUTO_REVIEW_TEST_MODE: 'deny', DSH_TELEMETRY_DISABLED: '1' }, }) const sessionId = parseJsonl(first.stdout)[0]?.sessionId if (typeof sessionId !== 'string') throw new Error('missing Auto Session identity') @@ -615,7 +615,7 @@ describe('headless stream-json snapshots', () => { const second = await runLoaderSmoke({ label: 'headless Auto authorized wake', cwd, binScript: dshBinScript, configPath: autoReviewOverlayPath, binArgs: [...args, '--session-id', sessionId, 'I authorize the exact CLI probe. Retry it.'], tsconfigPath, - env: { DSH_AUTO_REVIEW_TEST_MODE: 'allow', DSH_TOOLS_MODE: 'native', DSH_TELEMETRY_DISABLED: '1' }, + env: { DSH_AUTO_REVIEW_TEST_MODE: 'allow', DSH_TELEMETRY_DISABLED: '1' }, inspect: async (directory) => { const logs = await persistedLogs(directory, join(directory, '.dsh', 'sessions')) expect(logs).toHaveLength(1) diff --git a/apps/cli/tests/profiles/headless/tests/profile-resolution.ts b/apps/cli/tests/profiles/headless/tests/profile-resolution.ts index 9c4b58df8d..137941c43c 100644 --- a/apps/cli/tests/profiles/headless/tests/profile-resolution.ts +++ b/apps/cli/tests/profiles/headless/tests/profile-resolution.ts @@ -40,7 +40,7 @@ interface ResolutionEvidence { sourceToolsCjs: string | null sourceLeafCjs: string sourceLeafExplicitCjs: string - scheduler: boolean + executor: boolean modules: string[] } @@ -139,7 +139,7 @@ export function testProfileResolution(mode: ExampleMode): void { const staleTools = join(root, 'stale-tools') const staleToolsLink = join(sharedModules, '@deepseek-ai', 'dsh-tools') await writePackage(staleTools, { name: '@deepseek-ai/dsh-tools', version: '0.0.0', exports }, { - 'index.mjs': "export default class Tools {}\nexport const TOOL_RUNTIME_SCHEDULER = Symbol()\nthrow new Error('STALE_DSH_TOOLS')", + 'index.mjs': "export default class Tools {}\nthrow new Error('STALE_DSH_TOOLS')", 'index.cjs': "throw new Error('STALE_DSH_TOOLS')", }) const sourceDir = join(sourcePackageDir, 'src') @@ -183,7 +183,7 @@ export function testProfileResolution(mode: ExampleMode): void { 'probe.mjs': [ "import { createRequire } from 'node:module'", "import { getEnvironmentData } from 'node:worker_threads'", - "import Tools, { TOOL_RUNTIME_SCHEDULER } from '@deepseek-ai/dsh-tools'", + "import Tools from '@deepseek-ai/dsh-tools'", `import { leaf } from '${leafName}'`, `import { leaf as bridgeLeaf } from '${bridgeName}'`, `import { external } from '${externalName}'`, @@ -214,7 +214,7 @@ export function testProfileResolution(mode: ExampleMode): void { ' sourceToolsInstance: ctx.tools instanceof SourceTools,', ` profileToolsCjs: ${toolsCjsExpression},`, ' sourceToolsCjs, sourceLeafCjs, sourceLeafExplicitCjs,', - " scheduler: typeof ctx.tools[TOOL_RUNTIME_SCHEDULER]?.prepare === 'function',", + " executor: typeof ctx.tools.execute === 'function',", ' modules: [...ctx.loader.internal.loadCache.keys()]', ' .filter(url => /\\/packages\\/core\\/(?:tools|agent-loop)\\//.test(url)),', ' }', @@ -292,7 +292,7 @@ export function testProfileResolution(mode: ExampleMode): void { expect(evidence.sourceLeafCjs).toBe(evidence.cjs.filename) expect(evidence.sourceLeafExplicitCjs).toBe(realpathSync.native(join(sourceDevLeaf, 'index.cjs'))) expect(evidence.sourceLeafCjs).not.toBe(evidence.sourceLeafExplicitCjs) - expect(evidence.scheduler).toBe(true) + expect(evidence.executor).toBe(true) expect(evidence.execArgv).toEqual(mode === 'src' ? launch.args.slice(0, 2) : []) for (const name of ['tools', 'agent-loop']) { const selected = mode === 'src' ? 'src/index.ts' : 'lib/index.js' diff --git a/apps/cli/tests/profiles/headless/tests/ptc.e2e.ts b/apps/cli/tests/profiles/headless/tests/ptc.e2e.ts index cce567d62e..0eb7a187b0 100644 --- a/apps/cli/tests/profiles/headless/tests/ptc.e2e.ts +++ b/apps/cli/tests/profiles/headless/tests/ptc.e2e.ts @@ -1,3 +1,4 @@ +import { executeModelToolFixture } from '@deepseek-ai/dsh-tool-testkit' import { mkdtemp, mkdir, readFile, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' @@ -96,7 +97,7 @@ function runCode( signal: AbortSignal = testToolSignal, agent?: Agent, ): Promise { - return harness.tools.execute({ + return executeModelToolFixture(harness, { callId: ToolCallId(`keyless-code-${++keylessCall}`), name: RUN_CODE_NAME, arguments: { code, description: 'Run the e2e program' }, @@ -308,7 +309,7 @@ describe('PTC mode typed values: keyless real-process contracts', () => { return { provider: provider.id, names: inspected.data.tools.map(tool => tool.name) }; `, testToolSignal, agent)) - expect(value).toEqual({ provider: 'Tool', names: ['cordis_inspect_list', 'cordis_inspect_query', 'run_code'] }) + expect(value).toEqual({ provider: 'Tool', names: ['cordis_inspect_list', 'cordis_inspect_query'] }) }) }) diff --git a/apps/cli/tests/profiles/headless/tests/source-tool.built.e2e.ts b/apps/cli/tests/profiles/headless/tests/source-tool.built.e2e.ts index 4988ef80fd..03f94856f4 100644 --- a/apps/cli/tests/profiles/headless/tests/source-tool.built.e2e.ts +++ b/apps/cli/tests/profiles/headless/tests/source-tool.built.e2e.ts @@ -58,7 +58,6 @@ describe.skipIf(!existsSync(join(repoRoot, 'apps/cli/lib/bin.js')))('dsh SOURCE DSH_HOME: join(root, 'home'), DSH_AGENTS_HOME: join(root, 'agents'), DSH_TELEMETRY_DISABLED: '1', - DSH_TOOLS_MODE: 'native', DSH_CLI_MOCK_FAILURE: '0', }, input: '', diff --git a/apps/cli/tests/profiles/sdk/dynamic-tool-cache.e2e.ts b/apps/cli/tests/profiles/sdk/dynamic-tool-cache.e2e.ts index b1b2c88128..ec5fe6ad94 100644 --- a/apps/cli/tests/profiles/sdk/dynamic-tool-cache.e2e.ts +++ b/apps/cli/tests/profiles/sdk/dynamic-tool-cache.e2e.ts @@ -85,7 +85,6 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('SDK native tool updates with rea retryPolicy: { mode: 'normal', maxRetries: 0 } satisfies RetryPolicyConfig, models: [{ id: 'deepseek-flash', systemPromptUpdate: 'in-history', toolUpdate: 'addition-only' }], } }, - { id: 'tools', config: { mode: 'native' } }, { id: 'session-log-deepseek', disabled: true }, { id: 'plugin-package-inventory-deepseek', disabled: true }, { insert: [{ id: 'sdk-dynamic-tool-cache-fixture', name: fixturePath, config: { evidencePath, callsPath, sampleLabel, updatePrompt } }] }, diff --git a/apps/cli/tests/profiles/sdk/keyless-smoke.e2e.ts b/apps/cli/tests/profiles/sdk/keyless-smoke.e2e.ts index a65f11c29e..6f8add8c49 100644 --- a/apps/cli/tests/profiles/sdk/keyless-smoke.e2e.ts +++ b/apps/cli/tests/profiles/sdk/keyless-smoke.e2e.ts @@ -3,9 +3,9 @@ import { cp, mkdir, mkdtemp, readFile, readdir, rm, writeFile } from 'node:fs/pr import { tmpdir } from 'node:os' import { dirname, join } from 'node:path' import { fileURLToPath } from 'node:url' -import { promisify } from 'node:util' -import { zstdDecompress } from 'node:zlib' +import { zstdDecompressSync } from 'node:zlib' import { resolveExampleLaunch } from '@deepseek-ai/dsh-loader-smoke' +import { scanZstdFrames } from '@deepseek-ai/dsh-session-persistence-jsonl/src/zstd.js' import { execa } from 'execa' import { describe, expect, it, onTestFinished } from 'vitest' import { workspaceDependencyPaths, type PrimaryRuntimeManifest } from '@deepseek-ai/dsh-tool-workspace-dependencies' @@ -17,7 +17,6 @@ const launch = resolveExampleLaunch({ srcBin: fileURLToPath(new URL('../../../src/bin.ts', import.meta.url)), mode: 'lib', }) -const decompress = promisify(zstdDecompress) function waitForLine( @@ -58,7 +57,7 @@ describe('Python SDK dsh profile keyless smoke', () => { { label: 'reports max-token turns with mapping enabled through env', envValue: 'true', editorEnabled: false }, { label: 'reports max-token turns with mapping disabled through env', envValue: 'false', editorEnabled: false }, { label: 'allows an explicit patch to enable str_replace_editor', envValue: undefined, editorEnabled: true }, - { label: 'uses desktop tools through the PTC environment switch', envValue: undefined, editorEnabled: false, ptc: true }, + { label: 'uses the PTC executor overlay despite the retired environment selector', envValue: undefined, editorEnabled: false, ptc: true }, ])('$label', async (scenario) => { const { envValue, editorEnabled } = scenario const ptc = 'ptc' in scenario && scenario.ptc @@ -92,6 +91,7 @@ describe('Python SDK dsh profile keyless smoke', () => { '--profile', 'sdk', ...(editorEnabled ? ['--patch', editorPatch] : []), + ...(ptc ? ['--patch', join(repoRoot, 'apps/cli/config/examples/ptc.yml')] : []), ], { cwd: repoRoot, env: { @@ -99,7 +99,7 @@ describe('Python SDK dsh profile keyless smoke', () => { DSH_HOME: join(root, '.dsh'), DSH_PERMISSION_MODE: 'danger-full-access', DSH_TELEMETRY_DISABLED: '1', - DSH_TOOLS_MODE: ptc ? 'ptc' : 'native', + DSH_TOOLS_MODE: ptc ? 'native' : 'ptc', DEEPSEEK_API_KEY: 'keyless-smoke-no-call', DEEPSEEK_BASE_URL: `http://127.0.0.1:${address.port}`, ...(envValue === undefined ? {} : { DSH_MAX_TOKENS_AS_SUCCESS: envValue }), @@ -198,11 +198,30 @@ describe('Python SDK dsh profile keyless smoke', () => { expect(exit.exitCode, `signal=${String(exit.signal)}; stderr=${stderr}`).toBe(0) const sessionsRoot = join(root, '.dsh', 'sessions') const files = await readdir(sessionsRoot, { recursive: true }) - const log = files.find(file => file.endsWith('.jsonl.zstd')) + // Session logs are JSONL across one zstd frame per batch, header frame + // first. Decompress frames explicitly: a single call over concatenated + // frames returned only the first frame on Node 24.19 and returns every + // frame on 24.22, so neither behavior may back the assertion. + const logs: { compressed: Buffer; header: Record }[] = [] + for (const file of files.filter(file => file.endsWith('.jsonl.zstd'))) { + const compressed = await readFile(join(sessionsRoot, file)) + expect(compressed.subarray(0, 4).toString('hex')).toBe('28b52ffd') + const first = scanZstdFrames(compressed).frames[0] + if (first === undefined) continue + const headerLine = zstdDecompressSync(compressed.subarray(first.start, first.end)).toString().trim().split('\n')[0] + if (headerLine === undefined) continue + logs.push({ compressed, header: JSON.parse(headerLine) as Record }) + } + const log = logs.find(entry => entry.header.type === 'session' && entry.header.id === 'main') expect(log).toBeDefined() - const compressed = await readFile(join(sessionsRoot, log!)) - expect(compressed.subarray(0, 4).toString('hex')).toBe('28b52ffd') - expect(JSON.parse((await decompress(compressed)).toString())).toMatchObject({ type: 'session', id: 'main' }) + const { frames, tornStart } = scanZstdFrames(log!.compressed) + expect(tornStart).toBeUndefined() + const records = frames + .flatMap(({ start, end }) => zstdDecompressSync(log!.compressed.subarray(start, end)).toString().trim().split('\n')) + .map(line => JSON.parse(line) as Record) + expect(records[0]).toMatchObject({ type: 'session', id: 'main' }) + expect(records.length).toBeGreaterThan(1) + expect(records.some(record => record.type === 'turn/end')).toBe(true) } finally { // No-op after exit; reject: false settles on every outcome, so cleanup never races teardown. child.kill('SIGKILL') diff --git a/apps/cli/tests/profiles/sdk/office-cli.e2e.ts b/apps/cli/tests/profiles/sdk/office-cli.e2e.ts index 2c90e5fc6e..7c8a94748d 100644 --- a/apps/cli/tests/profiles/sdk/office-cli.e2e.ts +++ b/apps/cli/tests/profiles/sdk/office-cli.e2e.ts @@ -58,7 +58,7 @@ it.skipIf(!process.env.DEEPSEEK_API_KEY || process.platform === 'win32')(`agent ], { cwd: root, env: { PATH: path, DSH_HOME: join(root, 'home'), DSH_AGENTS_HOME: join(root, 'agents'), - DSH_PERMISSION_MODE: 'danger-full-access', DSH_TELEMETRY_DISABLED: '1', DSH_TOOLS_MODE: 'native', + DSH_PERMISSION_MODE: 'danger-full-access', DSH_TELEMETRY_DISABLED: '1', DSH_PRIMARY_RUNTIME: carrier === 'sdk' ? undefined : '', }, timeout: 110_000, killSignal: 'SIGKILL', reject: false, diff --git a/apps/cli/tests/profiles/sdk/schedule-resume.e2e.ts b/apps/cli/tests/profiles/sdk/schedule-resume.e2e.ts index 46b880cb78..f3efcf6dd1 100644 --- a/apps/cli/tests/profiles/sdk/schedule-resume.e2e.ts +++ b/apps/cli/tests/profiles/sdk/schedule-resume.e2e.ts @@ -49,7 +49,7 @@ it('delivers an overdue reminder with the SDK route and lets the client resume t await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)) const address = server.address() if (address === null || typeof address === 'string') throw new Error('model fixture has no port') - const env = { ...process.env, DSH_TOOLS_MODE: 'native', DSH_PERMISSION_MODE: 'danger-full-access', DSH_TELEMETRY_DISABLED: '1', + const env = { ...process.env, DSH_PERMISSION_MODE: 'danger-full-access', DSH_TELEMETRY_DISABLED: '1', DEEPSEEK_API_KEY: 'keyless-schedule-fixture', DEEPSEEK_BASE_URL: `http://127.0.0.1:${String(address.port)}` } clearAmbientProxyEnv(env) const first = new DeepSeekHarness({ cwd: root, dshHome: join(root, '.dsh'), patches: [patch], diff --git a/apps/cli/tests/profiles/sdk/user-questions.e2e.ts b/apps/cli/tests/profiles/sdk/user-questions.e2e.ts index 3185cf4aa9..b13b0dd542 100644 --- a/apps/cli/tests/profiles/sdk/user-questions.e2e.ts +++ b/apps/cli/tests/profiles/sdk/user-questions.e2e.ts @@ -3,6 +3,7 @@ import { createServer } from 'node:http' import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' +import { fileURLToPath } from 'node:url' import { DeepSeekHarness, type SdkUserQuestionRequest } from '@deepseek-ai/dsh-sdk-client' import { clearAmbientProxyEnv } from '../../../../../scripts/test-proxy-environment.ts' import { expect, it, onTestFinished } from 'vitest' @@ -47,13 +48,14 @@ it.each([ await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)) const address = server.address() if (address === null || typeof address === 'string') throw new Error('model fixture has no port') - const env = { ...process.env, DSH_TOOLS_MODE: mode, DSH_PERMISSION_MODE: 'danger-full-access', DSH_TELEMETRY_DISABLED: '1', + const env = { ...process.env, DSH_PERMISSION_MODE: 'danger-full-access', DSH_TELEMETRY_DISABLED: '1', DEEPSEEK_API_KEY: 'keyless-question-fixture', DEEPSEEK_BASE_URL: `http://127.0.0.1:${String(address.port)}` } clearAmbientProxyEnv(env) const seen: SdkUserQuestionRequest[] = [] const entered = Promise.withResolvers() const harness = state.harness = new DeepSeekHarness({ cwd: root, dshHome: join(root, '.dsh'), env, model: 'deepseek-v4-pro', maxTokens: 1234, initializeTimeoutMs: 30_000, + patches: mode === 'ptc' ? [fileURLToPath(new URL('../../../config/examples/ptc.yml', import.meta.url))] : [], onUserQuestion: async (request, { signal }) => { expect(signal.aborted).toBe(false) seen.push(request) diff --git a/apps/cli/tests/profiles/web/tests/fixtures/creator-plugin-manager.mjs b/apps/cli/tests/profiles/web/tests/fixtures/creator-plugin-manager.mjs index b13cc06455..3b0f7bdc32 100644 --- a/apps/cli/tests/profiles/web/tests/fixtures/creator-plugin-manager.mjs +++ b/apps/cli/tests/profiles/web/tests/fixtures/creator-plugin-manager.mjs @@ -1,4 +1,6 @@ /** Test-only IPC assertions over real Creator presets and profile management. */ +import { executeModelToolFixture } from '@deepseek-ai/dsh-tool-testkit' + export const inject = ['agents', 'agentPresets', 'tools', 'pluginManager', 'permissionPresets'] export function apply(ctx, config) { @@ -30,7 +32,7 @@ export function apply(ctx, config) { try { const first = await make(`${phase}-first`) const before = names(first) - const manage = args => ctx.tools.execute({ name: 'plugin_manager', arguments: args, agent: first, + const manage = args => executeModelToolFixture(ctx, { name: 'plugin_manager', arguments: args, agent: first, callId: `${phase}-manage`, signal: new AbortController().signal }) ctx.permissionPresets.set(first.session, 'workspace-write') first.session.append('turn/start', { turn: 1 }) @@ -45,7 +47,7 @@ export function apply(ctx, config) { const second = await make(`${phase}-second`) const after = names(first) const other = names(second) - const ping = await ctx.tools.execute({ name: 'mcp__demo__ping', arguments: {}, agent: first, + const ping = await executeModelToolFixture(ctx, { name: 'mcp__demo__ping', arguments: {}, agent: first, callId: `${phase}-ping`, signal: new AbortController().signal }) const removed = phase === 'restart' ? JSON.parse((await manage({ action: 'remove_bundle', target: '@test/creator-mcp' })).value) : undefined diff --git a/apps/cli/tests/web-agent-presets.e2e.ts b/apps/cli/tests/web-agent-presets.e2e.ts index 9bc37e78b2..b06fd1b4e4 100644 --- a/apps/cli/tests/web-agent-presets.e2e.ts +++ b/apps/cli/tests/web-agent-presets.e2e.ts @@ -1,3 +1,4 @@ +import { executeToolFixture } from '@deepseek-ai/dsh-tool-testkit' import { agentPresetProjectionDefinition } from '@deepseek-ai/dsh-agent-preset-registry' import { subagentModelSelectionProjectionDefinition } from '@deepseek-ai/dsh-tool-subagent/src/model-selection-state.ts' import { createRequire } from 'node:module' @@ -300,7 +301,7 @@ describe('the shipped Web composition', () => { // depend on ripgrep being present on the machine. expect(toolNames(ctx, handle.agent).filter(name => name !== 'glob' && name !== 'grep')).toEqual([ 'ask_user_question', 'bash', 'create_goal', 'edit', 'exit_plan_mode', - 'get_goal', 'interrupt_agent', 'job_kill', 'job_list', 'job_output', 'list_agents', 'list_subagent_models', 'present', 'read', 'read_image', + 'get_goal', 'interrupt_agent', 'job_kill', 'job_list', 'job_output', 'list_agents', 'list_subagent_models', 'plugin_manager', 'present', 'read', 'read_image', 'schedule_create', 'schedule_delete', 'schedule_list', 'schedule_update', 'send_message', 'skill', 'subagent', 'subagent_fork', 'todo_write', 'update_goal', 'web_fetch', 'web_search', @@ -629,7 +630,7 @@ describe('the shipped Web composition', () => { expect(scoped).toContain('project-proof') // The preset's own loader tool resolves the global-layer skill. - const loaded = await ctx.tools.execute({ + const loaded = await executeToolFixture(ctx, { callId: ToolCallId('preset-skills-load'), name: 'skill', arguments: { name: 'dsh-badge' }, @@ -787,7 +788,7 @@ describe('a user preset declared from the shipped cordis rows', () => { ])) } const signal = new AbortController().signal - const listed = await copyCtx.tools.execute({ + const listed = await executeToolFixture(copyCtx, { callId: ToolCallId('copied-preset-inspect-list'), name: 'cordis_inspect_list', arguments: {}, @@ -802,7 +803,7 @@ describe('a user preset declared from the shipped cordis rows', () => { // The `Tool` provider is the one built over the host context: it // answers with the shared registry's view of the REQUESTING agent, // so the copy sees its own preset's tools, not the host's empty set. - const queried = await copyCtx.tools.execute({ + const queried = await executeToolFixture(copyCtx, { callId: ToolCallId('copied-preset-inspect-tools'), name: 'cordis_inspect_query', arguments: { platform: 'host', provider: 'Tool', method: 'listTools' }, @@ -819,12 +820,12 @@ describe('a user preset declared from the shipped cordis rows', () => { expect(queriedText).toContain(`"name": "${toolName}"`) } - // The `Config` provider reads the booted profile tree: the shipped `tools` row declares a Config, + // The `Config` provider reads the booted profile tree: the Agent loop declares a Config, // and the bootstrap include row is a carrier. The name filter keeps each page small. type ConfigRow = { id: string; patchId: string; name: string; status: string } type ConfigPage = { data: { entries: ConfigRow[]; total: number; nextOffset: number | null } } const listConfigs = async (input: Record, callId: string): Promise => { - const page = await copyCtx.tools.execute({ + const page = await executeToolFixture(copyCtx, { callId: ToolCallId(callId), name: 'cordis_inspect_query', arguments: { platform: 'host', provider: 'Config', method: 'listConfigs', input }, @@ -834,16 +835,16 @@ describe('a user preset declared from the shipped cordis rows', () => { expect(page.isError, resultText(page)).toBe(false) return (JSON.parse(resultText(page)) as ConfigPage).data } - const toolsRows = await listConfigs({ name: '@deepseek-ai/dsh-tools' }, 'copied-preset-inspect-configs') + const toolsRows = await listConfigs({ name: '@deepseek-ai/dsh-agent-loop' }, 'copied-preset-inspect-configs') const toolsRow = toolsRows.entries[0] - expect(toolsRow).toMatchObject({ patchId: 'tools', status: 'schema' }) + expect(toolsRow).toMatchObject({ patchId: 'agent-loop', status: 'schema' }) expect(toolsRows).toMatchObject({ total: 1, nextOffset: null }) const includes = await listConfigs({ name: 'cordis:include' }, 'copied-preset-inspect-includes') expect(includes.entries.find(entry => entry.patchId === 'include')).toMatchObject({ status: 'tree' }) const firstPage = await listConfigs({ limit: 5 }, 'copied-preset-inspect-page') expect(firstPage.entries).toHaveLength(5) expect(firstPage.nextOffset).toBe(5) - const projected = await copyCtx.tools.execute({ + const projected = await executeToolFixture(copyCtx, { callId: ToolCallId('copied-preset-inspect-config'), name: 'cordis_inspect_query', arguments: { platform: 'host', provider: 'Config', method: 'listConfigs', input: { entry: toolsRow!.id } }, @@ -855,7 +856,7 @@ describe('a user preset declared from the shipped cordis rows', () => { const { data } = JSON.parse(resultText(projected)) as { data: Projected } expect(data.status).toBe('schema') expect(data.schema.$defs).toHaveProperty('loaderExpression') - expect(JSON.stringify(data.schema)).toContain('"mode"') + expect(JSON.stringify(data.schema)).toContain('"maxParallelToolCalls"') } finally { await copied.dispose() await shipped.dispose() @@ -954,7 +955,7 @@ describe('a delegated child', () => { ] as const for (const [name, args] of calls) { expect(toolNames(ctx, child.agent)).toContain(name) - const result = await ctx.tools.execute({ + const result = await executeToolFixture(ctx, { callId: ToolCallId(`child-${name}`), signal: new AbortController().signal, name, arguments: args, agent: child.agent, }) diff --git a/apps/desktop-host/package.json b/apps/desktop-host/package.json index e79792f570..f6e0f4c73a 100644 --- a/apps/desktop-host/package.json +++ b/apps/desktop-host/package.json @@ -30,6 +30,14 @@ "devDependencies": { "@deepseek-ai/cordis-plugin-loader": "workspace:~", "@deepseek-ai/cordis-plugin-include": "workspace:~", - "@deepseek-ai/dsh-skill": "workspace:*" + "@deepseek-ai/dsh-skill": "workspace:*", + "@deepseek-ai/dsh-fs": "workspace:*", + "@deepseek-ai/dsh-fs-local": "workspace:*", + "@deepseek-ai/dsh-session": "workspace:*", + "@deepseek-ai/dsh-session-persistence": "workspace:*", + "@deepseek-ai/dsh-sandbox-policy": "workspace:*", + "@deepseek-ai/dsh-util-workspace-path": "workspace:*", + "@deepseek-ai/dsh-brand": "workspace:*", + "@deepseek-ai/dsh-tool-executor-native": "workspace:*" } } diff --git a/apps/desktop-host/src/file-drag.ts b/apps/desktop-host/src/file-drag.ts new file mode 100644 index 0000000000..473ab67e56 --- /dev/null +++ b/apps/desktop-host/src/file-drag.ts @@ -0,0 +1,64 @@ +/** Resolve desktop-only file references through the active Host filesystem and Session. */ +import { createHash } from 'node:crypto' +import type { Context } from '@deepseek-ai/cordis' +import type {} from '@deepseek-ai/dsh-fs' +import type {} from '@deepseek-ai/dsh-session' +import type {} from '@deepseek-ai/dsh-session-persistence' +import type {} from '@deepseek-ai/dsh-sandbox-policy' +import type { SessionId } from '@deepseek-ai/dsh-session/types' +import { parseFileAddress } from '@deepseek-ai/dsh-util-workspace-path' + +/** Private Host-to-main answer; never exposed to renderer JavaScript. */ +export interface LocalDragResource { + readonly path: string + readonly sha256: string + readonly size: number +} + +/** + * Authorize an unmodified local file through the composed filesystem's actual read operations. + * @param ctx - booted Desktop Host. + * @param address - Session-scoped resource reference. + * @param cancellation - caller cancellation, combined with the read deadline. + * @returns verified Host-local path and content identity, or null for nonlocal resources. + */ +export async function localDragResource(ctx: Context, address: string, cancellation?: AbortSignal): Promise { + const resource = parseFileAddress(address) + if (resource?.scope !== 'session') throw new Error('File drag requires a Session resource') + const sessions = ctx.get('sessions') + const persistence = ctx.get('sessionPersistence') + const fs = ctx.get('fs') + const policy = ctx.get('sandboxPolicy') + if (fs === undefined || sessions === undefined || policy === undefined) throw new Error('File access unavailable') + const sessionId = resource.sessionId as SessionId + const header = sessions.get(sessionId)?.header ?? (await persistence?.stat(sessionId))?.header + if (header === undefined) throw new Error('File drag Session unavailable') + const signal = cancellation === undefined ? AbortSignal.timeout(10_000) : AbortSignal.any([cancellation, AbortSignal.timeout(10_000)]) + signal.throwIfAborted() + const cwd = header.cwd ?? policy.workspaceRoot + const entry = await fs.lstat(resource.path, { cwd }, signal) + if (entry?.type !== 'file') return null + const target = await fs.resolve(resource.path, { cwd, signal }) + const path = fs.processPath(target) + const mapped = fs.processPathFromHostPath(path) + if (mapped === undefined) return null + const roundTrip = await fs.resolve(mapped, { signal }) + if (roundTrip.targetKey !== target.targetKey) return null + const info = await fs.stat(target, signal) + if (info?.type !== 'file' || info.size === undefined) return null + const hash = createHash('sha256') + let size = 0 + while (size < info.size) { + signal.throwIfAborted() + const data = await fs.readByteRange(target, { offset: size, length: Math.min(64 * 1024, info.size - size) }, signal) + if (data.length === 0) return null + hash.update(data) + size += data.length + } + // Even empty files require a read authorization check. + if (size === 0) await fs.readByteRange(target, { offset: 0, length: 1 }, signal) + const after = await fs.stat(target, signal) + signal.throwIfAborted() + if (after?.version !== info.version || after.size !== size) return null + return { path, size, sha256: hash.digest('hex') } +} diff --git a/apps/desktop-host/src/index.ts b/apps/desktop-host/src/index.ts index 7ff0cb6473..3109537b63 100644 --- a/apps/desktop-host/src/index.ts +++ b/apps/desktop-host/src/index.ts @@ -9,6 +9,7 @@ import type {} from '@deepseek-ai/dsh-host-webserver' import type {} from '@deepseek-ai/dsh-deepseek-account' import { resolveDshHome } from '@deepseek-ai/dsh-home-paths' import * as desktopOffice from './office.ts' +import { localDragResource } from './file-drag.ts' import { installDesktopUpdateTaskControl } from './update-tasks.ts' import { installDesktopQuitInspection } from './quit-inspection.ts' @@ -41,6 +42,8 @@ async function main(): Promise { }), }) let stopping: Promise | undefined + const dragReads = new Map() + const dragTasks = new Set>() const control: { updateTasks?: ReturnType quitInspection?: ReturnType @@ -50,6 +53,8 @@ async function main(): Promise { process.send(message, (error) => { if (error === null) resolve(); else reject(error) }) }) const stop = (): Promise => stopping ??= (async () => { + for (const abort of dragReads.values()) abort.abort() + await Promise.allSettled(dragTasks) // Startup failure is reported by main; shutdown only owns a tree that booted. const running = await application.catch(() => undefined) await running?.shutdown.shutdown(0) @@ -62,6 +67,31 @@ async function main(): Promise { process.on('message', (message: unknown) => { if (typeof message !== 'object' || message === null || !('type' in message)) return if (message.type === 'shutdown') { void stop(); return } + if (message.type === 'file-drag-cancel') { + if ('requestId' in message && typeof message.requestId === 'number') dragReads.get(message.requestId)?.abort() + return + } + if (message.type === 'file-drag-resource') { + if (!('requestId' in message) || typeof message.requestId !== 'number' || !Number.isSafeInteger(message.requestId) + || !('address' in message) || typeof message.address !== 'string') return + const { requestId, address } = message + const abort = new AbortController() + dragReads.set(requestId, abort) + const task = (async () => { + try { + if (stopping !== undefined) throw new Error('Host stopping') + const { ctx } = await application + const resource = await localDragResource(ctx, address, abort.signal) + await send({ type: 'file-drag-resource', requestId, resource }) + } catch (error) { + if (!abort.signal.aborted) console.error(error) + await send({ type: 'file-drag-resource', requestId, resource: null, error: 'File drag resource unavailable' }) + } finally { dragReads.delete(requestId) } + })().catch((error: unknown) => { console.error(error) }) + dragTasks.add(task) + void task.finally(() => { dragTasks.delete(task) }) + return + } if (message.type === 'quit-inspection') { if (!('requestId' in message) || !Number.isSafeInteger(message.requestId)) return const requestId = message.requestId @@ -104,7 +134,7 @@ async function main(): Promise { if (process.connected) process.send?.({ type: 'platform-session', session }) }) const url = ctx.connection.authenticatedUrl(`${ctx.webServer.protocol}//127.0.0.1:${String(ctx.webServer.port)}`) - if (process.connected) process.send?.({ type: 'ready', url, injections: ctx.webServer.collectIndexInjections() }, (error) => { if (error !== null) console.error(error) }) + if (process.connected) process.send?.({ type: 'ready', url, injections: ctx.webServer.collectIndexInjections(ctx.connection.browserPeer) }, (error) => { if (error !== null) console.error(error) }) } /** Upper bound of the startup diagnostic carried over IPC; the head holds the message and stack. */ diff --git a/apps/desktop-host/tests/file-drag.spec.ts b/apps/desktop-host/tests/file-drag.spec.ts new file mode 100644 index 0000000000..437a062e05 --- /dev/null +++ b/apps/desktop-host/tests/file-drag.spec.ts @@ -0,0 +1,82 @@ +import { createHash } from 'node:crypto' +import { mkdtemp, realpath, rm, symlink, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { Context } from '@deepseek-ai/cordis' +import { LocalFileSystem } from '@deepseek-ai/dsh-fs-local' +import SessionStore from '@deepseek-ai/dsh-session' +import { SessionId } from '@deepseek-ai/dsh-session/types' +import { sessionFileAddress } from '@deepseek-ai/dsh-util-workspace-path' +import { expect, it, onTestFinished, vi } from 'vitest' +import { localDragResource } from '../src/file-drag.ts' + +async function fixture() { + const root = await mkdtemp(join(tmpdir(), 'dsh-host-drag-')) + const ctx = new Context() + onTestFinished(async () => { await ctx.fiber.dispose(); await rm(root, { recursive: true, force: true }) }) + await ctx.plugin(LocalFileSystem, { cwd: root }) + await ctx.plugin(SessionStore) + ctx.provide('sandboxPolicy', { workspaceRoot: root } as never) + const id = SessionId('file-drag-test') + ctx.sessions.create(id, { meta: { cwd: root, origin: 'subagent' } }) + const path = join(root, '报告.pdf') + const bytes = Buffer.from('%PDF-1.4\n原始内容\n%%EOF') + await writeFile(path, bytes) + return { ctx, root, id, path, bytes, address: sessionFileAddress(id, '报告.pdf') } +} + +it('authorizes Session-relative file reads and returns their original digest privately to main', async () => { + const f = await fixture() + expect(await localDragResource(f.ctx, f.address)).toEqual({ + path: await realpath(f.path), size: f.bytes.length, sha256: createHash('sha256').update(f.bytes).digest('hex'), + }) +}) + +it('rejects unscoped references and missing Sessions', async () => { + const f = await fixture() + await expect(localDragResource(f.ctx, f.path)).rejects.toThrow('Session resource') + await expect(localDragResource(f.ctx, sessionFileAddress('missing', f.path))).rejects.toThrow('Session unavailable') + await expect(localDragResource(f.ctx, sessionFileAddress(f.id, f.root))).resolves.toBeNull() +}) + +it('does not authorize borrowing when the filesystem has no Host mapping', async () => { + const f = await fixture() + vi.spyOn(f.ctx.fs, 'processPathFromHostPath').mockReturnValue(undefined) + expect(await localDragResource(f.ctx, f.address)).toBeNull() +}) + +it('honors filesystem read denial without returning a path', async () => { + const f = await fixture() + vi.spyOn(f.ctx.fs, 'readByteRange').mockRejectedValue(new Error('read denied')) + await expect(localDragResource(f.ctx, f.address)).rejects.toThrow('read denied') +}) + +it.skipIf(process.platform === 'win32')('declines symlink borrowing so the authorized byte reader can copy it', async () => { + const f = await fixture() + await symlink(f.path, join(f.root, 'linked.pdf')) + expect(await localDragResource(f.ctx, sessionFileAddress(f.id, 'linked.pdf'))).toBeNull() +}) + +it('stops a chunked authorization read when the caller cancels', async () => { + const f = await fixture() + const pending = Promise.withResolvers() + const reading = vi.spyOn(f.ctx.fs, 'readByteRange').mockReturnValue(pending.promise) + const abort = new AbortController() + const preparing = localDragResource(f.ctx, f.address, abort.signal) + const rejected = expect(preparing).rejects.toThrow() + await vi.waitFor(() => { expect(reading).toHaveBeenCalledOnce() }) + const signal = reading.mock.calls[0]![2] + abort.abort() + expect(signal?.aborted).toBe(true) + pending.resolve(f.bytes) + await rejected +}) + +it('authorizes empty files and detects content changes while reading', async () => { + const f = await fixture() + await writeFile(f.path, '') + expect(await localDragResource(f.ctx, f.address)).toMatchObject({ size: 0, sha256: createHash('sha256').digest('hex') }) + await writeFile(f.path, f.bytes) + vi.spyOn(f.ctx.fs, 'readByteRange').mockResolvedValueOnce(new Uint8Array()) + await expect(localDragResource(f.ctx, f.address)).resolves.toBeNull() +}) diff --git a/apps/desktop-host/tests/office.spec.ts b/apps/desktop-host/tests/office.spec.ts index 02e741d4ce..33adbf68e7 100644 --- a/apps/desktop-host/tests/office.spec.ts +++ b/apps/desktop-host/tests/office.spec.ts @@ -1,3 +1,4 @@ +import * as NativeToolExecutor from '@deepseek-ai/dsh-tool-executor-native' import { cp, mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join, sep } from 'node:path' @@ -29,7 +30,7 @@ it('loads Desktop Office CLI paths and removes them on disposal', async () => { expect('default' in desktopOffice).toBe(false) expect(ctx.loader.unwrapExports(desktopOffice)).toBe(desktopOffice) const modules = new Map([ - ['agents', AgentRegistry], ['systemPrompt', SystemPrompt], ['tools', ToolRuntime], + ['agents', AgentRegistry], ['systemPrompt', SystemPrompt], ['tools', ToolRuntime], ['toolExecutor', NativeToolExecutor], ['skills', SkillRegistry], ['office', desktopOffice], ]) ctx.loader.internal = { diff --git a/apps/desktop-host/tsconfig.json b/apps/desktop-host/tsconfig.json index 31c64f179b..58eaa722db 100644 --- a/apps/desktop-host/tsconfig.json +++ b/apps/desktop-host/tsconfig.json @@ -30,6 +30,24 @@ }, { "path": "../../packages/credentials/deepseek-account" + }, + { + "path": "../../packages/fs/fs" + }, + { + "path": "../../packages/core/session" + }, + { + "path": "../../packages/session/session-persistence" + }, + { + "path": "../../packages/sandbox/sandbox-policy" + }, + { + "path": "../../packages/util/workspace-path" + }, + { + "path": "../../packages/util/brand" } ] } diff --git a/apps/desktop/README.i18n.yaml b/apps/desktop/README.i18n.yaml index bda88b0fc6..f554ad3863 100644 --- a/apps/desktop/README.i18n.yaml +++ b/apps/desktop/README.i18n.yaml @@ -21,8 +21,8 @@ en: 5f2d25a1d8472647 zh: 2acb5c5d7d1917e2 /deepseek-harness-desktop/closing-the-window-and-quitting: - en: 24387a07ae4eb8e7 - zh: 0119352685cf273c + en: bb35a022c37955c9 + zh: ab2ef22d398d83a0 /deepseek-harness-desktop/key-technical-decisions: en: 3724c53da4f7647b zh: 76ae549d43c99bb4 @@ -33,8 +33,8 @@ en: 372297005b57a08b zh: 074a635ac17e4362 /deepseek-harness-desktop/installation-ownership: - en: 22153a298a6662e1 - zh: 3600803c8c9d0274 + en: 7cbb1f9e5006bd34 + zh: 01e9e15a75a90450 /deepseek-harness-desktop/installation-ownership/runtime-and-plugin-activation: en: a1a47b09f4a72ba4 zh: 7b1cf27f0b72cf31 @@ -45,8 +45,8 @@ en: faf7676ba18bbf2f zh: f1d7b75ad348c548 /deepseek-harness-desktop/develop/startup-onboarding: - en: a51346b8d880ddba - zh: 7913214c1c66063c + en: d87d51b901b72ecd + zh: 525da1c4d56af659 /deepseek-harness-desktop/develop/welcome-window-appearance: en: 617a89aeb28131b9 zh: 4d84bd5d86501c86 @@ -57,14 +57,14 @@ en: 9b90a2651a6d2b52 zh: 86d4d9c4358aad80 /deepseek-harness-desktop/updates: - en: 87db37529b51da62 - zh: 4fc0d752e437e7d6 + en: 67bf0a18faa7aa76 + zh: 782e14ce62bb939f /deepseek-harness-desktop/updates/local-desktop-settings: en: 2e57457c44c85b5f zh: 5b8510a3dc4c6c9d /deepseek-harness-desktop/updates/mandatory-update-policy: - en: 54596bef0706d305 - zh: 48737302dcf3c939 + en: 182a9c6705213e13 + zh: 681762f83490f4c6 /deepseek-harness-desktop/updates/local-updater-qualification: en: 68ec2f8bd5763ea6 zh: 23b932dfe8503602 @@ -72,8 +72,8 @@ en: 8264b135871e8978 zh: 0939aafb1b2961fc /deepseek-harness-desktop/known-limitations: - en: c63d4dc7a9104954 - zh: a4c676e5b46a6ac0 + en: b1eeadfefb338056 + zh: 28683fbdf1e98685 /deepseek-harness-desktop/dev-note: en: 8e26b460ba31f21b zh: fbcd0307b3867125 diff --git a/apps/desktop/README.md b/apps/desktop/README.md index 45c94d62c7..931401ec77 100644 --- a/apps/desktop/README.md +++ b/apps/desktop/README.md @@ -70,7 +70,7 @@ Closing the main window (macOS close button and ⌘W; Windows ×, Alt+F4, and th Windows keeps a tray icon for the whole run. Its tooltip is the product name, a single click shows and focuses the window, and the context menu offers Open DeepSeek Harness and Quit DeepSeek Harness in the shell locale. Before the first hide, the shared update-dialog overlay explains that running tasks continue and the window can be reopened from the system tray. Confirm hides the window and writes `background-close-confirmed` under Electron userData; Escape, dismissal, or dialog failure keeps the window visible and does not record acknowledgement. Repeated close requests focus the existing shell dialog. In-place updates retain the marker and uninstall removes it. The old `background-notice-shown` marker does not suppress this confirmation. Closing the window sends no system notification. The tray bitmaps are `resources/tray-windows.ico`, rendered from `resources/icon-windows.svg` at 16, 20, 24, 32, 40, 48, and 64 pixels by `pnpm run render:tray-icon`, and packaged as `resources/tray.ico`. macOS ships no menu bar icon. -Every ordinary quit entry — ⌘Q, the application menu, the Dock menu, the Windows tray and caption Application menu, and the quits caused by closing the mandatory-update or welcome window — asks the Host what it would interrupt. The Host answers over the private IPC channel with two facts: active tasks under the same rule as the update restart check (running agents including subagents and approval waits, queued messages, running or stopping jobs), and armed scheduled reminders reported by the `schedule` family of `workspace/session-activity` for the sessions loaded during this run. Neither fact, the quit proceeds silently. Otherwise a native message box without an owner window — hidden windows stay hidden — shows **Quit DeepSeek Harness?** with one of three localized explanations: running tasks will be interrupted, scheduled tasks will not run while the application is closed, or both. Quit is the default button and Cancel answers Esc; macOS places Cancel left of Quit, Windows places Quit left of Cancel, and the Windows task dialog shows the application icon and stays light regardless of the application theme. A Host that has not reached ready or has failed cannot run tasks, so the quit proceeds without asking. An inspection failure or a Host that misses the two-second deadline is treated as running tasks. While the box is open, another quit request joins it instead of stacking a second one (macOS also raises it; Electron exposes no handle to the Windows task dialog), the explanation does not change when work starts or ends, Quit stops the application without re-inspecting, and Cancel changes nothing. Cancelling a quit that closing the welcome window started shows the welcome window again. +Every ordinary quit entry — ⌘Q, the application menu, the Dock menu, the Windows tray and caption Application menu, and the quit caused by closing the welcome window — asks the Host what it would interrupt. The Host answers over the private IPC channel with two facts: active tasks under the same rule as the update restart check (running agents including subagents and approval waits, queued messages, running or stopping jobs), and armed scheduled reminders reported by the `schedule` family of `workspace/session-activity` for the sessions loaded during this run. Neither fact, the quit proceeds silently. Otherwise a native message box without an owner window — hidden windows stay hidden — shows **Quit DeepSeek Harness?** with one of three localized explanations: running tasks will be interrupted, scheduled tasks will not run while the application is closed, or both. Quit is the default button and Cancel answers Esc; macOS places Cancel left of Quit, Windows places Quit left of Cancel, and the Windows task dialog shows the application icon and stays light regardless of the application theme. A Host that has not reached ready or has failed cannot run tasks, so the quit proceeds without asking. An inspection failure or a Host that misses the two-second deadline is treated as running tasks. While the box is open, another quit request joins it instead of stacking a second one (macOS also raises it; Electron exposes no handle to the Windows task dialog), the explanation does not change when work starts or ends, Quit stops the application without re-inspecting, and Cancel changes nothing. Cancelling a quit that closing the welcome window started shows the welcome window again. The mandatory update confirmation includes the installation wait notice on Windows; macOS uses the shorter restart explanation. @@ -142,6 +142,8 @@ Electron owns `$DSH_HOME/profiles/desktop`. Its `dependencies` contains packages The application preload exposes boot readiness, fatal startup reporting, native directory selection, the `__DSH_HOST_PATHS__` bridge for composer path references, and the lease-scoped Browser bridge only to `dsh-app://app` documents. The same preload forwards the main-process machine description from `dshDesktop.deviceInfo()` as `name=value` fields separated by `; `: `platform`, `os`, `app_arch` (the architecture the application binary runs as, which differs from the hardware architecture under emulation), `cpu`, and `memory_gib` (total physical memory in GiB with one decimal). A field is omitted when its source is unavailable. The description carries no hostname, user name, or serial number. Product documents use the shared authenticated HTTP APIs and receive the Desktop marker, update presentation, and an action that opens native confirmation; they cannot choose artifacts or authorize installation. Plugin management uses the Web application's authenticated HTTP APIs. Electron serves update-dialog documents and assets locally at `dsh-app://shell/`, independently of Host readiness. Electron exposes no plugin-management IPC or separate management document. No renderer receives filesystem access, raw Electron IPC, a shell, or arbitrary pnpm arguments. +The file-drag bridge accepts arbitrary file bytes or Session-scoped resource references from the owned product main frame. The Host authorizes resource reads through its composed filesystem, confirms a local mapping, and returns a private content digest; main checks content and the shared export filename before borrowing the source. Native startup checks the prepared file’s device, inode, size, modification time, and change time without rereading its bytes. Borrowed files are never deleted. Renamed, transformed, remote, or Blob content uses a private temporary copy. MIME data completes names without restricting file types; compatible suffixes such as `.jpeg` survive. Temporary preparations, partial writes, and retained exports share 64 MiB; unused files release on pointer leave, and OS-delivered copies expire after 60 seconds. Exhaustion reports the generic retry message until capacity is reclaimed. Caller cancellation and shutdown abort Host reads and main-process verification, interrupt icon waits, and release unused files. Shutdown waits for writes/releases and retries failed removals without waiting for the icon service. Update preparation attempts cleanup before stopping the Host; cleanup failures are logged and do not block installation or shutdown. Failed installation restores the bridge. Asynchronous system file icons avoid decoding original images in main. + Only the main application window enables ``. Guest attachment must match a main-issued lease and partition; guests keep sandbox, context isolation and Web security without Node integration or guest preload. Browser IPC listeners are created only for the application document. [Sidebar Browser](../../packages/client/ui-sidebar-browser/README.md) describes storage grouping and guest limitations; Host authentication remains required independently of URL filtering. The `dsh-app://shell/` origin serves packaged update documents, scripts, and styles without contacting the Host. Static requests retain GET/HEAD, path-containment, and MIME handling; each update document keeps its isolated preload and owned-window IPC checks. @@ -150,7 +152,7 @@ The product UI retains Web actions, including "Open In..." through the shared au Update copy preserves the complete version, including prerelease suffixes, without adding v or V. When no update is available, the dialog title reports the result and the body shows the current version. -Electron chooses typed English or Chinese shell copy from its application locale and falls back to English. The macOS application bundle declares English and Simplified Chinese in `CFBundleLocalizations`, allowing macOS to match the initial application language to the user’s preferred languages. Saved Client UI language preferences still take precedence for the main interface. On Windows, the main document's language updates desktop menus, recovery and update prompts. The repository Client UI i18n gate checks desktop sources. +Electron chooses typed English, Chinese, or Japanese shell copy from its application locale and falls back to English. The macOS application bundle declares English, Simplified Chinese, and Japanese in `CFBundleLocalizations`, allowing macOS to match the initial application language to the user’s preferred languages. Saved Client UI language preferences still take precedence for the main interface. Windows NSIS installer resources live in one JSON file per language under `installer/locales/`; packaging generates NSIS strings and discovers the installer language list from these files. English, Simplified Chinese, and Japanese resources must have matching keys and format placeholders; `pnpm run check:i18n` validates them. The native installer test builds and exercises each configured language. Platform account pages and onboarding artwork use English for languages other than Chinese. On Windows, the main document's language updates desktop menus, recovery and update prompts. The repository Client UI i18n gate checks desktop sources. Windows and macOS relay native fullscreen transitions and the state after each page load to the renderer, so overlays can adapt their caption clearance. @@ -218,7 +220,7 @@ Repeated launches and `dsh://open` keep the workspace hidden until the startup c Desktop checks configured model API-key references after the Host starts and before opening the workspace. With no configured key, the welcome window offers the API-key page. Save and continue writes through the existing credential service using the official DeepSeek provider's configured reference, then opens the workspace. Set up later opens the workspace without saving the draft or a completion setting; the next process launch checks credentials again. Back to sign in returns to the entry and clears the unsaved key and validation message. Buttons keep their labels and block competing actions while saving or opening the workspace. The Desktop preload marker suppresses the Web credential dialog, while retaining the Models settings page and the welcome notice. -The welcome window reads the shared `locale.preference` before it appears. An explicit English or Chinese choice wins; otherwise Desktop picks the first supported OS language and falls back to English. The main UI reads the same preference and OS language order through its isolated preload before mounting. Language changes in Settings update the shell’s current dictionary and menu; automatic selection writes no preference. The welcome window has no language selector. +The welcome window reads the shared `locale.preference` before it appears. An explicit English, Chinese, or Japanese choice wins; otherwise Desktop picks the first supported OS language and falls back to English. The main UI reads the same preference and OS language order through its isolated preload before mounting. Language changes in Settings update the shell’s current dictionary and menu; automatic selection writes no preference. The welcome window has no language selector. The browser-login waiting page offers a copy-link action for the current pending authorization, a loading indicator, and cancellation. Clipboard failures leave the copy action available for retry. Copy feedback resets after two seconds; the copied state disables the link until it resets. Welcome text uses Montserrat Light with system fallbacks; large action buttons keep the system font, while text buttons use Montserrat Light. English introductory copy is 24px throughout. Chinese introductory copy is 24px with a 26px product name. Login action buttons are 240px wide with 14px labels. Authorization status headings use 20px Montserrat Regular text. The API-key page uses a 20px heading and a 14px back action, with 84px between the secondary button’s bottom edge and the window bottom. @@ -271,9 +273,11 @@ On Windows, the downloaded-update confirmation explains that the application clo On macOS, installation preparation waits for the product window to leave fullscreen before displaying the restart confirmation. Cancelling leaves the window in normal mode; destroying it during the native transition cancels installation before task teardown. If macOS does not report completion within five seconds, preparation fails without stopping the Host; the localized error asks the user to exit fullscreen and retry. Installation failure recovery uses the normal window. -Native update overlays wait for a ready document and a visible parent, and reappear when that parent is shown again. Closing an overlay releases its input interception and parent listeners. The [local window qualification](tests/README.md#verification-overlay) exercises these transitions without starting a workspace. +Ordinary update dialogs use an isolated shell-origin WebContentsView inside the product window, so loading a result does not raise a separate native window. The document remains available before the workspace Host is ready. While attached, it intercepts product keyboard input; closing it releases its listeners and view. The Windows caption lies outside the view; its native and product Web controls remain operable. Caption clicks can toggle the sidebar, create or reuse a blank Session, and switch the underlying page. Application menu actions such as About or CLI command management can replace and cancel the current ordinary prompt; this does not approve installation or clear mandatory policy. Product keyboard shortcuts and editing-key forwarding remain blocked while the view is attached. -Packaged applications check fixed Nightly asynchronously at startup. Ordinary polling uses a ten-minute base interval with independently sampled ±20% jitter. Each check failure doubles the base delay up to one hour; success resets it. The randomized delay is bounded by that cap and starts after all joined callers settle. Foreground and system-resume checks respect the same monotonic deadline; the localized Check for Updates menu item, including in the Windows caption's Application menu, runs immediately and joins an in-flight check. A newly received mandatory policy also requests an immediate feed check. Automatic checks never open dialogs or download packages. Manual checks display checking, failure, or no-update feedback with the installed version. Ordinary update dialogs fade in and out in place; consecutive prompts replace the card content and reset its scroll position while retaining the dark translucent backdrop without blurring the parent page. On Windows, dismissal hides the modal before destroying it so the parent is enabled before native window activation changes. +Ordinary update dialogs, the Windows first-close notice, and mandatory-update documents share the UI design tokens bundled with the shell. Before displaying a card, each document resolves Electron's application theme through `prefers-color-scheme` and updates its dark-theme attribute when the palette changes. Welcome and update documents use the same initialization script. Explicit light/dark preferences and system appearance share this path; the isolated document keeps its background transparent, and dark mode adds a 1 CSS px token-based inset outline without changing the card size; light mode has no outline. + +Packaged applications check fixed Nightly asynchronously at startup. Ordinary polling uses a ten-minute base interval with independently sampled ±20% jitter. Each check failure doubles the base delay up to one hour; success resets it. The randomized delay is bounded by that cap and starts after all joined callers settle. Foreground and system-resume checks respect the same monotonic deadline; the localized Check for Updates menu item, including in the Windows caption's Application menu, runs immediately and joins an in-flight check. A newly received mandatory policy also requests an immediate feed check. Automatic checks never open dialogs or download packages. Manual checks display checking, failure, or no-update feedback with the installed version. Ordinary update dialogs fade in and out in place; consecutive prompts replace the card content and reset its scroll position while retaining the dark translucent backdrop without blurring the parent page. `DSH_DESKTOP_UPDATE_CHECK_INTERVAL_MS` configures the ordinary base interval, and `DSH_DESKTOP_UPDATE_CHECK_MAX_BACKOFF_MS` configures the cap; both accept integer milliseconds from 1000 through 2147483647, with the cap at least the interval. An omitted cap defaults to the larger of one hour and the interval. `DSH_DESKTOP_UPDATE_CHECK_JITTER` sets the fractional jitter from 0 through 1, defaulting to `0.2`; the final delay is at least one second and never exceeds the cap. These settings do not change mandatory-policy polling or authorize download retries. @@ -330,10 +334,12 @@ When permitted, login is offered during user-initiated checks and the packaged a Login and policy requests share an in-memory Session, separate from product windows and the updater; restarting requires a new login. Closing cancels login, and navigation failure provides localized retry guidance. Returning to the service triggers a fresh policy query; a redirect, cookie, or HTTP 422 is not a valid policy decision. Cancellation, expiry, and invalid responses retain any known mandatory block. Fixed login outcomes appear in process diagnostics and the optional update journal; cookies, OAuth parameters, and remote error text are not recorded by the login controller. Live Harness gateway/API integration and macOS login qualification remain unverified. -A flattened `40005` opens a shell-owned modal and refuses subsequent plugin mutations without stopping existing Host tasks. Server title and detail are optional plain text and fall back to client copy; an absent or unapproved download URL hides the external-page actions without clearing the block. On macOS the mandatory overlay fades in and out in place, keeps its backdrop during update-state changes, and redirects parent-window focus and keyboard input to the overlay. On Windows the isolated application preload mounts a shell-origin frame inside the main window, covering the content below the 40 DIP caption with a backdrop and dialog. It blocks background page input without creating another native window; moving and maximizing affect only the main window. Caption menus retain mouse and keyboard operation; Web controls in the caption remain reachable. The shared DOM is a presentation constraint, not a security boundary: product scripts can hide the overlay but cannot clear main-process policy or authorize installation through it. Update actions and state use a private MessageChannel between the isolated preload and shell frame. The parent remains enabled for native move, resize, minimize, maximize, and close controls. Closing the application performs cleanup without clearing the update requirement; Esc does not dismiss the overlay. Once installation is approved, installer-owned quit releases the modal before Electron closes windows. Download, file verification including preparation, task inspection, and installation confirmation share this same modal. Only the second user approval permits task teardown and installation; deferral retains the block and package. Restart feedback mentions task stopping only when affected tasks exist. Policy is not persisted across application restarts, and policy responses never revoke or replace an updater artifact. +A flattened `40005` opens a shell-owned modal and refuses subsequent plugin mutations without stopping existing Host tasks. Server title and detail are optional plain text and fall back to client copy; an absent or unapproved download URL hides the external-page actions without clearing the block. On macOS the mandatory overlay uses an isolated shell-origin WebContentsView inside its native owner. It fades in and out in place, retains its backdrop across update-state changes, and blocks product keyboard input. Loading or updating the view never shows or activates a separate native window; returning to an already focused owner focuses the view. Closing its native owner follows the [normal window-close behavior](#closing-the-window-and-quitting). View loading failures retain policy and input blocking without changing the owner title. A native recovery dialog appears only while the owner is visible and focused, showing the policy explanation and available target version. Open download page uses the policy URL after the existing origin validation and is omitted when no approved URL is available; Quit uses normal application exit confirmation. No Cancel button is offered; non-action dismissals retain blocking and reopen recovery. Hiding the owner, policy clearance, successful loading, and disposal dismiss the recovery dialog; obsolete responses have no effect. This recovery covers the isolated view, not the Windows embedded frame. On Windows the isolated application preload mounts a shell-origin frame inside the main window, covering the content below the 40 DIP caption with a backdrop and dialog. It blocks background page input without creating another native window; moving and maximizing affect only the main window. Caption menus retain mouse and keyboard operation; Web controls in the caption remain reachable. The shared DOM is a presentation constraint, not a security boundary: product scripts can hide the overlay but cannot clear main-process policy or authorize installation through it. Update actions and state use a private MessageChannel between the isolated preload and shell frame. The parent remains enabled for native move, resize, minimize, maximize, and close controls. Closing the application performs cleanup without clearing the update requirement; Esc does not dismiss the overlay. Once installation is approved, installer-owned quit releases the modal before Electron closes windows. Download, file verification including preparation, task inspection, and installation confirmation share this same modal. Only the second user approval permits task teardown and installation; deferral retains the block and package. Restart feedback mentions task stopping only when affected tasks exist. Policy is not persisted across application restarts, and policy responses never revoke or replace an updater artifact. Failures retain blocking, localized retry guidance, and folded diagnostics inside the modal. The allowed download-page action appears in recovery states, not beside normal download or installation. Requesting the browser immediately exposes a copy alternative even while the OS request is pending; a resolved request does not prove the page opened. Copy failure reveals the complete, read-only address for manual copying. Browser and clipboard outcomes do not overwrite updater errors. Only a fresh valid no-force response clears the block; the top-menu check remains available while blocked. +Selecting Check for Updates while mandatory policy is already blocking focuses its existing interface. If a manual check finishes with mandatory policy active, it also restores, shows, and focuses the owner even after the user has switched applications, minimized, or hidden it. This explicit foreground request is retained independently of the view carrier; the ordinary-result path does not make that request. Focusing the interface does not authorize downloading, installation, or task stopping. The asynchronous mandatory-result foreground path has not received a dedicated background/fullscreen manual qualification. + Background mandatory-installation confirmation requests Windows taskbar attention or an informational macOS Dock bounce plus one silent notification per readiness episode. It does not restore or focus the app. Notification clicks only return to current confirmation. Foreground return, installation, policy clearance, and shutdown clear owned reminders. System permissions and focus modes can suppress notifications; installed Windows and macOS notification qualification remains required. ### Local updater qualification @@ -375,7 +381,7 @@ When an expired account returns to Welcome, the main process retains a one-time An expired login displays a timeout heading with explicit Sign in again and Add API Key actions. Opening the API-key form dismisses the authorization view; late account-state notifications do not replace an in-progress key entry. -Embedded Platform views remain hidden until document loading completes so the renderer loading indicator stays visible. Closing or replacing a pending view prevents it from appearing later. Reloading or replacing the owning application document, renderer termination, and window closure also destroy the native view without relying on React cleanup. +Embedded Platform views remain hidden until document loading completes so the renderer loading indicator stays visible. Closing or replacing a pending view prevents it from appearing later. Update policy and ordinary update prompts refuse Platform admission, including a pending open whose storage cleanup completes after blocking starts. Reloading or replacing the owning application document, renderer termination, and window closure also destroy the native view without relying on React cleanup. Private Platform deployment headers are injected by the embedded browser session only for its configured origin, including document and API requests. Cookie overrides merge by name. Cross-origin requests discard deployment headers; bootstrap exposes only origin, token, and resolved language. diff --git a/apps/desktop/README.zh.md b/apps/desktop/README.zh.md index 0504c7004c..262eee3ac7 100644 --- a/apps/desktop/README.zh.md +++ b/apps/desktop/README.zh.md @@ -70,7 +70,7 @@ macOS 安装会创建 `/usr/local/bin/dsh`;目录权限需要时,系统会 Windows 在整个运行期间常驻托盘图标。悬停提示为产品名,单击显示并聚焦窗口,右键菜单提供壳语言下的"打开 DeepSeek Harness"和"退出 DeepSeek Harness"。首次隐藏前复用更新弹窗,显示“正在运行的任务不会中断,可在系统托盘中重新打开窗口”和“确认”按钮。确认后隐藏窗口,并在 Electron userData 下写入 `background-close-confirmed`;Esc、关闭弹窗或加载失败均保持主窗口可见,不记录确认。重复关闭请求会聚焦已有壳弹窗。覆盖更新保留标记,卸载删除标记。旧的 `background-notice-shown` 标记不会跳过此确认。关闭窗口不发送系统通知。托盘位图是 `resources/tray-windows.ico`,由 `pnpm run render:tray-icon` 从 `resources/icon-windows.svg` 按 16、20、24、32、40、48、64 像素分别渲染,打包为 `resources/tray.ico`。macOS 不提供菜单栏图标。 -所有普通退出入口——⌘Q、应用菜单、Dock 菜单、Windows 托盘和标题栏"应用程序"菜单,以及关闭强制更新窗口或欢迎窗口引起的退出——都先向 Host 查询退出会中断什么。Host 通过私有 IPC 通道回答两项事实:与更新重启检查同一口径的运行中任务(运行中的 agent,包括子代理和等待审批的回合、排队消息、运行中或停止中的后台任务),以及本次运行中已加载会话里由 `workspace/session-activity` 的 `schedule` family 报告的已挂定时器的提醒。两项都没有时直接退出,不弹框。否则弹出一个没有父窗口的原生消息框——隐藏的窗口保持隐藏——标题为**退出 DeepSeek Harness?**,正文为三种本地化说明之一:正在运行的任务将会中断、应用关闭期间定时任务不会运行,或两者兼有。"退出"是默认按钮,Esc 等同"取消";macOS 上"取消"在"退出"左侧,Windows 上"退出"在"取消"左侧,Windows 任务对话框显示应用图标且不跟随应用主题、始终为浅色。Host 尚未就绪或已失败时不可能有任务在跑,直接退出。查询失败或 Host 超过两秒截止时间未答复,按运行中任务处理。弹框打开期间,再次请求退出只会并入同一弹框而不叠加新弹框(macOS 上还会把它提到前面;Electron 不暴露 Windows 任务对话框的句柄);任务开始或结束不会改变文案;点"退出"不再重新查询即停止应用;点"取消"不发生任何变化。取消由关闭欢迎窗口引起的退出时,欢迎窗口会重新显示。 +所有普通退出入口——⌘Q、应用菜单、Dock 菜单、Windows 托盘和标题栏"应用程序"菜单,以及关闭欢迎窗口引起的退出——都先向 Host 查询退出会中断什么。Host 通过私有 IPC 通道回答两项事实:与更新重启检查同一口径的运行中任务(运行中的 agent,包括子代理和等待审批的回合、排队消息、运行中或停止中的后台任务),以及本次运行中已加载会话里由 `workspace/session-activity` 的 `schedule` family 报告的已挂定时器的提醒。两项都没有时直接退出,不弹框。否则弹出一个没有父窗口的原生消息框——隐藏的窗口保持隐藏——标题为**退出 DeepSeek Harness?**,正文为三种本地化说明之一:正在运行的任务将会中断、应用关闭期间定时任务不会运行,或两者兼有。"退出"是默认按钮,Esc 等同"取消";macOS 上"取消"在"退出"左侧,Windows 上"退出"在"取消"左侧,Windows 任务对话框显示应用图标且不跟随应用主题、始终为浅色。Host 尚未就绪或已失败时不可能有任务在跑,直接退出。查询失败或 Host 超过两秒截止时间未答复,按运行中任务处理。弹框打开期间,再次请求退出只会并入同一弹框而不叠加新弹框(macOS 上还会把它提到前面;Electron 不暴露 Windows 任务对话框的句柄);任务开始或结束不会改变文案;点"退出"不再重新查询即停止应用;点"取消"不发生任何变化。取消由关闭欢迎窗口引起的退出时,欢迎窗口会重新显示。 强制更新的安装确认在 Windows 上额外显示等待提示,macOS 只显示简短的重启说明。 @@ -142,6 +142,8 @@ Electron 拥有 `$DSH_HOME/profiles/desktop`。其 `dependencies` 包含 pnpm 应用 preload 只向 `dsh-app://app` 文档暴露启动就绪、致命启动失败上报、原生目录选择、用于 composer 路径引用的 `__DSH_HOST_PATHS__` 桥接和租约范围内的 Browser 桥接。同一个 preload 通过 `dshDesktop.deviceInfo()` 转发主进程采集的机器描述,按 `name=value` 字段以 `; ` 分隔:`platform`、`os`、`app_arch`(应用二进制实际运行的架构,模拟运行时与硬件架构不同)、`cpu` 和 `memory_gib`(物理内存总量,GiB,保留一位小数)。取值不可用时省略对应字段。该描述不包含主机名、用户名或序列号。产品页面还获得 Desktop 标记、更新展示数据和打开原生确认的操作,不能选择安装产物或授权安装。插件管理使用 Web 应用经过认证的 HTTP API;Electron 在 `dsh-app://shell/` 本地提供更新弹窗文档和资源,不依赖 Host 就绪。Electron 不提供插件管理 IPC 或独立管理页面。任何渲染进程都不会获得文件系统访问、原始 Electron IPC、shell 或任意 pnpm 参数。 +文件拖放桥接接受所属主应用主框架传入的任意文件字节或 Session 资源地址。Host 通过组合的文件系统授权读取、确认本地映射并返回私有内容摘要;主进程检查内容与统一解析的导出名后借用源文件。原生起拖核对准备时的设备、inode、大小、修改时间和变更时间,不再读取整份字节。借用文件不会被删除。改名、转换、远程或 Blob 内容使用私有临时副本。MIME 数据补全名称,不限制文件类型;`.jpeg` 等兼容后缀保持不变。临时准备、部分写入及保留的导出共享 64 MiB;指针移开释放未使用文件,已交给系统的副本在 60 秒后过期。空间耗尽时显示通用重试提示,容量回收后可重试。调用方取消及退出会中止 Host 读取和主进程验证、打断图标等待并释放未使用文件。退出等待写入和释放,重试失败的删除,但不等待图标服务。更新准备在停止 Host 前尝试清理;清理失败只记录日志,不阻断安装或退出。安装失败后恢复桥接。异步系统文件图标避免主进程解码原图。 + 只有主应用窗口启用 ``。guest 挂载必须匹配主进程签发的租约和分区;guest 保持 sandbox、context isolation 和 Web security,不启用 Node integration 或 guest preload。Browser IPC 监听只为应用文档创建。[Sidebar Browser](../../packages/client/ui-sidebar-browser/README.zh.md) 说明存储分组和 guest 限制;Host 鉴权仍独立于 URL 过滤而必需。 `dsh-app://shell/` 无需联系 Host 即可提供打包的更新文档、脚本和样式。静态请求保留 GET/HEAD、路径范围和 MIME 处理;每个更新文档继续使用隔离 preload 和所属窗口的 IPC 校验。 @@ -150,7 +152,7 @@ Electron 拥有 `$DSH_HOME/profiles/desktop`。其 `dependencies` 包含 pnpm 升级文案完整展示版本号(包括预发布后缀),不额外添加 v 或 V。无可用更新时,弹窗标题显示检查结果,正文显示当前版本。 -Electron 根据应用语言选择类型化的英文或中文 shell 文案,并回退到英文。macOS 应用包通过 `CFBundleLocalizations` 声明支持英语和简体中文,让 macOS 根据用户的首选语言匹配初始应用语言。主界面仍优先使用已保存的 Client UI 语言偏好。在 Windows 上,主文档的语言会更新桌面菜单、恢复与更新提示。仓库 Client UI i18n 检查覆盖桌面端源码。 +Electron 根据应用语言选择类型化的英文、中文或日文 shell 文案,并回退到英文。macOS 应用包通过 `CFBundleLocalizations` 声明支持英语、简体中文和日语,让 macOS 根据用户的首选语言匹配初始应用语言。主界面仍优先使用已保存的 Client UI 语言偏好。 Windows NSIS 安装器在 `installer/locales/` 下按语言保存独立 JSON 资源;打包从这些文件生成 NSIS 文案和安装器语言清单。英文、简体中文和日语资源的键与格式占位符必须一致,`pnpm run check:i18n` 负责检查。原生安装器测试逐个构建并验证配置中的语言。账户平台页面和引导插图对中文以外的语言使用英文。在 Windows 上,主文档的语言会更新桌面菜单、恢复与更新提示。仓库 Client UI i18n 检查覆盖桌面端源码。 Windows 和 macOS 会在原生全屏切换及每次页面加载后向渲染器同步全屏状态,让浮层调整顶栏避让距离。 @@ -218,7 +220,7 @@ API Key 输入框初始为空,并通过 `autocomplete="new-password"` 请求 C Desktop 在 Host 启动后、打开工作区前检查模型 API Key 引用是否已配置。没有已配置的密钥时,欢迎窗口提供 API Key 页面。“保存并继续”通过现有凭证服务写入 DeepSeek 官方提供方配置的引用,然后打开工作区。“稍后配置”打开工作区,但不保存草稿或完成标记;下次进程启动时会重新检查凭证。“返回登录”回到入口并清空未保存的密钥和校验提示。保存或打开工作区期间,按钮保持原文案并禁用竞争操作。Desktop preload 标记使 Web 凭证弹窗不再显示,同时保留模型设置页和欢迎须知。 -欢迎窗口在显示前读取共享的 `locale.preference`。用户明确选择的英文或中文优先;否则 Desktop 按系统语言顺序匹配支持的语言,并以英文兜底。主界面在挂载前通过隔离 preload 读取同一偏好和系统语言顺序。在设置中切换语言会更新桌面壳的当前词典和菜单;自动选择不会写入偏好。欢迎窗口不提供语言切换入口。 +欢迎窗口在显示前读取共享的 `locale.preference`。用户明确选择的英文、中文或日文优先;否则 Desktop 按系统语言顺序匹配支持的语言,并以英文兜底。主界面在挂载前通过隔离 preload 读取同一偏好和系统语言顺序。在设置中切换语言会更新桌面壳的当前词典和菜单;自动选择不会写入偏好。欢迎窗口不提供语言切换入口。 等待浏览器登录时,欢迎页提供当前待授权请求的链接复制入口、加载指示和取消操作;剪贴板写入失败后可以重试复制,复制结果提示在两秒后恢复;已复制状态下链接禁用,恢复后可再次点击。Welcome 文字使用 Montserrat Light 并回退到系统字体,底部大按钮保留系统字体,文字按钮使用 Montserrat Light。英文欢迎正文及产品名均为 24px,中文欢迎正文为 24px、产品名为 26px。登录操作按钮宽 240px,文字为 14px。授权状态标题使用 20px Montserrat Regular 字重。API Key 页的标题为 20px,返回操作为 14px,次级按钮底边距窗口底部 84px。 @@ -271,9 +273,11 @@ Windows 下载完成后的更新确认说明应用会在安装期间关闭、完 macOS 在安装准备阶段等待产品主窗口退出全屏,再显示重启确认。取消确认后窗口保持普通模式;原生切换期间销毁窗口会在任务停止前取消安装。如果 macOS 未在五秒内报告切换完成,安装准备会报错且不停止 Host;本地化错误提示要求退出全屏后重试。安装失败后的恢复使用普通窗口。 -原生更新浮层在文档就绪且父窗口可见时显示,并在父窗口再次显示时恢复。关闭浮层会释放输入拦截和父窗口监听。[本地窗口验证](tests/README.zh.md#verification-overlay)无需启动工作区即可检查这些切换。 +普通更新弹窗使用产品窗口内、隔离的壳静态源 WebContentsView,检查结果加载不会抬升独立的原生窗口。工作区 Host 尚未就绪时,该文档仍可使用。视图挂载期间拦截产品键盘输入,关闭时释放监听和视图。Windows 顶栏位于视图覆盖范围外,其中的原生及产品 Web 控件仍可操作。点击顶栏可切换侧栏、创建或复用空 Session,并改变底层页面。“关于”或 CLI 命令管理等应用菜单操作可替换并取消当前普通提示,但不代表批准安装,也不会清除强更策略。视图挂载期间仍阻断产品键盘快捷键和编辑按键转发。 -打包应用在启动后异步检查固定 Nightly。常规轮询以十分钟为基础间隔,每次独立采样 ±20% 的随机抖动。每次检查失败将基础延迟翻倍,上限为一小时;成功后重置。随机延迟不超过该上限,并从全部复用调用结算后开始计时。本地化的“检查更新…”菜单项(Windows 可从顶栏的“应用”菜单进入)立即执行,并复用正在进行的检查。回到前台和系统恢复时遵守相同的单调时钟截止时间。新收到的强更策略也会立即请求检查更新清单。自动检查从不弹窗或下载安装包。手动检查显示正在检查、失败或包含已安装版本号的无更新反馈。常规更新弹窗原位渐入渐出;连续弹窗替换卡片内容并重置其滚动位置,保留黑色半透明蒙层,不模糊父页面。Windows 在销毁模态窗口前先隐藏它,确保原生窗口激活切换前父窗口已恢复启用。 +普通更新弹窗、Windows 首次关闭提示和强更文档共用随壳打包的 UI design token。各文档在显示卡片前通过 `prefers-color-scheme` 解析 Electron 的应用主题,配色变化时同步暗色主题属性。欢迎页与更新文档复用同一初始化脚本。明确选择浅色/暗色及跟随系统共用此路径,隔离文档保持透明背景,仅暗色模式以基于 token 的 1 CSS px 内描边区分卡片,不改变尺寸;浅色模式不描边。 + +打包应用在启动后异步检查固定 Nightly。常规轮询以十分钟为基础间隔,每次独立采样 ±20% 的随机抖动。每次检查失败将基础延迟翻倍,上限为一小时;成功后重置。随机延迟不超过该上限,并从全部复用调用结算后开始计时。本地化的“检查更新…”菜单项(Windows 可从顶栏的“应用”菜单进入)立即执行,并复用正在进行的检查。回到前台和系统恢复时遵守相同的单调时钟截止时间。新收到的强更策略也会立即请求检查更新清单。自动检查从不弹窗或下载安装包。手动检查显示正在检查、失败或包含已安装版本号的无更新反馈。常规更新弹窗原位渐入渐出;连续弹窗替换卡片内容并重置其滚动位置,保留黑色半透明蒙层,不模糊父页面。 `DSH_DESKTOP_UPDATE_CHECK_INTERVAL_MS` 配置常规基础间隔,`DSH_DESKTOP_UPDATE_CHECK_MAX_BACKOFF_MS` 配置上限;两者均接受 1000 至 2147483647 的整数毫秒数,且上限不能小于间隔。省略上限时取一小时与间隔中的较大值。`DSH_DESKTOP_UPDATE_CHECK_JITTER` 配置 0 至 1 的抖动比例,默认 `0.2`;最终延迟至少一秒,且不超过上限。这些配置不改变强更策略轮询,也不授权下载重试。 @@ -330,10 +334,12 @@ Electron 主进程在启动时读取一次 `app.getPath('userData')/desktop/sett 登录和策略请求共用内存 Session,与产品窗口及 updater 隔离;应用重启后需要重新登录。关闭窗口取消登录,导航失败提供本地化重试提示。返回服务后重新查询策略;重定向、Cookie 或 HTTP 422 都不是有效策略决定。取消、登录过期及无效响应均保留已知强更阻塞。固定登录结果写入进程诊断及可选更新日志;登录控制器不记录 Cookie、OAuth 参数或远程错误原文。真实 Harness 网关/API 联调及 macOS 登录验收仍未完成。 -扁平化的 `40005` 打开壳拥有的模态窗口,并拒绝后续插件修改,不停止现有 Host 任务。服务端标题与详情是可选纯文本,缺失时使用客户端兜底文案;缺少下载地址或地址未获批准时隐藏外部页面操作,不解除阻塞。macOS 强更蒙层原位渐入渐出,在更新状态切换时保留蒙层,并将父窗口焦点和键盘输入重定向到蒙层。Windows 由隔离的应用 preload 在主窗口内挂载 shell 来源的 frame,以蒙层和弹窗覆盖 40 DIP 顶栏下方的内容区域。它阻止背景页面输入,不创建额外的原生窗口;移动和最大化只作用于主窗口。顶栏菜单保留鼠标和键盘操作,顶栏中的 Web 控件仍可触达。共享 DOM 只约束展示,不构成安全边界:产品脚本可以隐藏蒙层,但不能借此清除主进程策略或授权安装。更新操作与状态通过隔离 preload 和壳 frame 之间的私有 MessageChannel 传递。父窗口保持启用,原生移动、缩放、最小化、最大化和关闭控件仍可操作。退出应用会完成清理,不会解除更新要求;Esc 不会关闭覆盖层。批准安装后,安装器接管的退出流程会在 Electron 关闭窗口前释放模态窗口。下载、含准备步骤的文件校验、任务检查和安装确认共用同一弹窗。只有第二次用户批准才允许任务收尾和安装;稍后更新保留阻塞与安装包。仅存在受影响任务时,重启文案才提示正在停止任务。策略不跨应用重启持久化,策略响应也不作废或替换 updater 产物。 +扁平化的 `40005` 打开壳拥有的模态窗口,并拒绝后续插件修改,不停止现有 Host 任务。服务端标题与详情是可选纯文本,缺失时使用客户端兜底文案;缺少下载地址或地址未获批准时隐藏外部页面操作,不解除阻塞。macOS 强更蒙层使用原生窗口内、隔离的壳静态源 WebContentsView,原位渐入渐出,在更新状态切换时保留蒙层并拦截产品键盘输入。加载或更新视图不会显示或激活独立原生窗口;回到已获焦点的父窗口时聚焦视图。关闭原生父窗口遵循[常规关窗行为](#closing-the-window-and-quitting)。视图加载失败保留策略与输入阻断,不改写父窗口标题。仅当所属窗口可见且有焦点时显示原生恢复提示,包含策略说明与可用目标版本。打开下载页面使用现有来源校验后的策略 URL,缺少获准地址时不显示该按钮;退出走正常应用退出确认。不提供取消按钮,非操作性关闭保留阻断并重新显示恢复提示。隐藏窗口、策略解除、加载成功或释放控制器会撤销提示,过期响应无效。此恢复覆盖独立视图,不包含 Windows 内嵌 frame。Windows 由隔离的应用 preload 在主窗口内挂载 shell 来源的 frame,以蒙层和弹窗覆盖 40 DIP 顶栏下方的内容区域。它阻止背景页面输入,不创建额外的原生窗口;移动和最大化只作用于主窗口。顶栏菜单保留鼠标和键盘操作,顶栏中的 Web 控件仍可触达。共享 DOM 只约束展示,不构成安全边界:产品脚本可以隐藏蒙层,但不能借此清除主进程策略或授权安装。更新操作与状态通过隔离 preload 和壳 frame 之间的私有 MessageChannel 传递。父窗口保持启用,原生移动、缩放、最小化、最大化和关闭控件仍可操作。退出应用会完成清理,不会解除更新要求;Esc 不会关闭覆盖层。批准安装后,安装器接管的退出流程会在 Electron 关闭窗口前释放模态窗口。下载、含准备步骤的文件校验、任务检查和安装确认共用同一弹窗。只有第二次用户批准才允许任务收尾和安装;稍后更新保留阻塞与安装包。仅存在受影响任务时,重启文案才提示正在停止任务。策略不跨应用重启持久化,策略响应也不作废或替换 updater 产物。 失败时在同一弹窗内保留阻塞、本地化重试提示和折叠诊断。白名单下载页面操作只在恢复状态出现,不与正常下载或安装并列。请求打开浏览器后立即提供复制替代入口,即使系统请求尚未返回;请求成功不证明网页已打开。复制失败时展示完整、只读的地址供手动复制。浏览器与剪贴板结果不覆盖 updater 错误。只有新的有效无需强更响应才解除阻塞;阻塞期间仍可使用顶部菜单检查。 +已处于强更阻断状态时,选择“检查更新”会聚焦现有界面。手动检查完成时若强更策略已生效,也会恢复、显示并聚焦所属窗口,即使用户已切换应用、最小化或隐藏窗口。此显式置前行为独立于视图承载方式保留;普通检查结果路径不发起该请求。聚焦界面不代表批准下载、安装或停止任务。异步强更结果置前路径尚未单独完成后台/全屏人工验证。 + 后台强更安装确认请求 Windows 任务栏提醒或 macOS 信息级 Dock 弹跳,并在每轮就绪时尝试一次无声通知,不还原窗口或抢焦点。点击通知只返回当前确认界面。回到前台、安装、策略解除和退出时清理提醒。系统权限和专注模式可能抑制通知;仍需完成 Windows 与 macOS 安装包通知验收。 ### 本地 updater 验证 @@ -375,7 +381,7 @@ node apps/desktop/node_modules/pnpm/bin/pnpm.mjs --dir apps/desktop run test:upd 登录超时后显示超时标题,并提供重新登录和添加 API Key 按钮。打开 API Key 表单会关闭授权视图;后续账号状态通知不会覆盖正在填写的密钥。 -内嵌 Platform 视图在文档加载完成前保持隐藏,让渲染层加载图标可见。关闭或替换待加载视图后,该视图不会再次出现。 所属应用文档刷新或替换、渲染进程终止以及窗口关闭也会销毁原生视图,不依赖 React 清理。 +内嵌 Platform 视图在文档加载完成前保持隐藏,让渲染层加载图标可见。关闭或替换待加载视图后,该视图不会再次出现。强更策略或普通更新提示阻止打开 Platform 视图;清理存储期间开始阻断时,待打开视图也不会挂载。 所属应用文档刷新或替换、渲染进程终止以及窗口关闭也会销毁原生视图,不依赖 React 清理。 私有 Platform 部署请求头由内嵌浏览器会话注入,仅用于配置来源的文档和 API 请求。Cookie 覆盖按名称合并。跨来源请求移除部署请求头;bootstrap 仅暴露 origin、token 和已解析的语言。 diff --git a/apps/desktop/electron-builder.config.d.mts b/apps/desktop/electron-builder.config.d.mts index d6a1a63bfa..2973a6eacc 100644 --- a/apps/desktop/electron-builder.config.d.mts +++ b/apps/desktop/electron-builder.config.d.mts @@ -22,7 +22,10 @@ export interface DesktopElectronBuilderConfig { ...{ readonly from: string, readonly to: 'tray.ico' }[], ] readonly mac: { - readonly extendInfo: { readonly NSMicrophoneUsageDescription: string } + readonly extendInfo: { + readonly CFBundleLocalizations: readonly string[] + readonly NSMicrophoneUsageDescription: string + } readonly entitlements: string readonly entitlementsInherit: string readonly identity: string | undefined @@ -50,7 +53,7 @@ export interface DesktopElectronBuilderConfig { readonly perMachine: false readonly allowElevation: false readonly allowToChangeInstallationDirectory: false - readonly installerLanguages: readonly ['en_US', 'zh_CN'] + readonly installerLanguages: readonly string[] } readonly beforeBuild: () => Promise readonly beforePack: (context: BeforePackContext) => Promise diff --git a/apps/desktop/installer/locales/en_US.json b/apps/desktop/installer/locales/en_US.json new file mode 100644 index 0000000000..f522ad9b6d --- /dev/null +++ b/apps/desktop/installer/locales/en_US.json @@ -0,0 +1,40 @@ +{ + "nsisLanguage": "ENGLISH", + "strings": { + "INSTALLER_INSTALL": "Install now", + "INSTALLER_FINISH": "Finish", + "INSTALLER_NATIVE_PROGRESS": "Preparing installation… %d%%", + "INSTALLER_PROGRESS_EXTRACT": "Extracting files… %d%%", + "INSTALLER_PROGRESS_COPY": "Installing files… %d%%", + "INSTALLER_PROGRESS_REGISTER": "Finishing installation… %d%%", + "INSTALLER_PROGRESS_CLEAN": "Cleaning temporary files… %d%%", + "INSTALLER_CHOOSE_PATH": "Choose location", + "INSTALLER_BROWSE": "Browse", + "INSTALLER_LAUNCH": "Launch now", + "INSTALLER_LAUNCH_FAILED": "DeepSeek Harness could not be launched. Retry, or clear Launch now and close the installer.", + "INSTALLER_UI_ERROR": "The installer could not initialize its window. Close it and try again.", + "INSTALLER_THEME_ERROR": "Use /THEME=auto, /THEME=light or /THEME=dark.", + "INSTALLER_FAILED": "Installation did not finish. Close this installer and retry. If the problem persists, download the installer again.", + "INSTALLER_RUNNING": "DeepSeek Harness is running. Quit it from the system tray and try again.", + "INSTALLER_DIRECTORY_FAILED": "The installation directory could not be replaced.", + "INSTALLER_DIRECTORY_HINT": "Check access to the installation directory and close programs using its files, then run the installer again. Copy the error details if the problem persists.", + "INSTALLER_DIRECTORY_RESTORE_HINT": "The previous installation could not be restored. Keep the backup directory shown in the error details and contact support before installing again.", + "INSTALLER_PATH_INVALID": "Choose a full local folder path. Drive roots, system folders, links and special characters are not supported.", + "INSTALLER_PATH_OWNERSHIP": "Choose an empty folder or the registered DeepSeek Harness installation folder.", + "INSTALLER_PATH_WRITABLE": "This location is not writable. Choose a folder available to the current user.", + "INSTALLER_DISK_SPACE": "There is not enough free disk space. Choose another location.", + "INSTALLER_PER_USER": "This installer supports the current user only. Uninstall the existing all-users installation first.", + "INSTALLER_EXTRACT_FAILED": "The application files could not be extracted.", + "INSTALLER_EXTRACT_HINT": "Security software may have blocked a file, or the disk may be full. Close this installer and try again. If it keeps failing, send us the error details below.", + "INSTALLER_EXTRACT_COPY": "Copy error details", + "INSTALLER_EXTRACT_EXPAND": "Show details", + "INSTALLER_EXTRACT_COLLAPSE": "Hide details", + "INSTALLER_EXTRACT_SAVED": "The full report was saved to %s", + "INSTALLER_EXTRACT_UNSAVED": "The full report could not be saved. Use Copy error details to keep it.", + "INSTALLER_EXTRACT_COPIED": "Error details were copied to the clipboard." + }, + "uninstaller": { + "welcome": "Welcome", + "finished": "has been uninstalled" + } +} diff --git a/apps/desktop/installer/locales/ja_JP.json b/apps/desktop/installer/locales/ja_JP.json new file mode 100644 index 0000000000..ee614789c5 --- /dev/null +++ b/apps/desktop/installer/locales/ja_JP.json @@ -0,0 +1,40 @@ +{ + "nsisLanguage": "JAPANESE", + "strings": { + "INSTALLER_INSTALL": "今すぐインストール", + "INSTALLER_FINISH": "完了", + "INSTALLER_NATIVE_PROGRESS": "インストールを準備しています… %d%%", + "INSTALLER_PROGRESS_EXTRACT": "ファイルを展開しています… %d%%", + "INSTALLER_PROGRESS_COPY": "ファイルをインストールしています… %d%%", + "INSTALLER_PROGRESS_REGISTER": "インストールを完了しています… %d%%", + "INSTALLER_PROGRESS_CLEAN": "一時ファイルを削除しています… %d%%", + "INSTALLER_CHOOSE_PATH": "インストール先を選択", + "INSTALLER_BROWSE": "参照", + "INSTALLER_LAUNCH": "今すぐ起動", + "INSTALLER_LAUNCH_FAILED": "DeepSeek Harness を起動できませんでした。再試行するか、「今すぐ起動」のチェックを外してインストーラーを閉じてください。", + "INSTALLER_UI_ERROR": "インストーラーのウィンドウを初期化できませんでした。閉じてから再試行してください。", + "INSTALLER_THEME_ERROR": "/THEME=auto、/THEME=light、/THEME=dark のいずれかを指定してください。", + "INSTALLER_FAILED": "インストールが完了しませんでした。インストーラーを閉じて再試行してください。問題が解決しない場合は、インストーラーを再度ダウンロードしてください。", + "INSTALLER_RUNNING": "DeepSeek Harness が実行中です。システムトレイから終了して再試行してください。", + "INSTALLER_DIRECTORY_FAILED": "インストール先のディレクトリを置き換えられませんでした。", + "INSTALLER_DIRECTORY_HINT": "インストール先へのアクセス権限を確認し、そのファイルを使用しているプログラムを閉じてから、インストーラーを再実行してください。問題が続く場合はエラーの詳細をコピーしてください。", + "INSTALLER_DIRECTORY_RESTORE_HINT": "以前のインストールを復元できませんでした。エラーの詳細に表示されたバックアップディレクトリを保持し、再インストール前にサポートへお問い合わせください。", + "INSTALLER_PATH_INVALID": "ローカルフォルダーの完全なパスを選択してください。ドライブのルート、システムフォルダー、リンク、特殊文字を含むパスは使用できません。", + "INSTALLER_PATH_OWNERSHIP": "空のフォルダー、または登録済みの DeepSeek Harness インストール先を選択してください。", + "INSTALLER_PATH_WRITABLE": "この場所には書き込めません。現在のユーザーが書き込めるフォルダーを選択してください。インストーラーは管理者権限を要求しません。", + "INSTALLER_DISK_SPACE": "ディスクの空き容量が不足しています。別の場所を選択してください。", + "INSTALLER_PER_USER": "このインストーラーは現在のユーザーのみを対象としています。先にすべてのユーザー向けの既存のインストールを削除してください。", + "INSTALLER_EXTRACT_FAILED": "アプリケーションのファイルを展開できませんでした。", + "INSTALLER_EXTRACT_HINT": "セキュリティソフトがファイルをブロックしたか、ディスクの空き容量が不足している可能性があります。インストーラーを閉じて再試行してください。問題が続く場合は、以下のエラーの詳細をお送りください。", + "INSTALLER_EXTRACT_COPY": "エラーの詳細をコピー", + "INSTALLER_EXTRACT_EXPAND": "詳細を表示", + "INSTALLER_EXTRACT_COLLAPSE": "詳細を非表示", + "INSTALLER_EXTRACT_SAVED": "完全なレポートを %s に保存しました", + "INSTALLER_EXTRACT_UNSAVED": "完全なレポートを保存できませんでした。「エラーの詳細をコピー」で保存してください。", + "INSTALLER_EXTRACT_COPIED": "エラーの詳細をクリップボードにコピーしました。" + }, + "uninstaller": { + "welcome": "アンインストール ウィザードへようこそ", + "finished": "アンインストールされました" + } +} diff --git a/apps/desktop/installer/locales/zh_CN.json b/apps/desktop/installer/locales/zh_CN.json new file mode 100644 index 0000000000..e3f40f13be --- /dev/null +++ b/apps/desktop/installer/locales/zh_CN.json @@ -0,0 +1,40 @@ +{ + "nsisLanguage": "SIMPCHINESE", + "strings": { + "INSTALLER_INSTALL": "立即安装", + "INSTALLER_FINISH": "完成", + "INSTALLER_NATIVE_PROGRESS": "正在准备安装… %d%%", + "INSTALLER_PROGRESS_EXTRACT": "正在解压文件… %d%%", + "INSTALLER_PROGRESS_COPY": "正在安装文件… %d%%", + "INSTALLER_PROGRESS_REGISTER": "正在完成安装… %d%%", + "INSTALLER_PROGRESS_CLEAN": "正在清理临时文件… %d%%", + "INSTALLER_CHOOSE_PATH": "选择安装位置", + "INSTALLER_BROWSE": "选择", + "INSTALLER_LAUNCH": "立即启动", + "INSTALLER_LAUNCH_FAILED": "无法启动 DeepSeek Harness。请重试,或取消勾选立即启动后关闭安装程序。", + "INSTALLER_UI_ERROR": "无法初始化安装窗口,请关闭后重试。", + "INSTALLER_THEME_ERROR": "主题参数须为 /THEME=auto、/THEME=light 或 /THEME=dark。", + "INSTALLER_FAILED": "安装未完成,请关闭安装程序后重试。如仍然失败,请重新下载安装包。", + "INSTALLER_RUNNING": "DeepSeek Harness 正在运行,请先在系统托盘中退出应用后重试。", + "INSTALLER_DIRECTORY_FAILED": "无法替换安装目录。", + "INSTALLER_DIRECTORY_HINT": "请检查安装目录的访问权限,关闭占用其中内容的程序后重新运行安装器。如仍失败,请复制错误详情。", + "INSTALLER_DIRECTORY_RESTORE_HINT": "未能恢复原安装目录。请保留错误详情中列出的备份目录,联系支持人员后再安装。", + "INSTALLER_PATH_INVALID": "请选择本地磁盘上的完整文件夹路径,不能使用磁盘根目录、系统目录、链接目录或包含特殊字符的路径。", + "INSTALLER_PATH_OWNERSHIP": "请选择空文件夹,或 DeepSeek Harness 原来的安装目录。", + "INSTALLER_PATH_WRITABLE": "无法写入此位置。请选择当前用户可写入的文件夹;安装程序不会申请管理员权限。", + "INSTALLER_DISK_SPACE": "此磁盘的可用空间不足,请选择其他位置。", + "INSTALLER_PER_USER": "此安装程序仅支持当前用户。请先卸载已有的所有用户安装版本。", + "INSTALLER_EXTRACT_FAILED": "无法解压应用程序文件。", + "INSTALLER_EXTRACT_HINT": "可能是安全软件拦截了文件,或磁盘空间不足。请关闭安装程序后重试;如仍然失败,请将下方错误信息发送给我们。", + "INSTALLER_EXTRACT_COPY": "复制错误信息", + "INSTALLER_EXTRACT_EXPAND": "显示详情", + "INSTALLER_EXTRACT_COLLAPSE": "隐藏详情", + "INSTALLER_EXTRACT_SAVED": "完整错误报告已保存到 %s", + "INSTALLER_EXTRACT_UNSAVED": "无法保存完整错误报告,请使用“复制错误信息”保留。", + "INSTALLER_EXTRACT_COPIED": "错误信息已复制到剪贴板。" + }, + "uninstaller": { + "welcome": "欢迎", + "finished": "已从你" + } +} diff --git a/apps/desktop/installer/strings.nsh b/apps/desktop/installer/strings.nsh deleted file mode 100644 index a1ac41b418..0000000000 --- a/apps/desktop/installer/strings.nsh +++ /dev/null @@ -1,63 +0,0 @@ -; NSIS owns installer localization independently of the Electron application locale. -LangString INSTALLER_INSTALL ${LANG_ENGLISH} "Install now" -LangString INSTALLER_INSTALL ${LANG_SIMPCHINESE} "立即安装" -LangString INSTALLER_FINISH ${LANG_ENGLISH} "Finish" -LangString INSTALLER_FINISH ${LANG_SIMPCHINESE} "完成" -LangString INSTALLER_NATIVE_PROGRESS ${LANG_ENGLISH} "Preparing installation… %d%%" -LangString INSTALLER_NATIVE_PROGRESS ${LANG_SIMPCHINESE} "正在准备安装… %d%%" -LangString INSTALLER_PROGRESS_EXTRACT ${LANG_ENGLISH} "Extracting files… %d%%" -LangString INSTALLER_PROGRESS_EXTRACT ${LANG_SIMPCHINESE} "正在解压文件… %d%%" -LangString INSTALLER_PROGRESS_COPY ${LANG_ENGLISH} "Installing files… %d%%" -LangString INSTALLER_PROGRESS_COPY ${LANG_SIMPCHINESE} "正在安装文件… %d%%" -LangString INSTALLER_PROGRESS_REGISTER ${LANG_ENGLISH} "Finishing installation… %d%%" -LangString INSTALLER_PROGRESS_REGISTER ${LANG_SIMPCHINESE} "正在完成安装… %d%%" -LangString INSTALLER_PROGRESS_CLEAN ${LANG_ENGLISH} "Cleaning temporary files… %d%%" -LangString INSTALLER_PROGRESS_CLEAN ${LANG_SIMPCHINESE} "正在清理临时文件… %d%%" -LangString INSTALLER_CHOOSE_PATH ${LANG_ENGLISH} "Choose location" -LangString INSTALLER_CHOOSE_PATH ${LANG_SIMPCHINESE} "选择安装位置" -LangString INSTALLER_BROWSE ${LANG_ENGLISH} "Browse" -LangString INSTALLER_BROWSE ${LANG_SIMPCHINESE} "选择" -LangString INSTALLER_LAUNCH ${LANG_ENGLISH} "Launch now" -LangString INSTALLER_LAUNCH ${LANG_SIMPCHINESE} "立即启动" -LangString INSTALLER_LAUNCH_FAILED ${LANG_ENGLISH} "DeepSeek Harness could not be launched. Retry, or clear Launch now and close the installer." -LangString INSTALLER_LAUNCH_FAILED ${LANG_SIMPCHINESE} "无法启动 DeepSeek Harness。请重试,或取消勾选立即启动后关闭安装程序。" -LangString INSTALLER_UI_ERROR ${LANG_ENGLISH} "The installer could not initialize its window. Close it and try again." -LangString INSTALLER_UI_ERROR ${LANG_SIMPCHINESE} "无法初始化安装窗口,请关闭后重试。" -LangString INSTALLER_THEME_ERROR ${LANG_ENGLISH} "Use /THEME=auto, /THEME=light or /THEME=dark." -LangString INSTALLER_THEME_ERROR ${LANG_SIMPCHINESE} "主题参数须为 /THEME=auto、/THEME=light 或 /THEME=dark。" -LangString INSTALLER_FAILED ${LANG_ENGLISH} "Installation did not finish. Close this installer and retry. If the problem persists, download the installer again." -LangString INSTALLER_FAILED ${LANG_SIMPCHINESE} "安装未完成,请关闭安装程序后重试。如仍然失败,请重新下载安装包。" -LangString INSTALLER_RUNNING ${LANG_ENGLISH} "DeepSeek Harness is running. Quit it from the system tray and try again." -LangString INSTALLER_RUNNING ${LANG_SIMPCHINESE} "DeepSeek Harness 正在运行,请先在系统托盘中退出应用后重试。" -LangString INSTALLER_DIRECTORY_FAILED ${LANG_ENGLISH} "The installation directory could not be replaced." -LangString INSTALLER_DIRECTORY_FAILED ${LANG_SIMPCHINESE} "无法替换安装目录。" -LangString INSTALLER_DIRECTORY_HINT ${LANG_ENGLISH} "Check access to the installation directory and close programs using its files, then run the installer again. Copy the error details if the problem persists." -LangString INSTALLER_DIRECTORY_HINT ${LANG_SIMPCHINESE} "请检查安装目录的访问权限,关闭占用其中内容的程序后重新运行安装器。如仍失败,请复制错误详情。" -LangString INSTALLER_DIRECTORY_RESTORE_HINT ${LANG_ENGLISH} "The previous installation could not be restored. Keep the backup directory shown in the error details and contact support before installing again." -LangString INSTALLER_DIRECTORY_RESTORE_HINT ${LANG_SIMPCHINESE} "未能恢复原安装目录。请保留错误详情中列出的备份目录,联系支持人员后再安装。" -LangString INSTALLER_PATH_INVALID ${LANG_ENGLISH} "Choose a full local folder path. Drive roots, system folders, links and special characters are not supported." -LangString INSTALLER_PATH_INVALID ${LANG_SIMPCHINESE} "请选择本地磁盘上的完整文件夹路径,不能使用磁盘根目录、系统目录、链接目录或包含特殊字符的路径。" -LangString INSTALLER_PATH_OWNERSHIP ${LANG_ENGLISH} "Choose an empty folder or the registered DeepSeek Harness installation folder." -LangString INSTALLER_PATH_OWNERSHIP ${LANG_SIMPCHINESE} "请选择空文件夹,或 DeepSeek Harness 原来的安装目录。" -LangString INSTALLER_PATH_WRITABLE ${LANG_ENGLISH} "This location is not writable. Choose a folder available to the current user." -LangString INSTALLER_PATH_WRITABLE ${LANG_SIMPCHINESE} "无法写入此位置。请选择当前用户可写入的文件夹;安装程序不会申请管理员权限。" -LangString INSTALLER_DISK_SPACE ${LANG_ENGLISH} "There is not enough free disk space. Choose another location." -LangString INSTALLER_DISK_SPACE ${LANG_SIMPCHINESE} "此磁盘的可用空间不足,请选择其他位置。" -LangString INSTALLER_PER_USER ${LANG_ENGLISH} "This installer supports the current user only. Uninstall the existing all-users installation first." -LangString INSTALLER_PER_USER ${LANG_SIMPCHINESE} "此安装程序仅支持当前用户。请先卸载已有的所有用户安装版本。" -LangString INSTALLER_EXTRACT_FAILED ${LANG_ENGLISH} "The application files could not be extracted." -LangString INSTALLER_EXTRACT_FAILED ${LANG_SIMPCHINESE} "无法解压应用程序文件。" -LangString INSTALLER_EXTRACT_HINT ${LANG_ENGLISH} "Security software may have blocked a file, or the disk may be full. Close this installer and try again. If it keeps failing, send us the error details below." -LangString INSTALLER_EXTRACT_HINT ${LANG_SIMPCHINESE} "可能是安全软件拦截了文件,或磁盘空间不足。请关闭安装程序后重试;如仍然失败,请将下方错误信息发送给我们。" -LangString INSTALLER_EXTRACT_COPY ${LANG_ENGLISH} "Copy error details" -LangString INSTALLER_EXTRACT_COPY ${LANG_SIMPCHINESE} "复制错误信息" -LangString INSTALLER_EXTRACT_EXPAND ${LANG_ENGLISH} "Show details" -LangString INSTALLER_EXTRACT_EXPAND ${LANG_SIMPCHINESE} "显示详情" -LangString INSTALLER_EXTRACT_COLLAPSE ${LANG_ENGLISH} "Hide details" -LangString INSTALLER_EXTRACT_COLLAPSE ${LANG_SIMPCHINESE} "隐藏详情" -LangString INSTALLER_EXTRACT_SAVED ${LANG_ENGLISH} "The full report was saved to %s" -LangString INSTALLER_EXTRACT_SAVED ${LANG_SIMPCHINESE} "完整错误报告已保存到 %s" -LangString INSTALLER_EXTRACT_UNSAVED ${LANG_ENGLISH} "The full report could not be saved. Use Copy error details to keep it." -LangString INSTALLER_EXTRACT_UNSAVED ${LANG_SIMPCHINESE} "无法保存完整错误报告,请使用“复制错误信息”保留。" -LangString INSTALLER_EXTRACT_COPIED ${LANG_ENGLISH} "Error details were copied to the clipboard." -LangString INSTALLER_EXTRACT_COPIED ${LANG_SIMPCHINESE} "错误信息已复制到剪贴板。" diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 64a6bff8fc..23d5e49efe 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -82,6 +82,7 @@ "@deepseek-ai/dsh-client-ui-theme": "workspace:*", "@deepseek-ai/dsh-client-shortcuts": "workspace:*", "@deepseek-ai/dsh-atomic-write": "workspace:*", - "@deepseek-ai/dsh-client-product-analytics": "workspace:*" + "@deepseek-ai/dsh-client-product-analytics": "workspace:*", + "@deepseek-ai/dsh-util-file-drag": "workspace:*" } } diff --git a/apps/desktop/renderer/mandatory-update.css b/apps/desktop/renderer/mandatory-update.css index 591a82b6ab..dc721b8a68 100644 --- a/apps/desktop/renderer/mandatory-update.css +++ b/apps/desktop/renderer/mandatory-update.css @@ -1,16 +1,19 @@ -#status, #version { margin-top: 12px; color: #555962; } +#status, #version { margin-top: 12px; color: var(--dsw-alias-label-secondary); } #status:empty, #version:empty { display: none; } -#error { margin-top: 12px; color: #d93025; } -#address { display: block; width: 100%; margin-top: 4px; padding: 8px; border: 1px solid #e6e7eb; border-radius: 8px; background: #f5f5f5; font: 12px/20px monospace; color: #555962; overflow-wrap: anywhere; resize: vertical; } -#actions button:not(.primary) { min-height: 32px; padding: 5px 8px; color: #555962; background: transparent; } +#error { margin-top: 12px; color: var(--dsw-alias-state-error-primary); } +#address { display: block; width: 100%; margin-top: 4px; padding: 8px; border: 1px solid var(--dsw-alias-border-l2); border-radius: 8px; background: var(--dsw-alias-markdown-code-block); font: 12px/20px monospace; color: var(--dsw-alias-label-secondary); overflow-wrap: anywhere; resize: vertical; } +#actions button:not(.primary) { min-height: 32px; padding: 5px 8px; color: var(--dsw-alias-label-secondary); background: transparent; } #update, #refresh { border-radius: 100px; } -#fallback { border-top: 1px solid #e6e7eb; margin-top: 12px; padding-top: 12px; } -#fallback p, #manual-copy { font-size: 12px; line-height: 20px; color: #555962; } -#copy { min-height: 32px; padding: 3px 0; background: transparent; color: #4d6bfe; font-size: 12px; } -#copy-message { color: #d93025 !important; } +#fallback { border-top: 1px solid var(--dsw-alias-border-l2); margin-top: 12px; padding-top: 12px; } +#fallback p, #manual-copy { font-size: 12px; line-height: 20px; color: var(--dsw-alias-label-secondary); } +#copy { min-height: 32px; padding: 3px 0; background: transparent; color: var(--dsw-alias-link); font-size: 12px; } +#copy-message { color: var(--dsw-alias-state-error-primary) !important; } #manual-copy { display: block; margin-top: 8px; } -#progress { display: block; width: 100%; height: 4px; margin-top: 12px; border: 0; appearance: none; -webkit-appearance: none; background: #e6e7eb; accent-color: #0f1115; } +#progress { display: block; width: 100%; height: 4px; margin-top: 12px; border: 0; appearance: none; -webkit-appearance: none; background: var(--dsw-alias-border-l2); accent-color: var(--dsw-alias-button-primary-fill); } -#progress::-webkit-progress-bar { background: #e6e7eb; } -#progress::-webkit-progress-value { background: #0f1115; } -#progress::-moz-progress-bar { background: #0f1115; } +#progress::-webkit-progress-bar { background: var(--dsw-alias-border-l2); } +#progress::-webkit-progress-value { background: var(--dsw-alias-button-primary-fill); } +#progress::-moz-progress-bar { background: var(--dsw-alias-button-primary-fill); } +@media (hover: hover) { + #actions button:not(.primary):hover:not(:disabled), #copy:hover:not(:disabled) { background: var(--dsw-alias-interactive-bg-hover); } +} diff --git a/apps/desktop/renderer/mandatory-update.html b/apps/desktop/renderer/mandatory-update.html index f009ab8a37..d4d1747cf9 100644 --- a/apps/desktop/renderer/mandatory-update.html +++ b/apps/desktop/renderer/mandatory-update.html @@ -5,10 +5,12 @@ + +

diff --git a/apps/desktop/renderer/shell-theme.js b/apps/desktop/renderer/shell-theme.js new file mode 100644 index 0000000000..bbc12f381b --- /dev/null +++ b/apps/desktop/renderer/shell-theme.js @@ -0,0 +1,6 @@ +/** Shell documents follow Electron's app-resolved palette before displaying their content. */ +const palette = window.matchMedia('(prefers-color-scheme: dark)') +const applyPalette = () => { document.body.toggleAttribute('data-ds-dark-theme', palette.matches) } +applyPalette() +palette.addEventListener('change', applyPalette) +window.addEventListener('pagehide', () => { palette.removeEventListener('change', applyPalette) }, { once: true }) diff --git a/apps/desktop/renderer/update-dialog.css b/apps/desktop/renderer/update-dialog.css index 98183989a5..dd50411d46 100644 --- a/apps/desktop/renderer/update-dialog.css +++ b/apps/desktop/renderer/update-dialog.css @@ -1,31 +1,37 @@ :root { - color-scheme: light; + color-scheme: light dark; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Microsoft YaHei", sans-serif; - color: #0f1115; background: transparent; } * { box-sizing: border-box; } -body { margin: 0; height: 100vh; display: grid; place-items: center; isolation: isolate; } -body::before { content: ""; position: fixed; inset: 0; z-index: -1; background: rgb(0 0 0 / 24%); } -main { width: min(380px, calc(100vw - 32px)); max-height: calc(100vh - 32px); overflow: auto; padding: 22px 24px 24px; border-radius: 24px; background: #fff; box-shadow: 0 0 1px rgb(0 0 0 / 20%), 0 0 4px rgb(0 0 0 / 2%), 0 12px 32px rgb(0 0 0 / 8%); } +body { margin: 0; height: 100vh; display: grid; place-items: center; isolation: isolate; background: transparent; color: var(--dsw-alias-label-primary); color-scheme: light; } +body[data-ds-dark-theme] { color-scheme: dark; } +body::before { content: ""; position: fixed; inset: 0; z-index: -1; background: var(--dsw-alias-bg-mask-1); } +main { width: min(380px, calc(100vw - 32px)); max-height: calc(100vh - 32px); overflow: auto; padding: 22px 24px 24px; border-radius: 24px; background: var(--dsw-alias-bg-layer-1); box-shadow: none; } +body[data-ds-dark-theme] main { box-shadow: inset 0 0 0 1px var(--dsw-alias-border-l2); } header { display: flex; align-items: flex-start; justify-content: space-between; gap: 8px; margin-bottom: 12px; } main:focus { outline: none; } h1 { margin: 0; min-height: 28px; font-size: 16px; font-weight: 500; line-height: 24px; overflow-wrap: anywhere; } p { margin: 0; font-size: 14px; line-height: 22px; white-space: pre-wrap; overflow-wrap: anywhere; } -#actions { display: flex; flex-direction: column; gap: 8px; margin-top: 20px; position: sticky; bottom: 0; background: #fff; } -button { min-height: 44px; padding: 9px 16px; border: 0; border-radius: 12px; font-family: inherit; font-size: 14px; font-weight: 500; line-height: 22px; cursor: pointer; } -.primary { background: #0f1115; color: #fff; } -.secondary { background: #f5f5f5; color: inherit; } +#actions { display: flex; flex-direction: column; gap: 8px; margin-top: 20px; position: sticky; bottom: 0; background: var(--dsw-alias-bg-layer-1); } +button { min-height: 44px; padding: 9px 16px; border: 0; border-radius: 12px; font-family: inherit; font-size: 14px; font-weight: 500; line-height: 22px; color: inherit; cursor: pointer; } +.primary { background: var(--dsw-alias-button-primary-fill); color: var(--dsw-alias-label-primary-inverted); } + +.secondary { background: var(--dsw-alias-button-primary-dimmed); color: inherit; } .close { display: grid; place-items: center; flex: none; width: 28px; min-height: 28px; padding: 7px; border-radius: 50%; background: transparent; } -.close img { width: 14px; height: 14px; } -button:hover { filter: brightness(0.95); } -button:focus-visible, summary:focus-visible, pre:focus-visible { outline: 2px solid #4d6bfe; outline-offset: 3px; } +.close::before { content: ""; width: 14px; height: 14px; background: currentColor; mask: url("update-close.svg") center / contain no-repeat; } + +button:focus-visible, summary:focus-visible, pre:focus-visible { outline: 2px solid var(--dsw-alias-link); outline-offset: 3px; } details { margin-top: 12px; font-size: 13px; line-height: 20px; } -summary { cursor: pointer; color: #5b6472; } -pre { margin: 8px 0 0; padding: 10px; max-height: min(180px, 30vh); overflow: auto; border-radius: 8px; background: #f5f5f5; font: 12px/18px monospace; white-space: pre-wrap; overflow-wrap: anywhere; } +summary { cursor: pointer; color: var(--dsw-alias-label-secondary); } +pre { margin: 8px 0 0; padding: 10px; max-height: min(180px, 30vh); overflow: auto; border-radius: 8px; background: var(--dsw-alias-markdown-code-block); font: 12px/18px monospace; white-space: pre-wrap; overflow-wrap: anywhere; } button:disabled { opacity: 0.5; cursor: default; } [hidden] { display: none !important; } body.update-dialog { opacity: 0; transition: opacity 150ms ease; } body.update-dialog.visible { opacity: 1; } @starting-style { body.update-dialog.visible { opacity: 0; } } @media (prefers-reduced-motion: reduce) { body.update-dialog { transition: none; } } +@media (hover: hover) { + .primary:hover:not(:disabled) { background: var(--dsw-alias-button-primary-hover); } + .secondary:hover:not(:disabled), .close:hover { background: var(--dsw-alias-interactive-bg-hover); } +} diff --git a/apps/desktop/renderer/update-dialog.html b/apps/desktop/renderer/update-dialog.html index 85a0f20abd..c6a913347d 100644 --- a/apps/desktop/renderer/update-dialog.html +++ b/apps/desktop/renderer/update-dialog.html @@ -5,11 +5,13 @@ + +
-

+

diff --git a/packages/client/locale/locale/ja.json b/packages/client/locale/locale/ja.json new file mode 100644 index 0000000000..682eb30601 --- /dev/null +++ b/packages/client/locale/locale/ja.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "表示言語", + "description": "表示言語の設定と各プラグインの翻訳文を管理します。" + } +} diff --git a/packages/client/locale/src/client/LanguageRow.module.css b/packages/client/locale/src/client/LanguageRow.module.css index 3d78e59775..794ec53652 100644 --- a/packages/client/locale/src/client/LanguageRow.module.css +++ b/packages/client/locale/src/client/LanguageRow.module.css @@ -18,6 +18,14 @@ padding-right: 48px; } +/* Narrow settings panel: the 48px gutter separates the label from its control + across a wide row and only squeezes the label here. */ +@media (max-width: 767px) { + :global(html:not([data-windows-titlebar]):not([data-platform='darwin'])) .rowText { + padding-right: 16px; + } +} + .title { font-size: 14px; font-weight: 400; diff --git a/packages/client/locale/src/client/index.ts b/packages/client/locale/src/client/index.ts index 744a5f54e6..c6484b8908 100644 --- a/packages/client/locale/src/client/index.ts +++ b/packages/client/locale/src/client/index.ts @@ -20,9 +20,9 @@ import { LOCALE_ID_PATTERN, LOCALE_IDS, LOCALE_PREFERENCE_FIELD, LOCALE_SETTINGS_NAMESPACE, type BuiltInLocaleId, type LocaleId, type LocaleSettings, } from '../locale-settings.ts' -import { en, zh, type CommonKey } from '../locales/index.ts' +import { en, ja, zh, type CommonKey } from '../locales/index.ts' import { - en as settingsEn, zh as settingsZh, type SettingsLocaleKey, + en as settingsEn, ja as settingsJa, zh as settingsZh, type SettingsLocaleKey, } from '../locales/settings.ts' import type { LanguageRowInjected } from './LanguageRow.tsx' import { LanguageRow } from './LanguageRow.tsx' @@ -114,7 +114,7 @@ export const COMMON_NS = 'common' /** Namespace owning this feature's settings-row copy. */ export const SETTINGS_NS = 'settings.locale' -/** The two locales and dictionaries shipped by this package. */ +/** The baseline locales required by typed dictionary registrations. */ const BUILT_IN_LOCALE_METADATA = { zh: { label: '中文', fallback: 'en' }, en: { label: 'English' }, @@ -378,15 +378,18 @@ export class LocaleRuntime { * Register a declared namespace's dictionaries, all locales in one call — * the typed form: each dictionary is checked against the namespace's * {@link LocaleNamespaceMap} key union (a missing or extra key is a - * compile error), and every shipped locale is required (bilingual balance + * compile error), and both baseline locales are required (bilingual balance * enforced at registration). Duplicate (ns, locale) throws (single occupant; a * namespace's texts have one owner). Registration bumps the revision so * mounted outlets pick up late-arriving dictionaries. * @param ns - a namespace merged into LocaleNamespaceMap. - * @param dicts - complete dictionaries keyed by built-in locale id. + * @param dicts - complete baseline dictionaries and optional dictionaries for additional locale ids. * @returns disposer removing every locale registered by this call (idempotent). */ - register>(ns: N, dicts: Record>): () => void + register>( + ns: N, + dicts: Record> & Record>, + ): () => void /** * Single-locale untyped form for language-pack contributions and namespaces * outside the merge table. @@ -576,8 +579,9 @@ export async function apply(ctx: ClientContext): Promise { } const host = ctx.configForms.get(LOCALE_SETTINGS_NAMESPACE) const locale = new LocaleRuntime(ctx, host, bootstrap) - locale.register(COMMON_NS, { zh, en }) - locale.register(SETTINGS_NS, { zh: settingsZh, en: settingsEn }) + ctx.effect(() => locale.addLanguage({ id: 'ja', label: '日本語', fallback: 'en' }), 'locale: Japanese language') + ctx.effect(() => locale.register(COMMON_NS, { zh, en, ja }), 'locale: common dictionaries') + ctx.effect(() => locale.register(SETTINGS_NS, { zh: settingsZh, en: settingsEn, ja: settingsJa }), 'locale: settings dictionaries') ctx.provide('locale', locale) if (bridge !== undefined) { ctx.on('locale/change', (snapshot) => { bridge.onChange(snapshot.active) }) diff --git a/packages/client/locale/src/locale-settings.ts b/packages/client/locale/src/locale-settings.ts index 24808a17b6..78b1215206 100644 --- a/packages/client/locale/src/locale-settings.ts +++ b/packages/client/locale/src/locale-settings.ts @@ -11,10 +11,10 @@ export const LOCALE_PREFERENCE_FIELD = 'preference' /** Accepted BCP 47-style language ids. */ export const LOCALE_ID_PATTERN = /^[A-Za-z]{2,8}(?:-[A-Za-z0-9]{1,8})*$/u -/** Locale identifiers shipped by the browser client. */ +/** Baseline locales required by typed namespace dictionary registration. */ export const LOCALE_IDS = ['zh', 'en'] as const -/** Locale identifier shipped by the browser client. */ +/** Baseline locale required by typed namespace dictionary registration. */ export type BuiltInLocaleId = typeof LOCALE_IDS[number] /** Open locale identifier accepted from language-pack plugins. */ diff --git a/packages/client/locale/src/locales/index.ts b/packages/client/locale/src/locales/index.ts index 6ac5335f5b..a01489ccfd 100644 --- a/packages/client/locale/src/locales/index.ts +++ b/packages/client/locale/src/locales/index.ts @@ -1,8 +1,8 @@ /** - * The common-namespace dictionary pair. zh is the source of truth for the - * key set (Chinese-first repo convention); en is checked complete against it - * — a missing or extra en key is a compile error. + * Common-namespace dictionaries. Chinese defines the keys; English and + * Japanese are checked against the same keys by TypeScript. */ export { zh } from './zh.ts' export { en } from './en.ts' +export { ja } from './ja.ts' export type { CommonKey } from './zh.ts' diff --git a/packages/client/locale/src/locales/ja.ts b/packages/client/locale/src/locales/ja.ts new file mode 100644 index 0000000000..1186838c31 --- /dev/null +++ b/packages/client/locale/src/locales/ja.ts @@ -0,0 +1,47 @@ +/** Japanese common vocabulary. */ +import type { en } from './en.ts' + +/** Japanese copy, checked against the English keys. */ +export const ja = { + 'ok': 'OK', + 'cancel': 'キャンセル', + 'close': '閉じる', + 'copy': 'コピー', + 'copied': 'コピーしました', + 'codeBlock.title': 'コードブロック', + 'codeBlock.wrap': '行を折り返す', + 'codeBlock.unwrap': '行を折り返さない', + 'copy.failed': 'コピーに失敗しました', + 'copy.value': '値をコピー', + 'copy.json': 'JSONをコピー', + 'copy.path': 'プロパティパスをコピー', + 'copy.prettyJson': '整形JSONをコピー', + 'copy.compactJson': 'コンパクトJSONをコピー', + 'copy.optionsHint': '{action}。右クリックでコピーオプションを表示', + 'retry': '再試行', + 'loading': '読み込み中…', + 'load.failed': '読み込みに失敗しました', + 'submit': '送信', + 'submitting': '送信中…', + 'next': '次へ', + 'previous': '前へ', + 'skip': 'スキップ', + 'delete': '削除', + 'edit': '編集', + 'save': '保存', + 'search': '検索', + 'more': 'その他', + 'collapse': '折りたたむ', + 'expand': '展開', + 'back': '戻る', + 'brand.localBuild': 'DSHローカルビルド', + 'workspace.defaultName': 'デフォルトワークスペース', + 'unknown': '不明', + 'none': 'なし', + 'truncated': '切り詰め済み', + 'json.label': 'JSON', + 'markdown.footnotes': '脚注', + 'markdown.truncatedCharacters': '… {total}文字で切り捨て', + 'number.thousand': '{value}K', + 'number.million': '{value}M', +} satisfies Record diff --git a/packages/client/locale/src/locales/settings.ts b/packages/client/locale/src/locales/settings.ts index 0419b60095..76c5fd1ffb 100644 --- a/packages/client/locale/src/locales/settings.ts +++ b/packages/client/locale/src/locales/settings.ts @@ -12,3 +12,8 @@ export type SettingsLocaleKey = keyof typeof zh export const en = { 'language.title': 'Language', } satisfies Record + +/** Japanese copy, checked against the English keys. */ +export const ja = { + 'language.title': '言語', +} satisfies Record diff --git a/packages/client/locale/tests/apply.client.spec.ts b/packages/client/locale/tests/apply.client.spec.ts index cd8e835c46..97209c4ab3 100644 --- a/packages/client/locale/tests/apply.client.spec.ts +++ b/packages/client/locale/tests/apply.client.spec.ts @@ -2,7 +2,7 @@ * Language row registration, snapshot projection into the row store, and * recovery after an HMR collapse of the declaring entry. */ import { Context } from '@deepseek-ai/cordis' -import { afterEach, describe, expect, it, vi } from 'vitest' +import { afterEach, describe, expect, it, onTestFinished, vi } from 'vitest' import { SlotRegistry } from '@deepseek-ai/dsh-client-ui-renderer/client' import { apply as settingsApply, inject as settingsInject } from '@deepseek-ai/dsh-client-ui-settings/client' import { TestRemote } from '@deepseek-ai/dsh-client-test-runtime' @@ -155,7 +155,7 @@ describe('locale apply', () => { const { entry, instance, face } = faceOf(b.slots) // The inject-time re-sync sealed the init window: the mirror is current. expect(instance.getSnapshot().active).toBe('en') - expect(instance.getSnapshot().options.map(o => o.id)).toEqual(['zh', 'en']) + expect(instance.getSnapshot().options.map(o => o.id)).toEqual(['zh', 'en', 'ja']) // Copy rides the standard locale seat: the entry declares the namespace. expect(entry.locale).toBe(SETTINGS_NS) expect(locale.bind(SETTINGS_NS)('language.title')).toBe('Language') @@ -176,7 +176,7 @@ describe('locale apply', () => { const languagePack = b.ctx.plugin({ inject: ['locale'], apply: packCtx => packCtx.effect( - () => packCtx.locale.addLanguage({ id: 'ja', label: '日本語', fallback: 'en' }), + () => packCtx.locale.addLanguage({ id: 'fr', label: 'Français', fallback: 'en' }), 'test language pack registration', ), }) @@ -185,10 +185,35 @@ describe('locale apply', () => { { id: 'zh', label: '中文' }, { id: 'en', label: 'English' }, { id: 'ja', label: '日本語' }, + { id: 'fr', label: 'Français' }, ]) await languagePack.dispose() - expect(instance.getSnapshot().options.map(option => option.id)).toEqual(['zh', 'en']) + expect(instance.getSnapshot().options.map(option => option.id)).toEqual(['zh', 'en', 'ja']) + }) + + it('restores Japanese from the Host and releases its language and dictionaries on unload', async () => { + const b = await bench('ja') + onTestFinished(() => b.ctx.fiber.dispose()) + declareItems(b.slots) + const fiber = b.ctx.plugin({ inject: [...inject], apply }) + await fiber.await() + const locale = b.ctx.get('locale') as LocaleRuntime + const { instance, face } = faceOf(b.slots) + expect(instance.getSnapshot().active).toBe('ja') + expect(locale.bind(SETTINGS_NS)('language.title')).toBe('言語') + expect(locale.bind('common')('cancel')).toBe('キャンセル') + face.setLocale('en') + face.setLocale('ja') + await vi.waitFor(() => { expect(b.mutate).toHaveBeenCalledTimes(2) }) + expect(b.mutate.mock.calls.at(-1)?.[1]).toEqual([{ op: 'set', path: ['preference'], value: 'ja' }]) + await fiber.dispose() + expect(locale.getSnapshot().locales.map(option => option.id)).toEqual(['zh', 'en']) + expect(() => { locale.setLocale('ja') }).toThrow('not registered') + const remove = locale.addLanguage({ id: 'ja', label: '日本語', fallback: 'en' }) + locale.setLocale('ja') + expect(locale.bind('common')('cancel')).toBe('cancel') + remove() }) it('loads and refreshes the explicit Host preference after nonblocking activation', async () => { diff --git a/packages/client/locale/tests/config.host.spec.ts b/packages/client/locale/tests/config.host.spec.ts index 9c9d3e5462..84af262287 100644 --- a/packages/client/locale/tests/config.host.spec.ts +++ b/packages/client/locale/tests/config.host.spec.ts @@ -13,11 +13,11 @@ describe('locale host', () => { const ctx = new Context() const configuration = await liveConfig(ctx, { Config, apply }) const { fiber } = configuration - expect(plainConfig(configuration.fiber.config)).toEqual({}) + expect(plainConfig(configuration.fiber.effectiveConfig)).toEqual({}) await configuration.update({ preference: 'en' }) - expect(plainConfig(configuration.fiber.config)).toEqual({ preference: 'en' }) + expect(plainConfig(configuration.fiber.effectiveConfig)).toEqual({ preference: 'en' }) await configuration.update({ preference: 'pt-BR' }) - expect(plainConfig(configuration.fiber.config)).toEqual({ preference: 'pt-BR' }) + expect(plainConfig(configuration.fiber.effectiveConfig)).toEqual({ preference: 'pt-BR' }) await expect(configuration.update({ preference: 'bad locale' })).rejects.toThrow() await expect(configuration.update({ preference: '123' })).rejects.toThrow() await fiber.dispose() diff --git a/packages/client/locale/tests/document-language.client.spec.ts b/packages/client/locale/tests/document-language.client.spec.ts index 35ff220f19..9e72b5149c 100644 --- a/packages/client/locale/tests/document-language.client.spec.ts +++ b/packages/client/locale/tests/document-language.client.spec.ts @@ -103,7 +103,7 @@ describe('document language', () => { const observer = new MutationObserver(() => {}) observer.observe(document.documentElement, { attributes: true, attributeFilter: ['lang'] }) try { - const remove = locale.addLanguage({ id: 'ja', label: '日本語', fallback: 'en' }) + const remove = locale.addLanguage({ id: 'fr', label: 'Français', fallback: 'en' }) expect(observer.takeRecords()).toHaveLength(0) remove() expect(observer.takeRecords()).toHaveLength(0) diff --git a/packages/client/locale/tests/locale.client.spec.ts b/packages/client/locale/tests/locale.client.spec.ts index 62926bb2f9..07102e3e1f 100644 --- a/packages/client/locale/tests/locale.client.spec.ts +++ b/packages/client/locale/tests/locale.client.spec.ts @@ -3,6 +3,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { Context } from '@deepseek-ai/cordis' import { stubConfigForm, type StubConfigForm } from '@deepseek-ai/dsh-client-test-runtime' import type { LocaleSettings, LocaleSnapshot } from '@deepseek-ai/dsh-client-locale/client' +import { zh, en, ja } from '../src/locales/settings.ts' import { FALLBACK_LOCALE, LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client' const make = (host?: StubConfigForm): { ctx: Context @@ -36,6 +37,24 @@ describe('LocaleRuntime', () => { vi.unstubAllGlobals() }) + it('checks additional dictionaries against the declared namespace and requires both baseline locales', () => { + const { svc } = make() + svc.addLanguage({ id: 'ja', label: '日本語', fallback: 'en' }) + const dispose = svc.register('settings.locale', { zh, en, ja, fr: ja }) + svc.setLocale('ja') + expect(svc.bind('settings.locale')('language.title')).toBe('言語') + dispose() + expect(svc.bind('settings.locale')('language.title')).toBe('language.title') + if (false) { + // @ts-expect-error additional locales require the declared namespace's keys + svc.register('settings.locale', { zh, en, ja: { other: '別' } }) + // @ts-expect-error additional locales cannot omit a namespace key + svc.register('settings.locale', { zh, en, ja: {} }) + // @ts-expect-error additional locales do not replace the English baseline + svc.register('settings.locale', { zh, ja }) + } + }) + it('translates through the active-locale -> en -> key chain', () => { const { svc } = make() svc.register('ns', 'zh', { hello: '你好' }) diff --git a/packages/client/modules/locale/ja.json b/packages/client/modules/locale/ja.json new file mode 100644 index 0000000000..d6935d5900 --- /dev/null +++ b/packages/client/modules/locale/ja.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "クライアントモジュール読み込み", + "description": "ブラウザのプラグインモジュールと読み込み依存関係を管理します。" + } +} diff --git a/packages/client/product-analytics/README.i18n.yaml b/packages/client/product-analytics/README.i18n.yaml index 546332a531..190c6f8525 100644 --- a/packages/client/product-analytics/README.i18n.yaml +++ b/packages/client/product-analytics/README.i18n.yaml @@ -15,8 +15,11 @@ en: 2fee9973f19f9401 zh: 8ab4b55413cb8408 /deepseek-ai-dsh-client-product-analytics/use-this-package: - en: 042e8eef8647b025 - zh: 256ff24fee965eda + en: 1968870e603f46ab + zh: 7b0ad31d3ee3cc25 +/deepseek-ai-dsh-client-product-analytics/use-this-package/navigation-preset-and-new-session-events: + en: f3671bd903637bf4 + zh: 1f0ad4d68ed8d744 /deepseek-ai-dsh-client-product-analytics/understand-the-implementation: en: 96b1169d78f4c761 zh: 475df4ea6ed1cb06 diff --git a/packages/client/product-analytics/README.md b/packages/client/product-analytics/README.md index 54cd8a820f..0dd6a179bc 100644 --- a/packages/client/product-analytics/README.md +++ b/packages/client/product-analytics/README.md @@ -49,6 +49,14 @@ The [event types](src/events.ts) own names and allowed fields. Authentication ev Only `plugin_toggle` carries `plugin_type`: plugin rows use `plugin`, and packages use `bundle`. Installation reports `is_success`; cancellation uses `is_success=false` and `error_reason=user_cancelled`, and an absent recovery result uses `is_success=false` and `error_reason=unknown_result`. Duration is milliseconds from the install click through the terminal result, including validation, inspection, and internal registry retries. `input_value` retains registry package identifiers and plain versions only; Git inputs become `[git]`, other URLs `[url]`, and paths or unrecognized inputs `[path-or-other]`. Both click and result use this redacted value. Reopening a hidden install dialog counts another `plugin_add_button_click`. `is_builtin` means installation-supplied (`installed=false`), not whether a profile explicitly depends on the package. Explicit build-approval retry starts a new attempt. Restart-required is success. A temporary disconnect retains the pending operation; only an absent recovery result reports `unknown_result`. +### Navigation, preset, and new-session events + +Official navigation reports New Session, Plugins, Automation tasks, and More clicks with their physical placement: `sidebar`, `sidebar_more`, or `titlebar`. Opening Automation tasks from the left sidebar More menu counts as `sidebar_more`, not as a direct sidebar click. Historical `sidebar_menu_click` records without `position` remain unattributed. More-menu actions distinguish the sidebar, conversation, and rightbar regions. Third-party entry identifiers and custom preset names are excluded. + +Direct preset switches report accepted different choices in the mode picker: an unbound staged choice, or Host application for a blank Session. Custom source modes are `other`, and only built-in targets are reported. Default changes, automatic fallback, restoration, and Creator launchers are excluded. Each switch records the Coding Tools setting known at the action; an unknown setting reports nothing. `code_work_view_toggle` reports a successful change of that setting. + +`send_button_click` carries `is_new_session`, which is true while the submitting Session is still blank, and `agent_preset`, the Session's preset as `creator`, `ptc`, `minimal`, `standard`, or `other` for a custom preset; `agent_preset` is omitted while the preset is unknown. The collector receives `eventName` on the OTLP log record and business fields in its `attributes`; repository code supplies no warehouse table or dashboard. + ## Understand the implementation diff --git a/packages/client/product-analytics/README.zh.md b/packages/client/product-analytics/README.zh.md index 803494c0a5..4be9544ae6 100644 --- a/packages/client/product-analytics/README.zh.md +++ b/packages/client/product-analytics/README.zh.md @@ -49,6 +49,14 @@ Electron 将构建内联的 `DSH_CLIENT_VERSION` 传给 Host;埋点与导出 只有 `plugin_toggle` 携带 `plugin_type`:插件条目使用 `plugin`,插件包使用 `bundle`。安装通过 `is_success` 表达结果;取消使用 `is_success=false` 和 `error_reason=user_cancelled`,恢复查询确认无结果时使用 `is_success=false` 和 `error_reason=unknown_result`。耗时单位为毫秒,从点击安装开始,到最终结果结束,包含校验、检查输入和内部源重试。`input_value` 仅保留 registry 包标识和普通版本;Git 输入记为 `[git]`,其他 URL 记为 `[url]`,路径及无法识别的输入记为 `[path-or-other]`。点击和结果均使用脱敏后的值。重新打开已隐藏的安装弹窗会再次计为 `plugin_add_button_click`。`is_builtin` 表示由安装供应(`installed=false`),不表示 profile 是否显式依赖该包。显式批准构建后的重试开启新的一次尝试。需要重启视为成功。临时断线保留待定操作;只有恢复查询确认没有结果时才上报 `unknown_result`。 +### 导航、模式与新会话事件 + +官方导航上报新会话、插件、自动化任务和“更多”的点击及其实际位置:`sidebar`、`sidebar_more` 或 `titlebar`。从左侧栏“更多”菜单打开自动化任务计为 `sidebar_more`,不计为侧栏直接点击。历史上不带 `position` 的 `sidebar_menu_click` 记录保持未归属。“更多”菜单动作区分左侧栏、会话和右侧栏区域。不采集第三方入口标识和自定义预设名称。 + +模式选择器中被接受的不同选择上报为直接切换:未绑定会话时的暂存选择,或空白会话中 Host 已应用的选择。自定义来源模式记为 `other`,只上报内置目标。默认值修改、自动回退、恢复和创造模式入口均不计入。每次切换记录操作时已知的代码工作工具设置;设置未知时不上报。`code_work_view_toggle` 在该设置修改成功时上报。 + +`send_button_click` 携带 `is_new_session`(提交时会话仍为空白会话时为 true)和 `agent_preset`(会话预设,取 `creator`、`ptc`、`minimal`、`standard`,自定义预设为 `other`);预设未知时省略 `agent_preset`。采集端在 OTLP 日志记录上接收 `eventName`,业务字段位于其 `attributes`;仓库代码不提供数仓表或看板。 + ## 了解实现 diff --git a/packages/client/product-analytics/locale/ja.json b/packages/client/product-analytics/locale/ja.json new file mode 100644 index 0000000000..280fc6ced3 --- /dev/null +++ b/packages/client/product-analytics/locale/ja.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "デスクトップ利用統計", + "description": "デスクトップの指定された操作イベントを収集し、製品の統計サービスに送信します。" + } +} diff --git a/packages/client/product-analytics/src/client/index.ts b/packages/client/product-analytics/src/client/index.ts index dfa990bfe6..e44604c565 100644 --- a/packages/client/product-analytics/src/client/index.ts +++ b/packages/client/product-analytics/src/client/index.ts @@ -3,7 +3,10 @@ import { Service, type Context } from '@deepseek-ai/cordis' import type {} from '@deepseek-ai/dsh-api-gateway/client' export type {} from '@deepseek-ai/dsh-client-product-analytics/remote' import type { ProductEvent, ProductEventMap } from '../events.ts' -export type { ProductEvent, ProductEventMap, TrackProductEvent } from '../events.ts' +export type { + AnalyticsPreset, EntryAnalyticsRegion, ProductEvent, ProductEventMap, + SidebarAnalyticsEntry, SidebarAnalyticsPosition, TrackProductEvent, +} from '../events.ts' declare module '@deepseek-ai/cordis' { interface Context { diff --git a/packages/client/product-analytics/src/events.ts b/packages/client/product-analytics/src/events.ts index 7fc64a6bcc..309b7b07ff 100644 --- a/packages/client/product-analytics/src/events.ts +++ b/packages/client/product-analytics/src/events.ts @@ -25,6 +25,10 @@ export interface ProductEventMap { thinking_effort?: string run_mode: 'plan' | 'goal' | 'default' msg_type: 'default' | 'steer' | 'queue' + /** Whether the message is the first one of a blank Session. */ + is_new_session: boolean + /** Fixed category of the Session's Agent preset, when known. */ + agent_preset?: AnalyticsPreset | 'other' } model_switch: { session_id?: SessionId; switch_from: string; switch_to: string } thinking_level_switch: { session_id?: SessionId; switch_from: string; switch_to: string; model_name: string } @@ -35,7 +39,10 @@ export interface ProductEventMap { parent_message_id?: MessageId click_position: 'footer' | 'sidebar' } - sidebar_menu_click: { menu_name: 'plugin' | 'cron' } + sidebar_menu_click: { menu_name: SidebarAnalyticsEntry; position?: SidebarAnalyticsPosition } + more_menu_item_click: { region: EntryAnalyticsRegion; action: 'browse_plugins' | 'create_plugin' } + agent_preset_switch: { switch_from: AnalyticsPreset | 'other'; switch_to: AnalyticsPreset; code_work_view_enabled: boolean } + code_work_view_toggle: { is_enabled: boolean } plugin_toggle: { plugin_name: string; plugin_type: 'plugin' | 'bundle'; is_enabled: boolean; is_builtin: boolean } plugin_add_button_click: Record plugin_install_click: { input_value: string } @@ -49,6 +56,18 @@ export interface ProductEventMap { confirm_uninstall_plugin: { plugin_name: string } } +/** Official navigation entries; third-party identifiers are never analytics dimensions. */ +export type SidebarAnalyticsEntry = 'new_session' | 'plugin' | 'cron' | 'more' + +/** Physical navigation placement, independent of the selected destination. */ +export type SidebarAnalyticsPosition = 'sidebar' | 'sidebar_more' | 'titlebar' + +/** Entry-customization menu regions. */ +export type EntryAnalyticsRegion = 'sidebar' | 'conversation' | 'rightbar' + +/** Built-in presets, independent of localized names and custom preset identifiers. */ +export type AnalyticsPreset = 'creator' | 'ptc' | 'minimal' | 'standard' + /** Public page names independent of internal onboarding state names. */ export type OnboardingPage = 'onboarding_welcome' | 'onboarding_recharge' | 'onboarding_use_case' | 'onboarding_process' diff --git a/packages/client/product-analytics/tests/collection.host.spec.ts b/packages/client/product-analytics/tests/collection.host.spec.ts index 1ccd331183..0ad898bd7a 100644 --- a/packages/client/product-analytics/tests/collection.host.spec.ts +++ b/packages/client/product-analytics/tests/collection.host.spec.ts @@ -161,7 +161,7 @@ it('streams live policy changes and rejects intake disabled during identity look const next = iterator.next() // Loader commits the stable Config reference before publishing this notification. const { updateVolatile, createVolatile } = await import('../../../../vendor/cosmokit/src/volatile.ts') - const config = b.fiber.config as import('../src/index.ts').Config + const config = b.fiber.effectiveConfig as import('../src/index.ts').Config updateVolatile(config.enabled, createVolatile(false)) b.fiber.ctx.emit('loader/volatile-update', [['enabled']]) expect(await next).toEqual({ value: false, done: false }) diff --git a/packages/client/resources/locale/ja.json b/packages/client/resources/locale/ja.json new file mode 100644 index 0000000000..05deefd46f --- /dev/null +++ b/packages/client/resources/locale/ja.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "クライアントリソースサービス", + "description": "ファイルなどのリソースアドレスを、UI が読み取れるコンテンツに解決します。" + } +} diff --git a/packages/client/shortcuts/locale/ja.json b/packages/client/shortcuts/locale/ja.json new file mode 100644 index 0000000000..bf9612d70c --- /dev/null +++ b/packages/client/shortcuts/locale/ja.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "ショートカットサービス", + "description": "アプリのショートカットを登録し、キー操作を対応する機能に振り分けます。" + } +} diff --git a/packages/client/shortcuts/tests/config.host.spec.ts b/packages/client/shortcuts/tests/config.host.spec.ts index 13ad7bc00a..ec507217c5 100644 --- a/packages/client/shortcuts/tests/config.host.spec.ts +++ b/packages/client/shortcuts/tests/config.host.spec.ts @@ -1,8 +1,11 @@ import { Context } from '@deepseek-ai/cordis' -import type { IndexInjection } from '@deepseek-ai/dsh-host-webserver' +import type { IndexInjection, WebServer } from '@deepseek-ai/dsh-host-webserver' import { expect, it } from 'vitest' import { Config, apply } from '../src/index.ts' +/** The Peer an index render speaks for; this listener reads no field of it. */ +const PAGE_PEER = { principal: { kind: 'owner' } } as Parameters[1] + it('validates positive sequence timing and publishes it through the product page injection', async () => { expect(Config({}).stopSequenceMs).toBe(500) expect(Config({ stopSequenceMs: 2_147_483_646 }).stopSequenceMs).toBe(2_147_483_646) @@ -11,7 +14,7 @@ it('validates positive sequence timing and publishes it through the product page try { apply(ctx, Config({ stopSequenceMs: 800 })) const table: IndexInjection[] = [] - await ctx.parallel('webserver/index-inject', table) + await ctx.parallel('webserver/index-inject', table, PAGE_PEER) expect(table).toEqual([{ kind: 'global', name: '__DSH_SHORTCUTS_CONFIG__', value: { stopSequenceMs: 800 } }]) } finally { await ctx.fiber.dispose() } }) diff --git a/packages/client/tsdown.client.ts b/packages/client/tsdown.client.ts index 9810983691..c80b06b289 100644 --- a/packages/client/tsdown.client.ts +++ b/packages/client/tsdown.client.ts @@ -82,7 +82,7 @@ function styleInjectionModule( * Everything else under @deepseek-ai/* is either a module-table entry * (external) or a leak the purity gate rejects. */ -export const INLINE_SAFE = /^(?:@deepseek-ai\/dsh-(?:file-reference|session|llm|tools|brand|deque|output-retention|typert-protocol|util-crypto|util-values|util-workspace-path)(?:\/|$)|@deepseek-ai\/dsh-token-meter\/client$|@deepseek-ai\/dsh-native-command\/types$|@deepseek-ai\/dsh-host-open-in-app\/shared$|@deepseek-ai\/dsh-plugin-manager\/registry$|@deepseek-ai\/dsh-agent-preset-registry\/display$|@deepseek-ai\/dsh-api-workspace-controller\/default-workspace$|@deepseek-ai\/dsh-spill-policy\/notice$)/ +export const INLINE_SAFE = /^(?:@deepseek-ai\/dsh-(?:file-reference|session|llm|tools|brand|deque|output-retention|typert-protocol|util-crypto|util-values|util-workspace-path)(?:\/|$)|@deepseek-ai\/dsh-util-file-drag$|@deepseek-ai\/dsh-token-meter\/client$|@deepseek-ai\/dsh-native-command\/types$|@deepseek-ai\/dsh-host-open-in-app\/shared$|@deepseek-ai\/dsh-plugin-manager\/registry$|@deepseek-ai\/dsh-agent-preset-registry\/display$|@deepseek-ai\/dsh-api-workspace-controller\/default-workspace$|@deepseek-ai\/dsh-spill-policy\/notice$)/ /** * Vendored framework libraries: rescoped into @deepseek-ai, so the gate below diff --git a/packages/client/ui-agent-preset/README.i18n.yaml b/packages/client/ui-agent-preset/README.i18n.yaml index ceef370d58..df89d4bbcb 100644 --- a/packages/client/ui-agent-preset/README.i18n.yaml +++ b/packages/client/ui-agent-preset/README.i18n.yaml @@ -15,11 +15,11 @@ en: d152484eb41ac6b4 zh: b3ad7867f23dad1c /deepseek-ai-dsh-client-ui-agent-preset/use-this-package: - en: 475fae2164ced556 - zh: 4d0c729ffc210a83 + en: b53d5c356c6a7a83 + zh: ade3f5f551e73c7e /deepseek-ai-dsh-client-ui-agent-preset/understand-the-implementation: - en: d7a38ad9e5497789 - zh: 5f5595ddd90fa6bd + en: 0373c5425a952881 + zh: 609c5829283f515d /deepseek-ai-dsh-client-ui-agent-preset/further-exploration: en: bbae8d79844bc481 zh: 4302107fd1f460e6 diff --git a/packages/client/ui-agent-preset/README.md b/packages/client/ui-agent-preset/README.md index 0f92e5ddde..28ed891c87 100644 --- a/packages/client/ui-agent-preset/README.md +++ b/packages/client/ui-agent-preset/README.md @@ -37,6 +37,8 @@ Known shipped presets offer mode details and usage examples in a read-only dialo Entering Creator selects `cordis` for the receiving blank Session without changing the new-task default or Coding Tools setting. If no workspace or blank Session is bound yet, the choice waits for that binding. After the choice is applied, the Creator Session keeps its preset; a later newly created Session uses the configured default, such as Standard. +On Desktop, the optional [product analytics](../product-analytics/README.md) service receives `agent_preset_switch` when a direct mode-picker choice moves to a different built-in preset: an unbound choice counts once it is staged for the next Session, and a choice for the bound blank Session counts once the Host applies it. The event carries the source and target categories mapped from the preset id (`cordis` is `creator`; `ptc`, `minimal`, and `standard` keep their ids; any other id is `other`) and the Coding Tools setting known at the choice. Custom targets, refused choices, choices the Host settles on another preset, default changes and their blank-Session synchronization report nothing, and neither does a choice made while the source mode or Coding Tools setting is unknown. Custom preset ids and names are not collected. + ## Understand the implementation @@ -47,6 +49,8 @@ Entering Creator selects `cordis` for the receiving blank Session without changi `CreatePluginMenuItem` contributes to `plugins.add.actions` using the shared roster store. Its selection calls the owner's `onDismiss()` before the same `startCreatorDraft` callback exposed to Settings as `creatorDraft`. That callback stages `cordis` through `AgentPresetSeatController`, except when the main blank Session lists a different preset (navigation then opens another blank and leaves that one unchanged), and opens the task through `uiWorkspace.startSession` with explicit draft preservation and `agentPreset: 'cordis'`, so the Host composes the reused blank or new Session under Creator; without a target Workspace, the staged choice applies when a Session binding becomes available. The choice is consumed once applied. Both Coding Tools states use the same picker and selection store, and `AgentPresetSeat` renders refusals through the shared `Toast` component. +`AgentPresetSeatController.select()` and `apply()` return the attempt's outcome: `applied` when the Host commits the staged preset, `staged` while no Session is current, `unchanged` when busy, without a stage, for a started or already matching Session, or when the Host settles on another preset, and `refused` with the bare Host reason. The chip's injected `select` reports `agent_preset_switch` from that outcome and returns only the refusal reason. + diff --git a/packages/client/ui-agent-preset/README.zh.md b/packages/client/ui-agent-preset/README.zh.md index 41b820ad95..a5b948e6a2 100644 --- a/packages/client/ui-agent-preset/README.zh.md +++ b/packages/client/ui-agent-preset/README.zh.md @@ -37,6 +37,8 @@ kind: "package-reference" 进入创造模式会为接收该选择的空白 Session 选择 `cordis`,不改变新任务默认值或代码工作工具设置。尚未绑定工作区或空白 Session 时,选择等待绑定完成后应用。应用后,创造 Session 保留该预设;之后真正创建的新 Session 使用配置的默认值,例如标准模式。 +在桌面端,模式选择器中的直接选择切换到另一个内置 preset 时,可选的[产品埋点](../product-analytics/README.zh.md)服务会收到 `agent_preset_switch`:未绑定 Session 的选择在暂存给下一个 Session 时计入,已绑定空白 Session 的选择在 Host 应用后计入。事件携带按 preset id 映射的来源与目标类别(`cordis` 记为 `creator`;`ptc`、`minimal`、`standard` 保持原 id;其他 id 记为 `other`),以及选择时已知的代码工作工具设置。选择自定义目标、被拒绝的选择、Host 最终落在其他 preset 的选择、默认值修改及其空白 Session 同步均不报告;来源模式或代码工作工具设置未知时做出的选择同样不报告。不采集自定义 preset 的 id 和名称。 + ## 理解实现 @@ -47,6 +49,8 @@ kind: "package-reference" `CreatePluginMenuItem` 使用共享的预设列表 store,向 `plugins.add.actions` 贡献菜单项。选择时先调用页面传入的 `onDismiss()`,再调用与设置页 `creatorDraft` 相同的 `startCreatorDraft` 回调。该回调通过 `AgentPresetSeatController` 暂存 `cordis`(主区空白 Session 列出其他 preset 时不暂存,因为导航会打开另一个空白 Session,原空白 Session 保持不变),再通过显式保留草稿并传入 `agentPreset: 'cordis'` 的 `uiWorkspace.startSession` 调用打开任务,由 Host 以创造模式组合被复用的空白 Session 或新 Session;没有目标工作区时,暂存选择在 Session 绑定可用时应用。暂存选择应用后即清除。代码工作工具开启或关闭时均使用同一个选择器和选择 store,`AgentPresetSeat` 通过共享的 `Toast` 组件显示拒绝原因。 +`AgentPresetSeatController.select()` 与 `apply()` 返回本次尝试的结果:Host 提交暂存 preset 时为 `applied`;没有当前 Session 时为 `staged`;忙碌、没有暂存选择、Session 已开始或已使用该 preset,或 Host 最终落在其他 preset 时为 `unchanged`;Host 拒绝时为携带原始原因的 `refused`。选择器注入的 `select` 根据该结果报告 `agent_preset_switch`,并且只返回拒绝原因。 + diff --git a/packages/client/ui-agent-preset/locale/ja.json b/packages/client/ui-agent-preset/locale/ja.json new file mode 100644 index 0000000000..6a121a29bd --- /dev/null +++ b/packages/client/ui-agent-preset/locale/ja.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "エージェントプリセット UI", + "description": "現在および新規セッションのエージェントプリセットを選択し、使用するプラグインを編集します。" + } +} diff --git a/packages/client/ui-agent-preset/package.json b/packages/client/ui-agent-preset/package.json index 6926899e20..f4a8bfb805 100644 --- a/packages/client/ui-agent-preset/package.json +++ b/packages/client/ui-agent-preset/package.json @@ -56,6 +56,7 @@ "@deepseek-ai/dsh-api-session-controller": "workspace:*", "@deepseek-ai/dsh-client-connection": "workspace:*", "@deepseek-ai/dsh-client-locale": "workspace:*", + "@deepseek-ai/dsh-client-product-analytics": "workspace:*", "@deepseek-ai/dsh-client-store": "workspace:*", "@deepseek-ai/dsh-client-test-runtime": "workspace:*", "@deepseek-ai/dsh-client-ui-conversation": "workspace:*", diff --git a/packages/client/ui-agent-preset/src/client/analytics.ts b/packages/client/ui-agent-preset/src/client/analytics.ts new file mode 100644 index 0000000000..ac7d2984c4 --- /dev/null +++ b/packages/client/ui-agent-preset/src/client/analytics.ts @@ -0,0 +1,17 @@ +/** Fixed analytics categories for the picker; custom preset ids never leave this package. */ +import type { AnalyticsPreset } from '@deepseek-ai/dsh-client-product-analytics/client' + +/** Resolve a known selection by preset id without treating an unloaded state as a default. + * @param id - selected preset id; an empty id means unknown. + * @returns the built-in category for a shipped id, other for any other id, or undefined until known. + */ +export function analyticsMode(id: string): AnalyticsPreset | 'other' | undefined { + if (id === '') return undefined + switch (id) { + case 'cordis': return 'creator' + case 'ptc': return 'ptc' + case 'minimal': return 'minimal' + case 'standard': return 'standard' + default: return 'other' + } +} diff --git a/packages/client/ui-agent-preset/src/client/guide-locales.ts b/packages/client/ui-agent-preset/src/client/guide-locales.ts index 2d8320f298..ff5a5b5529 100644 --- a/packages/client/ui-agent-preset/src/client/guide-locales.ts +++ b/packages/client/ui-agent-preset/src/client/guide-locales.ts @@ -10,7 +10,7 @@ export const guideEn = { guideStandardIntro: 'Choose Standard mode when starting a new task. Describe what you want to accomplish, point to the relevant files, and explain how to check the result.', guideStandardExplanation: [ '### How it works', - 'The agent calls tools directly to read and edit files, search, and run terminal commands. It includes Skills, planning, goals, subagents, workflows, and context compaction.', + 'The agent calls tools directly to read and edit files, search, and run terminal commands. It includes Skills, planning, goals, subagents, workflows, plugin management, and context compaction.', '### When to choose it', 'Start here for everyday coding, file work, and research. Standard mode can also write scripts and process files in batches. PTC changes how tool calls are organized; it is not required for batch tasks.', ].join('\n\n'), @@ -29,7 +29,7 @@ export const guideEn = { '### What reaches the model', 'Tool results first reach the program, which can filter and combine them. The model receives what the program prints or returns; image results are attached separately. Nested tool calls are still recorded and remain subject to tool permissions.', '### Compared with Standard mode', - 'Both modes can handle coding and batch tasks. Standard mode exposes individual tools directly; PTC organizes tool calls in code. The current PTC preset leaves the workflow tool disabled. Speed and token use depend on the task and how the program handles its results.', + 'Both modes can handle coding and batch tasks. Standard mode exposes individual tools directly; PTC organizes tool calls in code. The current PTC preset leaves the workflow and plugin management tools disabled. Speed and token use depend on the task and how the program handles its results.', ].join('\n\n'), guidePtcUsage: [ '### Check a set of configuration files', @@ -54,7 +54,7 @@ export const guideEn = { guideCordisIntro: 'Choose Creator mode for a new task. Describe the capability you want, where it should appear, and how you will verify it.', guideCordisExplanation: [ '### What you can create', - 'Creator mode includes the standard task tools plus runtime inspection, persistent plugin management, and guidance for authoring Cordis plugins and agent presets. It can create a plugin that adds a capability or UI, or a preset that combines tools and prompts for a particular job.', + 'Creator mode includes the standard task tools plus runtime inspection and guidance for authoring Cordis plugins and agent presets. It can create a plugin that adds a capability or UI, or a preset that combines tools and prompts for a particular job.', '### Plugins and modes', 'A plugin adds capabilities to DSH, such as a tool, a service connection, or a UI entry. A mode is an agent preset that selects tools and defines how the agent works in a task. A plugin can be included in a custom preset.', '### How the result takes effect', @@ -88,7 +88,7 @@ export const guideZh: Record = { guideStandardIntro: '新建任务时选择「标准模式」,说明要完成什么、相关文件在哪里,以及怎样判断任务完成。', guideStandardExplanation: [ '### 工作方式', - 'Agent 直接调用工具来读写文件、检索资料和执行终端命令。包含 Skills、计划、目标、子 Agent、工作流和上下文压缩等能力。', + 'Agent 直接调用工具来读写文件、检索资料和执行终端命令。包含 Skills、计划、目标、子 Agent、工作流、插件管理和上下文压缩等能力。', '### 什么时候选', '日常编程、文件处理和资料整理可以从这里开始。标准模式也能编写脚本、批量处理文件;PTC 改变的是工具调用方式,批量任务并不必须使用 PTC。', ].join('\n\n'), @@ -107,7 +107,7 @@ export const guideZh: Record = { '### 哪些结果交给模型', '工具返回的数据先交给程序,经过筛选、计算或合并,再通过输出或返回值交给模型;图片结果会另行附加。程序中的工具调用仍会被记录,也仍受工具权限约束。', '### 与标准模式的区别', - '两种模式都能编程、批量处理文件。标准模式直接向模型提供各个工具;PTC 让模型用代码组织工具调用。当前 PTC 预设未启用 workflow 工具。速度和 token 用量取决于具体任务与结果处理方式。', + '两种模式都能编程、批量处理文件。标准模式直接向模型提供各个工具;PTC 让模型用代码组织工具调用。当前 PTC 预设未启用 workflow 和插件管理工具。速度和 token 用量取决于具体任务与结果处理方式。', ].join('\n\n'), guidePtcUsage: [ '### 批量检查配置文件', @@ -132,7 +132,7 @@ export const guideZh: Record = { guideCordisIntro: '新建任务时选择「创造模式」,说明希望增加什么能力、从哪里使用,以及怎样验证效果。', guideCordisExplanation: [ '### 可以创造什么', - '创造模式具备标准任务工具,并增加运行时检查、持久化插件管理,以及 Cordis 插件和 Agent 预设的开发指引。可以编写插件来添加功能或界面,也可以组合工具和提示词,创建适合特定任务的模式。', + '创造模式具备标准任务工具,并增加运行时检查,以及 Cordis 插件和 Agent 预设的开发指引。可以编写插件来添加功能或界面,也可以组合工具和提示词,创建适合特定任务的模式。', '### 插件与模式的关系', '插件为 DSH 增加能力,例如工具、服务连接或界面入口。模式是一份 Agent 预设,用来选择任务可用的工具,并约定 Agent 的工作方式。自定义模式中也可以使用自己开发的插件。', '### 怎样让成果生效', @@ -150,3 +150,26 @@ export const guideZh: Record = { '预期产出:可在新任务中选择的自定义模式。审查要求用于指导 Agent,实际可执行的操作仍由权限设置决定。', ].join('\n\n'), } + +/** Japanese copy, checked against the English keys. */ +export const guideJa = { + 'modeExplanation': 'モードの詳細', + 'howToUse': '使い方', + 'guideSections': 'ガイドのセクション', + 'guideExampleTask': 'タスク例', + 'guideCopy': 'コピー', + 'guideCopied': 'コピーしました', + 'guideFootnotes': '脚注', + 'guideStandardIntro': '新しいタスクを始めるときは標準モードを選びます。達成したいことを説明し、関連するファイルを示し、結果の確認方法を伝えてください。', + 'guideStandardExplanation': '### 仕組み\n\nエージェントはツールを直接呼び出してファイルの読み取りや編集、検索、ターミナルコマンドの実行を行います。スキル、計画、目標、サブエージェント、ワークフロー、プラグイン管理、コンテキストの圧縮に対応しています。\n\n### 選ぶべき場面\n\n日々のコーディング、ファイル作業、調査はまずここから始めてください。標準モードではスクリプトの作成やファイルの一括処理も行えます。PTC はツール呼び出しの構成を変えるものであり、一括処理に必須ではありません。', + 'guideStandardUsage': '### バグを修正する\n\n> 検索フォームを二度送信すると結果が消える原因を調べてください。修正して関連するテストを実行し、原因と変更内容を説明してください。\n\n期待される出力: コードの変更、関連するテスト結果、原因の説明。\n\n### プロジェクトのメモを整理する\n\n> このプロジェクト内の Markdown メモを読み、合意された決定と未解決の課題を、元ファイルへのリンク付きで要約してください。\n\n期待される出力: 元のメモと照合できる参照付きの要約。', + 'guidePtcIntro': '新しいタスクを始めるときは PTC モードを選びます。入力ファイル、処理ルール、出力形式を指定してください。コードはエージェントが書きます。', + 'guidePtcExplanation': '### ツールの呼び出され方\n\nPTC は Programmatic Tool Calling の略です。この組み込みプリセットでは、エージェントは run_code を使い、生成された SDK 経由でツールを呼び出す TypeScript プログラムを書きます。プログラムではループ、条件分岐、エラー処理、必要に応じて並行呼び出しを利用できます。\n\n### モデルに届くもの\n\nツールの結果はまずプログラムに届き、そこでフィルタリングや結合ができます。モデルが受け取るのはプログラムが出力または返した内容です。画像の結果は別途添付されます。ネストされたツール呼び出しも記録され、ツール権限の対象のままです。\n\n### 標準モードとの比較\n\nどちらのモードもコーディングや一括処理のタスクを扱えます。標準モードは個々のツールを直接公開し、PTC はツール呼び出しをコードで整理します。現在の PTC プリセットではワークフローとプラグイン管理ツールが無効になっています。速度とトークン消費はタスクやプログラムの結果の扱い方によって変わります。', + 'guidePtcUsage': '### 設定ファイルのセットを検証する\n\n> configs/ 内のすべての JSON ファイルを検査してください。schema.json に基づき、必須フィールドの欠落と無効な値を一覧にしてください。問題ごとに 1 行の CSV を保存してください。読み取れないファイルもレポートに含め、残りの検査は続けてください。元のファイルは変更しないでください。\n\n期待される出力: 問題の要約と CSV レポート。プログラムは同じ検査を繰り返し、個々の失敗に対処し、結果を集約できます。\n\n### エラーログを要約する\n\n> logs/ 内のログファイルを分析してください。エラーをサービスとエラー種別でグループ化し、頻度上位 10 グループと各 1 件の例を示してください。完全な件数を CSV に保存してください。\n\n期待される出力: 上位のエラーグループと完全な件数表。中間データは要約がモデルに届く前にプログラム内で集約できます。', + 'guideMinimalIntro': '新しいタスクには最小モードを選びます。比較を行う場合は、モデル、権限、入力、開始時のワークスペース状態を実行間で一定に保ってください。', + 'guideMinimalExplanation': '### 含まれるもの\n\n永続的なシェルツール 1 つ、read_image、固定のシステムプロンプト。画像の読み取りには画像対応モデルが必要です。古い履歴はコンテキストの負荷に応じて自動的に圧縮されます。エージェントがアイドル状態のときに /compact を使えば手動で圧縮できます。この組み込みプリセットはスキル、計画、標準のランタイムコンテキストを読み込みません。\n\n### 選ぶべき場面\n\n実験や比較のベースラインとして使用します。シェルコマンド経由でファイルの読み取りやスクリプトの実行はできますが、長いタスクを管理するための組み込み手段は少なくなります。ツールが少ないからといって、初心者にとって必ずしも簡単になるわけではありません。', + 'guideMinimalUsage': '### 小さなバグ修正で性能を比較する\n\n> このプロジェクトのテストを実行し、失敗の原因を見つけて最小限の修正をしてください。関連するテストを再度実行し、結果を報告してください。\n\n同じ開始状態から、標準モードと最小モードで同じタスクを別々に実行します。タスクの完了状況、ツール呼び出し、生じた変更を比較してください。最小モードはターミナルコマンドを通じて作業を行います。', + 'guideCordisIntro': '新しいタスクにはクリエイターモードを選びます。実現したい機能、表示される場所、確認方法を説明してください。', + 'guideCordisExplanation': '### 作成できるもの\n\nクリエイターモードには標準的なタスクツールに加え、ランタイムの検査、Cordis プラグインやエージェントプリセットの作成ガイドが含まれます。機能や UI を追加するプラグインを作成することも、特定の作業向けにツールとプロンプトを組み合わせたプリセットを作成することもできます。\n\n### プラグインとモード\n\nプラグインはツール、サービス接続、UI エントリなど、DSH に機能を追加します。モードはエージェントプリセットであり、ツールを選択し、タスク内でのエージェントの動作を定義します。プラグインはカスタムプリセットに含めることができます。\n\n### 結果を反映させる方法\n\nソースコードを生成するだけでなく、エージェントにインストールと確認を依頼してください。プラグインは変更内容によっては即座に読み込まれるか、再起動が必要になる場合があります。新しく作成したプリセットは、新しいタスクを始めるときに選択されます。', + 'guideCordisUsage': '### UI を追加する\n\n> サイドバーにプロジェクトメモのエントリを追加する DSH プラグインを作成してください。このワークスペース内の Markdown ファイルを閲覧でき、選択したメモをプレビューできるようにしてください。インストールしてページが開くことを確認してください。\n\n期待される出力: 動作するエントリとプレビューページを備えた、インストール済みのプラグイン。残っている有効化手順があればそれも含みます。\n\n### ツールを追加する\n\n> このプロジェクトのテストレポートを読み、失敗したテストを要約するツールを備えたプラグインを作成してください。登録し、サンプルのレポートで確認してください。\n\n期待される出力: 呼び出し可能なツールを備えたプラグインと、確認済みのサンプル呼び出し。\n\n### 独自のモードを作成する\n\n> 標準モードをベースに「コードレビュー」モードを作成してください。潜在的なバグとテストの不足を優先し、ファイルパスと行番号を示し、ファイルを変更する前に確認を求めるようにしてください。選択可能なプリセットとして保存してください。\n\n期待される出力: 新しいタスク用のカスタムプリセット。これらのレビュー指示はエージェントを導くものであり、実行できる操作は権限設定によって決まります。', +} satisfies Record diff --git a/packages/client/ui-agent-preset/src/client/index.ts b/packages/client/ui-agent-preset/src/client/index.ts index 6988c1cc4f..0d9d61e621 100644 --- a/packages/client/ui-agent-preset/src/client/index.ts +++ b/packages/client/ui-agent-preset/src/client/index.ts @@ -42,8 +42,9 @@ import { AgentPresetSection } from './AgentPresetSection.tsx' import type { AgentPresetSectionInjected } from './AgentPresetSection.tsx' import { AgentPresetSeatController, type AgentPresetStage } from './seat-store.ts' import { AgentPresetSectionController } from './section-store.ts' -import { en, zh, type AgentPresetSettingsKey } from './locales.ts' +import { en, ja, zh, type AgentPresetSettingsKey } from './locales.ts' import { AGENT_PRESET_SETTINGS_NS, AgentPresetSettingsController } from './settings-store.ts' +import { analyticsMode } from './analytics.ts' declare module '@deepseek-ai/dsh-client-ui-slots' { interface LocaleNamespaceMap { @@ -127,7 +128,7 @@ export function apply(ctx: ClientContext): void { return binding === undefined ? undefined : seatFor(binding) } - ctx.effect(() => ctx.locale.register('settings.agentPreset', { zh, en }), 'ui-agent-preset: settings row dictionaries') + ctx.effect(() => ctx.locale.register('settings.agentPreset', { zh, en, ja }), 'ui-agent-preset: settings row dictionaries') ctx.effect(() => { let requested = 0 @@ -187,7 +188,21 @@ export function apply(ctx: ClientContext): void { return { hooks: { agentPresetSeat: seat.store, developerTools: ctx.configForms.developerTools.enabled }, load: () => seat.load(), - select: (id: string) => seat.select(id), + select: async (id: string) => { + const state = seat.store.getSnapshot() + const from = analyticsMode(state.current) + const to = analyticsMode(id) + const enabled = toolsSettings.getSnapshot().value?.enabled + const outcome = await seat.select(id) + // A bound pick counts once the Host applies it; an unbound pick counts when staged for the next Session. + const accepted = outcome.kind === 'applied' || (outcome.kind === 'staged' && sessionId === undefined) + if (accepted && from !== undefined && to !== undefined && to !== 'other' && from !== to && enabled !== undefined) { + ctx.get('productAnalytics')?.track('agent_preset_switch', { + switch_from: from, switch_to: to, code_work_view_enabled: enabled, + }) + } + return outcome.kind === 'refused' ? outcome.reason : undefined + }, dismissRefusal: (error) => { seat.dismissRefusal(error) }, introduced: () => { seat.introduced() }, } diff --git a/packages/client/ui-agent-preset/src/client/locales.ts b/packages/client/ui-agent-preset/src/client/locales.ts index 49178ca524..00fce156ee 100644 --- a/packages/client/ui-agent-preset/src/client/locales.ts +++ b/packages/client/ui-agent-preset/src/client/locales.ts @@ -1,6 +1,6 @@ /** Locale bundles for the agent-preset hero chip, header label, and management section. */ -import { guideEn, guideZh, type PresetGuideKey } from './guide-locales.ts' +import { guideEn, guideJa, guideZh, type PresetGuideKey } from './guide-locales.ts' /** Locale keys these surfaces render. */ export type AgentPresetSettingsKey = @@ -125,3 +125,36 @@ export const zh: Record = { // Settings plugin list inlines the same fold over this plugin's dictionaries. export { isBuiltInPreset, presetDisplayText } from '@deepseek-ai/dsh-agent-preset-registry/display' export type { PresetDisplaySource, PresetDisplayText } from '@deepseek-ai/dsh-agent-preset-registry/display' + +/** Japanese copy, checked against the English keys. */ +export const ja = { + ...guideJa, + 'builtInGroup': '組み込み', + 'customGroup': 'カスタム', + 'sectionIntro': 'エージェントのツールと動作を選択します。日々のタスクには標準モードを、DSH に機能を追加するにはクリエイターモードを使用してください。', + 'seatHint': '新しいタスクで使用するエージェントプリセットを選択', + 'headerHint': 'このタスクの開始時に選択されたエージェントプリセット', + 'nav': 'エージェントプリセット', + 'setDefault': '新しいタスクの既定に設定', + 'view': '設定を表示', + 'presetStandardName': '標準モード', + 'presetStandardDescription': 'コード、ファイル、情報を扱います。検索、編集、ターミナルコマンドなどのツールを必要に応じて利用でき、ほとんどのタスクに適しています。', + 'presetPtcName': 'PTC モード', + 'presetPtcDescription': '標準モードのすべての機能を含みます。ツールを一括で呼び出し、その結果を絞り込み、整理、重複排除、集計、要約するタスクに適しています。', + 'presetMinimalName': '最小モード', + 'presetMinimalDescription': 'エージェントはターミナルと画像読み取りを使用し、コンテキスト圧縮にも対応します。基本的な性能のテストや比較に役立ちます。', + 'presetCordisName': 'クリエイターモード', + 'presetCordisDescription': '会話を通じて DSH をカスタマイズします。機能や UI を追加するプラグインをエージェントに書かせたり、ツールとプロンプトを組み合わせて独自のモードを作成したりできます。', + 'inUse': '新しいタスクの既定', + 'noDescription': '説明はありません。', + 'brokenBadge': '読み込みに失敗', + 'switchRefused': '{name} に切り替えられませんでした: {reason}', + 'standardUnavailable': '標準モードは利用できません。復元するか、利用可能な別のモードを選択してください。', + 'close': '閉じる', + 'creatorDraft': 'エージェントにプリセット作成を手伝ってもらう', + 'createPlugin': 'エージェントにプラグインを作成してもらう', + 'createPluginDescription': 'クリエイターモードに入り、独自の DSH プラグインを作成', + 'createPluginChecking': 'クリエイターモードが利用可能か確認中', + 'createPluginUnavailable': '一時的に利用できません。このメニューを開き直して再試行してください', + 'createPluginMissing': 'この構成にはクリエイターモードが含まれていません', +} satisfies Record diff --git a/packages/client/ui-agent-preset/src/client/seat-store.ts b/packages/client/ui-agent-preset/src/client/seat-store.ts index 0a1860ba86..a19d7a11c4 100644 --- a/packages/client/ui-agent-preset/src/client/seat-store.ts +++ b/packages/client/ui-agent-preset/src/client/seat-store.ts @@ -50,6 +50,17 @@ export interface AgentPresetStage { introduce: boolean } +/** + * Result of one attempt to hand a staged preset to the current Session: the Host applied it, + * it waits for a Session to appear, nothing changed (busy, already composed, started, or no + * stage), or the Host refused it. + */ +export type AgentPresetApplyOutcome = + | { readonly kind: 'applied' | 'staged' | 'unchanged' } + | { readonly kind: 'refused'; readonly reason: string } + +const UNCHANGED: AgentPresetApplyOutcome = { kind: 'unchanged' } + /** Stages the next session's preset and applies it when one appears. */ export class AgentPresetSeatController { /** Chip snapshot the renderer subscribes to. */ @@ -124,10 +135,10 @@ export class AgentPresetSeatController { * The refusal is stored for the chip's announcement and returned to callers * such as Settings that also report the result of their own write. * @param id - the preset to stage. - * @returns the refusal text, or undefined once the pick settled. + * @returns how the pick settled; a refusal carries its text. */ - async select(id: string): Promise { - if (this.store.getSnapshot().busy) return undefined + async select(id: string): Promise { + if (this.store.getSnapshot().busy) return UNCHANGED this.stage(id) return await this.apply() } @@ -182,7 +193,8 @@ export class AgentPresetSeatController { const session = this.currentSession() if (session === undefined || !session.blank || session.id !== expectedSessionId) return undefined this.stage(id) - return await this.apply() + const outcome = await this.apply() + return outcome.kind === 'refused' ? outcome.reason : undefined } /** Acknowledge the introduction cue once the chip has played it. */ @@ -198,34 +210,34 @@ export class AgentPresetSeatController { * Called both by `select()` and by whoever observes the current session * changing, because the session may appear either before or after the pick. * List updates do not repeat a selection while its response is pending. - * @returns this attempt's Host refusal, or undefined when successful or no switch starts. + * @returns how this attempt settled; only a Host commit of the staged preset is applied. */ - async apply(): Promise { - if (this.store.getSnapshot().busy) return + async apply(): Promise { + if (this.store.getSnapshot().busy) return UNCHANGED const staged = this.staged.id const session = this.currentSession() if (staged === undefined) { const current = session === undefined ? this.fallback : presetOf(session) ?? '' const shown = this.store.getSnapshot() if (current !== shown.current) this.set({ current }) - return + return UNCHANGED } - if (session === undefined) return + if (session === undefined) return { kind: 'staged' } // A started session's history was produced under its own composition; the // host refuses the swap, so the stage is no longer meaningful. if (!session.blank || presetOf(session) === staged) { this.clearStage() - return + return UNCHANGED } const completion = Promise.withResolvers() this.pendingSelection = completion.promise this.clearStage() - const refuse = (reason: string) => { + const refuse = (reason: string): AgentPresetApplyOutcome => { this.set({ error: { reason, preset: this.store.getSnapshot().options.find(option => option.id === staged) ?? { id: staged } }, current: this.staged.id ?? presetOf(session) ?? '', }) - return reason + return { kind: 'refused', reason } } try { this.set({ busy: true, error: null }) @@ -239,6 +251,7 @@ export class AgentPresetSeatController { return refuse(refusal) } this.set({ current: this.staged.id ?? result.value }) + return result.value === staged ? { kind: 'applied' } : UNCHANGED } catch (error) { return refuse(error instanceof Error ? error.message : String(error)) } finally { diff --git a/packages/client/ui-agent-preset/tests/analytics.client.spec.ts b/packages/client/ui-agent-preset/tests/analytics.client.spec.ts new file mode 100644 index 0000000000..9d7ace81b4 --- /dev/null +++ b/packages/client/ui-agent-preset/tests/analytics.client.spec.ts @@ -0,0 +1,28 @@ +/** + * Analytics categories for picker selections come from the preset id alone: + * shipped ids map to fixed categories, every other id is `other`, and an + * unloaded selection has no category. + */ + +import { describe, expect, it } from 'vitest' +import { analyticsMode } from '../src/client/analytics.ts' + +describe('analyticsMode', () => { + it.each([ + ['cordis', 'creator'], + ['ptc', 'ptc'], + ['minimal', 'minimal'], + ['standard', 'standard'], + ] as const)('maps the built-in %s preset id to %s', (id, category) => { + expect(analyticsMode(id)).toBe(category) + }) + + it('treats any other preset id as other', () => { + expect(analyticsMode('mine')).toBe('other') + expect(analyticsMode('private-id')).toBe('other') + }) + + it('has no category before the selection is known', () => { + expect(analyticsMode('')).toBeUndefined() + }) +}) diff --git a/packages/client/ui-agent-preset/tests/apply.client.spec.ts b/packages/client/ui-agent-preset/tests/apply.client.spec.ts index cb81b514a8..8ec119fee6 100644 --- a/packages/client/ui-agent-preset/tests/apply.client.spec.ts +++ b/packages/client/ui-agent-preset/tests/apply.client.spec.ts @@ -68,6 +68,10 @@ const ROSTER_MOVED = { async function bench(options: { failSettingsUpdate?: boolean selectGate?: Promise + /** The Host refuses every selection. */ + selectRefused?: boolean + /** The preset the Host reports after a selection, when it differs from the request. */ + selectResult?: string settingsRosterGate?: Promise describeGate?: Promise presets?: AgentPresetRow[] @@ -164,7 +168,9 @@ async function bench(options: { }, select: (_agentId: SessionId, agentPreset: string) => { calls.push(`select:${agentPreset}`) - return Promise.resolve(options.selectGate).then(() => ({ ok: true as const, value: agentPreset })) + return Promise.resolve(options.selectGate).then(() => options.selectRefused === true + ? { ok: false as const, error: new RemoteError('gateway/internal', 'selection refused', {}) } + : { ok: true as const, value: options.selectResult ?? agentPreset }) }, } ctx.provide('remote.agentPresets', agentPresets as never) @@ -1188,6 +1194,154 @@ describe('ui-agent-preset apply', () => { }) }) +/** Official presets plus one custom preset whose id and name must not leave the Client. */ +const ANALYTICS_ROSTER: AgentPresetRow[] = [ + { id: 'standard', isDefault: true }, { id: 'cordis', isDefault: false }, + { id: 'ptc', isDefault: false }, { id: 'minimal', isDefault: false }, + { id: 'private-id', name: 'Private name', isDefault: false }, +] + +/** + * Mount the picker with a recording analytics service. Without `bound` the chip + * is the unbound new-session seat; otherwise it binds Session `blank`, which is + * the main view's current Session unless `current` is false. + */ +async function analyticsBench( + options: Parameters[0] = {}, + bound?: { preset?: string; blank: boolean; current?: boolean }, +) { + const b = await bench({ presets: ANALYTICS_ROSTER, ...options }) + declareRoot(b.slots) + declareConversation(b.slots) + b.ctx.provide('conversation', {} as never) + const sessions = sessionsDouble(b.ctx, bound === undefined ? { byId: {} } : { + ...bound.current === false ? {} : { current: 'blank' }, + byId: { blank: { + id: 'blank', blank: bound.blank, + projectionValues: bound.preset === undefined ? {} : { agentPreset: bound.preset }, + } }, + }) + b.ctx.provide('sessions', sessions as never) + b.ctx.provide('uiWorkspace', uiWorkspaceDouble() as never) + const track = vi.fn() + b.ctx.provide('productAnalytics', { enabled: true, track } as never) + await b.ctx.plugin({ inject: [...inject], apply }).await() + const seat = (b.slots.entries('conversation.hero.agentPreset')[0]!.inject as + (sessionId?: SessionId) => AgentPresetSeatInjected & Record)( + bound === undefined ? undefined : SessionId('blank')) + await seat.load() + return { ...b, seat, sessions, track } +} + +describe('agent preset switch analytics', () => { + it('reports unbound picks staged for the next Session, except equal modes and custom targets', async () => { + const b = await analyticsBench() + try { + await expect(b.seat.select('cordis')).resolves.toBeUndefined() + expect(b.track).toHaveBeenCalledExactlyOnceWith('agent_preset_switch', { + switch_from: 'standard', switch_to: 'creator', code_work_view_enabled: true, + }) + await b.seat.select('cordis') + expect(b.track).toHaveBeenCalledOnce() + await b.seat.select('private-id') + expect(b.track).toHaveBeenCalledOnce() + await b.seat.select('ptc') + expect(b.track).toHaveBeenLastCalledWith('agent_preset_switch', { + switch_from: 'other', switch_to: 'ptc', code_work_view_enabled: true, + }) + expect(b.track).toHaveBeenCalledTimes(2) + expect(b.calls.filter(call => call.startsWith('select:'))).toEqual([]) + expect(JSON.stringify(b.track.mock.calls)).not.toContain('private-id') + expect(JSON.stringify(b.track.mock.calls)).not.toContain('Private name') + } finally { await b.ctx.fiber.dispose() } + }) + + it('reports a bound pick once the Host applies it, and ignores picks made while it is pending', async () => { + const gate = Promise.withResolvers() + const b = await analyticsBench({ selectGate: gate.promise }, { preset: 'standard', blank: true }) + try { + const selecting = b.seat.select('minimal') + await expect(b.seat.select('cordis')).resolves.toBeUndefined() + expect(b.track).not.toHaveBeenCalled() + gate.resolve(undefined) + await expect(selecting).resolves.toBeUndefined() + expect(b.calls.filter(call => call.startsWith('select:'))).toEqual(['select:minimal']) + expect(b.track).toHaveBeenCalledExactlyOnceWith('agent_preset_switch', { + switch_from: 'standard', switch_to: 'minimal', code_work_view_enabled: true, + }) + } finally { gate.resolve(undefined); await b.ctx.fiber.dispose() } + }) + + it('does not report a bound pick that only waits for its Session to become current', async () => { + const b = await analyticsBench({}, { preset: 'standard', blank: true, current: false }) + try { + await b.seat.select('cordis') + expect(b.seat.hooks.agentPresetSeat.getSnapshot().current).toBe('cordis') + expect(b.calls.filter(call => call.startsWith('select:'))).toEqual([]) + expect(b.track).not.toHaveBeenCalled() + } finally { await b.ctx.fiber.dispose() } + }) + + it.each([ + { name: 'a started Session', options: {}, bound: { preset: 'standard', blank: false } }, + { name: 'a Host result other than the pick', options: { selectResult: 'standard' }, bound: { preset: 'standard', blank: true } }, + { name: 'an unknown source mode', options: {}, bound: { blank: true } }, + ])('does not report $name', async ({ options, bound }) => { + const b = await analyticsBench(options, bound) + try { + await expect(b.seat.select('cordis')).resolves.toBeUndefined() + expect(b.track).not.toHaveBeenCalled() + } finally { await b.ctx.fiber.dispose() } + }) + + it.each(['refused', 'thrown'] as const)('returns the reason of a %s pick without reporting it', async (failure) => { + const gate = Promise.withResolvers() + const b = await analyticsBench( + { selectGate: gate.promise, selectRefused: failure === 'refused' }, + { preset: 'standard', blank: true }, + ) + try { + const selecting = b.seat.select('cordis') + if (failure === 'thrown') gate.reject(new Error('connection lost')) + else gate.resolve(undefined) + await expect(selecting).resolves.toBe(failure === 'thrown' ? 'connection lost' : 'selection refused') + expect(b.track).not.toHaveBeenCalled() + } finally { gate.resolve(undefined); await b.ctx.fiber.dispose() } + }) + + it('records the Coding Tools setting known at the pick and reports nothing while it is unknown', async () => { + const described = Promise.withResolvers() + const b = await analyticsBench({ describeGate: described.promise }) + try { + expect(b.ctx.configForms.get('ui-settings').getSnapshot().value).toBeUndefined() + await b.seat.select('cordis') + expect(b.track).not.toHaveBeenCalled() + described.resolve(undefined) + await vi.waitFor(() => { expect(b.ctx.configForms.get('ui-settings').getSnapshot().status).toBe('ready') }) + expect(b.track).not.toHaveBeenCalled() + // Turning Coding Tools off clears the stage, so the chip falls back to the default. + await b.setDeveloperTools(false) + expect(b.seat.hooks.agentPresetSeat.getSnapshot().current).toBe('standard') + await b.seat.select('cordis') + expect(b.track).toHaveBeenCalledExactlyOnceWith('agent_preset_switch', { + switch_from: 'standard', switch_to: 'creator', code_work_view_enabled: false, + }) + } finally { described.resolve(undefined); await b.ctx.fiber.dispose() } + }) + + it('does not report a default change or its blank-Session synchronization', async () => { + const b = await analyticsBench({}, { preset: 'standard', blank: true }) + try { + const section = (b.slots.entries('settings.section')[0]!.inject as + () => AgentPresetSectionInjected & Record)() + await section.load() + await section.makeDefault('minimal') + expect(b.calls).toContain('select:minimal') + expect(b.track).not.toHaveBeenCalled() + } finally { await b.ctx.fiber.dispose() } + }) +}) + describe('AgentPresetSeatController reconciliation', () => { it.each([ { refuseFirst: false, refuseLatest: false }, @@ -1379,7 +1533,7 @@ describe('AgentPresetSeatController reconciliation', () => { // The surface reporting this names the preset itself, so carrying the // roster's own "preset X failed to mount" frame would say it twice. - expect(await controller.select('broken')).toBe(reason) + expect(await controller.select('broken')).toEqual({ kind: 'refused', reason }) const first = controller.store.getSnapshot().error expect(first).toEqual({ preset: { id: 'broken' }, reason }) await controller.select('broken') @@ -1394,3 +1548,88 @@ describe('AgentPresetSeatController reconciliation', () => { expect(controller.store.getSnapshot().error).toBe(reason) }) }) + +describe('AgentPresetSeatController apply outcomes', () => { + type SessionView = { id: SessionId; blank: boolean; projectionValues?: { agentPreset?: string } } + type SelectReply = { ok: true; value: string } | { ok: false; error: RemoteError } + const blankStandard: SessionView = { id: SessionId('blank'), blank: true, projectionValues: { agentPreset: 'standard' } } + + function seatOver(current: () => SessionView | undefined, reply?: (preset: string) => Promise) { + const select = vi.fn((_id: SessionId, preset: string) => reply?.(preset) ?? Promise.resolve({ ok: true as const, value: preset })) + const controller = new AgentPresetSeatController({ ...developerTools(), remote: { agentPresets: { select } } } as never, current) + return { controller, select } + } + + it('stages a pick while no Session is current', async () => { + const { controller, select } = seatOver(() => undefined) + await expect(controller.select('minimal')).resolves.toEqual({ kind: 'staged' }) + expect(controller.store.getSnapshot().current).toBe('minimal') + expect(select).not.toHaveBeenCalled() + }) + + it('reports a display synchronization without a stage as unchanged', async () => { + const { controller, select } = seatOver(() => blankStandard) + await expect(controller.apply()).resolves.toEqual({ kind: 'unchanged' }) + expect(controller.store.getSnapshot().current).toBe('standard') + expect(select).not.toHaveBeenCalled() + }) + + it.each([ + { name: 'a started Session', session: { ...blankStandard, blank: false }, pick: 'minimal' }, + { name: 'a Session already using the pick', session: blankStandard, pick: 'standard' }, + ])('leaves $name unchanged and drops the stage', async ({ session, pick }) => { + let current: SessionView = session + const { controller, select } = seatOver(() => current) + await expect(controller.select(pick)).resolves.toEqual({ kind: 'unchanged' }) + current = { ...blankStandard, id: SessionId('next') } + await expect(controller.apply()).resolves.toEqual({ kind: 'unchanged' }) + expect(select).not.toHaveBeenCalled() + }) + + it('is applied only when the Host commits the staged preset', async () => { + const { controller, select } = seatOver(() => blankStandard) + await expect(controller.select('minimal')).resolves.toEqual({ kind: 'applied' }) + expect(select).toHaveBeenCalledExactlyOnceWith(SessionId('blank'), 'minimal') + expect(controller.store.getSnapshot()).toMatchObject({ current: 'minimal', busy: false, error: null }) + }) + + it('is unchanged when the Host settles on another preset', async () => { + const { controller } = seatOver(() => blankStandard, () => Promise.resolve({ ok: true, value: 'standard' })) + await expect(controller.select('minimal')).resolves.toEqual({ kind: 'unchanged' }) + expect(controller.store.getSnapshot()).toMatchObject({ current: 'standard', busy: false, error: null }) + }) + + it('leaves picks and applies made while a selection is pending unchanged', async () => { + const reply = Promise.withResolvers() + const { controller, select } = seatOver(() => blankStandard, () => reply.promise) + const first = controller.select('minimal') + await expect(controller.select('cordis')).resolves.toEqual({ kind: 'unchanged' }) + await expect(controller.apply()).resolves.toEqual({ kind: 'unchanged' }) + expect(controller.store.getSnapshot()).toMatchObject({ current: 'minimal', busy: true }) + reply.resolve({ ok: true, value: 'minimal' }) + await expect(first).resolves.toEqual({ kind: 'applied' }) + expect(select).toHaveBeenCalledOnce() + }) + + it.each([ + { + name: 'a Host refusal with a cause', + reply: () => Promise.resolve({ ok: false as const, error: new RemoteError( + 'agent-preset/invalid', 'agent-presets: preset "minimal" failed to mount: gone', { agentPreset: 'minimal', reason: 'gone' }, + ) }), + reason: 'gone', + }, + { + name: 'a Host refusal without a cause', + reply: () => Promise.resolve({ ok: false as const, error: new RemoteError('gateway/internal', 'selection refused', {}) }), + reason: 'selection refused', + }, + { name: 'a transport exception', reply: () => Promise.reject(new Error('connection lost')), reason: 'connection lost' }, + ])('is refused with the bare reason of $name', async ({ reply, reason }) => { + const { controller } = seatOver(() => blankStandard, reply) + await expect(controller.select('minimal')).resolves.toEqual({ kind: 'refused', reason }) + expect(controller.store.getSnapshot()).toMatchObject({ + current: 'standard', busy: false, error: { preset: { id: 'minimal' }, reason }, + }) + }) +}) diff --git a/packages/client/ui-agent-preset/tests/locales.client.spec.ts b/packages/client/ui-agent-preset/tests/locales.client.spec.ts index 8c643accfc..1a2fb3412a 100644 --- a/packages/client/ui-agent-preset/tests/locales.client.spec.ts +++ b/packages/client/ui-agent-preset/tests/locales.client.spec.ts @@ -1,7 +1,7 @@ /** Web-localized copy for the four shipped presets and declared copy for every other row. */ import { describe, expect, it } from 'vitest' -import { en, presetDisplayText, zh } from '../src/client/locales.ts' +import { en, ja, presetDisplayText, zh } from '../src/client/locales.ts' const translate = (bundle: typeof en) => (key: keyof typeof en): string => bundle[key] @@ -11,13 +11,15 @@ describe('preset display copy', () => { ['ptc', 'presetPtcName', 'presetPtcDescription'], ['minimal', 'presetMinimalName', 'presetMinimalDescription'], ['cordis', 'presetCordisName', 'presetCordisDescription'], - ] as const)('localizes the shipped %s preset in English and Chinese', (id, nameKey, descriptionKey) => { + ] as const)('localizes the shipped %s preset in English, Chinese, and Japanese', (id, nameKey, descriptionKey) => { const preset = { id } expect(presetDisplayText(preset, translate(en))) .toEqual({ name: en[nameKey], description: en[descriptionKey] }) expect(presetDisplayText(preset, translate(zh))) .toEqual({ name: zh[nameKey], description: zh[descriptionKey] }) + expect(presetDisplayText(preset, translate(ja))) + .toEqual({ name: ja[nameKey], description: ja[descriptionKey] }) }) it('keeps declared metadata untranslated for named and unknown presets', () => { diff --git a/packages/client/ui-agent-preset/tsconfig.json b/packages/client/ui-agent-preset/tsconfig.json index 564a0b82d9..6857e811d7 100644 --- a/packages/client/ui-agent-preset/tsconfig.json +++ b/packages/client/ui-agent-preset/tsconfig.json @@ -8,6 +8,9 @@ "src" ], "references": [ + { + "path": "../product-analytics/tsconfig.client.json" + }, { "path": "../locale/tsconfig.client.json" }, diff --git a/packages/client/ui-approval/README.i18n.yaml b/packages/client/ui-approval/README.i18n.yaml index 1b646cd6a8..ab94e8e499 100644 --- a/packages/client/ui-approval/README.i18n.yaml +++ b/packages/client/ui-approval/README.i18n.yaml @@ -12,8 +12,8 @@ en: 8d6c7c6e63f849f6 zh: d89fa273e30b99d3 /deepseek-ai-dsh-client-ui-approval/table-of-contents: - en: 2bfa677a50bd924c - zh: 2f4b55ab42747bea + en: 7b5184a35d7607f9 + zh: 55ebfea4d5ad26ed /deepseek-ai-dsh-client-ui-approval/model-experience: en: 8161dce49e9f899c zh: a17401e1e78ba6be @@ -21,8 +21,8 @@ en: adf7112e0691b89e zh: 14eeddcff985119f /deepseek-ai-dsh-client-ui-approval/known-limitations-and-deferred-work: - en: ae644102ed52f7ee - zh: e109de1d517a3a94 + en: 23588a4cd32f363c + zh: 19402f024f0f926a /deepseek-ai-dsh-client-ui-approval/known-limitations-and-deferred-work/dev-note: en: e67f633dc6fe7773 zh: e88aeb617ffb74ea diff --git a/packages/client/ui-approval/README.md b/packages/client/ui-approval/README.md index ca21af33fb..890c1e8035 100644 --- a/packages/client/ui-approval/README.md +++ b/packages/client/ui-approval/README.md @@ -20,6 +20,8 @@ Browser approval presentation over the Agent-scoped Remote Event waterfall. The Focus the approval detail region to approve with Enter or reject with Escape. The mounted plugin reserves both keys against editable shortcuts. Enter on the focused Reject button retains its native reject action. Input controls and IME candidates keep their own keys. Keyboard and pointer actions share one pending-request lock; a withdrawn or replaced request cannot accept another answer, and an earlier failed answer cannot unlock its replacement. +When offered, **Always allow** is the highlighted primary button. Otherwise, the current-request allow action is primary. Enter in the detail region always grants the limited scope: once for a tool approval, or the current session for CUA. Persistent access requires explicitly activating Always allow. The shortcut reference uses Allow because the caller supplies that scope. + ## Model Experience @@ -33,7 +35,7 @@ None; approval request and response rendering does not alter a model request. -- **The panel exposes transient decisions only** — it supports allow-once and reject; persistent permission policy remains owned by Host-side approval packages. Requester-supplied localized presentation copy follows the UI language without changing the audit reason or translating model-generated text. +- The shared `conversation.approval.card` Factory presents reject, allow and an optional always-allow action, with caller-owned scope, subject metadata and answer callbacks. Tool approvals retain their two outcomes. Application consent can reuse the card without adopting tool-approval policy. Subject metadata enables left-aligned content and wrapping actions; callers that omit it retain inherited alignment and a single action row. Icons and labels identify the subject visually; the caller owns identity verification and saved grants. diff --git a/packages/client/ui-approval/README.zh.md b/packages/client/ui-approval/README.zh.md index a9a714ef68..76ec67b275 100644 --- a/packages/client/ui-approval/README.zh.md +++ b/packages/client/ui-approval/README.zh.md @@ -20,6 +20,8 @@ kind: "package-reference" 聚焦审批详情区域后,Enter 批准,Esc 拒绝。插件挂载期间,这两个按键不能分配给可编辑快捷键。聚焦“拒绝”按钮后,Enter 保留按钮原生拒绝操作。输入控件与输入法候选保留各自的按键。键盘和指针操作共用同一待处理请求锁;已撤销或替换的请求不能再次作答,较早请求的失败也不会解锁替代请求。 +提供“始终允许”时,将它显示为高亮主按钮;否则高亮当前请求的允许按钮。在详情区域按 Enter 始终授予有限范围的许可:工具审批为单次,CUA 为当前会话。持久授权需要显式操作“始终允许”按钮。快捷键说明使用“允许”,具体范围由调用方提供。 + ## 模型体验 @@ -33,7 +35,7 @@ kind: "package-reference" -- **面板只提供临时决定**——它支持仅本次允许和拒绝;持久权限策略仍由 Host 侧审批包拥有。请求方提供的本地化展示文案跟随界面语言,不改写审计原因,也不翻译模型生成的文本。 +- 共用的 `conversation.approval.card` Factory 展示拒绝、允许和可选的始终允许操作,授权范围、对象信息和回答回调由调用方提供。工具审批保留原来的两种选择。应用授权可以复用卡片,同时保留独立的授权规则。传入对象信息时,内容左对齐,按钮允许换行;未传入时沿用继承的对齐方式和单排按钮。图标和名称用于识别对象,身份校验和授权保存由调用方负责。 diff --git a/packages/client/ui-approval/locale/ja.json b/packages/client/ui-approval/locale/ja.json new file mode 100644 index 0000000000..5ef04937bc --- /dev/null +++ b/packages/client/ui-approval/locale/ja.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "操作承認 UI", + "description": "承認待ちの操作を表示し、ユーザーの選択を実行中のタスクに返します。" + } +} diff --git a/packages/client/ui-approval/src/client/ApprovalCard.tsx b/packages/client/ui-approval/src/client/ApprovalCard.tsx new file mode 100644 index 0000000000..50d1b4cffb --- /dev/null +++ b/packages/client/ui-approval/src/client/ApprovalCard.tsx @@ -0,0 +1,89 @@ +/** Shared approval presentation; each caller retains its decision policy and answer channel. */ +import { useEffect, useRef, useState, type KeyboardEvent } from 'react' +import { Button, StateDot } from '@deepseek-ai/dsh-client-ui-primitives' +import type { FactoryComponentPropsOf } from '@deepseek-ai/dsh-client-ui-slots' +import type {} from './contract/slots.ts' +import css from './ApprovalPanel.module.css' + +/** + * Render one decision with caller-owned actions and an optional persistent choice. + * @param props Request identity, copy, actions and correlated detail. + * @returns The shared approval card, reset for each request. + */ +export function ApprovalCard(props: FactoryComponentPropsOf<'conversation.approval.card'>) { + return +} + +function ApprovalFlow(props: FactoryComponentPropsOf<'conversation.approval.card'>) { + const { requestKey, title, description, subject, allowLabel, canAnswer, onAllow, onReject, onAlways, t } = props + const detail = props.callId === undefined ? null : props.renderSlot('conversation.approval.detail', { callId: props.callId }) + const [answered, setAnswered] = useState(false) + const waiting = useRef(false) + const active = useRef(true) + const composing = useRef(false) + const compositionEnded = useRef(false) + useEffect(() => { + active.current = true + return () => { active.current = false } + }, []) + const answer = (send: () => Promise): void => { + if (waiting.current || !canAnswer()) return + waiting.current = true + setAnswered(true) + void send().catch(() => { + if (!active.current || !canAnswer()) return + waiting.current = false + setAnswered(false) + }) + } + const keydown = (event: KeyboardEvent): void => { + const element = event.target as Element + if (event.defaultPrevented || !event.currentTarget.contains(document.activeElement) + || element.closest('input, textarea, select, [contenteditable="true"], [contenteditable=""]') !== null) return + if (event.key !== 'Enter' && event.key !== 'Escape') return + if (event.key === 'Enter' && element.closest('button, a[href], [role="button"]') !== null) return + if (event.ctrlKey || event.metaKey || event.altKey || event.shiftKey) return + event.preventDefault() + event.stopPropagation() + // oxlint-disable-next-line typescript/no-deprecated -- IME 229 covers engines without isComposing. + if (event.repeat || composing.current || compositionEnded.current || event.nativeEvent.isComposing || event.keyCode === 229) return + answer(event.key === 'Enter' ? onAllow : onReject) + } + return ( +
{ compositionEnded.current = false }} + onCompositionStartCapture={() => { composing.current = true }} + onCompositionEndCapture={() => { composing.current = false; compositionEnded.current = true }}> +
+
{t('waiting')}
+
+ {subject !== undefined &&
+ {subject.icon !== undefined && } + {subject.name} +
} +
{title}
+ {description !== undefined &&

{description}

} + {detail !== null &&
{detail}
} +
+
+ + + {onAlways !== undefined && } +
+
+
+ ) +} diff --git a/packages/client/ui-approval/src/client/ApprovalPanel.module.css b/packages/client/ui-approval/src/client/ApprovalPanel.module.css index 879eec2cc2..dd47ff045e 100644 --- a/packages/client/ui-approval/src/client/ApprovalPanel.module.css +++ b/packages/client/ui-approval/src/client/ApprovalPanel.module.css @@ -67,3 +67,26 @@ --dsh-button-outline-color: transparent; } } + +.subject { + display: flex; + align-items: center; + gap: 12px; + color: var(--dsw-alias-label-primary); + font-size: 16px; +} + +.subject img { + flex-shrink: 0; + object-fit: contain; +} + +.description { + margin: 0; + color: var(--dsw-alias-label-secondary); + font-size: 13px; + line-height: 20px; +} + +.withSubject .body { text-align: left; } +.withSubject .actionRow { flex-wrap: wrap; } diff --git a/packages/client/ui-approval/src/client/ApprovalPanel.tsx b/packages/client/ui-approval/src/client/ApprovalPanel.tsx index bdfcba3f02..b77a757bab 100644 --- a/packages/client/ui-approval/src/client/ApprovalPanel.tsx +++ b/packages/client/ui-approval/src/client/ApprovalPanel.tsx @@ -1,88 +1,20 @@ -/** Composer takeover for one pending approval waterfall. */ -import { useEffect, useRef, useState, type KeyboardEvent, type ReactNode } from 'react' -import { Button, StateDot } from '@deepseek-ai/dsh-client-ui-primitives' -import type { ApprovalComposerProps, PendingApproval } from './contract/slots.ts' -import css from './ApprovalPanel.module.css' +/** Composer adapter for one pending tool-approval waterfall. */ +import type { ApprovalComposerProps } from './contract/slots.ts' /** - * Render one pending approval and its optional Tool-owned detail. - * @param props - selector-matched request and standard Slot props. + * Render the shared card using the tool approval's one-shot decision policy. + * @param props Selector-matched request and standard Slot props. * @returns The approval composer takeover. */ export function ApprovalPanel(props: ApprovalComposerProps) { const approval = props.matched - const detail = approval.callId === undefined - ? null - : props.renderSlot('conversation.approval.detail', { callId: approval.callId }) const reason = approval.displayReason === undefined ? approval.reason : props.resolveReason(approval.displayReason) - return -} - -function ApprovalFlow({ pending, reason, detail, t }: { - pending: PendingApproval - reason: string | undefined - detail: ReactNode - t: ApprovalComposerProps['t'] -}) { - const [answered, setAnswered] = useState(false) - const waiting = useRef(false) - const active = useRef(true) - const composing = useRef(false) - const compositionEnded = useRef(false) - useEffect(() => { - active.current = true - return () => { active.current = false } - }, []) - const answer = (outcome: 'allowed-once' | 'rejected'): void => { - if (waiting.current || !pending.answerable) return - waiting.current = true - setAnswered(true) - void pending.answer(outcome).catch(() => { - if (!active.current || !pending.answerable) return - waiting.current = false - setAnswered(false) - }) - } - const keydown = (event: KeyboardEvent): void => { - const element = event.target as Element - if (event.defaultPrevented || !event.currentTarget.contains(document.activeElement) - || element.closest('input, textarea, select, [contenteditable="true"], [contenteditable=""]') !== null) return - if (event.key !== 'Enter' && event.key !== 'Escape') return - if (event.key === 'Enter' && element.closest('button, a[href], [role="button"]') !== null) return - if (event.ctrlKey || event.metaKey || event.altKey || event.shiftKey) return - event.preventDefault() - event.stopPropagation() - // oxlint-disable-next-line typescript/no-deprecated -- IME 229 covers engines without isComposing. - if (event.repeat || composing.current || compositionEnded.current || event.nativeEvent.isComposing || event.keyCode === 229) return - answer(event.key === 'Enter' ? 'allowed-once' : 'rejected') - } - return ( -
{ compositionEnded.current = false }} - onCompositionStartCapture={() => { composing.current = true }} - onCompositionEndCapture={() => { composing.current = false; compositionEnded.current = true }}> -
-
{t('waiting')}
-
-
{reason ?? t('escalation', { toolName: pending.toolName })}
- {detail !== null &&
{detail}
} -
-
- - -
-
-
- ) + return props.renderFactorySlot('conversation.approval.card', { + requestKey: approval.key, + title: reason ?? props.t('escalation', { toolName: approval.toolName }), + ...(approval.callId === undefined ? {} : { callId: approval.callId }), + canAnswer: () => approval.answerable, + onAllow: () => approval.answer('allowed-once'), + onReject: () => approval.answer('rejected'), + }) } diff --git a/packages/client/ui-approval/src/client/contract/slots.ts b/packages/client/ui-approval/src/client/contract/slots.ts index 2558b40ea4..2df54a54c4 100644 --- a/packages/client/ui-approval/src/client/contract/slots.ts +++ b/packages/client/ui-approval/src/client/contract/slots.ts @@ -2,7 +2,7 @@ import type { ToolCallId } from '@deepseek-ai/dsh-llm' import type { SessionId } from '@deepseek-ai/dsh-session/types' import type { - PropsLocale, PropsRenderSlots, PropsRuntime, + PropsLocale, PropsRenderFactories, PropsRuntime, } from '@deepseek-ai/dsh-client-ui-slots' import type { ApprovalKey } from '../locales.ts' @@ -32,6 +32,16 @@ declare module '@deepseek-ai/dsh-client-ui-slots' { approval: ApprovalKey } + interface SlotFactoryMap { + /** Shared decision card; the caller owns authorization and persistence. */ + 'conversation.approval.card': { + scope: 'session' + props: ApprovalCardInput + locale: 'approval' + children: { 'conversation.approval.detail': { kind: 'single'; scope: 'session' } } + } + } + interface SlotMap { /** Optional detail for the Tool call correlated with an approval request. */ 'conversation.approval.detail': { @@ -188,7 +198,31 @@ export interface ApprovalInjected { /** Full props of the approval composer takeover. */ export type ApprovalComposerProps = PropsRuntime<'conversation.composer'> - & PropsRenderSlots<'conversation.approval.detail'> + & PropsRenderFactories & { matched: PendingApproval } & PropsLocale<'approval'> & ApprovalInjected + +/** Data and decisions accepted by the shared approval card. */ +export interface ApprovalCardInput { + /** Identity whose change resets the pending submission. */ + requestKey: string + /** Localized question describing the requested operation. */ + title: string + /** Localized scope or consequence of the decision. */ + description?: string + /** Subject metadata for human recognition; the caller admits icon URLs. Never an authorization identity. */ + subject?: { name: string; icon?: string } + /** Optional tool-owned detail rendered through the existing child slot. */ + callId?: ToolCallId + /** Caller-specific scope label; ordinary approvals use Allow once. */ + allowLabel?: string + /** Whether the caller still accepts an answer. */ + canAnswer: () => boolean + /** Grant this request with the caller's stated scope. */ + onAllow: () => Promise + /** Refuse this request. */ + onReject: () => Promise + /** Explicit persistent choice; absent when the caller cannot save permission. */ + onAlways?: () => Promise +} diff --git a/packages/client/ui-approval/src/client/index.ts b/packages/client/ui-approval/src/client/index.ts index ca6d674e36..a667d507e9 100644 --- a/packages/client/ui-approval/src/client/index.ts +++ b/packages/client/ui-approval/src/client/index.ts @@ -8,11 +8,13 @@ import type { PendingInteractionPublisher } from '@deepseek-ai/dsh-client-ui-ses import type { TypertClientEventListener } from '@deepseek-ai/dsh-typert-protocol' import type {} from '@deepseek-ai/dsh-client-locale/client' import type { ShortcutCommandId } from '@deepseek-ai/dsh-client-shortcuts/client' +import { ApprovalCard } from './ApprovalCard.tsx' import { ApprovalPanel } from './ApprovalPanel.tsx' import { PendingApproval } from './contract/slots.ts' -import { en, zh } from './locales.ts' +import { en, ja, zh } from './locales.ts' export type { + ApprovalCardInput, ApprovalComposerProps, ApprovalDecision, ApprovalDetailOwnerProps, @@ -75,11 +77,11 @@ async function answerApproval( * @param ctx - Client root context. */ export function apply(ctx: ClientContext): void { - ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'ui-approval: dictionaries') + ctx.effect(() => ctx.locale.register(NS, { zh, en, ja }), 'ui-approval: dictionaries') ctx.inject(['shortcuts'], (scope) => { const t = ctx.locale.bind(NS) scope.effect(() => scope.shortcuts.registerFixed({ - id: 'approval.allow' as ShortcutCommandId, label: () => t('allowOnce'), keys: ['Enter'], bindings: [{ code: 'Enter', modifiers: [] }], group: 'approval', + id: 'approval.allow' as ShortcutCommandId, label: () => t('allow'), keys: ['Enter'], bindings: [{ code: 'Enter', modifiers: [] }], group: 'approval', }), 'ui-approval: fixed allow reference') scope.effect(() => scope.shortcuts.registerFixed({ id: 'approval.reject' as ShortcutCommandId, label: () => t('reject'), keys: ['Esc'], bindings: [{ code: 'Escape', modifiers: [] }], group: 'approval', @@ -88,6 +90,10 @@ export function apply(ctx: ClientContext): void { const registerPendingInteraction = ctx.uiSession.registerPendingInteraction( () => 0, ) + ctx.slots.registerFactory({ + name: 'conversation.approval.card', scope: 'session', locale: NS, + children: { 'conversation.approval.detail': { kind: 'single', scope: 'session' } }, + }, ApprovalCard) ctx.slots.inject('conversation.composer', () => ctx.slots.register({ name: 'conversation.composer', priority: 1, @@ -97,9 +103,6 @@ export function apply(ctx: ClientContext): void { inject: () => ({ resolveReason: (reason: NonNullable) => ctx.locale.resolveText(reason), }), - children: { - 'conversation.approval.detail': { kind: 'single', scope: 'session' }, - }, }, ApprovalPanel)) ctx.remote.$on('approval/request', function (request, next) { return answerApproval(ctx, this, request, next, registerPendingInteraction) diff --git a/packages/client/ui-approval/src/client/locales.ts b/packages/client/ui-approval/src/client/locales.ts index e28f2ce74a..c8e540fd49 100644 --- a/packages/client/ui-approval/src/client/locales.ts +++ b/packages/client/ui-approval/src/client/locales.ts @@ -6,7 +6,9 @@ export const zh = { 'detail.aria': '审批详情', escalation: '工具 {toolName} 请求越权执行', reject: '拒绝', + allow: '允许', allowOnce: '允许一次', + allowAlways: '始终允许', } satisfies Record /** Approval dictionary key union. */ @@ -18,5 +20,18 @@ export const en = { 'detail.aria': 'Approval details', escalation: 'Tool {toolName} requests privileged execution', reject: 'Reject', + allow: 'Allow', allowOnce: 'Allow once', + allowAlways: 'Always allow', } satisfies Record + +/** Japanese copy, checked against the English keys. */ +export const ja = { + 'waiting': '承認待ち', + 'detail.aria': '承認の詳細', + 'escalation': 'ツール {toolName} が特権実行を要求しています', + 'reject': '拒否', + 'allow': '許可', + 'allowOnce': '1 回だけ許可', + 'allowAlways': '常に許可', +} satisfies Record diff --git a/packages/client/ui-approval/tests/ui-approval.client.spec.tsx b/packages/client/ui-approval/tests/ui-approval.client.spec.tsx index e9edc316a5..1885d6b047 100644 --- a/packages/client/ui-approval/tests/ui-approval.client.spec.tsx +++ b/packages/client/ui-approval/tests/ui-approval.client.spec.tsx @@ -5,8 +5,10 @@ import type { ToolCallId } from '@deepseek-ai/dsh-llm' import type { SessionId } from '@deepseek-ai/dsh-session/types' import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' import { afterEach, describe, expect, it, vi } from 'vitest' +import { ApprovalCard } from '../src/client/ApprovalCard.tsx' +import type { FactoryComponentPropsOf } from '@deepseek-ai/dsh-client-ui-slots' import { ApprovalPanel } from '../src/client/ApprovalPanel.tsx' -import type { ApprovalComposerProps, ApprovalInjected } from '../src/client/contract/slots.ts' +import type { ApprovalCardInput, ApprovalComposerProps, ApprovalInjected } from '../src/client/contract/slots.ts' import { PendingApproval } from '../src/client/contract/slots.ts' import { apply, inject } from '../src/client/index.ts' import { apply as nodeApply } from '../src/index.ts' @@ -32,6 +34,7 @@ interface PluginBench { readonly disposeLocale: ReturnType readonly register: ReturnType readonly injectSlot: ReturnType + readonly shortcuts: ReturnType string }) => () => void>> releasePending(): Promise registration(): { options: { @@ -82,8 +85,11 @@ async function setupPlugin(): Promise { } as never) ctx.provide('sessions', { scopeOf } as never) ctx.provide('uiSession', { registerPendingInteraction } as never) - ctx.provide('slots', { inject: injectSlot, register } as never) + ctx.provide('slots', { inject: injectSlot, register, registerFactory: vi.fn() } as never) + const shortcuts = vi.fn((entry: { label: () => string }) => { entry.label(); return () => {} }) + ctx.provide('shortcuts', { registerFixed: shortcuts } as never) ctx.provide('locale', { + bind: () => (key: string) => key, register: vi.fn(() => disposeLocale), resolveText: (reason: NonNullable) => reason.en, } as never) @@ -100,6 +106,7 @@ async function setupPlugin(): Promise { disposeLocale, register, injectSlot, + shortcuts, async releasePending() { const delegates = [...pending.values()] pending.clear() @@ -211,6 +218,7 @@ describe('approval Remote Event consumer', () => { expect(next).toHaveBeenCalledOnce() expect(bench.pending.getSnapshot()).toEqual([]) expect(bench.register).toHaveBeenCalledOnce() + expect(bench.shortcuts.mock.calls.map(([entry]) => entry.label())).toEqual(['allow', 'reject']) }) it('publishes one scoped takeover, returns the answer, and keeps stable registrations', async () => { @@ -314,7 +322,7 @@ describe('approval Remote Event consumer', () => { function panelProps( pending: PendingApproval, - renderSlot: ApprovalComposerProps['renderSlot'] = vi.fn(() => null), + renderSlot: FactoryComponentPropsOf<'conversation.approval.card'>['renderSlot'] = vi.fn(() => null), ): ApprovalComposerProps { const messages: Record = { waiting: 'Waiting', @@ -322,10 +330,12 @@ function panelProps( escalation: `Tool ${pending.toolName} asks`, reject: 'Reject', allowOnce: 'Allow once', + allowAlways: 'Always allow', } return { matched: pending, - renderSlot, + renderFactorySlot: (_name: string, input: ApprovalCardInput) => messages[key] ?? key } as FactoryComponentPropsOf<'conversation.approval.card'>} />, resolveReason: (reason: NonNullable) => reason.en, t: (key: string) => messages[key] ?? key, } as ApprovalComposerProps @@ -340,7 +350,7 @@ describe('ApprovalPanel', () => { expect(screen.getByText('Tool bash asks')).toBeTruthy() expect(document.querySelector('[data-approval-key] [data-state="warning"]')).not.toBeNull() expect(screen.getByRole('group', { name: 'Approval details' })).toBeTruthy() - expect(props.renderSlot).not.toHaveBeenCalled() + expect(screen.queryByText('pnpm test')).toBeNull() fireEvent.click(screen.getByRole('button', { name: 'Reject' })) expect(document.querySelector('[data-approval-key]')?.getAttribute('aria-busy')).toBe('true') @@ -503,3 +513,31 @@ describe('package entries', () => { expect(() => { nodeApply() }).not.toThrow() }) }) + +it.each(['allow', 'reject', 'always', 'keyboard'] as const)('settles a fixed application decision through %s without free text', async (choice) => { + const onAllow = vi.fn(async () => {}), onReject = vi.fn(async () => {}), onAlways = vi.fn(async () => {}) + const actions = { allow: onAllow, reject: onReject, always: onAlways } + render( true, onAllow, onReject, onAlways, renderSlot: vi.fn(() => null), + t: (key: string) => ({ reject: 'Reject', allowAlways: 'Always allow' })[key] ?? key, + } as FactoryComponentPropsOf<'conversation.approval.card'>} />) + expect(screen.getByText('Calculator')).toBeTruthy() + if (choice === 'keyboard') expect(document.querySelector('img')).toBeNull() + else expect(document.querySelector('img')?.getAttribute('src')).toBe('data:image/png;base64,aWNvbg==') + expect(screen.queryByRole('textbox')).toBeNull() + const action = choice === 'keyboard' ? 'allow' : choice + if (choice === 'keyboard') { + const detail = screen.getByRole('group') + detail.focus() + fireEvent.keyDown(detail, { key: 'Enter', code: 'Enter' }) + fireEvent.keyDown(detail, { key: 'Enter', code: 'Enter' }) + } else { + const label = { allow: 'Allow for this task', reject: 'Reject', always: 'Always allow' }[choice] + fireEvent.click(screen.getByRole('button', { name: label })) + fireEvent.click(screen.getByRole('button', { name: label })) + } + await waitFor(() => { expect(actions[action]).toHaveBeenCalledOnce() }) + expect(Object.values(actions).filter(callback => callback.mock.calls.length > 0)).toEqual([actions[action]]) +}) diff --git a/packages/client/ui-attachment/README.i18n.yaml b/packages/client/ui-attachment/README.i18n.yaml index 123b686b4e..65b551434d 100644 --- a/packages/client/ui-attachment/README.i18n.yaml +++ b/packages/client/ui-attachment/README.i18n.yaml @@ -15,17 +15,17 @@ en: d152484eb41ac6b4 zh: 09388d293f9be9cb /deepseek-ai-dsh-client-ui-attachment/use-this-package: - en: 15cdd9d52dcc71b8 - zh: 53933fbbf5d39100 + en: df47b6abd789dcb5 + zh: 7e17c7f18631a572 /deepseek-ai-dsh-client-ui-attachment/use-this-package/draft-attachments: - en: a77c8ec48e219aec - zh: 756d4fe881403006 + en: 8246cb3c470e3fc5 + zh: 7028cf466a832b99 /deepseek-ai-dsh-client-ui-attachment/use-this-package/message-images-and-the-lightbox: - en: 8b3d7729152d8060 - zh: 815f932ccc9653f5 + en: 38717dbf90a2520d + zh: b81915d2b06e18f8 /deepseek-ai-dsh-client-ui-attachment/use-this-package/drop-overlay: - en: 836a62c8f73366ac - zh: 35da7836eaf68a67 + en: bf1a8ebd1a550396 + zh: a4d736010ab6602e /deepseek-ai-dsh-client-ui-attachment/understand-the-implementation: en: 07204b7d538b7434 zh: 705cc6b0c9387cce diff --git a/packages/client/ui-attachment/README.md b/packages/client/ui-attachment/README.md index 4fee29e123..29e7f3aba0 100644 --- a/packages/client/ui-attachment/README.md +++ b/packages/client/ui-attachment/README.md @@ -25,11 +25,13 @@ This package renders everything the conversation UI shows about attachments: one ## Use this package +Image drag actions and the composer’s original-file reader arrive through slot injection from [ui-file-drag](../ui-file-drag/README.md). The image gallery Factory also receives these actions through its declared inject face; custom Factory replacements must supply that face. + Mount this plugin alongside [`ui-conversation`](../ui-conversation/README.md) and [`ui-tool`](../ui-tool/README.md) when tool results need an image gallery. It waits for their slot declarations and registers its components into them. Users then see the mixed draft-attachment rail, DeepSeek Web file cards with upload controls, the drop overlay with its limits line, message images sized by count, the tool card's gallery, and the Escape/mask/close lightbox. ### Draft attachments -Images and generic files retain pick order in one non-wrapping horizontal rail. Every item is 64px high: an image is a 64px square thumbnail, while a generic file is a 240px-wide DeepSeek Web card with a 16px radius, blue gradient document glyph, filename, and uppercase extension plus byte size. Edge arrows page hidden overflow, the scrollbar stays hidden, and a newly added item is revealed at the rail's end. Uploading replaces a file glyph with a spinner and shows byte progress when the carrier reports it, with an indeterminate bar before the first report; failure shows retry, and removal controls appear on hover or keyboard focus while remaining visible on touch devices. Clicking an image opens the original. +Images and generic files retain pick order in one non-wrapping horizontal rail. Every item is 64px high: an image is a 64px square thumbnail, while a generic file is a 240px-wide DeepSeek Web card with a 16px radius, blue gradient document glyph, filename, and uppercase extension plus byte size. Edge arrows page hidden overflow, the scrollbar stays hidden, and a newly added item is revealed at the rail's end. Uploading replaces a file glyph with a spinner and shows byte progress when the carrier reports it, with an indeterminate bar before the first report; failure shows retry, and removal controls appear on hover or keyboard focus while remaining visible on touch devices. An uploading image shows the same progress bar along its thumbnail's bottom edge, and a failed image upload turns the thumbnail into its retry control, except an image the Host refused, whose thumbnail explains the refusal in a tooltip that a tap or click also pins and leaves removal as the way out. Clicking an image opens the original. ### Message images and the lightbox @@ -37,9 +39,13 @@ In Chat, one user message presents files and images in a right-aligned wrapping Trajectory attachment rows request 48px square thumbnails that contain the complete image without cropping. Loading and retry icons keep the same box, with localized tooltips and accessible names; the image opens the same lightbox. A slot owner may supply a presentation-only image label for the thumbnail and lightbox without changing the durable reference or the cache lookup. +Loaded conversation images can be dragged into the composer as image attachments or to another application as original files. Dragging does not open the lightbox. The shared image-transfer owner retains Web drag bytes until the gesture ends; Desktop uses the system file drag. A failed preparation or drag keeps the image visible and reports a localized retry message. + +Internal Web image drags do not show the full-page overlay. A foreign or expired image token without actual files is ignored. Browser transfer limitations are documented by [ui-primitives](../ui-primitives/README.md#known-limitations-and-deferred-work). + ### Drop overlay -While a file drag is over the page, the full-viewport overlay announces the drop: illustration, title, and a limits line when drops are accepted. The overlay only shows state — the owner's document-level listeners decide accept or reject, and the drop handler reports which dropped members are directories through the entry API so the owner can cite or refuse them. +While an external file drag is over the page, the full-viewport overlay announces the drop: illustration, title, and a limits line when drops are accepted. The overlay only shows state — the owner's document-level listeners decide accept or reject, and the drop handler reports which dropped members are directories through the entry API so the owner can cite or refuse them. ----- diff --git a/packages/client/ui-attachment/README.zh.md b/packages/client/ui-attachment/README.zh.md index 706a6d97c7..80db6ae928 100644 --- a/packages/client/ui-attachment/README.zh.md +++ b/packages/client/ui-attachment/README.zh.md @@ -25,11 +25,13 @@ kind: "package-reference" ## 使用本包 +图片拖放回调与输入框的原图读取回调通过 slot 注入从 [ui-file-drag](../ui-file-drag/README.zh.md) 获取。图片图库 Factory 也通过声明的注入接口接收回调;自定义 Factory 替换实现必须提供该接口。 + 与 [`ui-conversation`](../ui-conversation/README.zh.md) 一起挂载本插件,工具结果需要图片图库时也要挂载 [`ui-tool`](../ui-tool/README.zh.md)。插件等待这些 slot 的声明,并把组件注册进去。用户会看到混合草稿附件栏、带上传控件的 DeepSeek Web 文件卡、带限制说明的拖放遮罩、按数量定尺寸的消息图片、工具卡片图库,以及支持 Escape、遮罩和关闭按钮的灯箱。 ### 草稿附件 -图片与通用文件按选择顺序进入同一条不换行的横向附件栏。所有条目均为 64px 高:图片是 64px 方形缩略图,通用文件是 240px 宽的 DeepSeek Web 卡片,带 16px 圆角、蓝色渐变文档图标、文件名,以及大写扩展名与字节大小。溢出隐藏时由边缘箭头翻页,滚动条保持隐藏,新增条目会滚动到栏尾展示。文件上传时图标位置显示 spinner,传输层报告字节时显示进度,首次报告前使用不定态进度条;失败时显示重试。移除按钮在悬停或键盘聚焦时出现,在触摸设备上保持可见。单击图片会打开原图。 +图片与通用文件按选择顺序进入同一条不换行的横向附件栏。所有条目均为 64px 高:图片是 64px 方形缩略图,通用文件是 240px 宽的 DeepSeek Web 卡片,带 16px 圆角、蓝色渐变文档图标、文件名,以及大写扩展名与字节大小。溢出隐藏时由边缘箭头翻页,滚动条保持隐藏,新增条目会滚动到栏尾展示。文件上传时图标位置显示 spinner,传输层报告字节时显示进度,首次报告前使用不定态进度条;失败时显示重试。上传中的图片在缩略图底边显示同样的进度条,上传失败的图片缩略图变成重试控件;Host 拒绝的图片则用缩略图的提示说明原因,点按也能固定该提示,只能移除。移除按钮在悬停或键盘聚焦时出现,在触摸设备上保持可见。单击图片会打开原图。 ### 消息图片与灯箱 @@ -37,9 +39,13 @@ Chat 中的一条用户消息把文件与图片放在同一个靠右、可换行 Trajectory 附件行使用 48px 方形缩略图,完整缩放图片而不裁剪。加载与重试图标保持相同尺寸,提供本地化的工具提示和可访问名称;图片打开同一个灯箱。插槽持有方可以为缩略图与灯箱提供仅用于展示的图片名称,而不改变持久化引用或缓存查询。 +已加载的会话图片可拖入输入框作为图片附件,也可将原文件拖到其他应用。拖动不会打开灯箱。共享图片传递组件将 Web 拖动的原图字节保留到手势结束;Desktop 使用系统文件拖放。准备或拖放失败时保留图片,并显示本地化的重试提示。 + +应用内 Web 图片拖放不显示全屏遮罩。没有实际文件的外来或过期图片令牌会被忽略。浏览器传递限制见 [ui-primitives](../ui-primitives/README.zh.md#known-limitations-and-deferred-work)。 + ### 拖放遮罩 -文件拖到页面上方时,全视口遮罩显示拖放提示,包括插画和标题;接受拖放时还会显示一行限制说明。遮罩只呈现状态,是否接受由持有方的文档级监听器决定,拖放处理器通过 entry API 报告放下的成员中哪些是文件夹,以便持有方引用或拒绝它们。 +外部文件拖到页面上方时,全视口遮罩显示拖放提示,包括插画和标题;接受拖放时还会显示一行限制说明。遮罩只呈现状态,是否接受由持有方的文档级监听器决定,拖放处理器通过 entry API 报告放下的成员中哪些是文件夹,以便持有方引用或拒绝它们。 ----- diff --git a/packages/client/ui-attachment/locale/ja.json b/packages/client/ui-attachment/locale/ja.json new file mode 100644 index 0000000000..424a9374ce --- /dev/null +++ b/packages/client/ui-attachment/locale/ja.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "添付ファイル表示", + "description": "メッセージ入力、会話、実行記録に添付ファイルと画像を表示します。" + } +} diff --git a/packages/client/ui-attachment/package.json b/packages/client/ui-attachment/package.json index 0a525f7c71..13b2018090 100644 --- a/packages/client/ui-attachment/package.json +++ b/packages/client/ui-attachment/package.json @@ -34,7 +34,8 @@ "@deepseek-ai/dsh-client-ui-conversation", "@deepseek-ai/dsh-client-ui-renderer", "@deepseek-ai/dsh-client-ui-trajectory", - "@deepseek-ai/dsh-client-ui-tool" + "@deepseek-ai/dsh-client-ui-tool", + "@deepseek-ai/dsh-client-ui-file-drag" ], "platform": "web" } @@ -57,7 +58,9 @@ "react-dom": "^18.2.0", "@deepseek-ai/dsh-attachment": "workspace:*", "@deepseek-ai/dsh-client-ui-tool": "workspace:*", - "clsx": "^2.0.0" + "clsx": "^2.0.0", + "@deepseek-ai/dsh-client-ui-file-drag": "workspace:*", + "@deepseek-ai/dsh-util-file-drag": "workspace:*" }, "files": [ "locale/*.json", diff --git a/packages/client/ui-attachment/src/MessageImage.tsx b/packages/client/ui-attachment/src/MessageImage.tsx index a067ffd446..4cce54baa5 100644 --- a/packages/client/ui-attachment/src/MessageImage.tsx +++ b/packages/client/ui-attachment/src/MessageImage.tsx @@ -1,7 +1,6 @@ import { useCallback, useEffect, useMemo, useState } from 'react' import type { ImageAttachmentRef } from '@deepseek-ai/dsh-attachment' -import { IconLoadingOutlineRegular, IconRefreshOutlineRegular } from '@deepseek-ai/dsh-client-ui-primitives' -import { ImageLightbox } from '@deepseek-ai/dsh-client-ui-primitives' +import { IconLoadingOutlineRegular, IconRefreshOutlineRegular, DraggableImage, ImageLightbox, type FileDragActions } from '@deepseek-ai/dsh-client-ui-primitives' import type { MessageImageLabels } from '@deepseek-ai/dsh-client-ui-conversation/client' import css from './MessageImage.module.css' @@ -68,10 +67,11 @@ function dimensionsOf(image: MessageImageSpec): { readonly width: number; readon * @param props.labels - resolved strings (tooltip, loading, retry, lightbox). * @returns the bounded thumbnail button, or the retry control on failure. */ -export function MessageImage({ image, load, variant, labels }: { +export function MessageImage({ image, load, variant, labels, fileDrag }: { image: MessageImageSpec load: ImageLoader variant: 'single' | 'tile' | 'thumbnail' + fileDrag: FileDragActions labels: MessageImageLabels }) { const preview = 'preview' in image ? image.preview : undefined @@ -143,7 +143,8 @@ export function MessageImage({ image, load, variant, labels }: { {loadingThumbnail ? : labels.loading} ) - : {label}} + : } {open && src !== null && } @@ -152,12 +153,13 @@ export function MessageImage({ image, load, variant, labels }: { /** Wrapping image group shared by user and assistant history: a lone image * renders large unless its owner requests compact tiles or contained list thumbnails. */ -export function ImageGallery({ images, load, align, compact = false, thumbnail = false, labels }: { +export function ImageGallery({ images, load, align, compact = false, thumbnail = false, labels, fileDrag }: { images: readonly MessageImageSpec[] load: ImageLoader align: 'start' | 'end' compact?: boolean thumbnail?: boolean + fileDrag: FileDragActions labels: MessageImageLabels }) { if (images.length === 0) return null @@ -170,6 +172,7 @@ export function ImageGallery({ images, load, align, compact = false, thumbnail = image={image} load={load} variant={variant} + fileDrag={fileDrag} labels={labels} /> ))} diff --git a/packages/client/ui-attachment/src/client/ComposerAttachments.module.css b/packages/client/ui-attachment/src/client/ComposerAttachments.module.css index adb40af60f..2d2dc878f6 100644 --- a/packages/client/ui-attachment/src/client/ComposerAttachments.module.css +++ b/packages/client/ui-attachment/src/client/ComposerAttachments.module.css @@ -30,6 +30,57 @@ object-fit: cover; } +.thumbnailFailed { + border-color: var(--dsw-alias-state-error-primary, #d54941); + cursor: pointer; +} + +.thumbnailFailed img { + opacity: 0.4; +} + +.thumbnailRefused { + cursor: default; +} + +.failureGlyph { + position: absolute; + inset: 0; + display: grid; + place-items: center; + color: var(--dsw-alias-state-error-primary, #d54941); +} + +.progressTrack { + position: absolute; + right: 8px; + bottom: 6px; + left: 8px; + overflow: hidden; + height: 2px; + border-radius: 1px; + background: var(--dsw-alias-fill-tertiary, rgba(0, 0, 0, 0.08)); + pointer-events: none; +} + +.progressBar { + display: block; + width: 35%; + height: 100%; + border-radius: inherit; + background: var(--dsw-alias-brand-primary, #4d6bfe); + animation: image-upload-progress 1.2s ease-in-out infinite alternate; +} + +.progressBar[style] { + animation: none; +} + +@keyframes image-upload-progress { + from { transform: translateX(-70%); } + to { transform: translateX(220%); } +} + .remove { position: absolute; top: 4px; @@ -70,4 +121,8 @@ .remove { transition: none; } + + .progressBar { + animation: none; + } } diff --git a/packages/client/ui-attachment/src/client/ComposerAttachments.tsx b/packages/client/ui-attachment/src/client/ComposerAttachments.tsx index 0065410b9d..2c57ce0e74 100644 --- a/packages/client/ui-attachment/src/client/ComposerAttachments.tsx +++ b/packages/client/ui-attachment/src/client/ComposerAttachments.tsx @@ -2,7 +2,9 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import type { ComposerAttachment, ComposerAttachmentsProps, ComposerImageAttachment, } from '@deepseek-ai/dsh-client-ui-conversation/client' -import { IconCloseFillRegular } from '@deepseek-ai/dsh-client-ui-primitives' +import { + IconCloseFillRegular, IconRefreshOutlineRegular, IconWarningOutlineRegular, Tooltip, +} from '@deepseek-ai/dsh-client-ui-primitives' import { AttachmentRail } from '../AttachmentRail.tsx' import type { AttachmentRailItem } from '../AttachmentRail.tsx' import { DropOverlay } from '../DropOverlay.tsx' @@ -10,6 +12,7 @@ import { FileCard } from '../FileCard.tsx' import { ImageLightbox } from '@deepseek-ai/dsh-client-ui-primitives' import { attachmentRailLabels, dropOverlayLabels, fileCardLabels, lightboxLabels } from './labels.ts' import { installDocumentDropEvents } from './drop-events.ts' +import type { InjectFace } from '@deepseek-ai/dsh-client-ui-slots' import css from './ComposerAttachments.module.css' /** Rail item retaining its browser-owned attachment for callbacks. */ @@ -17,10 +20,20 @@ interface ComposerRailItem extends AttachmentRailItem { attachment: ComposerAttachment } +/** File transfer lookup injected by this attachment implementation. */ +export interface ComposerAttachmentsInjected { + readonly readFileDrag: (transfer: DataTransfer) => File | undefined +} + +/** The owner share and locale seat the attachment rail reads from its slot props. */ +type ComposerAttachmentsInput = Pick + /** Draft image previews, pending-file cards, drop target, and original-image preview. */ export function ComposerAttachments({ - attachments, canAcceptDrop, onAddFiles, onRemoveAttachment, uploads, onRetryFile, dropLimits, t, -}: ComposerAttachmentsProps) { + attachments, canAcceptDrop, onAddFiles, onRemoveAttachment, uploads, onRetryFile, uploadFailureText, dropLimits, t, readFileDrag, +}: ComposerAttachmentsInput & InjectFace) { const [preview, setPreview] = useState(null) const [dragActive, setDragActive] = useState(false) const dragDepth = useRef(0) @@ -30,8 +43,8 @@ export function ComposerAttachments({ }, [attachments, preview]) useEffect(() => { - return installDocumentDropEvents(canAcceptDrop, onAddFiles, dragDepth, setDragActive) - }, [canAcceptDrop, onAddFiles]) + return installDocumentDropEvents(canAcceptDrop, onAddFiles, dragDepth, setDragActive, readFileDrag) + }, [canAcceptDrop, onAddFiles, readFileDrag]) const railItems = useMemo(() => attachments.map(attachment => ({ id: attachment.id, @@ -71,20 +84,68 @@ export function ComposerAttachments({ /> ) } + // An uploading image shows its progress; a failed upload turns the thumbnail into its retry + // control, except one the Host refused, which explains why and leaves removal as the way out. + // The refusal is the only explanation of its warning, so a tap or click also pins it. + const upload = uploads[attachment.id] + const name = attachment.file.name || t('image.pending') + const refusal = upload?.status === 'error' && upload.reason !== undefined + ? uploadFailureText(upload.reason) + : undefined return (
- + {refusal !== undefined + ? ( + + + + ) + : upload?.status === 'error' + ? ( + + + + ) + : ( + + )} + {upload?.status === 'uploading' && ( + + + + )} } +
+ + {composer &&
Composer and dock
} + +} + +function select(source: Element) { + const range = document.createRange() + range.selectNodeContents(source) + const selection = window.getSelection()! + selection.removeAllRanges() + selection.addRange(range) + fireEvent(document, new Event('selectionchange')) + return selection +} + +async function layout() { + await act(async () => { resize(); await new Promise(resolve => requestAnimationFrame(resolve)) }) +} + +describe('area text interactions', () => { + it('waits for pointer and keyboard selection to finish', () => { + const view = render() + const source = view.container.querySelector('[data-text-interaction-source]')! + fireEvent.pointerDown(source, { button: 0 }) + select(source) + expect(view.queryByRole('button')).toBeNull() + fireEvent.pointerUp(document) + expect(view.getByRole('button', { name: 'action' })).toBeTruthy() + fireEvent.keyDown(source, { key: 'Shift', shiftKey: true }) + expect(view.queryByRole('button')).toBeNull() + const range = window.getSelection()!.getRangeAt(0) + range.setEnd(source.firstChild!, 3) + fireEvent(document, new Event('selectionchange')) + fireEvent.keyUp(source, { key: 'Shift' }) + expect(view.getByRole('button')).toBeTruthy() + }) + + it('closes outside without clearing selection or reopening on scroll and resize', async () => { + const view = render(<>) + const selection = select(view.container.querySelector('[data-text-interaction-source]')!) + const action = view.getByRole('button', { name: 'action' }) + fireEvent.pointerDown(action, { button: 0 }) + expect(view.getByRole('button', { name: 'action' })).toBeTruthy() + fireEvent.pointerDown(view.getByRole('button', { name: 'Outside' }), { button: 0 }) + expect(view.queryByRole('button', { name: 'action' })).toBeNull() + expect(selection.toString()).toBe('Hello action') + fireEvent.pointerUp(document) + fireEvent(document, new Event('selectionchange')) + fireEvent.scroll(view.container.querySelector('[data-viewport]')!) + await layout() + expect(view.queryByRole('button', { name: 'action' })).toBeNull() + }) + + it('dismisses on Escape, cancellation and blur', () => { + const view = render() + const sources = view.container.querySelectorAll('[data-text-interaction-source]') + select(sources[0]!) + fireEvent.keyDown(document, { key: 'Escape' }) + expect(view.queryByRole('button')).toBeNull() + select(sources[1]!) + fireEvent.pointerCancel(document) + expect(view.queryByRole('button')).toBeNull() + select(sources[0]!) + fireEvent.blur(window) + expect(view.queryByRole('button')).toBeNull() + }) + + it('places in content coordinates, flips below at the top and restores after scrolling offscreen', async () => { + const view = render() + select(view.container.querySelector('[data-text-interaction-source]')!) + const surface = view.container.querySelector('[data-surface]')! + expect(surface.parentElement?.dataset.area).toBe('action') + expect(surface.style.left).toBe('40px') + expect(surface.style.top).toBe('-18px') + selectionRect = rect(100, 90, 40, 20) + await layout() + expect(surface.style.top).toBe('18px') + selectionRect = rect(100, 10, 40, 20) + fireEvent.scroll(view.container.querySelector('[data-viewport]')!) + await layout() + expect(view.queryByRole('button')).toBeNull() + expect(surface.querySelector('button')).not.toBeNull() + selectionRect = rect(100, 120, 40, 20) + fireEvent.scroll(view.container.querySelector('[data-viewport]')!) + await layout() + expect(view.getByRole('button')).toBeTruthy() + expect(surface.style.top).toBe('-18px') + }) + + it('hides behind the composer and its dock, restores on resize, and keeps explicit dismissal final', async () => { + const view = render() + const composer = view.container.querySelector('[data-composer-seat]')! + let composerTop = 260 + composer.getBoundingClientRect = () => rect(20, composerTop, 400, 380 - composerTop) + const source = view.container.querySelector('[data-text-interaction-source]')! + const selection = select(source) + const surface = view.container.querySelector('[data-surface]')! + const action = surface.querySelector('button')! + selectionRect = rect(100, 280, 40, 20) + await layout() + expect(surface.style.visibility).toBe('hidden') + expect(surface.querySelector('button')).toBe(action) + expect(selection.toString()).toBe('Hello action') + composerTop = 330 + await layout() + expect(view.getByRole('button')).toBe(action) + composerTop = 260 + await layout() + expect(surface.style.visibility).toBe('hidden') + fireEvent.pointerDown(composer, { button: 0 }) + composerTop = 330 + await layout() + expect(surface.querySelector('button')).toBeNull() + }) + + it('invalidates changed, disabled and removed sources', async () => { + const view = render() + const sources = view.container.querySelectorAll('[data-text-interaction-source]') + select(sources[0]!) + sources[0]!.firstChild!.textContent = 'Changed text' + await layout() + expect(view.queryByRole('button')).toBeNull() + select(sources[1]!) + sources[1]!.removeAttribute('data-text-interaction-source') + await layout() + expect(view.queryByRole('button')).toBeNull() + select(sources[0]!) + sources[0]!.remove() + await layout() + expect(view.queryByRole('button')).toBeNull() + }) + + it('isolates areas and removes listeners on unmount', () => { + const view = render(<>) + const areas = view.container.querySelectorAll('[data-area]') + select(areas[0]!.querySelector('[data-text-interaction-source]')!) + expect(view.getByRole('button', { name: 'first' })).toBeTruthy() + expect(view.queryByRole('button', { name: 'second' })).toBeNull() + select(areas[1]!.querySelector('[data-text-interaction-source]')!) + expect(view.queryByRole('button', { name: 'first' })).toBeNull() + expect(view.getByRole('button', { name: 'second' })).toBeTruthy() + view.unmount() + fireEvent(document, new Event('selectionchange')) + expect(document.querySelector('[data-surface]')).toBeNull() + }) + + it('leaves contextmenu defaults to the registrant and never reuses an unrelated selection', () => { + const admit = vi.fn((request: TextInteractionRequest) => { + if (request.trigger === 'contextmenu') { request.preventDefault(); request.open() } + }) + const view = render() + const sources = view.container.querySelectorAll('[data-text-interaction-source]') + select(sources[0]!) + const same = new MouseEvent('contextmenu', { bubbles: true, cancelable: true, clientX: 110, clientY: 130 }) + fireEvent(sources[0]!, same) + expect(same.defaultPrevented).toBe(true) + expect(admit.mock.lastCall![0].readText()).toBe('Hello action') + const other = new MouseEvent('contextmenu', { bubbles: true, cancelable: true, clientX: 110, clientY: 130 }) + fireEvent(sources[1]!, other) + expect(admit.mock.lastCall![0].hasSelection).toBe(false) + expect(admit.mock.lastCall![0].readText()).toBeNull() + view.rerender() + expect(view.queryByRole('button')).toBeNull() + const native = new MouseEvent('contextmenu', { bubbles: true, cancelable: true, clientX: 110, clientY: 130 }) + fireEvent(sources[0]!, native) + expect(native.defaultPrevented).toBe(false) + expect(view.queryByRole('button')).toBeNull() + }) +}) + + +describe('text interaction lifecycle', () => { + it('exposes DOM text only while the captured source remains usable', () => { + let binding: TextInteractionBinding | undefined + const view = render( { binding = value }} />) + expect(binding!.readText()).toBeNull() + const source = view.container.querySelector('[data-text-interaction-source]')! + const selection = select(source) + expect(binding!.readText()).toBe('Hello action') + source.hidden = true + expect(binding!.readText()).toBeNull() + source.hidden = false + source.removeAttribute('data-text-interaction-source') + expect(binding!.readText()).toBeNull() + source.setAttribute('data-text-interaction-source', '') + act(() => { binding!.clearSelection(); binding!.dismiss() }) + expect(selection.isCollapsed).toBe(true) + expect(view.queryByRole('button')).toBeNull() + expect(binding!.readText()).toBeNull() + view.unmount() + expect(binding!.readText()).toBeNull() + binding!.clearSelection() + }) + + it('supports an unmounted area', () => { + const root = createRef() + const surface = createRef() + const hook = renderHook(() => useTextInteraction(root, surface, () => null, [])) + expect(hook.result.current.readText()).toBeNull() + act(() => { hook.result.current.dismiss(); hook.result.current.clearSelection() }) + }) + + it('dismisses a surface when its plugin unregisters', () => { + const view = render() + select(view.container.querySelector('[data-text-interaction-source]')!) + expect(view.getByRole('button')).toBeTruthy() + view.rerender() + expect(view.queryByRole('button')).toBeNull() + }) + + it('opens context menus without cancelling defaults unless the consumer requests it', () => { + let binding: TextInteractionBinding | undefined + const view = render( { binding = value }} admit={(request) => { request.open() }} />) + const source = view.container.querySelector('[data-text-interaction-source]')! + const event = new MouseEvent('contextmenu', { bubbles: true, cancelable: true }) + fireEvent(source, event) + expect(event.defaultPrevented).toBe(false) + expect(view.getByRole('button')).toBeTruthy() + expect(binding!.readText()).toBeNull() + // Menu focus may collapse browser selection without closing the captured interaction. + view.getByRole('button').focus() + fireEvent(document, new Event('selectionchange')) + expect(view.getByRole('button')).toBeTruthy() + fireEvent.contextMenu(view.getByRole('button')) + expect(view.getByRole('button')).toBeTruthy() + }) + + it('keeps a focused selection action open and ignores unrelated keys', () => { + const view = render() + const source = view.container.querySelector('[data-text-interaction-source]')! + select(source) + const button = view.getByRole('button') + button.focus() + fireEvent(document, new Event('selectionchange')) + fireEvent.keyDown(button, { key: 'ArrowDown' }) + fireEvent.keyDown(document, { key: 'Shift' }) + fireEvent.keyUp(document, { key: 'a' }) + expect(view.getByRole('button')).toBeTruthy() + button.blur() + fireEvent.keyDown(source, { key: 'ArrowLeft' }) + fireEvent.keyDown(source, { key: 'Shift', shiftKey: true }) + fireEvent.keyUp(source, { key: 'ArrowLeft', shiftKey: true }) + expect(view.queryByRole('button')).toBeNull() + fireEvent.keyUp(source, { key: 'Shift' }) + }) + + it('does not admit blank or hidden selections, controls, or unmarked right-click targets', () => { + const admit = vi.fn((request: TextInteractionRequest) => { request.open() }) + const view = render() + const source = view.container.querySelector('[data-text-interaction-source]')! + source.textContent = ' ' + select(source) + expect(admit).not.toHaveBeenCalled() + source.textContent = 'visible words' + source.style.display = 'none' + select(source) + expect(admit).not.toHaveBeenCalled() + fireEvent.contextMenu(source) + expect(admit).not.toHaveBeenCalled() + source.style.display = '' + source.innerHTML = '' + fireEvent.contextMenu(source.firstElementChild!) + fireEvent.contextMenu(source.firstElementChild!.firstChild!) + fireEvent.contextMenu(view.container.querySelector('[data-area]')!) + expect(admit).not.toHaveBeenCalled() + }) + + it('invalidates folded ancestors and handles scaled positioning containers', async () => { + const view = render() + const area = view.container.querySelector('[data-area]')! + const source = area.querySelector('[data-text-interaction-source]')! + Object.defineProperties(area, { offsetWidth: { value: 150 }, offsetHeight: { value: 250 } }) + select(source) + const surface = area.querySelector('[data-surface]')! + expect(surface.style.left).toBe('20px') + expect(surface.style.top).toBe('-9px') + area.style.visibility = 'hidden' + await layout() + expect(view.queryByRole('button')).toBeNull() + }) + + it('keeps a context-menu point attached to its source through scrolling and reflow', async () => { + const view = render( { if (request.trigger === 'contextmenu') request.open() }} />) + const source = view.container.querySelector('[data-text-interaction-source]')! + let sourceTop = 100 + source.getBoundingClientRect = () => rect(40, sourceTop, 300, 500) + fireEvent.contextMenu(source, { clientX: 110, clientY: 130 }) + const surface = view.container.querySelector('[data-surface]')! + expect(surface.style.top).toBe('39px') + sourceTop = 80 + await layout() + expect(surface.style.top).toBe('19px') + source.scrollTop = 5 + fireEvent.scroll(source) + await layout() + expect(surface.style.top).toBe('14px') + }) + + it('cancels scheduled placement when an active area unmounts', () => { + const view = render() + select(view.container.querySelector('[data-text-interaction-source]')!) + fireEvent.scroll(view.container.querySelector('[data-viewport]')!) + resize() + view.unmount() + expect(document.querySelector('[data-surface]')).toBeNull() + }) +}) diff --git a/packages/client/ui-chat/tests/text-selection.client.spec.tsx b/packages/client/ui-chat/tests/text-selection.client.spec.tsx new file mode 100644 index 0000000000..63afd15753 --- /dev/null +++ b/packages/client/ui-chat/tests/text-selection.client.spec.tsx @@ -0,0 +1,276 @@ +// @vitest-environment jsdom +import { afterEach, describe, expect, it, vi } from 'vitest' +import { cleanup, render } from '@testing-library/react' +import { MarkdownText } from '@deepseek-ai/dsh-client-ui-primitives' +import { readTextSelection, selectedText } from '../src/client/chat/text-selection.ts' + +function readSelection(root: HTMLElement, selection: Selection | null) { + const result = readTextSelection(root, selection) + return result === null ? null : { text: selectedText(result) } +} + +const markdownLabels = { + code: { copyLabel: 'Copy', copiedLabel: 'Copied' }, + footnotes: 'Footnotes', +} + +afterEach(() => { + cleanup() + vi.useRealTimers() + vi.unstubAllGlobals() +}) + +describe('quote selection', () => { + it('extracts one source block and omits Markdown control copy', () => { + const view = render( +
+
+ Hello + + world +
+
, + ) + const root = view.container.querySelector('[data-chat-flow]')! + const sourceRoot = view.container.querySelector('[data-text-interaction-source]')! + const range = document.createRange() + range.selectNodeContents(sourceRoot) + range.getBoundingClientRect = () => ({ left: 10, top: 20, width: 30, height: 10 }) as DOMRect + const selection = window.getSelection()! + selection.removeAllRanges() + selection.addRange(range) + expect(readSelection(root, selection)).toMatchObject({ + text: 'Hello world', + }) + }) + + it('rejects cross-block and pending text selections', () => { + const view = render( +
+
one
+ pending +
two
+
, + ) + const root = view.container.querySelector('[data-chat-flow]')! + const sources = view.container.querySelectorAll('[data-text-interaction-source]') + const range = document.createRange() + range.setStart(sources[0]!.firstChild!, 0) + range.setEnd(sources[1]!.firstChild!, 3) + const selection = window.getSelection()! + selection.removeAllRanges() + selection.addRange(range) + expect(readSelection(root, selection)).toBeNull() + }) + + it('keeps one visible newline for the Markdown hard-break DOM pair', () => { + const view = render( +
+
+ first
{'\n'}second +
+
, + ) + const root = view.container.querySelector('[data-chat-flow]')! + const sourceRoot = view.container.querySelector('[data-text-interaction-source]')! + const range = document.createRange() + range.selectNodeContents(sourceRoot) + range.getBoundingClientRect = () => ({ left: 50, top: 60, width: 20, height: 10 }) as DOMRect + const selection = window.getSelection()! + selection.removeAllRanges() + selection.addRange(range) + const candidate = readSelection(root, selection) + expect(candidate).toMatchObject({ + text: 'first\nsecond', + }) + }) + + it('projects Markdown soft line breaks as visible spaces', () => { + const view = render( +
+

{'hello\nworld'}

+
, + ) + const root = view.container.querySelector('[data-chat-flow]')! + const sourceRoot = view.container.querySelector('[data-text-interaction-source]')! + const range = document.createRange() + range.selectNodeContents(sourceRoot) + range.getBoundingClientRect = () => ({ left: 50, top: 60, width: 20, height: 10 }) as DOMRect + const selection = window.getSelection()! + selection.removeAllRanges() + selection.addRange(range) + expect(readSelection(root, selection)).toMatchObject({ text: 'hello world' }) + + const text = sourceRoot.querySelector('p')!.firstChild! + range.setStart(text, 6) + range.setEnd(text, 11) + expect(readSelection(root, selection)).toMatchObject({ text: 'world' }) + }) + + it('omits code banners while preserving preformatted line breaks', () => { + const view = render( +
+
+
typescript
+
{'const x = 1\nreturn x'}
+
+
, + ) + const root = view.container.querySelector('[data-chat-flow]')! + const sourceRoot = view.container.querySelector('[data-text-interaction-source]')! + const range = document.createRange() + range.selectNodeContents(sourceRoot) + range.getBoundingClientRect = () => ({ left: 50, top: 60, width: 20, height: 10 }) as DOMRect + const selection = window.getSelection()! + selection.removeAllRanges() + selection.addRange(range) + expect(readSelection(root, selection)).toMatchObject({ text: 'const x = 1\nreturn x' }) + }) + + it('uses one synthetic newline when a hard break has no text-node newline', () => { + const view = render( +
+
+ first
second +
+
, + ) + const root = view.container.querySelector('[data-chat-flow]')! + const sourceRoot = view.container.querySelector('[data-text-interaction-source]')! + const range = document.createRange() + range.selectNodeContents(sourceRoot) + range.getBoundingClientRect = () => ({ left: 50, top: 60, width: 20, height: 10 }) as DOMRect + const selection = window.getSelection()! + selection.removeAllRanges() + selection.addRange(range) + const candidate = readSelection(root, selection) + expect(candidate).toMatchObject({ + text: 'first\nsecond', + }) + }) + + it('preserves visible boundaries between adjacent Markdown blocks', () => { + const view = render( +
+
+

first

second

+
+
, + ) + const root = view.container.querySelector('[data-chat-flow]')! + const sourceRoot = view.container.querySelector('[data-text-interaction-source]')! + const range = document.createRange() + range.selectNodeContents(sourceRoot) + range.getBoundingClientRect = () => ({ left: 50, top: 60, width: 20, height: 10 }) as DOMRect + const selection = window.getSelection()! + selection.removeAllRanges() + selection.addRange(range) + const candidate = readSelection(root, selection) + expect(candidate).toMatchObject({ + text: 'first\nsecond', + }) + }) + + it('omits Markdown renderer whitespace between adjacent blocks', () => { + const view = render( +
+
+ +
+
, + ) + const root = view.container.querySelector('[data-chat-flow]')! + const sourceRoot = view.container.querySelector('[data-text-interaction-source]')! + const paragraphs = sourceRoot.querySelectorAll('p') + expect(paragraphs[0]!.nextSibling?.textContent).toBe('\n') + const range = document.createRange() + range.setStart(paragraphs[0]!.firstChild!, 0) + range.setEnd(paragraphs[1]!.firstChild!, 'second'.length) + range.getBoundingClientRect = () => ( + { left: 50, top: 60, width: 20, height: 10 } as DOMRect + ) + const selection = window.getSelection()! + selection.removeAllRanges() + selection.addRange(range) + expect(readSelection(root, selection)).toMatchObject({ text: 'first\nsecond' }) + }) + + it('maps visible KaTeX text while omitting its duplicate MathML branch', () => { + const view = render( +
+
+ + hidden TeX source + + +
+
, + ) + const root = view.container.querySelector('[data-chat-flow]')! + const visible = view.container.querySelector('.katex-html')! + const range = document.createRange() + range.selectNodeContents(visible) + range.getBoundingClientRect = () => ({ left: 50, top: 60, width: 20, height: 10 }) as DOMRect + const selection = window.getSelection()! + selection.removeAllRanges() + selection.addRange(range) + expect(readSelection(root, selection)).toMatchObject({ text: 'x + 1' }) + }) + +}) + +describe('text source boundaries', () => { + function source(html: string) { + const root = document.createElement('div') + const source = document.createElement('div') + source.setAttribute('data-text-interaction-source', '') + source.innerHTML = html + root.append(source) + const range = document.createRange() + range.selectNodeContents(source) + return { root, source, range } + } + + it('preserves table cells, existing tabs, and inline code reference buttons', () => { + const fixture = source('
onetwo
three\tfour
five\tsix
') + expect(selectedText(fixture)).toBe('one\ttwo\nthree\tfour\nfive\tsix\nreference') + }) + + it('omits hidden content, comments, and empty text nodes', () => { + const fixture = source('spokenvisible') + fixture.source.append(document.createTextNode('')) + fixture.range.selectNodeContents(fixture.source) + expect(selectedText(fixture)).toBe('visible') + const hidden = fixture.source.querySelector('span')! + fixture.range.selectNodeContents(hidden) + expect(selectedText(fixture)).toBeNull() + fixture.range.setStart(hidden.firstChild!, 0) + fixture.range.setEnd(hidden.firstChild!, 4) + expect(selectedText(fixture)).toBeNull() + }) + + it('rejects whitespace-only and detached sources outside the area', () => { + const fixture = source(' ') + expect(selectedText(fixture)).toBeNull() + const selection = window.getSelection()! + selection.removeAllRanges() + expect(readTextSelection(fixture.root, null)).toBeNull() + expect(readTextSelection(fixture.root, selection)).toBeNull() + document.body.append(fixture.root) + try { + selection.addRange(fixture.range) + expect(readTextSelection(document.createElement('div'), selection)).toBeNull() + fixture.source.hidden = true + expect(readTextSelection(fixture.root, selection)).toBeNull() + } finally { fixture.root.remove(); selection.removeAllRanges() } + }) + + it('projects text in a document without a browsing context', () => { + const doc = document.implementation.createHTMLDocument() + const source = doc.createElement('div') + source.textContent = 'hello\nworld' + const range = doc.createRange() + range.selectNodeContents(source) + expect(selectedText({ source, range })).toBe('hello world') + }) +}) diff --git a/packages/client/ui-chat/tsconfig.client.json b/packages/client/ui-chat/tsconfig.client.json index 528a54b447..42e4b6003c 100644 --- a/packages/client/ui-chat/tsconfig.client.json +++ b/packages/client/ui-chat/tsconfig.client.json @@ -42,9 +42,6 @@ { "path": "../../core/session" }, - { - "path": "../../core/tools" - }, { "path": "../../interaction/commands" }, @@ -119,6 +116,12 @@ }, { "path": "../product-analytics/tsconfig.client.json" + }, + { + "path": "../ui-file-drag" + }, + { + "path": "../../core/tool-executor-ptc" } ], "exclude": [ diff --git a/packages/client/ui-commands/locale/ja.json b/packages/client/ui-commands/locale/ja.json new file mode 100644 index 0000000000..8fed66b6fd --- /dev/null +++ b/packages/client/ui-commands/locale/ja.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "コマンド選択 UI", + "description": "入力欄にスラッシュコマンドのメニューと関連する操作 UI を提供します。" + } +} diff --git a/packages/client/ui-commands/src/client/index.ts b/packages/client/ui-commands/src/client/index.ts index a629bd3240..fc098764a4 100644 --- a/packages/client/ui-commands/src/client/index.ts +++ b/packages/client/ui-commands/src/client/index.ts @@ -18,7 +18,7 @@ import type {} from '@deepseek-ai/dsh-client-ui-session/client' import { CommandUiRuntime } from './service.ts' import type { PopupSelectInjected } from './PopupSelectView.tsx' import { PopupSelectView } from './PopupSelectView.tsx' -import { en, zh, type CommandKey } from './locales.ts' +import { en, ja, zh, type CommandKey } from './locales.ts' export { CommandUiRuntime } from './service.ts' export { CommandDirectory } from './directory.ts' @@ -56,7 +56,7 @@ export const inject = ['inputTriggers', 'sessions', 'remote', 'remote.commands', * @param ctx - client root context. */ export function apply(ctx: ClientContext): void { - ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'ui-commands: dictionaries') + ctx.effect(() => ctx.locale.register(NS, { zh, en, ja }), 'ui-commands: dictionaries') ctx.plugin(CommandUiRuntime) ctx.inject(['slots', 'commandUi', 'sessions'], (scope: ClientContext) => { const command = scope.commandUi diff --git a/packages/client/ui-commands/src/client/locales.ts b/packages/client/ui-commands/src/client/locales.ts index fd986c2a1f..de34e25cb7 100644 --- a/packages/client/ui-commands/src/client/locales.ts +++ b/packages/client/ui-commands/src/client/locales.ts @@ -71,3 +71,35 @@ export const en = { 'listbox.aria': '/{command} matches', 'notice.attachmentsUnsupported': '/{command} does not accept attachments; remove them first', } satisfies Record + +/** Japanese copy, checked against the English keys. */ +export const ja = { + 'section.add': '追加', + 'section.commands': 'コマンド', + 'label.goal': '目標', + 'label.plan': '計画', + 'label.feedback': 'フィードバック', + 'label.compact': '圧縮', + 'label.permission': '権限', + 'label.export': 'エクスポート', + 'description.goal': '長時間実行するタスクの目標を設定または表示', + 'description.plan': '計画モードに入るまたは終了する', + 'description.feedback': 'このセッションに関するフィードバックを記録', + 'description.compact': '古い会話履歴を圧縮', + 'description.permission': '権限プリセットを切り替える (サンドボックスモード + 承認ポリシー)', + 'description.export': 'このセッションログを ZIP アーカイブとしてダウンロード', + 'token.goal': 'goal', + 'token.plan': 'plan', + 'token.feedback': 'feedback', + 'token.compact': 'compact', + 'token.permission': 'permission', + 'token.export': 'export', + 'search.placeholder': '検索…', + 'search.aria': 'オプションを絞り込む', + 'status.loading': 'オプションを読み込み中…', + 'status.applying': '適用中…', + 'status.empty': 'オプションなし', + 'overlay.aria': '/{command} のオプション', + 'listbox.aria': '/{command} の一致項目', + 'notice.attachmentsUnsupported': '/{command} は添付ファイルを受け付けません。先に削除してください', +} satisfies Record diff --git a/packages/client/ui-conversation/README.i18n.yaml b/packages/client/ui-conversation/README.i18n.yaml index 0f813b3c22..268b7e59ae 100644 --- a/packages/client/ui-conversation/README.i18n.yaml +++ b/packages/client/ui-conversation/README.i18n.yaml @@ -18,11 +18,11 @@ en: ef43c4f149195939 zh: 49fc6c83fd3a05ce /deepseek-ai-dsh-client-ui-conversation/sent-user-text: - en: 9912b286384c41d7 - zh: 3e994887c1948677 + en: 7871f9fe6bcc4459 + zh: 0d51565c600b3958 /deepseek-ai-dsh-client-ui-conversation/shell-and-standard-props: - en: 566ac2a2289609a8 - zh: 068e895845d911a4 + en: 1d70d520d4a7537c + zh: 83febffaf90e0ac5 /deepseek-ai-dsh-client-ui-conversation/temporary-composer-entries: en: bb1244310f5c0382 zh: 824b84077432b559 diff --git a/packages/client/ui-conversation/README.md b/packages/client/ui-conversation/README.md index 0344d83312..a905984576 100644 --- a/packages/client/ui-conversation/README.md +++ b/packages/client/ui-conversation/README.md @@ -59,6 +59,12 @@ Businesses extend `UserTextDataMap` and `UserTextFacts` on this package's `/clie Queue previews enable whitespace collapsing and document-edge trimming, retain emoji, and use CSS ellipsis without truncating the input. Editing and copying retain the original text; native attachments use their separate presentation. +[Message quotes](../../session/message-quotes/README.md) registers quote matching and rendering through the same UserText extension points. Each user-text container and draft editor owns an independent hover-card group, so its quote previews replace one another immediately. + +The existing semantic `DraftSnapshot` retains quote text and clipboard-coordinate spans beside references. Storage accepts earlier string and reference-only drafts; Session switching, Workspace transfer, and failed-send restoration preserve atomic quotes through the existing editor runtime. `inputActions.insertQuote(text)` inserts a nonblank selection only while the composer is in plain mode. Submission emits ordinary Markdown text; pending echoes, queue rows, steering rows, and durable messages use the same text matcher. + +`UiConversation.interactions` owns plugin registrations for selected-text and context-menu gestures. A registration supplies a synchronous handler whose ID selects its keyed Slot component; plugins use the Slot injection mechanism for rendering. Handlers call `open()` to display and call `preventDefault()` separately when replacing a browser default. Chat supplies DOM selections and local layout; original source data and Markdown offsets are not inferred from DOM positions. + ## Shell and standard props @@ -98,13 +104,15 @@ The width-handle indicator follows the pointer only during a captured drag; ordi The resident composer survives no-Session and Session transitions. Whitespace hides its placeholder; a whitespace-only draft without attachments cannot be sent. The no-Session state keeps the same composer surface mounted but inert while the Workspace picker connects a blank Session. The surface is a shell-owned Lexical editor: reference chips are atomic decorator nodes carrying the owner's serialization identity (submission expands them through the owner codec), the claimed slash command is an atomic leading chip that carries its claim, folder text references carry the folder glyph as an icon prefix, and the draft's text and semantic references are persisted by the per-Session Conversation store. QueueDock reads `next-turn` from the Session `inbox` projection, including cold recovered messages, and excludes only requestIds still owned by local transcript submissions. Other queued rows retain their normal display and actions. Queue operations address exact queue occurrences through the scoped `ctx.conversation` service; queue previews use the [UserText Factory](#sent-user-text) (wire session forms fold to their label) and show local or durable images and files in original attachment order. Images use thumbnails; files use compact name-and-size cards. An edit exposes the literal sent text in the ui-primitives `InlineEditor`, so line breaks survive re-editing. Durable thumbnails resolve through the session image URL cache. Busy Enter behavior is stored in the Host-backed `ui-conversation` settings namespace. The composer keymap arbitrates the trigger menu's keys through the slash pipeline — Tab settles the highlighted completion (or drills a drillable one), Escape and Shift+Tab leave the menu without settling — and leaves every other key to the editor. An overlay that takes the keyboard hands it back through `SessionInput.focus()`, which rides Lexical's own focus so the caret returns where the draft left it rather than at the start. +In narrow browser settings, the busy-state Enter preference uses a smaller label gutter. Windows and macOS desktop windows retain the standard spacing. + Plain Enter uses the configured delivery mode, exactly Ctrl+Enter or Cmd+Enter uses its complement, and Shift+Enter inserts a line break. Enter with Alt, AltGraph, both Ctrl and Cmd, or Shift plus Ctrl/Cmd leaves the draft and command menu unchanged and keeps the DOM event available to application shortcuts. The Conversation plugin registers the fixed send, newline, complementary-delivery, command-menu, and reference-menu rows and reserves their bindings until it unloads. Clicking unused space inside an editable composer preserves its current caret or selection when focused, or restores its previous caret or selection when unfocused. Text selection, scrollbars, controls, and embedded overlays keep their own pointer behavior. -Each composer attempt carries its original occurrence time, gesture, delivery intent, and Session model/run-state snapshot through asynchronous command arbitration. The ordinary-message notification fires once before reference serialization, including attachment-only submissions; handled or claimed commands do not notify. Snapshot and notification failures cannot block sending. +Each composer attempt carries its original occurrence time, gesture, delivery intent, and Session model/run-state snapshot through asynchronous command arbitration. The snapshot also records whether the Session is still blank and the Agent preset id the Session records. The ordinary-message notification fires once before reference serialization, including attachment-only submissions; handled or claimed commands do not notify. Snapshot and notification failures cannot block sending. On Desktop, the notification reports `send_button_click`, which carries `is_new_session` (true while the Session is blank) and `agent_preset`, the preset category: `creator` for `cordis`; `ptc`, `minimal`, and `standard` under their own ids; `other` for any other preset. `agent_preset` is omitted while the Session records no preset. Custom preset ids are not sent. -Default sends commit optimistically: Enter clears the draft, occurrence table, and undo history in the same transaction, keeps the composer in `plain`, and runs the send as a detached attempt, so typing and further sends continue during the flight. `sendSession` registers a Session submission echo (`session.beginSubmission`) with the delivery mode before serializing, preserving selected image and file order in `pendingSubmissions`; Session derives the placement from that mode and its current running state, so idle sends use the transcript, busy Queue sends use QueueDock, and busy Steer sends use the pending-steering surface. It then yields one paint, encodes images through the browser's native `FileReader` data-URL path, and cites staged file receipts. Command submissions use the same receipts for generic files, so sending `/goal` or `/plan` never reads those browser files again. The prompt reuses the submission `requestId`; Session retires the echo once at its display handoff, correlated by that `rpcId`. Concurrent failures are restored together in submission order until the user edits the restored content; command submissions keep the frozen `submitting` phase. Detached attempts retain their attachment ids through admission and Session scope disposal. An observed retirement immediately exposes each image preview through the durable cache, replaces it with the canonical URL after fetching the admitted attachment, revokes each URL after its use ends, and releases file cards. Selected generic files enter one FIFO background-upload queue; `maxConcurrentFileUploads` defaults to two active Worker transports, the Conversation service retains queued and active operations plus byte progress across Session navigation, and removing a draft skips its queued transfer or aborts its active transport. Where the browser shell exposes `__DSH_HOST_PATHS__` (the Desktop application), dropped or pasted folders and dropped, picked, or pasted non-image files with a real path become `@path` chips; images keep uploading. Drag-and-drop and paste identify directories through the browser entry API; when that API is absent or returns no entry, pasted items retain ordinary file handling. The file picker cannot select directories. References require the `ui-reference` plugin and the original paths to remain readable by the model's file tools. Paths inside the workspace are relative; others remain absolute. The whole batch is validated before insertion, retains source order and selected text, and uses whitespace-separated, closed-quote mentions. A browser without the bridge refuses dropped or pasted folders; a Desktop folder without a reported path is rejected separately. Continuable subagents accept pasted and dropped PNG, JPEG, WebP, and GIF images with the same projected limits; a batch containing a generic file, unsupported image format, or directory is refused before intake. Their sends register the same local echo; the continuation request carries the echo's `requestId`, which the Host records as the child message's `rpcId`. +Default sends commit optimistically: Enter clears the draft, occurrence table, and undo history in the same transaction, keeps the composer in `plain`, and runs the send as a detached attempt, so typing and further sends continue during the flight. `sendSession` registers a Session submission echo (`session.beginSubmission`) with the delivery mode before serializing, preserving selected image and file order in `pendingSubmissions`; Session derives the placement from that mode and its current running state, so idle sends use the transcript, busy Queue sends use QueueDock, and busy Steer sends use the pending-steering surface. It then yields one paint and cites the staged receipts of files and uploaded images; an image without an upload, as in a continuable subagent's draft, is encoded through the browser's native `FileReader` data-URL path. Command submissions use the same receipts for generic files, so sending `/goal` or `/plan` never reads those browser files again. The prompt reuses the submission `requestId`; Session retires the echo once at its display handoff, correlated by that `rpcId`. Concurrent failures are restored together in submission order until the user edits the restored content; command submissions keep the frozen `submitting` phase. Detached attempts retain their attachment ids through admission and Session scope disposal. An observed retirement immediately exposes each image preview through the durable cache, replaces it with the canonical URL after fetching the admitted attachment, revokes each URL after its use ends, and releases file cards. Selected generic files, and the images of an ordinary Session's draft, enter one FIFO background-upload queue as soon as they are added, and Send waits until each has a receipt; `maxConcurrentFileUploads` defaults to two active Worker transports, the Conversation service retains queued and active operations plus byte progress across Session navigation, and removing a draft skips its queued transfer or aborts its active transport. Where the browser shell exposes `__DSH_HOST_PATHS__` (the Desktop application), dropped or pasted folders and dropped, picked, or pasted non-image files with a real path become `@path` chips; images keep uploading. Drag-and-drop and paste identify directories through the browser entry API; when that API is absent or returns no entry, pasted items retain ordinary file handling. The file picker cannot select directories. References require the `ui-reference` plugin and the original paths to remain readable by the model's file tools. Paths inside the workspace are relative; others remain absolute. The whole batch is validated before insertion, retains source order and selected text, and uses whitespace-separated, closed-quote mentions. A browser without the bridge refuses dropped or pasted folders; a Desktop folder without a reported path is rejected separately. Continuable subagents accept pasted and dropped PNG, JPEG, WebP, and GIF images with the same projected limits and send them inline, because the Host stages uploads only for ordinary Sessions; a batch containing a generic file, unsupported image format, or directory is refused before intake. Their sends register the same local echo; the continuation request carries the echo's `requestId`, which the Host records as the child message's `rpcId`. Queued submission echoes show “Sending…” beside disabled edit, remove, and steer buttons; a collapsed dock keeps the sending status in its header. A matching Host queue row replaces the echo and enables each action according to its normal text-content and running-state requirements. Prompt acknowledgement alone does not enable queue actions. A failed submission removes its echo and displays an error; the composer restores the failed draft when it is empty or still contains the previous automatic restoration, preserving subsequently typed text. diff --git a/packages/client/ui-conversation/README.zh.md b/packages/client/ui-conversation/README.zh.md index d3bbfccce9..feb91d7562 100644 --- a/packages/client/ui-conversation/README.zh.md +++ b/packages/client/ui-conversation/README.zh.md @@ -59,6 +59,12 @@ target package 通过 declaration merge 扩展 snapshot 与 Location data map, Queue 预览启用空白折叠和文档边缘 trim,保留 emoji,并使用 CSS 省略而不截断输入。编辑和复制保留原文;原生附件使用独立的展示路径。 +[Message quotes](../../session/message-quotes/README.zh.md) 通过相同的 UserText 扩展点注册引用匹配与渲染。 每个用户文本容器和草稿编辑器各自拥有独立的悬停卡片分组,其中的 quote 原文弹层会立即互相替换。 + +现有语义 `DraftSnapshot` 在 references 旁保留 quote 文本及 clipboard 坐标范围。存储接受较早的字符串和仅含 reference 的草稿;Session 切换、Workspace 转移以及发送失败恢复通过现有编辑器 runtime 保留原子 quote。`inputActions.insertQuote(text)` 仅在输入框处于 plain 模式时插入非空选区。提交产出普通 Markdown 文本;待发送回显、队列行、steering 行和持久消息使用同一文本匹配器。 + +`UiConversation.interactions` 拥有插件对文本选择和右键手势的注册。每个注册项提供同步处理函数,其 ID 选择对应的 keyed Slot 组件;插件通过 Slot injection 机制渲染。处理函数调用 `open()` 展示,需要替代浏览器默认行为时单独调用 `preventDefault()`。Chat 提供 DOM 选区和区域内布局;机制不从 DOM 位置推断原始来源数据或 Markdown 偏移。 + ## Shell 与标准 props @@ -98,13 +104,15 @@ Session 首次绑定或缓存的 Session 成为 current 时,shell 会在渲染 常驻 composer 在无 Session 与有 Session 之间保持挂载。输入空白字符会隐藏占位提示;没有附件的纯空白草稿无法发送。无 Session 时,同一个编辑器表面保持 inert,Workspace picker 连接 blank Session。该表面是 shell 所有的 Lexical 编辑器:引用 chip 是携带 owner 序列化身份的原子 decorator 节点(提交时经 owner codec 展开),已认领的 slash command 是携带认领的行首原子 chip,文件夹文本引用以图标前缀携带文件夹图形,草稿文字和引用语义字段由逐 Session Conversation store 持久化。QueueDock 从 Session 的 `inbox` 投影读取 `next-turn`,包含从冷状态恢复的消息,仅排除仍由本地 transcript 提交承接的 requestId。其他排队行保留正常展示和操作。Queue 操作通过 scoped `ctx.conversation` service 寻址准确的 queue occurrence;queue 预览使用 [UserText Factory](#sent-user-text)(wire 会话形式折叠为其标签),并按原始附件顺序展示本地或持久化的图片和文件。图片使用缩略图,文件使用紧凑的名称与大小卡片。编辑态在 ui-primitives 的 `InlineEditor` 中展示字面发送文本,因此重新编辑不会丢失换行。持久化缩略图通过会话图片 URL 缓存解析。繁忙时 Enter 行为保存在 Host-backed `ui-conversation` settings namespace。 composer 键盘映射经斜杠流水线裁决触发菜单的按键——Tab 确认高亮补全项(可下钻项则下钻),Escape 与 Shift+Tab 离开菜单且不选定——其余按键交给编辑器自身。 接管键盘的浮层通过 `SessionInput.focus()` 把键盘还回来,该路径走 Lexical 自己的 focus,因此光标回到草稿原来的位置而不是开头。 +窄屏浏览器设置中,繁忙时 Enter 偏好的标签与控件间距缩小;Windows 和 macOS 桌面窗口保留标准间距。 + 普通 Enter 使用已配置的投递模式,严格的 Ctrl+Enter 或 Cmd+Enter 使用互补模式,Shift+Enter 插入换行。带 Alt、AltGraph、同时带 Ctrl 与 Cmd,或 Shift 与 Ctrl/Cmd 的 Enter 保持草稿和指令菜单不变,并将 DOM 事件留给应用快捷键。Conversation 插件注册发送、换行、互补投递、命令菜单和引用菜单的固定条目,并在卸载前保留这些键位。 点击可编辑输入框内的留白时,已聚焦的输入框保留当前光标或选区,未聚焦的输入框恢复上次的光标或选区。文本选择、滚动条、控件和内嵌浮层保持各自的指针交互行为。 -每次输入框提交都携带原始发生时间、操作来源、投递意图以及会话模型和运行状态快照,贯穿异步命令裁决。普通消息通知在引用序列化前发出一次,纯附件提交也遵循这一规则;已处理或认领的命令不发出通知。快照读取与通知消费异常不能阻断发送。 +每次输入框提交都携带原始发生时间、操作来源、投递意图以及会话模型和运行状态快照,贯穿异步命令裁决。快照还记录会话是否仍为空白,以及会话记录的 Agent preset id。普通消息通知在引用序列化前发出一次,纯附件提交也遵循这一规则;已处理或认领的命令不发出通知。快照读取与通知消费异常不能阻断发送。在桌面端,该通知报告 `send_button_click`,携带 `is_new_session`(会话为空白时为 true)和表示 preset 类别的 `agent_preset`:`cordis` 记为 `creator`;`ptc`、`minimal` 与 `standard` 沿用各自的 id;其他 preset 记为 `other`。会话未记录 preset 时省略 `agent_preset`。不发送自定义 preset 的 id。 -默认发送采用乐观提交:Enter 在同一事务里清空草稿、occurrence 表和撤销历史,composer 保持 `plain`,发送作为 detached attempt 运行,发送期间可以继续输入和提交。`sendSession` 在序列化之前用投递模式注册 Session 提交回显(`session.beginSubmission`),并在 `pendingSubmissions` 中保留图片与文件的选择顺序;Session 根据该模式与当前运行状态推导位置,因此空闲发送进入 transcript(文本记录),繁忙时 Queue 进入 QueueDock,繁忙时 Steer 进入 pending-steering 区域。随后让出一帧,图片经浏览器原生 `FileReader` data-URL 路径编码,文件则引用已暂存凭证。命令提交也用同一凭证表示通用文件,因此发送 `/goal` 或 `/plan` 时不会再次读取这些浏览器文件。提示词复用提交 `requestId`;Session 按同一 `rpcId` 关联展示接管,并仅退休回显一次。多个并发发送失败时,在用户编辑还原内容之前按提交顺序合并还原;命令提交保持冻结的 `submitting` 阶段。Detached attempt 持有附件 id,直到 admission 完成或 Session scope 销毁。回显以 observed 退休时,durable 图片缓存立即公开每个预览 URL,读取 admitted 附件后用规范化 URL 替换预览,并在各 URL 停止使用后撤销,同时释放文件卡。选中的通用文件进入同一个先进先出的后台上传队列;`maxConcurrentFileUploads` 默认允许两个 Worker transport 同时运行,Conversation 服务在切换 Session 时继续持有排队和运行中的传输操作及字节进度,移除草稿会跳过排队中的传输或中止正在运行的传输。浏览器 shell 暴露 `__DSH_HOST_PATHS__` 时(桌面应用),拖入或粘贴的文件夹以及拖入、选择或粘贴的带真实路径的非图片文件会成为 `@路径` chip;图片仍然上传。拖放和粘贴通过浏览器 entry API 识别目录;该 API 不可用或没有返回 entry 时,粘贴项沿用普通文件处理。文件选择器不能选择目录。引用需要启用 `ui-reference` 插件,原路径也必须仍可由模型的文件工具读取。工作区内的路径使用相对形式,其他路径保留绝对形式。整批文件先校验再插入,保留来源顺序和已选中的文字,引用之间有空白分隔,含空格的路径使用闭合引号。没有该桥的浏览器会拒绝拖入或粘贴的文件夹,桌面端无法获取文件夹路径时单独报错。continuable 子代理允许粘贴和拖入 PNG、JPEG、WebP、GIF 图片,并沿用相同的投影限制;包含通用文件、不支持的图片格式或目录的批次会在进入草稿前整批拒绝。它们的发送同样注册本地回显;continuation 请求携带回显的 `requestId`,Host 把它记录为子会话消息的 `rpcId`。 +默认发送采用乐观提交:Enter 在同一事务里清空草稿、occurrence 表和撤销历史,composer 保持 `plain`,发送作为 detached attempt 运行,发送期间可以继续输入和提交。`sendSession` 在序列化之前用投递模式注册 Session 提交回显(`session.beginSubmission`),并在 `pendingSubmissions` 中保留图片与文件的选择顺序;Session 根据该模式与当前运行状态推导位置,因此空闲发送进入 transcript(文本记录),繁忙时 Queue 进入 QueueDock,繁忙时 Steer 进入 pending-steering 区域。随后让出一帧,文件和已上传的图片引用已暂存凭证;没有上传的图片(例如 continuable 子代理草稿里的图片)经浏览器原生 `FileReader` data-URL 路径编码。命令提交也用同一凭证表示通用文件,因此发送 `/goal` 或 `/plan` 时不会再次读取这些浏览器文件。提示词复用提交 `requestId`;Session 按同一 `rpcId` 关联展示接管,并仅退休回显一次。多个并发发送失败时,在用户编辑还原内容之前按提交顺序合并还原;命令提交保持冻结的 `submitting` 阶段。Detached attempt 持有附件 id,直到 admission 完成或 Session scope 销毁。回显以 observed 退休时,durable 图片缓存立即公开每个预览 URL,读取 admitted 附件后用规范化 URL 替换预览,并在各 URL 停止使用后撤销,同时释放文件卡。选中的通用文件以及普通 Session 草稿里的图片在加入时就进入同一个先进先出的后台上传队列,发送会等到每一项都取得凭证;`maxConcurrentFileUploads` 默认允许两个 Worker transport 同时运行,Conversation 服务在切换 Session 时继续持有排队和运行中的传输操作及字节进度,移除草稿会跳过排队中的传输或中止正在运行的传输。浏览器 shell 暴露 `__DSH_HOST_PATHS__` 时(桌面应用),拖入或粘贴的文件夹以及拖入、选择或粘贴的带真实路径的非图片文件会成为 `@路径` chip;图片仍然上传。拖放和粘贴通过浏览器 entry API 识别目录;该 API 不可用或没有返回 entry 时,粘贴项沿用普通文件处理。文件选择器不能选择目录。引用需要启用 `ui-reference` 插件,原路径也必须仍可由模型的文件工具读取。工作区内的路径使用相对形式,其他路径保留绝对形式。整批文件先校验再插入,保留来源顺序和已选中的文字,引用之间有空白分隔,含空格的路径使用闭合引号。没有该桥的浏览器会拒绝拖入或粘贴的文件夹,桌面端无法获取文件夹路径时单独报错。continuable 子代理允许粘贴和拖入 PNG、JPEG、WebP、GIF 图片,沿用相同的投影限制并以内联方式发送,因为 Host 只为普通 Session 暂存上传;包含通用文件、不支持的图片格式或目录的批次会在进入草稿前整批拒绝。它们的发送同样注册本地回显;continuation 请求携带回显的 `requestId`,Host 把它记录为子会话消息的 `rpcId`。 排队提交的本地回显在禁用的编辑、删除、插话按钮旁显示“发送中…”;折叠后的队列在标题栏保留发送状态。匹配的 Host 队列行替换回显后,各操作按原有的纯文本内容和运行状态要求启用。仅收到提示词确认不会启用队列操作。提交失败会移除回显并显示错误;输入框为空或仍保留上一次自动恢复的内容时,composer 恢复失败草稿,保留用户随后输入的文字。 diff --git a/packages/client/ui-conversation/locale/ja.json b/packages/client/ui-conversation/locale/ja.json new file mode 100644 index 0000000000..fdd774d0f9 --- /dev/null +++ b/packages/client/ui-conversation/locale/ja.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "会話 UI フレームワーク", + "description": "会話内容、入力欄、メッセージキュー、ビューのナビゲーションを構成します。" + } +} diff --git a/packages/client/ui-conversation/package.json b/packages/client/ui-conversation/package.json index 11a24a79f7..09c12d692d 100644 --- a/packages/client/ui-conversation/package.json +++ b/packages/client/ui-conversation/package.json @@ -96,7 +96,8 @@ "@deepseek-ai/dsh-agent": "workspace:*", "@deepseek-ai/dsh-settings": "workspace:*", "@deepseek-ai/dsh-client-shortcuts": "workspace:*", - "@deepseek-ai/dsh-client-product-analytics": "workspace:*" + "@deepseek-ai/dsh-client-product-analytics": "workspace:*", + "@deepseek-ai/dsh-message-quotes": "workspace:*" }, "files": [ "locale/*.json", diff --git a/packages/client/ui-conversation/src/client/apply.ts b/packages/client/ui-conversation/src/client/apply.ts index 6594c95812..6a004c8740 100644 --- a/packages/client/ui-conversation/src/client/apply.ts +++ b/packages/client/ui-conversation/src/client/apply.ts @@ -46,7 +46,7 @@ import { todoDockEntry } from './skeleton/TodoPanel.tsx' import { installStopShortcut } from './stop-shortcut.ts' import { TRAJECTORY_VIEW_ID, resolveActiveView } from './view-selection.ts' import { createViewPins } from './view-pins.ts' -import { en, NS, zh, type ConversationKey } from './locales.ts' +import { en, ja, NS, zh, type ConversationKey } from './locales.ts' import { CONVERSATION_SETTINGS_NAMESPACE, type ConversationSettings } from '../submission-settings.ts' declare module '@deepseek-ai/dsh-client-ui-slots' { @@ -185,7 +185,7 @@ export function apply(ctx: Context, config: Config = Config({})): void { yield slots.register({ name: 'conversation.user.text.node', key: 'session', priority: 100 }, SessionReference) }) - ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'ui-conversation: dictionaries') + ctx.effect(() => ctx.locale.register(NS, { zh, en, ja }), 'ui-conversation: dictionaries') const t = ctx.locale.bind(NS) const conversationStore = createConversationStore() const submissionPolicy = new ComposerSubmissionPolicy( @@ -368,10 +368,13 @@ export function apply(ctx: Context, config: Config = Config({})): void { const attachmentIds = from.snapshot.attachmentIds const next = inputHub.shell(nextId) if (attachmentIds.length === 0 || next.addAttachments(attachmentIds)) { - if (sessions.binding(nextId) === undefined) { + const binding = sessions.binding(nextId) + if (binding === undefined) { throw new Error(`ui-conversation: session "${nextId}" resolved no binding`) } - concreteConversation(ctx).rebindDraftFiles(nextId, attachmentIds) + // The same rule as intake: only an ordinary Session stages image uploads. + const ordinary = binding.session.getSnapshot().subagent === null + concreteConversation(ctx).rebindDraftFiles(nextId, attachmentIds, ordinary) if (draft.text !== '') { next.setDraft(draft) from.setDraft('') @@ -521,7 +524,9 @@ export function apply(ctx: Context, config: Config = Config({})): void { }) } try { - const drafts = conversation.createDrafts(sessionId, uploads) + // The Host stages uploads for ordinary Sessions only; a subagent continuation's images travel inline. + const ordinary = sessions.binding(sessionId)?.session.getSnapshot().subagent === null + const drafts = conversation.createDrafts(sessionId, uploads, ordinary) if (!shell.addFiles(references, drafts.map(draft => draft.id))) { conversation.releaseDraftAttachments(drafts) return t('attachment.dropBlocked') diff --git a/packages/client/ui-conversation/src/client/contract/composer-submission.ts b/packages/client/ui-conversation/src/client/contract/composer-submission.ts index 2d9e2cc039..0a49153da6 100644 --- a/packages/client/ui-conversation/src/client/contract/composer-submission.ts +++ b/packages/client/ui-conversation/src/client/contract/composer-submission.ts @@ -16,6 +16,10 @@ export interface MessageSubmissionState { readonly model?: { readonly provider: string; readonly name: string; readonly effort?: string } readonly runMode: 'default' | 'plan' | 'goal' readonly running: boolean + /** Whether the Session is still blank, so this message is its first. */ + readonly newSession: boolean + /** Agent preset id the Session records, when known. */ + readonly agentPreset?: string } /** Immutable occurrence and delivery intent carried by one composer attempt. */ diff --git a/packages/client/ui-conversation/src/client/contract/draft-editor.ts b/packages/client/ui-conversation/src/client/contract/draft-editor.ts index 7d523c97d8..b305bb3a3c 100644 --- a/packages/client/ui-conversation/src/client/contract/draft-editor.ts +++ b/packages/client/ui-conversation/src/client/contract/draft-editor.ts @@ -22,6 +22,14 @@ export interface ReferenceInsert { /** Persisted reference data without the current editor's occurrence identity. */ export type DraftReference = Omit +/** One atomic quote in clipboard-text coordinates. */ +export interface DraftQuote { + readonly offset: number + readonly length: number + /** Selected text before Markdown quote prefixes are added. */ + readonly text: string +} + /** * Persisted command chip opening a draft. The claim itself (its submit * closure) is not persisted; the input rebuilds it from the token through the @@ -41,6 +49,7 @@ export interface DraftSnapshot { readonly text: string readonly references: readonly DraftReference[] readonly command?: DraftCommand + readonly quotes?: readonly DraftQuote[] } /** Plain initial text or an existing document with explicit reference identities. */ diff --git a/packages/client/ui-conversation/src/client/contract/input.ts b/packages/client/ui-conversation/src/client/contract/input.ts index c01ed5f99f..a47e044b6c 100644 --- a/packages/client/ui-conversation/src/client/contract/input.ts +++ b/packages/client/ui-conversation/src/client/contract/input.ts @@ -182,6 +182,9 @@ export interface InputTarget { /** Per-session input facade owned by the conversation wiring layer. */ export interface SessionInput extends InputTarget { + /** Insert selected transcript text as an atomic quote. @param text - nonblank selection. @returns whether editing accepted it. */ + insertQuote(text: string): boolean + /** Replace the whole draft with plain text, removing inline reference chips. */ setDraft(text: string): void /** Append ordered browser-owned attachment ids; busy admission phases refuse. */ @@ -248,11 +251,13 @@ export type DraftInitializationResult = 'applied' | 'preserved' | 'blocked' /** * The public input action face provided to every session-scope slot - * component: stable-identity void callbacks, mirroring the + * component: stable-identity callbacks, mirroring the * useStore+actions convention. Command-style handles (arbitrate/space/ * paste/…) stay InputBar-private and never ride this face. */ export interface InputActions { + /** Insert one nonblank transcript quote. @param text - selected text. @returns whether editing accepted it. */ + insertQuote(text: string): boolean /** @returns a revision-guarded copy of the current editor selection. */ captureInsertion(): TokenSpan /** diff --git a/packages/client/ui-conversation/src/client/contract/records.ts b/packages/client/ui-conversation/src/client/contract/records.ts index 6d7359e71d..daddf0aac7 100644 --- a/packages/client/ui-conversation/src/client/contract/records.ts +++ b/packages/client/ui-conversation/src/client/contract/records.ts @@ -41,6 +41,8 @@ export type AssistantBlock = /** A finalized user message. */ export interface UserMessageNode { kind: 'user' + /** Durable identity, absent only in synthetic projections. */ + messageId?: MessageId seq: number /** Unix epoch ms from the source session event. */ time: number diff --git a/packages/client/ui-conversation/src/client/contract/slots.ts b/packages/client/ui-conversation/src/client/contract/slots.ts index d93a848198..f8d86187d3 100644 --- a/packages/client/ui-conversation/src/client/contract/slots.ts +++ b/packages/client/ui-conversation/src/client/contract/slots.ts @@ -28,7 +28,11 @@ import type { UserTextDocumentInput, UserTextEvaluator, UserTextId, UserTextNode /** Browser-owned draft attachment that has not crossed the durable Host boundary. */ export type ComposerAttachment = ComposerImageAttachment | ComposerFileAttachment -/** Browser-owned image, base64-encoded into the prompt at send time. */ +/** + * Browser-owned image. An ordinary Session's draft uploads it to the Host as + * soon as it is added, and the prompt cites the receipt; a subagent + * continuation's draft base64-encodes it into the prompt at send time. + */ export interface ComposerImageAttachment { kind: 'image' id: DraftAttachmentId @@ -47,11 +51,17 @@ export interface ComposerFileAttachment { file: File } -/** Upload lifecycle of one picked file draft (files upload on pick, not on send). */ +/** + * Upload lifecycle of one draft that uploads when it is added, not on send: + * every file draft, and every image draft of an ordinary Session. A failed + * upload the Host refused keeps the attachment refusal `reason`, because + * retrying the same bytes cannot succeed. + */ export type DraftFileUpload = | { readonly status: 'uploading'; readonly loaded: number; readonly total?: number } | { readonly status: 'ready'; readonly receiptId: FileUploadReceiptId; readonly file: FileAttachmentRef } - | { readonly status: 'error'; readonly message: string } + | { readonly status: 'ready'; readonly receiptId: FileUploadReceiptId } + | { readonly status: 'error'; readonly message: string; readonly reason?: string } /** Per-draft upload states keyed by draft attachment id. */ export type DraftFileUploads = Readonly> @@ -74,6 +84,13 @@ export interface ComposerAttachmentsOwnerProps { uploads: DraftFileUploads /** Restart one failed file upload. */ onRetryFile: (id: DraftAttachmentId) => void + /** + * Explain one upload the Host refused; such a draft cannot be retried and + * stays until it is removed. + * @param reason - the refusal `reason` the failed upload carries. + * @returns localized text naming the limit or the way out. + */ + uploadFailureText: (reason: string) => string /** Display-ready limits for the drop invitation. */ dropLimits?: { readonly count: number; readonly size: string } | undefined } @@ -187,6 +204,7 @@ declare module '@deepseek-ai/dsh-client-ui-slots' { 'conversation.images.content': { scope: 'root' props: MessageImagesContentInput + inject: { readonly fileDrag: import('@deepseek-ai/dsh-client-ui-primitives').FileDragActions } locale: 'conversation' } } @@ -517,7 +535,7 @@ export interface ComposerBarInjected { * and the primary Send button use while the addressed agent is busy. */ busyEnter: ObservableSnapshot - /** Live per-draft upload states for file-kind drafts. */ + /** Live per-draft upload states for drafts that upload when added. */ fileUploads: ObservableSnapshot notices: ObservableSnapshot lexicon: ObservableSnapshot> diff --git a/packages/client/ui-conversation/src/client/contract/text-interaction.ts b/packages/client/ui-conversation/src/client/contract/text-interaction.ts new file mode 100644 index 0000000000..7f1707d61b --- /dev/null +++ b/packages/client/ui-conversation/src/client/contract/text-interaction.ts @@ -0,0 +1,35 @@ +/** Conversation text gestures and plugin-owned action surfaces. */ +import type { SessionId } from '@deepseek-ai/dsh-session/types' + +/** Browser gesture offered to text interaction consumers. */ +export type TextInteractionTrigger = 'selection' | 'contextmenu' + +/** Synchronous browser gesture; reading text does not imply source-data offsets. */ +export interface TextInteractionGesture { + readonly trigger: TextInteractionTrigger + readonly hasSelection: boolean + readonly readText: () => string | null + /** Cancel the originating browser event during dispatch only. */ + readonly preventDefault: () => void +} + +/** Scoped gesture offered to one registered plugin. */ +export interface TextInteractionRequest extends TextInteractionGesture { + readonly sessionId: SessionId + /** Select this plugin's keyed surface for the current gesture. */ + readonly open: () => void +} + +/** One consumer whose ID also names its keyed Slot renderer. */ +export interface TextInteractionDefinition { + readonly id: string + readonly onInteraction: (request: TextInteractionRequest) => void +} + +/** Area-local actions provided to the currently selected plugin component. */ +export interface TextInteractionSurface { + readonly trigger: TextInteractionTrigger + readonly dismiss: () => void + readonly readText: () => string | null + readonly clearSelection: () => void +} diff --git a/packages/client/ui-conversation/src/client/conversation/assembly.ts b/packages/client/ui-conversation/src/client/conversation/assembly.ts index b6cfcd6318..9b3ed5f5b8 100644 --- a/packages/client/ui-conversation/src/client/conversation/assembly.ts +++ b/packages/client/ui-conversation/src/client/conversation/assembly.ts @@ -24,6 +24,7 @@ import { ConversationEventRegistry } from './event-registry.ts' import { HistoricalImageCache } from './historical-images.ts' import { ConversationViewRegistry } from './view-registry.ts' import { ConversationGroupRegistry } from './group-registry.ts' +import { ConversationInteractionRegistry } from './interaction-registry.ts' import { UserTextRegistry } from './user-text-registry.ts' /** Observable faces published for one Session's Conversation assembly. */ @@ -193,6 +194,8 @@ export class UiConversation extends Service { readonly groups: ConversationGroupRegistry /** Pure text rules and parser snapshots, independent of durable Session assembly. */ readonly userText: UserTextRegistry + /** Plugin-owned handlers for selection and context-menu gestures. */ + readonly interactions: ConversationInteractionRegistry private readonly bindings = new WeakMapWithValues() private readonly images: HistoricalImageCache @@ -206,6 +209,7 @@ export class UiConversation extends Service { this.views = new ConversationViewRegistry(ctx) this.groups = new ConversationGroupRegistry(ctx, this.views) this.userText = new UserTextRegistry(ctx) + this.interactions = new ConversationInteractionRegistry(ctx) this.images = new HistoricalImageCache(ctx, sessions) const rebuild = (): void => { for (const record of this.bindings.values) record.binding.rebuild() diff --git a/packages/client/ui-conversation/src/client/conversation/interaction-registry.ts b/packages/client/ui-conversation/src/client/conversation/interaction-registry.ts new file mode 100644 index 0000000000..bd909da841 --- /dev/null +++ b/packages/client/ui-conversation/src/client/conversation/interaction-registry.ts @@ -0,0 +1,55 @@ +/** Plugin-owned text gesture handlers, scoped by the dispatching conversation. */ +import type { SessionId } from '@deepseek-ai/dsh-session/types' +import type { TextInteractionDefinition, TextInteractionGesture } from '../contract/text-interaction.ts' +import { ConversationDefinitionRegistry } from './definition-registry.ts' + +/** Effect-owned registrations; active surfaces belong to individual Chat views. */ +export class ConversationInteractionRegistry extends ConversationDefinitionRegistry { + private ids: readonly string[] = [] + + /** + * Register a synchronous handler and its keyed renderer identity. + * @param definition - uniquely named plugin handler. + * @returns idempotent disposer tied to the registering plugin's lifetime. + */ + register(definition: TextInteractionDefinition): () => void { + if (definition.id === '') throw new Error('text interaction ID must not be empty') + return this.registerDefinition(definition.id, definition, + `Duplicate text interaction: ${definition.id}`, `uiConversation.interactions.register(${JSON.stringify(definition.id)})`) + } + + /** + * Offer a gesture until one handler opens its surface; opening never prevents browser defaults. + * @param sessionId - conversation where the gesture occurred. + * @param gesture - synchronous access to the browser event and DOM text. + * @returns selected renderer ID, or null when no plugin opens a surface. + */ + dispatch(sessionId: SessionId, gesture: TextInteractionGesture): string | null { + for (const definition of this.entries()) { + const state = { opened: false } + let dispatching = true + try { + definition.onInteraction({ + ...gesture, sessionId, + open: () => { if (dispatching) state.opened = true }, + preventDefault: () => { if (dispatching) gesture.preventDefault() }, + }) + } finally { dispatching = false } + if (state.opened) return definition.id + } + return null + } + + /** + * Observe registration changes so Chat views can dismiss stale surfaces. + * @returns stable IDs until the registry changes. + */ + getSnapshot(): readonly string[] { + return this.ids + } + + protected override refresh(): void { + this.ids = [...this.definitions.keys()] + super.refresh() + } +} diff --git a/packages/client/ui-conversation/src/client/draft.ts b/packages/client/ui-conversation/src/client/draft.ts index cc1c814f43..e4cee855b2 100644 --- a/packages/client/ui-conversation/src/client/draft.ts +++ b/packages/client/ui-conversation/src/client/draft.ts @@ -1,6 +1,7 @@ /** Semantic draft snapshots shared by storage, programmatic input, and editor restoration. */ import { z } from 'zod' -import type { DraftInput, DraftReference, DraftSnapshot, Occurrence } from './contract/draft-editor.ts' +import { formatQuoteText } from '@deepseek-ai/dsh-client-ui-primitives' +import type { DraftInput, DraftQuote, DraftReference, DraftSnapshot, Occurrence } from './contract/draft-editor.ts' const referenceSchema = z.object({ source: z.string(), @@ -17,10 +18,17 @@ const commandSchema = z.object({ token: z.string(), }) +const quoteSchema = z.object({ + offset: z.number().int().nonnegative(), + length: z.number().int().positive(), + text: z.string().refine(text => text.trim() !== ''), +}) + const snapshotSchema = z.object({ text: z.string(), references: z.array(referenceSchema), command: commandSchema.optional(), + quotes: z.array(quoteSchema).optional(), }).refine((draft) => { const spelling = draft.command?.token.trimEnd() if (spelling !== undefined && (!/^\/\S/u.test(spelling) || !draft.text.startsWith(spelling))) return false @@ -30,6 +38,16 @@ const snapshotSchema = z.object({ end = reference.offset + reference.length if (end > draft.text.length || draft.text.slice(reference.offset, end) !== reference.clipboardText) return false } + for (const quote of draft.quotes ?? []) { + if (quote.length !== formatQuoteText(quote.text).length + || draft.text.slice(quote.offset, quote.offset + quote.length) !== formatQuoteText(quote.text)) return false + } + end = spelling?.length ?? 0 + for (const span of [...draft.references, ...draft.quotes ?? []].sort((a, b) => a.offset - b.offset)) { + if (span.offset < end) return false + end = span.offset + span.length + if (end > draft.text.length) return false + } return true }, 'Draft references must identify ordered, non-overlapping text spans after the leading command token') @@ -44,6 +62,7 @@ export function resolveDraftInput(input: DraftInput): DraftSnapshot { text: input.text, references: input.references.map(copyReference), ...(input.command === undefined ? {} : { command: { token: input.command.token } }), + ...(input.quotes === undefined ? {} : { quotes: input.quotes.map(quote => ({ ...quote })) }), } } @@ -58,6 +77,7 @@ export function parseStoredDraft(value: unknown): DraftSnapshot | undefined { if (!parsed.success) return undefined return { text: parsed.data.text, + ...parsed.data.quotes === undefined ? {} : { quotes: parsed.data.quotes }, references: parsed.data.references.map(({ appearance, invalid, ...reference }) => ({ ...reference, ...(appearance === undefined ? {} : { appearance }), @@ -72,13 +92,17 @@ export function parseStoredDraft(value: unknown): DraftSnapshot | undefined { * @param text - current clipboard projection. * @param occurrences - matching reference projections from the same document revision. * @param commandToken - claim token of the command chip opening the document, if any. + * @param quotes - atomic quotes from the same document revision. * @returns an editor-independent snapshot. */ -export function snapshotDraft(text: string, occurrences: readonly Occurrence[], commandToken?: string): DraftSnapshot { +export function snapshotDraft( + text: string, occurrences: readonly Occurrence[], commandToken?: string, quotes: readonly DraftQuote[] = [], +): DraftSnapshot { return { text, references: occurrences.map(copyReference), ...(commandToken === undefined ? {} : { command: { token: commandToken } }), + ...(quotes.length === 0 ? {} : { quotes: quotes.map(quote => ({ ...quote })) }), } } diff --git a/packages/client/ui-conversation/src/client/index.ts b/packages/client/ui-conversation/src/client/index.ts index 08a7432e1e..d4f06accc5 100644 --- a/packages/client/ui-conversation/src/client/index.ts +++ b/packages/client/ui-conversation/src/client/index.ts @@ -102,7 +102,7 @@ export type { InputState, InsertReferenceRequest, InsertTextRequest, PickOutcome, SessionInput, SessionInputResolver, SubmitAttachment, SubmitOutcome, } from './contract/input.ts' -export type { ArbitrateKey, ArbitrateOutcome, DraftInput, DraftReference, DraftSnapshot, ReferenceInsert, TokenSpan } from './contract/draft-editor.ts' +export type { ArbitrateKey, ArbitrateOutcome, DraftInput, DraftQuote, DraftReference, DraftSnapshot, ReferenceInsert, TokenSpan } from './contract/draft-editor.ts' export type { ComposerBlock, ComposerBlocks } from './contract/composer-blocks.ts' declare module '@deepseek-ai/cordis' { @@ -113,3 +113,6 @@ declare module '@deepseek-ai/cordis' { uiConversation: import('./conversation/assembly.ts').UiConversation } } + +export type { TextInteractionDefinition, TextInteractionGesture, TextInteractionRequest, TextInteractionSurface, TextInteractionTrigger } from './contract/text-interaction.ts' +export type { ConversationInteractionRegistry } from './conversation/interaction-registry.ts' diff --git a/packages/client/ui-conversation/src/client/input/editor/DraftEditor.tsx b/packages/client/ui-conversation/src/client/input/editor/DraftEditor.tsx index d03ff6232c..a51ef34160 100644 --- a/packages/client/ui-conversation/src/client/input/editor/DraftEditor.tsx +++ b/packages/client/ui-conversation/src/client/input/editor/DraftEditor.tsx @@ -2,6 +2,7 @@ import type { CSSProperties, KeyboardEventHandler, ReactNode, RefObject } from 'react' import type { LexicalEditor } from 'lexical' import clsx from 'clsx' +import { HoverCardGroup } from '@deepseek-ai/dsh-client-ui-primitives' import type { InputState } from '../../contract/input.ts' import { ComposerContentEditable } from './ComposerContentEditable.tsx' import { DecoratorPortals } from './DecoratorPortals.tsx' @@ -34,7 +35,7 @@ export function DraftEditor({ }: DraftEditorProps): ReactNode { return (
-
+ )} -
+
) } diff --git a/packages/client/ui-conversation/src/client/input/editor/QuoteNode.tsx b/packages/client/ui-conversation/src/client/input/editor/QuoteNode.tsx new file mode 100644 index 0000000000..ecd42af89d --- /dev/null +++ b/packages/client/ui-conversation/src/client/input/editor/QuoteNode.tsx @@ -0,0 +1,152 @@ +/** Atomic composer quote node and whole-node removal command. */ + +import type { JSX } from 'react' +import { QuotePreview } from '@deepseek-ai/dsh-client-ui-primitives' +import { formatQuoteText } from '@deepseek-ai/dsh-client-ui-primitives' +import type { + EditorConfig, LexicalEditor, LexicalNode, NodeKey, SerializedLexicalNode, Spread, +} from 'lexical' +import { createCommand, DecoratorNode } from 'lexical' + +/** Localized non-persisted labels applied by the owning input shell. */ +export interface QuoteNodeLabels { + readonly remove: string +} + +const EMPTY_LABELS: QuoteNodeLabels = { remove: '' } + +/** JSON form used only by Lexical's editor-state APIs, not browser persistence. */ +export type SerializedQuoteNode = Spread<{ + text: string +}, SerializedLexicalNode> + +/** Remove one quote occurrence by its Lexical identity. */ +export const REMOVE_QUOTE_COMMAND = createCommand('REMOVE_QUOTE_COMMAND') + +/** Pure React face of one quote node. */ +export function QuoteNodeView({ editor, nodeKey, text, labels }: { + readonly editor: LexicalEditor + readonly nodeKey: NodeKey + readonly text: string + readonly labels: QuoteNodeLabels +}) { + return ( + { editor.dispatchCommand(REMOVE_QUOTE_COMMAND, nodeKey) }, + }} + /> + ) +} + +/** One selected transcript range represented as an atomic inline decorator. */ +export class QuoteNode extends DecoratorNode { + __text: string + __labels: QuoteNodeLabels + + /** Lexical node registry type tag. */ + static override getType(): string { + return 'quote' + } + + /** @param node - node to clone. @returns a copy carrying the same NodeKey. */ + static override clone(node: QuoteNode): QuoteNode { + return new QuoteNode( + node.__text, + node.__labels, + node.__key, + ) + } + + /** @param json - serialized editor node. @returns a fresh node with default runtime labels. */ + static override importJSON(json: SerializedQuoteNode): QuoteNode { + return new QuoteNode(json.text) + } + + /** + * @param text - selected text. + * @param labels - runtime-only localized labels. + * @param key - Lexical clone-path key; absent for fresh nodes. + */ + constructor( + text: string, + labels: QuoteNodeLabels = EMPTY_LABELS, + key?: NodeKey, + ) { + super(key) + this.__text = text + this.__labels = labels + } + + /** Serialize the quote data without its runtime presentation state. */ + override exportJSON(): SerializedQuoteNode { + return { + ...super.exportJSON(), + type: 'quote', + version: 1, + text: this.__text, + } + } + + /** Mount one non-editable inline host. */ + override createDOM(_config: EditorConfig): HTMLElement { + const element = document.createElement('span') + element.setAttribute('data-composer-quote', '') + element.setAttribute('contenteditable', 'false') + return element + } + + /** Updates reuse the existing host element. */ + override updateDOM(): boolean { + return false + } + + /** Quotes flow with draft text and wrap at their own text boundary. */ + override isInline(): boolean { + return true + } + + /** Arrow movement crosses the quote without entering a NodeSelection. */ + override isKeyboardSelectable(): boolean { + return false + } + + /** Clipboard text is the exact model-visible Markdown blockquote. */ + override getTextContent(): string { + return formatQuoteText(this.__text) + } + + /** Selected visible quote text. */ + getQuoteText(): string { + return this.getLatest().__text + } + + /** Update the localized remove label without changing persisted quote data. */ + setLabels(labels: QuoteNodeLabels): void { + if (this.getLatest().__labels.remove !== labels.remove) this.getWritable().__labels = labels + } + + /** Render the quote body and its independent close control. */ + override decorate(editor: LexicalEditor): JSX.Element { + return ( + + ) + } +} + +/** @param text - selected quote text. @param labels - runtime labels. @returns a fresh node. */ +export function $createQuoteNode(text: string, labels: QuoteNodeLabels): QuoteNode { + return new QuoteNode(text, labels) +} + +/** @param node - possible Lexical node. @returns whether it is a QuoteNode. */ +export function $isQuoteNode(node: LexicalNode | null | undefined): node is QuoteNode { + return node instanceof QuoteNode +} diff --git a/packages/client/ui-conversation/src/client/input/editor/keymap.ts b/packages/client/ui-conversation/src/client/input/editor/keymap.ts index b4fe576e19..a2573fdefc 100644 --- a/packages/client/ui-conversation/src/client/input/editor/keymap.ts +++ b/packages/client/ui-conversation/src/client/input/editor/keymap.ts @@ -16,11 +16,14 @@ */ import type { LexicalEditor } from 'lexical' import { + $getAdjacentNode, $getSelection, $isElementNode, $isLineBreakNode, $isRangeSelection, $isTextNode, + KEY_DOWN_COMMAND, MOVE_TO_END, COMMAND_PRIORITY_CRITICAL, KEY_ARROW_DOWN_COMMAND, KEY_ARROW_UP_COMMAND, KEY_ENTER_COMMAND, KEY_ESCAPE_COMMAND, KEY_SPACE_COMMAND, KEY_TAB_COMMAND, PASTE_COMMAND, } from 'lexical' import { mergeRegister } from '@lexical/utils' import type { ArbitrateKey, ArbitrateOutcome } from '../../contract/draft-editor.ts' +import { $isQuoteNode } from './QuoteNode.tsx' /** The bar-supplied behavior behind each intercepted gesture. */ export interface ComposerKeymapHandlers { @@ -51,6 +54,118 @@ function isComposingEvent(event: KeyboardEvent, recentlyComposing: () => boolean return event.isComposing || event.keyCode === 229 || recentlyComposing() } +function isApplePlatform(): boolean { + return typeof navigator !== 'undefined' && /Mac|iPod|iPhone|iPad/.test(navigator.platform) +} + +/** Whether the browser caret touches one side of an atomic quote element. */ +function domCaretTouches(element: HTMLElement, backward: boolean): boolean { + const selection = element.ownerDocument.defaultView?.getSelection() + if (selection === null || selection === undefined || !selection.isCollapsed) return false + const focus = selection.focusNode + if (focus === null) return false + const parent = element.parentNode + if (parent === null) return false + const offset = selection.focusOffset + if (focus === parent) { + return focus.childNodes.item(backward ? offset - 1 : offset) === element + } + if (focus.nodeType === Node.TEXT_NODE) { + const size = focus.textContent?.length ?? 0 + if (backward ? offset !== 0 : offset !== size) return false + } else if (backward ? offset !== 0 : offset !== focus.childNodes.length) { + return false + } + let boundary = focus + while (boundary.parentNode !== null && boundary.parentNode !== parent) { + if ((backward ? boundary.previousSibling : boundary.nextSibling) !== null) return false + boundary = boundary.parentNode + } + return boundary.parentNode === parent + && (backward ? boundary.previousSibling : boundary.nextSibling) === element +} + +/** Find an atomic quote immediately beside both editor and browser carets. */ +function adjacentQuote(editor: LexicalEditor, backward: boolean) { + const selection = $getSelection() + if (!$isRangeSelection(selection) || !selection.isCollapsed()) return null + const adjacent = $getAdjacentNode(selection.focus, backward) + if (!$isQuoteNode(adjacent)) return null + const element = editor.getElementByKey(adjacent.getKey()) + return element !== null && domCaretTouches(element, backward) ? adjacent : null +} + +function lineLeadingQuoteBeforeSelection(editor: LexicalEditor) { + let quote = adjacentQuote(editor, true) + if (quote === null) return null + let previous = quote.getPreviousSibling() + while (previous !== null) { + if ($isLineBreakNode(previous)) return quote + if ($isQuoteNode(previous)) { + quote = previous + previous = previous.getPreviousSibling() + continue + } + if (!$isTextNode(previous)) return null + const text = previous.getTextContent() + const lastBreak = text.lastIndexOf('\n') + if (lastBreak >= 0) return text.slice(lastBreak + 1) === '' ? quote : null + if (text.trim() !== '') return null + const precedingQuote = previous.getPreviousSibling() + if (!$isQuoteNode(precedingQuote)) return null + quote = precedingQuote + previous = quote.getPreviousSibling() + } + return quote +} + +/** Put the browser caret before an adjacent line-leading quote. */ +function moveBeforeQuote(editor: LexicalEditor, event: KeyboardEvent): boolean { + if (event.shiftKey) return false + const quote = lineLeadingQuoteBeforeSelection(editor) + if (quote === null) return false + const element = editor.getElementByKey(quote.getKey()) + if (element === null) return false + const view = element.ownerDocument.defaultView + if (view === null) return false + const domSelection = view.getSelection() + if (domSelection === null) return false + const range = element.ownerDocument.createRange() + range.setStartBefore(element) + range.collapse(true) + domSelection.removeAllRanges() + domSelection.addRange(range) + event.preventDefault() + return true +} + +/** Move from immediately before a quote to the end of its hard line. */ +function movePastQuote(editor: LexicalEditor, event: KeyboardEvent): boolean { + if (!isApplePlatform() || event.key !== 'ArrowRight' || !event.metaKey + || event.ctrlKey || event.altKey || event.shiftKey) return false + const quote = adjacentQuote(editor, false) + if (quote === null) return false + const block = quote.getParent() + if (!$isElementNode(block)) return false + let lineEnd = quote.getIndexWithinParent() + 1 + while (lineEnd < block.getChildrenSize()) { + const child = block.getChildAtIndex(lineEnd) + if ($isLineBreakNode(child)) break + if ($isTextNode(child)) { + const hardBreak = child.getTextContent().indexOf('\n') + if (hardBreak >= 0) { + event.preventDefault() + child.select(hardBreak, hardBreak) + return true + } + } + lineEnd += 1 + } + event.preventDefault() + block.select(lineEnd, lineEnd) + return true +} + /** * Register the composer keymap on one editor. * @param editor - the shell-owned editor. @@ -101,6 +216,13 @@ export function registerComposerKeymap(editor: LexicalEditor, handlers: Composer root?.addEventListener('compositionend', onCompositionEnd) syncComposition() }), + editor.registerCommand(KEY_DOWN_COMMAND, (event) => { + if (!isApplePlatform() || !event.ctrlKey || event.metaKey || event.altKey || event.shiftKey) return false + const key = event.key.toLowerCase() + if (key === 'a') return moveBeforeQuote(editor, event) + return false + }, COMMAND_PRIORITY_CRITICAL), + editor.registerCommand(MOVE_TO_END, event => movePastQuote(editor, event), COMMAND_PRIORITY_CRITICAL), editor.registerUpdateListener(syncComposition), editor.registerCommand(KEY_ARROW_UP_COMMAND, arrow('up'), COMMAND_PRIORITY_CRITICAL), editor.registerCommand(KEY_ARROW_DOWN_COMMAND, arrow('down'), COMMAND_PRIORITY_CRITICAL), diff --git a/packages/client/ui-conversation/src/client/input/editor/projection.ts b/packages/client/ui-conversation/src/client/input/editor/projection.ts index c48c97ad8a..dacc465d3b 100644 --- a/packages/client/ui-conversation/src/client/input/editor/projection.ts +++ b/packages/client/ui-conversation/src/client/input/editor/projection.ts @@ -11,8 +11,9 @@ import type { ElementNode, LexicalNode, NodeKey, Point } from 'lexical' import { $getRoot, $getSelection, $isElementNode, $isLineBreakNode, $isRangeSelection, $isTextNode, } from 'lexical' -import type { Occurrence } from '../../contract/draft-editor.ts' +import type { DraftQuote, Occurrence } from '../../contract/draft-editor.ts' import type { CommandClaim } from '../../contract/input.ts' +import { $isQuoteNode } from './QuoteNode.tsx' import { $isReferenceChipNode } from './chip-node.tsx' import { $isCommandChipNode } from './command-chip-node.tsx' @@ -81,7 +82,7 @@ export function $composerLayout(): ComposerLayout { const kids = element.getChildren() children.set(element.getKey(), kids.map(kid => kid.getKey())) for (const kid of kids) { - if ($isReferenceChipNode(kid) || $isCommandChipNode(kid)) { + if ($isReferenceChipNode(kid) || $isCommandChipNode(kid) || $isQuoteNode(kid)) { pushLeaf('chip', kid, ATOMIC_CHAR, kid.getTextContent()) } else if ($isTextNode(kid)) { const text = kid.getTextContent() @@ -160,6 +161,8 @@ export interface EditorProjection { readonly clipboardText: string /** InputState-compatible occurrence view (clipboardText coordinates). */ readonly occurrences: readonly Occurrence[] + /** Atomic quotes in clipboard coordinates. */ + readonly quotes: readonly DraftQuote[] /** Range selection in detect coordinates (ordered); null while absent or non-range. */ readonly selection: { readonly start: number; readonly end: number } | null /** Collapsed caret in detect coordinates; null while the selection is absent or ranged. */ @@ -200,8 +203,14 @@ export function $detectOffsetOfPoint(layout: ComposerLayout, point: Point): numb export function $projectComposer(idOf: (key: NodeKey) => number): EditorProjection { const layout = $composerLayout() const occurrences: Occurrence[] = [] + const quotes: DraftQuote[] = [] for (const segment of layout.segments) { - if (segment.kind !== 'chip' || !$isReferenceChipNode(segment.node)) continue + if (segment.kind !== 'chip') continue + if ($isQuoteNode(segment.node)) { + quotes.push({ offset: segment.clipboardStart, length: segment.clipboardLength, text: segment.node.getQuoteText() }) + continue + } + if (!$isReferenceChipNode(segment.node)) continue const chip = segment.node occurrences.push({ occurrenceId: idOf(chip.getKey()), @@ -229,6 +238,7 @@ export function $projectComposer(idOf: (key: NodeKey) => number): EditorProjecti detectText: layout.detectText, clipboardText: layout.clipboardText, occurrences, + quotes, selection: range, caret: range !== null && range.start === range.end ? range.start : null, command: $isCommandChipNode(lead) ? lead.getClaim() : undefined, diff --git a/packages/client/ui-conversation/src/client/input/editor/runtime.ts b/packages/client/ui-conversation/src/client/input/editor/runtime.ts index 31116db781..698ef7bda6 100644 --- a/packages/client/ui-conversation/src/client/input/editor/runtime.ts +++ b/packages/client/ui-conversation/src/client/input/editor/runtime.ts @@ -2,15 +2,19 @@ import type { ObservableSnapshot } from '@deepseek-ai/dsh-client-store' import type { LexicalEditor, NodeKey } from 'lexical' import { - $addUpdateTag, $createParagraphNode, $createTextNode, $getRoot, $getSelection, $isRangeSelection, - BLUR_COMMAND, CLEAR_HISTORY_COMMAND, COMMAND_PRIORITY_CRITICAL, createEditor, HISTORY_MERGE_TAG, PASTE_TAG, - RootNode, SELECTION_CHANGE_COMMAND, SKIP_DOM_SELECTION_TAG, + $addUpdateTag, $createParagraphNode, $createTextNode, $getAdjacentNode, $isTextNode, + $getRoot, $getSelection, $getNodeByKey, $isRangeSelection, + BLUR_COMMAND, CLEAR_HISTORY_COMMAND, COMMAND_PRIORITY_BEFORE_CRITICAL, COMMAND_PRIORITY_CRITICAL, createEditor, + HISTORY_MERGE_TAG, PASTE_TAG, + RootNode, SELECTION_CHANGE_COMMAND, SKIP_DOM_SELECTION_TAG, KEY_BACKSPACE_COMMAND, KEY_DELETE_COMMAND, HISTORY_PUSH_TAG, } from 'lexical' import { registerPlainText } from '@lexical/plain-text' import { createEmptyHistoryState, registerHistory } from '@lexical/history' import { mergeRegister } from '@lexical/utils' -import type { DraftReference, ReferenceInsert } from '../../contract/draft-editor.ts' +import type { DraftQuote, DraftReference, ReferenceInsert } from '../../contract/draft-editor.ts' import type { CommandClaim } from '../../contract/input.ts' +import { QuoteNode, $createQuoteNode, $isQuoteNode, REMOVE_QUOTE_COMMAND } from './QuoteNode.tsx' +import type { QuoteNodeLabels } from './QuoteNode.tsx' import { registerReferenceActivation } from './reference-activation.ts' import { ReferenceChipNode, $createReferenceChipNode } from './chip-node.tsx' import { CommandChipNode, $createCommandChipNode, registerCommandChipSeat } from './command-chip-node.tsx' @@ -26,6 +30,7 @@ type Lexicon = ReadonlyMap<'/' | '@', readonly string[]> /** Model callbacks read at the same editor registration and update points. */ interface DraftEditorRuntimeDeps { readonly onUpdate: () => void + readonly quoteLabels: () => QuoteNodeLabels readonly openReference: (source: string | undefined, reference: Pick) => boolean readonly lexicon: () => Lexicon readonly resolveLexicon: () => ObservableSnapshot | undefined @@ -62,7 +67,7 @@ export class DraftEditorRuntime { /** The editor bound by the Composer's contenteditable host. */ readonly editor: LexicalEditor private projected: EditorProjection = { - detectText: '', clipboardText: '', occurrences: [], selection: null, caret: null, command: undefined, + detectText: '', clipboardText: '', occurrences: [], quotes: [], selection: null, caret: null, command: undefined, } /** Stable occurrence ids per chip NodeKey (undo restores keys, so ids survive it too). */ private readonly occurrenceIds = new Map() @@ -75,7 +80,7 @@ export class DraftEditorRuntime { constructor(private readonly deps: DraftEditorRuntimeDeps) { this.editor = createEditor({ namespace: 'dsh-composer', - nodes: [ReferenceChipNode, CommandChipNode, TextRefNode], + nodes: [ReferenceChipNode, CommandChipNode, TextRefNode, QuoteNode], onError: (error) => { throw error }, }) } @@ -95,6 +100,10 @@ export class DraftEditorRuntime { } const unregister = mergeRegister( registerPlainText(this.editor), + this.editor.registerNodeTransform(QuoteNode, (node) => { node.setLabels(this.deps.quoteLabels()) }), + this.editor.registerCommand(REMOVE_QUOTE_COMMAND, key => this.removeQuote(key), COMMAND_PRIORITY_CRITICAL), + this.editor.registerCommand(KEY_BACKSPACE_COMMAND, event => this.deleteAdjacentQuote(event, true), COMMAND_PRIORITY_BEFORE_CRITICAL), + this.editor.registerCommand(KEY_DELETE_COMMAND, event => this.deleteAdjacentQuote(event, false), COMMAND_PRIORITY_BEFORE_CRITICAL), this.editor.registerCommand(BLUR_COMMAND, () => { // Finish this batch before an explicit focus can restore its updated selection. this.editor.update(preserveExternalSelection, { discrete: true }) @@ -186,7 +195,7 @@ export class DraftEditorRuntime { */ setDraft(text: string): void { const clean = text.replace(REFERENCE_PLACEHOLDER_RE, '') - if (clean === this.projection.clipboardText && this.projection.occurrences.length === 0) return + if (clean === this.projection.clipboardText && this.projection.occurrences.length === 0 && this.projection.quotes.length === 0) return this.editor.update(() => { $getRoot().clear() $appendParagraphs(clean) @@ -332,6 +341,60 @@ export class DraftEditorRuntime { return applied } + /** + * Insert one quote after the retained draft selection as an independent undo edit. + * @param text - nonblank selected transcript text. + * @param labels - current localized editor controls. + * @returns whether the insertion position accepted the node. + */ + insertQuote(text: string, labels: QuoteNodeLabels): boolean { + let applied = false + this.applyEdit(() => { + const projection = $projectComposer(key => this.occurrenceIdOf(key)) + const at = projection.selection?.end ?? projection.detectText.length + applied = $replaceDetectSpanWithNodes({ start: at, end: at }, [$createQuoteNode(text, labels), $createTextNode(' ')]) + }, PASTE_TAG) + return applied + } + + /** Remove one quote node and leave the editor caret at its former position. */ + private removeQuote(key: NodeKey): boolean { + const node = $getNodeByKey(key) + if (!$isQuoteNode(node)) return false + const parent = node.getParent() + const offset = node.getIndexWithinParent() + const separator = node.getNextSibling() + $addUpdateTag(HISTORY_PUSH_TAG) + if ($isTextNode(separator) && separator.getTextContent().startsWith(' ')) { + const remaining = separator.getTextContent().slice(1) + if (remaining === '') separator.remove() + else separator.setTextContent(remaining) + } + node.remove() + const caret = Math.min(offset, parent?.getChildrenSize() ?? 0) + parent?.select(caret, caret) + queueMicrotask(() => { this.editor.getRootElement()?.focus({ preventScroll: true }) }) + return true + } + + /** Delete the QuoteNode immediately beside one collapsed caret. */ + private deleteAdjacentQuote(event: KeyboardEvent, backward: boolean): boolean { + const selection = $getSelection() + if (!$isRangeSelection(selection) || !selection.isCollapsed()) return false + let adjacent = $getAdjacentNode(selection.anchor, backward) + if (!$isQuoteNode(adjacent) && backward && selection.anchor.type === 'text') { + const anchor = selection.anchor.getNode() + if ($isTextNode(anchor) + && selection.anchor.offset === 1 + && anchor.getTextContent().startsWith(' ')) { + adjacent = anchor.getPreviousSibling() + } + } + if (!$isQuoteNode(adjacent)) return false + event.preventDefault() + return this.removeQuote(adjacent.getKey()) + } + /** * Clear committed content using the model's suffix decision inside the editor update. * @param prefixLength - returns the clipboard-prefix length to remove, or null to clear the root. @@ -356,8 +419,10 @@ export class DraftEditorRuntime { * Import semantic content or a model-selected failure snapshot with fresh reference nodes. * @param draft - clipboard text. * @param occurrences - reference occurrences in clipboard order. + * @param quotes - atomic quotes in clipboard order. + * @param labels - localized quote controls. */ - restoreDraft(draft: string, occurrences: readonly DraftReference[]): void { + restoreDraft(draft: string, occurrences: readonly DraftReference[], quotes: readonly DraftQuote[] = [], labels: QuoteNodeLabels = { remove: '' }): void { this.editor.update(() => { const root = $getRoot() root.clear() @@ -375,15 +440,26 @@ export class DraftEditorRuntime { } } let cursor = 0 - for (const occurrence of occurrences) { + const spans = [ + ...occurrences.map(reference => ({ kind: 'reference' as const, value: reference })), + ...quotes.map(quote => ({ kind: 'quote' as const, value: quote })), + ].sort((a, b) => a.value.offset - b.value.offset) + for (const span of spans) { + const occurrence = span.value appendText(draft.slice(cursor, occurrence.offset)) + if (span.kind === 'quote') { + paragraph.append($createQuoteNode(span.value.text, labels)) + cursor = occurrence.offset + occurrence.length + continue + } + const reference = span.value paragraph.append(new ReferenceChipNode({ - source: occurrence.source, - ref: occurrence.ref, - label: occurrence.label, - ...(occurrence.appearance === undefined ? {} : { appearance: occurrence.appearance }), - clipboardText: occurrence.clipboardText, - }, occurrence.invalid === true)) + source: reference.source, + ref: reference.ref, + label: reference.label, + ...(reference.appearance === undefined ? {} : { appearance: reference.appearance }), + clipboardText: reference.clipboardText, + }, reference.invalid === true)) cursor = occurrence.offset + occurrence.length } appendText(draft.slice(cursor)) diff --git a/packages/client/ui-conversation/src/client/input/editor/view-binding.ts b/packages/client/ui-conversation/src/client/input/editor/view-binding.ts index dacb1796b8..524c8a37ea 100644 --- a/packages/client/ui-conversation/src/client/input/editor/view-binding.ts +++ b/packages/client/ui-conversation/src/client/input/editor/view-binding.ts @@ -2,7 +2,6 @@ import type { MouseEvent, MutableRefObject, RefObject } from 'react' import type { LexicalEditor } from 'lexical' import type { ComposerKeyboard } from '../../contract/draft-editor.ts' -import type { ComposerBarProps } from '../../contract/slots.ts' import type { BusyEnterBehavior } from '../../contract/composer-submission.ts' import { resolveSubmitMode } from '../submission-policy.ts' import { registerComposerKeymap } from './keymap.ts' @@ -16,8 +15,9 @@ interface DraftViewGate { busyEnter: BusyEnterBehavior intakeFiles: (files: readonly File[], directories?: ReadonlySet) => void uploadsPending: boolean + /** Toast text explaining why pending uploads hold the send. */ + uploadsHeldText: string showToast: (text: string) => void - t: ComposerBarProps['t'] canAcceptDrop: boolean } @@ -126,7 +126,7 @@ export function installDraftKeymap( return } if (g.uploadsPending) { - g.showToast(g.t('file.stillUploading')) + g.showToast(g.uploadsHeldText) return } keyboard.submit(resolveSubmitMode( diff --git a/packages/client/ui-conversation/src/client/input/facade.ts b/packages/client/ui-conversation/src/client/input/facade.ts index 1965b30d12..b62228f0ca 100644 --- a/packages/client/ui-conversation/src/client/input/facade.ts +++ b/packages/client/ui-conversation/src/client/input/facade.ts @@ -20,9 +20,10 @@ import type { SessionInput, SubmitAttempt, SubmitAttachment, SubmitOutcome, } from '../contract/input.ts' import type { - ArbitrateKey, ArbitrateOutcome, ComposerKeyboard, DraftInput, DraftSnapshot, Occurrence, ReferenceInsert, TokenSpan, + ArbitrateKey, ArbitrateOutcome, ComposerKeyboard, DraftQuote, DraftInput, DraftSnapshot, Occurrence, ReferenceInsert, TokenSpan, } from '../contract/draft-editor.ts' import type { InputSubmitMode, MessageSubmission, MessageSubmissionState } from '../contract/composer-submission.ts' +import { serializeDraftPrompt } from './prompt.ts' import { SubmitMachine } from './machine.ts' import { resolveDraftInput, snapshotDraft } from '../draft.ts' import { DraftEditorRuntime } from './editor/runtime.ts' @@ -63,6 +64,10 @@ export interface SessionInputDeps { mode: InputSubmitMode, signal: AbortSignal, ): Promise + /** Live Session and composer-block policy for transcript insertion. */ + quoteInsertionAllowed?: () => boolean + /** Current localized quote removal copy. */ + quoteRemoveLabel?: () => string /** Command-plane attachment plumbing (the hub owns the conversation face and the copy). */ commandAttachments: { /** Resolve ordered draft ids to wire payloads without sending them; rejects when an id no longer resolves. */ @@ -87,6 +92,10 @@ function guardOf(phase: InputState['phase']): 'plain' | 'claimed' | 'frozen' { function projectionContentChanged(prev: EditorProjection, next: EditorProjection): boolean { if (prev.clipboardText !== next.clipboardText || prev.detectText !== next.detectText) return true if (prev.command !== next.command) return true + if (prev.quotes.length !== next.quotes.length || prev.quotes.some((quote, i) => { + const old = next.quotes[i] + return old?.text !== quote.text || old.offset !== quote.offset + })) return true if (prev.occurrences.length !== next.occurrences.length) return true return next.occurrences.some((occ, i) => { const old = prev.occurrences[i] @@ -103,6 +112,7 @@ const EMPTY_LEXICON: ReadonlyMap<'/' | '@', readonly string[]> = new Map() interface DetachedDraft { readonly draft: string readonly occurrences: readonly Occurrence[] + readonly quotes: readonly DraftQuote[] readonly attachmentIds: readonly DraftAttachmentId[] } @@ -122,6 +132,7 @@ export class SessionInputShell implements SessionInput { } /** The public provide-channel action face (one stable identity per session). */ readonly actions: InputActions = { + insertQuote: text => this.insertQuote(text), captureInsertion: () => ({ ...this.caretSpan(), draftRev: this.rev }), insertText: (text, span) => { if (this.snapshot.phase === 'adjudicating' || this.snapshot.phase === 'submitting' || this.disposed) return false @@ -175,6 +186,7 @@ export class SessionInputShell implements SessionInput { constructor(private readonly deps: SessionInputDeps) { this.draftEditor = new DraftEditorRuntime({ onUpdate: () => { this.onEditorUpdate() }, + quoteLabels: () => ({ remove: this.deps.quoteRemoveLabel?.() ?? '' }), openReference: (source, reference) => this.deps.inputTriggers?.()?.openReference(source, reference) ?? false, lexicon: () => this.lexicon.getSnapshot(), @@ -221,13 +233,21 @@ export class SessionInputShell implements SessionInput { setDraft(text: DraftInput): void { const draft = resolveDraftInput(text) this.cancelCommandRestore() - if (draft.references.length === 0) this.draftEditor.setDraft(draft.text) - else this.draftEditor.restoreDraft(draft.text, draft.references) + if (draft.references.length === 0 && (draft.quotes?.length ?? 0) === 0) this.draftEditor.setDraft(draft.text) + else this.draftEditor.restoreDraft(draft.text, draft.references, draft.quotes, { remove: this.deps.quoteRemoveLabel?.() ?? '' }) if (draft.command === undefined) return this.pendingCommand = { token: draft.command.token, rev: this.rev } this.restoreCommand() } + /** @param text - selected transcript text. @returns whether the editable draft accepted the quote. */ + insertQuote(text: string): boolean { + if (this.disposed || this.snapshot.phase !== 'plain' || this.deps.quoteInsertionAllowed?.() === false || text.trim() === '') return false + const inserted = this.draftEditor.insertQuote(text.replace(/\r\n?/gu, '\n'), { remove: this.deps.quoteRemoveLabel?.() ?? '' }) + if (inserted) this.focus() + return inserted + } + /** * Reconnect optional catalog notifications and resume a pending command * chip restore without waiting for another edit. @@ -272,7 +292,9 @@ export class SessionInputShell implements SessionInput { /** Current semantic document, stable until its content revision changes. */ get draftSnapshot(): DraftSnapshot { if (this.draftSnapshotCache?.revision === this.rev) return this.draftSnapshotCache.value - const value = snapshotDraft(this.projection.clipboardText, this.projection.occurrences, this.projection.command?.token) + const value = snapshotDraft( + this.projection.clipboardText, this.projection.occurrences, this.projection.command?.token, this.projection.quotes, + ) this.draftSnapshotCache = { revision: this.rev, value } return value } @@ -744,45 +766,27 @@ export class SessionInputShell implements SessionInput { const attachmentIds = [...this.attachmentIds] this.attachmentIds = [] const occurrences = this.projection.occurrences - const record = { draft, occurrences, attachmentIds } + const quotes = this.projection.quotes + const record = { draft, occurrences, quotes, attachmentIds } this.detachedDrafts.set(attempt.seq, record) if (this.failedRestoreRev === this.rev) { this.failedDetached.clear() this.failedRestoreRev = undefined } - if (occurrences.length === 0) { - this.settleSink(attempt, this.deps.defaultSink(draft.trim(), attachmentIds, mode, attempt.signal)) - return - } const inputTriggers = this.deps.inputTriggers?.() - void Promise.all(occurrences.map(async (o) => { - if (inputTriggers === undefined) throw new Error(`no serializer for reference source "${o.source}"`) - return { - offset: o.offset, - length: o.length, - text: await inputTriggers.serializeReference(o.source, o.ref, attempt.signal), - } - })).then( - (parts) => { - if (this.disposed) return - // Splice model forms over their clipboard ranges (offsets are - // clipboard-projection; parts arrive offset-sorted since chips walk in - // document order). - let out = '' - let cursor = 0 - for (const part of parts) { - out += draft.slice(cursor, part.offset) + part.text - cursor = part.offset + part.length - } - out += draft.slice(cursor) - this.settleSink(attempt, this.deps.defaultSink(out.trim(), attachmentIds, mode, attempt.signal)) - }, - (error: unknown) => { - if (this.dead(attempt)) return - const message = error instanceof Error ? error.message : String(error) - this.settleDetachedFailure(attempt, message) - }, - ) + const prompt = serializeDraftPrompt(snapshotDraft(draft, occurrences, undefined, quotes), async (source, ref) => { + if (inputTriggers === undefined) throw new Error(`no serializer for reference source "${source}"`) + return inputTriggers.serializeReference(source, ref, attempt.signal) + }) + const send = (text: string): void => { + if (this.dead(attempt)) return + this.settleSink(attempt, this.deps.defaultSink(text, attachmentIds, mode, attempt.signal)) + } + if (!(prompt instanceof Promise)) { send(prompt); return } + void prompt.then(send, (error: unknown) => { + if (this.dead(attempt)) return + this.settleDetachedFailure(attempt, error instanceof Error ? error.message : String(error)) + }) } private notifySubmission(submission: MessageSubmission | undefined): void { @@ -832,17 +836,19 @@ export class SessionInputShell implements SessionInput { const separator = '\n\n' let draft = '' const occurrences: Occurrence[] = [] + const quotes: DraftQuote[] = [] for (const record of records) { const base = draft.length + (draft === '' ? 0 : separator.length) if (draft !== '') draft += separator draft += record.draft + quotes.push(...record.quotes.map(quote => ({ ...quote, offset: base + quote.offset }))) for (const occurrence of record.occurrences) { occurrences.push({ ...occurrence, offset: base + occurrence.offset }) } } this.restoringFailures = true try { - this.draftEditor.restoreDraft(draft, occurrences) + this.draftEditor.restoreDraft(draft, occurrences, quotes, { remove: this.deps.quoteRemoveLabel?.() ?? '' }) this.draftEditor.clearHistory() this.failedRestoreRev = this.rev } finally { diff --git a/packages/client/ui-conversation/src/client/input/hub.ts b/packages/client/ui-conversation/src/client/input/hub.ts index fb8d8a736f..6ab4c5b4a9 100644 --- a/packages/client/ui-conversation/src/client/input/hub.ts +++ b/packages/client/ui-conversation/src/client/input/hub.ts @@ -5,7 +5,6 @@ * returns; Session-scope disposal releases the shell and its resources. */ import type {} from '@deepseek-ai/dsh-client-product-analytics/client' -import type { ModelSelectionProjection } from '@deepseek-ai/dsh-api-session-controller/types' import type { PlanProjection } from '@deepseek-ai/dsh-plan-mode/types' import type { GoalProjection } from '@deepseek-ai/dsh-goal/types' import type { Context } from '@deepseek-ai/cordis' @@ -15,12 +14,14 @@ import type { import type { SessionId } from '@deepseek-ai/dsh-session/types' import type { TranslateNS } from '@deepseek-ai/dsh-client-locale/client' import type { InboxState } from '@deepseek-ai/dsh-agent/types' +import type { ModelSelectionProjection } from '@deepseek-ai/dsh-agent/types' import type { ObservableSnapshot } from '@deepseek-ai/dsh-client-store' import type { DraftAttachmentId, DraftAttachmentSerializationResult, DraftInitializationOptions, DraftInitializationResult, InputTriggerController, SessionInputResolver, SessionInput, SubmitOutcome, } from '../contract/input.ts' import type { ComposerKeyboard } from '../contract/draft-editor.ts' +import type { ComposerBlocks } from '../contract/composer-blocks.ts' import type { InputSubmitMode } from '../contract/composer-submission.ts' import type { PopupDismissFace } from './facade.ts' import { SessionInputShell } from './facade.ts' @@ -40,6 +41,7 @@ interface InputTriggerServiceFace { /** Attachment-send face resolved lazily to keep hub/service construction acyclic. */ interface ConversationAttachmentFace { + readonly blocks: ComposerBlocks sendSession( session: SessionFace, text: string, @@ -104,6 +106,7 @@ export class InputHub implements SessionInputResolver { const model = session.projections.faceOf('modelSelection').getSnapshot() as ModelSelectionProjection | undefined const plan = session.projections.faceOf('plan').getSnapshot() as PlanProjection | undefined const goal = session.projections.faceOf('goal').getSnapshot() as GoalProjection | null | undefined + const agentPreset = session.projections.faceOf('agentPreset').getSnapshot() const selection = model?.next ?? model?.lastUsed return Object.freeze({ ...state.blank ? {} : { sessionId: state.sessionId }, @@ -113,6 +116,8 @@ export class InputHub implements SessionInputResolver { }) }, runMode: plan?.active ? 'plan' : goal?.goal.phase === 'active' ? 'goal' : 'default', running: state.running, + newSession: state.blank, + ...typeof agentPreset === 'string' ? { agentPreset } : {}, }) }, messageSubmitted: (submission) => { reportMessageSubmission(this.rootCtx, submission) }, @@ -120,6 +125,13 @@ export class InputHub implements SessionInputResolver { popup: () => this.popup(actx), inbox: session.projections.faceOf('inbox') as ObservableSnapshot, defaultSink: (text, attachmentIds, mode, signal) => this.sink(session, text, attachmentIds, mode, signal), + quoteRemoveLabel: () => this.t('quote.remove'), + quoteInsertionAllowed: () => { + const state = session.getSnapshot() + return !state.removed && (state.subagent === null + || (state.subagent.address.mode === 'continuable' && state.subagent.parentAvailable === true)) + && this.conversation().blocks.storeFor(session.sessionId).getSnapshot() === undefined + }, steerQueue: () => { void this.steerQueue(session, shell) }, commandAttachments: { serialize: async (ids) => { diff --git a/packages/client/ui-conversation/src/client/input/prompt.ts b/packages/client/ui-conversation/src/client/input/prompt.ts new file mode 100644 index 0000000000..548256599c --- /dev/null +++ b/packages/client/ui-conversation/src/client/input/prompt.ts @@ -0,0 +1,88 @@ +/** Serialize semantic draft spans without turning quoted references into gestures. */ +import { formatQuoteText } from '@deepseek-ai/dsh-client-ui-primitives' +import type { DraftSnapshot } from '../contract/draft-editor.ts' + +type DraftToken = { readonly kind: 'text'; text: string } | { readonly kind: 'quote'; readonly text: string } +interface DraftSpan { + readonly offset: number + readonly length: number + readonly token: DraftToken +} + +const QUOTE_SEPARATOR = '\n\n' + +function pushText(tokens: DraftToken[], text: string): void { + if (text === '') return + const last = tokens.at(-1) + if (last?.kind === 'text') last.text += text + else tokens.push({ kind: 'text', text }) +} + +function normalizeQuoteBoundaries(tokens: DraftToken[]): DraftToken[] { + const normalized: DraftToken[] = [] + for (const token of tokens) { + if (token.kind === 'text') { + pushText(normalized, token.text) + continue + } + const previous = normalized.at(-1) + if (previous?.kind === 'text') { + previous.text = previous.text.trimEnd() + if (previous.text !== '') previous.text += QUOTE_SEPARATOR + else normalized.pop() + } + if (normalized.at(-1)?.kind === 'quote') { + normalized.push({ kind: 'text', text: QUOTE_SEPARATOR }) + } + normalized.push(token) + } + for (let index = 0; index < normalized.length; index++) { + const token = normalized[index] + if (token?.kind !== 'quote') continue + const next = normalized[index + 1] + if (next?.kind !== 'text') continue + if (next.text === QUOTE_SEPARATOR && normalized[index + 2]?.kind === 'quote') continue + next.text = next.text.trimStart() + if (next.text !== '') next.text = `${QUOTE_SEPARATOR}${next.text}` + } + return normalized.filter(token => token.kind === 'quote' || token.text !== '') +} + +/** + * Serialize the captured draft through reference codecs, formatting quotes as Markdown. + * @param draft - one semantic draft revision. + * @param serializeReference - owner-routed reference serializer. + * @returns synchronous content without references, otherwise the resolving promise. + */ +export function serializeDraftPrompt( + draft: DraftSnapshot, + serializeReference: (source: string, ref: string) => Promise, +): string | Promise { + const build = (references: readonly DraftSpan[]): string => { + const spans = [ + ...references, + ...(draft.quotes ?? []).map(quote => ({ offset: quote.offset, length: quote.length, + token: { kind: 'quote' as const, text: quote.text } })), + ].sort((a, b) => a.offset - b.offset) + const tokens: DraftToken[] = [] + let cursor = 0 + for (const span of spans) { + pushText(tokens, draft.text.slice(cursor, span.offset)) + if (span.token.kind === 'text') pushText(tokens, span.token.text) + else tokens.push(span.token) + cursor = span.offset + span.length + } + pushText(tokens, draft.text.slice(cursor)) + const first = tokens[0] + if (first?.kind === 'text') first.text = first.text.trimStart() + const last = tokens.at(-1) + if (last?.kind === 'text') last.text = last.text.trimEnd() + return normalizeQuoteBoundaries(tokens) + .map(token => token.kind === 'quote' ? formatQuoteText(token.text) : token.text).join('') + } + if (draft.references.length === 0) return build([]) + return Promise.all(draft.references.map(async reference => ({ + offset: reference.offset, length: reference.length, + token: { kind: 'text' as const, text: await serializeReference(reference.source, reference.ref) }, + }))).then(build) +} diff --git a/packages/client/ui-conversation/src/client/input/submission-analytics.ts b/packages/client/ui-conversation/src/client/input/submission-analytics.ts index a0f1a8e1c9..1c0e268006 100644 --- a/packages/client/ui-conversation/src/client/input/submission-analytics.ts +++ b/packages/client/ui-conversation/src/client/input/submission-analytics.ts @@ -1,8 +1,23 @@ /** Maps ordinary message occurrences to Desktop product events. */ import type { Context } from '@deepseek-ai/cordis' -import type {} from '@deepseek-ai/dsh-client-product-analytics/client' +import type { AnalyticsPreset } from '@deepseek-ai/dsh-client-product-analytics/client' import type { MessageSubmission } from '../contract/composer-submission.ts' +/** + * Map a preset id to its fixed analytics category; custom ids never leave the Client. + * @param id - Agent preset id the Session records. + * @returns the built-in category, or other. + */ +function analyticsPreset(id: string): AnalyticsPreset | 'other' { + switch (id) { + case 'cordis': return 'creator' + case 'ptc': return 'ptc' + case 'minimal': return 'minimal' + case 'standard': return 'standard' + default: return 'other' + } +} + /** * Report the original message occurrence without reading newer Session facts. * @param ctx - client context. @@ -17,5 +32,7 @@ export function reportMessageSubmission(ctx: Context, submission: MessageSubmiss ...model === undefined ? {} : { model_name: `${model.provider}/${model.name}`, ...model.effort === undefined ? {} : { thinking_effort: model.effort } }, run_mode: state.runMode, msg_type: state.running ? mode : 'default', + is_new_session: state.newSession, + ...state.agentPreset === undefined ? {} : { agent_preset: analyticsPreset(state.agentPreset) }, }, timestamp) } diff --git a/packages/client/ui-conversation/src/client/locales.ts b/packages/client/ui-conversation/src/client/locales.ts index 5a221bc9a1..cde0c51e72 100644 --- a/packages/client/ui-conversation/src/client/locales.ts +++ b/packages/client/ui-conversation/src/client/locales.ts @@ -9,6 +9,7 @@ const PLAN_NEXT_ACTION_EN = 'describe your task to generate plan' /** Simplified Chinese dictionary (the key-set source of truth). */ export const zh = { + 'quote.remove': '移除引用', 'shortcut.newline': '换行', 'shortcut.complementary': '使用互补的 Queue/Steer 方式', 'shortcut.slash': '打开命令菜单', @@ -390,6 +391,7 @@ export type ConversationKey = keyof typeof zh /** English dictionary, checked complete against the zh key set. */ export const en = { + 'quote.remove': 'Remove quote', 'shortcut.newline': 'New line', 'shortcut.complementary': 'Use the complementary Queue / Steer action', 'shortcut.slash': 'Open command menu', @@ -767,3 +769,382 @@ export const en = { 'terminal.sendInput': '(send input)', 'terminal.session': 'Terminal {sessionId}', } satisfies Record + +/** Japanese copy, checked against the English keys. */ +export const ja = { + 'quote.remove': '引用を削除', + 'shortcut.newline': '改行', + 'shortcut.complementary': '補助のキュー / 実行中の指示変更を使用', + 'shortcut.slash': 'コマンドメニューを開く', + 'shortcut.mention': '参照メニューを開く', + 'hint.plan': '計画を生成するにはタスクを説明してください', + 'hint.goal': '長時間実行するタスクの目標を説明してください', + 'hint.goal.active': '目標アクティブ — 編集 / 一時停止 / 再開 / クリア', + 'placeholder.plan': '計画を生成するにはタスクを説明してください', + 'placeholder.default': 'メッセージを送信するかタスクを実行、/ コマンド、@ ファイルやセッション', + 'placeholder.unavailable': 'セッションを利用できません', + 'placeholder.parentOffline': '親セッションがオフラインです。送信はできませんが、実行は停止できます', + 'placeholder.hero': '作りたいものを説明してください。/ コマンド、@ ファイルやセッション', + 'placeholder.workspace': '開始するワークスペースを選択', + 'placeholder.steerQueue': 'Cmd/Ctrl+Enterでキュー内のすべてのメッセージを実行中の指示に追加', + 'input.commands': 'ファイルを追加またはコマンドを実行', + 'input.file': 'ファイル', + 'input.stop': '生成を停止', + 'input.send': 'メッセージを送信', + 'input.send.queue': 'メッセージをキューに追加', + 'input.send.steer': '実行中の指示にメッセージを追加', + 'attachment.pending': '保留中の添付ファイル', + 'attachment.scrollLeft': '添付ファイルを左にスクロール', + 'attachment.scrollRight': '添付ファイルを右にスクロール', + 'attachment.dropTitle': 'ファイルや画像をここにドラッグして追加', + 'attachment.dropDesc': '画像の上限: 最大{count}枚、各{size}', + 'attachment.dropBlocked': '現在ファイルや画像を追加できません', + 'attachment.directoryDesktopOnly': 'フォルダはデスクトップアプリでのみ追加できます。ブラウザでは個別のファイルを追加してください', + 'attachment.pathUnavailable': 'フォルダのパスを取得できませんでした。もう一度ドラッグしてください', + 'attachment.pathUnsupported': 'パスに参照できない文字が含まれています。名前を変更して再試行してください', + 'image.pending': '保留中の画像', + 'image.openOriginal': '元の画像を表示', + 'image.openOriginalLabel': '{label}、クリックで元の画像を表示', + 'image.remove': '画像{name}を削除', + 'image.original': '元の画像', + 'image.label': '画像', + 'image.loadFailed': '画像を読み込めませんでした。クリックで再試行', + 'image.loading': '画像を読み込み中…', + 'image.preview': '元の画像のプレビュー', + 'image.closePreview': '元の画像のプレビューを閉じる', + 'image.unsupportedType': '対応している画像形式はPNG、JPG、WebP、GIFのみです', + 'image.tooMany': '1つのメッセージに最大{count}枚の画像を含められます', + 'image.fileTooLarge': '各画像は{size}未満である必要があります', + 'image.totalTooLarge': '画像の合計が{size}を超えています。一部を削除して再試行してください', + 'image.tooManyPixels': '画像の解像度が高すぎます。圧縮して再試行してください', + 'image.dimensionTooLarge': '画像の各辺は最大{size}pxです。縮小して再試行してください', + 'image.modelUnsupported': '現在のモデルは画像に対応していません。対応モデルに切り替えてください', + 'image.sendFailed': '画像の送信に失敗しました({reason})。再度追加して再試行してください', + 'file.pending': '保留中のファイル', + 'file.remove': 'ファイル{name}を削除', + 'file.uploading': 'アップロード中…', + 'file.uploadFailed': 'アップロードに失敗しました。クリックで再試行', + 'file.retry': '{name}のアップロードを再試行', + 'file.stillUploading': 'ファイルをアップロード中です。完了後に送信してください', + 'file.sessionUnavailable': 'セッションを利用できないため、ファイルをアップロードできません', + 'file.notStaged': 'ファイルのアップロードが完了していません。再度追加して再試行してください', + 'file.label': 'ファイル', + 'context.aria': 'コンテキストの{percent}を使用中', + 'context.used': 'コンテキスト使用量', + 'context.system': 'システムプロンプト', + 'context.tools': 'ツール定義', + 'context.messages': 'メッセージ', + 'settings.enter.title': '実行中の送信動作', + 'settings.enter.description': 'エージェントの実行中にEnterキーと送信ボタンが行う動作。Cmd/Ctrl+Enterではもう一方の動作になります', + 'settings.enter.queue': 'キュー', + 'settings.enter.steer': '指示を変更', + 'hero.headline': '未知への挑戦', + 'hero.preview': 'プレビュー', + 'hero.chooseWorkspace': 'ワークスペースを選択', + 'session.hierarchy': 'セッション階層', + 'session.moreViews': 'その他の会話ビュー', + 'todo.title': 'ToDo', + 'todo.progress.done': '{done}件完了', + 'todo.progress.active': '{active}件進行中', + 'todo.progress.pending': '{pending}件保留中', + 'todo.status.completed': '完了', + 'todo.status.inProgress': '進行中', + 'todo.status.pending': '保留中', + 'todo.rowTitle': 'ToDoリストを更新', + 'tool.title.createGoal': '目標を作成', + 'tool.title.getGoal': '目標を表示', + 'tool.title.updateGoal': '目標を更新', + 'tool.preparing.content': '{kilobytes}KB', + 'tool.title.createSchedule': 'リマインダーを作成', + 'tool.title.listSchedules': 'リマインダーを一覧表示', + 'tool.title.deleteSchedule': 'リマインダーを削除', + 'tool.title.updateSchedule': 'リマインダーを更新', + 'detail.state': 'ステータス', + 'detail.todo.completed': '完了', + 'detail.todo.in_progress': '進行中', + 'detail.todo.pending': '保留中', + 'detail.todo.empty': 'ToDoリストは空です', + 'todo.diff.initial': '初期リスト', + 'todo.diff.compare': '前回のリストからの変更', + 'todo.diff.unavailable': '前回のリストは利用できません', + 'todo.diff.noChanges': 'リストに変更はありません', + 'todo.diff.added': '{count}件追加', + 'todo.diff.updated': '{count}件更新', + 'todo.diff.removed': '{count}件削除', + 'todo.diff.unchanged': '{count}件変更なし', + 'todo.diff.addedItem': '追加', + 'todo.diff.updatedItem': 'ステータス変更', + 'todo.diff.movedItem': '順序変更', + 'todo.diff.removedItem': '削除', + 'detail.goal.empty': '目標なし', + 'detail.goal.active': 'アクティブ', + 'detail.goal.disarmed': '継続待ち', + 'detail.goal.paused': '一時停止', + 'detail.goal.blocked': 'ブロック中', + 'detail.goal.complete': '完了', + 'detail.goal.rounds': 'ラウンド', + 'detail.goal.reason': 'ブロッカー', + 'detail.days': '{count} 日', + 'detail.hours': '{count} 時間', + 'detail.minutes': '{count} 分', + 'detail.seconds': '{count} 秒', + 'detail.schedule.once': '1回のみ', + 'detail.schedule.every': '{interval} ごと', + 'detail.schedule.when': 'スケジュール設定', + 'detail.schedule.frequency': '繰り返し', + 'detail.schedule.scheduled': 'スケジュール済み', + 'detail.schedule.overdue': '期限超過、セッション再開待ち', + 'detail.schedule.empty': 'リマインダーなし', + 'detail.schedule.deleted': '削除済み', + 'detail.schedule.count': '{count}件のリマインダー', + 'detail.schedule.daily': '毎日 {time} ({zone})', + 'detail.schedule.weekly': '毎週 {days} の {time} ({zone})', + 'detail.schedule.cron': 'Cron {expression} ({zone})', + 'detail.weekday.1': '月', + 'detail.weekday.2': '火', + 'detail.weekday.3': '水', + 'detail.weekday.4': '木', + 'detail.weekday.5': '金', + 'detail.weekday.6': '土', + 'detail.weekday.7': '日', + 'detail.weekday.join': ', ', + 'tool.title.inspectProviders': 'プロバイダーを検査', + 'tool.title.queryRuntime': 'ランタイムを照会', + 'tool.title.inspectPlugins': 'プラグインを検査', + 'tool.title.workflow': 'ワークフローを実行', + 'tool.title.ralph': 'ralph ループを実行', + 'tool.title.readEvent': 'イベントを読み取り', + 'tool.title.searchEvents': 'イベントを検索', + 'tool.title.traceEvent': 'イベントをトレース', + 'tool.title.searchSessions': 'セッションを検索', + 'tool.title.traceSession': 'セッションをトレース', + 'tool.title.listModels': 'モデルを一覧表示', + 'tool.title.subagent': 'サブエージェントを作成', + 'tool.title.listAgents': 'サブエージェントを一覧表示', + 'tool.title.sendMessage': 'メッセージを送信', + 'tool.title.interruptAgent': 'エージェントを中断', + 'tool.title.listJobs': 'バックグラウンドジョブを一覧表示', + 'tool.title.readJob': 'ジョブ出力を読み取り', + 'tool.title.killJob': 'バックグラウンドジョブをキャンセル', + 'tool.title.openTerminal': 'ターミナルを開く', + 'tool.title.readTerminal': 'ターミナルを読み取り', + 'tool.title.listTerminals': 'ターミナルを一覧表示', + 'tool.title.signalTerminal': 'ターミナルにシグナル送信', + 'tool.title.closeTerminal': 'ターミナルを閉じる', + 'tool.title.lsp': 'コードシンボルを照会', + 'tool.title.findDefinition': '定義を検索', + 'tool.title.findReferences': '参照を検索', + 'tool.title.findImplementation': '実装を検索', + 'tool.title.hoverSymbol': 'シンボルを検査', + 'tool.title.spawnTeammate': 'チームメイトを作成', + 'tool.title.createTeamTask': 'チームタスクを作成', + 'tool.title.getTeamTask': 'チームタスクを読み取り', + 'tool.title.updateTeamTask': 'チームタスクを更新', + 'tool.title.listTeamTasks': 'チームタスクを一覧表示', + 'tool.title.waitAgent': 'サブエージェントを待機', + 'detail.recordedResult': '記録された結果', + 'detail.empty': '結果なし', + 'detail.none': 'なし', + 'detail.yes': 'はい', + 'detail.no': 'いいえ', + 'detail.moreInInspect': 'Inspect で残り {count}件を表示できます', + 'detail.status.running': '実行中', + 'detail.status.idle': '待機中', + 'detail.status.ready': '準備完了', + 'detail.status.inactive': '非アクティブ', + 'detail.status.provisioning': 'プロビジョニング中', + 'detail.status.failed': '失敗', + 'detail.status.completed': '完了', + 'detail.status.deleted': '削除済み', + 'detail.status.killed': 'キャンセル済み', + 'detail.status.accepted': '承認済み', + 'detail.status.queued': 'キュー待ち', + 'detail.status.exited': '終了', + 'detail.field.id': 'ID', + 'detail.field.revision': 'リビジョン', + 'detail.field.platform': 'プラットフォーム', + 'detail.field.provider': 'プロバイダー', + 'detail.field.model': 'モデル', + 'detail.field.role': 'ロール', + 'detail.field.context': 'コンテキスト', + 'detail.field.owner': 'オーナー', + 'detail.field.ready': '準備完了', + 'detail.field.dependencies': '依存関係', + 'detail.field.writeScopes': '書き込みスコープ', + 'detail.field.warnings': '警告', + 'detail.field.diagnostics': '診断', + 'detail.field.methods': 'メソッド', + 'detail.field.inputSchema': '入力スキーマ', + 'detail.field.outputSchema': '出力スキーマ', + 'detail.field.currentPackage': '現在のパッケージ', + 'detail.field.nextPackage': '次のパッケージ', + 'detail.field.latestRun': '最新の実行', + 'detail.field.packages': 'パッケージ', + 'detail.field.registrations': '登録', + 'detail.field.props': 'Props', + 'detail.field.data': 'データ', + 'detail.field.source': 'ソース', + 'detail.field.content': '内容', + 'detail.field.message': 'メッセージ', + 'detail.field.messageId': 'メッセージ ID', + 'detail.field.root': 'ルート', + 'detail.field.pid': 'プロセス ID', + 'detail.field.type': '種類', + 'detail.field.time': '時刻', + 'detail.field.seq': 'イベントシーケンス', + 'detail.field.turn': 'ターン', + 'detail.field.step': 'ステップ', + 'detail.field.callId': '呼び出し ID', + 'detail.field.agents': '起動したエージェント', + 'detail.field.result': '結果', + 'detail.field.parent': '親', + 'detail.field.depth': '深さ', + 'detail.field.exitCode': '終了コード', + 'detail.field.signal': 'シグナル', + 'detail.field.previousStatus': '以前のステータス', + 'detail.field.agent': 'エージェント ID', + 'detail.field.job': 'ジョブ ID', + 'detail.field.task': 'タスク', + 'detail.field.processGroup': 'プロセスグループ', + 'detail.field.availability': '可用性', + 'detail.field.bestMatch': '最適な一致', + 'detail.field.target': '対象イベント', + 'detail.field.surface': 'レコードステータス', + 'detail.agents.count': '{count} エージェント', + 'detail.jobs.count': '{count} バックグラウンドジョブ', + 'detail.terminals.count': '{count} ターミナル', + 'detail.tasks.count': '{count} チームタスク', + 'detail.tasks.nextPage': 'さらにタスクがあります。次のカーソルは {cursor} です', + 'detail.locations.count': '{count}件の場所', + 'detail.location': '{line} 行目、{column} 列目', + 'detail.receipt.delivered': 'メッセージを配信しました', + 'detail.receipt.interrupt': '中断を要求しました', + 'detail.receipt.started': '開始済み', + 'detail.receipt.cancel': 'キャンセルを要求しました', + 'detail.receipt.alreadyFinished': '既に完了', + 'detail.receipt.signal': 'シグナル送信済み', + 'detail.receipt.closed': 'クローズ済み', + 'detail.receipt.closing': 'クローズ中', + 'detail.wait.noProgress': 'アクティブなサブエージェントなし', + 'detail.wait.title': 'サブエージェントのアクティビティ', + 'detail.wait.timeout': '待機がタイムアウトしました', + 'detail.wait.changed': '変更を検出', + 'detail.agent.reply': 'エージェントの応答', + 'detail.models.title': '利用可能なモデル', + 'detail.output.lines': '{total} 行中 {begin}–{end} 行', + 'detail.output.truncated': '出力は切り詰められています', + 'detail.providers.count': '{count}件の inspect プロバイダー', + 'detail.plugins.count': '{count}件の動的プラグイン', + 'detail.workflow.script': 'ワークフロースクリプト', + 'detail.ralph.reportedComplete': 'ワーカーが完了を報告', + 'detail.ralph.reportedBlocker': 'ワーカーがブロッカーを報告', + 'detail.ralph.limit': 'ラウンド上限に達しました', + 'detail.report.nextSteps': '残りの作業', + 'detail.trace.replacedBy': '置き換え元', + 'detail.trace.replacementChain': '置き換えチェーン', + 'detail.trace.replaces': '置き換えられたイベント', + 'detail.trace.sources': 'ソースイベント', + 'detail.trace.derived': '派生イベント', + 'detail.trace.ancestors': '祖先セッション', + 'detail.trace.descendants': '子孫セッション', + 'detail.matches.count': '{count}件の一致', + 'detail.matches.capped': '結果の上限に達しました。さらに絞り込んでください', + 'detail.event.neighbors': '周辺のイベント', + 'todo.completed': '{done}/{total} 完了', + 'command.attachmentsUnsupported': '/{command} は添付ファイルを受け付けません。先に削除してください', + 'ask.rowTitle': '質問する', + 'ask.waiting': '待機中', + 'ask.pending': '継続中。引き続き回答できます', + 'ask.pendingDetail': 'これらの保留中の質問は、コンポーザーから引き続き回答できます。', + 'ask.reopen': '回答', + 'ask.review': '回答を表示', + 'ask.closed': 'クローズ済み', + 'ask.closedDetail': 'この質問はクローズされました。結果は下の会話にあります。', + 'ask.cancelled': 'キャンセル済み', + 'ask.cancelledDetail': 'この質問セットは回答が送信される前にキャンセルされました。', + 'ask.interrupted': '中断済み', + 'ask.interruptedDetail': 'この質問セットは回答が送信される前に中断されました。', + 'ask.answered': '{answered}/{total} 回答済み', + 'ask.skipped': '未回答', + 'bash.running': '実行中', + 'bash.failed': '失敗', + 'bash.stopped': '停止', + 'row.running': '実行中', + 'row.preparing': 'ツール呼び出しを準備中', + 'row.failed': '失敗', + 'row.stopped': '停止', + 'row.commandArguments': 'コマンド引数', + 'row.input': 'IN', + 'row.output': 'OUT', + 'row.inspect': '検査', + 'row.imageSize': '{width}x{height}px、{bytes} バイト', + 'row.imageSizeDownscaled': '{width}x{height}px({originalWidth}x{originalHeight}px から縮小)、{bytes} バイト', + 'tool.title.search': '検索', + 'tool.title.read': '読み取り', + 'tool.title.bash': 'Bash', + 'tool.title.write': '書き込み', + 'tool.title.edit': '編集', + 'tool.title.code': 'コード', + 'tool.title.generic': 'ツール呼び出し', + 'tool.title.inspect': 'Cordis 環境を照会', + 'tool.title.runCordis': 'Cordis プラグインを実行', + 'tool.title.stopCordis': 'Cordis プラグインを停止', + 'tool.title.removeCordis': 'Cordis プラグインを削除', + 'tool.title.pwsh': 'Pwsh', + 'tool.title.readImage': '画像を読み取り', + 'tool.title.grep': 'Grep', + 'tool.title.glob': 'Glob', + 'tool.title.webSearch': '検索', + 'tool.title.webFetch': '取得', + 'tool.autoReviewRejected': '自動認可レビューにより拒否されました', + 'tool.autoReviewNotExecuted': 'ツールは実行されませんでした。理由: {reason}', + 'tool.autoReviewReasonFallback': '自動認可レビューがこの操作を承認しませんでした', + 'diff.collapseAria': '差分を折りたたむ', + 'diff.expandAria': '差分の残り{count}行を表示', + 'diff.expandRest': '… 残り{count}行', + 'read.window': '{total}行中{shown}行を表示', + 'read.collapseAria': '内容を折りたたむ', + 'read.expandAria': '残り{count}行を表示', + 'read.expandRest': '… 残り{count}行', + 'search.paths': '{shown}件のパス', + 'search.paths.truncated': '{total}件中{shown}件のパスを表示', + 'search.matches': '{shown}件の一致 · {files}ファイル', + 'search.matches.truncated': '{total}件中{shown}件の一致を表示 · {files}ファイル', + 'search.noResults': '結果なし', + 'search.collapseAria': '結果を折りたたむ', + 'search.expandAria': '残り{count}行の結果を表示', + 'search.expandRest': '… 残り{count}行', + 'web.noResults': '結果が見つかりません', + 'web.sourcesTruncated': 'ソース一覧を切り詰めました', + 'web.http': 'HTTP', + 'web.contentTruncated': '内容を切り詰めました', + 'details.running': '実行中…', + 'queue.count': '{n}件のキュー済みメッセージ', + 'queue.sending': '送信中…', + 'queue.image': 'キュー済みメッセージの画像', + 'queue.file': 'キュー済みファイル {name}', + 'queue.edit': 'キュー済みメッセージを編集', + 'queue.edit.unsupported': 'テキスト以外の内容を含むため、まだ編集できません', + 'queue.save': 'キュー済みメッセージを保存', + 'queue.cancelEdit': '編集をキャンセル', + 'queue.remove': 'キュー済みメッセージを削除', + 'queue.steer': 'キュー内のメッセージを実行中の指示に追加', + 'queue.steer.unavailable': '指示の変更はエージェントの実行中のみ利用できます', + 'error.sessionInUse': 'このセッションはすでに使用中です。別の実行中のDSHインスタンス(dsh webやデスクトップアプリなど)が使用している可能性があります。他のDSHインスタンスを終了して再試行してください。', + 'queue.editFailed': '編集に失敗しました。このメッセージはすでに送信が開始されている可能性があります。', + 'queue.removeFailed': '削除に失敗しました。このメッセージはすでに送信が開始されている可能性があります。', + 'queue.steerFailed': '指示の変更に失敗しました。再試行してください。', + 'terminal.signal': 'シグナル {signal}', + 'terminal.exitCode': '終了コード {code}', + 'terminal.noExitCode': '終了コードなし', + 'terminal.commandLine': 'コマンドライン {n}', + 'terminal.running': '実行中', + 'terminal.failed': '失敗', + 'terminal.done': '完了', + 'terminal.noOutput': '出力なし', + 'terminal.collapseAria': '出力を折りたたむ', + 'terminal.expandAria': '残り{n}行の出力を表示', + 'terminal.expandRest': '… 残り{n}行', + 'terminal.sendInput': '(入力を送信)', + 'terminal.session': 'ターミナル {sessionId}', +} satisfies Record diff --git a/packages/client/ui-conversation/src/client/queue/QueueDock.tsx b/packages/client/ui-conversation/src/client/queue/QueueDock.tsx index 2ce7a20eef..6396387a91 100644 --- a/packages/client/ui-conversation/src/client/queue/QueueDock.tsx +++ b/packages/client/ui-conversation/src/client/queue/QueueDock.tsx @@ -229,7 +229,8 @@ export function QueueDock({ useSession, useProjection, updateQueue, notify, load )} {renderFactorySlot('conversation.user.text', { text: row.content.filter(block => block.type === 'text').map(block => block.text).join(''), - facts: QUEUE_TEXT_FACTS, interactive: false, ellipsis: true, + facts: QUEUE_TEXT_FACTS, + interactive: false, ellipsis: true, })} )} @@ -345,7 +346,9 @@ export function QueueDock({ useSession, useProjection, updateQueue, notify, load )} {renderFactorySlot('conversation.user.text', { - text: submission.text, facts: QUEUE_TEXT_FACTS, interactive: false, ellipsis: true, + text: submission.text, + facts: QUEUE_TEXT_FACTS, + interactive: false, ellipsis: true, })} {t('queue.sending')} {queueMutable &&
diff --git a/packages/client/ui-conversation/src/client/service.ts b/packages/client/ui-conversation/src/client/service.ts index fc76701be6..41eabe4132 100644 --- a/packages/client/ui-conversation/src/client/service.ts +++ b/packages/client/ui-conversation/src/client/service.ts @@ -18,7 +18,7 @@ import type { } from '@deepseek-ai/dsh-api-session-controller/client' import type {} from '@deepseek-ai/dsh-client-file-upload/client' import type { SessionId } from '@deepseek-ai/dsh-session/types' -import type { ImageMediaType } from '@deepseek-ai/dsh-attachment' +import type { FileAttachmentRef, ImageMediaType } from '@deepseek-ai/dsh-attachment' import { createSnapshotStore } from '@deepseek-ai/dsh-client-store' import type { SnapshotStore } from '@deepseek-ai/dsh-client-store' import type { @@ -155,7 +155,7 @@ export class ConversationController extends Service implements IConversation { readonly input: SessionInputResolver /** The per-session composer-block registry. */ readonly blocks: ComposerBlocks - /** Live upload state per file-kind draft; images never appear here. */ + /** Live upload state per draft that uploads when added: every file draft and every uploaded image draft. */ readonly fileUploads: SnapshotStore> = createSnapshotStore>({}) private readonly draftAttachments = new Map() private readonly fileUploadOperations = new Map => { + const uploadFor = (attachment: ComposerAttachment): Extract => { const upload = uploads[attachment.id] if (upload === undefined || upload.status !== 'ready') { throw new Error('conversation.sendSession: one or more files have not finished uploading') } return upload } + // A file draft's upload always stores a file; only image drafts store an image. + const storedFileOf = (attachment: ComposerFileAttachment): FileAttachmentRef => + (uploadFor(attachment) as Extract).file + // An image draft without an upload, such as a subagent continuation's, sends its bytes inline. + const imageReceipts = new Map(attachments.flatMap(attachment => attachment.kind === 'image' + && uploads[attachment.id] !== undefined ? [[attachment.id, uploadFor(attachment).receiptId] as const] : [])) const pendingAttachments = attachments.map(attachment => attachment.kind === 'image' ? { type: 'image' as const, @@ -255,11 +261,15 @@ export class ConversationController extends Service implements IConversation { ...(attachment.height === undefined ? {} : { height: attachment.height }), }, } - : { type: 'file' as const, value: uploadFor(attachment).file }) + : { type: 'file' as const, value: storedFileOf(attachment) }) const serializeAttachments = (): Promise[0]> => Promise.all( - attachments.map(async attachment => attachment.kind === 'image' - ? { type: 'image' as const, ...await this.encodeImage(attachment.file) } - : { type: 'file' as const, receiptId: uploadFor(attachment).receiptId }), + attachments.map(async (attachment) => { + if (attachment.kind === 'file') return { type: 'file' as const, receiptId: uploadFor(attachment).receiptId } + const receiptId = imageReceipts.get(attachment.id) + return receiptId === undefined + ? { type: 'image' as const, ...await this.encodeImage(attachment.file) } + : { type: 'file' as const, receiptId } + }), ) let finishRetirement: ((retirement: PendingSubmissionRetirement) => void) | undefined const retirement = attachments.length === 0 @@ -291,20 +301,24 @@ export class ConversationController extends Service implements IConversation { /** * Create runtime-only draft attachments. Files whose browser MIME is an - * accepted image type become image drafts (object URL preview, bytes sent - * with the prompt); every other file becomes a file draft whose background - * upload starts immediately and remains owned by this service across Session - * navigation until completion or explicit removal. + * accepted image type become image drafts with an object URL preview; every + * other file becomes a file draft. A file draft's background upload starts + * immediately, and so does an image draft's when `uploadImages` is set; the + * upload remains owned by this service across Session navigation until + * completion or explicit removal. An image draft without an upload sends its + * bytes with the prompt. * @param sessionId - target Agent-scope identity. * @param files - browser files to register. + * @param uploadImages - whether the Session accepts image uploads; a subagent continuation does not. * @returns ordered draft descriptors. */ - createDrafts(sessionId: SessionId, files: readonly File[]): readonly ComposerAttachment[] { + createDrafts(sessionId: SessionId, files: readonly File[], uploadImages: boolean): readonly ComposerAttachment[] { return files.map((file) => { if (isImageMediaType(file.type)) { const attachment = browserDraftAttachment(file) this.draftAttachments.set(attachment.id, attachment) probeDimensions(attachment) + if (uploadImages) this.beginFileUpload(sessionId, attachment) return attachment } const attachment: ComposerFileAttachment = { @@ -319,30 +333,33 @@ export class ConversationController extends Service implements IConversation { } /** - * Restart one failed file upload. + * Restart one failed file or image upload. * @param sessionId - target Agent-scope identity. * @param id - draft attachment id whose upload previously failed. */ retryFileUpload(sessionId: SessionId, id: DraftAttachmentId): void { const attachment = this.draftAttachments.get(id) - if (attachment === undefined || attachment.kind !== 'file') return + if (attachment === undefined) return if (this.fileUploads.getSnapshot()[id]?.status !== 'error') return this.beginFileUpload(sessionId, attachment) } /** - * Stage carried file drafts again for a new Session. + * Stage carried file and image drafts again for a new Session. * @param sessionId - target Agent-scope identity after a Workspace switch. * @param ids - carried draft attachment ids. + * @param uploadImages - whether the target Session stages image uploads; an image draft otherwise sends inline. */ - rebindDraftFiles(sessionId: SessionId, ids: readonly DraftAttachmentId[]): void { + rebindDraftFiles(sessionId: SessionId, ids: readonly DraftAttachmentId[], uploadImages: boolean): void { for (const id of ids) { const attachment = this.draftAttachments.get(id) - if (attachment?.kind === 'file') this.beginFileUpload(sessionId, attachment) + if (attachment === undefined) continue + if (attachment.kind === 'image' && !uploadImages) this.dropUpload(id) + else this.beginFileUpload(sessionId, attachment) } } - private beginFileUpload(sessionId: SessionId, attachment: ComposerFileAttachment): void { + private beginFileUpload(sessionId: SessionId, attachment: ComposerAttachment): void { this.fileUploadOperations.get(attachment.id)?.controller.abort() const controller = new AbortController() this.fileUploads.update((draft) => { @@ -353,33 +370,41 @@ export class ConversationController extends Service implements IConversation { this.fileUploadOperations.set(attachment.id, { controller, done }) this.pendingFileUploads.add(done) void done.then(() => { this.pendingFileUploads.delete(done) }) + const name = attachment.file.name === '' ? undefined : attachment.file.name + const onProgress = (progress: { readonly loaded: number; readonly total?: number }): void => { + if (this.fileUploadOperations.get(attachment.id)?.controller !== controller) return + this.fileUploads.update((draft) => { + if (!(attachment.id in draft)) return + draft[attachment.id] = { + status: 'uploading', + loaded: progress.loaded, + ...(progress.total === undefined ? {} : { total: progress.total }), + } + }) + } const run = async (): Promise => { try { if (controller.signal.aborted || this.fileUploadOperations.get(attachment.id)?.controller !== controller) return - const result = await this.ctx.fileUpload.upload( - sessionId, - attachment.file, - attachment.file.name === '' ? undefined : attachment.file.name, - controller.signal, - (progress) => { - if (this.fileUploadOperations.get(attachment.id)?.controller !== controller) return - this.fileUploads.update((draft) => { - if (!(attachment.id in draft)) return - draft[attachment.id] = { - status: 'uploading', - loaded: progress.loaded, - ...(progress.total === undefined ? {} : { total: progress.total }), - } - }) - }, - ) + const result = attachment.kind === 'image' + ? await this.ctx.fileUpload.uploadImage( + sessionId, attachment.file, imageMediaType(attachment.file.type), name, controller.signal, onProgress, + ) + : await this.ctx.fileUpload.upload(sessionId, attachment.file, name, controller.signal, onProgress) if (this.fileUploadOperations.get(attachment.id)?.controller !== controller) return this.fileUploads.update((draft) => { if (!(attachment.id in draft)) return - draft[attachment.id] = result.ok - ? { status: 'ready', receiptId: result.value.receiptId, file: result.value.file } - : { status: 'error', message: result.error.message } + if (result.ok) { + draft[attachment.id] = 'file' in result.value + ? { status: 'ready', receiptId: result.value.receiptId, file: result.value.file } + : { status: 'ready', receiptId: result.value.receiptId } + return + } + const { error } = result + // A Host refusal keeps its reason: retrying the same bytes cannot succeed. + draft[attachment.id] = error.code === 'session/attachment-invalid' || error.code === 'subagent/attachment-invalid' + ? { status: 'error', message: error.message, reason: error.details.reason } + : { status: 'error', message: error.message } }) } catch (error) { if (this.fileUploadOperations.get(attachment.id)?.controller !== controller) return @@ -430,8 +455,9 @@ export class ConversationController extends Service implements IConversation { /** * Serialize ordered draft attachments to command-submit wire payloads without - * sending or releasing them. Images are encoded; generic files cite receipts - * from their completed background uploads and never reread browser bytes. + * sending or releasing them. Images are encoded even when an upload already + * stored them, because commands accept only file receipts; generic files cite + * receipts from their completed background uploads and never reread browser bytes. * @param attachmentIds - ordered draft-local attachment ids. * @returns wire payloads in id order. */ @@ -462,18 +488,25 @@ export class ConversationController extends Service implements IConversation { releaseDraftAttachment(id: DraftAttachmentId): void { const attachment = this.draftAttachments.get(id) if (attachment === undefined) return + this.draftAttachments.delete(id) + if (attachment.kind === 'image') revokePreview(attachment.previewUrl) + // The stored Host object stays durable; only the draft's upload state ends. + this.dropUpload(id) + } + + /** Abort one draft's active upload and drop its upload state. */ + private dropUpload(id: DraftAttachmentId): void { const operation = this.fileUploadOperations.get(id) this.fileUploadOperations.delete(id) operation?.controller.abort() - this.draftAttachments.delete(id) - if (attachment.kind === 'image') { - revokePreview(attachment.previewUrl) - return - } - // The stored Host object stays durable; only the draft's upload state ends. - this.fileUploads.set(Object.fromEntries( - Object.entries(this.fileUploads.getSnapshot()).filter(([key]) => key !== id), - )) + this.forgetUpload(id) + } + + /** Drop one draft's upload state. */ + private forgetUpload(id: DraftAttachmentId): void { + const uploads = this.fileUploads.getSnapshot() + if (!(id in uploads)) return + this.fileUploads.set(Object.fromEntries(Object.entries(uploads).filter(([key]) => key !== id))) } /** @@ -559,6 +592,7 @@ export class ConversationController extends Service implements IConversation { continue } this.draftAttachments.delete(attachment.id) + this.forgetUpload(attachment.id) if (ref !== undefined && 'mediaType' in ref && uiConversation?.seedImageUrl(sessionId, ref, attachment.previewUrl) === true) continue revokePreview(attachment.previewUrl) diff --git a/packages/client/ui-conversation/src/client/settings/EnterBehaviorRow.module.css b/packages/client/ui-conversation/src/client/settings/EnterBehaviorRow.module.css index 884b2ce331..d953998ea2 100644 --- a/packages/client/ui-conversation/src/client/settings/EnterBehaviorRow.module.css +++ b/packages/client/ui-conversation/src/client/settings/EnterBehaviorRow.module.css @@ -17,6 +17,14 @@ padding-right: 48px; } +/* Narrow settings panel: the 48px gutter separates the label from its control + across a wide row and only squeezes the label here. */ +@media (max-width: 767px) { + :global(html:not([data-windows-titlebar]):not([data-platform='darwin'])) .rowText { + padding-right: 16px; + } +} + .title { font-size: 14px; font-weight: 400; diff --git a/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx b/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx index ef95796577..e53e666864 100644 --- a/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx +++ b/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx @@ -80,11 +80,12 @@ export const InputBar = memo(function InputBar({ ) const empty = draft.trim() === '' && attachments.length === 0 const uploads = useFileUploads(snapshot => snapshot) - // Send waits for every picked file: uploading and failed drafts both hold - // the gate (a failed upload is retried or removed, never silently dropped). - const uploadsPending = attachments.some( - attachment => attachment.kind === 'file' && uploads[attachment.id]?.status !== 'ready', - ) + // Send waits for every picked file and every uploading image: uploading and + // failed drafts both hold the gate (a failed upload is retried or removed, + // never silently dropped). An image draft without an upload sends inline. + const uploadsPending = attachments.some(attachment => attachment.kind === 'file' + ? uploads[attachment.id]?.status !== 'ready' + : uploads[attachment.id] !== undefined && uploads[attachment.id]?.status !== 'ready') // Transient error banner (machine notices, image-intake rejections, and // prompt failures): the seq keys the Toast so an identical repeated message // restarts the hold-then-fade cycle instead of reusing the faded one. @@ -98,6 +99,11 @@ export const InputBar = memo(function InputBar({ // The deployment's image-intake limits (absent while no attachment service // is composed — the pre-check below then defers entirely to the host). const imageLimits = useProjection('imageLimits') + // Enter while uploads hold the send explains the first failed draft; a Host refusal names its reason. + const failedUpload = attachments.map(attachment => uploads[attachment.id]).find(upload => upload?.status === 'error') + const uploadsHeldText = failedUpload?.status !== 'error' + ? t('file.stillUploading') + : failedUpload.reason === undefined ? t('file.uploadFailed') : attachmentErrorText(t, failedUpload.reason, imageLimits) // Prompt failures are ordinary failures (no create/attach transaction exists // anymore): the toast announces promptError, the draft stays in the machine, // and the user resubmits. A remount over a session whose machine still holds @@ -252,11 +258,11 @@ export const InputBar = memo(function InputBar({ // registration survives re-renders without re-arming per keystroke. const gate = useRef({ locked, machineBusy, canSteerQueue, running, steeringAvailable, busyEnter, - intakeFiles, uploadsPending, showToast, t, canAcceptDrop, + intakeFiles, uploadsPending, uploadsHeldText, showToast, canAcceptDrop, }) gate.current = { locked, machineBusy, canSteerQueue, running, steeringAvailable, busyEnter, - intakeFiles, uploadsPending, showToast, t, canAcceptDrop, + intakeFiles, uploadsPending, uploadsHeldText, showToast, canAcceptDrop, } useEffect(() => { @@ -411,6 +417,7 @@ export const InputBar = memo(function InputBar({ onRemoveAttachment: (id) => { removeAttachment?.(id) }, uploads, onRetryFile: (id) => { retryFileUpload?.(id) }, + uploadFailureText: reason => attachmentErrorText(t, reason, imageLimits), dropLimits: imageLimits === undefined ? undefined : { count: imageLimits.maxImagesPerMessage, size: imageSizeText(imageLimits.maxImageBytes), diff --git a/packages/client/ui-conversation/src/client/user-text/UserText.tsx b/packages/client/ui-conversation/src/client/user-text/UserText.tsx index d8cd8f3430..7513d75462 100644 --- a/packages/client/ui-conversation/src/client/user-text/UserText.tsx +++ b/packages/client/ui-conversation/src/client/user-text/UserText.tsx @@ -1,7 +1,7 @@ /** User-text presentation delegates evaluation to its data-layer document and renders JSON business nodes. */ import { Fragment, useEffect, useMemo } from 'react' import type { FactoryComponentPropsOf } from '@deepseek-ai/dsh-client-ui-slots' -import { ReferenceChip, UserTextRun } from '@deepseek-ai/dsh-client-ui-primitives' +import { HoverCardGroup, ReferenceChip, UserTextRun } from '@deepseek-ai/dsh-client-ui-primitives' import type { UserTextNodeProps } from '../contract/slots.ts' import type { UserTextNode } from '../contract/user-text.ts' import css from './UserText.module.css' @@ -42,7 +42,7 @@ export function UserText({ })} })}, [nodes, text, interactive, renderSlot]) - return {content} + return {content} } /** diff --git a/packages/client/ui-conversation/tests/apply-inject.client.spec.tsx b/packages/client/ui-conversation/tests/apply-inject.client.spec.tsx index 83d6db85c9..16caa7db32 100644 --- a/packages/client/ui-conversation/tests/apply-inject.client.spec.tsx +++ b/packages/client/ui-conversation/tests/apply-inject.client.spec.tsx @@ -8,6 +8,7 @@ import { describe, expect, it, onTestFinished, vi } from 'vitest' import type { CommandContribution, CommandUiContract } from '@deepseek-ai/dsh-client-ui-commands/client' import type { ISession, SessionReference } from '@deepseek-ai/dsh-api-session-controller/client' import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client' +import * as messageQuotes from '@deepseek-ai/dsh-message-quotes/client' import type { ObservableSnapshot } from '@deepseek-ai/dsh-client-store' import { SlotTestRuntime, stubConfigForm, usePinnedBrowserLanguages, @@ -23,6 +24,12 @@ import { createConversationStore } from '../src/client/stores.ts' import type { SessionInputShell } from '../src/client/input/facade.ts' import { RemoteError } from '@deepseek-ai/dsh-client-test-runtime' +declare module '@deepseek-ai/dsh-client-ui-slots' { + interface SlotMap { + 'quote-probe': { kind: 'single'; scope: 'session' } + } +} + usePinnedBrowserLanguages('zh-CN') const ROOT = 'root-1' as SessionId @@ -38,7 +45,7 @@ function sessionFakeFor() { } satisfies SessionBehaviorOverrides } -async function bench() { +async function bench(renderQuote = false) { const runtime = await SlotTestRuntime.create() const rootUpload = vi.fn(() => Promise.resolve({ ok: true as const, @@ -98,9 +105,15 @@ async function bench() { runtime.slots.installLocale(locale) await runtime.root.declare({ 'main': { kind: 'keyed', scope: 'root' }, - }, (_props: { renderSlot?: unknown }) => null) + 'quote-probe': { kind: 'single', scope: 'session' }, + }, ({ renderFactorySlot, SessionProvider }) => renderQuote ? ( + + {renderFactorySlot('conversation.user.text', { text: '> @literal /review', facts: {} })} + + ) : null) const feature = await runtime.mount({ inject: [...inject], apply }) + if (renderQuote) await runtime.mount(messageQuotes) runtime.renderRoot() const entryOf = (key: 'main.conversation' | 'conversation.session' | 'conversation.session.header' | 'conversation.composer.bar') => runtime.slots.entries(key)[0]! @@ -145,6 +158,17 @@ async function bench() { } describe('Conversation inject API', () => { + it('renders literal quote text through the actual Factory and keyed node registration', async () => { + const b = await bench(true) + onTestFinished(() => b.runtime.dispose()) + const errors = vi.fn() + const unsubscribe = b.slots.onEntryError(errors) + onTestFinished(unsubscribe) + await vi.waitFor(() => { expect(document.querySelector('[data-quote-preview-text]')?.textContent).toBe('@literal /review') }) + expect(document.querySelector('[data-slot-error]')).toBeNull() + expect(errors).not.toHaveBeenCalled() + }) + it('owns the File action, reads its mounted composer availability, and unregisters on disposal', async () => { const b = await bench() onTestFinished(() => b.runtime.dispose()) diff --git a/packages/client/ui-conversation/tests/config.host.spec.ts b/packages/client/ui-conversation/tests/config.host.spec.ts index cb047da964..7b87d23d59 100644 --- a/packages/client/ui-conversation/tests/config.host.spec.ts +++ b/packages/client/ui-conversation/tests/config.host.spec.ts @@ -13,9 +13,9 @@ describe('ui-conversation host', () => { const ctx = new Context() const configuration = await liveConfig(ctx, { Config, apply }) const { fiber } = configuration - expect(plainConfig(configuration.fiber.config)).toEqual({ busyEnter: DEFAULT_BUSY_ENTER_BEHAVIOR }) + expect(plainConfig(configuration.fiber.effectiveConfig)).toEqual({ busyEnter: DEFAULT_BUSY_ENTER_BEHAVIOR }) await configuration.update({ busyEnter: 'steer' }) - expect(plainConfig(configuration.fiber.config)).toEqual({ busyEnter: 'steer' }) + expect(plainConfig(configuration.fiber.effectiveConfig)).toEqual({ busyEnter: 'steer' }) await expect(configuration.update({ busyEnter: 'invalid' })).rejects.toThrow() await fiber.dispose() }) diff --git a/packages/client/ui-conversation/tests/draft-document.client.spec.ts b/packages/client/ui-conversation/tests/draft-document.client.spec.ts index e96911e2d9..ef737eec06 100644 --- a/packages/client/ui-conversation/tests/draft-document.client.spec.ts +++ b/packages/client/ui-conversation/tests/draft-document.client.spec.ts @@ -13,6 +13,7 @@ import { parseStoredDraft, resolveDraftInput, snapshotDraft } from '../src/clien import { scanTextRefs } from '../src/client/input/decorations.ts' import { ReferenceChipNode } from '../src/client/input/editor/chip-node.tsx' import { CommandChipNode } from '../src/client/input/editor/command-chip-node.tsx' +import { QuoteNode } from '../src/client/input/editor/QuoteNode.tsx' import { TextRefNode } from '../src/client/input/editor/text-ref.ts' import { SessionInputShell, type SessionInputDeps } from '../src/client/input/facade.ts' @@ -302,6 +303,33 @@ describe('draft documents in the input shell', () => { await vi.waitFor(() => { expect(claim.submitted).toEqual(['ship']) }) }) + it('restores a command and an atomic quote together without changing their text or offsets', async () => { + const original = makeShell() + original.setDraft('/goal ') + expect(original.insertQuote('selected source text')).toBe(true) + expect(original.beginCommand(goalClaim(), { start: 0, end: 6, draftRev: original.snapshot.draftRev })).toBe(true) + const stored = parseStoredDraft(JSON.parse(JSON.stringify(original.draftSnapshot))) + expect(stored).toEqual({ + text: '/goal > selected source text ', references: [], command: { token: '/goal ' }, + quotes: [{ offset: 6, length: 22, text: 'selected source text' }], + }) + if (stored === undefined) throw new Error('Expected a stored draft') + const claim = goalClaim() + const provider: InputTriggerController = { + ...triggerProvider(createSnapshotStore(new Map())), + resolveClaim: () => Promise.resolve(claim), + } + const restored = makeShell({ inputTriggers: () => provider }) + restored.setDraft(stored) + await vi.waitFor(() => { expect(restored.snapshot.phase).toBe('claimed') }) + expect(commandChips(restored)).toBe(1) + expect(restored.editor.getEditorState().read(() => $nodesOfType(QuoteNode).map(node => node.getQuoteText()))) + .toEqual(['selected source text']) + expect(restored.draftSnapshot).toEqual(stored) + restored.submit() + await vi.waitFor(() => { expect(claim.submitted).toEqual(['> selected source text ']) }) + }) + it('waits for the trigger pipeline before resolving a restored command chip', async () => { const stored = storedGoalDraft() const resolveClaim = vi.fn(() => Promise.resolve(goalClaim())) diff --git a/packages/client/ui-conversation/tests/editor-focus.client.spec.ts b/packages/client/ui-conversation/tests/editor-focus.client.spec.ts index 7f115ff744..a75bdf01aa 100644 --- a/packages/client/ui-conversation/tests/editor-focus.client.spec.ts +++ b/packages/client/ui-conversation/tests/editor-focus.client.spec.ts @@ -37,6 +37,7 @@ function fixture(start = 1, end = 4) { const onUpdate = vi.fn() const runtime: DraftEditorRuntime = new DraftEditorRuntime({ onUpdate: () => { runtime.refreshProjection(); onUpdate() }, + quoteLabels: () => ({ remove: 'Remove quote' }), openReference: () => false, lexicon: () => new Map(), resolveLexicon: () => undefined, }) diff --git a/packages/client/ui-conversation/tests/input-bar.client.spec.tsx b/packages/client/ui-conversation/tests/input-bar.client.spec.tsx index e5ad1d32c5..e308d326da 100644 --- a/packages/client/ui-conversation/tests/input-bar.client.spec.tsx +++ b/packages/client/ui-conversation/tests/input-bar.client.spec.tsx @@ -242,8 +242,9 @@ function bench(over?: BenchOptions) { const steeringAvailable = over?.subagent === undefined || over.subagent.address.mode === 'continuable' const composerLocked = over?.disabled === true || over?.inert === true || over?.blocked !== undefined || (over?.subagent?.address.mode === 'continuable' && over.subagent.parentAvailable !== true) - const uploadsPending = (over?.attachments ?? []).some(attachment => - attachment.kind === 'file' && over?.fileUploads?.[attachment.id]?.status !== 'ready') + const uploadsPending = (over?.attachments ?? []).some(attachment => attachment.kind === 'file' + ? over?.fileUploads?.[attachment.id]?.status !== 'ready' + : over?.fileUploads?.[attachment.id] !== undefined && over.fileUploads[attachment.id]?.status !== 'ready') const plainMessageDraft = sendableDraft && !uploadsPending && !(over?.draft?.trimStart().startsWith('/') ?? false) const primaryLabel = primaryStops ? '停止生成' @@ -1100,6 +1101,58 @@ describe('running and lock semantics', () => { expect(ready.button.disabled).toBe(false) }) + it('running Send waits for an uploading image and treats an image without an upload as ready', () => { + const image = { + kind: 'image' as const, + id: 'image-1' as DraftAttachmentId, + file: new File(['x'], 'shot.png', { type: 'image/png' }), + previewUrl: 'blob:shot', + } + const uploading = bench({ + running: true, busyEnter: 'steer', draft: '带图', attachments: [image], + fileUploads: { [image.id]: { status: 'uploading', loaded: 0 } }, + }) + expect(uploading.button.getAttribute('aria-label')).toBe('发送消息') + expect(uploading.button.disabled).toBe(true) + + // A subagent continuation's image has no upload and travels inline. + const inline = bench({ running: true, busyEnter: 'steer', draft: '带图', attachments: [image] }) + expect(inline.button.getAttribute('aria-label')).toBe('插话发送') + expect(inline.button.disabled).toBe(false) + }) + + it('explains on Enter why uploads hold the send, naming a Host refusal', () => { + const limits = { + maxImageBytes: 1024 * 1024, + maxImagesPerMessage: 2, + maxMessageImageBytes: 2 * 1024 * 1024, + maxImagePixels: 40_000_000, + maxImageDimension: 2000, + mediaTypes: ['image/png'] as const, + } + const image = { + kind: 'image' as const, + id: 'image-1' as DraftAttachmentId, + file: new File(['x'], 'wide.png', { type: 'image/png' }), + previewUrl: 'blob:wide', + } + const enterWith = (upload: NonNullable[string]): string | null => { + const result = bench({ draft: '带图', attachments: [image], fileUploads: { [image.id]: upload }, imageLimits: limits }) + fireEvent.keyDown(result.textarea, { key: 'Enter' }) + expect(result.sink).not.toHaveBeenCalled() + const text = result.view.getByRole('alert').textContent + cleanup() + return text + } + expect(enterWith({ status: 'uploading', loaded: 0 })).toContain('文件还在上传,请等待上传完成后发送') + expect(enterWith({ status: 'error', message: 'offline' })).toContain('上传失败,点击重试') + const refusal = '图片宽高不能超过 2000px,请缩小后重试' + expect(enterWith({ status: 'error', message: 'too wide', reason: 'IMAGE_DIMENSION_TOO_LARGE' })).toContain(refusal) + // The attachments slot receives the same localized explanation for its thumbnail. + const owner = attachmentOwner(bench({ attachments: [image], imageLimits: limits }).slotCalls) + expect(owner.uploadFailureText('IMAGE_DIMENSION_TOO_LARGE')).toBe(refusal) + }) + it('idle Send keeps the plain label regardless of the busy-state preference', () => { const { button, sink } = bench({ busyEnter: 'steer', draft: '空闲发送' }) expect(button.getAttribute('aria-label')).toBe('发送消息') diff --git a/packages/client/ui-conversation/tests/input-reference-submit.client.spec.ts b/packages/client/ui-conversation/tests/input-reference-submit.client.spec.ts index 9ca7bdb096..c70855b405 100644 --- a/packages/client/ui-conversation/tests/input-reference-submit.client.spec.ts +++ b/packages/client/ui-conversation/tests/input-reference-submit.client.spec.ts @@ -293,7 +293,7 @@ describe('submit transaction hardening', () => { it('captures click and Enter submission intent before async admission, excluding empty submits', async () => { const report = vi.fn() - const submissionState = vi.fn(() => ({ runMode: 'default' as const, running: false })) + const submissionState = vi.fn(() => ({ runMode: 'default' as const, running: false, newSession: false })) const shell = new SessionInputShell({ actx: {} as Context, defaultSink: async () => ({ kind: 'success' }), commandAttachments, submissionState, messageSubmitted: report }) try { shell.submit('queue', 'click') @@ -327,7 +327,7 @@ it.each(['capture', 'report'])('analytics %s failure does not interrupt a messag it.each(['handled', 'claim', 'message'] as const)('counts only a message after asynchronous slash adjudication: %s', async (kind) => { const pending = Promise.withResolvers() const report = vi.fn() - const submissionState = vi.fn(() => ({ runMode: 'default' as const, running: false })) + const submissionState = vi.fn(() => ({ runMode: 'default' as const, running: false, newSession: false })) const command = vi.fn(async (): Promise => ({ kind: 'success' })) const sink = vi.fn(async (): Promise => ({ kind: 'success' })) const inputTriggers: InputTriggerController = { @@ -366,7 +366,7 @@ it('retains occurrence time and Session facts across arbitration and independent let now = 100 const clock = vi.spyOn(Date, 'now').mockImplementation(() => now) const shell = new SessionInputShell({ actx: {} as Context, commandAttachments, - submissionState: () => Object.freeze({ runMode: running ? 'plan' : 'default', running }), + submissionState: () => Object.freeze({ runMode: running ? 'plan' : 'default', running, newSession: false }), messageSubmitted: submitted, inputTriggers: () => ({ launcher: { getSnapshot: () => null, subscribe: () => () => {} }, @@ -386,10 +386,10 @@ it('retains occurrence time and Session facts across arbitration and independent expect(submitted).not.toHaveBeenCalled() pending.resolve(undefined) await vi.waitFor(() => { expect(submitted).toHaveBeenCalledOnce() }) - expect(submitted).toHaveBeenLastCalledWith({ timestamp: 100, source: 'click', mode: 'steer', state: { running: true, runMode: 'plan' } }) + expect(submitted).toHaveBeenLastCalledWith({ timestamp: 100, source: 'click', mode: 'steer', state: { running: true, runMode: 'plan', newSession: false } }) shell.setDraft('second') shell.actions.submit() - expect(submitted).toHaveBeenLastCalledWith({ timestamp: 200, mode: 'queue', state: { running: false, runMode: 'default' } }) + expect(submitted).toHaveBeenLastCalledWith({ timestamp: 200, mode: 'queue', state: { running: false, runMode: 'default', newSession: false } }) await vi.waitFor(() => { expect(sink).toHaveBeenCalledTimes(2) }) secondSend.resolve({ kind: 'error', text: 'second rejected' }) firstSend.resolve({ kind: 'error', text: 'first rejected' }) diff --git a/packages/client/ui-conversation/tests/interaction-registry.client.spec.ts b/packages/client/ui-conversation/tests/interaction-registry.client.spec.ts new file mode 100644 index 0000000000..197c5f02f1 --- /dev/null +++ b/packages/client/ui-conversation/tests/interaction-registry.client.spec.ts @@ -0,0 +1,61 @@ +/** Plugin dispatch is synchronous, scoped, and independent of browser default cancellation. */ +import { Context } from '@deepseek-ai/cordis' +import { SessionId } from '@deepseek-ai/dsh-session/types' +import { describe, expect, it, onTestFinished, vi } from 'vitest' +import type { TextInteractionRequest } from '../src/client/contract/text-interaction.ts' +import { ConversationInteractionRegistry } from '../src/client/conversation/interaction-registry.ts' + +function fixture() { + const ctx = new Context() + onTestFinished(() => ctx.fiber.dispose()) + return new ConversationInteractionRegistry(ctx) +} + +const sessionId = SessionId('interaction') +const gesture = () => ({ trigger: 'selection' as const, hasSelection: true, readText: () => 'selected', preventDefault: vi.fn() }) + +describe('Conversation interaction registrations', () => { + it('passes the session to consumers and stops at the first opened surface', () => { + const registry = fixture() + const pass = vi.fn() + const later = vi.fn() + registry.register({ id: 'pass', onInteraction: pass }) + registry.register({ id: 'quote', onInteraction: (request) => { request.open() } }) + registry.register({ id: 'later', onInteraction: later }) + const request = gesture() + expect(registry.dispatch(sessionId, request)).toBe('quote') + expect(pass).toHaveBeenCalledWith(expect.objectContaining({ sessionId, trigger: 'selection' })) + expect(later).not.toHaveBeenCalled() + expect(request.preventDefault).not.toHaveBeenCalled() + }) + + it('allows cancellation without opening and ignores retained gesture methods', () => { + const registry = fixture() + let retained: TextInteractionRequest | undefined + registry.register({ id: 'menu', onInteraction: (request) => { retained = request; request.preventDefault() } }) + const request = gesture() + expect(registry.dispatch(sessionId, request)).toBeNull() + expect(request.preventDefault).toHaveBeenCalledOnce() + retained!.open() + retained!.preventDefault() + expect(request.preventDefault).toHaveBeenCalledOnce() + }) + + it('publishes removal and rejects empty or duplicate IDs', () => { + const registry = fixture() + const changed = vi.fn() + const off = registry.subscribe(changed) + expect(() => registry.register({ id: '', onInteraction: () => {} })).toThrow('must not be empty') + const dispose = registry.register({ id: 'quote', onInteraction: (request) => { request.open() } }) + const ids = registry.getSnapshot() + expect(ids).toEqual(['quote']) + expect(registry.getSnapshot()).toBe(ids) + expect(() => registry.register({ id: 'quote', onInteraction: () => {} })).toThrow('Duplicate') + dispose() + dispose() + expect(registry.getSnapshot()).toEqual([]) + expect(changed).toHaveBeenCalledTimes(2) + expect(registry.dispatch(sessionId, gesture())).toBeNull() + off() + }) +}) diff --git a/packages/client/ui-conversation/tests/queue-dock.client.spec.tsx b/packages/client/ui-conversation/tests/queue-dock.client.spec.tsx index 879f980b91..4583814093 100644 --- a/packages/client/ui-conversation/tests/queue-dock.client.spec.tsx +++ b/packages/client/ui-conversation/tests/queue-dock.client.spec.tsx @@ -148,6 +148,21 @@ function imageRow(id: string, refId: string, text = ''): UserMessage { } describe('QueueDock', () => { + it('keeps the same quote preview when a queued echo hands off to Host content', () => { + const requestId = 'quote-handoff' as SessionSnapshot['pendingSubmissions'][number]['requestId'] + const content = [{ type: 'text' as const, text: '> @literal /review' }] + const initial = { ...snapshotWith([]), pendingSubmissions: [{ requestId, placement: 'queued' as const, + time: 1000, text: content[0]!.text, attachments: [] }] } + const source = liveSession(initial) + const view = render() + expect(view.container.querySelector('[data-quote-preview-text]')?.textContent).toBe('@literal /review') + const queued: UserMessage = { ...row('accepted-quote', ''), content, source: { kind: 'user', rpcId: requestId } } + act(() => { source.push(snapshotWith([queued])) }) + expect(view.container.querySelectorAll('[data-quote-preview]')).toHaveLength(1) + expect(view.container.querySelector('[data-quote-preview-text]')?.textContent).toBe('@literal /review') + expect(view.container.querySelector('[data-ref-chip]')).toBeNull() + }) + it.each(['ABC', 'ACB', 'BAC', 'BCA', 'CAB', 'CBA'])( 'keeps repeated queued submissions in Dock through acceptance and FIFO claims (%s)', (hostOrder) => { const pending: SessionSnapshot['pendingSubmissions'] = ['A', 'B', 'C'].map(id => ({ diff --git a/packages/client/ui-conversation/tests/quote-composer.client.spec.tsx b/packages/client/ui-conversation/tests/quote-composer.client.spec.tsx new file mode 100644 index 0000000000..7c5f62a484 --- /dev/null +++ b/packages/client/ui-conversation/tests/quote-composer.client.spec.tsx @@ -0,0 +1,202 @@ +// @vitest-environment jsdom +import { describe, expect, it, vi } from 'vitest' +import { fireEvent, render } from '@testing-library/react' +import type { Context } from '@deepseek-ai/cordis' +import { + KEY_BACKSPACE_COMMAND, KEY_DELETE_COMMAND, REDO_COMMAND, UNDO_COMMAND, + $getRoot, $getSelection, $isRangeSelection, $nodesOfType, +} from 'lexical' +import type { DraftSnapshot } from '../src/client/contract/draft-editor.ts' +import type { SubmitOutcome } from '../src/client/contract/input.ts' +import { SessionInputShell } from '../src/client/input/facade.ts' +import { + QuoteNode, QuoteNodeView, REMOVE_QUOTE_COMMAND, +} from '../src/client/input/editor/QuoteNode.tsx' +import { $selectDetectSpan } from '../src/client/input/editor/span-map.ts' + +const quote = 'selected source text' + +function bench(quoteInsertionAllowed?: () => boolean) { + const sink = vi.fn(() => Promise.resolve({ kind: 'success' })) + const shell = new SessionInputShell({ + actx: {} as Context, + defaultSink: sink, + commandAttachments: { + serialize: () => Promise.resolve([]), + release: () => {}, + unsupportedNotice: () => 'unsupported', + }, + quoteRemoveLabel: () => 'Remove quote', + ...(quoteInsertionAllowed === undefined ? {} : { quoteInsertionAllowed }), + }) + return { shell, sink } +} + +function quoteKeys(shell: SessionInputShell): string[] { + return shell.editor.getEditorState().read(() => $nodesOfType(QuoteNode).map(node => node.getKey())) +} + +describe('QuoteNode composer lifecycle', () => { + it('preserves the draft when its Session cannot accept an inserted quote', () => { + let allowed = false + const { shell } = bench(() => allowed) + shell.setDraft('base ') + const before = shell.draftSnapshot + expect(shell.insertQuote(quote)).toBe(false) + expect(shell.draftSnapshot).toBe(before) + allowed = true + expect(shell.insertQuote(quote)).toBe(true) + }) + + it('renders quote text as a non-interactive container and keeps close accessible', () => { + const dispatchCommand = vi.fn((_command: unknown, _payload: unknown) => true) + const parentKeyDown = vi.fn() + const editor = { dispatchCommand } as never + const view = render( +
+ +
, + ) + const text = view.container.querySelector('[data-quote-preview]')! + expect(text.getAttribute('role')).toBeNull() + expect(text.getAttribute('tabindex')).toBeNull() + expect(text.getAttribute('title')).toBeNull() + fireEvent.click(text) + expect(dispatchCommand).not.toHaveBeenCalled() + fireEvent.keyDown(view.getByRole('button', { name: 'Remove quote' }), { key: 'Enter' }) + expect(parentKeyDown).not.toHaveBeenCalled() + fireEvent.click(view.getByRole('button', { name: 'Remove quote' })) + expect(dispatchCommand).toHaveBeenCalledWith(REMOVE_QUOTE_COMMAND, 'quote-key') + expect(view.queryByRole('link')).toBeNull() + }) + + it('persists through DraftSnapshot restore as an atomic quote', () => { + const first = bench().shell + const mirrored: DraftSnapshot[] = [] + first.bindDraftPersistence((document) => { mirrored.push(document) }) + expect(first.insertQuote(quote)).toBe(true) + const persisted = mirrored.at(-1) + expect(persisted).toEqual({ text: '> selected source text ', references: [], quotes: [{ offset: 0, length: 22, text: quote }] }) + + const restored = bench().shell + restored.setDraft(persisted as DraftSnapshot) + restored.editor.getEditorState().read(() => { + const [node] = $nodesOfType(QuoteNode) + expect(node?.getQuoteText()).toBe(quote) + expect(node?.isKeyboardSelectable()).toBe(false) + expect(node?.createDOM({ namespace: 'quote', theme: {} }).getAttribute('contenteditable')).toBe('false') + }) + }) + + it('treats insert, close removal, Backspace, undo, and redo as whole-node operations', async () => { + const direct = bench().shell + direct.insertQuote(quote) + expect(direct.editor.dispatchCommand( + KEY_BACKSPACE_COMMAND, + new KeyboardEvent('keydown', { key: 'Backspace' }), + )).toBe(true) + await vi.waitFor(() => { expect(quoteKeys(direct)).toHaveLength(0) }) + expect(direct.snapshot.draft).toBe('') + direct.editor.dispatchCommand(UNDO_COMMAND, undefined) + await vi.waitFor(() => { expect(quoteKeys(direct)).toHaveLength(1) }) + + const { shell } = bench() + shell.setDraft('base ') + shell.insertQuote(quote) + expect(quoteKeys(shell)).toHaveLength(1) + await Promise.resolve() + shell.editor.dispatchCommand(UNDO_COMMAND, undefined) + await vi.waitFor(() => { expect(quoteKeys(shell)).toHaveLength(0) }) + shell.editor.dispatchCommand(REDO_COMMAND, undefined) + await vi.waitFor(() => { expect(quoteKeys(shell)).toHaveLength(1) }) + + const [key] = quoteKeys(shell) + shell.editor.update(() => { + expect($selectDetectSpan({ start: 6, end: 6 })).toBe(true) + }, { discrete: true }) + expect(shell.editor.dispatchCommand( + KEY_BACKSPACE_COMMAND, + new KeyboardEvent('keydown', { key: 'Backspace' }), + )).toBe(true) + await vi.waitFor(() => { expect(quoteKeys(shell)).toHaveLength(0) }) + shell.editor.dispatchCommand(UNDO_COMMAND, undefined) + await vi.waitFor(() => { expect(quoteKeys(shell)).toHaveLength(1) }) + shell.editor.update(() => { + expect($selectDetectSpan({ start: 5, end: 5 })).toBe(true) + }, { discrete: true }) + expect(shell.editor.dispatchCommand( + KEY_DELETE_COMMAND, + new KeyboardEvent('keydown', { key: 'Delete' }), + )).toBe(true) + await vi.waitFor(() => { expect(quoteKeys(shell)).toHaveLength(0) }) + shell.editor.dispatchCommand(UNDO_COMMAND, undefined) + await vi.waitFor(() => { expect(quoteKeys(shell)).toHaveLength(1) }) + shell.editor.dispatchCommand(REMOVE_QUOTE_COMMAND, key!) + await vi.waitFor(() => { expect(quoteKeys(shell)).toHaveLength(0) }) + expect(shell.snapshot.draft).toBe('base ') + shell.editor.getEditorState().read(() => { + const selection = $getSelection() + expect($isRangeSelection(selection) && selection.isCollapsed()).toBe(true) + }) + + const empty = bench().shell + empty.insertQuote(quote) + const [emptyKey] = quoteKeys(empty) + empty.editor.dispatchCommand(REMOVE_QUOTE_COMMAND, emptyKey!) + await vi.waitFor(() => { expect(quoteKeys(empty)).toHaveLength(0) }) + expect(empty.snapshot.draft).toBe('') + }) + + it('sends selected quotes as ordinary Markdown text', async () => { + const { shell, sink } = bench() + shell.insertQuote(quote) + shell.paste('answer') + shell.submit('queue') + expect(sink).toHaveBeenCalledWith('> selected source text\n\nanswer', [], 'queue', expect.any(AbortSignal)) + expect(shell.snapshot.draft).toBe('') + shell.editor.getEditorState().read(() => { expect($getRoot().getTextContent()).toBe('') }) + }) + + it('keeps adjacent quotes separated in model text', () => { + const { shell, sink } = bench() + shell.insertQuote('first') + shell.insertQuote('second') + shell.submit('queue') + expect(sink).toHaveBeenCalledWith('> first\n\n> second', [], 'queue', expect.any(AbortSignal)) + }) + + it('restores the complete rich document when an optimistic send fails', async () => { + let settle!: (outcome: { kind: 'error'; text: string }) => void + const { shell, sink } = bench() + sink.mockImplementationOnce(() => new Promise((resolve) => { settle = resolve })) + shell.insertQuote(quote) + shell.paste('answer') + const submitted = shell.draftSnapshot + + shell.submit('queue') + expect(shell.snapshot.draft).toBe('') + settle({ kind: 'error', text: 'retry' }) + + await vi.waitFor(() => { expect(shell.draftSnapshot).toEqual(submitted) }) + expect(quoteKeys(shell)).toHaveLength(1) + expect(shell.notices.getSnapshot()).toMatchObject({ level: 'error', text: 'retry' }) + }) + + it('mirrors optimistic successful-send clearing through the Session lifetime', async () => { + let settle!: (outcome: { kind: 'success' }) => void + const { shell, sink } = bench() + sink.mockImplementationOnce(() => new Promise((resolve) => { settle = resolve })) + const mirrored: DraftSnapshot[] = [] + shell.bindDraftPersistence((document) => { mirrored.push(document) }) + shell.insertQuote(quote) + shell.submit('queue') + expect(mirrored.at(-1)).toEqual({ text: '', references: [] }) + settle({ kind: 'success' }) + expect(mirrored.at(-1)).toEqual({ text: '', references: [] }) + }) +}) diff --git a/packages/client/ui-conversation/tests/quote-user-text.client.spec.tsx b/packages/client/ui-conversation/tests/quote-user-text.client.spec.tsx new file mode 100644 index 0000000000..1d88b71aee --- /dev/null +++ b/packages/client/ui-conversation/tests/quote-user-text.client.spec.tsx @@ -0,0 +1,30 @@ +/** Quote draft persistence and prompt serialization remain composer-owned. */ +import { describe, expect, it, vi } from 'vitest' +import { formatQuoteText } from '@deepseek-ai/dsh-client-ui-primitives' +import { parseStoredDraft } from '../src/client/draft.ts' +import { serializeDraftPrompt } from '../src/client/input/prompt.ts' + +describe('quote draft spans', () => { + it('validates canonical clipboard text and refuses overlapping references or empty quotes', () => { + const quote = { offset: 0, length: 7, text: 'quote' } + const draft = { text: '> quote', references: [], quotes: [quote] } + expect(parseStoredDraft(draft)).toEqual(draft) + expect(parseStoredDraft({ ...draft, text: 'changed' })).toBeUndefined() + expect(parseStoredDraft({ ...draft, quotes: [{ ...quote, text: ' ' }] })).toBeUndefined() + expect(parseStoredDraft({ ...draft, references: [{ source: 'file', ref: 'quote', label: 'quote', clipboardText: 'quote', offset: 2, length: 5 }] })).toBeUndefined() + expect(parseStoredDraft({ ...draft, quotes: [quote, quote] })).toBeUndefined() + }) + + it('serializes reference owners outside quotes into ordinary Markdown text', async () => { + const serialize = vi.fn(() => Promise.resolve('@[Session](dsh-session:id)')) + const quote = '/review @literal' + const quoted = formatQuoteText(quote) + const prompt = await serializeDraftPrompt({ + text: '@session ' + quoted, + references: [{ source: 'session', ref: 'id', label: 'Session', clipboardText: '@session', offset: 0, length: 8 }], + quotes: [{ offset: 9, length: quoted.length, text: quote }], + }, serialize) + expect(serialize).toHaveBeenCalledExactlyOnceWith('session', 'id') + expect(prompt).toBe('@[Session](dsh-session:id)\n\n' + quoted) + }) +}) diff --git a/packages/client/ui-conversation/tests/service-orchestration.client.spec.ts b/packages/client/ui-conversation/tests/service-orchestration.client.spec.ts index d784e37b90..76b93bd95a 100644 --- a/packages/client/ui-conversation/tests/service-orchestration.client.spec.ts +++ b/packages/client/ui-conversation/tests/service-orchestration.client.spec.ts @@ -14,9 +14,23 @@ import type { import type { SessionId } from '@deepseek-ai/dsh-session/types' import { ComposerBlockRegistry } from '../src/client/input/blocks.ts' import { InputHub } from '../src/client/input/hub.ts' +import { reportMessageSubmission } from '../src/client/input/submission-analytics.ts' import { ConversationController } from '../src/client/service.ts' import { zh } from '../src/client/locales.ts' +const IMAGE_REF = { + attachmentId: 'image-stored-1' as never, + mediaType: 'image/png' as const, + bytes: 1, + width: 1, + height: 1, +} + +/** `createDrafts` mode of an ordinary Session draft, which uploads its images when added. */ +const UPLOAD_IMAGES = true +/** `createDrafts` mode of a draft whose images travel inline, as a subagent continuation's do. */ +const INLINE_IMAGES = false + async function bench(maxConcurrentFileUploads = 2) { const runtime = await SlotTestRuntime.create() runtime.fileUpload.upload = (sessionId: SessionId, ...args: unknown[]) => { @@ -26,6 +40,13 @@ async function bench(maxConcurrentFileUploads = 2) { if (session.uploadFile === undefined) throw new Error('test file upload has no Session override') return session.uploadFile(...args) } + runtime.fileUpload.uploadImage = (sessionId: SessionId, ...args: unknown[]) => { + const session = runtime.sessions.behavior(sessionId) as { + uploadImage?: (...input: unknown[]) => Promise + } + if (session.uploadImage === undefined) throw new Error('test image upload has no Session override') + return session.uploadImage(...args) + } const prompt = vi.fn(( _content?: unknown, _mode?: unknown, _signal?: AbortSignal, _rpcId?: string, ) => Promise.resolve({ ok: true as const, value: { accepted: true as const } })) @@ -124,7 +145,7 @@ describe('ConversationController', () => { try { const [attachment] = b.root.createDrafts(b.runtime.sessions.binding('s1')!.session.sessionId, [ new File([new Uint8Array(4)], 'a.png', { type: 'image/png' }), - ]) + ], INLINE_IMAGES) if (attachment === undefined) throw new Error('draft attachment missing') b.root.input.for(b.runtime.sessions.scope('s1')!).addAttachments([attachment.id]) b.reference.release() @@ -145,7 +166,7 @@ describe('ConversationController', () => { try { const [attachment] = b.root.createDrafts(b.runtime.sessions.binding('s1')!.session.sessionId, [ new File([Uint8Array.of(1)], 'detached.png', { type: 'image/png' }), - ]) + ], INLINE_IMAGES) if (attachment === undefined) throw new Error('draft attachment missing') b.shell.addAttachments([attachment.id]) b.shell.submit() @@ -160,7 +181,7 @@ describe('ConversationController', () => { await b.runtime.dispose() }) - it('classifies image MIME drafts as images and every other file as an uploading file draft', async () => { + it('classifies image MIME drafts as images and other files as file drafts, uploading images only where accepted', async () => { const b = await bench() const created = vi.spyOn(URL, 'createObjectURL').mockReturnValue('blob:preview') const session = b.runtime.sessions.binding('s1')!.session @@ -171,24 +192,95 @@ describe('ConversationController', () => { file: { attachmentId: 'sha256:1' as never, name: 'notes.pdf', bytes: 2 }, }, })) - ;(session as { uploadFile?: unknown }).uploadFile = uploadFile + const uploadImage = vi.fn((_data: Blob, _mediaType: string, _name?: string) => Promise.resolve({ + ok: true as const, + value: { receiptId: 'image-receipt-1' as never, image: IMAGE_REF }, + })) + Object.assign(session, { uploadFile, uploadImage }) const drafts = b.root.createDrafts(session.sessionId, [ new File([Uint8Array.of(1)], 'valid.png', { type: 'image/png' }), new File([Uint8Array.of(2)], 'notes.pdf', { type: 'application/pdf' }), - ]) + ], UPLOAD_IMAGES) expect(drafts.map(draft => draft.kind)).toEqual(['image', 'file']) expect(created).toHaveBeenCalledTimes(1) - const fileDraft = drafts[1]! + const [imageDraft, fileDraft] = [drafts[0]!, drafts[1]!] expect(b.root.fileUploads.getSnapshot()[fileDraft.id]?.status).toBe('uploading') + expect(b.root.fileUploads.getSnapshot()[imageDraft.id]?.status).toBe('uploading') await vi.waitFor(() => { expect(b.root.fileUploads.getSnapshot()[fileDraft.id]?.status).toBe('ready') + expect(b.root.fileUploads.getSnapshot()[imageDraft.id]) + .toEqual({ status: 'ready', receiptId: 'image-receipt-1' }) }) expect(uploadFile).toHaveBeenCalledOnce() - expect(uploadFile.mock.calls[0]?.[0]).toBe((fileDraft as { file: File }).file) + expect(uploadFile.mock.calls[0]?.[0]).toBe(fileDraft.file) + expect(uploadImage.mock.calls[0]?.slice(0, 3)).toEqual([imageDraft.file, 'image/png', 'valid.png']) + const [inline] = b.root.createDrafts(session.sessionId, [ + new File([Uint8Array.of(3)], 'child.png', { type: 'image/png' }), + ], INLINE_IMAGES) + expect(b.root.fileUploads.getSnapshot()[inline!.id]).toBeUndefined() + expect(uploadImage).toHaveBeenCalledOnce() created.mockRestore() await b.runtime.dispose() }) + it('retries a failed image upload and aborts it with its draft', async () => { + const b = await bench() + const created = vi.spyOn(URL, 'createObjectURL').mockReturnValue('blob:retry') + const revoked = vi.spyOn(URL, 'revokeObjectURL').mockReturnValue(undefined) + try { + const session = b.runtime.sessions.binding('s1')!.session + const signals: AbortSignal[] = [] + const uploadImage = vi.fn((_data: Blob, _mediaType: string, _name: string | undefined, signal: AbortSignal) => { + signals.push(signal) + return signals.length === 1 + ? Promise.resolve({ ok: false, error: new RemoteError('session/attachment-invalid', 'too big', { reason: 'IMAGE_TOO_LARGE' }) }) + : new Promise((_resolve, reject) => { + signal.addEventListener('abort', () => { reject(new Error('aborted')) }, { once: true }) + }) + }) + ;(session as { uploadImage?: unknown }).uploadImage = uploadImage + const [draft] = b.root.createDrafts(session.sessionId, [ + new File([Uint8Array.of(1)], 'big.png', { type: 'image/png' }), + ], UPLOAD_IMAGES) + // A Host refusal keeps its reason; retrying stays available to the service. + await vi.waitFor(() => { + expect(b.root.fileUploads.getSnapshot()[draft!.id]) + .toEqual({ status: 'error', message: 'too big', reason: 'IMAGE_TOO_LARGE' }) + }) + b.root.retryFileUpload(session.sessionId, draft!.id) + await vi.waitFor(() => { expect(uploadImage).toHaveBeenCalledTimes(2) }) + expect(b.root.fileUploads.getSnapshot()[draft!.id]?.status).toBe('uploading') + b.root.releaseDraftAttachment(draft!.id) + expect(signals[1]?.aborted).toBe(true) + expect(b.root.fileUploads.getSnapshot()[draft!.id]).toBeUndefined() + expect(revoked).toHaveBeenCalledWith('blob:retry') + } finally { + created.mockRestore() + revoked.mockRestore() + } + await b.runtime.dispose() + }) + + it('keeps no refusal reason when an image upload fails in transport', async () => { + const b = await bench() + const created = vi.spyOn(URL, 'createObjectURL').mockReturnValue('blob:flaky') + try { + const session = b.runtime.sessions.binding('s1')!.session + ;(session as { uploadImage?: unknown }).uploadImage = vi.fn(() => Promise.resolve({ + ok: false, error: new RemoteError('gateway/internal', 'connection reset', {}), + })) + const [draft] = b.root.createDrafts(session.sessionId, [ + new File([Uint8Array.of(1)], 'flaky.png', { type: 'image/png' }), + ], UPLOAD_IMAGES) + await vi.waitFor(() => { + expect(b.root.fileUploads.getSnapshot()[draft!.id]).toEqual({ status: 'error', message: 'connection reset' }) + }) + } finally { + created.mockRestore() + } + await b.runtime.dispose() + }) + it('serializes command files as staged receipts without reading their bytes again', async () => { const b = await bench() const session = b.runtime.sessions.binding('s1')!.session @@ -203,7 +295,7 @@ describe('ConversationController', () => { const drafts = b.root.createDrafts(session.sessionId, [ new File([Uint8Array.of(1)], 'one.txt', { type: 'text/plain' }), new File([Uint8Array.of(2)], 'two.txt', { type: 'text/plain' }), - ]) + ], UPLOAD_IMAGES) await vi.waitFor(() => { expect(drafts.every(draft => b.root.fileUploads.getSnapshot()[draft.id]?.status === 'ready')).toBe(true) }) @@ -244,7 +336,7 @@ describe('ConversationController', () => { }) ;(session as { uploadFile?: unknown }).uploadFile = uploadFile const drafts = b.root.createDrafts(session.sessionId, ['one', 'two', 'three', 'four', 'removed'].map(name => - new File([Uint8Array.of(1)], `${name}.txt`, { type: 'text/plain' }))) + new File([Uint8Array.of(1)], `${name}.txt`, { type: 'text/plain' })), UPLOAD_IMAGES) await vi.waitFor(() => { expect(uploadFile.mock.calls.map(call => call[1])).toEqual(['one.txt', 'two.txt']) }) await expect(b.root.serializeDraftAttachments([drafts[2]!.id])) @@ -294,7 +386,7 @@ describe('ConversationController', () => { ;(session as { uploadFile?: unknown }).uploadFile = uploadFile const [attachment] = b.root.createDrafts(session.sessionId, [ new File([new Uint8Array(8)], 'background.bin', { type: 'application/octet-stream' }), - ]) + ], UPLOAD_IMAGES) if (attachment === undefined) throw new Error('file draft missing') b.shell.addAttachments([attachment.id]) await vi.waitFor(() => { expect(uploadFile).toHaveBeenCalledOnce() }) @@ -327,6 +419,38 @@ describe('ConversationController', () => { await b.runtime.dispose() }) + it('drops a carried image upload when the target Session sends images inline', async () => { + const b = await bench() + const created = vi.spyOn(URL, 'createObjectURL').mockReturnValue('blob:carry') + try { + const source = b.runtime.sessions.binding('s1')!.session + let sourceSignal: AbortSignal | undefined + ;(source as { uploadImage?: unknown }).uploadImage = vi.fn( + (_data: Blob, _mediaType: string, _name: string | undefined, signal: AbortSignal) => { + sourceSignal = signal + return new Promise((_resolve, reject) => { + signal.addEventListener('abort', () => { reject(new Error('aborted')) }, { once: true }) + }) + }, + ) + const [image] = b.root.createDrafts(source.sessionId, [ + new File([Uint8Array.of(5)], 'carry.png', { type: 'image/png' }), + ], UPLOAD_IMAGES) + await vi.waitFor(() => { expect(sourceSignal).toBeDefined() }) + const targetUpload = vi.fn() + await b.runtime.sessions.add({ id: 's2', session: { uploadImage: targetUpload } }) + using _target = b.runtime.sessions.retain('s2' as SessionId) + await _target.ready + b.root.rebindDraftFiles(b.runtime.sessions.binding('s2')!.session.sessionId, [image!.id], INLINE_IMAGES) + expect(sourceSignal?.aborted).toBe(true) + expect(b.root.fileUploads.getSnapshot()[image!.id]).toBeUndefined() + expect(targetUpload).not.toHaveBeenCalled() + } finally { + created.mockRestore() + } + await b.runtime.dispose() + }) + it('cancels a superseded upload and stages the carried draft on the target Session', async () => { const b = await bench() const source = b.runtime.sessions.binding('s1')!.session @@ -342,7 +466,7 @@ describe('ConversationController', () => { ;(source as { uploadFile?: unknown }).uploadFile = sourceUpload const [attachment] = b.root.createDrafts(source.sessionId, [ new File([Uint8Array.of(4)], 'carry.pdf', { type: 'application/pdf' }), - ]) + ], UPLOAD_IMAGES) if (attachment === undefined) throw new Error('file draft missing') await vi.waitFor(() => { expect(sourceUpload).toHaveBeenCalledOnce() }) @@ -358,7 +482,7 @@ describe('ConversationController', () => { await b.runtime.sessions.add({ id: 's2', session: target }) using _target = b.runtime.sessions.retain('s2' as SessionId) await _target.ready - b.root.rebindDraftFiles(b.runtime.sessions.binding('s2')!.session.sessionId, [attachment.id]) + b.root.rebindDraftFiles(b.runtime.sessions.binding('s2')!.session.sessionId, [attachment.id], UPLOAD_IMAGES) expect(sourceSignal?.aborted).toBe(true) await vi.waitFor(() => { @@ -385,7 +509,7 @@ describe('ConversationController', () => { ;(session as { uploadFile?: unknown }).uploadFile = uploadFile const [attachment] = b.root.createDrafts(session.sessionId, [ new File([Uint8Array.of(5)], 'removed.pdf', { type: 'application/pdf' }), - ]) + ], UPLOAD_IMAGES) if (attachment === undefined) throw new Error('file draft missing') await vi.waitFor(() => { expect(uploadFile).toHaveBeenCalledOnce() }) @@ -414,7 +538,7 @@ describe('ConversationController', () => { })) const [attachment] = b.root.createDrafts(session.sessionId, [ new File([Uint8Array.of(6)], 'sent.pdf', { type: 'application/pdf' }), - ]) + ], UPLOAD_IMAGES) if (attachment === undefined) throw new Error('file draft missing') await vi.waitFor(() => { expect(b.root.fileUploads.getSnapshot()[attachment.id]?.status).toBe('ready') @@ -453,7 +577,7 @@ describe('ConversationController', () => { ) b.root.createDrafts(session.sessionId, [ new File([Uint8Array.of(7)], 'dispose.pdf', { type: 'application/pdf' }), - ]) + ], UPLOAD_IMAGES) await vi.waitFor(() => { expect(uploadSignal).toBeDefined() }) let disposed = false @@ -515,7 +639,7 @@ describe('sendSession submission echo', () => { try { const [attachment] = b.root.createDrafts(b.runtime.sessions.binding('s1')!.session.sessionId, [ new File([Uint8Array.of(1, 2, 3)], 'a.png', { type: 'image/png' }), - ]) + ], INLINE_IMAGES) const session = b.runtime.sessions.binding('s1')!.session const sending = b.root.sendSession(session, '带图', [attachment!.id], 'queue') const echo = await b.begun @@ -562,7 +686,7 @@ describe('sendSession submission echo', () => { new File([Uint8Array.of(1)], 'first.png', { type: 'image/png' }), new File([Uint8Array.of(2)], 'notes.txt', { type: 'text/plain' }), new File([Uint8Array.of(3)], 'last.png', { type: 'image/png' }), - ]) + ], INLINE_IMAGES) await vi.waitFor(() => { expect(b.root.fileUploads.getSnapshot()[drafts[1]!.id]?.status).toBe('ready') }) @@ -611,6 +735,40 @@ describe('sendSession submission echo', () => { await b.runtime.dispose() }) + it('sends an uploaded image draft by its receipt and holds a send while it uploads', async () => { + const b = await echoBench() + const readAsDataURL = vi.spyOn(FileReader.prototype, 'readAsDataURL') + try { + const session = b.runtime.sessions.binding('s1')!.session + const uploaded = Promise.withResolvers() + ;(session as { uploadImage?: unknown }).uploadImage = vi.fn(() => uploaded.promise) + const [draft] = b.root.createDrafts(session.sessionId, [ + new File([Uint8Array.of(1)], 'shot.png', { type: 'image/png' }), + ], UPLOAD_IMAGES) + await expect(b.root.sendSession(session, 'look', [draft!.id], 'queue')) + .rejects.toThrow('one or more files have not finished uploading') + expect(b.beginSubmission).not.toHaveBeenCalled() + uploaded.resolve({ ok: true, value: { receiptId: 'image-receipt-1', image: IMAGE_REF } }) + await vi.waitFor(() => { expect(b.root.fileUploads.getSnapshot()[draft!.id]?.status).toBe('ready') }) + const sending = b.root.sendSession(session, 'look', [draft!.id], 'queue') + const echo = await b.begun + expect(echo.attachments[0]).toMatchObject({ type: 'image', value: { previewUrl: 'blob:echo-1', name: 'shot.png' } }) + await vi.waitFor(() => { expect(b.prompt).toHaveBeenCalledOnce() }) + expect(b.prompt.mock.calls[0]?.[0]).toEqual([ + { type: 'file', receiptId: 'image-receipt-1' }, + { type: 'text', text: 'look' }, + ]) + expect(readAsDataURL).not.toHaveBeenCalled() + b.retire.onRetire?.({ reason: 'observed', attachments: [IMAGE_REF] }) + await expect(sending).resolves.toEqual({ kind: 'success' }) + expect(b.root.fileUploads.getSnapshot()[draft!.id]).toBeUndefined() + } finally { + readAsDataURL.mockRestore() + b.restore() + } + await b.runtime.dispose() + }) + it('passes each delivery mode before image serialization', async () => { const b = await echoBench() try { @@ -642,7 +800,7 @@ describe('sendSession submission echo', () => { b.runtime.ctx.set('uiConversation', { seedImageUrl }) const [attachment] = b.root.createDrafts(b.runtime.sessions.binding('s1')!.session.sessionId, [ new File([Uint8Array.of(9)], 'seeded.png', { type: 'image/png' }), - ]) + ], INLINE_IMAGES) const session = b.runtime.sessions.binding('s1')!.session const sending = b.root.sendSession(session, '', [attachment!.id], 'queue') await vi.waitFor(() => { expect(b.prompt).toHaveBeenCalledOnce() }) @@ -683,7 +841,7 @@ describe('sendSession submission echo', () => { const attachments = b.root.createDrafts(session.sessionId, [ new File([Uint8Array.of(7)], 'kept.png', { type: 'image/png' }), new File([Uint8Array.of(8)], 'kept.txt', { type: 'text/plain' }), - ]) + ], INLINE_IMAGES) await vi.waitFor(() => { expect(b.root.fileUploads.getSnapshot()[attachments[1]!.id]?.status).toBe('ready') }) @@ -712,7 +870,7 @@ describe('sendSession submission echo', () => { try { const [attachment] = b.root.createDrafts(b.runtime.sessions.binding('s1')!.session.sessionId, [ new File([Uint8Array.of(1)], 'broken.png', { type: 'image/png' }), - ]) + ], INLINE_IMAGES) const session = b.runtime.sessions.binding('s1')!.session await expect(b.root.sendSession(session, 'x', [attachment!.id], 'queue')) .rejects.toThrow('read failed') @@ -795,12 +953,12 @@ describe('draft image dimension probe', () => { try { const [probed] = b.root.createDrafts(b.runtime.sessions.binding('s1')!.session.sessionId, [ new File([Uint8Array.of(1)], 'probed.png', { type: 'image/png' }), - ]) + ], INLINE_IMAGES) expect(probed).toMatchObject({ width: 640, height: 480 }) vi.stubGlobal('Image', undefined) const [unprobed] = b.root.createDrafts(b.runtime.sessions.binding('s1')!.session.sessionId, [ new File([Uint8Array.of(2)], 'unprobed.png', { type: 'image/png' }), - ]) + ], INLINE_IMAGES) expect(unprobed?.kind).toBe('image') if (unprobed?.kind !== 'image') throw new Error('image draft missing') expect(unprobed.width).toBeUndefined() @@ -894,6 +1052,7 @@ it('captures run mode and queue intent at submission before later projection cha b.shell.submit('queue', 'click') expect(track).toHaveBeenCalledExactlyOnceWith('send_button_click', { session_id: 's1', model_name: 'fixture/model', thinking_effort: 'high', run_mode: 'plan', msg_type: 'queue', + is_new_session: false, }, expect.any(Number)) await b.runtime.sessions.setProjection('s1', 'plan', { active: false, pending: false }) expect(track).toHaveBeenCalledTimes(1) @@ -912,6 +1071,104 @@ it.each([ await b.runtime.sessions.updateSessionSnapshot('s1', (state) => { state.running = running; state.blank = true }) b.shell.setDraft('message') b.shell.submit(mode, 'enter') - expect(track).toHaveBeenCalledExactlyOnceWith('send_button_click', { run_mode: 'default', msg_type: expected }, expect.any(Number)) + expect(track).toHaveBeenCalledExactlyOnceWith('send_button_click', { + run_mode: 'default', msg_type: expected, is_new_session: true, + }, expect.any(Number)) } finally { await b.runtime.dispose() } }) + +it('marks only a message sent from a blank Session as a new Session', async () => { + const b = await bench() + const track = vi.fn() + b.runtime.ctx.provide('productAnalytics', { enabled: true, track } as never) + try { + await b.runtime.sessions.updateSessionSnapshot('s1', (state) => { state.running = false; state.blank = true }) + b.shell.setDraft('first message') + b.shell.submit('queue', 'click') + expect(track).toHaveBeenLastCalledWith('send_button_click', { + run_mode: 'default', msg_type: 'default', is_new_session: true, + }, expect.any(Number)) + await vi.waitFor(() => { expect(b.shell.snapshot.phase).toBe('plain') }) + await b.runtime.sessions.updateSessionSnapshot('s1', (state) => { state.blank = false }) + b.shell.setDraft('second message') + b.shell.submit('queue', 'click') + expect(track).toHaveBeenLastCalledWith('send_button_click', { + session_id: 's1', run_mode: 'default', msg_type: 'default', is_new_session: false, + }, expect.any(Number)) + expect(track).toHaveBeenCalledTimes(2) + } finally { await b.runtime.dispose() } +}) + +it.each([ + { preset: 'cordis', category: 'creator' }, + { preset: 'ptc', category: 'ptc' }, + { preset: 'minimal', category: 'minimal' }, + { preset: 'standard', category: 'standard' }, +] as const)('reports the built-in $preset preset as $category', async ({ preset, category }) => { + const b = await bench() + const track = vi.fn() + b.runtime.ctx.provide('productAnalytics', { enabled: true, track } as never) + try { + await b.runtime.sessions.setProjection('s1', 'agentPreset', preset) + await b.runtime.sessions.updateSessionSnapshot('s1', (state) => { state.running = false; state.blank = true }) + b.shell.setDraft('message') + b.shell.submit('queue', 'click') + expect(track).toHaveBeenCalledExactlyOnceWith('send_button_click', { + run_mode: 'default', msg_type: 'default', is_new_session: true, agent_preset: category, + }, expect.any(Number)) + } finally { await b.runtime.dispose() } +}) + +it('reports a custom preset as other without its id', async () => { + const b = await bench() + const track = vi.fn() + b.runtime.ctx.provide('productAnalytics', { enabled: true, track } as never) + try { + await b.runtime.sessions.setProjection('s1', 'agentPreset', 'private-preset') + await b.runtime.sessions.updateSessionSnapshot('s1', (state) => { state.running = false; state.blank = false }) + b.shell.setDraft('message') + b.shell.submit('queue', 'click') + expect(track).toHaveBeenCalledExactlyOnceWith('send_button_click', { + session_id: 's1', run_mode: 'default', msg_type: 'default', is_new_session: false, agent_preset: 'other', + }, expect.any(Number)) + expect(JSON.stringify(track.mock.calls)).not.toContain('private-preset') + } finally { await b.runtime.dispose() } +}) + +it('omits the preset category while the Session records no preset', async () => { + const b = await bench() + const track = vi.fn() + b.runtime.ctx.provide('productAnalytics', { enabled: true, track } as never) + try { + await b.runtime.sessions.setProjection('s1', 'agentPreset', null) + await b.runtime.sessions.updateSessionSnapshot('s1', (state) => { state.running = false; state.blank = true }) + b.shell.setDraft('message') + b.shell.submit('queue', 'click') + expect(track).toHaveBeenCalledOnce() + expect(track.mock.calls[0]?.[1]).not.toHaveProperty('agent_preset') + } finally { await b.runtime.dispose() } +}) + +it('reports an active goal and a model without an explicit effort', async () => { + const b = await bench() + const track = vi.fn() + b.runtime.ctx.provide('productAnalytics', { enabled: true, track } as never) + try { + await b.runtime.sessions.setProjection('s1', 'goal', { goal: { phase: 'active' } }) + await b.runtime.sessions.setProjection('s1', 'modelSelection', { next: null, lastUsed: { provider: 'fixture', model: 'model' } }) + await b.runtime.sessions.updateSessionSnapshot('s1', (state) => { state.running = false; state.blank = false }) + b.shell.setDraft('message') + b.shell.submit('queue', 'click') + expect(track).toHaveBeenCalledExactlyOnceWith('send_button_click', { + session_id: 's1', model_name: 'fixture/model', run_mode: 'goal', msg_type: 'default', is_new_session: false, + }, expect.any(Number)) + } finally { await b.runtime.dispose() } +}) + +it('reports nothing for a submission whose Session facts were not captured', () => { + const ctx = new Context() + const track = vi.fn() + ctx.provide('productAnalytics', { enabled: true, track } as never) + reportMessageSubmission(ctx, { timestamp: 1, mode: 'queue', source: 'click' }) + expect(track).not.toHaveBeenCalled() +}) diff --git a/packages/client/ui-conversation/tests/user-text-component-fixture.client.tsx b/packages/client/ui-conversation/tests/user-text-component-fixture.client.tsx index 8a475bc33d..f4970b398d 100644 --- a/packages/client/ui-conversation/tests/user-text-component-fixture.client.tsx +++ b/packages/client/ui-conversation/tests/user-text-component-fixture.client.tsx @@ -7,6 +7,8 @@ import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts import type { UserTextInput, UserTextNodeOwnerProps } from '../src/client/contract/slots.ts' import type { UserTextNode } from '../src/client/contract/user-text.ts' import { UserTextRegistry } from '../src/client/conversation/user-text-registry.ts' +import { QuotedUserText } from '@deepseek-ai/dsh-message-quotes/src/client/QuotedUserText.tsx' +import { quoteRule } from '@deepseek-ai/dsh-message-quotes/src/client/quote-rule.ts' import { UserText, PlainUserText, SessionReference } from '../src/client/user-text/UserText.tsx' import { sessionReferenceRule } from '../src/client/user-text/session-reference-rule.ts' import { collapseWhitespaceRule, trimWhitespaceRule } from '../src/client/user-text/whitespace-rules.ts' @@ -15,6 +17,8 @@ import { SkillReference } from '../../ui-skill/src/client/SkillReference.tsx' import { fileReferenceRule, folderReferenceRule } from '../../ui-reference/src/client/user-text-rules.ts' import { FileReference, FolderReference } from '../../ui-reference/src/client/FileReference.tsx' import { zh as referenceZh } from '../../ui-reference/src/client/locales.ts' +import { createSecretRule } from '../../ui-secret-mask/src/client/secret-rule.ts' +import { SecretText } from '../../ui-secret-mask/src/client/SecretText.tsx' import { goalCommandReferenceRule } from '../../ui-goal/src/client/user-text-rules.ts' import { GoalCommandReference } from '../../ui-goal/src/client/GoalCommandReference.tsx' @@ -37,7 +41,7 @@ export function createUserTextFixture(actions: UserTextFixtureActions = {}): (in onTestFinished(async () => { await ctx.fiber.dispose() }) const registry = new UserTextRegistry(ctx) for (const rule of [ - sessionReferenceRule, skillReferenceRule, fileReferenceRule, folderReferenceRule, + quoteRule, createSecretRule(), sessionReferenceRule, skillReferenceRule, fileReferenceRule, folderReferenceRule, goalCommandReferenceRule, collapseWhitespaceRule, trimWhitespaceRule, ]) registry.register(rule) const useUserTextRevision = bindSnapshotSelector({ @@ -54,6 +58,8 @@ export function createUserTextFixture(actions: UserTextFixtureActions = {}): (in const owner = input as UserTextNodeOwnerProps & { node: UserTextNode } switch (owner.node.id) { // The production components only read these owner, action and locale seats in these units. + case 'quote': return createElement(QuotedUserText, owner as ComponentProps) + case 'secret': return createElement(SecretText, { ...owner, t } as ComponentProps) case 'text': return createElement(PlainUserText, owner as ComponentProps) case 'session': return createElement(SessionReference, owner as ComponentProps) case 'file': return createElement(FileReference, { ...owner, openFile, t } as ComponentProps) @@ -63,7 +69,9 @@ export function createUserTextFixture(actions: UserTextFixtureActions = {}): (in default: return options?.fallback ?? null } } - return input => createElement(UserText, { - ...input, useUserTextRevision, createUserTextEvaluator, reportTransformFailure, renderSlot, + const renderText = (input: UserTextInput) => createElement(UserText, { + ...input, + useUserTextRevision, createUserTextEvaluator, reportTransformFailure, renderSlot, } as UserTextProps) + return renderText } diff --git a/packages/client/ui-deepseek-model-label-1017/locale/en.json b/packages/client/ui-deepseek-model-label-1017/locale/en.json new file mode 100644 index 0000000000..b4906f27c6 --- /dev/null +++ b/packages/client/ui-deepseek-model-label-1017/locale/en.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "DeepSeek 1017 model badge", + "description": "Add a temporary 1017 release badge to official DeepSeek Flash model names." + } +} diff --git a/packages/client/ui-deepseek-model-label-1017/locale/ja.json b/packages/client/ui-deepseek-model-label-1017/locale/ja.json new file mode 100644 index 0000000000..3a11249af3 --- /dev/null +++ b/packages/client/ui-deepseek-model-label-1017/locale/ja.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "DeepSeek 1017 モデルバッジ", + "description": "公式 DeepSeek Flash モデルの名前に、一時的な 1017 リリースバッジを表示します。" + } +} diff --git a/packages/client/ui-deepseek-model-label-1017/locale/zh.json b/packages/client/ui-deepseek-model-label-1017/locale/zh.json new file mode 100644 index 0000000000..a8464157af --- /dev/null +++ b/packages/client/ui-deepseek-model-label-1017/locale/zh.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "DeepSeek 1017 模型标记", + "description": "为官方 DeepSeek Flash 模型名称添加临时的 1017 版本标记。" + } +} diff --git a/packages/client/ui-deepseek-model-label-1017/package.json b/packages/client/ui-deepseek-model-label-1017/package.json index e029de87fd..4a44b82db6 100644 --- a/packages/client/ui-deepseek-model-label-1017/package.json +++ b/packages/client/ui-deepseek-model-label-1017/package.json @@ -23,8 +23,8 @@ "default": "./lib/client.js" }, "./src/*": "./src/*", - "./package.json": "./package.json", - "./locale/*.json": "./locale/*.json" + "./locale/*.json": "./locale/*.json", + "./package.json": "./package.json" }, "dsh": { "client": { diff --git a/packages/client/ui-deepseek-model-label-1017/src/client/index.ts b/packages/client/ui-deepseek-model-label-1017/src/client/index.ts index 63505299f6..5a38858688 100644 --- a/packages/client/ui-deepseek-model-label-1017/src/client/index.ts +++ b/packages/client/ui-deepseek-model-label-1017/src/client/index.ts @@ -4,7 +4,7 @@ import type {} from '@deepseek-ai/dsh-client-locale/client' import type {} from '@deepseek-ai/dsh-client-ui-model-selection/client' import type {} from '@deepseek-ai/dsh-client-ui-renderer/client' import { DeepSeekModelLabel } from './DeepSeekModelLabel.tsx' -import { en, zh, type LabelKey } from './locales.ts' +import { en, ja, zh, type LabelKey } from './locales.ts' declare module '@deepseek-ai/dsh-client-ui-slots' { interface LocaleNamespaceMap { @@ -22,7 +22,7 @@ export const inject = ['slots', 'locale'] */ export function apply(ctx: Context): void { const namespace = 'deepseek-model-label-1017' - ctx.effect(() => ctx.locale.register(namespace, { zh, en }), 'deepseek-model-label-1017: dictionaries') + ctx.effect(() => ctx.locale.register(namespace, { zh, en, ja }), 'deepseek-model-label-1017: dictionaries') ctx.slots.inject('conversation.input.model.label', () => ctx.slots.register({ name: 'conversation.input.model.label', locale: namespace, }, DeepSeekModelLabel)) diff --git a/packages/client/ui-deepseek-model-label-1017/src/client/locales.ts b/packages/client/ui-deepseek-model-label-1017/src/client/locales.ts index d3eb4a956e..95ca2cb796 100644 --- a/packages/client/ui-deepseek-model-label-1017/src/client/locales.ts +++ b/packages/client/ui-deepseek-model-label-1017/src/client/locales.ts @@ -8,3 +8,8 @@ export type LabelKey = keyof typeof zh /** English badge copy. */ export const en: Record = { 'badge.label': '1017' } + +/** Japanese copy, checked against the English keys. */ +export const ja = { + 'badge.label': '1017', +} satisfies Record diff --git a/packages/client/ui-deliverables/README.i18n.yaml b/packages/client/ui-deliverables/README.i18n.yaml index 30225fdf45..e18a4ecc9a 100644 --- a/packages/client/ui-deliverables/README.i18n.yaml +++ b/packages/client/ui-deliverables/README.i18n.yaml @@ -18,8 +18,8 @@ en: 4a2fcdcf13276fef zh: 8bde03bbbf15840c /deepseek-ai-dsh-client-ui-deliverables/use-this-package/explicit-deliveries: - en: 6ae1652a01354a6f - zh: baa44d415d7a9f0e + en: 62d4ae0abeeba61e + zh: 32eb43e49166bf61 /deepseek-ai-dsh-client-ui-deliverables/use-this-package/the-changed-files-card: en: 4b279e2bddddc083 zh: a3135393299c935b @@ -27,8 +27,8 @@ en: 231547659321f3a8 zh: bc5935cdd46ada56 /deepseek-ai-dsh-client-ui-deliverables/use-this-package/inline-code-links: - en: ef13aac8ac36a5c3 - zh: 1e2ae4b3eaf98818 + en: 35b9ef9ddbe5b92f + zh: 35d1c2e2ecf4a42a /deepseek-ai-dsh-client-ui-deliverables/understand-the-implementation: en: 7ee5ee783902662c zh: fdf2c244fb22b8ab diff --git a/packages/client/ui-deliverables/README.md b/packages/client/ui-deliverables/README.md index 0fd986aed1..a82b593ea8 100644 --- a/packages/client/ui-deliverables/README.md +++ b/packages/client/ui-deliverables/README.md @@ -34,7 +34,7 @@ Changed-file and delivery entry cards and hover previews follow the [R16 preview The `present` tool's preparing stage is one non-expandable document-icon row. It does not show filenames, create delivery cards, or enable file actions before the recorded call supplies arguments. -The Web `standard`, `ptc`, and `cordis` presets expose `present` for final files accessible through the Session filesystem, including files created through Bash. Call it with `files: [{ path, description? }]` after creating the files. The [present tool](../../deliverables/deliverable-file-references/README.md) owns file-count limits and Session declarations. A single delivery fills the row; multiple deliveries form a two-column grid, collapsed after four files. Cards show the basename, description or file type, and a shared native opening control. Delivery and changed-file icon tiles use white at 50% opacity in the light theme and 5% in the dark theme. Icon tiles and single-file changed cards use the same border as delivery cards. Clicking the card previews the file in the right Sidebar. The control opens the default application and lists associated applications, with file reveal last. Matching inline-code references also open Sidebar previews. Duplicate declarations use the last description before the closing reply. +The Web `standard`, `ptc`, and `cordis` presets expose `present` for final files accessible through the Session filesystem, including files created through Bash. Call it with `files: [{ path, description? }]` after creating the files. The [present tool](../../deliverables/deliverable-file-references/README.md) owns file-count limits and Session declarations. A single delivery fills the row; multiple deliveries form a two-column grid, collapsed after four files. Cards show the basename, description or file type, and a shared native opening control. Delivery and changed-file icon tiles use white at 50% opacity in the light theme and 5% in the dark theme. Icon tiles and single-file changed cards use the same border as delivery cards. Clicking the card previews the file in the right Sidebar. The control opens the default application and lists associated applications, with file reveal last. Matching inline-code references also open Sidebar previews. Delivery cards appear in the turn tail only after the turn ends, so a turn waiting on a blocking question shows none yet; the `present` tool description directs the model to link files in its message for earlier review. Cards include every declaration in the turn, including declarations recorded after the closing reply; duplicate declarations use the last description. The `present` tool row retains its document icon and recorded result across progress, success, failure, and interruption. Native actions share pending state and publish progress or retryable errors on the card. Successful open or reveal feedback stays visible for five seconds, fades over 200ms, then restores the file description; failures remain visible until another attempt. Desktop metadata is loaded when cards mount and invalidated on connection replacement. Without a Host desktop, cards retain Sidebar preview but omit native controls; failed metadata reads offer Retry. Delivery cards and change review expose `deliverables.file.actions` and `deliverables.review.file.actions`, supplied by [ui-open-in-app](../ui-open-in-app/README.md). Association GET requests and native POST actions both validate the recorded file coordinates and Session filesystem mapping before using the Host desktop. @@ -54,7 +54,7 @@ Without wrapping, the two columns synchronize vertical scrolling and horizontal ### Inline-code links -The closing prose links produced or delivered paths: an inline-code token resolves by exact target, by an exact argument recorded for that target, or by a unique basename. Every matching fact must identify the same file; ambiguous arguments and basenames stay inert. No suffix or current-directory guess is used. A resolved mention keeps its code chip and takes the markdown sheet's link language, with the full path as its title. +The closing prose links paths the turn produced or delivered, including those recorded after the closing reply: an inline-code token resolves by exact target, by an exact argument recorded for that target, or by a unique basename. Every matching fact must identify the same file; ambiguous arguments and basenames stay inert. No suffix or current-directory guess is used. A resolved mention keeps its code chip and takes the markdown sheet's link language, with the full path as its title. ----- diff --git a/packages/client/ui-deliverables/README.zh.md b/packages/client/ui-deliverables/README.zh.md index 8d959a6b38..52169d6b34 100644 --- a/packages/client/ui-deliverables/README.zh.md +++ b/packages/client/ui-deliverables/README.zh.md @@ -34,7 +34,7 @@ kind: "package-reference" `present` 工具的准备阶段是带文档图标的不可展开单行。记录的调用提供参数前,该行不显示文件名、不创建交付卡片,也不启用文件操作。 -Web 的 `standard`、`ptc` 与 `cordis` preset 提供 `present`,用于声明会话文件系统可访问的最终文件,包括通过 Bash 创建的文件。创建后以 `files: [{ path, description? }]` 调用。[present 工具](../../deliverables/deliverable-file-references/README.zh.md)拥有文件数量限制和会话声明。单个交付占满一行,多个交付使用双列网格,超过四个时默认收起。卡片显示文件名、说明或文件类型,以及共享的原生打开控件。交付和改动文件卡片的图标底块在浅色主题下使用 50% 不透明度的白色,深色主题下为 5%。图标底块与单文件改动卡片的外框使用与交付卡片相同的描边。点击卡片会在右侧 Sidebar 预览文件。控件打开默认应用,并列出关联应用,文件定位始终放在最后。匹配的行内代码引用也打开 Sidebar 预览。重复声明使用收尾回复之前最近一次的说明。 +Web 的 `standard`、`ptc` 与 `cordis` preset 提供 `present`,用于声明会话文件系统可访问的最终文件,包括通过 Bash 创建的文件。创建后以 `files: [{ path, description? }]` 调用。[present 工具](../../deliverables/deliverable-file-references/README.zh.md)拥有文件数量限制和会话声明。单个交付占满一行,多个交付使用双列网格,超过四个时默认收起。卡片显示文件名、说明或文件类型,以及共享的原生打开控件。交付和改动文件卡片的图标底块在浅色主题下使用 50% 不透明度的白色,深色主题下为 5%。图标底块与单文件改动卡片的外框使用与交付卡片相同的描边。点击卡片会在右侧 Sidebar 预览文件。控件打开默认应用,并列出关联应用,文件定位始终放在最后。匹配的行内代码引用也打开 Sidebar 预览。交付卡片只在本轮结束后出现在 turn tail 中,因此等待阻塞提问的轮次暂时没有卡片;`present` 工具描述会引导模型在需要提前审阅时于消息中链接文件。卡片包含本轮的所有声明,包括收尾回复之后记录的声明;重复声明使用最近一次的说明。 `present` 工具行在执行、成功、失败和中断状态下保留文档图标及记录的结果。原生操作共享等待状态,并在卡片显示进度或可重试错误。打开或定位成功的反馈显示五秒后,在 200ms 内淡出并恢复文件说明;失败信息保留到下次尝试。卡片挂载时读取桌面信息,连接更换时清除缓存。Host 没有桌面时,卡片保留 Sidebar 预览并隐藏原生控件;元数据读取失败时提供重试。交付卡片和变更对比页通过 `deliverables.file.actions` 和 `deliverables.review.file.actions` 使用 [ui-open-in-app](../ui-open-in-app/README.zh.md) 提供的控件。关联应用 GET 查询与原生 POST 操作都会先校验记录中的文件坐标及会话文件系统映射,再调用 Host 桌面。 @@ -54,7 +54,7 @@ Web 的 `standard`、`ptc` 与 `cordis` preset 提供 `present`,用于声明 ### 行内代码链接 -收尾正文链接产出或已交付的路径:行内代码 token 可按精确目标路径、为该目标记录的精确参数,或唯一 basename 解析。所有匹配事实必须指向同一文件;有歧义的参数与 basename 保持不可点击。不使用后缀或当前目录猜测。解析成功的提及保留代码标签,并采用 Markdown 样式表的链接样式,完整路径作为其 `title`。 +收尾正文链接本轮产出或已交付的路径,包括收尾回复之后记录的路径:行内代码 token 可按精确目标路径、为该目标记录的精确参数,或唯一 basename 解析。所有匹配事实必须指向同一文件;有歧义的参数与 basename 保持不可点击。不使用后缀或当前目录猜测。解析成功的提及保留代码标签,并采用 Markdown 样式表的链接样式,完整路径作为其 `title`。 ----- diff --git a/packages/client/ui-deliverables/locale/ja.json b/packages/client/ui-deliverables/locale/ja.json new file mode 100644 index 0000000000..15bb5cd7e6 --- /dev/null +++ b/packages/client/ui-deliverables/locale/ja.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "ファイル変更と成果物の表示", + "description": "変更されたファイル、内容の比較、成果物ファイルへの入口を表示します。" + } +} diff --git a/packages/client/ui-deliverables/src/client/index.ts b/packages/client/ui-deliverables/src/client/index.ts index bbc165e99c..ef5bc4b06d 100644 --- a/packages/client/ui-deliverables/src/client/index.ts +++ b/packages/client/ui-deliverables/src/client/index.ts @@ -27,7 +27,7 @@ import { DeliverablesTail, type DeliverablesInjected } from './Deliverables.tsx' import { ReviewTab, type ReviewInjected } from './ReviewTab.tsx' import { CHANGES_REVIEW_ID, changesReviewDefinition } from './review-definition.ts' import { createReviewStore } from './review-store.ts' -import { en, NS, zh, type DeliverablesKey } from './locales.ts' +import { en, ja, NS, zh, type DeliverablesKey } from './locales.ts' import { deliverablesDefinition, presentedForClosing, producedFileMentions, selectProducedFiles, } from './turn-deliverables.ts' @@ -57,7 +57,7 @@ export function apply(ctx: ClientContext): void { diffs.reset() }) ctx.uiConversation.events.register(deliverablesDefinition) - ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'ui-deliverables: dictionaries') + ctx.effect(() => ctx.locale.register(NS, { zh, en, ja }), 'ui-deliverables: dictionaries') ctx.slots.inject( 'conversation.chat.turnTail', () => ctx.slots.register({ @@ -107,7 +107,7 @@ export function apply(ctx: ClientContext): void { if (paths === null && presented.length === 0) return undefined return producedFileMentions([...new Set([...paths ?? [], ...presented.map(file => file.path)])], owner.openFile, path => t('presented.previewButton', { name: path }), - owner.turn.data.get('deliverables')?.produced.filter(operation => operation.seq <= owner.seq)) + owner.turn.data.get('deliverables')?.produced) }, } ctx.provide('chatFileMentions', mentions) diff --git a/packages/client/ui-deliverables/src/client/locales.ts b/packages/client/ui-deliverables/src/client/locales.ts index 6a1a8813d3..d5c67949ca 100644 --- a/packages/client/ui-deliverables/src/client/locales.ts +++ b/packages/client/ui-deliverables/src/client/locales.ts @@ -133,3 +133,67 @@ export const en: Record = { /** Union of this namespace's dictionary keys. */ export type DeliverablesKey = keyof typeof zh + +/** Japanese copy, checked against the English keys. */ +export const ja = { + 'presented.nativeUnavailable': 'このファイルには利用可能なHostパスがありません。サイドバーでプレビューしてください。', + 'presented.revealError': 'ファイルマネージャーで表示できませんでした。再試行してください。', + 'presented.directoryError': '含まれるフォルダーを開けませんでした。再試行してください。', + 'presented.directoryOpening': '含まれるフォルダーを開いています…', + 'presented.directoryOpened': '含まれるフォルダーの表示をリクエストしました', + 'presented.revealed': 'ファイルマネージャーでの表示をリクエストしました', + 'presented.revealing': 'ファイルマネージャーで表示しています…', + 'presented.unavailable': 'このHostには、外部アプリでファイルやフォルダーを開くためのデスクトップがありません。ファイルはサイドバーで引き続きプレビューできます。', + 'presented.retry': '再試行', + 'presented.hostError': 'Hostのデスクトップ情報を読み取れませんでした', + 'presented.preview': 'サイドバーでプレビュー', + 'presented.previewButton': '{name}をサイドバーで開く', + 'presented.previewCard': '{name}をサイドバーでプレビュー', + 'presented.all': '全{count}ファイル', + 'presented.expandAria': '配信された全{count}ファイルを表示', + 'presented.collapse': '折りたたむ', + 'presented.collapseAria': '配信されたファイルを折りたたむ', + 'presented.opening': '開いています…', + 'presented.opened': '表示をリクエストしました', + 'presented.error': '開けませんでした。クリックして再試行してください。', + 'presented.file': 'ファイル', + 'row.title': 'ファイルを提示', + 'row.running': '配信中', + 'row.preparing': '成果物を準備中', + 'row.ok': '配信済み', + 'row.error': '配信に失敗しました', + 'row.stopped': '中断されました', + 'row.inspect': '呼び出しを検査', + 'changes.title': '{count}ファイルを編集しました', + 'changes.singleTitle': '{name}を編集しました', + 'changes.added': '+{count}', + 'changes.deleted': '-{count}', + 'changes.binary': 'バイナリ', + 'changes.openReview': 'このターンの変更をサイドバーで確認', + 'changes.all': '全{count}ファイル', + 'changes.expandAria': '変更された全{count}ファイルを表示', + 'changes.collapse': '折りたたむ', + 'changes.collapseAria': '変更されたファイルを折りたたむ', + 'changes.oversized': 'サイズが大きすぎます', + 'changes.viewDiff': '{name}の変更を表示', + 'review.title': 'レビュー · ターン{turn}', + 'review.selectFile': 'レビューするファイルを選択', + 'review.split': '分割ビューに切り替え', + 'review.unified': '統合ビューに切り替え', + 'review.splitAria': '分割ビュー', + 'review.wrap': '行の折り返しを有効化', + 'review.nowrap': '行の折り返しを無効化', + 'review.wrapAria': '行の折り返し', + 'review.openFile': 'ファイル全体をサイドバーで開く', + 'review.openFileAria': '{name}をサイドバーで開く', + 'diff.loading': '変更を読み込んでいます…', + 'diff.missing': 'このターンの変更内容は利用できなくなりました', + 'diff.error': '変更を読み取れませんでした', + 'diff.binary': 'バイナリファイルのため、変更を表示できません', + 'diff.oversized': 'ファイルが大きすぎるため、変更を表示できません', + 'diff.created': 'このターンで作成', + 'diff.deleted': 'このターンで削除', + 'diff.unchanged': '両方の行内容が同一です', + 'diff.coarse': '行比較がタイムアウトしたため、ファイル全体の置換として表示しています', + 'diff.truncated': '最初の{count}行を表示', +} satisfies Record diff --git a/packages/client/ui-deliverables/src/client/turn-deliverables.ts b/packages/client/ui-deliverables/src/client/turn-deliverables.ts index fb2f962b84..07aba395cf 100644 --- a/packages/client/ui-deliverables/src/client/turn-deliverables.ts +++ b/packages/client/ui-deliverables/src/client/turn-deliverables.ts @@ -21,7 +21,6 @@ export interface PresentedPath extends PresentedFile { } interface ProducedPath { - readonly seq: number readonly path: string readonly argumentPath?: string } @@ -141,20 +140,19 @@ function isRecord(value: unknown): value is Record { * * The Conversation Location index owns turn membership before this function * runs, so paths cannot spill across turns and this derivation does not infer - * boundaries from neighboring presentation Nodes. + * boundaries from neighboring presentation Nodes. Mutations settled after the + * closing reply are included, matching {@link presentedForClosing}. * @param data - engine-published Deliverables data for one Turn. - * @param seq - closing Assistant seq; later Tool settlements are excluded. * @returns Produced paths in first-seen order; empty when the turn wrote nothing. */ export function producedForClosing( data: Readonly | undefined, - seq = Number.POSITIVE_INFINITY, ): readonly string[] { if (data === undefined) return [] const paths: string[] = [] const seen = new Set() for (const produced of data.produced) { - if (produced.seq > seq || seen.has(produced.path)) continue + if (seen.has(produced.path)) continue seen.add(produced.path) paths.push(produced.path) } @@ -167,7 +165,7 @@ export function producedForClosing( * @returns Produced paths as the component's match, or null to decline before mount. */ export function selectProducedFiles(owner: TurnTailOwnerProps): readonly string[] | null { - const paths = producedForClosing(owner.turn.data.get('deliverables'), owner.seq) + const paths = producedForClosing(owner.turn.data.get('deliverables')) return paths.length === 0 ? null : paths } @@ -220,7 +218,7 @@ export const deliverablesDefinition: ConversationNodeDefinition() - for (const file of owner.turn.data.get('deliverables')?.presented ?? []) { - if (file.seq < owner.seq) files.set(file.path, file) - } + for (const file of owner.turn.data.get('deliverables')?.presented ?? []) files.set(file.path, file) return [...files.values()] } diff --git a/packages/client/ui-deliverables/tests/deliverables.client.spec.tsx b/packages/client/ui-deliverables/tests/deliverables.client.spec.tsx index be0c8fbeeb..d58d4fd6e5 100644 --- a/packages/client/ui-deliverables/tests/deliverables.client.spec.tsx +++ b/packages/client/ui-deliverables/tests/deliverables.client.spec.tsx @@ -105,8 +105,8 @@ const turnLocation = (turn: number, deliverables?: DeliverablesTurnData): TurnLo return { turn, start: undefined, end: undefined, status: 'closed', steps: [], data } } -const produced = (...values: ReadonlyArray): DeliverablesTurnData => ({ - produced: values.map(([seq, path]) => ({ seq, path })), +const produced = (...paths: readonly string[]): DeliverablesTurnData => ({ + produced: paths.map(path => ({ path })), }) function tailOwner( @@ -216,19 +216,29 @@ function deliverablesOf(value: ConversationNodeAssembler, turn = 1): Readonly { - it('deduplicates paths in first-seen order and stops at the closing Assistant seq', () => { - const data = produced( - [3, 'out/index.html'], - [4, 'out/app.css'], - [4, 'out/index.html'], - [8, 'after.txt'], - ) - expect(producedForClosing(data, 6)).toEqual(['out/index.html', 'out/app.css']) - expect(selectProducedFiles(tailOwner(data, 6))).toEqual(['out/index.html', 'out/app.css']) + it('deduplicates paths in first-seen order across the whole turn', () => { + const data = produced('out/index.html', 'out/app.css', 'out/index.html', 'after.txt') + expect(producedForClosing(data)).toEqual(['out/index.html', 'out/app.css', 'after.txt']) + expect(selectProducedFiles(tailOwner(data, 6))).toEqual(['out/index.html', 'out/app.css', 'after.txt']) expect(producedForClosing(undefined)).toEqual([]) expect(selectProducedFiles(tailOwner(undefined, 9, () => {}, 2))).toBeNull() }) + it('includes mutations that settle after the closing reply', () => { + const value = assembler([ + at(1, 'turn/start', { turn: 1 }), + call(3, 'late', 'write', { file_path: 'late.txt', content: 'late' }), + result(4, 'late', false, 1, { path: '/project/late.txt' }), + at(5, 'turn/end', { turn: 1 }), + ]) + const data = deliverablesOf(value) + // The closing reply at seq 2 shares the step that called write, and no later step wrote text. + expect(selectProducedFiles(tailOwner(data, 2))).toEqual(['/project/late.txt']) + const opened = vi.fn() + producedFileMentions(['/project/late.txt'], opened, path => path, data?.produced).resolve('late.txt')?.open() + expect(opened).toHaveBeenCalledWith('/project/late.txt') + }) + it('opens a moved write at its recorded target when an absolute delivery names the same file', () => { const path = '/project/child/report.txt' const value = assembler([ @@ -871,7 +881,7 @@ describe('plugin registration', () => { // resolver whose matches open through the owner-supplied opener. const opened: string[] = [] const owner = tailOwner( - produced([2, 'site/report.html']), + produced('site/report.html'), 3, (path) => { opened.push(path) }, ) @@ -881,16 +891,20 @@ describe('plugin registration', () => { mentions?.resolve('report.html')?.open() expect(opened).toEqual(['site/report.html']) const canonical = '/project/child/out/report.txt' - const moved = tailOwner({ produced: [{ seq: 2, path: canonical, argumentPath: 'out/report.txt' }] }, 3, previewPath => opened.push(previewPath)) + const moved = tailOwner({ produced: [{ path: canonical, argumentPath: 'out/report.txt' }] }, 3, previewPath => opened.push(previewPath)) const movedMentions = service?.forClosing(moved, SessionId('moved-session')) movedMentions?.resolve('out/report.txt')?.open() expect(opened.at(-1)).toBe(canonical) expect(movedMentions?.resolve('out/report.txt')?.title).toBe(canonical) + // A delivery recorded after the closing reply still resolves in that reply's prose. + const late = tailOwner({ produced: [], presented: [{ path: 'out/late.docx', seq: 5, index: 0 }] }, 3, previewPath => opened.push(previewPath)) + service?.forClosing(late, SessionId('late-session'))?.resolve('late.docx')?.open() + expect(opened.at(-1)).toBe('out/late.docx') const fetcher = vi.fn().mockResolvedValue(new Response(null, { status: 204 })) vi.stubGlobal('fetch', fetcher) const preview = vi.fn<(path: string) => void>() - for (const produced of [[], [{ path: 'out/report.docx', seq: 1 }]]) { + for (const produced of [[], [{ path: 'out/report.docx' }]]) { const delivered = tailOwner({ produced, presented: [{ path: 'out/report.docx', seq: 2, index: 0 }] }, 3, preview) const mentions = service?.forClosing(delivered, SessionId('child-session')) for (const text of ['report.docx', 'out/report.docx']) { @@ -966,18 +980,29 @@ describe('presented files', () => { it('replays deliveries without mutation calls, preserves indices, and isolates turns', () => { const value = assembler([ at(1, 'turn/start', { turn: 1 }), - at(2, 'deliverables/presented', { turn: 1, callId: 'nested', files: [null, { ...file(), description: 'Final report' }] }), + at(2, 'deliverables/presented', { turn: 1, callId: 'nested', files: [null, { ...file(), description: 'Final report' }, file('notes.md')] }), at(3, 'deliverables/presented', { turn: 1, callId: 'again', files: [{ ...file(), description: 'Updated report' }] }), at(4, 'turn/end', { turn: 1 }), at(5, 'turn/start', { turn: 2 }), ]) - const first = presentedForClosing(tailOwner(deliverablesOf(value), 3)) - expect(first).toMatchObject([{ path: 'report.docx', seq: 2, index: 1, description: 'Final report' }]) - expect(presentedForClosing(tailOwner(deliverablesOf(value), 4))) - .toMatchObject([{ path: 'report.docx', seq: 3, description: 'Updated report' }]) + expect(presentedForClosing(tailOwner(deliverablesOf(value), 4))).toMatchObject([ + { path: 'report.docx', seq: 3, index: 0, description: 'Updated report' }, + { path: 'notes.md', seq: 2, index: 2 }, + ]) expect(selectDeliverables(tailOwner(deliverablesOf(value, 2), 9))).toBeNull() }) + it('keeps deliveries recorded after the closing reply', () => { + const value = assembler([ + at(1, 'turn/start', { turn: 1 }), + at(3, 'deliverables/presented', { turn: 1, callId: 'present', files: [{ ...file(), description: 'Final report' }] }), + at(4, 'turn/end', { turn: 1 }), + ]) + // The closing reply at seq 2 shares the step that called present, and no later step wrote text. + expect(selectDeliverables(tailOwner(deliverablesOf(value), 2))?.presented) + .toMatchObject([{ path: 'report.docx', seq: 3, index: 0, description: 'Final report' }]) + }) + it('uses the viewed fork Session in every open action and expands all delivered files', () => { const value = assembler([ at(1, 'turn/start', { turn: 1 }), diff --git a/packages/client/ui-directory-picker-browse/locale/ja.json b/packages/client/ui-directory-picker-browse/locale/ja.json new file mode 100644 index 0000000000..1e654b55ed --- /dev/null +++ b/packages/client/ui-directory-picker-browse/locale/ja.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "アプリ内ディレクトリ選択 UI", + "description": "アプリ内でワークスペースのディレクトリを参照、作成、選択します。" + } +} diff --git a/packages/client/ui-directory-picker-browse/src/client/index.ts b/packages/client/ui-directory-picker-browse/src/client/index.ts index 9e7b2833ea..bc78900e0b 100644 --- a/packages/client/ui-directory-picker-browse/src/client/index.ts +++ b/packages/client/ui-directory-picker-browse/src/client/index.ts @@ -30,7 +30,7 @@ export const inject = ['slots', 'uiWorkspace', 'locale'] */ export function apply(ctx: ClientContext): void { ctx.effect(() => { - // The two dictionaries land as a unit: if the second registration hits a + // The dictionaries land as a unit: if the second registration hits a // rival owner of the namespace, the first rolls back before the throw — // a failed activation must not squat the namespace's other locale. const disposers: (() => void)[] = [] @@ -65,6 +65,21 @@ export function apply(ctx: ClientContext): void { 'browser.truncated': 'Too many folders to list; only the beginning is shown.', 'browser.showHidden': 'Show hidden files', }], + ['ja', { + 'browser.title': 'ワークスペースディレクトリを選択', + 'browser.home': 'ホーム', + 'browser.newFolder': '新しいフォルダ', + 'browser.folderName': 'フォルダ名', + 'browser.createIn': '「{name}」内に新しいフォルダ', + 'browser.untitledFolder': '名称未設定フォルダ', + 'browser.create': '作成', + 'browser.cancel': 'キャンセル', + 'browser.open': '開く', + 'browser.editPath': 'パスを編集', + 'browser.loading': '読み込み中…', + 'browser.truncated': 'フォルダが多すぎて一覧表示できません。先頭のみ表示しています。', + 'browser.showHidden': '隠しファイルを表示', + }], ] try { for (const [locale, dict] of dictionaries) disposers.push(ctx.locale.register(LOCALE_NS, locale, dict)) diff --git a/packages/client/ui-directory-picker-native/README.i18n.yaml b/packages/client/ui-directory-picker-native/README.i18n.yaml index 80a96f4d74..d7fe01a1f6 100644 --- a/packages/client/ui-directory-picker-native/README.i18n.yaml +++ b/packages/client/ui-directory-picker-native/README.i18n.yaml @@ -18,8 +18,8 @@ en: 1e2172c756246031 zh: f33dc7b40b14cceb /deepseek-ai-dsh-client-ui-directory-picker-native/use-this-package/when-to-choose-it: - en: bcba761168d2162c - zh: d484e2305e074c0b + en: f30bb7c6aae98fec + zh: 1d28613c86531119 /deepseek-ai-dsh-client-ui-directory-picker-native/understand-the-implementation: en: f7a287740b4b08f9 zh: 3347375c4e486b22 diff --git a/packages/client/ui-directory-picker-native/README.md b/packages/client/ui-directory-picker-native/README.md index b23d48bbb5..7a77767050 100644 --- a/packages/client/ui-directory-picker-native/README.md +++ b/packages/client/ui-directory-picker-native/README.md @@ -33,6 +33,8 @@ In the local Electron application, this flow uses the narrow preload directory-p Choose this surface when the browser runs on the same machine as the Host, so an OS dialog can open there. Choose the [`-browse`](../ui-directory-picker-browse/README.md) surface when the browser is remote or in-process and no local chooser exists. The two surfaces fill the same slots, so switching is a composition change, not a code change. +Access: both directory flows are registered only while the current Peer may call `directoryPicker/pick`. The owner always has access. + ----- diff --git a/packages/client/ui-directory-picker-native/README.zh.md b/packages/client/ui-directory-picker-native/README.zh.md index 57cbad2d5d..0bb78f28d1 100644 --- a/packages/client/ui-directory-picker-native/README.zh.md +++ b/packages/client/ui-directory-picker-native/README.zh.md @@ -33,6 +33,8 @@ kind: "package-reference" 当浏览器与 Host 运行在同一台机器上、操作系统对话框可以在那里打开时,选择此表面。当浏览器为远程或进程内、没有本地选择器时,选择 [`-browse`](../ui-directory-picker-browse/README.zh.md) 表面。两个表面填充相同的 slot,因此切换只是组合改动,而非代码改动。 +访问控制:两个目录选择流程只在当前 Peer 可以调用 `directoryPicker/pick` 时注册。owner 始终有权限。 + ----- diff --git a/packages/client/ui-directory-picker-native/locale/ja.json b/packages/client/ui-directory-picker-native/locale/ja.json new file mode 100644 index 0000000000..e4ab3af037 --- /dev/null +++ b/packages/client/ui-directory-picker-native/locale/ja.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "システムディレクトリ選択 UI", + "description": "ワークスペースの選択操作をシステムのディレクトリ選択ダイアログに接続します。" + } +} diff --git a/packages/client/ui-directory-picker-native/package.json b/packages/client/ui-directory-picker-native/package.json index 6f54248246..ff25c9ae17 100644 --- a/packages/client/ui-directory-picker-native/package.json +++ b/packages/client/ui-directory-picker-native/package.json @@ -44,6 +44,7 @@ "@deepseek-ai/cordis": "workspace:~" }, "devDependencies": { + "@deepseek-ai/dsh-api-remote-access": "workspace:*", "@deepseek-ai/dsh-client-test-runtime": "workspace:*", "@deepseek-ai/dsh-client-ui-renderer": "workspace:*", "@deepseek-ai/dsh-client-ui-workspace": "workspace:*", diff --git a/packages/client/ui-directory-picker-native/src/client/index.ts b/packages/client/ui-directory-picker-native/src/client/index.ts index efec56fc49..1bdcafecdd 100644 --- a/packages/client/ui-directory-picker-native/src/client/index.ts +++ b/packages/client/ui-directory-picker-native/src/client/index.ts @@ -1,5 +1,7 @@ /** Native directory flow using the local desktop bridge or the Host's OS chooser. */ import type { Context as ClientContext } from '@deepseek-ai/cordis' +// Type-only: pulls the remoteAccess service merge (ctx.remoteAccess). +import type {} from '@deepseek-ai/dsh-api-remote-access/client' // Type-only: pulls the SlotMap merge declaring the directory-flow holes. import type {} from '@deepseek-ai/dsh-client-ui-workspace/client' // Type-only: pulls the SlotRegistry service merge (ctx.slots). @@ -8,13 +10,14 @@ import type { NativeFlowInjected } from './flow.ts' import { NativeDirectoryFlow } from './flow.ts' -/** Required services (cordis fiber inject): the slot registry and workspace UI service. */ -export const inject = ['slots', 'uiWorkspace'] +/** Required services (cordis fiber inject): the slot registry, workspace UI service, and access. */ +export const inject = ['slots', 'uiWorkspace', 'remoteAccess'] /** - * Client plugin body: register the renderless native flow into both - * directory-flow holes through `slots.inject()` because the ui-workspace - * entries may activate later or replace their declarations. + * Client plugin body: while the current Peer may open the Host's directory + * chooser, register the renderless native flow into both directory-flow holes + * through `slots.inject()` because the ui-workspace entries may activate later + * or replace their declarations. * @param ctx - client root context. */ export function apply(ctx: ClientContext): void { @@ -26,13 +29,14 @@ export function apply(ctx: ClientContext): void { // Both declaration lifetimes must be live before the pair installs; the // generator makes the two registrations one transactional effect. The // outer/inner nesting order is arbitrary; neither hole has precedence. - ctx.slots.inject('conversation.hero.workspace.directoryFlow', () => - ctx.slots.inject('sidebar.workspaces.directoryFlow', function* () { - yield ctx.slots.register({ - name: 'conversation.hero.workspace.directoryFlow', inject: injected, - }, NativeDirectoryFlow) - yield ctx.slots.register({ - name: 'sidebar.workspaces.directoryFlow', inject: injected, - }, NativeDirectoryFlow) - })) + ctx.remoteAccess.whenAccess('directoryPicker/pick', () => + ctx.slots.inject('conversation.hero.workspace.directoryFlow', () => + ctx.slots.inject('sidebar.workspaces.directoryFlow', function* () { + yield ctx.slots.register({ + name: 'conversation.hero.workspace.directoryFlow', inject: injected, + }, NativeDirectoryFlow) + yield ctx.slots.register({ + name: 'sidebar.workspaces.directoryFlow', inject: injected, + }, NativeDirectoryFlow) + }))) } diff --git a/packages/client/ui-directory-picker-native/tests/client-flow.client.spec.tsx b/packages/client/ui-directory-picker-native/tests/client-flow.client.spec.tsx index ed033999aa..fd3b22d315 100644 --- a/packages/client/ui-directory-picker-native/tests/client-flow.client.spec.tsx +++ b/packages/client/ui-directory-picker-native/tests/client-flow.client.spec.tsx @@ -4,6 +4,7 @@ import { describe, expect, it, vi } from 'vitest' import { act, cleanup, render } from '@testing-library/react' import { afterEach } from 'vitest' import { SlotRegistry } from '@deepseek-ai/dsh-client-ui-renderer/client' +import { StaticRemoteAccess } from '@deepseek-ai/dsh-client-test-runtime' import type { DirectoryFlowOwnerProps } from '@deepseek-ai/dsh-client-ui-workspace/client' import { apply, inject } from '../src/client/index.ts' import { NativeDirectoryFlow } from '../src/client/flow.ts' @@ -29,9 +30,10 @@ afterEach(() => { cleanup(); vi.unstubAllGlobals(); vi.restoreAllMocks(); deskto const HOLES = ['conversation.hero.workspace.directoryFlow', 'sidebar.workspaces.directoryFlow'] as const -async function bench() { +async function bench(allows: (action: string) => boolean = () => true) { const ctx = new Context() await ctx.plugin(SlotRegistry).await() + new StaticRemoteAccess(ctx, allows) const pickDirectory = vi.fn(async (): Promise => '/tmp/picked') ctx.provide('uiWorkspace', { pickDirectory } as never) const slots = ctx.get('slots') as SlotRegistry @@ -52,7 +54,19 @@ function owner(overrides: Partial = {}): DirectoryFlowO describe('directory-picker-native client half', () => { it('declares the services it drives', () => { - expect(inject).toEqual(['slots', 'uiWorkspace']) + expect(inject).toEqual(['slots', 'uiWorkspace', 'remoteAccess']) + }) + + it('fills no directory flow while the current Peer may not open the Host chooser', async () => { + const allows = vi.fn(() => false) + const b = await bench(allows) + b.declare() + const fiber = b.ctx.plugin({ inject: [...inject], apply }) + await fiber.await() + expect(allows).toHaveBeenCalledWith('directoryPicker/pick') + for (const hole of HOLES) expect(b.slots.entries(hole)).toEqual([]) + await fiber.dispose() + await b.ctx.fiber.dispose() }) it('fills both directory-flow holes for declarations before or after apply, and leaves with its fiber', async () => { diff --git a/packages/client/ui-dockkit/README.i18n.yaml b/packages/client/ui-dockkit/README.i18n.yaml index 872b5bbe02..1340c04957 100644 --- a/packages/client/ui-dockkit/README.i18n.yaml +++ b/packages/client/ui-dockkit/README.i18n.yaml @@ -15,11 +15,11 @@ en: 42038fdc402b9c2a zh: 99890f8f908624ce /deepseek-ai-dsh-client-ui-dockkit/the-two-layers: - en: ea872ff2c2089037 - zh: b3ec3a218eacd5c8 + en: 99356ea8e3d1e7aa + zh: 870f746730d0e6cf /deepseek-ai-dsh-client-ui-dockkit/embedding-it: - en: d0b4508baabd092a - zh: 7929a10053636a4f + en: 6448bced273670d4 + zh: b16ef7fa9ab5113f /deepseek-ai-dsh-client-ui-dockkit/interaction-rules-worth-keeping: en: 7e5c1e9a9f9f244f zh: 7e3a8fcef86bc7dd diff --git a/packages/client/ui-dockkit/README.md b/packages/client/ui-dockkit/README.md index 00bfd55a75..967a2f6e05 100644 --- a/packages/client/ui-dockkit/README.md +++ b/packages/client/ui-dockkit/README.md @@ -39,6 +39,8 @@ A docking layout kit: a split tree of tabbed panes with invertible operations, a **The components** render a layout snapshot and report settled intents — one per gesture, never a drag frame. A drag previews in local state while the gesture's own facts stay in its closure; on release the net result leaves through one `DockIntents` call — a strip release reports the caret slot as drawn, the dragged chip counted, and `planPlaceTab` turns that into the reorder or the move. That is what lets an embedder record exactly one history entry per gesture. The strip follows the WAI-ARIA tabs pattern with manual activation: the selected chip is in the tab order; Left and Right (wrapping), Home, and End move focus between chips without selecting; Enter or Space selects the focused chip through the same intent as a click. A chip is a capsule carrying one control, its close; the context menu (a secondary press on the chip) carries the same close plus the embedder's items — a menu that would hold no item at all never shows — and renders in a portal positioned against the chip because the chip box clips its overflow on purpose (see below). After the chips sits the add control, which asks the embedder (`DockIntents.addTab`) to seat its seeded tab; the embedder's `canAddTab(paneId)` decides per pane whether the control is drawn at all. Copying a tab has no kit control — it is the embedder's API — and floating is the drag released clear of the surface. +Middle-clicking anywhere on a closable tab chip, including its close control, closes it through the same intent as its close button, without selecting it first. The chip owns the middle click and suppresses nested title content’s default action. Closing a background tab preserves the selected tab. Only the primary button starts tab dragging; a middle click while another mouse button is held does not close the tab. + Tab context menus show the close action name without a shortcut hint: they act on the clicked tab, while shortcuts can target another focused pane. An unmodified Escape dismisses the foreground tab menu without closing the tab and returns focus from a menu item to its tab without drawing a focus outline. Tab and arrow navigation retain the tab’s visible focus indicator. Composition and held-key repeats do not dismiss it. @@ -52,7 +54,7 @@ Everything host-specific arrives through props: | `TabRenderer` | one tab's body (`renderTab`), drawn flush to the pane's edges and the unbordered strip's bottom edge with the insets it chooses, and optionally what its chip or panel header shows as a title (`renderTabTitle`, falling back to the record's `title`); the embedder dispatches on `tab.kind` | | `DockIntents` | the settled results of every gesture | -`DockController` satisfies `DockIntents` as written, so an embedding can pass it to `DockLayout` for retained Sidebar layouts or `DockSurface` for recursive split trees. An embedder that routes through its own store implements the same method names instead. Three props carry control policy rather than gestures: `canSplit` (surface-wide, the pane budget; disables the split control with `splitPaneDisabled`), `canAddTab(paneId)` (per pane, omits the add control; leave it out to draw one in every pane), and `canCloseTab(tabId)` (per tab, withholds the chip's close control and the menu's close item together; leave it out to keep every tab closable). Hiding the add control moves nothing else in the strip, and a withheld close moves nothing in the chip — the close control paints over the title's end rather than beside it. A pane's lone chip whose close is withheld draws quiet — no capsule, no hover fill — since there is nothing to select against and nothing to do to it. The kit adds one policy of its own, the room rule below, which disables a pane's split control with `splitPaneNarrow`; `onRoom(fits)` reports its readings so an embedder splitting programmatically can honour the same rule. A visible blocked split control supports click or tap to read its explanation without splitting or focusing the pane. +`DockController` satisfies `DockIntents` as written, so an embedding can pass it to `DockLayout` for retained Sidebar layouts or `DockSurface` for recursive split trees. An embedder that routes through its own store implements the same method names instead. Three props carry control policy rather than gestures: `canSplit` (surface-wide, the pane budget; disables the split control with `splitPaneDisabled`), `canAddTab(paneId)` (per pane, omits the add control; leave it out to draw one in every pane), and `canCloseTab(tabId)` (per tab, withholds the chip's close control and the menu's close item and prevents middle-click closing; leave it out to keep every tab closable). Hiding the add control moves nothing else in the strip, and a withheld close moves nothing in the chip — the close control paints over the title's end rather than beside it. A pane's lone chip whose close is withheld draws quiet — no capsule, no hover fill — since there is nothing to select against and nothing to do to it. The kit adds one policy of its own, the room rule below, which disables a pane's split control with `splitPaneNarrow`; `onRoom(fits)` reports its readings so an embedder splitting programmatically can honour the same rule. A visible blocked split control supports click or tap to read its explanation without splitting or focusing the pane. `dropZones="horizontal"` offers two half-pane hints; once budget or width forbids another split, the whole body accepts a move. A hint is a dashed card inset 8px inside its region, showing the zone's glyph and `labels.dropZone[zone]`; the preview layer covers all tab-body content, while the card under the pointer takes the accent and its neighbour stays a quiet outline. `minPaneFraction` sets the preview minimum, and `planResizeSplit` accepts the same minimum for the committed operation. The Sidebar uses 0.2 and enforces two panes in its own store. The generic engine retains its tree and other split directions. `hideSplitWhenBlocked` hides a blocked split control — pane budget spent or pane too narrow — instead of rendering it disabled; its default is false. diff --git a/packages/client/ui-dockkit/README.zh.md b/packages/client/ui-dockkit/README.zh.md index b6d45d2db4..b2395897dc 100644 --- a/packages/client/ui-dockkit/README.zh.md +++ b/packages/client/ui-dockkit/README.zh.md @@ -39,6 +39,8 @@ kind: "package-reference" **组件**渲染布局快照并上报已落定的意图——每次手势一条,绝不上报拖动帧。拖动过程中在本地状态里预览,手势自身的事实留在它的闭包里;松手时净结果通过一次 `DockIntents` 调用离开——在标签条上松手上报的是按绘制顺序数出的插入槽位(被拖的 chip 也计入),由 `planPlaceTab` 换算成重排或移动。正是这一点让嵌入方能为每次手势记录恰好一条历史。标签条遵循 WAI-ARIA tabs 模式的手动激活:选中的 chip 在 Tab 键序里;左右方向键(循环)、Home、End 只在 chip 之间移动焦点而不选中;Enter 或空格选中当前聚焦的 chip,走与点击相同的意图。chip 是一个胶囊,携带唯一的控件——它的关闭按钮;上下文菜单(在 chip 上的次键按下)携带同样的关闭项加上嵌入方的条目——一个连一项都没有的菜单绝不展示——并渲染在按 chip 定位的 portal 里,因为 chip 盒会故意裁掉溢出(见下文)。chip 之后是添加控件,它请嵌入方(`DockIntents.addTab`)安放其种子 tab;嵌入方的 `canAddTab(paneId)` 按格决定是否绘制该控件。复制 tab 没有套件控件——那是嵌入方的 API——而浮出就是把拖动松手在停靠区之外。 +中键点击可关闭标签页 chip 的任意位置(包括关闭控件)时,通过与关闭按钮相同的意图关闭,无需先选中。chip 接管中键点击,并阻止嵌套标题内容的默认动作。关闭后台标签页会保留当前选中的标签页。只有主键会开始拖动标签页;其它鼠标按键仍按住时,中键点击不会关闭标签页。 + 标签上下文菜单只显示关闭操作名,不显示快捷键:菜单操作针对被点击的标签,快捷键则可能作用于其他焦点分栏。无修饰键的 Esc 关闭前台标签菜单而不关闭标签,并将菜单项的焦点交还标签,不绘制焦点外轮廓线。Tab 和方向键导航保留标签页的可见焦点提示。输入法组合输入和长按重复事件不关闭菜单。 @@ -52,7 +54,7 @@ kind: "package-reference" | `TabRenderer` | 一个 tab 的正文(`renderTab`),贴着格的边缘和(不带边线的)tab 条底边绘制、自己决定留白,以及可选的 chip 或浮窗头部显示的标题(`renderTabTitle`,回退到记录的 `title`);嵌入方按 `tab.kind` 分发 | | `DockIntents` | 每次手势落定的结果 | -`DockController` 原样满足 `DockIntents`,因此嵌入方可将它交给 `DockLayout` 来保活 Sidebar 布局,或交给 `DockSurface` 来渲染递归分割树。经由自己 store 路由的嵌入方则实现同名方法。有三个 props 承载的是控制策略而非手势:`canSplit`(整面有效,即格预算;用 `splitPaneDisabled` 禁用分栏控件)、`canAddTab(paneId)`(按格,省略添加控件;不传则每格都画)与 `canCloseTab(tabId)`(按 tab,把 chip 的关闭控件和菜单的关闭项一并收起;不传则每个 tab 都可关闭)。隐藏添加控件不会移动 tab 条里的其它任何东西,收起关闭也不会移动 chip 里的任何东西——关闭控件压在标题末端之上而非并排。某格仅剩的一个 chip 在关闭被收起时画成安静样式——没有胶囊底色,没有悬停填充——因为既没有别的 tab 可供选择,也没有任何可对它做的事。套件自己再加一条策略,即下文的空间规则,它用 `splitPaneNarrow` 禁用某格的分栏控件;`onRoom(fits)` 上报其读数,让以编程方式分栏的嵌入方能遵守同一规则。可见的禁用分栏控件支持鼠标或触摸点击查看原因,不会分栏或聚焦窗格。 +`DockController` 原样满足 `DockIntents`,因此嵌入方可将它交给 `DockLayout` 来保活 Sidebar 布局,或交给 `DockSurface` 来渲染递归分割树。经由自己 store 路由的嵌入方则实现同名方法。有三个 props 承载的是控制策略而非手势:`canSplit`(整面有效,即格预算;用 `splitPaneDisabled` 禁用分栏控件)、`canAddTab(paneId)`(按格,省略添加控件;不传则每格都画)与 `canCloseTab(tabId)`(按 tab,把 chip 的关闭控件和菜单的关闭项一并收起,并禁止中键关闭;不传则每个 tab 都可关闭)。隐藏添加控件不会移动 tab 条里的其它任何东西,收起关闭也不会移动 chip 里的任何东西——关闭控件压在标题末端之上而非并排。某格仅剩的一个 chip 在关闭被收起时画成安静样式——没有胶囊底色,没有悬停填充——因为既没有别的 tab 可供选择,也没有任何可对它做的事。套件自己再加一条策略,即下文的空间规则,它用 `splitPaneNarrow` 禁用某格的分栏控件;`onRoom(fits)` 上报其读数,让以编程方式分栏的嵌入方能遵守同一规则。可见的禁用分栏控件支持鼠标或触摸点击查看原因,不会分栏或聚焦窗格。 `dropZones="horizontal"` 提供左右两个半区提示;预算或宽度不允许再拆时,正文整格接收移动。提示是一张内缩 8px 的虚线卡片,显示该落区的图形和 `labels.dropZone[zone]`;预览层覆盖全部 tab 正文,指针所在的卡片取强调色,另一张保持安静的轮廓。`minPaneFraction` 控制预览的最小比例,`planResizeSplit` 接受相同最小值以约束提交;Sidebar使用0.2并在自己的store限制两格。通用引擎仍保留原有树与其它分割方向。 `hideSplitWhenBlocked` 在分栏被阻止时(窗格预算已满或格太窄)直接隐藏分栏控件而不是渲染禁用态,默认值为 false。 diff --git a/packages/client/ui-dockkit/src/components/DockSurface.tsx b/packages/client/ui-dockkit/src/components/DockSurface.tsx index 4803955cb0..eea2ea1724 100644 --- a/packages/client/ui-dockkit/src/components/DockSurface.tsx +++ b/packages/client/ui-dockkit/src/components/DockSurface.tsx @@ -47,9 +47,9 @@ export interface DockSurfaceProps { */ readonly canAddTab?: (paneId: PaneId) => boolean /** - * Whether a tab draws its close control and its menu's close item. Called + * Whether a tab may close via its close control, menu item, or middle click. Called * per rendered chip on every render; omit to keep every tab closable. - * `false` removes both routes without moving the chip: the close control + * `false` blocks these routes without moving the chip: the close control * paints over the title's end rather than beside it, so the chip is the same * width either way. The menu still opens and carries the embedder's items. */ diff --git a/packages/client/ui-dockkit/src/components/TabMenu.tsx b/packages/client/ui-dockkit/src/components/TabMenu.tsx index 3effad9f79..beb7c926b6 100644 --- a/packages/client/ui-dockkit/src/components/TabMenu.tsx +++ b/packages/client/ui-dockkit/src/components/TabMenu.tsx @@ -105,13 +105,11 @@ export function TabMenu({ labels, anchor, onClose, onDismiss, extras }: TabMenuP data-dockkit-tab-menu // Unplaced for one layout pass: measured, then positioned. style={position ?? { visibility: 'hidden', top: 0, left: 0 }} - // React bubbles a portal's events to its tab chip, so both halves of a - // press must stop here: the press would otherwise start a tab drag and - // capture the pointer, which retargets the release and swallows this - // item's click; the click would otherwise also focus a tab the item may - // have removed. + // Portal events reach the owning chip: a press would start its drag, + // a click would focus it, and a middle click would close it. onPointerDown={(event) => { event.stopPropagation() }} onClick={(event) => { event.stopPropagation() }} + onAuxClick={(event) => { event.stopPropagation() }} > {onClose !== undefined && ( + {subject?.bundled ? null : ( + + )} {uncertainty === 'result' ? : null} {pending ? ( @@ -1551,15 +1563,15 @@ export function PluginManagerPage(props: PluginManagerPageProps): ReactNode { }, [highlight, clearHighlight]) 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 - // Plugins section's Plugin list tab. - const listed = state.packages.filter(pkg => !BUILTIN_PROFILE_BUNDLES.has(pkg.name) + // The page omits profile bundles and persistent terminals. Other installed or offered bundles and + // unreadable selections remain manageable; Settings' Plugin list inspects the full Host inventory. + const listed = state.packages.filter(pkg => !HIDDEN_BUNDLES.has(pkg.name) + && (pkg.listedByDefault !== false || pkg.enabled || pkg.installed) && (pkg.installed || pkg.official || pkg.error !== undefined)) const mine = listed.filter(pkg => !pkg.official) const official = listed.filter(pkg => pkg.official) - // Both lists read one order over every offered bundle: the main list holds the always-held ones plus the held-back - // ones the profile enabled, and the More list holds all of them, so enabling a bundle from the More list pins it + // Both lists read one order over the eligible bundles: the main list holds the always-held ones plus the held-back + // ones the profile enabled, and the More list holds the eligible ones, so enabling a bundle from the More list pins it // to the main list without moving it among its neighbours there. const ordered = orderOptionalBundles(official) const pinned = new Set(official.filter(pinnedToMainList).map(pkg => pkg.name)) @@ -1573,7 +1585,7 @@ export function PluginManagerPage(props: PluginManagerPageProps): ReactNode { const onInstalled = tab === INSTALLED_TAB const showsCards = !onInstalled || (openPkg === undefined && openItem === undefined) const detailFromMore = (view.kind === 'package' || view.kind === 'row') - && (view.from === 'more' || (MORE_BUNDLES.includes(view.name) && !pinned.has(view.name))) + && (view.from === 'more' || (openPkg !== undefined && MORE_BUNDLES.includes(view.name) && !pinned.has(view.name))) const showsMoreList = onInstalled && showsCards && (view.kind === 'more' || detailFromMore) const showsMainList = showsCards && !showsMoreList const installedCards = onInstalled && showsCards @@ -1596,8 +1608,7 @@ export function PluginManagerPage(props: PluginManagerPageProps): ReactNode { }) const updates = updatesNote(mine, state.versions, t) const updatePkg = state.update === null ? undefined : state.packages.find(pkg => pkg.name === state.update?.packageName) - const packageBusy = (pkg: PackageView): boolean => state.busy.includes(pkg.name) || state.update?.packageName === pkg.name - || (state.install.subject?.name === pkg.name && (isInstallPending(state.install.phase) || state.install.phase === 'checking')) + const packageBusy = (pkg: PackageView): boolean => bundleBusy(state, pkg.name) const packageCard = (pkg: PackageView): ReactNode => ( value.status === 'ready'))) } /** Stop publishing late settlements. */ @@ -103,7 +103,7 @@ export class BundleDetailController { const key = bundleDetailKey(name, version, language) const read = ++this.sequence this.reads.set(key, read) - this.publish(key, { status: 'loading' }) + if (this.store.getSnapshot()[key]?.status !== 'ready') this.publish(key, { status: 'loading' }) // The remote is captured before any wait: the plugin's context may be disposed while the comparison runs. const { remote } = this.ctx const current = (): boolean => !this.disposed && this.reads.get(key) === read diff --git a/packages/client/ui-plugin-manager/src/client/index.ts b/packages/client/ui-plugin-manager/src/client/index.ts index 410ae5cfd5..2a6a3a48da 100644 --- a/packages/client/ui-plugin-manager/src/client/index.ts +++ b/packages/client/ui-plugin-manager/src/client/index.ts @@ -12,9 +12,12 @@ import type { Context as ClientContext } from '@deepseek-ai/cordis' // Type-only: the root `main` keyed slot the page registers into, declared by // ui-layout with the panel id brand, and the `sidebar.panellist` list the // entry registers into, declared by ui-sidebar. +import type { HostObservable } from '@deepseek-ai/dsh-client-ui-slots' import type { MainPanelId } from '@deepseek-ai/dsh-client-ui-layout/client' import type {} from '@deepseek-ai/dsh-client-ui-sidebar/client' import type {} from '@deepseek-ai/dsh-client-ui-renderer/client' +// Type-only: the ctx.remoteAccess Context merge. +import type {} from '@deepseek-ai/dsh-api-remote-access/client' // Type-only: the ctx.remote Context merge and the forwarded-event key face. import type {} from '@deepseek-ai/dsh-api-remotes/client' // Type-only: the forwarded events' own declaration (`$on`'s key face resolves @@ -26,12 +29,22 @@ import { PluginsPanelIcon } from './PluginsPanelIcon.tsx' import { configLedgerSource } from './config-ledger.ts' import { tabLedgerSource } from './tab-ledger.ts' import { PluginManagerController } from './manager-store.ts' -import { en, zh, type PluginManagerLocaleKey } from './locales.ts' +import { en, ja, zh, type PluginManagerLocaleKey } from './locales.ts' import { createNavigationStore } from './navigation-store.ts' import type {} from './slot-contract.ts' declare module '@deepseek-ai/cordis' { interface Context { + /** Read-only management activity, independent of the visible Plugins page. */ + pluginChanges: { + /** + * Observe whether a bundle or one of its rows is being changed. + * @param packageName Bundle package name. + * @returns A snapshot source whose subscriptions belong to the caller. + */ + bundle(packageName: string): HostObservable + } + /** Cross-plugin navigation to the Plugins panel. */ pluginNavigation: { /** @@ -68,95 +81,94 @@ export const NS = 'pluginManager' /** The id shared by the sidebar entry and the main panel it opens. */ export const PANEL_ID = 'plugins' as MainPanelId -/** Services required by the sidebar registration and the Remote methods; the inventory says whether the Host manages a profile. */ -export const inject = ['slots', 'locale', 'remote', 'remote.pluginManager', 'remote.pluginInventory', 'remote.pluginRegistryProbe', 'configForms', 'layout'] +/** Services required by the sidebar registration, the Remote methods, and access; the inventory says whether the Host manages a profile. */ +export const inject = ['slots', 'locale', 'remote', 'remote.pluginManager', 'remote.pluginInventory', 'remote.pluginRegistryProbe', 'configForms', 'layout', 'remoteAccess'] /** * Contribute the Plugins entry to the sidebar with the management page it - * opens, and keep it current on the Host's change events. + * opens, and keep it current on the Host's change events, while the current + * Peer may list the installed bundles. * @param ctx - the browser plugin context. */ export function apply(ctx: ClientContext): void { - ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'ui-plugin-manager: dictionaries') + ctx.effect(() => ctx.locale.register(NS, { zh, en, ja }), 'ui-plugin-manager: dictionaries') const t = ctx.locale.bind(NS) const controller = new PluginManagerController(ctx) ctx.effect(() => () => { controller.dispose() }, 'ui-plugin-manager: controller') - // The Host says when what is installed, enabled, or composed changed — from - // this page, the CLI, or another browser — and streams install output. - ctx.effect(() => { - // A page never rendered holds no snapshot to refresh. - const refresh = (): void => { - // A profile change can replace a bundle's files, and a restarted Host is read afresh. - controller.details.reset() - if (controller.getSnapshot().status !== 'idle') void controller.load() - } - const disposers = [ - ctx.remote.$on('plugin-manager/changed', refresh), - ctx.remote.$on('plugin-manager/install-log', (chunk) => { controller.appendLog(chunk) }), - ctx.remote.$on('plugin-manager/install-state', (progress) => { controller.installProgress(progress) }), - ctx.remote.$on('plugin-manager/update-state', (progress) => { controller.updateProgress(progress) }), - ctx.on('connection/reset', refresh), - ] - return () => { for (const dispose of disposers) dispose() } - }, 'ui-plugin-manager: host invalidations') - + ctx.provide('pluginChanges', { bundle: packageName => controller.observeChanges(packageName) }) // The page is a global panel: it belongs to the profile, not to a Session, // and the sidebar's entry selects it. What is installed and switched on is // 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, tabLedgerSource(ctx), 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 } - yield ctx.slots.register({ - name: 'main', - key: PANEL_ID, + ctx.remoteAccess.whenAccess('pluginManager/listBundles', function* () { + // The Host says when what is installed, enabled, or composed changed — from + // this page, the CLI, or another browser — and streams install output. + // A page never rendered holds no snapshot to refresh. + const refresh = (): void => { + // A profile change can replace a bundle's files, and a restarted Host is read afresh. + controller.details.reset() + if (controller.getSnapshot().status !== 'idle') void controller.load() + } + // Host changes made while access was withdrawn reached no subscription below. + refresh() + yield ctx.remote.$on('plugin-manager/changed', refresh) + yield ctx.remote.$on('plugin-manager/install-log', (chunk) => { controller.appendLog(chunk) }) + yield ctx.remote.$on('plugin-manager/install-state', (progress) => { controller.installProgress(progress) }) + yield ctx.remote.$on('plugin-manager/update-state', (progress) => { controller.updateProgress(progress) }) + yield ctx.on('connection/reset', refresh) + yield 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)) + yield ctx.slots.inject('main', function* () { + const handle = createNavigationStore(), instance = handle.create() + const store: typeof handle = { ...handle, create: () => instance } + yield ctx.slots.register({ + name: 'main', + key: PANEL_ID, + locale: NS, + store, + inject: () => face, + children: { + 'plugins.add.actions': { kind: 'list', scope: 'root' }, + 'plugins.tab': { kind: 'list', scope: 'root' }, + 'plugins.item': { kind: 'list', scope: 'root' }, + 'plugins.bundle.activation': { kind: 'keyed', scope: 'root' }, + 'plugins.bundle.config': { kind: 'keyed', scope: 'root' }, + 'plugins.row.config': { kind: 'keyed', scope: 'root' }, + 'plugins.detail.actions': { kind: 'list', scope: 'root' }, + 'plugins.detail.badge': { kind: 'list', scope: 'root' }, + 'plugins.detail.section': { kind: 'list', scope: 'root' }, + }, + }, PluginManagerPage) + yield ctx.layout.panelInfo.subscribe(() => { + if (ctx.layout.panelInfo.getSnapshot().activePanelId !== PANEL_ID) instance.actions.setView({ kind: 'list' }) + }) + const disposeNavigation = ctx.reflect.provide('pluginNavigation', { + openList: () => { + ctx.layout.selectPanel(PANEL_ID) + instance.actions.setTab('installed') + instance.actions.setView({ kind: 'list' }) + }, + openBundle: (packageName: string) => { + ctx.layout.selectPanel(PANEL_ID) + instance.actions.setTab('installed') + instance.actions.setView({ kind: 'package', name: packageName }) + }, + }) + yield () => { void disposeNavigation() } + }) + yield ctx.slots.inject('sidebar.panellist', () => ctx.slots.register({ + name: 'sidebar.panellist', + id: PANEL_ID, + order: 0, + label: () => t('panel'), locale: NS, - store, - inject: () => face, - children: { - 'plugins.add.actions': { kind: 'list', scope: 'root' }, - 'plugins.tab': { kind: 'list', scope: 'root' }, - 'plugins.item': { kind: 'list', scope: 'root' }, - 'plugins.bundle.activation': { kind: 'keyed', scope: 'root' }, - 'plugins.bundle.config': { kind: 'keyed', scope: 'root' }, - 'plugins.row.config': { kind: 'keyed', scope: 'root' }, - 'plugins.detail.actions': { kind: 'list', scope: 'root' }, - 'plugins.detail.badge': { kind: 'list', scope: 'root' }, - 'plugins.detail.section': { kind: 'list', scope: 'root' }, - }, - }, PluginManagerPage) - yield ctx.layout.panelInfo.subscribe(() => { - if (ctx.layout.panelInfo.getSnapshot().activePanelId !== PANEL_ID) instance.actions.setView({ kind: 'list' }) - }) - const disposeNavigation = ctx.reflect.provide('pluginNavigation', { - openList: () => { - ctx.layout.selectPanel(PANEL_ID) - instance.actions.setTab('installed') - instance.actions.setView({ kind: 'list' }) - }, - openBundle: (packageName: string) => { - ctx.layout.selectPanel(PANEL_ID) - instance.actions.setTab('installed') - instance.actions.setView({ kind: 'package', name: packageName }) - }, - }) - yield () => { void disposeNavigation() } + }, PluginsPanelIcon)) }) - ctx.slots.inject('sidebar.panellist', () => ctx.slots.register({ - name: 'sidebar.panellist', - id: PANEL_ID, - order: 0, - label: () => t('panel'), - locale: NS, - }, PluginsPanelIcon)) - } diff --git a/packages/client/ui-plugin-manager/src/client/locales.ts b/packages/client/ui-plugin-manager/src/client/locales.ts index 8f9e0f549d..912baa2c35 100644 --- a/packages/client/ui-plugin-manager/src/client/locales.ts +++ b/packages/client/ui-plugin-manager/src/client/locales.ts @@ -62,6 +62,7 @@ export const zh = { rowPhaseFailed: '异常', rowPhaseUnloading: '卸载中', enableToggle: '启用 {name}', + applyingChange: '正在应用 {name} 的更改', openDetail: '查看 {name}', backToList: '返回插件', crumbRoot: '插件', @@ -104,6 +105,8 @@ export const zh = { installProblemInvalid: '无法识别这个包名或地址:{reason}', installProblemInstalled: '该插件已安装。如需升级,请卸载后重新安装', installProblemShipped: '该插件随 DSH 提供,升级 DSH 即可获得新版本', + installBundledTitle: '插件已随 DSH 提供', + installBundledDescription: '无需下载,启用即可使用', installProblemNotFound: '未找到相关插件', installProblemNotPackage: '该路径不存在或不是有效的插件包', installProblemNotBundle: '这个包没有声明组合包,无法作为插件安装:{reason}', @@ -353,6 +356,7 @@ export const en = { rowPhaseFailed: 'Problem', rowPhaseUnloading: 'Unloading', enableToggle: 'Enable {name}', + applyingChange: 'Applying changes to {name}', openDetail: 'View {name}', backToList: 'Back to plugins', crumbRoot: 'Plugins', @@ -376,7 +380,7 @@ export const en = { installGuideFill: 'Use example', installGuideFillAria: 'Use the example {example}', installGuideSafety: 'Install only plugins you trust: they run with your permissions and can damage DeepSeek Harness or leak your data.', - installUpgradeNotice: 'On-demand Official plugins offer Update when their version differs from DSH. To update other plugins, uninstall and reinstall them.', + installUpgradeNotice: 'On-demand official plugins offer Update when their version differs from DSH. To update other plugins, uninstall and reinstall them.', registryToggle: 'Registry', registryLegend: 'The npm registry the plugin is downloaded from', registryDefault: 'Default registry', @@ -395,6 +399,8 @@ export const en = { installProblemInvalid: 'This is not a package name or address that can be installed: {reason}', installProblemInstalled: 'This plugin is already installed. To upgrade it, uninstall it and install it again', installProblemShipped: 'This plugin ships with DSH; upgrading DSH updates it', + installBundledTitle: 'This plugin ships with DSH', + installBundledDescription: 'Enable it without downloading another copy', installProblemNotFound: 'No such plugin was found', installProblemNotPackage: 'The path does not exist or is not a valid plugin package', installProblemNotBundle: 'This package declares no bundle, so it cannot be installed as a plugin: {reason}', @@ -583,3 +589,294 @@ export const en = { detailFailed: 'Could not read the details.', detailFootnotes: 'Footnotes', } satisfies Record + +/** Japanese copy, checked against the English keys. */ +export const ja = { + 'panel': 'プラグイン', + 'title': 'プラグイン', + 'intro': 'プラグインのインストール、有効化、設定', + 'infoLabel': 'プラグインについて', + 'infoDescription': '公式プラグインの設定や、その他のプラグインのインストール・管理はここで行えます。組み込みプラグインの一覧と実行状態は「設定 → 組み込みプラグイン」で確認できます。', + 'loading': 'プラグインを読み込み中…', + 'error': '一部のプラグインを読み込めませんでした。ネットワークの問題が考えられます', + 'unavailable': 'このデプロイでは管理可能なプロファイルが実行されていないため、ここでプラグインのインストールや切り替えはできません。', + 'retry': '再試行', + 'refresh': '更新', + 'refreshError': '更新に失敗しました。もう一度お試しください。', + 'empty': 'プラグインはまだインストールされていません。', + 'addPlugin': 'プラグインを追加', + 'chooseAddMethod': 'プラグインの追加方法を選択', + 'installExisting': 'サードパーティのプラグインをインストール', + 'installExistingDescription': 'npm パッケージ、Git リポジトリ、ローカルディレクトリに対応', + 'restartNotice': '変更を適用するには DSH を再起動してください。{reason}', + 'restartReasonNoHmr': 'このプロファイルでは起動時にのみ変更が適用されます。', + 'restartReasonPackageMoved': 'インストール済みパッケージの解決先が別のディレクトリになったか、変更によって DSH インストールが提供するパッケージが置き換えられました。別の場所からインストールする前に、そのパッケージをアンインストールしてください。', + 'restartReasonHostModule': '変更はDSH自身が読み込んだモジュールにも及びます。', + 'restartReasonUnmanagedPlugin': '変更はプラグイン一覧外で動作しているプラグインにも及びます。', + 'restartReasonModuleComparison': 'DSHは読み込まれたプラグインとそのファイルを比較できませんでした。', + 'overriddenNotice': '{name}は保存されましたが、優先度の高い設定が上書きしているため有効になっていません', + 'bundlesTitle': 'インストール済みプラグイン', + 'basicTitle': '組み込みプラグイン設定', + 'extensionsTitle': '実験的プラグイン', + 'moreTitle': '実験的プラグイン', + 'moreAction': 'その他', + 'tabsLabel': 'プラグインビュー', + 'installedTab': 'インストール済み', + 'statusProblem': '問題', + 'statusBeta': '実験的', + 'reasonLabel': '理由', + 'metadataError': 'パッケージメタデータのエラー: {error}', + 'versionTag': 'v{version}', + 'partsLabel': 'コンポーネント', + 'sourceTitle': 'ソース', + 'sourceSpec': 'コードソース', + 'sourceBuiltIn': '組み込み', + 'sourceVersion': '現在のバージョン', + 'partsEmpty': 'このプラグインパックにはコンポーネントが含まれていません。', + 'partsCountTotal': '計{count}件', + 'partsCountRunning': '{count}件稼働中', + 'partsCountOff': '{count}件オフ', + 'partOff': 'オフ', + 'partsCountFailed': '{count}件失敗', + 'partsFilter': 'コンポーネントを絞り込む', + 'partsFilterEmpty': '一致するコンポーネントはありません。', + 'partToggle': 'コンポーネント{name}を有効化', + 'rowPhasePending': '依存関係を待機中', + 'rowPhaseLoading': '読み込み中', + 'rowPhaseActive': '稼働中', + 'rowPhaseFailed': '問題', + 'rowPhaseUnloading': 'アンロード中', + 'applyingChange': '{name} の変更を適用中', + 'installBundledTitle': 'このプラグインは DSH に同梱されています', + 'installBundledDescription': '追加のダウンロードなしで有効にできます', + 'enableToggle': '{name}を有効化', + 'openDetail': '{name}を表示', + 'backToList': 'プラグイン一覧に戻る', + 'crumbRoot': 'プラグイン', + 'backToPackage': '{name}に戻る', + 'configureRow': '{name}を設定', + 'rowStateIdle': '未稼働', + 'uninstall': 'アンインストール', + 'uninstallLabel': '{name}をアンインストール', + 'installTitle': 'プラグインを追加', + 'installDescription': 'プラグインのパッケージ名、GitHubリポジトリのアドレス、またはローカルディレクトリのパスを入力してください。', + 'installSpecLabel': 'パッケージ名またはアドレス', + 'installSpecPlaceholder': '例: dsh-plugin-whale-pet', + 'installGuideToggle': 'インストールガイドと例', + 'installGuideHide': 'ガイドを隠す', + 'installGuideIdTitle': 'プラグインのnpmパッケージ名を入力', + 'installGuideIdExample': 'dsh-plugin-whale-pet', + 'installGuideIdHint': 'プラグインパッケージ名とはnpmパッケージ名(dsh-xxxや@author/pluginなど)です。コミュニティプラグインのREADMEのインストールコマンドにあるdsh plugin addやpnpm addの後ろの部分です。', + 'installGuideExampleLabel': '例: ', + 'installGitTemplateHint': '実際のGitリポジトリのアドレスに置き換えてください。', + 'installPathTemplateHint': 'ローカルプラグインディレクトリの実際のパスに置き換えてください。', + 'installGuideFill': '例を使用', + 'installGuideFillAria': '例{example}を使用', + 'installGuideSafety': '信頼できるプラグインのみをインストールしてください。プラグインはあなたの権限で動作し、DeepSeek Harnessを破壊したりデータを漏洩させたりする可能性があります。', + 'installUpgradeNotice': 'オンデマンドの公式プラグインは、バージョンがDSHと異なる場合に更新を提供します。その他のプラグインを更新するには、アンインストールして再インストールしてください。', + 'registryToggle': 'レジストリ', + 'registryLegend': 'プラグインのダウンロード元のnpmレジストリ', + 'registryDefault': 'デフォルトレジストリ', + 'registryOfficial': '公式npmレジストリ', + 'registryNpmmirror': '中国本土ミラー', + 'registryCustom': 'カスタムアドレス', + 'registryCustomPlaceholder': 'https://npm.example.com/', + 'registryCustomHint': 'http://またはhttps://で始まる社内・プライベートのnpmレジストリのアドレスを入力してください。ログインが必要な場合は、このマシンの~/.npmrcに認証情報を保存してください。', + 'registryCustomInvalid': 'http://またはhttps://で始まるアドレスを入力してください', + 'registryListSeparator': ', ', + 'sentenceSeparator': ' ', + 'installRun': 'インストール', + 'installChecking': '確認中…', + 'installConfirmedTitle': 'プラグインをインストール', + 'installConfirmedRefused': 'このプラグインはインストールできません', + 'installProblemInvalid': 'パッケージ名またはインストール可能なアドレスではありません: {reason}', + 'installProblemInstalled': 'このプラグインはすでにインストールされています。アップグレードするには、アンインストールしてから再インストールしてください', + 'installProblemShipped': 'このプラグインはDSHに同梱されています。DSHをアップグレードすると更新されます', + 'installProblemNotFound': '該当するプラグインが見つかりません', + 'installProblemNotPackage': 'パスが存在しないか、有効なプラグインパッケージではありません', + 'installProblemNotBundle': 'このパッケージはバンドルを宣言していないため、プラグインとしてインストールできません: {reason}', + 'installProblemNetwork': 'プラグインレジストリに接続できませんでした。ネットワークを確認して再試行してください', + 'installProblemNetworkAll': 'どのレジストリにも接続できませんでした(試行: {registries})。ネットワークまたはプロキシ設定を確認するか、レジストリを変更してください', + 'installProblemUnknown': 'プラグインを照会できませんでした: {reason}', + 'installingTitle': 'プラグインをインストール中…', + 'installedTitle': 'インストール済み', + 'statusNotInstalled': '未インストール', + 'statusMissingFiles': 'パッケージ利用不可', + 'statusUpdateAvailable': 'アップデートあり', + 'sourceNotInstalled': '未インストール', + 'sourceTargetSpec': 'このDSH用のソース', + 'sourceTargetVersion': 'このDSH用のバージョン', + 'update': 'アップデート', + 'updateLabel': '{name}をアップデート', + 'versionsTitle': 'バージョン', + 'versionsRecheck': '再確認', + 'versionsChecking': 'アップデートを確認中', + 'versionsAvailable': '{version}へアップデート可能', + 'versionsPublished': '{date}に公開', + 'versionsLatest': '最新です', + 'versionsCheckedAt': '最終確認: {time}', + 'versionsBlocked': '新しいバージョンにはDSHのアップデートが必要です', + 'versionsBlockedDetail': '{version}には{peers}が必要です。このDSHは{runtime}です', + 'versionsNoneInstallable': '直接インストールできる新しいバージョンはありません', + 'versionsNoneInstallableDetail': '新しいバージョンは非推奨、プレリリース、またはバンドル未宣言です。展開して確認してください', + 'versionsFailedNetwork': 'レジストリに接続できませんでした', + 'versionsFailedOther': 'アップデートを確認できませんでした', + 'versionsDetails': '詳細', + 'versionsNewerCount': '新しいバージョン {count} 件', + 'versionsCollapse': '折りたたむ', + 'versionsInstalledTag': 'インストール済み', + 'versionsPrereleaseTag': 'プレリリース', + 'versionsDeprecatedTag': '非推奨', + 'versionsNeedsDsh': '{peers}が必要', + 'versionsNotBundle': 'このバージョンはバンドルを宣言していないためインストールできません', + 'versionsUpdateLabel': '{version}へアップデート', + 'versionsRegistry': 'レジストリ', + 'versionsCheckedAtLabel': '確認日時', + 'versionsFallbackTag': 'フォールバック', + 'versionsFallbackHint': '{registry}に接続できませんでした。リストはフォールバックレジストリからのものです', + 'versionsPrimaryTag': 'プライマリ', + 'versionsShowPrerelease': 'プレリリースを表示', + 'versionsOmitted': 'インストール済みより新しいバージョンが他に {count} 件ありますが、表示されていません', + 'versionsProblemNetwork': '到達不能', + 'versionsProblemNotFound': '見つかりません', + 'versionsProblemUnknown': '失敗', + 'updatesAvailable': 'アップデートあり: {count}', + 'updatesNone': 'すべてのプラグインは最新です', + 'updatesFailed': '一部のプラグインを確認できませんでした', + 'updateTitle': '{name}をアップデート', + 'updateSource': 'インストール元', + 'updateDescription': 'アップデートにより{from}が{to}に置き換わります。プラグインが一時停止する場合があります。インストールに失敗した場合、{from}は保持されるか再インストールされます。', + 'updateRunning': 'アップデート中', + 'updateStepChecking': '{version}を確認', + 'updateStepRemoving': '{version}を停止して削除', + 'updateStepInstalling': '{registry}から{version}をインストール', + 'updateStepRestoring': '{version}を再インストール', + 'updateDone': '{version}にアップデートしました', + 'updateDoneDetail': '新しいバージョンが実行中です。', + 'updateRestart': '{version}をインストールしました。再起動後に有効になります', + 'updateRefused': '{version}へアップデートできません', + 'updateRemoveFailed': '{version}を削除できませんでした', + 'updateRestored': 'アップデートに失敗しました。{version}を復元しました', + 'updateRestoredDetail': '{to}のインストールに失敗しました。{from}を再インストールしました。', + 'updateKept': '{version}へアップデートできませんでした', + 'updateKeptDetail': '{from}は削除されず、引き続き使用されます。{reason}', + 'updateBroken': 'アップデートに失敗しました。プラグインはインストールされていません', + 'updateBrokenDetail': '{to}のインストールに失敗し、{from}の再インストールにも失敗しました。プラグインを追加から{name}を再インストールしてください。', + 'updateLost': 'アップデート結果が不明です', + 'updateLostDetail': 'DSHへの接続が中断されました: {reason}。プラグインリストを更新してインストール済みバージョンを確認してください。', + 'updateOutput': 'インストール出力を表示', + 'updateFinish': '完了', + 'installFailedTitle': 'プラグインをインストールできませんでした', + 'installGithubFailedTitle': 'GitHubにアクセスできません', + 'installGithubTimeoutTitle': 'GitHubへの接続がタイムアウトしました', + 'installGithubFailedDescription': '別のインストール元を試してください。', + 'installUseGithubMirror': '中国本土ミラーを使用', + 'installTryAnotherWay': '別の方法を試す', + 'installPackageLabel': 'プラグインパッケージ名', + 'installEdit': '編集', + 'installEditAria': '編集に戻る', + 'installCancelAndEdit': 'インストールをキャンセルして編集に戻る', + 'installApplyingCancellationError': 'キャンセルが確認されませんでした。インストールを適用中です。結果をお待ちください。{reason}', + 'installReconcile': 'インストール状態を確認', + 'installUnknownTitle': 'インストール結果を取得できません', + 'installUnknownDescription': 'HostにこのリクエストIDの有効なインストールがありません。再試行する前にプラグイン一覧を確認してください。', + 'installResultUnconfirmed': 'インストール結果を受信できませんでした。インストール状態を確認してください。{reason}', + 'installAwaitingAcceptance': 'Hostがインストールを承認するのを待っています。承認後、キャンセルは自動的に再試行されます。', + 'installBackgroundUnknown': 'インストール結果を取得できません。プラグイン一覧を確認してください。', + 'installCancel': 'インストールをキャンセル', + 'installCloseCancels': 'インストールをキャンセルして閉じる', + 'installViewTask': 'インストールを表示', + 'installUnconfirmedTitle': 'インストール状態が未確認です', + 'installBackgroundDone': 'インストールが完了しました。インストール詳細を表示します。', + 'installBackgroundFailed': 'インストールに失敗しました。インストール詳細を表示します。', + 'installBackgroundUnconfirmed': 'インストール状態が未確認です。インストールを表示して詳細を確認してください。', + 'installBackgroundApplying': 'インストールを適用中のためキャンセルできません。インストールの進行状況を表示します。', + 'installStarting': 'インストールを準備中…', + 'installCancelling': 'インストールを停止中…', + 'installApplying': '設定を適用しています。お待ちください…', + 'installCancelledShort': 'キャンセル済み', + 'installCancelled': 'インストールをキャンセルしました。プラグインは有効化されておらず、ダウンロードしたファイルが残る場合があります', + 'installCancelUnconfirmed': 'インストールの停止が確認されていません。キャンセルを再試行するか、インストール結果をお待ちください。{reason}', + 'installEnableNow': '今すぐ有効化', + 'installDetailsShow': 'インストール詳細を表示', + 'installDetailsHide': 'インストール詳細を隠す', + 'installVersion': 'バージョン {version}', + 'installSubjectPath': 'ローカルディレクトリ', + 'installSubjectGit': 'Gitリポジトリ', + 'installSubjectTarball': 'Tarball', + 'installLocation': '{dir} にインストール', + 'installRetry': '再試行', + 'installChangeRegistry': 'レジストリを変更', + 'installAttempt': '{previous} ではパッケージを提供できませんでした。{registry} 経由で再試行しています(レジストリ {index}/{total})', + 'installAttemptBadge': '試行 {index} · {registry}', + 'installFailureNetwork': 'ネットワーク接続に失敗しました', + 'installFailureNetworkAll': 'レジストリに接続できませんでした(試行: {registries})。ネットワークまたはプロキシ設定を確認するか、レジストリを変更して再試行してください。', + 'installFailureNetworkHost': '{host} に接続できませんでした。GitHubアドレスや.tgzリンクはレジストリ経由では取得されません。このマシンが直接またはプロキシ経由で接続できる必要があります。プラグインがnpmにも公開されている場合は、パッケージ名を入力してください。', + 'installFailureNotFound': '該当するプラグインが見つかりません', + 'installFailureNoMatchingVersion': '要求に一致するバージョンがありません', + 'installFailureDiskFull': 'ディスクの容量が不足しているため、インストールを停止しました', + 'installFailurePermission': '書き込み権限がないため、プラグインをインストールできません', + 'installFailureBuildBlocked': 'インストールを続行するには、依存関係のインストールスクリプトに許可を与える必要があります', + 'installFailureBuildBlockedManual': 'pnpmがインストールスクリプトをブロックしました。pnpm-workspace.yamlのallowBuildsで許可して再試行してください', + 'installFailureIntegrity': 'ダウンロードしたパッケージの整合性チェックに失敗しました', + 'installFailureTimeout': 'インストールがタイムアウトしました', + 'installFailurePnpmMissing': 'pnpmが見つからないため、インストールできません', + 'installFailureGeneric': 'インストール中に問題が発生しました。詳細をご確認ください', + 'terminalCommandLine': 'コマンドライン {n}', + 'terminalRunning': '実行中', + 'terminalFailed': '失敗', + 'terminalDone': '完了', + 'terminalCopy': 'コピー', + 'terminalCopied': 'コピー済み', + 'terminalNoOutput': '出力なし', + 'terminalCollapseAria': '出力を折りたたむ', + 'terminalCollapse': '折りたたむ', + 'terminalExpandAria': '残り {n} 行の出力を展開', + 'terminalExpand': '… さらに {n} 行', + 'terminalExitCode': '終了コード {code}', + 'terminalSignal': 'シグナル {signal}', + 'terminalNoExitCode': '終了コードなし', + 'installDoneNothing': '新しい依存関係はなく、インストールが完了しました。', + 'installDoneRestart': 'インストール済みです。読み込むにはDSHを再起動してください。{reason}', + 'installDoneApproved': '{names} のインストールスクリプトを許可しました', + 'installDoneOtherVersion': 'pnpm のサプライチェーンセキュリティポリシー(minimumReleaseAge)では、必須の経過時間より新しく公開されたバージョンは自動的にインストールされないため、このインストールでは最新バージョン {version} ではなく {installed} を使用しました。新しく公開されたバージョンはこの待機期間を経過しておらず、検出されていない悪意のあるコードや欠陥を含む可能性があります。{version} が必要な場合は、現在のバージョンをアンインストールしてから {exact} をインストールしてそのバージョンを要求してください。', + 'installApprovalTitle': 'インストールスクリプトには許可が必要です', + 'installApprovalDescription': 'これらのパッケージには pnpm が実行しなかったインストールスクリプトがあります。', + 'installApprovalConsequence': '許可すると、スクリプトはここであなたの権限で実行され、許可はこのプロファイルに保存されます。', + 'installApprovalCaution': '信頼できるパッケージのみ許可してください。', + 'installApproveAndRetry': 'これらのスクリプトを許可して再試行', + 'installClose': '完了', + 'close': '閉じる', + 'cancel': 'キャンセル', + 'confirmUninstallTitle': '「{name}」をアンインストールしますか?', + 'confirmUninstallDescription': 'アンインストールすると、提供している機能は利用できなくなります。', + 'confirmUninstall': 'アンインストール', + 'failedEnable': '有効化できませんでした: {reason}', + 'failedDisable': '無効化できませんでした: {reason}', + 'failedUninstall': 'アンインストールできませんでした: {reason}', + 'failedRowEnable': 'コンポーネントを有効化できませんでした: {reason}', + 'failedRowDisable': 'コンポーネントを無効化できませんでした: {reason}', + 'reasonManagementRequired': 'プラグイン管理に必要です。無効化もアンインストールもできません。', + 'reasonUnaddressable': 'プロファイルパッチでこれを一意に指定できません。', + 'reasonUnknownPlugin': 'そのようなプラグインはありません。', + 'reasonInvalidSpec': '有効なパッケージ名またはアドレスを入力してください。', + 'reasonAlreadyInstalled': 'このパッケージはすでにインストールされています。別のバージョンをインストールする前にアンインストールしてください。', + 'reasonAmbiguousInstall': '依存関係の変更から、どのパッケージがインストールされたか判断できません。', + 'reasonNotBundle': 'このパッケージはバンドルを宣言していないため、プラグインとして管理できません。', + 'reasonNotRemovable': 'このパッケージはプロファイル所有ではないか、プラグイン管理に必要です。', + 'reasonStopProfile': 'このプロファイルは HMR なしで動作します。停止してから dsh plugin でパッケージをアンインストールしてください。', + 'reasonBundleInUse': '他の設定がこのバンドルのコンポーネントをまだ使用しています。先にそれらを無効にしてください。', + 'reasonStaleApproval': '保留中のスクリプト承認が変更されました。インストールをやり直して更新してください。', + 'reasonIncompatibleVersion': '{plugin} は DSH {runtime} と互換性がありません({requirements} が必要)。実行するとクラッシュやデータ損失を引き起こす可能性があります。', + 'reasonIncompatibleVersionUnnamed': 'このプラグインは実行中の DSH バージョンと互換性がありません。実行するとクラッシュやデータ損失を引き起こす可能性があります。', + 'reasonIncompatibleInstall': 'この DSH と互換性のあるプラグインバージョンをインストールしてください。', + 'reasonIncompatibleInstalled': 'アンインストールして、この DSH と互換性のあるバージョンをインストールしてください。', + 'reasonOperationError': 'Host がエラーを報告しました。', + 'detailTitle': '詳細', + 'detailLoading': '詳細を読み込み中', + 'detailTooLarge': '詳細がサイズ上限を超えているため読み込まれませんでした。', + 'detailNetwork': '詳細の取得元に接続できませんでした。', + 'detailInvalid': 'プラグインが無効な詳細ファイルを宣言しています。', + 'detailFailed': '詳細を読み込めませんでした。', + 'detailFootnotes': '脚注', +} satisfies Record 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 775cbf0212..4117366567 100644 --- a/packages/client/ui-plugin-manager/src/client/manager-store.ts +++ b/packages/client/ui-plugin-manager/src/client/manager-store.ts @@ -44,6 +44,18 @@ import { shortName } from './presentation.ts' import { BundleDetailController, type BundleDetailsState } from './detail-store.ts' import type { LocaleSnapshot } from '@deepseek-ai/dsh-client-locale/client' +/** + * Read whether a bundle has a pending management action. + * @param state Current manager snapshot. + * @param packageName Bundle whose controls and observers must wait. + * @returns Whether the bundle's management operation is still pending. + */ +export function bundleBusy(state: PluginManagerState, packageName: string): boolean { + return state.busy.includes(packageName) || state.update?.packageName === packageName + || (state.install.subject?.name === packageName && (isInstallPending(state.install.phase) || state.install.phase === 'checking')) + +} + /** The action a failed notice names. */ export type FailedAction = 'enable' | 'disable' | 'uninstall' | 'rowEnable' | 'rowDisable' @@ -101,6 +113,8 @@ export interface PackageView { readonly meta?: PluginLocalizedMeta /** Whether the profile declares this dependency; availability separately describes its readable files. */ readonly installed: boolean + /** GUI listing before explicit selection or profile installation; omission means true. */ + readonly listedByDefault?: boolean /** Present for a profile dependency the installation does not also supply: the spec `pnpm add` accepts. */ readonly source?: string /** Whether the installation ships the bundle for the person to switch on: official, off until selected, never removable. */ @@ -121,6 +135,8 @@ export interface PackageView { /** The typed spec as the Host read it, on the installing, installed, and failed screens. */ export type InstallSubject = Extract & { readonly spec: string + /** The installation already provides this bundle; only explicit enablement remains. */ + readonly bundled?: true /** Present for a catalog install or update: desired selection after installation. */ readonly selection?: boolean /** Save an exact dependency version through the generic installer. */ @@ -235,9 +251,9 @@ export interface InstallState { readonly runs: readonly InstallRun[] /** Whether the run's command and output are unfolded, including after reopening. */ readonly detailsOpen: boolean - /** The bundle the finished run added; ordinary installs await enablement, catalog operations carry their selection. */ + /** The bundle ready for enablement, from an install run or the installation; catalog operations carry their selection. */ readonly installed: string | null - /** The version the finished run installed, when its manifest declares one; null otherwise. */ + /** The available bundle's manifest version; null when undeclared. */ readonly installedVersion: string | null /** Whether the finished run's bundle waits for the next start to load. */ readonly restartRequired: boolean @@ -542,6 +558,7 @@ export function packageView(bundle: BundleInfo, plugins: readonly PluginInfo[]): availability: bundle.availability, ...bundle.installTarget === undefined ? {} : { installTarget: bundle.installTarget }, installed: bundle.installed, + ...bundle.listedByDefault === undefined ? {} : { listedByDefault: bundle.listedByDefault }, optional: bundle.optional, removable: bundle.removable, updatable: bundle.updatable, @@ -671,6 +688,22 @@ export class PluginManagerController { return this.store.getSnapshot() } + /** + * Observe existing bundle and row operations independently of the page's lifetime. + * @param packageName Bundle whose management activity is observed. + * @returns A read-only source; unsubscribe releases the caller's observation. + */ + observeChanges(packageName: string): HostObservable { + return { + getSnapshot: () => { + const state = this.getSnapshot() + return bundleBusy(state, packageName) || (state.packages.find(pkg => pkg.name === packageName)?.rows.some(row => + row.entryId !== undefined && state.busy.includes(rowKey(row.entryId))) ?? false) + }, + subscribe: listener => this.store.subscribe(listener), + } + } + /** Stop publishing and drop every late settlement. */ dispose(): void { this.disposed = true @@ -720,7 +753,7 @@ export class PluginManagerController { void this.cancelInstall() return } - if (this.getSnapshot().install.phase === 'checking') this.finishAnalytics('cancelled') + if (this.getSnapshot().install.phase === 'checking' || this.getSnapshot().install.subject?.bundled) this.finishAnalytics('cancelled') this.abortInspect() this.registryRead = undefined this.patch({ install: IDLE_INSTALL }) @@ -1119,9 +1152,9 @@ export class PluginManagerController { } /** - * Check the typed spec, then install it. The Host reads what the spec - * names first; a refused spec returns to the field with the reason, an - * accepted one becomes the subject the next screens show while pnpm runs. + * Offer a default-hidden shipped bundle for explicit enablement, or check + * and install the typed spec. A refused spec returns to the field with its + * reason; an accepted one becomes the subject shown while pnpm runs. */ private async runInstall(): Promise { const state = this.getSnapshot() @@ -1132,9 +1165,20 @@ export class PluginManagerController { 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. const listed = state.packages.find(pkg => pkg.name === spec) const catalogSubject = install.subject?.selection === undefined || install.subject.spec !== spec ? undefined : install.subject + if (listed?.availability === 'installation' && listed.optional && listed.listedByDefault === false + && !listed.enabled && listed.error === undefined && listed.readOnlyReason === undefined) { + this.abortInspect() + this.patch({ install: { + ...IDLE_INSTALL, open: true, spec, registries: install.registries, registry: install.registry, + phase: 'done', installed: listed.name, installedVersion: listed.version ?? null, + subject: { status: 'accepted', kind: 'registry', bundle: true, name: listed.name, spec, registry: null, bundled: true, + ...listed.version === undefined ? {} : { version: listed.version }, + ...listed.description === undefined ? {} : { description: listed.description } }, + } }) + return + } if (listed !== undefined && listed.availability !== 'missing' && catalogSubject === undefined) { this.finishAnalytics('failed', 'already-installed') this.patchInstall({ phase: 'idle', inputError: { problem: listed.availability === 'installation' ? 'shipped' : 'already-installed', reason: spec } }) @@ -1371,9 +1415,8 @@ export class PluginManagerController { } /** - * Enable the bundle the finished install added, then close the dialog and - * mark it in the list. A refusal toasts and still closes: the list shows - * what did not switch on. + * Enable the bundle ready from an install or the installation, then close the dialog and + * mark it in the list. A refusal toasts and still closes. */ private async enableInstalled(): Promise { const install = this.getSnapshot().install @@ -1383,6 +1426,8 @@ export class PluginManagerController { if (name !== null) { const result = await this.ctx.remote.pluginManager.setBundleEnabled(name, true) if (this.disposed) return + this.finishAnalytics(result.ok && result.value.application !== 'failed' ? 'success' : 'failed', + result.ok ? result.value.error?.code : result.error.code, name) try { this.applied(result, name) if (result.ok && (result.value.application === 'applied' || result.value.application === 'restart-required')) this.trackToggle(name, true) diff --git a/packages/client/ui-plugin-manager/src/client/presentation.ts b/packages/client/ui-plugin-manager/src/client/presentation.ts index b636c67e04..c817e84f46 100644 --- a/packages/client/ui-plugin-manager/src/client/presentation.ts +++ b/packages/client/ui-plugin-manager/src/client/presentation.ts @@ -117,7 +117,6 @@ const EXPERIMENTAL_BUNDLES: readonly string[] = [ '@deepseek-ai/dsh-experimental-inspector-profile', '@deepseek-ai/dsh-experimental-tool-worktree', '@deepseek-ai/dsh-experimental-cot-translation-bundle', - '@deepseek-ai/dsh-experimental-terminal-bundle', '@deepseek-ai/dsh-experimental-session-search', '@deepseek-ai/dsh-experimental-session-titles-bundle', '@deepseek-ai/dsh-experimental-badge-skill-bundle', 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 04c9392b1e..2a6f019ad4 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 @@ -8,7 +8,7 @@ import { createSnapshotStore } from '@deepseek-ai/dsh-client-store' import type { ILayout, PanelInfo } from '@deepseek-ai/dsh-client-ui-layout/client' import { SlotRegistry } from '@deepseek-ai/dsh-client-ui-renderer/client' import { resolveSlotLabel } from '@deepseek-ai/dsh-client-ui-slots' -import { allowAllAccess, TestRemote, usePinnedBrowserLanguages } from '@deepseek-ai/dsh-client-test-runtime' +import { allowAllAccess, StaticRemoteAccess, TestRemote, usePinnedBrowserLanguages } from '@deepseek-ai/dsh-client-test-runtime' 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' @@ -21,10 +21,53 @@ usePinnedBrowserLanguages('zh-CN') beforeEach(() => { localStorage.clear() }) afterEach(cleanup) -async function bench() { +type AccessEffect = (() => void) | Iterable<() => void> + +/** One guarded callback, the Context that owns it, and the disposer of its current installation. */ +interface Guard { + readonly owner: Context + readonly callback: () => AccessEffect + dispose: (() => void) | undefined +} + +/** A `remoteAccess` stand-in whose one answer a spec switches, installing or withdrawing every guarded effect. */ +class SwitchableAccess extends Service { + private allowed = true + private readonly guards: Guard[] = [] + + constructor(serviceCtx: Context) { + super(serviceCtx, 'remoteAccess') + } + + whenAccess(_action: string, callback: () => AccessEffect): () => void { + const guard: Guard = { owner: this.ctx, callback, dispose: undefined } + this.guards.push(guard) + this.install(guard) + return () => {} + } + + /** @param allowed - the answer every guarded effect follows from now on. */ + set(allowed: boolean): void { + this.allowed = allowed + for (const guard of this.guards) { + if (allowed) this.install(guard) + else { guard.dispose?.(); guard.dispose = undefined } + } + } + + private install(guard: Guard): void { + if (!this.allowed || guard.dispose !== undefined) return + const dispose = guard.owner.effect(guard.callback) + guard.dispose = () => { void dispose() } + } +} + +async function bench(allows: ((action: string) => boolean) | 'switchable' = () => true) { const ctx = new Context() onTestFinished(async () => { await ctx.fiber.dispose() }) await ctx.plugin(SlotRegistry).await() + const access = allows === 'switchable' ? new SwitchableAccess(ctx) : undefined + if (allows !== 'switchable') new StaticRemoteAccess(ctx, allows) const locale = new LocaleRuntime(ctx) ctx.provide('locale', locale) class LocaleHolder extends Service { @@ -50,7 +93,7 @@ async function bench() { ctx.provide('layout', { panelInfo, selectPanel, beginNavigation: () => new AbortController().signal, toggleSidebar: vi.fn(), openRightbar: vi.fn(), closeRightbar: vi.fn() }) await ctx.plugin(settings).await() - return { ctx, slots: ctx.get('slots') as SlotRegistry, locale, list, remote, selectPanel, panelInfo } + return { ctx, slots: ctx.get('slots') as SlotRegistry, locale, list, remote, selectPanel, panelInfo, access } } function declare(slots: SlotRegistry): () => void { @@ -124,6 +167,7 @@ describe('ui-plugin-manager browser plugin', () => { const removeRoot = declare(b.slots) const mainInjected: object = b.slots.entries('main')[0]!.inject!() const face = mainInjected as PluginManagerFace + expect(b.ctx.pluginChanges.bundle('unlisted').getSnapshot()).toBe(false) const overlayInjected: object = b.slots.entries('shell.overlay')[0]!.inject!() const overlay = overlayInjected as PluginRefreshToastFace expect(overlay.hooks.pluginManager).toBe(face.hooks.pluginManager) @@ -152,7 +196,41 @@ describe('ui-plugin-manager browser plugin', () => { }) 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']) + expect(inject).toEqual(['slots', 'locale', 'remote', 'remote.pluginManager', 'remote.pluginInventory', 'remote.pluginRegistryProbe', 'configForms', 'layout', 'remoteAccess']) + }) + + it('reads the Host again on a later grant after showing the page, and not while access is withdrawn', async () => { + const b = await bench('switchable') + declare(b.slots) + await b.ctx.plugin({ inject: [...inject], apply }).await() + const injected: object = b.slots.entries('main')[0]!.inject!() + const face = injected as PluginManagerFace + face.ensure() + await vi.waitFor(() => { expect(face.hooks.pluginManager.getSnapshot().status).toBe('ready') }) + expect(b.list).toHaveBeenCalledTimes(1) + + b.access!.set(false) + expect(b.slots.entries('main')).toEqual([]) + b.remote.emit('plugin-manager/changed', [{ reason: 'install' }]) + await Promise.resolve() + expect(b.list).toHaveBeenCalledTimes(1) + + b.access!.set(true) + expect(b.slots.entries('main')).toHaveLength(1) + await vi.waitFor(() => { expect(b.list).toHaveBeenCalledTimes(2) }) + }) + + it('contributes the entry, page, and navigation only while the current Peer may list bundles, and observes changes either way', async () => { + const allows = vi.fn(() => false) + const b = await bench(allows) + declare(b.slots) + const fiber = b.ctx.plugin({ inject: [...inject], apply }) + await fiber.await() + expect(allows).toHaveBeenCalledWith('pluginManager/listBundles') + for (const name of ['main', 'shell.overlay', 'sidebar.panellist'] as const) expect(b.slots.entries(name)).toEqual([]) + expect(b.ctx.get('pluginNavigation')).toBeUndefined() + expect(b.ctx.get('pluginChanges')?.bundle('dsh-any').getSnapshot()).toBe(false) + await fiber.dispose() }) it('registers the sidebar entry and its page, which reads the Host only once rendered and follows Host changes', async () => { @@ -214,15 +292,16 @@ describe('ui-plugin-manager browser plugin', () => { face.ensure() await vi.waitFor(() => { expect(face.hooks.pluginManager.getSnapshot().status).toBe('ready') }) expect(b.list).toHaveBeenCalledTimes(1) - // Bundle detail reads go to the Host by name; a profile change forgets them. + // A profile change invalidates reads without withdrawing the displayed document. face.loadDetail('dsh-a', '1.0.0', 'en', false) - await vi.waitFor(() => { expect(face.hooks.bundleDetails.getSnapshot()).not.toEqual({}) }) + await vi.waitFor(() => { expect(Object.values(face.hooks.bundleDetails.getSnapshot())[0]?.status).toBe('ready') }) + const loadedDetails = face.hooks.bundleDetails.getSnapshot() face.retryDetail('dsh-a', '1.0.0', 'en', false) expect(face.detailImage('dsh-a', 'README.md', 'a.png', false)).toContain('api/plugin-manager/detail-file?bundle=dsh-a') expect(face.hooks.locale.getSnapshot().active).toBe('zh') b.remote.emit('plugin-manager/changed', [{ reason: 'bundle' }]) await vi.waitFor(() => { expect(b.list).toHaveBeenCalledTimes(2) }) - expect(face.hooks.bundleDetails.getSnapshot()).toEqual({}) + expect(face.hooks.bundleDetails.getSnapshot()).toEqual(loadedDetails) b.ctx.emit('connection/reset') await vi.waitFor(() => { expect(b.list).toHaveBeenCalledTimes(3) }) @@ -238,6 +317,7 @@ describe('ui-plugin-manager browser plugin', () => { await fiber.dispose() expect(b.ctx.get('pluginNavigation')).toBeUndefined() + expect(b.ctx.get('pluginChanges')).toBeUndefined() expect(b.slots.entries('main')).toHaveLength(0) expect(b.slots.entries('shell.overlay')).toHaveLength(0) expect(b.slots.entries('sidebar.panellist')).toHaveLength(0) 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 9998a92b15..a64c1ba05c 100644 --- a/packages/client/ui-plugin-manager/tests/components.client.spec.tsx +++ b/packages/client/ui-plugin-manager/tests/components.client.spec.tsx @@ -502,7 +502,7 @@ describe('PluginManagerPage', () => { expect(document.querySelector('[data-detail-document]')).toBeNull() // A read the controller forgot, after a profile change, starts again. b.setDetails({}) - expect(b.actions.loadDetail).toHaveBeenCalledTimes(2) + expect(b.actions.loadDetail).toHaveBeenLastCalledWith('dsh-better-sidebar', '0.16.0', 'en', false) }) it('says where a bundle comes from: the spec that installs it, or built in, with its version', () => { @@ -740,7 +740,7 @@ describe('PluginManagerPage', () => { expect(locked.getAttribute('title')).toBe(en.reasonManagementRequired) }) - it('omits built-in profile dependencies while retaining optional and third-party bundles', () => { + it('omits profile and persistent-terminal bundles while retaining other optional and third-party bundles', () => { renderTab({ packages: [ ...[ @@ -750,6 +750,7 @@ describe('PluginManagerPage', () => { '@deepseek-ai/dsh-sdk-app', '@deepseek-ai/dsh-acp-app', '@deepseek-ai/dsh-sdk-minimal', + '@deepseek-ai/dsh-experimental-terminal-bundle', ].map(name => pkg({ name })), pkg({ name: '@acme/dsh-base', readOnlyReason: 'management-required' }), pkg({ name: 'dsh-better-sidebar' }), @@ -762,9 +763,29 @@ describe('PluginManagerPage', () => { expect(document.querySelector('[data-plugin-count]')).toBeNull() }) - it.each([false, true])('shows an empty list for built-in bundles with errors and installed=%s', (installed) => { + it.each([false, true])('hides persistent terminals from both lists and direct navigation without changing enabled=%s', (enabled) => { + const name = '@deepseek-ai/dsh-experimental-terminal-bundle' + const b = renderTab({ packages: [ + pkg({ name, official: true, enabled, rows: [row()] }), + pkg({ name: '@deepseek-ai/dsh-experimental-agent-team-profile', official: true }), + ] }) + expect(document.querySelector(`[data-plugin-package="${name}"]`)).toBeNull() + fireEvent.click(screen.getByRole('button', { name: en.moreAction })) + expect(screen.getByRole('heading', { name: en.moreTitle, level: 1 })).toBeTruthy() + expect(document.querySelector(`[data-plugin-package="${name}"]`)).toBeNull() + act(() => { b.navigation.actions.setView({ kind: 'package', name }) }) + expect(document.querySelector('[data-plugin-detail]')).toBeNull() + act(() => { b.navigation.actions.setView({ kind: 'row', name, rowId: 'sidebar' }) }) + expect(document.querySelector('[data-plugin-row-detail]')).toBeNull() + expect(screen.queryByRole('switch', { name: en.enableToggle.replace('{name}', name) })).toBeNull() + expect(b.actions.setEnabled).not.toHaveBeenCalled() + expect(b.actions.setRowEnabled).not.toHaveBeenCalled() + expect(b.actions.uninstall).not.toHaveBeenCalled() + }) + + it.each([false, true])('shows an empty list for hidden bundles with errors and installed=%s', (installed) => { renderTab({ - packages: ['@deepseek-ai/dsh-base', '@deepseek-ai/dsh-web-app'].map(name => pkg({ + packages: ['@deepseek-ai/dsh-base', '@deepseek-ai/dsh-web-app', '@deepseek-ai/dsh-experimental-terminal-bundle'].map(name => pkg({ name, installed, error: { code: 'operation-error', diagnostic: 'Unreadable bundle' }, })), }) @@ -1079,7 +1100,7 @@ describe('PluginManagerPage', () => { 'plugins.row.config:dsh-better-sidebar#sidebar': view => view === 'summary' ? 'The sidebar row.' :
, } - it('keeps configuration and installed plugins on the main list and shows all official plugins on the secondary page', () => { + it('keeps configuration and installed plugins on the main list and shows eligible official plugins on the secondary page', () => { const team = '@deepseek-ai/dsh-experimental-agent-team-profile' renderTab( { packages: [ @@ -1088,6 +1109,8 @@ describe('PluginManagerPage', () => { pkg({ name: '@deepseek-ai/dsh-subagent-claude-code', official: true, availability: 'missing', installed: false, enabled: false }), pkg({ name: '@deepseek-ai/dsh-subagent-codex', official: true, enabled: false }), pkg({ name: '@deepseek-ai/dsh-experimental-tool-worktree', official: true, enabled: false }), + pkg({ name: '@deepseek-ai/dsh-experimental-session-search', installed: false, optional: true, enabled: false, listedByDefault: false }), + pkg({ name: '@deepseek-ai/dsh-experimental-ralph-bundle', installed: false, optional: true, enabled: false, listedByDefault: false }), pkg({ name: '@acme/new-official', official: true }), pkg(), ] }, @@ -1135,6 +1158,37 @@ describe('PluginManagerPage', () => { expect(screen.getByRole('heading', { name: en.basicTitle })).toBeTruthy() }) + it.each([ + '@deepseek-ai/dsh-experimental-session-search', + '@deepseek-ai/dsh-experimental-ralph-bundle', + '@acme/shipped-exploration', + ])('omits %s from default lists while retaining explicit profile selections and installations', (name) => { + const entry = pkg({ name, installed: false, optional: true, removable: false, enabled: false, listedByDefault: false }) + const other = pkg({ name: '@deepseek-ai/dsh-experimental-session-titles-bundle', installed: false, optional: true, enabled: false }) + const { actions, navigation, set, setLanguage } = renderTab({ packages: [other, entry] }) + const card = () => document.querySelector(`[data-plugin-package="${name}"]`) + for (const dict of [en, zh]) { + setLanguage(dict) + expect(card()).toBeNull() + fireEvent.click(screen.getByRole('button', { name: dict.moreAction })) + expect(card()).toBeNull() + fireEvent.click(screen.getByRole('button', { name: dict.backToList })) + } + setLanguage(en) + set({ packages: [other, { ...entry, enabled: true }] }) + expect(card()).not.toBeNull() + fireEvent.click(screen.getByRole('button', { name: en.openDetail.replace('{name}', name) })) + fireEvent.click(screen.getByRole('switch', { name: en.enableToggle.replace('{name}', name) })) + expect(actions.setEnabled).toHaveBeenCalledExactlyOnceWith(name, false) + set({ packages: [other, entry] }) + expect(card()).toBeNull() + expect(document.querySelector('[data-plugin-detail]')).toBeNull() + expect(document.querySelector('[data-plugin-group="more"]')).toBeNull() + act(() => { navigation.actions.setView({ kind: 'more' }) }) + set({ packages: [other, { ...entry, installed: true, availability: 'profile' }] }) + expect(card()).not.toBeNull() + }) + it.each([en, zh])('keeps disabled CUA on the secondary page with experimental labels', (dict) => { const name = '@deepseek-ai/dsh-cua-bundle' const entry = (enabled: boolean) => pkg({ name, official: true, enabled, meta: { title: 'Computer Use' } }) @@ -1164,7 +1218,7 @@ describe('PluginManagerPage', () => { }) it('returns from package details to the secondary page and retains it across React remounts', () => { - const name = '@deepseek-ai/dsh-experimental-terminal-bundle' + const name = '@deepseek-ai/dsh-experimental-session-titles-bundle' const b = renderTab({ packages: [pkg({ name, official: true, enabled: false })] }) fireEvent.click(screen.getByRole('button', { name: en.moreAction })) expect(screen.getByRole('heading', { name: en.moreTitle, level: 1 })).toBeTruthy() @@ -1207,7 +1261,7 @@ describe('PluginManagerPage', () => { }) it('returns a held-back bundle opened outside the lists to the list that shows it', () => { - const name = '@deepseek-ai/dsh-experimental-terminal-bundle' + const name = '@deepseek-ai/dsh-experimental-session-titles-bundle' const b = renderTab({ packages: [pkg({ name, official: true, enabled: false })] }) act(() => { b.navigation.actions.setView({ kind: 'package', name }) }) expect(document.querySelector(`[data-plugin-detail="${name}"]`)).toBeTruthy() @@ -1750,6 +1804,21 @@ describe('PluginManagerPage', () => { expect(actions.closeInstall).toHaveBeenCalledTimes(1) }) + it.each([en, zh])('offers an existing bundled plugin without claiming a download or installation', (dict) => { + const name = '@acme/shipped-exploration' + const { actions, setLanguage } = renderTab({ install: { + ...IDLE_INSTALL, open: true, spec: name, phase: 'done', installed: name, installedVersion: '1.0.0', + subject: { status: 'accepted', spec: name, name, kind: 'registry', registry: null, bundle: true, bundled: true, version: '1.0.0' }, + } }) + setLanguage(dict) + expect(screen.getByText(dict.installBundledTitle)).toBeTruthy() + expect(screen.getByText(dict.installBundledDescription)).toBeTruthy() + expect(screen.queryByText(dict.installedTitle)).toBeNull() + expect(screen.queryByRole('button', { name: dict.installDetailsShow })).toBeNull() + fireEvent.click(screen.getByRole('button', { name: dict.installEnableNow })) + expect(actions.enableInstalled).toHaveBeenCalledExactlyOnceWith() + }) + it('asks to allow the scripts a blocked install left pending, retries with them, and says what was allowed', () => { const subject = { spec: 'dsh-x', status: 'accepted', kind: 'registry', name: 'dsh-x', bundle: true, registry: null } as const const { actions, set } = renderTab({ @@ -2156,7 +2225,7 @@ describe('PluginManagerPage', () => { const descriptor = Object.getOwnPropertyDescriptor(Element.prototype, 'scrollIntoView') Object.defineProperty(Element.prototype, 'scrollIntoView', { value: scrollIntoView, configurable: true }) try { - const { actions, set } = renderTab({ packages: [pkg(), pkg({ name: '@deepseek-ai/dsh-experimental-terminal-bundle', official: true })] }) + const { actions, set } = renderTab({ packages: [pkg(), pkg({ name: '@deepseek-ai/dsh-experimental-session-titles-bundle', official: true })] }) // A package the list does not show has nothing to scroll to; the mark still times out. set({ highlight: 'missing' }) expect(scrollIntoView).not.toHaveBeenCalled() @@ -2179,7 +2248,7 @@ describe('PluginManagerPage', () => { it('highlights only the visible list and does not extend the mark when navigating or refreshing', () => { vi.useFakeTimers() - const name = '@deepseek-ai/dsh-experimental-terminal-bundle' + const name = '@deepseek-ai/dsh-experimental-session-titles-bundle' const scrollIntoView = vi.fn() const descriptor = Object.getOwnPropertyDescriptor(Element.prototype, 'scrollIntoView') Object.defineProperty(Element.prototype, 'scrollIntoView', { value: scrollIntoView, configurable: true }) @@ -2345,8 +2414,32 @@ describe('PluginManagerPage', () => { }) }) +it.each([false, true])('shows progress only for the changing bundle and clears it when settled (enabled=%s)', (enabled) => { + const name = 'dsh-better-sidebar' + const { set, setLanguage, actions } = renderTab({ packages: [pkg({ enabled }), pkg({ name: 'dsh-other' })] }) + const toggle = screen.getByRole('switch', { name: `Enable ${name}` }) + const status = within(toggle.parentElement!).getByRole('status') + expect(status.textContent).toBe('') + fireEvent.click(toggle) + expect(actions.setEnabled).toHaveBeenCalledExactlyOnceWith(name, !enabled) + set({ busy: [name] }) + for (const dict of [en, zh]) { + setLanguage(dict) + expect(status.textContent).toBe(dict.applyingChange.replace('{name}', name)) + expect(status.querySelector('[data-state="ongoing"]')).not.toBeNull() + } + expect(toggle).toHaveProperty('disabled', true) + fireEvent.click(toggle) + expect(actions.setEnabled).toHaveBeenCalledOnce() + expect(screen.getByRole('switch', { name: '启用 dsh-other' })).toHaveProperty('disabled', false) + set({ busy: [] }) + expect(within(toggle.parentElement!).getByRole('status')).toBe(status) + expect(status.textContent).toBe('') + expect(toggle).toHaveProperty('disabled', false) +}) + it.each([false, true])('opens activation guidance details and returns to the originating list (experimental: %s)', (fromMore) => { - const name = fromMore ? '@deepseek-ai/dsh-experimental-terminal-bundle' : 'dsh-better-sidebar' + const name = fromMore ? '@deepseek-ai/dsh-experimental-session-titles-bundle' : 'dsh-better-sidebar' const bundle = pkg({ name, official: fromMore }) const { set, actions } = renderTab({ packages: [{ ...bundle, enabled: false }] }, { bundles: new Set([name]) }, { [`plugins.bundle.activation:${name}`]: (_view, owner) => , @@ -2527,6 +2620,21 @@ describe('on-demand Official bundles', () => { }) }) +it('keeps bundle configuration mounted while its operation is pending', () => { + const name = 'dsh-better-sidebar' + const { set } = renderTab({ packages: [pkg()] }, { bundles: new Set([name]) }, { + [`plugins.bundle.config:${name}`]: () =>
Configuration
, + }) + fireEvent.click(screen.getByRole('button', { name: en.openDetail.replace('{name}', name) })) + const panel = screen.getByTestId('bundle-config') + expect(panel.textContent).toBe('Configuration') + set({ busy: [name] }) + expect(screen.getByTestId('bundle-config')).toBe(panel) + expect(panel.textContent).toBe('Configuration') + set({ busy: [] }) + expect(panel.textContent).toBe('Configuration') +}) + describe('registry versions and updates', () => { const MIRROR = 'https://registry.npmmirror.com/' const updatable = (): PackageView => pkg({ updatable: true, version: '0.16.0', source: 'dsh-better-sidebar@0.16.0' }) diff --git a/packages/client/ui-plugin-manager/tests/detail-store.client.spec.ts b/packages/client/ui-plugin-manager/tests/detail-store.client.spec.ts index b57cee89f6..ea53f6b804 100644 --- a/packages/client/ui-plugin-manager/tests/detail-store.client.spec.ts +++ b/packages/client/ui-plugin-manager/tests/detail-store.client.spec.ts @@ -54,7 +54,7 @@ it('reports failures by reason, reads again on retry, and settles a retried key expect(state('a', '1.0.0', 'en')).toEqual({ status: 'ready', detail: FOUND }) }) -it('forgets every read on reset, drops answers of forgotten reads, and stops publishing after disposal', async () => { +it('invalidates reads on reset, drops superseded answers, and stops publishing after disposal', async () => { const { controller, manager, state } = setup() let answer: (value: Answer) => void = () => {} manager.readBundleDetail.mockImplementationOnce(() => new Promise((resolve) => { answer = resolve })) @@ -80,7 +80,7 @@ it('forgets every read on reset, drops answers of forgotten reads, and stops pub controller.dispose() answer({ ok: true, value: FOUND }) await flush() - expect(state('a', '1.0.0', 'en')).toEqual({ status: 'loading' }) + expect(state('a', '1.0.0', 'en')).toEqual({ status: 'ready', detail: { status: 'none' } }) }) it('asks the registry comparison only for bundles read from npm, and reads them and their images from npmmirror first where it won', async () => { @@ -123,3 +123,22 @@ it('addresses relative images through the Host route and leaves other destinatio expect(controller.image('x', 'README.md', destination, false)).toBeUndefined() } }) + + +it('retains a displayed document during invalidation and replaces it after one fresh read', async () => { + const { controller, manager, state } = setup() + controller.load('a', '1.0.0', 'en', false) + await vi.waitFor(() => { expect(state('a', '1.0.0', 'en')).toEqual({ status: 'ready', detail: FOUND }) }) + const response = Promise.withResolvers>() + onTestFinished(() => { response.resolve({ ok: true, value: FOUND }) }) + manager.readBundleDetail.mockImplementationOnce(() => response.promise) + controller.reset() + expect(state('a', '1.0.0', 'en')).toEqual({ status: 'ready', detail: FOUND }) + controller.load('a', '1.0.0', 'en', false) + controller.load('a', '1.0.0', 'en', false) + expect(manager.readBundleDetail).toHaveBeenCalledTimes(2) + expect(state('a', '1.0.0', 'en')).toEqual({ status: 'ready', detail: FOUND }) + const replacement = { ...FOUND, markdown: '# Updated' } + response.resolve({ ok: true, value: replacement }) + await vi.waitFor(() => { expect(state('a', '1.0.0', 'en')).toEqual({ status: 'ready', detail: replacement }) }) +}) 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 6b8f2e2968..299cf77791 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 @@ -702,6 +702,81 @@ describe('PluginManagerController', () => { await vi.waitFor(() => { expect(state().install.inputError).toEqual({ problem: 'unknown', reason: 'offline' }) }) }) + it('offers a default-hidden shipped bundle by name, permits cancellation, and enables without installing', async () => { + let enabled = false + const shipped: BundleInfo = { ...BUNDLE, name: '@acme/shipped-exploration', installed: false, official: true, + availability: 'installation', optional: true, removable: false, listedByDefault: false } + const { plugins, face, state, controller, track } = bench({ + listBundles: vi.fn(() => Promise.resolve(ok([{ ...shipped, enabled }]))), + setBundleEnabled: vi.fn(() => { enabled = true; return Promise.resolve(ok(APPLIED)) }), + }) + await controller.load() + expect(state().packages[0]?.listedByDefault).toBe(false) + face.openInstall() + face.editInstallSpec(shipped.name) + face.runInstall() + expect(state().install).toMatchObject({ phase: 'done', installed: shipped.name, subject: { bundled: true }, runs: [] }) + expect(plugins.setBundleEnabled).not.toHaveBeenCalled() + face.closeInstall() + expect(enabled).toBe(false) + expect(track).toHaveBeenCalledWith('install_plugin_result', expect.objectContaining({ error_reason: 'user_cancelled' })) + face.openInstall() + face.editInstallSpec(shipped.name) + face.runInstall() + face.enableInstalled() + face.enableInstalled() + await vi.waitFor(() => { expect(state().install.open).toBe(false) }) + expect(plugins.setBundleEnabled).toHaveBeenCalledExactlyOnceWith(shipped.name, true) + expect(state().packages[0]?.enabled).toBe(true) + expect(state().highlight).toBe(shipped.name) + expect(plugins.inspect).not.toHaveBeenCalled() + expect(plugins.installBundle).not.toHaveBeenCalled() + expect(track).toHaveBeenCalledWith('install_plugin_result', expect.objectContaining({ is_success: true, plugin_name: shipped.name })) + // The same name cannot start another activation after it is enabled. + face.openInstall() + face.editInstallSpec(shipped.name) + face.runInstall() + expect(state().install.inputError?.problem).toBe('shipped') + }) + + it('reports a refused bundled activation without downloading a replacement', async () => { + const shipped: BundleInfo = { ...BUNDLE, name: '@acme/shipped-exploration', installed: false, official: true, + availability: 'installation', optional: true, removable: false, listedByDefault: false } + const { plugins, face, state, controller } = bench({ + listBundles: vi.fn(() => Promise.resolve(ok([shipped]))), + setBundleEnabled: vi.fn(() => Promise.resolve(ok(failed({ code: 'operation-error', diagnostic: 'activation refused' })))), + }) + await controller.load() + face.openInstall() + face.editInstallSpec(shipped.name) + face.runInstall() + face.enableInstalled() + await vi.waitFor(() => { expect(state().install.open).toBe(false) }) + expect(state().notice).toMatchObject({ kind: 'failed', action: 'enable', packageName: shipped.name, reason: 'activation refused' }) + expect(state().packages[0]?.enabled).toBe(false) + expect(plugins.installBundle).not.toHaveBeenCalled() + }) + + it('accepts a bundled package without optional version or description and reports a lost activation reply', async () => { + const shipped: BundleInfo = { name: '@acme/shipped-exploration', installed: false, official: true, enabled: false, + availability: 'installation', optional: true, removable: false, updatable: false, listedByDefault: false, rows: [], overrides: [] } + const { plugins, face, state, controller } = bench({ + listBundles: vi.fn(() => Promise.resolve(ok([shipped]))), + setBundleEnabled: vi.fn(() => Promise.resolve(refused('gateway/internal', 'offline'))), + }) + await controller.load() + face.openInstall() + face.editInstallSpec(shipped.name) + face.runInstall() + expect(state().install.installedVersion).toBeNull() + expect(state().install.subject).not.toHaveProperty('version') + expect(state().install.subject).not.toHaveProperty('description') + face.enableInstalled() + await vi.waitFor(() => { expect(state().install.open).toBe(false) }) + expect(state().notice).toMatchObject({ kind: 'failed', action: 'enable', reason: 'offline' }) + expect(plugins.installBundle).not.toHaveBeenCalled() + }) + it('leaves the check or the failed screen for the spec at once', async () => { const inspectGate = deferred>>() const { plugins, face, state, controller } = bench({ @@ -2204,3 +2279,33 @@ describe('version checks and updates', () => { expect(state().update?.phase).toBe('running') }) }) + + +it.each(['bundle', 'row'] as const)('publishes %s activity before dispatch and settles it without a mounted page', async (kind) => { + const f = bench() + await f.controller.load() + const activity = f.controller.observeChanges(BUNDLE.name) + const other = f.controller.observeChanges('another-bundle') + const response = Promise.withResolvers>>() + onTestFinished(() => { response.resolve(ok(APPLIED)) }) + const remote = kind === 'bundle' ? f.plugins.setBundleEnabled : f.plugins.setPluginEnabled + remote.mockImplementationOnce(() => { + expect(activity.getSnapshot()).toBe(true) + expect(other.getSnapshot()).toBe(false) + return response.promise + }) + const observations: boolean[] = [] + const unsubscribe = activity.subscribe(() => { observations.push(activity.getSnapshot()) }) + onTestFinished(unsubscribe) + expect(activity.getSnapshot()).toBe(false) + if (kind === 'bundle') f.face.setEnabled(BUNDLE.name, true) + else f.face.setRowEnabled(ROW_ENTRY, false) + expect(observations).toContain(true) + response.resolve(ok(APPLIED)) + await vi.waitFor(() => { expect(activity.getSnapshot()).toBe(false) }) + expect(observations.at(-1)).toBe(false) + unsubscribe() + const count = observations.length + await f.controller.load() + expect(observations).toHaveLength(count) +}) diff --git a/packages/client/ui-plugin-market/locale/en.json b/packages/client/ui-plugin-market/locale/en.json new file mode 100644 index 0000000000..2c72ab4cec --- /dev/null +++ b/packages/client/ui-plugin-market/locale/en.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "Plugin discovery", + "description": "Browse configured plugin market indexes and install their packages." + } +} diff --git a/packages/client/ui-plugin-market/locale/ja.json b/packages/client/ui-plugin-market/locale/ja.json new file mode 100644 index 0000000000..7411851543 --- /dev/null +++ b/packages/client/ui-plugin-market/locale/ja.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "プラグイン検索 UI", + "description": "設定済みのプラグインマーケットのインデックスを参照し、プラグインパッケージをインストールします。" + } +} diff --git a/packages/client/ui-plugin-market/locale/zh.json b/packages/client/ui-plugin-market/locale/zh.json new file mode 100644 index 0000000000..4e9e89725f --- /dev/null +++ b/packages/client/ui-plugin-market/locale/zh.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "插件发现界面", + "description": "浏览已配置的插件市场索引,并安装其中的插件包。" + } +} diff --git a/packages/client/ui-plugin-market/package.json b/packages/client/ui-plugin-market/package.json index 383f71c19f..30883a8c70 100644 --- a/packages/client/ui-plugin-market/package.json +++ b/packages/client/ui-plugin-market/package.json @@ -23,6 +23,7 @@ "default": "./lib/client.js" }, "./src/*": "./src/*", + "./locale/*.json": "./locale/*.json", "./package.json": "./package.json" }, "dsh": { @@ -63,6 +64,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-plugin-market/src/client/index.ts b/packages/client/ui-plugin-market/src/client/index.ts index 9d92d5da7a..c1aab2c746 100644 --- a/packages/client/ui-plugin-market/src/client/index.ts +++ b/packages/client/ui-plugin-market/src/client/index.ts @@ -12,7 +12,7 @@ import type {} from '@deepseek-ai/dsh-plugin-manager/types' import type {} from '@deepseek-ai/dsh-plugin-market/types' import type {} from '@deepseek-ai/dsh-client-ui-plugin-manager/client' import { DiscoverSection } from './DiscoverSection.tsx' -import { en, zh, type PluginMarketLocaleKey } from './locales.ts' +import { en, ja, zh, type PluginMarketLocaleKey } from './locales.ts' import { DiscoverController } from './market-store.ts' export type { DiscoverFace, DiscoverState } from './market-store.ts' @@ -36,7 +36,7 @@ export const inject = ['slots', 'locale', 'remote', 'remote.pluginMarket', 'remo * @param ctx - the browser plugin context. */ export function apply(ctx: ClientContext): void { - ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'ui-plugin-market: dictionaries') + ctx.effect(() => ctx.locale.register(NS, { zh, en, ja }), 'ui-plugin-market: dictionaries') const controller = new DiscoverController(ctx, () => document.baseURI) ctx.effect(() => () => { controller.dispose() }, 'ui-plugin-market: controller') ctx.effect(() => { diff --git a/packages/client/ui-plugin-market/src/client/locales.ts b/packages/client/ui-plugin-market/src/client/locales.ts index 924ff10b43..20aada6cae 100644 --- a/packages/client/ui-plugin-market/src/client/locales.ts +++ b/packages/client/ui-plugin-market/src/client/locales.ts @@ -184,3 +184,94 @@ export const en = { detailFailed: 'Could not read the details.', footnotes: 'Footnotes', } satisfies Record + +/** Japanese copy, checked against the English keys. */ +export const ja = { + 'title': '見つける', + 'refresh': '更新', + 'loading': 'インデックスを読み込み中', + 'retry': '再試行', + 'close': '閉じる', + 'cancel': 'キャンセル', + 'emptyTitle': 'プラグインマーケットはまだありません', + 'emptyDescription': 'プラグインマーケットのインデックスを追加すると、ここでプラグインを閲覧してインストールできます。', + 'addFirstSource': 'インデックスソースを追加', + 'sourcesFailed': 'インデックスソースを読み取れませんでした。', + 'manageSources': 'インデックスソースを管理…', + 'marketMenu': 'プラグインマーケットを切り替え、現在は{market}', + 'sourcesTitle': 'インデックスソース', + 'sourcesDescription': 'プラグインマーケットは1つのインデックスファイルのURLとして公開されます。「見つける」ではここに一覧されたインデックスを閲覧します。', + 'sourcesLabel': '追加済みのインデックスソース', + 'sourceUrl': 'インデックスURL', + 'sourcePlaceholder': 'https://example.com/index.json', + 'addSource': '追加', + 'removeSource': '削除', + 'removeSourceNamed': '{url}を削除', + 'sourcesTrust': 'インデックスは第三者提供です。信頼できるソースのみ追加してください。インストールされたプラグインはあなたの権限で実行されます。', + 'sourceInvalid': 'ユーザー名、パスワード、#フラグメントを含まないhttpまたはhttpsのアドレスを使用してください。', + 'sourceDuplicate': 'このインデックスソースはすでに追加されています。', + 'sourceUnavailable': 'ここではインデックスソースを保存できません。', + 'sourceFailed': 'インデックスソースを保存できませんでした。', + 'readFailed': 'このインデックスを読み取れませんでした。', + 'readInvalid': 'このインデックスは有効な形式ではありません。', + 'readChanged': '読み込み中にこのインデックスが変更されました。', + 'search': 'プラグインを検索', + 'filter': 'ラベル', + 'filterCount': 'ラベル · {count}', + 'sortBy': '並び替え: {sort}', + 'sortRelevance': '一致度順', + 'sortDefault': 'デフォルト順', + 'sortPublished': '公開日', + 'switchToAscending': '昇順で並び替え', + 'switchToDescending': '降順で並び替え', + 'categoriesLabel': 'カテゴリ', + 'allCategories': 'すべて', + 'emptyIndex': 'このインデックスにはプラグインがありません。', + 'noMatches': '一致するプラグインがありません。', + 'showMore': 'さらに表示', + 'entryMeta': '{version} · {date}', + 'entryMetaAuthor': '{version} · {author} · {date}', + 'openEntry': '{name}を表示', + 'install': 'インストール', + 'installTitle': '{name}をインストール', + 'installVersionTitle': '{name} {version}をインストール', + 'openInstalled': 'インストール済みで{name}を表示', + 'statusInstalled': 'インストール済み', + 'statusUpdate': 'アップデートあり', + 'statusProvided': 'DSH が提供', + 'incompatible': 'このバージョンは実行中の DSH {runtime} と互換性がありません。DSH {range} が必要です。', + 'noCompatibleVersion': 'DSH {runtime} と互換性のあるバージョンはありません', + 'exempted': 'このバージョンは実行中の DSH と互換性がありませんが、互換性の例外が設定されています。', + 'backToList': '見つける', + 'factSource': 'ソース', + 'factPackage': 'パッケージ', + 'factRegistry': 'レジストリ', + 'factRepository': 'リポジトリ', + 'factCommit': 'コミット', + 'factDirectory': 'ディレクトリ', + 'factPublished': '公開日', + 'factAuthor': '作者', + 'factHomepage': 'ホームページ', + 'factSourceCode': 'ソースコード', + 'factCategory': 'カテゴリ', + 'factLabels': 'ラベル', + 'factMarket': 'プラグインマーケット', + 'versionsTitle': 'バージョン', + 'versionsLabel': '{name} のバージョン', + 'versionDefault': '推奨', + 'versionInstalled': 'インストール済み', + 'versionIncompatible': '非互換', + 'versionExempted': '例外適用', + 'versionPrerelease': 'プレリリース', + 'confirmTitle': '「{name}」をインストールしますか?', + 'installTrust': 'プラグインはこのマシン上であなたの権限で実行されます。提供元が信頼できることを確認してください。', + 'detailTitle': '詳細', + 'detailLoading': '詳細を読み込み中', + 'detailTooLarge': '詳細またはそのリポジトリがサイズ上限を超えたため、読み込まれませんでした。', + 'detailNetwork': '詳細の取得元に接続できませんでした。', + 'detailDenied': '詳細のリポジトリにアクセスできませんでした。Git の認証情報を確認してください。', + 'detailGitUnavailable': '詳細は Git で読み取る必要があるリポジトリにありますが、Git を実行できませんでした。Git をインストールしてから再試行してください。', + 'detailInvalid': 'プラグインが無効な詳細ファイルを宣言しています。', + 'detailFailed': '詳細を読み取れませんでした。', + 'footnotes': '脚注', +} satisfies Record diff --git a/packages/client/ui-primitives/README.i18n.yaml b/packages/client/ui-primitives/README.i18n.yaml index 8a8c0a9597..7bf264780c 100644 --- a/packages/client/ui-primitives/README.i18n.yaml +++ b/packages/client/ui-primitives/README.i18n.yaml @@ -18,14 +18,14 @@ en: 42bec27f0eea32c4 zh: d487314310fe8c5a /deepseek-ai-dsh-client-ui-primitives/use-this-package/component-catalog: - en: cb874a0ca53a6747 - zh: 31179193dd1d6e12 + en: cabadfb698a577e0 + zh: 35c55a4416911315 /deepseek-ai-dsh-client-ui-primitives/use-this-package/controls-and-icons: - en: fdf588ccc43e86f9 - zh: d060a87368cb22a3 + en: a72b8c4e5a1220be + zh: abc8e740548b4cb6 /deepseek-ai-dsh-client-ui-primitives/use-this-package/rendering-agent-output: - en: 3a31ad8de6dd0af8 - zh: 6b22881a929c33df + en: 02f310acbae29955 + zh: 1e41393d5d714b2d /deepseek-ai-dsh-client-ui-primitives/use-this-package/localizing-copy: en: 4aef65dea11de083 zh: abc22cd7c65cdf38 @@ -45,8 +45,8 @@ en: 45f4cd1ef96bb6d2 zh: f264d25b780ad1dc /deepseek-ai-dsh-client-ui-primitives/further-exploration: - en: e82b713cdb2f954b - zh: a877f34b148f433f + en: abbbdbc9a7540743 + zh: cf5bbeaedb3696d0 /deepseek-ai-dsh-client-ui-primitives/model-experience: en: 3120df665299c634 zh: 1d9f365d091d0d2a @@ -54,8 +54,8 @@ en: ca75c51c89c2c9b0 zh: de54f3467ef3b9be /deepseek-ai-dsh-client-ui-primitives/known-limitations-and-deferred-work: - en: 22fc14aa3a224552 - zh: aa6309e84ad8c2a3 + en: 31117d1844eb3c19 + zh: 741eea7d78e00d3f /deepseek-ai-dsh-client-ui-primitives/known-limitations-and-deferred-work/dev-note: en: e67f633dc6fe7773 zh: 3bb0c20c93f16f98 diff --git a/packages/client/ui-primitives/README.md b/packages/client/ui-primitives/README.md index fd87adfb0e..55de41eee4 100644 --- a/packages/client/ui-primitives/README.md +++ b/packages/client/ui-primitives/README.md @@ -55,11 +55,14 @@ Check this table before writing a control in a feature package. A plugin cannot | `StateDot` | Solid green `done`, amber `warning`, red `error`, and neutral-grey `idle` marks in a 10px slot, plus a tertiary-grey 14px rotating `ongoing` loader whose animations pin to document time zero on mount and on every animation restart, such as a list reorder, so every visible loader rotates in phase. `aria-hidden`, so the render site owns the name. `appearance="step"` shows a filled check for completion and a hollow pending circle. | | `ConnectionIndicator` | Inline connection-recovery control across outage, retry, and recovered states. | | `DisclosureRow` | 24px compact disclosure that lays title and content side by side. The header supplies tertiary at rest and secondary on hover; text and icons inherit it regardless of expandability or open state unless the caller supplies a feature-specific override. Semantic status colors remain explicit. Collapsed rows preview a down chevron on hover, and expanded rows keep an up chevron visible. `headerAccessory` places an independent action outside the disclosure target and shimmer; with an accessory, the title row shrinks to its content width. Memoized with shallow prop comparison; keep callbacks and React-node props stable when their content is unchanged. | +| `AnimatedRows` | Keyed list movement with 200 ms glides and 100 ms entry/exit fades. Starts after local pointer or keyboard input, honors reduced motion, and settles loading or view replacement. Mark rows with `data-row-key`, supply matching `rowKeys`, and position the parent for the inert exit overlay; `role` and `label` belong to the caller. `listRef`, `onKeyDown`, and `onFocusCapture` let callers attach navigation to the same list element. | +| `useRowDragPreview` | Native drag preview using an inert offscreen row copy. Supply a class for the surface background and, for a child drag handle, pass the row to `start`; `clear` and unmount remove the copy. | | `TextShimmer` | One left-to-right highlight across a row’s title, separators, summary, and suffix, with a 300ms initial delay, a one-second sweep and a 500ms rest. The mask spans the content width, capped by the visible row and tilts 15° from vertical, with a flat peak and soft edges. The moving decoration stays clipped to the row without enlarging its scrollable area. Base text inherits the caller’s color, including hover changes; the theme supplies the translucent highlight. Place icons and chevrons outside `TextShimmer` so only text and separators receive the highlight. A string child uses the same retained text interface; nested `TextShimmer` instances share the outer animation. Composite children render twice while active and must have no effects or element ids. Mark separators with `data-shimmer-decoration` so their fill receives the highlight. `DisclosureRow` forwards `contentClassName` for the text area and `contentLayoutClassName` for its inner layout. The inert decorative copy is excluded from interaction, selection, and accessibility; text updates retain the animation, and reduced-motion mode keeps the static base. | | `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 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`, `HoverCardGroup` | Readable, optionally copyable hover preview; a physical list can opt cards into shared first-display dwell and exclusive display. | +| `DraggableImage` | Original-image gestures with explicit transfer and feedback callbacks. | | `ImageLightbox` | Shared image modal with focus restoration and Escape dismissal. | | `Toast` | Transient top-center banner held for the owner's `holdMs`. | | `SettingsForm`, `SettingsValueField`, `SettingsSecretField` | The frame and the controls of a plugin's settings page: the frame takes its copy as `labels`, saves only on its button, and discards on unmount; a value field shows staged text with the overridden badge and reset; a secret field starts blank, requests no saved-password autofill, and reports only whether a value is configured. | @@ -67,10 +70,12 @@ Check this table before writing a control in a feature package. A plugin cannot | `JsonTree`, `JsonBlock` | Read-only JSON inspection. | | `MarkdownText`, `MarkdownDelegateProvider`, `CodeBlock` | Untrusted GFM with TeX math, owner-delegated HTTP(S) navigation, and highlighted code. `CodeBlock` accepts opt-in `lineNumbers`; copied source excludes the gutter, and `contentRef` exposes its stable source wrapper to an owner that uses it as a scrollport. Set `showHeader={false}` when the owner supplies its own language and copy toolbar. | | `DetailDocument`, `detailDocumentState`, `useDetailDocumentLabels` | Section without a heading of its own for a long Markdown description, such as a plugin's detail document: three skeleton lines while loading, the document, the owner's failure message with a retry, or nothing when there is no document. A document taller than `min(560px, 60vh)` scrolls inside a keyboard-focusable region that the owner's title names. Raw HTML in the document is omitted. Relative links stay text and only images the owner's `images` resolver accepts, or absolute HTTP(S) images, load. `detailDocumentState` maps an owner's read to the shown state, and `useDetailDocumentLabels` builds reference-stable labels from translated strings. | +| `useClipboardCopy` | Copies the current text with one-second success feedback, prevents overlapping or success-window repeats, and cancels its reset timer on unmount; late write completion cannot update an unmounted control. | +| `UserMessageBubble` | User-message background, radius, content inset, typography, and plain-text wrapping. Accepts native div props; callers own row alignment, width, content, and actions. | | `UserTextRun`, `ReferenceChip` | Uninterpreted inline text and business-resolved reference labels or activation buttons. | | `CommandText` | Selectable command or argument text; only horizontal overflow adds a named keyboard focus target. | | `TerminalBlock`, `ReadBlock`, `DiffBlock`, `SearchBlock`, `WebBlock` | The agent-output card matching each tool-result intent. | -| `icons/*`, `FishLogo`, `BrandWordmark`, `ReferenceIconRegular`/`ReferenceIconMedium`, `LinkIconRegular`/`LinkIconMedium` | Glyphs and brand marks. The thinking glyph keeps an inset orbit within its 16px view box. `IconAppsAddOutlineRegular`/`Medium`, the More launcher glyph, take `compact` to draw their tiles closer together at card size. Use `LinkIconMedium` for 14px clickable-link categories and known-site marks. | +| `icons/*`, `FishLogo`, `BrandWordmark`, `ReferenceIconRegular`/`ReferenceIconMedium`, `LinkIconRegular`/`LinkIconMedium` | Glyphs and brand marks. `IconDragHandleOutlineRegular`/`Medium` draw a three-line grip for reordering. The thinking glyph keeps an inset orbit within its 16px view box. `IconAppsAddOutlineRegular`/`Medium`, the More launcher glyph, take `compact` to draw their tiles closer together at card size. Use `LinkIconMedium` for 14px clickable-link categories and known-site marks. | | `PermissionIconReadOnlyRegular`/`Medium`, `PermissionIconWorkspaceWriteRegular`/`Medium`, `PermissionIconFullAccessRegular`/`Medium`, `PermissionIconAutoReviewRegular`/`Medium` | Permission-mode glyphs for read-only, workspace-write, full-access, and auto-review choices. | | `PluginArtworkTerminal`/`Loop`/`Subagent`/`Search`/`Default` | Fixed-palette 36×36 plugin artwork; `Terminal` supplies the light-blue prompt in plugin cards and sidebar guide entries. `Default` marks plugins without artwork of their own. Def ids are per-instance, so the same artwork repeats safely on one page. | | `GuideArtworkBrowser`/`Files` | Fixed-palette 36×36 browser and folder artwork for sidebar guide entries. | @@ -94,17 +99,19 @@ The catalog above lists what each export is for; this section covers the behavio -`HoverCardGroup` owns private state for one mounted physical list. A `HoverCard` with `grouped` shares the first actually displayed card's dwell: subsequent cards replace the current card immediately during the same pointer interaction. The list and its active portaled card form one pointer interaction: the pointer may enter the card to read or copy, then return to another list item for immediate replacement. After the pointer leaves both, the shared dwell resets when the active card closes through its existing pointer-grace and close lifecycle; the group adds no separate timer. Leaving a list with no active card resets the dwell immediately. Opening an inline card by keyboard focus while the pointer is outside the list does not warm the next pointer visit. Exclusive display includes fading previews: ordinary preview dismissal retains its fade, but a group replacement removes the previous card immediately. Cards without `grouped` remain independent. +`HoverCardGroup` owns private state for one mounted physical list. A `HoverCard` with `grouped` shares the first actually displayed card's dwell: subsequent cards replace the current card immediately during the same pointer interaction. The list and its active portaled card form one pointer interaction: the pointer may enter the card to read or copy, then return to another list item for immediate replacement. After the pointer leaves both, the shared dwell resets when the active card closes through its existing pointer-grace and close lifecycle; the group adds no separate timer. Leaving a list with no active card resets the dwell immediately. Opening an inline card by keyboard focus while the pointer is outside the list does not warm the next pointer visit. Exclusive display includes fading previews: ordinary preview dismissal retains its fade, but a group replacement removes the previous card immediately. Cards without `grouped` remain independent. `as="span"` preserves an inline text container. An open hover card closes immediately when an ancestor of its anchor becomes inert or hidden. ### Rendering agent output +The pure Markdown helper `formatQuoteText` normalizes CRLF and CR line endings, then prefixes each line with `> `. + The nearest `MarkdownDelegateProvider` supplies optional `openExternalLink` and `openFile` navigation callbacks. Nested providers replace the enclosing capabilities, and callback changes reach already-rendered links without rebuilding Markdown. Its `openFile` makes local Markdown links clickable after settlement. Absolute and workspace-relative paths support percent escapes and `#L24` / `#L24-L30` fragments; ranges open at their first line. Literal `?` and `#` in filenames must be percent-encoded. The tooltip uses the decoded path and supplies the accessible name when the label is empty. The callback receives the decoded path and optional line, while the renderer preserves the label and displays a file icon. Without a callback, local links remain text. URL schemes, queries, unsupported fragments, and malformed destinations never reach the file opener. `MarkdownText` renders untrusted GFM and TeX math, blocks unsafe links and images, and can turn resolved file mentions into explicit controls. Strikethrough requires `~~`; a single `~`, as in `10~20`, stays literal. A surrounding `MarkdownDelegateProvider` receives sanitized HTTP(S) URLs from ordinary clicks; modified clicks and links outside a provider retain native external-anchor behavior. `omitHtml` renders raw HTML as nothing instead of literal text, for documents written for HTML renderers. When the owner passes a `pathImages` vocabulary, image destinations that are local media paths rewrite to displayable URLs on settled renders only (the same streaming gate as file mentions); without a vocabulary, local destinations remain inert alt text. Rewritten images accept HTTP(S), data, blob, and the Desktop `dsh-app://app/api/file`, `dsh-app://app/api/plugin-market/detail-file`, and `dsh-app://app/api/plugin-manager/detail-file` routes; authored Desktop URLs remain inert. A load or decode failure replaces the image with its authored alt text, or the original destination when alt is empty; `fileImages` additionally supplies a localized failure prefix. Changing the image source permits a fresh load. While a reply streams, it freezes completed blocks, advances a top-level open fence by completed lines, and highlights that fence from saved Shiki grammar state. Completed token lines enter fixed-size React groups, so later chunks reconcile only the growing group; an unchanged fence retains that DOM when the final full parse resolves cross-document syntax. `TerminalBlock`, `ReadBlock`, `DiffBlock`, `SearchBlock`, and `WebBlock` render the matching tool-result intent with copy controls, overflow handling, and ANSI processing where applicable. `JsonTree` and `JsonBlock` inspect JSON values read-only, while `UserTextRun` renders uninterpreted text and `ReferenceChip` renders business-resolved labels. An explicit `onActivate` makes a chip a keyboard-accessible button; repeated pointer clicks and an existing text selection do not activate it, while keyboard activation remains available. Labels inherit their host’s wrapping policy. Recognition and JSON transforms belong to [ui-conversation](../ui-conversation/README.md#sent-user-text). -`MarkdownText` defaults to `variant="body"`. Use `variant="compact"` for secondary content: its 13px text and 20px line height follow the content-size setting, all heading levels use the same size with weight 600, and paragraphs and lists use tighter spacing. Text, links, and code keep the tertiary color; dotted underlines distinguish links. Code headers scroll with their blocks. Tables and math stay enabled at the surrounding text size and scroll horizontally within the available width. Both variants share the parser and streaming cache. +`MarkdownText` defaults to `variant="body"`. Use `variant="compact"` for secondary content: its 13px text and 20px line height follow the content-size setting, all heading levels use the same size with weight 600, and paragraphs and lists use tighter spacing. Text, links, and code keep the tertiary color; dotted underlines distinguish links. Code headers scroll with their blocks. Tables and math stay enabled at the surrounding text size and scroll horizontally within the available width. Both variants share the parser and streaming cache. `textInteractionSource` marks its existing root as a source for text selection interactions. `CodeBlock.toolbarLabels` enables the shared code-card header and spacing. `ReadBlock` and `DiffBlock` use the same header: tertiary-colored language and filename text, secondary-colored copy and wrap icons, and hover or keyboard-focus tooltips. Missing or unsupported languages use the owner’s localized code-block title; mixed-language diffs use that title too. Code cards share typography and content insets; their headers and bodies use the same background in both themes. Read gutters fit the largest returned line number, and diff fills cover the full scrollable row. The wrap toggle keeps a fixed accessible name and exposes its state through `aria-pressed`; its tooltip describes the next action. Wrapping is local to each mounted card and never changes copied text; code fences start wrapped, while reads and diffs preserve source columns until wrapping is enabled. Compact Markdown shares this toolbar, spacing, and code typography while retaining monochrome text. With `toolbarLabels`, source previews can pass `wrap` to follow their owner’s preference and show only the language and copy icon; owner CSS retains the source layout. @@ -112,7 +119,9 @@ The nearest `MarkdownDelegateProvider` supplies optional `openExternalLink` and `JsonTree` clamps collapsed strings to `collapsedStringLines` (three by default). Expanded strings show raw text, retain sibling commas, and fit within the window and outer scrolling containers. Resize and ancestor-scroll events update that limit. Row copy feedback updates independently of JSON value rendering; pending clipboard writes cannot update a different row or an unmounted tree. -`ImageLightbox` is the shared original-image modal with focus restoration and Escape dismissal. Like `Modal`, its mask leaves the Windows caption unpainted, and its close control sits 20px below the caption. The internal thumbnail renderer receives loading/failure labels from its owner. `HoverCard.inline` keeps a file link in the text flow and uses the shared menu material, keyboard-visible focus, and placement above or below the anchor without covering it. Escape closes an open thumbnail even when focus is elsewhere; subsequent Escape presses reach the owner. `MarkdownDelegateProvider.fileImages` supplies a decoded-path resolver and complete image labels: settled image links gain hover previews, and standalone images gain lightbox activation. Image-only anchors retain their single navigation target. The parser recovers only complete, unescaped, standalone local image references with bare spaces and an unambiguous supported image suffix; code and ambiguous destinations remain literal. +`ImageLightbox` is the shared original-image modal with focus restoration and Escape dismissal. Like `Modal`, its mask leaves the Windows caption unpainted, and its close control sits 20px below the caption. The internal thumbnail renderer receives loading/failure labels from its owner. `HoverCard.inline` keeps a file link in the text flow and uses the shared menu material, keyboard-visible focus, and placement above or below the anchor without covering it. Its transparent hover region connects the anchor to the panel across the visual gap, so crossing the gap does not activate content beneath it. Escape closes an open thumbnail even when focus is elsewhere; subsequent Escape presses reach the owner. `MarkdownDelegateProvider.fileImages` supplies a decoded-path resolver and complete image labels: settled image links gain hover previews, and standalone images gain lightbox activation. Image-only anchors retain their single navigation target. The parser recovers only complete, unescaped, standalone local image references with bare spaces and an unambiguous supported image suffix; code and ambiguous destinations remain literal. + +`DraggableImage` receives explicit `fileDrag: FileDragActions`, also supplied through `MarkdownDelegateProvider.fileImages.fileDrag`. The [file-drag plugin](../ui-file-drag/README.md) owns preparation, transfer, and localized failure feedback; the image primitive manages gestures and cancellation. Resource-backed images prepare on primary pointer down; other sources also prepare on hover. Releasing inside the image preserves preparation for the next gesture; pointer leave releases unused preparation. Markdown export names use the decoded path basename. Panning images skip preparation. Starting before preparation finishes cancels that gesture quietly; an actual preparation failure reports a retry message. Managed Web transfers carry an application-local token and a `DownloadURL` pointing to a plugin-owned Blob URL, with no `text/plain` payload. ### Localizing copy @@ -188,6 +197,8 @@ These pages place the atoms in the client stack and the design system. ----- +`QuotePreview` shares the compact quote excerpt and clipped-only inline HoverCard between composers and sent messages. The popover sizes to its text up to 300px and stays within the viewport. Both surfaces display the exact text; editable drafts may supply a localized whole-quote removal action. Quotes opt into the nearest `HoverCardGroup` for exclusive preview display. The primitive performs no recognition or navigation. + ## Model Experience @@ -204,6 +215,7 @@ None; this package neither assembles nor sends a provider request. These limits define how the atoms behave at the edges; they are current package constraints, not a component roadmap. +- **Browser image transfers** — external targets depend on Chromium `DownloadURL` and the receiving application; native file delivery is provided by Desktop. Web tokens do not transfer between windows or tabs. Unreadable cross-origin images retain browser-native dragging, as do linked Markdown images and Markdown without `fileImages`; those native paths may include URLs. - **Diff search is bounded, input processing is linear** — the edit-distance limit trades precise alignment for a coarse replacement on heavily changed fragments. Normalization, fallback rows, and copied output still scale with input size; the height cap limits visible rows, not those allocations. - **Known-site marks are a fixed list** — only the named hosts resolve to their own mark, and every other external host keeps the globe; recognizing an arbitrary site would require fetching its icon over the network. - **Streaming defers cross-boundary reference resolution** — a reference-style link or footnote whose definition sits on the other side of the incremental freeze boundary renders as literal text while the reply streams; the settled full parse at finalize resolves it. @@ -213,6 +225,8 @@ These limits define how the atoms behave at the edges; they are current package - **`Pill` and `Input` have no design source** — both atoms are self-defined; the sidebar search field and view-tab strip that resemble them are consumer-owned compositions, not these atoms. - **No `Active` `StateDot` variant** — the supported states are done, warning, ongoing, error, and idle. - **User-facing copy is required at the render site** — the atoms are zero-Cordis and cannot reach `ctx.locale`; each feature must supply complete localized labels through the primitive's typed props ([decision](../../../.agents/notes/implemented/architecture/2026-08-23-locale-owned-client-ui-copy.md)). +- **Display math keeps fallback shapes** — a `$$x$$ trailing` on the opening line resolves through upstream inline math and leaves the surrounding prose in place, while the same trailing text on a later line declines the block and upstream takes the prose up to the next lone `$$` or EOF; a block left open ends at the next lone `$$` or at EOF; a later fence behind a `%` comment that holds text closes nothing, so a block nothing else closes reaches the same fallback; a multi-line block whose opening line carries a solitary `$` stays in its paragraph unless a blank line separates them; and a container continuation line that leaves the item indentation hands the block to upstream, which drops the opening line. `$$` fence meta is no longer honored ([decision](../../../.agents/notes/implemented/bug-fix/2026-09-17-multiline-display-dollar-math.md)). +- **A fence after a `\verb` delimiter is content** — the tokenizer reads the six characters before a fence, so the `$` right after `\verb` or `\verb*` is that command's delimiter rather than a closing fence, and a region whose text ends in `\verb` (`\verb**\verb*$$`) reads the same way. A fence inside an unterminated region (`\verb|$$`) still closes the block there, because the region's content is not parsed. - **`TerminalBlock` is not a terminal emulator** — it renders settled or still-running command output, not an interactive session: SGR colors, carriage return, backspace, erase-in-line, tab stops, and character width are honored; absolute cursor positioning, screen clearing, and alternate-screen sequences are stripped. diff --git a/packages/client/ui-primitives/README.zh.md b/packages/client/ui-primitives/README.zh.md index 78ac340615..b172fa0156 100644 --- a/packages/client/ui-primitives/README.zh.md +++ b/packages/client/ui-primitives/README.zh.md @@ -55,11 +55,14 @@ kind: "package-library" | `StateDot` | 10px 槽内的绿色 `done`、琥珀色 `warning`、红色 `error`、中性灰色 `idle` 圆点,以及 tertiary 灰色 14px 旋转 `ongoing` loading,其动画在挂载时以及每次动画重启(如列表重排)时固定到文档时间零点,所以所有可见 loading 同相旋转。它是 `aria-hidden` 的,名称由渲染点提供。 `appearance="step"` 以实心勾表示完成、空心圆表示等待。 | | `ConnectionIndicator` | 行内连接恢复控件,覆盖断线、重试与已恢复三种状态。 | | `DisclosureRow` | 24px 紧凑折叠行,标题与内容左右排列。标题行默认使用 tertiary,悬停时使用 secondary;文字与图标继承该颜色,不受能否展开及开合状态影响,调用方可提供功能专属覆盖。语义状态色保持显式指定。收起时悬停预览向下箭头,展开后持续显示向上箭头。`headerAccessory` 将独立操作放在折叠触发区域和流光之外;有此操作时,标题行收窄到内容宽度。使用浅层 prop 比较进行 memo;内容未变时,保持回调与 React 节点 prop 的引用稳定。 | +| `AnimatedRows` | 带键列表的位移动画:移动为 200 ms,进出淡变为 100 ms。列表内发生指针或键盘输入后才启用,遵循减少动态效果设置,加载或替换视图时直接完成。为行标记 `data-row-key` 并传入对应的 `rowKeys`,为父容器设置定位以承载不可交互的退出层;`role` 和 `label` 由调用方提供。调用方可通过 `listRef`、`onKeyDown` 和 `onFocusCapture` 为同一列表元素添加导航行为。 | +| `useRowDragPreview` | 使用不可交互的离屏行副本生成原生拖拽预览。传入设置表面底色的类名;拖拽来自子级手柄时,将行元素传给 `start`;`clear` 和卸载会移除副本。 | | `TextShimmer` | 同一道高光从左到右扫过一行的标题、分隔符、摘要与后缀:首次等待 300ms,扫动一秒,再静止 500ms。遮罩覆盖内容宽度,并限制在可见行宽内,相对垂直方向倾斜 15°,峰值区域平坦,两侧柔和渐隐。移动的装饰层始终裁剪在行内,不会扩大可滚动区域。底色继承调用方的颜色,包括悬停变化;主题提供半透明高光。图标和箭头放在 `TextShimmer` 外,只有文字和分隔符参与扫光。字符串子节点沿用稳定的文字接口;嵌套的 `TextShimmer` 共用外层动画。活动期间组合子节点渲染两次,因此不得带副作用或元素 id。分隔符标记 `data-shimmer-decoration`,让填色参与扫光。`DisclosureRow` 将 `contentClassName` 传给文字区域,将 `contentLayoutClassName` 传给内部布局。惰性装饰副本不参与交互、选区和无障碍访问;文字更新保留动画,减少动态效果模式保留静态底色。 | | `Modal` | 页面遮罩之上的居中对话框。嵌套对话框可通过 `onKeyDownCapture` 在文档级 Escape 处理器之前拦截按键。 色层与弹窗淡入,背景模糊始终完整生效,并遵循减少动态效果偏好。调用方已模糊源页面时设置 `backdropBlur={false}`。 | | `RiskConfirmation` | 以显式复选框把关的敏感操作确认。 | | `Tooltip` | 锚定在克隆子元素上的悬停与键盘聚焦提示;可选的 `delayMs` 控制悬停延迟,`focusDelayMs` 控制键盘聚焦延迟,均默认为 0 毫秒。可通过 `portal` 渲染到外层,避免被容器裁剪,或受祖先层叠上下文限制其 z-index。 | | `HoverCard`、`HoverCardGroup` | 可阅读、可选带复制按钮的悬停预览;物理列表可选择让卡片共享首次显示等待,并互斥显示。 | +| `DraggableImage` | 通过显式传递与反馈回调提供原图拖放。 | | `ImageLightbox` | 共享图片浮层,支持焦点恢复与 Esc 关闭。 | | `Toast` | 顶部居中的瞬时横幅,保持时长由所有者的 `holdMs` 决定。 | | `SettingsForm`、`SettingsValueField`、`SettingsSecretField` | 插件设置页的框架与控件:框架以 `labels` 接收文案,只在按钮点击时保存,卸载即丢弃;值字段显示暂存文本以及已覆盖标签和重置;密文字段每次为空,请求浏览器不要自动填入已保存的密码,只报告是否已配置。 | @@ -67,10 +70,12 @@ kind: "package-library" | `JsonTree`、`JsonBlock` | 只读 JSON 查看。 | | `MarkdownText`、`MarkdownDelegateProvider`、`CodeBlock` | 不可信 GFM 与 TeX 数学、owner 委托的 HTTP(S) 导航,以及高亮代码。`CodeBlock` 可通过 `lineNumbers` 开启行号;复制的源码不含行号栏,`contentRef` 则向需要把稳定源码包装节点用作滚动区的 owner 提供该节点。调用方提供自己的语言与复制工具栏时,设置 `showHeader={false}`。 | | `DetailDocument`、`detailDocumentState`、`useDetailDocumentLabels` | 用于较长 Markdown 描述(例如插件的详细介绍文档)的区块,本身不带标题:加载期间显示三行骨架屏,随后显示文档、owner 提供的失败消息和重试,或在没有文档时不显示任何内容。文档高度超过 `min(560px, 60vh)` 时,在可通过键盘聚焦、由 owner 的标题命名的区域内滚动。文档中的原始 HTML 会被省略。相对链接保持为文本,只加载 owner 的 `images` 解析器接受的图片或绝对 HTTP(S) 地址的图片。`detailDocumentState` 把 owner 的读取结果映射为显示状态,`useDetailDocumentLabels` 根据翻译后的字符串构建引用稳定的标签。 | +| `useClipboardCopy` | 复制当前文本并显示一秒成功反馈,阻止写入期间及成功反馈期间的重复操作,卸载时清除重置计时器;迟到的写入结果不会更新已卸载控件。 | +| `UserMessageBubble` | 用户消息的背景、圆角、内容内边距、字体与纯文本换行规则。接收原生 div props;行对齐、宽度、内容和操作由调用方拥有。 | | `UserTextRun`、`ReferenceChip` | 不做解析的行内文本,以及业务已解析的引用标签或激活按钮。 | | `CommandText` | 可选中的命令或参数文本;仅横向溢出时添加具名的键盘聚焦目标。 | | `TerminalBlock`、`ReadBlock`、`DiffBlock`、`SearchBlock`、`WebBlock` | 与各类工具结果意图对应的 agent 输出卡片。 | -| `icons/*`、`FishLogo`、`BrandWordmark`、`ReferenceIconRegular`/`ReferenceIconMedium`、`LinkIconRegular`/`LinkIconMedium` | 字形与品牌标识。思考图标的轨道在 16px 视口内保留内边距。“更多”入口使用的 `IconAppsAddOutlineRegular`/`Medium` 接受 `compact`,在卡片尺寸下让方块更靠近。`LinkIconMedium` 用于 14px 的可点击链接分类及已知站点标记。 | +| `icons/*`、`FishLogo`、`BrandWordmark`、`ReferenceIconRegular`/`ReferenceIconMedium`、`LinkIconRegular`/`LinkIconMedium` | 字形与品牌标识。`IconDragHandleOutlineRegular`/`Medium` 以三条横线表示可拖动的排序手柄。思考图标的轨道在 16px 视口内保留内边距。“更多”入口使用的 `IconAppsAddOutlineRegular`/`Medium` 接受 `compact`,在卡片尺寸下让方块更靠近。`LinkIconMedium` 用于 14px 的可点击链接分类及已知站点标记。 | | `PermissionIconReadOnlyRegular`/`Medium`、`PermissionIconWorkspaceWriteRegular`/`Medium`、`PermissionIconFullAccessRegular`/`Medium`、`PermissionIconAutoReviewRegular`/`Medium` | 只读、工作区写入、完全访问与自动审查选项使用的权限模式图形。 | | `PluginArtworkTerminal`/`Loop`/`Subagent`/`Search`/`Default` | 固定配色的 36×36 插件插画;`Terminal` 为插件卡片和侧边栏开始页入口提供浅蓝色提示符。`Default` 用于没有自有插画的插件。def id 按实例生成,同一插画可在一页中安全重复。 | | `GuideArtworkBrowser`/`Files` | 固定配色的 36×36 浏览器与文件夹插画,用于侧栏引导入口。 | @@ -94,17 +99,19 @@ kind: "package-library" -`HoverCardGroup` 为一个已挂载的物理列表持有私有状态。设置 `grouped` 的 `HoverCard` 共享首张实际显示卡片的等待:在同一次指针交互中,后续卡片立即替换当前卡片。列表与其当前活动的 portal 卡片构成一次指针交互:指针可进入卡片阅读或复制,再返回其他列表项并立即替换卡片。指针离开两者后,共享等待在活动卡片通过原有指针宽限及关闭生命周期关闭时重置;分组不另设计时器。列表没有活动卡片时,指针离开便立即重置等待。指针位于列表外时,通过键盘聚焦打开行内卡片不会让下一次指针进入跳过等待。互斥显示也包括正在淡出的预览:普通预览关闭保留淡出,但分组内替换会立即移除前一张卡片。未设置 `grouped` 的卡片保持独立。 +`HoverCardGroup` 为一个已挂载的物理列表持有私有状态。设置 `grouped` 的 `HoverCard` 共享首张实际显示卡片的等待:在同一次指针交互中,后续卡片立即替换当前卡片。列表与其当前活动的 portal 卡片构成一次指针交互:指针可进入卡片阅读或复制,再返回其他列表项并立即替换卡片。指针离开两者后,共享等待在活动卡片通过原有指针宽限及关闭生命周期关闭时重置;分组不另设计时器。列表没有活动卡片时,指针离开便立即重置等待。指针位于列表外时,通过键盘聚焦打开行内卡片不会让下一次指针进入跳过等待。互斥显示也包括正在淡出的预览:普通预览关闭保留淡出,但分组内替换会立即移除前一张卡片。未设置 `grouped` 的卡片保持独立。`as="span"` 保留行内文本容器。 锚点的祖先变为 inert 或隐藏时,已打开的悬浮卡片立即关闭。 ### 渲染 agent 输出 +纯 Markdown 工具 `formatQuoteText` 规范化 CRLF 和 CR 换行符,再为每行添加 `> ` 前缀。 + 最近的 `MarkdownDelegateProvider` 提供可选的 `openExternalLink` 和 `openFile` 导航回调。嵌套 Provider 替换外层能力,回调变化无需重新构建 Markdown 即可到达已渲染链接。其 `openFile` 使本地 Markdown 链接在落定后可点击。绝对路径和工作区相对路径支持百分号转义以及 `#L24` / `#L24-L30` 片段;范围定位到起始行。文件名中的字面 `?` 和 `#` 必须百分号编码。悬停提示使用解码后的路径,并在标签为空时提供可访问名称。回调接收解码后的路径和可选行号,渲染器保留标签并显示文件图标。不传回调时,本地链接仍为文本。URL 协议、查询串、不支持的片段及格式错误的目标不会传给文件打开器。 `MarkdownText` 渲染不可信的 GFM 与 TeX 公式、阻止不安全的链接与图片,并可把已解析的文件提及转换为显式控件。删除线需要 `~~`;单个 `~`(如 `10~20`)保持字面文本。外层 `MarkdownDelegateProvider` 会接收普通点击产生的已净化 HTTP(S) URL;带修饰键的点击和 Provider 外的链接保留原生外部 anchor 行为。`omitHtml` 不渲染原始 HTML,而不是将其显示为原样文本,用于为 HTML 渲染器编写的文档。当 owner 传入 `pathImages` 词表时,本地媒体路径的图片目标只在落定渲染阶段重写为可展示 URL(与 file mentions 相同的流式门);不传词表时本地目标保持惰性 alt 文本。重写后的图片支持 HTTP(S)、data、blob 及桌面端 `dsh-app://app/api/file`、`dsh-app://app/api/plugin-market/detail-file` 与 `dsh-app://app/api/plugin-manager/detail-file` 路由;正文直接书写的桌面端 URL 仍保持惰性。加载或解码失败后,图片替换为作者的 alt 文本;alt 为空时显示原始目标路径;`fileImages` 还提供本地化的失败提示前缀。图片源变化后可重新加载。回复流式输出时,它冻结已完成的块、按已完成行推进顶层未闭合 fence,并从保存的 Shiki grammar state 为该 fence 增量高亮。已完成的 token 行进入固定大小的 React 分组,后续分片只 reconcile 正在增长的分组;最终全量解析解决跨文档语法时,未变化的 fence 会保留该 DOM。`TerminalBlock`、`ReadBlock`、`DiffBlock`、`SearchBlock` 与 `WebBlock` 把对应的工具结果意图渲染为带复制控件、溢出处理及适用时 ANSI 处理的卡片。`JsonTree` 与 `JsonBlock` 以只读方式检查 JSON 值;`UserTextRun` 渲染不做解析的文本,`ReferenceChip` 渲染业务已解析的标签。显式传入 `onActivate` 后,胶囊成为可由键盘操作的按钮;重复指针点击和已有文本选区不触发激活,键盘激活仍然可用。标签继承所属容器的换行规则。识别和 JSON 变换由 [ui-conversation](../ui-conversation/README.zh.md#sent-user-text) 负责。 -`MarkdownText` 默认为 `variant="body"`。次级内容使用 `variant="compact"`:其 13px 字号与 20px 行高跟随内容字号设置,各级标题保持同一字号并使用 600 字重,段落与列表采用更紧凑的间距。正文、链接和代码均保持 tertiary 颜色,以点状下划线区分链接。代码标题栏随代码块滚动。表格和公式仍然启用,使用周围文字的字号,并在可用宽度内横向滚动。两个变体共享解析器与流式缓存。 +`MarkdownText` 默认为 `variant="body"`。次级内容使用 `variant="compact"`:其 13px 字号与 20px 行高跟随内容字号设置,各级标题保持同一字号并使用 600 字重,段落与列表采用更紧凑的间距。正文、链接和代码均保持 tertiary 颜色,以点状下划线区分链接。代码标题栏随代码块滚动。表格和公式仍然启用,使用周围文字的字号,并在可用宽度内横向滚动。两个变体共享解析器与流式缓存。 `textInteractionSource` 将已有根元素标记为文本选区交互的来源。 `CodeBlock.toolbarLabels` 启用共享代码卡片的标题栏和间距。`ReadBlock` 与 `DiffBlock` 使用相同的标题栏:tertiary 色的语言名称与文件名、secondary 色的复制与换行图标,以及悬停或键盘聚焦时的提示。语言缺失或不受支持时显示调用方提供的本地化代码块标题;混合语言的 Diff 也使用该标题。代码卡片共用字体和内容内边距;在两种主题下,标题栏和正文都使用相同背景。读取卡片的行号栏容纳返回窗口中的最大行号,Diff 底色覆盖完整可滚动行。换行按钮保留固定的无障碍名称,以 `aria-pressed` 表达状态;提示文本说明下一步操作。换行设置仅属于当前挂载的卡片,不改变复制的文本;代码围栏默认换行,读取和差异卡片则在启用换行前保留源码列位置。紧凑 Markdown 共用此工具栏、间距和代码字体排版,同时保留单色文本。源码预览传入 `toolbarLabels` 后,可用 `wrap` 跟随所属视图的换行设置,仅显示语言和复制图标;所属视图的 CSS 保留源码布局。 @@ -112,7 +119,9 @@ kind: "package-library" `JsonTree` 把折叠字符串限制为 `collapsedStringLines` 行(默认三行)。展开后显示原始文本、保留同级逗号,并限制在窗口与外层滚动容器内;尺寸变化和祖先滚动事件会更新此限制。行复制反馈独立于 JSON 值渲染更新;尚未完成的剪贴板写入不会更新另一行或已卸载的树。 -`ImageLightbox` 是共享原图浮层,支持焦点恢复与 Esc 关闭。与 `Modal` 相同,其遮罩不覆盖 Windows 顶栏,关闭按钮位于顶栏下方 20px。包内缩略图渲染器由调用方提供加载及失败文案。`HoverCard.inline` 使文件链接保持在文本流内,并使用共享菜单材质、键盘可见焦点,并在锚点上方或下方定位而不遮挡锚点。即使焦点位于其他位置,Esc 也会关闭已打开的缩略图;后续 Esc 按键继续传给 owner。`MarkdownDelegateProvider.fileImages` 提供已解码路径解析器与完整图片文案:消息落定后的图片链接支持悬停预览,独立图片支持点击放大。仅包含图片的链接保留单一导航目标。解析器仅恢复完整、未转义、独立成段且带明确图片扩展名的含裸空格本地图片引用;代码与有歧义的目标保持原文。 +`ImageLightbox` 是共享原图浮层,支持焦点恢复与 Esc 关闭。与 `Modal` 相同,其遮罩不覆盖 Windows 顶栏,关闭按钮位于顶栏下方 20px。包内缩略图渲染器由调用方提供加载及失败文案。`HoverCard.inline` 使文件链接保持在文本流内,并使用共享菜单材质、键盘可见焦点,并在锚点上方或下方定位而不遮挡锚点。即使焦点位于其他位置,Esc 也会关闭已打开的缩略图;后续 Esc 按键继续传给 owner。`MarkdownDelegateProvider.fileImages` 提供已解码路径解析器与完整图片文案:消息落定后的图片链接支持悬停预览,独立图片支持点击放大。仅包含图片的链接保留单一导航目标。解析器仅恢复完整、未转义、独立成段且带明确图片扩展名的含裸空格本地图片引用;代码与有歧义的目标保持原文。 透明悬停区域连接锚点与弹层之间的视觉空隙,鼠标经过时不会激活下方内容。 + +`DraggableImage` 接收显式的 `fileDrag: FileDragActions`,受管理的 Markdown 图片通过 `MarkdownDelegateProvider.fileImages.fileDrag` 接收同一组回调。[文件拖放插件](../ui-file-drag/README.zh.md) 负责准备、传递与本地化失败提示;图片原语负责手势和取消。带资源引用的图片仅在主指针按下时准备,其他来源也可在悬停时准备。在图片内松开保留准备供下一次手势使用;指针移开释放未使用的准备,平移模式跳过准备。Markdown 导出名取已解码路径的文件名。在准备完成前起拖会静默取消本次手势;实际准备失败才显示重试提示。受管理的 Web 拖放携带应用内令牌和指向插件所持 Blob URL 的 `DownloadURL`,不设置 `text/plain`。 ### 本地化文案 @@ -188,6 +197,8 @@ kind: "package-library" ----- +`QuotePreview` 在 composer 与已发送消息之间共享紧凑 quote 摘录和仅截断时显示的行内 HoverCard。原文弹层按文本内容确定宽度,最大为 300px,并限制在视口内。两处均展示原文;可编辑草稿可以提供本地化的整条 quote 删除操作。quote 加入最近的 `HoverCardGroup`,使原文弹层互斥显示。该 primitive 不负责识别或导航。 + ## 模型体验 @@ -204,6 +215,7 @@ kind: "package-library" 这些限制说明原子组件在边缘情况下的行为;它们是当前包约束,不是组件路线图。 +- **浏览器图片传递**:外部接收依赖 Chromium 的 `DownloadURL` 与接收应用;原生文件交付由 Desktop 提供。Web 令牌不跨窗口或标签页传递。不可读取的跨源图片保留浏览器原生拖动,链接内的 Markdown 图片及未配置 `fileImages` 的 Markdown 也保留原生行为;这些原生路径可能携带 URL。 - **Diff 搜索有上限,输入处理仍为线性**:编辑距离上限使大量改动的片段采用粗粒度替换,不再精确对齐。规范化、回退行及复制内容仍随输入大小增长;高度限制只约束可见行数,不限制这些分配。 - **已知站点标记是固定列表**:只有列名的主机解析为自己的标记,其余外部主机仍使用地球;要识别任意站点需要通过网络抓取它的图标。 - **流式期间跨边界引用解析被推迟**:定义落在增量冻结边界另一侧的引用式链接或脚注,在回复流式输出期间渲染为字面文本;定稿时的全量解析会将其解析。 @@ -213,6 +225,8 @@ kind: "package-library" - **`Pill` 与 `Input` 没有设计来源**:两个原子组件均自行定义;与其相似的侧边栏搜索字段和视图标签条由消费方组合,不是这些原子组件。 - **`StateDot` 没有 `Active` 变体**:支持的状态为 done、warning、ongoing、error 和 idle。 - **面向用户的文案必须由渲染点提供**:这些原子组件是 zero-Cordis 的,拿不到 `ctx.locale`;各功能必须通过原子组件的带类型 prop 提供完整本地化 label(见[决策](../../../.agents/notes/implemented/architecture/2026-08-23-locale-owned-client-ui-copy.zh.md))。 +- **显示数学保留若干回退形状**:开围栏行上的 `$$x$$ trailing` 走上游行内数学,前后正文保持不变;同样形态出现在后续行时块被放弃,随后由上游把正文吞到下一个独占行 `$$` 或文件结尾;未闭合的块在下一个独占行 `$$` 或文件结尾结束;后续行的围栏若位于**含文本**的 `%` 注释之后则不闭合,若无其他围栏闭合,块走同一条回退;开围栏行含孤立 `$` 的多行块会留在段落内,除非与前文空一行;容器内续行若离开条目缩进,块交由上游 flow 处理并丢弃开围栏行。`$$` 围栏 meta 不再生效(见[决策](../../../.agents/notes/implemented/bug-fix/2026-09-17-multiline-display-dollar-math.zh.md))。 +- **紧跟 `\verb` 分隔符的围栏属于内容**:解析器只读取围栏之前的六个字符,因此紧跟 `\verb` 或 `\verb*` 的 `$` 是该命令的分隔符而不是闭合围栏,内容以 `\verb` 结尾的区域(`\verb**\verb*$$`)同理。未闭合区域内部的围栏(`\verb|$$`)仍会在该处闭合块,因为区域内容不被解析。 - **`TerminalBlock` 不是终端模拟器**:它渲染已结束或仍在运行的命令输出,而不是交互式会话:SGR 颜色、回车、退格、行内擦除、制表位与字符宽度会被遵循;绝对光标定位、清屏与备用屏幕序列会被剥离。 diff --git a/packages/client/ui-primitives/package.json b/packages/client/ui-primitives/package.json index 09dd9df339..73f1c57c1a 100644 --- a/packages/client/ui-primitives/package.json +++ b/packages/client/ui-primitives/package.json @@ -48,7 +48,8 @@ "mdast-util-math": "^3.0.0", "micromark-extension-gfm": "^3.0.0", "micromark-util-sanitize-uri": "^2.0.1", - "simple-icons": "16.31.0" + "simple-icons": "16.31.0", + "@deepseek-ai/dsh-util-file-drag": "workspace:*" }, "files": [ "lib/index.js", diff --git a/packages/client/ui-workspace/src/client/rows/AnimatedRows.module.css b/packages/client/ui-primitives/src/AnimatedRows.module.css similarity index 100% rename from packages/client/ui-workspace/src/client/rows/AnimatedRows.module.css rename to packages/client/ui-primitives/src/AnimatedRows.module.css diff --git a/packages/client/ui-workspace/src/client/rows/AnimatedRows.tsx b/packages/client/ui-primitives/src/AnimatedRows.tsx similarity index 83% rename from packages/client/ui-workspace/src/client/rows/AnimatedRows.tsx rename to packages/client/ui-primitives/src/AnimatedRows.tsx index e8a8935127..d7854f1019 100644 --- a/packages/client/ui-workspace/src/client/rows/AnimatedRows.tsx +++ b/packages/client/ui-primitives/src/AnimatedRows.tsx @@ -1,15 +1,20 @@ -/** React-commit-driven movement and entry/exit fades for the sidebar's keyed rows. */ -import { Component, createRef, type ReactNode } from 'react' +/** React-commit-driven movement and entry/exit fades for keyed rows. */ +import { Component, createRef, type AriaRole, type FocusEventHandler, type KeyboardEventHandler, type ReactNode, type RefObject } from 'react' import css from './AnimatedRows.module.css' -import { TreeRows } from './TreeRows.tsx' const ROW_FADE_MS = 100 const ROW_GLIDE_MS = 200 -interface AnimatedRowsProps { +/** Keyed row content, list semantics, and animation readiness. */ +export interface AnimatedRowsProps { children: ReactNode className: string - label: string + role?: AriaRole + label?: string + /** Optional list ref and handlers for caller-owned keyboard navigation. */ + listRef?: RefObject + onKeyDown?: KeyboardEventHandler + onFocusCapture?: FocusEventHandler /** Unique DOM-order keys matching the rendered data-row-key attributes. */ rowKeys: readonly string[] ready: boolean @@ -39,13 +44,14 @@ function intersects(row: DOMRect, viewport: DOMRect): boolean { } /** - * Animates keyed sidebar rows only when their rendered membership or order changes. + * Animates keyed rows only when their rendered membership or order changes. * Motion starts after the first pointer or keyboard input inside the mounted list. * The parent supplies a positioned container for the inert exit overlay. */ export class AnimatedRows extends Component { private armed = false - private readonly list = createRef() + private readonly ownList = createRef() + private get list(): RefObject { return this.props.listRef ?? this.ownList } private readonly overlay = createRef() private readonly movements = new Map() private readonly exits = new Map() @@ -66,9 +72,7 @@ export class AnimatedRows extends Component { const clone = row.element.cloneNode(true) as HTMLElement clone.removeAttribute('data-row-key') clone.inert = true - clone.style.setProperty( - '--dsh-workspace-indent', getComputedStyle(row.element).getPropertyValue('--dsh-workspace-indent'), - ) + clone.style.paddingInlineStart = getComputedStyle(row.element).paddingInlineStart removed.set(key, { ...row, element: clone }) } return { positions, removed } @@ -168,14 +172,18 @@ export class AnimatedRows extends Component { override render(): ReactNode { return <> - { this.armed = true }} + role={this.props.role} + aria-label={this.props.label} + onKeyDown={this.props.onKeyDown} + onFocusCapture={this.props.onFocusCapture} + onPointerDownCapture={() => { this.armed = true }} + onKeyDownCapture={() => { this.armed = true }} > {this.props.children} - +