diff --git a/.agents/notes/implemented/architecture/2026-06-24-web-capability-seam.i18n.yaml b/.agents/notes/implemented/architecture/2026-06-24-web-capability-seam.i18n.yaml index 3f8444bcfa..d4ad622140 100644 --- a/.agents/notes/implemented/architecture/2026-06-24-web-capability-seam.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-06-24-web-capability-seam.i18n.yaml @@ -9,8 +9,8 @@ en: c8b932b02a4e1c80 zh: 96c466be1a521f3f /agent-note-web-capability-seam-stable-tools-over-multiple-providers/decision: - en: b57eb0f268033ff1 - zh: 4143382a50181414 + en: 2e08d801099cbe18 + zh: 8c5ab162ada9cc41 /agent-note-web-capability-seam-stable-tools-over-multiple-providers/package-topology: en: 5ea3ddfae82df181 zh: 2d05d95e51081696 diff --git a/.agents/notes/implemented/architecture/2026-06-24-web-capability-seam.md b/.agents/notes/implemented/architecture/2026-06-24-web-capability-seam.md index 91e212ad9f..74e88534ee 100644 --- a/.agents/notes/implemented/architecture/2026-06-24-web-capability-seam.md +++ b/.agents/notes/implemented/architecture/2026-06-24-web-capability-seam.md @@ -32,7 +32,7 @@ Search and fetch are separate tools but one web-access seam. `ctx.web` owns prov - A tool is never unregistered merely because its selected provider is missing, misconfigured, missing credentials, ambiguous, or temporarily unavailable. - The provider is resolved at execution time, and a structured `WebError` is returned when the selected capability cannot run. -This keeps the model schema stable without making plugin load order, credential state, or HMR timing part of the model-facing contract. If web search is enabled but no usable search provider exists, `web_search` remains visible and execution fails with a structured `WebError` such as `WEB_PROVIDER_UNAVAILABLE` or `WEB_PROVIDER_CONFIGURED_UNAVAILABLE`. If a provider appears after `dsh-tool-web`, the next execution can use it without changing the schema. If a provider disappears mid-call, execution fails with a structured `WebError` instead of silently choosing another provider or falling through to `UNKNOWN_TOOL`. +This keeps the model schema stable without making plugin load order, credential state, or HMR timing part of the model-facing contract. If web search is enabled but no usable search provider exists, `web_search` remains visible and execution fails with a structured `WebError` such as `WEB_PROVIDER_UNAVAILABLE` or `WEB_PROVIDER_CONFIGURED_UNAVAILABLE`. `dsh-tool-web` narrows this per model request: it [withholds `web_search` while the selected provider reports that the request's search cannot authenticate](2026-09-27-web-search-offered-by-provider-readiness.md). If a provider appears after `dsh-tool-web`, the next execution can use it without changing the schema. If a provider disappears mid-call, execution fails with a structured `WebError` instead of silently choosing another provider or falling through to `UNKNOWN_TOOL`. The seam deliberately exposes no observation surface — no registry-change event and no aggregated capability-status query. Unavailability is a fact a caller observes by executing: `search()`/`fetch()` resolve the provider at call time and throw the structured `WebError` that names what failed. [The observation-surface Agent Note](../../archived/simplification/2026-07-04-drop-unconsumed-web-observation-surface.md) records that judgment: derived-on-call selection and enablement-based registration leave no consumer that needs a change signal or an availability probe distinct from executing and routing the error, and a future provider-status panel reintroduces the smallest signal or query it actually consumes. diff --git a/.agents/notes/implemented/architecture/2026-06-24-web-capability-seam.zh.md b/.agents/notes/implemented/architecture/2026-06-24-web-capability-seam.zh.md index 27b7777f23..ac5f935c92 100644 --- a/.agents/notes/implemented/architecture/2026-06-24-web-capability-seam.zh.md +++ b/.agents/notes/implemented/architecture/2026-06-24-web-capability-seam.zh.md @@ -32,7 +32,7 @@ Web 访问是一个一等能力 seam,遵循[能力 seam Agent Note](2026-06-13 - 工具绝不会仅仅因为其选定的提供方缺失、配置错误、缺少凭证、存在歧义或暂时不可用就被注销。 - 提供方在执行时解析,当选定的能力无法运行时返回结构化的 `WebError`。 -这使模型 schema 保持稳定,而不将插件加载顺序、凭证状态或 HMR(热模块替换)时序纳入面向模型的约定。如果 web 搜索已启用但不存在可用的搜索提供方,`web_search` 仍然可见,执行时以结构化的 `WebError`(如 `WEB_PROVIDER_UNAVAILABLE` 或 `WEB_PROVIDER_CONFIGURED_UNAVAILABLE`)失败。如果某个提供方在 `dsh-tool-web` 之后出现,下一次执行即可使用它而无需更改 schema。如果某个提供方在调用过程中消失,执行以结构化的 `WebError` 失败,而不是静默选择另一个提供方或回退到 `UNKNOWN_TOOL`。 +这使模型 schema 保持稳定,而不将插件加载顺序、凭证状态或 HMR(热模块替换)时序纳入面向模型的约定。如果 web 搜索已启用但不存在可用的搜索提供方,`web_search` 仍然可见,执行时以结构化的 `WebError`(如 `WEB_PROVIDER_UNAVAILABLE` 或 `WEB_PROVIDER_CONFIGURED_UNAVAILABLE`)失败。发行版的 `dsh-tool-web` 按模型请求收窄了这一点:选中的提供方报告该请求的搜索无法鉴权时,它会[撤下 `web_search`](2026-09-27-web-search-offered-by-provider-readiness.zh.md)。如果某个提供方在 `dsh-tool-web` 之后出现,下一次执行即可使用它而无需更改 schema。如果某个提供方在调用过程中消失,执行以结构化的 `WebError` 失败,而不是静默选择另一个提供方或回退到 `UNKNOWN_TOOL`。 该 seam 刻意不暴露任何观察面——没有注册表变更事件,也没有聚合的能力状态查询。不可用性是调用方通过执行观察到的事实:`search()`/`fetch()` 在调用时解析提供方,并抛出命名了失败原因的结构化 `WebError`。[观察面 Agent Note](../../archived/simplification/2026-07-04-drop-unconsumed-web-observation-surface.md) 记录了这一判断:基于调用的派生选择与基于启用的注册使得没有消费方需要变更信号或独立于执行和错误路由的可用性探测;未来的提供方状态面板会重新引入它实际消费的最小信号或查询。 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-09-24-deepseek-account-remote-model-catalog.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-24-deepseek-account-remote-model-catalog.i18n.yaml index 71c61113a6..d8e3f3fcaf 100644 --- a/.agents/notes/implemented/architecture/2026-09-24-deepseek-account-remote-model-catalog.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-09-24-deepseek-account-remote-model-catalog.i18n.yaml @@ -9,8 +9,8 @@ en: 2eeb916eb65c66e7 zh: 7bf5c0abb8bb61e4 /agent-note-deepseek-account-models-come-from-the-account-listing-and-gate-by-client-version/decision: - en: 42e800c4cd3b6ebe - zh: 06fabe5a9681fa27 + en: 0125e33ba966c8ac + zh: bf6af389336999af /agent-note-deepseek-account-models-come-from-the-account-listing-and-gate-by-client-version/alternatives-considered: en: 7c7880fdb0cc95f6 zh: 2b8a539192626789 diff --git a/.agents/notes/implemented/architecture/2026-09-24-deepseek-account-remote-model-catalog.md b/.agents/notes/implemented/architecture/2026-09-24-deepseek-account-remote-model-catalog.md index 75265db1ce..867198fba2 100644 --- a/.agents/notes/implemented/architecture/2026-09-24-deepseek-account-remote-model-catalog.md +++ b/.agents/notes/implemented/architecture/2026-09-24-deepseek-account-remote-model-catalog.md @@ -12,8 +12,8 @@ The account route's catalog was deployment configuration: a build advertised the - [`llm-deepseek-account`](../../../../packages/llm/llm-deepseek-account/README.md) loads the listing lazily for each credential, waits for it once up to `modelsTimeoutMs`, refreshes it in the background after `modelsMaxAgeMs`, keeps the last good listing when a load fails or returns no model, and serves the bundled catalog before any listing loads. The cache lives in memory and is keyed by endpoint and token fingerprint, so per-account rollouts stay separate. - A listed model is available when its `client_requirements["deepseek-harness"].min_version` is at most the current DeepSeek Harness version in semantic-version order. Without that declaration, only ids the built-in catalog knows are available; the others stay hidden, because a client cannot tell which future version supports them. A request for a refused listed model fails before network I/O with `CLIENT_UPGRADE_REQUIRED` or `MODEL_UNSUPPORTED`. -- Listed capabilities replace bundled ones field by field; the bundled entry keeps client-side knowledge such as tool-update mode and image budgets; `modelOverrides` applies last and reuses the `models` entry fields and validation. `max_output_tokens` bounds the request default instead of becoming it. -- The shared transport gains only two provider hooks, `connect` and `admit`; listing, gating, and merging stay in the account package. +- Listed capabilities replace bundled ones field by field; the bundled entry keeps client-side knowledge the listing does not declare, such as image budgets and a protocol mode the listing omits or sets to `null`; `modelOverrides` applies last and reuses the `models` entry fields and validation. `max_output_tokens` bounds the request default instead of becoming it. +- The shared transport gains only two provider hooks, `connect` and `admit`. Listing, gating, and merging live in the shared transport, which the [official route](2026-10-22-deepseek-official-model-listing.md) also uses. ## Alternatives considered diff --git a/.agents/notes/implemented/architecture/2026-09-24-deepseek-account-remote-model-catalog.zh.md b/.agents/notes/implemented/architecture/2026-09-24-deepseek-account-remote-model-catalog.zh.md index 7199091c73..37487fbc88 100644 --- a/.agents/notes/implemented/architecture/2026-09-24-deepseek-account-remote-model-catalog.zh.md +++ b/.agents/notes/implemented/architecture/2026-09-24-deepseek-account-remote-model-catalog.zh.md @@ -12,8 +12,8 @@ Status: implemented - [`llm-deepseek-account`](../../../../packages/llm/llm-deepseek-account/README.zh.md) 按凭据懒加载列表,首次访问最多等待 `modelsTimeoutMs`,超过 `modelsMaxAgeMs` 后在后台刷新;获取失败或返回的列表中没有模型时保留上一份,还没有加载到任何列表时使用内置目录。缓存只在内存中,以端点和 token 指纹为键,因此按账号灰度互不干扰。 - 列出的模型在其 `client_requirements["deepseek-harness"].min_version` 按 semver 顺序不高于当前 DeepSeek Harness 版本时可用。没有这个声明时,只有内置目录认识的 id 可用,其余保持隐藏,因为客户端无法判断将来哪个版本会支持它们。请求被拒绝的已列出模型时,在网络请求之前以 `CLIENT_UPGRADE_REQUIRED` 或 `MODEL_UNSUPPORTED` 失败。 -- 列表中的能力逐字段覆盖内置能力;内置条目保留工具更新模式、图片预算等客户端知识;`modelOverrides` 最后叠加,复用 `models` 条目的字段与校验。`max_output_tokens` 只限制请求默认值,不直接成为默认值。 -- 共享传输层只新增 `connect` 与 `admit` 两个提供方钩子;列表、门控与合并都留在账号包中。 +- 列表中的能力逐字段覆盖内置能力;内置条目保留列表没有声明的客户端知识,例如图片预算,以及列表没有给出或设为 `null` 的协议模式;`modelOverrides` 最后叠加,复用 `models` 条目的字段与校验。`max_output_tokens` 只限制请求默认值,不直接成为默认值。 +- 共享传输层只新增 `connect` 与 `admit` 两个提供方钩子。列表、门控与合并位于共享传输包中,[官方路由](2026-10-22-deepseek-official-model-listing.zh.md)也使用它们。 ## 考虑过的替代方案 diff --git a/.agents/notes/implemented/architecture/2026-09-27-web-search-offered-by-provider-readiness.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-27-web-search-offered-by-provider-readiness.i18n.yaml new file mode 100644 index 0000000000..4a2fe2ba9d --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-27-web-search-offered-by-provider-readiness.i18n.yaml @@ -0,0 +1,19 @@ +# Bilingual-pair consistency record for 2026-09-27-web-search-offered-by-provider-readiness.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-09-27-web-search-offered-by-provider-readiness.md +/agent-note-offer-web-search-and-web-fetch-only-while-the-selected-provider-is-ready: + en: 9163af91ea0e030d + zh: 92e44043b999d32f +/agent-note-offer-web-search-and-web-fetch-only-while-the-selected-provider-is-ready/problem: + en: 6fac3c73a4415c83 + zh: f65b61cc1705348b +/agent-note-offer-web-search-and-web-fetch-only-while-the-selected-provider-is-ready/decision: + en: 458416ed7e47f644 + zh: 8b4dd820228e9dfd +/agent-note-offer-web-search-and-web-fetch-only-while-the-selected-provider-is-ready/alternatives-considered: + en: b731254983be656d + zh: 5dee2b09f7faa537 +/agent-note-offer-web-search-and-web-fetch-only-while-the-selected-provider-is-ready/consequences: + en: 798faeaa0b406eac + zh: 97bbfdaebc84b1d4 diff --git a/.agents/notes/implemented/architecture/2026-09-27-web-search-offered-by-provider-readiness.md b/.agents/notes/implemented/architecture/2026-09-27-web-search-offered-by-provider-readiness.md new file mode 100644 index 0000000000..c3e74990d7 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-27-web-search-offered-by-provider-readiness.md @@ -0,0 +1,51 @@ +# Agent Note: Offer web_search and web_fetch only while the selected provider is ready + +Status: implemented + +English | [中文](2026-09-27-web-search-offered-by-provider-readiness.zh.md) + +## Problem + +`dsh-tool-web` registers `web_search` whenever search is enabled, and the [web capability seam decision](2026-06-24-web-capability-seam.md) keeps it visible while the selected provider cannot run. The shipped search provider, `dsh-web-search-deepseek`, authenticates a search with the DeepSeek account token only for a model request on the `deepseek-account` route; every other route needs a configured API key. A Session on an API-key route without a search key was therefore offered a tool whose every call failed with `WEB_PROVIDER_CREDENTIAL_MISSING`. Sessions in one Host can use different routes, a Session can switch routes between requests, and the Web app registers `web_search` once per agent preset, so no registration decision matches every request. The Search and Fetch APIs of `dsh-web-deepseek-api`, which the Web search settings switch selects together, need that credential for both operations, so the same Session was also offered a `web_fetch` whose every call failed. + +## Decision + +Each role of the web capability seam owns one part of a per-step check: + +- **Service Definition.** `WebSearchProvider` and `WebFetchProvider` have an optional `ready(llmProvider)` that answers whether an operation made for a model request on that chat route has a credential to send. It checks configuration only and makes no network calls. `ctx.web.searchReady(llmProvider?)` and `ctx.web.fetchReady(llmProvider?)` resolve the provider with the same rules as `search()` and `fetch()` and return its answer. A provider without `ready()` counts as ready, and a selection failure answers true because the operation reports that failure as a structured `WebError`. +- **Service Provider.** `dsh-web-search-deepseek` answers true when the route is `deepseek-account` and `ctx.deepseekAccount.resolveToken()` returns a token for the configured search endpoint, when the section carries a literal `apiKey`, or when `ctx.credentials.describe()` reports the `apiKeyEnv` reference configured (without that service, when the launching environment carries it). It checks credentials in the order `search()` resolves them, and a failed account-token or credential-store read answers false, because `search()` fails on that read instead of trying the next credential. The Search and Fetch API providers of `dsh-web-deepseek-api` share one credential and answer the same way for their API base; their transport repeats these checks, so neither package depends on the other. The providers name no tool or prompt section. +- **Consumer.** `dsh-tool-web` registers `web_search` and `web_fetch` with [step activation conditions](../../../../packages/core/tools/README.md#conditional-tools) that ask `ctx.web.searchReady()` and `ctx.web.fetchReady()` about the route of the step's model selection. The agent loop resolves conditions before it assembles the step, so the declared tools, the `tool:web_search` and `tool:web_fetch` prompt sections, PTC program bindings, and dispatch all read the same admitted set. + +The route decides only whether the account token is checked before the API key, which happens on the `deepseek-account` route; an API key counts on every route, so a conversation on another provider's model is offered the DeepSeek web tools whenever a DeepSeek key is configured. The providers do not compare the chat route's endpoint with their own: each operation sends the configured key, `DEEPSEEK_API_KEY` by default, to the configured endpoint, even when the official route chats through a gateway. + +Only presence decides: no API key value is read and no endpoint is contacted, so a wrong key or an endpoint that does not serve the native `web_search_20250305` server tool keeps the tool admitted. A step that does not admit a web tool also refuses calls to it as `UNKNOWN_TOOL`, so a model that repeats an earlier call reaches no provider; an operation made outside a model step resolves its credential when it runs and fails with `WEB_PROVIDER_CREDENTIAL_MISSING` when there is none. + +The request header records the assembled tools, so the agent loop announces each change through tool-registry updates. A route switch, a stored key, or a removed key changes the next request; DeepSeek routes load a returning tool as a deferred addition, and routes without tool-update support receive the complete tool list. + +## Alternatives considered + +**Let the DeepSeek provider remove `web_search` in its own assemble listener.** The seam stays unchanged, but the provider then names another package's tool, prompt section, and PTC transport, and it removes the tool even when the deployment selects another search provider, such as Exa, whose searches would succeed. + +**Extend `available()`.** It is synchronous and takes no request, while this check reads an asynchronous credential store and depends on the request's route. A route-dependent `available()` would also make provider selection depend on the conversation. + +**Read the route from the Session's latest `request/context` event instead of passing it.** That event records the previous request, so a new Session's first request and the first request after a model switch would be decided for the wrong route or for none. + +**Register or unregister `web_search` in `dsh-tool-web`.** Registration is shared by every Session of an agent preset, while credentials depend on each request's route. Unregistering would also make `toolOrder` entries and subagent `toolFilter` restrictions that name `web_search` fail. + +**Remove `web_search` from the assembled request.** A `system-prompt/assemble` listener could drop the declaration and its section, but the tool would stay executable, so a model that repeats an earlier call would still run it, and PTC programs would still bind it. Prompt assembly also no longer carries tool declarations. + +**Offer search only on the DeepSeek account route.** Account tokens are issued only for the official endpoint, so a gateway key could never reach DeepSeek. But every shipped profile defaults to the `deepseek-official` route, so search would be off by default, including the official API-key case. + +**Withhold the tools from other providers' conversations unless search has a key of its own.** A search from another model's conversation bills the DeepSeek key, but DeepSeek search is one of the services that key pays for, and a user who configured it expects search to work whatever model chats. The Web search settings page saves its key under `apiKeyEnv`, `DEEPSEEK_API_KEY` by default, which the chat route also reads, so a key entered there could never serve another model; a `routes` setting that opens them would be one more thing a user has to find. + +**Compare the official chat route's endpoint with the search endpoint.** Withholding the tools or the key when the two endpoints differ in origin keeps a gateway's key away from DeepSeek, but the providers would have to find the chat route's configuration themselves. Taking the first active `dsh-llm-deepseek-api-key` Loader row answers for an unrelated instance when a composition isolates several, which disables a correct route or lets a gateway key through; only the route's owner knows which endpoint serves a request, and an endpoint field in `dsh-llm` would reach the browser through the Remote `listProviders()`. The check would also withhold search from users who chat through a proxy or development endpoint with a key the official search endpoint accepts. Without it, a gateway user's search fails at the configured endpoint. + +**Keep the tool offered and fail at execution.** The model spends a call on a search that cannot authenticate, and the failure repeats on every attempt. + +**Probe the key or endpoint before offering the tool.** A network request per model request adds latency and cost; the user controls configuration, not its validity at that moment. + +## Consequences + +`dsh-web` gains public members: the optional `ready()` of both provider interfaces, `WebRuntime.searchReady()`, and `WebRuntime.fetchReady()`. Each readiness method is the smallest query its one consumer needs, which is how the [observation-surface decision](../../archived/simplification/2026-07-04-drop-unconsumed-web-observation-surface.md) lets a status query return; neither reports availability or selection failures. The Exa, Perplexity, and HTTP fetch providers implement no `ready()`, so their behavior is unchanged, and `web_fetch` stays admitted whenever the switch leaves HTTP fetch selected. A step that does not admit a web tool leaves it out of PTC program bindings too. An endpoint that cannot serve the native server tool keeps the tool admitted and fails each call. Removing a tool on an unchanged route changes the declared tools and misses the prompt cache once; a model switch already invalidates it. Searches and fetches from other providers' conversations bill the configured DeepSeek key, and a deployment whose official route chats through a gateway sends the gateway's key to the configured endpoint, the official one by default, where each operation fails unless that endpoint accepts the key. The DeepSeek web packages read no Loader rows and depend on no LLM package. + +Unit tests pin each role: `packages/web/web/tests/web.spec.ts` covers which provider `searchReady()` and `fetchReady()` ask and their answer on selection failure, `packages/web/web-search-deepseek/tests/deepseek.spec.ts` and `packages/web/web-deepseek-api/tests/readiness.spec.ts` cover each credential case of `ready()` on the account route and on other routes, and `packages/web/tool-web/tests/tool-web.spec.ts` resolves step admission of both tools through the real prompt, tool, and executor services, including the `UNKNOWN_TOOL` refusal of a call in a step that withheld the tool. In real compositions, the shipped headless keyless smoke test and the `web-search-unconfigured` recorded-session snapshot, whose search key references are unset, pin the withheld search tool. Snapshot replays supply a placeholder DeepSeek key, so their tool lists do not depend on the runner's environment; with it, the `pi-ai-conversation-updates` conversation on a pi-ai route keeps `web_search`. diff --git a/.agents/notes/implemented/architecture/2026-09-27-web-search-offered-by-provider-readiness.zh.md b/.agents/notes/implemented/architecture/2026-09-27-web-search-offered-by-provider-readiness.zh.md new file mode 100644 index 0000000000..9c4b0f6948 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-27-web-search-offered-by-provider-readiness.zh.md @@ -0,0 +1,51 @@ +# Agent Note: 仅在选中的提供方就绪时提供 web_search 与 web_fetch + +Status: implemented + +[English](2026-09-27-web-search-offered-by-provider-readiness.md) | 中文 + +## Problem + +`dsh-tool-web` 在启用搜索时总会注册 `web_search`,而 [web 能力 seam 决策](2026-06-24-web-capability-seam.zh.md)让它在选中的提供方无法运行时仍然可见。发行版的搜索提供方 `dsh-web-search-deepseek` 只对 `deepseek-account` 路由上的模型请求使用 DeepSeek 账号 token 鉴权;其他路由都需要已配置的 API 密钥。因此,API 密钥路由上没有搜索密钥的会话会获得一个每次调用都以 `WEB_PROVIDER_CREDENTIAL_MISSING` 失败的工具。同一 Host 中的会话可以使用不同路由,一个会话可以在请求之间切换路由,而 Web 应用按 agent preset 只注册一次 `web_search`,因此任何注册层面的决定都无法与每个请求相符。Web 搜索设置开关会同时选中 `dsh-web-deepseek-api` 的 Search 与 Fetch API,两个操作都需要这份凭据,因此同一会话还会获得一个每次调用都失败的 `web_fetch`。 + +## Decision + +web 能力 seam 的每个角色各负责逐步骤检查的一部分: + +- **Service Definition。** `WebSearchProvider` 与 `WebFetchProvider` 有一个可选的 `ready(llmProvider)`,回答为该对话路由上的模型请求发起的操作是否有可发送的凭据。它只检查配置,不发起网络调用。`ctx.web.searchReady(llmProvider?)` 与 `ctx.web.fetchReady(llmProvider?)` 按与 `search()`、`fetch()` 相同的规则解析提供方并返回其回答。没有 `ready()` 的提供方视为就绪;选择失败时回答 true,因为对应操作会把该失败报告为结构化 `WebError`。 +- **Service Provider。** `dsh-web-search-deepseek` 在以下任一情况下回答 true:路由为 `deepseek-account` 且 `ctx.deepseekAccount.resolveToken()` 为配置的搜索端点返回 token;配置段带有字面 `apiKey`;或 `ctx.credentials.describe()` 报告 `apiKeyEnv` 引用已配置(没有该服务时,启动环境中存在该变量)。它按 `search()` 解析凭据的顺序检查凭据,读取账号 token 或凭据存储失败时回答 false,因为 `search()` 会在该次读取上失败,而不会尝试下一种凭据。`dsh-web-deepseek-api` 的 Search 与 Fetch API 提供方共用一份凭据,并对其 API 基础地址以同样方式回答;它们的传输层重复了这些检查,因此两个包互不依赖。提供方不点名任何工具或提示词段落。 +- **Consumer。** `dsh-tool-web` 注册 `web_search` 与 `web_fetch` 时各带一个[步骤激活条件](../../../../packages/core/tools/README.zh.md#conditional-tools),以该步骤模型选择的路由询问 `ctx.web.searchReady()` 与 `ctx.web.fetchReady()`。agent loop 在组装该步骤之前解析各条件,因此声明的工具、`tool:web_search` 与 `tool:web_fetch` 提示词段落、PTC 程序绑定与派发都读取同一个准入集合。 + +路由只决定是否在 API 密钥之前检查账号 token,这只发生在 `deepseek-account` 路由上;API 密钥在任何路由上都算数,因此只要配置了 DeepSeek 密钥,使用其他提供方模型的对话也会获得 DeepSeek Web 工具。提供方不比较聊天路由的地址与自己的端点:即使官方路由经网关聊天,每次操作也把配置的密钥(默认 `DEEPSEEK_API_KEY`)发往配置的端点。 + +只看凭据是否存在:不读取 API 密钥的值,也不访问任何端点,因此错误的密钥,或不提供原生 `web_search_20250305` 服务器工具的端点,仍会让该工具保持准入。未准入某个 Web 工具的步骤也会以 `UNKNOWN_TOOL` 拒绝对它的调用,因此模型重复先前的调用也到达不了任何提供方;在模型步骤之外发起的操作在运行时解析凭据,没有凭据时以 `WEB_PROVIDER_CREDENTIAL_MISSING` 失败。 + +请求头记录组装后的工具,因此 agent loop(智能体循环)通过工具注册表更新宣告每次变化。路由切换、存储密钥或删除密钥都会改变下一个请求;DeepSeek 路由以延迟加载的新增方式载入重新提供的工具,不支持工具更新的路由则接收完整工具列表。 + +## Alternatives considered + +**让 DeepSeek 提供方在自己的 assemble 监听器中移除 `web_search`。** seam 保持不变,但提供方因此要点名另一个包的工具、提示词段落与 PTC 传输工具;而且即使部署选择了另一个搜索成功的提供方(例如 Exa),它也会移除该工具。 + +**扩展 `available()`。** 它是同步的且不接收请求,而这项检查要读取异步凭据存储,并取决于请求的路由。依赖路由的 `available()` 还会让提供方选择取决于对话。 + +**从会话最近的 `request/context` 事件读取路由,而不是传入路由。** 该事件记录的是上一个请求,因此新会话的首个请求,以及模型切换后的首个请求,会按错误的路由判断,或根本没有路由可用。 + +**在 `dsh-tool-web` 中注册或注销 `web_search`。** 注册由同一 agent preset 的所有会话共享,而凭据取决于每个请求的路由。注销还会使点名 `web_search` 的 `toolOrder` 条目和 subagent `toolFilter` 限制失败。 + +**从组装好的请求中移除 `web_search`。** `system-prompt/assemble` 监听器可以删掉该声明及其段落,但工具仍可执行,因此模型重复先前的调用时仍会运行它,PTC 程序也仍能绑定它。提示词组装也已不再携带工具声明。 + +**只在 DeepSeek 账号路由上提供搜索。** 账号 token 只对官方端点签发,因此网关的密钥不可能到达 DeepSeek。但所有发行 profile 的默认路由都是 `deepseek-official`,搜索会因此默认关闭,连官方 API 密钥的情况也一并关闭。 + +**除非搜索有自己的密钥,否则不向其他提供方的对话提供这些工具。** 其他模型对话中的搜索会由 DeepSeek 密钥计费,但 DeepSeek 搜索本就是该密钥付费使用的服务之一,配置了它的用户也期望无论用哪个模型对话,搜索都能用。Web 搜索设置页把密钥存在 `apiKeyEnv` 指向的引用下,默认是聊天路由也在读取的 `DEEPSEEK_API_KEY`,因此在那里输入的密钥永远无法服务其他模型;再加一个开放这些路由的 `routes` 配置项,只会让用户多找一样东西。 + +**比较官方聊天路由的端点与搜索端点。** 两个端点不同源时撤下工具或不发送密钥,能让网关的密钥远离 DeepSeek,但提供方得自己找出聊天路由的配置。取第一条激活的 `dsh-llm-deepseek-api-key` Loader 行时,如果组合隔离了多个实例,回答的可能是无关的实例,从而关掉正确的路由,或放过网关的密钥;只有路由的所有者知道哪个端点服务某个请求,而在 `dsh-llm` 加地址字段会让它经 Remote 方法 `listProviders()` 进入浏览器。这项检查还会让经代理或开发端点对话、而密钥能被官方搜索端点接受的用户失去搜索。不做这项检查时,网关用户的搜索会在配置的端点上失败。 + +**保持工具可见,在执行时失败。** 模型会把一次调用花在无法鉴权的搜索上,并且每次尝试都会重复失败。 + +**提供工具前探测密钥或端点。** 每个模型请求都发起一次网络请求会增加延迟与成本;用户控制的是配置本身,而不是它在那一刻是否有效。 + +## Consequences + +`dsh-web` 增加以下公共成员:两个提供方接口上可选的 `ready()`、`WebRuntime.searchReady()` 与 `WebRuntime.fetchReady()`。每个就绪方法都是其唯一消费方所需的最小查询,这正是[观测接口决策](../../archived/simplification/2026-07-04-drop-unconsumed-web-observation-surface.md)允许状态查询回归的方式;它们既不报告可用性,也不报告选择失败。Exa、Perplexity 与 HTTP 抓取提供方没有实现 `ready()`,其行为不变;只要开关让 HTTP 抓取保持选中,`web_fetch` 就始终准入。未准入某个 Web 工具的步骤,其 PTC 程序绑定中也没有它。无法提供原生服务器工具的端点仍会让工具保持准入,并使每次调用失败。在路由不变时移除工具会改变声明的工具,并使提示词缓存失效一次;模型切换本身已经使缓存失效。其他提供方对话中的搜索与抓取由配置的 DeepSeek 密钥计费;官方路由经网关对话的部署会把网关的密钥发往配置的端点(默认是官方端点),除非该端点接受这个密钥,否则每次操作都会失败。DeepSeek Web 包不读取 Loader 的行,也不依赖任何 LLM 包。 + +单元测试分别固定每个角色:`packages/web/web/tests/web.spec.ts` 覆盖 `searchReady()` 与 `fetchReady()` 询问哪个提供方以及选择失败时的回答,`packages/web/web-search-deepseek/tests/deepseek.spec.ts` 与 `packages/web/web-deepseek-api/tests/readiness.spec.ts` 覆盖 `ready()` 在账号路由与其他路由上的每种凭据情况,`packages/web/tool-web/tests/tool-web.spec.ts` 通过真实的提示词、工具与执行器服务解析两个工具的步骤准入,包括在撤下该工具的步骤中调用它时以 `UNKNOWN_TOOL` 拒绝。在真实组合中,发行版 headless 无密钥冒烟测试与 `web-search-unconfigured` 录制会话快照的搜索密钥引用都未设置,二者固定了搜索工具的撤下行为。快照回放提供占位 DeepSeek 密钥,因此其工具列表不依赖运行环境;借助该密钥,`pi-ai-conversation-updates` 中 pi-ai 路由上的对话保留 `web_search`。 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-14-webworker-preview-profile-parity.i18n.yaml b/.agents/notes/implemented/architecture/2026-10-14-webworker-preview-profile-parity.i18n.yaml index 3ae45a7509..b04f873a24 100644 --- a/.agents/notes/implemented/architecture/2026-10-14-webworker-preview-profile-parity.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-10-14-webworker-preview-profile-parity.i18n.yaml @@ -12,8 +12,8 @@ en: a15c7c13f167e5ac zh: 63bcc2ef60c2f344 /agent-note-boot-the-preview-worker-from-the-shipped-profile-and-fetch-lazy-chunks-on-demand/decision/boot-the-same-profile-as-the-served-web-deployment: - en: caf1ca77192dcb20 - zh: d663e2cbb05fe4fc + en: 9b957e37f290abaf + zh: 06a4599c8fe449ad /agent-note-boot-the-preview-worker-from-the-shipped-profile-and-fetch-lazy-chunks-on-demand/decision/node-internal-module-interfaces-in-the-worker: en: 37d8e809eaa21d25 zh: 8c7c31bd6f45c823 diff --git a/.agents/notes/implemented/architecture/2026-10-14-webworker-preview-profile-parity.md b/.agents/notes/implemented/architecture/2026-10-14-webworker-preview-profile-parity.md index 47b2866402..71509500e5 100644 --- a/.agents/notes/implemented/architecture/2026-10-14-webworker-preview-profile-parity.md +++ b/.agents/notes/implemented/architecture/2026-10-14-webworker-preview-profile-parity.md @@ -23,7 +23,7 @@ Measured: with no bundle enabled, the two compositions differ in only one of 188 The served launch path (apps/cli, app-boot) carries no Preview-specific code; the webworker packages reproduce it: - **packer** - - Package roster = `PROFILE_TEMPLATES[profile].bundles` + `OPTIONAL_BUNDLES` + the rows named in those bundles' patch files; the bundle packages themselves also enter the image (patches, locale, and icon through the publish view). Both tables are read from app-boot, the same source the Plugins page uses. + - Package roster = `PROFILE_TEMPLATES[profile].bundles` + `OPTIONAL_BUNDLES` + the rows named in those bundles' patch files; the bundle packages themselves also enter the image (patches, locale, and icon through the publish view). Both tables are read from `@deepseek-ai/dsh-official-bundles`, the same source the Plugins page uses. - The image carries a manifest-only `node_modules/@deepseek-ai/dsh/package.json` (a verbatim copy of `apps/cli/package.json`) as the installation anchor, and the image manifest names it in `installation`. - The image carries no composed configuration and no `config/cordis.yml`; the image manifest records the `profile` the Worker boots. - The image carries a home patch, `home/cordis.patch.yml`: for each row the runtime's `PLATFORM_ENTRY_CONFIG` names (`session-persistence-jsonl`), the config the template's bundle layers compose with the required fields over it (`compression: none`). A patch replaces a row's whole config, so the composed fields stay; the bundle files read at pack time are the ones in the image. diff --git a/.agents/notes/implemented/architecture/2026-10-14-webworker-preview-profile-parity.zh.md b/.agents/notes/implemented/architecture/2026-10-14-webworker-preview-profile-parity.zh.md index 018992e103..5c9b5bf233 100644 --- a/.agents/notes/implemented/architecture/2026-10-14-webworker-preview-profile-parity.zh.md +++ b/.agents/notes/implemented/architecture/2026-10-14-webworker-preview-profile-parity.zh.md @@ -23,7 +23,7 @@ Status: implemented 正式启动链路(apps/cli、app-boot)不含 Preview 专用代码;由 webworker 包复现这条链路: - **packer** - - 收包清单 = `PROFILE_TEMPLATES[profile].bundles` + `OPTIONAL_BUNDLES` + 这些 bundle 补丁文件里的行;bundle 包本身也打进镜像(补丁、locale、icon 走 publish view)。两张表从 app-boot 读,与插件页同一来源。 + - 收包清单 = `PROFILE_TEMPLATES[profile].bundles` + `OPTIONAL_BUNDLES` + 这些 bundle 补丁文件里的行;bundle 包本身也打进镜像(补丁、locale、icon 走 publish view)。两张表从 `@deepseek-ai/dsh-official-bundles` 读,与插件页同一来源。 - 镜像里放一份只含清单的 `node_modules/@deepseek-ai/dsh/package.json`(原样复制 `apps/cli/package.json`),作为安装锚点,镜像 manifest 在 `installation` 里记录它。 - 镜像不带组合好的配置,也没有 `config/cordis.yml`;镜像 manifest 记录 Worker 要启动的 `profile`。 - 镜像带一份 home patch `home/cordis.patch.yml`:对运行时 `PLATFORM_ENTRY_CONFIG` 列出的每一行(`session-persistence-jsonl`),写入模板各 bundle 层合成出的配置,并在其上设置所需字段(`compression: none`)。补丁会整体替换一行的配置,所以保留合成出的字段;打包时读取的 bundle 文件就是镜像里的那些。 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-21-replaceable-icon-sprite.i18n.yaml b/.agents/notes/implemented/architecture/2026-10-21-replaceable-icon-sprite.i18n.yaml new file mode 100644 index 0000000000..59918ce6a1 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-10-21-replaceable-icon-sprite.i18n.yaml @@ -0,0 +1,19 @@ +# Bilingual-pair consistency record for 2026-10-21-replaceable-icon-sprite.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-replaceable-icon-sprite.md +/agent-note-replaceable-product-icons-through-an-svg-sprite: + en: 2b527e550e071451 + zh: 7de256912270cae0 +/agent-note-replaceable-product-icons-through-an-svg-sprite/problem: + en: d55557fe41ab87c8 + zh: eea3f7d0c82561bf +/agent-note-replaceable-product-icons-through-an-svg-sprite/decision: + en: 6ddd80eae56693dd + zh: 4d654a76eb64c50a +/agent-note-replaceable-product-icons-through-an-svg-sprite/alternatives-considered: + en: 2e25ece88bccdb22 + zh: bb24a28bef7ea7ce +/agent-note-replaceable-product-icons-through-an-svg-sprite/consequences: + en: 0ad6db37bbb76ddb + zh: 0ca09d55a2167970 diff --git a/.agents/notes/implemented/architecture/2026-10-21-replaceable-icon-sprite.md b/.agents/notes/implemented/architecture/2026-10-21-replaceable-icon-sprite.md new file mode 100644 index 0000000000..f4e228ee02 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-10-21-replaceable-icon-sprite.md @@ -0,0 +1,29 @@ +# Agent Note: Replaceable product icons through an SVG sprite + +Status: implemented + +English | [中文](2026-10-21-replaceable-icon-sprite.zh.md) + +## Problem + +Long-term skin customization needs themes to replace icons, not only colors. Product icons were React components that drew inline SVG paths, so a theme registered through `ctx.theme` could recolor them through `currentColor` but could not change their artwork. About 150 Client files import these components. + +## Decision + +Each product glyph weight is one sprite symbol, `dsw-icon--`. ui-primitives keeps the default SVG documents as `ICON_SYMBOLS`; an icon component keeps its export name, `size`, and `className`, and renders an `` box with one `` of its symbol. ui-theme keeps one hidden sprite in the document whose `` children come from the active theme's `ThemeDefinition.icons` replacements or the defaults. Artwork reached through `` inherits `currentColor` and CSS custom properties from the use site, so a replacement can be multi-color, can paint with `--dsw-*` color tokens, and follows light and dark palettes without separate values. + +## Alternatives considered + +- **Icons as CSS image tokens painted through `mask-image`.** Rejected after design review: a mask keeps only the image's alpha, so every icon stays one color, and a `url()` image cannot read CSS variables, so multi-color artwork could not follow color tokens. It would have reused the token override layer without a new theme field. +- **A runtime icon registry service with React components as values.** Rejected: components cannot read services or React context, so every icon import site would need a new prop or slot. +- **A slot per icon.** Rejected for the same caller cost; slots carry composition, not artwork. + +## Consequences + +- Icon DOM contains a `` reference instead of paths; tests identify glyphs through `data-icon` and inspect artwork through `ICON_SYMBOLS`. +- Without ui-theme, the sprite is absent and icons render nothing. +- Every symbol shares the document id space, so `register` requires element ids inside a replacement to start with `-`; `` in another document, such as an iframe, cannot reach the sprite. +- Replacement markup is trusted like the registering plugin's code; `register` rejects `
@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..f6d4f804f1 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -48,6 +48,7 @@ "@deepseek-ai/dsh-jobs-local": "workspace:*", "@deepseek-ai/dsh-launch-environment": "workspace:*", "@deepseek-ai/dsh-mcp-client": "workspace:*", + "@deepseek-ai/dsh-official-bundles": "workspace:*", "@deepseek-ai/dsh-persona": "workspace:*", "@deepseek-ai/dsh-plan-mode": "workspace:*", "@deepseek-ai/dsh-pwsh-local": "workspace:*", @@ -155,6 +156,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/src/plugin.ts b/apps/cli/src/plugin.ts index 86d0cc7525..baad6e32f5 100644 --- a/apps/cli/src/plugin.ts +++ b/apps/cli/src/plugin.ts @@ -1,7 +1,8 @@ /** Profile package management and explicit, exact-version compatibility approvals. */ import { runPluginCommand, runProfilePnpm, setProfileVersionExemption, type PackageOperationOptions } from '@deepseek-ai/dsh-plugin-manager/operations' import { INSTALL_ANCHOR } from './profile-boot.ts' -import { DEFAULT_PROFILE_BUNDLES, initProfile, PROFILE_TEMPLATES, readProfileCompatibility, resolveProfileDir, type ProfileContext } from '@deepseek-ai/dsh-app-boot' +import { initProfile, readProfileCompatibility, resolveProfileDir, type ProfileContext } from '@deepseek-ai/dsh-app-boot' +import { PROFILE_TEMPLATES, DEFAULT_PROFILE_BUNDLES } from '@deepseek-ai/dsh-official-bundles' import { withFileLock } from '@deepseek-ai/dsh-atomic-write' import { existsSync } from 'node:fs' import { mkdir } from 'node:fs/promises' diff --git a/apps/cli/src/profile-boot.ts b/apps/cli/src/profile-boot.ts index d4da86eeb1..da14308bd6 100644 --- a/apps/cli/src/profile-boot.ts +++ b/apps/cli/src/profile-boot.ts @@ -26,12 +26,12 @@ import { reportSkippedBundles, PluginPackages, PROFILE_PATCH_FILENAME, - PROFILE_TEMPLATES, resolveProfileDir, type ProfileContext, type Profile, type RuntimeResolution, } from '@deepseek-ai/dsh-app-boot' +import { PROFILE_TEMPLATES } from '@deepseek-ai/dsh-official-bundles' import { resolveDshHome } from '@deepseek-ai/dsh-home-paths' import { installProxyFromEnvironment } from '@deepseek-ai/dsh-http-proxy' import { DSH_LAUNCH_ENVIRONMENT_KEY, type LaunchEnvironmentSnapshot } from '@deepseek-ai/dsh-launch-environment' diff --git a/apps/cli/tests/built-bin.e2e.ts b/apps/cli/tests/built-bin.e2e.ts index cd8b5aad9c..a97d158697 100644 --- a/apps/cli/tests/built-bin.e2e.ts +++ b/apps/cli/tests/built-bin.e2e.ts @@ -443,6 +443,9 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)', }, SPAWN_TIMEOUT_MS + 30_000) it('serves the SDK protocol with an absolute-path overlay plugin and exits after shutdown', async () => { + // On the official endpoint the route requests its model listing with this key; + // the loopback mock keeps the run offline and records any model call. + const server = await startMockLlmServer({ sequence: ['success'] }) const home = mkdtempSync(join(tmpdir(), 'dsh-built-sdk-')) const pluginPath = join(home, 'plugin #100%.mjs') const marker = join(home, 'plugin-loaded') @@ -465,6 +468,7 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)', DSH_HOME: home, DSH_TELEMETRY_DISABLED: '1', DEEPSEEK_API_KEY: 'built-sdk-profile-no-call', + DEEPSEEK_BASE_URL: server.baseURL, }, extendEnv: false, }) @@ -504,10 +508,12 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)', expect(result.signal, stderr).toBeUndefined() expect(result.exitCode, stderr).toBe(0) expect(stderr).toBe('') + expect(server.requests).toEqual([]) expect(readFileSync(marker, 'utf8')).toBe('loaded') } finally { child.kill('SIGKILL') await child + await server.close() rmSync(home, { recursive: true, force: true }) } }, SPAWN_TIMEOUT_MS + 30_000) @@ -1268,6 +1274,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/github-webhook-real.e2e.ts b/apps/cli/tests/github-webhook-real.e2e.ts index 4c23211d81..0117077442 100644 --- a/apps/cli/tests/github-webhook-real.e2e.ts +++ b/apps/cli/tests/github-webhook-real.e2e.ts @@ -13,7 +13,8 @@ import { setTimeout as delay } from 'node:timers/promises' import { fileURLToPath } from 'node:url' import { describe, expect, it } from 'vitest' import WebSocket from 'ws' -import { initProfile, PROFILE_TEMPLATES } from '@deepseek-ai/dsh-app-boot' +import { initProfile } from '@deepseek-ai/dsh-app-boot' +import { PROFILE_TEMPLATES } from '@deepseek-ai/dsh-official-bundles' const REPO_ROOT = fileURLToPath(new URL('../../..', import.meta.url)) const BUILT_BIN = join(REPO_ROOT, 'apps/cli/lib/bin.js') 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/plugin.spec.ts b/apps/cli/tests/plugin.spec.ts index bb757eb5e0..9185315cb1 100644 --- a/apps/cli/tests/plugin.spec.ts +++ b/apps/cli/tests/plugin.spec.ts @@ -3,7 +3,8 @@ import { existsSync, mkdtempSync, readFileSync, rmSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { expect, it, onTestFinished, vi } from 'vitest' -import { getDshRuntimeVersion, initProfile, PROFILE_TEMPLATES, readProfileVersionExemptions } from '@deepseek-ai/dsh-app-boot' +import { getDshRuntimeVersion, initProfile, readProfileVersionExemptions } from '@deepseek-ai/dsh-app-boot' +import { PROFILE_TEMPLATES } from '@deepseek-ai/dsh-official-bundles' import { runPluginCommand, runProfilePnpm } from '@deepseek-ai/dsh-plugin-manager/operations' import { runPlugin } from '../src/plugin.ts' diff --git a/apps/cli/tests/profile-initialization.spec.ts b/apps/cli/tests/profile-initialization.spec.ts index 7fa3ba6059..6c421c803e 100644 --- a/apps/cli/tests/profile-initialization.spec.ts +++ b/apps/cli/tests/profile-initialization.spec.ts @@ -7,11 +7,11 @@ import { fileURLToPath } from 'node:url' import { initProfile, PROFILE_PATCH_FILENAME, - PROFILE_TEMPLATES, readProfileManifest, resolveProfileDir, writeProfileManifest, } from '@deepseek-ai/dsh-app-boot' +import { PROFILE_TEMPLATES } from '@deepseek-ai/dsh-official-bundles' import { afterEach, describe, expect, it, vi } from 'vitest' import { execa } from 'execa' import { initializeProfileFromDefault } from '../src/profile-boot.ts' diff --git a/apps/cli/tests/profile-mcp.spec.ts b/apps/cli/tests/profile-mcp.spec.ts index 2bd56d7ed3..b082d8316b 100644 --- a/apps/cli/tests/profile-mcp.spec.ts +++ b/apps/cli/tests/profile-mcp.spec.ts @@ -5,7 +5,8 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { fileURLToPath } from 'node:url' import { describe, expect, it } from 'vitest' -import { composeEntries, loadProfile, PROFILE_TEMPLATES } from '@deepseek-ai/dsh-app-boot' +import { composeEntries, loadProfile } from '@deepseek-ai/dsh-app-boot' +import { PROFILE_TEMPLATES } from '@deepseek-ai/dsh-official-bundles' const installAnchor = fileURLToPath(new URL('../package.json', import.meta.url)) const resourcePackage = '@deepseek-ai/dsh-mcp-resources' diff --git a/apps/cli/tests/profiles/headless/tests/expected/goal-tools/stream-json.expected.jsonl b/apps/cli/tests/profiles/headless/tests/expected/goal-tools/stream-json.expected.jsonl index ab06b02706..c54f84df0b 100644 --- a/apps/cli/tests/profiles/headless/tests/expected/goal-tools/stream-json.expected.jsonl +++ b/apps/cli/tests/profiles/headless/tests/expected/goal-tools/stream-json.expected.jsonl @@ -4,7 +4,7 @@ {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/start","seq":6,"time":0,"data":{"turn":1,"step":1}}} {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"system/message","seq":7,"time":0,"data":{"turn":1,"step":1,"message":{"role":"system","content":[{"type":"text","text":"{{system}}"}],"source":{"kind":"system-prompt"},"id":"{{sessionId}}"}},"surfaceOp":"append"}} {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"user/message","seq":8,"time":0,"data":{"content":[{"type":"text","text":"Probe strict-schema fillers against missing-goal revision 1, then create a durable goal to finish the snapshot proof and inspect it."}],"source":{"kind":"user"},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"user/message","seq":9,"time":0,"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: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable.\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: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable."},{"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":"session_event","sessionId":"{{sessionId}}","event":{"type":"user/message","seq":9,"time":0,"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: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable.\n\nApproval prompts are enabled in this session: 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: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable."},{"name":"approval:policy","text":"Approval prompts are enabled in this session: 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":"session_event","sessionId":"{{sessionId}}","event":{"type":"request/header","seq":10,"time":0,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"tools":"{{tools}}"},"reason":"initial"}}} {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"request/context","seq":11,"time":0,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}}} {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"session/title","seq":12,"time":0,"data":{"title":"Probe strict-schema fillers against miss","messageSeqs":[8],"source":{"kind":"fallback"}}}} 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/invalid-credential/stream-json.expected.jsonl b/apps/cli/tests/profiles/headless/tests/expected/invalid-credential/stream-json.expected.jsonl index 6ea0d43a17..e747f996ad 100644 --- a/apps/cli/tests/profiles/headless/tests/expected/invalid-credential/stream-json.expected.jsonl +++ b/apps/cli/tests/profiles/headless/tests/expected/invalid-credential/stream-json.expected.jsonl @@ -4,7 +4,7 @@ {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/start","seq":6,"time":0,"data":{"turn":1,"step":1}}} {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"system/message","seq":7,"time":0,"data":{"turn":1,"step":1,"message":{"role":"system","content":[{"type":"text","text":"{{system}}"}],"source":{"kind":"system-prompt"},"id":"{{sessionId}}"}},"surfaceOp":"append"}} {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"user/message","seq":8,"time":0,"data":{"content":[{"type":"text","text":"say pong"}],"source":{"kind":"user"},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"user/message","seq":9,"time":0,"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: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable.\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: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable."},{"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":"session_event","sessionId":"{{sessionId}}","event":{"type":"user/message","seq":9,"time":0,"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: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable.\n\nApproval prompts are enabled in this session: 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: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable."},{"name":"approval:policy","text":"Approval prompts are enabled in this session: 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":"session_event","sessionId":"{{sessionId}}","event":{"type":"request/header","seq":10,"time":0,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash","maxTokens":256000,"reasoningEffort":"high"},"adapterDefaults":{"reasoningEffort":true,"maxTokens":true},"tools":"{{tools}}"},"reason":"initial"}}} {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"request/context","seq":11,"time":0,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash","contextWindow":1000000}}} {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"session/title","seq":12,"time":0,"data":{"title":"say pong","messageSeqs":[8],"source":{"kind":"fallback"}}}} diff --git a/apps/cli/tests/profiles/headless/tests/expected/missing-credential/stream-json.expected.jsonl b/apps/cli/tests/profiles/headless/tests/expected/missing-credential/stream-json.expected.jsonl index f24d221b96..bc7c728199 100644 --- a/apps/cli/tests/profiles/headless/tests/expected/missing-credential/stream-json.expected.jsonl +++ b/apps/cli/tests/profiles/headless/tests/expected/missing-credential/stream-json.expected.jsonl @@ -4,7 +4,7 @@ {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/start","seq":6,"time":0,"data":{"turn":1,"step":1}}} {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"system/message","seq":7,"time":0,"data":{"turn":1,"step":1,"message":{"role":"system","content":[{"type":"text","text":"{{system}}"}],"source":{"kind":"system-prompt"},"id":"{{sessionId}}"}},"surfaceOp":"append"}} {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"user/message","seq":8,"time":0,"data":{"content":[{"type":"text","text":"say pong"}],"source":{"kind":"user"},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"user/message","seq":9,"time":0,"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: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable.\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: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable."},{"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":"session_event","sessionId":"{{sessionId}}","event":{"type":"user/message","seq":9,"time":0,"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: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable.\n\nApproval prompts are enabled in this session: 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: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable."},{"name":"approval:policy","text":"Approval prompts are enabled in this session: 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":"session_event","sessionId":"{{sessionId}}","event":{"type":"request/header","seq":10,"time":0,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash","maxTokens":256000,"reasoningEffort":"high"},"adapterDefaults":{"reasoningEffort":true,"maxTokens":true},"tools":"{{tools}}"},"reason":"initial"}}} {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"request/context","seq":11,"time":0,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash","contextWindow":1000000}}} {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"session/title","seq":12,"time":0,"data":{"title":"say pong","messageSeqs":[8],"source":{"kind":"fallback"}}}} diff --git a/apps/cli/tests/profiles/headless/tests/expected/provider-retry/stream-json.expected.jsonl b/apps/cli/tests/profiles/headless/tests/expected/provider-retry/stream-json.expected.jsonl index a667224c5f..5792ae3774 100644 --- a/apps/cli/tests/profiles/headless/tests/expected/provider-retry/stream-json.expected.jsonl +++ b/apps/cli/tests/profiles/headless/tests/expected/provider-retry/stream-json.expected.jsonl @@ -4,7 +4,7 @@ {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/start","seq":6,"time":0,"data":{"turn":1,"step":1}}} {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"system/message","seq":7,"time":0,"data":{"turn":1,"step":1,"message":{"role":"system","content":[{"type":"text","text":"{{system}}"}],"source":{"kind":"system-prompt"},"id":"{{sessionId}}"}},"surfaceOp":"append"}} {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"user/message","seq":8,"time":0,"data":{"content":[{"type":"text","text":"retry the transient provider failure"}],"source":{"kind":"user"},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"user/message","seq":9,"time":0,"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: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable.\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: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable."},{"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":"session_event","sessionId":"{{sessionId}}","event":{"type":"user/message","seq":9,"time":0,"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: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable.\n\nApproval prompts are enabled in this session: 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: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable."},{"name":"approval:policy","text":"Approval prompts are enabled in this session: 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":"session_event","sessionId":"{{sessionId}}","event":{"type":"request/header","seq":10,"time":0,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"tools":"{{tools}}"},"reason":"initial"}}} {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"request/context","seq":11,"time":0,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}}} {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"session/title","seq":12,"time":0,"data":{"title":"retry the transient provider failure","messageSeqs":[8],"source":{"kind":"fallback"}}}} diff --git a/apps/cli/tests/profiles/headless/tests/expected/semantic-checkpoint/session.expected.jsonl b/apps/cli/tests/profiles/headless/tests/expected/semantic-checkpoint/session.expected.jsonl index 2afe21d157..11ae2ec827 100644 --- a/apps/cli/tests/profiles/headless/tests/expected/semantic-checkpoint/session.expected.jsonl +++ b/apps/cli/tests/profiles/headless/tests/expected/semantic-checkpoint/session.expected.jsonl @@ -18,7 +18,7 @@ {"type":"step/start","data":{"turn":2,"step":1}} {"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":"Continue safely from the interrupted operation."}],"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: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable.\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: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable."},{"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":"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: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable.\n\nApproval prompts are enabled in this session: 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: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable."},{"name":"approval:policy","text":"Approval prompts are enabled in this session: 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"},"tools":"{{tools}}"},"reason":"initial","startsSeries":true}} {"type":"request/context","data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} {"type":"session/title","data":{"title":"Perform one side-effecting remote mutati","messageSeqs":[3],"source":{"kind":"fallback"}}} diff --git a/apps/cli/tests/profiles/headless/tests/expected/subagent-diagnostic/parent.expected.jsonl b/apps/cli/tests/profiles/headless/tests/expected/subagent-diagnostic/parent.expected.jsonl index 5ea5179b2e..9da1c6379a 100644 --- a/apps/cli/tests/profiles/headless/tests/expected/subagent-diagnostic/parent.expected.jsonl +++ b/apps/cli/tests/profiles/headless/tests/expected/subagent-diagnostic/parent.expected.jsonl @@ -16,7 +16,7 @@ {"type":"step/start","data":{"turn":2,"step":1}} {"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":"Call list_agents once and report what it shows."}],"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: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable.\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: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable."},{"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":"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: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable.\n\nApproval prompts are enabled in this session: 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: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable."},{"name":"approval:policy","text":"Approval prompts are enabled in this session: 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"},"tools":"{{tools}}"},"reason":"initial","startsSeries":true}} {"type":"request/context","data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} {"type":"session/title","data":{"title":"Start a background job.","messageSeqs":[3],"source":{"kind":"fallback"}}} 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..3f3dee9c95 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 @@ -16,9 +16,9 @@ {"type":"step/start","data":{"turn":2,"step":1}} {"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":"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 prompts are enabled in this session: 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 prompts are enabled in this session: 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","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/expected/subagent-settlement/stream-json.expected.jsonl b/apps/cli/tests/profiles/headless/tests/expected/subagent-settlement/stream-json.expected.jsonl index f06a75700f..441cab1aa2 100644 --- a/apps/cli/tests/profiles/headless/tests/expected/subagent-settlement/stream-json.expected.jsonl +++ b/apps/cli/tests/profiles/headless/tests/expected/subagent-settlement/stream-json.expected.jsonl @@ -4,7 +4,7 @@ {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/start","seq":6,"time":0,"data":{"turn":1,"step":1}}} {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"system/message","seq":7,"time":0,"data":{"turn":1,"step":1,"message":{"role":"system","content":[{"type":"text","text":"{{system}}"}],"source":{"kind":"system-prompt"},"id":"{{sessionId}}"}},"surfaceOp":"append"}} {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"user/message","seq":8,"time":0,"data":{"content":[{"type":"text","text":"Start one continuable subagent and acknowledge its completion notice. Do not call list_agents or send_message."}],"source":{"kind":"user"},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"user/message","seq":9,"time":0,"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: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable.\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: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable."},{"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":"session_event","sessionId":"{{sessionId}}","event":{"type":"user/message","seq":9,"time":0,"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: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable.\n\nApproval prompts are enabled in this session: 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: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable."},{"name":"approval:policy","text":"Approval prompts are enabled in this session: 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":"session_event","sessionId":"{{sessionId}}","event":{"type":"request/header","seq":10,"time":0,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"tools":"{{tools}}"},"reason":"initial"}}} {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"request/context","seq":11,"time":0,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}}} {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"session/title","seq":12,"time":0,"data":{"title":"Start one continuable subagent and","messageSeqs":[8],"source":{"kind":"fallback"}}}} diff --git a/apps/cli/tests/profiles/headless/tests/expected/workspace-context-resume/offline-edit/session.expected.jsonl b/apps/cli/tests/profiles/headless/tests/expected/workspace-context-resume/offline-edit/session.expected.jsonl index 5ed8464fb7..cbb82cb603 100644 --- a/apps/cli/tests/profiles/headless/tests/expected/workspace-context-resume/offline-edit/session.expected.jsonl +++ b/apps/cli/tests/profiles/headless/tests/expected/workspace-context-resume/offline-edit/session.expected.jsonl @@ -17,7 +17,7 @@ {"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":"Acknowledge the current workspace instruction."}],"source":{"kind":"user"},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"} {"type":"user/message","data":{"content":[{"type":"text","text":"\nUpdated instructions from: AGENTS.md\n\nThis file changed after it was loaded. Use the following content instead of the previously loaded instructions from this file.\n\nNew workspace instruction after offline edit.\n\n"}],"source":{"kind":"agent-instructions","form":"instructions","changes":[{"action":"replace","scope":".\u0000AGENTS.md","path":"AGENTS.md","digest":"d8375b516f158718bd3463bc8eb7ed42c011b29f"}]},"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: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable.\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: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable."},{"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":"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: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable.\n\nApproval prompts are enabled in this session: 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: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable."},{"name":"approval:policy","text":"Approval prompts are enabled in this session: 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"},"tools":"{{tools}}"},"reason":"initial","startsSeries":true}} {"type":"request/context","data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} {"type":"session/title","data":{"title":"Remember the workspace instruction.","messageSeqs":[3],"source":{"kind":"fallback"}}} diff --git a/apps/cli/tests/profiles/headless/tests/expected/workspace-context-resume/precedence-change/session.expected.jsonl b/apps/cli/tests/profiles/headless/tests/expected/workspace-context-resume/precedence-change/session.expected.jsonl index f52d5f06e9..3bd9c55f95 100644 --- a/apps/cli/tests/profiles/headless/tests/expected/workspace-context-resume/precedence-change/session.expected.jsonl +++ b/apps/cli/tests/profiles/headless/tests/expected/workspace-context-resume/precedence-change/session.expected.jsonl @@ -17,7 +17,7 @@ {"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":"Acknowledge the current workspace instruction."}],"source":{"kind":"user"},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"} {"type":"user/message","data":{"content":[{"type":"text","text":"\nThis complete workspace instruction baseline replaces all earlier workspace instruction baselines. The following workspace instructions may be relevant to your work. Use them as guidance when applicable. More specific instructions take precedence over broader ones. They do not override system, developer, or direct user instructions.\n\nInstructions from: AGENTS.md\n\nCurrent AGENTS rule.\n\n\nInstructions from: CLAUDE.md\n\nCurrent CLAUDE rule.\n\n"}],"source":{"kind":"agent-instructions","form":"instructions","baseline":true,"baselineIdentity":"{\"projectRoot\":\"\",\"projectRootMarkers\":[\".git\"],\"maxBytes\":65536,\"maxSourceBytes\":1048576,\"instructionFileCandidates\":[\"AGENTS.md\",\"CLAUDE.md\"],\"localInstructionFileCandidates\":[\"AGENTS.local.md\",\"CLAUDE.local.md\"]}","changes":[{"action":"set","scope":".\u0000AGENTS.md","path":"AGENTS.md","digest":"7f53d2327837129750aef117f9754a001c46cf68"},{"action":"set","scope":".\u0000CLAUDE.md","path":"CLAUDE.md","digest":"5b1e9e3fd759eee6b43ceff899e47fb10c64701a"}]},"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: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable.\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: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable."},{"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":"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: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable.\n\nApproval prompts are enabled in this session: 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: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable."},{"name":"approval:policy","text":"Approval prompts are enabled in this session: 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"},"tools":"{{tools}}"},"reason":"initial","startsSeries":true}} {"type":"request/context","data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} {"type":"session/title","data":{"title":"Remember the workspace instruction.","messageSeqs":[3],"source":{"kind":"fallback"}}} diff --git a/apps/cli/tests/profiles/headless/tests/fixtures/cli.patch.yml b/apps/cli/tests/profiles/headless/tests/fixtures/cli.patch.yml index d4c74ac347..aa22a2d2f0 100644 --- a/apps/cli/tests/profiles/headless/tests/fixtures/cli.patch.yml +++ b/apps/cli/tests/profiles/headless/tests/fixtures/cli.patch.yml @@ -41,3 +41,9 @@ config: mode: !!js process.env.DSH_PERMISSION_MODE ?? 'workspace-write' workspaceRoot: !!js process.cwd() + +# A search key reference no runner sets: the turn runs as a deployment without +# web search credentials, whatever the launching environment exports. +- id: web-search-deepseek + config: + apiKeyEnv: DSH_KEYLESS_SMOKE_SEARCH_KEY 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/keyless-smoke.e2e.ts b/apps/cli/tests/profiles/headless/tests/keyless-smoke.e2e.ts index 7869b81183..0272ff8752 100644 --- a/apps/cli/tests/profiles/headless/tests/keyless-smoke.e2e.ts +++ b/apps/cli/tests/profiles/headless/tests/keyless-smoke.e2e.ts @@ -60,7 +60,9 @@ describe('headless-agent keyless smoke', () => { }) expect(String(result?.['output'])).toContain('CLI_TOOL_ROUND_TRIP') expect(persistedHeader).toMatchObject({ type: 'session' }) - expect(persistedToolNames).toEqual(expect.arrayContaining(['read', 'write', 'edit', 'web_fetch', 'web_search'])) + expect(persistedToolNames).toEqual(expect.arrayContaining(['read', 'write', 'edit', 'web_fetch'])) + // Search has no key in this composition, so the model request does not offer it. + expect(persistedToolNames).not.toContain('web_search') expect(persistedToolNames).not.toContain('str_replace_editor') }, PRODUCTION_PROFILE_TEST_TIMEOUT_MS) }) 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/headless/tests/subagent-inheritance.expected.e2e.ts b/apps/cli/tests/profiles/headless/tests/subagent-inheritance.expected.e2e.ts index adaf678278..d2f970bc65 100644 --- a/apps/cli/tests/profiles/headless/tests/subagent-inheritance.expected.e2e.ts +++ b/apps/cli/tests/profiles/headless/tests/subagent-inheritance.expected.e2e.ts @@ -11,6 +11,7 @@ import { fixtureContext, normalizeSessionSnapshot, normalizeSessionSnapshots, + REPLAY_CREDENTIAL_ENV, type NormalizeContext, } from '@deepseek-ai/dsh-session-snapshot' import { LOADER_SMOKE_TEST_TIMEOUT_MS, runLoaderSmoke } from '@deepseek-ai/dsh-loader-smoke' @@ -104,6 +105,7 @@ describe('parent-only override inheritance snapshot', () => { binArgs: [configPath, task], tsconfigPath, env: { + ...REPLAY_CREDENTIAL_ENV, // The primary fixture path must exist for llm-replay's config guard; // the override sidecar fully replaces the derived parent script. DSH_SNAPSHOT_FILE: replayOverride, 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/cli/tsconfig.json b/apps/cli/tsconfig.json index 1bcdb5bb1a..c52e4793e2 100644 --- a/apps/cli/tsconfig.json +++ b/apps/cli/tsconfig.json @@ -20,6 +20,9 @@ { "path": "../../packages/boot/app-boot" }, + { + "path": "../../packages/util/official-bundles" + }, { "path": "../../packages/boot/cmdline" }, 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..01b1154394 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: 160b500cdcd6a39a + zh: 6c267f1762ec3689 /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: 67dc7ef3e293236a + zh: 1b224db684a2d1d7 /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..1d5292c68b 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 shell copy in the eight [shipped Client languages](../../packages/client/locale/README.md) from its application locale and falls back to English. The macOS application bundle declares all eight languages 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. All shipped language resources must have matching keys, format placeholders, and command examples; `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 supported language 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..c2078e9d30 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 根据应用语言选择类型化的八种[内置 Client 语言](../../packages/client/locale/README.zh.md)的 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/de_DE.json b/apps/desktop/installer/locales/de_DE.json new file mode 100644 index 0000000000..faf725b88e --- /dev/null +++ b/apps/desktop/installer/locales/de_DE.json @@ -0,0 +1,40 @@ +{ + "nsisLanguage": "GERMAN", + "strings": { + "INSTALLER_INSTALL": "Jetzt installieren", + "INSTALLER_FINISH": "Fertigstellen", + "INSTALLER_NATIVE_PROGRESS": "Installation wird vorbereitet… %d%%", + "INSTALLER_PROGRESS_EXTRACT": "Dateien extrahieren… %d%%", + "INSTALLER_PROGRESS_COPY": "Dateien werden installiert… %d%%", + "INSTALLER_PROGRESS_REGISTER": "Abschluss der Installation… %d%%", + "INSTALLER_PROGRESS_CLEAN": "Bereinigen temporärer Dateien… %d%%", + "INSTALLER_CHOOSE_PATH": "Standort wählen", + "INSTALLER_BROWSE": "Durchsuchen", + "INSTALLER_LAUNCH": "Jetzt starten", + "INSTALLER_LAUNCH_FAILED": "DeepSeek Harness konnte nicht gestartet werden. Versuchen Sie es noch einmal oder deaktivieren Sie „Jetzt starten“ und schließen Sie das Installationsprogramm.", + "INSTALLER_UI_ERROR": "Das Installationsprogramm konnte sein Fenster nicht initialisieren. Schließen Sie es und versuchen Sie es erneut.", + "INSTALLER_THEME_ERROR": "Verwenden Sie /THEME=auto, /THEME=light oder /THEME=dark.", + "INSTALLER_FAILED": "Die Installation wurde nicht abgeschlossen. Schließen Sie dieses Installationsprogramm und versuchen Sie es erneut. Wenn das Problem weiterhin besteht, laden Sie das Installationsprogramm erneut herunter.", + "INSTALLER_RUNNING": "DeepSeek Harness läuft. Beenden Sie es über die Taskleiste und versuchen Sie es erneut.", + "INSTALLER_DIRECTORY_FAILED": "Das Installationsverzeichnis konnte nicht ersetzt werden.", + "INSTALLER_DIRECTORY_HINT": "Überprüfen Sie den Zugriff auf das Installationsverzeichnis und schließen Sie Programme mithilfe seiner Dateien. Führen Sie dann das Installationsprogramm erneut aus. Kopieren Sie die Fehlerdetails, wenn das Problem weiterhin besteht.", + "INSTALLER_DIRECTORY_RESTORE_HINT": "Die vorherige Installation konnte nicht wiederhergestellt werden. Behalten Sie das in den Fehlerdetails angezeigte Sicherungsverzeichnis bei und wenden Sie sich vor der erneuten Installation an den Support.", + "INSTALLER_PATH_INVALID": "Wählen Sie einen vollständigen lokalen Ordnerpfad. Laufwerksstämme, Systemordner, Links und Sonderzeichen werden nicht unterstützt.", + "INSTALLER_PATH_OWNERSHIP": "Wählen Sie einen leeren Ordner oder den registrierten DeepSeek Harness-Installationsordner.", + "INSTALLER_PATH_WRITABLE": "Dieser Ort ist nicht beschreibbar. Wählen Sie einen Ordner, der für den aktuellen Benutzer verfügbar ist.", + "INSTALLER_DISK_SPACE": "Es ist nicht genügend freier Speicherplatz vorhanden. Wählen Sie einen anderen Standort.", + "INSTALLER_PER_USER": "Dieses Installationsprogramm unterstützt nur den aktuellen Benutzer. Deinstallieren Sie zunächst die vorhandene All-User-Installation.", + "INSTALLER_EXTRACT_FAILED": "Die Anwendungsdateien konnten nicht extrahiert werden.", + "INSTALLER_EXTRACT_HINT": "Möglicherweise hat die Sicherheitssoftware eine Datei blockiert oder die Festplatte ist voll. Schließen Sie dieses Installationsprogramm und versuchen Sie es erneut. Wenn es weiterhin fehlschlägt, senden Sie uns die Fehlerdetails unten.", + "INSTALLER_EXTRACT_COPY": "Fehlerdetails kopieren", + "INSTALLER_EXTRACT_EXPAND": "Details anzeigen", + "INSTALLER_EXTRACT_COLLAPSE": "Details ausblenden", + "INSTALLER_EXTRACT_SAVED": "Der vollständige Bericht wurde unter %s gespeichert", + "INSTALLER_EXTRACT_UNSAVED": "Der vollständige Bericht konnte nicht gespeichert werden. Verwenden Sie „Fehlerdetails kopieren“, um sie beizubehalten.", + "INSTALLER_EXTRACT_COPIED": "Fehlerdetails wurden in die Zwischenablage kopiert." + }, + "uninstaller": { + "welcome": "Willkommen", + "finished": "entfernt worden" + } +} 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/es_ES.json b/apps/desktop/installer/locales/es_ES.json new file mode 100644 index 0000000000..d4fbfd972f --- /dev/null +++ b/apps/desktop/installer/locales/es_ES.json @@ -0,0 +1,40 @@ +{ + "nsisLanguage": "SPANISHINTERNATIONAL", + "strings": { + "INSTALLER_INSTALL": "Instalar ahora", + "INSTALLER_FINISH": "Finalizar", + "INSTALLER_NATIVE_PROGRESS": "Preparando la instalación… %d%%", + "INSTALLER_PROGRESS_EXTRACT": "Extrayendo archivos… %d%%", + "INSTALLER_PROGRESS_COPY": "Instalando archivos… %d%%", + "INSTALLER_PROGRESS_REGISTER": "Terminando la instalación… %d%%", + "INSTALLER_PROGRESS_CLEAN": "Limpieza de archivos temporales… %d%%", + "INSTALLER_CHOOSE_PATH": "Elige ubicación", + "INSTALLER_BROWSE": "Examinar", + "INSTALLER_LAUNCH": "Lanzar ahora", + "INSTALLER_LAUNCH_FAILED": "No se pudo iniciar DeepSeek Harness. Vuelva a intentarlo o desmarque Iniciar ahora y cierre el instalador.", + "INSTALLER_UI_ERROR": "El instalador no pudo inicializar su ventana. Ciérralo y vuelve a intentarlo.", + "INSTALLER_THEME_ERROR": "Utilice /THEME=auto, /THEME=light o /THEME=dark.", + "INSTALLER_FAILED": "La instalación no terminó. Cierre este instalador y vuelva a intentarlo. Si el problema persiste, descargue el instalador nuevamente.", + "INSTALLER_RUNNING": "DeepSeek Harness se está ejecutando. Salga de la bandeja del sistema y vuelva a intentarlo.", + "INSTALLER_DIRECTORY_FAILED": "No se pudo reemplazar el directorio de instalación.", + "INSTALLER_DIRECTORY_HINT": "Verifique el acceso al directorio de instalación y cierre los programas usando sus archivos, luego ejecute el instalador nuevamente. Copie los detalles del error si el problema persiste.", + "INSTALLER_DIRECTORY_RESTORE_HINT": "No se pudo restaurar la instalación anterior. Mantenga el directorio de respaldo que se muestra en los detalles del error y comuníquese con el soporte antes de volver a instalar.", + "INSTALLER_PATH_INVALID": "Elija una ruta de carpeta local completa. No se admiten raíces de unidades, carpetas del sistema, enlaces ni caracteres especiales.", + "INSTALLER_PATH_OWNERSHIP": "Elija una carpeta vacía o la carpeta de instalación registrada de DeepSeek Harness.", + "INSTALLER_PATH_WRITABLE": "Esta ubicación no se puede escribir. Elija una carpeta disponible para el usuario actual.", + "INSTALLER_DISK_SPACE": "No hay suficiente espacio libre en disco. Elija otra ubicación.", + "INSTALLER_PER_USER": "Este instalador solo admite el usuario actual. Primero desinstale la instalación existente para todos los usuarios.", + "INSTALLER_EXTRACT_FAILED": "No se pudieron extraer los archivos de la aplicación.", + "INSTALLER_EXTRACT_HINT": "Es posible que el software de seguridad haya bloqueado un archivo o que el disco esté lleno. Cierra este instalador y vuelve a intentarlo. Si sigue fallando, envíenos los detalles del error a continuación.", + "INSTALLER_EXTRACT_COPY": "Copiar detalles del error", + "INSTALLER_EXTRACT_EXPAND": "Mostrar detalles", + "INSTALLER_EXTRACT_COLLAPSE": "Ocultar detalles", + "INSTALLER_EXTRACT_SAVED": "El informe completo se guardó en %s.", + "INSTALLER_EXTRACT_UNSAVED": "No se pudo guardar el informe completo. Utilice Copiar detalles del error para conservarlo.", + "INSTALLER_EXTRACT_COPIED": "Los detalles del error se copiaron al portapapeles." + }, + "uninstaller": { + "welcome": "Bienvenido", + "finished": "ha sido desinstalado" + } +} diff --git a/apps/desktop/installer/locales/fr_FR.json b/apps/desktop/installer/locales/fr_FR.json new file mode 100644 index 0000000000..326ead62a8 --- /dev/null +++ b/apps/desktop/installer/locales/fr_FR.json @@ -0,0 +1,40 @@ +{ + "nsisLanguage": "FRENCH", + "strings": { + "INSTALLER_INSTALL": "Installer maintenant", + "INSTALLER_FINISH": "Terminer", + "INSTALLER_NATIVE_PROGRESS": "Préparation de l'installation… %d%%", + "INSTALLER_PROGRESS_EXTRACT": "Extraction de fichiers… %d%%", + "INSTALLER_PROGRESS_COPY": "Installation des fichiers… %d%%", + "INSTALLER_PROGRESS_REGISTER": "Finition de l'installation… %d%%", + "INSTALLER_PROGRESS_CLEAN": "Nettoyage des fichiers temporaires… %d%%", + "INSTALLER_CHOOSE_PATH": "Choisissez un emplacement", + "INSTALLER_BROWSE": "Parcourir", + "INSTALLER_LAUNCH": "Lancez-vous maintenant", + "INSTALLER_LAUNCH_FAILED": "DeepSeek Harness n'a pas pu être lancé. Réessayez ou désactivez Lancer maintenant et fermez le programme d'installation.", + "INSTALLER_UI_ERROR": "Le programme d'installation n'a pas pu initialiser sa fenêtre. Fermez-le et réessayez.", + "INSTALLER_THEME_ERROR": "Utilisez /THEME=auto, /THEME=light ou /THEME=dark.", + "INSTALLER_FAILED": "L'installation n'est pas terminée. Fermez ce programme d'installation et réessayez. Si le problème persiste, téléchargez à nouveau le programme d'installation.", + "INSTALLER_RUNNING": "DeepSeek Harness est en cours d'exécution. Quittez-le de la barre d'état système et réessayez.", + "INSTALLER_DIRECTORY_FAILED": "Le répertoire d'installation n'a pas pu être remplacé.", + "INSTALLER_DIRECTORY_HINT": "Vérifiez l'accès au répertoire d'installation et fermez les programmes à l'aide de ses fichiers, puis réexécutez le programme d'installation. Copiez les détails de l'erreur si le problème persiste.", + "INSTALLER_DIRECTORY_RESTORE_HINT": "L'installation précédente n'a pas pu être restaurée. Conservez le répertoire de sauvegarde indiqué dans les détails de l'erreur et contactez l'assistance avant de procéder à une nouvelle installation.", + "INSTALLER_PATH_INVALID": "Choisissez un chemin de dossier local complet. Les racines du lecteur, les dossiers système, les liens et les caractères spéciaux ne sont pas pris en charge.", + "INSTALLER_PATH_OWNERSHIP": "Choisissez un dossier vide ou le dossier d'installation DeepSeek Harness enregistré.", + "INSTALLER_PATH_WRITABLE": "Cet emplacement n'est pas accessible en écriture. Choisissez un dossier disponible pour l'utilisateur actuel.", + "INSTALLER_DISK_SPACE": "Il n'y a pas assez d'espace disque libre. Choisissez un autre emplacement.", + "INSTALLER_PER_USER": "Ce programme d'installation prend en charge uniquement l'utilisateur actuel. Désinstallez d’abord l’installation existante pour tous les utilisateurs.", + "INSTALLER_EXTRACT_FAILED": "Les fichiers de l'application n'ont pas pu être extraits.", + "INSTALLER_EXTRACT_HINT": "Un logiciel de sécurité a peut-être bloqué un fichier ou le disque est peut-être plein. Fermez ce programme d'installation et réessayez. Si l'échec persiste, envoyez-nous les détails de l'erreur ci-dessous.", + "INSTALLER_EXTRACT_COPY": "Copier les détails de l'erreur", + "INSTALLER_EXTRACT_EXPAND": "Afficher les détails", + "INSTALLER_EXTRACT_COLLAPSE": "Masquer les détails", + "INSTALLER_EXTRACT_SAVED": "Le rapport complet a été enregistré dans %s", + "INSTALLER_EXTRACT_UNSAVED": "Le rapport complet n'a pas pu être enregistré. Utilisez Copier les détails de l'erreur pour le conserver.", + "INSTALLER_EXTRACT_COPIED": "Les détails de l'erreur ont été copiés dans le presse-papiers." + }, + "uninstaller": { + "welcome": "Bienvenue", + "finished": "a été supprimé" + } +} 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/pt_BR.json b/apps/desktop/installer/locales/pt_BR.json new file mode 100644 index 0000000000..dda249e46f --- /dev/null +++ b/apps/desktop/installer/locales/pt_BR.json @@ -0,0 +1,40 @@ +{ + "nsisLanguage": "PORTUGUESEBR", + "strings": { + "INSTALLER_INSTALL": "Instale agora", + "INSTALLER_FINISH": "Concluir", + "INSTALLER_NATIVE_PROGRESS": "Preparando a instalação… %d%%", + "INSTALLER_PROGRESS_EXTRACT": "Extraindo arquivos… %d%%", + "INSTALLER_PROGRESS_COPY": "Instalando arquivos… %d%%", + "INSTALLER_PROGRESS_REGISTER": "Concluindo a instalação… %d%%", + "INSTALLER_PROGRESS_CLEAN": "Limpando arquivos temporários… %d%%", + "INSTALLER_CHOOSE_PATH": "Escolha o local", + "INSTALLER_BROWSE": "Procurar", + "INSTALLER_LAUNCH": "Lançar agora", + "INSTALLER_LAUNCH_FAILED": "DeepSeek Harness não pôde ser iniciado. Tente novamente ou desmarque Iniciar agora e feche o instalador.", + "INSTALLER_UI_ERROR": "O instalador não conseguiu inicializar sua janela. Feche-o e tente novamente.", + "INSTALLER_THEME_ERROR": "Use /THEME=auto, /THEME=light ou /THEME=dark.", + "INSTALLER_FAILED": "A instalação não foi concluída. Feche este instalador e tente novamente. Se o problema persistir, baixe o instalador novamente.", + "INSTALLER_RUNNING": "DeepSeek Harness está em execução. Saia da bandeja do sistema e tente novamente.", + "INSTALLER_DIRECTORY_FAILED": "O diretório de instalação não pôde ser substituído.", + "INSTALLER_DIRECTORY_HINT": "Verifique o acesso ao diretório de instalação e feche os programas usando seus arquivos e execute o instalador novamente. Copie os detalhes do erro se o problema persistir.", + "INSTALLER_DIRECTORY_RESTORE_HINT": "A instalação anterior não pôde ser restaurada. Mantenha o diretório de backup mostrado nos detalhes do erro e entre em contato com o suporte antes de instalar novamente.", + "INSTALLER_PATH_INVALID": "Escolha um caminho completo para a pasta local. Raízes de unidade, pastas de sistema, links e caracteres especiais não são suportados.", + "INSTALLER_PATH_OWNERSHIP": "Escolha uma pasta vazia ou a pasta de instalação DeepSeek Harness registrada.", + "INSTALLER_PATH_WRITABLE": "Este local não é gravável. Escolha uma pasta disponível para o usuário atual.", + "INSTALLER_DISK_SPACE": "Não há espaço livre suficiente em disco. Escolha outro local.", + "INSTALLER_PER_USER": "Este instalador oferece suporte apenas ao usuário atual. Desinstale primeiro a instalação existente para todos os usuários.", + "INSTALLER_EXTRACT_FAILED": "Os arquivos do aplicativo não puderam ser extraídos.", + "INSTALLER_EXTRACT_HINT": "O software de segurança pode ter bloqueado um arquivo ou o disco pode estar cheio. Feche este instalador e tente novamente. Se continuar falhando, envie-nos os detalhes do erro abaixo.", + "INSTALLER_EXTRACT_COPY": "Copiar detalhes do erro", + "INSTALLER_EXTRACT_EXPAND": "Mostrar detalhes", + "INSTALLER_EXTRACT_COLLAPSE": "Ocultar detalhes", + "INSTALLER_EXTRACT_SAVED": "O relatório completo foi salvo em %s", + "INSTALLER_EXTRACT_UNSAVED": "Não foi possível salvar o relatório completo. Use Copiar detalhes do erro para mantê-lo.", + "INSTALLER_EXTRACT_COPIED": "Os detalhes do erro foram copiados para a área de transferência." + }, + "uninstaller": { + "welcome": "Bem-vindo", + "finished": "foi desinstalado" + } +} diff --git a/apps/desktop/installer/locales/ru_RU.json b/apps/desktop/installer/locales/ru_RU.json new file mode 100644 index 0000000000..e5fec3f8d1 --- /dev/null +++ b/apps/desktop/installer/locales/ru_RU.json @@ -0,0 +1,40 @@ +{ + "nsisLanguage": "RUSSIAN", + "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..18a188eb76 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -57,6 +57,7 @@ "@deepseek-ai/dsh-client-ui-settings-general": "workspace:*", "@deepseek-ai/dsh-client-ui-sidebar-browser": "workspace:*", "@deepseek-ai/dsh-home-paths": "workspace:*", + "@deepseek-ai/dsh-official-bundles": "workspace:*", "@deepseek-ai/node-addon-system": "workspace:~", "@electron/get": "^5.1.0", "@electron/notarize": "2.5.0", @@ -82,6 +83,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/de.json b/packages/client/locale/locale/de.json new file mode 100644 index 0000000000..f7eb734545 --- /dev/null +++ b/packages/client/locale/locale/de.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "Gebietsschema", + "description": "Verwalten Sie die Spracheinstellungen der Benutzeroberfläche und die Übersetzungskopie jedes Plug-Ins." + } +} diff --git a/packages/client/locale/locale/es.json b/packages/client/locale/locale/es.json new file mode 100644 index 0000000000..ea4d2f0869 --- /dev/null +++ b/packages/client/locale/locale/es.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "local", + "description": "Administre las preferencias de idioma de la interfaz y la copia de traducción de cada complemento." + } +} diff --git a/packages/client/locale/locale/fr.json b/packages/client/locale/locale/fr.json new file mode 100644 index 0000000000..c3ff597891 --- /dev/null +++ b/packages/client/locale/locale/fr.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "paramètres régionaux", + "description": "Gérez les préférences linguistiques de l’interface et la copie de traduction de chaque plug-in." + } +} 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/locale/pt.json b/packages/client/locale/locale/pt.json new file mode 100644 index 0000000000..7b4b751941 --- /dev/null +++ b/packages/client/locale/locale/pt.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "localidade", + "description": "Gerencie as preferências de idioma da interface e a cópia de tradução de cada plug-in." + } +} diff --git a/packages/client/locale/locale/ru.json b/packages/client/locale/locale/ru.json new file mode 100644 index 0000000000..4b11903438 --- /dev/null +++ b/packages/client/locale/locale/ru.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..3ba1bae29b 100644 --- a/packages/client/locale/src/client/index.ts +++ b/packages/client/locale/src/client/index.ts @@ -20,9 +20,10 @@ 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, es, fr, de, ru, pt, zh, type CommonKey } from '../locales/index.ts' import { - en as settingsEn, zh as settingsZh, type SettingsLocaleKey, + en as settingsEn, ja as settingsJa, es as settingsEs, fr as settingsFr, + de as settingsDe, ru as settingsRu, pt as settingsPt, zh as settingsZh, type SettingsLocaleKey, } from '../locales/settings.ts' import type { LanguageRowInjected } from './LanguageRow.tsx' import { LanguageRow } from './LanguageRow.tsx' @@ -114,7 +115,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 +379,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 +580,11 @@ 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 }) + for (const [id, label] of [['ja', '日本語'], ['es', 'Español'], ['fr', 'Français'], ['de', 'Deutsch'], ['ru', 'Русский'], ['pt', 'Português']] as const) { + ctx.effect(() => locale.addLanguage({ id, label, fallback: 'en' }), `locale: ${id} language`) + } + ctx.effect(() => locale.register(COMMON_NS, { zh, en, ja, es, fr, de, ru, pt }), 'locale: common dictionaries') + ctx.effect(() => locale.register(SETTINGS_NS, { zh: settingsZh, en: settingsEn, ja: settingsJa, es: settingsEs, fr: settingsFr, de: settingsDe, ru: settingsRu, pt: settingsPt }), '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/de.ts b/packages/client/locale/src/locales/de.ts new file mode 100644 index 0000000000..dd88cf6fc4 --- /dev/null +++ b/packages/client/locale/src/locales/de.ts @@ -0,0 +1,47 @@ +/** German common-namespace dictionary. */ +import type { en } from './en.ts' + +/** Complete German copy for the common namespace. */ +export const de = { + 'ok': 'OK', + 'cancel': 'Abbrechen', + 'close': 'Schließen', + 'copy': 'Kopieren', + 'copied': 'Kopiert', + 'codeBlock.title': 'Codeblock', + 'codeBlock.wrap': 'Zeilen umbrechen', + 'codeBlock.unwrap': 'Zeilen nicht umbrechen', + 'copy.failed': 'Das Kopieren ist fehlgeschlagen', + 'copy.value': 'Wert kopieren', + 'copy.json': 'JSON kopieren', + 'copy.path': 'Eigenschaftspfad kopieren', + 'copy.prettyJson': 'Formatiertes JSON kopieren', + 'copy.compactJson': 'Kompaktes JSON kopieren', + 'copy.optionsHint': '{action}; Klicken Sie mit der rechten Maustaste, um die Kopieroptionen anzuzeigen', + 'retry': 'Erneut versuchen', + 'loading': 'Laden…', + 'load.failed': 'Laden fehlgeschlagen', + 'submit': 'Senden', + 'submitting': 'Wird gesendet…', + 'next': 'Weiter', + 'previous': 'Zurück', + 'skip': 'Überspringen', + 'delete': 'Löschen', + 'edit': 'Bearbeiten', + 'save': 'Speichern', + 'search': 'Suchen', + 'more': 'Mehr', + 'collapse': 'Einklappen', + 'expand': 'Ausklappen', + 'back': 'Zurück', + 'brand.localBuild': 'Lokaler DSH-Build', + 'workspace.defaultName': 'Standardarbeitsbereich', + 'unknown': 'Unbekannt', + 'none': 'Keine', + 'truncated': 'Abgeschnitten', + 'json.label': 'JSON', + 'markdown.footnotes': 'Fußnoten', + 'markdown.truncatedCharacters': '… nach {total} Zeichen abgeschnitten', + 'number.thousand': '{value}K', + 'number.million': '{value}M', +} satisfies Record diff --git a/packages/client/locale/src/locales/es.ts b/packages/client/locale/src/locales/es.ts new file mode 100644 index 0000000000..7eb95374e1 --- /dev/null +++ b/packages/client/locale/src/locales/es.ts @@ -0,0 +1,47 @@ +/** Spanish common-namespace dictionary. */ +import type { en } from './en.ts' + +/** Complete Spanish copy for the common namespace. */ +export const es = { + 'ok': 'OK', + 'cancel': 'Cancelar', + 'close': 'Cerrar', + 'copy': 'Copiar', + 'copied': 'Copiado', + 'codeBlock.title': 'Bloque de código', + 'codeBlock.wrap': 'Ajustar líneas', + 'codeBlock.unwrap': 'No ajustar líneas', + 'copy.failed': 'Copia fallida', + 'copy.value': 'Copiar valor', + 'copy.json': 'Copiar JSON', + 'copy.path': 'Copiar ruta de propiedad', + 'copy.prettyJson': 'Copiar JSON con formato', + 'copy.compactJson': 'Copiar JSON compacto', + 'copy.optionsHint': '{action}; haga clic derecho para opciones de copia', + 'retry': 'Reintentar', + 'loading': 'Cargando…', + 'load.failed': 'No se pudo cargar', + 'submit': 'Enviar', + 'submitting': 'Enviando…', + 'next': 'Siguiente', + 'previous': 'Anterior', + 'skip': 'Saltar', + 'delete': 'Eliminar', + 'edit': 'Editar', + 'save': 'Guardar', + 'search': 'Buscar', + 'more': 'Más', + 'collapse': 'Contraer', + 'expand': 'Expandir', + 'back': 'Atrás', + 'brand.localBuild': 'Compilación local de DSH', + 'workspace.defaultName': 'Espacio de trabajo predeterminado', + 'unknown': 'Desconocido', + 'none': 'Ninguno', + 'truncated': 'Truncado', + 'json.label': 'JSON', + 'markdown.footnotes': 'Notas a pie de página', + 'markdown.truncatedCharacters': '… truncado a {total} caracteres', + 'number.thousand': '{value}K', + 'number.million': '{value}M', +} satisfies Record diff --git a/packages/client/locale/src/locales/fr.ts b/packages/client/locale/src/locales/fr.ts new file mode 100644 index 0000000000..c57f1a3b11 --- /dev/null +++ b/packages/client/locale/src/locales/fr.ts @@ -0,0 +1,47 @@ +/** French common-namespace dictionary. */ +import type { en } from './en.ts' + +/** Complete French copy for the common namespace. */ +export const fr = { + 'ok': 'OK', + 'cancel': 'Annuler', + 'close': 'Fermer', + 'copy': 'Copier', + 'copied': 'Copié', + 'codeBlock.title': 'Bloc de code', + 'codeBlock.wrap': 'Activer le retour à la ligne', + 'codeBlock.unwrap': 'Désactiver le retour à la ligne', + 'copy.failed': 'Échec de la copie', + 'copy.value': 'Copier la valeur', + 'copy.json': 'Copier le JSON', + 'copy.path': 'Copier le chemin de la propriété', + 'copy.prettyJson': 'Copier le JSON formaté', + 'copy.compactJson': 'Copier le JSON compact', + 'copy.optionsHint': '{action}; clic droit pour les options de copie', + 'retry': 'Réessayer', + 'loading': 'Chargement…', + 'load.failed': 'Échec du chargement', + 'submit': 'Soumettre', + 'submitting': 'Envoi…', + 'next': 'Suivant', + 'previous': 'Précédent', + 'skip': 'Sauter', + 'delete': 'Supprimer', + 'edit': 'Modifier', + 'save': 'Enregistrer', + 'search': 'Rechercher', + 'more': 'Plus', + 'collapse': 'Réduire', + 'expand': 'Développer', + 'back': 'Retour', + 'brand.localBuild': 'Version locale de DSH', + 'workspace.defaultName': 'Espace de travail par défaut', + 'unknown': 'Inconnu', + 'none': 'Aucun', + 'truncated': 'Tronqué', + 'json.label': 'JSON', + 'markdown.footnotes': 'Notes de bas de page', + 'markdown.truncatedCharacters': '… tronqué à {total} caractères', + 'number.thousand': '{value}K', + 'number.million': '{value}M', +} satisfies Record diff --git a/packages/client/locale/src/locales/index.ts b/packages/client/locale/src/locales/index.ts index 6ac5335f5b..6690d74aeb 100644 --- a/packages/client/locale/src/locales/index.ts +++ b/packages/client/locale/src/locales/index.ts @@ -1,8 +1,13 @@ /** - * 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; + * every shipped language is checked against them by TypeScript. */ export { zh } from './zh.ts' export { en } from './en.ts' +export { ja } from './ja.ts' export type { CommonKey } from './zh.ts' +export { es } from './es.ts' +export { fr } from './fr.ts' +export { de } from './de.ts' +export { ru } from './ru.ts' +export { pt } from './pt.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/pt.ts b/packages/client/locale/src/locales/pt.ts new file mode 100644 index 0000000000..75cdb19c29 --- /dev/null +++ b/packages/client/locale/src/locales/pt.ts @@ -0,0 +1,47 @@ +/** Portuguese common-namespace dictionary. */ +import type { en } from './en.ts' + +/** Complete Portuguese copy for the common namespace. */ +export const pt = { + 'ok': 'OK', + 'cancel': 'Cancelar', + 'close': 'Fechar', + 'copy': 'Copiar', + 'copied': 'Copiado', + 'codeBlock.title': 'Bloco de código', + 'codeBlock.wrap': 'Quebrar linhas', + 'codeBlock.unwrap': 'Não quebrar linhas', + 'copy.failed': 'Falha na cópia', + 'copy.value': 'Copiar valor', + 'copy.json': 'Copiar JSON', + 'copy.path': 'Copiar caminho da propriedade', + 'copy.prettyJson': 'Copiar JSON formatado', + 'copy.compactJson': 'Copiar JSON compacto', + 'copy.optionsHint': '{action}; clique com o botão direito para opções de cópia', + 'retry': 'Tentar novamente', + 'loading': 'Carregando…', + 'load.failed': 'Falha ao carregar', + 'submit': 'Enviar', + 'submitting': 'Enviando…', + 'next': 'Próximo', + 'previous': 'Anterior', + 'skip': 'Pular', + 'delete': 'Excluir', + 'edit': 'Editar', + 'save': 'Salvar', + 'search': 'Pesquisar', + 'more': 'Mais', + 'collapse': 'Recolher', + 'expand': 'Expandir', + 'back': 'Voltar', + 'brand.localBuild': 'Versão local do DSH', + 'workspace.defaultName': 'Espaço de trabalho padrão', + 'unknown': 'Desconhecido', + 'none': 'Nenhum', + 'truncated': 'Truncado', + 'json.label': 'JSON', + 'markdown.footnotes': 'Notas de rodapé', + 'markdown.truncatedCharacters': '… truncado em {total} caracteres', + 'number.thousand': '{value}K', + 'number.million': '{value}M', +} satisfies Record diff --git a/packages/client/locale/src/locales/ru.ts b/packages/client/locale/src/locales/ru.ts new file mode 100644 index 0000000000..a8a6d829da --- /dev/null +++ b/packages/client/locale/src/locales/ru.ts @@ -0,0 +1,47 @@ +/** Russian common-namespace dictionary. */ +import type { en } from './en.ts' + +/** Complete Russian copy for the common namespace. */ +export const ru = { + '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..8aafb48c44 100644 --- a/packages/client/locale/src/locales/settings.ts +++ b/packages/client/locale/src/locales/settings.ts @@ -12,3 +12,33 @@ 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 + +/** Spanish dictionary, checked against the English keys. */ +export const es = { + 'language.title': 'Idioma', +} satisfies Record + +/** French dictionary, checked against the English keys. */ +export const fr = { + 'language.title': 'Langue', +} satisfies Record + +/** German dictionary, checked against the English keys. */ +export const de = { + 'language.title': 'Sprache', +} satisfies Record + +/** Russian dictionary, checked against the English keys. */ +export const ru = { + 'language.title': 'Язык', +} satisfies Record + +/** Portuguese dictionary, checked against the English keys. */ +export const pt = { + 'language.title': 'Idioma', +} satisfies Record diff --git a/packages/client/locale/tests/apply.client.spec.ts b/packages/client/locale/tests/apply.client.spec.ts index cd8e835c46..43920d60c0 100644 --- a/packages/client/locale/tests/apply.client.spec.ts +++ b/packages/client/locale/tests/apply.client.spec.ts @@ -1,8 +1,9 @@ +import { SHIPPED_LOCALE_IDS } from '../../../../scripts/shipped-locales.ts' /** locale apply wiring: service + dictionaries provision, declaration-aware * 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 +156,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).sort()).toEqual([...SHIPPED_LOCALE_IDS].sort()) // 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 +177,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: 'it', label: 'Italiano', fallback: 'en' }), 'test language pack registration', ), }) @@ -185,10 +186,47 @@ describe('locale apply', () => { { id: 'zh', label: '中文' }, { id: 'en', label: 'English' }, { id: 'ja', label: '日本語' }, + { id: 'es', label: 'Español' }, + { id: 'fr', label: 'Français' }, + { id: 'de', label: 'Deutsch' }, + { id: 'ru', label: 'Русский' }, + { id: 'pt', label: 'Português' }, + { id: 'it', label: 'Italiano' }, ]) 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', 'es', 'fr', 'de', 'ru', 'pt']) + }) + + it.each([ + ['ja', '日本語', '言語', 'キャンセル'], + ['es', 'Español', 'Idioma', 'Cancelar'], + ['fr', 'Français', 'Langue', 'Annuler'], + ['de', 'Deutsch', 'Sprache', 'Abbrechen'], + ['ru', 'Русский', 'Язык', 'Отмена'], + ['pt', 'Português', 'Idioma', 'Cancelar'], + ])('restores %s from the Host and releases its language and dictionaries on unload', async (id, label, languageTitle, cancel) => { + const b = await bench(id) + 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(id) + expect(locale.bind(SETTINGS_NS)('language.title')).toBe(languageTitle) + expect(locale.bind('common')('cancel')).toBe(cancel) + face.setLocale('en') + face.setLocale(id) + await vi.waitFor(() => { expect(b.mutate).toHaveBeenCalledTimes(2) }) + expect(b.mutate.mock.calls.at(-1)?.[1]).toEqual([{ op: 'set', path: ['preference'], value: id }]) + await fiber.dispose() + expect(locale.getSnapshot().locales.map(option => option.id)).toEqual(['zh', 'en']) + expect(() => { locale.setLocale(id) }).toThrow('not registered') + const remove = locale.addLanguage({ id, label, fallback: 'en' }) + locale.setLocale(id) + 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..a513d988b5 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: 'it', label: 'Italiano', 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/de.json b/packages/client/modules/locale/de.json new file mode 100644 index 0000000000..4e5c22fba2 --- /dev/null +++ b/packages/client/modules/locale/de.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "Module", + "description": "Verwalten Sie Browser-Plug-in-Module und ihre Ladeabhängigkeiten." + } +} diff --git a/packages/client/modules/locale/es.json b/packages/client/modules/locale/es.json new file mode 100644 index 0000000000..f005ac4417 --- /dev/null +++ b/packages/client/modules/locale/es.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "módulos", + "description": "Administre los módulos de complementos del navegador y sus dependencias de carga." + } +} diff --git a/packages/client/modules/locale/fr.json b/packages/client/modules/locale/fr.json new file mode 100644 index 0000000000..29801bfe86 --- /dev/null +++ b/packages/client/modules/locale/fr.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "modules", + "description": "Gérez les modules de plug-in du navigateur et leurs dépendances de chargement." + } +} 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/modules/locale/pt.json b/packages/client/modules/locale/pt.json new file mode 100644 index 0000000000..167cc3f507 --- /dev/null +++ b/packages/client/modules/locale/pt.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "módulos", + "description": "Gerencie módulos de plug-in do navegador e suas dependências de carregamento." + } +} diff --git a/packages/client/modules/locale/ru.json b/packages/client/modules/locale/ru.json new file mode 100644 index 0000000000..67041fde84 --- /dev/null +++ b/packages/client/modules/locale/ru.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "модули", + "description": "Управляйте подключаемыми модулями браузера и их зависимостями при загрузке." + } +} diff --git a/packages/client/modules/src/index.ts b/packages/client/modules/src/index.ts index c8b48a0fea..c87f28d4b8 100644 --- a/packages/client/modules/src/index.ts +++ b/packages/client/modules/src/index.ts @@ -28,11 +28,9 @@ import { createHash } from 'node:crypto' import { createReadStream, existsSync, readFileSync, statSync } from 'node:fs' import { readFile } from 'node:fs/promises' -import type { IncomingMessage, ServerResponse } from 'node:http' import { createRequire } from 'node:module' import { dirname, isAbsolute, join } from 'node:path' import { Readable } from 'node:stream' -import { pipeline } from 'node:stream/promises' import { fileURLToPath, pathToFileURL } from 'node:url' import { isDeepStrictEqual } from 'node:util' import { Service } from '@deepseek-ai/cordis' @@ -255,6 +253,19 @@ function transforStreamBody(stream: Readable): ReadableStream { }) } +/** + * Expose a held bundle buffer as a Response body without copying it: the + * single chunk is the buffer itself, which readers must not modify. + */ +function heldBody(buffer: Buffer): ReadableStream { + return new ReadableStream({ + start(controller) { + controller.enqueue(buffer) + controller.close() + }, + }) +} + /** Combo query addressing one ordered plugin-file list. */ function comboSearch(ids: readonly string[], rev: string, sourceMap = false): string { const resources = ids.map(id => `${id}/client.js${sourceMap ? '.map' : ''}`).join(',') @@ -689,7 +700,7 @@ export class ClientModuleRegistry extends Service { const registerWebCarrier = (webCtx: Context): void => { webCtx.effect( - () => webCtx.webServer.register({ kind: 'prefix', path: PLUGIN_ROUTE, handler: this.serveBundle }), + () => webCtx.webServer.register({ kind: 'prefix', path: PLUGIN_ROUTE, resource: request => this.fetchBundle(request) }), 'client-modules: bundle route', ) } @@ -723,14 +734,16 @@ export class ClientModuleRegistry extends Service { * body. Each bundle body is built once on its first `GET`; script * construction never reads maps. A package asset is read on every request * and answers 304 without a body when `If-None-Match` equals its entity tag; - * its body holds the file open until the caller consumes or cancels it. + * its body holds the file open until the caller consumes or cancels it. A + * bundle, map, or chunk body is the held buffer itself, not a copy, so a + * caller must not modify its bytes. * @param request - shell-carrier request for a `/plugins` resource. * @returns the exact response also exposed by the optional Web route. */ async fetchBundle(request: Request): Promise { const resource = await this.bundleResource(request.method, request.url, request.headers.get('if-none-match') ?? undefined) const body = resource.body === undefined ? null - : resource.body instanceof Readable ? transforStreamBody(resource.body) : Uint8Array.from(resource.body) + : resource.body instanceof Readable ? transforStreamBody(resource.body) : heldBody(resource.body) return new Response(body, { status: resource.status, ...(resource.headers === undefined ? {} : { headers: resource.headers }), @@ -1256,21 +1269,6 @@ export class ClientModuleRegistry extends Service { return { status: 404 } } - private readonly serveBundle = async (req: IncomingMessage, res: ServerResponse): Promise => { - /* v8 ignore next -- `?? '/'` arm: node:http always sets url on server requests. */ - const response = await this.bundleResource(req.method, req.url ?? '/', req.headers['if-none-match']) - res.writeHead(response.status, response.headers) - if (!(response.body instanceof Readable)) { - res.end(response.body) - return - } - try { - await pipeline(response.body, res) - } catch (error) { - // The browser cancelled the request, as leaving an onboarding step does; pipeline already closed the file. - if ((error as NodeJS.ErrnoException).code !== 'ERR_STREAM_PREMATURE_CLOSE') throw error - } - } } export default ClientModuleRegistry diff --git a/packages/client/modules/tests/node-half.client.spec.ts b/packages/client/modules/tests/node-half.client.spec.ts index e85c6371e6..283383149c 100644 --- a/packages/client/modules/tests/node-half.client.spec.ts +++ b/packages/client/modules/tests/node-half.client.spec.ts @@ -2,7 +2,6 @@ import { Hash } from 'node:crypto' import { mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, statSync, utimesSync, writeFileSync } from 'node:fs' -import type { IncomingMessage, ServerResponse } from 'node:http' import { SourceMap } from 'node:module' import { tmpdir } from 'node:os' import { dirname, join } from 'node:path' @@ -10,7 +9,7 @@ import { pathToFileURL } from 'node:url' import { runInNewContext } from 'node:vm' import { Context, FiberState, type Fiber } from '@deepseek-ai/cordis' import { afterEach, describe, expect, it, vi } from 'vitest' -import { renderIndexInjections, type WebServer, type WebRoute } from '@deepseek-ai/dsh-host-webserver' +import { renderIndexInjections, type WebServer, type WebResourceRoute, type WebRoute } from '@deepseek-ai/dsh-host-webserver' import * as modulesClient from '../src/client/index.ts' import { ClientModuleRegistry, bootInjections, orderByModuleGraph } from '../src/index.ts' import type { ClientModuleLoaderTarget, WebBootEntry, WebBootGraph } from '../src/client/index.ts' @@ -68,7 +67,7 @@ const BOOTSTRAP_URL = comboReference([MODULES_ID], 'boot') const APPLICATION_URL = comboReference([UI_RENDERER_ID], 'app') let root: string | undefined -const contexts: { ctx: Context; ready?: Promise }[] = [] +const contexts: { ctx: Context; ready?: Promise }[] = [] afterEach(async () => { await Promise.all(contexts.splice(0).map(async ({ ctx, ready }) => { @@ -166,7 +165,7 @@ function constructWithRoute( entryBaseUrl?: string internal?: NonNullable } = {}, -): { context: Context; service: ClientModuleRegistry; route: Promise } { +): { context: Context; service: ClientModuleRegistry; route: Promise } { const ctx = new Context() const owned: typeof contexts[number] = { ctx } contexts.push(owned) @@ -184,11 +183,11 @@ function constructWithRoute( } }, }) - const route = Promise.withResolvers() + const route = Promise.withResolvers() const webServer: Pick = { port: 0, register: (candidate) => { - if (candidate.path === '/plugins') route.resolve(candidate) + if (candidate.path === '/plugins' && 'resource' in candidate) route.resolve(candidate) return () => {} }, tapIndex: () => () => {}, @@ -204,28 +203,15 @@ function construct(packageNames: string[]): ClientModuleRegistry { return constructWithRoute(packageNames).service } -/** Invoke the registered plugin route and capture status, headers, and bytes. */ -async function routeRequest(route: Promise, url: string, method = 'GET'): Promise<{ +/** Invoke the registered plugin resource and capture status, headers, and bytes. */ +async function routeRequest(route: Promise, url: string, method = 'GET'): Promise<{ status: number - headers: Record | undefined + headers: Record body: Buffer }> { - let status = 0 - let headers: Record | undefined - let body = Buffer.alloc(0) - const response = { - writeHead(nextStatus: number, nextHeaders?: Record) { - status = nextStatus - headers = nextHeaders - return response - }, - end(chunk?: Uint8Array) { - body = chunk === undefined ? Buffer.alloc(0) : Buffer.from(chunk) - return response - }, - } as unknown as ServerResponse - await (await route).handler({ method, url, headers: {} } as IncomingMessage, response) - return { status, headers, body } + const { resource } = await route + const response = await resource(new Request(new URL(url, 'http://localhost/'), { method })) + return { status: response.status, headers: Object.fromEntries(response.headers), body: Buffer.from(await response.arrayBuffer()) } } /** Execute the exact first inline script emitted by the Host boot rows. */ @@ -903,7 +889,7 @@ describe('client bundle activation', () => { expect(batch).toMatchObject({ phase: 'application', entries: [packageName] }) const batchScript = await routeRequest(route, batch.url) expect(batchScript.status).toBe(200) - expect(batchScript.headers?.['cache-control']).toBe('public, max-age=31536000, immutable') + expect(batchScript.headers['cache-control']).toBe('public, max-age=31536000, immutable') expect(batchScript.body.toString('utf8')).toContain(`//# sourceMappingURL=${mapReference(batch.url)}`) const shellResponse = await service.fetchBundle(new Request(`dsh-app://app/${batch.url}`)) expect(shellResponse.status).toBe(200) diff --git a/packages/client/modules/tests/package-assets.client.spec.ts b/packages/client/modules/tests/package-assets.client.spec.ts index acbb8889b7..1e63c951ac 100644 --- a/packages/client/modules/tests/package-assets.client.spec.ts +++ b/packages/client/modules/tests/package-assets.client.spec.ts @@ -121,7 +121,10 @@ interface Host { port: number /** Warnings any plugin of the composed Host logged. */ warnings: unknown[][] - /** Send one raw request target through the Web route and the shell carrier and require identical answers. */ + /** + * Send one raw request target over HTTP, through `webServer.readResource`, + * and through `fetchBundle`, and require identical answers. + */ both: (path: string, init?: AssetRequestInit) => Promise } @@ -159,14 +162,19 @@ async function composeHost(): Promise { outgoing.on('error', reject) outgoing.end() }) + const reader = { principal: { kind: 'owner' } } as Parameters[1] const both = async (path: string, init?: AssetRequestInit): Promise => { const answer = await http(path, init) - const response = await service.fetchBundle(new Request(`http://localhost${path}`, init)) - expect({ - status: response.status, - headers: assetHeaders(name => response.headers.get(name)), - body: Buffer.from(await response.arrayBuffer()), - }).toEqual(answer) + for (const response of [ + await context.webServer.readResource(new Request(`http://localhost${path}`, init), reader), + await service.fetchBundle(new Request(`http://localhost${path}`, init)), + ]) { + expect({ + status: response.status, + headers: assetHeaders(name => response.headers.get(name)), + body: Buffer.from(await response.arrayBuffer()), + }).toEqual(answer) + } return answer } // The Web route registers once the injected webServer is ready. @@ -268,9 +276,9 @@ describe('package asset route', () => { }) const stream = opened.findLast(entry => entry.path === file)?.stream if (stream === undefined) throw new Error('the asset route opened no file stream') - // events.once would reject on the premature-close error the cancelled pipeline reports; only the close matters here. + // The cancelled response body closes the file stream; only the close matters here. if (!stream.closed) await new Promise((resolve) => { stream.once('close', () => { resolve() }) }) - // pipeline settles its rejection in the turn after the file stream closes; the route logs nothing then. + // The webserver settles the cancelled exchange in the turn after the file stream closes; nothing is logged then. await new Promise(resolve => setImmediate(resolve)) expect(warnings).toEqual([]) }) 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/de.json b/packages/client/product-analytics/locale/de.json new file mode 100644 index 0000000000..fb577f04ad --- /dev/null +++ b/packages/client/product-analytics/locale/de.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "Statistiken zur Desktop-Nutzung", + "description": "Sammeln Sie bestimmte Interaktionsereignisse auf dem Desktop und übermitteln Sie sie an den Produktstatistikdienst." + } +} diff --git a/packages/client/product-analytics/locale/es.json b/packages/client/product-analytics/locale/es.json new file mode 100644 index 0000000000..838528b1c4 --- /dev/null +++ b/packages/client/product-analytics/locale/es.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "Estadísticas de uso de escritorio", + "description": "Recopile eventos de interacción específicos en el escritorio y envíelos al servicio de estadísticas del producto." + } +} diff --git a/packages/client/product-analytics/locale/fr.json b/packages/client/product-analytics/locale/fr.json new file mode 100644 index 0000000000..2edef0a2b3 --- /dev/null +++ b/packages/client/product-analytics/locale/fr.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "Statistiques d'utilisation du bureau", + "description": "Collectez les événements d'interaction spécifiés sur le bureau et soumettez-les au service de statistiques du produit." + } +} 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/locale/pt.json b/packages/client/product-analytics/locale/pt.json new file mode 100644 index 0000000000..de66d5a74b --- /dev/null +++ b/packages/client/product-analytics/locale/pt.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "Estatísticas de uso da área de trabalho", + "description": "Colete eventos de interação especificados na área de trabalho e envie-os ao serviço de estatísticas do produto." + } +} diff --git a/packages/client/product-analytics/locale/ru.json b/packages/client/product-analytics/locale/ru.json new file mode 100644 index 0000000000..f089774778 --- /dev/null +++ b/packages/client/product-analytics/locale/ru.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/de.json b/packages/client/resources/locale/de.json new file mode 100644 index 0000000000..c5d2e7508e --- /dev/null +++ b/packages/client/resources/locale/de.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "Ressourcen", + "description": "Lösen Sie Ressourcenadressen wie Dateien in Inhalte auf, die von der Schnittstelle gelesen werden können." + } +} diff --git a/packages/client/resources/locale/es.json b/packages/client/resources/locale/es.json new file mode 100644 index 0000000000..a09f476f6d --- /dev/null +++ b/packages/client/resources/locale/es.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "recursos", + "description": "Resuelva direcciones de recursos, como archivos, en contenido que la interfaz pueda leer." + } +} diff --git a/packages/client/resources/locale/fr.json b/packages/client/resources/locale/fr.json new file mode 100644 index 0000000000..4c02e4e9fe --- /dev/null +++ b/packages/client/resources/locale/fr.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "ressources", + "description": "Résolvez les adresses de ressources telles que les fichiers en contenu pouvant être lu par l'interface." + } +} 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/resources/locale/pt.json b/packages/client/resources/locale/pt.json new file mode 100644 index 0000000000..9b5f1c60fa --- /dev/null +++ b/packages/client/resources/locale/pt.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "recursos", + "description": "Resolva endereços de recursos, como arquivos, em conteúdo que possa ser lido pela interface." + } +} diff --git a/packages/client/resources/locale/ru.json b/packages/client/resources/locale/ru.json new file mode 100644 index 0000000000..e5571b2d32 --- /dev/null +++ b/packages/client/resources/locale/ru.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "ресурсы", + "description": "Преобразуйте адреса ресурсов, такие как файлы, в контент, который может быть прочитан интерфейсом." + } +} diff --git a/packages/client/shortcuts/locale/de.json b/packages/client/shortcuts/locale/de.json new file mode 100644 index 0000000000..0de9fbe7f1 --- /dev/null +++ b/packages/client/shortcuts/locale/de.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "Abkürzungen", + "description": "Registrieren Sie Tastenkombinationen für Anwendungen und verteilen Sie Tastenoperationen auf entsprechende Funktionen." + } +} diff --git a/packages/client/shortcuts/locale/es.json b/packages/client/shortcuts/locale/es.json new file mode 100644 index 0000000000..e6d9faac53 --- /dev/null +++ b/packages/client/shortcuts/locale/es.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "atajos", + "description": "Registre las teclas de acceso directo de la aplicación y distribuya las operaciones clave a las funciones correspondientes." + } +} diff --git a/packages/client/shortcuts/locale/fr.json b/packages/client/shortcuts/locale/fr.json new file mode 100644 index 0000000000..85dcaf0f78 --- /dev/null +++ b/packages/client/shortcuts/locale/fr.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "raccourcis", + "description": "Enregistrez les touches de raccourci de l'application et distribuez les opérations clés aux fonctions correspondantes." + } +} 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/locale/pt.json b/packages/client/shortcuts/locale/pt.json new file mode 100644 index 0000000000..10f9714ea8 --- /dev/null +++ b/packages/client/shortcuts/locale/pt.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "atalhos", + "description": "Registre teclas de atalho do aplicativo e distribua as operações das teclas para as funções correspondentes." + } +} diff --git a/packages/client/shortcuts/locale/ru.json b/packages/client/shortcuts/locale/ru.json new file mode 100644 index 0000000000..eedd6069c5 --- /dev/null +++ b/packages/client/shortcuts/locale/ru.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..ca7bbc4154 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|official-bundles|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/de.json b/packages/client/ui-agent-preset/locale/de.json new file mode 100644 index 0000000000..bb0817b9bb --- /dev/null +++ b/packages/client/ui-agent-preset/locale/de.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "Standardschnittstelle des Agenten", + "description": "Wählen Sie Agent-Standardeinstellungen für aktuelle und neue Sitzungen aus und bearbeiten Sie die von den Standardeinstellungen verwendeten Plugins." + } +} diff --git a/packages/client/ui-agent-preset/locale/es.json b/packages/client/ui-agent-preset/locale/es.json new file mode 100644 index 0000000000..71f9b56076 --- /dev/null +++ b/packages/client/ui-agent-preset/locale/es.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "Interfaz predeterminada del agente", + "description": "Seleccione los valores predeterminados del agente para las sesiones actuales y nuevas, y edite los complementos utilizados por los valores predeterminados." + } +} diff --git a/packages/client/ui-agent-preset/locale/fr.json b/packages/client/ui-agent-preset/locale/fr.json new file mode 100644 index 0000000000..55d48a7007 --- /dev/null +++ b/packages/client/ui-agent-preset/locale/fr.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "Interface par défaut de l'agent", + "description": "Sélectionnez les valeurs par défaut de l'agent pour les sessions actuelles et les nouvelles, et modifiez les plugins utilisés par les valeurs par défaut." + } +} 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/locale/pt.json b/packages/client/ui-agent-preset/locale/pt.json new file mode 100644 index 0000000000..794e0ca90c --- /dev/null +++ b/packages/client/ui-agent-preset/locale/pt.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "Interface padrão do agente", + "description": "Selecione Padrões do agente para sessões atuais e novas e edite os plug-ins usados pelos padrões." + } +} diff --git a/packages/client/ui-agent-preset/locale/ru.json b/packages/client/ui-agent-preset/locale/ru.json new file mode 100644 index 0000000000..5ee3dcb323 --- /dev/null +++ b/packages/client/ui-agent-preset/locale/ru.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "Интерфейс агента по умолчанию", + "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..d92283aad2 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,401 @@ 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 + +/** Spanish dictionary, checked against the English keys. */ +export const guideEs = { + modeExplanation: 'Detalles del modo', + howToUse: 'como usar', + guideSections: 'Secciones de guía', + guideExampleTask: 'Tarea de ejemplo', + guideCopy: 'Copiar', + guideCopied: 'Copiado', + guideFootnotes: 'Notas a pie de página', + guideStandardIntro: 'Elija el modo Estándar al iniciar una nueva tarea. Describe lo que quieres lograr, señala los archivos relevantes y explica cómo verificar el resultado.', + guideStandardExplanation: [ + '### Cómo funciona', + 'El agente llama a herramientas directamente para leer y editar archivos, buscar y ejecutar comandos de terminal. Incluye habilidades, planificación, objetivos, subagentes, flujos de trabajo, gestión de complementos y compactación de contexto.', + '### Cuándo elegirlo', + 'Comience aquí para la codificación, el trabajo con archivos y la investigación diaria. El modo estándar también puede escribir scripts y procesar archivos en lotes. PTC cambia la forma en que se organizan las llamadas a herramientas; no es necesario para tareas por lotes.', + ].join('\n\n\n\n'), + guideStandardUsage: [ + '### Corregir un error', + '> Descubra por qué enviar el formulario de búsqueda dos veces hace que los resultados desaparezcan. Arréglalo y ejecuta las pruebas pertinentes. Explique la causa y qué cambió.', + 'Resultado esperado: un cambio de código, los resultados de las pruebas relevantes y una explicación de la causa.', + '### Organizar notas del proyecto', + '> Lea las Markdown notas de este proyecto. Resuma las decisiones acordadas y las preguntas abiertas, con enlaces a los archivos fuente.', + 'Resultado esperado: un resumen con referencias que puede comparar con las notas originales.', + ].join('\n\n\n\n'), + guidePtcIntro: 'Elija el modo PTC al iniciar una nueva tarea. Especifique los archivos de entrada, las reglas de procesamiento y el formato de salida. El agente escribe el código.', + guidePtcExplanation: [ + '### Cómo se llaman las herramientas', + 'PTC significa llamada a herramientas programáticas. En este ajuste preestablecido integrado, el agente usa run_code para escribir un programa TypeScript que llama a herramientas a través de un SDK generado. El programa puede utilizar bucles, condiciones, manejo de errores y llamadas simultáneas cuando corresponda.', + '### Lo que llega al modelo', + 'Los resultados de la herramienta llegan primero al programa, que puede filtrarlos y combinarlos. El modelo recibe lo que el programa imprime o devuelve; Los resultados de la imagen se adjuntan por separado. Las llamadas a herramientas anidadas aún se registran y permanecen sujetas a los permisos de la herramienta.', + '### Comparado con el modo Estándar', + 'Ambos modos pueden manejar tareas de codificación y por lotes. El modo estándar expone herramientas individuales directamente; PTC organiza llamadas a herramientas en código. El ajuste preestablecido actual PTC deja las herramientas de administración de complementos y flujo de trabajo deshabilitadas. La velocidad y el uso de tokens dependen de la tarea y de cómo el programa maneja sus resultados.', + ].join('\n\n\n\n'), + guidePtcUsage: [ + '### Verifique un conjunto de archivos de configuración', + '> Verifique todos los JSON archivos en configs/. Enumere los campos obligatorios faltantes y los valores no válidos en el archivo Schema.json. Guarde un CSV con una fila por número. Incluya archivos ilegibles en el informe y siga revisando el resto. Deje los archivos originales sin cambios.', + 'Resultado esperado: un resumen del problema y un informe CSV. El programa puede repetir las mismas comprobaciones, gestionar fallos individuales y recopilar los resultados.', + '### Resumir registros de errores', + '> Analizar los archivos de registro en logs/. Agrupe errores por servicio y tipo de error. Muestra los diez grupos más frecuentes y un ejemplo de cada uno. Guarde los recuentos completos en un CSV.', + 'Resultado esperado: los principales grupos de errores y una tabla de recuento completa. Los datos intermedios se pueden agregar en el programa antes de que el resumen llegue al modelo.', + ].join('\n\n\n\n'), + guideMinimalIntro: 'Elija el modo mínimo para una nueva tarea. Para realizar una comparación, mantenga constantes el modelo, los permisos, la entrada y el estado inicial del espacio de trabajo en todas las ejecuciones.', + guideMinimalExplanation: [ + '### ¿Qué está incluido?', + 'Una herramienta de shell persistente, read_image, y un indicador de sistema fijo. La lectura de imágenes requiere un modelo compatible con imágenes. La historia más antigua se compacta automáticamente bajo la presión del contexto; use /compact mientras el agente esté inactivo para compactarlo manualmente. El ajuste preestablecido integrado no carga las habilidades, la planificación ni el contexto de tiempo de ejecución estándar.', + '### Cuándo elegirlo', + 'Úselo como base para experimentos y comparaciones. Todavía puede leer archivos y ejecutar scripts mediante comandos de shell, pero ofrece menos formas integradas de gestionar una tarea larga. Menos herramientas no necesariamente lo hace más fácil para un principiante.', + ].join('\n\n\n\n'), + guideMinimalUsage: [ + '### Comparar el rendimiento en una pequeña corrección de errores', + '> Ejecute las pruebas para este proyecto, encuentre la causa del error y realice la solución más pequeña. Ejecute las pruebas pertinentes nuevamente e informe el resultado.', + 'Ejecute la misma tarea por separado en los modos Estándar y Mínimo desde el mismo estado inicial. Compare la finalización de tareas, las llamadas a herramientas y los cambios resultantes. El modo mínimo realiza el trabajo a través de comandos de terminal.', + ].join('\n\n\n\n'), + guideCordisIntro: 'Elija el modo Creador para una nueva tarea. Describe la capacidad que deseas, dónde debería aparecer y cómo la verificarás.', + guideCordisExplanation: [ + '### Lo que puedes crear', + 'El modo Creador incluye las herramientas de tareas estándar además de inspección del tiempo de ejecución y orientación para crear Cordis complementos y ajustes preestablecidos de agentes. Puede crear un complemento que agregue una capacidad o interfaz de usuario, o un ajuste preestablecido que combine herramientas e indicaciones para un trabajo en particular.', + '### Complementos y modos', + 'Un complemento agrega capacidades a DSH, como una herramienta, una conexión de servicio o una entrada de UI. Un modo es un ajuste preestablecido del agente que selecciona herramientas y define cómo trabaja el agente en una tarea. Se puede incluir un complemento en un ajuste preestablecido personalizado.', + '### Cómo surte efecto el resultado', + 'Pídale al agente que instale y verifique el resultado, no solo que genere el código fuente. Un complemento puede cargarse inmediatamente o requerir un reinicio, dependiendo de lo que cambie. Se selecciona un ajuste preestablecido recién creado al iniciar una nueva tarea.', + ].join('\n\n\n\n'), + guideCordisUsage: [ + '### Agregar una interfaz de usuario', + '> Cree un complemento DSH que agregue una entrada de notas del proyecto a la barra lateral. Permítanme explorar los Markdown archivos en este espacio de trabajo y obtener una vista previa de una nota seleccionada. Instálalo y verifica que se abre la página.', + 'Resultado esperado: un complemento instalado con una entrada funcional y una página de vista previa, además de los pasos de activación restantes.', + '### Agregar una herramienta', + '> Cree un complemento con una herramienta que lea el informe de prueba de este proyecto y resuma las pruebas fallidas. Regístrelo y verifíquelo con un informe de muestra.', + 'Resultado esperado: un complemento con una herramienta invocable y una llamada de muestra verificada.', + '### Crear mi propio modo', + '> Cree un modo de “Revisión de código” basado en el modo Estándar. Haga que priorice posibles errores y pruebe las lagunas, cite rutas y líneas de archivos y pregunte antes de modificar archivos. Guárdelo como un ajuste preestablecido seleccionable.', + 'Resultado esperado: un ajuste preestablecido personalizado para nuevas tareas. Estas instrucciones de revisión guían al agente; La configuración de permisos determina qué acciones puede ejecutar.', + ].join('\n\n\n\n'), +} satisfies typeof guideEn + +/** French dictionary, checked against the English keys. */ +export const guideFr = { + modeExplanation: 'Détails du mode', + howToUse: 'Comment utiliser', + guideSections: 'Sections du guide', + guideExampleTask: 'Exemple de tâche', + guideCopy: 'Copier', + guideCopied: 'Copié', + guideFootnotes: 'Notes de bas de page', + guideStandardIntro: 'Choisissez le mode Standard lorsque vous démarrez une nouvelle tâche. Décrivez ce que vous souhaitez accomplir, pointez vers les fichiers pertinents et expliquez comment vérifier le résultat.', + guideStandardExplanation: [ + '### Comment ça marche', + "L'agent appelle directement des outils pour lire et modifier des fichiers, rechercher et exécuter des commandes de terminal. Il comprend les compétences, la planification, les objectifs, les sous-agents, les flux de travail, la gestion des plugins et le compactage du contexte.", + '### Quand le choisir', + "Commencez ici pour le codage quotidien, le travail sur les fichiers et la recherche. Le mode standard peut également écrire des scripts et traiter des fichiers par lots. PTC modifie la façon dont les appels d'outils sont organisés ; il n'est pas requis pour les tâches par lots.", + ].join('\n\n\n\n'), + guideStandardUsage: [ + "### Correction d'un bug", + '> Découvrez pourquoi le fait de soumettre deux fois le formulaire de recherche fait disparaître les résultats. Corrigez-le et exécutez les tests appropriés. Expliquez la cause et ce qui a changé.', + 'Résultat attendu : un changement de code, les résultats des tests pertinents et une explication de la cause.', + '### Organiser les notes de projet', + '> Lisez les Markdown notes de ce projet. Résumez les décisions convenues et les questions ouvertes, avec des liens vers les fichiers sources.', + 'Résultat attendu : un résumé avec des références que vous pouvez vérifier par rapport aux notes originales.', + ].join('\n\n\n\n'), + guidePtcIntro: "Choisissez le mode PTC lorsque vous démarrez une nouvelle tâche. Spécifiez les fichiers d'entrée, les règles de traitement et le format de sortie. L'agent écrit le code.", + guidePtcExplanation: [ + '### Comment sont appelés les outils', + "PTC signifie appel d'outil de programmation. Dans ce préréglage intégré, l'agent utilise run_code pour écrire un programme TypeScript qui appelle des outils via un SDK généré. Le programme peut utiliser des boucles, des conditions, une gestion des erreurs et des appels simultanés, le cas échéant.", + '### Ce qui arrive au modèle', + "Les résultats des outils parviennent d’abord au programme, qui peut les filtrer et les combiner. Le modèle reçoit ce que le programme imprime ou renvoie ; les résultats des images sont joints séparément. Les appels d'outils imbriqués sont toujours enregistrés et restent soumis aux autorisations de l'outil.", + '### Par rapport au mode Standard', + "Les deux modes peuvent gérer les tâches de codage et par lots. Le mode standard expose directement les outils individuels ; PTC organise les appels d'outils dans le code. Le préréglage PTC actuel laisse les outils de gestion de flux de travail et de plugins désactivés. La vitesse et l'utilisation des jetons dépendent de la tâche et de la manière dont le programme gère ses résultats.", + ].join('\n\n\n\n'), + guidePtcUsage: [ + '### Vérifier un ensemble de fichiers de configuration', + '> Vérifiez tous les JSON fichiers dans configs/. Répertoriez les champs obligatoires manquants et les valeurs non valides par rapport à schema.json. Enregistrez un CSV avec une ligne par numéro. Incluez les fichiers illisibles dans le rapport et continuez à vérifier le reste. Laissez les fichiers originaux inchangés.', + 'Résultat attendu : un résumé du problème et un rapport CSV. Le programme peut répéter les mêmes vérifications, gérer les échecs individuels et collecter les résultats.', + "### Résumer les journaux d'erreurs", + '> Analysez les fichiers journaux dans logs/. Regroupez les erreurs par service et type d’erreur. Montrez les dix groupes les plus fréquents et un exemple de chacun. Enregistrez les décomptes complets dans un CSV.', + "Résultat attendu : les principaux groupes d’erreurs et un tableau de décompte complet. Les données intermédiaires peuvent être agrégées dans le programme avant que le résumé n'atteigne le modèle.", + ].join('\n\n\n\n'), + guideMinimalIntro: 'Choisissez le mode Minimal pour une nouvelle tâche. À des fins de comparaison, maintenez le modèle, les autorisations, l’entrée et l’état de départ de l’espace de travail constants d’une exécution à l’autre.', + guideMinimalExplanation: [ + '### Ce qui est inclus', + "Un outil shell persistant, read_image et une invite système fixe. La lecture d'images nécessite un modèle compatible avec les images. L’historique plus ancien est automatiquement compacté sous la pression du contexte ; utilisez /compact pendant que l'agent est inactif pour le compacter manuellement. Le préréglage intégré ne charge pas les compétences, la planification ou le contexte d'exécution standard.", + '### Quand le choisir', + 'Utilisez-le comme base de référence pour des expériences et des comparaisons. Il peut toujours lire des fichiers et exécuter des scripts via des commandes shell, mais offre moins de moyens intégrés pour gérer une tâche longue. Moins d’outils ne facilite pas nécessairement la tâche d’un débutant.', + ].join('\n\n\n\n'), + guideMinimalUsage: [ + '### Comparez les performances sur une petite correction de bug', + "> Exécutez les tests pour ce projet, trouvez la cause de l'échec et effectuez le moindre correctif. Exécutez à nouveau les tests pertinents et rapportez le résultat.", + "Exécutez la même tâche séparément en modes Standard et Minimal à partir du même état de départ. Comparez l'achèvement des tâches, les appels d'outils et les modifications qui en résultent. Le mode minimal effectue le travail via les commandes du terminal.", + ].join('\n\n\n\n'), + guideCordisIntro: 'Choisissez le mode Créateur pour une nouvelle tâche. Décrivez la fonctionnalité souhaitée, où elle doit apparaître et comment vous la vérifierez.', + guideCordisExplanation: [ + '### Ce que vous pouvez créer', + "Le mode Créateur inclut les outils de tâches standard, ainsi qu'une inspection d'exécution et des conseils pour la création de Cordis plug-ins et préréglages d'agent. Il peut créer un plugin qui ajoute une fonctionnalité ou une interface utilisateur, ou un préréglage combinant des outils et des invites pour une tâche particulière.", + '### Plugins et modes', + "Un plug-in ajoute des fonctionnalités à DSH, telles qu'un outil, une connexion à un service ou une entrée d'interface utilisateur. Un mode est un préréglage d'agent qui sélectionne les outils et définit la manière dont l'agent fonctionne dans une tâche. Un plugin peut être inclus dans un préréglage personnalisé.", + '### Comment le résultat prend effet', + "Demandez à l'agent d'installer et de vérifier le résultat, pas seulement de générer le code source. Un plugin peut se charger immédiatement ou nécessiter un redémarrage, en fonction de ce qu'il modifie. Un préréglage nouvellement créé est sélectionné lors du démarrage d'une nouvelle tâche.", + ].join('\n\n\n\n'), + guideCordisUsage: [ + '### Ajouter une interface utilisateur', + "> Créez un plugin DSH qui ajoute une entrée de notes de projet à la barre latérale. Permettez-moi de parcourir les Markdown fichiers de cet espace de travail et de prévisualiser une note sélectionnée. Installez-le et vérifiez que la page s'ouvre.", + "Résultat attendu : un plugin installé avec une page d'entrée et d'aperçu fonctionnelle, ainsi que toutes les étapes d'activation restantes.", + '### Ajouter un outil', + '> Créez un plugin avec un outil qui lit le rapport de test de ce projet et résume les tests échoués. Enregistrez-le et vérifiez-le avec un exemple de rapport.', + "Résultat attendu : un plugin avec un outil appelable et un exemple d'appel vérifié.", + '### Créer mon propre mode', + '> Créer un mode « Revue de code » basé sur le mode Standard. Demandez-lui de prioriser les bogues potentiels et de tester les lacunes, de citer les chemins et les lignes des fichiers et de demander avant de modifier les fichiers. Enregistrez-le en tant que préréglage sélectionnable.', + "Résultat attendu : un préréglage personnalisé pour les nouvelles tâches. Ces instructions de révision guident l'agent ; les paramètres d'autorisation déterminent les actions qu'il peut exécuter.", + ].join('\n\n\n\n'), +} satisfies typeof guideEn + +/** German dictionary, checked against the English keys. */ +export const guideDe = { + modeExplanation: 'Modusdetails', + howToUse: 'Wie zu verwenden', + guideSections: 'Leitfadenabschnitte', + guideExampleTask: 'Beispielaufgabe', + guideCopy: 'Kopieren', + guideCopied: 'Kopiert', + guideFootnotes: 'Fußnoten', + guideStandardIntro: 'Wählen Sie den Standardmodus, wenn Sie eine neue Aufgabe starten. Beschreiben Sie, was Sie erreichen möchten, zeigen Sie auf die relevanten Dateien und erklären Sie, wie Sie das Ergebnis überprüfen können.', + guideStandardExplanation: [ + '### Wie es funktioniert', + 'Der Agent ruft Tools direkt auf, um Dateien zu lesen und zu bearbeiten, zu suchen und Terminalbefehle auszuführen. Es umfasst Fähigkeiten, Planung, Ziele, Subagenten, Arbeitsabläufe, Plugin-Verwaltung und Kontextkomprimierung.', + '### Wann man es wählt', + 'Beginnen Sie hier mit alltäglicher Codierung, Dateiarbeit und Recherche. Im Standardmodus können auch Skripts geschrieben und Dateien stapelweise verarbeitet werden. PTC ändert die Organisation von Toolaufrufen; Für Batch-Aufgaben ist es nicht erforderlich.', + ].join('\n\n\n\n'), + guideStandardUsage: [ + '### Einen Fehler beheben', + '> Finden Sie heraus, warum das zweimalige Absenden des Suchformulars dazu führt, dass die Ergebnisse verschwinden. Beheben Sie das Problem und führen Sie die entsprechenden Tests durch. Erklären Sie die Ursache und was sich geändert hat.', + 'Erwartete Ausgabe: eine Codeänderung, die relevanten Testergebnisse und eine Erklärung der Ursache.', + '### Projektnotizen organisieren', + '> Lesen Sie die Markdown-Hinweise in diesem Projekt. Fassen Sie die vereinbarten Entscheidungen und offenen Fragen zusammen, mit Links zu den Quelldateien.', + 'Erwartete Ausgabe: eine Zusammenfassung mit Referenzen, die Sie mit den Originalnotizen vergleichen können.', + ].join('\n\n\n\n'), + guidePtcIntro: 'Wählen Sie den Modus PTC, wenn Sie eine neue Aufgabe starten. Geben Sie die Eingabedateien, Verarbeitungsregeln und das Ausgabeformat an. Der Agent schreibt den Code.', + guidePtcExplanation: [ + '### Wie Werkzeuge heißen', + 'PTC bedeutet Programmatic Tool Calling. In dieser integrierten Voreinstellung verwendet der Agent run_code, um ein TypeScript-Programm zu schreiben, das Tools über ein generiertes SDK aufruft. Das Programm kann gegebenenfalls Schleifen, Bedingungen, Fehlerbehandlung und gleichzeitige Aufrufe verwenden.', + '### Was das Modell erreicht', + 'Werkzeugergebnisse gelangen zunächst in das Programm, das diese filtern und kombinieren kann. Das Modell empfängt, was das Programm ausgibt oder zurückgibt; Bildergebnisse sind separat beigefügt. Verschachtelte Werkzeugaufrufe werden weiterhin aufgezeichnet und unterliegen weiterhin den Werkzeugberechtigungen.', + '### Im Vergleich zum Standardmodus', + 'Beide Modi können Codierungs- und Batch-Aufgaben bewältigen. Im Standardmodus werden einzelne Tools direkt verfügbar gemacht. PTC organisiert Toolaufrufe im Code. Die aktuelle Voreinstellung PTC lässt die Workflow- und Plugin-Verwaltungstools deaktiviert. Geschwindigkeit und Token-Nutzung hängen von der Aufgabe ab und davon, wie das Programm mit seinen Ergebnissen umgeht.', + ].join('\n\n\n\n'), + guidePtcUsage: [ + '### Überprüfen Sie eine Reihe von Konfigurationsdateien', + '> Überprüfen Sie alle JSON Dateien in configs/. Fehlende Pflichtfelder und ungültige Werte für schema.json auflisten. Speichern Sie eine CSV-Datei mit einer Zeile pro Ausgabe. Fügen Sie unleserliche Dateien in den Bericht ein und überprüfen Sie den Rest weiter. Lassen Sie die Originaldateien unverändert.', + 'Erwartete Ausgabe: eine Problemzusammenfassung und ein CSV-Bericht. Das Programm kann dieselben Prüfungen wiederholen, einzelne Fehler behandeln und die Ergebnisse sammeln.', + '### Fehlerprotokolle zusammenfassen', + '> Analysieren Sie die Protokolldateien in logs/. Gruppieren Sie Fehler nach Dienst und Fehlertyp. Zeigen Sie die zehn häufigsten Gruppen und jeweils ein Beispiel. Speichern Sie die vollständigen Zählungen in einer CSV-Datei.', + 'Erwartete Ausgabe: die Top-Fehlergruppen und eine vollständige Zähltabelle. Zwischendaten können im Programm aggregiert werden, bevor die Zusammenfassung das Modell erreicht.', + ].join('\n\n\n\n'), + guideMinimalIntro: 'Wählen Sie den Minimalmodus für eine neue Aufgabe. Halten Sie für einen Vergleich das Modell, die Berechtigungen, die Eingabe und den Status des Startarbeitsbereichs über alle Ausführungen hinweg konstant.', + guideMinimalExplanation: [ + '### Was ist enthalten', + 'Ein persistentes Shell-Tool, read_image, und eine feste Systemeingabeaufforderung. Das Lesen von Bildern erfordert ein bildfähiges Modell. Der ältere Verlauf wird unter Kontextdruck automatisch komprimiert; Verwenden Sie /compact, während der Agent inaktiv ist, um ihn manuell zu komprimieren. Die integrierte Voreinstellung lädt weder Skills, Planung noch den Standardlaufzeitkontext.', + '### Wann man es wählt', + 'Verwenden Sie es als Grundlage für Experimente und Vergleiche. Es kann weiterhin Dateien lesen und Skripte über Shell-Befehle ausführen, bietet jedoch weniger integrierte Möglichkeiten zur Verwaltung einer langen Aufgabe. Weniger Werkzeuge machen es einem Anfänger nicht unbedingt einfacher.', + ].join('\n\n\n\n'), + guideMinimalUsage: [ + '### Vergleichen Sie die Leistung mit einer kleinen Fehlerbehebung', + '> Führen Sie die Tests für dieses Projekt durch, finden Sie die Fehlerursache und nehmen Sie die kleinste Lösung vor. Führen Sie die entsprechenden Tests erneut durch und melden Sie das Ergebnis.', + 'Führen Sie dieselbe Aufgabe separat im Standard- und Minimalmodus aus, und zwar im selben Startzustand. Vergleichen Sie die Aufgabenerledigung, Werkzeugaufrufe und die daraus resultierenden Änderungen. Der Minimalmodus führt die Arbeit über Terminalbefehle aus.', + ].join('\n\n\n\n'), + guideCordisIntro: 'Wählen Sie den Erstellermodus für eine neue Aufgabe. Beschreiben Sie die gewünschte Funktion, wo sie angezeigt werden soll und wie Sie sie überprüfen.', + guideCordisExplanation: [ + '### Was Sie erstellen können', + 'Der Erstellermodus umfasst die Standardaufgabentools sowie eine Laufzeitprüfung und Anleitungen zum Erstellen von Cordis-Plugins und Agent-Voreinstellungen. Es kann ein Plugin erstellt werden, das eine Funktion oder Benutzeroberfläche hinzufügt, oder eine Voreinstellung, die Tools und Eingabeaufforderungen für einen bestimmten Job kombiniert.', + '### Plugins und Modi', + 'Ein Plugin fügt DSH Funktionen hinzu, beispielsweise ein Tool, eine Dienstverbindung oder einen UI-Eintrag. Ein Modus ist eine Agentenvoreinstellung, die Tools auswählt und definiert, wie der Agent in einer Aufgabe arbeitet. Ein Plugin kann in eine benutzerdefinierte Voreinstellung eingebunden werden.', + '### Wie das Ergebnis wirksam wird', + 'Bitten Sie den Agenten, das Ergebnis zu installieren und zu überprüfen und nicht nur Quellcode zu generieren. Abhängig von den Änderungen kann ein Plugin sofort geladen werden oder einen Neustart erfordern. Beim Starten einer neuen Aufgabe wird eine neu erstellte Voreinstellung ausgewählt.', + ].join('\n\n\n\n'), + guideCordisUsage: [ + '### Fügen Sie eine Benutzeroberfläche hinzu', + '> Erstellen Sie ein DSH-Plugin, das der Seitenleiste einen Eintrag für Projektnotizen hinzufügt. Lassen Sie mich die Markdown Dateien in diesem Arbeitsbereich durchsuchen und eine Vorschau einer ausgewählten Notiz anzeigen. Installieren Sie es und stellen Sie sicher, dass die Seite geöffnet wird.', + 'Erwartete Ausgabe: ein installiertes Plugin mit einer funktionierenden Einstiegs- und Vorschauseite sowie allen verbleibenden Aktivierungsschritten.', + '### Ein Werkzeug hinzufügen', + '> Erstellen Sie ein Plugin mit einem Tool, das den Testbericht dieses Projekts liest und die fehlgeschlagenen Tests zusammenfasst. Registrieren Sie es und überprüfen Sie es anhand eines Beispielberichts.', + 'Erwartete Ausgabe: ein Plugin mit einem aufrufbaren Tool und einem verifizierten Beispielaufruf.', + '### Erstelle meinen eigenen Modus', + '> Erstellen Sie einen „Code Review“-Modus basierend auf dem Standardmodus. Lassen Sie potenzielle Fehler priorisieren und Lücken testen, geben Sie Dateipfade und -zeilen an und fragen Sie nach, bevor Sie Dateien ändern. Speichern Sie es als auswählbare Voreinstellung.', + 'Erwartete Ausgabe: eine benutzerdefinierte Voreinstellung für neue Aufgaben. Diese Überprüfungsanweisungen leiten den Agenten; Berechtigungseinstellungen bestimmen, welche Aktionen ausgeführt werden können.', + ].join('\n\n\n\n'), +} satisfies typeof guideEn + +/** Russian dictionary, checked against the English keys. */ +export const guideRu = { + modeExplanation: 'Подробности режима', + howToUse: 'Как использовать', + guideSections: 'Разделы руководства', + guideExampleTask: 'Пример задачи', + guideCopy: 'Копировать', + guideCopied: 'Скопировано', + guideFootnotes: 'Сноски', + guideStandardIntro: 'Выбирайте стандартный режим при запуске новой задачи. Опишите, чего вы хотите добиться, укажите соответствующие файлы и объясните, как проверить результат.', + guideStandardExplanation: [ + '### Как это работает', + 'Агент напрямую вызывает инструменты для чтения и редактирования файлов, поиска и выполнения команд терминала. Он включает в себя навыки, планирование, цели, субагенты, рабочие процессы, управление плагинами и сжатие контекста.', + '### Когда это выбирать', + 'Начните здесь ежедневное программирование, работу с файлами и исследования. Стандартный режим также позволяет писать сценарии и обрабатывать файлы в пакетном режиме. PTC изменяет организацию вызовов инструментов; для пакетных задач это не требуется.', + ].join('\n\n\n\n'), + guideStandardUsage: [ + '### Исправить ошибку', + '> Узнайте, почему при двойной отправке формы поиска результаты исчезают. Исправьте это и запустите соответствующие тесты. Объясните причину и что изменилось.', + 'Ожидаемый результат: изменение кода, соответствующие результаты испытаний и объяснение причины.', + '### Организация заметок по проекту', + '> Прочтите примечания Markdown в этом проекте. Кратко изложите согласованные решения и открытые вопросы со ссылками на исходные файлы.', + 'Ожидаемый результат: резюме со ссылками, которые вы можете сверить с исходными заметками.', + ].join('\n\n\n\n'), + guidePtcIntro: 'Выберите режим PTC при запуске новой задачи. Укажите входные файлы, правила обработки и выходной формат. Агент пишет код.', + guidePtcExplanation: [ + '### Как называются инструменты', + 'PTC означает программный вызов инструмента. В этой встроенной настройке агент использует run_code для написания программы TypeScript, которая вызывает инструменты через сгенерированный SDK. Программа может использовать циклы, условия, обработку ошибок и параллельные вызовы, где это необходимо.', + '### Что достигает модели', + 'Результаты инструмента сначала поступают в программу, которая может их фильтровать и комбинировать. Модель получает то, что программа печатает или возвращает; Результаты изображения прилагаются отдельно. Вызовы вложенных инструментов по-прежнему записываются, и для них действуют разрешения инструмента.', + '### По сравнению со стандартным режимом', + 'Оба режима могут выполнять кодирование и пакетные задачи. Стандартный режим напрямую предоставляет доступ к отдельным инструментам; PTC организует вызовы инструментов в коде. Текущая предустановка PTC оставляет инструменты управления рабочим процессом и плагинами отключенными. Скорость и использование токенов зависят от задачи и того, как программа обрабатывает ее результаты.', + ].join('\n\n\n\n'), + guidePtcUsage: [ + '### Проверка набора файлов конфигурации', + '> Проверьте все файлы JSON в configs/. Перечислите отсутствующие обязательные поля и недопустимые значения в файле Schema.json. Сохраните CSV-файл с одной строкой на каждую проблему. Включите в отчет нечитаемые файлы и продолжайте проверять остальные. Оставьте исходные файлы без изменений.', + 'Ожидаемый результат: краткое описание проблемы и отчет в формате CSV. Программа может повторять одни и те же проверки, обрабатывать отдельные сбои и собирать результаты.', + '### Обобщение журналов ошибок', + '> Проанализируйте файлы журналов в logs/. Группируйте ошибки по сервису и типу ошибки. Покажите десять наиболее часто встречающихся групп и по одному примеру из каждой. Сохраните полные данные в формате CSV.', + 'Ожидаемый результат: основные группы ошибок и полная таблица подсчета. Промежуточные данные могут быть агрегированы в программе до того, как сводка достигнет модели.', + ].join('\n\n\n\n'), + guideMinimalIntro: 'Выберите минимальный режим для новой задачи. Для сравнения сохраняйте постоянство модели, разрешений, входных данных и состояния начальной рабочей области во всех запусках.', + guideMinimalExplanation: [ + '### Что включено', + 'Один постоянный инструмент оболочки read_image и фиксированное системное приглашение. Для чтения изображений требуется модель с поддержкой изображений. Старая история автоматически сжимается под давлением контекста; используйте /compact, пока агент простаивает, чтобы сжать его вручную. Встроенная предустановка не загружает навыки, планирование или стандартный контекст времени выполнения.', + '### Когда это выбирать', + 'Используйте его как основу для экспериментов и сравнений. Он по-прежнему может читать файлы и выполнять сценарии с помощью команд оболочки, но предлагает меньше встроенных способов управления длительными задачами. Меньшее количество инструментов не обязательно облегчает работу новичку.', + ].join('\n\n\n\n'), + guideMinimalUsage: [ + '### Сравните производительность с исправлением небольшой ошибки', + '> Запустите тесты для этого проекта, найдите причину сбоя и внесите минимальные исправления. Запустите соответствующие тесты еще раз и сообщите о результате.', + 'Запустите одну и ту же задачу отдельно в стандартном и минимальном режимах из одного и того же начального состояния. Сравните выполнение задач, вызовы инструментов и результирующие изменения. Минимальный режим выполняет работу посредством команд терминала.', + ].join('\n\n\n\n'), + guideCordisIntro: 'Выберите режим «Создатель» для новой задачи. Опишите желаемую возможность, где она должна появиться и как вы ее проверите.', + guideCordisExplanation: [ + '### Что вы можете создать', + 'Режим Creator включает в себя стандартные инструменты задач, а также проверку во время выполнения и рекомендации по созданию подключаемых модулей Cordis и предустановок агента. Он может создать плагин, добавляющий возможности или пользовательский интерфейс, или предустановку, объединяющую инструменты и подсказки для конкретной работы.', + '### Плагины и режимы', + 'Плагин добавляет к DSH возможности, такие как инструмент, подключение к службе или запись пользовательского интерфейса. Режим — это предустановка агента, которая выбирает инструменты и определяет, как агент работает над задачей. Плагин может быть включен в пользовательский пресет.', + '### Как результат вступает в силу', + 'Попросите агента установить и проверить результат, а не просто генерировать исходный код. Плагин может загрузиться немедленно или потребовать перезагрузки, в зависимости от того, что он меняет. Вновь созданный пресет выбирается при запуске новой задачи.', + ].join('\n\n\n\n'), + guideCordisUsage: [ + '### Добавьте пользовательский интерфейс', + '> Создайте плагин DSH, который добавляет запись примечаний к проекту на боковую панель. Позвольте мне просмотреть файлы Markdown в этом рабочем пространстве и просмотреть выбранную заметку. Установите его и убедитесь, что страница открывается.', + 'Ожидаемый результат: установленный плагин с работающей страницей входа и предварительного просмотра, а также все оставшиеся шаги активации.', + '### Добавить инструмент', + '> Создайте плагин с инструментом, который читает отчет об испытаниях этого проекта и суммирует неудачные тесты. Зарегистрируйте его и проверьте с помощью образца отчета.', + 'Ожидаемый результат: плагин с вызываемым инструментом и проверенным примером вызова.', + '### Создать свой режим', + '> Создайте режим «Проверка кода» на основе стандартного режима. Попросите его определить приоритетность потенциальных ошибок и проверить пробелы, указать пути и строки к файлам и спросить, прежде чем изменять файлы. Сохраните его как выбираемую предустановку.', + 'Ожидаемый результат: индивидуальная настройка для новых задач. Эти инструкции по проверке помогут агенту; настройки разрешений определяют, какие действия он может выполнять.', + ].join('\n\n\n\n'), +} satisfies typeof guideEn + +/** Portuguese dictionary, checked against the English keys. */ +export const guidePt = { + modeExplanation: 'Detalhes do modo', + howToUse: 'Como usar', + guideSections: 'Seções do guia', + guideExampleTask: 'Tarefa de exemplo', + guideCopy: 'Copiar', + guideCopied: 'Copiado', + guideFootnotes: 'Notas de rodapé', + guideStandardIntro: 'Escolha o modo Padrão ao iniciar uma nova tarefa. Descreva o que você deseja realizar, indique os arquivos relevantes e explique como verificar o resultado.', + guideStandardExplanation: [ + '### Como funciona', + 'O agente chama ferramentas diretamente para ler e editar arquivos, pesquisar e executar comandos de terminal. Inclui habilidades, planejamento, metas, subagentes, fluxos de trabalho, gerenciamento de plugins e compactação de contexto.', + '### Quando escolher', + 'Comece aqui para codificação diária, trabalho de arquivo e pesquisa. O modo padrão também pode escrever scripts e processar arquivos em lotes. PTC altera a forma como as chamadas de ferramenta são organizadas; não é necessário para tarefas em lote.', + ].join('\n\n\n\n'), + guideStandardUsage: [ + '### Corrigir um bug', + '> Descubra por que enviar o formulário de pesquisa duas vezes faz com que os resultados desapareçam. Corrija-o e execute os testes relevantes. Explique a causa e o que mudou.', + 'Resultado esperado: uma alteração de código, os resultados de teste relevantes e uma explicação da causa.', + '### Organize notas do projeto', + '> Leia as Markdown notas deste projeto. Resuma as decisões acordadas e as questões abertas, com links para os arquivos originais.', + 'Resultado esperado: um resumo com referências que você pode comparar com as notas originais.', + ].join('\n\n\n\n'), + guidePtcIntro: 'Escolha o modo PTC ao iniciar uma nova tarefa. Especifique os arquivos de entrada, regras de processamento e formato de saída. O agente escreve o código.', + guidePtcExplanation: [ + '### Como as ferramentas são chamadas', + 'PTC significa chamada de ferramenta programática. Nesta predefinição integrada, o agente usa run_code para escrever um programa TypeScript que chama ferramentas por meio de um SDK gerado. O programa pode usar loops, condições, tratamento de erros e chamadas simultâneas quando apropriado.', + '### O que chega ao modelo', + 'Os resultados da ferramenta chegam primeiro ao programa, que pode filtrá-los e combiná-los. O modelo recebe o que o programa imprime ou retorna; os resultados da imagem são anexados separadamente. As chamadas de ferramentas aninhadas ainda são gravadas e permanecem sujeitas às permissões da ferramenta.', + '### Comparado com o modo Padrão', + 'Ambos os modos podem lidar com tarefas de codificação e em lote. O modo padrão expõe ferramentas individuais diretamente; PTC organiza chamadas de ferramentas em código. A predefinição PTC atual deixa o fluxo de trabalho e as ferramentas de gerenciamento de plug-ins desativados. A velocidade e o uso do token dependem da tarefa e de como o programa lida com seus resultados.', + ].join('\n\n\n\n'), + guidePtcUsage: [ + '### Verifique um conjunto de arquivos de configuração', + '> Verifique todos os arquivos JSON em configs/. Liste os campos obrigatórios ausentes e os valores inválidos em relação ao schema.json. Salve um CSV com uma linha por problema. Inclua arquivos ilegíveis no relatório e continue verificando o restante. Deixe os arquivos originais inalterados.', + 'Resultado esperado: um resumo do problema e um relatório CSV. O programa pode repetir as mesmas verificações, tratar falhas individuais e coletar os resultados.', + '### Resumir logs de erros', + '> Analise os arquivos de log em logs/. Agrupe erros por serviço e tipo de erro. Mostre os dez grupos mais frequentes e um exemplo de cada. Salve as contagens completas em um CSV.', + 'Resultado esperado: os principais grupos de erros e uma tabela de contagem completa. Os dados intermediários podem ser agregados no programa antes que o resumo chegue ao modelo.', + ].join('\n\n\n\n'), + guideMinimalIntro: 'Escolha o modo Mínimo para uma nova tarefa. Para uma comparação, mantenha o modelo, as permissões, a entrada e o estado inicial do espaço de trabalho constantes em todas as execuções.', + guideMinimalExplanation: [ + '### O que está incluído', + 'Uma ferramenta de shell persistente, read_image, e um prompt de sistema fixo. A leitura de imagens requer um modelo com capacidade de imagem. O histórico mais antigo é compactado automaticamente sob pressão do contexto; use /compact enquanto o agente estiver ocioso para compactá-lo manualmente. A predefinição integrada não carrega habilidades, planejamento ou contexto de tempo de execução padrão.', + '### Quando escolher', + 'Use-o como base para experimentos e comparações. Ele ainda pode ler arquivos e executar scripts por meio de comandos shell, mas oferece menos maneiras integradas de gerenciar uma tarefa longa. Menos ferramentas não torna necessariamente mais fácil para um iniciante.', + ].join('\n\n\n\n'), + guideMinimalUsage: [ + '### Compare o desempenho em uma pequena correção de bug', + '> Execute os testes para este projeto, encontre a causa da falha e faça a menor correção. Execute os testes relevantes novamente e relate o resultado.', + 'Execute a mesma tarefa separadamente nos modos Padrão e Mínimo a partir do mesmo estado inicial. Compare a conclusão da tarefa, as chamadas de ferramentas e as alterações resultantes. O modo mínimo executa o trabalho por meio de comandos de terminal.', + ].join('\n\n\n\n'), + guideCordisIntro: 'Escolha o modo Criador para uma nova tarefa. Descreva o recurso desejado, onde ele deve aparecer e como você o verificará.', + guideCordisExplanation: [ + '### O que você pode criar', + 'O modo Criador inclui ferramentas de tarefas padrão, além de inspeção de tempo de execução e orientação para criação de Cordis plug-ins e predefinições de agente. Ele pode criar um plug-in que adicione um recurso ou UI, ou uma predefinição que combine ferramentas e prompts para um trabalho específico.', + '### Plug-ins e modos', + 'Um plug-in adiciona recursos a DSH, como uma ferramenta, uma conexão de serviço ou uma entrada de IU. Um modo é uma predefinição de agente que seleciona ferramentas e define como o agente trabalha em uma tarefa. Um plugin pode ser incluído em uma predefinição personalizada.', + '### Como o resultado entra em vigor', + 'Peça ao agente para instalar e verificar o resultado, não apenas gerar o código-fonte. Um plug-in pode carregar imediatamente ou exigir uma reinicialização, dependendo do que for alterado. Uma predefinição recém-criada é selecionada ao iniciar uma nova tarefa.', + ].join('\n\n\n\n'), + guideCordisUsage: [ + '### Adicionar uma IU', + '> Crie um plug-in DSH que adicione uma entrada de notas do projeto à barra lateral. Deixe-me navegar pelos arquivos Markdown nesta área de trabalho e visualizar uma nota selecionada. Instale-o e verifique se a página abre.', + 'Resultado esperado: um plug-in instalado com uma entrada funcional e uma página de visualização, além de quaisquer etapas de ativação restantes.', + '### Adicionar uma ferramenta', + '> Crie um plugin com uma ferramenta que leia o relatório de teste deste projeto e resuma os testes que falharam. Registre-o e verifique-o com um relatório de amostra.', + 'Resultado esperado: um plugin com uma ferramenta que pode ser chamada e uma chamada de amostra verificada.', + '### Crie meu próprio modo', + '> Crie um modo “Revisão de código” baseado no modo Padrão. Faça com que ele priorize possíveis bugs e teste lacunas, cite caminhos e linhas de arquivos e pergunte antes de modificar arquivos. Salve-o como uma predefinição selecionável.', + 'Resultado esperado: uma predefinição personalizada para novas tarefas. Estas instruções de revisão orientam o agente; as configurações de permissão determinam quais ações ele pode executar.', + ].join('\n\n\n\n'), +} satisfies typeof guideEn diff --git a/packages/client/ui-agent-preset/src/client/index.ts b/packages/client/ui-agent-preset/src/client/index.ts index 6988c1cc4f..b9e1363c6d 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, es, fr, de, ru, pt, 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, es, fr, de, ru, pt }), '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..f5a3c8ed67 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, guideEs, guideFr, guideDe, guideRu, guidePt, guideZh, type PresetGuideKey } from './guide-locales.ts' /** Locale keys these surfaces render. */ export type AgentPresetSettingsKey = @@ -125,3 +125,261 @@ 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 + +/** Spanish dictionary, checked against the English keys. */ +export const es = { + ...guideEs, + builtInGroup: 'Incorporado', customGroup: 'personalizado', + sectionIntro: 'Elija las herramientas del agente y cómo funciona. Utilice el modo Estándar para las tareas diarias o el modo Creador para agregar capacidades a DSH.', + + seatHint: 'Elija el agente preestablecido para su nueva tarea', + headerHint: 'El agente preestablecido elegido cuando comenzó esta tarea', + nav: 'Ajustes preestablecidos del agente', + + setDefault: 'Establecer como nueva tarea predeterminada', + view: 'Ver configuración', + + presetStandardName: 'Modo estándar', + presetStandardDescription: + 'Trabaje con código, archivos e información. Adecuado para la mayoría de las tareas, con búsqueda, edición, comandos de terminal y otras herramientas disponibles según sea necesario.', + presetPtcName: 'Modo PTC', + presetPtcDescription: + 'Incluye todas las capacidades del modo Estándar. Más adecuado para tareas que llaman a herramientas en lotes y luego filtran, organizan, deduplican, cuentan o resumen los resultados.', + presetMinimalName: 'modo mínimo', + presetMinimalDescription: + 'El agente utiliza un terminal y lectura de imágenes, con compactación de contexto. Útil para probar y comparar su rendimiento básico.', + presetCordisName: 'Modo creador', + presetCordisDescription: + 'Personaliza DSH a través de la conversación. Deje que el agente escriba complementos que agreguen funciones o interfaz de usuario, o combine herramientas e indicaciones para crear su propio modo.', + + inUse: 'Nueva tarea predeterminada', + + noDescription: 'Sin descripción.', + brokenBadge: 'No se pudo cargar', + + switchRefused: 'No se pudo cambiar a {name}: {reason}', + standardUnavailable: 'El modo estándar no está disponible. Restáuralo o elige otro modo disponible.', + + close: 'Cerrar', + + creatorDraft: 'Deja que el agente me ayude a crear un preset', + createPlugin: 'Deje que el agente cree un complemento', + createPluginDescription: 'Ingrese al modo Creador y cree su propio complemento DSH', + createPluginChecking: 'Comprobando si el modo Creador está disponible', + createPluginUnavailable: 'Temporalmente no disponible. Vuelve a abrir este menú para volver a intentarlo.', + createPluginMissing: 'El modo creador no está incluido en esta configuración.', + +} satisfies Record + +/** French dictionary, checked against the English keys. */ +export const fr = { + ...guideFr, + builtInGroup: 'Intégré', customGroup: 'Personnalisé', + sectionIntro: 'Choisissez les outils de l’agent et son fonctionnement. Utilisez le mode Standard pour les tâches quotidiennes ou le mode Créateur pour ajouter des fonctionnalités à DSH.', + + seatHint: "Choisissez le préréglage d'agent pour votre nouvelle tâche", + headerHint: "Le préréglage d'agent choisi au démarrage de cette tâche", + nav: "Préréglages d'agent", + + setDefault: 'Définir comme nouvelle tâche par défaut', + view: 'Afficher la configuration', + + presetStandardName: 'Mode standard', + presetStandardDescription: + 'Travaillez avec du code, des fichiers et des informations. Convient à la plupart des tâches, avec recherche, édition, commandes de terminal et autres outils disponibles selon les besoins.', + presetPtcName: 'Mode PTC', + presetPtcDescription: + 'Inclut toutes les fonctionnalités du mode Standard. Mieux adapté aux tâches qui appellent des outils par lots, puis filtrent, organisent, dédupliquent, comptent ou résument les résultats.', + presetMinimalName: 'Mode minimal', + presetMinimalDescription: + "L'agent utilise un terminal et une lecture d'image, avec compactage du contexte. Utile pour tester et comparer ses performances de base.", + presetCordisName: 'Mode Créateur', + presetCordisDescription: + "Personnalisez DSH via la conversation. Laissez l'agent écrire des plugins qui ajoutent des fonctionnalités ou une interface utilisateur, ou combinez des outils et des invites pour créer votre propre mode.", + + inUse: 'Nouvelle tâche par défaut', + + noDescription: 'Aucune description.', + brokenBadge: 'Échec du chargement', + + switchRefused: 'Impossible de passer à {name} : {reason}', + standardUnavailable: "Le mode standard n'est pas disponible. Restaurez-le ou choisissez un autre mode disponible.", + + close: 'Fermer', + + creatorDraft: "Laissez l'agent m'aider à créer un préréglage", + createPlugin: "Laissez l'agent créer un plugin", + createPluginDescription: 'Entrez en mode Créateur et créez votre propre plugin DSH', + createPluginChecking: 'Vérifier si le mode Créateur est disponible', + createPluginUnavailable: 'Temporairement indisponible. Rouvrez ce menu pour réessayer', + createPluginMissing: "Le mode Créateur n'est pas inclus dans cette configuration", + +} satisfies Record + +/** German dictionary, checked against the English keys. */ +export const de = { + ...guideDe, + builtInGroup: 'Eingebaut', customGroup: 'Benutzerdefiniert', + sectionIntro: 'Wählen Sie die Tools des Agenten und deren Funktionsweise aus. Verwenden Sie den Standardmodus für alltägliche Aufgaben oder den Creator-Modus, um DSH Funktionen hinzuzufügen.', + + seatHint: 'Wählen Sie die Agentenvoreinstellung für Ihre neue Aufgabe', + headerHint: 'Die Agentenvoreinstellung, die beim Start dieser Aufgabe ausgewählt wurde', + nav: 'Agentenvoreinstellungen', + + setDefault: 'Als Standard für neue Aufgabe festlegen', + view: 'Konfiguration anzeigen', + + presetStandardName: 'Standardmodus', + presetStandardDescription: + 'Arbeiten Sie mit Code, Dateien und Informationen. Geeignet für die meisten Aufgaben, da bei Bedarf Suche, Bearbeitung, Terminalbefehle und andere Tools verfügbar sind.', + presetPtcName: 'PTC-Modus', + presetPtcDescription: + 'Beinhaltet alle Funktionen des Standardmodus. Besser geeignet für Aufgaben, bei denen Tools stapelweise aufgerufen und die Ergebnisse dann gefiltert, organisiert, dedupliziert, gezählt oder zusammengefasst werden.', + presetMinimalName: 'Minimalmodus', + presetMinimalDescription: + 'Der Agent verwendet ein Terminal und das Lesen von Bildern mit Kontextkomprimierung. Nützlich zum Testen und Vergleichen der Grundleistung.', + presetCordisName: 'Erstellermodus', + presetCordisDescription: + 'Passen Sie DSH durch Gespräche an. Lassen Sie den Agenten Plugins schreiben, die Funktionen oder Benutzeroberflächen hinzufügen, oder kombinieren Sie Tools und Eingabeaufforderungen, um Ihren eigenen Modus zu erstellen.', + + inUse: 'Neuer Aufgabenstandard', + + noDescription: 'Keine Beschreibung.', + brokenBadge: 'Laden fehlgeschlagen', + + switchRefused: 'Konnte nicht zu {name} wechseln: {reason}', + standardUnavailable: 'Der Standardmodus ist nicht verfügbar. Stellen Sie es wieder her oder wählen Sie einen anderen verfügbaren Modus.', + + close: 'Schließen', + + creatorDraft: 'Lassen Sie sich vom Agenten beim Erstellen einer Voreinstellung helfen', + createPlugin: 'Lassen Sie den Agenten ein Plugin erstellen', + createPluginDescription: 'Wechseln Sie in den Erstellermodus und erstellen Sie Ihr eigenes DSH-Plugin', + createPluginChecking: 'Prüfen, ob der Creator-Modus verfügbar ist', + createPluginUnavailable: 'Vorübergehend nicht verfügbar. Öffnen Sie dieses Menü erneut, um es erneut zu versuchen', + createPluginMissing: 'Der Creator-Modus ist in dieser Konfiguration nicht enthalten', + +} satisfies Record + +/** Russian dictionary, checked against the English keys. */ +export const ru = { + ...guideRu, + builtInGroup: 'Встроенный', customGroup: 'Пользовательский', + sectionIntro: 'Выбирайте инструменты агента и то, как они работают. Используйте стандартный режим для повседневных задач или режим Creator, чтобы расширить возможности DSH.', + + seatHint: 'Выберите предустановку агента для вашей новой задачи', + headerHint: 'Предварительная настройка агента, выбранная при запуске этой задачи', + nav: 'Настройки агента', + + setDefault: 'Установить в качестве новой задачи по умолчанию', + view: 'Посмотреть конфигурацию', + + presetStandardName: 'Стандартный режим', + presetStandardDescription: + 'Работайте с кодом, файлами и информацией. Подходит для большинства задач, с поиском, редактированием, командами терминала и другими инструментами, доступными по мере необходимости.', + presetPtcName: 'Режим PTC', + presetPtcDescription: + 'Включает все возможности стандартного режима. Лучше подходит для задач, которые вызывают инструменты в пакетном режиме, а затем фильтруют, организуют, дедуплицируют, подсчитывают или суммируют результаты.', + presetMinimalName: 'Минимальный режим', + presetMinimalDescription: + 'Агент использует терминал и чтение изображений со сжатием контекста. Полезно для тестирования и сравнения его базовой производительности.', + presetCordisName: 'Режим автора', + presetCordisDescription: + 'Настройте DSH через разговор. Позвольте агенту писать плагины, которые добавляют функции или пользовательский интерфейс, или комбинируйте инструменты и подсказки для создания собственного режима.', + + inUse: 'Новая задача по умолчанию', + + noDescription: 'Нет описания.', + brokenBadge: 'Не удалось загрузить', + + switchRefused: 'Не удалось переключиться на {name}: {reason}', + standardUnavailable: 'Стандартный режим недоступен. Восстановите его или выберите другой доступный режим.', + + close: 'Закрыть', + + creatorDraft: 'Позвольте агенту помочь мне создать пресет', + createPlugin: 'Позвольте агенту создать плагин', + createPluginDescription: 'Войдите в режим Creator и создайте свой собственный плагин DSH.', + createPluginChecking: 'Проверка доступности режима Creator', + createPluginUnavailable: 'Временно недоступен. Откройте это меню еще раз, чтобы повторить попытку.', + createPluginMissing: 'Режим Creator не включен в эту конфигурацию.', + +} satisfies Record + +/** Portuguese dictionary, checked against the English keys. */ +export const pt = { + ...guidePt, + builtInGroup: 'Integrado', customGroup: 'Personalizado', + sectionIntro: 'Escolha as ferramentas do agente e como funciona. Use o modo Padrão para tarefas diárias ou o modo Criador para adicionar recursos ao DSH.', + + seatHint: 'Escolha a predefinição de agente para sua nova tarefa', + headerHint: 'A predefinição do agente escolhida quando esta tarefa foi iniciada', + nav: 'Predefinições de agente', + + setDefault: 'Definir como nova tarefa padrão', + view: 'Ver configuração', + + presetStandardName: 'Modo padrão', + presetStandardDescription: + 'Trabalhe com código, arquivos e informações. Adequado para a maioria das tarefas, com pesquisa, edição, comandos de terminal e outras ferramentas disponíveis conforme necessário.', + presetPtcName: 'modo PTC', + presetPtcDescription: + 'Inclui todos os recursos do modo Padrão. Mais adequado para tarefas que chamam ferramentas em lotes e depois filtram, organizam, desduplicam, contam ou resumem os resultados.', + presetMinimalName: 'Modo mínimo', + presetMinimalDescription: + 'O agente utiliza terminal e leitura de imagens, com compactação de contexto. Útil para testar e comparar seu desempenho básico.', + presetCordisName: 'Modo criador', + presetCordisDescription: + 'Personalize DSH por meio de conversa. Deixe o agente escrever plug-ins que adicionem recursos ou UI, ou combine ferramentas e prompts para criar seu próprio modo.', + + inUse: 'Novo padrão de tarefa', + + noDescription: 'Nenhuma descrição.', + brokenBadge: 'Falha ao carregar', + + switchRefused: 'Não foi possível mudar para {name}: {reason}', + standardUnavailable: 'O modo padrão não está disponível. Restaure-o ou escolha outro modo disponível.', + + close: 'Fechar', + + creatorDraft: 'Deixe o agente me ajudar a criar uma predefinição', + createPlugin: 'Deixe o agente criar um plugin', + createPluginDescription: 'Entre no modo Criador e crie seu próprio plug-in DSH', + createPluginChecking: 'Verificando se o modo Criador está disponível', + createPluginUnavailable: 'Temporariamente indisponível. Reabra este menu para tentar novamente', + createPluginMissing: 'O modo criador não está incluído nesta configuração', + +} 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/de.json b/packages/client/ui-approval/locale/de.json new file mode 100644 index 0000000000..ee89d86fe1 --- /dev/null +++ b/packages/client/ui-approval/locale/de.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "Schnittstelle zur Betriebsgenehmigung", + "description": "Zeigt Aktionen an, deren Genehmigung noch aussteht, und führt Benutzerauswahlen zu laufenden Aufgaben zurück." + } +} diff --git a/packages/client/ui-approval/locale/es.json b/packages/client/ui-approval/locale/es.json new file mode 100644 index 0000000000..115786cd1c --- /dev/null +++ b/packages/client/ui-approval/locale/es.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "Interfaz de aprobación de operación", + "description": "Muestra acciones pendientes de aprobación y devuelve las selecciones del usuario a las tareas en ejecución." + } +} diff --git a/packages/client/ui-approval/locale/fr.json b/packages/client/ui-approval/locale/fr.json new file mode 100644 index 0000000000..69768c0bab --- /dev/null +++ b/packages/client/ui-approval/locale/fr.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "Interface d'approbation des opérations", + "description": "Affiche les actions en attente d'approbation et renvoie les sélections de l'utilisateur aux tâches en cours d'exécution." + } +} 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/locale/pt.json b/packages/client/ui-approval/locale/pt.json new file mode 100644 index 0000000000..7fe00b6570 --- /dev/null +++ b/packages/client/ui-approval/locale/pt.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "Interface de aprovação de operação", + "description": "Exibe ações com aprovação pendente e retorna as seleções do usuário para tarefas em execução." + } +} diff --git a/packages/client/ui-approval/locale/ru.json b/packages/client/ui-approval/locale/ru.json new file mode 100644 index 0000000000..b656b6b2d5 --- /dev/null +++ b/packages/client/ui-approval/locale/ru.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "Интерфейс одобрения операций", + "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..4073a85aa2 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, es, fr, de, ru, pt, 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, es, fr, de, ru, pt }), '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..99408815d9 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,73 @@ 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 + +/** Spanish dictionary, checked against the English keys. */ +export const es = { + waiting: 'Esperando aprobación', + 'detail.aria': 'Detalles de aprobación', + escalation: 'La herramienta {toolName} solicita ejecución privilegiada', + reject: 'Rechazar', + allow: 'Permitir', + allowOnce: 'permitir una vez', + allowAlways: 'Permitir siempre', +} satisfies Record + +/** French dictionary, checked against the English keys. */ +export const fr = { + waiting: "En attente d'approbation", + 'detail.aria': "Détails de l'approbation", + escalation: "L'outil {toolName} demande une exécution privilégiée", + reject: 'Rejeter', + allow: 'Autoriser', + allowOnce: 'Autoriser une fois', + allowAlways: 'Toujours autoriser', +} satisfies Record + +/** German dictionary, checked against the English keys. */ +export const de = { + waiting: 'Warten auf Genehmigung', + 'detail.aria': 'Einzelheiten zur Genehmigung', + escalation: 'Tool {toolName} fordert privilegierte Ausführung an', + reject: 'Ablehnen', + allow: 'Erlauben', + allowOnce: 'Einmal zulassen', + allowAlways: 'Immer zulassen', +} satisfies Record + +/** Russian dictionary, checked against the English keys. */ +export const ru = { + waiting: 'Ожидание одобрения', + 'detail.aria': 'Детали утверждения', + escalation: 'Инструмент {toolName} запрашивает привилегированное выполнение', + reject: 'Отклонить', + allow: 'Разрешить', + allowOnce: 'Разрешить один раз', + allowAlways: 'Всегда разрешать', +} satisfies Record + +/** Portuguese dictionary, checked against the English keys. */ +export const pt = { + waiting: 'Aguardando aprovação', + 'detail.aria': 'Detalhes de aprovação', + escalation: 'A ferramenta {toolName} solicita execução privilegiada', + reject: 'Rejeitar', + allow: 'Permitir', + allowOnce: 'Permitir uma vez', + allowAlways: 'Sempre permitir', +} 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/de.json b/packages/client/ui-attachment/locale/de.json new file mode 100644 index 0000000000..07c79593b0 --- /dev/null +++ b/packages/client/ui-attachment/locale/de.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "Anhangsanzeige", + "description": "Zeigen Sie Anhänge und Bilder in Nachrichteneingaben, Konversationen und Ausführungsprotokollen an." + } +} diff --git a/packages/client/ui-attachment/locale/es.json b/packages/client/ui-attachment/locale/es.json new file mode 100644 index 0000000000..635cff5f3d --- /dev/null +++ b/packages/client/ui-attachment/locale/es.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "Visualización de archivos adjuntos", + "description": "Muestre archivos adjuntos e imágenes en la entrada de mensajes, conversaciones y registros de ejecución." + } +} diff --git a/packages/client/ui-attachment/locale/fr.json b/packages/client/ui-attachment/locale/fr.json new file mode 100644 index 0000000000..c89a8bf6f9 --- /dev/null +++ b/packages/client/ui-attachment/locale/fr.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "Affichage des pièces jointes", + "description": "Affichez les pièces jointes et les images dans la saisie des messages, les conversations et les journaux d'exécution." + } +} 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/locale/pt.json b/packages/client/ui-attachment/locale/pt.json new file mode 100644 index 0000000000..6295a88f6d --- /dev/null +++ b/packages/client/ui-attachment/locale/pt.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "Exibição de anexo", + "description": "Exiba anexos e imagens em entradas de mensagens, conversas e registros de execução." + } +} diff --git a/packages/client/ui-attachment/locale/ru.json b/packages/client/ui-attachment/locale/ru.json new file mode 100644 index 0000000000..8f81ace343 --- /dev/null +++ b/packages/client/ui-attachment/locale/ru.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' && ( + + + + )} + {/* The open dialog already shows every figure; a tooltip would cover it. */} + + + {open && createPortal(
0 ? decodeSpeed(stats, t) : null const icon = - if (mode === 'compact') return speed === null ? null : + if (mode === 'compact') return speed === null ? null : if (stats.steps === 0) return null const counts = t('stats.counts', { turns: stats.turns, steps: stats.steps }) - const content: PillContent = { stat: 'activity', icon, label: joined(counts, speed) } + const content: PillContent = { stat: 'activity', icon, ...reading(counts, speed) } // A window without one timed figure has no dialog rows to show, so the pill // stays a plain reading instead of a button opening an empty dialog. if (stats.llmMs <= 0 && stats.toolMs <= 0 && stats.ttftSteps <= 0 && speed === null) return return ( - + // The counts head the dialog, so a cut-off reading stays complete once the dialog opens. +
{stats.llmMs > 0 && ( <> @@ -289,7 +304,7 @@ export const UsagePill = memo(function UsagePill({ useProjection, usePerformance const cacheHitText = cacheHit !== null ? t('stats.cacheHit', { percent: cacheHit }) : null const icon = if (mode === 'compact') { - return cacheHitText === null ? null : + return cacheHitText === null ? null : } // Same aggregate as the Turn pill's totalTokens: every prompt-side billing bucket plus output. const total = billedInputTokens(usage) + usage.outputTokens @@ -298,8 +313,7 @@ export const UsagePill = memo(function UsagePill({ useProjection, usePerformance diff --git a/packages/client/ui-chat/src/client/chat/UserContent.tsx b/packages/client/ui-chat/src/client/chat/UserContent.tsx index 64024a096c..ad73ea7a98 100644 --- a/packages/client/ui-chat/src/client/chat/UserContent.tsx +++ b/packages/client/ui-chat/src/client/chat/UserContent.tsx @@ -1,7 +1,7 @@ /** Reusable user contents inside a caller-owned message row. */ import { Fragment } from 'react' import type { FactoryComponentPropsOf } from '@deepseek-ai/dsh-client-ui-slots' -import { fileExtension, FileTypeIcon, fileSizeText, JsonBlock } from '@deepseek-ai/dsh-client-ui-primitives' +import { fileExtension, FileTypeIcon, fileSizeText, JsonBlock, UserMessageBubble } from '@deepseek-ai/dsh-client-ui-primitives' import css from './MessageItem.module.css' const EMPTY_LABELS: readonly string[] = [] @@ -44,12 +44,12 @@ FactoryComponentPropsOf<'conversation.chat.user.content'>) { ))}
)} - {showBubble &&
+ {showBubble && {renderFactorySlot('conversation.user.text', { text, facts, interactive, ellipsis })} {extraBlocks.map((block, index) => ( ))} -
} + } {referenceLabels.length > 0 && (
{t('message.referenceSummary', { labels: referenceLabels.join(t('message.referenceSeparator')) })} diff --git a/packages/client/ui-chat/src/client/chat/text-interaction.ts b/packages/client/ui-chat/src/client/chat/text-interaction.ts new file mode 100644 index 0000000000..f497b66952 --- /dev/null +++ b/packages/client/ui-chat/src/client/chat/text-interaction.ts @@ -0,0 +1,298 @@ +/** Area-local text gestures, action ownership, dismissal, and content-relative placement. */ +import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react' +import type { RefObject } from 'react' +import { readTextSelection, selectedText } from './text-selection.ts' +import type { TextSelection } from './text-selection.ts' + +import type { + TextInteractionGesture, TextInteractionSurface, TextInteractionTrigger, +} from '@deepseek-ai/dsh-client-ui-conversation/client' + +/** Current area-owned surface and its actions. */ +export interface TextInteractionBinding extends Omit { + readonly active: { readonly id: string; readonly trigger: TextInteractionTrigger } | null +} + +interface Target { + readonly source: HTMLElement + readonly selection: TextSelection | null + readonly text: string | null + readonly point: { readonly x: number; readonly y: number } | null +} + +interface Bounds { left: number; top: number; right: number; bottom: number } + +function rectBounds(rect: DOMRect): Bounds { + return { left: rect.left, top: rect.top, right: rect.left + rect.width, bottom: rect.top + rect.height } +} + +function intersects(a: Bounds, b: Bounds): boolean { + return a.right > b.left && a.left < b.right && a.bottom > b.top && a.top < b.bottom +} + +function sameRange(a: Range | null, b: Range | null): boolean { + return a === b || (a !== null && b !== null && a.startContainer === b.startContainer + && a.startOffset === b.startOffset && a.endContainer === b.endContainer && a.endOffset === b.endOffset) +} + +function available(root: HTMLElement, target: Target): boolean { + if (!root.isConnected || !root.contains(target.source) || !target.source.hasAttribute('data-text-interaction-source')) return false + const view = root.ownerDocument.defaultView + /* v8 ignore if -- Mounted browser content always has an owner window. */ + if (view === null) return false + for (let node: HTMLElement | null = target.source; node !== null; node = node.parentElement) { + const style = view.getComputedStyle(node) + if (node.hidden || node.inert || node.hasAttribute('data-text-interaction-exclude') + || style.display === 'none' || style.visibility === 'hidden') return false + } + return target.selection === null || (target.source.contains(target.selection.range.startContainer) + && target.source.contains(target.selection.range.endContainer) && selectedText(target.selection) === target.text) +} + +function composerSeat(root: HTMLElement): HTMLElement | null { + return root.closest('[data-conversation-scroll]')?.querySelector('[data-composer-seat]') ?? null +} + +function visibleBounds(root: HTMLElement, source: HTMLElement, view: Window): Bounds { + const rootRect = rectBounds(root.getBoundingClientRect()) + const bounds: Bounds = { + left: Math.max(0, rootRect.left), right: Math.min(view.innerWidth, rootRect.right), top: 0, bottom: view.innerHeight, + } + for (let node: HTMLElement | null = source; node !== null; node = node.parentElement) { + const style = view.getComputedStyle(node) + const rect = rectBounds(node.getBoundingClientRect()) + if (/(auto|scroll|hidden|clip)/u.test(style.overflowX)) { + bounds.left = Math.max(bounds.left, rect.left) + bounds.right = Math.min(bounds.right, rect.right) + } + if (/(auto|scroll|hidden|clip)/u.test(style.overflowY)) { + bounds.top = Math.max(bounds.top, rect.top) + bounds.bottom = Math.min(bounds.bottom, rect.bottom) + } + } + const composer = composerSeat(root)?.getBoundingClientRect() + if (composer !== undefined && composer.height > 0 + && composer.right > bounds.left && composer.left < bounds.right) { + bounds.bottom = Math.min(bounds.bottom, composer.top) + } + return bounds +} + +/** + * Own text interactions for one positioned content area and its local surface. + * @param rootRef - existing content element with position: relative. + * @param surfaceRef - absolute surface inside that element; only the selected Slot consumer is mounted. + * @param dispatchInteraction - framework-injected plugin gesture dispatch. + * @param registeredIds - live registered owners used to dismiss removed plugins. + * @returns current owner and area-local selection actions. + */ +export function useTextInteraction( + rootRef: RefObject, surfaceRef: RefObject, + dispatchInteraction: (gesture: TextInteractionGesture) => string | null, + registeredIds: readonly string[], +): TextInteractionBinding { + const target = useRef(null) + const [active, setActive] = useState(null) + const dismiss = useCallback(() => { + target.current = null + if (surfaceRef.current !== null) surfaceRef.current.style.visibility = 'hidden' + setActive(null) + }, [surfaceRef]) + useEffect(dismiss, [registeredIds, dispatchInteraction, dismiss]) + const readText = useCallback(() => { + const root = rootRef.current + const current = target.current + if (root === null || current === null || !available(root, current)) return null + return current.selection === null ? null : selectedText(current.selection) + }, [rootRef]) + const clearSelection = useCallback(() => { rootRef.current?.ownerDocument.getSelection()?.removeAllRanges() }, [rootRef]) + + const place = useCallback(() => { + const root = rootRef.current + const surface = surfaceRef.current + const current = target.current + if (root === null || surface === null || current === null) return + if (!available(root, current)) { dismiss(); return } + const view = root.ownerDocument.defaultView + /* v8 ignore if -- Mounted browser content always has an owner window. */ + if (view === null) return + const visible = visibleBounds(root, current.source, view) + const selectionRects = current.selection === null ? [] : [...current.selection.range.getClientRects()] + const fallback = current.selection?.range.getBoundingClientRect() ?? current.source.getBoundingClientRect() + const rects = selectionRects.length === 0 ? [fallback] : selectionRects + const sourceRect = current.source.getBoundingClientRect() + const pointX = current.point === null ? 0 : sourceRect.left + current.point.x - current.source.scrollLeft + const pointY = current.point === null ? 0 : sourceRect.top + current.point.y - current.source.scrollTop + const anchor = current.point === null + ? rects.map(rectBounds).find(rect => intersects(rect, visible)) + : { left: pointX, right: pointX + 1, top: pointY, bottom: pointY + 1 } + if (anchor === undefined || !intersects(anchor, visible) || visible.right <= visible.left || visible.bottom <= visible.top) { + surface.style.visibility = 'hidden' + return + } + const gap = 8 + surface.style.maxWidth = `${visible.right - visible.left}px` + surface.style.maxHeight = `${visible.bottom - visible.top}px` + const size = surface.getBoundingClientRect() + const x = current.point === null + ? (Math.max(anchor.left, visible.left) + Math.min(anchor.right, visible.right) - size.width) / 2 + : anchor.left + const above = anchor.top - gap - size.height + const below = anchor.bottom + gap + const preferredY = current.point === null && above >= visible.top ? above : below + const left = Math.max(visible.left, Math.min(x, visible.right - size.width)) + const top = Math.max(visible.top, Math.min(preferredY, visible.bottom - size.height)) + const rootRect = root.getBoundingClientRect() + const scaleX = root.offsetWidth === 0 ? 1 : rootRect.width / root.offsetWidth + const scaleY = root.offsetHeight === 0 ? 1 : rootRect.height / root.offsetHeight + surface.style.left = `${(left - rootRect.left) / scaleX - root.clientLeft + root.scrollLeft}px` + surface.style.top = `${(top - rootRect.top) / scaleY - root.clientTop + root.scrollTop}px` + surface.style.visibility = 'visible' + }, [dismiss, rootRef, surfaceRef]) + + // The surface's measured dimensions are needed after its Slot owner renders, before paint. + useLayoutEffect(place, [active, place]) + useEffect(() => { + const root = rootRef.current + const surface = surfaceRef.current + if (root === null || surface === null) return + const document = root.ownerDocument + const view = document.defaultView + /* v8 ignore if -- Mounted browser content always has an owner window. */ + if (view === null) return + let pointerSelecting = false + let keyboardSelecting = false + let selectionChangedDuringGesture = false + let previousRange: Range | null = null + let frame: number | undefined + const schedulePlacement = (): void => { + if (target.current === null || frame !== undefined) return + frame = view.requestAnimationFrame(() => { frame = undefined; place() }) + } + const dispatch = (trigger: TextInteractionTrigger, next: Target, event: Event): void => { + if (!available(root, next)) return + const id = dispatchInteraction({ + trigger, hasSelection: next.selection !== null, + readText: () => next.text, + preventDefault: () => { event.preventDefault() }, + }) + if (id === null) return + target.current = next + setActive({ id, trigger }) + } + const updateSelection = (event: Event): void => { + if (pointerSelecting || keyboardSelecting) { selectionChangedDuringGesture = true; return } + if (target.current !== null && target.current.point !== null) return + if (target.current !== null && surface.contains(document.activeElement)) return + const selection = readTextSelection(root, document.getSelection()) + const range = selection?.range ?? null + if (!selectionChangedDuringGesture && sameRange(previousRange, range)) return + selectionChangedDuringGesture = false + previousRange = range?.cloneRange() ?? null + dismiss() + if (selection === null) return + const text = selectedText(selection) + if (text === null) return + dispatch('selection', { source: selection.source, selection: { ...selection, range: selection.range.cloneRange() }, text, point: null }, event) + } + const insideSurface = (event: Event): boolean => event.composedPath().includes(surface) + const pointerDown = (event: PointerEvent): void => { + if (insideSurface(event)) return + const selection = document.getSelection() + previousRange = selection?.rangeCount === 1 ? selection.getRangeAt(0).cloneRange() : null + dismiss() + selectionChangedDuringGesture = false + pointerSelecting = event.button === 0 && event.target instanceof Node && root.contains(event.target) + } + const pointerUp = (event: PointerEvent): void => { + if (!pointerSelecting) return + pointerSelecting = false + updateSelection(event) + } + const cancel = (): void => { pointerSelecting = false; keyboardSelecting = false; dismiss() } + const keyDown = (event: KeyboardEvent): void => { + if (event.key === 'Escape') { cancel(); return } + if (insideSurface(event) || !(event.target instanceof Node) || !root.contains(event.target)) return + if (event.key === 'Shift' || event.shiftKey) { keyboardSelecting = true; dismiss() } + } + const keyUp = (event: KeyboardEvent): void => { + if (!keyboardSelecting || (event.key !== 'Shift' && event.shiftKey)) return + keyboardSelecting = false + updateSelection(event) + } + const contextMenu = (event: MouseEvent): void => { + if (insideSurface(event)) return + const element = event.target instanceof Element ? event.target : null + const source = element?.closest('[data-text-interaction-source]') + if (source === undefined || source === null || !root.contains(source) + || element?.closest('[data-text-interaction-exclude], button, input, textarea, [contenteditable="true"]')) return + pointerSelecting = false + dismiss() + const selected = readTextSelection(root, document.getSelection()) + const keyboard = event.clientX === 0 && event.clientY === 0 + const inSelection = selected?.source === source && (keyboard || [...selected.range.getClientRects()].some(rect => + event.clientX >= rect.left && event.clientX <= rect.right && event.clientY >= rect.top && event.clientY <= rect.bottom)) + const selection = inSelection ? selected : null + const bounds = source.getBoundingClientRect() + dispatch('contextmenu', { + source, selection: selection === null ? null : { ...selection, range: selection.range.cloneRange() }, + text: selection === null ? null : selectedText(selection), + point: { + x: (keyboard ? 0 : event.clientX - bounds.left) + source.scrollLeft, + y: (keyboard ? 0 : event.clientY - bounds.top) + source.scrollTop, + }, + }, event) + } + document.addEventListener('selectionchange', updateSelection) + document.addEventListener('pointerdown', pointerDown, true) + document.addEventListener('pointerup', pointerUp) + document.addEventListener('pointercancel', cancel) + document.addEventListener('keydown', keyDown) + document.addEventListener('keyup', keyUp) + document.addEventListener('scroll', schedulePlacement, true) + root.addEventListener('contextmenu', contextMenu) + view.addEventListener('resize', schedulePlacement) + view.addEventListener('blur', cancel) + return () => { + document.removeEventListener('selectionchange', updateSelection) + document.removeEventListener('pointerdown', pointerDown, true) + document.removeEventListener('pointerup', pointerUp) + document.removeEventListener('pointercancel', cancel) + document.removeEventListener('keydown', keyDown) + document.removeEventListener('keyup', keyUp) + document.removeEventListener('scroll', schedulePlacement, true) + root.removeEventListener('contextmenu', contextMenu) + view.removeEventListener('resize', schedulePlacement) + view.removeEventListener('blur', cancel) + if (frame !== undefined) view.cancelAnimationFrame(frame) + } + }, [dismiss, place, rootRef, surfaceRef, dispatchInteraction]) + useEffect(() => { + const root = rootRef.current + const surface = surfaceRef.current + if (active === null || root === null || surface === null) return + const view = root.ownerDocument.defaultView + /* v8 ignore if -- Mounted browser content always has an owner window. */ + if (view === null) return + let frame: number | undefined + const schedule = (): void => { + if (frame !== undefined) return + frame = view.requestAnimationFrame(() => { frame = undefined; place() }) + } + const resize = new ResizeObserver(schedule) + resize.observe(root) + resize.observe(surface) + const composer = composerSeat(root) + if (composer !== null) resize.observe(composer) + const mutations = new MutationObserver((records) => { + if (records.some(record => !surface.contains(record.target))) schedule() + }) + mutations.observe(root, { childList: true, subtree: true, characterData: true, attributes: true }) + return () => { + resize.disconnect() + mutations.disconnect() + if (frame !== undefined) view.cancelAnimationFrame(frame) + } + }, [active, place, rootRef, surfaceRef]) + return { active, dismiss, readText, clearSelection } +} diff --git a/packages/client/ui-chat/src/client/chat/text-selection.ts b/packages/client/ui-chat/src/client/chat/text-selection.ts new file mode 100644 index 0000000000..bc3ae4dec0 --- /dev/null +++ b/packages/client/ui-chat/src/client/chat/text-selection.ts @@ -0,0 +1,180 @@ +/** DOM selection validation and visible-text projection, independent of business actions. */ + +const PROJECTED_BLOCK_TAGS = new Set([ + 'ADDRESS', 'ARTICLE', 'ASIDE', 'BLOCKQUOTE', 'DD', 'DIV', 'DL', 'DT', 'FIGCAPTION', + 'FIGURE', 'FOOTER', 'H1', 'H2', 'H3', 'H4', 'H5', 'H6', 'HEADER', 'LI', 'MAIN', + 'NAV', 'OL', 'P', 'PRE', 'SECTION', 'TABLE', 'TBODY', 'TFOOT', 'THEAD', 'TR', 'UL', +]) +const PROJECTED_CELL_TAGS = new Set(['TD', 'TH']) + +function excluded(element: Element): boolean { + if (element.matches('.katex-mathml, [data-text-interaction-exclude], [hidden], .sr-only')) return true + if (element.matches('[aria-hidden="true"]') && !element.matches('.katex-html')) return true + return element.tagName === 'BUTTON' && element.parentElement?.tagName !== 'CODE' +} + +function projectedBoundary(node: Node): '\n' | '\t' | '' { + if (!(node instanceof Element)) return '' + if (PROJECTED_CELL_TAGS.has(node.tagName)) return '\t' + return PROJECTED_BLOCK_TAGS.has(node.tagName) ? '\n' : '' +} + +function projectedSeparator(previous: Node, current: Node): '\n' | '\t' | '' { + const left = projectedBoundary(previous) + const right = projectedBoundary(current) + if (left === '\n' || right === '\n') return '\n' + return left === '\t' || right === '\t' ? '\t' : '' +} + +interface ProjectedText { + readonly text: string + readonly offsets: readonly number[] +} + +function projectedText(node: Text): ProjectedText { + const source = node.data + const parent = node.parentElement + const view = node.ownerDocument.defaultView + const whiteSpace = parent === null || view === null ? '' : view.getComputedStyle(parent).whiteSpace + const preservesBreaks = whiteSpace === 'pre' || whiteSpace === 'pre-wrap' + || whiteSpace === 'pre-line' || whiteSpace === 'break-spaces' + const offsets = new Array(source.length + 1).fill(0) + const structuralWhitespace = !preservesBreaks + && /^[\t\n\f\r ]*$/u.test(source) + && [node.previousSibling, node.nextSibling].some( + sibling => sibling !== null && projectedBoundary(sibling) !== '', + ) + if (structuralWhitespace) return { text: '', offsets } + let text = '' + let cursor = 0 + if (!preservesBreaks && node.previousSibling instanceof HTMLElement + && node.previousSibling.tagName === 'BR' && source.startsWith('\n')) { + cursor = 1 + } + while (cursor < source.length) { + const softBreak = preservesBreaks ? null : /^[ \t]*[\r\n]+[ \t]*/u.exec(source.slice(cursor)) + if (softBreak !== null) { + const end = cursor + softBreak[0].length + for (let index = cursor; index < end; index++) offsets[index] = text.length + text += ' ' + offsets[end] = text.length + cursor = end + continue + } + offsets[cursor] = text.length + text += source.charAt(cursor) + cursor += 1 + offsets[cursor] = text.length + } + return { text, offsets } +} + +interface ProjectedTextSpan { + readonly node: Text + readonly start: number + readonly offsets: readonly number[] +} + +interface ProjectedFragment { + readonly text: string + readonly spans: readonly ProjectedTextSpan[] + readonly boundaries: readonly (readonly [Node, readonly number[]])[] +} + +function projectedFragment(node: Node): ProjectedFragment { + if (node.nodeType === Node.TEXT_NODE) { + const projected = projectedText(node as Text) + return { + text: projected.text, + spans: projected.offsets.length <= 1 + ? [] + : [{ node: node as Text, start: 0, offsets: projected.offsets }], + boundaries: [], + } + } + if (!(node instanceof Element)) { + return { text: '', spans: [], boundaries: [] } + } + if (excluded(node)) return { text: '', spans: [], boundaries: [] } + if (node.tagName === 'BR') return { text: '\n', spans: [], boundaries: [] } + let text = '' + let previous: Node | null = null + const spans: ProjectedTextSpan[] = [] + const boundaries: Array = [] + const offsets = new Array(node.childNodes.length + 1).fill(0) + for (const [index, child] of [...node.childNodes].entries()) { + const fragment = projectedFragment(child) + if (fragment.text !== '' && previous !== null) { + const separator = projectedSeparator(previous, child) + const needsSeparator = separator === '\n' + ? !text.endsWith('\n') && !fragment.text.startsWith('\n') + : separator === '\t' && !/[\t\n]$/u.test(text) && !/^[\t\n]/u.test(fragment.text) + if (needsSeparator) text += separator + } + offsets[index] = text.length + const base = text.length + text += fragment.text + for (const span of fragment.spans) { + spans.push({ node: span.node, start: base + span.start, offsets: span.offsets }) + } + for (const [owner, childOffsets] of fragment.boundaries) { + boundaries.push([owner, childOffsets.map(offset => base + offset)]) + } + if (fragment.text !== '') previous = child + offsets[index + 1] = text.length + } + boundaries.push([node, offsets]) + return { text, spans, boundaries } +} + +function projectedOffset( + projection: ProjectedFragment, + container: Node, + offset: number, +): number | null { + if (container.nodeType === Node.TEXT_NODE) { + const span = projection.spans.find(candidate => candidate.node === container) + const projected = span?.offsets[offset] + return span === undefined || projected === undefined ? null : span.start + projected + } + const boundaries = projection.boundaries.find(([owner]) => owner === container)?.[1] + return boundaries?.[offset] ?? null +} + +/** One browser range contained by an enabled text source. */ +export interface TextSelection { + readonly source: HTMLElement + readonly range: Range +} + +/** + * Find a single noncollapsed selection inside one enabled source. + * @param root - mounted interaction area. + * @param selection - browser selection in that area's document. + * @returns the selected source and range, or null across sources and excluded content. + */ +export function readTextSelection(root: HTMLElement, selection: Selection | null): TextSelection | null { + if (selection === null || selection.rangeCount !== 1 || selection.isCollapsed) return null + const range = selection.getRangeAt(0) + const sourceOf = (node: Node): HTMLElement | null => + (node instanceof Element ? node : node.parentElement)?.closest('[data-text-interaction-source]') ?? null + const source = sourceOf(range.startContainer) + if (source === null || source !== sourceOf(range.endContainer) || !root.contains(source) + || source.closest('[hidden], [inert], [data-text-interaction-exclude]') !== null) return null + return { source, range } +} + +/** + * Project selected DOM text with visible block boundaries and without renderer chrome. + * @param selection - range and its source, obtained through readTextSelection. + * @returns trimmed visible text; empty or excluded selections return null. + */ +export function selectedText(selection: TextSelection): string | null { + const { source, range } = selection + const projection = projectedFragment(source) + const start = projectedOffset(projection, range.startContainer, range.startOffset) + const end = projectedOffset(projection, range.endContainer, range.endOffset) + if (start === null || end === null) return null + const text = projection.text.slice(start, end).trim() + return text === '' ? null : text +} diff --git a/packages/client/ui-chat/src/client/contract/slots.ts b/packages/client/ui-chat/src/client/contract/slots.ts index 4bd88840f6..948a546689 100644 --- a/packages/client/ui-chat/src/client/contract/slots.ts +++ b/packages/client/ui-chat/src/client/contract/slots.ts @@ -7,6 +7,7 @@ import type { FileAttachmentRef } from '@deepseek-ai/dsh-attachment' import type { InboxState } from '@deepseek-ai/dsh-agent/types' import type { CommandNode, CompactionSummaryNode, ConversationLocationDataStore, ConversationTurnDataMap, + TextInteractionGesture, TextInteractionSurface, ConversationGroupData, GroupSnapshot, MessageImageLoader, MessageImageSource, MessageImagesOwnerProps, RenderEntry, TurnLocation, UserTextFacts, UserTextInput, } from '@deepseek-ai/dsh-client-ui-conversation/client' @@ -297,7 +298,13 @@ export interface PerformanceUsageInjected { /** Business callbacks injected into the Chat view. */ export interface ChatViewInjected { + /** File preparation and failure callbacks owned by the file-drag plugin. */ + readonly fileDrag: import('@deepseek-ai/dsh-client-ui-primitives').FileDragActions + /** Dispatch one gesture to the Conversation plugin registry. */ + dispatchInteraction: (gesture: TextInteractionGesture) => string | null hooks: { + /** Live plugin IDs; removed plugins dismiss their active surface. */ + interactionIds: ObservableSnapshot /** Live presentation policy derived from the accepted work-details mode. */ presentation: ObservableSnapshot } @@ -328,7 +335,7 @@ export interface ChatViewInjected { /** Session-owned node sources shared with the view, plus the flow's connection state. */ export type ChatFlowDataInjected = Pick & { - hooks: ChatViewInjected['hooks'] & { + hooks: Pick & { /** Client connection lifecycle; the running clock pauses while it is not connected. */ connectionState: ConnectionStateSource } @@ -355,7 +362,7 @@ export type ChatFlowSlotProps = /** Full Chat view props. */ export type ChatViewSlotProps = PropsRuntime<'conversation.view'> - & PropsRenderSlots<'conversation.chat.flow'> + & PropsRenderSlots<'conversation.chat.flow' | 'conversation.chat.text-interaction'> & PropsStore & InjectFace & PropsLocale<'chat'> @@ -392,6 +399,9 @@ declare module '@deepseek-ai/dsh-client-ui-slots' { } interface SlotMap { + /** Area-local text gestures and surfaces; each consumer registers its own synchronous admission. */ + 'conversation.chat.text-interaction': { kind: 'keyed'; scope: 'session'; owner: TextInteractionSurface } + /** Ordered Chat rows and local echoes with viewport-bound visibility hooks. */ 'conversation.chat.flow': { kind: 'single' diff --git a/packages/client/ui-chat/src/client/conversation-nodes/message.ts b/packages/client/ui-chat/src/client/conversation-nodes/message.ts index 967b5446c1..fb7684a8f7 100644 --- a/packages/client/ui-chat/src/client/conversation-nodes/message.ts +++ b/packages/client/ui-chat/src/client/conversation-nodes/message.ts @@ -126,6 +126,7 @@ export const messageDefinition: ConversationNodeDefinition = { } : { kind: 'user', + messageId: event.data.id, seq: event.seq, time: event.time, content: event.data.content, diff --git a/packages/client/ui-chat/src/client/conversation-nodes/tool.ts b/packages/client/ui-chat/src/client/conversation-nodes/tool.ts index 0252539059..3ed7b86d13 100644 --- a/packages/client/ui-chat/src/client/conversation-nodes/tool.ts +++ b/packages/client/ui-chat/src/client/conversation-nodes/tool.ts @@ -4,7 +4,7 @@ import type { ToolCallBlock, ToolResultNode, } from '@deepseek-ai/dsh-client-ui-conversation/client' import { isAppendSurfaceEvent } from '@deepseek-ai/dsh-session/surface' -import type {} from '@deepseek-ai/dsh-tools/types' +import type {} from '@deepseek-ai/dsh-tool-executor-ptc/client' import { PartialArguments } from '@deepseek-ai/dsh-util-values' import type { ChatNode, ToolChatData } from '../contract/chat-nodes.ts' import { CHAT_SYNTHETIC_SEQ_OFFSETS, chatNode, contextLocation } from './common.ts' diff --git a/packages/client/ui-chat/src/client/conversation-nodes/turn-process.ts b/packages/client/ui-chat/src/client/conversation-nodes/turn-process.ts index 9f9d8ed329..a0ad62091b 100644 --- a/packages/client/ui-chat/src/client/conversation-nodes/turn-process.ts +++ b/packages/client/ui-chat/src/client/conversation-nodes/turn-process.ts @@ -4,7 +4,7 @@ import type { } from '@deepseek-ai/dsh-client-ui-conversation/client' import type {} from '@deepseek-ai/dsh-llm-retry/types' import type { StreamChunk } from '@deepseek-ai/dsh-llm' -import type {} from '@deepseek-ai/dsh-tools/types' +import type {} from '@deepseek-ai/dsh-tool-executor-ptc/client' import { hasAssistantReplyContent } from '../contract/assistant-content.ts' import type { AssistantChatData, ChatNode, FinalAssistantChatData } from '../contract/chat-nodes.ts' import { diff --git a/packages/client/ui-chat/src/client/locale.ts b/packages/client/ui-chat/src/client/locale.ts index 7a950132e2..6f0e6eda52 100644 --- a/packages/client/ui-chat/src/client/locale.ts +++ b/packages/client/ui-chat/src/client/locale.ts @@ -403,3 +403,1197 @@ export const en = { 'clock.md': '{m}/{d}', 'clock.ymd': '{y}-{m}-{d}', } satisfies Record + +/** Japanese copy, checked against the English keys. */ +export const ja = { + 'message.stepProcess.thinking': 'リクエストを分析中', + 'message.stepProcess.read': 'ファイルを読み取り中', + 'message.stepProcess.readImage': '画像を読み取り中', + 'message.stepProcess.write': 'ファイルを書き込み中', + 'message.stepProcess.search': 'コードを検索中', + 'message.stepProcess.edit': 'ファイルを編集中', + 'message.stepProcess.commands': 'コマンドを実行中', + 'message.stepProcess.code': 'コードを実行中', + 'message.stepProcess.webSearch': 'ウェブを検索中', + 'message.stepProcess.webFetch': 'ウェブページを閲覧中', + 'message.stepProcess.subagents': 'サブエージェントを調整中', + 'message.stepProcess.plan': '計画を更新中', + 'message.stepProcess.questions': '操作を待機中', + 'message.stepProcess.tools': 'ツールを呼び出し中', + 'message.stepProcess.prepare.read': 'ファイル読み取りの準備中', + 'message.stepProcess.prepare.readImage': '画像読み取りの準備中', + 'message.stepProcess.prepare.write': 'ファイル書き込みの準備中', + 'message.stepProcess.prepare.search': 'コード検索の準備中', + 'message.stepProcess.prepare.edit': 'ファイル編集の準備中', + 'message.stepProcess.prepare.commands': 'コマンド実行の準備中', + 'message.stepProcess.prepare.code': 'コード実行の準備中', + 'message.stepProcess.prepare.webSearch': 'ウェブ検索の準備中', + 'message.stepProcess.prepare.webFetch': 'ウェブページ閲覧の準備中', + 'message.stepProcess.prepare.subagents': 'サブエージェント調整の準備中', + 'message.stepProcess.prepare.plan': '計画更新の準備中', + 'message.stepProcess.prepare.questions': '質問の準備中', + 'message.stepProcess.prepare.tools': 'ツール呼び出しの準備中', + 'message.stepProcess.done.thinking': '分析完了', + 'message.stepProcess.done.read': 'ファイルを読み取りました', + 'message.stepProcess.done.readImage': '画像を読み取りました', + 'message.stepProcess.done.write': 'ファイルを書き込みました', + 'message.stepProcess.done.search': 'コードを検索しました', + 'message.stepProcess.done.edit': 'ファイルを編集しました', + 'message.stepProcess.done.commands': 'コマンドを実行しました', + 'message.stepProcess.done.code': 'コードを実行しました', + 'message.stepProcess.done.webSearch': 'ウェブを検索しました', + 'message.stepProcess.done.webFetch': 'ウェブページを閲覧しました', + 'message.stepProcess.done.subagents': 'サブエージェントを調整しました', + 'message.stepProcess.done.plan': '計画を更新しました', + 'message.stepProcess.done.questions': '質問しました', + 'message.stepProcess.done.tools': 'ツールを呼び出しました', + 'message.stepProcess.joinTwo': '{first}と{second}', + 'message.stepProcess.comma': '、', + 'message.stepProcess.sharedPrefix': '', + 'message.stepProcess.more': '{title}など', + 'message.trigger.request': '実行が要求されました', + 'message.trigger.goal': '目標を継続中', + 'message.trigger.agent': 'タスクメッセージを受信しました', + 'message.trigger.team': 'チームメッセージを受信しました', + 'message.trigger.subagent': 'サブタスクの状態が更新されました', + 'message.trigger.github': 'GitHubイベントを受信しました', + 'message.trigger.webhook': '外部イベントを受信しました', + 'message.trigger.schedule': '自動化タスク', + 'message.trigger.job': 'バックグラウンドタスクが更新されました', + 'message.trigger.plugin': 'プラグインの状態が更新されました', + 'message.trigger.explanation': 'この通知がこの応答のトリガーとなりました。', + 'message.turnProcess.worked': '完了', + 'message.turnProcess.took': '完了まで ', + 'message.turnProcess.failed': '失敗', + 'view.chat': 'チャット', + 'image.open': '画像を全画面表示', + 'image.loading': '画像を読み込み中…', + 'image.failed': '画像プレビューを利用できません', + 'image.dialog': '画像プレビュー', + 'image.close': '画像プレビューを閉じる', + 'number.groupSeparator': ',', + 'duration.compactSeconds': '{seconds}秒', + 'duration.compactMinutes': '{minutes}分{seconds}秒', + 'duration.milliseconds': '{milliseconds}ms', + 'stats.counts': '{turns}ターン {steps}ステップ', + 'stats.cacheHit': 'キャッシュヒット {percent}%', + 'stats.dialog.title': 'セッション統計', + 'stats.dialog.usageTitle': 'トークン使用量', + 'stats.dialog.llmTime': 'LLM時間', + 'stats.dialog.toolTime': 'ツール時間', + 'stats.dialog.ttft': '平均初回トークン時間(TTFT)', + 'stats.dialog.speed': '毎秒トークン数(TPS)', + 'chat.loadingHistory': '履歴を読み込み中…', + 'chat.loadError': '履歴の読み込みに失敗しました: {message}({code})', + 'chat.loadOlder': '以前を読み込む', + 'chat.toBottom': '最下部へ戻る', + 'chat.deepDiving': '深掘り中', + 'chat.deepDivingFor': '{duration} の間深く分析中 ···', + 'chat.turnNavigation.label': 'ターン移動', + 'chat.turnNavigation.jump': 'ターン {turn} へ移動', + 'chat.turnNavigation.jumpLoad': '読み込んでターン {turn} へ移動', + 'chat.turnNavigation.turn': 'ターン {turn}', + 'settings.performance.title': 'パフォーマンスと使用状況', + 'settings.performance.description': 'パフォーマンスと使用状況の表示量を選択', + 'settings.performance.compact': 'コンパクト', + 'settings.performance.detailed': '詳細', + 'settings.links.title': 'チャットリンクを開く場所', + 'settings.links.description': 'Web リンクを開く場所を選択', + 'settings.links.sidebar': 'アプリ内サイドバー', + 'settings.links.newTab': '既定のブラウザー', + 'settings.transcript.title': '作業の詳細', + 'settings.transcript.description': 'ツール呼び出しの表示詳細度を選択', + 'settings.collapse.title': '作業詳細を折りたたむタイミング', + 'settings.collapse.description': '作業詳細を自動で折りたたむタイミングを選択', + 'settings.collapse.completion': '完了時', + 'settings.collapse.nextInput': '次のメッセージ送信時', + 'settings.foldedReplies.title': '折りたたみ後の返信', + 'settings.foldedReplies.description': '作業詳細の折りたたみ時に表示するアシスタントの返信を選択', + 'settings.foldedReplies.all': 'すべての返信', + 'settings.foldedReplies.final': '最終回答のみ', + 'settings.transcript.compact': 'コンパクト', + 'settings.transcript.standard': '標準', + 'settings.transcript.detailed': '詳細', + 'settings.transcript.verbose': '冗長', + 'fileOpen.title': 'ファイルを開けませんでした', + 'fileOpen.unknown': 'このファイルを開けませんでした', + 'message.extraBlock': '追加のコンテンツブロック', + 'message.systemPrompt': 'システムプロンプト', + 'message.systemPromptUpdate': 'システムプロンプトの更新', + 'message.toolAdded': 'ツールを追加: {name}', + 'message.toolRemoved': 'ツールを削除: {name}', + 'message.toolsAdded': '追加: {names}', + 'message.toolsAddedCount': '{count} 件追加', + 'message.toolsChanged': '{added} 件追加、{removed} 件削除', + 'message.toolsRemoved': '削除: {names}', + 'message.toolsRemovedCount': '{count} 件削除', + 'message.toolsUpdated': 'ツールを更新しました', + 'message.contextInjection': 'コンテキスト注入', + 'message.contextRecall': 'セッションの呼び出し', + 'message.referenceSummary': '参照セッション · {labels}', + 'message.referenceSeparator': ', ', + 'message.context.instructions.loaded': '読み込み済み', + 'message.context.instructions.added': '追加済み', + 'message.context.instructions.updated': '更新済み', + 'message.context.instructions.removed': '削除済み', + 'message.context.catalog.replaced': '置換カタログ', + 'message.context.catalog.more': '… 他 {count} 件', + 'message.context.snapshot.supersedes': '以前のスナップショットを置き換えます', + 'message.context.relay.from': 'セッション {session} から', + 'message.context.recall.counts': '{retained} 件保持 · {omitted} 件除外', + 'message.context.recall.truncated': '切り詰め済み', + 'message.compaction': 'コンテキストを圧縮しました', + 'message.compaction.running': 'コンテキストを圧縮中…', + 'message.compaction.completed': '履歴 {items} 件を圧縮しました(約 {tokens} トークン)', + 'message.compaction.expand': '圧縮の要約を表示', + 'message.compaction.unavailable': '圧縮の要約は利用できません', + 'message.compaction.commandTitle': 'compact', + 'message.think': '思考', + 'message.unknownSurface': '不明なサーフェスイベント: {type}', + 'message.unknownBlock': '不明なコンテンツブロック', + 'message.turnProcess.toolCalls.one': 'ツール呼び出し {count} 件', + 'message.turnProcess.toolCalls.other': 'ツール呼び出し {count} 件', + 'message.turnProcess.messages.one': 'メッセージ {count} 件', + 'message.turnProcess.messages.other': 'メッセージ {count} 件', + 'message.turnProcess.subagents.one': 'サブエージェント {count} 件', + 'message.turnProcess.subagents.other': 'サブエージェント {count} 件', + 'message.turnProcess.thoughtForAWhile': 'しばらく思考しました', + 'message.turnProcess.separator': ' · ', + 'message.stopped': '停止しました', + 'message.branch': '新しい会話に分岐', + 'message.branchUnavailable': '完了したターンの最後のメッセージでのみ利用できます', + 'message.retry.active': 'モデルリクエストを再試行中', + 'message.retry.cancelled': 'モデルリクエストの再試行をキャンセルしました', + 'message.retry.started': 'モデルリクエストを再試行しました', + 'message.retry.scheduled': 'モデルリクエストの再試行待ちです', + 'message.retry.status': '{label} ({retry}/{maximum}) · {seconds}秒', + 'message.retry.delay': '再試行までの待機時間: ', + 'message.retry.failure': '失敗理由: ', + 'message.failure.auth': 'APIキーが無効です', + 'message.taskStopped': 'タスクを停止しました', + 'message.failure.accountSignedOut': 'DeepSeekからログアウトしたため停止しました。', + 'message.failure.interrupted': 'このターンが完了する前にDSHプロセスが終了したため(強制終了、クラッシュ、シャットダウン中のタイムアウトなど)、DSHの再起動時にターンは閉じられました。未完了のツール呼び出しの結果は不明です。', + 'message.failure.accountSignInRequired': 'DeepSeekにログインし、リクエスト先がアカウント認証に対応していることを確認してください。', + 'message.failure.quota': 'リクエストのクォータを使い切りました。', + 'message.turnError': 'このターンは失敗しました', + 'message.maxTokens': '出力トークンの上限に達しました', + 'message.maxTokens.hint': '応答が途中で打ち切られました。それまでの出力は会話に保持されています。「続けて」と送信するとモデルが再開します。', + 'message.tokensPerSecond': '{tps} tok/s', + 'message.turnUsage.title': 'ターンの使用量', + 'message.turnUsage.consumed': '使用量 {total}', + 'message.turnUsage.model': 'プロバイダー / モデル', + 'message.turnUsage.cacheHit': 'キャッシュヒット', + 'message.turnUsage.input': '未キャッシュの入力', + 'message.turnUsage.cacheRead': 'キャッシュ済み入力', + 'message.turnUsage.cacheWrite': 'キャッシュ書き込み', + 'message.turnUsage.output': '出力', + 'message.turnUsage.reasoning': ' (推論 {tokens})', + 'message.turnUsage.count': '{count} tok', + 'duration.secondUnit': '秒', + 'duration.minuteUnit': '分 ', + 'duration.hourUnit': '時間 ', + 'command.running': '実行中…', + 'command.failed': 'コマンドが失敗しました', + 'command.done': '完了', + 'command.title': 'コマンド', + 'row.running': '実行中', + 'row.failed': '失敗', + 'json.truncated': '… 省略表示、全{total}文字', + 'clock.md': '{m}/{d}', + 'clock.ymd': '{y}-{m}-{d}', +} satisfies Record + +/** Spanish dictionary, checked against the English keys. */ +export const es = { + 'message.stepProcess.thinking': 'Analizando la solicitud', + 'message.stepProcess.read': 'Leyendo archivos', + 'message.stepProcess.readImage': 'Leyendo imágenes', + 'message.stepProcess.write': 'Escribir archivos', + 'message.stepProcess.search': 'código de búsqueda', + 'message.stepProcess.edit': 'Editar archivos', + 'message.stepProcess.commands': 'Ejecutar comandos', + 'message.stepProcess.code': 'Código en ejecución', + 'message.stepProcess.webSearch': 'buscando en la web', + 'message.stepProcess.webFetch': 'Visitar páginas web', + 'message.stepProcess.subagents': 'Subagentes coordinadores', + 'message.stepProcess.plan': 'Actualizando el plan', + 'message.stepProcess.questions': 'Esperando tu acción', + 'message.stepProcess.tools': 'herramientas de llamada', + 'message.stepProcess.prepare.read': 'Preparándose para leer archivos', + 'message.stepProcess.prepare.readImage': 'Preparándose para leer imágenes', + 'message.stepProcess.prepare.write': 'Preparándose para escribir archivos', + 'message.stepProcess.prepare.search': 'Preparándose para buscar el código', + 'message.stepProcess.prepare.edit': 'Preparándose para editar archivos', + 'message.stepProcess.prepare.commands': 'Preparándose para ejecutar comandos', + 'message.stepProcess.prepare.code': 'Preparándose para ejecutar código', + 'message.stepProcess.prepare.webSearch': 'Preparándose para buscar en la web', + 'message.stepProcess.prepare.webFetch': 'Preparándose para visitar páginas web', + 'message.stepProcess.prepare.subagents': 'Preparándose para coordinar subagentes', + 'message.stepProcess.prepare.plan': 'Preparándose para actualizar el plan', + 'message.stepProcess.prepare.questions': 'Preparando preguntas', + 'message.stepProcess.prepare.tools': 'Preparando llamadas a herramientas', + 'message.stepProcess.done.thinking': 'Análisis completado', + 'message.stepProcess.done.read': 'leer archivos', + 'message.stepProcess.done.readImage': 'Leer imágenes', + 'message.stepProcess.done.write': 'Escribió archivos', + 'message.stepProcess.done.search': 'código buscado', + 'message.stepProcess.done.edit': 'Archivos editados', + 'message.stepProcess.done.commands': 'Comandos ejecutados', + 'message.stepProcess.done.code': 'código ejecutado', + 'message.stepProcess.done.webSearch': 'Busqué en la web', + 'message.stepProcess.done.webFetch': 'Paginas web visitadas', + 'message.stepProcess.done.subagents': 'Subagentes coordinados', + 'message.stepProcess.done.plan': 'Actualizado el plan', + 'message.stepProcess.done.questions': 'preguntas hechas', + 'message.stepProcess.done.tools': 'herramientas llamadas', + 'message.stepProcess.joinTwo': '{first} y {second}', + 'message.stepProcess.comma': ', ', + 'message.stepProcess.sharedPrefix': '', + 'message.stepProcess.more': '{title}, etc.', + 'message.trigger.request': 'Ejecución solicitada', + 'message.trigger.goal': 'Objetivo continuo', + 'message.trigger.agent': 'Mensaje de tarea recibido', + 'message.trigger.team': 'Mensaje del equipo recibido', + 'message.trigger.subagent': 'Estado de subtarea actualizado', + 'message.trigger.github': 'Evento GitHub recibido', + 'message.trigger.webhook': 'Evento externo recibido', + 'message.trigger.schedule': 'Tarea de automatización', + 'message.trigger.job': 'Tarea en segundo plano actualizada', + 'message.trigger.plugin': 'Estado del complemento actualizado', + 'message.trigger.explanation': 'Esta notificación desencadenó esta respuesta.', + 'message.turnProcess.worked': 'Completado', + 'message.turnProcess.took': 'Completado en ', + 'message.turnProcess.failed': 'Fallido', + 'view.chat': 'Chat', + 'image.open': 'Ver imagen completa', + 'image.loading': 'Cargando imagen…', + 'image.failed': 'Vista previa de la imagen no disponible', + 'image.dialog': 'Vista previa de la imagen', + 'image.close': 'Cerrar vista previa de la imagen', + 'number.groupSeparator': ',', + 'duration.compactSeconds': '{seconds}s', + 'duration.compactMinutes': '{minutes}m{seconds}s', + 'duration.milliseconds': '{milliseconds}ms', + 'stats.counts': '{turns} turnos, {steps} pasos', + 'stats.cacheHit': 'Aciertos de caché: {percent}%', + 'stats.dialog.title': 'Estadísticas de sesión', + 'stats.dialog.usageTitle': 'Uso de tokens', + 'stats.dialog.llmTime': 'tiempo de maestría', + 'stats.dialog.toolTime': 'tiempo de herramienta', + 'stats.dialog.ttft': 'Tiempo promedio hasta el primer token (TTFT)', + 'stats.dialog.speed': 'Fichas por segundo (TPS)', + 'chat.loadingHistory': 'Cargando historial…', + 'chat.loadError': 'No se pudo cargar el historial: {message} ({code})', + 'chat.loadOlder': 'Cargar antes', + 'chat.toBottom': 'Volver al fondo', + 'chat.deepDiving': 'Razonamiento profundo', + 'chat.deepDivingFor': 'Razonando a fondo durante {duration} ···', + 'chat.turnNavigation.label': 'Navegación por turnos', + 'chat.turnNavigation.jump': 'Ir al turno {turn}', + 'chat.turnNavigation.jumpLoad': 'Cargar e ir al turno {turn}', + 'chat.turnNavigation.turn': 'Turno {turn}', + 'settings.performance.title': 'Rendimiento y uso', + 'settings.performance.description': 'Elija cuánta información de rendimiento y uso desea mostrar', + 'settings.performance.compact': 'Compacto', + 'settings.performance.detailed': 'Detallado', + 'settings.links.title': 'Abrir enlaces de chat en', + 'settings.links.description': 'Elija dónde abrir enlaces web', + 'settings.links.sidebar': 'Barra lateral en la aplicación', + 'settings.links.newTab': 'Navegador predeterminado', + 'settings.transcript.title': 'Detalles del trabajo', + 'settings.transcript.description': 'Elija cuántos detalles mostrar para las llamadas de herramientas', + 'settings.collapse.title': 'Cuándo contraer los detalles del trabajo', + 'settings.collapse.description': 'Elija cuándo contraer automáticamente los detalles del trabajo', + 'settings.collapse.completion': 'Al finalizar', + 'settings.collapse.nextInput': 'En el siguiente mensaje', + 'settings.foldedReplies.title': 'Respuestas después del plegado', + 'settings.foldedReplies.description': 'Elija qué respuestas del asistente permanecen visibles cuando los detalles del trabajo colapsan', + 'settings.foldedReplies.all': 'Todas las respuestas', + 'settings.foldedReplies.final': 'Solo respuesta final', + 'settings.transcript.compact': 'Compacto', + 'settings.transcript.standard': 'Estándar', + 'settings.transcript.detailed': 'Detallado', + 'settings.transcript.verbose': 'Detallado', + 'fileOpen.title': 'No se pudo abrir el archivo', + 'fileOpen.unknown': 'No se pudo abrir este archivo', + 'message.extraBlock': 'Bloque de contenido adicional', + 'message.systemPrompt': 'Aviso del sistema', + 'message.systemPromptUpdate': 'Actualización del mensaje del sistema', + 'message.toolAdded': 'Herramienta agregada: {name}', + 'message.toolRemoved': 'Herramienta eliminada: {name}', + 'message.toolsAdded': 'Añadido: {names}', + 'message.toolsAddedCount': '{count} añadido', + 'message.toolsChanged': '{added} agregado, {removed} eliminado', + 'message.toolsRemoved': 'Eliminado: {names}', + 'message.toolsRemovedCount': '{count} eliminado', + 'message.toolsUpdated': 'Herramientas actualizadas', + 'message.contextInjection': 'inyección de contexto', + 'message.contextRecall': 'Recuerdo de sesión', + 'message.referenceSummary': 'Sesión referenciada · {labels}', + 'message.referenceSeparator': ', ', + 'message.context.instructions.loaded': 'cargado', + 'message.context.instructions.added': 'añadido', + 'message.context.instructions.updated': 'actualizado', + 'message.context.instructions.removed': 'eliminado', + 'message.context.catalog.replaced': 'Catálogo de repuestos', + 'message.context.catalog.more': '… {count} más', + 'message.context.snapshot.supersedes': 'Reemplaza instantáneas anteriores', + 'message.context.relay.from': 'De la sesión {session}', + 'message.context.recall.counts': '{retained} mantenido · {omitted} omitido', + 'message.context.recall.truncated': 'truncado', + 'message.compaction': 'Contexto compactado', + 'message.compaction.running': 'Contexto de compactación…', + 'message.compaction.completed': 'Elementos del historial {items} compactados (~tokens {tokens})', + 'message.compaction.expand': 'Ver resumen de compactación', + 'message.compaction.unavailable': 'Resumen de compactación no disponible', + 'message.compaction.commandTitle': 'compacto', + 'message.think': 'pensar', + 'message.unknownSurface': 'Evento de superficie desconocido: {type}', + 'message.unknownBlock': 'Bloque de contenido desconocido', + 'message.turnProcess.toolCalls.one': 'Llamada a la herramienta {count}', + 'message.turnProcess.toolCalls.other': 'Llamadas a herramientas {count}', + 'message.turnProcess.messages.one': 'Mensaje {count}', + 'message.turnProcess.messages.other': 'Mensajes {count}', + 'message.turnProcess.subagents.one': 'Subagente {count}', + 'message.turnProcess.subagents.other': 'Subagentes {count}', + 'message.turnProcess.thoughtForAWhile': 'Razonó durante un tiempo', + 'message.turnProcess.separator': ' · ', + 'message.stopped': 'Detenido', + 'message.branch': 'Pasar a una nueva conversación', + 'message.branchUnavailable': 'Disponible solo en el último mensaje de un turno completado', + 'message.retry.active': 'Reintentando solicitud de modelo', + 'message.retry.cancelled': 'Reintento de solicitud de modelo cancelado', + 'message.retry.started': 'Solicitud de modelo reintentada', + 'message.retry.scheduled': 'Esperando para volver a intentar la solicitud de modelo', + 'message.retry.status': '{label} ({retry}/{maximum}) · {seconds}s', + 'message.retry.delay': 'Retraso de reintento: ', + 'message.retry.failure': 'Razón del fracaso: ', + 'message.failure.auth': 'La clave API no es válida', + 'message.taskStopped': 'Tarea detenida', + 'message.failure.accountSignedOut': 'Se detuvo porque cerró sesión en DeepSeek.', + 'message.failure.interrupted': 'El proceso DSH salió antes de que terminara este turno (por ejemplo, se detuvo, se estrelló o se agotó el tiempo de espera mientras se apagaba), por lo que el turno se cerró cuando se reinició DSH. Las llamadas a herramientas inacabadas tienen resultados desconocidos.', + 'message.failure.accountSignInRequired': 'Inicie sesión en DeepSeek y asegúrese de que el destino de la solicitud admita la autenticación de la cuenta.', + 'message.failure.quota': 'Solicitud de cupo agotado.', + 'message.turnError': 'Este turno falló', + 'message.maxTokens': 'Se alcanzó el límite del token de salida', + 'message.maxTokens.hint': 'La respuesta fue cortada; La salida anterior se conserva en la conversación. Envíe "continuar" para permitir que se reanude el modelo.', + 'message.tokensPerSecond': '{tps} tok/s', + 'message.turnUsage.title': 'Uso del turno', + 'message.turnUsage.consumed': 'Uso {total}', + 'message.turnUsage.model': 'Proveedor / modelo', + 'message.turnUsage.cacheHit': 'Aciertos de caché', + 'message.turnUsage.input': 'Entrada sin caché', + 'message.turnUsage.cacheRead': 'Entrada en caché', + 'message.turnUsage.cacheWrite': 'escritura en caché', + 'message.turnUsage.output': 'Salida', + 'message.turnUsage.reasoning': ' (razonamiento {tokens})', + 'message.turnUsage.count': 'Tok {count}', + 'duration.secondUnit': 's', + 'duration.minuteUnit': 'm ', + 'duration.hourUnit': 'h ', + 'command.running': 'Corriendo…', + 'command.failed': 'El comando falló', + 'command.done': 'Completado', + 'command.title': 'Comando', + 'row.running': 'corriendo', + 'row.failed': 'Fallido', + 'json.truncated': '… truncado, caracteres {total} en total', + 'clock.md': '{m}/{d}', + 'clock.ymd': '{y}-{m}-{d}', +} satisfies Record + +/** French dictionary, checked against the English keys. */ +export const fr = { + 'message.stepProcess.thinking': 'Analyser la demande', + 'message.stepProcess.read': 'Lecture de fichiers', + 'message.stepProcess.readImage': 'Lire des images', + 'message.stepProcess.write': 'Écriture de fichiers', + 'message.stepProcess.search': 'Recherche de code', + 'message.stepProcess.edit': 'Modification de fichiers', + 'message.stepProcess.commands': 'Exécuter des commandes', + 'message.stepProcess.code': 'Exécution du code', + 'message.stepProcess.webSearch': 'Recherche sur le Web', + 'message.stepProcess.webFetch': 'Visiter des pages Web', + 'message.stepProcess.subagents': 'Sous-agents de coordination', + 'message.stepProcess.plan': 'Mise à jour du plan', + 'message.stepProcess.questions': 'En attendant votre action', + 'message.stepProcess.tools': "Outils d'appel", + 'message.stepProcess.prepare.read': 'Préparation à la lecture de fichiers', + 'message.stepProcess.prepare.readImage': 'Se préparer à lire des images', + 'message.stepProcess.prepare.write': "Préparation à l'écriture de fichiers", + 'message.stepProcess.prepare.search': 'Préparation à la recherche de code', + 'message.stepProcess.prepare.edit': 'Préparation à la modification de fichiers', + 'message.stepProcess.prepare.commands': "Préparation à l'exécution de commandes", + 'message.stepProcess.prepare.code': "Préparation à l'exécution du code", + 'message.stepProcess.prepare.webSearch': 'Se préparer à effectuer une recherche sur le Web', + 'message.stepProcess.prepare.webFetch': 'Se préparer à visiter des pages Web', + 'message.stepProcess.prepare.subagents': 'Se préparer à coordonner les sous-agents', + 'message.stepProcess.prepare.plan': 'Préparation de la mise à jour du plan', + 'message.stepProcess.prepare.questions': 'Préparation des questions', + 'message.stepProcess.prepare.tools': "Préparation des appels d'outils", + 'message.stepProcess.done.thinking': 'Analyse terminée', + 'message.stepProcess.done.read': 'Lire des fichiers', + 'message.stepProcess.done.readImage': 'Lire des images', + 'message.stepProcess.done.write': 'A écrit des fichiers', + 'message.stepProcess.done.search': 'Code recherché', + 'message.stepProcess.done.edit': 'Fichiers modifiés', + 'message.stepProcess.done.commands': 'Commandes exécutées', + 'message.stepProcess.done.code': 'Code exécuté', + 'message.stepProcess.done.webSearch': "J'ai cherché sur le Web", + 'message.stepProcess.done.webFetch': 'Pages Web visitées', + 'message.stepProcess.done.subagents': 'Sous-agents coordonnés', + 'message.stepProcess.done.plan': 'Plan mis à jour', + 'message.stepProcess.done.questions': 'Questions posées', + 'message.stepProcess.done.tools': 'Outils appelés', + 'message.stepProcess.joinTwo': '{first} et {second}', + 'message.stepProcess.comma': ', ', + 'message.stepProcess.sharedPrefix': '', + 'message.stepProcess.more': '{title}, etc.', + 'message.trigger.request': 'Exécution demandée', + 'message.trigger.goal': 'Objectif continu', + 'message.trigger.agent': 'Message de tâche reçu', + 'message.trigger.team': "Message d'équipe reçu", + 'message.trigger.subagent': 'Statut de la sous-tâche mis à jour', + 'message.trigger.github': 'Événement GitHub reçu', + 'message.trigger.webhook': 'Événement externe reçu', + 'message.trigger.schedule': "Tâche d'automatisation", + 'message.trigger.job': 'Tâche en arrière-plan mise à jour', + 'message.trigger.plugin': 'Statut du plugin mis à jour', + 'message.trigger.explanation': 'Cette notification a déclenché cette réponse.', + 'message.turnProcess.worked': 'Terminé', + 'message.turnProcess.took': 'Terminé en ', + 'message.turnProcess.failed': 'Échec', + 'view.chat': 'Discussion', + 'image.open': "Voir l'image complète", + 'image.loading': "Chargement de l'image…", + 'image.failed': "Aperçu de l'image indisponible", + 'image.dialog': "Aperçu de l'image", + 'image.close': "Fermer l'aperçu de l'image", + 'number.groupSeparator': ',', + 'duration.compactSeconds': '{seconds}s', + 'duration.compactMinutes': '{minutes}m{seconds}s', + 'duration.milliseconds': '{milliseconds}ms', + 'stats.counts': '{turns} tours, {steps} étapes', + 'stats.cacheHit': 'Taux de réussite du cache : {percent} %', + 'stats.dialog.title': 'Statistiques de session', + 'stats.dialog.usageTitle': 'Utilisation des jetons', + 'stats.dialog.llmTime': 'Temps LLM', + 'stats.dialog.toolTime': "Temps d'outil", + 'stats.dialog.ttft': "Temps moyen jusqu'au premier jeton (TTFT)", + 'stats.dialog.speed': 'Jetons par seconde (TPS)', + 'chat.loadingHistory': "Chargement de l'historique…", + 'chat.loadError': "Échec du chargement de l'historique : {message} ({code})", + 'chat.loadOlder': 'Charger plus tôt', + 'chat.toBottom': 'Retour en bas', + 'chat.deepDiving': 'Réflexion approfondie', + 'chat.deepDivingFor': 'Réflexion approfondie depuis {duration} ···', + 'chat.turnNavigation.label': 'Navigation entre les tours', + 'chat.turnNavigation.jump': 'Aller au tour {turn}', + 'chat.turnNavigation.jumpLoad': 'Charger et aller au tour {turn}', + 'chat.turnNavigation.turn': 'Tour {turn}', + 'settings.performance.title': 'Performances et utilisation', + 'settings.performance.description': "Choisissez la quantité d'informations sur les performances et l'utilisation à afficher", + 'settings.performance.compact': 'Compacte', + 'settings.performance.detailed': 'Détaillé', + 'settings.links.title': 'Ouvrir les liens de discussion dans', + 'settings.links.description': 'Choisissez où ouvrir les liens Web', + 'settings.links.sidebar': "Barre latérale dans l'application", + 'settings.links.newTab': 'Navigateur par défaut', + 'settings.transcript.title': 'Détails du travail', + 'settings.transcript.description': "Choisissez le niveau de détails à afficher pour les appels d'outils", + 'settings.collapse.title': 'Quand réduire les détails du travail', + 'settings.collapse.description': 'Choisissez quand réduire automatiquement les détails du travail', + 'settings.collapse.completion': 'À la fin', + 'settings.collapse.nextInput': 'Au prochain message', + 'settings.foldedReplies.title': 'Réponses après le repli', + 'settings.foldedReplies.description': "Choisissez quelles réponses de l'assistant restent visibles lorsque les détails du travail sont réduits", + 'settings.foldedReplies.all': 'Toutes les réponses', + 'settings.foldedReplies.final': 'Réponse finale seulement', + 'settings.transcript.compact': 'Compacte', + 'settings.transcript.standard': 'Norme', + 'settings.transcript.detailed': 'Détaillé', + 'settings.transcript.verbose': 'Verbeux', + 'fileOpen.title': "Impossible d'ouvrir le fichier", + 'fileOpen.unknown': "Impossible d'ouvrir ce fichier", + 'message.extraBlock': 'Bloc de contenu supplémentaire', + 'message.systemPrompt': 'Invite système', + 'message.systemPromptUpdate': "Mise à jour de l'invite du système", + 'message.toolAdded': 'Outil ajouté : {name}', + 'message.toolRemoved': 'Outil supprimé : {name}', + 'message.toolsAdded': 'Ajouté : {names}', + 'message.toolsAddedCount': '{count} ajouté', + 'message.toolsChanged': '{added} ajouté, {removed} supprimé', + 'message.toolsRemoved': 'Supprimé : {names}', + 'message.toolsRemovedCount': '{count} supprimé', + 'message.toolsUpdated': 'Outils mis à jour', + 'message.contextInjection': 'Injection de contexte', + 'message.contextRecall': 'Rappel de session', + 'message.referenceSummary': 'Séance référencée · {labels}', + 'message.referenceSeparator': ', ', + 'message.context.instructions.loaded': 'chargé', + 'message.context.instructions.added': 'ajouté', + 'message.context.instructions.updated': 'mis à jour', + 'message.context.instructions.removed': 'supprimé', + 'message.context.catalog.replaced': 'Catalogue de remplacement', + 'message.context.catalog.more': '… {count} plus', + 'message.context.snapshot.supersedes': 'Remplace les instantanés précédents', + 'message.context.relay.from': 'De la séance {session}', + 'message.context.recall.counts': '{retained} conservé · {omitted} omis', + 'message.context.recall.truncated': 'tronqué', + 'message.compaction': 'Contexte compacté', + 'message.compaction.running': 'Contexte de compactage…', + 'message.compaction.completed': "Éléments d'historique {items} compactés (~jetons {tokens})", + 'message.compaction.expand': 'Afficher le résumé du compactage', + 'message.compaction.unavailable': 'Résumé du compactage indisponible', + 'message.compaction.commandTitle': 'compacte', + 'message.think': 'Pensez', + 'message.unknownSurface': 'Événement de surface inconnu : {type}', + 'message.unknownBlock': 'Bloc de contenu inconnu', + 'message.turnProcess.toolCalls.one': "Appel d'outil {count}", + 'message.turnProcess.toolCalls.other': "Appels d'outil {count}", + 'message.turnProcess.messages.one': 'Message {count}', + 'message.turnProcess.messages.other': 'Messages {count}', + 'message.turnProcess.subagents.one': 'Sous-agent {count}', + 'message.turnProcess.subagents.other': 'Sous-agents {count}', + 'message.turnProcess.thoughtForAWhile': 'Réflexion pendant un moment', + 'message.turnProcess.separator': ' · ', + 'message.stopped': 'Arrêté', + 'message.branch': 'Lancez-vous dans une nouvelle conversation', + 'message.branchUnavailable': "Disponible uniquement sur le dernier message d'un tour terminé", + 'message.retry.active': 'Nouvelle tentative de demande de modèle', + 'message.retry.cancelled': 'Nouvelle tentative de demande de modèle annulée', + 'message.retry.started': 'Demande de modèle réessayée', + 'message.retry.scheduled': 'En attente de réessayer la demande de modèle', + 'message.retry.status': '{label} ({retry}/{maximum}) · {seconds}', + 'message.retry.delay': 'Délai de nouvelle tentative : ', + 'message.retry.failure': "Raison de l'échec : ", + 'message.failure.auth': "La clé API n'est pas valide", + 'message.taskStopped': 'Tâche arrêtée', + 'message.failure.accountSignedOut': 'Arrêté car vous vous êtes déconnecté de DeepSeek.', + 'message.failure.interrupted': "Le processus DSH s'est terminé avant la fin de ce tour (par exemple, il a été arrêté, s'est écrasé ou a expiré lors de l'arrêt), de sorte que le tour a été fermé lorsque DSH a redémarré. Les appels d’outils inachevés ont des résultats inconnus.", + 'message.failure.accountSignInRequired': "Connectez-vous à DeepSeek et assurez-vous que la destination de la demande prend en charge l'authentification du compte.", + 'message.failure.quota': 'Quota de demandes épuisé.', + 'message.turnError': 'Ce tour a échoué', + 'message.maxTokens': 'Limite de jetons de sortie atteinte', + 'message.maxTokens.hint': 'La réponse fut coupée ; la sortie antérieure est conservée dans la conversation. Envoyez « continuer » pour laisser le modèle reprendre.', + 'message.tokensPerSecond': '{tps} jetons/s', + 'message.turnUsage.title': 'Consommation du tour', + 'message.turnUsage.consumed': 'Utilisation {total}', + 'message.turnUsage.model': 'Fournisseur / modèle', + 'message.turnUsage.cacheHit': 'Taux de réussite du cache', + 'message.turnUsage.input': 'Entrée non mise en cache', + 'message.turnUsage.cacheRead': 'Entrée en cache', + 'message.turnUsage.cacheWrite': 'Écriture en cache', + 'message.turnUsage.output': 'Sortie', + 'message.turnUsage.reasoning': ' (raisonnement {tokens})', + 'message.turnUsage.count': 'Jeton {count}', + 'duration.secondUnit': 's', + 'duration.minuteUnit': 'm ', + 'duration.hourUnit': 'h ', + 'command.running': 'Courir…', + 'command.failed': 'La commande a échoué', + 'command.done': 'Terminé', + 'command.title': 'Commande', + 'row.running': 'Courir', + 'row.failed': 'Échec', + 'json.truncated': '… tronqué, nombre total de caractères {total}', + 'clock.md': '{m}/{d}', + 'clock.ymd': '{y}-{m}-{d}', +} satisfies Record + +/** German dictionary, checked against the English keys. */ +export const de = { + 'message.stepProcess.thinking': 'Analyse der Anfrage', + 'message.stepProcess.read': 'Dateien lesen', + 'message.stepProcess.readImage': 'Bilder lesen', + 'message.stepProcess.write': 'Dateien schreiben', + 'message.stepProcess.search': 'Code wird gesucht', + 'message.stepProcess.edit': 'Bearbeiten von Dateien', + 'message.stepProcess.commands': 'Befehle ausführen', + 'message.stepProcess.code': 'Ausführen von Code', + 'message.stepProcess.webSearch': 'Suche im Internet', + 'message.stepProcess.webFetch': 'Besuch von Webseiten', + 'message.stepProcess.subagents': 'Koordinierende Unteragenten', + 'message.stepProcess.plan': 'Aktualisierung des Plans', + 'message.stepProcess.questions': 'Ich warte auf Ihre Aktion', + 'message.stepProcess.tools': 'Aufrufende Werkzeuge', + 'message.stepProcess.prepare.read': 'Vorbereitung zum Lesen von Dateien', + 'message.stepProcess.prepare.readImage': 'Vorbereitung zum Lesen von Bildern', + 'message.stepProcess.prepare.write': 'Vorbereitung zum Schreiben von Dateien', + 'message.stepProcess.prepare.search': 'Vorbereiten der Codesuche', + 'message.stepProcess.prepare.edit': 'Vorbereitung zum Bearbeiten von Dateien', + 'message.stepProcess.prepare.commands': 'Vorbereitung zum Ausführen von Befehlen', + 'message.stepProcess.prepare.code': 'Vorbereiten, Code auszuführen', + 'message.stepProcess.prepare.webSearch': 'Bereiten Sie sich auf die Suche im Internet vor', + 'message.stepProcess.prepare.webFetch': 'Vorbereitung auf den Besuch von Webseiten', + 'message.stepProcess.prepare.subagents': 'Vorbereitung zur Koordinierung von Subagenten', + 'message.stepProcess.prepare.plan': 'Vorbereitung zur Aktualisierung des Plans', + 'message.stepProcess.prepare.questions': 'Fragen vorbereiten', + 'message.stepProcess.prepare.tools': 'Toolaufrufe vorbereiten', + 'message.stepProcess.done.thinking': 'Analyse abgeschlossen', + 'message.stepProcess.done.read': 'Dateien lesen', + 'message.stepProcess.done.readImage': 'Bilder lesen', + 'message.stepProcess.done.write': 'Dateien geschrieben', + 'message.stepProcess.done.search': 'Gesuchter Code', + 'message.stepProcess.done.edit': 'Bearbeitete Dateien', + 'message.stepProcess.done.commands': 'Befehle ausgeführt', + 'message.stepProcess.done.code': 'Code ausgeführt', + 'message.stepProcess.done.webSearch': 'Habe das Internet durchsucht', + 'message.stepProcess.done.webFetch': 'Besuchte Webseiten', + 'message.stepProcess.done.subagents': 'Koordinierte Unteragenten', + 'message.stepProcess.done.plan': 'Plan aktualisiert', + 'message.stepProcess.done.questions': 'Gestellte Fragen', + 'message.stepProcess.done.tools': 'Werkzeuge genannt', + 'message.stepProcess.joinTwo': '{first} und {second}', + 'message.stepProcess.comma': ', ', + 'message.stepProcess.sharedPrefix': '', + 'message.stepProcess.more': '{title} usw.', + 'message.trigger.request': 'Ausführung beantragt', + 'message.trigger.goal': 'Weiterführendes Ziel', + 'message.trigger.agent': 'Aufgabennachricht erhalten', + 'message.trigger.team': 'Teamnachricht erhalten', + 'message.trigger.subagent': 'Unteraufgabenstatus aktualisiert', + 'message.trigger.github': 'GitHub-Ereignis empfangen', + 'message.trigger.webhook': 'Externes Ereignis empfangen', + 'message.trigger.schedule': 'Automatisierungsaufgabe', + 'message.trigger.job': 'Hintergrundaufgabe aktualisiert', + 'message.trigger.plugin': 'Plugin-Status aktualisiert', + 'message.trigger.explanation': 'Diese Benachrichtigung hat diese Reaktion ausgelöst.', + 'message.turnProcess.worked': 'Abgeschlossen', + 'message.turnProcess.took': 'Fertiggestellt in ', + 'message.turnProcess.failed': 'Fehlgeschlagen', + 'view.chat': 'Chat', + 'image.open': 'Vollständiges Bild anzeigen', + 'image.loading': 'Bild wird geladen…', + 'image.failed': 'Bildvorschau nicht verfügbar', + 'image.dialog': 'Bildvorschau', + 'image.close': 'Bildvorschau schließen', + 'number.groupSeparator': ',', + 'duration.compactSeconds': '{seconds}s', + 'duration.compactMinutes': '{minutes}m{seconds}s', + 'duration.milliseconds': '{milliseconds}ms', + 'stats.counts': '{turns} Runden, {steps} Schritte', + 'stats.cacheHit': 'Cache-Treffer: {percent}%', + 'stats.dialog.title': 'Sitzungsstatistiken', + 'stats.dialog.usageTitle': 'Token-Nutzung', + 'stats.dialog.llmTime': 'LLM-Zeit', + 'stats.dialog.toolTime': 'Werkzeugzeit', + 'stats.dialog.ttft': 'Durchschnittliche Zeit bis zum ersten Token (TTFT)', + 'stats.dialog.speed': 'Token pro Sekunde (TPS)', + 'chat.loadingHistory': 'Verlauf wird geladen…', + 'chat.loadError': 'Fehler beim Laden des Verlaufs: {message} ({code})', + 'chat.loadOlder': 'Früher laden', + 'chat.toBottom': 'Zurück nach unten', + 'chat.deepDiving': 'Gründliches Nachdenken', + 'chat.deepDivingFor': 'Gründliches Nachdenken seit {duration} ···', + 'chat.turnNavigation.label': 'Rundennavigation', + 'chat.turnNavigation.jump': 'Zu Runde {turn} springen', + 'chat.turnNavigation.jumpLoad': 'Runde {turn} laden und öffnen', + 'chat.turnNavigation.turn': 'Runde {turn}', + 'settings.performance.title': 'Leistung und Nutzung', + 'settings.performance.description': 'Wählen Sie aus, wie viele Leistungs- und Nutzungsinformationen angezeigt werden sollen', + 'settings.performance.compact': 'Kompakt', + 'settings.performance.detailed': 'Ausführlich', + 'settings.links.title': 'Öffnen Sie Chat-Links in', + 'settings.links.description': 'Wählen Sie aus, wo Weblinks geöffnet werden sollen', + 'settings.links.sidebar': 'In-App-Seitenleiste', + 'settings.links.newTab': 'Standardbrowser', + 'settings.transcript.title': 'Arbeitsdetails', + 'settings.transcript.description': 'Wählen Sie aus, wie viele Details für Werkzeugaufrufe angezeigt werden sollen', + 'settings.collapse.title': 'Wann Arbeitsdetails minimiert werden sollten', + 'settings.collapse.description': 'Wählen Sie aus, wann Arbeitsdetails automatisch ausgeblendet werden sollen', + 'settings.collapse.completion': 'Nach Fertigstellung', + 'settings.collapse.nextInput': 'Bei der nächsten Nachricht', + 'settings.foldedReplies.title': 'Antworten nach dem Einklappen', + 'settings.foldedReplies.description': 'Wählen Sie aus, welche Assistentenantworten sichtbar bleiben, wenn die Arbeitsdetails ausgeblendet werden', + 'settings.foldedReplies.all': 'Alle Antworten', + 'settings.foldedReplies.final': 'Nur die endgültige Antwort', + 'settings.transcript.compact': 'Kompakt', + 'settings.transcript.standard': 'Standard', + 'settings.transcript.detailed': 'Ausführlich', + 'settings.transcript.verbose': 'Ausführlich', + 'fileOpen.title': 'Datei konnte nicht geöffnet werden', + 'fileOpen.unknown': 'Diese Datei konnte nicht geöffnet werden', + 'message.extraBlock': 'Zusätzlicher Inhaltsblock', + 'message.systemPrompt': 'Systemaufforderung', + 'message.systemPromptUpdate': 'Aktualisierung der Systemaufforderung', + 'message.toolAdded': 'Tool hinzugefügt: {name}', + 'message.toolRemoved': 'Entferntes Werkzeug: {name}', + 'message.toolsAdded': 'Hinzugefügt: {names}', + 'message.toolsAddedCount': '{count} hinzugefügt', + 'message.toolsChanged': '{added} hinzugefügt, {removed} entfernt', + 'message.toolsRemoved': 'Entfernt: {names}', + 'message.toolsRemovedCount': '{count} entfernt', + 'message.toolsUpdated': 'Werkzeuge aktualisiert', + 'message.contextInjection': 'Kontextinjektion', + 'message.contextRecall': 'Sitzungsrückruf', + 'message.referenceSummary': 'Referenzierte Sitzung · {labels}', + 'message.referenceSeparator': ', ', + 'message.context.instructions.loaded': 'geladen', + 'message.context.instructions.added': 'hinzugefügt', + 'message.context.instructions.updated': 'aktualisiert', + 'message.context.instructions.removed': 'entfernt', + 'message.context.catalog.replaced': 'Ersatzkatalog', + 'message.context.catalog.more': '… {count} mehr', + 'message.context.snapshot.supersedes': 'Ersetzt frühere Schnappschüsse', + 'message.context.relay.from': 'Aus der Sitzung {session}', + 'message.context.recall.counts': '{retained} beibehalten · {omitted} weggelassen', + 'message.context.recall.truncated': 'abgeschnitten', + 'message.compaction': 'Kontext verdichtet', + 'message.compaction.running': 'Kontext verdichten…', + 'message.compaction.completed': 'Komprimierte {items}-Verlaufselemente (~{tokens}-Tokens)', + 'message.compaction.expand': 'Komprimierungszusammenfassung anzeigen', + 'message.compaction.unavailable': 'Komprimierungszusammenfassung nicht verfügbar', + 'message.compaction.commandTitle': 'kompakt', + 'message.think': 'Denken Sie nach', + 'message.unknownSurface': 'Unbekanntes Oberflächenereignis: {type}', + 'message.unknownBlock': 'Unbekannter Inhaltsblock', + 'message.turnProcess.toolCalls.one': 'Werkzeugaufruf {count}', + 'message.turnProcess.toolCalls.other': '{count} Toolaufrufe', + 'message.turnProcess.messages.one': '{count}-Nachricht', + 'message.turnProcess.messages.other': '{count}-Nachrichten', + 'message.turnProcess.subagents.one': '{count}-Subagent', + 'message.turnProcess.subagents.other': '{count}-Subagenten', + 'message.turnProcess.thoughtForAWhile': 'Eine Weile nachgedacht', + 'message.turnProcess.separator': ' · ', + 'message.stopped': 'Angehalten', + 'message.branch': 'Verzweigen Sie in ein neues Gespräch', + 'message.branchUnavailable': 'Nur verfügbar bei der letzten Nachricht einer abgeschlossenen Runde', + 'message.retry.active': 'Modellanforderung wird erneut versucht', + 'message.retry.cancelled': 'Wiederholungsversuch der Modellanforderung abgebrochen', + 'message.retry.started': 'Modellanfrage erneut versucht', + 'message.retry.scheduled': 'Warten auf erneuten Versuch der Modellanforderung', + 'message.retry.status': '{label} ({retry}/{maximum}) · {seconds}s', + 'message.retry.delay': 'Wiederholungsverzögerung: ', + 'message.retry.failure': 'Fehlergrund: ', + 'message.failure.auth': 'Der API-Schlüssel ist ungültig', + 'message.taskStopped': 'Aufgabe gestoppt', + 'message.failure.accountSignedOut': 'Gestoppt, weil Sie sich von DeepSeek abgemeldet haben.', + 'message.failure.interrupted': 'Der DSH-Prozess wurde beendet, bevor dieser Zug beendet war (z. B. wurde er beendet, stürzte ab oder es kam zu einer Zeitüberschreitung beim Herunterfahren), sodass der Zug geschlossen wurde, als DSH neu gestartet wurde. Unvollendete Werkzeugaufrufe haben unbekannte Ergebnisse.', + 'message.failure.accountSignInRequired': 'Melden Sie sich bei DeepSeek an und stellen Sie sicher, dass das Anforderungsziel die Kontoauthentifizierung unterstützt.', + 'message.failure.quota': 'Anforderungskontingent ausgeschöpft.', + 'message.turnError': 'Diese Runde ist fehlgeschlagen', + 'message.maxTokens': 'Ausgabe-Token-Limit erreicht', + 'message.maxTokens.hint': 'Die Antwort wurde abgeschnitten; Frühere Ausgaben bleiben in der Konversation erhalten. Senden Sie „Weiter“, um das Modell fortzusetzen.', + 'message.tokensPerSecond': '{tps} tok/s', + 'message.turnUsage.title': 'Rundenverbrauch', + 'message.turnUsage.consumed': 'Verwendung {total}', + 'message.turnUsage.model': 'Anbieter / Modell', + 'message.turnUsage.cacheHit': 'Cache-Treffer', + 'message.turnUsage.input': 'Nicht zwischengespeicherte Eingabe', + 'message.turnUsage.cacheRead': 'Zwischengespeicherte Eingabe', + 'message.turnUsage.cacheWrite': 'Cache-Schreiben', + 'message.turnUsage.output': 'Ausgabe', + 'message.turnUsage.reasoning': ' ({tokens}-Argumentation)', + 'message.turnUsage.count': '{count}-Token', + 'duration.secondUnit': 's', + 'duration.minuteUnit': 'm ', + 'duration.hourUnit': 'h ', + 'command.running': 'Laufen…', + 'command.failed': 'Der Befehl ist fehlgeschlagen', + 'command.done': 'Abgeschlossen', + 'command.title': 'Befehl', + 'row.running': 'Laufen', + 'row.failed': 'Fehlgeschlagen', + 'json.truncated': '… gekürzt, {total} Zeichen insgesamt', + 'clock.md': '{m}/{d}', + 'clock.ymd': '{y}-{m}-{d}', +} satisfies Record + +/** Russian dictionary, checked against the English keys. */ +export const ru = { + 'message.stepProcess.thinking': 'Анализ запроса', + 'message.stepProcess.read': 'Чтение файлов', + 'message.stepProcess.readImage': 'Чтение изображений', + 'message.stepProcess.write': 'Запись файлов', + 'message.stepProcess.search': 'Поиск кода', + 'message.stepProcess.edit': 'Редактирование файлов', + 'message.stepProcess.commands': 'Запуск команд', + 'message.stepProcess.code': 'Запуск кода', + 'message.stepProcess.webSearch': 'Поиск в Интернете', + 'message.stepProcess.webFetch': 'Посещение веб-страниц', + 'message.stepProcess.subagents': 'Координирующие субагенты', + 'message.stepProcess.plan': 'Обновление плана', + 'message.stepProcess.questions': 'Жду ваших действий', + 'message.stepProcess.tools': 'Вызов инструментов', + 'message.stepProcess.prepare.read': 'Подготовка к чтению файлов', + 'message.stepProcess.prepare.readImage': 'Подготовка к чтению изображений', + 'message.stepProcess.prepare.write': 'Подготовка к записи файлов', + 'message.stepProcess.prepare.search': 'Подготовка к поиску кода', + 'message.stepProcess.prepare.edit': 'Подготовка к редактированию файлов', + 'message.stepProcess.prepare.commands': 'Подготовка к выполнению команд', + 'message.stepProcess.prepare.code': 'Подготовка к запуску кода', + 'message.stepProcess.prepare.webSearch': 'Подготовка к поиску в Интернете', + 'message.stepProcess.prepare.webFetch': 'Подготовка к посещению веб-страниц', + 'message.stepProcess.prepare.subagents': 'Подготовка к координации субагентов', + 'message.stepProcess.prepare.plan': 'Подготовка к обновлению плана', + 'message.stepProcess.prepare.questions': 'Подготовка вопросов', + 'message.stepProcess.prepare.tools': 'Подготовка вызовов инструментов', + 'message.stepProcess.done.thinking': 'Анализ завершен', + 'message.stepProcess.done.read': 'Чтение файлов', + 'message.stepProcess.done.readImage': 'Чтение изображений', + 'message.stepProcess.done.write': 'Написал файлы', + 'message.stepProcess.done.search': 'Искомый код', + 'message.stepProcess.done.edit': 'Отредактированные файлы', + 'message.stepProcess.done.commands': 'Выполнение команд', + 'message.stepProcess.done.code': 'Выполненный код', + 'message.stepProcess.done.webSearch': 'Искал в Интернете', + 'message.stepProcess.done.webFetch': 'Посещенные веб-страницы', + 'message.stepProcess.done.subagents': 'Координированные субагенты', + 'message.stepProcess.done.plan': 'Обновлен план', + 'message.stepProcess.done.questions': 'Задаваемые вопросы', + 'message.stepProcess.done.tools': 'Вызываемые инструменты', + 'message.stepProcess.joinTwo': '{first} и {second}', + 'message.stepProcess.comma': ', ', + 'message.stepProcess.sharedPrefix': '', + 'message.stepProcess.more': '{title} и т. д.', + 'message.trigger.request': 'Запрошено выполнение', + 'message.trigger.goal': 'Постоянная цель', + 'message.trigger.agent': 'Сообщение о задаче получено', + 'message.trigger.team': 'Сообщение команды получено', + 'message.trigger.subagent': 'Статус подзадачи обновлен', + 'message.trigger.github': 'Событие GitHub получено', + 'message.trigger.webhook': 'Получено внешнее событие', + 'message.trigger.schedule': 'Задача автоматизации', + 'message.trigger.job': 'Фоновая задача обновлена.', + 'message.trigger.plugin': 'Статус плагина обновлен', + 'message.trigger.explanation': 'Это уведомление вызвало такой ответ.', + 'message.turnProcess.worked': 'Завершено', + 'message.turnProcess.took': 'Завершено в ', + 'message.turnProcess.failed': 'Не удалось', + 'view.chat': 'Чат', + 'image.open': 'Посмотреть полное изображение', + 'image.loading': 'Загрузка изображения…', + 'image.failed': 'Предварительный просмотр изображения недоступен.', + 'image.dialog': 'Предварительный просмотр изображения', + 'image.close': 'Закрыть предварительный просмотр изображения', + 'number.groupSeparator': ',', + 'duration.compactSeconds': '{seconds}s', + 'duration.compactMinutes': '{minutes}m{seconds}s', + 'duration.milliseconds': '{milliseconds}ms', + 'stats.counts': 'Ходов: {turns}, шагов: {steps}', + 'stats.cacheHit': 'Попадание в кэш: {percent}%', + 'stats.dialog.title': 'Статистика сеансов', + 'stats.dialog.usageTitle': 'Использование токена', + 'stats.dialog.llmTime': 'время магистратуры', + 'stats.dialog.toolTime': 'Время работы инструмента', + 'stats.dialog.ttft': 'Среднее время до первого токена (TTFT)', + 'stats.dialog.speed': 'Токенов в секунду (TPS)', + 'chat.loadingHistory': 'Загрузка истории…', + 'chat.loadError': 'Не удалось загрузить историю: {message} ({code})', + 'chat.loadOlder': 'Загрузить раньше', + 'chat.toBottom': 'Вернуться к низу', + 'chat.deepDiving': 'Глубокое размышление', + 'chat.deepDivingFor': 'Глубокое размышление в течение {duration} ···', + 'chat.turnNavigation.label': 'Навигация по ходам', + 'chat.turnNavigation.jump': 'Перейти к ходу {turn}', + 'chat.turnNavigation.jumpLoad': 'Загрузить ход {turn} и перейти к нему', + 'chat.turnNavigation.turn': 'Ход {turn}', + 'settings.performance.title': 'Производительность и использование', + 'settings.performance.description': 'Выберите, какой объем информации о производительности и использовании показывать', + 'settings.performance.compact': 'Компактный', + 'settings.performance.detailed': 'Подробный', + 'settings.links.title': 'Открыть ссылки на чат в', + 'settings.links.description': 'Выберите, где открывать веб-ссылки', + 'settings.links.sidebar': 'Боковая панель в приложении', + 'settings.links.newTab': 'Браузер по умолчанию', + 'settings.transcript.title': 'Детали работы', + 'settings.transcript.description': 'Выберите, насколько подробно следует отображать вызовы инструментов.', + 'settings.collapse.title': 'Когда сворачивать детали работы', + 'settings.collapse.description': 'Выберите, когда автоматически сворачивать сведения о работе', + 'settings.collapse.completion': 'По завершении', + 'settings.collapse.nextInput': 'В следующем сообщении', + 'settings.foldedReplies.title': 'Ответы после сворачивания', + 'settings.foldedReplies.description': 'Выберите, какие ответы помощника останутся видимыми, когда сведения о работе будут свернуты.', + 'settings.foldedReplies.all': 'Все ответы', + 'settings.foldedReplies.final': 'Только окончательный ответ', + 'settings.transcript.compact': 'Компактный', + 'settings.transcript.standard': 'Стандартный', + 'settings.transcript.detailed': 'Подробный', + 'settings.transcript.verbose': 'Многословный', + 'fileOpen.title': 'Не удалось открыть файл', + 'fileOpen.unknown': 'Не удалось открыть этот файл', + 'message.extraBlock': 'Дополнительный блок контента', + 'message.systemPrompt': 'Системная подсказка', + 'message.systemPromptUpdate': 'Обновление системного запроса', + 'message.toolAdded': 'Добавлен инструмент: {name}.', + 'message.toolRemoved': 'Инструмент удален: {name}.', + 'message.toolsAdded': 'Добавлено: {names}', + 'message.toolsAddedCount': '{count} добавлен', + 'message.toolsChanged': '{added} добавлен, {removed} удален.', + 'message.toolsRemoved': 'Удалено: {names}.', + 'message.toolsRemovedCount': '{count} удален.', + 'message.toolsUpdated': 'Инструменты обновлены', + 'message.contextInjection': 'Внедрение контекста', + 'message.contextRecall': 'Отзыв сеанса', + 'message.referenceSummary': 'Ссылка на сессию · {labels}', + 'message.referenceSeparator': ', ', + 'message.context.instructions.loaded': 'загружен', + 'message.context.instructions.added': 'добавлено', + 'message.context.instructions.updated': 'обновлено', + 'message.context.instructions.removed': 'удалено', + 'message.context.catalog.replaced': 'Каталог замены', + 'message.context.catalog.more': '… {count} ещё', + 'message.context.snapshot.supersedes': 'Заменяет предыдущие снимки', + 'message.context.relay.from': 'Из сеанса {session}', + 'message.context.recall.counts': '{retained} сохранен · {omitted} опущен', + 'message.context.recall.truncated': 'усеченный', + 'message.compaction': 'Контекст сжат', + 'message.compaction.running': 'Сжатие контекста…', + 'message.compaction.completed': 'Сжатые элементы истории {items} (токены ~{tokens})', + 'message.compaction.expand': 'Посмотреть сводку по уплотнению', + 'message.compaction.unavailable': 'Сводка по уплотнению недоступна.', + 'message.compaction.commandTitle': 'компактный', + 'message.think': 'Подумайте', + 'message.unknownSurface': 'Неизвестное событие на поверхности: {type}.', + 'message.unknownBlock': 'Неизвестный блок контента', + 'message.turnProcess.toolCalls.one': '{count} вызов инструмента', + 'message.turnProcess.toolCalls.other': '{count} вызовов инструментов', + 'message.turnProcess.messages.one': 'Сообщение {count}', + 'message.turnProcess.messages.other': 'Сообщения {count}', + 'message.turnProcess.subagents.one': 'Субагент {count}', + 'message.turnProcess.subagents.other': 'Субагенты {count}', + 'message.turnProcess.thoughtForAWhile': 'Размышлял некоторое время', + 'message.turnProcess.separator': ' · ', + 'message.stopped': 'Остановлено', + 'message.branch': 'Перейти к новому разговору', + 'message.branchUnavailable': 'Доступно только в последнем сообщении о завершенном ходу.', + 'message.retry.active': 'Повторный запрос модели', + 'message.retry.cancelled': 'Повтор запроса модели отменен.', + 'message.retry.started': 'Повторный запрос модели', + 'message.retry.scheduled': 'Ожидание повторного запроса модели', + 'message.retry.status': '{label} ({retry}/{maximum}) · {seconds}s', + 'message.retry.delay': 'Задержка повтора: ', + 'message.retry.failure': 'Причина неисправности: ', + 'message.failure.auth': 'Ключ API недействителен.', + 'message.taskStopped': 'Задача остановлена', + 'message.failure.accountSignedOut': 'Остановлено, поскольку вы вышли из DeepSeek.', + 'message.failure.interrupted': 'Процесс DSH завершился до завершения этого хода (например, был завершен, произошел сбой или истекло время ожидания при завершении работы), поэтому ход был закрыт при перезапуске DSH. Незавершенные вызовы инструментов имеют неизвестные результаты.', + 'message.failure.accountSignInRequired': 'Войдите в DeepSeek и убедитесь, что пункт назначения запроса поддерживает аутентификацию учетной записи.', + 'message.failure.quota': 'Квота запросов исчерпана.', + 'message.turnError': 'Этот ход завершился ошибкой', + 'message.maxTokens': 'Достигнут предел выходных токенов', + 'message.maxTokens.hint': 'Ответ был прерван; более ранний вывод сохраняется в разговоре. Отправьте «продолжить», чтобы модель возобновила работу.', + 'message.tokensPerSecond': '{tps} ток/с', + 'message.turnUsage.title': 'Использование ресурсов за ход', + 'message.turnUsage.consumed': 'Использование {total}', + 'message.turnUsage.model': 'Поставщик/модель', + 'message.turnUsage.cacheHit': 'Попадание в кэш', + 'message.turnUsage.input': 'Некэшированный ввод', + 'message.turnUsage.cacheRead': 'Кэшированный ввод', + 'message.turnUsage.cacheWrite': 'Запись в кэш', + 'message.turnUsage.output': 'Выход', + 'message.turnUsage.reasoning': ' (рассуждение {tokens})', + 'message.turnUsage.count': '{count} ток', + 'duration.secondUnit': 'с', + 'duration.minuteUnit': 'м ', + 'duration.hourUnit': 'ч ', + 'command.running': 'Бег…', + 'command.failed': 'Команда не выполнена', + 'command.done': 'Завершено', + 'command.title': 'Команда', + 'row.running': 'Бег', + 'row.failed': 'Не удалось', + 'json.truncated': '… усечено, всего символов {total}', + 'clock.md': '{m}/{d}', + 'clock.ymd': '{y}-{m}-{d}', +} satisfies Record + +/** Portuguese dictionary, checked against the English keys. */ +export const pt = { + 'message.stepProcess.thinking': 'Analisando a solicitação', + 'message.stepProcess.read': 'Lendo arquivos', + 'message.stepProcess.readImage': 'Lendo imagens', + 'message.stepProcess.write': 'Gravando arquivos', + 'message.stepProcess.search': 'Pesquisando código', + 'message.stepProcess.edit': 'Editando arquivos', + 'message.stepProcess.commands': 'Executando comandos', + 'message.stepProcess.code': 'Executando código', + 'message.stepProcess.webSearch': 'Pesquisando na web', + 'message.stepProcess.webFetch': 'Visitando páginas da web', + 'message.stepProcess.subagents': 'Coordenando subagentes', + 'message.stepProcess.plan': 'Atualizando o plano', + 'message.stepProcess.questions': 'Esperando por sua ação', + 'message.stepProcess.tools': 'Ferramentas de chamada', + 'message.stepProcess.prepare.read': 'Preparando-se para ler arquivos', + 'message.stepProcess.prepare.readImage': 'Preparando-se para ler imagens', + 'message.stepProcess.prepare.write': 'Preparando-se para gravar arquivos', + 'message.stepProcess.prepare.search': 'Preparando-se para pesquisar o código', + 'message.stepProcess.prepare.edit': 'Preparando-se para editar arquivos', + 'message.stepProcess.prepare.commands': 'Preparando para executar comandos', + 'message.stepProcess.prepare.code': 'Preparando-se para executar o código', + 'message.stepProcess.prepare.webSearch': 'Preparando-se para pesquisar na web', + 'message.stepProcess.prepare.webFetch': 'Preparando-se para visitar páginas da web', + 'message.stepProcess.prepare.subagents': 'Preparando-se para coordenar subagentes', + 'message.stepProcess.prepare.plan': 'Preparando-se para atualizar o plano', + 'message.stepProcess.prepare.questions': 'Preparando perguntas', + 'message.stepProcess.prepare.tools': 'Preparando chamadas de ferramenta', + 'message.stepProcess.done.thinking': 'Análise concluída', + 'message.stepProcess.done.read': 'Ler arquivos', + 'message.stepProcess.done.readImage': 'Ler imagens', + 'message.stepProcess.done.write': 'Escreveu arquivos', + 'message.stepProcess.done.search': 'Código pesquisado', + 'message.stepProcess.done.edit': 'Arquivos editados', + 'message.stepProcess.done.commands': 'Comandos executados', + 'message.stepProcess.done.code': 'Código executado', + 'message.stepProcess.done.webSearch': 'Pesquisei na web', + 'message.stepProcess.done.webFetch': 'Páginas da web visitadas', + 'message.stepProcess.done.subagents': 'Subagentes coordenados', + 'message.stepProcess.done.plan': 'Atualizado o plano', + 'message.stepProcess.done.questions': 'Perguntas feitas', + 'message.stepProcess.done.tools': 'Ferramentas chamadas', + 'message.stepProcess.joinTwo': '{first} e {second}', + 'message.stepProcess.comma': ', ', + 'message.stepProcess.sharedPrefix': '', + 'message.stepProcess.more': '{title}, etc.', + 'message.trigger.request': 'Execução solicitada', + 'message.trigger.goal': 'Objetivo contínuo', + 'message.trigger.agent': 'Mensagem de tarefa recebida', + 'message.trigger.team': 'Mensagem da equipe recebida', + 'message.trigger.subagent': 'Status da subtarefa atualizado', + 'message.trigger.github': 'GitHub evento recebido', + 'message.trigger.webhook': 'Evento externo recebido', + 'message.trigger.schedule': 'Tarefa de automação', + 'message.trigger.job': 'Tarefa em segundo plano atualizada', + 'message.trigger.plugin': 'Status do plug-in atualizado', + 'message.trigger.explanation': 'Esta notificação desencadeou esta resposta.', + 'message.turnProcess.worked': 'Concluído', + 'message.turnProcess.took': 'Concluído em ', + 'message.turnProcess.failed': 'Falha', + 'view.chat': 'Chat', + 'image.open': 'Ver imagem completa', + 'image.loading': 'Carregando imagem…', + 'image.failed': 'Visualização da imagem indisponível', + 'image.dialog': 'Visualização da imagem', + 'image.close': 'Fechar visualização da imagem', + 'number.groupSeparator': ',', + 'duration.compactSeconds': '{seconds}s', + 'duration.compactMinutes': '{minutes}m{seconds}s', + 'duration.milliseconds': '{milliseconds}ms', + 'stats.counts': '{turns} turnos, {steps} etapas', + 'stats.cacheHit': 'Acertos no cache: {percent}%', + 'stats.dialog.title': 'Estatísticas da sessão', + 'stats.dialog.usageTitle': 'Uso de token', + 'stats.dialog.llmTime': 'Horário do LLM', + 'stats.dialog.toolTime': 'Tempo da ferramenta', + 'stats.dialog.ttft': 'Tempo médio para o primeiro token (TTFT)', + 'stats.dialog.speed': 'Tokens por segundo (TPS)', + 'chat.loadingHistory': 'Carregando histórico…', + 'chat.loadError': 'Falha ao carregar o histórico: {message} ({code})', + 'chat.loadOlder': 'Carregar mais cedo', + 'chat.toBottom': 'De volta ao fundo', + 'chat.deepDiving': 'Raciocínio profundo', + 'chat.deepDivingFor': 'Raciocínio profundo há {duration} ···', + 'chat.turnNavigation.label': 'Navegação entre turnos', + 'chat.turnNavigation.jump': 'Ir para o turno {turn}', + 'chat.turnNavigation.jumpLoad': 'Carregar e ir para o turno {turn}', + 'chat.turnNavigation.turn': 'Turno {turn}', + 'settings.performance.title': 'Desempenho e uso', + 'settings.performance.description': 'Escolha quantas informações de desempenho e uso serão exibidas', + 'settings.performance.compact': 'Compacto', + 'settings.performance.detailed': 'Detalhado', + 'settings.links.title': 'Abra links de bate-papo em', + 'settings.links.description': 'Escolha onde abrir links da web', + 'settings.links.sidebar': 'Barra lateral no aplicativo', + 'settings.links.newTab': 'Navegador padrão', + 'settings.transcript.title': 'Detalhes do trabalho', + 'settings.transcript.description': 'Escolha quantos detalhes serão exibidos nas chamadas de ferramenta', + 'settings.collapse.title': 'Quando recolher detalhes do trabalho', + 'settings.collapse.description': 'Escolha quando recolher automaticamente os detalhes do trabalho', + 'settings.collapse.completion': 'Na conclusão', + 'settings.collapse.nextInput': 'Na próxima mensagem', + 'settings.foldedReplies.title': 'Respostas após o recolhimento', + 'settings.foldedReplies.description': 'Escolha quais respostas do assistente permanecerão visíveis quando os detalhes do trabalho forem recolhidos', + 'settings.foldedReplies.all': 'Todas as respostas', + 'settings.foldedReplies.final': 'Apenas resposta final', + 'settings.transcript.compact': 'Compacto', + 'settings.transcript.standard': 'Padrão', + 'settings.transcript.detailed': 'Detalhado', + 'settings.transcript.verbose': 'Verboso', + 'fileOpen.title': 'Não foi possível abrir o arquivo', + 'fileOpen.unknown': 'Não foi possível abrir este arquivo', + 'message.extraBlock': 'Bloco de conteúdo extra', + 'message.systemPrompt': 'Alerta do sistema', + 'message.systemPromptUpdate': 'Atualização imediata do sistema', + 'message.toolAdded': 'Ferramenta adicionada: {name}', + 'message.toolRemoved': 'Ferramenta removida: {name}', + 'message.toolsAdded': 'Adicionado: {names}', + 'message.toolsAddedCount': '{count} adicionado', + 'message.toolsChanged': '{added} adicionado, {removed} removido', + 'message.toolsRemoved': 'Removido: {names}', + 'message.toolsRemovedCount': '{count} removido', + 'message.toolsUpdated': 'Ferramentas atualizadas', + 'message.contextInjection': 'Injeção de contexto', + 'message.contextRecall': 'Recuperação de sessão', + 'message.referenceSummary': 'Sessão referenciada · {labels}', + 'message.referenceSeparator': ', ', + 'message.context.instructions.loaded': 'carregado', + 'message.context.instructions.added': 'adicionado', + 'message.context.instructions.updated': 'atualizado', + 'message.context.instructions.removed': 'removido', + 'message.context.catalog.replaced': 'Catálogo de substituição', + 'message.context.catalog.more': '… {count} mais', + 'message.context.snapshot.supersedes': 'Substitui instantâneos anteriores', + 'message.context.relay.from': 'Da sessão {session}', + 'message.context.recall.counts': '{retained} mantido · {omitted} omitido', + 'message.context.recall.truncated': 'truncado', + 'message.compaction': 'Contexto compactado', + 'message.compaction.running': 'Contexto compactado…', + 'message.compaction.completed': '{items} itens de histórico compactados (~{tokens} tokens)', + 'message.compaction.expand': 'Ver resumo de compactação', + 'message.compaction.unavailable': 'Resumo de compactação indisponível', + 'message.compaction.commandTitle': 'compacto', + 'message.think': 'Pense', + 'message.unknownSurface': 'Evento de superfície desconhecido: {type}', + 'message.unknownBlock': 'Bloco de conteúdo desconhecido', + 'message.turnProcess.toolCalls.one': '{count} chamada de ferramenta', + 'message.turnProcess.toolCalls.other': '{count} chamadas de ferramenta', + 'message.turnProcess.messages.one': '{count} mensagem', + 'message.turnProcess.messages.other': '{count} mensagens', + 'message.turnProcess.subagents.one': '{count} subagente', + 'message.turnProcess.subagents.other': '{count} subagentes', + 'message.turnProcess.thoughtForAWhile': 'Pensou por um tempo', + 'message.turnProcess.separator': ' · ', + 'message.stopped': 'Parado', + 'message.branch': 'Ramifique para uma nova conversa', + 'message.branchUnavailable': 'Disponível apenas na última mensagem de um turno concluído', + 'message.retry.active': 'Tentando novamente a solicitação de modelo', + 'message.retry.cancelled': 'Nova tentativa de solicitação de modelo cancelada', + 'message.retry.started': 'Solicitação de modelo repetida', + 'message.retry.scheduled': 'Aguardando para tentar novamente a solicitação de modelo', + 'message.retry.status': '{label} ({retry}/{maximum}) · {seconds}s', + 'message.retry.delay': 'Atraso na nova tentativa: ', + 'message.retry.failure': 'Motivo da falha: ', + 'message.failure.auth': 'A chave API é inválida', + 'message.taskStopped': 'Tarefa interrompida', + 'message.failure.accountSignedOut': 'Interrompido porque você saiu de DeepSeek.', + 'message.failure.interrupted': 'O processo DSH foi encerrado antes do término deste turno (por exemplo, ele foi eliminado, travou ou expirou durante o desligamento), portanto, o turno foi encerrado quando DSH foi reiniciado. Chamadas de ferramentas inacabadas têm resultados desconhecidos.', + 'message.failure.accountSignInRequired': 'Faça login em DeepSeek e verifique se o destino da solicitação suporta autenticação de conta.', + 'message.failure.quota': 'Cota de solicitação esgotada.', + 'message.turnError': 'Este turno falhou', + 'message.maxTokens': 'Limite de token de saída atingido', + 'message.maxTokens.hint': 'A resposta foi cortada; a saída anterior é preservada na conversa. Envie "continuar" para permitir que o modelo seja retomado.', + 'message.tokensPerSecond': '{tps} tok/s', + 'message.turnUsage.title': 'Uso do turno', + 'message.turnUsage.consumed': 'Uso {total}', + 'message.turnUsage.model': 'Provedor/modelo', + 'message.turnUsage.cacheHit': 'Acertos no cache', + 'message.turnUsage.input': 'Entrada sem cache', + 'message.turnUsage.cacheRead': 'Entrada em cache', + 'message.turnUsage.cacheWrite': 'Gravação em cache', + 'message.turnUsage.output': 'Saída', + 'message.turnUsage.reasoning': ' ({tokens} raciocínio)', + 'message.turnUsage.count': '{count} tok', + 'duration.secondUnit': 's', + 'duration.minuteUnit': 'm ', + 'duration.hourUnit': 'h ', + 'command.running': 'Correndo…', + 'command.failed': 'Falha no comando', + 'command.done': 'Concluído', + 'command.title': 'Comando', + 'row.running': 'Correndo', + 'row.failed': 'Falha', + 'json.truncated': '… truncado, {total} caracteres no total', + 'clock.md': '{m}/{d}', + 'clock.ymd': '{y}-{m}-{d}', +} satisfies Record diff --git a/packages/client/ui-chat/src/client/model/tool-call-tree.ts b/packages/client/ui-chat/src/client/model/tool-call-tree.ts index 2be2121414..d9f3244abf 100644 --- a/packages/client/ui-chat/src/client/model/tool-call-tree.ts +++ b/packages/client/ui-chat/src/client/model/tool-call-tree.ts @@ -1,5 +1,5 @@ import type { SessionEvent } from '@deepseek-ai/dsh-session/types' -import type {} from '@deepseek-ai/dsh-tools/types' +import type {} from '@deepseek-ai/dsh-tool-executor-ptc/client' import type { ConversationNode, RunningToolCall, ToolCallBlock, ToolResultNode, } from '@deepseek-ai/dsh-client-ui-conversation/client' diff --git a/packages/client/ui-chat/src/client/settings/PreferenceRow.module.css b/packages/client/ui-chat/src/client/settings/PreferenceRow.module.css index dfcb95648d..142f42af73 100644 --- a/packages/client/ui-chat/src/client/settings/PreferenceRow.module.css +++ b/packages/client/ui-chat/src/client/settings/PreferenceRow.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-chat/tests/apply-inject.client.spec.tsx b/packages/client/ui-chat/tests/apply-inject.client.spec.tsx index 3bdd5e706b..557b9e2a04 100644 --- a/packages/client/ui-chat/tests/apply-inject.client.spec.tsx +++ b/packages/client/ui-chat/tests/apply-inject.client.spec.tsx @@ -62,6 +62,7 @@ async function bench(initialSettings?: ChatSettings, withBrowserRegistry = true, } as never) const layout = { closeRightbar: vi.fn(), openRightbar: vi.fn() } runtime.ctx.provide('connection', { state: createSnapshotStore('connected') } as never) + runtime.ctx.provide('fileDrag', { actions: fileDrag, read: () => undefined }) runtime.ctx.provide('layout', layout as never) const sidebarRight = { openResource: vi.fn<(address: string) => void>(), @@ -138,6 +139,50 @@ async function bench(initialSettings?: ChatSettings, withBrowserRegistry = true, } describe('Chat inject API', () => { + it('routes text gestures from each Chat view to the registered consumer with its own Session', async () => { + const b = await bench() + try { + const secondId = await b.runtime.sessions.add({ id: 'second-chat' }) + using secondReference = b.runtime.sessions.retain(secondId) + await secondReference.ready + const first = b.chatViewApi(b.rootReference).injected + const second = b.chatViewApi(secondReference).injected + const received: { sessionId: SessionId; text: string | null }[] = [] + const dispose = b.runtime.ctx.uiConversation.interactions.register({ + id: 'selection-menu', + onInteraction(request) { + received.push({ sessionId: request.sessionId, text: request.readText() }) + if (request.trigger !== 'contextmenu') return + request.preventDefault() + request.open() + }, + }) + expect(first.hooks.interactionIds.getSnapshot()).toEqual(['selection-menu']) + expect(second.hooks.interactionIds.getSnapshot()).toEqual(['selection-menu']) + const nativeMenu = vi.fn() + expect(first.dispatchInteraction({ + trigger: 'selection', hasSelection: true, readText: () => 'first selection', preventDefault: nativeMenu, + })).toBeNull() + expect(nativeMenu).not.toHaveBeenCalled() + const contextMenu = { + trigger: 'contextmenu' as const, hasSelection: true, + readText: () => 'second selection', preventDefault: nativeMenu, + } + expect(second.dispatchInteraction(contextMenu)).toBe('selection-menu') + expect(nativeMenu).toHaveBeenCalledOnce() + expect(received).toEqual([ + { sessionId: ROOT, text: 'first selection' }, + { sessionId: secondId, text: 'second selection' }, + ]) + dispose() + expect(first.hooks.interactionIds.getSnapshot()).toEqual([]) + expect(second.dispatchInteraction(contextMenu)).toBeNull() + expect(nativeMenu).toHaveBeenCalledOnce() + } finally { + await b.runtime.dispose() + } + }) + it('projects independent Settings choices and saves timing to Host settings', async () => { const b = await bench() try { @@ -491,3 +536,5 @@ describe('Chat inject API', () => { await b.runtime.dispose() }) }) + +const fileDrag = { prepare: vi.fn(async () => ({ start: () => true, release: () => {} })), reportFailure: vi.fn() } diff --git a/packages/client/ui-chat/tests/chat-apply.client.spec.tsx b/packages/client/ui-chat/tests/chat-apply.client.spec.tsx index f5f070f6f4..ab256ad15b 100644 --- a/packages/client/ui-chat/tests/chat-apply.client.spec.tsx +++ b/packages/client/ui-chat/tests/chat-apply.client.spec.tsx @@ -44,6 +44,7 @@ const SID = 'session-1' as SessionId async function bench() { const runtime = await SlotTestRuntime.create() + runtime.ctx.provide('fileDrag', { actions: { prepare: async () => ({ start: () => true, release: () => {} }), reportFailure: () => {} }, read: () => undefined } satisfies import('@deepseek-ai/cordis').Context['fileDrag']) const chatSettings = stubConfigForm() runtime.ctx.provide('configForms', { developerTools: { enabled: createSnapshotStore(true) }, diff --git a/packages/client/ui-chat/tests/chat-font-axis-styles.client.spec.ts b/packages/client/ui-chat/tests/chat-font-axis-styles.client.spec.ts index de1f0688dd..aeba01b380 100644 --- a/packages/client/ui-chat/tests/chat-font-axis-styles.client.spec.ts +++ b/packages/client/ui-chat/tests/chat-font-axis-styles.client.spec.ts @@ -161,8 +161,8 @@ describe('chat flow font-size axis', () => { const empty = declarationsFrom(css, emptySelector) expect(empty).toContain('height: 0') expect(empty).not.toContain('display: none') - expect(css).toContain(`:is(.column, :global([data-slot="conversation.chat.flow"])) > :not([hidden]):not(${emptySelector})`) - expect(css).toContain(`~ :not([hidden]):not(${emptySelector})`) + expect(css).toContain(`:is(.column, :global([data-slot="conversation.chat.flow"])) > :not([hidden]):not(.textInteraction):not(${emptySelector})`) + expect(css).toContain(`~ :not([hidden]):not(.textInteraction):not(${emptySelector})`) }) it('the interrupted-turn tag stays fixed like the dense token variants', () => { diff --git a/packages/client/ui-chat/tests/chat-scroll-overflow.client.spec.ts b/packages/client/ui-chat/tests/chat-scroll-overflow.client.spec.ts new file mode 100644 index 0000000000..875c6e91cf --- /dev/null +++ b/packages/client/ui-chat/tests/chat-scroll-overflow.client.spec.ts @@ -0,0 +1,24 @@ +/** The transcript scroller never exposes horizontal scrollable overflow. */ +import { readFileSync } from 'node:fs' +import { fileURLToPath } from 'node:url' +import { describe, expect, it } from 'vitest' + +const read = (name: string): string => + readFileSync(fileURLToPath(new URL(`../src/client/chat/${name}`, import.meta.url)), 'utf8') + +const rule = (css: string, selector: string): string => { + const start = css.indexOf(`${selector} {`) + return css.slice(start, css.indexOf('}', start) + 1) +} + +describe('Transcript scroll overflow', () => { + it('clips the horizontal axis in both scrollport modes', () => { + // The text-interaction surface is absolutely positioned inside the message + // column and can reach past its right edge; an `auto` horizontal axis turns + // that into a scroll range whose appearance and resolution jitter the + // transcript while the tail is pinned. + const css = read('ChatView.module.css') + expect(rule(css, '.scroll')).toMatch(/overflow-x:\s*clip/) + expect(rule(css, ':global([data-conversation-scroll]) .scroll')).toMatch(/overflow-x:\s*clip/) + }) +}) diff --git a/packages/client/ui-chat/tests/chat-stats.client.spec.tsx b/packages/client/ui-chat/tests/chat-stats.client.spec.tsx index 79281d39d1..8d63088052 100644 --- a/packages/client/ui-chat/tests/chat-stats.client.spec.tsx +++ b/packages/client/ui-chat/tests/chat-stats.client.spec.tsx @@ -268,7 +268,7 @@ describe('composer stats pills', () => { expect(dialog.getAttribute('aria-label')).toBe('Session statistics') // Portaled out of the composer dock. expect(dialog.parentElement).toBe(document.body) - expect(dialog.firstChild?.textContent).toBe('Session statistics') + expect(dialog.firstChild?.textContent).toBe('Session statistics1 turns 1 steps') const details = dialog.querySelector('[data-session-stats-details]') as HTMLElement expect(details).toBeTruthy() expect(details.textContent).toContain('LLM time3.8s') @@ -375,6 +375,53 @@ describe('composer stats pills', () => { expect(usageDialog.textContent).toContain('未缓存输入5 tok') }) + describe('full reading of a cut-off pill', () => { + const FULL = '105 tok · Cache hit 90%' + + function renderCut(overflow: boolean, mode: 'detailed' | 'compact' = 'detailed') { + vi.spyOn(Element.prototype, 'scrollWidth', 'get').mockReturnValue(overflow ? 200 : 80) + vi.spyOn(Element.prototype, 'clientWidth', 'get').mockReturnValue(80) + vi.useFakeTimers() + const { source } = makeSource({ nodes: [timedStep()] }) + return render( selector(mode)} />) + } + const tooltipText = (view: ReturnType) => view.queryByRole('tooltip')?.textContent ?? null + afterEach(() => { vi.useRealTimers() }) + + it.each([false, true])('shows on hover after the dock delay only while the label is cut off: %s', (overflow) => { + const view = renderCut(overflow) + fireEvent.pointerEnter(view.getAllByRole('button')[1]!) + expect(tooltipText(view)).toBeNull() + act(() => { vi.advanceTimersByTime(200) }) + expect(tooltipText(view)).toBe(overflow ? FULL : null) + }) + + it('shows on keyboard focus of a dialog pill', () => { + const view = renderCut(true) + fireEvent.keyDown(document.body, { key: 'Tab' }) + fireEvent.focus(view.getAllByRole('button')[1]!) + expect(tooltipText(view)).toBe(FULL) + }) + + it('yields to the open dialog, whose head carries the counts', () => { + const view = renderCut(true) + const [timePill] = view.getAllByRole('button') as [HTMLElement] + fireEvent.click(timePill) + fireEvent.pointerEnter(timePill) + act(() => { vi.advanceTimersByTime(200) }) + expect(tooltipText(view)).toBeNull() + expect(view.getByRole('dialog').firstChild?.textContent).toBe('Session statistics1 turns 1 steps') + }) + + it('pins a cut-off static reading on tap', () => { + const view = renderCut(true, 'compact') + const plainPill = view.getByText('Cache hit 90%').parentElement! + fireEvent.pointerDown(plainPill, { pointerType: 'touch' }) + fireEvent.click(plainPill) + expect(tooltipText(view)).toBe('Cache hit 90%') + }) + }) + it('keeps the durable usage pill after the visible step window is empty', () => { const { source } = makeSource() const view = render(( } declare const nodeDispatch: PropsRenderSlots<'conversation.chat.node'>['renderSlot'] -declare const flowDispatch: ChatViewSlotProps['renderSlot'] +declare const flowDispatch: PropsRenderSlots<'conversation.chat.flow'>['renderSlot'] type NodeRenderer = typeof nodeDispatch<'conversation.chat.node'> type FlowRenderer = typeof flowDispatch<'conversation.chat.flow'> type ImageRenderer = (owner: MessageImagesOwnerProps) => React.ReactNode @@ -453,7 +453,8 @@ function makeHarness( if (name !== 'conversation.chat.user.content') throw new Error(`Unexpected Chat Factory: ${name}`) return renderUserContent(owner as UserContentInput, renderImages, props.t) } - const renderSlot: FlowRenderer = (key, owner, opts) => { + const renderSlot = (key: string, owner: ChatFlowOwnerProps, opts: Parameters[2]) => { + if (key === 'conversation.chat.text-interaction') return null if (key !== 'conversation.chat.flow') throw new Error(`Undeclared ChatView child: ${key}`) if ('renderSlot' in owner || 'useStore' in owner || 'actions' in owner) { throw new Error('Flow owner data must not replace its rendering or store authority') @@ -467,6 +468,7 @@ function makeHarness( const useConnectionState = bindSnapshotSelector(connection) const SessionProviderStub: ChatViewSlotProps['SessionProvider'] = ({ children }) => <>{children} const props: HarnessStandardProps = { + fileDrag, renderFactorySlot, usePanelInfo: selector => selector({ activePanelId: null }), sessionId: SID, @@ -492,6 +494,7 @@ function makeHarness( }, useInput: (() => { throw new Error('unused') }), inputActions: { + insertQuote: () => false, captureInsertion: () => ({ start: 0, end: 0, draftRev: 0 }), insertText: () => false, setDraft: () => {}, @@ -503,6 +506,8 @@ function makeHarness( }, useStore: bindSnapshotSelector(chat), actions: chat.actions, + useInteractionIds: bindSnapshotSelector(createSnapshotStore([])), + dispatchInteraction: () => null, usePresentation: bindSnapshotSelector(derivePresentationPolicy(transcriptView, collapseTiming, foldedReplies)), // The fixture implements the concrete flow dispatch behind the generic slot signature. renderSlot: renderSlot as ChatViewSlotProps['renderSlot'], @@ -4575,7 +4580,7 @@ describe('ChatView', () => { const view = render() const column = view.container.querySelector('[data-chat-flow]')! const status = column.querySelector('[data-chat-running]') - expect(column.lastElementChild).toBe(status) + expect([...column.children].filter(child => !child.hasAttribute('data-text-interaction-surface')).at(-1)).toBe(status) expect(within(status as HTMLElement).getByRole('status').textContent).toBe('深度求索中') expect(status?.lastElementChild?.textContent).toBe('深度求索中') const icon = status?.querySelector('svg')?.parentElement @@ -4605,3 +4610,9 @@ describe('ChatView', () => { expect(rows).toEqual(['fixture:steering:3']) }) }) + +const fileDrag = { prepare: vi.fn(async () => ({ start: () => true, release: () => {} })), reportFailure: vi.fn() } + +it('formats Portuguese elapsed minutes with numeric units', () => { + expect(formatRunDuration(90_000, key => pt[key])).toBe('1m 30s') +}) diff --git a/packages/client/ui-chat/tests/config.host.spec.ts b/packages/client/ui-chat/tests/config.host.spec.ts index a1b7328c92..e3996ec43d 100644 --- a/packages/client/ui-chat/tests/config.host.spec.ts +++ b/packages/client/ui-chat/tests/config.host.spec.ts @@ -14,26 +14,26 @@ describe('ui-chat Host settings', () => { const configuration = await liveConfig(ctx, { Config, apply }) const { fiber } = configuration - expect(plainConfig(configuration.fiber.config)).toEqual({ + expect(plainConfig(configuration.fiber.effectiveConfig)).toEqual({ performanceUsage: 'detailed', linkOpening: 'sidebar', collapseTiming: 'completion', foldedReplies: 'all', }) await configuration.update({ transcriptView: 'normal' }) - expect(plainConfig(configuration.fiber.config)).toEqual({ + expect(plainConfig(configuration.fiber.effectiveConfig)).toEqual({ transcriptView: 'normal', performanceUsage: 'detailed', linkOpening: 'sidebar', collapseTiming: 'completion', foldedReplies: 'all', }) for (const mode of ['expanded', 'compact', 'standard', 'detailed', 'verbose']) { await configuration.update({ transcriptView: mode }) - expect(plainConfig(configuration.fiber.config)).toMatchObject({ transcriptView: mode }) + expect(plainConfig(configuration.fiber.effectiveConfig)).toMatchObject({ transcriptView: mode }) } for (const mode of ['dense', '', 42, false, {}, null, undefined]) { await configuration.update({ transcriptView: mode }) - expect(plainConfig(configuration.fiber.config)).toMatchObject({ transcriptView: mode === null ? null : undefined }) + expect(plainConfig(configuration.fiber.effectiveConfig)).toMatchObject({ transcriptView: mode === null ? null : undefined }) } await configuration.update({ performanceUsage: 'compact' }) - expect(plainConfig(configuration.fiber.config)).toMatchObject({ performanceUsage: 'compact' }) + expect(plainConfig(configuration.fiber.effectiveConfig)).toMatchObject({ performanceUsage: 'compact' }) await expect(configuration.update({ performanceUsage: 'hidden' })).rejects.toThrow() await configuration.update({ linkOpening: 'new-tab' }) - expect(plainConfig(configuration.fiber.config)).toMatchObject({ linkOpening: 'new-tab' }) + expect(plainConfig(configuration.fiber.effectiveConfig)).toMatchObject({ linkOpening: 'new-tab' }) await expect(configuration.update({ linkOpening: 'popup' })).rejects.toThrow() await fiber.dispose() @@ -42,15 +42,15 @@ describe('ui-chat Host settings', () => { it('validates collapse timing and restores its saved config independently of work details', async () => { const ctx = new Context() const configuration = await liveConfig(ctx, { Config, apply }) - expect(plainConfig(configuration.fiber.config)).toMatchObject({ collapseTiming: 'completion' }) + expect(plainConfig(configuration.fiber.effectiveConfig)).toMatchObject({ collapseTiming: 'completion' }) await configuration.update({ transcriptView: 'compact', collapseTiming: 'next-input' }) - const saved = plainConfig(configuration.fiber.config) + const saved = plainConfig(configuration.fiber.effectiveConfig) if (typeof saved !== 'object' || saved === null) throw new Error('expected saved configuration object') expect(saved).toMatchObject({ transcriptView: 'compact', collapseTiming: 'next-input' }) await expect(configuration.update({ collapseTiming: 'never' })).rejects.toThrow() await configuration.fiber.dispose() const restored = await liveConfig(ctx, { Config, apply }, saved) - expect(plainConfig(restored.fiber.config)).toMatchObject({ transcriptView: 'compact', collapseTiming: 'next-input' }) + expect(plainConfig(restored.fiber.effectiveConfig)).toMatchObject({ transcriptView: 'compact', collapseTiming: 'next-input' }) await restored.fiber.dispose() }) diff --git a/packages/client/ui-chat/tests/coverage-tails.client.spec.tsx b/packages/client/ui-chat/tests/coverage-tails.client.spec.tsx index 241a0faded..2d4043f261 100644 --- a/packages/client/ui-chat/tests/coverage-tails.client.spec.tsx +++ b/packages/client/ui-chat/tests/coverage-tails.client.spec.tsx @@ -16,6 +16,26 @@ const t: AssistantMarkdownProps['t'] = makeTranslate(zh, commonZh) afterEach(cleanup) describe('tails', () => { + it('enables quoting on the existing Markdown root without replacing streamed content', () => { + const body = (textInteractionSource: boolean, streaming: boolean) => ( + null} useDisclosure={useDisclosure} useGroupAction={useSearchableHidden} + usePresentation={useDetailedPresentation} t={t} loadImage={unusedImageLoader} + blocks={[{ kind: 'text', text: 'source words' }]} textInteractionSource={textInteractionSource} streaming={streaming} /> + ) + const view = render(body(false, true)) + const paragraph = view.getByText('source words') + const markdown = paragraph.parentElement + expect(view.container.querySelector('[data-text-interaction-source]')).toBeNull() + + view.rerender(body(true, false)) + expect(view.container.querySelector('[data-text-interaction-source]')).toBe(markdown) + expect(view.getByText('source words')).toBe(paragraph) + + view.rerender(body(false, false)) + expect(view.container.querySelector('[data-text-interaction-source]')).toBeNull() + expect(view.getByText('source words').parentElement).toBe(markdown) + }) + it('AssistantMarkdown renders reasoning as a Think row and unknown blocks as JSON fallback', () => { const view = render( null} useDisclosure={useDisclosure} useGroupAction={useSearchableHidden} diff --git a/packages/client/ui-chat/tests/quota-notice.client.spec.tsx b/packages/client/ui-chat/tests/quota-notice.client.spec.tsx index e6992183a5..c7f67f3505 100644 --- a/packages/client/ui-chat/tests/quota-notice.client.spec.tsx +++ b/packages/client/ui-chat/tests/quota-notice.client.spec.tsx @@ -46,6 +46,7 @@ const quotaEntry = (seq: number, code: string): SessionLiveEventEntry => ({ */ async function bench() { const runtime = await SlotTestRuntime.create() + runtime.ctx.provide('fileDrag', { actions: { prepare: async () => ({ start: () => true, release: () => {} }), reportFailure: () => {} }, read: () => undefined } satisfies import('@deepseek-ai/cordis').Context['fileDrag']) disposers.push(() => runtime.dispose()) const chatSettings = stubConfigForm() runtime.ctx.provide('configForms', { diff --git a/packages/client/ui-chat/tests/text-interaction.client.spec.tsx b/packages/client/ui-chat/tests/text-interaction.client.spec.tsx new file mode 100644 index 0000000000..2d8a1d6426 --- /dev/null +++ b/packages/client/ui-chat/tests/text-interaction.client.spec.tsx @@ -0,0 +1,374 @@ +// @vitest-environment jsdom +import { createRef, useCallback, useMemo, useRef } from 'react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { act, cleanup, fireEvent, render, renderHook } from '@testing-library/react' +import { useTextInteraction } from '../src/client/chat/text-interaction.ts' +import { SessionId } from '@deepseek-ai/dsh-session/types' +import type { TextInteractionBinding } from '../src/client/chat/text-interaction.ts' +import type { TextInteractionGesture, TextInteractionRequest } from '@deepseek-ai/dsh-client-ui-conversation/client' + +const rect = (left: number, top: number, width: number, height: number): DOMRect => new DOMRect(left, top, width, height) +let selectionRect = rect(100, 120, 40, 20) +let resize: () => void + +beforeEach(() => { + selectionRect = rect(100, 120, 40, 20) + vi.stubGlobal('ResizeObserver', class { + constructor(callback: () => void) { resize = callback } + observe() {} + disconnect() {} + }) + Object.defineProperty(Range.prototype, 'getBoundingClientRect', { configurable: true, value: () => selectionRect }) + Object.defineProperty(Range.prototype, 'getClientRects', { configurable: true, value: () => Object.assign([selectionRect], { item: (i: number) => i === 0 ? selectionRect : null }) }) + vi.stubGlobal('PointerEvent', MouseEvent) + vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(function (this: HTMLElement) { + if (this.dataset.surface !== undefined) return rect(0, 0, 80, 30) + if (this.dataset.viewport !== undefined) return rect(20, 80, 400, 300) + return rect(40, 100, 300, 500) + }) +}) + +afterEach(() => { + cleanup() + window.getSelection()?.removeAllRanges() + Reflect.deleteProperty(Range.prototype, 'getBoundingClientRect') + Reflect.deleteProperty(Range.prototype, 'getClientRects') + vi.restoreAllMocks() + vi.unstubAllGlobals() +}) + +function Area({ id = 'action', admit, onBinding, enabled = true, composer = false }: { + enabled?: boolean + composer?: boolean + id?: string + admit?: (request: TextInteractionRequest) => void + onBinding?: (binding: TextInteractionBinding) => void +}) { + const root = useRef(null) + const surface = useRef(null) + const ids = useMemo(() => enabled ? [id] : [], [enabled, id]) + const dispatch = useCallback((gesture: TextInteractionGesture): string | null => { + if (!enabled) return null + let opened = false + const request: TextInteractionRequest = { ...gesture, sessionId: SessionId(id), open: () => { opened = true } } + if (admit === undefined) { if (request.trigger === 'selection') request.open() } + else admit(request) + return opened ? id : null + }, [id, admit, enabled]) + const interaction = useTextInteraction(root, surface, dispatch, ids) + onBinding?.(interaction) + return
+
+

Hello {id}

+

Other text

+
+ {interaction.active?.id === id && } +
+
+ {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/README.i18n.yaml b/packages/client/ui-commands/README.i18n.yaml index 2521e4cb81..eed1e982c7 100644 --- a/packages/client/ui-commands/README.i18n.yaml +++ b/packages/client/ui-commands/README.i18n.yaml @@ -15,8 +15,8 @@ en: d152484eb41ac6b4 zh: 09388d293f9be9cb /deepseek-ai-dsh-client-ui-commands/use-this-package: - en: 01adce72791579fa - zh: 70eb55a1529038e7 + en: c325c9eaca4efe47 + zh: dbbc29cc9cec54bc /deepseek-ai-dsh-client-ui-commands/use-this-package/kinds-and-decorations: en: 57679a20574d8b3f zh: 98d5e5a7781e6de1 diff --git a/packages/client/ui-commands/README.md b/packages/client/ui-commands/README.md index fc5e7efe21..ef8781649e 100644 --- a/packages/client/ui-commands/README.md +++ b/packages/client/ui-commands/README.md @@ -25,7 +25,7 @@ Typing a `/` command opens a registered popup, a client action, a host command's ## Use this package -Mount this plugin alongside `ui-input-trigger` and `ui-conversation`; the `/` source then appears in the trigger menu, and business packages register their command surfaces through `ctx.commandUi`. Typing `/model` opens the registered popup; a host command with an argument claim opens its input or executes directly. The popup holds composer focus: typing filters the loaded rows locally, `↑`/`↓` walk them, Enter and `Tab` accept the highlighted row, and Escape and `Shift+Tab` return to the composer. The highlight opens on the row the options mark as the session's current value, so accepting on a freshly opened panel confirms it. The composer's `+` button and a typed `/` open the same menu: an Add section (File, Goal, Plan, Feedback) and a Commands section (Compact, Permission, Model, Export) in usage order, each row with a glyph, a localized title and description, and the command name as an alias where the localized title differs from it. +Mount this plugin alongside `ui-input-trigger` and `ui-conversation`; the `/` source then appears in the trigger menu, and business packages register their command surfaces through `ctx.commandUi`. Typing `/model` opens the registered popup; a host command with an argument claim opens its input or executes directly. The popup automatically focuses its search input on desktop. Phones and tablets require tapping the search input, following the shared [text-input focus policy](../ui-primitives/README.md#use-this-package). With search focused, typing filters the loaded rows locally, `↑`/`↓` walk them, Enter and `Tab` accept the highlighted row, and Escape and `Shift+Tab` return to the composer. The highlight opens on the row the options mark as the session's current value, so accepting on a freshly opened panel confirms it. The composer's `+` button and a typed `/` open the same menu: an Add section (File, Goal, Plan, Feedback) and a Commands section (Compact, Permission, Model, Export) in usage order, each row with a glyph, a localized title and description, and the command name as an alias where the localized title differs from it. ### Kinds and decorations diff --git a/packages/client/ui-commands/README.zh.md b/packages/client/ui-commands/README.zh.md index 7393d20329..7ad5b4f9a1 100644 --- a/packages/client/ui-commands/README.zh.md +++ b/packages/client/ui-commands/README.zh.md @@ -25,7 +25,7 @@ kind: "package-reference" ## 使用本包 -与 `ui-input-trigger` 及 `ui-conversation` 一起挂载本插件;`/` source 随即出现在触发菜单中,业务包经 `ctx.commandUi` 注册自己的命令表面。键入 `/model` 打开已注册的弹窗;带参数声明的宿主命令打开其输入或直接执行。弹窗持有 composer 焦点:键入即在已加载的行上本地筛选,`↑`/`↓` 在行间移动,回车与 `Tab` 接受高亮行,Escape 与 `Shift+Tab` 把焦点还给 composer。高亮落在选项标记为会话当前值的行上,因此在刚打开的弹窗上接受即确认当前值。composer 的 `+` 按钮与键入的 `/` 打开同一个菜单:「添加」小节(文件、目标、计划、反馈)与「指令」小节(压缩、权限、模型、下载日志)按使用频次排列,每行带图标、本地化的标题与说明,本地化标题与命令名不同时还显示命令名作为别名。 +与 `ui-input-trigger` 及 `ui-conversation` 一起挂载本插件;`/` source 随即出现在触发菜单中,业务包经 `ctx.commandUi` 注册自己的命令表面。键入 `/model` 打开已注册的弹窗;带参数声明的宿主命令打开其输入或直接执行。桌面端的弹窗自动聚焦搜索框。手机和平板需要点击搜索框,遵循共享的[文字输入框聚焦策略](../ui-primitives/README.zh.md#use-this-package)。搜索框聚焦后,键入即在已加载的行上本地筛选,`↑`/`↓` 在行间移动,回车与 `Tab` 接受高亮行,Escape 与 `Shift+Tab` 把焦点还给 composer。高亮落在选项标记为会话当前值的行上,因此在刚打开的弹窗上接受即确认当前值。composer 的 `+` 按钮与键入的 `/` 打开同一个菜单:「添加」小节(文件、目标、计划、反馈)与「指令」小节(压缩、权限、模型、下载日志)按使用频次排列,每行带图标、本地化的标题与说明,本地化标题与命令名不同时还显示命令名作为别名。 ### 种类与装饰 diff --git a/packages/client/ui-commands/locale/de.json b/packages/client/ui-commands/locale/de.json new file mode 100644 index 0000000000..8fdc53edce --- /dev/null +++ b/packages/client/ui-commands/locale/de.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "Befehlsauswahlschnittstelle", + "description": "Bietet ein Slash-Befehlsmenü und eine zugehörige Bedienoberfläche im Eingabefeld." + } +} diff --git a/packages/client/ui-commands/locale/es.json b/packages/client/ui-commands/locale/es.json new file mode 100644 index 0000000000..9fb9b09c55 --- /dev/null +++ b/packages/client/ui-commands/locale/es.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "Interfaz de selección de comandos", + "description": "Proporciona un menú de comando de barra diagonal y una interfaz de operación relacionada en el cuadro de entrada." + } +} diff --git a/packages/client/ui-commands/locale/fr.json b/packages/client/ui-commands/locale/fr.json new file mode 100644 index 0000000000..e0e61417a6 --- /dev/null +++ b/packages/client/ui-commands/locale/fr.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "Interface de sélection de commandes", + "description": "Fournit un menu de commande slash et une interface d’opération associée dans la zone de saisie." + } +} 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/locale/pt.json b/packages/client/ui-commands/locale/pt.json new file mode 100644 index 0000000000..01fc89d3a8 --- /dev/null +++ b/packages/client/ui-commands/locale/pt.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "Interface de seleção de comandos", + "description": "Fornece um menu de comando de barra e interface de operação relacionada na caixa de entrada." + } +} diff --git a/packages/client/ui-commands/locale/ru.json b/packages/client/ui-commands/locale/ru.json new file mode 100644 index 0000000000..529ec9773b --- /dev/null +++ b/packages/client/ui-commands/locale/ru.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "Интерфейс выбора команд", + "description": "Предоставляет меню команд с косой чертой и соответствующий рабочий интерфейс в поле ввода." + } +} diff --git a/packages/client/ui-commands/src/client/PopupSelectView.tsx b/packages/client/ui-commands/src/client/PopupSelectView.tsx index 4b0b4bf418..911a8f5d21 100644 --- a/packages/client/ui-commands/src/client/PopupSelectView.tsx +++ b/packages/client/ui-commands/src/client/PopupSelectView.tsx @@ -1,9 +1,10 @@ /** * Official popupSelect shell: renders one session's PopupSelectController * store into the conversation.input.overlay anchor. Unlike the slash menu - * (combobox — textarea keeps focus), this shell HOLDS focus while open: the - * inner search input takes focus, plain typing filters the loaded options - * locally, Enter and Tab accept the filtered highlight, ↑↓ walk it (wrapping, + * (combobox — textarea keeps focus), this shell focuses its search input on + * desktop; phones and tablets require explicit search focus. Typing filters + * the loaded options locally, Enter and Tab accept the filtered highlight, + * ↑↓ walk it (wrapping, * scrolled into view), and Escape and Shift+Tab dismiss back to the composer. * ←→ keep the search input's native caret. Any pointer interaction outside the * box dismisses (the click's own target takes focus). Closed state renders @@ -14,7 +15,7 @@ import { MenuGroup, MenuSurface, observeStickyMenuGroups } from '@deepseek-ai/ds import { Fragment, useEffect, useMemo, useRef } from 'react' import { useSyncExternalStore } from 'react' import clsx from 'clsx' -import { IconCheckOutlineRegular, RiskConfirmation, useAnchoredMaxHeight } from '@deepseek-ai/dsh-client-ui-primitives' +import { IconCheckOutlineRegular, isMobileDevice, RiskConfirmation, useAnchoredMaxHeight } from '@deepseek-ai/dsh-client-ui-primitives' import type { PropsLocale } from '@deepseek-ai/dsh-client-ui-slots' import { filterOptions } from './popup.ts' import type { SelectOption } from './contract.ts' @@ -67,8 +68,7 @@ export function PopupSelectView({ popup, t }: PopupSelectViewProps) { cardRef.current?.querySelector('[aria-selected="true"]')?.scrollIntoView({ block: 'nearest' }) }, [active, rows, state.confirming]) - // Focus ownership: the search input grabs on open, and ANY outside - // pointer interaction dismisses — + // Any outside pointer interaction dismisses — // capture phase so a click landing anywhere else (textarea included) // closes the shell before its own handlers run; that click's target then // takes focus naturally, so no focusComposer here. @@ -84,7 +84,9 @@ export function PopupSelectView({ popup, t }: PopupSelectViewProps) { // Focus the search input after it mounts (separate effect so the ref is populated). useEffect(() => { - if (state.open && state.confirming === null) searchRef.current?.focus() + if (!state.open || state.confirming !== null) return + if (isMobileDevice()) return + searchRef.current?.focus() }, [state.open, state.confirming]) if (!state.open) return null diff --git a/packages/client/ui-commands/src/client/index.ts b/packages/client/ui-commands/src/client/index.ts index a629bd3240..f1e8d62bf2 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, es, fr, de, ru, pt, 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, es, fr, de, ru, pt }), '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..ae0517b5f4 100644 --- a/packages/client/ui-commands/src/client/locales.ts +++ b/packages/client/ui-commands/src/client/locales.ts @@ -71,3 +71,195 @@ 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 + +/** Spanish dictionary, checked against the English keys. */ +export const es = { + 'section.add': 'Añadir', + 'section.commands': 'Comandos', + 'label.goal': 'Objetivo', + 'label.plan': 'Planificar', + 'label.feedback': 'Comentarios', + 'label.compact': 'Compacto', + 'label.permission': 'Permiso', + 'label.export': 'Exportar', + 'description.goal': 'Establecer o ver el objetivo de una tarea de larga duración', + 'description.plan': 'Entrar o salir del modo plan', + 'description.feedback': 'Registrar comentarios sobre esta sesión', + 'description.compact': 'Historial de conversaciones antiguo compacto', + 'description.permission': 'Cambiar el ajuste preestablecido de permisos (modo sandbox + política de aprobación)', + 'description.export': 'Descargue este registro de sesión como un archivo ZIP', + 'token.goal': 'objetivo', + 'token.plan': 'planear', + 'token.feedback': 'retroalimentación', + 'token.compact': 'compacto', + 'token.permission': 'permiso', + 'token.export': 'exportar', + 'search.placeholder': 'Buscar…', + 'search.aria': 'Opciones de filtro', + 'status.loading': 'Cargando opciones…', + 'status.applying': 'Aplicando…', + 'status.empty': 'Sin opciones', + 'overlay.aria': 'Opciones de /{command}', + 'listbox.aria': '/{command} coincidencias', + 'notice.attachmentsUnsupported': '/{command} no acepta archivos adjuntos; eliminarlos primero', +} satisfies Record + +/** French dictionary, checked against the English keys. */ +export const fr = { + 'section.add': 'Ajouter', + 'section.commands': 'Commandes', + 'label.goal': 'Objectif', + 'label.plan': 'Planifier', + 'label.feedback': 'Commentaires', + 'label.compact': 'Compacte', + 'label.permission': 'Autorisation', + 'label.export': 'Exporter', + 'description.goal': "Définir ou afficher l'objectif d'une tâche de longue durée", + 'description.plan': 'Entrer ou quitter le mode Plan', + 'description.feedback': 'Enregistrer les commentaires sur cette session', + 'description.compact': 'Compacter l’historique des conversations anciennes', + 'description.permission': "Changer le préréglage d'autorisation (mode sandbox + politique d'approbation)", + 'description.export': "Téléchargez ce journal de session sous forme d'archive ZIP", + 'token.goal': 'objectif', + 'token.plan': 'planifier', + 'token.feedback': 'commentaires', + 'token.compact': 'compacte', + 'token.permission': 'autorisation', + 'token.export': 'exporter', + 'search.placeholder': 'Rechercher…', + 'search.aria': 'Options de filtrage', + 'status.loading': 'Options de chargement…', + 'status.applying': 'Application…', + 'status.empty': 'Aucune option', + 'overlay.aria': 'Options /{command}', + 'listbox.aria': '/{command} correspondances', + 'notice.attachmentsUnsupported': "/{command} n'accepte pas les pièces jointes ; retirez-les d'abord", +} satisfies Record + +/** German dictionary, checked against the English keys. */ +export const de = { + 'section.add': 'Hinzufügen', + 'section.commands': 'Befehle', + 'label.goal': 'Ziel', + 'label.plan': 'Planen', + 'label.feedback': 'Rückmeldung', + 'label.compact': 'Kompakt', + 'label.permission': 'Erlaubnis', + 'label.export': 'Exportieren', + 'description.goal': 'Legen Sie das Ziel für eine Aufgabe mit langer Laufzeit fest oder zeigen Sie es an', + 'description.plan': 'Aktivieren oder verlassen Sie den Planmodus', + 'description.feedback': 'Zeichnen Sie Feedback zu dieser Sitzung auf', + 'description.compact': 'Kompakter älterer Gesprächsverlauf', + 'description.permission': 'Ändern Sie die Berechtigungsvoreinstellung (Sandbox-Modus + Genehmigungsrichtlinie).', + 'description.export': 'Laden Sie dieses Sitzungsprotokoll als ZIP-Archiv herunter', + 'token.goal': 'Ziel', + 'token.plan': 'planen', + 'token.feedback': 'Rückmeldung', + 'token.compact': 'kompakt', + 'token.permission': 'Erlaubnis', + 'token.export': 'exportieren', + 'search.placeholder': 'Suchen…', + 'search.aria': 'Filteroptionen', + 'status.loading': 'Ladeoptionen…', + 'status.applying': 'Wird angewendet…', + 'status.empty': 'Keine Optionen', + 'overlay.aria': '/{command}-Optionen', + 'listbox.aria': '/{command} stimmt überein', + 'notice.attachmentsUnsupported': '/{command} akzeptiert keine Anhänge; Entfernen Sie sie zuerst', +} satisfies Record + +/** Russian dictionary, checked against the English keys. */ +export const ru = { + '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': 'цель', + 'token.plan': 'план', + 'token.feedback': 'обратная связь', + 'token.compact': 'компактный', + 'token.permission': 'разрешение', + 'token.export': 'экспорт', + 'search.placeholder': 'Искать…', + 'search.aria': 'Параметры фильтра', + 'status.loading': 'Загрузка параметров…', + 'status.applying': 'Применение…', + 'status.empty': 'Нет вариантов', + 'overlay.aria': 'Опции /{command}', + 'listbox.aria': '/{command} совпадений', + 'notice.attachmentsUnsupported': '/{command} не принимает вложения; сначала удали их', +} satisfies Record + +/** Portuguese dictionary, checked against the English keys. */ +export const pt = { + 'section.add': 'Adicionar', + 'section.commands': 'Comandos', + 'label.goal': 'Objetivo', + 'label.plan': 'Plano', + 'label.feedback': 'Comentários', + 'label.compact': 'Compacto', + 'label.permission': 'Permissão', + 'label.export': 'Exportar', + 'description.goal': 'Defina ou visualize a meta para uma tarefa de longa duração', + 'description.plan': 'Entrar ou sair do modo de plano', + 'description.feedback': 'Registre feedback sobre esta sessão', + 'description.compact': 'Histórico compacto de conversas antigas', + 'description.permission': 'Alterar a predefinição de permissão (modo sandbox + política de aprovação)', + 'description.export': 'Baixe este log de sessão como um arquivo ZIP', + 'token.goal': 'objetivo', + 'token.plan': 'planejar', + 'token.feedback': 'comentários', + 'token.compact': 'compacto', + 'token.permission': 'permissão', + 'token.export': 'exportar', + 'search.placeholder': 'Pesquisar…', + 'search.aria': 'Opções de filtro', + 'status.loading': 'Carregando opções…', + 'status.applying': 'Aplicando…', + 'status.empty': 'Sem opções', + 'overlay.aria': '/{command} opções', + 'listbox.aria': '/{command} correspondências', + 'notice.attachmentsUnsupported': '/{command} não aceita anexos; remova-os primeiro', +} satisfies Record diff --git a/packages/client/ui-commands/tests/popup-view.client.spec.tsx b/packages/client/ui-commands/tests/popup-view.client.spec.tsx index 3003a868d9..9b1d246927 100644 --- a/packages/client/ui-commands/tests/popup-view.client.spec.tsx +++ b/packages/client/ui-commands/tests/popup-view.client.spec.tsx @@ -1,7 +1,8 @@ // @vitest-environment jsdom /** * PopupSelectView interaction spec: the search input takes - * focus on open and plain typing filters locally, ↑↓ move the filtered + * focus on desktop open; mobile search requires explicit focus. Typing filters + * locally, ↑↓ move the filtered * highlight while ←→ stay native to the input, Enter and Tab select * single-flight, Escape and Shift+Tab dismiss back through focusComposer, * outside pointerdown dismisses @@ -9,7 +10,8 @@ * retry button, the highlighted row scrolls into view, and the card height * clamps to the space above the composer. */ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import * as uiPrimitives from '@deepseek-ai/dsh-client-ui-primitives' +import { afterEach, beforeEach, describe, expect, it, onTestFinished, vi } from 'vitest' import { act, cleanup, fireEvent, render, screen } from '@testing-library/react' import type { SelectOption } from '../src/client/contract.ts' import type { PopupSpec, TokenSegment } from '../src/client/popup.ts' @@ -140,6 +142,40 @@ describe('PopupSelectView', () => { expect(rowLabels()).toEqual(['Dark', 'Light', 'Sepia']) }) + it.each([ + { name: 'iPhone', userAgent: 'Mozilla/5.0 (iPhone; CPU iPhone OS 18_6 like Mac OS X) AppleWebKit/605.1.15 Version/18.6 Mobile/15E148 Safari/604.1', platform: 'iPhone', maxTouchPoints: 5, automatic: false }, + { name: 'Android phone', userAgent: 'Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 Chrome/119.0.0.0 Mobile Safari/537.36', platform: 'Linux armv8l', maxTouchPoints: 0, automatic: false }, + { name: 'Android tablet', userAgent: 'Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 Chrome/119.0.0.0 Safari/537.36', platform: 'Linux armv8l', maxTouchPoints: 5, automatic: false }, + { name: 'desktop-mode iPad', userAgent: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 Version/18.6 Safari/605.1.15', platform: 'MacIntel', maxTouchPoints: 5, automatic: false }, + { name: 'Mac', userAgent: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 Version/18.6 Safari/605.1.15', platform: 'MacIntel', maxTouchPoints: 0, automatic: true }, + { name: 'Windows touchscreen', userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/119.0.0.0 Safari/537.36', platform: 'Win32', maxTouchPoints: 5, automatic: true }, + ])('automatic search focus respects device classification: $name', async ({ userAgent, platform, maxTouchPoints, automatic }) => { + for (const [name, value] of Object.entries({ userAgent, platform, maxTouchPoints })) { + const descriptor = Object.getOwnPropertyDescriptor(navigator, name) + Object.defineProperty(navigator, name, { configurable: true, value }) + onTestFinished(() => { + if (descriptor === undefined) Reflect.deleteProperty(navigator, name) + else Object.defineProperty(navigator, name, descriptor) + }) + } + vi.resetModules() + const detection = await import('@deepseek-ai/dsh-client-ui-primitives/src/focus.ts') + const device = vi.spyOn(uiPrimitives, 'isMobileDevice').mockImplementation(detection.isMobileDevice) + onTestFinished(() => { device.mockRestore() }) + const { popup, search } = await mountOpen() + expect(document.activeElement === search).toBe(automatic) + act(() => { search.focus() }) + fireEvent.change(search, { target: { value: 'dark' } }) + expect(document.activeElement).toBe(search) + expect(rowLabels()).toEqual(['Dark']) + act(() => { popup.dismiss() }) + await act(async () => { + popup.open('theme', spec(), 'ctx-A', SEGMENT) + await Promise.resolve() + }) + expect(document.activeElement === screen.getByRole('textbox', { name: '筛选选项' })).toBe(automatic) + }) + it('renders an optional option badge as a superscript marker', async () => { await mountOpen({ options: () => Promise.resolve([{ id: 'auto', label: 'Auto review', badge: 'EXP' }]), diff --git a/packages/client/ui-conversation/README.i18n.yaml b/packages/client/ui-conversation/README.i18n.yaml index 0f813b3c22..7d9bf9f3c1 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: e910ac7fec4fd72d + zh: 741a399436dc98d9 /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..86a1ffd236 100644 --- a/packages/client/ui-conversation/README.md +++ b/packages/client/ui-conversation/README.md @@ -59,23 +59,35 @@ 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 The shared image slot props keep display choices separate from durable references: `thumbnail` requests a contained attachment-list thumbnail, while `compact` requests a cropped gallery tile. An optional per-image `label` supplies the accessible display name; loading and cache identity still use the original attachment reference. `MessageImagesContentInput` adds optional complete `MessageImageLabels` for the root-scoped `conversation.images.content` Factory. Message, tool, and Factory inputs carry the view's authorized `loadImage` callback explicitly; loading and URL caching remain with the resource owner. [ui-attachment](../ui-attachment/README.md) provides this Factory and owns rendering and the lightbox. +The composer uses a minimum 16px draft font on coarse-pointer devices to avoid focus-triggered browser zoom; larger font preferences still apply. + The composer measures its expanded control groups after size, content, visibility, or font-loading changes. If they cannot share a line, the row sets `--dsh-composer-model-text-display: none` and `--dsh-composer-model-icon-display: block` for the model seat; their defaults are `block` and `none`. Wrapping remains available if even the icon cannot fit. The context meter follows the [statistics-control radius](../../../docs/ui-radius.md#radius-scale). +The composer uses the shared [text-input focus policy](../ui-primitives/README.md#use-this-package) on mount, Session switching, and unlock. Identified phones and tablets require tapping the input; desktop devices retain automatic focus. On these mobile devices, a submission that empties the focused draft moves focus to the non-text composer card, dismissing the keyboard while retaining its navigation position. Rejected submissions, retained newer draft text, and another control already holding focus preserve focus. The [Lexical patch](../../../patches/lexical@0.49.0.patch) keeps caret scrolling in the same coordinate system as DOM geometry when a mobile keyboard pans the page. + The context-occupancy button shows a ring and percentage below the input card, after the Session statistics. Clicking it opens the token breakdown in a panel kept inside the viewport, including when no statistics are shown; the button stays hidden until context usage and capacity are available. With Coding Tools off, the shell hides only Trajectory; other plugin-contributed Views remain available. The View tab bar is hidden when fewer than two Views are available. Turning Coding Tools off while Trajectory is active returns to Chat; stored View preferences and recorded Session data remain intact. Turning it on makes Trajectory available again. View owners receive the available roster so their navigation actions can follow the same visibility. -While the root-scoped `conversation.session.header.views.actions` list has registrations, a **More conversation views** button with a tooltip follows the View tabs in the tab row. It opens a menu whose rows are that list's registrations. Each receives `entries` (available Views in registration order with `id`, `label`, and `pinned`; Chat is never listed), `onOpen(id)`, which selects the View in this Session, `onPinnedChange(id, pinned)`, and `onDismiss()`, which closes the menu. The header keeps one root list of unpinned View ids in browser `localStorage` under `dsh.slot.manual-order.conversation.view.v1`, stored as `{ "unpinned": string[] }` and shared by every Session; the tab bar omits unpinned Views, and Chat always shows. The tab bar's visibility counts available Views, not pinned ones, so the bar and More stay while every other View is unpinned. Unpinning the active View leaves its content selected and keeps its tab, with `aria-selected`, until another View is selected. Ids of Views that are not registered stay stored; stored content other than a string array shows every tab. +While the root-scoped `conversation.session.header.views.actions` list has registrations, a **More conversation views** button with a tooltip follows the View tabs in the tab row. It opens a menu whose rows are that list's registrations. Each receives `entries` (Views outside the header in registration order with `id`, `label`, `pinned`, and `active`; Chat is never listed), `onOpen(id)`, which selects the View in this Session, `onPinnedChange(id, pinned)`, and `onDismiss()`, which closes the menu. The header keeps an in-memory list of unpinned View ids shared by every Session; the tab bar omits unpinned Views and places registered Chat first so it always remains outside More. Plugin reload resets pin choices, and legacy localStorage values are ignored. The tab bar's visibility counts available Views, not pinned ones, so the bar and More stay while every other View is unpinned. Unpinning the active View hides its tab immediately without changing the selected content. More lists only Views outside the header and identifies the current View through `active`. The header reads its available row width, reserves 48px for More and padding, and estimates 120px per tab. Remaining Views keep registration order in More. A single ResizeObserver watches the tab row so window resizing and sidebar expansion update the split; active selection does not change the order. The observer disconnects when the row is removed. If ResizeObserver is unavailable, the initial width calculation still applies without subsequent observation. The More button highlights and names the active View in its tooltip when that View is folded. Individual tabs are not measured. At least one preferred tab remains outside, and long labels truncate with a full-name tooltip. Unregistered View ids remain in the in-memory pin list until plugin reload. The composer registers the File command action and owns its label, availability, and native file-dialog callback. Menu availability and invocation both consult the mounted composer's current attachment-intake policy. Unmounting or locking the composer disables that action; disposing the plugin removes its registration. The callback binding stays inside the input module. +The composer reads its Session's access answers through the Session-scope `useAccess` Hook: Send disappears while the access snapshot excludes `session/prompt`, Stop while it excludes `session/cancel`, and file intake follows `fileUploads/upload`. A composer without a Session has no answer and keeps its ordinary controls. Send and Stop are withheld from their buttons alone: a keyboard submission or the Esc-Esc sequence still reaches the Host, which refuses it and reports the failure. These UI answers never replace Host authorization. + `SessionInputShell` owns one Lexical editor per Session through its private [DraftEditorRuntime](src/client/input/editor/runtime.ts), while retaining submission, attachment selection, and recovery decisions. [DraftEditor](src/client/input/editor/DraftEditor.tsx) renders the borrowed editor; InputBar retains its Hooks and refs and installs DOM behavior through [view-binding](src/client/input/editor/view-binding.ts). Editor-facing types live in [draft-editor.ts](src/client/contract/draft-editor.ts), with shared input and submission types in [input.ts](src/client/contract/input.ts). Background editor updates retain the draft selection without changing the document selection or reclaiming focus from another control; explicit Composer focus restores its selection. This separation does not support simultaneous editable roots for one Session; [the two-stage isolation proposal](../../../.agents/notes/proposed/architecture/2026-09-14-composer-model-and-draft-editor.md) defines the remaining work. Drafts retain plain text and file, folder, and Session reference chips across Session switching and reload. Public input methods accept text; internal restoration and Workspace transfer preserve structured references. The input model imports saved content before its first published read; React only mounts that editor and binds the existing Conversation store writer. The original storage key accepts legacy strings and writes semantic snapshots without runtime node IDs. A pending skill catalog does not block editing: slash names remain ordinary text until a matching catalog entry arrives. Arrival rematches the current text without changing chips, selection, or Undo history. Browser-owned attachment File objects are not persisted by this mechanism. The [draft initialization decision](../../../.agents/notes/implemented/architecture/2026-09-30-structured-draft-initialization.md) defines ownership and restoration. @@ -98,13 +110,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..ebfbaff130 100644 --- a/packages/client/ui-conversation/README.zh.md +++ b/packages/client/ui-conversation/README.zh.md @@ -59,23 +59,35 @@ 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 共享图片插槽属性将展示选择与持久化引用分开:`thumbnail` 请求完整缩放的附件列表缩略图,`compact` 请求裁剪的图片方块。每张图片可通过可选的 `label` 提供无障碍展示名称;加载和缓存标识仍使用原始附件引用。`MessageImagesContentInput` 为 root 作用域的 `conversation.images.content` Factory 增加可选的完整 `MessageImageLabels`。消息、工具及 Factory 输入显式携带视图提供的授权 `loadImage` 回调;读取和 URL 缓存仍由资源所有者负责。[ui-attachment](../ui-attachment/README.zh.md) 提供该 Factory,负责渲染与灯箱。 +在粗指针设备上,composer 的草稿字号至少为 16px,以避免获得焦点时浏览器自动放大;更大的字号偏好仍然生效。 + 控件组的尺寸、内容、可见性或字体加载状态变化后,composer 测量展开状态下的控件组。若无法排在同一行,控制栏为模型位设置 `--dsh-composer-model-text-display: none` 和 `--dsh-composer-model-icon-display: block`;两者默认值分别为 `block` 和 `none`。若连图标也放不下,仍允许换行。 上下文占用率入口遵循[统计控件圆角规则](../../../docs/ui-radius.zh.md#radius-scale)。 +composer 在挂载、切换 Session 和解除锁定时使用共享的[文字输入框聚焦策略](../ui-primitives/README.zh.md#use-this-package)。识别出的手机和平板需要点击输入框;桌面设备保留自动聚焦。在这些移动设备上,提交清空正在聚焦的草稿后,焦点移到不接受文字输入的 composer 卡片,收起键盘并保留键盘导航位置。被拒绝的提交、保留的新草稿文字,以及已经聚焦的其他控件均保留原有焦点。移动端键盘平移页面时,[Lexical 补丁](../../../patches/lexical@0.49.0.patch)让光标滚动计算与 DOM 几何使用同一坐标系。 + 上下文占用按钮在输入卡片下方、会话统计右侧显示圆环和百分比。点击按钮可在视口内的面板查看 token 构成,没有统计项时面板也不会越界;上下文用量和容量尚不可用时,按钮保持隐藏。 关闭代码工作工具时,外壳仅隐藏 Trajectory;其他插件贡献的 View 仍然可用。可用 View 少于两个时隐藏 View 标签栏。在 Trajectory 激活时关闭代码工作工具会返回 Chat;已保存的 View 偏好和 Session 记录保持不变。开启后,Trajectory 恢复可用。View 所有者接收可用列表,使导航操作跟随相同的可见性。 -root 作用域的 `conversation.session.header.views.actions` list 有注册项时,标签行中的 View 标签之后显示带提示框的**更多会话视图**按钮。它打开的菜单以该 list 的注册项为菜单行。每个注册项接收 `entries`(按注册顺序的可用 View,含 `id`、`label` 与 `pinned`;不含 Chat)、在本 Session 选择 View 的 `onOpen(id)`、`onPinnedChange(id, pinned)`,以及关闭菜单的 `onDismiss()`。头部在浏览器 `localStorage` 的 `dsh.slot.manual-order.conversation.view.v1` 下保存一份 root 级的已收起 View id 列表,格式为 `{ "unpinned": string[] }`,所有 Session 共用;标签栏不显示已收起的 View,Chat 始终显示。标签栏是否显示按可用 View 计算,不按已固定的 View 计算,因此其他 View 全部收起时,标签栏和“更多”仍然显示。收起当前激活的 View 不会切走其内容,其标签及 `aria-selected` 保留到选中其他 View 为止。未注册 View 的 id 保留在列表中;保存内容不是字符串数组时,所有标签都显示。 +root 作用域的 `conversation.session.header.views.actions` list 有注册项时,标签行中的 View 标签之后显示带提示框的**更多会话视图**按钮。它打开的菜单以该 list 的注册项为菜单行。每个注册项接收 `entries`(按注册顺序的头部之外的 View,含 `id`、`label`、`pinned` 与 `active`;不含 Chat)、在本 Session 选择 View 的 `onOpen(id)`、`onPinnedChange(id, pinned)`,以及关闭菜单的 `onDismiss()`。头部在内存中保存所有 Session 共用的已收起 View id 列表;标签栏不显示已收起的 View,并将已注册的 Chat 放在首位,确保它始终留在更多菜单外。插件重新加载时 Pin 选择重置,不读取旧 localStorage 值。标签栏是否显示按可用 View 计算,不按已固定的 View 计算,因此其他 View 全部收起时,标签栏和“更多”仍然显示。收起当前激活的 View 会立即隐藏其标签,但不会切走其内容。更多菜单仅包含头部之外的 View,通过 `active` 标识当前 View。头部读取当前行可用宽度,为更多按钮与内边距预留 48px,每个标签估算 120px,其余 View 按注册顺序进入更多。单个 ResizeObserver 观察标签行,使窗口缩放和侧栏展开能够更新分组;当前选中项变化不改变顺序。标签行移除时断开观察。如果环境没有 ResizeObserver,仍按初始宽度计算,但不继续观察。当前 View 收起时,更多按钮高亮并在提示框中显示其名称。不逐项测量标签。至少保留一个优先展示的外部标签,长标题截断并通过提示框提供全名。未注册 View 的 id 保留在内存 Pin 列表中,直到插件重新加载。 输入框注册「文件」命令动作,负责其标题、可用性和原生文件选择器回调。菜单可用性与实际调用都读取已挂载输入框当前的附件接收策略。输入框卸载或锁定后该动作不可用,插件 dispose(资源释放)时移除注册。回调绑定留在输入模块内部。 +输入框通过会话作用域的 `useAccess` 钩子读取所在 Session 的访问答案:访问快照不含 `session/prompt` 时隐藏发送,不含 `session/cancel` 时隐藏停止,附件接收跟随 `fileUploads/upload`。没有 Session 的输入框尚无答案,保留普通控件。发送与停止都只对按钮生效——键盘提交或 Esc-Esc 序列仍会到达 Host,由 Host 拒绝并上报失败。这些界面判断不替代 Host 授权。 + `SessionInputShell` 通过私有 [DraftEditorRuntime](src/client/input/editor/runtime.ts) 为每个 Session 持有一个 Lexical editor,同时保留提交、附件选择和恢复决策。[DraftEditor](src/client/input/editor/DraftEditor.tsx) 呈现借用的 editor;InputBar 保留钩子与 refs,并通过 [view-binding](src/client/input/editor/view-binding.ts) 安装 DOM 行为。编辑器类型位于 [draft-editor.ts](src/client/contract/draft-editor.ts),共享输入和提交类型位于 [input.ts](src/client/contract/input.ts)。编辑器后台更新会保留草稿选区,不修改文档选区或从其他控件夺回焦点;显式聚焦 Composer 时恢复其选区。这一拆分不支持同一 Session 同时挂载多个可编辑 root;[两阶段隔离提案](../../../.agents/notes/proposed/architecture/2026-09-14-composer-model-and-draft-editor.zh.md) 定义剩余工作。 草稿中的普通文字及文件、目录、Session 引用胶囊在切换 Session 和刷新后保留。公开输入方法接受文本;内部恢复和工作区携带保留结构化引用。输入模型在首次对外读取前导入保存内容;React 只挂载同一编辑器并绑定现有 Conversation store 写入回调。原存储 key 兼容旧字符串,新写入保存不含运行时节点 ID 的语义快照。等待 skill 目录不会阻塞编辑:斜杠名称保持普通文字,直到匹配的目录项到达。目录到达只重匹配当前文字,不改变胶囊、选区或 Undo 历史。浏览器持有的附件 File 对象不由这一机制持久化。[草稿初始化决策](../../../.agents/notes/implemented/architecture/2026-09-30-structured-draft-initialization.zh.md)规定所有权与恢复流程。 @@ -98,13 +110,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/de.json b/packages/client/ui-conversation/locale/de.json new file mode 100644 index 0000000000..def54f49a5 --- /dev/null +++ b/packages/client/ui-conversation/locale/de.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "Konversationsschnittstellen-Framework", + "description": "Organisieren Sie Dialoginhalte, Eingabefelder, Nachrichtenwarteschlangen und zeigen Sie die Navigation an." + } +} diff --git a/packages/client/ui-conversation/locale/es.json b/packages/client/ui-conversation/locale/es.json new file mode 100644 index 0000000000..89664f8f6a --- /dev/null +++ b/packages/client/ui-conversation/locale/es.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "marco de interfaz conversacional", + "description": "Organice el contenido del diálogo, los cuadros de entrada, las colas de mensajes y vea la navegación." + } +} diff --git a/packages/client/ui-conversation/locale/fr.json b/packages/client/ui-conversation/locale/fr.json new file mode 100644 index 0000000000..a24689adf7 --- /dev/null +++ b/packages/client/ui-conversation/locale/fr.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "cadre d'interface conversationnelle", + "description": "Organisez le contenu des boîtes de dialogue, les zones de saisie, les files d'attente de messages et la navigation dans les vues." + } +} 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/locale/pt.json b/packages/client/ui-conversation/locale/pt.json new file mode 100644 index 0000000000..25642a6dc7 --- /dev/null +++ b/packages/client/ui-conversation/locale/pt.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "estrutura de interface conversacional", + "description": "Organize o conteúdo da caixa de diálogo, caixas de entrada, filas de mensagens e visualize a navegação." + } +} diff --git a/packages/client/ui-conversation/locale/ru.json b/packages/client/ui-conversation/locale/ru.json new file mode 100644 index 0000000000..5e527b7f33 --- /dev/null +++ b/packages/client/ui-conversation/locale/ru.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "структура диалогового интерфейса", + "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..897347729f 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, es, fr, de, ru, pt, 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' { @@ -158,7 +158,7 @@ export function apply(ctx: Context, config: Config = Config({})): void { const slots = ctx.slots // Schemastery's field default is materialized before Cordis calls apply. const maxConcurrentFileUploads = config.maxConcurrentFileUploads as number - const workspaceNavigation = ctx.get('uiWorkspace') as unknown as WorkspaceNavigation + const workspaceNavigation = ctx.get('uiWorkspace') as WorkspaceNavigation const uiConversation = new UiConversation(ctx, sessions) uiConversation.userText.register(sessionReferenceRule) uiConversation.userText.register(collapseWhitespaceRule) @@ -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, es, fr, de, ru, pt }), '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..3bf918336e 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' } } @@ -430,13 +448,15 @@ export interface ConversationViewActionEntry { readonly id: string /** Resolved View label. */ readonly label: string - /** False while the tab is unpinned from the header. */ + /** False while the View is manually unpinned. A pinned View still enters More when the header's width budget cannot hold it. */ readonly pinned: boolean + /** Whether this View is selected in this Session, independently of its pin state. */ + readonly active?: boolean } /** Owner share of the View tab row's More menu rows. */ export interface ConversationViewActionsOwnerProps { - /** Available Views in registration order; Chat is never listed. */ + /** Views outside the header in registration order. */ readonly entries: readonly ConversationViewActionEntry[] /** * Select a View in this Session without changing its pin state. @@ -444,9 +464,9 @@ export interface ConversationViewActionsOwnerProps { */ readonly onOpen: (id: string) => void /** - * Show (true) or unpin (false) a View tab in every Session. + * Pin or unpin a View in every Session. * @param id - Listed View id. - * @param pinned - Whether the tab shows in the header. + * @param pinned - Whether the View is manually pinned; the width budget can still fold it into More. */ readonly onPinnedChange: (id: string, pinned: boolean) => void /** Close the More menu. */ @@ -458,15 +478,15 @@ export interface ConversationSessionHeaderInjected { /** Package-owned View roster source bound only for the Conversation header. */ readonly hooks: { readonly conversationViews: ObservableSnapshot - /** Ids of unpinned View tabs, persisted across reloads and shared by every Session. */ + /** Ids of unpinned View tabs held in memory and shared by every Session until plugin reload. */ readonly entryPins: ObservableSnapshot /** Whether `conversation.session.header.views.actions` has registrations. */ readonly entryActions: ObservableSnapshot } /** - * Show or unpin one View tab in every Session; Chat is ignored. + * Pin or unpin one View tab in every Session; Chat is ignored. * @param id - View id. - * @param pinned - Whether the tab shows in the header. + * @param pinned - Whether the View is manually pinned; the width budget can still fold it into More. */ setPinned: (id: string, pinned: boolean) => void /** Select a Session through the Session Controller. */ @@ -517,7 +537,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..f162943b6f 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 @@ -57,12 +62,15 @@ function $appendParagraphs(text: string): void { /** Undo merge window for contiguous typing, in ms (the old machine's mergeWindowMs). */ const HISTORY_MERGE_DELAY_MS = 1000 +/** Editor update that removes submitted content while retaining any newer draft suffix. */ +export const DRAFT_SUBMITTED_TAG = 'dsh-draft-submitted' + /** One model-owned editor; registration and disposal remain with its model. */ 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 +83,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 +103,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 +198,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 +344,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. @@ -349,15 +415,17 @@ export class DraftEditorRuntime { const root = $getRoot() root.clear() root.selectEnd() - }, { discrete: true, tag: HISTORY_MERGE_TAG }) + }, { discrete: true, tag: [HISTORY_MERGE_TAG, DRAFT_SUBMITTED_TAG] }) } /** * 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 +443,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..9018592b8b 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 @@ -1,11 +1,31 @@ /** DOM and keymap bindings installed by the InputBar's existing effects. */ import type { MouseEvent, MutableRefObject, RefObject } from 'react' import type { LexicalEditor } from 'lexical' +import { $getRoot } from 'lexical' +import { focusWithoutRing, isMobileDevice } from '@deepseek-ai/dsh-client-ui-primitives' 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' +import { DRAFT_SUBMITTED_TAG } from './runtime.ts' + +/** + * Transfer mobile editor focus after a submission empties the draft. + * A control the user already focused retains focus. + * @param editor - the Session-owned editor. + * @param destination - stable, focusable non-text element that retains the composer's keyboard position. + * @returns the update-listener cleanup. + */ +export function installDraftKeyboardDismissal(editor: LexicalEditor, destination: RefObject): () => void { + return editor.registerUpdateListener(({ editorState, tags }) => { + if (!tags.has(DRAFT_SUBMITTED_TAG) || !isMobileDevice()) return + const input = editor.getRootElement() + if (input === null || input.ownerDocument.activeElement !== input) return + if (editorState.read(() => $getRoot().getTextContentSize() === 0) && destination.current !== null) { + focusWithoutRing(destination.current, { preventScroll: true }) + } + }) +} interface DraftViewGate { locked: boolean @@ -16,8 +36,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 +147,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..45f11ed7f7 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': '打开命令菜单', @@ -79,6 +80,7 @@ export const zh = { 'hero.chooseWorkspace': '选择工作区', 'session.hierarchy': '会话层级', 'session.moreViews': '更多会话视图', + 'session.moreViewsCurrent': '更多会话视图 · {name}', 'todo.title': '任务', 'todo.progress.done': '{done} 已完成', 'todo.progress.active': '{active} 进行中', @@ -390,6 +392,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', @@ -460,6 +463,7 @@ export const en = { 'hero.chooseWorkspace': 'Choose workspace', 'session.hierarchy': 'Session hierarchy', 'session.moreViews': 'More conversation views', + 'session.moreViewsCurrent': 'More conversation views · {name}', 'todo.title': 'To-dos', 'todo.progress.done': '{done} completed', 'todo.progress.active': '{active} in progress', @@ -767,3 +771,2293 @@ 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': 'その他の会話ビュー', + 'session.moreViewsCurrent': 'その他の会話ビュー · {name}', + '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 + +/** Spanish dictionary, checked against the English keys. */ +export const es = { + 'quote.remove': 'Eliminar cotización', + 'shortcut.newline': 'Nueva linea', + 'shortcut.complementary': 'Usar la acción alternativa de poner en cola o redirigir', + 'shortcut.slash': 'Abrir menú de comandos', + 'shortcut.mention': 'Abrir menú de referencia', + 'hint.plan': PLAN_NEXT_ACTION_EN, + 'hint.goal': 'describir el objetivo de una tarea de larga duración', + 'hint.goal.active': 'Objetivo activo: editar/pausar/reanudar/borrar', + 'placeholder.plan': PLAN_NEXT_ACTION_EN, + 'placeholder.default': 'Envía mensajes o ejecuta una tarea, / comandos, @ archivos o sesiones', + 'placeholder.unavailable': 'Sesión no disponible', + 'placeholder.parentOffline': 'Sesión para padres sin conexión; El envío no está disponible pero aún puedes detener la ejecución.', + 'placeholder.hero': 'Describe lo que quieres construir, / comandos, @ archivos o sesiones', + 'placeholder.workspace': 'Elija un espacio de trabajo para comenzar', + 'placeholder.steerQueue': 'Cmd/Ctrl+Enter dirige todos los mensajes en cola', + 'input.commands': 'Agregar archivos o ejecutar comandos', + 'input.file': 'Archivo', + 'input.stop': 'Detener generación', + 'input.send': 'Enviar mensaje', + 'input.send.queue': 'mensaje de cola', + 'input.send.steer': 'Redirigir mensaje', + 'attachment.pending': 'Anexos pendientes', + 'attachment.scrollLeft': 'Desplazar archivos adjuntos hacia la izquierda', + 'attachment.scrollRight': 'Desplazar archivos adjuntos hacia la derecha', + 'attachment.dropTitle': 'Arrastre archivos o imágenes aquí para agregarlos', + 'attachment.dropDesc': 'Límite de imágenes: hasta imágenes {count}, {size} cada una', + 'attachment.dropBlocked': 'No se pueden agregar archivos e imágenes en este momento', + 'attachment.directoryDesktopOnly': 'Las carpetas sólo se pueden agregar en la aplicación de escritorio; agregar archivos individuales en el navegador', + 'attachment.pathUnavailable': 'No se pudo obtener la ruta de la carpeta; arrástralo de nuevo', + 'attachment.pathUnsupported': 'La ruta contiene caracteres que una referencia no puede contener; cámbiale el nombre y vuelve a intentarlo.', + 'image.pending': 'Imágenes pendientes', + 'image.openOriginal': 'Ver original', + 'image.openOriginalLabel': '{label}, haga clic para ver el original', + 'image.remove': 'Eliminar imagen {name}', + 'image.original': 'imagen original', + 'image.label': 'Imagen', + 'image.loadFailed': 'La imagen no se pudo cargar; haga clic para volver a intentar', + 'image.loading': 'Cargando imagen…', + 'image.preview': 'Vista previa de la imagen original', + 'image.closePreview': 'Cerrar vista previa de la imagen original', + 'image.unsupportedType': 'Solo se admiten imágenes PNG, JPG, WebP y GIF', + 'image.tooMany': 'Un mensaje puede incluir hasta imágenes {count}.', + 'image.fileTooLarge': 'Cada imagen debe ser más pequeña que {size}.', + 'image.totalTooLarge': 'Las imágenes superan {size} en total; elimina algunos y vuelve a intentarlo', + 'image.tooManyPixels': 'La resolución de la imagen es demasiado alta; comprímelo y vuelve a intentarlo.', + 'image.dimensionTooLarge': 'Los lados de la imagen deben ser como máximo {size}px; reduce la escala y vuelve a intentarlo', + 'image.modelUnsupported': 'El modelo actual no admite imágenes; cambiar a un modelo que lo haga', + 'image.sendFailed': 'Error al enviar imágenes ({reason}); Vuelve a agregarlos y vuelve a intentarlo.', + 'file.pending': 'Archivos pendientes', + 'file.remove': 'Eliminar archivo {name}', + 'file.uploading': 'Subiendo…', + 'file.uploadFailed': 'La carga falló; haga clic para volver a intentar', + 'file.retry': 'Reintente cargar {name}', + 'file.stillUploading': 'Los archivos todavía se están cargando; enviar después de que terminen', + 'file.sessionUnavailable': 'Sesión no disponible; los archivos no se pueden cargar', + 'file.notStaged': 'El archivo no ha terminado de cargarse; vuelve a agregarlo y vuelve a intentarlo', + 'file.label': 'Archivo', + 'context.aria': '{percent} de contexto utilizado', + 'context.used': 'de contexto utilizado', + 'context.system': 'Aviso del sistema', + 'context.tools': 'Definiciones de herramientas', + 'context.messages': 'Mensajes', + 'settings.enter.title': 'Enviar comportamiento mientras está ocupado', + 'settings.enter.description': 'Qué hacen los botones Intro y Enviar mientras el agente se está ejecutando; Cmd/Ctrl+Enter usa el otro comportamiento', + 'settings.enter.queue': 'cola', + 'settings.enter.steer': 'Redirigir', + 'hero.headline': 'Hacia lo desconocido', + 'hero.preview': 'Vista previa', + 'hero.chooseWorkspace': 'Elige espacio de trabajo', + 'session.hierarchy': 'Jerarquía de sesiones', + 'session.moreViews': 'Más vistas de conversación', + 'session.moreViewsCurrent': 'Más vistas de conversación · {name}', + 'todo.title': 'Tareas pendientes', + 'todo.progress.done': '{done} completado', + 'todo.progress.active': '{active} en progreso', + 'todo.progress.pending': '{pending} pendiente', + 'todo.status.completed': 'Completado', + 'todo.status.inProgress': 'En progreso', + 'todo.status.pending': 'Pendiente', + 'todo.rowTitle': 'Actualizar lista de tareas pendientes', + 'tool.title.createGoal': 'Crear objetivo', + 'tool.title.getGoal': 'Ver objetivo', + 'tool.title.updateGoal': 'Actualizar objetivo', + 'tool.preparing.content': '{kilobytes}KB', + 'tool.title.createSchedule': 'Crear recordatorio', + 'tool.title.listSchedules': 'Recordatorios de lista', + 'tool.title.deleteSchedule': 'Eliminar recordatorio', + 'tool.title.updateSchedule': 'Recordatorio de actualización', + 'detail.state': 'Estado', + 'detail.todo.completed': 'Completado', + 'detail.todo.in_progress': 'En progreso', + 'detail.todo.pending': 'Pendiente', + 'detail.todo.empty': 'La lista de tareas pendientes está vacía', + 'todo.diff.initial': 'lista inicial', + 'todo.diff.compare': 'Cambios desde la lista anterior', + 'todo.diff.unavailable': 'Lista anterior no disponible', + 'todo.diff.noChanges': 'No hay cambios en la lista.', + 'todo.diff.added': '{count} añadido', + 'todo.diff.updated': '{count} actualizado', + 'todo.diff.removed': '{count} eliminado', + 'todo.diff.unchanged': '{count} sin cambios', + 'todo.diff.addedItem': 'Añadido', + 'todo.diff.updatedItem': 'Estado cambiado', + 'todo.diff.movedItem': 'Reordenado', + 'todo.diff.removedItem': 'Eliminado', + 'detail.goal.empty': 'Sin gol', + 'detail.goal.active': 'Activo', + 'detail.goal.disarmed': 'En espera de continuación', + 'detail.goal.paused': 'En pausa', + 'detail.goal.blocked': 'Bloqueado', + 'detail.goal.complete': 'Completado', + 'detail.goal.rounds': 'Rondas', + 'detail.goal.reason': 'bloqueador', + 'detail.days': '{count} d', + 'detail.hours': '{count} h', + 'detail.minutes': '{count} mín.', + 'detail.seconds': '{count}', + 'detail.schedule.once': 'una vez', + 'detail.schedule.every': 'Cada {interval}', + 'detail.schedule.when': 'Programado para', + 'detail.schedule.frequency': 'repetir', + 'detail.schedule.scheduled': 'Programado', + 'detail.schedule.overdue': 'Atrasado, a la espera de reanudar la sesión.', + 'detail.schedule.empty': 'Sin recordatorios', + 'detail.schedule.deleted': 'Eliminado', + 'detail.schedule.count': 'Recordatorios de {count}', + 'detail.schedule.daily': 'Todos los días en {time} ({zone})', + 'detail.schedule.weekly': 'Semanalmente en {days} en {time} ({zone})', + 'detail.schedule.cron': 'Cron {expression} ({zone})', + 'detail.weekday.1': 'lun', + 'detail.weekday.2': 'mar', + 'detail.weekday.3': 'mié', + 'detail.weekday.4': 'jueves', + 'detail.weekday.5': 'viernes', + 'detail.weekday.6': 'sábado', + 'detail.weekday.7': 'sol', + 'detail.weekday.join': ', ', + 'tool.title.inspectProviders': 'Inspeccionar proveedores', + 'tool.title.queryRuntime': 'Tiempo de ejecución de consultas', + 'tool.title.inspectPlugins': 'Inspeccionar complementos', + 'tool.title.workflow': 'Ejecutar flujo de trabajo', + 'tool.title.ralph': 'Ejecutar bucle ralph', + 'tool.title.readEvent': 'Leer evento', + 'tool.title.searchEvents': 'Buscar eventos', + 'tool.title.traceEvent': 'Evento de seguimiento', + 'tool.title.searchSessions': 'Buscar sesiones', + 'tool.title.traceSession': 'Sesión de seguimiento', + 'tool.title.listModels': 'Listar modelos', + 'tool.title.subagent': 'Crear subagente', + 'tool.title.listAgents': 'Listar subagentes', + 'tool.title.sendMessage': 'Enviar mensaje', + 'tool.title.interruptAgent': 'agente de interrupción', + 'tool.title.listJobs': 'Listar trabajos en segundo plano', + 'tool.title.readJob': 'Leer la salida del trabajo', + 'tool.title.killJob': 'Cancelar trabajo en segundo plano', + 'tool.title.openTerminal': 'terminal abierta', + 'tool.title.readTerminal': 'Leer terminal', + 'tool.title.listTerminals': 'Lista de terminales', + 'tool.title.signalTerminal': 'terminal de señal', + 'tool.title.closeTerminal': 'Cerrar terminal', + 'tool.title.lsp': 'Símbolos de código de consulta', + 'tool.title.findDefinition': 'encontrar definición', + 'tool.title.findReferences': 'encontrar referencias', + 'tool.title.findImplementation': 'Encontrar implementación', + 'tool.title.hoverSymbol': 'Símbolo de inspección', + 'tool.title.spawnTeammate': 'Crear compañero de equipo', + 'tool.title.createTeamTask': 'Crear tarea de equipo', + 'tool.title.getTeamTask': 'Leer tarea de equipo', + 'tool.title.updateTeamTask': 'Actualizar tarea de equipo', + 'tool.title.listTeamTasks': 'Listar tareas del equipo', + 'tool.title.waitAgent': 'Espere al subagente', + 'detail.recordedResult': 'Resultado registrado', + 'detail.empty': 'Sin resultados', + 'detail.none': 'Ninguno', + 'detail.yes': 'si', + 'detail.no': 'No', + 'detail.moreInInspect': '{count} más artículos disponibles en Inspeccionar', + 'detail.status.running': 'corriendo', + 'detail.status.idle': 'inactivo', + 'detail.status.ready': 'Listo', + 'detail.status.inactive': 'Inactivo', + 'detail.status.provisioning': 'Aprovisionamiento', + 'detail.status.failed': 'Fallido', + 'detail.status.completed': 'Completado', + 'detail.status.deleted': 'Eliminado', + 'detail.status.killed': 'Cancelado', + 'detail.status.accepted': 'Aceptado', + 'detail.status.queued': 'En cola', + 'detail.status.exited': 'Salido', + 'detail.field.id': 'ID', + 'detail.field.revision': 'Revisión', + 'detail.field.platform': 'Plataforma', + 'detail.field.provider': 'Proveedor', + 'detail.field.model': 'modelo', + 'detail.field.role': 'Rol', + 'detail.field.context': 'Contexto', + 'detail.field.owner': 'propietario', + 'detail.field.ready': 'Listo', + 'detail.field.dependencies': 'Dependencias', + 'detail.field.writeScopes': 'Escribir alcances', + 'detail.field.warnings': 'Advertencias', + 'detail.field.diagnostics': 'Diagnóstico', + 'detail.field.methods': 'Métodos', + 'detail.field.inputSchema': 'Esquema de entrada', + 'detail.field.outputSchema': 'Esquema de salida', + 'detail.field.currentPackage': 'Paquete actual', + 'detail.field.nextPackage': 'Siguiente paquete', + 'detail.field.latestRun': 'Última ejecución', + 'detail.field.packages': 'Paquetes', + 'detail.field.registrations': 'Inscripciones', + 'detail.field.props': 'Accesorios', + 'detail.field.data': 'Datos', + 'detail.field.source': 'Fuente', + 'detail.field.content': 'Contenido', + 'detail.field.message': 'Mensaje', + 'detail.field.messageId': 'ID de mensaje', + 'detail.field.root': 'raíz', + 'detail.field.pid': 'ID de proceso', + 'detail.field.type': 'Tipo', + 'detail.field.time': 'tiempo', + 'detail.field.seq': 'Secuencia de eventos', + 'detail.field.turn': 'Turno', + 'detail.field.step': 'paso', + 'detail.field.callId': 'ID de llamada', + 'detail.field.agents': 'Los agentes comenzaron', + 'detail.field.result': 'Resultado', + 'detail.field.parent': 'Padre', + 'detail.field.depth': 'Profundidad', + 'detail.field.exitCode': 'código de salida', + 'detail.field.signal': 'señal', + 'detail.field.previousStatus': 'Estado anterior', + 'detail.field.agent': 'ID del agente', + 'detail.field.job': 'ID de trabajo', + 'detail.field.task': 'Tarea', + 'detail.field.processGroup': 'grupo de procesos', + 'detail.field.availability': 'Disponibilidad', + 'detail.field.bestMatch': 'mejor partido', + 'detail.field.target': 'Evento objetivo', + 'detail.field.surface': 'Estado de registro', + 'detail.agents.count': 'Agentes {count}', + 'detail.jobs.count': 'Trabajos en segundo plano {count}', + 'detail.terminals.count': 'Terminales {count}', + 'detail.tasks.count': 'Tareas del equipo {count}', + 'detail.tasks.nextPage': 'Más tareas disponibles; el siguiente cursor es {cursor}', + 'detail.locations.count': 'Ubicaciones de {count}', + 'detail.location': 'Línea {line}, columna {column}', + 'detail.receipt.delivered': 'Mensaje entregado', + 'detail.receipt.interrupt': 'Interrupción solicitada', + 'detail.receipt.started': 'iniciado', + 'detail.receipt.cancel': 'Cancelación solicitada', + 'detail.receipt.alreadyFinished': 'ya terminé', + 'detail.receipt.signal': 'Señal entregada', + 'detail.receipt.closed': 'Cerrado', + 'detail.receipt.closing': 'Cierre', + 'detail.wait.noProgress': 'No hay subagentes activos', + 'detail.wait.title': 'Actividad del subagente', + 'detail.wait.timeout': 'Se agotó el tiempo de espera', + 'detail.wait.changed': 'Cambio detectado', + 'detail.agent.reply': 'Respuesta del agente', + 'detail.models.title': 'Modelos disponibles', + 'detail.output.lines': 'Líneas {begin}–{end} de {total}', + 'detail.output.truncated': 'Salida truncada', + 'detail.providers.count': '{count} inspeccionar proveedores', + 'detail.plugins.count': 'Complementos dinámicos {count}', + 'detail.workflow.script': 'Guión de flujo de trabajo', + 'detail.ralph.reportedComplete': 'finalización reportada por el trabajador', + 'detail.ralph.reportedBlocker': 'Trabajador reportó un bloqueador', + 'detail.ralph.limit': 'Límite de ronda alcanzado', + 'detail.report.nextSteps': 'Trabajo restante', + 'detail.trace.replacedBy': 'Reemplazado por', + 'detail.trace.replacementChain': 'Cadena de repuesto', + 'detail.trace.replaces': 'Eventos reemplazados', + 'detail.trace.sources': 'Eventos de origen', + 'detail.trace.derived': 'Eventos derivados', + 'detail.trace.ancestors': 'Sesiones de antepasados', + 'detail.trace.descendants': 'Sesiones de descendientes', + 'detail.matches.count': 'Partidos {count}', + 'detail.matches.capped': 'Límite de resultados alcanzado; acotar la búsqueda para más', + 'detail.event.neighbors': 'Eventos circundantes', + 'todo.completed': '{done}/{total} completado', + 'command.attachmentsUnsupported': '/{command} no acepta archivos adjuntos; eliminarlos primero', + 'ask.rowTitle': 'hacer pregunta', + 'ask.waiting': 'esperando', + 'ask.pending': 'continuó; respuesta aún disponible', + 'ask.pendingDetail': 'Estas cuestiones pendientes siguen siendo responsabilidad del compositor.', + 'ask.reopen': 'respuesta', + 'ask.review': 'Ver respuestas', + 'ask.closed': 'cerrado', + 'ask.closedDetail': 'Esta pregunta está cerrada; su resultado está en la conversación a continuación.', + 'ask.cancelled': 'cancelado', + 'ask.cancelledDetail': 'Este conjunto de preguntas se canceló antes de enviar las respuestas.', + 'ask.interrupted': 'interrumpido', + 'ask.interruptedDetail': 'Este conjunto de preguntas se interrumpió antes de que se enviaran las respuestas.', + 'ask.answered': '{answered}/{total} respondió', + 'ask.skipped': 'No respondido', + 'bash.running': 'corriendo', + 'bash.failed': 'Fallido', + 'bash.stopped': 'Detenido', + 'row.running': 'corriendo', + 'row.preparing': 'Preparando la llamada a la herramienta', + 'row.failed': 'Fallido', + 'row.stopped': 'Detenido', + 'row.commandArguments': 'Argumentos de comando', + 'row.input': 'IN', + 'row.output': 'OUT', + 'row.inspect': 'inspeccionar', + 'row.imageSize': '{width}x{height}px, {bytes} bytes', + 'row.imageSizeDownscaled': '{width}x{height}px (reducido de {originalWidth}x{originalHeight}px), {bytes} bytes', + 'tool.title.search': 'Buscar', + 'tool.title.read': 'leer', + 'tool.title.bash': 'golpe', + 'tool.title.write': 'escribir', + 'tool.title.edit': 'Editar', + 'tool.title.code': 'Código', + 'tool.title.generic': 'llamada de herramienta', + 'tool.title.inspect': 'Consulta entorno Cordis', + 'tool.title.runCordis': 'Ejecute el complemento Cordis', + 'tool.title.stopCordis': 'Detener el complemento Cordis', + 'tool.title.removeCordis': 'Eliminar el complemento Cordis', + 'tool.title.pwsh': 'Pwsh', + 'tool.title.readImage': 'Leer imagen', + 'tool.title.grep': 'grep', + 'tool.title.glob': 'globo', + 'tool.title.webSearch': 'Buscar', + 'tool.title.webFetch': 'buscar', + 'tool.autoReviewRejected': 'Rechazado por Auto review', + 'tool.autoReviewNotExecuted': 'La herramienta no fue ejecutada. Razón: {reason}', + 'tool.autoReviewReasonFallback': 'Auto review no autorizó esta acción', + 'diff.collapseAria': 'Contraer diferencia', + 'diff.expandAria': 'Expanda {count} más líneas diferenciales', + 'diff.expandRest': '… {count} más líneas', + 'read.window': 'Mostrando {shown} de líneas {total}', + 'read.collapseAria': 'Contraer contenido', + 'read.expandAria': 'Ampliar {count} más líneas', + 'read.expandRest': '… {count} más líneas', + 'search.paths': 'Rutas {shown}', + 'search.paths.truncated': 'Mostrando {shown} de las rutas {total}', + 'search.matches': 'Coincidencias {shown} · Archivos {files}', + 'search.matches.truncated': 'Mostrando {shown} de coincidencias {total} · Archivos {files}', + 'search.noResults': 'Sin resultados', + 'search.collapseAria': 'Contraer resultados', + 'search.expandAria': 'Expandir {count} más líneas de resultados', + 'search.expandRest': '… {count} más líneas', + 'web.noResults': 'No se encontraron resultados', + 'web.sourcesTruncated': 'Lista de fuentes truncada', + 'web.http': 'HTTP', + 'web.contentTruncated': 'Contenido truncado', + 'details.running': 'Corriendo…', + 'queue.count': 'Mensajes en cola {n}', + 'queue.sending': 'Enviando…', + 'queue.image': 'Imagen de mensaje en cola', + 'queue.file': 'Archivo en cola {name}', + 'queue.edit': 'Editar mensaje en cola', + 'queue.edit.unsupported': 'Contiene contenido que no es texto; la edición aún no es compatible', + 'queue.save': 'Guardar mensaje en cola', + 'queue.cancelEdit': 'Cancelar edición', + 'queue.remove': 'Eliminar mensaje en cola', + 'queue.steer': 'Redirigir mensaje en cola', + 'queue.steer.unavailable': 'La dirección solo está disponible mientras el agente está en ejecución', + 'error.sessionInUse': 'Esta sesión ya está en uso, posiblemente por otra instancia de DSH en ejecución (como la web dsh o la aplicación de escritorio). Salga de otras instancias de DSH en ejecución y vuelva a intentarlo.', + 'queue.editFailed': 'Error de edición: es posible que este mensaje ya haya comenzado a enviarse.', + 'queue.removeFailed': 'Error al eliminar: es posible que este mensaje ya haya comenzado a enviarse.', + 'queue.steerFailed': 'La dirección falló. Intentar otra vez.', + 'terminal.signal': 'señal {signal}', + 'terminal.exitCode': 'código de salida {code}', + 'terminal.noExitCode': 'sin código de salida', + 'terminal.commandLine': 'Línea de comando {n}', + 'terminal.running': 'corriendo', + 'terminal.failed': 'Fallido', + 'terminal.done': 'hecho', + 'terminal.noOutput': 'Sin salida', + 'terminal.collapseAria': 'Contraer salida', + 'terminal.expandAria': 'Expanda las líneas de salida {n} restantes', + 'terminal.expandRest': '… {n} más líneas', + // The Host terminal_send presenter has no locale seat; keep its fallbacks + // aligned with these English values. + 'terminal.sendInput': '(enviar entrada)', + 'terminal.session': 'Terminal {sessionId}', +} satisfies Record + +/** French dictionary, checked against the English keys. */ +export const fr = { + 'quote.remove': 'Supprimer le devis', + 'shortcut.newline': 'Nouvelle ligne', + 'shortcut.complementary': 'Utiliser l’action alternative de mise en attente ou de guidage', + 'shortcut.slash': 'Ouvrir le menu des commandes', + 'shortcut.mention': 'Ouvrir le menu de référence', + 'hint.plan': PLAN_NEXT_ACTION_EN, + 'hint.goal': "décrire l'objectif d'une tâche de longue durée", + 'hint.goal.active': 'objectif actif — modifier/mettre en pause/reprendre/effacer', + 'placeholder.plan': PLAN_NEXT_ACTION_EN, + 'placeholder.default': 'Envoyez un message ou exécutez une tâche, des commandes /, des fichiers @ ou des sessions', + 'placeholder.unavailable': 'Séance indisponible', + 'placeholder.parentOffline': "Session parent hors ligne ; l'envoi n'est pas disponible mais vous pouvez toujours arrêter l'exécution", + 'placeholder.hero': 'Décrivez ce que vous souhaitez construire, / commandes, @ fichiers ou sessions', + 'placeholder.workspace': 'Choisissez un espace de travail pour commencer', + 'placeholder.steerQueue': "Cmd/Ctrl+Entrée dirige tous les messages en file d'attente", + 'input.commands': 'Ajouter des fichiers ou exécuter des commandes', + 'input.file': 'Fichier', + 'input.stop': 'Arrêter la génération', + 'input.send': 'Envoyer le message', + 'input.send.queue': "Message de file d'attente", + 'input.send.steer': 'Guider avec ce message', + 'attachment.pending': 'Pièces jointes en attente', + 'attachment.scrollLeft': 'Faire défiler les pièces jointes vers la gauche', + 'attachment.scrollRight': 'Faire défiler les pièces jointes vers la droite', + 'attachment.dropTitle': 'Faites glisser des fichiers ou des images ici pour les ajouter', + 'attachment.dropDesc': "Limite d'images : jusqu'à des images {count}, {size} chacune", + 'attachment.dropBlocked': 'Les fichiers et les images ne peuvent pas être ajoutés pour le moment', + 'attachment.directoryDesktopOnly': "Les dossiers ne peuvent être ajoutés que dans l'application de bureau ; ajouter des fichiers individuels dans le navigateur", + 'attachment.pathUnavailable': "Impossible d'obtenir le chemin du dossier ; fais-le glisser à nouveau", + 'attachment.pathUnsupported': "Le chemin contient des caractères qu'une référence ne peut pas transporter ; renommez-le et réessayez", + 'image.pending': 'Images en attente', + 'image.openOriginal': "Voir l'original", + 'image.openOriginalLabel': "{label}, cliquez pour voir l'original", + 'image.remove': "Supprimer l'image {name}", + 'image.original': 'Image originale', + 'image.label': 'Images', + 'image.loadFailed': "L'image n'a pas pu être chargée ; cliquez pour réessayer", + 'image.loading': "Chargement de l'image…", + 'image.preview': "Aperçu de l'image originale", + 'image.closePreview': "Fermer l'aperçu de l'image d'origine", + 'image.unsupportedType': 'Seules les images PNG, JPG, WebP et GIF sont prises en charge', + 'image.tooMany': "Un message peut inclure jusqu'à des images {count}", + 'image.fileTooLarge': 'Chaque image doit être plus petite que {size}', + 'image.totalTooLarge': 'Les images dépassent {size} au total ; enlève-en un peu et réessaye', + 'image.tooManyPixels': "La résolution de l'image est trop élevée ; compressez-le et réessayez", + 'image.dimensionTooLarge': "Les côtés de l'image doivent être au maximum {size}px ; réduisez-le et réessayez", + 'image.modelUnsupported': 'Le modèle actuel ne prend pas en charge les images ; passer à un modèle qui le fait', + 'image.sendFailed': "L'envoi des images a échoué ({reason}) ; rajoutez-les et réessayez", + 'file.pending': 'Fichiers en attente', + 'file.remove': 'Supprimer le fichier {name}', + 'file.uploading': 'Téléchargement…', + 'file.uploadFailed': 'Échec du téléchargement ; cliquez pour réessayer', + 'file.retry': 'Réessayez de télécharger {name}', + 'file.stillUploading': 'Les fichiers sont toujours en cours de téléchargement ; envoyer après avoir fini', + 'file.sessionUnavailable': 'Séance indisponible ; les fichiers ne peuvent pas être téléchargés', + 'file.notStaged': "Le téléchargement du fichier n'est pas terminé ; rajoutez-le et réessayez", + 'file.label': 'Fichier', + 'context.aria': '{percent} de contexte utilisé', + 'context.used': 'du contexte utilisé', + 'context.system': 'Invite système', + 'context.tools': "Définitions d'outils", + 'context.messages': 'Messages', + 'settings.enter.title': "Comportement d'envoi lorsque vous êtes occupé", + 'settings.enter.description': "Ce que font Entrée et le bouton Envoyer pendant l'exécution de l'agent ; Cmd/Ctrl+Entrée utilise l'autre comportement", + 'settings.enter.queue': "File d'attente", + 'settings.enter.steer': 'Guider', + 'hero.headline': "Dans l'inconnu", + 'hero.preview': 'Aperçu', + 'hero.chooseWorkspace': 'Choisir un espace de travail', + 'session.hierarchy': 'Hiérarchie des sessions', + 'session.moreViews': 'Plus de vues de conversations', + 'session.moreViewsCurrent': 'Plus de vues de conversations · {name}', + 'todo.title': 'À faire', + 'todo.progress.done': '{done} terminé', + 'todo.progress.active': '{active} en cours', + 'todo.progress.pending': '{pending} en attente', + 'todo.status.completed': 'Terminé', + 'todo.status.inProgress': 'En cours', + 'todo.status.pending': 'En attente', + 'todo.rowTitle': 'Mettre à jour la liste de tâches', + 'tool.title.createGoal': 'Créer un objectif', + 'tool.title.getGoal': "Afficher l'objectif", + 'tool.title.updateGoal': 'Objectif de mise à jour', + 'tool.preparing.content': '{kilobytes}KB', + 'tool.title.createSchedule': 'Créer un rappel', + 'tool.title.listSchedules': 'Liste des rappels', + 'tool.title.deleteSchedule': 'Supprimer le rappel', + 'tool.title.updateSchedule': 'Rappel de mise à jour', + 'detail.state': 'Statut', + 'detail.todo.completed': 'Terminé', + 'detail.todo.in_progress': 'En cours', + 'detail.todo.pending': 'En attente', + 'detail.todo.empty': 'La liste de tâches est vide', + 'todo.diff.initial': 'Liste initiale', + 'todo.diff.compare': 'Changements depuis la liste précédente', + 'todo.diff.unavailable': 'Liste précédente indisponible', + 'todo.diff.noChanges': 'Aucun changement dans la liste', + 'todo.diff.added': '{count} ajouté', + 'todo.diff.updated': '{count} mis à jour', + 'todo.diff.removed': '{count} supprimé', + 'todo.diff.unchanged': '{count} inchangé', + 'todo.diff.addedItem': 'Ajouté', + 'todo.diff.updatedItem': 'Statut modifié', + 'todo.diff.movedItem': 'Réorganisé', + 'todo.diff.removedItem': 'Supprimé', + 'detail.goal.empty': 'Aucun but', + 'detail.goal.active': 'Actif', + 'detail.goal.disarmed': 'En attente de suite', + 'detail.goal.paused': 'En pause', + 'detail.goal.blocked': 'Bloqué', + 'detail.goal.complete': 'Terminé', + 'detail.goal.rounds': 'Tours', + 'detail.goal.reason': 'Bloqueur', + 'detail.days': '{count}d', + 'detail.hours': '{count} h', + 'detail.minutes': '{count} min.', + 'detail.seconds': '{count}s', + 'detail.schedule.once': 'Une fois', + 'detail.schedule.every': 'Chaque {interval}', + 'detail.schedule.when': 'Prévu pour', + 'detail.schedule.frequency': 'Répéter', + 'detail.schedule.scheduled': 'Programmé', + 'detail.schedule.overdue': 'En retard, en attente de reprise de la session', + 'detail.schedule.empty': 'Aucun rappel', + 'detail.schedule.deleted': 'Supprimé', + 'detail.schedule.count': 'Rappels {count}', + 'detail.schedule.daily': 'Tous les jours à {time} ({zone})', + 'detail.schedule.weekly': 'Hebdomadaire sur {days} à {time} ({zone})', + 'detail.schedule.cron': 'Cron {expression} ({zone})', + 'detail.weekday.1': 'lundi', + 'detail.weekday.2': 'mar.', + 'detail.weekday.3': 'mer', + 'detail.weekday.4': 'jeu.', + 'detail.weekday.5': 'vendredi', + 'detail.weekday.6': 'Samedi', + 'detail.weekday.7': 'Soleil', + 'detail.weekday.join': ', ', + 'tool.title.inspectProviders': 'Inspecter les fournisseurs', + 'tool.title.queryRuntime': 'Exécution de la requête', + 'tool.title.inspectPlugins': 'Inspecter les plugins', + 'tool.title.workflow': 'Exécuter le flux de travail', + 'tool.title.ralph': 'Exécuter la boucle Ralph', + 'tool.title.readEvent': "Lire l'événement", + 'tool.title.searchEvents': 'Rechercher des événements', + 'tool.title.traceEvent': 'Événement de trace', + 'tool.title.searchSessions': 'Rechercher des sessions', + 'tool.title.traceSession': 'Session de traçage', + 'tool.title.listModels': 'Liste des modèles', + 'tool.title.subagent': 'Créer un sous-agent', + 'tool.title.listAgents': 'Liste des sous-agents', + 'tool.title.sendMessage': 'Envoyer le message', + 'tool.title.interruptAgent': "Agent d'interruption", + 'tool.title.listJobs': 'Répertorier les tâches en arrière-plan', + 'tool.title.readJob': 'Lire le résultat de la tâche', + 'tool.title.killJob': 'Annuler le travail en arrière-plan', + 'tool.title.openTerminal': 'Terminal ouvert', + 'tool.title.readTerminal': 'Lire le terminal', + 'tool.title.listTerminals': 'Liste des terminaux', + 'tool.title.signalTerminal': 'Borne de signal', + 'tool.title.closeTerminal': 'Fermer le terminal', + 'tool.title.lsp': 'Symboles du code de requête', + 'tool.title.findDefinition': 'Trouver une définition', + 'tool.title.findReferences': 'Trouver des références', + 'tool.title.findImplementation': 'Trouver la mise en œuvre', + 'tool.title.hoverSymbol': 'Inspecter le symbole', + 'tool.title.spawnTeammate': 'Créer un coéquipier', + 'tool.title.createTeamTask': "Créer une tâche d'équipe", + 'tool.title.getTeamTask': "Lire la tâche d'équipe", + 'tool.title.updateTeamTask': "Mettre à jour la tâche de l'équipe", + 'tool.title.listTeamTasks': "Répertorier les tâches de l'équipe", + 'tool.title.waitAgent': 'Attendez le sous-agent', + 'detail.recordedResult': 'Résultat enregistré', + 'detail.empty': 'Aucun résultat', + 'detail.none': 'Aucun', + 'detail.yes': 'Oui', + 'detail.no': 'Non', + 'detail.moreInInspect': "{count} plus d'articles disponibles dans Inspecter", + 'detail.status.running': 'Courir', + 'detail.status.idle': 'Inactif', + 'detail.status.ready': 'Prêt', + 'detail.status.inactive': 'Inactif', + 'detail.status.provisioning': 'Approvisionnement', + 'detail.status.failed': 'Échec', + 'detail.status.completed': 'Terminé', + 'detail.status.deleted': 'Supprimé', + 'detail.status.killed': 'Annulé', + 'detail.status.accepted': 'Accepté', + 'detail.status.queued': "En file d'attente", + 'detail.status.exited': 'Quitté', + 'detail.field.id': 'ID', + 'detail.field.revision': 'Révision', + 'detail.field.platform': 'Plateforme', + 'detail.field.provider': 'Fournisseur', + 'detail.field.model': 'Modèle', + 'detail.field.role': 'Rôle', + 'detail.field.context': 'Contexte', + 'detail.field.owner': 'Propriétaire', + 'detail.field.ready': 'Prêt', + 'detail.field.dependencies': 'Dépendances', + 'detail.field.writeScopes': 'Écrire des étendues', + 'detail.field.warnings': 'Avertissements', + 'detail.field.diagnostics': 'Diagnostic', + 'detail.field.methods': 'Méthodes', + 'detail.field.inputSchema': "Schéma d'entrée", + 'detail.field.outputSchema': 'Schéma de sortie', + 'detail.field.currentPackage': 'Forfait actuel', + 'detail.field.nextPackage': 'Forfait suivant', + 'detail.field.latestRun': 'Dernière course', + 'detail.field.packages': 'Forfaits', + 'detail.field.registrations': 'Inscriptions', + 'detail.field.props': 'Accessoires', + 'detail.field.data': 'Données', + 'detail.field.source': 'Source', + 'detail.field.content': 'Contenu', + 'detail.field.message': 'Message', + 'detail.field.messageId': 'Identifiant du message', + 'detail.field.root': 'Racine', + 'detail.field.pid': 'ID de processus', + 'detail.field.type': 'Tapez', + 'detail.field.time': 'Temps', + 'detail.field.seq': "Séquence d'événements", + 'detail.field.turn': 'Tour', + 'detail.field.step': 'Étape', + 'detail.field.callId': "Identifiant d'appel", + 'detail.field.agents': 'Les agents ont commencé', + 'detail.field.result': 'Résultat', + 'detail.field.parent': 'Parent', + 'detail.field.depth': 'Profondeur', + 'detail.field.exitCode': 'Code de sortie', + 'detail.field.signal': 'Signal', + 'detail.field.previousStatus': 'Statut précédent', + 'detail.field.agent': "ID d'agent", + 'detail.field.job': 'ID de travail', + 'detail.field.task': 'Tâche', + 'detail.field.processGroup': 'Groupe de processus', + 'detail.field.availability': 'Disponibilité', + 'detail.field.bestMatch': 'Meilleure correspondance', + 'detail.field.target': 'Événement cible', + 'detail.field.surface': "Statut d'enregistrement", + 'detail.agents.count': 'Agents {count}', + 'detail.jobs.count': "Travaux d'arrière-plan {count}", + 'detail.terminals.count': 'Bornes {count}', + 'detail.tasks.count': "Tâches de l'équipe {count}", + 'detail.tasks.nextPage': 'Plus de tâches disponibles ; le curseur suivant est {cursor}', + 'detail.locations.count': 'Localisations {count}', + 'detail.location': 'Ligne {line}, colonne {column}', + 'detail.receipt.delivered': 'Message délivré', + 'detail.receipt.interrupt': 'Interruption demandée', + 'detail.receipt.started': 'Commencé', + 'detail.receipt.cancel': 'Annulation demandée', + 'detail.receipt.alreadyFinished': 'Déjà fini', + 'detail.receipt.signal': 'Signal délivré', + 'detail.receipt.closed': 'Fermé', + 'detail.receipt.closing': 'Clôture', + 'detail.wait.noProgress': 'Aucun sous-agent actif', + 'detail.wait.title': 'Activité du sous-agent', + 'detail.wait.timeout': "Le délai d'attente a expiré", + 'detail.wait.changed': 'Changement détecté', + 'detail.agent.reply': "Réponse de l'agent", + 'detail.models.title': 'Modèles disponibles', + 'detail.output.lines': 'Lignes {begin}–{end} de {total}', + 'detail.output.truncated': 'Sortie tronquée', + 'detail.providers.count': '{count} inspecte les fournisseurs', + 'detail.plugins.count': 'Plugins dynamiques {count}', + 'detail.workflow.script': 'Script de flux de travail', + 'detail.ralph.reportedComplete': "Le travailleur a signalé l'achèvement", + 'detail.ralph.reportedBlocker': 'Le travailleur a signalé un bloqueur', + 'detail.ralph.limit': 'Limite de tours atteinte', + 'detail.report.nextSteps': 'Travail restant', + 'detail.trace.replacedBy': 'Remplacé par', + 'detail.trace.replacementChain': 'Chaîne de remplacement', + 'detail.trace.replaces': 'Événements remplacés', + 'detail.trace.sources': 'Événements sources', + 'detail.trace.derived': 'Événements dérivés', + 'detail.trace.ancestors': "Séances d'ancêtres", + 'detail.trace.descendants': 'Séances descendantes', + 'detail.matches.count': 'Correspondances {count}', + 'detail.matches.capped': 'Limite de résultat atteinte ; affiner la recherche pour en savoir plus', + 'detail.event.neighbors': 'Événements environnants', + 'todo.completed': '{done}/{total} terminé', + 'command.attachmentsUnsupported': "/{command} n'accepte pas les pièces jointes ; retirez-les d'abord", + 'ask.rowTitle': 'Poser une question', + 'ask.waiting': 'en attendant', + 'ask.pending': 'suite ; réponse toujours disponible', + 'ask.pendingDetail': 'Ces questions en suspens restent à la charge du compositeur.', + 'ask.reopen': 'Réponse', + 'ask.review': 'Afficher les réponses', + 'ask.closed': 'fermé', + 'ask.closedDetail': 'Cette question est fermée ; son résultat est dans la conversation ci-dessous.', + 'ask.cancelled': 'annulé', + 'ask.cancelledDetail': 'Cet ensemble de questions a été annulé avant que les réponses ne soient soumises.', + 'ask.interrupted': 'interrompu', + 'ask.interruptedDetail': 'Cette série de questions a été interrompue avant que les réponses ne soient soumises.', + 'ask.answered': '{answered}/{total} répondu', + 'ask.skipped': 'Pas de réponse', + 'bash.running': 'Courir', + 'bash.failed': 'Échec', + 'bash.stopped': 'Arrêté', + 'row.running': 'Courir', + 'row.preparing': "Préparation de l'appel à l'outil", + 'row.failed': 'Échec', + 'row.stopped': 'Arrêté', + 'row.commandArguments': 'Arguments de commande', + 'row.input': 'IN', + 'row.output': 'OUT', + 'row.inspect': 'Inspecter', + 'row.imageSize': '{width}x{height}px, {bytes} octets', + 'row.imageSizeDownscaled': '{width}x{height}px (réduction de {originalWidth}x{originalHeight}px), {bytes} octets', + 'tool.title.search': 'Rechercher', + 'tool.title.read': 'Lire', + 'tool.title.bash': 'Frapper', + 'tool.title.write': 'Écrire', + 'tool.title.edit': 'Modifier', + 'tool.title.code': 'Coder', + 'tool.title.generic': "Appel d'outil", + 'tool.title.inspect': "Interroger l'environnement Cordis", + 'tool.title.runCordis': 'Exécuter le plugin Cordis', + 'tool.title.stopCordis': 'Arrêter le plug-in Cordis', + 'tool.title.removeCordis': 'Supprimer le plug-in Cordis', + 'tool.title.pwsh': 'Pwsh', + 'tool.title.readImage': "Lire l'image", + 'tool.title.grep': 'Grep', + 'tool.title.glob': 'Glob', + 'tool.title.webSearch': 'Rechercher', + 'tool.title.webFetch': 'Récupérer', + 'tool.autoReviewRejected': 'Rejeté par Auto review', + 'tool.autoReviewNotExecuted': "L'outil n'a pas été exécuté. Raison : {reason}", + 'tool.autoReviewReasonFallback': "Auto review n'a pas autorisé cette action", + 'diff.collapseAria': 'Réduire la différence', + 'diff.expandAria': 'Développez {count} plus de lignes de différence', + 'diff.expandRest': '… {count} plus de lignes', + 'read.window': 'Affichage des lignes {shown} des lignes {total}', + 'read.collapseAria': 'Réduire le contenu', + 'read.expandAria': 'Développez {count} plus de lignes', + 'read.expandRest': '… {count} plus de lignes', + 'search.paths': 'Chemins {shown}', + 'search.paths.truncated': 'Affichage des chemins {shown} sur {total}', + 'search.matches': 'Correspondances {shown} · Fichiers {files}', + 'search.matches.truncated': 'Affichage des correspondances {shown} des correspondances {total} · Fichiers {files}', + 'search.noResults': 'Aucun résultat', + 'search.collapseAria': 'Réduire les résultats', + 'search.expandAria': 'Développez {count} plus de lignes de résultats', + 'search.expandRest': '… {count} plus de lignes', + 'web.noResults': 'Aucun résultat trouvé', + 'web.sourcesTruncated': 'Liste source tronquée', + 'web.http': 'HTTP', + 'web.contentTruncated': 'Contenu tronqué', + 'details.running': 'Courir…', + 'queue.count': "Messages en file d'attente {n}", + 'queue.sending': 'Envoi…', + 'queue.image': "Image du message en file d'attente", + 'queue.file': "Fichier en file d'attente {name}", + 'queue.edit': "Modifier le message en file d'attente", + 'queue.edit.unsupported': "Contient du contenu non textuel ; l'édition n'est pas encore prise en charge", + 'queue.save': "Enregistrer le message en file d'attente", + 'queue.cancelEdit': 'Annuler la modification', + 'queue.remove': "Supprimer le message en file d'attente", + 'queue.steer': 'Guider avec le message en attente', + 'queue.steer.unavailable': "Le pilotage est disponible uniquement lorsque l'agent est en cours d'exécution", + 'error.sessionInUse': "Cette session est déjà utilisée, éventuellement par une autre instance DSH en cours d'exécution (telle que le Web dsh ou l'application de bureau). Quittez les autres instances DSH en cours d'exécution et réessayez.", + 'queue.editFailed': "Échec de la modification : l'envoi de ce message a peut-être déjà commencé.", + 'queue.removeFailed': "Échec de la suppression : l'envoi de ce message a peut-être déjà commencé.", + 'queue.steerFailed': 'La direction a échoué. Essayer à nouveau.', + 'terminal.signal': 'signal {signal}', + 'terminal.exitCode': 'code de sortie {code}', + 'terminal.noExitCode': 'pas de code de sortie', + 'terminal.commandLine': 'Ligne de commande {n}', + 'terminal.running': 'Courir', + 'terminal.failed': 'Échec', + 'terminal.done': 'Terminé', + 'terminal.noOutput': 'Aucune sortie', + 'terminal.collapseAria': 'Réduire la sortie', + 'terminal.expandAria': 'Développez les lignes de sortie {n} restantes', + 'terminal.expandRest': '… {n} plus de lignes', + // The Host terminal_send presenter has no locale seat; keep its fallbacks + // aligned with these English values. + 'terminal.sendInput': '(envoyer une entrée)', + 'terminal.session': 'Borne {sessionId}', +} satisfies Record + +/** German dictionary, checked against the English keys. */ +export const de = { + 'quote.remove': 'Zitat entfernen', + 'shortcut.newline': 'Neue Zeile', + 'shortcut.complementary': 'Die alternative Aktion zum Vormerken oder Lenken verwenden', + 'shortcut.slash': 'Befehlsmenü öffnen', + 'shortcut.mention': 'Referenzmenü öffnen', + 'hint.plan': PLAN_NEXT_ACTION_EN, + 'hint.goal': 'Beschreiben Sie das Ziel einer lang andauernden Aufgabe', + 'hint.goal.active': 'Ziel aktiv – Bearbeiten / Anhalten / Fortsetzen / Löschen', + 'placeholder.plan': PLAN_NEXT_ACTION_EN, + 'placeholder.default': 'Senden Sie eine Nachricht oder führen Sie eine Aufgabe, /-Befehle, @-Dateien oder Sitzungen aus', + 'placeholder.unavailable': 'Sitzung nicht verfügbar', + 'placeholder.parentOffline': 'Elternsitzung offline; Das Senden ist nicht möglich, aber Sie können den Lauf trotzdem stoppen', + 'placeholder.hero': 'Beschreiben Sie, was Sie erstellen möchten: /-Befehle, @-Dateien oder Sitzungen', + 'placeholder.workspace': 'Wählen Sie einen Arbeitsbereich zum Starten aus', + 'placeholder.steerQueue': 'Cmd/Strg+Enter steuert alle Nachrichten in der Warteschlange', + 'input.commands': 'Fügen Sie Dateien hinzu oder führen Sie Befehle aus', + 'input.file': 'Datei', + 'input.stop': 'Generierung stoppen', + 'input.send': 'Nachricht senden', + 'input.send.queue': 'Warteschlangennachricht', + 'input.send.steer': 'Mit Nachricht lenken', + 'attachment.pending': 'Ausstehende Anhänge', + 'attachment.scrollLeft': 'Anhänge nach links scrollen', + 'attachment.scrollRight': 'Anhänge nach rechts scrollen', + 'attachment.dropTitle': 'Ziehen Sie Dateien oder Bilder hierher, um sie hinzuzufügen', + 'attachment.dropDesc': 'Bildbeschränkung: bis zu {count} Bilder, jeweils {size}', + 'attachment.dropBlocked': 'Dateien und Bilder können derzeit nicht hinzugefügt werden', + 'attachment.directoryDesktopOnly': 'Ordner können nur in der Desktop-App hinzugefügt werden; einzelne Dateien im Browser hinzufügen', + 'attachment.pathUnavailable': 'Der Ordnerpfad konnte nicht abgerufen werden; Ziehen Sie es erneut hinein', + 'attachment.pathUnsupported': 'Der Pfad enthält Zeichen, die eine Referenz nicht enthalten kann; Benennen Sie es um und versuchen Sie es erneut', + 'image.pending': 'Ausstehende Bilder', + 'image.openOriginal': 'Original ansehen', + 'image.openOriginalLabel': '{label}, klicken Sie, um das Original anzuzeigen', + 'image.remove': 'Bild {name} entfernen', + 'image.original': 'Originalbild', + 'image.label': 'Bild', + 'image.loadFailed': 'Bild konnte nicht geladen werden; Klicken Sie, um es noch einmal zu versuchen', + 'image.loading': 'Bild wird geladen…', + 'image.preview': 'Originalbildvorschau', + 'image.closePreview': 'Originalbildvorschau schließen', + 'image.unsupportedType': 'Es werden nur PNG-, JPG-, WebP- und GIF-Bilder unterstützt', + 'image.tooMany': 'Eine Nachricht kann bis zu {count} Bilder enthalten', + 'image.fileTooLarge': 'Jedes Bild muss kleiner als {size} sein', + 'image.totalTooLarge': 'Die Anzahl der Bilder übersteigt insgesamt {size}; Entfernen Sie einige davon und versuchen Sie es erneut', + 'image.tooManyPixels': 'Die Bildauflösung ist zu hoch; Komprimieren Sie es und versuchen Sie es erneut', + 'image.dimensionTooLarge': 'Bildseiten dürfen höchstens {size}px sein; Skalieren Sie es herunter und versuchen Sie es erneut', + 'image.modelUnsupported': 'Das aktuelle Modell unterstützt keine Bilder; Wechseln Sie zu einem Modell, das dies tut', + 'image.sendFailed': 'Das Senden von Bildern ist fehlgeschlagen ({reason}); Fügen Sie sie erneut hinzu und versuchen Sie es erneut', + 'file.pending': 'Ausstehende Dateien', + 'file.remove': 'Datei {name} entfernen', + 'file.uploading': 'Hochladen…', + 'file.uploadFailed': 'Hochladen fehlgeschlagen; Klicken Sie, um es noch einmal zu versuchen', + 'file.retry': 'Versuchen Sie erneut, {name} hochzuladen', + 'file.stillUploading': 'Dateien werden noch hochgeladen; senden, nachdem sie fertig sind', + 'file.sessionUnavailable': 'Sitzung nicht verfügbar; Dateien können nicht hochgeladen werden', + 'file.notStaged': 'Der Upload der Datei wurde noch nicht abgeschlossen. Fügen Sie es erneut hinzu und versuchen Sie es erneut', + 'file.label': 'Datei', + 'context.aria': '{percent} des verwendeten Kontexts', + 'context.used': 'des verwendeten Kontexts', + 'context.system': 'Systemaufforderung', + 'context.tools': 'Werkzeugdefinitionen', + 'context.messages': 'Nachrichten', + 'settings.enter.title': 'Sendeverhalten, während beschäftigt ist', + 'settings.enter.description': 'Was die Eingabetaste und die Schaltfläche „Senden“ bewirken, während der Agent ausgeführt wird; Cmd/Strg+Enter verwendet das andere Verhalten', + 'settings.enter.queue': 'Warteschlange', + 'settings.enter.steer': 'Lenken', + 'hero.headline': 'Ins Unbekannte', + 'hero.preview': 'Vorschau', + 'hero.chooseWorkspace': 'Wählen Sie einen Arbeitsbereich', + 'session.hierarchy': 'Sitzungshierarchie', + 'session.moreViews': 'Weitere Konversationsansichten', + 'session.moreViewsCurrent': 'Weitere Konversationsansichten · {name}', + 'todo.title': 'Aufgaben', + 'todo.progress.done': '{done} abgeschlossen', + 'todo.progress.active': '{active} in Bearbeitung', + 'todo.progress.pending': '{pending} ausstehend', + 'todo.status.completed': 'Abgeschlossen', + 'todo.status.inProgress': 'In Bearbeitung', + 'todo.status.pending': 'Ausstehend', + 'todo.rowTitle': 'To-Do-Liste aktualisieren', + 'tool.title.createGoal': 'Ziel erstellen', + 'tool.title.getGoal': 'Ziel ansehen', + 'tool.title.updateGoal': 'Ziel aktualisieren', + 'tool.preparing.content': '{kilobytes}KB', + 'tool.title.createSchedule': 'Erinnerung erstellen', + 'tool.title.listSchedules': 'Listen Sie Erinnerungen auf', + 'tool.title.deleteSchedule': 'Erinnerung löschen', + 'tool.title.updateSchedule': 'Update-Erinnerung', + 'detail.state': 'Status', + 'detail.todo.completed': 'Abgeschlossen', + 'detail.todo.in_progress': 'In Bearbeitung', + 'detail.todo.pending': 'Ausstehend', + 'detail.todo.empty': 'Die To-Do-Liste ist leer', + 'todo.diff.initial': 'Erste Liste', + 'todo.diff.compare': 'Änderungen gegenüber der vorherigen Liste', + 'todo.diff.unavailable': 'Vorherige Liste nicht verfügbar', + 'todo.diff.noChanges': 'Keine Änderungen an der Liste', + 'todo.diff.added': '{count} hinzugefügt', + 'todo.diff.updated': '{count} aktualisiert', + 'todo.diff.removed': '{count} entfernt', + 'todo.diff.unchanged': '{count} unverändert', + 'todo.diff.addedItem': 'Hinzugefügt', + 'todo.diff.updatedItem': 'Status geändert', + 'todo.diff.movedItem': 'Nachbestellt', + 'todo.diff.removedItem': 'Entfernt', + 'detail.goal.empty': 'Kein Ziel', + 'detail.goal.active': 'Aktiv', + 'detail.goal.disarmed': 'Warten auf Fortsetzung', + 'detail.goal.paused': 'Angehalten', + 'detail.goal.blocked': 'Blockiert', + 'detail.goal.complete': 'Abgeschlossen', + 'detail.goal.rounds': 'Runden', + 'detail.goal.reason': 'Blocker', + 'detail.days': '{count} d', + 'detail.hours': '{count} h', + 'detail.minutes': '{count} min', + 'detail.seconds': '{count} s', + 'detail.schedule.once': 'Einmal', + 'detail.schedule.every': 'Jeder {interval}', + 'detail.schedule.when': 'Geplant für', + 'detail.schedule.frequency': 'Wiederholen', + 'detail.schedule.scheduled': 'Geplant', + 'detail.schedule.overdue': 'Überfällig, ich warte auf die Fortsetzung der Sitzung', + 'detail.schedule.empty': 'Keine Erinnerungen', + 'detail.schedule.deleted': 'Gelöscht', + 'detail.schedule.count': '{count} Erinnerungen', + 'detail.schedule.daily': 'Täglich um {time} ({zone})', + 'detail.schedule.weekly': 'Wöchentlich auf {days} um {time} ({zone})', + 'detail.schedule.cron': 'Cron {expression} ({zone})', + 'detail.weekday.1': 'Mo', + 'detail.weekday.2': 'Di', + 'detail.weekday.3': 'Mi', + 'detail.weekday.4': 'Do', + 'detail.weekday.5': 'Fr', + 'detail.weekday.6': 'Sa', + 'detail.weekday.7': 'Sonne', + 'detail.weekday.join': ', ', + 'tool.title.inspectProviders': 'Anbieter prüfen', + 'tool.title.queryRuntime': 'Abfragelaufzeit', + 'tool.title.inspectPlugins': 'Plugins prüfen', + 'tool.title.workflow': 'Workflow ausführen', + 'tool.title.ralph': 'Führen Sie die Ralph-Schleife aus', + 'tool.title.readEvent': 'Veranstaltung lesen', + 'tool.title.searchEvents': 'Suchen Sie nach Ereignissen', + 'tool.title.traceEvent': 'Trace-Ereignis', + 'tool.title.searchSessions': 'Sitzungen suchen', + 'tool.title.traceSession': 'Trace-Sitzung', + 'tool.title.listModels': 'Modelle auflisten', + 'tool.title.subagent': 'Subagent erstellen', + 'tool.title.listAgents': 'Subagenten auflisten', + 'tool.title.sendMessage': 'Nachricht senden', + 'tool.title.interruptAgent': 'Interrupt-Agent', + 'tool.title.listJobs': 'Hintergrundjobs auflisten', + 'tool.title.readJob': 'Jobausgabe lesen', + 'tool.title.killJob': 'Hintergrundjob abbrechen', + 'tool.title.openTerminal': 'Terminal öffnen', + 'tool.title.readTerminal': 'Terminal lesen', + 'tool.title.listTerminals': 'Terminals auflisten', + 'tool.title.signalTerminal': 'Signalklemme', + 'tool.title.closeTerminal': 'Terminal schließen', + 'tool.title.lsp': 'Codesymbole abfragen', + 'tool.title.findDefinition': 'Definition finden', + 'tool.title.findReferences': 'Finden Sie Referenzen', + 'tool.title.findImplementation': 'Umsetzung finden', + 'tool.title.hoverSymbol': 'Symbol prüfen', + 'tool.title.spawnTeammate': 'Teamkollege erstellen', + 'tool.title.createTeamTask': 'Teamaufgabe erstellen', + 'tool.title.getTeamTask': 'Teamaufgabe lesen', + 'tool.title.updateTeamTask': 'Teamaufgabe aktualisieren', + 'tool.title.listTeamTasks': 'Teamaufgaben auflisten', + 'tool.title.waitAgent': 'Warten Sie auf den Subagenten', + 'detail.recordedResult': 'Aufgezeichnetes Ergebnis', + 'detail.empty': 'Keine Ergebnisse', + 'detail.none': 'Keine', + 'detail.yes': 'Ja', + 'detail.no': 'Nein', + 'detail.moreInInspect': '{count} weitere Artikel in Inspect verfügbar', + 'detail.status.running': 'Laufen', + 'detail.status.idle': 'Leerlauf', + 'detail.status.ready': 'Bereit', + 'detail.status.inactive': 'Inaktiv', + 'detail.status.provisioning': 'Bereitstellung', + 'detail.status.failed': 'Fehlgeschlagen', + 'detail.status.completed': 'Abgeschlossen', + 'detail.status.deleted': 'Gelöscht', + 'detail.status.killed': 'Abgesagt', + 'detail.status.accepted': 'Akzeptiert', + 'detail.status.queued': 'In der Warteschlange', + 'detail.status.exited': 'Ausgegangen', + 'detail.field.id': 'ID', + 'detail.field.revision': 'Überarbeitung', + 'detail.field.platform': 'Plattform', + 'detail.field.provider': 'Anbieter', + 'detail.field.model': 'Modell', + 'detail.field.role': 'Rolle', + 'detail.field.context': 'Kontext', + 'detail.field.owner': 'Besitzer', + 'detail.field.ready': 'Bereit', + 'detail.field.dependencies': 'Abhängigkeiten', + 'detail.field.writeScopes': 'Bereiche schreiben', + 'detail.field.warnings': 'Warnungen', + 'detail.field.diagnostics': 'Diagnose', + 'detail.field.methods': 'Methoden', + 'detail.field.inputSchema': 'Eingabeschema', + 'detail.field.outputSchema': 'Ausgabeschema', + 'detail.field.currentPackage': 'Aktuelles Paket', + 'detail.field.nextPackage': 'Nächstes Paket', + 'detail.field.latestRun': 'Letzter Lauf', + 'detail.field.packages': 'Pakete', + 'detail.field.registrations': 'Anmeldungen', + 'detail.field.props': 'Requisiten', + 'detail.field.data': 'Daten', + 'detail.field.source': 'Quelle', + 'detail.field.content': 'Inhalt', + 'detail.field.message': 'Nachricht', + 'detail.field.messageId': 'Nachrichten-ID', + 'detail.field.root': 'Wurzel', + 'detail.field.pid': 'Prozess-ID', + 'detail.field.type': 'Typ', + 'detail.field.time': 'Zeit', + 'detail.field.seq': 'Ereignissequenz', + 'detail.field.turn': 'Runde', + 'detail.field.step': 'Schritt', + 'detail.field.callId': 'Aufruf-ID', + 'detail.field.agents': 'Agenten begannen', + 'detail.field.result': 'Ergebnis', + 'detail.field.parent': 'Eltern', + 'detail.field.depth': 'Tiefe', + 'detail.field.exitCode': 'Exit-Code', + 'detail.field.signal': 'Signal', + 'detail.field.previousStatus': 'Vorheriger Stand', + 'detail.field.agent': 'Agenten-ID', + 'detail.field.job': 'Job-ID', + 'detail.field.task': 'Aufgabe', + 'detail.field.processGroup': 'Prozessgruppe', + 'detail.field.availability': 'Verfügbarkeit', + 'detail.field.bestMatch': 'Beste Übereinstimmung', + 'detail.field.target': 'Zielereignis', + 'detail.field.surface': 'Aufnahmestatus', + 'detail.agents.count': '{count}-Agenten', + 'detail.jobs.count': '{count} Hintergrundjobs', + 'detail.terminals.count': '{count}-Klemmen', + 'detail.tasks.count': '{count} Teamaufgaben', + 'detail.tasks.nextPage': 'Weitere Aufgaben verfügbar; Der nächste Cursor ist {cursor}', + 'detail.locations.count': '{count}-Standorte', + 'detail.location': 'Zeile {line}, Spalte {column}', + 'detail.receipt.delivered': 'Nachricht zugestellt', + 'detail.receipt.interrupt': 'Unterbrechung angefordert', + 'detail.receipt.started': 'Begonnen', + 'detail.receipt.cancel': 'Stornierung beantragt', + 'detail.receipt.alreadyFinished': 'Schon fertig', + 'detail.receipt.signal': 'Signal geliefert', + 'detail.receipt.closed': 'Geschlossen', + 'detail.receipt.closing': 'Schließung', + 'detail.wait.noProgress': 'Keine aktiven Subagenten', + 'detail.wait.title': 'Aktivität des Subagenten', + 'detail.wait.timeout': 'Wartezeit abgelaufen', + 'detail.wait.changed': 'Änderung erkannt', + 'detail.agent.reply': 'Antwort des Agenten', + 'detail.models.title': 'Verfügbare Modelle', + 'detail.output.lines': 'Linien {begin}–{end} von {total}', + 'detail.output.truncated': 'Ausgabe abgeschnitten', + 'detail.providers.count': '{count} prüft Anbieter', + 'detail.plugins.count': 'Dynamische {count}-Plugins', + 'detail.workflow.script': 'Workflow-Skript', + 'detail.ralph.reportedComplete': 'Der Arbeiter hat die Fertigstellung gemeldet', + 'detail.ralph.reportedBlocker': 'Der Arbeiter hat einen Blocker gemeldet', + 'detail.ralph.limit': 'Rundenlimit erreicht', + 'detail.report.nextSteps': 'Verbleibende Arbeit', + 'detail.trace.replacedBy': 'Ersetzt durch', + 'detail.trace.replacementChain': 'Ersatzkette', + 'detail.trace.replaces': 'Ereignisse ersetzt', + 'detail.trace.sources': 'Quellereignisse', + 'detail.trace.derived': 'Abgeleitete Ereignisse', + 'detail.trace.ancestors': 'Ahnensitzungen', + 'detail.trace.descendants': 'Nachkommende Sitzungen', + 'detail.matches.count': '{count} Übereinstimmungen', + 'detail.matches.capped': 'Ergebnisgrenze erreicht; Grenzen Sie die Suche auf mehr ein', + 'detail.event.neighbors': 'Umliegende Veranstaltungen', + 'todo.completed': '{done}/{total} abgeschlossen', + 'command.attachmentsUnsupported': '/{command} akzeptiert keine Anhänge; Entfernen Sie sie zuerst', + 'ask.rowTitle': 'Frage stellen', + 'ask.waiting': 'warten', + 'ask.pending': 'Fortsetzung; Antwort noch verfügbar', + 'ask.pendingDetail': 'Diese offenen Fragen bleiben vom Komponisten zu beantworten.', + 'ask.reopen': 'Antwort', + 'ask.review': 'Antworten anzeigen', + 'ask.closed': 'geschlossen', + 'ask.closedDetail': 'Diese Frage ist geschlossen; Das Ergebnis finden Sie im folgenden Gespräch.', + 'ask.cancelled': 'abgesagt', + 'ask.cancelledDetail': 'Dieser Fragensatz wurde gelöscht, bevor die Antworten übermittelt wurden.', + 'ask.interrupted': 'unterbrochen', + 'ask.interruptedDetail': 'Dieser Fragensatz wurde unterbrochen, bevor Antworten übermittelt wurden.', + 'ask.answered': '{answered}/{total} antwortete', + 'ask.skipped': 'Nicht beantwortet', + 'bash.running': 'Laufen', + 'bash.failed': 'Fehlgeschlagen', + 'bash.stopped': 'Angehalten', + 'row.running': 'Laufen', + 'row.preparing': 'Werkzeugaufruf wird vorbereitet', + 'row.failed': 'Fehlgeschlagen', + 'row.stopped': 'Angehalten', + 'row.commandArguments': 'Befehlsargumente', + 'row.input': 'IN', + 'row.output': 'OUT', + 'row.inspect': 'Inspizieren', + 'row.imageSize': '{width}x{height}px, {bytes} Bytes', + 'row.imageSizeDownscaled': '{width}x{height}px (herunterskaliert von {originalWidth}x{originalHeight}px), {bytes} Bytes', + 'tool.title.search': 'Suchen', + 'tool.title.read': 'Lesen', + 'tool.title.bash': 'Bash', + 'tool.title.write': 'Schreiben', + 'tool.title.edit': 'Bearbeiten', + 'tool.title.code': 'Code', + 'tool.title.generic': 'Werkzeugaufruf', + 'tool.title.inspect': 'Fragen Sie die Cordis-Umgebung ab', + 'tool.title.runCordis': 'Führen Sie das Cordis-Plugin aus', + 'tool.title.stopCordis': 'Stoppen Sie das Cordis-Plugin', + 'tool.title.removeCordis': 'Entfernen Sie das Cordis-Plugin', + 'tool.title.pwsh': 'Pwsh', + 'tool.title.readImage': 'Bild lesen', + 'tool.title.grep': 'Grep', + 'tool.title.glob': 'Globus', + 'tool.title.webSearch': 'Suchen', + 'tool.title.webFetch': 'Holen', + 'tool.autoReviewRejected': 'Abgelehnt von Auto review', + 'tool.autoReviewNotExecuted': 'Werkzeug wurde nicht ausgeführt. Grund: {reason}', + 'tool.autoReviewReasonFallback': 'Auto review hat diese Aktion nicht autorisiert', + 'diff.collapseAria': 'Differenz ausblenden', + 'diff.expandAria': 'Erweitern Sie {count} um weitere Diff-Linien', + 'diff.expandRest': '… {count} weitere Zeilen', + 'read.window': 'Zeigt {shown} der {total}-Linien', + 'read.collapseAria': 'Inhalt reduzieren', + 'read.expandAria': 'Erweitern Sie {count} um weitere Zeilen', + 'read.expandRest': '… {count} weitere Zeilen', + 'search.paths': '{shown}-Pfade', + 'search.paths.truncated': 'Zeigt {shown} von {total}-Pfaden', + 'search.matches': '{shown} entspricht · {files}-Dateien', + 'search.matches.truncated': 'Zeigt {shown} von {total}-Übereinstimmungen · {files}-Dateien', + 'search.noResults': 'Keine Ergebnisse', + 'search.collapseAria': 'Ergebnisse reduzieren', + 'search.expandAria': 'Erweitern Sie {count} um weitere Ergebniszeilen', + 'search.expandRest': '… {count} weitere Zeilen', + 'web.noResults': 'Keine Ergebnisse gefunden', + 'web.sourcesTruncated': 'Quellenliste gekürzt', + 'web.http': 'HTTP', + 'web.contentTruncated': 'Inhalt abgeschnitten', + 'details.running': 'Laufen…', + 'queue.count': '{n} Nachrichten in der Warteschlange', + 'queue.sending': 'Senden…', + 'queue.image': 'Nachrichtenbild in der Warteschlange', + 'queue.file': 'Datei in der Warteschlange {name}', + 'queue.edit': 'Bearbeiten Sie die Nachricht in der Warteschlange', + 'queue.edit.unsupported': 'Enthält Nicht-Textinhalte; Bearbeitung wird noch nicht unterstützt', + 'queue.save': 'Speichern Sie die Nachricht in der Warteschlange', + 'queue.cancelEdit': 'Bearbeitung abbrechen', + 'queue.remove': 'Entfernen Sie die Nachricht in der Warteschlange', + 'queue.steer': 'Mit vorgemerkter Nachricht lenken', + 'queue.steer.unavailable': 'Die Steuerung ist nur verfügbar, während der Agent ausgeführt wird', + 'error.sessionInUse': 'Diese Sitzung wird bereits verwendet, möglicherweise von einer anderen laufenden DSH-Instanz (z. B. dsh Web oder der Desktop-App). Beenden Sie andere laufende DSH-Instanzen und versuchen Sie es erneut.', + 'queue.editFailed': 'Bearbeitung fehlgeschlagen: Der Versand dieser Nachricht wurde möglicherweise bereits gestartet.', + 'queue.removeFailed': 'Fehler beim Entfernen: Der Versand dieser Nachricht wurde möglicherweise bereits gestartet.', + 'queue.steerFailed': 'Lenkung versagt. Versuchen Sie es erneut.', + 'terminal.signal': 'Signal {signal}', + 'terminal.exitCode': 'Exit-Code {code}', + 'terminal.noExitCode': 'kein Exit-Code', + 'terminal.commandLine': 'Befehlszeile {n}', + 'terminal.running': 'Laufen', + 'terminal.failed': 'Fehlgeschlagen', + 'terminal.done': 'Fertig', + 'terminal.noOutput': 'Keine Ausgabe', + 'terminal.collapseAria': 'Ausgabe reduzieren', + 'terminal.expandAria': 'Erweitern Sie die verbleibenden {n}-Ausgabezeilen', + 'terminal.expandRest': '… {n} weitere Zeilen', + // The Host terminal_send presenter has no locale seat; keep its fallbacks + // aligned with these English values. + 'terminal.sendInput': '(Eingabe senden)', + 'terminal.session': 'Terminal {sessionId}', +} satisfies Record + +/** Russian dictionary, checked against the English keys. */ +export const ru = { + 'quote.remove': 'Удалить цитату', + 'shortcut.newline': 'Новая линия', + 'shortcut.complementary': 'Использовать альтернативное действие: поставить в очередь или направить', + 'shortcut.slash': 'Открыть командное меню', + 'shortcut.mention': 'Открыть справочное меню', + 'hint.plan': PLAN_NEXT_ACTION_EN, + 'hint.goal': 'описать цель долгосрочной задачи', + 'hint.goal.active': 'цель активна — изменить/приостановить/возобновить/очистить', + 'placeholder.plan': PLAN_NEXT_ACTION_EN, + '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': 'Сообщение может содержать до {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': 'Что делают кнопки «Ввод» и «Отправить» во время работы агента; Cmd/Ctrl+Enter использует другое поведение.', + 'settings.enter.queue': 'Очередь', + 'settings.enter.steer': 'Направить', + 'hero.headline': 'В неизвестность', + 'hero.preview': 'Предварительный просмотр', + 'hero.chooseWorkspace': 'Выберите рабочее пространство', + 'session.hierarchy': 'Иерархия сеансов', + 'session.moreViews': 'Больше просмотров беседы', + 'session.moreViewsCurrent': 'Больше просмотров беседы · {name}', + 'todo.title': 'Дела', + 'todo.progress.done': '{done} завершено', + 'todo.progress.active': '{active} в процессе', + 'todo.progress.pending': '{pending} в ожидании', + 'todo.status.completed': 'Завершено', + 'todo.status.inProgress': 'В процессе', + 'todo.status.pending': 'Ожидается', + 'todo.rowTitle': 'Обновить список дел', + '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.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': 'Однажды', + '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': 'Крон {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': 'Запустить цикл Ральфа', + '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': '{count} больше элементов доступно в Inspect', + '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': 'Реквизит', + 'detail.field.data': 'Данные', + 'detail.field.source': 'Источник', + 'detail.field.content': 'Содержание', + 'detail.field.message': 'Сообщение', + 'detail.field.messageId': 'Идентификатор сообщения', + 'detail.field.root': 'Корень', + 'detail.field.pid': 'Идентификатор процесса', + 'detail.field.type': 'Тип', + 'detail.field.time': 'Время', + 'detail.field.seq': 'Последовательность событий', + 'detail.field.turn': 'Ход', + 'detail.field.step': 'Шаг', + 'detail.field.callId': 'Идентификатор вызова', + 'detail.field.agents': 'Агенты запущены', + 'detail.field.result': 'Результат', + 'detail.field.parent': 'Родитель', + 'detail.field.depth': 'Глубина', + 'detail.field.exitCode': 'Код выхода', + 'detail.field.signal': 'Сигнал', + 'detail.field.previousStatus': 'Предыдущий статус', + 'detail.field.agent': 'Идентификатор агента', + 'detail.field.job': 'Идентификатор вакансии', + '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': 'Линии {begin} – {end} из {total}', + 'detail.output.truncated': 'Вывод усечен', + 'detail.providers.count': '{count} проверяет поставщиков', + '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': 'Баш', + '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': 'Пшш', + 'tool.title.readImage': 'Читать изображение', + 'tool.title.grep': 'Греп', + 'tool.title.glob': 'Глоб', + 'tool.title.webSearch': 'Поиск', + 'tool.title.webFetch': 'Получить', + 'tool.autoReviewRejected': 'Отклонено Auto review', + 'tool.autoReviewNotExecuted': 'Инструмент не был выполнен. Причина: {reason}', + 'tool.autoReviewReasonFallback': 'Auto review не санкционировал это действие', + 'diff.collapseAria': 'Свернуть разницу', + 'diff.expandAria': 'Разверните {count} больше строк различий', + 'diff.expandRest': '… {count} еще строки', + 'read.window': 'Показаны линии {shown} {total}.', + 'read.collapseAria': 'Свернуть содержимое', + 'read.expandAria': 'Разверните {count} еще строки', + 'read.expandRest': '… {count} еще строки', + 'search.paths': '{shown} пути', + 'search.paths.truncated': 'Отображение путей {shown} из {total}', + 'search.matches': '{shown} соответствует · файлам {files}', + 'search.matches.truncated': 'Отображение {shown} совпадений {total} · Файлы {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 или настольным приложением). Закройте другие работающие экземпляры 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} еще строки', + // The Host terminal_send presenter has no locale seat; keep its fallbacks + // aligned with these English values. + 'terminal.sendInput': '(отправить ввод)', + 'terminal.session': 'Терминал {sessionId}', +} satisfies Record + +/** Portuguese dictionary, checked against the English keys. */ +export const pt = { + 'quote.remove': 'Remover cotação', + 'shortcut.newline': 'Nova linha', + 'shortcut.complementary': 'Usar a ação alternativa de enfileirar ou orientar', + 'shortcut.slash': 'Abrir menu de comando', + 'shortcut.mention': 'Abrir menu de referência', + 'hint.plan': PLAN_NEXT_ACTION_EN, + 'hint.goal': 'descrever o objetivo de uma tarefa de longa duração', + 'hint.goal.active': 'meta ativa — editar/pausar/resumir/limpar', + 'placeholder.plan': PLAN_NEXT_ACTION_EN, + 'placeholder.default': 'Envie mensagens ou execute uma tarefa, / comandos, @ arquivos ou sessões', + 'placeholder.unavailable': 'Sessão indisponível', + 'placeholder.parentOffline': 'Sessão pai offline; o envio não está disponível, mas você ainda pode interromper a execução', + 'placeholder.hero': 'Descreva o que você deseja construir, / comandos, @ arquivos ou sessões', + 'placeholder.workspace': 'Escolha um espaço de trabalho para começar', + 'placeholder.steerQueue': 'Cmd/Ctrl+Enter direciona todas as mensagens na fila', + 'input.commands': 'Adicione arquivos ou execute comandos', + 'input.file': 'Arquivo', + 'input.stop': 'Parar geração', + 'input.send': 'Enviar mensagem', + 'input.send.queue': 'Mensagem de fila', + 'input.send.steer': 'Orientar com mensagem', + 'attachment.pending': 'Anexos pendentes', + 'attachment.scrollLeft': 'Rolar anexos para a esquerda', + 'attachment.scrollRight': 'Rolar os anexos para a direita', + 'attachment.dropTitle': 'Arraste arquivos ou imagens aqui para adicioná-los', + 'attachment.dropDesc': 'Limite de imagens: até {count} imagens, {size} cada', + 'attachment.dropBlocked': 'Arquivos e imagens não podem ser adicionados no momento', + 'attachment.directoryDesktopOnly': 'As pastas só podem ser adicionadas no aplicativo de desktop; adicione arquivos individuais no navegador', + 'attachment.pathUnavailable': 'Não foi possível obter o caminho da pasta; arraste-o novamente', + 'attachment.pathUnsupported': 'O caminho contém caracteres que uma referência não pode transportar; renomeie-o e tente novamente', + 'image.pending': 'Imagens pendentes', + 'image.openOriginal': 'Ver original', + 'image.openOriginalLabel': '{label}, clique para ver o original', + 'image.remove': 'Remover imagem {name}', + 'image.original': 'Imagem original', + 'image.label': 'Imagem', + 'image.loadFailed': 'A imagem não foi carregada; clique para tentar novamente', + 'image.loading': 'Carregando imagem…', + 'image.preview': 'Visualização da imagem original', + 'image.closePreview': 'Fechar visualização da imagem original', + 'image.unsupportedType': 'Somente imagens PNG, JPG, WebP e GIF são suportadas', + 'image.tooMany': 'Uma mensagem pode incluir até {count} imagens', + 'image.fileTooLarge': 'Cada imagem deve ser menor que {size}', + 'image.totalTooLarge': 'As imagens excedem {size} no total; remova alguns e tente novamente', + 'image.tooManyPixels': 'A resolução da imagem é muito alta; comprima e tente novamente', + 'image.dimensionTooLarge': 'Os lados da imagem devem ter no máximo {size}px; diminua a escala e tente novamente', + 'image.modelUnsupported': 'O modelo atual não suporta imagens; mude para um modelo que faça', + 'image.sendFailed': 'Falha no envio de imagens ({reason}); adicione-os novamente e tente novamente', + 'file.pending': 'Arquivos pendentes', + 'file.remove': 'Remover arquivo {name}', + 'file.uploading': 'Fazendo upload…', + 'file.uploadFailed': 'Falha no upload; clique para tentar novamente', + 'file.retry': 'Tente fazer upload novamente {name}', + 'file.stillUploading': 'Os arquivos ainda estão sendo carregados; enviar depois que terminarem', + 'file.sessionUnavailable': 'Sessão indisponível; os arquivos não podem ser carregados', + 'file.notStaged': 'O upload do arquivo não foi concluído; adicione novamente e tente novamente', + 'file.label': 'Arquivo', + 'context.aria': '{percent} de contexto usado', + 'context.used': 'do contexto usado', + 'context.system': 'Alerta do sistema', + 'context.tools': 'Definições de ferramentas', + 'context.messages': 'Mensagens', + 'settings.enter.title': 'Enviar comportamento enquanto estiver ocupado', + 'settings.enter.description': 'O que o botão Enter e o botão Enviar fazem enquanto o agente está em execução; Cmd/Ctrl+Enter usa o outro comportamento', + 'settings.enter.queue': 'Fila', + 'settings.enter.steer': 'Orientar', + 'hero.headline': 'Para o desconhecido', + 'hero.preview': 'Visualização', + 'hero.chooseWorkspace': 'Escolha o espaço de trabalho', + 'session.hierarchy': 'Hierarquia de sessão', + 'session.moreViews': 'Mais visualizações de conversas', + 'session.moreViewsCurrent': 'Mais visualizações de conversas · {name}', + 'todo.title': 'Tarefas', + 'todo.progress.done': '{done} concluído', + 'todo.progress.active': '{active} em andamento', + 'todo.progress.pending': '{pending} pendente', + 'todo.status.completed': 'Concluído', + 'todo.status.inProgress': 'Em andamento', + 'todo.status.pending': 'Pendente', + 'todo.rowTitle': 'Atualizar lista de tarefas', + 'tool.title.createGoal': 'Criar meta', + 'tool.title.getGoal': 'Ver meta', + 'tool.title.updateGoal': 'Atualizar meta', + 'tool.preparing.content': '{kilobytes}KB', + 'tool.title.createSchedule': 'Criar lembrete', + 'tool.title.listSchedules': 'Listar lembretes', + 'tool.title.deleteSchedule': 'Excluir lembrete', + 'tool.title.updateSchedule': 'Lembrete de atualização', + 'detail.state': 'Estado', + 'detail.todo.completed': 'Concluído', + 'detail.todo.in_progress': 'Em andamento', + 'detail.todo.pending': 'Pendente', + 'detail.todo.empty': 'A lista de tarefas está vazia', + 'todo.diff.initial': 'Lista inicial', + 'todo.diff.compare': 'Mudanças desde a lista anterior', + 'todo.diff.unavailable': 'Lista anterior indisponível', + 'todo.diff.noChanges': 'Sem alterações na lista', + 'todo.diff.added': '{count} adicionado', + 'todo.diff.updated': '{count} atualizado', + 'todo.diff.removed': '{count} removido', + 'todo.diff.unchanged': '{count} inalterado', + 'todo.diff.addedItem': 'Adicionado', + 'todo.diff.updatedItem': 'Status alterado', + 'todo.diff.movedItem': 'Reordenado', + 'todo.diff.removedItem': 'Removido', + 'detail.goal.empty': 'Sem objetivo', + 'detail.goal.active': 'Ativo', + 'detail.goal.disarmed': 'Aguardando continuação', + 'detail.goal.paused': 'Pausado', + 'detail.goal.blocked': 'Bloqueado', + 'detail.goal.complete': 'Concluído', + 'detail.goal.rounds': 'Rodadas', + 'detail.goal.reason': 'Bloqueador', + 'detail.days': '{count}d', + 'detail.hours': '{count}h', + 'detail.minutes': '{count} min', + 'detail.seconds': '{count} segundos', + 'detail.schedule.once': 'Uma vez', + 'detail.schedule.every': 'A cada {interval}', + 'detail.schedule.when': 'Agendado para', + 'detail.schedule.frequency': 'Repetir', + 'detail.schedule.scheduled': 'Agendado', + 'detail.schedule.overdue': 'Atrasado, aguardando retomada da sessão', + 'detail.schedule.empty': 'Sem lembretes', + 'detail.schedule.deleted': 'Excluído', + 'detail.schedule.count': '{count} lembretes', + 'detail.schedule.daily': 'Todos os dias às {time} ({zone})', + 'detail.schedule.weekly': 'Semanalmente em {days} às {time} ({zone})', + 'detail.schedule.cron': 'Cron {expression} ({zone})', + 'detail.weekday.1': 'segunda-feira', + 'detail.weekday.2': 'Terça', + 'detail.weekday.3': 'Quarta', + 'detail.weekday.4': 'Qui', + 'detail.weekday.5': 'Sexta-feira', + 'detail.weekday.6': 'Sábado', + 'detail.weekday.7': 'Sol', + 'detail.weekday.join': ', ', + 'tool.title.inspectProviders': 'Inspecionar fornecedores', + 'tool.title.queryRuntime': 'Tempo de execução da consulta', + 'tool.title.inspectPlugins': 'Inspecionar plug-ins', + 'tool.title.workflow': 'Executar fluxo de trabalho', + 'tool.title.ralph': 'Execute o circuito Ralph', + 'tool.title.readEvent': 'Ler evento', + 'tool.title.searchEvents': 'Pesquisar eventos', + 'tool.title.traceEvent': 'Evento de rastreamento', + 'tool.title.searchSessions': 'Buscar sessões', + 'tool.title.traceSession': 'Sessão de rastreamento', + 'tool.title.listModels': 'Listar modelos', + 'tool.title.subagent': 'Criar subagente', + 'tool.title.listAgents': 'Listar subagentes', + 'tool.title.sendMessage': 'Enviar mensagem', + 'tool.title.interruptAgent': 'Agente de interrupção', + 'tool.title.listJobs': 'Listar trabalhos em segundo plano', + 'tool.title.readJob': 'Ler saída do trabalho', + 'tool.title.killJob': 'Cancelar trabalho em segundo plano', + 'tool.title.openTerminal': 'Terminal aberto', + 'tool.title.readTerminal': 'Ler terminal', + 'tool.title.listTerminals': 'Listar terminais', + 'tool.title.signalTerminal': 'Terminal de sinal', + 'tool.title.closeTerminal': 'Fechar terminal', + 'tool.title.lsp': 'Símbolos de código de consulta', + 'tool.title.findDefinition': 'Encontre a definição', + 'tool.title.findReferences': 'Encontre referências', + 'tool.title.findImplementation': 'Encontre implementação', + 'tool.title.hoverSymbol': 'Símbolo de inspeção', + 'tool.title.spawnTeammate': 'Criar companheiro de equipe', + 'tool.title.createTeamTask': 'Criar tarefa de equipe', + 'tool.title.getTeamTask': 'Ler tarefa da equipe', + 'tool.title.updateTeamTask': 'Atualizar tarefa da equipe', + 'tool.title.listTeamTasks': 'Listar tarefas da equipe', + 'tool.title.waitAgent': 'Aguarde o subagente', + 'detail.recordedResult': 'Resultado registrado', + 'detail.empty': 'Nenhum resultado', + 'detail.none': 'Nenhum', + 'detail.yes': 'Sim', + 'detail.no': 'Não', + 'detail.moreInInspect': 'Mais {count} itens disponíveis no Inspecionar', + 'detail.status.running': 'Correndo', + 'detail.status.idle': 'Inativo', + 'detail.status.ready': 'Pronto', + 'detail.status.inactive': 'Inativo', + 'detail.status.provisioning': 'Provisionamento', + 'detail.status.failed': 'Falha', + 'detail.status.completed': 'Concluído', + 'detail.status.deleted': 'Excluído', + 'detail.status.killed': 'Cancelado', + 'detail.status.accepted': 'Aceito', + 'detail.status.queued': 'Na fila', + 'detail.status.exited': 'Saiu', + 'detail.field.id': 'ID', + 'detail.field.revision': 'Revisão', + 'detail.field.platform': 'Plataforma', + 'detail.field.provider': 'Provedor', + 'detail.field.model': 'Modelo', + 'detail.field.role': 'Função', + 'detail.field.context': 'Contexto', + 'detail.field.owner': 'Proprietário', + 'detail.field.ready': 'Pronto', + 'detail.field.dependencies': 'Dependências', + 'detail.field.writeScopes': 'Escrever escopos', + 'detail.field.warnings': 'Avisos', + 'detail.field.diagnostics': 'Diagnóstico', + 'detail.field.methods': 'Métodos', + 'detail.field.inputSchema': 'Esquema de entrada', + 'detail.field.outputSchema': 'Esquema de saída', + 'detail.field.currentPackage': 'Pacote atual', + 'detail.field.nextPackage': 'Próximo pacote', + 'detail.field.latestRun': 'Última execução', + 'detail.field.packages': 'Pacotes', + 'detail.field.registrations': 'Inscrições', + 'detail.field.props': 'Adereços', + 'detail.field.data': 'Dados', + 'detail.field.source': 'Fonte', + 'detail.field.content': 'Conteúdo', + 'detail.field.message': 'Mensagem', + 'detail.field.messageId': 'ID da mensagem', + 'detail.field.root': 'Raiz', + 'detail.field.pid': 'ID do processo', + 'detail.field.type': 'Tipo', + 'detail.field.time': 'Hora', + 'detail.field.seq': 'Sequência de eventos', + 'detail.field.turn': 'Turno', + 'detail.field.step': 'Passo', + 'detail.field.callId': 'ID de chamada', + 'detail.field.agents': 'Agentes iniciados', + 'detail.field.result': 'Resultado', + 'detail.field.parent': 'Pai', + 'detail.field.depth': 'Profundidade', + 'detail.field.exitCode': 'Código de saída', + 'detail.field.signal': 'Sinal', + 'detail.field.previousStatus': 'Estado anterior', + 'detail.field.agent': 'ID do agente', + 'detail.field.job': 'ID do trabalho', + 'detail.field.task': 'Tarefa', + 'detail.field.processGroup': 'Grupo de processos', + 'detail.field.availability': 'Disponibilidade', + 'detail.field.bestMatch': 'Melhor correspondência', + 'detail.field.target': 'Evento alvo', + 'detail.field.surface': 'Status do registro', + 'detail.agents.count': '{count} agentes', + 'detail.jobs.count': '{count} trabalhos em segundo plano', + 'detail.terminals.count': '{count} terminais', + 'detail.tasks.count': '{count} tarefas de equipe', + 'detail.tasks.nextPage': 'Mais tarefas disponíveis; o próximo cursor é {cursor}', + 'detail.locations.count': '{count} locais', + 'detail.location': 'Linha {line}, coluna {column}', + 'detail.receipt.delivered': 'Mensagem entregue', + 'detail.receipt.interrupt': 'Interrupção solicitada', + 'detail.receipt.started': 'Iniciado', + 'detail.receipt.cancel': 'Cancelamento solicitado', + 'detail.receipt.alreadyFinished': 'Já terminei', + 'detail.receipt.signal': 'Sinal entregue', + 'detail.receipt.closed': 'Fechado', + 'detail.receipt.closing': 'Fechando', + 'detail.wait.noProgress': 'Nenhum subagente ativo', + 'detail.wait.title': 'Atividade do subagente', + 'detail.wait.timeout': 'A espera expirou', + 'detail.wait.changed': 'Alteração detectada', + 'detail.agent.reply': 'Resposta do agente', + 'detail.models.title': 'Modelos disponíveis', + 'detail.output.lines': 'Linhas {begin}–{end} de {total}', + 'detail.output.truncated': 'Saída truncada', + 'detail.providers.count': '{count} inspecionar provedores', + 'detail.plugins.count': '{count} plug-ins dinâmicos', + 'detail.workflow.script': 'Roteiro de fluxo de trabalho', + 'detail.ralph.reportedComplete': 'Conclusão relatada pelo trabalhador', + 'detail.ralph.reportedBlocker': 'Trabalhador relatou um bloqueador', + 'detail.ralph.limit': 'Limite de rodadas atingido', + 'detail.report.nextSteps': 'Trabalho restante', + 'detail.trace.replacedBy': 'Substituído por', + 'detail.trace.replacementChain': 'Cadeia de substituição', + 'detail.trace.replaces': 'Eventos substituídos', + 'detail.trace.sources': 'Eventos de origem', + 'detail.trace.derived': 'Eventos derivados', + 'detail.trace.ancestors': 'Sessões ancestrais', + 'detail.trace.descendants': 'Sessões de descendentes', + 'detail.matches.count': '{count} correspondências', + 'detail.matches.capped': 'Limite de resultados atingido; restringir a busca por mais', + 'detail.event.neighbors': 'Eventos circundantes', + 'todo.completed': '{done}/{total} concluído', + 'command.attachmentsUnsupported': '/{command} não aceita anexos; remova-os primeiro', + 'ask.rowTitle': 'Faça uma pergunta', + 'ask.waiting': 'esperando', + 'ask.pending': 'continuou; resposta ainda disponível', + 'ask.pendingDetail': 'Essas questões pendentes permanecem respondidas pelo compositor.', + 'ask.reopen': 'Resposta', + 'ask.review': 'Ver respostas', + 'ask.closed': 'fechado', + 'ask.closedDetail': 'Esta questão está encerrada; seu resultado está na conversa abaixo.', + 'ask.cancelled': 'cancelado', + 'ask.cancelledDetail': 'Este conjunto de perguntas foi cancelado antes do envio das respostas.', + 'ask.interrupted': 'interrompido', + 'ask.interruptedDetail': 'Este conjunto de perguntas foi interrompido antes do envio das respostas.', + 'ask.answered': '{answered}/{total} respondeu', + 'ask.skipped': 'Não respondido', + 'bash.running': 'Correndo', + 'bash.failed': 'Falha', + 'bash.stopped': 'Parado', + 'row.running': 'Correndo', + 'row.preparing': 'Preparando chamada de ferramenta', + 'row.failed': 'Falha', + 'row.stopped': 'Parado', + 'row.commandArguments': 'Argumentos de comando', + 'row.input': 'IN', + 'row.output': 'OUT', + 'row.inspect': 'Inspecionar', + 'row.imageSize': '{width}x{height}px, {bytes} bytes', + 'row.imageSizeDownscaled': '{width}x{height}px (reduzido de {originalWidth}x{originalHeight}px), {bytes} bytes', + 'tool.title.search': 'Pesquisar', + 'tool.title.read': 'Leia', + 'tool.title.bash': 'Bash', + 'tool.title.write': 'Escreva', + 'tool.title.edit': 'Editar', + 'tool.title.code': 'Código', + 'tool.title.generic': 'Chamada de ferramenta', + 'tool.title.inspect': 'Consultar ambiente Cordis', + 'tool.title.runCordis': 'Execute o plug-in Cordis', + 'tool.title.stopCordis': 'Parar o plug-in Cordis', + 'tool.title.removeCordis': 'Remover plug-in Cordis', + 'tool.title.pwsh': 'Pwsh', + 'tool.title.readImage': 'Ler imagem', + 'tool.title.grep': 'Grep', + 'tool.title.glob': 'globo', + 'tool.title.webSearch': 'Pesquisar', + 'tool.title.webFetch': 'Buscar', + 'tool.autoReviewRejected': 'Rejeitado por Auto review', + 'tool.autoReviewNotExecuted': 'A ferramenta não foi executada. Motivo: {reason}', + 'tool.autoReviewReasonFallback': 'Auto review não autorizou esta ação', + 'diff.collapseAria': 'Recolher diferença', + 'diff.expandAria': 'Expanda mais {count} linhas de diferenças', + 'diff.expandRest': '… mais {count} linhas', + 'read.window': 'Mostrando {shown} de {total} linhas', + 'read.collapseAria': 'Recolher conteúdo', + 'read.expandAria': 'Expanda mais {count} linhas', + 'read.expandRest': '… mais {count} linhas', + 'search.paths': '{shown} caminhos', + 'search.paths.truncated': 'Mostrando {shown} de {total} caminhos', + 'search.matches': '{shown} correspondências · {files} arquivos', + 'search.matches.truncated': 'Mostrando {shown} de {total} correspondências · {files} arquivos', + 'search.noResults': 'Nenhum resultado', + 'search.collapseAria': 'Recolher resultados', + 'search.expandAria': 'Expanda mais {count} linhas de resultados', + 'search.expandRest': '… mais {count} linhas', + 'web.noResults': 'Nenhum resultado encontrado', + 'web.sourcesTruncated': 'Lista de fontes truncada', + 'web.http': 'HTTP', + 'web.contentTruncated': 'Conteúdo truncado', + 'details.running': 'Correndo…', + 'queue.count': '{n} mensagens na fila', + 'queue.sending': 'Enviando…', + 'queue.image': 'Imagem de mensagem na fila', + 'queue.file': 'Arquivo na fila {name}', + 'queue.edit': 'Editar mensagem na fila', + 'queue.edit.unsupported': 'Contém conteúdo não textual; a edição ainda não é suportada', + 'queue.save': 'Salvar mensagem na fila', + 'queue.cancelEdit': 'Cancelar edição', + 'queue.remove': 'Remover mensagem na fila', + 'queue.steer': 'Orientar com mensagem da fila', + 'queue.steer.unavailable': 'A direção está disponível apenas enquanto o agente está em execução', + 'error.sessionInUse': 'Esta sessão já está em uso, possivelmente por outra instância DSH em execução (como dsh web ou aplicativo de desktop). Saia de outras instâncias DSH em execução e tente novamente.', + 'queue.editFailed': 'Falha na edição: esta mensagem pode já ter começado a ser enviada.', + 'queue.removeFailed': 'Falha na remoção: esta mensagem pode já ter começado a ser enviada.', + 'queue.steerFailed': 'A direção falhou. Tente novamente.', + 'terminal.signal': 'sinal {signal}', + 'terminal.exitCode': 'código de saída {code}', + 'terminal.noExitCode': 'sem código de saída', + 'terminal.commandLine': 'Linha de comando {n}', + 'terminal.running': 'Correndo', + 'terminal.failed': 'Falha', + 'terminal.done': 'Concluído', + 'terminal.noOutput': 'Sem saída', + 'terminal.collapseAria': 'Recolher saída', + 'terminal.expandAria': 'Expanda as {n} linhas de saída restantes', + 'terminal.expandRest': '… mais {n} linhas', + // The Host terminal_send presenter has no locale seat; keep its fallbacks + // aligned with these English values. + 'terminal.sendInput': '(enviar entrada)', + 'terminal.session': 'Terminal {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/ConversationRoot.module.css b/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.module.css index cf92656162..1f0380680c 100644 --- a/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.module.css +++ b/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.module.css @@ -198,6 +198,9 @@ z-index: 1; display: flex; gap: 16px 20px; + width: 100%; + min-width: 0; + box-sizing: border-box; align-items: flex-start; flex-wrap: wrap; margin-top: 10px; @@ -212,12 +215,20 @@ min-height: 25px; } -/* The 16px text line retains 9px below it. The active bar reaches 1px past - the tab box to sit over the header rule. */ +/* The 16px text line retains 9px below it. The press target extends 10px + above the text into the strip's top margin and 10px to each side, matching + the inline padding of the Sidebar tab chips (ui-dockkit `.tab`); negative + margins keep the layout box at its 25px row and text width. The active bar + spans the text and reaches 1px past the tab box to sit over the header + rule. */ .tab { + --dsh-tab-hit-inline: 10px; + position: relative; - min-height: 25px; - padding: 0 0 9px; + box-sizing: border-box; + min-height: 35px; + margin: -10px calc(-1 * var(--dsh-tab-hit-inline)) 0; + padding: 10px var(--dsh-tab-hit-inline) 9px; border: none; background: transparent; font-size: 13px; @@ -230,9 +241,9 @@ .tab::after { content: ''; position: absolute; - right: 0; + right: var(--dsh-tab-hit-inline); bottom: -1px; - left: 0; + left: var(--dsh-tab-hit-inline); height: 2px; border-radius: 2px; background: transparent; @@ -262,6 +273,10 @@ color: var(--dsw-alias-label-secondary); } +.viewsMoreActive { color: var(--dsw-alias-state-business-primary); } + +.tabLabel { display: block; max-width: 88px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } + .viewsMore[aria-expanded='true'] { background: var(--dsw-alias-interactive-bg-hover); } diff --git a/packages/client/ui-conversation/src/client/skeleton/ConversationSession.tsx b/packages/client/ui-conversation/src/client/skeleton/ConversationSession.tsx index f4ef1f342a..3089b083df 100644 --- a/packages/client/ui-conversation/src/client/skeleton/ConversationSession.tsx +++ b/packages/client/ui-conversation/src/client/skeleton/ConversationSession.tsx @@ -1,6 +1,6 @@ /** Strict per-session header/body content inserted into the resident conversation layout. */ -import { useState } from 'react' +import { useLayoutEffect, useMemo, useRef, useState } from 'react' import clsx from 'clsx' import { Button, IconEllipsisOutlineRegular, Menu, Tooltip } from '@deepseek-ai/dsh-client-ui-primitives' import type { SessionListState, SessionSummary } from '@deepseek-ai/dsh-api-session-controller/client' @@ -22,17 +22,17 @@ export type ConversationSessionHeaderProps = ConversationSessionHeaderSlotProps type ViewsMoreMenuProps = Omit & Pick - & { readonly label: string } + & { readonly label: string; readonly current: boolean } /** The View tab row's More launcher; its menu rows come from `conversation.session.header.views.actions`. */ -function ViewsMoreMenu({ label, entries, onOpen, onPinnedChange, renderSlot }: ViewsMoreMenuProps) { +function ViewsMoreMenu({ label, current, entries, onOpen, onPinnedChange, renderSlot }: ViewsMoreMenuProps) { const [open, setOpen] = useState(false) const onDismiss = (): void => { setOpen(false) } return ( - @@ -93,6 +93,31 @@ export function ConversationSessionHeader({ const unpinned = useEntryPins(ids => ids) const hasEntryActions = useEntryActions(registered => registered) const showTabs = !hideChrome && tabs.length > 1 + const tabRow = useRef(null) + const [visibleViewLimit, setVisibleViewLimit] = useState(1) + // Observe only the available row width; individual tabs are not measured. + useLayoutEffect(() => { + const row = tabRow.current + if (row === null || !hasEntryActions) return + const updateLimit = (): void => { + setVisibleViewLimit(Math.max(1, Math.floor((row.clientWidth - 48) / 120))) + } + updateLimit() + if (typeof ResizeObserver === 'undefined') return + const observer = new ResizeObserver(updateLimit) + observer.observe(row) + return () => { observer.disconnect() } + }, [showTabs, hasEntryActions]) + const { visibleTabs, moreTabs } = useMemo(() => { + const preferred = [ + ...tabs.filter(tab => tab.id === FIXED_VIEW_ID), + ...tabs.filter(tab => tab.id !== FIXED_VIEW_ID && !unpinned.includes(tab.id)), + ] + const visibleTabs = hasEntryActions ? preferred.slice(0, visibleViewLimit) : preferred + const visibleIds = new Set(visibleTabs.map(tab => tab.id)) + return { visibleTabs, moreTabs: tabs.filter(tab => !visibleIds.has(tab.id)) } + }, [tabs, unpinned, hasEntryActions, visibleViewLimit]) + const currentInMore = moreTabs.some(tab => tab.id === active?.id) return ( <>
@@ -169,10 +194,9 @@ export function ConversationSessionHeader({ {showTabs && ( // data-conversation-tabs: marks the tab strip, which the window-chrome // geometry and the browser coverage lane anchor on. -
+
- {tabs.filter(viewTab => viewTab.id === FIXED_VIEW_ID || viewTab.id === active?.id - || !unpinned.includes(viewTab.id)).map(viewTab => ( + {visibleTabs.map(viewTab => ( ))}
{hasEntryActions && ( viewTab.id !== FIXED_VIEW_ID) - .map(viewTab => ({ id: viewTab.id, label: viewTab.label, pinned: !unpinned.includes(viewTab.id) }))} + label={currentInMore && active !== undefined ? t('session.moreViewsCurrent', { name: active.label }) : t('session.moreViews')} + current={currentInMore} + entries={moreTabs.map(viewTab => ({ + id: viewTab.id, label: viewTab.label, + pinned: !unpinned.includes(viewTab.id), active: viewTab.id === active?.id, + }))} onOpen={selectView} onPinnedChange={setPinned} renderSlot={renderSlot} diff --git a/packages/client/ui-conversation/src/client/skeleton/EmptyHero.tsx b/packages/client/ui-conversation/src/client/skeleton/EmptyHero.tsx index 40df87269b..b46db926da 100644 --- a/packages/client/ui-conversation/src/client/skeleton/EmptyHero.tsx +++ b/packages/client/ui-conversation/src/client/skeleton/EmptyHero.tsx @@ -138,12 +138,13 @@ export function HeroShell({ t, renderSlot, children }: HeroShellProps) { {/* figma 34:10412: fish 34×25 leading the headline, gap 10. */} { - if (window.matchMedia('(hover: hover) and (prefers-reduced-motion: no-preference)').matches) { + onPointerEnter={(event) => { + if (event.pointerType !== 'touch' + && window.matchMedia('(hover: hover) and (prefers-reduced-motion: no-preference)').matches) { setHovering(true) } }} - onMouseLeave={() => { setHovering(false) }} + onPointerLeave={() => { setHovering(false) }} > {renderSlot('conversation.hero.brand.mark', { size: 34, className: css.fish }, { fallback: , diff --git a/packages/client/ui-conversation/src/client/skeleton/InputBar.module.css b/packages/client/ui-conversation/src/client/skeleton/InputBar.module.css index 8e9041c151..67ad49f958 100644 --- a/packages/client/ui-conversation/src/client/skeleton/InputBar.module.css +++ b/packages/client/ui-conversation/src/client/skeleton/InputBar.module.css @@ -76,6 +76,12 @@ --dsh-scrollbar-thumb-hover: var(--dsw-alias-scrollbar-hover-l2); } +@media (pointer: coarse) { + .card { + font-size: max(16px, var(--dsh-content-font-size, 14px)); + } +} + /* No-workspace trigger state: an inset 1px dashed outline marks the card as a workspace picker rather than a live composer. Keeping the complete stroke inside the rounded box prevents clipped corner pixels from reading heavier. */ diff --git a/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx b/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx index ef95796577..fd0d9190ec 100644 --- a/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx +++ b/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx @@ -17,7 +17,7 @@ import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useStat import type { ChangeEvent, KeyboardEvent, MouseEvent } from 'react' import clsx from 'clsx' import { - IconPlusOutlineMedium, IconWarningOutlineRegular, Toast, Tooltip, + IconPlusOutlineMedium, IconWarningOutlineRegular, isMobileDevice, Toast, Tooltip, } from '@deepseek-ai/dsh-client-ui-primitives' // Type-only: the `plan` projection key merge (the TodoDock posture — the // composer reads a host-computed value; the domain owns the key). @@ -31,7 +31,7 @@ import type { Translate } from '@deepseek-ai/dsh-client-ui-slots' import type { ComposerBarProps } from '../contract/slots.ts' import { DraftEditor } from '../input/editor/DraftEditor.tsx' import { - focusDraftEditor, installDraftFilePicker, installDraftKeymap, installDraftWheel, + focusDraftEditor, installDraftFilePicker, installDraftKeyboardDismissal, installDraftKeymap, installDraftWheel, keepDraftFocus, revealDraftSelection, } from '../input/editor/view-binding.ts' import { resolveSubmitMode } from '../input/submission-policy.ts' @@ -48,6 +48,7 @@ export const InputBar = memo(function InputBar({ retryFileUpload, toggleCommandMenu, stop, t, renderSlot, useBusyEnter, useFileUploads, useNotices, useLexicon, useMenuLauncher, useStopShortcut, + useAccess, useProjection, sessionId, variant, disabled: inert = false, blocked, workspacePickerOpen = false, onRequestWorkspace, placeholder, accessory, @@ -56,6 +57,13 @@ export const InputBar = memo(function InputBar({ const notice = useNotices(s => s) const busyEnter = useBusyEnter(s => s) const stopKeys = useStopShortcut(keys => keys) + // A page whose access snapshot excludes prompting, stopping, or uploading to this Session is offered + // no Send, no Stop, or no file intake rather than a disabled control. `useAccess` is undefined only + // without a Session, where the bar is an inert placeholder with no keyboard, stop, or file intake. + const promptDenied = useAccess('session/prompt') === false + const cancelDenied = useAccess('session/cancel') === false + const uploadDenied = useAccess('fileUploads/upload') === false + const stopAction = cancelDenied ? undefined : stop void useLexicon // hook seat stays bound by the inject compartment; text-ref decoration rides the shell's editor transforms const commandMenuOpen = useMenuLauncher(source => source === 'command') const [activity, setActivity] = useState(false) @@ -80,11 +88,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 +107,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 @@ -172,13 +186,11 @@ export const InputBar = memo(function InputBar({ revealDraftSelection(scrollRef) } - // Unlock (mount / session switch) returns focus to the box, and owns the - // reveal that comes with it. Lexical's focus() suppresses the browser's - // scroll walk (preventScroll inside), so the reveal in our own scrollport - // is ours to perform — switching to a longer draft otherwise leaves the - // caret (restored at the draft's end) off screen. + // Automatic desktop focus restores and reveals the draft selection without + // scrolling the conversation. useEffect(() => { if (locked || editor === null) return + if (isMobileDevice()) return focusDraftEditor(editor, revealSelection) }, [locked, sessionId, editor]) @@ -210,7 +222,8 @@ export const InputBar = memo(function InputBar({ // The host enforces the same image limits at submit for callers that bypass // this composer. const intakeFiles = useCallback((files: readonly File[], directories?: ReadonlySet): void => { - if (locked || machineBusy || (subagent !== null && !continuable) || addFiles === undefined || files.length === 0) return + if (uploadDenied || locked || machineBusy || (subagent !== null && !continuable) + || addFiles === undefined || files.length === 0) return const rejected = ((): string | null => { // Child prompts support images, but not generic file receipts or the // Desktop path-reference fallback. Refuse mixed batches before intake. @@ -236,9 +249,9 @@ export const InputBar = memo(function InputBar({ return addFiles(files, directories) })() if (rejected !== null) showToast(rejected) - }, [locked, machineBusy, subagent, continuable, addFiles, attachments, imageLimits, showToast, t]) + }, [uploadDenied, locked, machineBusy, subagent, continuable, addFiles, attachments, imageLimits, showToast, t]) - const canAcceptDrop = (subagent === null || continuable) && !locked && !machineBusy && addFiles !== undefined + const canAcceptDrop = !uploadDenied && (subagent === null || continuable) && !locked && !machineBusy && addFiles !== undefined const fileInputRef = useRef(null) const onPickFiles = (e: ChangeEvent): void => { @@ -252,11 +265,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(() => { @@ -269,6 +282,11 @@ export const InputBar = memo(function InputBar({ return installDraftKeymap(editor, keyboard, gate) }, [editor, keyboard]) + useEffect(() => { + if (editor === null) return + return installDraftKeyboardDismissal(editor, cardRef) + }, [editor]) + // Button presses steal focus from the editor; suppress at mousedown so // typing continues seamlessly. Lexical's focus() carries preventScroll and // restores the previous selection, so no reveal is needed: the caret has @@ -284,7 +302,7 @@ export const InputBar = memo(function InputBar({ if (target.closest('[data-composer-overlay]') !== null) return if (scrollRef.current?.contains(target)) return const control = target.closest('button, a, input, select, textarea, [role="button"], [contenteditable], [tabindex]') - if (control !== null && e.currentTarget.contains(control)) return + if (control !== null && control !== e.currentTarget && e.currentTarget.contains(control)) return e.preventDefault() // Lexical's stored selection can lag behind native caret moves, so calling // editor.focus() on a focused editor can restore an older caret. @@ -322,7 +340,7 @@ export const InputBar = memo(function InputBar({ // exposes Stop independently. const primaryStops = running && subagent === null && (empty || blocked !== undefined) // Disabled native buttons may omit mouseleave; their tooltip must close from state. - const primaryDisabled = primaryStops ? stop === undefined : empty || disabled || machineBusy || uploadsPending + const primaryDisabled = primaryStops ? stopAction === undefined : empty || disabled || machineBusy || uploadsPending const interruptible = running && continuable const primarySubmitMode = resolveSubmitMode(busyEnter, running, 'enter', steeringAvailable) const plainMessageDraft = !empty && input?.phase === 'plain' && !draft.trimStart().startsWith('/') @@ -333,7 +351,7 @@ export const InputBar = memo(function InputBar({ : t('input.send') const onPrimary = (): void => { if (primaryStops) { - stop?.() + stopAction?.() return } if (keyboard === undefined) return // absent machine: the button is disabled @@ -394,6 +412,7 @@ export const InputBar = memo(function InputBar({ click's reopen (close-then-open flickers the chip's open echo). */}
{ removeAttachment?.(id) }, uploads, onRetryFile: (id) => { retryFileUpload?.(id) }, + uploadFailureText: reason => attachmentErrorText(t, reason, imageLimits), dropLimits: imageLimits === undefined ? undefined : { count: imageLimits.maxImagesPerMessage, size: imageSizeText(imageLimits.maxImageBytes), @@ -456,7 +476,7 @@ export const InputBar = memo(function InputBar({ ref={fileInputRef} type="file" multiple - disabled={subagent !== null} + disabled={!canAcceptDrop || subagent !== null} hidden onChange={onPickFiles} /> @@ -478,15 +498,15 @@ export const InputBar = memo(function InputBar({ {input === undefined || sessionId === undefined ? null :
{renderSlot('conversation.input.activity', { locked, onActiveChange: setActivity })}
} - {interruptible && ( - + {interruptible && !cancelDenied && ( + )} - - - + {(primaryStops ? cancelDenied : promptDenied) ? null : ( + + + + )}
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/src/client/view-pins.ts b/packages/client/ui-conversation/src/client/view-pins.ts index aebf97470a..d3d4e1f408 100644 --- a/packages/client/ui-conversation/src/client/view-pins.ts +++ b/packages/client/ui-conversation/src/client/view-pins.ts @@ -1,11 +1,9 @@ -/** Persisted pin choices for the Conversation View tabs. */ +/** In-memory pin choices for the Conversation View tabs. */ import { createSnapshotStore, type ObservableSnapshot } from '@deepseek-ai/dsh-client-store' /** View whose tab always shows and never enters the customization list. */ export const FIXED_VIEW_ID = 'chat' -/** Root-level key shared with the slot layer's future per-list preferences; Sessions share one list. */ -const STORAGE_KEY = 'dsh.slot.manual-order.conversation.view.v1' const NO_VIEWS: readonly string[] = [] /** Unpinned View ids and their only mutation. */ @@ -13,30 +11,24 @@ export interface ViewPins { /** Unpinned View ids; ids of Views that are not registered stay listed. */ readonly unpinned: ObservableSnapshot /** - * Show or hide one View tab; the fixed View is ignored. + * Pin or unpin one View tab; the fixed View is ignored. A pinned tab still + * enters More when the header's width budget cannot hold it. * @param id - View id. - * @param pinned - Whether the tab shows in the Conversation header. + * @param pinned - Whether the tab is manually pinned. */ readonly setPinned: (id: string, pinned: boolean) => void } -/** Stored content is unchecked; anything but a string array leaves every View pinned. */ -function unpinnedViews(value: unknown): readonly string[] { - if (typeof value !== 'object' || value === null || !('unpinned' in value)) return NO_VIEWS - const { unpinned } = value - return Array.isArray(unpinned) && unpinned.every((id): id is string => typeof id === 'string') ? unpinned : NO_VIEWS -} - /** - * Create the persisted View pin list; the snapshot keeps its reference until the list changes. - * @returns Validated unpinned View ids and the pin mutation. + * Create the in-memory View pin list; the snapshot keeps its reference until the list changes. + * @returns Unpinned View ids and the pin mutation. */ export function createViewPins(): ViewPins { - const store = createSnapshotStore({ unpinned: NO_VIEWS }, { persist: { name: STORAGE_KEY } }) + const store = createSnapshotStore<{ readonly unpinned: readonly string[] }>({ unpinned: NO_VIEWS }) return { - unpinned: { getSnapshot: () => unpinnedViews(store.getSnapshot()), subscribe: listener => store.subscribe(listener) }, + unpinned: { getSnapshot: () => store.getSnapshot().unpinned, subscribe: listener => store.subscribe(listener) }, setPinned: (id, pinned) => { - const current = unpinnedViews(store.getSnapshot()) + const current = store.getSnapshot().unpinned if (id === FIXED_VIEW_ID || pinned !== current.includes(id)) return store.set({ unpinned: pinned ? current.filter(entry => entry !== id) : [...current, id] }) }, 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/apply-wiring.client.spec.tsx b/packages/client/ui-conversation/tests/apply-wiring.client.spec.tsx index fa7f2fc4ef..a7b0bfdbdf 100644 --- a/packages/client/ui-conversation/tests/apply-wiring.client.spec.tsx +++ b/packages/client/ui-conversation/tests/apply-wiring.client.spec.tsx @@ -44,7 +44,7 @@ function entry( } describe('target-neutral Conversation apply wiring', () => { - it('hands every Session header the same persisted View pins', async () => { + it('hands every Session header the same in-memory View pins without restoring saved choices', async () => { localStorage.setItem('dsh.slot.manual-order.conversation.view.v1', JSON.stringify({ unpinned: ['trajectory'] })) try { const b = await bench() @@ -56,10 +56,10 @@ describe('target-neutral Conversation apply wiring', () => { const inject = header.inject as (sessionId: SessionId) => PinHooks const injected = inject(SID) const first = injected.hooks.entryPins - expect(first.getSnapshot()).toEqual(['trajectory']) + expect(first.getSnapshot()).toEqual([]) expect(inject('session-2' as SessionId).hooks.entryPins).toBe(first) injected.setPinned('timeline', false) - expect(first.getSnapshot()).toEqual(['trajectory', 'timeline']) + expect(first.getSnapshot()).toEqual(['timeline']) const actions = injected.hooks.entryActions const changed = vi.fn() actions.subscribe(changed) 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..9f7cc0a878 100644 --- a/packages/client/ui-conversation/tests/editor-focus.client.spec.ts +++ b/packages/client/ui-conversation/tests/editor-focus.client.spec.ts @@ -1,9 +1,10 @@ // @vitest-environment jsdom /** Composer selection survives focus transfers and background editor reconciliation. */ +import * as uiPrimitives from '@deepseek-ai/dsh-client-ui-primitives' import { expect, it, onTestFinished, vi } from 'vitest' import { $getRoot, $getSelection, $isRangeSelection, SELECTION_CHANGE_COMMAND, UNDO_COMMAND } from 'lexical' import { DraftEditorRuntime } from '../src/client/input/editor/runtime.ts' -import { focusDraftEditor } from '../src/client/input/editor/view-binding.ts' +import { focusDraftEditor, installDraftKeyboardDismissal } from '../src/client/input/editor/view-binding.ts' function fixture(start = 1, end = 4) { // jsdom has no Range geometry; these cases observe selection and focus, not scrolling. @@ -16,9 +17,12 @@ function fixture(start = 1, end = 4) { const root = document.createElement('div') root.setAttribute('contenteditable', 'true') root.tabIndex = 0 + const card = document.createElement('div') + card.tabIndex = -1 + card.append(root) const outside = document.createElement('button') - document.body.append(root, outside) - onTestFinished(() => { root.remove(); outside.remove() }) + document.body.append(card, outside) + onTestFinished(() => { card.remove(); outside.remove() }) // jsdom collapses document Selection on every focus(); Chromium retains the // editor range here. Keep native focus events and activeElement changes. for (const element of [root, outside]) { @@ -37,6 +41,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, }) @@ -65,7 +70,7 @@ function fixture(start = 1, end = 4) { editor.read(() => {}) return reveal } - return { runtime, editor, root, outside, onUpdate, dispose, selection, domSelection, focusComposer } + return { runtime, editor, root, card, outside, onUpdate, dispose, selection, domSelection, focusComposer } } it.each([ @@ -137,6 +142,48 @@ it('restores a pending selection when blur and explicit focus share one update b expect(reveal).toHaveBeenCalledOnce() }) +it('hands mobile submission focus to the composer without stealing focus from another control', async () => { + const userAgent = vi.spyOn(navigator, 'userAgent', 'get').mockReturnValue('Mozilla/5.0 (iPhone; CPU iPhone OS 18_6 like Mac OS X) AppleWebKit/605.1.15 Version/18.6 Mobile/15E148 Safari/604.1') + onTestFinished(() => { userAgent.mockRestore() }) + vi.resetModules() + const detection = await import('@deepseek-ai/dsh-client-ui-primitives/src/focus.ts') + const device = vi.spyOn(uiPrimitives, 'isMobileDevice').mockImplementation(detection.isMobileDevice) + onTestFinished(() => { device.mockRestore() }) + const f = fixture() + const dispose = installDraftKeyboardDismissal(f.editor, { current: f.card }) + onTestFinished(dispose) + + f.runtime.clearCommittedDraft(() => null) + expect(f.root.textContent).toBe('') + expect(document.activeElement).toBe(f.card) + + f.runtime.setDraft('next draft') + f.outside.focus() + f.runtime.clearCommittedDraft(() => null) + expect(document.activeElement).toBe(f.outside) +}) + +it('retains mobile focus for a newer draft suffix and removes dismissal with the view', async () => { + const userAgent = vi.spyOn(navigator, 'userAgent', 'get').mockReturnValue('Mozilla/5.0 (iPhone; CPU iPhone OS 18_6 like Mac OS X) AppleWebKit/605.1.15 Version/18.6 Mobile/15E148 Safari/604.1') + onTestFinished(() => { userAgent.mockRestore() }) + vi.resetModules() + const detection = await import('@deepseek-ai/dsh-client-ui-primitives/src/focus.ts') + const device = vi.spyOn(uiPrimitives, 'isMobileDevice').mockImplementation(detection.isMobileDevice) + onTestFinished(() => { device.mockRestore() }) + const f = fixture() + const dispose = installDraftKeyboardDismissal(f.editor, { current: f.card }) + onTestFinished(dispose) + + f.runtime.clearCommittedDraft(() => 6) + expect(f.root.textContent).toBe('beta') + expect(document.activeElement).toBe(f.root) + + dispose() + f.runtime.clearCommittedDraft(() => null) + expect(f.root.textContent).toBe('') + expect(document.activeElement).toBe(f.root) +}) + it('removes focus preservation and update subscriptions when registration is disposed', () => { const f = fixture() f.dispose() diff --git a/packages/client/ui-conversation/tests/editor-viewport.client.spec.ts b/packages/client/ui-conversation/tests/editor-viewport.client.spec.ts new file mode 100644 index 0000000000..b000350885 --- /dev/null +++ b/packages/client/ui-conversation/tests/editor-viewport.client.spec.ts @@ -0,0 +1,73 @@ +// @vitest-environment jsdom +/** Composer caret visibility across page scrolling and virtual-keyboard viewport changes. */ +import { expect, it, onTestFinished, vi } from 'vitest' +import { DraftEditorRuntime } from '../src/client/input/editor/runtime.ts' + +function fixture(viewport: { height: number; offsetTop: number; pageTop: number } | undefined, pageScroll: number) { + // jsdom has no visual viewport or Range geometry; use the browser measurements + // while leaving Lexical's DOM selection and scrolling decisions intact. + vi.stubGlobal('visualViewport', viewport) + vi.stubGlobal('scrollY', pageScroll) + vi.stubGlobal('innerHeight', 600) + const scrollBy = vi.spyOn(window, 'scrollBy').mockImplementation(() => {}) + const geometry = Object.getOwnPropertyDescriptor(Range.prototype, 'getBoundingClientRect') + let caret = new DOMRect(20, 169, 0, 26) + Object.defineProperty(Range.prototype, 'getBoundingClientRect', { + configurable: true, + value: () => caret, + }) + const root = document.createElement('div') + root.setAttribute('contenteditable', 'true') + root.tabIndex = 0 + root.getBoundingClientRect = () => new DOMRect(0, 0, 400, 1000) + document.body.append(root) + const runtime: DraftEditorRuntime = new DraftEditorRuntime({ + onUpdate: () => { runtime.refreshProjection() }, + quoteLabels: () => ({ remove: 'Remove quote' }), + openReference: () => false, + lexicon: () => new Map(), resolveLexicon: () => undefined, + }) + const dispose = runtime.register() + onTestFinished(() => { + dispose() + root.remove() + scrollBy.mockRestore() + if (geometry === undefined) Reflect.deleteProperty(Range.prototype, 'getBoundingClientRect') + else Object.defineProperty(Range.prototype, 'getBoundingClientRect', geometry) + vi.unstubAllGlobals() + }) + runtime.editor.setRootElement(root) + root.focus({ preventScroll: true }) + scrollBy.mockClear() + return { + scrollBy, + typeAt(top: number): void { + caret = new DOMRect(20, top, 0, 26) + runtime.setDraft('a') + }, + } +} + +it('keeps a visible caret still after Safari pans the page above the keyboard', () => { + const f = fixture({ height: 376, offsetTop: 196, pageTop: 196 }, 196) + f.typeAt(169) + expect(f.scrollBy).not.toHaveBeenCalled() +}) + +it('reveals a caret below the keyboard after the page has scrolled', () => { + const f = fixture({ height: 376, offsetTop: 196, pageTop: 196 }, 196) + f.typeAt(410) + expect(f.scrollBy).toHaveBeenCalledExactlyOnceWith(0, 60) +}) + +it('retains visual viewport panning relative to an already scrolled page', () => { + const f = fixture({ height: 376, offsetTop: 150, pageTop: 350 }, 200) + f.typeAt(100) + expect(f.scrollBy).toHaveBeenCalledExactlyOnceWith(0, -50) +}) + +it('uses the window height when the browser has no visual viewport', () => { + const f = fixture(undefined, 200) + f.typeAt(630) + expect(f.scrollBy).toHaveBeenCalledExactlyOnceWith(0, 56) +}) diff --git a/packages/client/ui-conversation/tests/header-styles.client.spec.ts b/packages/client/ui-conversation/tests/header-styles.client.spec.ts index c3abd2bcf4..3c964950d0 100644 --- a/packages/client/ui-conversation/tests/header-styles.client.spec.ts +++ b/packages/client/ui-conversation/tests/header-styles.client.spec.ts @@ -8,8 +8,10 @@ describe('conversation header geometry', () => { it('keeps the single-line header at the shared 76px height', () => { expect(css.match(/\.header\s*\{([^}]*)\}/)?.[1]).toContain('min-height: 76px') expect(css.match(/\.viewTabs\s*\{([^}]*)\}/)?.[1]).toContain('min-height: 25px') - expect(css.match(/\.tab\s*\{([^}]*)\}/)?.[1]).toContain('min-height: 25px') - expect(css.match(/\.tab\s*\{([^}]*)\}/)?.[1]).toContain('padding: 0 0 9px') + // The tab's 35px press target sits 10px above its 25px row through a negative top margin. + expect(css.match(/\.tab\s*\{([^}]*)\}/)?.[1]).toContain('min-height: 35px') + expect(css.match(/\.tab\s*\{([^}]*)\}/)?.[1]).toContain('margin: -10px calc(-1 * var(--dsh-tab-hit-inline)) 0') + expect(css.match(/\.tab\s*\{([^}]*)\}/)?.[1]).toContain('padding: 10px var(--dsh-tab-hit-inline) 9px') }) it('uses 32px between view labels without changing the action gap', () => { 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..a6968a7055 100644 --- a/packages/client/ui-conversation/tests/input-bar.client.spec.tsx +++ b/packages/client/ui-conversation/tests/input-bar.client.spec.tsx @@ -8,6 +8,7 @@ // root listener routes them through the keymap commands); draft writes drive // the shell (jsdom's beforeinput lacks the ranges Lexical needs). +import * as uiPrimitives from '@deepseek-ai/dsh-client-ui-primitives' import './control-row-dom.ts' import type { InboxState } from '@deepseek-ai/dsh-agent/types' import type { GlobalStandardProps } from '@deepseek-ai/dsh-client-ui-slots' @@ -105,6 +106,8 @@ interface BenchOptions { attachments?: readonly ComposerAttachment[] /** Upload states served for file-kind drafts (absent = every file is ready). */ fileUploads?: DraftFileUploads + /** Endpoints the Session's access snapshot excludes (absent = every endpoint is allowed). */ + deniedEndpoints?: readonly string[] addFiles?: (files: readonly File[], directories?: ReadonlySet) => string | null commandMenuOpen?: boolean busyEnter?: 'queue' | 'steer' @@ -192,6 +195,7 @@ function bench(over?: BenchOptions) { items: [], archivedSessionIds: [], pinnedSessionIds: [], state: 'idle', phase: 'ready', error: null, })), ...allowAllAccess, + useAccess: endpoint => !(over?.deniedEndpoints ?? []).includes(endpoint), useProjection: ((key: string, selector?: (v: unknown) => unknown) => (selector ?? (v => v))(key === 'plan' ? over?.plan @@ -242,8 +246,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 ? '停止生成' @@ -706,6 +711,23 @@ describe('image draft rail', () => { } }) + it('keeps text editable but refuses attachment-slot, picker and paste intake without permission', () => { + const added = vi.fn(() => null) + const result = bench({ deniedEndpoints: ['fileUploads/upload'], addFiles: added, draft: 'text stays editable' }) + const attachment = attachmentOwner(result.slotCalls) + expect(attachment.canAcceptDrop).toBe(false) + const file = new File(['note'], 'note.txt') + attachment.onAddFiles([file]) + const picker = result.view.container.querySelector('input[type=file]')! + expect(picker.disabled).toBe(true) + fireEvent.change(picker, { target: { files: [file] } }) + fireEvent.paste(result.textarea, { clipboardData: { + items: [{ kind: 'file', type: 'text/plain', getAsFile: () => file }], getData: () => '', + } }) + expect(added).not.toHaveBeenCalled() + expect(result.textarea.getAttribute('contenteditable')).toBe('true') + }) + it('announces a rejected attachment-slot intake through the same toast', () => { const addFiles = vi.fn(() => '图片读取服务不可用') const result = bench({ addFiles }) @@ -1100,6 +1122,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('发送消息') @@ -1143,6 +1217,41 @@ describe('running and lock semantics', () => { expect(sink).toHaveBeenCalledWith('直接插话', [], 'steer', expect.any(AbortSignal)) }) + it('a running composer whose access excludes prompting and stopping offers neither Stop nor Send', () => { + const { view, stop } = bench({ + running: true, + // The bar is handed a live Stop handler all the same, so what withholds the action is the access + // answer and not a missing face. + deniedEndpoints: ['session/prompt', 'session/cancel'], + blocked: { reason: '只读查看:本页由 owner 分享,无法发送消息' }, + placeholder: '只读查看:本页由 owner 分享,无法发送消息', + }) + expect(view.container.querySelector('button[aria-label="停止生成"]')).toBeNull() + expect(view.container.querySelector('button[aria-label="发送消息"]')).toBeNull() + expect(stop).not.toHaveBeenCalled() + }) + + it('a running composer whose access excludes only prompting keeps Stop but offers no busy Send', () => { + const empty = bench({ running: true, deniedEndpoints: ['session/prompt'] }) + expect(empty.view.getByLabelText('停止生成')).toBeTruthy() + cleanup() + // A draft would make the primary action the busy Send, which the snapshot excludes. + const drafted = bench({ running: true, deniedEndpoints: ['session/prompt'], draft: '排到下一轮' }) + expect(drafted.view.container.querySelector('button[aria-label="排队发送"]')).toBeNull() + }) + + it('an idle composer whose access excludes prompting offers no Send', () => { + const { view } = bench({ deniedEndpoints: ['session/prompt'], draft: 'hello' }) + expect(view.container.querySelector('button[aria-label="发送消息"]')).toBeNull() + }) + + it('a composer whose access has no answer yet keeps its ordinary Send', () => { + const { view, props } = bench({ draft: 'hello' }) + // Without a bound Session the Session-scope Hook answers undefined, which denies nothing. + view.rerender( undefined} />) + expect(view.getByLabelText('发送消息')).toBeTruthy() + }) + it('running Cmd/Ctrl+Enter uses the opposite of the busy-state Enter preference', () => { const meta = bench({ running: true, busyEnter: 'steer', draft: '排到下一轮' }) fireEvent.keyDown(meta.textarea, { key: 'Enter', metaKey: true }) @@ -1415,6 +1524,81 @@ describe('running and lock semantics', () => { expect(focused).toEqual([true]) }) + it.each([ + { name: 'iPhone', userAgent: 'Mozilla/5.0 (iPhone; CPU iPhone OS 18_6 like Mac OS X) AppleWebKit/605.1.15 Version/18.6 Mobile/15E148 Safari/604.1', platform: 'iPhone', maxTouchPoints: 5, automatic: false }, + { name: 'Android phone', userAgent: 'Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 Chrome/119.0.0.0 Mobile Safari/537.36', platform: 'Linux armv8l', maxTouchPoints: 0, automatic: false }, + { name: 'Android tablet', userAgent: 'Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 Chrome/119.0.0.0 Safari/537.36', platform: 'Linux armv8l', maxTouchPoints: 5, automatic: false }, + { name: 'desktop-mode iPad', userAgent: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 Version/18.6 Safari/605.1.15', platform: 'MacIntel', maxTouchPoints: 5, automatic: false }, + { name: 'Mac', userAgent: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 Version/18.6 Safari/605.1.15', platform: 'MacIntel', maxTouchPoints: 0, automatic: true }, + { name: 'Windows touchscreen', userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/119.0.0.0 Safari/537.36', platform: 'Win32', maxTouchPoints: 5, automatic: true }, + ])('composer focus and submission respect device classification: $name', async ({ userAgent, platform, maxTouchPoints, automatic }) => { + for (const [name, value] of Object.entries({ userAgent, platform, maxTouchPoints })) { + const descriptor = Object.getOwnPropertyDescriptor(navigator, name) + Object.defineProperty(navigator, name, { configurable: true, value }) + onTestFinished(() => { + if (descriptor === undefined) Reflect.deleteProperty(navigator, name) + else Object.defineProperty(navigator, name, descriptor) + }) + } + vi.resetModules() + const detection = await import('@deepseek-ai/dsh-client-ui-primitives/src/focus.ts') + const device = vi.spyOn(uiPrimitives, 'isMobileDevice').mockImplementation(detection.isMobileDevice) + onTestFinished(() => { device.mockRestore() }) + const outside = document.createElement('input') + document.body.append(outside) + onTestFinished(() => { outside.remove() }) + outside.focus() + + const { view, textarea, props, shell, button, sink } = bench({ draft: 'draft' }) + expect(document.activeElement).toBe(automatic ? textarea : outside) + act(() => { outside.focus() }) + act(() => { view.rerender() }) + expect(document.activeElement).toBe(automatic ? textarea : outside) + act(() => { outside.focus() }) + act(() => { view.rerender() }) + act(() => { view.rerender() }) + expect(document.activeElement).toBe(automatic ? textarea : outside) + + act(() => { outside.focus() }) + fireEvent.mouseDown(view.container.querySelector('[data-composer-card]')!, { button: 0 }) + expect(document.activeElement).toBe(textarea) + + writeDraft(shell, '') + expect(document.activeElement).toBe(textarea) + fireEvent.keyDown(textarea, { key: 'Enter' }) + expect(sink).not.toHaveBeenCalled() + expect(document.activeElement).toBe(textarea) + + for (const gesture of ['click', 'enter'] as const) { + writeDraft(shell, `send by ${gesture}`) + act(() => { textarea.focus() }) + if (gesture === 'click') { + fireEvent.mouseDown(button, { button: 0 }) + fireEvent.click(button) + } else { + fireEvent.keyDown(textarea, { key: 'Enter' }) + } + await act(async () => {}) + expect(sink).toHaveBeenLastCalledWith(`send by ${gesture}`, [], 'queue', expect.any(AbortSignal)) + expect(shell.snapshot.draft).toBe('') + expect(document.activeElement === textarea).toBe(automatic) + } + + writeDraft(shell, '/goal ') + act(() => { + shell.beginCommand( + { name: 'goal', token: '/goal ', submit: () => Promise.resolve({ kind: 'error', text: 'Missing goal' }) }, + { start: 0, end: 6, draftRev: shell.snapshot.draftRev }, + ) + textarea.focus() + }) + fireEvent.keyDown(textarea, { key: 'Enter' }) + await act(async () => {}) + expect(shell.snapshot.phase).toBe('claimed') + expect(shell.snapshot.draft).toBe('/goal ') + expect(document.activeElement).toBe(textarea) + }) + it('a persisted draft adopted after mount does not steal focus from another control', () => { const { shell } = bench() const other = document.createElement('input') @@ -1830,6 +2014,21 @@ describe('command launcher chrome and control seats', () => { expect(launcher.getAttribute('aria-expanded')).toBe('true') }) + it('follows the access answers over a running Session, and restores the actions when they return', () => { + const { view, props } = bench({ running: true }) + // While the page may stop the running Session, the primary action is its Stop. + expect(view.getByLabelText('停止生成')).toBeTruthy() + + // The access snapshot now excludes the Session's writes: the bar offers neither Stop nor Send. + view.rerender( false} />) + expect(view.queryByLabelText('停止生成')).toBeNull() + expect(view.queryByLabelText('发送消息')).toBeNull() + + // The answers return: the bar offers the action the page holds again. + view.rerender() + expect(view.getByLabelText('停止生成')).toBeTruthy() + }) + it('opening the command menu from the button puts the keyboard in the editor first', () => { const toggleCommandMenu = vi.fn() const { view, textarea } = bench({ toggleCommandMenu }) 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/skeleton.client.spec.tsx b/packages/client/ui-conversation/tests/skeleton.client.spec.tsx index b278797d5d..bda16c8a08 100644 --- a/packages/client/ui-conversation/tests/skeleton.client.spec.tsx +++ b/packages/client/ui-conversation/tests/skeleton.client.spec.tsx @@ -85,6 +85,7 @@ function fireResize(el: Element): void { afterEach(() => { cleanup() + vi.restoreAllMocks() vi.unstubAllGlobals() resizeObservers.length = 0 }) @@ -144,6 +145,8 @@ function mount( unpinnedViews?: readonly string[] /** Whether the View actions list has registrations. */ entryActions?: boolean + /** Isolate header capability checks from the composer's required layout observers. */ + headerOnly?: boolean } = {}, ) { const sessionId = 'sessionId' in options ? options.sessionId : SID @@ -340,6 +343,7 @@ function mount( const renderFactorySlot = ((_name: string, input: ConversationContentInputProps, factoryOptions?: { slots?: Record ReactNode> }) => { + if (options.headerOnly) return null const common: ConversationViewsProps = { sessionId: SID, SessionProvider, @@ -681,7 +685,30 @@ describe('ConversationRoot resident composer', () => { expect(b.view.getByRole('tablist').hasAttribute('data-conversation-tabs')).toBe(true) }) - it('keeps the selected View tab while it is unpinned and hides it once another View is selected', () => { + it('keeps Chat outside at the narrowest width even when it registers after other Views', () => { + vi.spyOn(HTMLElement.prototype, 'clientWidth', 'get').mockReturnValue(180) + const viewTabs = ['trajectory', 'timeline', 'chat', 'notes'].map(id => ({ id, label: id })) + const b = mount(sessionSnapshotOf(), undefined, undefined, { viewTabs, entryActions: true }) + expect(within(b.view.getByRole('tablist')).getAllByRole('tab').map(tab => tab.textContent)).toEqual(['chat']) + fireEvent.click(b.view.getByRole('button', { name: zh['session.moreViews'] })) + expect(b.actionsOwner().entries.map(entry => entry.id)).toEqual(['trajectory', 'timeline', 'notes']) + act(() => { b.actionsOwner().onOpen('trajectory') }) + expect(b.view.getByRole('tab', { name: 'chat' })).toBeTruthy() + expect(b.view.queryByRole('tab', { name: 'trajectory' })).toBeNull() + expect(b.actionsOwner().entries.find(entry => entry.id === 'trajectory')?.active).toBe(true) + }) + + it('calculates the initial tab budget when ResizeObserver is unavailable', () => { + vi.stubGlobal('ResizeObserver', undefined) + vi.spyOn(HTMLElement.prototype, 'clientWidth', 'get').mockReturnValue(300) + const viewTabs = ['chat', 'trajectory', 'timeline'].map(id => ({ id, label: id })) + const b = mount(sessionSnapshotOf(), undefined, undefined, { viewTabs, entryActions: true, headerOnly: true }) + expect(within(b.view.getByRole('tablist')).getAllByRole('tab').map(tab => tab.textContent)).toEqual(['chat', 'trajectory']) + fireEvent.click(b.view.getByRole('button', { name: zh['session.moreViews'] })) + expect(b.actionsOwner().entries.map(entry => entry.id)).toEqual(['timeline']) + }) + + it('keeps an unpinned View selected without exposing its tab', () => { const viewTabs: ViewTab[] = [ { id: 'chat', label: 'Chat' }, { id: 'trajectory', label: 'Trajectory' }, @@ -691,7 +718,7 @@ describe('ConversationRoot resident composer', () => { act(() => { b.store.actions.setView('trajectory') }) const tabs = (): HTMLElement[] => within(b.view.getByRole('tablist')).getAllByRole('tab') expect(tabs().map(tab => [tab.textContent, tab.getAttribute('aria-selected')])).toEqual([ - ['Chat', 'false'], ['Trajectory', 'true'], ['Timeline', 'false'], + ['Chat', 'false'], ['Timeline', 'false'], ]) expect(b.view.getByTestId('view-trajectory')).toBeTruthy() act(() => { b.store.actions.setView('chat') }) @@ -708,6 +735,75 @@ describe('ConversationRoot resident composer', () => { expect(b.view.queryByRole('menu')).toBeNull() }) + it('reuses the width budget on View roster and selection changes without a window resize listener', () => { + let width = 480 + const readWidth = vi.spyOn(HTMLElement.prototype, 'clientWidth', 'get').mockImplementation(() => width) + const viewTabs: ViewTab[] = [ + { id: 'chat', label: 'Chat' }, + { id: 'trajectory', label: 'Trajectory' }, + { id: 'timeline', label: 'Timeline' }, + { id: 'inspector', label: 'Inspector' }, + ] + const b = mount(sessionSnapshotOf(), undefined, undefined, { viewTabs, entryActions: true }) + const external = () => within(b.view.getByRole('tablist')).getAllByRole('tab').map(tab => tab.textContent) + expect(external()).toEqual(['Chat', 'Trajectory', 'Timeline']) + fireEvent.click(b.view.getByRole('button', { name: zh['session.moreViews'] })) + expect(b.actionsOwner().entries.map(entry => entry.id)).toEqual(['inspector']) + act(() => { b.actionsOwner().onOpen('inspector') }) + expect(external()).toEqual(['Chat', 'Trajectory', 'Timeline']) + expect(b.actionsOwner().entries.find(entry => entry.id === 'inspector')?.active).toBe(true) + expect(b.view.getByRole('button', { name: '更多会话视图 · Inspector' })).toBeTruthy() + const reads = readWidth.mock.calls.length + width = 1000 + fireEvent(window, new Event('resize')) + act(() => { b.store.actions.setView('timeline') }) + expect(readWidth).toHaveBeenCalledTimes(reads) + expect(external()).toEqual(['Chat', 'Trajectory', 'Timeline']) + viewTabs.splice(1, 1) + act(() => { b.store.actions.setView('chat') }) + expect(external()).toEqual(['Chat', 'Timeline', 'Inspector']) + expect(readWidth).toHaveBeenCalledTimes(reads) + expect(b.actionsOwner().entries).toEqual([]) + expect(b.setPinned).not.toHaveBeenCalled() + }) + + it('folds and restores Views on container resize without changing selection, and disconnects on unmount', () => { + let width = 1000 + vi.spyOn(HTMLElement.prototype, 'clientWidth', 'get').mockImplementation(() => width) + const viewTabs = ['chat', 'trajectory', 'timeline', 'inspector', 'notes'].map(id => ({ id, label: id })) + const b = mount(sessionSnapshotOf(), undefined, undefined, { viewTabs, entryActions: true }) + const row = b.view.getByRole('tablist').parentElement! + const external = () => within(b.view.getByRole('tablist')).getAllByRole('tab').map(tab => tab.textContent) + expect(external()).toEqual(viewTabs.map(tab => tab.label)) + act(() => { b.store.actions.setView('notes') }) + width = 300 + act(() => { fireResize(row) }) + expect(external()).toEqual(['chat', 'trajectory']) + fireEvent.click(b.view.getByRole('button', { name: '更多会话视图 · notes' })) + expect(b.actionsOwner().entries.map(entry => entry.id)).toEqual(['timeline', 'inspector', 'notes']) + expect(b.actionsOwner().entries.find(entry => entry.id === 'notes')?.active).toBe(true) + width = 1000 + act(() => { fireResize(row) }) + expect(external()).toEqual(viewTabs.map(tab => tab.label)) + expect(b.view.getByRole('tab', { name: 'notes' }).getAttribute('aria-selected')).toBe('true') + expect(b.actionsOwner().entries).toEqual([]) + expect(b.setPinned).not.toHaveBeenCalled() + const observers = resizeObservers.filter(observer => observer.targets.includes(row)) + expect(observers).toHaveLength(1) + b.view.unmount() + expect(observers[0]!.targets).toEqual([]) + }) + + it('keeps all five Views outside when the sampled width budget is sufficient', () => { + vi.spyOn(HTMLElement.prototype, 'clientWidth', 'get').mockReturnValue(1000) + const viewTabs = ['chat', 'trajectory', 'timeline', 'inspector', 'notes'].map(id => ({ id, label: id })) + const b = mount(sessionSnapshotOf(), undefined, undefined, { viewTabs, entryActions: true }) + expect(within(b.view.getByRole('tablist')).getAllByRole('tab').map(tab => tab.textContent)) + .toEqual(viewTabs.map(tab => tab.label)) + fireEvent.click(b.view.getByRole('button', { name: zh['session.moreViews'] })) + expect(b.actionsOwner().entries).toEqual([]) + }) + it('omits More while the View actions list has no registrations', () => { const b = mount(sessionSnapshotOf()) expect(b.view.getByRole('tablist')).toBeTruthy() @@ -726,11 +822,13 @@ describe('ConversationRoot resident composer', () => { fireEvent.click(more) expect(more.getAttribute('aria-expanded')).toBe('true') expect(within(b.view.getByRole('menu')).getByRole('menuitem', { name: 'Header view action' })).toBeTruthy() - expect(b.actionsOwner().entries).toEqual([{ id: 'trajectory', label: 'Trajectory', pinned: false }]) + expect(b.actionsOwner().entries).toEqual([{ id: 'trajectory', label: 'Trajectory', pinned: false, active: false }]) b.actionsOwner().onPinnedChange('trajectory', true) expect(b.setPinned).toHaveBeenCalledExactlyOnceWith('trajectory', true) act(() => { b.actionsOwner().onOpen('trajectory') }) expect(b.view.getByTestId('view-trajectory')).toBeTruthy() + expect(b.view.queryByRole('tab', { name: 'Trajectory' })).toBeNull() + expect(b.actionsOwner().entries).toEqual([{ id: 'trajectory', label: 'Trajectory', pinned: false, active: true }]) fireEvent.click(b.view.getByRole('menuitem', { name: 'Header view action' })) expect(b.view.queryByRole('menu')).toBeNull() expect(more.getAttribute('aria-expanded')).toBe('false') 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-conversation/tests/view-pins.client.spec.ts b/packages/client/ui-conversation/tests/view-pins.client.spec.ts index b42e6171ee..fa4b42d53d 100644 --- a/packages/client/ui-conversation/tests/view-pins.client.spec.ts +++ b/packages/client/ui-conversation/tests/view-pins.client.spec.ts @@ -1,5 +1,5 @@ // @vitest-environment jsdom -/** Conversation View pins persist one root list for every Session and ignore Chat. */ +/** Conversation View pins share one in-memory root list for every Session and ignore Chat. */ import { afterEach, describe, expect, it, vi } from 'vitest' import { createViewPins } from '../src/client/view-pins.ts' @@ -7,15 +7,15 @@ const KEY = 'dsh.slot.manual-order.conversation.view.v1' afterEach(() => { localStorage.clear() }) describe('Conversation View pins', () => { - it('persists unpinned View ids and restores them in a new instance', () => { + it('keeps unpinned View ids in memory without restoring them in a new instance', () => { const pins = createViewPins() const initial = pins.unpinned.getSnapshot() expect(initial).toEqual([]) expect(pins.unpinned.getSnapshot()).toBe(initial) pins.setPinned('trajectory', false) pins.setPinned('timeline', false) - expect(JSON.parse(localStorage.getItem(KEY) ?? 'null')).toEqual({ unpinned: ['trajectory', 'timeline'] }) - expect(createViewPins().unpinned.getSnapshot()).toEqual(['trajectory', 'timeline']) + expect(localStorage.getItem(KEY)).toBeNull() + expect(createViewPins().unpinned.getSnapshot()).toEqual([]) pins.setPinned('trajectory', true) expect(pins.unpinned.getSnapshot()).toEqual(['timeline']) }) @@ -34,20 +34,14 @@ describe('Conversation View pins', () => { }) it('keeps ids of Views that are not registered', () => { - localStorage.setItem(KEY, JSON.stringify({ unpinned: ['removed-view'] })) const pins = createViewPins() + pins.setPinned('removed-view', false) pins.setPinned('timeline', false) expect(pins.unpinned.getSnapshot()).toEqual(['removed-view', 'timeline']) }) - it.each([ - ['a non-array list', { unpinned: { timeline: true } }], - ['non-string ids', { unpinned: [null] }], - ['a missing list', { pinned: [] }], - ['null', null], - ['an array root', ['timeline']], - ])('reads %s as no unpinned Views with a stable snapshot', (_name, stored) => { - localStorage.setItem(KEY, JSON.stringify(stored)) + it('ignores a legacy stored list instead of restoring it', () => { + localStorage.setItem(KEY, JSON.stringify({ unpinned: ['trajectory'] })) const pins = createViewPins() const snapshot = pins.unpinned.getSnapshot() expect(snapshot).toEqual([]) diff --git a/packages/client/ui-deepseek-model-label-1017/locale/de.json b/packages/client/ui-deepseek-model-label-1017/locale/de.json new file mode 100644 index 0000000000..97f572f313 --- /dev/null +++ b/packages/client/ui-deepseek-model-label-1017/locale/de.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "DeepSeek 1017 Modellplakette", + "description": "Fügen Sie den offiziellen DeepSeek Flash-Modellnamen ein temporäres 1017-Release-Abzeichen hinzu." + } +} 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/es.json b/packages/client/ui-deepseek-model-label-1017/locale/es.json new file mode 100644 index 0000000000..39db24f093 --- /dev/null +++ b/packages/client/ui-deepseek-model-label-1017/locale/es.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "Insignia del modelo DeepSeek 1017", + "description": "Agregue una insignia de lanzamiento temporal 1017 a los nombres oficiales de los modelos Flash DeepSeek." + } +} diff --git a/packages/client/ui-deepseek-model-label-1017/locale/fr.json b/packages/client/ui-deepseek-model-label-1017/locale/fr.json new file mode 100644 index 0000000000..a0d47622b6 --- /dev/null +++ b/packages/client/ui-deepseek-model-label-1017/locale/fr.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "Badge modèle DeepSeek 1017", + "description": "Ajoutez un badge de version 1017 temporaire aux noms de modèles Flash DeepSeek officiels." + } +} 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/pt.json b/packages/client/ui-deepseek-model-label-1017/locale/pt.json new file mode 100644 index 0000000000..10be9f4c03 --- /dev/null +++ b/packages/client/ui-deepseek-model-label-1017/locale/pt.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "DeepSeek selo do modelo 1017", + "description": "Adicione um selo de versão temporário 1017 aos nomes oficiais dos modelos do DeepSeek Flash." + } +} diff --git a/packages/client/ui-deepseek-model-label-1017/locale/ru.json b/packages/client/ui-deepseek-model-label-1017/locale/ru.json new file mode 100644 index 0000000000..1d116a03be --- /dev/null +++ b/packages/client/ui-deepseek-model-label-1017/locale/ru.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "DeepSeek Значок модели 1017", + "description": "Добавьте временный значок выпуска 1017 к официальным названиям моделей Flash DeepSeek." + } +} 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..7e35d4e14c 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, es, fr, de, ru, pt, 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, es, fr, de, ru, pt }), '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..d23df8c829 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,23 @@ 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 + +/** Spanish dictionary, checked against the English keys. */ +export const es = { 'badge.label': '1017' } satisfies Record + +/** French dictionary, checked against the English keys. */ +export const fr = { 'badge.label': '1017' } satisfies Record + +/** German dictionary, checked against the English keys. */ +export const de = { 'badge.label': '1017' } satisfies Record + +/** Russian dictionary, checked against the English keys. */ +export const ru = { 'badge.label': '1017' } satisfies Record + +/** Portuguese dictionary, checked against the English keys. */ +export const pt = { '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/de.json b/packages/client/ui-deliverables/locale/de.json new file mode 100644 index 0000000000..a87831a167 --- /dev/null +++ b/packages/client/ui-deliverables/locale/de.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "Dokumentänderungen und Lieferpräsentation", + "description": "Anzeige geänderter Dateien, Inhaltsvergleich und Eingabe der Lieferdatei." + } +} diff --git a/packages/client/ui-deliverables/locale/es.json b/packages/client/ui-deliverables/locale/es.json new file mode 100644 index 0000000000..930c363d05 --- /dev/null +++ b/packages/client/ui-deliverables/locale/es.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "Cambios de documentos y presentación de entrega.", + "description": "Muestra archivos modificados, comparación de contenido y entrada de archivos de entrega." + } +} diff --git a/packages/client/ui-deliverables/locale/fr.json b/packages/client/ui-deliverables/locale/fr.json new file mode 100644 index 0000000000..965bcb788d --- /dev/null +++ b/packages/client/ui-deliverables/locale/fr.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "Modifications du document et présentation de la livraison", + "description": "Afficher les fichiers modifiés, la comparaison du contenu et la saisie du fichier de livraison." + } +} 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/locale/pt.json b/packages/client/ui-deliverables/locale/pt.json new file mode 100644 index 0000000000..caef83a2ab --- /dev/null +++ b/packages/client/ui-deliverables/locale/pt.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "Alterações de documentos e apresentação de entrega", + "description": "Exibir arquivos modificados, comparação de conteúdo e entrada de arquivo de entrega." + } +} diff --git a/packages/client/ui-deliverables/locale/ru.json b/packages/client/ui-deliverables/locale/ru.json new file mode 100644 index 0000000000..b940a1eba1 --- /dev/null +++ b/packages/client/ui-deliverables/locale/ru.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..5750f585f3 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, es, fr, de, ru, pt, 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, es, fr, de, ru, pt }), '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..e25cc96c43 100644 --- a/packages/client/ui-deliverables/src/client/locales.ts +++ b/packages/client/ui-deliverables/src/client/locales.ts @@ -133,3 +133,387 @@ 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 + +/** Spanish dictionary, checked against the English keys. */ +export const es = { + 'presented.nativeUnavailable': 'Este archivo no tiene una ruta de host disponible. Vista previa en la barra lateral.', + 'presented.revealError': 'No se pudo mostrar en el administrador de archivos. Intentar otra vez.', + 'presented.directoryError': 'No se pudo abrir la carpeta que lo contiene. Intentar otra vez.', + 'presented.directoryOpening': 'Abriendo la carpeta que contiene...', + 'presented.directoryOpened': 'Apertura solicitada que contiene la carpeta', + 'presented.revealed': 'Visualización solicitada en el administrador de archivos.', + 'presented.revealing': 'Mostrando en el administrador de archivos…', + 'presented.unavailable': 'Este Host no tiene un escritorio disponible para abrir archivos o carpetas en aplicaciones externas. Los archivos aún se pueden obtener una vista previa en la barra lateral.', + 'presented.retry': 'Reintentar', + 'presented.hostError': 'No se pudo leer la información del escritorio del host', + 'presented.preview': 'Vista previa en la barra lateral', + 'presented.previewButton': 'Abra {name} en la barra lateral', + 'presented.previewCard': 'Vista previa de {name} en la barra lateral', + 'presented.all': 'Todos los archivos {count}', + 'presented.expandAria': 'Mostrar todos los archivos entregados {count}', + 'presented.collapse': 'Contraer', + 'presented.collapseAria': 'Contraer archivos entregados', + 'presented.opening': 'Abriendo…', + 'presented.opened': 'Abierto solicitado', + 'presented.error': 'No se pudo abrir. Haga clic para volver a intentarlo.', + 'presented.file': 'Archivo', + 'row.title': 'Presentar archivos', + 'row.running': 'Entregando', + 'row.preparing': 'Preparando entregables', + 'row.ok': 'Entregado', + 'row.error': 'Entrega fallida', + 'row.stopped': 'interrumpido', + 'row.inspect': 'inspeccionar llamada', + 'changes.title': 'Archivos {count} editados', + 'changes.singleTitle': 'Editado {name}', + 'changes.added': '+{count}', + 'changes.deleted': '-{count}', + 'changes.binary': 'binario', + 'changes.openReview': 'Revisa los cambios de este turno en la barra lateral.', + 'changes.all': 'Todos los archivos {count}', + 'changes.expandAria': 'Mostrar todos los archivos modificados {count}', + 'changes.collapse': 'Contraer', + 'changes.collapseAria': 'Contraer archivos modificados', + 'changes.oversized': 'demasiado grande', + 'changes.viewDiff': 'Ver cambios en {name}', + 'review.title': 'Revisión · turno {turn}', + 'review.selectFile': 'Elija el archivo para revisar', + 'review.split': 'Cambiar a vista dividida', + 'review.unified': 'Cambiar a vista unificada', + 'review.splitAria': 'Vista dividida', + 'review.wrap': 'Habilitar ajuste de línea', + 'review.nowrap': 'Desactivar ajuste de línea', + 'review.wrapAria': 'Ajuste de línea', + 'review.openFile': 'Abra el archivo completo en la barra lateral.', + 'review.openFileAria': 'Abra {name} en la barra lateral', + 'diff.loading': 'Cambios de lectura…', + 'diff.missing': 'El contenido de los cambios de este turno ya no está disponible.', + 'diff.error': 'No se pudieron leer los cambios.', + 'diff.binary': 'Archivo binario; los cambios no se pueden mostrar', + 'diff.oversized': 'Archivo demasiado grande; los cambios no se pueden mostrar', + 'diff.created': 'Creado en este turno', + 'diff.deleted': 'Eliminado en este turno', + 'diff.unchanged': 'Ambos lados mantienen las mismas líneas.', + 'diff.coarse': 'Se agotó el tiempo de espera para la comparación de líneas; se muestra como un reemplazo de archivo completo', + 'diff.truncated': 'Mostrando las primeras líneas {count}', +} satisfies Record + +/** French dictionary, checked against the English keys. */ +export const fr = { + 'presented.nativeUnavailable': "Ce fichier n'a pas de chemin d'hôte disponible. Prévisualisez-le dans la barre latérale.", + 'presented.revealError': "Impossible d'afficher dans le gestionnaire de fichiers. Essayer à nouveau.", + 'presented.directoryError': "Impossible d'ouvrir le dossier contenant. Essayer à nouveau.", + 'presented.directoryOpening': 'Ouverture du dossier contenant…', + 'presented.directoryOpened': 'Ouverture demandée contenant le dossier', + 'presented.revealed': 'Affichage demandé dans le gestionnaire de fichiers', + 'presented.revealing': 'Affichage dans le gestionnaire de fichiers…', + 'presented.unavailable': 'Cet hôte ne dispose pas de bureau disponible pour ouvrir des fichiers ou des dossiers dans des applications externes. Les fichiers peuvent toujours être prévisualisés dans la barre latérale.', + 'presented.retry': 'Réessayer', + 'presented.hostError': 'Impossible de lire les informations du bureau hôte', + 'presented.preview': 'Aperçu dans la barre latérale', + 'presented.previewButton': 'Ouvrir {name} dans la barre latérale', + 'presented.previewCard': 'Aperçu de {name} dans la barre latérale', + 'presented.all': 'Tous les fichiers {count}', + 'presented.expandAria': 'Afficher tous les fichiers livrés {count}', + 'presented.collapse': 'Réduire', + 'presented.collapseAria': 'Réduire les fichiers livrés', + 'presented.opening': 'Ouverture…', + 'presented.opened': 'Ouvert demandé', + 'presented.error': "Impossible d'ouvrir. Cliquez pour réessayer.", + 'presented.file': 'Fichier', + 'row.title': 'Présenter des fichiers', + 'row.running': 'Livrer', + 'row.preparing': 'Préparation des livrables', + 'row.ok': 'Livré', + 'row.error': 'La livraison a échoué', + 'row.stopped': 'Interrompu', + 'row.inspect': "Inspecter l'appel", + 'changes.title': 'Fichiers {count} modifiés', + 'changes.singleTitle': 'Modifié {name}', + 'changes.added': '+{count}', + 'changes.deleted': '-{count}', + 'changes.binary': 'binaire', + 'changes.openReview': 'Consultez les modifications de ce tour dans la barre latérale', + 'changes.all': 'Tous les fichiers {count}', + 'changes.expandAria': 'Afficher tous les fichiers modifiés {count}', + 'changes.collapse': 'Réduire', + 'changes.collapseAria': 'Réduire les fichiers modifiés', + 'changes.oversized': 'trop grand', + 'changes.viewDiff': 'Afficher les modifications apportées à {name}', + 'review.title': 'Révision · tour {turn}', + 'review.selectFile': 'Choisissez le fichier à examiner', + 'review.split': 'Passer à la vue fractionnée', + 'review.unified': 'Passer à la vue unifiée', + 'review.splitAria': 'Vue fractionnée', + 'review.wrap': 'Activer le retour à la ligne', + 'review.nowrap': 'Désactiver le retour à la ligne', + 'review.wrapAria': 'Retour à la ligne', + 'review.openFile': "Ouvrez l'intégralité du fichier dans la barre latérale", + 'review.openFileAria': 'Ouvrir {name} dans la barre latérale', + 'diff.loading': 'La lecture change…', + 'diff.missing': "Le contenu des modifications de ce tour n'est plus disponible", + 'diff.error': 'Impossible de lire les modifications', + 'diff.binary': 'Fichier binaire ; les modifications ne peuvent pas être affichées', + 'diff.oversized': 'Fichier trop volumineux ; les modifications ne peuvent pas être affichées', + 'diff.created': 'Créé à ce tour', + 'diff.deleted': 'Supprimé à ce tour', + 'diff.unchanged': 'Les deux côtés tiennent les mêmes lignes', + 'diff.coarse': "La comparaison de lignes a expiré ; affiché en remplacement de l'ensemble du fichier", + 'diff.truncated': 'Affichage des premières lignes {count}', +} satisfies Record + +/** German dictionary, checked against the English keys. */ +export const de = { + 'presented.nativeUnavailable': 'Für diese Datei ist kein Hostpfad verfügbar. Sehen Sie sich die Vorschau in der Seitenleiste an.', + 'presented.revealError': 'Konnte im Dateimanager nicht angezeigt werden. Versuchen Sie es erneut.', + 'presented.directoryError': 'Der enthaltende Ordner konnte nicht geöffnet werden. Versuchen Sie es erneut.', + 'presented.directoryOpening': 'Öffnen des enthaltenden Ordners…', + 'presented.directoryOpened': 'Angeforderte Öffnung mit Ordner', + 'presented.revealed': 'Angeforderte Anzeige im Dateimanager', + 'presented.revealing': 'Wird im Dateimanager angezeigt…', + 'presented.unavailable': 'Auf diesem Host steht kein Desktop zum Öffnen von Dateien oder Ordnern in externen Apps zur Verfügung. Dateien können weiterhin in der Seitenleiste in der Vorschau angezeigt werden.', + 'presented.retry': 'Erneut versuchen', + 'presented.hostError': 'Die Host-Desktop-Informationen konnten nicht gelesen werden', + 'presented.preview': 'Vorschau in der Seitenleiste', + 'presented.previewButton': 'Öffnen Sie {name} in der Seitenleiste', + 'presented.previewCard': 'Vorschau von {name} in der Seitenleiste', + 'presented.all': 'Alle {count}-Dateien', + 'presented.expandAria': 'Alle von {count} gelieferten Dateien anzeigen', + 'presented.collapse': 'Einklappen', + 'presented.collapseAria': 'Gelieferte Dateien ausblenden', + 'presented.opening': 'Eröffnung…', + 'presented.opened': 'Öffnung erbeten', + 'presented.error': 'Konnte nicht geöffnet werden. Klicken Sie, um es erneut zu versuchen.', + 'presented.file': 'Datei', + 'row.title': 'Vorhandene Akten', + 'row.running': 'Liefern', + 'row.preparing': 'Vorbereiten von Liefergegenständen', + 'row.ok': 'Geliefert', + 'row.error': 'Die Lieferung ist fehlgeschlagen', + 'row.stopped': 'Unterbrochen', + 'row.inspect': 'Aufruf prüfen', + 'changes.title': 'Bearbeitete {count}-Dateien', + 'changes.singleTitle': 'Bearbeitet {name}', + 'changes.added': '+{count}', + 'changes.deleted': '-{count}', + 'changes.binary': 'binär', + 'changes.openReview': 'Überprüfen Sie die Änderungen dieser Runde in der Seitenleiste', + 'changes.all': 'Alle {count}-Dateien', + 'changes.expandAria': 'Alle von {count} geänderten Dateien anzeigen', + 'changes.collapse': 'Einklappen', + 'changes.collapseAria': 'Geänderte Dateien reduzieren', + 'changes.oversized': 'zu groß', + 'changes.viewDiff': 'Änderungen an {name} anzeigen', + 'review.title': 'Überprüfung · Runde {turn}', + 'review.selectFile': 'Wählen Sie die zu überprüfende Datei aus', + 'review.split': 'Wechseln Sie zur geteilten Ansicht', + 'review.unified': 'Wechseln Sie zur einheitlichen Ansicht', + 'review.splitAria': 'Geteilte Ansicht', + 'review.wrap': 'Zeilenumbruch aktivieren', + 'review.nowrap': 'Zeilenumbruch deaktivieren', + 'review.wrapAria': 'Zeilenumbruch', + 'review.openFile': 'Öffnen Sie die gesamte Datei in der Seitenleiste', + 'review.openFileAria': 'Öffnen Sie {name} in der Seitenleiste', + 'diff.loading': 'Änderungen lesen…', + 'diff.missing': 'Der Inhalt der Änderungen dieser Runde ist nicht mehr verfügbar', + 'diff.error': 'Die Änderungen konnten nicht gelesen werden', + 'diff.binary': 'Binärdatei; Änderungen können nicht angezeigt werden', + 'diff.oversized': 'Datei zu groß; Änderungen können nicht angezeigt werden', + 'diff.created': 'In dieser Runde erstellt', + 'diff.deleted': 'In dieser Runde gelöscht', + 'diff.unchanged': 'Beide Seiten vertreten die gleichen Linien', + 'diff.coarse': 'Zeitüberschreitung beim Leitungsvergleich; wird als vollständige Dateiersetzung angezeigt', + 'diff.truncated': 'Zeigt die ersten {count}-Zeilen', +} satisfies Record + +/** Russian dictionary, checked against the English keys. */ +export const ru = { + 'presented.nativeUnavailable': 'У этого файла нет доступного пути к хосту. Предварительный просмотр на боковой панели.', + 'presented.revealError': 'Не удалось отобразить в файловом менеджере. Попробуйте еще раз.', + 'presented.directoryError': 'Не удалось открыть содержащую папку. Попробуйте еще раз.', + 'presented.directoryOpening': 'Открытие папки, содержащей…', + 'presented.directoryOpened': 'Запрошенное открытие, содержащее папку', + 'presented.revealed': 'Запрошенное отображение в файловом менеджере', + 'presented.revealing': 'Отображается в файловом менеджере…', + 'presented.unavailable': 'У этого хоста нет рабочего стола, на котором можно было бы открывать файлы или папки во внешних приложениях. Файлы по-прежнему можно просматривать на боковой панели.', + 'presented.retry': 'Повторить', + 'presented.hostError': 'Не удалось прочитать информацию о рабочем столе хоста.', + '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 + +/** Portuguese dictionary, checked against the English keys. */ +export const pt = { + 'presented.nativeUnavailable': 'Este arquivo não possui caminho de host disponível. Visualize-o na barra lateral.', + 'presented.revealError': 'Não foi possível mostrar no gerenciador de arquivos. Tente novamente.', + 'presented.directoryError': 'Não foi possível abrir a pasta que contém. Tente novamente.', + 'presented.directoryOpening': 'Abrindo a pasta que contém…', + 'presented.directoryOpened': 'Abertura solicitada contendo pasta', + 'presented.revealed': 'Exibição solicitada no gerenciador de arquivos', + 'presented.revealing': 'Mostrando no gerenciador de arquivos…', + 'presented.unavailable': 'Este Host não possui área de trabalho disponível para abrir arquivos ou pastas em aplicativos externos. Os arquivos ainda podem ser visualizados na barra lateral.', + 'presented.retry': 'Tentar novamente', + 'presented.hostError': 'Não foi possível ler as informações da área de trabalho do Host', + 'presented.preview': 'Pré-visualização na barra lateral', + 'presented.previewButton': 'Abra {name} na barra lateral', + 'presented.previewCard': 'Visualizar {name} na barra lateral', + 'presented.all': 'Todos os {count} arquivos', + 'presented.expandAria': 'Mostrar todos os {count} arquivos entregues', + 'presented.collapse': 'Recolher', + 'presented.collapseAria': 'Recolher arquivos entregues', + 'presented.opening': 'Abrindo…', + 'presented.opened': 'Aberto solicitado', + 'presented.error': 'Não foi possível abrir. Clique para tentar novamente.', + 'presented.file': 'Arquivo', + 'row.title': 'Apresentar arquivos', + 'row.running': 'Entregando', + 'row.preparing': 'Preparando resultados', + 'row.ok': 'Entregue', + 'row.error': 'Falha na entrega', + 'row.stopped': 'Interrompido', + 'row.inspect': 'Inspecionar chamada', + 'changes.title': '{count} arquivos editados', + 'changes.singleTitle': 'Editado {name}', + 'changes.added': '+{count}', + 'changes.deleted': '-{count}', + 'changes.binary': 'binário', + 'changes.openReview': 'Revise as mudanças deste turno na barra lateral', + 'changes.all': 'Todos os {count} arquivos', + 'changes.expandAria': 'Mostrar todos os {count} arquivos alterados', + 'changes.collapse': 'Recolher', + 'changes.collapseAria': 'Recolher arquivos alterados', + 'changes.oversized': 'muito grande', + 'changes.viewDiff': 'Ver alterações em {name}', + 'review.title': 'Revisão · turno {turn}', + 'review.selectFile': 'Escolha o arquivo para revisar', + 'review.split': 'Mudar para visualização dividida', + 'review.unified': 'Mudar para visualização unificada', + 'review.splitAria': 'Visualização dividida', + 'review.wrap': 'Ativar quebra de linha', + 'review.nowrap': 'Desativar quebra de linha', + 'review.wrapAria': 'Quebra de linha', + 'review.openFile': 'Abra o arquivo inteiro na barra lateral', + 'review.openFileAria': 'Abra {name} na barra lateral', + 'diff.loading': 'A leitura muda…', + 'diff.missing': 'O conteúdo das alterações deste turno não está mais disponível', + 'diff.error': 'Não foi possível ler as alterações', + 'diff.binary': 'Arquivo binário; as alterações não podem ser mostradas', + 'diff.oversized': 'Arquivo muito grande; as alterações não podem ser mostradas', + 'diff.created': 'Criado neste turno', + 'diff.deleted': 'Excluído neste turno', + 'diff.unchanged': 'Ambos os lados mantêm as mesmas linhas', + 'diff.coarse': 'A comparação de linha expirou; mostrado como uma substituição de arquivo inteiro', + 'diff.truncated': 'Mostrando as primeiras {count} linhas', +} 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/de.json b/packages/client/ui-directory-picker-browse/locale/de.json new file mode 100644 index 0000000000..0ae1c16fad --- /dev/null +++ b/packages/client/ui-directory-picker-browse/locale/de.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "Schnittstelle zur In-App-Verzeichnisauswahl", + "description": "Durchsuchen, erstellen und wählen Sie ein Arbeitsbereichsverzeichnis innerhalb der App aus." + } +} diff --git a/packages/client/ui-directory-picker-browse/locale/es.json b/packages/client/ui-directory-picker-browse/locale/es.json new file mode 100644 index 0000000000..1ae27d3528 --- /dev/null +++ b/packages/client/ui-directory-picker-browse/locale/es.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "Interfaz de selección de directorio en la aplicación", + "description": "Busque, cree y seleccione un directorio de espacio de trabajo dentro de la aplicación." + } +} diff --git a/packages/client/ui-directory-picker-browse/locale/fr.json b/packages/client/ui-directory-picker-browse/locale/fr.json new file mode 100644 index 0000000000..c68b6dfdcd --- /dev/null +++ b/packages/client/ui-directory-picker-browse/locale/fr.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "Interface de sélection de répertoire dans l'application", + "description": "Parcourez, créez et sélectionnez un répertoire d'espace de travail dans l'application." + } +} 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/locale/pt.json b/packages/client/ui-directory-picker-browse/locale/pt.json new file mode 100644 index 0000000000..236a105e59 --- /dev/null +++ b/packages/client/ui-directory-picker-browse/locale/pt.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "Interface de seleção de diretório no aplicativo", + "description": "Navegue, crie e selecione um diretório de espaço de trabalho no aplicativo." + } +} diff --git a/packages/client/ui-directory-picker-browse/locale/ru.json b/packages/client/ui-directory-picker-browse/locale/ru.json new file mode 100644 index 0000000000..5c2a88cff4 --- /dev/null +++ b/packages/client/ui-directory-picker-browse/locale/ru.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "Интерфейс выбора каталога в приложении", + "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..d3cff16b31 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,96 @@ 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': '隠しファイルを表示', + }], + ['es', { + 'browser.title': 'Seleccionar carpeta de trabajo', + 'browser.home': 'Inicio', + 'browser.newFolder': 'Nueva carpeta', + 'browser.folderName': 'Nombre de la carpeta', + 'browser.createIn': 'Nueva carpeta en "{name}"', + 'browser.untitledFolder': 'Carpeta sin título', + 'browser.create': 'Crear', + 'browser.cancel': 'Cancelar', + 'browser.open': 'Abrir', + 'browser.editPath': 'Editar ruta', + 'browser.loading': 'Cargando…', + 'browser.truncated': 'Hay demasiadas carpetas; solo se muestra el principio.', + 'browser.showHidden': 'Mostrar archivos ocultos', + }], + ['fr', { + 'browser.title': 'Choisir le dossier de travail', + 'browser.home': 'Dossier personnel', + 'browser.newFolder': 'Nouveau dossier', + 'browser.folderName': 'Nom du dossier', + 'browser.createIn': 'Nouveau dossier dans « {name} »', + 'browser.untitledFolder': 'Dossier sans titre', + 'browser.create': 'Créer', + 'browser.cancel': 'Annuler', + 'browser.open': 'Ouvrir', + 'browser.editPath': 'Modifier le chemin', + 'browser.loading': 'Chargement…', + 'browser.truncated': 'Trop de dossiers à afficher ; seul le début est affiché.', + 'browser.showHidden': 'Afficher les fichiers cachés', + }], + ['de', { + 'browser.title': 'Arbeitsverzeichnis auswählen', + 'browser.home': 'Persönlicher Ordner', + 'browser.newFolder': 'Neuer Ordner', + 'browser.folderName': 'Ordnername', + 'browser.createIn': 'Neuer Ordner in „{name}“', + 'browser.untitledFolder': 'Unbenannter Ordner', + 'browser.create': 'Erstellen', + 'browser.cancel': 'Abbrechen', + 'browser.open': 'Öffnen', + 'browser.editPath': 'Pfad bearbeiten', + 'browser.loading': 'Laden…', + 'browser.truncated': 'Zu viele Ordner; nur der Anfang wird angezeigt.', + 'browser.showHidden': 'Versteckte Dateien anzeigen', + }], + ['ru', { + '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': 'Показать скрытые файлы', + }], + ['pt', { + 'browser.title': 'Selecionar pasta de trabalho', + 'browser.home': 'Pasta pessoal', + 'browser.newFolder': 'Nova pasta', + 'browser.folderName': 'Nome da pasta', + 'browser.createIn': 'Nova pasta em "{name}"', + 'browser.untitledFolder': 'Pasta sem nome', + 'browser.create': 'Criar', + 'browser.cancel': 'Cancelar', + 'browser.open': 'Abrir', + 'browser.editPath': 'Editar caminho', + 'browser.loading': 'Carregando…', + 'browser.truncated': 'Há pastas demais; apenas o início é exibido.', + 'browser.showHidden': 'Mostrar arquivos ocultos', + }], ] 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/de.json b/packages/client/ui-directory-picker-native/locale/de.json new file mode 100644 index 0000000000..d0bd0ff6aa --- /dev/null +++ b/packages/client/ui-directory-picker-native/locale/de.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "Schnittstelle zur Auswahl des Systemverzeichnisses", + "description": "Verbinden Sie das Arbeitsbereich-Auswahlportal und das Systemverzeichnis-Auswahldialogfeld." + } +} diff --git a/packages/client/ui-directory-picker-native/locale/es.json b/packages/client/ui-directory-picker-native/locale/es.json new file mode 100644 index 0000000000..67dfd34b96 --- /dev/null +++ b/packages/client/ui-directory-picker-native/locale/es.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "Interfaz de selección del directorio del sistema", + "description": "Conecte el portal de selección del espacio de trabajo y el cuadro de diálogo de selección del directorio del sistema." + } +} diff --git a/packages/client/ui-directory-picker-native/locale/fr.json b/packages/client/ui-directory-picker-native/locale/fr.json new file mode 100644 index 0000000000..87bff91413 --- /dev/null +++ b/packages/client/ui-directory-picker-native/locale/fr.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "Interface de sélection du répertoire système", + "description": "Connectez le portail de sélection de l'espace de travail et la boîte de dialogue de sélection du répertoire système." + } +} 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/locale/pt.json b/packages/client/ui-directory-picker-native/locale/pt.json new file mode 100644 index 0000000000..908a8e4105 --- /dev/null +++ b/packages/client/ui-directory-picker-native/locale/pt.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "Interface de seleção de diretório do sistema", + "description": "Conecte o portal de seleção de espaço de trabalho e a caixa de diálogo de seleção de diretório do sistema." + } +} diff --git a/packages/client/ui-directory-picker-native/locale/ru.json b/packages/client/ui-directory-picker-native/locale/ru.json new file mode 100644 index 0000000000..e57e07e0d0 --- /dev/null +++ b/packages/client/ui-directory-picker-native/locale/ru.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "Интерфейс выбора системного каталога", + "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 9adce0d3b1..f631fbecc2 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: 718f52cfce426009 + zh: e8282e2e3713d242 /deepseek-ai-dsh-client-ui-dockkit/embedding-it: - en: bc9ecb9633fb3208 - zh: fa141e77c2bf6eae + en: c4ba08038bfa6f30 + zh: 95db30618b52babe /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 ff8e81e5eb..976148bde2 100644 --- a/packages/client/ui-dockkit/README.md +++ b/packages/client/ui-dockkit/README.md @@ -37,7 +37,11 @@ A docking layout kit: a split tree of tabbed panes with invertible operations, a - `planSettle` is the opt-in rule that keeps every docked pane populated after an intent: panes an intent emptied are merged away, and an emptied root pane is reseeded through the embedder's factory — withholding the factory keeps the merge and leaves the root pane empty. An embedder that wants empty panes simply does not call it, or calls it without a factory. `planDropTab` takes the same factory: with one, a sole tab released on its own pane's edge splits and the factory's tab backfills the pane it vacates (the dragged tab stays focused); without one that release changes nothing. - `DockController` is the intent layer and an observable source (`subscribe` + `getSnapshot`, whose reference only changes when the layout does). -**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. +**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 a placement only when a caret is 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. + +Strip slots that preserve the dragged tab’s position show no insertion caret and report no placement; the tab can still be dragged to split a pane or float. 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 +56,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 7eddb644ce..85ad53eb45 100644 --- a/packages/client/ui-dockkit/README.zh.md +++ b/packages/client/ui-dockkit/README.zh.md @@ -37,7 +37,11 @@ kind: "package-reference" - `planSettle` 是可选加入的规则,保证意图之后每个停靠格都有内容:被意图清空的格会被并掉,被清空的根格通过嵌入方的工厂重新播种——不传工厂则只并格、让根格保持为空。想要空格的嵌入方只需不调用它,或不带工厂调用。`planDropTab` 接受同一工厂:带工厂时,唯一 tab 放到本格边缘会分栏,工厂的 tab 回填它腾出的格(被拖的 tab 保持聚焦);不带工厂时这种释放不改变任何东西。 - `DockController` 是意图层,也是一个可观察源(`subscribe` + `getSnapshot`,其引用只在布局变化时才变)。 -**组件**渲染布局快照并上报已落定的意图——每次手势一条,绝不上报拖动帧。拖动过程中在本地状态里预览,手势自身的事实留在它的闭包里;松手时净结果通过一次 `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——而浮出就是把拖动松手在停靠区之外。 +**组件**渲染布局快照并上报已落定的意图——每次手势一条,绝不上报拖动帧。拖动过程中在本地状态里预览,手势自身的事实留在它的闭包里;松手时净结果通过一次 `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 +56,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..846a4b75e8 100644 --- a/packages/client/ui-dockkit/src/components/DockSurface.tsx +++ b/packages/client/ui-dockkit/src/components/DockSurface.tsx @@ -5,8 +5,8 @@ * * A gesture only previews until it ends, then leaves through one intent, so the * embedder's operation sequence stays the single source of truth. Releasing a tab - * clear of this surface floats it; releasing inside it but on no pane is not a - * move at all. + * clear of this surface floats it; releasing inside it without an effective + * drop target changes nothing. */ import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react' import type { ReactNode } from 'react' @@ -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. */ @@ -110,12 +110,15 @@ const ALWAYS = (): boolean => true /** * Resolve where a pointer sits inside the docked surface. An edge zone is only * offered where the split it would make is allowed: within the pane budget and - * with room for two halves; otherwise the release is not a move at all. + * with room for two halves. Strip slots that preserve the dragged tab's position + * offer no caret or placement intent. An undefined target inside the surface + * leaves the layout unchanged on release. */ function hitTest( root: HTMLElement, x: number, y: number, + tabId: TabId, canSplit: boolean, fits: ReadonlyMap, dropZones: 'edges' | 'horizontal', @@ -126,7 +129,10 @@ function hitTest( const strip = pane.querySelector('[data-dockkit-strip]') if (strip !== null && containsPoint(strip.getBoundingClientRect(), x, y)) { const tabs = [...strip.querySelectorAll('[data-dockkit-tab]')] - return { kind: 'strip', paneId, index: insertionIndex(tabs.map(tab => tab.getBoundingClientRect()), x) } + const index = insertionIndex(tabs.map(tab => tab.getBoundingClientRect()), x) + const sourceIndex = tabs.findIndex(tab => tab.dataset.dockkitTab === tabId) + if (sourceIndex >= 0 && (index === sourceIndex || index === sourceIndex + 1)) return undefined + return { kind: 'strip', paneId, index } } const zone = dropZones === 'horizontal' ? canSplit && fitOf(fits, paneId).row @@ -229,12 +235,12 @@ function Surface({ setPreview({ ...NO_PREVIEW, draggingTabId: tabId, - dropTarget: hitTest(root, moved.clientX, moved.clientY, canSplit, fits, dropZones), + dropTarget: hitTest(root, moved.clientX, moved.clientY, tabId, canSplit, fits, dropZones), }) }, up: (released) => { if (!dragging) return - const target = hitTest(root, released.clientX, released.clientY, canSplit, fits, dropZones) + const target = hitTest(root, released.clientX, released.clientY, tabId, canSplit, fits, dropZones) if (target === undefined) { if (containsPoint(root.getBoundingClientRect(), released.clientX, released.clientY)) return intents.floatTab(tabId, floatRectAt(released.clientX, released.clientY, FLOAT_DEFAULT_SIZE)) 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 && ( - - -
-} diff --git a/packages/client/ui-entry-customization/src/client/index.ts b/packages/client/ui-entry-customization/src/client/index.ts index 254c0977b1..689279b22c 100644 --- a/packages/client/ui-entry-customization/src/client/index.ts +++ b/packages/client/ui-entry-customization/src/client/index.ts @@ -1,6 +1,8 @@ /** Rows for the More menus of the left sidebar, Conversation View tabs, and right-sidebar guide. */ import type { Context } from '@deepseek-ai/cordis' +import { createSnapshotStore } from '@deepseek-ai/dsh-client-store' import type {} from '@deepseek-ai/dsh-client-locale/client' +import type {} from '@deepseek-ai/dsh-client-product-analytics/client' import type {} from '@deepseek-ai/dsh-client-ui-renderer/client' import type {} from '@deepseek-ai/dsh-client-ui-sidebar/client' import type {} from '@deepseek-ai/dsh-client-ui-conversation/client' @@ -8,29 +10,42 @@ import type {} from '@deepseek-ai/dsh-client-ui-sidebar-right/client' import type {} from '@deepseek-ai/dsh-client-ui-plugin-manager/client' import type {} from '@deepseek-ai/dsh-client-ui-workspace/client' import { EntryMenuContent, type EntryMenuInjected, type EntryRegion } from './EntryMenuContent.tsx' -import { en, zh } from './locales.ts' +import { en, ja, es, fr, de, ru, pt, zh } from './locales.ts' declare module '@deepseek-ai/dsh-client-ui-slots' { interface LocaleNamespaceMap { - /** More menu rows: pin actions, footer actions, and Creator draft prompts. */ + /** More menu rows: navigation actions, and Creator draft prompts. */ entryCustomization: keyof typeof en } } -/** Services used by the menu rows' footer actions. */ -export const inject = ['slots', 'locale', 'pluginNavigation', 'uiWorkspace'] +/** Services used by the menu rows' footer actions; the optional `pluginNavigation` shows the discovery row while present. */ +export const inject = ['slots', 'locale', 'uiWorkspace'] /** * Register the same menu rows into each region's More menu. - * @param ctx - Browser context with slot, locale, plugin navigation, and Workspace services. + * @param ctx - Browser context with slot, locale, and Workspace services, and the optional plugin navigation. */ export function apply(ctx: Context): void { - ctx.effect(() => ctx.locale.register('entryCustomization', { en, zh })) + ctx.effect(() => ctx.locale.register('entryCustomization', { en, zh, ja, es, fr, de, ru, pt })) const t = ctx.locale.bind('entryCustomization') + // The Plugins page provides its navigation only while the current Peer may use it. + const pluginNavigation = createSnapshotStore(false) + ctx.inject(['pluginNavigation'], (scope: Context) => { + scope.effect(() => { + pluginNavigation.set(true) + return () => { pluginNavigation.set(false) } + }, 'entry-customization: plugin navigation presence') + }) const injected = (region: EntryRegion): EntryMenuInjected => ({ region, - browsePlugins: () => { ctx.pluginNavigation.openList() }, + hooks: { pluginNavigation }, + browsePlugins: () => { + ctx.get('productAnalytics')?.track('more_menu_item_click', { region, action: 'browse_plugins' }) + ctx.get('pluginNavigation')?.openList() + }, createPlugin: () => { + ctx.get('productAnalytics')?.track('more_menu_item_click', { region, action: 'create_plugin' }) ctx.uiWorkspace.startSession(undefined, { agentPreset: 'cordis', prompt: t(`createPrompt.${region}`), whenDraftExists: 'append', }) diff --git a/packages/client/ui-entry-customization/src/client/locales.ts b/packages/client/ui-entry-customization/src/client/locales.ts index 5b7692411a..c0a0e2cccc 100644 --- a/packages/client/ui-entry-customization/src/client/locales.ts +++ b/packages/client/ui-entry-customization/src/client/locales.ts @@ -1,12 +1,5 @@ -/** Copy for the More menu rows: pin actions, footer actions, and Creator draft prompts per region. */ +/** Copy for the More menu rows: navigation actions, and Creator draft prompts per region. */ export const en = { - 'pin.sidebar': 'Pin to left sidebar', - 'unpin.sidebar': 'Unpin from left sidebar', - 'pin.conversation': 'Pin to conversation tabs', - 'unpin.conversation': 'Unpin from conversation tabs', - 'pin.rightbar': 'Pin to right sidebar', - 'unpin.rightbar': 'Unpin from right sidebar', - pinLabel: '{action}: {name}', install: 'Browse other plugins', create: 'Create your plugin', 'createPrompt.sidebar': 'Create a DSH plugin with a left sidebar entry.', @@ -16,16 +9,63 @@ export const en = { /** Chinese counterpart of the entry-customization dictionary. */ export const zh: Record = { - 'pin.sidebar': '固定到左侧栏', - 'unpin.sidebar': '从左侧栏取消固定', - 'pin.conversation': '固定到会话标签栏', - 'unpin.conversation': '从会话标签栏取消固定', - 'pin.rightbar': '固定到右侧栏', - 'unpin.rightbar': '从右侧栏取消固定', - pinLabel: '{action}:{name}', install: '浏览更多插件', create: '创建你的插件', 'createPrompt.sidebar': '创建一个带左侧栏入口的 DSH 插件。', 'createPrompt.conversation': '创建一个 DSH 会话视图插件。', 'createPrompt.rightbar': '创建一个带右侧栏入口的 DSH 插件。', } + +/** Japanese copy, checked against the English keys. */ +export const ja = { + 'install': '他のプラグインを探す', + 'create': 'プラグインを作成', + 'createPrompt.sidebar': '左サイドバーにエントリを持つ DSH プラグインを作成します。', + 'createPrompt.conversation': 'DSH 会話ビュープラグインを作成します。', + 'createPrompt.rightbar': '右サイドバーにエントリを持つ DSH プラグインを作成します。', +} satisfies Record + +/** Spanish dictionary, checked against the English keys. */ +export const es = { + install: 'Explorar otros complementos', + create: 'Crea tu complemento', + 'createPrompt.sidebar': 'Cree un complemento DSH con una entrada en la barra lateral izquierda.', + 'createPrompt.conversation': 'Cree un complemento de vista de conversación DSH.', + 'createPrompt.rightbar': 'Cree un complemento DSH con una entrada en la barra lateral derecha.', +} satisfies Record + +/** French dictionary, checked against the English keys. */ +export const fr = { + install: "Parcourir d'autres plugins", + create: 'Créez votre plugin', + 'createPrompt.sidebar': 'Créez un plugin DSH avec une entrée dans la barre latérale gauche.', + 'createPrompt.conversation': "Créez un plugin d'affichage de conversation DSH.", + 'createPrompt.rightbar': 'Créez un plugin DSH avec une entrée dans la barre latérale droite.', +} satisfies Record + +/** German dictionary, checked against the English keys. */ +export const de = { + install: 'Durchsuchen Sie andere Plugins', + create: 'Erstellen Sie Ihr Plugin', + 'createPrompt.sidebar': 'Erstellen Sie ein DSH-Plugin mit einem Eintrag in der linken Seitenleiste.', + 'createPrompt.conversation': 'Erstellen Sie ein DSH-Plugin für die Konversationsansicht.', + 'createPrompt.rightbar': 'Erstellen Sie ein DSH-Plugin mit einem Eintrag in der rechten Seitenleiste.', +} satisfies Record + +/** Russian dictionary, checked against the English keys. */ +export const ru = { + install: 'Просмотрите другие плагины', + create: 'Создайте свой плагин', + 'createPrompt.sidebar': 'Создайте плагин DSH с записью на левой боковой панели.', + 'createPrompt.conversation': 'Создайте плагин просмотра диалога DSH.', + 'createPrompt.rightbar': 'Создайте плагин DSH с записью на правой боковой панели.', +} satisfies Record + +/** Portuguese dictionary, checked against the English keys. */ +export const pt = { + install: 'Procure outros plug-ins', + create: 'Crie seu plug-in', + 'createPrompt.sidebar': 'Crie um plug-in DSH com uma entrada na barra lateral esquerda.', + 'createPrompt.conversation': 'Crie um plug-in de visualização de conversa DSH.', + 'createPrompt.rightbar': 'Crie um plug-in DSH com uma entrada na barra lateral direita.', +} satisfies Record diff --git a/packages/client/ui-entry-customization/tests/menu.client.spec.tsx b/packages/client/ui-entry-customization/tests/menu.client.spec.tsx index d0922fc7a0..ff77c49ca7 100644 --- a/packages/client/ui-entry-customization/tests/menu.client.spec.tsx +++ b/packages/client/ui-entry-customization/tests/menu.client.spec.tsx @@ -1,5 +1,5 @@ // @vitest-environment jsdom -/** The plugin fills each region's More menu with entry rows and the discovery and Creator actions. */ +/** The plugin fills each region's More menu with the discovery and Creator actions. */ import type { Context } from '@deepseek-ai/cordis' import { afterEach, describe, expect, it, vi } from 'vitest' import { act, cleanup, fireEvent, render, within } from '@testing-library/react' @@ -8,6 +8,7 @@ import { useState } from 'react' import { makeTranslate, SlotTestRuntime } from '@deepseek-ai/dsh-client-test-runtime' import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client' import type { PropsRenderSlots } from '@deepseek-ai/dsh-client-ui-slots' +import type { TrackProductEvent } from '@deepseek-ai/dsh-client-product-analytics/client' import { EntryMenuContent } from '../src/client/EntryMenuContent.tsx' import { en } from '../src/client/locales.ts' import { apply, inject } from '../src/client/index.ts' @@ -29,10 +30,10 @@ function content(overrides: Partial = {}) { onDismiss: vi.fn(), browsePlugins: vi.fn(), createPlugin: vi.fn(), } - // Framework hooks stay unused stubs; this isolated row set reads only its owner, inject, and locale shares. const props = { entries: [{ id: 'notes', label: 'Notes', pinned: true }, { id: 'tasks', label: 'Tasks', pinned: false }], - region: 'sidebar', t: makeTranslate(en), renderFactorySlot: unused, + region: 'sidebar', t: makeTranslate(en), + usePluginNavigation: (select: (present: boolean) => T): T => select(true), usePanelInfo: unused, useSessions: unused, useSessionStatus: unused, useSessionRetainInfo: unused, useResource: unused, useWorkspaces: unused, ...callbacks, @@ -43,54 +44,59 @@ function content(overrides: Partial = {}) { } describe('More menu rows', () => { - it('separates opening from pinning and reports the inverted pin state', () => { - const b = content() - expect(b.view.getAllByRole('menuitem').map(row => row.getAttribute('aria-label') ?? row.textContent)).toEqual([ - 'Notes', 'Unpin from left sidebar: Notes', 'Tasks', 'Pin to left sidebar: Tasks', 'Browse other plugins', 'Create your plugin', - ]) - expect(b.view.getAllByRole('separator')).toHaveLength(1) - fireEvent.click(b.view.getByRole('menuitem', { name: 'Unpin from left sidebar: Notes' })) - fireEvent.click(b.view.getByRole('menuitem', { name: 'Pin to left sidebar: Tasks' })) - expect(b.onPinnedChange).toHaveBeenNthCalledWith(1, 'notes', false) - expect(b.onPinnedChange).toHaveBeenNthCalledWith(2, 'tasks', true) - expect(b.onDismiss).not.toHaveBeenCalled() - expect(b.onOpen).not.toHaveBeenCalled() - fireEvent.click(b.view.getByRole('menuitem', { name: 'Tasks' })) - expect(b.onDismiss).toHaveBeenCalledOnce() - expect(b.onOpen).toHaveBeenCalledExactlyOnceWith('tasks') - expect(b.onDismiss.mock.invocationCallOrder[0]).toBeLessThan(b.onOpen.mock.invocationCallOrder[0]!) - }) - - it.each([ - ['conversation', 'Pin to conversation tabs: Tasks'], - ['rightbar', 'Pin to right sidebar: Tasks'], - ] as const)('names the %s pin destination', (region, name) => { - const { view } = content({ region }) - expect(view.getByRole('menuitem', { name })).toBeTruthy() - }) - - it('keeps only the discovery and Creator actions, without a separator, for an empty roster', () => { - const b = content({ entries: [], region: 'rightbar' }) + it.each(['sidebar', 'rightbar'] as const)('shows only discovery and creation in %s regardless of entries', (region) => { + const b = content({ region, entries: [ + { id: 'notes', label: 'Notes', pinned: true, active: true }, + { id: 'tasks', label: 'Tasks', pinned: false }, + ] }) expect(b.view.getAllByRole('menuitem').map(row => row.textContent)).toEqual(['Browse other plugins', 'Create your plugin']) expect(b.view.queryByRole('separator')).toBeNull() fireEvent.click(b.view.getByRole('menuitem', { name: 'Browse other plugins' })) expect(b.browsePlugins).toHaveBeenCalledExactlyOnceWith() + expect(b.onDismiss.mock.invocationCallOrder[0]).toBeLessThan(b.browsePlugins.mock.invocationCallOrder[0]!) fireEvent.click(b.view.getByRole('menuitem', { name: 'Create your plugin' })) expect(b.createPlugin).toHaveBeenCalledExactlyOnceWith() - expect(b.onDismiss).toHaveBeenCalledTimes(2) + expect(b.onDismiss.mock.invocationCallOrder[1]).toBeLessThan(b.createPlugin.mock.invocationCallOrder[0]!) + expect(b.onOpen).not.toHaveBeenCalled() + expect(b.onPinnedChange).not.toHaveBeenCalled() }) - it('shows the pin hint after the shared tooltip delay outside the menu card', () => { - vi.stubGlobal('matchMedia', vi.fn((query: string) => ({ matches: query === '(any-hover: hover)' }))) - const { view } = content() - const unpin = view.getByRole('menuitem', { name: 'Unpin from left sidebar: Notes' }) - vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }) - fireEvent.pointerEnter(unpin, { pointerType: 'mouse' }) - act(() => { vi.advanceTimersByTime(499) }) - expect(view.queryByRole('tooltip', { hidden: true })).toBeNull() - act(() => { vi.advanceTimersByTime(1) }) - expect(view.getByRole('tooltip', { hidden: true }).closest('[role="menu"]')).toBeNull() - fireEvent.pointerLeave(unpin, { pointerType: 'mouse' }) + it.each([true, false])('opens folded Views and marks the active View with plugin navigation present=%s', (browsable) => { + const b = content({ region: 'conversation', + usePluginNavigation: select => select(browsable), + entries: [ + { id: 'notes', label: 'Notes', pinned: true, active: true }, + { id: 'tasks', label: 'Tasks', pinned: true, active: false }, + ], + }) + expect(b.view.getAllByRole('menuitem').map(row => row.textContent)) + .toEqual(['Notes', 'Tasks', ...(browsable ? ['Browse other plugins'] : []), 'Create your plugin']) + const currentRow = b.view.getByRole('menuitem', { name: 'Notes' }) + expect(currentRow.getAttribute('aria-current')).toBe('page') + expect(currentRow.querySelector('svg')?.previousElementSibling?.textContent).toBe('Notes') + expect(b.view.getByRole('menuitem', { name: 'Tasks' }).hasAttribute('aria-current')).toBe(false) + expect(b.view.getAllByRole('separator')).toHaveLength(1) + fireEvent.click(b.view.getByRole('menuitem', { name: 'Tasks' })) + expect(b.onOpen).toHaveBeenCalledExactlyOnceWith('tasks') + expect(b.onDismiss.mock.invocationCallOrder[0]).toBeLessThan(b.onOpen.mock.invocationCallOrder[0]!) + expect(b.onPinnedChange).not.toHaveBeenCalled() + }) + + it.each([true, false])('keeps actions unseparated for an empty roster with plugin navigation present=%s', (browsable) => { + const b = content({ region: 'conversation', entries: [], usePluginNavigation: select => select(browsable) }) + expect(b.view.getAllByRole('menuitem').map(row => row.textContent)) + .toEqual([...(browsable ? ['Browse other plugins'] : []), 'Create your plugin']) + expect(b.view.queryByRole('separator')).toBeNull() + }) + + it.each(['sidebar', 'rightbar'] as const)('keeps only creation in %s without plugin navigation', (region) => { + const b = content({ region, usePluginNavigation: select => select(false) }) + expect(b.view.getAllByRole('menuitem').map(row => row.textContent)).toEqual(['Create your plugin']) + expect(b.view.queryByRole('separator')).toBeNull() + fireEvent.click(b.view.getByRole('menuitem', { name: 'Create your plugin' })) + expect(b.createPlugin).toHaveBeenCalledExactlyOnceWith() + expect(b.onOpen).not.toHaveBeenCalled() + expect(b.onPinnedChange).not.toHaveBeenCalled() }) }) @@ -101,7 +107,7 @@ function Frame({ renderSlot }: PropsRenderSlots) { return <> {SEATS.map(seat =>
{renderSlot(seat, { - entries, + entries: seat === 'conversation.session.header.views.actions' ? [] : entries, onOpen: (id) => { setOpened(`${seat}:${id}`) }, onPinnedChange: (_id, next) => { setPinned(next) }, onDismiss: () => {}, @@ -111,10 +117,13 @@ function Frame({ renderSlot }: PropsRenderSlots) { } -async function bench(language: 'en' | 'zh' = 'en') { +async function bench( + language: 'en' | 'zh' = 'en', + { analytics = true, navigation = true }: { analytics?: boolean; navigation?: boolean } = {}, +) { const runtime = await SlotTestRuntime.create() runtimes.add(runtime) - const openList = vi.fn(), startSession = vi.fn() + const openList = vi.fn(), startSession = vi.fn(), track = vi.fn() await runtime.mount({ inject: ['slots'], apply(ctx: Context) { @@ -122,8 +131,9 @@ async function bench(language: 'en' | 'zh' = 'en') { locale.setLocale(language) ctx.provide('locale', locale) ctx.slots.installLocale(locale) - ctx.provide('pluginNavigation', { openList } as never) + if (navigation) ctx.provide('pluginNavigation', { openList } as never) ctx.provide('uiWorkspace', { startSession } as never) + if (analytics) ctx.provide('productAnalytics', { track } as never) }, }) await runtime.root.declare({ @@ -133,23 +143,40 @@ async function bench(language: 'en' | 'zh' = 'en') { }, Frame) const plugin = await runtime.mount({ inject: [...inject], apply }) const view = runtime.renderRoot() - return { runtime, openList, startSession, plugin, view } + return { runtime, openList, startSession, track, plugin, view } } describe('More menu registrations', () => { + it('shows plugin discovery only while the Plugins page navigation is present', async () => { + const b = await bench('en', { navigation: false }) + const sidebar = () => within(b.view.getByRole('menu', { name: 'sidebar.panellist.actions' })) + expect(sidebar().queryByRole('menuitem', { name: 'Browse other plugins' })).toBeNull() + expect(sidebar().getByRole('menuitem', { name: 'Create your plugin' })).toBeTruthy() + expect(sidebar().queryByRole('separator')).toBeNull() + const injected = b.runtime.slots.entries('sidebar.panellist.actions')[0]!.inject!() as { browsePlugins: () => void } + expect(() => { injected.browsePlugins() }).not.toThrow() + + const navigation = await b.runtime.mount({ + inject: [], + apply(ctx: Context) { ctx.provide('pluginNavigation', { openList: b.openList } as never) }, + }) + await act(async () => { await Promise.resolve() }) + fireEvent.click(sidebar().getByRole('menuitem', { name: 'Browse other plugins' })) + expect(b.openList).toHaveBeenCalledExactlyOnceWith() + + await navigation.dispose() + await act(async () => { await Promise.resolve() }) + expect(sidebar().queryByRole('menuitem', { name: 'Browse other plugins' })).toBeNull() + }) + it('registers one row set per region and removes all of them when the plugin unloads', async () => { const b = await bench() for (const seat of SEATS) { expect(b.runtime.slots.entries(seat).map(entry => entry.options.id)).toEqual(['entry-customization']) - expect(within(b.view.getByRole('menu', { name: seat })).getByRole('menuitem', { name: 'Notes' })).toBeTruthy() + expect(within(b.view.getByRole('menu', { name: seat })).getAllByRole('menuitem').map(row => row.textContent)) + .toEqual(['Browse other plugins', 'Create your plugin']) } const sidebar = within(b.view.getByRole('menu', { name: 'sidebar.panellist.actions' })) - fireEvent.click(sidebar.getByRole('menuitem', { name: 'Unpin from left sidebar: Notes' })) - expect(within(b.view.getByRole('menu', { name: 'sidebar.right.tab.guide.actions' })) - .getByRole('menuitem', { name: 'Pin to right sidebar: Notes' })).toBeTruthy() - fireEvent.click(within(b.view.getByRole('menu', { name: 'conversation.session.header.views.actions' })) - .getByRole('menuitem', { name: 'Notes' })) - expect(b.view.getByTestId('opened').textContent).toBe('conversation.session.header.views.actions:notes') fireEvent.click(sidebar.getByRole('menuitem', { name: 'Browse other plugins' })) expect(b.openList).toHaveBeenCalledExactlyOnceWith() await b.plugin.dispose() @@ -174,4 +201,30 @@ describe('More menu registrations', () => { agentPreset: 'cordis', prompt, whenDraftExists: 'append', }) }) + + it.each([ + ['sidebar.panellist.actions', 'sidebar'], + ['conversation.session.header.views.actions', 'conversation'], + ['sidebar.right.tab.guide.actions', 'rightbar'], + ] as const)('reports both footer actions of %s as the %s region before acting', async (seat, region) => { + const b = await bench() + const menu = within(b.view.getByRole('menu', { name: seat })) + fireEvent.click(menu.getByRole('menuitem', { name: 'Browse other plugins' })) + expect(b.track).toHaveBeenCalledExactlyOnceWith('more_menu_item_click', { region, action: 'browse_plugins' }) + expect(b.track.mock.invocationCallOrder[0]).toBeLessThan(b.openList.mock.invocationCallOrder[0]!) + fireEvent.click(menu.getByRole('menuitem', { name: 'Create your plugin' })) + expect(b.track).toHaveBeenLastCalledWith('more_menu_item_click', { region, action: 'create_plugin' }) + expect(b.track.mock.invocationCallOrder[1]).toBeLessThan(b.startSession.mock.invocationCallOrder[0]!) + expect(b.track).toHaveBeenCalledTimes(2) + }) + + it('keeps both footer actions working without the analytics service', async () => { + const b = await bench('en', { analytics: false }) + const menu = within(b.view.getByRole('menu', { name: 'sidebar.right.tab.guide.actions' })) + fireEvent.click(menu.getByRole('menuitem', { name: 'Browse other plugins' })) + fireEvent.click(menu.getByRole('menuitem', { name: 'Create your plugin' })) + expect(b.openList).toHaveBeenCalledExactlyOnceWith() + expect(b.startSession).toHaveBeenCalledOnce() + expect(b.track).not.toHaveBeenCalled() + }) }) diff --git a/packages/client/ui-entry-customization/tests/pin-styles.client.spec.ts b/packages/client/ui-entry-customization/tests/pin-styles.client.spec.ts deleted file mode 100644 index c30d3ee705..0000000000 --- a/packages/client/ui-entry-customization/tests/pin-styles.client.spec.ts +++ /dev/null @@ -1,20 +0,0 @@ -/** Pin controls use Session-action ink without changing the navigation control's dimensions. */ -import { readFileSync } from 'node:fs' -import { describe, expect, it } from 'vitest' - -const css = readFileSync(new URL('../src/client/EntryMenu.module.css', import.meta.url), 'utf8') - -describe('entry pin styling', () => { - it('uses tertiary ink for both pin states and primary ink on pointer hover', () => { - expect(css).toMatch(/\.pinButton\s*\{[^}]*color:\s*var\(--dsw-alias-label-tertiary\)/) - expect(css).toMatch(/\.pinButton:hover\s*\{[^}]*color:\s*var\(--dsw-alias-label-primary\)/) - expect(css).not.toMatch(/\[data-pinned=['"]true['"]\]\s+\.pinButton\s*\{[^}]*color:/) - }) - - it('centers the pin without creating a transformed tooltip container', () => { - const pinAnchor = css.match(/\.pinAnchor\s*\{([^}]*)\}/)?.[1] - expect(pinAnchor).toBeDefined() - expect(pinAnchor).toContain('align-items: center') - expect(pinAnchor).not.toContain('transform:') - }) -}) diff --git a/packages/client/ui-entry-customization/tests/reader-boot.client.spec.ts b/packages/client/ui-entry-customization/tests/reader-boot.client.spec.ts new file mode 100644 index 0000000000..539dd64964 --- /dev/null +++ b/packages/client/ui-entry-customization/tests/reader-boot.client.spec.ts @@ -0,0 +1,12 @@ +// @vitest-environment jsdom +/** A page whose Peer may not list plugin bundles still boots, and the More menus keep their other rows. */ +import { expect } from 'vitest' +import { createClientTest, webApp } from '@deepseek-ai/dsh-client-test-runtime/src/assembly/index.ts' + +const withoutPluginManager = createClientTest({ roster: webApp }, { access: action => action !== 'pluginManager/listBundles' }) + +withoutPluginManager('boots the shipped Web client without the Plugins page navigation', async ({ start }) => { + const c = await start() + expect(c.ctx.get('pluginNavigation')).toBeUndefined() + expect(c.ctx.slots.entries('sidebar.panellist.actions').map(entry => entry.options.id)).toContain('entry-customization') +}, 60_000) diff --git a/packages/client/ui-entry-customization/tsconfig.json b/packages/client/ui-entry-customization/tsconfig.json index 9bca43d7f8..e65933f5ea 100644 --- a/packages/client/ui-entry-customization/tsconfig.json +++ b/packages/client/ui-entry-customization/tsconfig.json @@ -5,6 +5,7 @@ "references": [ { "path": "../../../vendor/cordis" }, { "path": "../../client/locale/tsconfig.client.json" }, + { "path": "../../client/product-analytics/tsconfig.client.json" }, { "path": "../../client/ui-sidebar" }, { "path": "../../client/ui-conversation/tsconfig.client.json" }, { "path": "../../client/ui-sidebar-right" }, @@ -12,6 +13,7 @@ { "path": "../../client/ui-workspace" }, { "path": "../../client/ui-primitives" }, { "path": "../../client/ui-renderer" }, - { "path": "../../client/ui-slots" } + { "path": "../../client/ui-slots" }, + { "path": "../store" } ] } diff --git a/packages/client/ui-file-drag/README.i18n.yaml b/packages/client/ui-file-drag/README.i18n.yaml new file mode 100644 index 0000000000..af7b1ead0d --- /dev/null +++ b/packages/client/ui-file-drag/README.i18n.yaml @@ -0,0 +1,34 @@ +# Bilingual-pair consistency record for README.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 packages/client/ui-file-drag/README.md +/: + en: 354a238a0316cc97 + zh: 8b23d2fb131be7c7 +/deepseek-ai-dsh-client-ui-file-drag: + en: 1cda49c784cdc224 + zh: 12633697bfec7214 +/deepseek-ai-dsh-client-ui-file-drag/summary: + en: b9fd57af1a4d9ed5 + zh: 97e2bfc158465d12 +/deepseek-ai-dsh-client-ui-file-drag/table-of-contents: + en: 122f0d41696980b1 + zh: 5423073f383f055d +/deepseek-ai-dsh-client-ui-file-drag/use-this-package: + en: 86b2a44b7966615b + zh: 5998751060e63a0f +/deepseek-ai-dsh-client-ui-file-drag/understand-the-implementation: + en: d52facbaeb4b2d5e + zh: 674ab93d84588733 +/deepseek-ai-dsh-client-ui-file-drag/model-experience: + en: bcbde555cf1ac05e + zh: c555dba949ddd0e9 +/deepseek-ai-dsh-client-ui-file-drag/model-experience/kv-cache-effect: + en: 410edb0ad2da08cf + zh: 581cbff891b5f34d +/deepseek-ai-dsh-client-ui-file-drag/known-limitations-and-deferred-work: + en: ff752628c43903ac + zh: 1eba3fbd50d35263 +/deepseek-ai-dsh-client-ui-file-drag/known-limitations-and-deferred-work/dev-note: + en: b836f01bd0bceb4a + zh: 25e979141fbe5501 diff --git a/packages/client/ui-file-drag/README.md b/packages/client/ui-file-drag/README.md new file mode 100644 index 0000000000..ce8f34cfca --- /dev/null +++ b/packages/client/ui-file-drag/README.md @@ -0,0 +1,67 @@ +--- +description: "Application-scoped file transfers and localized drag failure feedback." +kind: package-reference +--- + +# @deepseek-ai/dsh-client-ui-file-drag + +English | [中文](README.zh.md) + +## Summary + +Users can drag original images from conversations or the sidebar into the composer, or into other applications through Desktop. Drag failures remain visible after the originating image or panel closes. Transfers between browser windows remain subject to browser capabilities. + +## Table of Contents + +- [Use this package](#use-this-package) +- [Understand the implementation](#understand-the-implementation) +- [Model Experience](#model-experience) +- [Known Limitations and Deferred Work](#known-limitations-and-deferred-work) +- [Dev Note](#dev-note) + + +## Use this package + +The Web profile includes this plugin alongside locale, the slot renderer, and a shell declaring `shell.overlay`. Consumers inject `fileDrag` and pass `ctx.fileDrag.actions` through their entry’s inject face. `prepare(source, signal)` accepts Blob or URL content with an export name and optional Session resource address, returning synchronous start and release operations. `reportFailure()` uses the plugin dictionary; failure feedback and plugin metadata are available in Chinese, English, and Japanese. Composer drop targets receive `ctx.fileDrag.read`; components do not read a service or React context. + +The service retains one original `File` for a browser drag. The random `application/x-dsh-file` token resolves only in its application instance. A native listener stays on the source node after React unmount, and drag end, drop completion, or plugin disposal clears the transfer. Internal tokens are accepted only over the composer. External files keep the existing full-page drop overlay. Desktop native drags return as ordinary files and use that overlay, including within the source window. + +The desktop bridge is resolved once at activation for protocol version 1. File naming is shared with Desktop through [dsh-util-file-drag](../../util/file-drag/README.md): explicit output suffix wins, compatible suffixes survive, known conflicts use the declared MIME suffix, and unknown types retain the caller name. The plugin owns failure copy and its overlay, which survives source-panel closure. + +Managed browser transfers carry an application-local token and `DownloadURL` pointing to a plugin-owned Blob URL containing the prepared bytes. They do not expose the source workspace URL, filename, or alt text through `text/plain`. A drag started before preparation completes is cancelled quietly; a settled failure reports the localized retry message on the next attempt. Resource-backed images prepare on primary pointer down; other sources may prepare on hover. Releasing inside the source preserves preparation for a subsequent drag. Pointer leave without a pressed drag, release outside the source, blur, or unmount retires unused preparation. Cancellation reaches pending native requests, including Host reads and icon waits. Optional resource borrowing failures fall back to the supplied Blob or the authorized URL read; cancellation never starts that fallback. + + +## Understand the implementation + +
+Implementation internals + +The Cordis plugin owns file preparation, transport choice, retained browser bytes, and transient feedback. [DraggableImage](../ui-primitives/README.md) handles image gestures; [Desktop](../../../apps/desktop/README.md) authorizes borrowed resources and owns temporary native exports. Images are the first consumer; the same callbacks accept PDF and other file content without an image MIME gate. + +
+ + +## Model Experience + +None, as this plugin changes browser interaction without adding model-visible messages or tools. + +#### KV Cache effect + +None; this plugin neither constructs nor sends model requests. + +## Known Limitations and Deferred Work + + + +- Browser tokens do not transfer between applications, windows, or tabs. External browser file delivery depends on Chromium `DownloadURL` and the receiving application; Desktop supplies native file delivery. Unreadable cross-origin images retain browser-native dragging. +- The Blob `DownloadURL` path has no verified external-application delivery result. Receivers that read after the transfer ends may find its URL revoked; use Desktop native dragging when original-file delivery is required. + + +### Dev Note + +
+Working context for maintainers + +None. + +
diff --git a/packages/client/ui-file-drag/README.zh.md b/packages/client/ui-file-drag/README.zh.md new file mode 100644 index 0000000000..4960ef286c --- /dev/null +++ b/packages/client/ui-file-drag/README.zh.md @@ -0,0 +1,67 @@ +--- +description: "应用作用域内的文件传递与本地化拖放失败反馈。" +kind: package-reference +--- + +# @deepseek-ai/dsh-client-ui-file-drag + +[English](README.md) | 中文 + +## 概述 + +用户可以把会话或侧栏中的原图拖回输入框,也可以通过桌面端拖到其他应用。图片或面板关闭后,拖放失败提示仍然可见。浏览器跨窗口传递受浏览器能力限制。 + +## 目录 + +- [使用此包](#use-this-package) +- [理解实现](#understand-the-implementation) +- [模型体验](#model-experience) +- [已知限制与后续工作](#known-limitations-and-deferred-work) +- [开发备注](#dev-note) + + +## 使用此包 + +Web profile 将该插件与 locale、插槽渲染器及声明 `shell.overlay` 的外壳一同加载。消费方注入 `fileDrag`,通过入口的注入接口传递 `ctx.fileDrag.actions`。`prepare(source, signal)` 接受 Blob 或 URL 内容、导出名及可选 Session 资源地址,返回同步启动和释放操作。`reportFailure()` 使用插件词典;失败提示和插件元数据提供中文、英文与日语。输入框接收方获取 `ctx.fileDrag.read`;组件不读取服务或 React context。 + +服务为一次浏览器拖放保留一个原始 `File`。随机的 `application/x-dsh-file` 令牌只能在所属应用实例解析。原生监听器在 React 卸载后仍绑定源节点,拖放结束、投放完成或插件卸载会清除传递。内部令牌仅在输入框范围接受;外部文件保留已有的全页投放遮罩。Desktop 原生拖放返回时属于普通文件,包括拖回源窗口,也使用该遮罩。 + +桌面桥接在插件激活时解析一次,支持协议版本 1。文件命名通过 [dsh-util-file-drag](../../util/file-drag/README.zh.md) 与桌面端共享:显式后缀优先,兼容后缀保留,已知冲突使用声明 MIME 的后缀,未知类型保留调用方名称。失败文案与覆盖层由插件持有,在源面板关闭后仍然可见。 + +托管浏览器传输携带应用内令牌,以及指向插件持有、包含准备字节的 Blob URL 的 `DownloadURL`,不会通过 `text/plain` 暴露源工作区 URL、文件名或替代文本。准备完成前起拖会静默取消;已确定的失败在下一次尝试时显示本地化重试提示。带资源引用的图片仅在主指针按下时准备,其他来源可在悬停时准备。在源图片内松开会保留准备,供后续起拖使用。未按住拖动时移出图片、在图片外松开、窗口失焦或卸载会释放未使用的准备。取消会传递到原生请求,包括 Host 读取和图标等待。可选资源借用失败后回退到传入的 Blob 或经过授权的 URL 读取;取消不会触发回退。 + + +## 理解实现 + +
+实现内部细节 + +Cordis 插件负责文件准备、传输选择、浏览器保留字节与临时反馈。[DraggableImage](../ui-primitives/README.zh.md) 处理图片手势,[Desktop](../../../apps/desktop/README.zh.md) 授权借用资源并持有原生临时导出。图片是首个调用方,同一组回调可接收 PDF 等文件内容,不设图片 MIME 门禁。 + +
+ + +## 模型体验 + +无;该插件只改变浏览器交互,不增加模型可见消息或工具。 + +#### KV Cache effect + +无;该插件既不构建也不发送模型请求。 + +## 已知限制与后续工作 + + + +- 浏览器令牌不能跨应用、窗口或标签页传递。浏览器向外部交付文件依赖 Chromium 的 `DownloadURL` 和接收应用;Desktop 提供原生文件交付。不可读取的跨域图片保留浏览器原生拖动。 +- Blob `DownloadURL` 路径尚无经过验证的外部应用交付结果。传递结束后才读取的接收方可能遇到 URL 已撤销;需要原始文件交付时使用 Desktop 原生拖放。 + + +### 开发备注 + +
+维护者工作上下文 + +无。 + +
diff --git a/packages/client/ui-file-drag/locale/de.json b/packages/client/ui-file-drag/locale/de.json new file mode 100644 index 0000000000..5f610493c3 --- /dev/null +++ b/packages/client/ui-file-drag/locale/de.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "Ziehen von Dateien", + "description": "Ziehen Sie Originaldateien in den Composer oder andere Anwendungen, mit lokalisierter Fehlerrückmeldung." + } +} diff --git a/packages/client/ui-file-drag/locale/en.json b/packages/client/ui-file-drag/locale/en.json new file mode 100644 index 0000000000..36a9a27247 --- /dev/null +++ b/packages/client/ui-file-drag/locale/en.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "File dragging", + "description": "Drag original files into the composer or other applications, with localized failure feedback." + } +} diff --git a/packages/client/ui-file-drag/locale/es.json b/packages/client/ui-file-drag/locale/es.json new file mode 100644 index 0000000000..ff04cd6d4c --- /dev/null +++ b/packages/client/ui-file-drag/locale/es.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "Arrastrar archivos", + "description": "Arrastre los archivos originales al compositor u otras aplicaciones, con comentarios de falla localizados." + } +} diff --git a/packages/client/ui-file-drag/locale/fr.json b/packages/client/ui-file-drag/locale/fr.json new file mode 100644 index 0000000000..302a536b7d --- /dev/null +++ b/packages/client/ui-file-drag/locale/fr.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "Glisser un fichier", + "description": "Faites glisser les fichiers originaux dans le composeur ou d'autres applications, avec un retour d'échec localisé." + } +} diff --git a/packages/client/ui-file-drag/locale/ja.json b/packages/client/ui-file-drag/locale/ja.json new file mode 100644 index 0000000000..3bfb13a66b --- /dev/null +++ b/packages/client/ui-file-drag/locale/ja.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "ファイルのドラッグ&ドロップ", + "description": "元のファイルを入力欄や他のアプリにドラッグし、失敗時には選択した言語で通知します。" + } +} diff --git a/packages/client/ui-file-drag/locale/pt.json b/packages/client/ui-file-drag/locale/pt.json new file mode 100644 index 0000000000..9d59d7c683 --- /dev/null +++ b/packages/client/ui-file-drag/locale/pt.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "Arrastando arquivo", + "description": "Arraste os arquivos originais para o compositor ou outros aplicativos, com feedback de falha localizado." + } +} diff --git a/packages/client/ui-file-drag/locale/ru.json b/packages/client/ui-file-drag/locale/ru.json new file mode 100644 index 0000000000..00cd72b430 --- /dev/null +++ b/packages/client/ui-file-drag/locale/ru.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "Перетаскивание файлов", + "description": "Перетащите исходные файлы в композитор или другие приложения с локализованной обратной связью об ошибках." + } +} diff --git a/packages/client/ui-file-drag/locale/zh.json b/packages/client/ui-file-drag/locale/zh.json new file mode 100644 index 0000000000..0930345d15 --- /dev/null +++ b/packages/client/ui-file-drag/locale/zh.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "文件拖放", + "description": "将原始文件拖入输入框或其他应用,并提供本地化失败提示。" + } +} diff --git a/packages/client/ui-file-drag/package.json b/packages/client/ui-file-drag/package.json new file mode 100644 index 0000000000..16e865db2a --- /dev/null +++ b/packages/client/ui-file-drag/package.json @@ -0,0 +1,64 @@ +{ + "name": "@deepseek-ai/dsh-client-ui-file-drag", + "description": "Application-owned file drag transfers and localized failure feedback", + "version": "0.2.1-rc.2", + "publishConfig": { + "access": "public" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/deepseek-ai/deepseek-harness.git", + "directory": "packages/client/ui-file-drag" + }, + "type": "module", + "main": "lib/index.js", + "types": "lib/types/index.d.ts", + "exports": { + ".": { + "types": "./lib/types/index.d.ts", + "default": "./lib/index.js" + }, + "./client": { + "types": "./lib/types/client/index.d.ts", + "default": "./lib/client.js" + }, + "./src/*": "./src/*", + "./package.json": "./package.json", + "./locale/*.json": "./locale/*.json" + }, + "dsh": { + "client": { + "inject": [ + "@deepseek-ai/dsh-client-ui-layout" + ], + "platform": "web" + } + }, + "scripts": { + "bundle": "tsdown", + "watch": "tsdown --watch" + }, + "license": "MIT", + "peerDependencies": { + "@deepseek-ai/cordis": "workspace:~" + }, + "devDependencies": { + "@deepseek-ai/cordis": "workspace:~", + "@deepseek-ai/dsh-client-store": "workspace:*", + "@deepseek-ai/dsh-client-ui-slots": "workspace:*", + "@deepseek-ai/dsh-client-ui-primitives": "workspace:*", + "@deepseek-ai/dsh-client-ui-renderer": "workspace:*", + "@deepseek-ai/dsh-client-ui-layout": "workspace:*", + "react": "^18.2.0", + "@deepseek-ai/dsh-client-test-runtime": "workspace:*", + "@deepseek-ai/dsh-util-file-drag": "workspace:*", + "@deepseek-ai/dsh-util-crypto": "workspace:*", + "@deepseek-ai/dsh-client-locale": "workspace:*" + }, + "files": [ + "locale/*.json", + "lib/index.js", + "lib/client.js", + "lib/types/**/*.d.ts" + ] +} diff --git a/packages/client/ui-file-drag/src/client/feedback.tsx b/packages/client/ui-file-drag/src/client/feedback.tsx new file mode 100644 index 0000000000..a90201e195 --- /dev/null +++ b/packages/client/ui-file-drag/src/client/feedback.tsx @@ -0,0 +1,26 @@ +/** File-drag failure presentation in the existing application overlay. */ +import { Toast } from '@deepseek-ai/dsh-client-ui-primitives' +import type { ObservableSnapshot } from '@deepseek-ai/dsh-client-store' +import type { InjectFace } from '@deepseek-ai/dsh-client-ui-slots' + +/** Localized transient failure, with identity for restarting its presentation lifetime. */ +export interface FileDragFailure { + readonly text: string + readonly sequence: number +} + +/** Registration-owned feedback source and dismissal callback. */ +export interface FileDragFeedbackInjected { + readonly hooks: { readonly failure: ObservableSnapshot } + readonly dismiss: () => void +} + +/** + * Keep transient failures visible after their originating source unmounts. + * @param props - framework-bound failure source and dismissal callback. + * @returns the current failure toast, or nothing. + */ +export function FileDragFeedback({ useFailure, dismiss }: InjectFace) { + const failure = useFailure(value => value) + return failure === null ? null : +} diff --git a/packages/client/ui-file-drag/src/client/index.ts b/packages/client/ui-file-drag/src/client/index.ts new file mode 100644 index 0000000000..cfc9c1c85c --- /dev/null +++ b/packages/client/ui-file-drag/src/client/index.ts @@ -0,0 +1,50 @@ +/** Cordis-owned file transfers and localized feedback shared across independent entries. */ +import type { Context } from '@deepseek-ai/cordis' +import { createSnapshotStore } from '@deepseek-ai/dsh-client-store' +import type { DesktopFileDrag } from '@deepseek-ai/dsh-util-file-drag' +import type { FileDragActions } from '@deepseek-ai/dsh-client-ui-primitives' +import type {} from '@deepseek-ai/dsh-client-ui-renderer/client' +import type {} from '@deepseek-ai/dsh-client-ui-layout/client' +import type {} from '@deepseek-ai/dsh-client-locale/client' +import { FileDragFeedback, type FileDragFailure, type FileDragFeedbackInjected } from './feedback.tsx' +import { FileTransfers } from './transfers.ts' +import { en, ja, es, fr, de, ru, pt, zh } from './locales.ts' + +/** Application-local file preparation and drop resolution. */ +export interface FileDragService { + readonly actions: FileDragActions + /** @param transfer - browser drop payload. @returns original file for a matching application-local token. */ + readonly read: (transfer: DataTransfer) => File | undefined +} + +declare module '@deepseek-ai/cordis' { + interface Context { + /** File transfer ownership scoped to this plugin's lifetime. */ + fileDrag: FileDragService + } +} + +/** Registry and locale used by the independent feedback overlay. */ +export const inject = ['slots', 'locale'] + +/** @param ctx - client plugin context carrying slots and locale. */ +export function apply(ctx: Context): void { + const bridge = (globalThis as typeof globalThis & { dshDesktop?: { protocolVersion: number; fileDrag?: DesktopFileDrag } }).dshDesktop + const desktop = bridge?.protocolVersion === 1 ? bridge.fileDrag : undefined + const failure = createSnapshotStore(null) + const t = ctx.locale.bind('fileDrag') + ctx.effect(() => ctx.locale.register('fileDrag', { zh, en, ja, es, fr, de, ru, pt })) + let sequence = 0 + const reportFailure = (): void => { failure.set({ text: t('failed'), sequence: ++sequence }) } + const transfers = new FileTransfers(desktop, reportFailure) + const dismiss = (): void => { failure.set(null) } + ctx.provide('fileDrag', { + actions: { prepare: (source, signal) => transfers.prepare(source, signal), reportFailure }, + read: transfer => transfers.read(transfer), + }) + ctx.effect(() => () => { transfers.dispose(); dismiss() }) + ctx.slots.inject('shell.overlay', () => ctx.slots.register({ + name: 'shell.overlay', id: 'file-drag.feedback', + inject: (): FileDragFeedbackInjected => ({ hooks: { failure }, dismiss }), + }, FileDragFeedback)) +} diff --git a/packages/client/ui-file-drag/src/client/locales.ts b/packages/client/ui-file-drag/src/client/locales.ts new file mode 100644 index 0000000000..d0da371eca --- /dev/null +++ b/packages/client/ui-file-drag/src/client/locales.ts @@ -0,0 +1,30 @@ +/** File drag failure copy owned by the transfer plugin. */ +export const zh = { failed: '拖放失败,请重试' } + +/** English file drag failure copy. */ +export const en = { failed: 'Drag failed. Try again.' } satisfies Record + +/** Japanese file drag failure copy. */ +export const ja = { failed: 'ドラッグ&ドロップに失敗しました。もう一度お試しください。' } satisfies Record + +/** Spanish dictionary, checked against the English keys. */ +export const es = { failed: 'Error al arrastrar. Intentar otra vez.' } satisfies Record + +/** French dictionary, checked against the English keys. */ +export const fr = { failed: 'Le glissement a échoué. Essayer à nouveau.' } satisfies Record + +/** German dictionary, checked against the English keys. */ +export const de = { failed: 'Ziehen ist fehlgeschlagen. Versuchen Sie es erneut.' } satisfies Record + +/** Russian dictionary, checked against the English keys. */ +export const ru = { failed: 'Перетащить не удалось. Попробуйте еще раз.' } satisfies Record + +/** Portuguese dictionary, checked against the English keys. */ +export const pt = { failed: 'Falha ao arrastar. Tente novamente.' } satisfies Record + +declare module '@deepseek-ai/dsh-client-ui-slots' { + interface LocaleNamespaceMap { + /** File transfer failure feedback. */ + fileDrag: keyof typeof zh + } +} diff --git a/packages/client/ui-file-drag/src/client/transfers.ts b/packages/client/ui-file-drag/src/client/transfers.ts new file mode 100644 index 0000000000..88c1419588 --- /dev/null +++ b/packages/client/ui-file-drag/src/client/transfers.ts @@ -0,0 +1,164 @@ +/** File transport selected once per plugin activation; sources only receive prepared operations. */ +import { + FILE_DRAG_TOKEN_TYPE, resolveDragFileName, + type DesktopFileDrag, type DesktopFileSource, type FileDragHandle, type FileDragRequestId, +} from '@deepseek-ai/dsh-util-file-drag' +import { randomUUID } from '@deepseek-ai/dsh-util-crypto' +import type { FileDragSource, PreparedFileDrag } from '@deepseek-ai/dsh-client-ui-primitives' + +/** Application-local browser transfer owner, including detached source-node drag end. */ +export class FileTransfers { + private active: { token: string; file: File; end: () => void } | undefined + private readonly prepared = new Set<() => void>() + private disposed = false + private readonly lifetime = new AbortController() + + /** @param desktop - verified native bridge, absent in Web. @param reportFailure - localized plugin feedback. */ + constructor(private readonly desktop: DesktopFileDrag | undefined, private readonly reportFailure: () => void) {} + + /** + * Resolve this instance's active transfer. + * @param transfer - browser drop data. + * @returns retained bytes only for this instance's current token. + */ + read(transfer: DataTransfer): File | undefined { + return this.active?.token === transfer.getData(FILE_DRAG_TOKEN_TYPE) ? this.active.file : undefined + } + + private assertOpen(signal: AbortSignal): void { + signal.throwIfAborted() + } + + private async prepareNative( + desktop: DesktopFileDrag, source: DesktopFileSource, signal: AbortSignal, + ): Promise { + this.assertOpen(signal) + const requestId = randomUUID() as FileDragRequestId + const cancel = (): void => { desktop.cancel(requestId) } + signal.addEventListener('abort', cancel, { once: true }) + try { return await desktop.prepare(source, requestId) } + finally { signal.removeEventListener('abort', cancel) } + } + + /** + * Prepare bytes or borrow an authorized native file. + * @param source - content plus export naming. + * @param signal - caller cancellation. + * @returns synchronous drag startup and preparation release. + */ + async prepare(source: FileDragSource, signal: AbortSignal): Promise { + signal = AbortSignal.any([signal, this.lifetime.signal]) + this.assertOpen(signal) + const desktop = this.desktop + if (desktop === undefined) { + const file = await this.readSource(source, signal) + this.assertOpen(signal) + return this.browser(file) + } + let handle: FileDragHandle | undefined + try { + if (source.resourceAddress !== undefined) { + const bytes = source.kind === 'blob' ? await source.blob.arrayBuffer() : undefined + const hash = bytes === undefined ? undefined + : Array.from(new Uint8Array(await crypto.subtle.digest('SHA-256', bytes)), byte => byte.toString(16).padStart(2, '0')).join('') + this.assertOpen(signal) + try { handle = await this.prepareNative(desktop, { + kind: 'resource', address: source.resourceAddress, name: source.name, + mediaType: source.mediaType, outputExtension: source.outputExtension, + ...(hash === undefined ? {} : { sha256: hash }), + ...(source.kind === 'blob' ? { size: source.blob.size } : {}), + }, signal) } catch (_error) { + // Borrowing is optional; the Blob or authorized URL remains the source for a copy. + this.assertOpen(signal) + } + } + if (handle === undefined) { + const file = await this.readSource(source, signal) + handle = await this.prepareNative(desktop, { kind: 'bytes', bytes: new Uint8Array(await file.arrayBuffer()), + name: file.name, mediaType: file.type, outputExtension: source.outputExtension }, signal) + if (handle === undefined) throw new Error('File preparation unavailable') + } + this.assertOpen(signal) + return this.native(desktop, handle) + } catch (error) { + if (handle !== undefined) desktop.release(handle) + throw error + } + } + + private async readSource(source: FileDragSource, signal: AbortSignal): Promise { + this.assertOpen(signal) + let content: Blob + if (source.kind === 'url') { + const response = await fetch(source.url, { signal, referrerPolicy: 'no-referrer' }) + if (!response.ok) throw new Error('File read failed') + content = await response.blob() + } else content = source.blob + this.assertOpen(signal) + const mediaType = source.mediaType || content.type + return new File([content], resolveDragFileName({ ...source, mediaType }), { type: mediaType }) + } + + private native(desktop: DesktopFileDrag, nativeHandle: FileDragHandle): PreparedFileDrag { + let starting: Promise | undefined + const release = (): void => { + if (!this.prepared.delete(release)) return + if (starting === undefined) desktop.release(nativeHandle) + else void starting.then(() => { desktop.release(nativeHandle) }) + } + this.prepared.add(release) + return { + start: () => { + if (!this.prepared.has(release)) return false + starting = desktop.start(nativeHandle).then((started) => { if (!started) this.reportFailure() }, this.reportFailure) + return false + }, + release, + } + } + + private browser(file: File): PreparedFileDrag { + const url = URL.createObjectURL(file) + let released = false + let started = false + const releaseUrl = (): void => { this.prepared.delete(release); URL.revokeObjectURL(url) } + const release = (): void => { released = true; if (!started) releaseUrl() } + this.prepared.add(release) + return { + start: (transfer, source) => { + if (released || this.disposed) return false + this.active?.end() + started = true + const token = randomUUID() + let ended = false + const end = (): void => { + if (ended) return + ended = true + source.removeEventListener('dragend', end) + window.removeEventListener('drop', dropped) + this.active = undefined + started = false + if (released) releaseUrl() + } + const dropped = (): void => { queueMicrotask(end) } + this.active = { token, file, end } + source.addEventListener('dragend', end, { once: true }) + window.addEventListener('drop', dropped, { once: true }) + transfer.items.clear() + transfer.effectAllowed = 'copy' + transfer.setData(FILE_DRAG_TOKEN_TYPE, token) + transfer.setData('DownloadURL', `${file.type || 'application/octet-stream'}:${file.name}:${url}`) + return true + }, + release, + } + } + + /** End active transfers and retire every preparation, including sources detached during dragging. */ + dispose(): void { + this.disposed = true + this.lifetime.abort(new Error('File transfers disposed')) + this.active?.end() + for (const release of this.prepared) release() + } +} diff --git a/packages/client/ui-file-drag/src/index.ts b/packages/client/ui-file-drag/src/index.ts new file mode 100644 index 0000000000..01bb3cdbe2 --- /dev/null +++ b/packages/client/ui-file-drag/src/index.ts @@ -0,0 +1,4 @@ +/** Host Loader entry; file dragging executes in the browser and desktop shell. */ + +/** Register the browser package without adding Host behavior. */ +export function apply(): void {} diff --git a/packages/client/ui-file-drag/tests/plugin.client.spec.tsx b/packages/client/ui-file-drag/tests/plugin.client.spec.tsx new file mode 100644 index 0000000000..b0c83a62ec --- /dev/null +++ b/packages/client/ui-file-drag/tests/plugin.client.spec.tsx @@ -0,0 +1,102 @@ +// @vitest-environment jsdom +/** Plugin lifecycle and feedback outlive the source component. */ +import { File as NodeFile } from 'node:buffer' +import { act, cleanup, fireEvent, render, screen } from '@testing-library/react' +import { afterEach, expect, it, onTestFinished, vi } from 'vitest' +import { SlotTestRuntime } from '@deepseek-ai/dsh-client-test-runtime' +import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client' +import { DraggableImage } from '@deepseek-ai/dsh-client-ui-primitives' +import { FILE_DRAG_TOKEN_TYPE, type DesktopFileDrag, type FileDragHandle } from '@deepseek-ai/dsh-util-file-drag' +import * as plugin from '../src/client/index.ts' +import { apply as applyHost } from '../src/index.ts' + +afterEach(() => { cleanup(); vi.unstubAllGlobals(); vi.restoreAllMocks(); vi.useRealTimers() }) +const revokeUrl = vi.fn<(url: string) => void>() + +function transfer(token = ''): DataTransfer { + const data = new Map([[FILE_DRAG_TOKEN_TYPE, token]]) + return Object.assign({} as DataTransfer, { + items: { clear: vi.fn() }, effectAllowed: 'none', + getData: (type: string) => data.get(type) ?? '', + setData: (type: string, value: string) => { data.set(type, value) }, + }) +} + +async function bench(language: 'zh' | 'en' | 'ja' = 'zh') { + revokeUrl.mockReset() + vi.stubGlobal('File', NodeFile) + vi.stubGlobal('URL', Object.assign(URL, { createObjectURL: vi.fn(() => 'blob:transfer'), revokeObjectURL: revokeUrl })) + const runtime = await SlotTestRuntime.create() + onTestFinished(() => runtime.dispose()) + const locale = new LocaleRuntime(runtime.ctx) + onTestFinished(locale.addLanguage({ id: 'ja', label: '日本語', fallback: 'en' })) + locale.setLocale(language) + runtime.ctx.provide('locale', locale) + runtime.slots.installLocale(locale) + const owner = await runtime.mount(plugin) + await runtime.root.declare({ 'shell.overlay': { kind: 'list', scope: 'root' } }, + ({ renderSlot }) => renderSlot('shell.overlay', {})) + const view = runtime.renderRoot() + return { runtime, owner, view, service: runtime.ctx.fileDrag } +} + +it('keeps the Host entry empty', () => { applyHost() }) + +it('retains bytes after source unmount and releases on the detached node dragend', async () => { + const { service, owner, runtime } = await bench() + const file = new File(['pdf-original'], '报告.pdf', { type: 'application/pdf' }) + const source = render() + const node = source.getByRole('img') + const data = transfer() + expect(service.read(data)).toBeUndefined() + await act(async () => { fireEvent.pointerEnter(node) }) + fireEvent.dragStart(node, { dataTransfer: data }) + source.unmount() + const received = service.read(data)! + expect(received.name).toBe(file.name) + expect(await received.text()).toBe('pdf-original') + expect(data.getData('DownloadURL')).toBe('application/pdf:报告.pdf:blob:transfer') + expect(service.read(transfer('foreign'))).toBeUndefined() + node.dispatchEvent(new Event('dragend')) + expect(service.read(data)).toBeUndefined() + expect(revokeUrl).toHaveBeenCalledWith('blob:transfer') + await owner.dispose() + expect(runtime.ctx.get('fileDrag')).toBeUndefined() + expect(runtime.slots.entries('shell.overlay')).toHaveLength(0) +}) + +it.each([ + ['zh', '拖放失败,请重试'], + ['en', 'Drag failed. Try again.'], + ['ja', 'ドラッグ&ドロップに失敗しました。もう一度お試しください。'], +] as const)('reports a late native failure after source unmount in %s', async (language, message) => { + const pending = Promise.withResolvers() + const handle = 'handle' as FileDragHandle + const desktop: DesktopFileDrag = { + prepare: vi.fn(async () => handle), cancel: vi.fn(), start: vi.fn(() => pending.promise), release: vi.fn(), + } + vi.stubGlobal('dshDesktop', { protocolVersion: 1, fileDrag: desktop }) + const { service } = await bench(language) + vi.stubGlobal('dshDesktop', { protocolVersion: 2 }) + const file = new File(['image'], 'image.png', { type: 'image/png' }) + const source = render() + await act(async () => { fireEvent.pointerEnter(source.getByRole('img')) }) + fireEvent.dragStart(source.getByRole('img'), { dataTransfer: transfer() }) + source.unmount() + expect(desktop.release).not.toHaveBeenCalled() + vi.useFakeTimers() + await act(async () => { pending.resolve(false); await pending.promise }) + expect(desktop.release).toHaveBeenCalledWith(handle) + expect(screen.getByRole('alert').textContent).toBe(message) + await act(async () => { await vi.runAllTimersAsync() }) + expect(screen.queryByRole('alert')).toBeNull() +}) + +it('ignores an unsupported desktop protocol and removes feedback with the plugin', async () => { + vi.stubGlobal('dshDesktop', { protocolVersion: 2, fileDrag: {} }) + const { service, owner } = await bench() + act(() => { service.actions.reportFailure() }) + expect(screen.getByRole('alert')).toBeTruthy() + await owner.dispose() + expect(screen.queryByRole('alert')).toBeNull() +}) diff --git a/packages/client/ui-file-drag/tests/transfers.client.spec.ts b/packages/client/ui-file-drag/tests/transfers.client.spec.ts new file mode 100644 index 0000000000..1b0a3d3fe1 --- /dev/null +++ b/packages/client/ui-file-drag/tests/transfers.client.spec.ts @@ -0,0 +1,202 @@ +// @vitest-environment jsdom +import { File as NodeFile, Blob as NodeBlob } from 'node:buffer' +import { webcrypto } from 'node:crypto' +import { afterEach, beforeEach, expect, it, vi } from 'vitest' +import { FILE_DRAG_TOKEN_TYPE, type DesktopFileDrag, type FileDragHandle } from '@deepseek-ai/dsh-util-file-drag' +import { FileTransfers } from '../src/client/transfers.ts' + +const revokeUrl = vi.fn<(url: string) => void>() +beforeEach(() => { + revokeUrl.mockReset() + vi.stubGlobal('File', NodeFile) + vi.stubGlobal('Blob', NodeBlob) + vi.stubGlobal('crypto', webcrypto) + vi.stubGlobal('URL', Object.assign(URL, { createObjectURL: vi.fn(() => 'blob:copy'), revokeObjectURL: revokeUrl })) +}) +afterEach(() => { vi.unstubAllGlobals(); vi.restoreAllMocks() }) +const blobSource = () => ({ kind: 'blob' as const, blob: new Blob(['%PDF-original'], { type: 'application/pdf' }), name: 'report', mediaType: '' }) +const signal = () => new AbortController().signal +const native = (): DesktopFileDrag => ({ + prepare: vi.fn(async () => 'handle' as FileDragHandle), cancel: vi.fn(), start: vi.fn(async () => true), release: vi.fn(), +}) +function transfer(): DataTransfer { + const data = new Map() + return Object.assign({} as DataTransfer, { items: { clear: vi.fn() }, effectAllowed: 'none', + setData: (key: string, value: string) => { data.set(key, value) }, getData: (key: string) => data.get(key) ?? '' }) +} + +it('borrows an authorized resource before fetching URL bytes', async () => { + const desktop = native() + const fetcher = vi.fn() + vi.stubGlobal('fetch', fetcher) + const files = new FileTransfers(desktop, vi.fn()) + const prepared = await files.prepare({ kind: 'url', url: '/api/file', name: 'report.pdf', mediaType: 'application/pdf', resourceAddress: 'dsh-resource://file/session/s/report.pdf' }, signal()) + expect(desktop.prepare).toHaveBeenCalledWith(expect.objectContaining({ kind: 'resource', address: 'dsh-resource://file/session/s/report.pdf' }), expect.any(String)) + expect(fetcher).not.toHaveBeenCalled() + expect(prepared.start(transfer(), document.createElement('div'))).toBe(false) + await Promise.resolve() + prepared.release() + await Promise.resolve() + expect(desktop.release).toHaveBeenCalledOnce() + prepared.release() + expect(prepared.start(transfer(), document.createElement('div'))).toBe(false) + files.dispose() +}) + +it('compares original Blob bytes by digest and copies a mismatching resource', async () => { + const desktop = native() + vi.mocked(desktop.prepare).mockResolvedValueOnce(undefined) + const files = new FileTransfers(desktop, vi.fn()) + const prepared = await files.prepare({ ...blobSource(), resourceAddress: 'dsh-resource://file/session/s/report.pdf', outputExtension: '.bin' }, signal()) + expect(desktop.prepare).toHaveBeenNthCalledWith(1, expect.objectContaining({ kind: 'resource', size: 13 }), expect.any(String)) + const request = vi.mocked(desktop.prepare).mock.calls[0]![0] + expect(request.kind === 'resource' ? request.sha256 : undefined).toMatch(/^[a-f0-9]{64}$/u) + expect(desktop.prepare).toHaveBeenNthCalledWith(2, expect.objectContaining({ kind: 'bytes', name: 'report.bin', outputExtension: '.bin' }), expect.any(String)) + prepared.release() + files.dispose() +}) + +it('fetches generic remote bytes and preserves explicit unknown types and extensions', async () => { + const fetcher = vi.fn(async () => new Response('sheet-bytes', { headers: { 'Content-Type': 'application/x-custom' } })) + vi.stubGlobal('fetch', fetcher) + const files = new FileTransfers(undefined, vi.fn()) + const prepared = await files.prepare({ kind: 'url', url: '/sheet', name: 'sheet.custom', mediaType: '' }, signal()) + const data = transfer() + const node = document.createElement('div') + expect(prepared.start(data, node)).toBe(true) + const file = files.read(data)! + expect(file.name).toBe('sheet.custom') + expect(await file.text()).toBe('sheet-bytes') + expect(data.getData(FILE_DRAG_TOKEN_TYPE)).not.toBe('') + node.dispatchEvent(new Event('dragend')) + expect(files.read(data)).toBeUndefined() + prepared.release() + files.dispose() +}) + +it('keeps retained files through the drop task and retires them afterward', async () => { + const files = new FileTransfers(undefined, vi.fn()) + const prepared = await files.prepare(blobSource(), signal()) + const data = transfer() + prepared.start(data, document.createElement('div')) + prepared.release() + window.dispatchEvent(new Event('drop')) + expect(files.read(data)).toBeDefined() + await Promise.resolve() + expect(files.read(data)).toBeUndefined() + expect(revokeUrl).toHaveBeenCalledOnce() + expect(prepared.start(data, document.createElement('div'))).toBe(false) + files.dispose() +}) + +it('replaces active transfers and disposes mounted preparations', async () => { + const files = new FileTransfers(undefined, vi.fn()) + const first = await files.prepare(blobSource(), signal()) + const second = await files.prepare(blobSource(), signal()) + const a = transfer(), b = transfer() + first.start(a, document.createElement('div')) + second.start(b, document.createElement('div')) + expect(files.read(a)).toBeUndefined() + expect(files.read(b)).toBeDefined() + files.dispose() + expect(files.read(b)).toBeUndefined() + expect(revokeUrl).toHaveBeenCalledTimes(2) + await expect(files.prepare(blobSource(), signal())).rejects.toThrow('File transfers disposed') +}) + +it('reports native start rejection and releases failed preparation', async () => { + const desktop = native() + const failed = vi.fn() + const files = new FileTransfers(desktop, failed) + const prepared = await files.prepare(blobSource(), signal()) + vi.mocked(desktop.start).mockRejectedValueOnce(new Error('OS failed')) + prepared.start(transfer(), document.createElement('div')) + await Promise.resolve() + expect(failed).toHaveBeenCalledOnce() + files.dispose() + vi.mocked(desktop.prepare).mockResolvedValue(undefined) + const retry = new FileTransfers(desktop, failed) + await expect(retry.prepare(blobSource(), signal())).rejects.toThrow('File preparation unavailable') + retry.dispose() +}) + +it('rejects unreadable remote files without retaining a preparation', async () => { + vi.stubGlobal('fetch', vi.fn(async () => new Response('', { status: 404 }))) + const files = new FileTransfers(undefined, vi.fn()) + await expect(files.prepare({ kind: 'url', url: '/missing', mediaType: '' }, signal())).rejects.toThrow('File read failed') + files.dispose() +}) + +it('copies already loaded PDF bytes when optional resource authorization rejects', async () => { + const desktop = native() + vi.mocked(desktop.prepare).mockRejectedValueOnce(new Error('Host resource unavailable')) + const files = new FileTransfers(desktop, vi.fn()) + const prepared = await files.prepare({ ...blobSource(), resourceAddress: 'dsh-resource://file/session/s/report.pdf' }, signal()) + const request = vi.mocked(desktop.prepare).mock.calls[1]![0] + expect(request.kind).toBe('bytes') + expect(request.name).toBe('report.pdf') + if (request.kind === 'bytes') expect(new TextDecoder().decode(request.bytes)).toBe('%PDF-original') + prepared.release() + files.dispose() +}) + +it('sends cancellation before native preparation has returned its handle', async () => { + const desktop = native() + const pending = Promise.withResolvers() + vi.mocked(desktop.prepare).mockReturnValue(pending.promise) + const files = new FileTransfers(desktop, vi.fn()) + const controller = new AbortController() + const preparing = files.prepare(blobSource(), controller.signal) + const rejected = expect(preparing).rejects.toThrow() + await vi.waitFor(() => { expect(desktop.prepare).toHaveBeenCalledOnce() }) + const requestId = vi.mocked(desktop.prepare).mock.calls[0]![1] + controller.abort() + expect(desktop.cancel).toHaveBeenCalledWith(requestId) + pending.resolve('late' as FileDragHandle) + await rejected + expect(desktop.release).toHaveBeenCalledWith('late') + files.dispose() +}) + +it('does not let an older drop microtask clear a newer transfer and supplies a MIME for untyped files', async () => { + const files = new FileTransfers(undefined, vi.fn()) + const first = await files.prepare(blobSource(), signal()) + const second = await files.prepare({ kind: 'blob', blob: new Blob(['raw']), name: 'raw.custom', mediaType: '' }, signal()) + const a = transfer(), b = transfer() + first.start(a, document.createElement('div')) + first.release() + window.dispatchEvent(new Event('drop')) + second.start(b, document.createElement('div')) + await Promise.resolve() + expect(files.read(b)?.name).toBe('raw.custom') + expect(b.getData('DownloadURL')).toBe('application/octet-stream:raw.custom:blob:copy') + files.dispose() +}) + +it.each(['abort', 'dispose'] as const)('releases a native preparation completed after %s', async (action) => { + const desktop = native() + const pending = Promise.withResolvers() + vi.mocked(desktop.prepare).mockReturnValue(pending.promise) + const files = new FileTransfers(desktop, vi.fn()) + const abort = new AbortController() + const preparing = files.prepare(blobSource(), abort.signal) + const rejected = expect(preparing).rejects.toThrow() + await vi.waitFor(() => { expect(desktop.prepare).toHaveBeenCalledOnce() }) + if (action === 'abort') abort.abort() + else files.dispose() + pending.resolve('late' as FileDragHandle) + await rejected + expect(desktop.release).toHaveBeenCalledWith('late') + files.dispose() +}) + +it('rejects a Web preparation when the plugin is disposed while fetching', async () => { + const pending = Promise.withResolvers() + vi.stubGlobal('fetch', vi.fn(() => pending.promise)) + const files = new FileTransfers(undefined, vi.fn()) + const preparing = files.prepare({ kind: 'url', url: '/pending', mediaType: 'application/pdf' }, signal()) + const rejected = expect(preparing).rejects.toThrow() + files.dispose() + pending.resolve(new Response('pdf')) + await rejected +}) diff --git a/packages/client/ui-file-drag/tsconfig.json b/packages/client/ui-file-drag/tsconfig.json new file mode 100644 index 0000000000..7aec375e70 --- /dev/null +++ b/packages/client/ui-file-drag/tsconfig.json @@ -0,0 +1,39 @@ +{ + "extends": "../../../tsconfig.base.client.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types" + }, + "include": [ + "src" + ], + "references": [ + { + "path": "../../../vendor/cordis" + }, + { + "path": "../store" + }, + { + "path": "../ui-slots" + }, + { + "path": "../ui-primitives" + }, + { + "path": "../ui-renderer" + }, + { + "path": "../ui-layout" + }, + { + "path": "../../util/file-drag" + }, + { + "path": "../../util/crypto" + }, + { + "path": "../locale/tsconfig.client.json" + } + ] +} diff --git a/packages/client/ui-file-drag/tsdown.config.ts b/packages/client/ui-file-drag/tsdown.config.ts new file mode 100644 index 0000000000..7aa03cebc1 --- /dev/null +++ b/packages/client/ui-file-drag/tsdown.config.ts @@ -0,0 +1,3 @@ +import { clientBundle } from '../tsdown.client.ts' + +export default clientBundle(import.meta.dirname, ['lib/types/index.js']) diff --git a/packages/client/ui-goal/locale/de.json b/packages/client/ui-goal/locale/de.json new file mode 100644 index 0000000000..2a2420166b --- /dev/null +++ b/packages/client/ui-goal/locale/de.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "Sitzungszielleiste", + "description": "Zeigt das Ziel und den Status der aktuellen Sitzung über dem Eingabefeld an." + } +} diff --git a/packages/client/ui-goal/locale/es.json b/packages/client/ui-goal/locale/es.json new file mode 100644 index 0000000000..e0135182ed --- /dev/null +++ b/packages/client/ui-goal/locale/es.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "barra de destino de sesión", + "description": "Muestra el objetivo y el estado de la sesión actual encima del cuadro de entrada." + } +} diff --git a/packages/client/ui-goal/locale/fr.json b/packages/client/ui-goal/locale/fr.json new file mode 100644 index 0000000000..2282fdbd25 --- /dev/null +++ b/packages/client/ui-goal/locale/fr.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "barre de cible de session", + "description": "Affiche l'objectif et le statut de la session en cours au-dessus de la zone de saisie." + } +} diff --git a/packages/client/ui-goal/locale/ja.json b/packages/client/ui-goal/locale/ja.json new file mode 100644 index 0000000000..fd28df331a --- /dev/null +++ b/packages/client/ui-goal/locale/ja.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "セッション目標バー", + "description": "入力欄の上に現在のセッションの目標と状態を表示します。" + } +} diff --git a/packages/client/ui-goal/locale/pt.json b/packages/client/ui-goal/locale/pt.json new file mode 100644 index 0000000000..d7e3fa30c3 --- /dev/null +++ b/packages/client/ui-goal/locale/pt.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "barra de destino da sessão", + "description": "Exibe a meta e o status da sessão atual acima da caixa de entrada." + } +} diff --git a/packages/client/ui-goal/locale/ru.json b/packages/client/ui-goal/locale/ru.json new file mode 100644 index 0000000000..2736841f94 --- /dev/null +++ b/packages/client/ui-goal/locale/ru.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "целевая панель сеанса", + "description": "Отображает цель и статус текущего сеанса над полем ввода." + } +} diff --git a/packages/client/ui-goal/src/client/index.ts b/packages/client/ui-goal/src/client/index.ts index ba15d921e2..7c1eadb38a 100644 --- a/packages/client/ui-goal/src/client/index.ts +++ b/packages/client/ui-goal/src/client/index.ts @@ -35,7 +35,7 @@ import { GoalCommandReference } from './GoalCommandReference.tsx' import { goalCommandInputDefinition } from './goal-command-input.ts' import { goalCommandReferenceRule } from './user-text-rules.ts' export type {} from './user-text-rules.ts' -import { en, zh, type GoalKey } from './locales.ts' +import { en, ja, es, fr, de, ru, pt, zh, type GoalKey } from './locales.ts' export { GoalBar, GoalDock } from './GoalBar.tsx' export type { @@ -70,7 +70,7 @@ export const inject = ['slots', 'sessions', 'remote', 'remote.goals', 'locale', export function apply(ctx: ClientContext): void { ctx.uiConversation.events.register(goalCommandInputDefinition) ctx.uiConversation.userText.register(goalCommandReferenceRule) - ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'ui-goal: dictionaries') + ctx.effect(() => ctx.locale.register(NS, { zh, en, ja, es, fr, de, ru, pt }), 'ui-goal: dictionaries') ctx.slots.inject('conversation.user.text.node', () => ctx.slots.register({ name: 'conversation.user.text.node', diff --git a/packages/client/ui-goal/src/client/locales.ts b/packages/client/ui-goal/src/client/locales.ts index 2296d4842c..f52946329e 100644 --- a/packages/client/ui-goal/src/client/locales.ts +++ b/packages/client/ui-goal/src/client/locales.ts @@ -34,3 +34,99 @@ export const en = { 'action.edit': 'Edit goal', 'action.clear': 'Clear goal', } satisfies Record + +/** Japanese copy, checked against the English keys. */ +export const ja = { + 'phase.active': '進行中の目標', + 'phase.active.disarmed': '非アクティブな目標', + 'phase.paused': '一時停止中の目標', + 'phase.blocked': 'ブロックされた目標', + 'objective.aria': '目標の目的', + 'commandInput.aria': 'コマンド入力', + 'action.save': '目標を保存', + 'action.cancel': '編集をキャンセル', + 'action.pause': '目標を一時停止', + 'action.resume': '目標を再開', + 'action.edit': '目標を編集', + 'action.clear': '目標をクリア', +} satisfies Record + +/** Spanish dictionary, checked against the English keys. */ +export const es = { + 'phase.active': 'Meta en curso', + 'phase.active.disarmed': 'Meta inactiva', + 'phase.paused': 'Objetivo en pausa', + 'phase.blocked': 'Meta bloqueada', + 'objective.aria': 'Objetivo de meta', + 'commandInput.aria': 'Entrada de comando', + 'action.save': 'Guardar gol', + 'action.cancel': 'Cancelar edición', + 'action.pause': 'Pausar objetivo', + 'action.resume': 'Reanudar objetivo', + 'action.edit': 'Editar objetivo', + 'action.clear': 'Objetivo claro', +} satisfies Record + +/** French dictionary, checked against the English keys. */ +export const fr = { + 'phase.active': 'Objectif continu', + 'phase.active.disarmed': 'Objectif inactif', + 'phase.paused': 'Objectif suspendu', + 'phase.blocked': 'Objectif bloqué', + 'objective.aria': 'Objectif du but', + 'commandInput.aria': 'Entrée de commande', + 'action.save': "Sauvegarder l'objectif", + 'action.cancel': 'Annuler la modification', + 'action.pause': "Suspendre l'objectif", + 'action.resume': "Reprendre l'objectif", + 'action.edit': "Modifier l'objectif", + 'action.clear': 'Objectif clair', +} satisfies Record + +/** German dictionary, checked against the English keys. */ +export const de = { + 'phase.active': 'Fortlaufendes Ziel', + 'phase.active.disarmed': 'Inaktives Ziel', + 'phase.paused': 'Pausiertes Ziel', + 'phase.blocked': 'Blockiertes Ziel', + 'objective.aria': 'Zielziel', + 'commandInput.aria': 'Befehlseingabe', + 'action.save': 'Ziel speichern', + 'action.cancel': 'Bearbeitung abbrechen', + 'action.pause': 'Ziel pausieren', + 'action.resume': 'Ziel fortsetzen', + 'action.edit': 'Ziel bearbeiten', + 'action.clear': 'Klares Ziel', +} satisfies Record + +/** Russian dictionary, checked against the English keys. */ +export const ru = { + 'phase.active': 'Текущая цель', + 'phase.active.disarmed': 'Неактивная цель', + 'phase.paused': 'Приостановленная цель', + 'phase.blocked': 'Заблокированная цель', + 'objective.aria': 'Цель Цель', + 'commandInput.aria': 'Ввод команды', + 'action.save': 'Сохранить цель', + 'action.cancel': 'Отменить редактирование', + 'action.pause': 'Приостановить цель', + 'action.resume': 'Возобновить цель', + 'action.edit': 'Изменить цель', + 'action.clear': 'Четкая цель', +} satisfies Record + +/** Portuguese dictionary, checked against the English keys. */ +export const pt = { + 'phase.active': 'Meta contínua', + 'phase.active.disarmed': 'Meta inativa', + 'phase.paused': 'Meta pausada', + 'phase.blocked': 'Meta bloqueada', + 'objective.aria': 'Objetivo da meta', + 'commandInput.aria': 'Entrada de comando', + 'action.save': 'Salvar meta', + 'action.cancel': 'Cancelar edição', + 'action.pause': 'Pausar meta', + 'action.resume': 'Retomar meta', + 'action.edit': 'Editar meta', + 'action.clear': 'Objetivo claro', +} satisfies Record diff --git a/packages/client/ui-input-trigger/locale/de.json b/packages/client/ui-input-trigger/locale/de.json new file mode 100644 index 0000000000..be4ceb8980 --- /dev/null +++ b/packages/client/ui-input-trigger/locale/de.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "Geben Sie Lenovo Services ein", + "description": "Erkennen Sie / und @ in der Eingabe, zeigen Sie die entsprechenden Kandidaten an und wählen Sie sie aus." + } +} diff --git a/packages/client/ui-input-trigger/locale/es.json b/packages/client/ui-input-trigger/locale/es.json new file mode 100644 index 0000000000..48be835ec5 --- /dev/null +++ b/packages/client/ui-input-trigger/locale/es.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "Ingrese a los servicios de Lenovo", + "description": "Reconocer / y @ en la entrada, mostrar y seleccionar los candidatos correspondientes." + } +} diff --git a/packages/client/ui-input-trigger/locale/fr.json b/packages/client/ui-input-trigger/locale/fr.json new file mode 100644 index 0000000000..225c4f9e3f --- /dev/null +++ b/packages/client/ui-input-trigger/locale/fr.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "Entrez dans les services Lenovo", + "description": "Reconnaître / et @ dans la saisie, afficher et sélectionner les candidats correspondants." + } +} diff --git a/packages/client/ui-input-trigger/locale/ja.json b/packages/client/ui-input-trigger/locale/ja.json new file mode 100644 index 0000000000..4a8f8af80f --- /dev/null +++ b/packages/client/ui-input-trigger/locale/ja.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "入力候補サービス", + "description": "入力中の / と @ を認識し、対応する候補を表示して選択できます。" + } +} diff --git a/packages/client/ui-input-trigger/locale/pt.json b/packages/client/ui-input-trigger/locale/pt.json new file mode 100644 index 0000000000..91b25e4363 --- /dev/null +++ b/packages/client/ui-input-trigger/locale/pt.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "Entre nos serviços Lenovo", + "description": "Reconheça / e @ na entrada, exiba e selecione os candidatos correspondentes." + } +} diff --git a/packages/client/ui-input-trigger/locale/ru.json b/packages/client/ui-input-trigger/locale/ru.json new file mode 100644 index 0000000000..9aeeefffd6 --- /dev/null +++ b/packages/client/ui-input-trigger/locale/ru.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "Войдите в услуги Lenovo", + "description": "Распознайте / и @ во входных данных, отобразите и выберите соответствующих кандидатов." + } +} diff --git a/packages/client/ui-input-trigger/src/client/index.ts b/packages/client/ui-input-trigger/src/client/index.ts index 9141210c0e..de4186135f 100644 --- a/packages/client/ui-input-trigger/src/client/index.ts +++ b/packages/client/ui-input-trigger/src/client/index.ts @@ -13,7 +13,7 @@ import type {} from '@deepseek-ai/dsh-client-ui-session/client' import { InputTriggerService } from './service.ts' import type { MenuViewInjected } from './slots.ts' import { MenuView } from './MenuView.tsx' -import { en, zh, type MenuKey } from './locales.ts' +import { en, ja, es, fr, de, ru, pt, zh, type MenuKey } from './locales.ts' export { InputTriggerService } from './service.ts' export { InputTriggerController } from './controller.ts' @@ -58,7 +58,7 @@ export const inject = ['sessions', 'locale'] */ export function apply(ctx: ClientContext): void { ctx.plugin(InputTriggerService) - ctx.effect(() => ctx.locale.register(MENU_NS, { zh, en }), 'ui-input-trigger: menu dictionaries') + ctx.effect(() => ctx.locale.register(MENU_NS, { zh, en, ja, es, fr, de, ru, pt }), 'ui-input-trigger: menu dictionaries') ctx.inject(['slots', 'inputTriggers', 'sessions'], (scope: ClientContext) => { const inputTriggers = scope.inputTriggers const sessions = scope.sessions diff --git a/packages/client/ui-input-trigger/src/client/locales.ts b/packages/client/ui-input-trigger/src/client/locales.ts index 704edbd633..63f0780d1d 100644 --- a/packages/client/ui-input-trigger/src/client/locales.ts +++ b/packages/client/ui-input-trigger/src/client/locales.ts @@ -32,3 +32,81 @@ export const en = { 'crumbs.aria': 'Folder navigation', 'suggestions.aria': 'Trigger suggestions', } satisfies Record + +/** Japanese copy, checked against the English keys. */ +export const ja = { + 'command': 'コマンド', + 'skill': 'スキル', + 'subagent': 'サブエージェント', + 'loading': '読み込み中…', + 'drill.aria': 'フォルダを参照', + 'drill.hint': 'フォルダを参照', + 'drill.key': 'Tab', + 'crumbs.aria': 'フォルダナビゲーション', + 'suggestions.aria': '候補を表示', +} satisfies Record + +/** Spanish dictionary, checked against the English keys. */ +export const es = { + 'command': 'Comandos', + 'skill': 'Habilidades', + 'subagent': 'Subagentes', + 'loading': 'Cargando…', + 'drill.aria': 'Explorar carpeta', + 'drill.hint': 'Explorar carpeta', + 'drill.key': 'Pestaña', + 'crumbs.aria': 'Navegación de carpetas', + 'suggestions.aria': 'Sugerencias de activación', +} satisfies Record + +/** French dictionary, checked against the English keys. */ +export const fr = { + 'command': 'Commandes', + 'skill': 'Compétences', + 'subagent': 'Sous-agents', + 'loading': 'Chargement…', + 'drill.aria': 'Parcourir le dossier', + 'drill.hint': 'Parcourir le dossier', + 'drill.key': 'Onglet', + 'crumbs.aria': 'Navigation dans les dossiers', + 'suggestions.aria': 'Suggestions de déclenchement', +} satisfies Record + +/** German dictionary, checked against the English keys. */ +export const de = { + 'command': 'Befehle', + 'skill': 'Fähigkeiten', + 'subagent': 'Subagenten', + 'loading': 'Laden…', + 'drill.aria': 'Ordner durchsuchen', + 'drill.hint': 'Ordner durchsuchen', + 'drill.key': 'Tab', + 'crumbs.aria': 'Ordnernavigation', + 'suggestions.aria': 'Trigger-Vorschläge', +} satisfies Record + +/** Russian dictionary, checked against the English keys. */ +export const ru = { + 'command': 'Команды', + 'skill': 'Навыки', + 'subagent': 'Субагенты', + 'loading': 'Загрузка…', + 'drill.aria': 'Просмотреть папку', + 'drill.hint': 'Просмотреть папку', + 'drill.key': 'Вкладка', + 'crumbs.aria': 'Навигация по папкам', + 'suggestions.aria': 'Триггерные предложения', +} satisfies Record + +/** Portuguese dictionary, checked against the English keys. */ +export const pt = { + 'command': 'Comandos', + 'skill': 'Habilidades', + 'subagent': 'Subagentes', + 'loading': 'Carregando…', + 'drill.aria': 'Navegar na pasta', + 'drill.hint': 'Navegar na pasta', + 'drill.key': 'Guia', + 'crumbs.aria': 'Navegação de pastas', + 'suggestions.aria': 'Sugestões de gatilho', +} satisfies Record diff --git a/packages/client/ui-jobs/locale/de.json b/packages/client/ui-jobs/locale/de.json new file mode 100644 index 0000000000..87cc3c7d1f --- /dev/null +++ b/packages/client/ui-jobs/locale/de.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "Hintergrundaufgabenschnittstelle", + "description": "Zeigen Sie Hintergrundaufgabenlisten, Status und Echtzeitausgabe innerhalb einer Sitzung an." + } +} diff --git a/packages/client/ui-jobs/locale/es.json b/packages/client/ui-jobs/locale/es.json new file mode 100644 index 0000000000..9ce2933127 --- /dev/null +++ b/packages/client/ui-jobs/locale/es.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "Interfaz de tareas en segundo plano", + "description": "Vea listas de tareas en segundo plano, estados y resultados en tiempo real dentro de una sesión." + } +} diff --git a/packages/client/ui-jobs/locale/fr.json b/packages/client/ui-jobs/locale/fr.json new file mode 100644 index 0000000000..e950919b81 --- /dev/null +++ b/packages/client/ui-jobs/locale/fr.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "Interface de tâche en arrière-plan", + "description": "Affichez les listes de tâches en arrière-plan, l'état et la sortie en temps réel au sein d'une session." + } +} diff --git a/packages/client/ui-jobs/locale/ja.json b/packages/client/ui-jobs/locale/ja.json new file mode 100644 index 0000000000..29c5699949 --- /dev/null +++ b/packages/client/ui-jobs/locale/ja.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "バックグラウンドタスク UI", + "description": "セッション内でバックグラウンドタスクの一覧、状態、リアルタイム出力を確認します。" + } +} diff --git a/packages/client/ui-jobs/locale/pt.json b/packages/client/ui-jobs/locale/pt.json new file mode 100644 index 0000000000..2820658f19 --- /dev/null +++ b/packages/client/ui-jobs/locale/pt.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "Interface de tarefa em segundo plano", + "description": "Visualize listas de tarefas em segundo plano, status e resultados em tempo real em uma sessão." + } +} diff --git a/packages/client/ui-jobs/locale/ru.json b/packages/client/ui-jobs/locale/ru.json new file mode 100644 index 0000000000..3d2a910429 --- /dev/null +++ b/packages/client/ui-jobs/locale/ru.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "Интерфейс фоновой задачи", + "description": "Просматривайте списки фоновых задач, статус и выходные данные в режиме реального времени в течение сеанса." + } +} diff --git a/packages/client/ui-jobs/src/client/index.ts b/packages/client/ui-jobs/src/client/index.ts index 91f93a2442..5174aa1fb8 100644 --- a/packages/client/ui-jobs/src/client/index.ts +++ b/packages/client/ui-jobs/src/client/index.ts @@ -12,7 +12,7 @@ import type {} from '@deepseek-ai/dsh-api-job-controller/client' import type {} from '@deepseek-ai/dsh-client-locale/client' import type {} from '@deepseek-ai/dsh-client-ui-renderer/client' import type {} from '@deepseek-ai/dsh-client-ui-session/client' -import { en, NS, zh, type JobKey } from './locales.ts' +import { en, ja, es, fr, de, ru, pt, NS, zh, type JobKey } from './locales.ts' declare module '@deepseek-ai/dsh-client-ui-slots' { interface LocaleNamespaceMap { @@ -31,7 +31,7 @@ export const inject = ['jobs', 'slots', 'locale'] * @param ctx - client root context. */ export function apply(ctx: ClientContext): void { - ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'ui-jobs: dictionaries') + ctx.effect(() => ctx.locale.register(NS, { zh, en, ja, es, fr, de, ru, pt }), 'ui-jobs: dictionaries') ctx.slots.inject( 'conversation.session.header.actions', () => ctx.slots.register({ diff --git a/packages/client/ui-jobs/src/client/locales.ts b/packages/client/ui-jobs/src/client/locales.ts index 5db43e0514..700009d9a5 100644 --- a/packages/client/ui-jobs/src/client/locales.ts +++ b/packages/client/ui-jobs/src/client/locales.ts @@ -93,3 +93,267 @@ export const en: Record = { /** Key domain of the `job` namespace (zh is the source of truth). */ export type JobKey = keyof typeof zh + +/** Japanese copy, checked against the English keys. */ +export const ja = { + 'count.live.one': 'バックグラウンドジョブ {count} 件が実行中', + 'count.live.other': 'バックグラウンドジョブ {count} 件が実行中', + 'count.idle.one': 'バックグラウンドジョブ {count} 件', + 'count.idle.other': 'バックグラウンドジョブ {count} 件', + 'list.aria': 'バックグラウンドジョブ', + 'section.live': '実行中', + 'section.settledCount': '完了 {count} 件', + 'section.clear': 'クリア', + 'row.expandAria': '{label} のライブ出力を表示', + 'row.collapseAria': '{label} のライブ出力を非表示', + 'kill.stop': 'タスク {label} を停止', + 'kill.confirm': 'もう一度クリックして確定', + 'kill.confirmAction': '停止を確定', + 'kill.failed': '停止に失敗しました', + 'status.running': '実行中', + 'status.stopping': '停止中', + 'status.completed': '完了', + 'status.killed': 'キャンセル済み', + 'status.failed': '失敗', + 'duration.seconds': '{seconds}秒', + 'duration.minutes': '{minutes}分 {seconds}秒', + 'duration.hours': '{hours}時間 {minutes}分', + 'duration.title.live': '実行時間 {duration}', + 'duration.title.done': '所要時間 {duration}', + 'output.gap': '…以前の出力は破棄されました…', + 'output.error': 'ライブ出力ストリームが中断されました: {error}', + 'terminal.signal': 'シグナル {signal}', + 'terminal.exitCode': '終了コード {code}', + 'terminal.noExitCode': '終了コードなし', + 'terminal.commandLine': 'コマンドライン {n}', + 'terminal.running': '実行中', + 'terminal.failed': '失敗', + 'terminal.done': '完了', + 'terminal.copy': 'コピー', + 'terminal.copied': 'コピーしました', + 'terminal.noOutput': '(出力なし)', + 'terminal.collapse': '折りたたむ', + 'terminal.collapseAria': '出力を折りたたむ', + 'terminal.expand': '残り {n} 行を表示', + 'terminal.expandAria': '折りたたまれた出力 {n} 行を展開', +} satisfies Record + +/** Spanish dictionary, checked against the English keys. */ +export const es = { + 'count.live.one': 'Tareas en segundo plano en ejecución: {count}', + 'count.live.other': 'Tareas en segundo plano en ejecución: {count}', + 'count.idle.one': 'Tareas en segundo plano: {count}', + 'count.idle.other': 'Tareas en segundo plano: {count}', + 'list.aria': 'Trabajos en segundo plano', + 'section.live': 'corriendo', + 'section.settledCount': 'Terminado {count}', + 'section.clear': 'Borrar', + 'row.expandAria': 'Mostrar salida en vivo de {label}', + 'row.collapseAria': 'Ocultar salida en vivo de {label}', + 'kill.stop': 'Detener tarea {label}', + 'kill.confirm': 'Haga clic nuevamente para confirmar', + 'kill.confirmAction': 'Confirmar parada', + 'kill.failed': 'Detener falló', + 'status.running': 'corriendo', + 'status.stopping': 'parando', + 'status.completed': 'completado', + 'status.killed': 'cancelado', + 'status.failed': 'falló', + 'duration.seconds': '{seconds}s', + 'duration.minutes': '{minutes}m {seconds}s', + 'duration.hours': '{hours}h {minutes}m', + 'duration.title.live': 'Corriendo para {duration}', + 'duration.title.done': 'Tomó {duration}', + 'output.gap': '… la producción anterior cayó…', + 'output.error': 'transmisión de salida en vivo interrumpida: {error}', + 'terminal.signal': 'señal {signal}', + 'terminal.exitCode': 'salir {code}', + 'terminal.noExitCode': 'sin código de salida', + 'terminal.commandLine': 'Línea de comando {n}', + 'terminal.running': 'corriendo', + 'terminal.failed': 'falló', + 'terminal.done': 'hecho', + 'terminal.copy': 'Copiar', + 'terminal.copied': 'Copiado', + 'terminal.noOutput': '(sin salida)', + 'terminal.collapse': 'Contraer', + 'terminal.collapseAria': 'Contraer salida', + 'terminal.expand': 'Mostrar {n} más líneas', + 'terminal.expandAria': 'Expandir líneas de salida colapsadas {n}', +} satisfies Record + +/** French dictionary, checked against the English keys. */ +export const fr = { + 'count.live.one': 'Tâches en arrière-plan en cours : {count}', + 'count.live.other': 'Tâches en arrière-plan en cours : {count}', + 'count.idle.one': 'Tâches en arrière-plan : {count}', + 'count.idle.other': 'Tâches en arrière-plan : {count}', + 'list.aria': 'Travaux en arrière-plan', + 'section.live': 'Courir', + 'section.settledCount': 'Terminé {count}', + 'section.clear': 'Effacer', + 'row.expandAria': 'Afficher la sortie en direct de {label}', + 'row.collapseAria': 'Masquer la sortie en direct de {label}', + 'kill.stop': 'Arrêter la tâche {label}', + 'kill.confirm': 'Cliquez à nouveau pour confirmer', + 'kill.confirmAction': "Confirmer l'arrêt", + 'kill.failed': "L'arrêt a échoué", + 'status.running': 'courir', + 'status.stopping': 'arrêter', + 'status.completed': 'terminé', + 'status.killed': 'annulé', + 'status.failed': 'échoué', + 'duration.seconds': '{seconds}s', + 'duration.minutes': '{minutes}m {seconds}s', + 'duration.hours': '{hours}h {minutes}m', + 'duration.title.live': 'Courir pour {duration}', + 'duration.title.done': 'A pris {duration}', + 'output.gap': '… la production antérieure a chuté…', + 'output.error': 'flux de sortie en direct interrompu : {error}', + 'terminal.signal': 'signal {signal}', + 'terminal.exitCode': 'quitter {code}', + 'terminal.noExitCode': 'pas de code de sortie', + 'terminal.commandLine': 'Ligne de commande {n}', + 'terminal.running': 'courir', + 'terminal.failed': 'échoué', + 'terminal.done': 'fait', + 'terminal.copy': 'Copier', + 'terminal.copied': 'Copié', + 'terminal.noOutput': '(pas de sortie)', + 'terminal.collapse': 'Réduire', + 'terminal.collapseAria': 'Réduire la sortie', + 'terminal.expand': 'Afficher {n} plus de lignes', + 'terminal.expandAria': 'Développer les lignes de sortie réduites {n}', +} satisfies Record + +/** German dictionary, checked against the English keys. */ +export const de = { + 'count.live.one': 'Laufende Hintergrundaufgaben: {count}', + 'count.live.other': 'Laufende Hintergrundaufgaben: {count}', + 'count.idle.one': 'Hintergrundaufgaben: {count}', + 'count.idle.other': 'Hintergrundaufgaben: {count}', + 'list.aria': 'Hintergrundjobs', + 'section.live': 'Laufen', + 'section.settledCount': 'Fertig {count}', + 'section.clear': 'Klar', + 'row.expandAria': 'Live-Ausgabe von {label} anzeigen', + 'row.collapseAria': 'Live-Ausgabe von {label} ausblenden', + 'kill.stop': 'Stoppen Sie die Aufgabe {label}', + 'kill.confirm': 'Klicken Sie zur Bestätigung erneut', + 'kill.confirmAction': 'Stopp bestätigen', + 'kill.failed': 'Stoppen ist fehlgeschlagen', + 'status.running': 'laufen', + 'status.stopping': 'anhalten', + 'status.completed': 'abgeschlossen', + 'status.killed': 'abgesagt', + 'status.failed': 'gescheitert', + 'duration.seconds': '{seconds}s', + 'duration.minutes': '{minutes}m {seconds}s', + 'duration.hours': '{hours}h {minutes}m', + 'duration.title.live': 'Laufen für {duration}', + 'duration.title.done': 'Nahm {duration}', + 'output.gap': '… frühere Produktion gesunken …', + 'output.error': 'Live-Ausgabestream unterbrochen: {error}', + 'terminal.signal': 'Signal {signal}', + 'terminal.exitCode': 'Beenden Sie {code}', + 'terminal.noExitCode': 'kein Exit-Code', + 'terminal.commandLine': 'Befehlszeile {n}', + 'terminal.running': 'laufen', + 'terminal.failed': 'gescheitert', + 'terminal.done': 'erledigt', + 'terminal.copy': 'Kopieren', + 'terminal.copied': 'Kopiert', + 'terminal.noOutput': '(keine Ausgabe)', + 'terminal.collapse': 'Einklappen', + 'terminal.collapseAria': 'Ausgabe reduzieren', + 'terminal.expand': 'Zeige {n} weitere Zeilen', + 'terminal.expandAria': 'Erweitern Sie die minimierten Ausgabezeilen {n}', +} satisfies Record + +/** Russian dictionary, checked against the English keys. */ +export const ru = { + 'count.live.one': 'Фоновые задачи выполняются: {count}', + 'count.live.other': 'Фоновые задачи выполняются: {count}', + 'count.idle.one': 'Фоновые задачи: {count}', + 'count.idle.other': 'Фоновые задачи: {count}', + 'list.aria': 'Фоновые задания', + 'section.live': 'Бег', + 'section.settledCount': 'Закончено {count}', + 'section.clear': 'Очистить', + 'row.expandAria': 'Показать живой вывод {label}', + 'row.collapseAria': 'Скрыть прямой вывод {label}', + 'kill.stop': 'Остановить задачу {label}', + 'kill.confirm': 'Нажмите еще раз, чтобы подтвердить', + 'kill.confirmAction': 'Подтвердить остановку', + 'kill.failed': 'Остановить не удалось', + 'status.running': 'бег', + 'status.stopping': 'остановка', + 'status.completed': 'завершено', + 'status.killed': 'отменен', + 'status.failed': 'не удалось', + 'duration.seconds': '{seconds}s', + 'duration.minutes': '{minutes}m {seconds}s', + 'duration.hours': '{hours}h {minutes}m', + 'duration.title.live': 'Бегу за {duration}', + 'duration.title.done': 'Взял {duration}', + 'output.gap': '… ранее объемы производства упали …', + 'output.error': 'поток вывода в реальном времени прерван: {error}', + 'terminal.signal': 'сигнал {signal}', + 'terminal.exitCode': 'выход {code}', + 'terminal.noExitCode': 'нет кода выхода', + 'terminal.commandLine': 'Командная строка {n}', + 'terminal.running': 'бег', + 'terminal.failed': 'не удалось', + 'terminal.done': 'сделано', + 'terminal.copy': 'Копировать', + 'terminal.copied': 'Скопировано', + 'terminal.noOutput': '(нет вывода)', + 'terminal.collapse': 'Свернуть', + 'terminal.collapseAria': 'Свернуть вывод', + 'terminal.expand': 'Показать {n} больше строк', + 'terminal.expandAria': 'Развернуть свернутые выходные строки {n}', +} satisfies Record + +/** Portuguese dictionary, checked against the English keys. */ +export const pt = { + 'count.live.one': 'Tarefas em segundo plano em execução: {count}', + 'count.live.other': 'Tarefas em segundo plano em execução: {count}', + 'count.idle.one': 'Tarefas em segundo plano: {count}', + 'count.idle.other': 'Tarefas em segundo plano: {count}', + 'list.aria': 'Trabalhos em segundo plano', + 'section.live': 'Correndo', + 'section.settledCount': 'Concluído {count}', + 'section.clear': 'Limpar', + 'row.expandAria': 'Mostrar saída ao vivo de {label}', + 'row.collapseAria': 'Ocultar saída ao vivo de {label}', + 'kill.stop': 'Parar tarefa {label}', + 'kill.confirm': 'Clique novamente para confirmar', + 'kill.confirmAction': 'Confirmar parada', + 'kill.failed': 'Falha na parada', + 'status.running': 'correndo', + 'status.stopping': 'parando', + 'status.completed': 'concluído', + 'status.killed': 'cancelado', + 'status.failed': 'falhou', + 'duration.seconds': '{seconds}s', + 'duration.minutes': '{minutes}m {seconds}s', + 'duration.hours': '{hours}h {minutes}m', + 'duration.title.live': 'Correndo por {duration}', + 'duration.title.done': 'Levou {duration}', + 'output.gap': '… a produção anterior caiu…', + 'output.error': 'transmissão ao vivo interrompida: {error}', + 'terminal.signal': 'sinal {signal}', + 'terminal.exitCode': 'sair {code}', + 'terminal.noExitCode': 'sem código de saída', + 'terminal.commandLine': 'Linha de comando {n}', + 'terminal.running': 'correndo', + 'terminal.failed': 'falhou', + 'terminal.done': 'feito', + 'terminal.copy': 'Copiar', + 'terminal.copied': 'Copiado', + 'terminal.noOutput': '(sem saída)', + 'terminal.collapse': 'Recolher', + 'terminal.collapseAria': 'Recolher saída', + 'terminal.expand': 'Mostrar mais {n} linhas', + 'terminal.expandAria': 'Expanda {n} linhas de saída recolhidas', +} satisfies Record diff --git a/packages/client/ui-jobs/tests/job-list-action.client.spec.tsx b/packages/client/ui-jobs/tests/job-list-action.client.spec.tsx index e6866c4182..4dcf892f87 100644 --- a/packages/client/ui-jobs/tests/job-list-action.client.spec.tsx +++ b/packages/client/ui-jobs/tests/job-list-action.client.spec.tsx @@ -5,7 +5,7 @@ import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' import type { JobsSnapshot, JobView, ObservedJob } from '@deepseek-ai/dsh-api-job-controller/client' import type { SessionId } from '@deepseek-ai/dsh-session/types' import { JobListAction, type JobListActionProps } from '../src/client/JobListAction.tsx' -import { zh } from '../src/client/locales.ts' +import { zh, ru } from '../src/client/locales.ts' afterEach(() => { cleanup() @@ -590,3 +590,12 @@ describe('JobListAction human kill', () => { expect(document.querySelector('[data-kill-state]')).toBeNull() }) }) + +it.each([1, 2, 5, 21])('renders count-independent Russian labels for %i jobs', (count) => { + const jobs = Array.from({ length: count }, (_, i) => job({ id: `job-${i}` as JobView['id'] })) + const translate: JobListActionProps['t'] = makeTranslate(ru) + const view = render() + expect(screen.getByRole('button', { name: `Фоновые задачи выполняются: ${count}` })).toBeDefined() + view.rerender( ({ ...item, status: 'completed', finishedAt: 1_700_000_003_000 })))} t={translate} />) + expect(screen.getByRole('button', { name: `Фоновые задачи: ${count}` })).toBeDefined() +}) diff --git a/packages/client/ui-layout/locale/de.json b/packages/client/ui-layout/locale/de.json new file mode 100644 index 0000000000..9d6d23789f --- /dev/null +++ b/packages/client/ui-layout/locale/de.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "Layout anwenden", + "description": "Organisieren Sie Navigation, Konversationen und Seitenbereiche und unterstützen Sie das Ziehen, um das Layout anzupassen." + } +} diff --git a/packages/client/ui-layout/locale/es.json b/packages/client/ui-layout/locale/es.json new file mode 100644 index 0000000000..01d553183d --- /dev/null +++ b/packages/client/ui-layout/locale/es.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "Aplicar diseño", + "description": "Organice la navegación, las conversaciones y los paneles laterales, y admita arrastrar para ajustar el diseño." + } +} diff --git a/packages/client/ui-layout/locale/fr.json b/packages/client/ui-layout/locale/fr.json new file mode 100644 index 0000000000..7526ace8cc --- /dev/null +++ b/packages/client/ui-layout/locale/fr.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "Appliquer la mise en page", + "description": "Organisez la navigation, les conversations et les panneaux latéraux, et prenez en charge le glisser-déposer pour ajuster la mise en page." + } +} diff --git a/packages/client/ui-layout/locale/ja.json b/packages/client/ui-layout/locale/ja.json new file mode 100644 index 0000000000..4b56f91718 --- /dev/null +++ b/packages/client/ui-layout/locale/ja.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "アプリレイアウト", + "description": "ナビゲーション、会話、サイドパネルを構成し、ドラッグによるレイアウト調整を提供します。" + } +} diff --git a/packages/client/ui-layout/locale/pt.json b/packages/client/ui-layout/locale/pt.json new file mode 100644 index 0000000000..10b0dcaae8 --- /dev/null +++ b/packages/client/ui-layout/locale/pt.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "Aplicar layout", + "description": "Organize a navegação, as conversas e os painéis laterais e suporte o recurso de arrastar para ajustar o layout." + } +} diff --git a/packages/client/ui-layout/locale/ru.json b/packages/client/ui-layout/locale/ru.json new file mode 100644 index 0000000000..ab90630f4d --- /dev/null +++ b/packages/client/ui-layout/locale/ru.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "Применить макет", + "description": "Организуйте навигацию, диалоги и боковые панели, а также поддерживайте перетаскивание для настройки макета." + } +} diff --git a/packages/client/ui-layout/src/client/index.ts b/packages/client/ui-layout/src/client/index.ts index 3948c198b9..ee9da22d2b 100644 --- a/packages/client/ui-layout/src/client/index.ts +++ b/packages/client/ui-layout/src/client/index.ts @@ -18,7 +18,7 @@ import { AppFrame } from './AppFrame.tsx' import { createLayoutStore } from './stores.ts' import { LayoutController } from './service.ts' import type { ShortcutCommandId } from '@deepseek-ai/dsh-client-shortcuts/client' -import { en, zh } from './shortcut-locales.ts' +import { en, ja, es, fr, de, ru, pt, zh } from './shortcut-locales.ts' import { ThemePresenter } from './theme-presenter.ts' // Contract exports only (export-convergence rule: cross-package consumers @@ -156,7 +156,7 @@ export const inject = ['slots', 'theme', 'locale', 'shortcuts'] * @param ctx - client root context. */ export function apply(ctx: ClientContext): void { - ctx.effect(() => ctx.locale.register('shortcuts.layout', { zh, en }), 'layout: command labels') + ctx.effect(() => ctx.locale.register('shortcuts.layout', { zh, en, ja, es, fr, de, ru, pt }), 'layout: command labels') const t = ctx.locale.bind('shortcuts.layout') ctx.effect(() => { diff --git a/packages/client/ui-layout/src/client/shortcut-locales.ts b/packages/client/ui-layout/src/client/shortcut-locales.ts index 7c90e1ae9a..c87cdc5026 100644 --- a/packages/client/ui-layout/src/client/shortcut-locales.ts +++ b/packages/client/ui-layout/src/client/shortcut-locales.ts @@ -2,3 +2,23 @@ export const zh = { toggle: '展开/收起左侧栏' } /** English labels for the same layout commands. */ export const en: Record = { toggle: 'Toggle left sidebar' } + +/** Japanese copy, checked against the English keys. */ +export const ja = { + 'toggle': '左サイドバーを切り替え', +} satisfies Record + +/** Spanish dictionary, checked against the English keys. */ +export const es = { toggle: 'Alternar barra lateral izquierda' } satisfies Record + +/** French dictionary, checked against the English keys. */ +export const fr = { toggle: 'Basculer la barre latérale gauche' } satisfies Record + +/** German dictionary, checked against the English keys. */ +export const de = { toggle: 'Linke Seitenleiste umschalten' } satisfies Record + +/** Russian dictionary, checked against the English keys. */ +export const ru = { toggle: 'Переключить левую боковую панель' } satisfies Record + +/** Portuguese dictionary, checked against the English keys. */ +export const pt = { toggle: 'Alternar barra lateral esquerda' } satisfies Record diff --git a/packages/client/ui-message-feedback/README.i18n.yaml b/packages/client/ui-message-feedback/README.i18n.yaml index e171909bad..91205d3552 100644 --- a/packages/client/ui-message-feedback/README.i18n.yaml +++ b/packages/client/ui-message-feedback/README.i18n.yaml @@ -18,8 +18,8 @@ en: e0127a604ff4aebb zh: 38f1b3db30f81b22 /deepseek-ai-dsh-client-ui-message-feedback/use-this-package/failures: - en: f060de7d9f44b058 - zh: 60770de36be6e90f + en: e4807e47bca06517 + zh: 649a3f9f62771865 /deepseek-ai-dsh-client-ui-message-feedback/understand-the-implementation: en: 61260495346ceb3e zh: 32e05d3eb82fcaf2 diff --git a/packages/client/ui-message-feedback/README.md b/packages/client/ui-message-feedback/README.md index ef293870b6..feffb193d6 100644 --- a/packages/client/ui-message-feedback/README.md +++ b/packages/client/ui-message-feedback/README.md @@ -31,6 +31,8 @@ Mount this plugin alongside `ui-conversation` and `ui-commands`; the Like/Dislik A rating or list-load failure shows inline in the row; a submission failure shows in a warning toast while the dialog stays open so the draft can be corrected. Only finalized messages reach the message entry — an interruption-frozen partial carries no `messageId` and therefore no feedback controls. +Access: the rating buttons render only while the current Peer may call `messageFeedback/put` on the Session; the `/feedback` decoration has no Client check, and the Host refuses a submission without access. + ----- diff --git a/packages/client/ui-message-feedback/README.zh.md b/packages/client/ui-message-feedback/README.zh.md index fce7d27049..27a879c2c8 100644 --- a/packages/client/ui-message-feedback/README.zh.md +++ b/packages/client/ui-message-feedback/README.zh.md @@ -31,6 +31,8 @@ kind: "package-reference" 评分或列表加载失败在行内展示;提交失败通过警告 toast 展示,弹窗保持打开以便修正草稿。只有已定稿的消息能到达消息条目——被中断冻结的部分输出不带 `messageId`,因此没有反馈控件。 +访问控制:评分按钮只在当前 Peer 可以在该会话上调用 `messageFeedback/put` 时渲染;`/feedback` 装饰不做前端判断,没有权限时提交由 Host 拒绝。 + ----- diff --git a/packages/client/ui-message-feedback/locale/de.json b/packages/client/ui-message-feedback/locale/de.json new file mode 100644 index 0000000000..71b5fc4559 --- /dev/null +++ b/packages/client/ui-message-feedback/locale/de.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "Nachrichten-Feedback-Schnittstelle", + "description": "Stellen Sie „Gefällt mir“- und „Gefällt mir nicht“-Schaltflächen für Antworten und Popup-Fenster für Gesprächsfeedback bereit." + } +} diff --git a/packages/client/ui-message-feedback/locale/es.json b/packages/client/ui-message-feedback/locale/es.json new file mode 100644 index 0000000000..26672a96ab --- /dev/null +++ b/packages/client/ui-message-feedback/locale/es.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "Interfaz de comentarios de mensajes", + "description": "Proporcione botones Me gusta y No me gusta para las ventanas emergentes de respuestas y comentarios de la conversación." + } +} diff --git a/packages/client/ui-message-feedback/locale/fr.json b/packages/client/ui-message-feedback/locale/fr.json new file mode 100644 index 0000000000..71009572ad --- /dev/null +++ b/packages/client/ui-message-feedback/locale/fr.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "Interface de retour de messages", + "description": "Fournissez des boutons J'aime et Je n'aime pas pour les réponses et les fenêtres contextuelles de commentaires sur la conversation." + } +} diff --git a/packages/client/ui-message-feedback/locale/ja.json b/packages/client/ui-message-feedback/locale/ja.json new file mode 100644 index 0000000000..22260213e1 --- /dev/null +++ b/packages/client/ui-message-feedback/locale/ja.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "メッセージフィードバック UI", + "description": "回答の評価ボタンとセッションのフィードバックダイアログを提供します。" + } +} diff --git a/packages/client/ui-message-feedback/locale/pt.json b/packages/client/ui-message-feedback/locale/pt.json new file mode 100644 index 0000000000..dcb779d6b0 --- /dev/null +++ b/packages/client/ui-message-feedback/locale/pt.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "Interface de feedback de mensagens", + "description": "Forneça botões de gostar e não gostar para respostas e janelas pop-up de feedback de conversas." + } +} diff --git a/packages/client/ui-message-feedback/locale/ru.json b/packages/client/ui-message-feedback/locale/ru.json new file mode 100644 index 0000000000..388f33bf3a --- /dev/null +++ b/packages/client/ui-message-feedback/locale/ru.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "Интерфейс обратной связи по сообщениям", + "description": "Предоставьте кнопки «Мне нравится» и «Не нравится» для ответов и всплывающих окон обратной связи." + } +} diff --git a/packages/client/ui-message-feedback/src/client/MessageFeedbackActions.tsx b/packages/client/ui-message-feedback/src/client/MessageFeedbackActions.tsx index 6709f79984..c95a28d21b 100644 --- a/packages/client/ui-message-feedback/src/client/MessageFeedbackActions.tsx +++ b/packages/client/ui-message-feedback/src/client/MessageFeedbackActions.tsx @@ -17,12 +17,17 @@ import type { MessageFeedbackActionProps } from './slots.ts' import css from './MessageFeedbackActions.module.css' /** - * One message's feedback controls. - * @param props - the owner's message identity, the injected verbs, and the - * shared feedback hook. - * @returns the rating buttons with any failure notice beside them. + * One message's feedback controls, rendered only while the current Peer may + * record message feedback in this Session. + * @param props - the owner's message identity, the injected verbs, the + * shared feedback hook, and the Session access Hook. + * @returns the rating buttons with any failure notice beside them, or nothing without access. */ -export function MessageFeedbackActions({ +export function MessageFeedbackActions(props: MessageFeedbackActionProps) { + return props.useAccess('messageFeedback/put') ? : null +} + +function FeedbackRatingButtons({ messageId, ensure, current, retract, openDialog, useFeedback, t, }: MessageFeedbackActionProps) { const item = useFeedback(view => view.items.get(messageId)) diff --git a/packages/client/ui-message-feedback/src/client/index.ts b/packages/client/ui-message-feedback/src/client/index.ts index 4f26c3bb0d..0d22230e13 100644 --- a/packages/client/ui-message-feedback/src/client/index.ts +++ b/packages/client/ui-message-feedback/src/client/index.ts @@ -26,7 +26,7 @@ import { FeedbackDialog } from './FeedbackDialog.tsx' import { MessageFeedbackActions } from './MessageFeedbackActions.tsx' import type { FeedbackDialogInjected, MessageFeedbackInjected } from './slots.ts' import { FeedbackSurface } from './surface.ts' -import { en, zh } from './locales.ts' +import { en, ja, es, fr, de, ru, pt, zh } from './locales.ts' export type { MessageFeedbackActionFailure, MessageFeedbackActionResult, MessageFeedbackStatus, @@ -66,7 +66,7 @@ export const inject = ['slots', 'remote', 'remote.messageFeedback', 'remote.sess * @param ctx - client root context. */ export function apply(ctx: ClientContext): void { - ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'ui-message-feedback: dictionaries') + ctx.effect(() => ctx.locale.register(NS, { zh, en, ja, es, fr, de, ru, pt }), 'ui-message-feedback: dictionaries') const surfaces = new Map() const surfaceFor = (sessionId: SessionId): FeedbackSurface => { diff --git a/packages/client/ui-message-feedback/src/client/locales.ts b/packages/client/ui-message-feedback/src/client/locales.ts index 808a15a9d4..d5a0fc66e3 100644 --- a/packages/client/ui-message-feedback/src/client/locales.ts +++ b/packages/client/ui-message-feedback/src/client/locales.ts @@ -57,3 +57,147 @@ export const en = { 'error.generic': 'Could not save feedback', 'error.noteTooLarge': 'The description is too long; shorten it and submit again', } satisfies Record + +/** Japanese copy, checked against the English keys. */ +export const ja = { + 'action.like': '良い回答', + 'action.likeActive': '評価を取り消す', + 'action.dislike': '悪い回答', + 'action.dislikeActive': '評価を取り消す', + 'dialog.title': 'フィードバックを送信', + 'dialog.categories': 'フィードバックのカテゴリ', + 'dialog.detail': 'フィードバックの詳細', + 'dialog.hint': '改善のため詳細を追加してください。現在の会話ログが送信内容に含まれます。', + 'category.task-result': 'タスク結果', + 'category.instruction-following': '指示の理解と遵守', + 'category.product-interaction': '製品機能と操作', + 'category.service-stability': '安定性と速度', + 'category.resource-cost': 'リソース使用量とコスト', + 'category.security-privacy-permission': 'セキュリティ、プライバシー、権限', + 'category.other': 'その他', + 'toast.recorded': 'フィードバックありがとうございます', + 'error.conflict': 'このフィードバックは別の場所で変更されました。最新の状態を表示しています', + 'error.load': 'フィードバックを読み込めませんでした', + 'error.generic': 'フィードバックを保存できませんでした', + 'error.noteTooLarge': '説明が長すぎます。短くして再送信してください', +} satisfies Record + +/** Spanish dictionary, checked against the English keys. */ +export const es = { + 'action.like': 'buena respuesta', + 'action.likeActive': 'Eliminar calificación', + 'action.dislike': 'Mala respuesta', + 'action.dislikeActive': 'Eliminar calificación', + 'dialog.title': 'Enviar comentarios', + 'dialog.categories': 'Categoría de comentarios', + 'dialog.detail': 'Detalles de comentarios', + 'dialog.hint': 'Añade detalles para ayudarnos a mejorar. Su envío incluirá el registro de conversación actual.', + 'category.task-result': 'Resultado de la tarea', + 'category.instruction-following': 'Comprensión y seguimiento de instrucciones.', + 'category.product-interaction': 'Características del producto e interacción.', + 'category.service-stability': 'Estabilidad y velocidad', + 'category.resource-cost': 'Uso y costo de recursos', + 'category.security-privacy-permission': 'Seguridad, privacidad y permisos', + 'category.other': 'Otro', + 'toast.recorded': 'Gracias por tus comentarios', + 'error.conflict': 'Esta retroalimentación cambió en otros lugares; se muestra el último estado', + 'error.load': 'No se pudieron cargar los comentarios', + 'error.generic': 'No se pudieron guardar los comentarios', + 'error.noteTooLarge': 'La descripción es demasiado larga; acortarlo y enviarlo de nuevo', +} satisfies Record + +/** French dictionary, checked against the English keys. */ +export const fr = { + 'action.like': 'Bonne réponse', + 'action.likeActive': 'Supprimer la note', + 'action.dislike': 'Mauvaise réponse', + 'action.dislikeActive': 'Supprimer la note', + 'dialog.title': 'Soumettre des commentaires', + 'dialog.categories': 'Catégorie de commentaires', + 'dialog.detail': 'Détails des commentaires', + 'dialog.hint': 'Ajoutez des détails pour nous aider à nous améliorer. Votre soumission inclura le journal de conversation actuel.', + 'category.task-result': 'Résultat de la tâche', + 'category.instruction-following': 'Compréhension et suivi des instructions', + 'category.product-interaction': 'Caractéristiques du produit et interaction', + 'category.service-stability': 'Stabilité et vitesse', + 'category.resource-cost': 'Utilisation et coût des ressources', + 'category.security-privacy-permission': 'Sécurité, confidentialité et autorisations', + 'category.other': 'Autre', + 'toast.recorded': 'Merci pour vos commentaires', + 'error.conflict': 'Cette rétroaction a changé ailleurs ; le dernier état est affiché', + 'error.load': 'Impossible de charger les commentaires', + 'error.generic': "Impossible d'enregistrer les commentaires", + 'error.noteTooLarge': 'La description est trop longue ; raccourcissez-le et soumettez-le à nouveau', +} satisfies Record + +/** German dictionary, checked against the English keys. */ +export const de = { + 'action.like': 'Gute Resonanz', + 'action.likeActive': 'Bewertung entfernen', + 'action.dislike': 'Schlechte Reaktion', + 'action.dislikeActive': 'Bewertung entfernen', + 'dialog.title': 'Feedback abgeben', + 'dialog.categories': 'Feedback-Kategorie', + 'dialog.detail': 'Feedback-Details', + 'dialog.hint': 'Fügen Sie Details hinzu, um uns bei der Verbesserung zu helfen. Ihre Einreichung enthält das aktuelle Gesprächsprotokoll.', + 'category.task-result': 'Aufgabenergebnis', + 'category.instruction-following': 'Anweisungen verstehen und befolgen', + 'category.product-interaction': 'Produkteigenschaften und Interaktion', + 'category.service-stability': 'Stabilität und Geschwindigkeit', + 'category.resource-cost': 'Ressourcennutzung und Kosten', + 'category.security-privacy-permission': 'Sicherheit, Datenschutz und Berechtigungen', + 'category.other': 'Andere', + 'toast.recorded': 'Vielen Dank für Ihr Feedback', + 'error.conflict': 'Dieses Feedback hat sich an anderer Stelle geändert; der aktuelle Stand wird angezeigt', + 'error.load': 'Feedback konnte nicht geladen werden', + 'error.generic': 'Das Feedback konnte nicht gespeichert werden', + 'error.noteTooLarge': 'Die Beschreibung ist zu lang; kürzen Sie es und reichen Sie es erneut ein', +} satisfies Record + +/** Russian dictionary, checked against the English keys. */ +export const ru = { + 'action.like': 'Хороший ответ', + 'action.likeActive': 'Удалить оценку', + 'action.dislike': 'Плохой ответ', + 'action.dislikeActive': 'Удалить оценку', + 'dialog.title': 'Отправить отзыв', + 'dialog.categories': 'Категория обратной связи', + 'dialog.detail': 'Подробности обратной связи', + 'dialog.hint': 'Добавьте детали, чтобы помочь нам стать лучше. Ваше сообщение будет включать текущий журнал разговоров.', + 'category.task-result': 'Результат задачи', + 'category.instruction-following': 'Понимание и следование инструкциям', + 'category.product-interaction': 'Особенности продукта и взаимодействие', + 'category.service-stability': 'Стабильность и скорость', + 'category.resource-cost': 'Использование ресурсов и стоимость', + 'category.security-privacy-permission': 'Безопасность, конфиденциальность и разрешения', + 'category.other': 'Другое', + 'toast.recorded': 'Спасибо за ваш отзыв', + 'error.conflict': 'Этот отзыв изменился в другом месте; отображается последнее состояние', + 'error.load': 'Не удалось загрузить отзыв.', + 'error.generic': 'Не удалось сохранить отзыв', + 'error.noteTooLarge': 'Описание слишком длинное; сократите его и отправьте снова', +} satisfies Record + +/** Portuguese dictionary, checked against the English keys. */ +export const pt = { + 'action.like': 'Boa resposta', + 'action.likeActive': 'Remover avaliação', + 'action.dislike': 'Resposta ruim', + 'action.dislikeActive': 'Remover avaliação', + 'dialog.title': 'Enviar comentários', + 'dialog.categories': 'Categoria de comentários', + 'dialog.detail': 'Detalhes do feedback', + 'dialog.hint': 'Adicione detalhes para nos ajudar a melhorar. Seu envio incluirá o registro da conversa atual.', + 'category.task-result': 'Resultado da tarefa', + 'category.instruction-following': 'Compreensão e seguimento das instruções', + 'category.product-interaction': 'Recursos e interação do produto', + 'category.service-stability': 'Estabilidade e velocidade', + 'category.resource-cost': 'Uso e custo de recursos', + 'category.security-privacy-permission': 'Segurança, privacidade e permissões', + 'category.other': 'Outro', + 'toast.recorded': 'Obrigado pelo seu feedback', + 'error.conflict': 'Este feedback mudou em outro lugar; o estado mais recente é mostrado', + 'error.load': 'Não foi possível carregar o feedback', + 'error.generic': 'Não foi possível salvar o feedback', + 'error.noteTooLarge': 'A descrição é muito longa; encurte-o e envie novamente', +} satisfies Record diff --git a/packages/client/ui-message-feedback/tests/message-feedback-actions.client.spec.tsx b/packages/client/ui-message-feedback/tests/message-feedback-actions.client.spec.tsx index 4121676697..2e80707a58 100644 --- a/packages/client/ui-message-feedback/tests/message-feedback-actions.client.spec.tsx +++ b/packages/client/ui-message-feedback/tests/message-feedback-actions.client.spec.tsx @@ -45,6 +45,8 @@ function mount(options: { ensureResult?: MessageFeedbackActionResult retractResult?: MessageFeedbackActionResult status?: MessageFeedbackView['status'] + /** The Session access answer for recording message feedback; allowed when omitted. */ + allowed?: boolean } = {}) { const view: MessageFeedbackView = { status: options.status ?? 'ready', @@ -58,12 +60,22 @@ function mount(options: { const current = vi.fn((_id: MessageId) => options.committed ?? options.current) const useFeedback = ((select: (v: MessageFeedbackView) => T): T => useSyncExternalStore(() => () => {}, () => select(view))) as never + const useAccess = vi.fn((_action: string) => options.allowed ?? true) const props = { messageId: MSG, ensure, current, retract, openDialog, useFeedback, t } as unknown as Parameters[0] - return { ...render(), ensure, retract, openDialog } + return { ...render(), ensure, retract, openDialog, useAccess } } describe('MessageFeedbackActions', () => { + it('renders nothing and reads nothing while the current Peer may not record message feedback in this Session', () => { + const ui = mount({ allowed: false }) + + expect(ui.useAccess).toHaveBeenCalledWith('messageFeedback/put') + expect(ui.queryByLabelText(zh['action.like'])).toBeNull() + expect(ui.queryByLabelText(zh['action.dislike'])).toBeNull() + expect(ui.ensure).not.toHaveBeenCalled() + }) + it('renders both rating buttons unpressed with no recorded feedback', () => { const ui = mount() @@ -222,7 +234,7 @@ describe('MessageFeedbackActions', () => { useFeedback, t, } as unknown as Parameters[0] - const ui = render() + const ui = render( true} />) const errors: unknown[] = [] const onError = (event: ErrorEvent): void => { errors.push(event.error) } window.addEventListener('error', onError) @@ -252,7 +264,7 @@ describe('MessageFeedbackActions', () => { useFeedback, t, } as unknown as Parameters[0] - const ui = render() + const ui = render( true} />) const errors: unknown[] = [] const onError = (event: ErrorEvent): void => { errors.push(event.error) } window.addEventListener('error', onError) @@ -286,7 +298,7 @@ describe('MessageFeedbackActions', () => { useFeedback, t, } as unknown as Parameters[0] - const ui = render() + const ui = render( true} />) const errors: unknown[] = [] const onError = (event: ErrorEvent): void => { errors.push(event.error) } window.addEventListener('error', onError) diff --git a/packages/client/ui-model-selection/README.i18n.yaml b/packages/client/ui-model-selection/README.i18n.yaml index fc05dbc20d..177bc09fb2 100644 --- a/packages/client/ui-model-selection/README.i18n.yaml +++ b/packages/client/ui-model-selection/README.i18n.yaml @@ -15,11 +15,11 @@ en: d152484eb41ac6b4 zh: 09388d293f9be9cb /deepseek-ai-dsh-client-ui-model-selection/use-this-package: - en: ef50e68e7493d0a9 - zh: 3b1faea17b606173 + en: 9b1eca0aec81ac29 + zh: 9a1599c3abf76513 /deepseek-ai-dsh-client-ui-model-selection/use-this-package/model-and-effort: - en: 48f8157ffdfe6e69 - zh: 64ec9a533e2aa8f4 + en: 2e6d9965e01dc0ce + zh: 332cc82233eb02f3 /deepseek-ai-dsh-client-ui-model-selection/use-this-package/model-label-extensions: en: bc47871cf8b61451 zh: 17d21da46604f071 diff --git a/packages/client/ui-model-selection/README.md b/packages/client/ui-model-selection/README.md index 5c181ca2ac..b2c809123f 100644 --- a/packages/client/ui-model-selection/README.md +++ b/packages/client/ui-model-selection/README.md @@ -31,13 +31,13 @@ DeepSeek account and API-key routes appear as separate provider groups, each exp When no model is selected or the saved selection is absent from the catalog, the composer shows “Select model” in regular weight and hides the reasoning effort caption. Clicking it opens the model list directly; Escape and Shift+Tab close it. An empty catalog displays “No models available.” -Mount this plugin alongside `ui-conversation` and the commands package; the composer then shows the model seat next to the pending indicator, and `/model` opens the same directory as a popup. While the seat's menu is open, `↑`/`↓` move focus through root and effort rows, or move the model-list highlight without leaving search. Enter and Tab settle the highlighted model or focused row; Escape and `Shift+Tab` leave a drilled pane first and otherwise close back to the trigger. Drilling focuses model search when shown, otherwise the current model or effort row, and going back lands on the cell that opened the pane left. The composer shows the catalog name while the selected model is available. Removing a model or provider, including account sign-out, preserves the stored provider, model, and reasoning effort without displaying their IDs. Restoring the catalog entry restores its name and effort caption. +Mount this plugin alongside `ui-conversation` and the commands package; the composer then shows the model seat next to the pending indicator, and `/model` opens the same directory as a popup. While the seat's menu is open, `↑`/`↓` move focus through root and effort rows, or move the model-list highlight without leaving search. Enter and Tab settle the highlighted model or focused row; Escape and `Shift+Tab` leave a drilled pane first and otherwise close back to the trigger. Drilling focuses model search when shown on desktop devices; otherwise it focuses the current model or effort row, or the first enabled row when none is selected. Going back focuses the cell that opened the pane. Phones and tablets leave search unfocused until the user taps it or uses keyboard navigation. The composer shows the catalog name while the selected model is available. Removing a model or provider, including account sign-out, preserves the stored provider, model, and reasoning effort without displaying their IDs. Restoring the catalog entry restores its name and effort caption. Mouse selection uses native browser clicks, including their cancellation behavior; a press alone never selects. Opening the root menu focuses its trigger, and clicking the trigger again closes the menu and returns focus there. Root rows and the search-clear button show the same fill for keyboard focus and hover, without a native outline. Selecting a model or effort returns focus to the trigger without a focus ring; leaving the trigger or reopening the menu restores its normal focus indication. While a selection from either entry is pending, focus stays on the trigger, the trigger shows a spinner in place of its chevron, and each row whose value the selection carries shows one in place of its check; a rejected selection leaves the menu open. Tab returns to model search with the current model highlighted when search is shown, otherwise to the current row. ### Model and effort -The button's model menu shows search only when its full catalog contains more than four models; smaller catalogs focus the current model or first row and support keyboard navigation directly. The `/model` command always keeps its search field. Both search fields match model names case-insensitively, including nonconsecutive characters in order; leading and trailing spaces are ignored. Within each provider, prefix matches come first, followed by alignment score and then catalog order. Empty groups are hidden. Empty catalogs and queries without matches are announced through a status region. Search keeps focus while arrow keys cycle the highlighted result across provider groups; Enter or Tab selects it. Left and right arrows keep their native caret behavior. Opening highlights the current model or the first available row, and editing the query resets the highlight to the first result. Reopening the model pane clears the query. +The button's model menu shows search only when its full catalog contains more than four models; smaller catalogs support keyboard navigation directly and focus the current model or first enabled row on all devices. The `/model` command always keeps its search field. Both search fields match model names case-insensitively, including nonconsecutive characters in order; leading and trailing spaces are ignored. Within each provider, prefix matches come first, followed by alignment score and then catalog order. Empty groups are hidden. Empty catalogs and queries without matches are announced through a status region. Search keeps focus while arrow keys cycle the highlighted result across provider groups; Enter or Tab selects it. Left and right arrows keep their native caret behavior. Opening highlights the current model or the first available row, and editing the query resets the highlight to the first result. Reopening the model pane clears the query. Model and reasoning-effort names use weight 400 (regular) in the composer menu, including the selected item. The search field follows the command popup's compact treatment with transparent background and border in both palettes, with no leading icon and a caption-tone placeholder. The clear button appears for a nonempty query and restores the full list with focus in the search field. diff --git a/packages/client/ui-model-selection/README.zh.md b/packages/client/ui-model-selection/README.zh.md index 9755602564..e99a1e05ce 100644 --- a/packages/client/ui-model-selection/README.zh.md +++ b/packages/client/ui-model-selection/README.zh.md @@ -31,13 +31,13 @@ DeepSeek 账号和 API Key 路由显示为独立提供方分组,各自展示 未选择模型或已保存的选择不在目录中时,composer 以常规字重显示“请选择模型”,并隐藏推理强度文案。点击后直接打开模型列表,Escape 和 Shift+Tab 关闭列表。目录为空时显示“暂无可用模型” -与 `ui-conversation` 及命令包一起挂载本插件;composer 随即在待处理指示器旁显示模型位,`/model` 则以弹窗打开同一份目录。模型位菜单打开期间,`↑`/`↓` 在根菜单和推理等级行间移动焦点,或在模型列表中移动高亮并让焦点保留在搜索框。Enter 和 Tab 选定高亮模型或聚焦行;Escape 与 `Shift+Tab` 先退出已下钻的面板,否则关闭并回到触发器。下钻时若显示模型搜索框则聚焦搜索框,否则聚焦当前模型或推理强度行,返回则落在打开该面板的格子上。所选模型可用时,composer 显示目录中的模型名称。模型或提供方被删除时(包括账号退登),保留已保存的提供方、模型和推理强度,但不展示其 ID。目录条目恢复后重新显示名称和推理强度。 +与 `ui-conversation` 及命令包一起挂载本插件;composer 随即在待处理指示器旁显示模型位,`/model` 则以弹窗打开同一份目录。模型位菜单打开期间,`↑`/`↓` 在根菜单和推理等级行间移动焦点,或在模型列表中移动高亮并让焦点保留在搜索框。Enter 和 Tab 选定高亮模型或聚焦行;Escape 与 `Shift+Tab` 先退出已下钻的面板,否则关闭并回到触发器。下钻时,桌面设备在显示模型搜索框时聚焦搜索框;其他情况聚焦当前模型或推理强度行,没有选中项时聚焦首个可用行。返回时聚焦打开该面板的格子。手机和平板在用户主动点按搜索框或使用键盘导航前,不会聚焦搜索框。所选模型可用时,composer 显示目录中的模型名称。模型或提供方被删除时(包括账号退登),保留已保存的提供方、模型和推理强度,但不展示其 ID。目录条目恢复后重新显示名称和推理强度。 鼠标选择沿用浏览器原生点击及其取消行为;仅按下按钮不会选定。打开根菜单时聚焦触发按钮,再次点击触发按钮会关闭菜单并把焦点还给它。根菜单行和搜索清除按钮的键盘焦点与悬停使用相同底色,不显示原生焦点轮廓。选定模型或推理等级后,焦点返回触发按钮但不显示焦点环;焦点离开按钮或重新打开菜单后恢复正常焦点提示。等待任一入口发起的选择结果时,焦点停在触发按钮,触发按钮以加载图标代替下拉箭头,该选择包含的值所在行以加载图标代替勾选标记;选择被拒绝后菜单保持打开。显示模型搜索框时,Tab 返回搜索框并高亮当前模型,否则返回当前选中行。 ### 模型与推理强度 -按钮模型菜单仅在完整目录超过四个模型时显示搜索框;较小目录聚焦当前模型或首行,直接支持键盘导航。`/model` 命令始终保留搜索框。两个搜索框均按模型名称进行不区分大小写的匹配,支持按顺序输入不连续字符,并忽略首尾空格。每个提供方内的结果优先按前缀匹配、其次按匹配得分、最后按目录顺序排列。没有匹配项的分组会隐藏。空目录和无匹配结果均通过状态区域通知屏幕阅读器。方向键在各提供方分组的结果间循环移动高亮,焦点保留在搜索框;Enter 或 Tab 选定高亮项。左右方向键仍用于移动输入光标。打开时高亮当前模型或首个可用选项,修改搜索内容后高亮首个结果。重新打开模型面板会清空搜索内容。 +按钮模型菜单仅在完整目录超过四个模型时显示搜索框;较小目录直接支持键盘导航,并在所有设备上聚焦当前模型或首个可用行。`/model` 命令始终保留搜索框。两个搜索框均按模型名称进行不区分大小写的匹配,支持按顺序输入不连续字符,并忽略首尾空格。每个提供方内的结果优先按前缀匹配、其次按匹配得分、最后按目录顺序排列。没有匹配项的分组会隐藏。空目录和无匹配结果均通过状态区域通知屏幕阅读器。方向键在各提供方分组的结果间循环移动高亮,焦点保留在搜索框;Enter 或 Tab 选定高亮项。左右方向键仍用于移动输入光标。打开时高亮当前模型或首个可用选项,修改搜索内容后高亮首个结果。重新打开模型面板会清空搜索内容。 composer 菜单中的模型名称和推理等级均使用 400(regular)字重,包括选中项。搜索框沿用 command 弹窗的紧凑样式,在浅/深色主题下均保持背景和边框透明,不显示前置图标,占位文字使用 caption 色。搜索内容非空时显示清除按钮;点击后恢复完整列表,并将焦点留在搜索框。 diff --git a/packages/client/ui-model-selection/locale/de.json b/packages/client/ui-model-selection/locale/de.json new file mode 100644 index 0000000000..c37cd4ec24 --- /dev/null +++ b/packages/client/ui-model-selection/locale/de.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "Schnittstelle zur Modellauswahl", + "description": "Sehen Sie sich die verfügbaren Modelle an und wechseln Sie das von der aktuellen Sitzung verwendete Modell." + } +} diff --git a/packages/client/ui-model-selection/locale/es.json b/packages/client/ui-model-selection/locale/es.json new file mode 100644 index 0000000000..4ce95350a1 --- /dev/null +++ b/packages/client/ui-model-selection/locale/es.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "Interfaz de selección de modelo", + "description": "Vea los modelos disponibles y cambie el modelo utilizado por la sesión actual." + } +} diff --git a/packages/client/ui-model-selection/locale/fr.json b/packages/client/ui-model-selection/locale/fr.json new file mode 100644 index 0000000000..bc3548d52e --- /dev/null +++ b/packages/client/ui-model-selection/locale/fr.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "Interface de sélection de modèle", + "description": "Affichez les modèles disponibles et changez le modèle utilisé par la session en cours." + } +} diff --git a/packages/client/ui-model-selection/locale/ja.json b/packages/client/ui-model-selection/locale/ja.json new file mode 100644 index 0000000000..e0bc4f6b8a --- /dev/null +++ b/packages/client/ui-model-selection/locale/ja.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "モデル選択 UI", + "description": "利用可能なモデルを確認し、現在のセッションで使用するモデルを切り替えます。" + } +} diff --git a/packages/client/ui-model-selection/locale/pt.json b/packages/client/ui-model-selection/locale/pt.json new file mode 100644 index 0000000000..17caef7c92 --- /dev/null +++ b/packages/client/ui-model-selection/locale/pt.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "Interface de seleção de modelo", + "description": "Visualize os modelos disponíveis e alterne o modelo usado pela sessão atual." + } +} diff --git a/packages/client/ui-model-selection/locale/ru.json b/packages/client/ui-model-selection/locale/ru.json new file mode 100644 index 0000000000..572c9b5541 --- /dev/null +++ b/packages/client/ui-model-selection/locale/ru.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "Интерфейс выбора модели", + "description": "Просмотрите доступные модели и переключите модель, используемую в текущем сеансе." + } +} diff --git a/packages/client/ui-model-selection/package.json b/packages/client/ui-model-selection/package.json index bdc95a4d27..51277d84c6 100644 --- a/packages/client/ui-model-selection/package.json +++ b/packages/client/ui-model-selection/package.json @@ -46,6 +46,7 @@ "@deepseek-ai/cordis": "workspace:~" }, "devDependencies": { + "@deepseek-ai/dsh-agent": "workspace:*", "@deepseek-ai/dsh-api-remotes": "workspace:*", "@deepseek-ai/dsh-api-session-controller": "workspace:*", "@deepseek-ai/dsh-client-locale": "workspace:*", diff --git a/packages/client/ui-model-selection/src/client/ModelSelect.tsx b/packages/client/ui-model-selection/src/client/ModelSelect.tsx index c497bcbe77..59dbf85427 100644 --- a/packages/client/ui-model-selection/src/client/ModelSelect.tsx +++ b/packages/client/ui-model-selection/src/client/ModelSelect.tsx @@ -12,7 +12,9 @@ * search field. Provider headings paint their background only while pinned * by scrolling. Clearing a query restores the full list and search focus. * Selecting restores trigger focus without a ring until the trigger loses focus - * or the menu reopens. Model names match a case-insensitive ordered subsequence + * or the menu reopens. Mobile devices restore button focus when opening or + * drilling; model search takes focus only after explicit user input. + * Model names match a case-insensitive ordered subsequence * within each provider group, ranked by * prefix, alignment score, then catalog order. Returning to the root pane * hands focus back to the cell that opened it. Data and submission ride the @@ -35,7 +37,7 @@ import clsx from 'clsx' import type { ModelReasoningEffort, ModelSelection } from '@deepseek-ai/dsh-api-remotes/client' import { IconCheckOutlineRegular, IconChevronDownOutlineRegular, IconChevronRightOutlineRegular, IconCloseFillRegular, - IconDataOutlineRegular, IconWarningOutlineRegular, Input, rankByName, StateDot, Toast, + IconDataOutlineRegular, IconWarningOutlineRegular, Input, isMobileDevice, rankByName, StateDot, Toast, } from '@deepseek-ai/dsh-client-ui-primitives' import type { PropsLocale, PropsRenderSlots, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' import type { ModelLabelOwner, ModelSelectInjected } from './slots.ts' @@ -184,7 +186,7 @@ export function ModelSelect( paneFocus.current = null if (!open || intent === null) return if (intent === 'drill') { - if (pane === 'model' && showSearch) { + if (pane === 'model' && showSearch && !isMobileDevice()) { searchRef.current?.focus() return } diff --git a/packages/client/ui-model-selection/src/client/directory.ts b/packages/client/ui-model-selection/src/client/directory.ts index 41f67832a0..67136d39c8 100644 --- a/packages/client/ui-model-selection/src/client/directory.ts +++ b/packages/client/ui-model-selection/src/client/directory.ts @@ -4,9 +4,10 @@ * Session's durable selection projection, then submit through the same * selectModel call. A switch made in either entry updates this shared state. */ +import type { ModelSelectionProjection } from '@deepseek-ai/dsh-agent/types' import type { TrackProductEvent } from '@deepseek-ai/dsh-client-product-analytics/client' import type { - ModelCatalogFailure, ModelProviderGroup, ModelSelection, ModelSelectionProjection, + ModelCatalogFailure, ModelProviderGroup, ModelSelection, } from '@deepseek-ai/dsh-api-session-controller/types' import type { SessionId } from '@deepseek-ai/dsh-api-remotes/client' import type { RemoteResult, TypertClientRemote } from '@deepseek-ai/dsh-typert-protocol' diff --git a/packages/client/ui-model-selection/src/client/index.ts b/packages/client/ui-model-selection/src/client/index.ts index b298d2ba95..f1f15a1154 100644 --- a/packages/client/ui-model-selection/src/client/index.ts +++ b/packages/client/ui-model-selection/src/client/index.ts @@ -27,7 +27,7 @@ import type { ModelDirectoryState } from './directory.ts' import { ModelDirectoryResolver } from './service.ts' import type { ModelSelectInjected } from './slots.ts' import { ModelSelect } from './ModelSelect.tsx' -import { en, zh, type ModelKey } from './locales.ts' +import { en, ja, es, fr, de, ru, pt, zh, type ModelKey } from './locales.ts' import { orderModelProviders } from './provider-order.ts' export { ModelDirectory } from './directory.ts' @@ -113,7 +113,7 @@ export const inject = ['commandUi', 'locale', 'sessions', 'slots', 'remote', 're * @param ctx - client root context. */ export function apply(ctx: ClientContext): void { - ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'ui-model-selection: dictionaries') + ctx.effect(() => ctx.locale.register(NS, { zh, en, ja, es, fr, de, ru, pt }), 'ui-model-selection: dictionaries') // Non-slot faces (the command description, the popup option builder) read // through the bound translate; the seat component reads the standard seat. diff --git a/packages/client/ui-model-selection/src/client/locales.ts b/packages/client/ui-model-selection/src/client/locales.ts index 63602c2780..cb2749f9a9 100644 --- a/packages/client/ui-model-selection/src/client/locales.ts +++ b/packages/client/ui-model-selection/src/client/locales.ts @@ -64,3 +64,165 @@ export const en = { 'empty.models': 'No models available.', 'empty.efforts': 'This model provides no reasoning effort levels.', } satisfies Record + +/** Japanese copy, checked against the English keys. */ +export const ja = { + 'provider.account': 'DeepSeek アカウント', + 'command.label': 'モデル', + 'command.description': 'この会話で使用するモデルを選択', + 'option.loadError': 'カタログの読み込みに失敗しました: {message}', + 'trigger.fallback': 'モデルを選択', + 'trigger.loading': 'モデルを読み込み中…', + 'trigger.selectAria': 'モデルを選択', + 'trigger.aria': 'モデルを選択、現在 {model}', + 'trigger.ariaEffort': 'モデルを選択、現在 {model}、推論強度 {effort}', + 'menu.aria': 'モデルと推論強度', + 'menu.model': 'モデル', + 'menu.effort': '強度', + 'effort.providerDefault': 'デフォルト', + 'status.loading': 'モデル一覧を更新中…', + 'error.action': 'モデル操作に失敗しました: {message}', + 'error.sessionInUse': 'このセッションは既に使用中です。別の DSH インスタンス(dsh web やデスクトップアプリなど)が実行中の可能性があります。他の DSH インスタンスを終了してから再試行してください。', + 'action.reload': '再読み込み', + 'warning.groupLoad': '{name} の読み込みに失敗しました: {message}', + 'search.placeholder': 'モデルを検索…', + 'search.clear': '検索をクリア', + 'search.empty': '一致するモデルがありません。', + 'empty.models': '利用可能なモデルがありません。', + 'empty.efforts': 'このモデルには推論強度の設定がありません。', +} satisfies Record + +/** Spanish dictionary, checked against the English keys. */ +export const es = { + 'provider.account': 'Cuenta DeepSeek', + 'command.label': 'modelo', + 'command.description': 'Selecciona el modelo para esta conversación', + 'option.loadError': 'No se pudo cargar el catálogo: {message}', + 'trigger.fallback': 'Seleccionar modelo', + 'trigger.loading': 'Cargando modelos…', + 'trigger.selectAria': 'Seleccionar modelo', + 'trigger.aria': 'Seleccionar modelo, actual {model}', + 'trigger.ariaEffort': 'Seleccionar modelo, {model} actual, esfuerzo de razonamiento {effort}', + 'menu.aria': 'Esfuerzo de modelo y razonamiento.', + 'menu.model': 'modelo', + 'menu.effort': 'Esfuerzo', + 'effort.providerDefault': 'Predeterminado', + 'status.loading': 'Lista de modelos refrescante...', + 'error.action': 'Error en la operación del modelo: {message}', + 'error.sessionInUse': 'Esta sesión ya está en uso, posiblemente por otra instancia de DSH en ejecución (como la web dsh o la aplicación de escritorio). Salga de otras instancias de DSH en ejecución y vuelva a intentarlo.', + 'action.reload': 'recargar', + 'warning.groupLoad': '{name} no pudo cargar: {message}', + 'search.placeholder': 'Buscar modelos…', + 'search.clear': 'Borrar búsqueda', + 'search.empty': 'No hay modelos coincidentes.', + 'empty.models': 'No hay modelos disponibles.', + 'empty.efforts': 'Este modelo no proporciona niveles de esfuerzo de razonamiento.', +} satisfies Record + +/** French dictionary, checked against the English keys. */ +export const fr = { + 'provider.account': 'Compte DeepSeek', + 'command.label': 'Modèle', + 'command.description': 'Sélectionnez le modèle pour cette conversation', + 'option.loadError': 'Échec du chargement du catalogue : {message}', + 'trigger.fallback': 'Sélectionnez le modèle', + 'trigger.loading': 'Chargement des modèles…', + 'trigger.selectAria': 'Sélectionnez le modèle', + 'trigger.aria': 'Sélectionnez le modèle, {model} actuel', + 'trigger.ariaEffort': 'Sélectionnez le modèle, {model} actuel, effort de raisonnement {effort}', + 'menu.aria': 'Effort de modélisation et de raisonnement', + 'menu.model': 'Modèle', + 'menu.effort': 'Effort', + 'effort.providerDefault': 'Par défaut', + 'status.loading': 'Liste des modèles actualisée…', + 'error.action': "Échec de l'opération du modèle : {message}", + 'error.sessionInUse': "Cette session est déjà utilisée, éventuellement par une autre instance DSH en cours d'exécution (telle que le Web dsh ou l'application de bureau). Quittez les autres instances DSH en cours d'exécution et réessayez.", + 'action.reload': 'Recharger', + 'warning.groupLoad': 'Échec du chargement de {name} : {message}', + 'search.placeholder': 'Rechercher des modèles…', + 'search.clear': 'Effacer la recherche', + 'search.empty': 'Aucun modèle correspondant.', + 'empty.models': 'Aucun modèle disponible.', + 'empty.efforts': 'Ce modèle ne fournit aucun niveau d’effort de raisonnement.', +} satisfies Record + +/** German dictionary, checked against the English keys. */ +export const de = { + 'provider.account': 'DeepSeek-Konto', + 'command.label': 'Modell', + 'command.description': 'Wählen Sie das Modell für dieses Gespräch aus', + 'option.loadError': 'Katalog konnte nicht geladen werden: {message}', + 'trigger.fallback': 'Modell auswählen', + 'trigger.loading': 'Modelle werden geladen…', + 'trigger.selectAria': 'Modell auswählen', + 'trigger.aria': 'Modell auswählen, aktuell {model}', + 'trigger.ariaEffort': 'Modell auswählen, aktuell {model}, Argumentationsaufwand {effort}', + 'menu.aria': 'Modell- und Argumentationsaufwand', + 'menu.model': 'Modell', + 'menu.effort': 'Aufwand', + 'effort.providerDefault': 'Standard', + 'status.loading': 'Erfrischende Modellliste…', + 'error.action': 'Modellvorgang fehlgeschlagen: {message}', + 'error.sessionInUse': 'Diese Sitzung wird bereits verwendet, möglicherweise von einer anderen laufenden DSH-Instanz (z. B. dsh Web oder der Desktop-App). Beenden Sie andere laufende DSH-Instanzen und versuchen Sie es erneut.', + 'action.reload': 'Neu laden', + 'warning.groupLoad': '{name} konnte nicht geladen werden: {message}', + 'search.placeholder': 'Modelle suchen…', + 'search.clear': 'Suche löschen', + 'search.empty': 'Keine passenden Modelle.', + 'empty.models': 'Keine Modelle verfügbar.', + 'empty.efforts': 'Dieses Modell liefert keine Argumentationsaufwandsstufen.', +} satisfies Record + +/** Russian dictionary, checked against the English keys. */ +export const ru = { + 'provider.account': 'DeepSeek Аккаунт', + 'command.label': 'Модель', + 'command.description': 'Выберите модель для этого разговора', + 'option.loadError': 'Не удалось загрузить каталог: {message}.', + 'trigger.fallback': 'Выберите модель', + 'trigger.loading': 'Загрузка моделей…', + 'trigger.selectAria': 'Выберите модель', + 'trigger.aria': 'Выберите модель, текущую {model}', + 'trigger.ariaEffort': 'Выберите модель, текущую версию {model}, аргументацию {effort}', + 'menu.aria': 'Модель и рассуждения', + 'menu.model': 'Модель', + 'menu.effort': 'Усилие', + 'effort.providerDefault': 'По умолчанию', + 'status.loading': 'Обновление списка моделей…', + 'error.action': 'Не удалось выполнить операцию модели: {message}.', + 'error.sessionInUse': 'Этот сеанс уже используется, возможно, другим работающим экземпляром DSH (например, веб-сайтом dsh или настольным приложением). Закройте другие работающие экземпляры DSH и повторите попытку.', + 'action.reload': 'Перезагрузить', + 'warning.groupLoad': '{name} не удалось загрузить: {message}', + 'search.placeholder': 'Поиск моделей…', + 'search.clear': 'Очистить поиск', + 'search.empty': 'Нет подходящих моделей.', + 'empty.models': 'Нет доступных моделей.', + 'empty.efforts': 'Эта модель не обеспечивает обоснования уровней усилий.', +} satisfies Record + +/** Portuguese dictionary, checked against the English keys. */ +export const pt = { + 'provider.account': 'DeepSeek Conta', + 'command.label': 'Modelo', + 'command.description': 'Selecione o modelo para esta conversa', + 'option.loadError': 'Falha ao carregar o catálogo: {message}', + 'trigger.fallback': 'Selecione o modelo', + 'trigger.loading': 'Carregando modelos…', + 'trigger.selectAria': 'Selecione o modelo', + 'trigger.aria': 'Selecione o modelo, atual {model}', + 'trigger.ariaEffort': 'Selecione modelo, atual {model}, esforço de raciocínio {effort}', + 'menu.aria': 'Modelo e esforço de raciocínio', + 'menu.model': 'Modelo', + 'menu.effort': 'Esforço', + 'effort.providerDefault': 'Padrão', + 'status.loading': 'Atualizando lista de modelos…', + 'error.action': 'Falha na operação do modelo: {message}', + 'error.sessionInUse': 'Esta sessão já está em uso, possivelmente por outra instância DSH em execução (como dsh web ou aplicativo de desktop). Saia de outras instâncias DSH em execução e tente novamente.', + 'action.reload': 'Recarregar', + 'warning.groupLoad': '{name} falhou ao carregar: {message}', + 'search.placeholder': 'Pesquisar modelos…', + 'search.clear': 'Limpar pesquisa', + 'search.empty': 'Não há modelos correspondentes.', + 'empty.models': 'Nenhum modelo disponível.', + 'empty.efforts': 'Este modelo não fornece níveis de esforço de raciocínio.', +} satisfies Record diff --git a/packages/client/ui-model-selection/tests/browser-plugin.client.spec.ts b/packages/client/ui-model-selection/tests/browser-plugin.client.spec.ts index 389b9cfc28..778a1df137 100644 --- a/packages/client/ui-model-selection/tests/browser-plugin.client.spec.ts +++ b/packages/client/ui-model-selection/tests/browser-plugin.client.spec.ts @@ -15,7 +15,9 @@ import type { SessionId } from '@deepseek-ai/dsh-session/types' import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client' import { createSnapshotStore, type SnapshotStore } from '@deepseek-ai/dsh-client-store' import { RemoteError, TestRemote } from '@deepseek-ai/dsh-client-test-runtime' -import type { ModelSelection, ModelSelectionProjection } from '@deepseek-ai/dsh-api-session-controller/types' +import type { ModelSelection } from '@deepseek-ai/dsh-agent/types' +import type { ModelSelectionProjection } from '@deepseek-ai/dsh-agent/types' +import { ReasoningEffortId } from '@deepseek-ai/dsh-llm/brand' import type { CommandContribution, PopupSelectSpec, SelectOption } from '@deepseek-ai/dsh-client-ui-commands/client' import type { ModelSelectInjected } from '../src/client/slots.ts' import { apply, inject } from '../src/client/index.ts' @@ -98,7 +100,7 @@ async function bench(locale: 'zh' | 'en' = 'zh') { model: payload.model, ...payload.reasoningEffort === undefined ? {} - : { reasoningEffort: payload.reasoningEffort }, + : { reasoningEffort: ReasoningEffortId(payload.reasoningEffort) }, } projections.get(payload.sessionId)?.set({ lastUsed: null, next: selected }) return Promise.resolve({ ok: true as const, value: { selected } }) @@ -446,12 +448,12 @@ describe('ui-model-selection dual entry', () => { it('retains saved effort for existing and new sessions after credentials disappear', async () => { const b = await bench() try { - b.setHostCurrent({ provider: 'deepseek-official', model: 'deepseek-v4-flash', reasoningEffort: 'max' }) + b.setHostCurrent({ provider: 'deepseek-official', model: 'deepseek-v4-flash', reasoningEffort: ReasoningEffortId('max') }) b.mint('existing') const existing = b.ctx.modelDirectories.directoryFor(sid('existing')) await existing.load() b.setProjected(sid('existing'), { lastUsed: null, - next: { provider: 'deepseek-official', model: 'deepseek-v4-flash', reasoningEffort: 'high' } }) + next: { provider: 'deepseek-official', model: 'deepseek-v4-flash', reasoningEffort: ReasoningEffortId('high') } }) b.setRoutable(false) b.remote.emit('settings/document-updated', ['llm-deepseek', 1]) expect(existing.store.getSnapshot().retainedEffort).toBe('High') @@ -544,7 +546,7 @@ it.each([false, true])('reports accepted switches with blank=%s and no refused s const scope = b.mint('analytics', blank) try { const face = b.seat().inject!(sid('analytics')) - await face.select({ provider: 'deepseek-official', model: 'deepseek-v4-pro', reasoningEffort: 'max' }) + await face.select({ provider: 'deepseek-official', model: 'deepseek-v4-pro', reasoningEffort: ReasoningEffortId('max') }) expect(b.track).toHaveBeenCalledWith('model_switch', { ...blank ? {} : { session_id: 'analytics' }, switch_from: 'deepseek-official/deepseek-v4-flash', switch_to: 'deepseek-official/deepseek-v4-pro' }) b.track.mockClear() await face.select({ provider: 'deepseek-official', model: 'deepseek-v4-pro', reasoningEffort: 'high' }) diff --git a/packages/client/ui-model-selection/tests/model-select.client.spec.tsx b/packages/client/ui-model-selection/tests/model-select.client.spec.tsx index 54c606f636..bb4832a0ce 100644 --- a/packages/client/ui-model-selection/tests/model-select.client.spec.tsx +++ b/packages/client/ui-model-selection/tests/model-select.client.spec.tsx @@ -1,4 +1,5 @@ // @vitest-environment jsdom +import * as uiPrimitives from '@deepseek-ai/dsh-client-ui-primitives' import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' import { afterEach, beforeEach, describe, expect, it, onTestFinished, vi } from 'vitest' import { RemoteError, SlotTestRuntime } from '@deepseek-ai/dsh-client-test-runtime' @@ -457,6 +458,71 @@ describe('ModelSelect keyboard walk', () => { return select } + it.each([ + { device: 'iPhone', userAgent: 'Mozilla/5.0 (iPhone; CPU iPhone OS 18_6 like Mac OS X) AppleWebKit/605.1.15 Version/18.6 Mobile/15E148 Safari/604.1', platform: 'iPhone', maxTouchPoints: 5 }, + { device: 'desktop-mode iPad', userAgent: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 Version/18.6 Safari/605.1.15', platform: 'MacIntel', maxTouchPoints: 5 }, + ].flatMap(device => [1, 5].map(modelCount => ({ ...device, modelCount }))))( + 'retains menu keyboard focus on $device with $modelCount models without automatically focusing search', + async ({ userAgent, platform, maxTouchPoints, modelCount }) => { + for (const [name, value] of Object.entries({ userAgent, platform, maxTouchPoints })) { + const descriptor = Object.getOwnPropertyDescriptor(navigator, name) + Object.defineProperty(navigator, name, { configurable: true, value }) + onTestFinished(() => { + if (descriptor === undefined) Reflect.deleteProperty(navigator, name) + else Object.defineProperty(navigator, name, descriptor) + }) + } + vi.resetModules() + const detection = await import('@deepseek-ai/dsh-client-ui-primitives/src/focus.ts') + const device = vi.spyOn(uiPrimitives, 'isMobileDevice').mockImplementation(detection.isMobileDevice) + onTestFinished(() => { device.mockRestore() }) + const select = vi.fn() + render() + const trigger = screen.getByRole('button', { name: /选择模型/ }) + fireEvent.click(trigger) + expect(document.activeElement).toBe(trigger) + + const modelCell = screen.getByRole('menuitem', { name: /^模型/ }) + const effortCell = screen.getByRole('menuitem', { name: /推理等级/ }) + fireEvent.keyDown(trigger, { key: 'ArrowDown' }) + fireEvent.keyDown(modelCell, { key: 'ArrowDown' }) + expect(document.activeElement).toBe(effortCell) + fireEvent.keyDown(effortCell, { key: 'Tab' }) + const high = screen.getByRole('menuitemradio', { name: 'High' }) + const max = screen.getByRole('menuitemradio', { name: /Max/ }) + expect(document.activeElement).toBe(high) + fireEvent.keyDown(high, { key: 'ArrowDown' }) + expect(document.activeElement).toBe(max) + fireEvent.keyDown(max, { key: 'Escape' }) + + const restoredEffortCell = screen.getByRole('menuitem', { name: /推理等级/ }) + expect(document.activeElement).toBe(restoredEffortCell) + fireEvent.keyDown(restoredEffortCell, { key: 'ArrowUp' }) + const restoredModelCell = screen.getByRole('menuitem', { name: /^模型/ }) + expect(document.activeElement).toBe(restoredModelCell) + fireEvent.keyDown(restoredModelCell, { key: 'Tab' }) + const model = screen.getByRole('menuitemradio', { name: 'DeepSeek-V4-Flash' }) + expect(document.activeElement).toBe(model) + if (modelCount > 4) { + const search = screen.getByRole('searchbox') + expect(document.activeElement).not.toBe(search) + fireEvent.keyDown(model, { key: 'ArrowDown' }) + expect(document.activeElement).toBe(search) + } else { + expect(screen.queryByRole('searchbox')).toBeNull() + } + fireEvent.keyDown(document.activeElement!, { key: 'Escape' }) + expect(document.activeElement).toBe(screen.getByRole('menuitem', { name: /^模型/ })) + fireEvent.keyDown(document.activeElement!, { key: 'Escape' }) + expect(screen.queryByRole('menu')).toBeNull() + await waitFor(() => { expect(document.activeElement).toBe(trigger) }) + expect(select).not.toHaveBeenCalled() + }, + ) + it.each(['model', 'effort'])('prevents button mousedown defaults in the %s pane without selecting it', (pane) => { const select = mountOpen() const cell = screen.getByRole('menuitem', { name: pane === 'model' ? /^模型/ : /推理等级/ }) diff --git a/packages/client/ui-model-selection/tsconfig.json b/packages/client/ui-model-selection/tsconfig.json index 82f037f52a..862648d622 100644 --- a/packages/client/ui-model-selection/tsconfig.json +++ b/packages/client/ui-model-selection/tsconfig.json @@ -8,6 +8,9 @@ "src" ], "references": [ + { + "path": "../../core/agent" + }, { "path": "../../api/remotes/tsconfig.client.json" }, diff --git a/packages/client/ui-open-in-app/locale/de.json b/packages/client/ui-open-in-app/locale/de.json new file mode 100644 index 0000000000..f5b24225cb --- /dev/null +++ b/packages/client/ui-open-in-app/locale/de.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "Schnittstelle zum Öffnen externer Anwendungen", + "description": "Bietet den Eintrag „Öffnen mit“ in der Sitzungs- und Dokumentvorschau." + } +} diff --git a/packages/client/ui-open-in-app/locale/es.json b/packages/client/ui-open-in-app/locale/es.json new file mode 100644 index 0000000000..1164cb3c6b --- /dev/null +++ b/packages/client/ui-open-in-app/locale/es.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "Interfaz de apertura de aplicaciones externas", + "description": "Proporciona entrada \"Abrir con\" en la sesión y vista previa del documento." + } +} diff --git a/packages/client/ui-open-in-app/locale/fr.json b/packages/client/ui-open-in-app/locale/fr.json new file mode 100644 index 0000000000..5f104625e3 --- /dev/null +++ b/packages/client/ui-open-in-app/locale/fr.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "Interface d'ouverture d'application externe", + "description": "Fournit une entrée « Ouvrir avec » dans la session et un aperçu du document." + } +} diff --git a/packages/client/ui-open-in-app/locale/ja.json b/packages/client/ui-open-in-app/locale/ja.json new file mode 100644 index 0000000000..55c6f40aaa --- /dev/null +++ b/packages/client/ui-open-in-app/locale/ja.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "外部アプリで開く UI", + "description": "セッションと文書プレビューに「別のアプリで開く」操作を提供します。" + } +} diff --git a/packages/client/ui-open-in-app/locale/pt.json b/packages/client/ui-open-in-app/locale/pt.json new file mode 100644 index 0000000000..105759e7e0 --- /dev/null +++ b/packages/client/ui-open-in-app/locale/pt.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "Interface de abertura de aplicativo externo", + "description": "Fornece entrada \"Abrir com\" na sessão e visualização do documento." + } +} diff --git a/packages/client/ui-open-in-app/locale/ru.json b/packages/client/ui-open-in-app/locale/ru.json new file mode 100644 index 0000000000..2655015ac7 --- /dev/null +++ b/packages/client/ui-open-in-app/locale/ru.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "Интерфейс открытия внешнего приложения", + "description": "Обеспечивает запись «Открыть с помощью» в сеансе и предварительном просмотре документа." + } +} diff --git a/packages/client/ui-open-in-app/src/client/index.ts b/packages/client/ui-open-in-app/src/client/index.ts index 146cfed2f7..8da9f15f9e 100644 --- a/packages/client/ui-open-in-app/src/client/index.ts +++ b/packages/client/ui-open-in-app/src/client/index.ts @@ -25,7 +25,7 @@ import { OpenInAppPathController } from './open-path.ts' import { OpenPathAction, type OpenPathInjected } from './OpenPathAction.tsx' import { FileRouteAction } from './FileRouteAction.tsx' import { OpenPathEmptyAction } from './OpenPathEmptyAction.tsx' -import { en, NS, zh, type OpenInAppKey } from './locales.ts' +import { en, ja, es, fr, de, ru, pt, NS, zh, type OpenInAppKey } from './locales.ts' declare module '@deepseek-ai/dsh-client-ui-slots' { interface LocaleNamespaceMap { @@ -51,7 +51,7 @@ export function apply(ctx: ClientContext): void { const controller = new OpenInAppController() void controller.load() const paths = new OpenInAppPathController(ctx.remote) - ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'open-in-app: dictionaries') + ctx.effect(() => ctx.locale.register(NS, { zh, en, ja, es, fr, de, ru, pt }), 'open-in-app: dictionaries') const t = ctx.locale.bind(NS) const target = () => { if (ctx.layout.panelInfo.getSnapshot().activePanelId !== null) return undefined diff --git a/packages/client/ui-open-in-app/src/client/locales.ts b/packages/client/ui-open-in-app/src/client/locales.ts index 2e8a9204aa..737b027d00 100644 --- a/packages/client/ui-open-in-app/src/client/locales.ts +++ b/packages/client/ui-open-in-app/src/client/locales.ts @@ -79,3 +79,123 @@ export const en: Record = { /** Key domain of the `open-in-app` namespace (zh is the source of truth). */ export type OpenInAppKey = keyof typeof zh + +/** Japanese copy, checked against the English keys. */ +export const ja = { + ...PRODUCT_NAMES, + 'open.title': '{app}で開く', + 'path.appDefault': '{app}(デフォルト)', + 'path.appsError': 'アプリケーションを読み込めませんでした', + 'shortcut.busy': 'ワークスペースを開いています', + 'shortcut.unavailable': '現在のワークスペースまたはローカルアプリケーションを利用できません', + 'open.tooltip': 'ローカルで開く', + 'path.open': '開く', + 'path.more': 'その他の開き方', + 'path.reveal': 'ファイルの場所を表示', + 'path.openError': '開けませんでした。もう一度お試しください。', + 'path.revealError': 'ファイルの場所を表示できませんでした。もう一度お試しください。', + 'app.finder': 'Finder', + 'app.explorer': 'エクスプローラー', + 'app.filemanager': 'ファイル', + 'app.terminal': 'ターミナル', +} satisfies Record + +/** Spanish dictionary, checked against the English keys. */ +export const es = { + 'open.title': 'Abrir en {app}', + 'path.appDefault': '{app} (predeterminado)', + 'path.appsError': 'No se pudieron cargar aplicaciones', + 'shortcut.busy': 'Abrir espacio de trabajo', + 'shortcut.unavailable': 'El espacio de trabajo actual o la aplicación local no están disponibles', + 'open.tooltip': 'Abierto localmente', + 'path.open': 'Abrir', + 'path.more': 'Más formas de abrir', + 'path.reveal': 'Mostrar ubicación del archivo', + 'path.openError': 'No se pudo abrir. Intentar otra vez.', + 'path.revealError': 'No se pudo mostrar la ubicación del archivo. Intentar otra vez.', + ...PRODUCT_NAMES, + 'app.finder': 'Finder', + 'app.explorer': 'Explorador de archivos', + 'app.filemanager': 'Archivos', + 'app.terminal': 'terminales', +} satisfies Record + +/** French dictionary, checked against the English keys. */ +export const fr = { + 'open.title': 'Ouvrir dans {app}', + 'path.appDefault': '{app} (par défaut)', + 'path.appsError': 'Impossible de charger les applications', + 'shortcut.busy': "Ouverture de l'espace de travail", + 'shortcut.unavailable': 'Espace de travail actuel ou application locale indisponible', + 'open.tooltip': 'Ouvrir localement', + 'path.open': 'Ouvrir', + 'path.more': "Plus de façons d'ouvrir", + 'path.reveal': "Afficher l'emplacement du fichier", + 'path.openError': "Impossible d'ouvrir. Essayer à nouveau.", + 'path.revealError': "Impossible d'afficher l'emplacement du fichier. Essayer à nouveau.", + ...PRODUCT_NAMES, + 'app.finder': 'Finder', + 'app.explorer': 'Explorateur de fichiers', + 'app.filemanager': 'Fichiers', + 'app.terminal': 'Borne', +} satisfies Record + +/** German dictionary, checked against the English keys. */ +export const de = { + 'open.title': 'In {app} öffnen', + 'path.appDefault': '{app} (Standard)', + 'path.appsError': 'Anwendungen konnten nicht geladen werden', + 'shortcut.busy': 'Arbeitsbereich öffnen', + 'shortcut.unavailable': 'Aktueller Arbeitsbereich oder lokale Anwendung nicht verfügbar', + 'open.tooltip': 'Vor Ort geöffnet', + 'path.open': 'Öffnen', + 'path.more': 'Mehr Möglichkeiten zum Öffnen', + 'path.reveal': 'Dateispeicherort anzeigen', + 'path.openError': 'Konnte nicht geöffnet werden. Versuchen Sie es erneut.', + 'path.revealError': 'Der Dateispeicherort konnte nicht angezeigt werden. Versuchen Sie es erneut.', + ...PRODUCT_NAMES, + 'app.finder': 'Finder', + 'app.explorer': 'Datei-Explorer', + 'app.filemanager': 'Dateien', + 'app.terminal': 'Terminal', +} satisfies Record + +/** Russian dictionary, checked against the English keys. */ +export const ru = { + 'open.title': 'Открыть в {app}', + 'path.appDefault': '{app} (по умолчанию)', + 'path.appsError': 'Не удалось загрузить приложения', + 'shortcut.busy': 'Открытие рабочей области', + 'shortcut.unavailable': 'Текущая рабочая область или локальное приложение недоступно.', + 'open.tooltip': 'Открыть локально', + 'path.open': 'Открыть', + 'path.more': 'Больше способов открыть', + 'path.reveal': 'Показать местоположение файла', + 'path.openError': 'Не удалось открыть. Попробуйте еще раз.', + 'path.revealError': 'Не удалось показать местоположение файла. Попробуйте еще раз.', + ...PRODUCT_NAMES, + 'app.finder': 'Finder', + 'app.explorer': 'Проводник файлов', + 'app.filemanager': 'Файлы', + 'app.terminal': 'Терминал', +} satisfies Record + +/** Portuguese dictionary, checked against the English keys. */ +export const pt = { + 'open.title': 'Abrir em {app}', + 'path.appDefault': '{app} (padrão)', + 'path.appsError': 'Não foi possível carregar aplicativos', + 'shortcut.busy': 'Abrindo espaço de trabalho', + 'shortcut.unavailable': 'Espaço de trabalho atual ou aplicativo local indisponível', + 'open.tooltip': 'Abrir localmente', + 'path.open': 'Abrir', + 'path.more': 'Mais maneiras de abrir', + 'path.reveal': 'Mostrar localização do arquivo', + 'path.openError': 'Não foi possível abrir. Tente novamente.', + 'path.revealError': 'Não foi possível mostrar o local do arquivo. Tente novamente.', + ...PRODUCT_NAMES, + 'app.finder': 'Finder', + 'app.explorer': 'Explorador de arquivos', + 'app.filemanager': 'Arquivos', + 'app.terminal': 'Terminais', +} satisfies Record diff --git a/packages/client/ui-permission-presets/README.i18n.yaml b/packages/client/ui-permission-presets/README.i18n.yaml index f9ae7348ed..9e64b4a3c4 100644 --- a/packages/client/ui-permission-presets/README.i18n.yaml +++ b/packages/client/ui-permission-presets/README.i18n.yaml @@ -15,8 +15,8 @@ en: d152484eb41ac6b4 zh: 09388d293f9be9cb /deepseek-ai-dsh-client-ui-permission-presets/use-this-package: - en: 91606bff7da02253 - zh: ef68e813d64c89be + en: b4193258023cf8ae + zh: e8e6e0ea0ccb8ef4 /deepseek-ai-dsh-client-ui-permission-presets/use-this-package/the-picker: en: 4e5a070ad4321548 zh: 010063336bd2b8df diff --git a/packages/client/ui-permission-presets/README.md b/packages/client/ui-permission-presets/README.md index 50d8b4918c..cc552f5a78 100644 --- a/packages/client/ui-permission-presets/README.md +++ b/packages/client/ui-permission-presets/README.md @@ -27,6 +27,8 @@ Choose permission presets for the current Web session or future sessions. Genera Mount this plugin alongside the settings, commands, and conversation packages. General settings receives the future-default row; the composer receives the `conversation.input.permission` control, and bare `/permission` opens the slash picker. Current-session controls require both the Session projection and a complete catalog snapshot. A permission-less composition exposes neither current-session controls nor the Settings row. +In narrow browser settings, the default-permission label uses a smaller gutter before its control. Windows and macOS desktop windows retain the standard spacing. + ### The picker A pick submits the `/permission ` command line. The argument-bearing form (`/permission ` typed directly) still switches directly; the decoration replaces only the bare invocation. The built-in labels are `Read Only`, `Workspace Write`, `Full access`, and `Auto review` in English and `仅可查看`, `工作区内修改`, `完全权限`, and `自动审查` in Chinese. Explicit host labels remain unchanged, unknown kebab-case names render in title case, and `auto` carries an `EXP` badge in menu options plus an experimental-risk confirmation. The selected composer control shows the mode without the badge. `custom` is display state, never a target. diff --git a/packages/client/ui-permission-presets/README.zh.md b/packages/client/ui-permission-presets/README.zh.md index df37cea7ec..d8c4b63854 100644 --- a/packages/client/ui-permission-presets/README.zh.md +++ b/packages/client/ui-permission-presets/README.zh.md @@ -27,6 +27,8 @@ kind: "package-reference" 将本插件与 settings、commands 和 conversation 包一同挂载。通用设置获得未来默认值行;composer 获得 `conversation.input.permission` 控件,裸 `/permission` 打开 slash 选择器。当前会话控件同时要求 Session 投影和完整目录快照。不提供权限功能的装配既不公开当前会话控件,也不公开设置行。 +窄屏浏览器设置中,默认权限标签与控件的间距缩小;Windows 和 macOS 桌面窗口保留标准间距。 + ### 选择器 选中即提交 `/permission ` 命令行。带参形式(直接键入 `/permission `)仍直接切换;装饰只替换裸调用。内置标签在英文界面中是 `Read Only`、`Workspace Write`、`Full access` 和 `Auto review`,在中文界面中是「仅可查看」「工作区内修改」「完全权限」和「自动审查」。显式 host 标签保持原样,未知 kebab-case 名称渲染为 Title Case;`auto` 在菜单选项中带有 `EXP` 标记,并在可见选择时要求实验风险确认。选中后的 composer 控件显示模式,不展示该标记。`custom` 只是显示状态,绝非目标。 diff --git a/packages/client/ui-permission-presets/locale/de.json b/packages/client/ui-permission-presets/locale/de.json new file mode 100644 index 0000000000..0f2614b5c2 --- /dev/null +++ b/packages/client/ui-permission-presets/locale/de.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "Schnittstelle für den Berechtigungsmodus", + "description": "Legt die Standardberechtigungen für neue Sitzungen fest und wechselt den Berechtigungsmodus für die aktuelle Sitzung." + } +} diff --git a/packages/client/ui-permission-presets/locale/es.json b/packages/client/ui-permission-presets/locale/es.json new file mode 100644 index 0000000000..aed991a5c6 --- /dev/null +++ b/packages/client/ui-permission-presets/locale/es.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "Interfaz de modo de permiso", + "description": "Establece los permisos predeterminados para nuevas sesiones y cambia el modo de permisos para la sesión actual." + } +} diff --git a/packages/client/ui-permission-presets/locale/fr.json b/packages/client/ui-permission-presets/locale/fr.json new file mode 100644 index 0000000000..8119ff4849 --- /dev/null +++ b/packages/client/ui-permission-presets/locale/fr.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "Interface du mode autorisation", + "description": "Définit les autorisations par défaut pour les nouvelles sessions et change le mode d'autorisations pour la session en cours." + } +} diff --git a/packages/client/ui-permission-presets/locale/ja.json b/packages/client/ui-permission-presets/locale/ja.json new file mode 100644 index 0000000000..21031f2ac0 --- /dev/null +++ b/packages/client/ui-permission-presets/locale/ja.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "権限モード UI", + "description": "新規セッションのデフォルト権限を設定し、現在のセッションの権限モードを切り替えます。" + } +} diff --git a/packages/client/ui-permission-presets/locale/pt.json b/packages/client/ui-permission-presets/locale/pt.json new file mode 100644 index 0000000000..fc65c0a5d0 --- /dev/null +++ b/packages/client/ui-permission-presets/locale/pt.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "Interface do modo de permissão", + "description": "Define as permissões padrão para novas sessões e alterna o modo de permissões para a sessão atual." + } +} diff --git a/packages/client/ui-permission-presets/locale/ru.json b/packages/client/ui-permission-presets/locale/ru.json new file mode 100644 index 0000000000..74ad24f1d4 --- /dev/null +++ b/packages/client/ui-permission-presets/locale/ru.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "Интерфейс режима разрешений", + "description": "Устанавливает разрешения по умолчанию для новых сеансов и переключает режим разрешений для текущего сеанса." + } +} diff --git a/packages/client/ui-permission-presets/src/client/PermissionRow.module.css b/packages/client/ui-permission-presets/src/client/PermissionRow.module.css index 32c6653554..e3d9b76252 100644 --- a/packages/client/ui-permission-presets/src/client/PermissionRow.module.css +++ b/packages/client/ui-permission-presets/src/client/PermissionRow.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-permission-presets/src/client/index.ts b/packages/client/ui-permission-presets/src/client/index.ts index 4541718839..8f4d98c854 100644 --- a/packages/client/ui-permission-presets/src/client/index.ts +++ b/packages/client/ui-permission-presets/src/client/index.ts @@ -41,7 +41,7 @@ import type { PermissionSelectInjected } from './PermissionSelect.tsx' import { PermissionRow } from './PermissionRow.tsx' import type { PermissionRowInjected } from './PermissionRow.tsx' import { - accessEn, accessZh, en, PERMISSION_ACCESS_NS, zh, + accessEn, accessJa, accessEs, accessFr, accessDe, accessRu, accessPt, accessZh, en, ja, es, fr, de, ru, pt, PERMISSION_ACCESS_NS, zh, } from './locales.ts' import { AUTO_REVIEW_PRESET, displayPermissionPreset, FULL_ACCESS_PRESET, @@ -113,8 +113,18 @@ function optionsOf( export function apply(ctx: ClientContext): void { const command = ctx.get('commandUi') as CommandUiContract const sessions = ctx.sessions + const accessDictionaries = { + zh: accessZh, + en: accessEn, + ja: accessJa, + es: accessEs, + fr: accessFr, + de: accessDe, + ru: accessRu, + pt: accessPt, + } ctx.effect( - () => ctx.locale.register(PERMISSION_ACCESS_NS, { zh: accessZh, en: accessEn }), + () => ctx.locale.register(PERMISSION_ACCESS_NS, accessDictionaries), 'ui-permission: current-session dictionaries', ) const t = ctx.locale.bind(PERMISSION_ACCESS_NS) @@ -141,7 +151,7 @@ export function apply(ctx: ClientContext): void { 'ui-permission: dismiss stale slash choices', ) - ctx.effect(() => ctx.locale.register('settings.permission', { zh, en }), 'ui-permission: settings row dictionaries') + ctx.effect(() => ctx.locale.register('settings.permission', { zh, en, ja, es, fr, de, ru, pt }), 'ui-permission: settings row dictionaries') // The shared ConfigForm mirror updates after document commits and reconnects. const controller = new PermissionPresetSettingsController( diff --git a/packages/client/ui-permission-presets/src/client/locales.ts b/packages/client/ui-permission-presets/src/client/locales.ts index 3754b8a07a..9fe5025f87 100644 --- a/packages/client/ui-permission-presets/src/client/locales.ts +++ b/packages/client/ui-permission-presets/src/client/locales.ts @@ -82,3 +82,225 @@ export const accessEn = { 'auto.confirm.acknowledge': 'I understand these risks and want to continue', 'auto.confirm.enable': 'Enable Auto review', } satisfies Record + +/** Japanese copy, checked against the English keys. */ +export const ja = { + 'title': '権限', + 'description': '新しいセッションのデフォルトの権限モードを選択', + 'loading': '読み込み中', + 'unavailable': '利用不可', + 'preset.readOnly': '読み取り専用', + 'preset.workspaceWrite': 'ワークスペース書き込み', + 'preset.fullAccess': 'フルアクセス', + 'confirm.title': 'フルアクセスを有効にしますか?', + 'confirm.description': 'フルアクセスでは、新しいセッションで確認ステップが減り、機密操作・ファイル変更・外部コマンドなど多くの操作を直接実行できるようになります。以降のタスクを信頼できる場合にのみ使用してください。', + 'confirm.acknowledge': 'リスクを理解し、続行します', + 'confirm.cancel': 'キャンセル', + 'confirm.enable': 'フルアクセスを有効にする', +} satisfies Record + +/** Spanish dictionary, checked against the English keys. */ +export const es = { + 'title': 'Permiso', + 'description': 'Elija el modo de permiso predeterminado para nuevas sesiones', + 'loading': 'Cargando', + 'unavailable': 'No disponible', + 'preset.readOnly': 'Sólo lectura', + 'preset.workspaceWrite': 'Escritura en el espacio de trabajo', + 'preset.fullAccess': 'Acceso completo', + 'confirm.title': '¿Habilitar acceso completo?', + 'confirm.description': 'El acceso completo permite que las nuevas sesiones reduzcan los pasos de confirmación y realicen más acciones directamente, incluidas operaciones confidenciales, cambios de archivos o comandos externos. Úselo sólo cuando confíe en las tareas posteriores.', + 'confirm.acknowledge': 'Entiendo los riesgos y quiero continuar.', + 'confirm.cancel': 'Cancelar', + 'confirm.enable': 'Habilitar acceso completo', +} satisfies Record + +/** French dictionary, checked against the English keys. */ +export const fr = { + 'title': 'Autorisation', + 'description': "Choisissez le mode d'autorisation par défaut pour les nouvelles sessions", + 'loading': 'Chargement', + 'unavailable': 'Indisponible', + 'preset.readOnly': 'Lecture seule', + 'preset.workspaceWrite': "Écriture dans l'espace de travail", + 'preset.fullAccess': 'Accès complet', + 'confirm.title': "Activer l'accès complet ?", + 'confirm.description': "L'accès complet permet aux nouvelles sessions de réduire les étapes de confirmation et d'effectuer directement davantage d'actions, notamment des opérations sensibles, des modifications de fichiers ou des commandes externes. Utilisez-le uniquement lorsque vous faites confiance aux tâches suivantes.", + 'confirm.acknowledge': 'Je comprends les risques et je veux continuer', + 'confirm.cancel': 'Annuler', + 'confirm.enable': "Activer l'accès complet", +} satisfies Record + +/** German dictionary, checked against the English keys. */ +export const de = { + 'title': 'Erlaubnis', + 'description': 'Wählen Sie den Standardberechtigungsmodus für neue Sitzungen', + 'loading': 'Laden', + 'unavailable': 'Nicht verfügbar', + 'preset.readOnly': 'Nur lesen', + 'preset.workspaceWrite': 'Arbeitsbereich schreiben', + 'preset.fullAccess': 'Voller Zugriff', + 'confirm.title': 'Vollzugriff aktivieren?', + 'confirm.description': 'Durch den vollständigen Zugriff können neue Sitzungen die Bestätigungsschritte reduzieren und mehr Aktionen direkt ausführen, einschließlich vertraulicher Vorgänge, Dateiänderungen oder externer Befehle. Verwenden Sie es nur, wenn Sie nachfolgenden Aufgaben vertrauen.', + 'confirm.acknowledge': 'Ich verstehe die Risiken und möchte weitermachen', + 'confirm.cancel': 'Abbrechen', + 'confirm.enable': 'Aktivieren Sie Vollzugriff', +} satisfies Record + +/** Russian dictionary, checked against the English keys. */ +export const ru = { + 'title': 'Разрешение', + 'description': 'Выберите режим разрешений по умолчанию для новых сеансов', + 'loading': 'Загрузка', + 'unavailable': 'Недоступно', + 'preset.readOnly': 'Только чтение', + 'preset.workspaceWrite': 'Запись в рабочей области', + 'preset.fullAccess': 'Полный доступ', + 'confirm.title': 'Включить полный доступ?', + 'confirm.description': 'Полный доступ позволяет новым сеансам сократить количество шагов подтверждения и напрямую выполнять больше действий, включая конфиденциальные операции, изменение файлов или внешние команды. Используйте его только в том случае, если вы доверяете последующим задачам.', + 'confirm.acknowledge': 'Я понимаю риски и хочу продолжить', + 'confirm.cancel': 'Отмена', + 'confirm.enable': 'Включить полный доступ', +} satisfies Record + +/** Portuguese dictionary, checked against the English keys. */ +export const pt = { + 'title': 'Permissão', + 'description': 'Escolha o modo de permissão padrão para novas sessões', + 'loading': 'Carregando', + 'unavailable': 'Indisponível', + 'preset.readOnly': 'Somente leitura', + 'preset.workspaceWrite': 'Gravação no espaço de trabalho', + 'preset.fullAccess': 'Acesso total', + 'confirm.title': 'Ativar acesso total?', + 'confirm.description': 'O acesso total permite que novas sessões reduzam as etapas de confirmação e executem mais ações diretamente, incluindo operações confidenciais, alterações de arquivos ou comandos externos. Use-o apenas quando confiar nas tarefas subsequentes.', + 'confirm.acknowledge': 'Entendo os riscos e quero continuar', + 'confirm.cancel': 'Cancelar', + 'confirm.enable': 'Ativar acesso total', +} satisfies Record + +/** Japanese copy, checked against the English keys. */ +export const accessJa = { + 'mode': 'アクセスモード、現在: {name}', + 'close': '閉じる', + 'preset.readOnly': '読み取り専用', + 'preset.workspaceWrite': 'ワークスペース書き込み', + 'preset.fullAccess': 'フルアクセス', + 'confirm.title': 'フルアクセスを有効にしますか?', + 'confirm.description': 'フルアクセスでは確認ステップが減り、エージェントが機密操作・ファイル変更・外部コマンドなど多くの操作を直接実行できるようになります。現在のタスクを信頼できる場合にのみ使用してください。', + 'confirm.acknowledge': 'リスクを理解し、続行します', + 'confirm.cancel': 'キャンセル', + 'confirm.enable': 'フルアクセスを有効にする', + 'auto.label': '自動認可レビュー', + 'auto.badge': 'EXP', + 'auto.description': 'すべてのネイティブツール呼び出しと PTC 内部呼び出しを実験的な同一モデルでレビューしたうえで、サンドボックスなしで実行します。', + 'auto.confirm.title': '自動認可レビュー(実験的)を有効にしますか?', + 'auto.confirm.description': '自動認可レビューはサンドボックスなしで実行されます。ネイティブツール呼び出しと PTC 内部呼び出しのたびに、現在のエージェントと同じモデルが許可可否をレビューし、拒否された呼び出しはあなたが承認または却下します。この機能は実験的であり、操作を誤って許可・拒否する可能性があり、追加のトークンを消費します。', + 'auto.confirm.acknowledge': 'これらのリスクを理解し、続行します', + 'auto.confirm.enable': '自動認可レビューを有効にする', +} satisfies Record + +/** Spanish dictionary, checked against the English keys. */ +export const accessEs = { + 'mode': 'Modo de acceso, actual: {name}', + 'close': 'Cerrar', + 'preset.readOnly': 'Sólo lectura', + 'preset.workspaceWrite': 'Escritura en el espacio de trabajo', + 'preset.fullAccess': 'Acceso completo', + 'confirm.title': '¿Habilitar acceso completo?', + 'confirm.description': 'El acceso completo reduce los pasos de confirmación y permite al agente realizar más acciones directamente, incluidas operaciones confidenciales, cambios de archivos o comandos externos. Úselo solo cuando confíe en la tarea actual.', + 'confirm.acknowledge': 'Entiendo los riesgos y quiero continuar.', + 'confirm.cancel': 'Cancelar', + 'confirm.enable': 'Habilitar acceso completo', + 'auto.label': 'Auto review', + 'auto.badge': 'EXP', + 'auto.description': 'Ejecute sin una zona de pruebas después de una revisión experimental del mismo modelo de cada llamada de herramienta nativa y llamada interna PTC.', + 'auto.confirm.title': '¿Habilitar Auto review (experimental)?', + 'auto.confirm.description': 'Auto review se ejecuta sin zona de pruebas. Antes de cada llamada a la herramienta nativa y llamada interna PTC, el mismo modelo que el agente actual revisa si se permite; usted aprueba o rechaza cada llamada que rechaza. Esta característica es experimental, puede permitir o denegar acciones falsamente y utiliza tokens adicionales.', + 'auto.confirm.acknowledge': 'Entiendo estos riesgos y quiero continuar', + 'auto.confirm.enable': 'Habilitar Auto review', +} satisfies typeof accessEn + +/** French dictionary, checked against the English keys. */ +export const accessFr = { + 'mode': "Mode d'accès, actuel : {name}", + 'close': 'Fermer', + 'preset.readOnly': 'Lecture seule', + 'preset.workspaceWrite': "Écriture dans l'espace de travail", + 'preset.fullAccess': 'Accès complet', + 'confirm.title': "Activer l'accès complet ?", + 'confirm.description': "L'accès complet réduit les étapes de confirmation et permet à l'agent d'effectuer directement davantage d'actions, notamment des opérations sensibles, des modifications de fichiers ou des commandes externes. Utilisez-le uniquement lorsque vous faites confiance à la tâche en cours.", + 'confirm.acknowledge': 'Je comprends les risques et je veux continuer', + 'confirm.cancel': 'Annuler', + 'confirm.enable': "Activer l'accès complet", + 'auto.label': 'Auto review', + 'auto.badge': 'EXP', + 'auto.description': "Exécutez sans bac à sable après un examen expérimental sur le même modèle de chaque appel d'outil natif et appel interne PTC.", + 'auto.confirm.title': 'Activer Auto review (expérimental) ?', + 'auto.confirm.description': "Auto review fonctionne sans bac à sable. Avant chaque appel d'outil natif et appel interne PTC, le même modèle que l'agent actuel vérifie s'il doit l'autoriser ; vous approuvez ou rejetez chaque appel qu'il refuse. Cette fonctionnalité est expérimentale, peut autoriser ou refuser à tort des actions et utilise des jetons supplémentaires.", + 'auto.confirm.acknowledge': 'Je comprends ces risques et je veux continuer', + 'auto.confirm.enable': 'Activer Auto review', +} satisfies typeof accessEn + +/** German dictionary, checked against the English keys. */ +export const accessDe = { + 'mode': 'Zugriffsmodus, aktuell: {name}', + 'close': 'Schließen', + 'preset.readOnly': 'Nur lesen', + 'preset.workspaceWrite': 'Arbeitsbereich schreiben', + 'preset.fullAccess': 'Voller Zugriff', + 'confirm.title': 'Vollzugriff aktivieren?', + 'confirm.description': 'Durch den Vollzugriff werden Bestätigungsschritte reduziert und der Agent kann mehr Aktionen direkt ausführen, einschließlich vertraulicher Vorgänge, Dateiänderungen oder externer Befehle. Verwenden Sie es nur, wenn Sie der aktuellen Aufgabe vertrauen.', + 'confirm.acknowledge': 'Ich verstehe die Risiken und möchte weitermachen', + 'confirm.cancel': 'Abbrechen', + 'confirm.enable': 'Aktivieren Sie Vollzugriff', + 'auto.label': 'Auto review', + 'auto.badge': 'EXP', + 'auto.description': 'Ausführung ohne Sandbox nach einer experimentellen, modellgleichen Überprüfung jedes nativen Toolaufrufs und PTC-Innenaufrufs.', + 'auto.confirm.title': 'Auto review aktivieren (experimentell)?', + 'auto.confirm.description': 'Auto review läuft ohne Sandbox. Vor jedem nativen Tool-Aufruf und PTC-Innenaufruf überprüft dasselbe Modell wie der aktuelle Agent, ob dies zulässig ist. Sie genehmigen oder lehnen jeden abgelehnten Aufruf ab. Diese Funktion ist experimentell, kann Aktionen fälschlicherweise zulassen oder verweigern und verwendet zusätzliche Token.', + 'auto.confirm.acknowledge': 'Ich verstehe diese Risiken und möchte weitermachen', + 'auto.confirm.enable': 'Aktivieren Sie Auto review', +} satisfies typeof accessEn + +/** Russian dictionary, checked against the English keys. */ +export const accessRu = { + 'mode': 'Режим доступа, текущий: {name}', + 'close': 'Закрыть', + 'preset.readOnly': 'Только чтение', + 'preset.workspaceWrite': 'Запись в рабочей области', + 'preset.fullAccess': 'Полный доступ', + 'confirm.title': 'Включить полный доступ?', + 'confirm.description': 'Полный доступ сокращает количество шагов подтверждения и позволяет агенту напрямую выполнять больше действий, включая конфиденциальные операции, изменения файлов или внешние команды. Используйте его только в том случае, если вы доверяете текущей задаче.', + 'confirm.acknowledge': 'Я понимаю риски и хочу продолжить', + 'confirm.cancel': 'Отмена', + 'confirm.enable': 'Включить полный доступ', + 'auto.label': 'Auto review', + 'auto.badge': 'EXP', + 'auto.description': 'Запуск без песочницы после экспериментальной проверки одной и той же модели каждого вызова встроенного инструмента и внутреннего вызова PTC.', + 'auto.confirm.title': 'Включить Auto review (экспериментально)?', + 'auto.confirm.description': 'Auto review работает без песочницы. Перед каждым вызовом собственного инструмента и внутренним вызовом PTC та же модель, что и текущий агент, проверяет, разрешить ли это; вы одобряете или отклоняете каждый вызов, который он отклоняет. Эта функция является экспериментальной, может ошибочно разрешать или запрещать действия и использует дополнительные токены.', + 'auto.confirm.acknowledge': 'Я понимаю эти риски и хочу продолжить', + 'auto.confirm.enable': 'Включить Auto review', +} satisfies typeof accessEn + +/** Portuguese dictionary, checked against the English keys. */ +export const accessPt = { + 'mode': 'Modo de acesso, atual: {name}', + 'close': 'Fechar', + 'preset.readOnly': 'Somente leitura', + 'preset.workspaceWrite': 'Gravação no espaço de trabalho', + 'preset.fullAccess': 'Acesso total', + 'confirm.title': 'Ativar acesso total?', + 'confirm.description': 'O acesso total reduz as etapas de confirmação e permite que o agente execute mais ações diretamente, incluindo operações confidenciais, alterações de arquivos ou comandos externos. Use-o apenas quando confiar na tarefa atual.', + 'confirm.acknowledge': 'Entendo os riscos e quero continuar', + 'confirm.cancel': 'Cancelar', + 'confirm.enable': 'Ativar acesso total', + 'auto.label': 'Auto review', + 'auto.badge': 'EXP', + 'auto.description': 'Execute sem um sandbox após uma revisão experimental do mesmo modelo de cada chamada de ferramenta nativa e PTC chamada interna.', + 'auto.confirm.title': 'Ativar Auto review (experimental)?', + 'auto.confirm.description': 'Auto review é executado sem sandbox. Antes de cada chamada de ferramenta nativa e chamada interna PTC, o mesmo modelo do agente atual analisa se deve permitir isso; você aprova ou rejeita cada chamada negada. Este recurso é experimental, pode permitir ou negar ações falsamente e usa tokens adicionais.', + 'auto.confirm.acknowledge': 'Entendo esses riscos e quero continuar', + 'auto.confirm.enable': 'Ativar Auto review', +} satisfies typeof accessEn diff --git a/packages/client/ui-plan/locale/de.json b/packages/client/ui-plan/locale/de.json new file mode 100644 index 0000000000..ce90df31f6 --- /dev/null +++ b/packages/client/ui-plan/locale/de.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "Schnittstelle zur Planüberprüfung", + "description": "Zeigt Steuerelemente für den Planungsmodus, Planungskarten und eine Markdown-Vorschau an." + } +} diff --git a/packages/client/ui-plan/locale/es.json b/packages/client/ui-plan/locale/es.json new file mode 100644 index 0000000000..1bc8dde649 --- /dev/null +++ b/packages/client/ui-plan/locale/es.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "Interfaz de revisión del plan", + "description": "Muestra controles del modo de planificación, tarjetas de planificación y vista previa de Markdown." + } +} diff --git a/packages/client/ui-plan/locale/fr.json b/packages/client/ui-plan/locale/fr.json new file mode 100644 index 0000000000..165c62a645 --- /dev/null +++ b/packages/client/ui-plan/locale/fr.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "Interface de révision des plans", + "description": "Affiche les commandes du mode planification, les cartes de planification et l'aperçu Markdown." + } +} diff --git a/packages/client/ui-plan/locale/ja.json b/packages/client/ui-plan/locale/ja.json new file mode 100644 index 0000000000..94dfa7c3a2 --- /dev/null +++ b/packages/client/ui-plan/locale/ja.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "計画レビュー UI", + "description": "計画モードの操作、計画カード、Markdown プレビューを表示します。" + } +} diff --git a/packages/client/ui-plan/locale/pt.json b/packages/client/ui-plan/locale/pt.json new file mode 100644 index 0000000000..1ad0ee2f88 --- /dev/null +++ b/packages/client/ui-plan/locale/pt.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "Interface de revisão do plano", + "description": "Mostra controles do modo de planejamento, cartões de planejamento e visualização do Markdown." + } +} diff --git a/packages/client/ui-plan/locale/ru.json b/packages/client/ui-plan/locale/ru.json new file mode 100644 index 0000000000..08eba25394 --- /dev/null +++ b/packages/client/ui-plan/locale/ru.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "Интерфейс проверки плана", + "description": "Показывает элементы управления режимом планирования, карты планирования и предварительный просмотр Markdown." + } +} diff --git a/packages/client/ui-plan/package.json b/packages/client/ui-plan/package.json index f6dd3292cd..a6a3dea5cf 100644 --- a/packages/client/ui-plan/package.json +++ b/packages/client/ui-plan/package.json @@ -68,9 +68,9 @@ "@deepseek-ai/dsh-client-ui-sidebar-right": "workspace:*", "@deepseek-ai/dsh-client-ui-user-questions": "workspace:*", "@deepseek-ai/dsh-client-resources": "workspace:*", + "@deepseek-ai/dsh-tool-executor-ptc": "workspace:*", "@deepseek-ai/dsh-typert-protocol": "workspace:*", "@deepseek-ai/dsh-llm": "workspace:*", - "@deepseek-ai/dsh-tools": "workspace:*", "@deepseek-ai/dsh-util-crypto": "workspace:*" }, "files": [ diff --git a/packages/client/ui-plan/src/client/index.ts b/packages/client/ui-plan/src/client/index.ts index 6dc0b66a2f..34196a00da 100644 --- a/packages/client/ui-plan/src/client/index.ts +++ b/packages/client/ui-plan/src/client/index.ts @@ -25,7 +25,7 @@ import { planAddress, parsePlanAddress } from './plan.ts' import { isReviewPreviewAddress, reviewPreviewAddress } from './review-preview.ts' import { createPlanReviewStore } from './review-store.ts' import { PlanChip } from './PlanModeControl.tsx' -import { en, zh, type PlanKey } from './locales.ts' +import { en, ja, es, fr, de, ru, pt, zh, type PlanKey } from './locales.ts' export type { PlanKey } from './locales.ts' @@ -56,7 +56,7 @@ export const inject = ['slots', 'remote', 'remote.commands', 'remote.session', ' * @param ctx - client root context. */ export function apply(ctx: ClientContext): void { - ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'ui-plan: dictionaries') + ctx.effect(() => ctx.locale.register(NS, { zh, en, ja, es, fr, de, ru, pt }), 'ui-plan: dictionaries') const previewId = '@deepseek-ai/dsh-client-ui-plan' const t = ctx.locale.bind(NS) diff --git a/packages/client/ui-plan/src/client/locales.ts b/packages/client/ui-plan/src/client/locales.ts index 56e7ca9978..5940519fa2 100644 --- a/packages/client/ui-plan/src/client/locales.ts +++ b/packages/client/ui-plan/src/client/locales.ts @@ -44,3 +44,129 @@ export const en = { 'chip.on.title': 'Plan mode on — click to turn off (/plan off)', 'chip.exitFailed': 'Failed to exit plan mode', } satisfies Record + +/** Japanese copy, checked against the English keys. */ +export const ja = { + 'chip.label': '計画', + 'preview.title': '計画', + 'preview.document': '計画 · Markdown', + 'preview.action': '開く', + 'preview.open': 'サイドバーで計画を開く', + 'preview.full': '計画全体を表示', + 'preview.openNamed': '計画を開く: {title}', + 'preview.loading': '計画を読み込み中…', + 'preview.failed': '計画を読み込めませんでした', + 'preview.invalidAddress': '計画のアドレスが無効です', + 'preview.historyUnavailable': 'セッション履歴を利用できません', + 'preview.notFound': 'この計画は見つかりませんでした', + 'preview.unavailable': '計画のプレビューを利用できません', + 'preview.expired': 'この一時的な計画プレビューは期限切れです。保留中のレビューカードから再度開いてください。', + 'chip.on.aria': '計画モードはオンです。押すとオフになります', + 'chip.on.title': '計画モードはオンです — クリックでオフ (/plan off)', + 'chip.exitFailed': '計画モードを終了できませんでした', +} satisfies Record + +/** Spanish dictionary, checked against the English keys. */ +export const es = { + 'chip.label': 'Planificar', + 'preview.title': 'Planificar', + 'preview.document': 'Plano · Markdown', + 'preview.action': 'Abrir', + 'preview.open': 'Plan abierto en la barra lateral', + 'preview.full': 'Ver plano completo', + 'preview.openNamed': 'Plan abierto: {title}', + 'preview.loading': 'Cargando plan…', + 'preview.failed': 'No se pudo cargar el plan', + 'preview.invalidAddress': 'Dirección del plan no válida', + 'preview.historyUnavailable': 'El historial de sesiones no está disponible', + 'preview.notFound': 'Este plan no fue encontrado.', + 'preview.unavailable': 'La vista previa del plan no está disponible', + 'preview.expired': 'Esta vista previa del plan temporal ha caducado. Vuelva a abrirlo desde la tarjeta de revisión pendiente.', + 'chip.on.aria': 'Modo plan activado, presione para apagar', + 'chip.on.title': 'Modo de planificación activado: haga clic para desactivar (/plan off)', + 'chip.exitFailed': 'No se pudo salir del modo de plan', +} satisfies Record + +/** French dictionary, checked against the English keys. */ +export const fr = { + 'chip.label': 'Planifier', + 'preview.title': 'Planifier', + 'preview.document': 'Plan · Markdown', + 'preview.action': 'Ouvrir', + 'preview.open': 'Plan ouvert dans la barre latérale', + 'preview.full': 'Voir le forfait complet', + 'preview.openNamed': 'Plan ouvert : {title}', + 'preview.loading': 'Plan de chargement…', + 'preview.failed': 'Impossible de charger le plan', + 'preview.invalidAddress': 'Adresse du forfait invalide', + 'preview.historyUnavailable': "L'historique des sessions n'est pas disponible", + 'preview.notFound': "Ce plan n'a pas été trouvé", + 'preview.unavailable': "L'aperçu du plan n'est pas disponible", + 'preview.expired': 'Cet aperçu du plan temporaire a expiré. Rouvrez-le à partir de la carte de révision en attente.', + 'chip.on.aria': 'Mode Plan activé, appuyez pour désactiver', + 'chip.on.title': 'Mode Plan activé — cliquez pour désactiver (/plan off)', + 'chip.exitFailed': 'Échec de la sortie du mode Plan', +} satisfies Record + +/** German dictionary, checked against the English keys. */ +export const de = { + 'chip.label': 'Planen', + 'preview.title': 'Planen', + 'preview.document': 'Plan · Markdown', + 'preview.action': 'Öffnen', + 'preview.open': 'Plan in der Seitenleiste öffnen', + 'preview.full': 'Vollständigen Plan anzeigen', + 'preview.openNamed': 'Offener Grundriss: {title}', + 'preview.loading': 'Ladeplan…', + 'preview.failed': 'Plan konnte nicht geladen werden', + 'preview.invalidAddress': 'Ungültige Planadresse', + 'preview.historyUnavailable': 'Der Sitzungsverlauf ist nicht verfügbar', + 'preview.notFound': 'Dieser Plan wurde nicht gefunden', + 'preview.unavailable': 'Die Planvorschau ist nicht verfügbar', + 'preview.expired': 'Diese temporäre Planvorschau ist abgelaufen. Öffnen Sie es erneut über die ausstehende Überprüfungskarte.', + 'chip.on.aria': 'Planmodus ein, zum Ausschalten drücken', + 'chip.on.title': 'Planmodus ein – zum Ausschalten klicken (/plan off)', + 'chip.exitFailed': 'Der Planmodus konnte nicht beendet werden', +} satisfies Record + +/** Russian dictionary, checked against the English keys. */ +export const ru = { + 'chip.label': 'План', + 'preview.title': 'План', + 'preview.document': 'План · Markdown', + 'preview.action': 'Открыть', + 'preview.open': 'Открытая планировка в боковой панели', + 'preview.full': 'Посмотреть полный план', + 'preview.openNamed': 'Открытая планировка: {title}', + 'preview.loading': 'План загрузки…', + 'preview.failed': 'Не удалось загрузить план', + 'preview.invalidAddress': 'Неверный адрес плана', + 'preview.historyUnavailable': 'История сеансов недоступна', + 'preview.notFound': 'Этот план не найден', + 'preview.unavailable': 'Предварительный просмотр плана недоступен.', + 'preview.expired': 'Срок действия этой временной предварительной версии плана истек. Откройте его снова из карточки ожидающего рассмотрения.', + 'chip.on.aria': 'Режим планирования включен. Нажмите, чтобы выключить.', + 'chip.on.title': 'Режим планирования включен — нажмите, чтобы выключить (/plan off)', + 'chip.exitFailed': 'Не удалось выйти из режима планирования.', +} satisfies Record + +/** Portuguese dictionary, checked against the English keys. */ +export const pt = { + 'chip.label': 'Plano', + 'preview.title': 'Plano', + 'preview.document': 'Plano · Markdown', + 'preview.action': 'Abrir', + 'preview.open': 'Abrir plano na barra lateral', + 'preview.full': 'Ver plano completo', + 'preview.openNamed': 'Plano aberto: {title}', + 'preview.loading': 'Carregando plano…', + 'preview.failed': 'Não foi possível carregar o plano', + 'preview.invalidAddress': 'Endereço do plano inválido', + 'preview.historyUnavailable': 'O histórico da sessão não está disponível', + 'preview.notFound': 'Este plano não foi encontrado', + 'preview.unavailable': 'A visualização do plano não está disponível', + 'preview.expired': 'Esta visualização temporária do plano expirou. Reabra-o no cartão de revisão pendente.', + 'chip.on.aria': 'Modo de planejamento ativado, pressione para desligar', + 'chip.on.title': 'Modo de planejamento ativado – clique para desligar (/plan off)', + 'chip.exitFailed': 'Falha ao sair do modo de plano', +} satisfies Record diff --git a/packages/client/ui-plan/src/client/plan-definition.ts b/packages/client/ui-plan/src/client/plan-definition.ts index 8c742f71fa..3bb675b3d9 100644 --- a/packages/client/ui-plan/src/client/plan-definition.ts +++ b/packages/client/ui-plan/src/client/plan-definition.ts @@ -1,5 +1,5 @@ /** Per-invocation plans use resolved Conversation locations for final Turn artifacts. */ -import type {} from '@deepseek-ai/dsh-tools/types' +import type {} from '@deepseek-ai/dsh-tool-executor-ptc/client' import type { ChatNode } from '@deepseek-ai/dsh-client-ui-chat/client' import type { ConversationNodeDefinition } from '@deepseek-ai/dsh-client-ui-conversation/client' import { submittedPlan, type SubmittedPlan } from './plan.ts' diff --git a/packages/client/ui-plan/src/client/plan.ts b/packages/client/ui-plan/src/client/plan.ts index d44b8f2dd9..6ec1937f5c 100644 --- a/packages/client/ui-plan/src/client/plan.ts +++ b/packages/client/ui-plan/src/client/plan.ts @@ -1,4 +1,5 @@ /** Plan text and resource identities derived from logged native or PTC calls. */ +import type {} from '@deepseek-ai/dsh-tool-executor-ptc/client' import type { ToolCallId } from '@deepseek-ai/dsh-llm/brand' import type { SessionId } from '@deepseek-ai/dsh-session/types' import type { SessionAddress } from '@deepseek-ai/dsh-api-session-controller/types' diff --git a/packages/client/ui-plan/tsconfig.json b/packages/client/ui-plan/tsconfig.json index 5e7d0fb6df..13d4930dd1 100644 --- a/packages/client/ui-plan/tsconfig.json +++ b/packages/client/ui-plan/tsconfig.json @@ -60,10 +60,10 @@ "path": "../../llm/llm" }, { - "path": "../../core/tools" + "path": "../../util/crypto" }, { - "path": "../../util/crypto" + "path": "../../core/tool-executor-ptc" } ] } diff --git a/packages/client/ui-plugin-manager/README.i18n.yaml b/packages/client/ui-plugin-manager/README.i18n.yaml index 82b5d33648..1fe97166b2 100644 --- a/packages/client/ui-plugin-manager/README.i18n.yaml +++ b/packages/client/ui-plugin-manager/README.i18n.yaml @@ -15,8 +15,8 @@ en: d152484eb41ac6b4 zh: 09388d293f9be9cb /deepseek-ai-dsh-client-ui-plugin-manager/use-this-package: - en: 10209bb3e7a9c058 - zh: 241c029e078d6862 + en: 216dcea8d6903007 + zh: 160888842501694e /deepseek-ai-dsh-client-ui-plugin-manager/use-this-package/installing-a-bundle: en: fcb2e69c8b028c0a zh: d505a075679974eb @@ -24,8 +24,8 @@ en: 9312d8b1b381378f zh: 8d0ae7ad26ada2ac /deepseek-ai-dsh-client-ui-plugin-manager/use-this-package/switching-a-bundle: - en: 58a4ec22fa624531 - zh: f838445232446850 + en: 390dc85aceac033a + zh: 746ceae45f70dd33 /deepseek-ai-dsh-client-ui-plugin-manager/use-this-package/switching-one-row-of-a-bundle: en: d3627d4dba5e6cc2 zh: e0d0eada4c20331f @@ -36,20 +36,20 @@ en: 54d7666c630da471 zh: d7ded257d0fd5559 /deepseek-ai-dsh-client-ui-plugin-manager/use-this-package/page-tabs: - en: 1de4fe390871d0da - zh: 1723ae12ccbe3697 + en: fc469bab4fbd6028 + zh: 8f5a04e1b9a46471 /deepseek-ai-dsh-client-ui-plugin-manager/understand-the-implementation: en: 203168fc87e939bd zh: 72d0ccd2c54f3f44 /deepseek-ai-dsh-client-ui-plugin-manager/understand-the-implementation/registration: - en: de110cb51ae1e984 - zh: 8703ded36ae7a1f1 + en: 93871919adaf1082 + zh: 67c9ef0b500e6917 /deepseek-ai-dsh-client-ui-plugin-manager/understand-the-implementation/the-store: en: 21a0bbe8eb92229c zh: 096ff63db61992bb /deepseek-ai-dsh-client-ui-plugin-manager/understand-the-implementation/configuration-slots: - en: 954a30f20c8b3d39 - zh: e76919d88f19c89a + en: 2b34cac9647abe63 + zh: 1929fc24aee6793c /deepseek-ai-dsh-client-ui-plugin-manager/further-exploration: en: f9a42dfed0360d6a zh: 455a70bcb6c69624 diff --git a/packages/client/ui-plugin-manager/README.md b/packages/client/ui-plugin-manager/README.md index cd4e5878d2..bc7802a592 100644 --- a/packages/client/ui-plugin-manager/README.md +++ b/packages/client/ui-plugin-manager/README.md @@ -27,9 +27,11 @@ Use the **Plugins** entry in the Web sidebar to manage the profile's installed b ## Use this package -Select **Plugins** in the sidebar. The information button uses an 11px caption-colored icon in a 20px square hover area, with a 4px gap after the subtitle. It explains that this page configures official plugins and installs or manages other plugins, while **Settings → Built-in plugins** shows the built-in inventory and runtime status. Hover or focus shows the shared tooltip; clicking keeps it open until another click, Escape, Tab, or an outside click. The page reads the inventory and the bundles through `api-remotes` when first opened; a Host without a managed profile shows the page as unavailable. **Built-in plugin settings** lists official configuration pages by their configured order: Subagent, web search, agent loop, then shell. **Experimental plugins** shows the primary optional bundles, with **More** beside its heading opening a separate page of the same title. That page lists all offered official bundles, including those on the main list, with Codex and Claude Code subagents last. Its back button returns to the main list, where **Installed plugins** remains below Experimental plugins. Bundle details return to the list they were opened from. Catalog entries keep their position after installation or removal. **Installed plugins** lists other profile bundles. Installation-carried optional bundles have no uninstall action. Experimental packages retain the **Experimental** marker on both lists. Switching on a bundle that page holds back adds it to the main list in the position that page shows it, and switching it off leaves it on that page alone; other bundles keep their place on both. A dependency without a bundle patch is not a plugin and is not listed unless the profile selects it, in which case it carries a problem tag. Global configuration remains in the Settings **Built-in plugins** section. +[Ralph loops](../../experimental/ralph-bundle/README.md) and [Session search](../../experimental/session-search/README.md) remain bundled but are hidden from both plugin lists unless the profile enables or separately installs them. Existing selections remain manageable; switching off an installation-carried copy hides it again. Enter either exact package name in **Add plugin**, then choose **Enable now** to use the shipped copy without downloading it again. Sidebar session search is independent. -The heading moves its complete action group to another line when space is insufficient. Action labels stay on one line, and the information button follows the subtitle text. Hover styling applies only where the pointer can hover, so a tap leaves no card, row, or menu option in its hover state; cards and icon actions answer a touch-down with the pressed fill instead. The install, registry, and filter fields take a 16px face under a coarse pointer, which keeps iOS Safari from zooming a focused field. +Select **Plugins** in the sidebar. The information button uses an 11px caption-colored icon in a 20px square hover area, with a 4px gap after the subtitle. It explains that this page configures official plugins and installs or manages other plugins, while **Settings → Built-in plugins** shows the built-in inventory and runtime status. Hover or focus shows the shared tooltip; clicking keeps it open until another click, Escape, Tab, or an outside click. The page reads the inventory and the bundles through `api-remotes` when first opened; a Host without a managed profile shows the page as unavailable. **Built-in plugin settings** lists official configuration pages by their configured order: Subagent, web search, agent loop, then shell. **Experimental plugins** shows the primary optional bundles, with **More** beside its heading opening a separate page of the same title. That page lists the official bundles eligible for display, including those on the main list, with Codex and Claude Code subagents last. Its back button returns to the main list, where **Installed plugins** remains below Experimental plugins. Bundle details return to the list they were opened from. Catalog entries keep their position after installation or removal. **Installed plugins** lists other profile bundles. Installation-carried optional bundles have no uninstall action. Experimental packages retain the **Experimental** marker on both lists. Switching on a bundle that page holds back adds it to the main list in the position that page shows it, and switching it off leaves it on that page alone unless its manifest hides unselected bundles; other bundles keep their place on both. A dependency without a bundle patch is not a plugin and is not listed unless the profile selects it, in which case it carries a problem tag. Global configuration remains in the Settings **Built-in plugins** section. + +As available header width decreases, Refresh and Add plugin first become icon-only controls with accessible names and tooltips; the tabs then shrink from their usual minimum width to fit their labels, and the action group wraps only when necessary. The information button follows the subtitle text. The main page header and list share one scroll container. Header controls move with the scroll position until they reach their compact positions; scrolling back follows the same coordinates. The header retains its resting layout height, so moving its controls does not change the list's scroll range. A barely scrolling list can stop before the header is fully compact. The More list's back-and-title row stays fixed, and detail pages scroll their head row with the content. The Installed/Discover tabs use the compact 34px control with a 2px inset around the selection indicator. The subtitle scrolls out of view; with tabs present, the title also scrolls out, and the tabs settle beside Refresh and Add plugin where space permits. On narrow pages, the tabs and icon actions share a row beneath the heading where they fit; otherwise the tabs stay above the actions. Their relative order stays the same throughout scrolling. The resting and compact layouts retain their 28px and 16px page-top insets respectively, with 16px below the compact controls. macOS adds the frame's titlebar clearance while resting; its compact bar keeps 16px above and below with the sidebar expanded, and with the sidebar collapsed it also clears the frame's top strip so the controls stay below the native window controls. Only on macOS, when the sidebar is collapsed, the compact controls keep 8px rather than 16px above them in addition to the unchanged frame clearance, at every width. The resting layout and compact bottom spacing stay unchanged. Windows relies on the frame's caption offset without adding it again. The divider spans the page at the bottom of the compact header; the list's initial top spacing scrolls with its content. Hover styling applies only where the pointer can hover, so a tap leaves no card, row, or menu option in its hover state; cards and icon actions answer a touch-down with the pressed fill instead. The install, registry, and filter fields take a 16px face under a coarse pointer, which keeps iOS Safari from zooming a focused field. Installed bundles and their plugin rows display their own title and description in the current UI language on cards and detail pages. Each field falls back from exported locale `meta` to the accessible `package.json` of a package-root plugin; subpath plugins never read one; the final title is the full package or module name, with no package description when both sources omit it. See [Plugin display metadata](../../../docs/cookbook/adding-a-package.md#plugin-display-metadata) for the author format. Bundle cards, details, and component rows display the package root's manifest `icon` or exported `./icon`, or a subpath plugin's exported `/icon`; absent or undecodable images retain the default artwork. Installation previews still use registry or manifest information. @@ -72,7 +74,7 @@ After the checks, the list toolbar says how many plugins can be updated, that al ### Switching a bundle -A bundle's page shows its full package name under the title. Its **Source** section, after the rows, names the spec that installs it elsewhere, as `pnpm add` accepts it, or **Built in** for an official bundle the installation ships, then its current version. A bundle's switch changes its layer selection. A displayed detail document stays visible while a profile change triggers a fresh read; the result replaces it when ready. While the Host applies the change, the switch stays disabled and a spinner appears beside it; the action area permanently reserves 22px to the left of the switch. A persistent status region announces the pending change to screen readers. A profile with HMR recomposes before the operation completes; one without HMR, and a bundle a higher layer overrides, say so in a toast. A bundle the Host cannot read carries a problem tag and its reason on its page and cannot be switched on; one that provides the management components stays locked. The Host answers with error codes, which the page's dictionary words; pnpm's and the Loader's own diagnostics are shown as they are. The page excludes built-in profile bundles from cards even when the profile holds them as dependencies or the Host reports an error. The Host inventory remains complete; the Settings Plugins section's Plugin list tab inspects their plugins. +A bundle's page shows its full package name under the title. Its **Source** section, after the rows, names the spec that installs it elsewhere, as `pnpm add` accepts it, or **Built in** for an official bundle the installation ships, then its current version. A bundle's switch changes its layer selection. A displayed detail document stays visible while a profile change triggers a fresh read; the result replaces it when ready. While the Host applies the change, the switch stays disabled and a spinner appears beside it; the action area permanently reserves 22px to the left of the switch. A persistent status region announces the pending change to screen readers. A profile with HMR recomposes before the operation completes; one without HMR, and a bundle a higher layer overrides, say so in a toast. A bundle the Host cannot read carries a problem tag and its reason on its page and cannot be switched on; one that provides the management components stays locked. The Host answers with error codes, which the page's dictionary words; pnpm's and the Loader's own diagnostics are shown as they are. The page excludes built-in profile bundles and the persistent-terminal bundle from cards and detail pages, regardless of activation, dependency declarations, or Host errors. Hiding a bundle preserves its Host availability and activation. The Host inventory remains complete; the Settings Plugins section's Plugin list tab inspects their plugins. ### Switching one row of a bundle @@ -110,6 +112,8 @@ A row's page exists only while a `plugins.row.config` entry names the row, so a Another plugin adds a tab after the page's own **Installed** tab by registering into the `plugins.tab` list slot; the entry's `label` names the tab and `order` places it. The tabs appear under the page header once one entry exists and are hidden on a bundle's, row's, or official plugin's page; the selected tab survives leaving **Plugins**, and a tab whose entry leaves falls back to **Installed**. The page renders the selected entry in place of its groups and hides its own refresh and add buttons there. The owner props carry `install({ spec, registry?, title, version })` and `openPackage(name)`. `install` is for a version the person already confirmed in the tab: the install dialog opens at its check screen, without the spec form, names the plugin with `title` and `version` above the spec, which it shows in smaller monospace text, keeps that name on the progress and result screens, with the installed version once done, asks `registry` first for that install only and never remembers it, and offers **Retry** after a refusal; a hidden installation reopens instead. `openPackage` switches to **Installed** and opens the bundle's page when that tab lists it, and that page's back button returns to the tab that opened it; a bundle the page leaves out, such as a built-in profile bundle, leaves the Installed list open. A confirmed install offers no GitHub mirror recovery, since that recovery reopens the spec form, and its back button cancels a running install. [`ui-plugin-market`](../ui-plugin-market/README.md) contributes the **Discover** tab here. +Access: the Plugins entry, page, refresh notice, Host change subscriptions, and `pluginNavigation` exist only while the current Peer may call `pluginManager/listBundles`; each later grant re-reads a list already loaded, and `pluginChanges` receives no updates while access is withdrawn. The owner always has access. + ----- @@ -128,7 +132,7 @@ The browser plugin registers the `plugins` sidebar entry and its `main` panel th The add control reuses `Button`, `Menu`, and `MenuItemButton`. Both installation actions call the same `openInstall` callback and use `InstallDialog` and `PluginManagerController`. The root-scoped list slot `plugins.add.actions` accepts contributed `MenuItemButton` rows after the install action; each contribution receives an `onDismiss()` callback to close the menu before starting its action. -Other Client plugins can inject `pluginNavigation` and call `openList()` to select the Plugins main list, including when a detail page is already open. This browsing action preserves the current Session and opens no installation dialog. Installation remains an explicit action on the Plugins page. +While the Plugins page has access, other Client plugins can read `pluginNavigation` and call `openList()` to select the Plugins main list, including when a detail page is already open. This browsing action preserves the current Session and opens no installation dialog. Installation remains an explicit action on the Plugins page. ### The store @@ -138,7 +142,7 @@ Other Client plugins can inject `pluginNavigation` and call `openList()` to sele Custom item pages use the Host entry id as their registration id; row pages use the bundle package and row id. The page owner supplies `form.state` and `form.mutate(operations, expectedRevision)` when the entry exposes editable Config fields. A custom page owns its draft and validation display, and may reuse `ConfigField` from ui-primitives. Bundle-wide pages can contain several entries and have no single form. -The page's `main` registration declares `plugins.item`, `plugins.bundle.config`, and `plugins.row.config` as its children, so the slots exist while the page does and a registrant's `ctx.slots.inject` waits for them. `configLedgerSource` projects the three ledgers into one observable — the official items in ledger order with their labels resolved in the active locale, and the bundle and row keys — cached until a ledger or the locale moves; the page binds it as `useConfigLedger` beside the store and never names a configurable plugin itself. The registration-owned navigation store selects the main list, the full experimental plugin list, a bundle, an official plugin, or a bundle row; switching away from the Plugins panel resets it to the list, while React remounts preserve the selected target. A fresh registration starts on the main list. Installation highlighting scrolls to a card only when it is on the current page; it does not change the selected page. Other Client plugins inject `pluginNavigation` and call `ctx.pluginNavigation.openBundle(packageName)` to open bundle details without changing the current Session. The target survives the initial inventory load; an absent bundle displays the list. A registration lives with the browser half that made it. `dsh-client-modules` attaches a package's browser half to the Loader row whose specifier is the bare package name, so every page a bundle registers, for itself or for any of its rows, goes away when that row is switched off; a sub-plugin whose page must outlive the other rows ships as its own package. Cards and detail pages show the Experimental marker only for packages in `EXPERIMENTAL_BUNDLES` in [presentation.ts](src/client/presentation.ts); package-name prefixes do not select the marker. +The page's `main` registration declares `plugins.item`, `plugins.bundle.config`, and `plugins.row.config` as its children, so the slots exist while the page does and a registrant's `ctx.slots.inject` waits for them. `configLedgerSource` projects the three ledgers into one observable — the official items in ledger order with their labels resolved in the active locale, and the bundle and row keys — cached until a ledger or the locale moves; the page binds it as `useConfigLedger` beside the store and never names a configurable plugin itself. The registration-owned navigation store selects the main list, the full experimental plugin list, a bundle, an official plugin, or a bundle row; switching away from the Plugins panel resets it to the list, while React remounts preserve the selected target. A fresh registration starts on the main list. Installation highlighting scrolls to a card only when it is on the current page; it does not change the selected page. Other Client plugins read `pluginNavigation` optionally, through `ctx.get` or `ctx.inject`, and call its `openBundle(packageName)` to open bundle details without changing the current Session; listing it in a plugin's `inject` keeps that plugin pending on a page without access. The target survives the initial inventory load; an absent bundle displays the list. A registration lives with the browser half that made it. `dsh-client-modules` attaches a package's browser half to the Loader row whose specifier is the bare package name, so every page a bundle registers, for itself or for any of its rows, goes away when that row is switched off; a sub-plugin whose page must outlive the other rows ships as its own package. Cards and detail pages show the Experimental marker only for packages in `EXPERIMENTAL_BUNDLES` in [`dsh-official-bundles`](../../util/official-bundles/README.md#plugin-manager-lists); package-name prefixes do not select the marker. `plugins.bundle.config` supplies bundle detail configuration, keyed by npm package name. Client plugins can subscribe to the read-only `ctx.pluginChanges.bundle(packageName)` source to pause their queries while a bundle or one of its rows is being changed. Notifications precede the management request and continue when the page closes; subscribers release their observation on disposal. `plugins.bundle.activation` offers bundle-owned guidance after explicit enablement from the list, with callbacks to dismiss it or open the bundle details. It does not appear merely because an enabled bundle was listed. diff --git a/packages/client/ui-plugin-manager/README.zh.md b/packages/client/ui-plugin-manager/README.zh.md index b89673c447..5be88a24b2 100644 --- a/packages/client/ui-plugin-manager/README.zh.md +++ b/packages/client/ui-plugin-manager/README.zh.md @@ -27,9 +27,11 @@ kind: "package-reference" ## 使用本包 -在侧栏选择**插件**。信息按钮使用 11px 的 caption 色图标和 20px 方形悬停区域,与副标题间隔 4px。它说明当前页用于配置官方插件、安装和管理其他插件,**设置 → 内置插件**则展示内置插件列表及运行状态。悬停或聚焦显示共享 tooltip;点击后保持展开,直至再次点击、Escape、Tab 或外部点击。页面首次打开时通过 `api-remotes` 读取清单与组合包;没有受管 profile 的 Host 上页面显示为不可用。**内置插件配置**按配置的顺序列出官方配置页:子智能体、网页搜索、Agent 循环、终端。**实验性插件**展示主要的可选组合包,标题旁的**更多**打开同名的独立页面。该页列出全部官方组合包,也包含主列表已有的条目,Codex 和 Claude Code 子智能体排在最后。返回按钮回到主列表,**已安装插件**保持在实验性插件下方。组合包详情返回打开它的列表。目录条目在安装或移除后保持原位置。**已安装插件**列出其他 profile 组合包。安装随附的可选组合包没有卸载操作。实验性包在两个列表中均保留**实验性**标签。开启仅在该页列出的组合包会按该页中的位置把它加入主列表,关闭后它只留在该页;其他组合包在两个列表中均保持原位。没有组合包 patch 的依赖不是插件,除非 profile 选中了它才会带异常标签列出。全局配置仍在设置的**内置插件**分区中编辑。 +[Ralph 循环](../../experimental/ralph-bundle/README.zh.md)和[会话搜索](../../experimental/session-search/README.zh.md)仍随包提供,但默认不在两个插件列表中展示,只有 profile 启用或另行安装后才显示。已有的启用状态仍可管理;关闭随安装提供的版本后,它会再次隐藏。在**添加插件**中输入准确包名,再选择**立即启用**,即可使用随包提供的版本,无需再次下载。侧栏会话搜索独立于此功能。 -空间不足时,页头将整个操作组移到下一行。操作文案保持单行,信息按钮紧随副标题文字。悬停样式只在可悬停的指针下生效,点按不会让卡片、行或菜单项停留在悬停态;卡片与图标操作改以按下填充回馈触摸。安装、注册表与筛选输入框在粗指针下使用 16px 字号,iOS Safari 不会因聚焦而放大页面。 +在侧栏选择**插件**。信息按钮使用 11px 的 caption 色图标和 20px 方形悬停区域,与副标题间隔 4px。它说明当前页用于配置官方插件、安装和管理其他插件,**设置 → 内置插件**则展示内置插件列表及运行状态。悬停或聚焦显示共享 tooltip;点击后保持展开,直至再次点击、Escape、Tab 或外部点击。页面首次打开时通过 `api-remotes` 读取清单与组合包;没有受管 profile 的 Host 上页面显示为不可用。**内置插件配置**按配置的顺序列出官方配置页:子智能体、网页搜索、Agent 循环、终端。**实验性插件**展示主要的可选组合包,标题旁的**更多**打开同名的独立页面。该页列出符合展示条件的官方组合包,也包含主列表已有的条目,Codex 和 Claude Code 子智能体排在最后。返回按钮回到主列表,**已安装插件**保持在实验性插件下方。组合包详情返回打开它的列表。目录条目在安装或移除后保持原位置。**已安装插件**列出其他 profile 组合包。安装随附的可选组合包没有卸载操作。实验性包在两个列表中均保留**实验性**标签。开启仅在该页列出的组合包会按该页中的位置把它加入主列表,关闭后它只留在该页,但 manifest 声明默认隐藏的组合包会从该页消失;其他组合包在两个列表中均保持原位。没有组合包 patch 的依赖不是插件,除非 profile 选中了它才会带异常标签列出。全局配置仍在设置的**内置插件**分区中编辑。 + +页头可用宽度缩小时,“刷新”和“添加插件”先变为保留可访问名称与 tooltip 的图标按钮;标签再从常规最小宽度收紧为文案所需宽度,仍不足时操作组才换行。信息按钮紧随副标题文字。主页面的页头和列表共用一个滚动容器。页头控件随滚动位置移动,到达紧凑态位置后停住;反向滚动沿同样的坐标返回。页头保留静止态的布局高度,因此控件移动不会改变列表的滚动范围。只能滚动少量距离的列表可能在页头完全收拢前到达底部。“更多”列表的返回与标题行固定,详情页的页头随内容滚动。“已安装/发现”标签使用总高 34px 的紧凑控件,选中指示块与外框之间留 2px。副标题随滚动离开视口;有标签时标题也随之滚出,标签在空间足够时停靠到“刷新”“添加插件”旁边。窄页面中,标签与图标操作优先在标题下方共用一行;放不下时,标签始终在操作组上方。滚动过程中两组控件的相对顺序保持不变。静止态与紧凑态分别保留 28px 和 16px 的页面顶部留白,紧凑态控件下方保留 16px。macOS 静止态保留框架的标题栏安全区;折叠态在侧栏展开时上下各留 16px,侧栏收起时再避让框架顶部安全区,使控件位于原生窗口控件下方。仅在 macOS 侧栏收起时,紧凑态控件在原有框架安全区之外的顶部留白统一由 16px 减为 8px,不受窗口宽度影响;静止态布局与紧凑态底部留白均不变。Windows 使用框架已有的标题栏偏移,不重复叠加。分隔线在紧凑页头底部横跨页面,列表初始的顶部留白随内容滚动。悬停样式只在可悬停的指针下生效,点按不会让卡片、行或菜单项停留在悬停态;卡片与图标操作改以按下填充回馈触摸。安装、注册表与筛选输入框在粗指针下使用 16px 字号,iOS Safari 不会因聚焦而放大页面。 已安装的组合包及其插件行在卡片和详情页中,按当前界面语言显示各自的标题与描述。每个字段先读取导出的 locale `meta`,缺失时回退到包根插件可访问的 `package.json`,子路径插件从不读取;标题最终使用完整包名或模块名,两处都没有描述时不提供包描述。作者格式见[插件展示元信息](../../../docs/cookbook/adding-a-package.zh.md#plugin-display-metadata)。组合包卡片、详情和组件行显示包根的清单 `icon` 或导出的 `./icon`,或子路径插件导出的 `<子路径>/icon`;未声明或无法解码时保留默认插画。安装预览仍使用注册表或 manifest 信息。 @@ -72,7 +74,7 @@ Host 将网络失败或超时归因于 GitHub 地址,且提供 npmmirror 时 ### 切换一个组合包 -组合包页面在标题下方显示完整包名。组件列表之后的「来源信息」给出在别处安装它所需的 spec(`pnpm add` 接受的形式),安装随附的官方组合包显示「内置」,随后是当前版本。组合包开关改变其层选择。profile 变更触发重新读取时,已显示的详情文档保持可见,直到新结果返回。Host 应用更改期间,开关保持禁用,旁边显示旋转指示器;操作区在开关左侧固定预留 22px。常驻状态区域向屏幕阅读器播报进行中的更改。启用了 HMR 的 profile 在操作完成前重组;没有 HMR 的 profile,以及被更高层覆盖的组合包,会以 toast 说明。Host 读不了的组合包带异常标签,其页面给出原因,且不能打开;提供管理组件的组合包保持锁定。Host 以错误码作答,由页面字典措辞;pnpm 与 Loader 自己的诊断原样显示。页面从卡片中排除内置 profile 组合包,即使 profile 将它们列为依赖或 Host 报告了异常。Host 清单仍保留完整数据;设置中「插件」分区的「插件列表」标签页负责查看它们的插件。 +组合包页面在标题下方显示完整包名。组件列表之后的「来源信息」给出在别处安装它所需的 spec(`pnpm add` 接受的形式),安装随附的官方组合包显示「内置」,随后是当前版本。组合包开关改变其层选择。profile 变更触发重新读取时,已显示的详情文档保持可见,直到新结果返回。Host 应用更改期间,开关保持禁用,旁边显示旋转指示器;操作区在开关左侧固定预留 22px。常驻状态区域向屏幕阅读器播报进行中的更改。启用了 HMR 的 profile 在操作完成前重组;没有 HMR 的 profile,以及被更高层覆盖的组合包,会以 toast 说明。Host 读不了的组合包带异常标签,其页面给出原因,且不能打开;提供管理组件的组合包保持锁定。Host 以错误码作答,由页面字典措辞;pnpm 与 Loader 自己的诊断原样显示。页面从卡片和详情页中排除内置 profile 组合包和持久终端组合包,无论其启用状态、依赖声明或 Host 异常如何。隐藏组合包保留其在 Host 中的可用性和启用状态。Host 清单仍保留完整数据;设置中「插件」分区的「插件列表」标签页负责查看它们的插件。 ### 切换组合包里的一行 @@ -110,6 +112,8 @@ ctx.slots.inject('plugins.detail.section', () => ctx.slots.register({ 其他插件向 `plugins.tab` 列表 slot 注册条目,即可在页面自带的**已安装**标签页之后添加一个标签页;条目的 `label` 为标签页命名,`order` 决定其位置。至少存在一个条目时,标签页显示在页头下方;在组合包、行或官方插件的页面上隐藏。离开**插件**后,所选标签页保持不变;所选标签页的条目被移除时,回到**已安装**。页面渲染所选条目以代替自身的分组,并在此时隐藏自身的刷新和添加按钮。owner props 提供 `install({ spec, registry?, title, version })` 和 `openPackage(name)`。`install` 用于用户已在标签页中确认的版本:安装对话框在检查界面打开,不显示 spec 表单,在以较小等宽字显示的 spec 上方用 `title` 和 `version` 标明插件,并在进度和结果界面保持该名称,完成后显示已安装的版本;只在这一次安装中首先使用 `registry`,且从不记住它;被拒绝后提供**重试**;如果有隐藏中的安装任务,则改为重新打开该任务。`openPackage` 切换到**已安装**,并在该标签页列出该组合包时打开其页面,该页面的返回按钮回到打开它的标签页;页面不列出的组合包(例如内置 profile 组合包)只打开「已安装」列表。已确认的安装不提供 GitHub 镜像恢复,因为该恢复会重新打开 spec 表单;其返回按钮用于取消正在进行的安装。[`ui-plugin-market`](../ui-plugin-market/README.zh.md) 在这里贡献**发现**标签页。 +访问控制:插件入口、页面、刷新提示、Host 变更订阅与 `pluginNavigation` 只在当前 Peer 可以调用 `pluginManager/listBundles` 时存在;之后每次授权都会重读已加载过的列表,撤权期间 `pluginChanges` 不更新。owner 始终有权限。 + ----- @@ -128,7 +132,7 @@ Host 入口通过生成的 Remote 接口暴露 `pluginRegistryProbe.fastest()` 添加控件复用 `Button`、`Menu` 和 `MenuItemButton`。两个安装入口调用同一个 `openInstall` 回调,使用 `InstallDialog` 与 `PluginManagerController`。root 作用域的 list slot `plugins.add.actions` 接受贡献的 `MenuItemButton` 行,放在安装项之后;每个贡献者接收 `onDismiss()` 回调,在开始动作前关闭菜单。 -其他 Client 插件可注入 `pluginNavigation` 并调用 `openList()`,选中插件主列表,已打开详情页时也如此。此浏览操作保留当前 Session,不打开安装对话框。安装仍需在插件页显式发起。 +插件页有权限时,其他 Client 插件可读取 `pluginNavigation` 并调用 `openList()`,选中插件主列表,已打开详情页时也如此。此浏览操作保留当前 Session,不打开安装对话框。安装仍需在插件页显式发起。 ### store @@ -138,7 +142,7 @@ Host 入口通过生成的 Remote 接口暴露 `pluginRegistryProbe.fastest()` 自定义条目页以 Host 条目 id 作为注册 id;行页面使用 bundle 包名和行 id。当条目提供可编辑 Config 字段时,页面宿主传入 `form.state` 和 `form.mutate(operations, expectedRevision)`。自定义页面负责草稿和校验提示,并可复用 ui-primitives 的 `ConfigField`。整个 bundle 的页面可以包含多个条目,因此没有单一表单。 -页面的 `main` 注册把 `plugins.item`、`plugins.bundle.config` 与 `plugins.row.config` 声明为子 slot,因此它们与页面同生,注册方的 `ctx.slots.inject` 会等到它们出现。`configLedgerSource` 把三份账本投影成一个可观察对象——按账本顺序排列、标签按当前语言解析的官方条目,以及组合包与行的键——在账本或语言变化前保持缓存;页面把它作为 `useConfigLedger` 绑在 store 旁边,自身从不点名任何可配置插件。注册拥有的导航 store 选择主列表、完整的实验性插件列表、某个组合包、某个官方插件或组合包的某一行;切换离开插件面板时重置为列表,React 重新挂载则保留所选目标。新的注册从主列表开始。安装后的高亮仅在当前页面有对应卡片时滚动定位,不改变所选页面。其他 Client 插件注入 `pluginNavigation`,调用 `ctx.pluginNavigation.openBundle(packageName)` 即可打开组合包详情,不改变当前 Session。首次读取清单期间保留导航目标;组合包不存在时显示列表。注册与做出它的浏览器半侧同生共死。`dsh-client-modules` 只把一个包的浏览器半侧挂在说明符恰为包名的那一行 Loader 行上,所以组合包为自己或任一行注册的页面,都会在那一行被关闭时一起消失;需要在其他行关闭时仍保留页面的子插件,应作为独立的包发布。 卡片和详情页仅为 [presentation.ts](src/client/presentation.ts) 中 `EXPERIMENTAL_BUNDLES` 名单内的包显示实验性标签,包名前缀不决定标签。 +页面的 `main` 注册把 `plugins.item`、`plugins.bundle.config` 与 `plugins.row.config` 声明为子 slot,因此它们与页面同生,注册方的 `ctx.slots.inject` 会等到它们出现。`configLedgerSource` 把三份账本投影成一个可观察对象——按账本顺序排列、标签按当前语言解析的官方条目,以及组合包与行的键——在账本或语言变化前保持缓存;页面把它作为 `useConfigLedger` 绑在 store 旁边,自身从不点名任何可配置插件。注册拥有的导航 store 选择主列表、完整的实验性插件列表、某个组合包、某个官方插件或组合包的某一行;切换离开插件面板时重置为列表,React 重新挂载则保留所选目标。新的注册从主列表开始。安装后的高亮仅在当前页面有对应卡片时滚动定位,不改变所选页面。其他 Client 插件经 `ctx.get` 或 `ctx.inject` 以可选方式读取 `pluginNavigation`,调用其 `openBundle(packageName)` 即可打开组合包详情,不改变当前 Session;把它写进插件的 `inject` 会让该插件在无权限的页面上一直等待。首次读取清单期间保留导航目标;组合包不存在时显示列表。注册与做出它的浏览器半侧同生共死。`dsh-client-modules` 只把一个包的浏览器半侧挂在说明符恰为包名的那一行 Loader 行上,所以组合包为自己或任一行注册的页面,都会在那一行被关闭时一起消失;需要在其他行关闭时仍保留页面的子插件,应作为独立的包发布。 卡片和详情页仅为 [`dsh-official-bundles`](../../util/official-bundles/README.zh.md#plugin-manager-lists) 中 `EXPERIMENTAL_BUNDLES` 名单内的包显示实验性标签,包名前缀不决定标签。 `plugins.bundle.config` 以 npm 包名为 key,提供 Bundle 详情配置。Client 插件可订阅只读的 `ctx.pluginChanges.bundle(packageName)`,在组合包或其组件行变更期间暂停查询。通知先于管理请求发出,页面关闭后仍会更新;订阅方在卸载时释放观察。`plugins.bundle.activation` 在用户从列表显式启用后提供 Bundle 自有引导,并传入关闭引导和打开详情的回调。仅列出已启用的 Bundle 不会触发引导。 diff --git a/packages/client/ui-plugin-manager/locale/de.json b/packages/client/ui-plugin-manager/locale/de.json new file mode 100644 index 0000000000..16a03e8049 --- /dev/null +++ b/packages/client/ui-plugin-manager/locale/de.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "Plug-in-Verwaltungsschnittstelle", + "description": "Plug-in-Pakete anzeigen und installieren sowie Vorgänge aktivieren, deaktivieren und wiederholen." + } +} diff --git a/packages/client/ui-plugin-manager/locale/es.json b/packages/client/ui-plugin-manager/locale/es.json new file mode 100644 index 0000000000..3ca20e033d --- /dev/null +++ b/packages/client/ui-plugin-manager/locale/es.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "Interfaz de gestión de complementos", + "description": "Vea e instale paquetes de complementos y habilite, deshabilite y reintente operaciones." + } +} diff --git a/packages/client/ui-plugin-manager/locale/fr.json b/packages/client/ui-plugin-manager/locale/fr.json new file mode 100644 index 0000000000..48bf4551cd --- /dev/null +++ b/packages/client/ui-plugin-manager/locale/fr.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "Interface de gestion des plug-ins", + "description": "Affichez et installez les packages de plug-ins, puis activez, désactivez et réessayez les opérations." + } +} diff --git a/packages/client/ui-plugin-manager/locale/ja.json b/packages/client/ui-plugin-manager/locale/ja.json new file mode 100644 index 0000000000..dea565954e --- /dev/null +++ b/packages/client/ui-plugin-manager/locale/ja.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "プラグイン管理 UI", + "description": "プラグインパッケージの確認、インストール、有効化、無効化、再試行を行います。" + } +} diff --git a/packages/client/ui-plugin-manager/locale/pt.json b/packages/client/ui-plugin-manager/locale/pt.json new file mode 100644 index 0000000000..f1e246b3a2 --- /dev/null +++ b/packages/client/ui-plugin-manager/locale/pt.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "Interface de gerenciamento de plug-ins", + "description": "Visualize e instale pacotes de plug-ins e ative, desative e repita as operações." + } +} diff --git a/packages/client/ui-plugin-manager/locale/ru.json b/packages/client/ui-plugin-manager/locale/ru.json new file mode 100644 index 0000000000..155d905c69 --- /dev/null +++ b/packages/client/ui-plugin-manager/locale/ru.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "Интерфейс управления плагинами", + "description": "Просмотрите и установите пакеты подключаемых модулей, а также включите, отключите и повторите операции." + } +} diff --git a/packages/client/ui-plugin-manager/package.json b/packages/client/ui-plugin-manager/package.json index 1cf5abf9b8..068208b79d 100644 --- a/packages/client/ui-plugin-manager/package.json +++ b/packages/client/ui-plugin-manager/package.json @@ -55,6 +55,7 @@ }, "devDependencies": { "@deepseek-ai/cordis": "workspace:~", + "@deepseek-ai/dsh-api-remote-access": "workspace:*", "@deepseek-ai/dsh-api-remotes": "workspace:*", "@deepseek-ai/dsh-client-locale": "workspace:*", "@deepseek-ai/dsh-client-store": "workspace:*", @@ -64,6 +65,7 @@ "@deepseek-ai/dsh-client-ui-renderer": "workspace:*", "@deepseek-ai/dsh-client-ui-sidebar": "workspace:*", "@deepseek-ai/dsh-client-ui-slots": "workspace:*", + "@deepseek-ai/dsh-official-bundles": "workspace:*", "@deepseek-ai/dsh-package-manifest": "workspace:*", "@testing-library/react": "^16.3.3", "clsx": "^2.1.1", diff --git a/packages/client/ui-plugin-manager/src/client/PluginManagerPage.module.css b/packages/client/ui-plugin-manager/src/client/PluginManagerPage.module.css index 9c51e514d6..6e779d10df 100644 --- a/packages/client/ui-plugin-manager/src/client/PluginManagerPage.module.css +++ b/packages/client/ui-plugin-manager/src/client/PluginManagerPage.module.css @@ -1,4 +1,5 @@ -/* Plugin manager page: a centred column with its header, notices, and the package cards. +/* Plugin manager page: the main header and content share one scroller; the header retains its flow height. + The More list has a separate fixed head, and detail views scroll with their content. Hover affordances sit under @media (hover: hover): a tap latches :hover, which would strand a card, row, or control in its pointer-only state. Text fields take @@ -6,27 +7,144 @@ for a focused field under 16px and leaves the page pannable afterwards. */ .page { + --plugin-page-inset: clamp(24px, 4vw, 48px); + /* Every head row's top inset; the collapsed page bar narrows it. */ + --plugin-page-top-inset: 28px; --bundle-progress-size: 14px; --bundle-toggle-gap: 8px; + + display: flex; + flex-direction: column; + align-items: center; + box-sizing: border-box; + height: 100%; + /* Inline inset only: the 28px top inset belongs to the head row + (`.pageHead`, `.detailTop`, `.moreHead`). macOS window dragging is owned by a row's own box, + so an inset above that box leaves a top strip no drag region covers. */ + padding: 0 var(--plugin-page-inset); + color: var(--dsw-alias-label-primary); +} + +.page > * { + flex: none; + width: 100%; + max-width: 960px; +} + +.pageBody > .pageBar { + container: plugin-page-bar / inline-size; + align-self: flex-start; + position: relative; + display: flex; + flex-direction: column; + width: calc(100% + 2 * var(--plugin-page-inset) + var(--plugin-page-scrollbar-width, 0px)); + max-width: none; + margin-inline: calc(-1 * var(--plugin-page-inset)); + box-sizing: border-box; + padding-inline: var(--plugin-page-inset); +} + +.pageBar > .pageHead { + align-self: center; + width: 100%; + max-width: 960px; +} + +/* Spans the page's full width, so its scrollbar sits at the page edge, and re-centres the column inside. */ +.page > .pageBody { + flex: 1; + min-height: 0; + width: calc(100% + 2 * var(--plugin-page-inset)); + max-width: none; + margin-inline: calc(-1 * var(--plugin-page-inset)); display: flex; flex-direction: column; align-items: center; gap: 32px; box-sizing: border-box; - height: 100%; - overflow: auto; - /* Inline and bottom insets only: the 28px top inset belongs to the head row - (`.pageHead`, `.detailTop`). macOS window dragging is owned by a row's own box, - so an inset above that box leaves a top strip no drag region covers. */ - padding: 0 clamp(24px, 4vw, 48px) 48px; - color: var(--dsw-alias-label-primary); + padding: 0 var(--plugin-page-inset) 48px; + overflow-x: hidden; + overflow-y: auto; + position: relative; + scroll-padding-top: var(--plugin-header-scroll-padding, 0px); } -.page > * { +/* The initial list inset scrolls away; the scroll viewport meets the fixed bar directly. */ +.page > :not(.pageBody) + .pageBody { + padding-top: 32px; +} + +.pageBody > * { + flex: none; width: 100%; max-width: 960px; } +/* Endpoint copies never enter the scroll layout or the accessibility tree. */ +.pageBody > .pageBar[data-measuring] { + position: absolute; + top: 0; + left: 0; + margin: 0; + visibility: hidden; + pointer-events: none; +} + +.pageBody > .pageBar[data-natural] { + position: sticky; + top: 0; + z-index: 2; + pointer-events: none; +} + +.pageBar[data-natural] .pageHead { + position: relative; + height: var(--plugin-header-rest-height); + clip-path: inset(0 0 max(0px, calc(var(--plugin-header-rest-height) - var(--plugin-header-paint-height))) 0); + pointer-events: auto; +} + +.pageBar[data-natural] .pageHeading { + display: contents; +} + +.pageBar[data-natural] [data-plugin-header-part] { + position: absolute; + left: var(--plugin-part-x); + top: var(--plugin-part-y); + width: var(--plugin-part-width); + margin: 0; + pointer-events: auto; +} + +.pageBar[data-natural] .pageTabs { + z-index: 2; + pointer-events: none; +} + +.pageBar[data-natural] .pageTabList { + pointer-events: auto; +} + +.pageBar[data-natural]::before { + content: ''; + position: absolute; + inset: 0 0 auto; + height: var(--plugin-header-paint-height, var(--plugin-header-rest-height)); + background: var(--dsw-alias-bg-base); + pointer-events: none; +} + +.pageBar[data-natural][data-compact]::after { + content: ''; + position: absolute; + inset-inline: 0; + top: 0; + height: var(--plugin-header-compact-height); + box-sizing: border-box; + border-bottom: 0.5px solid var(--dsw-alias-border-l3); +} + .pageHead { display: flex; flex-wrap: wrap; @@ -36,7 +154,7 @@ box-sizing: border-box; /* The page's former top inset: the row starts at the window's top edge, so the whole strip above the page content drags the window. */ - padding-top: 28px; + padding-top: var(--plugin-page-top-inset); } /* macOS hiddenInset titlebar: the frame's top clearance keeps the head below @@ -44,12 +162,13 @@ owns the window drag over its own box — clearance included — so this page's top strip drags without a frame band. */ :global([data-platform='darwin']) .pageHead { - padding-top: calc(28px + var(--dsh-frame-top-clearance, 0px)); + padding-top: calc(var(--plugin-page-top-inset) + var(--dsh-frame-top-clearance, 0px)); } .pageHeading { min-width: 0; max-width: 100%; + order: 1; } .pageTitle { @@ -86,6 +205,7 @@ gap: 8px 16px; min-width: 0; white-space: nowrap; + order: 2; } .addMenu { @@ -1263,9 +1383,9 @@ label.registryOption { .detailTop, .moreHead { display: flex; - /* The same 28px top inset `.pageHead` carries, so the row starts at the window's + /* The same top inset `.pageHead` carries, so the row starts at the window's top edge and the strip above the detail content drags the window. */ - padding-top: 28px; + padding-top: var(--plugin-page-top-inset); } .detailTop { @@ -1274,7 +1394,7 @@ label.registryOption { :global([data-platform='darwin']) .detailTop, :global([data-platform='darwin']) .moreHead { - padding-top: calc(28px + var(--dsh-frame-top-clearance, 0px)); + padding-top: calc(var(--plugin-page-top-inset) + var(--dsh-frame-top-clearance, 0px)); } .crumb { @@ -1565,11 +1685,64 @@ label.registryOption { display: none; } -/* Installed and contributed tabs under the page header, aligned with the title; the negative margin halves - the page's 32px row gap, so the tabs read as part of the header. */ +/* Installed and contributed tabs under the page header at rest; the scrolled bar + pulls them onto the actions' row. */ .pageTabs { display: flex; - margin-top: -16px; + order: 3; + flex: 1 1 100%; +} + +/* Keep spacing inside the compact bar, above its divider rather than outside the scroll viewport. */ +.pageBar[data-compact]:not([data-natural]) { + --plugin-page-top-inset: 16px; + + padding-bottom: 16px; +} + +/* With the sidebar open, native window controls sit in the sidebar rather than above the page. */ +:global([data-platform='darwin']) .pageBar[data-compact]:not([data-natural]) .pageHead { + padding-top: var(--plugin-page-top-inset); +} + +/* Collapsing the sidebar moves those controls above the page; the compact bar then clears them. */ +:global([data-platform='darwin']) :global([data-sidebar-collapsed]) .pageBar[data-compact]:not([data-natural]) .pageHead { + --plugin-page-top-inset: 8px; + + padding-top: calc(var(--plugin-page-top-inset) + var(--dsh-frame-top-clearance, 0px)); +} + +/* The collapsed bar's divider spans the page like the scrolled list and its scrollbar. */ +.pageBar[data-compact]:not([data-natural])::after { + content: ''; + position: absolute; + inset-inline: 0; + bottom: 0; + border-bottom: 0.5px solid var(--dsw-alias-border-l3); + pointer-events: none; +} + +.pageBar[data-compact]:not([data-natural]) .pageHead { + align-items: center; +} + +.pageBar[data-compact]:not([data-natural]) .pageIntro { + display: none; +} + +/* Visually hidden rather than removed, so the page keeps its only heading for assistive technology. */ +.pageBarWithTabs[data-compact]:not([data-natural]) .pageHeading { + position: absolute; + width: 1px; + height: 1px; + overflow: hidden; + clip: rect(0 0 0 0); + white-space: nowrap; +} + +.pageBarWithTabs[data-compact]:not([data-natural]) .pageTabs { + order: 1; + flex: 0 0 auto; } .pageTabList { @@ -1577,6 +1750,56 @@ label.registryOption { min-width: 240px; } +@container plugin-page-bar (width < 600px) { + .pageBarWithTabs .pageHead { + align-items: center; + } + + .pageBarWithTabs .pageHeading { + flex-basis: 100%; + } + + .pageBarWithTabs .pageTabs { + order: 2; + flex: 0 0 auto; + } + + .pageBarWithTabs .toolbar { + order: 3; + } + + .toolbarLabel { + display: none; + } + + .pageHead .addButton, + .pageHead .addPrimary { + width: 32px; + min-width: 32px; + padding-inline: 0; + } + + /* The icon actions keep their width; a wrapped update note stays within the head. */ + .toolbar { + flex-shrink: 0; + max-width: 100%; + } + + .updatesNote { + order: 3; + flex-basis: 100%; + min-width: 0; + white-space: normal; + } +} + +/* Keep the usual tab width until the icon actions and their gaps need its space. */ +@container plugin-page-bar (width < 372px) { + .pageTabList { + min-width: 0; + } +} + /* The Installed groups keep the page's row gap inside their tab panel. */ .installedPanel { display: flex; diff --git a/packages/client/ui-plugin-manager/src/client/PluginManagerPage.tsx b/packages/client/ui-plugin-manager/src/client/PluginManagerPage.tsx index fd0c4e3232..50c0ede3d5 100644 --- a/packages/client/ui-plugin-manager/src/client/PluginManagerPage.tsx +++ b/packages/client/ui-plugin-manager/src/client/PluginManagerPage.tsx @@ -21,7 +21,9 @@ import { type IconProps, type StateDotState, type TerminalBlockLabels, } from '@deepseek-ai/dsh-client-ui-primitives' import type { InjectFace, PropsLocale, PropsRenderSlots, PropsRuntime, PropsStore } from '@deepseek-ai/dsh-client-ui-slots' +import { EXTENSION_BUNDLES, HIDDEN_BUNDLES, MORE_BUNDLES } from '@deepseek-ai/dsh-official-bundles' import type { createNavigationStore } from './navigation-store.ts' +import type { PluginHeaderLayoutFace } from './header-scroll.ts' import { rowConfigKey, type OfficialItem } from './config-ledger.ts' import { INSTALL_GIT_EXAMPLE, INSTALL_PATH_EXAMPLE, type PluginManagerLocaleKey } from './locales.ts' import { @@ -45,7 +47,7 @@ export type PluginManagerPageProps = | 'plugins.add.actions' | 'plugins.tab' | 'plugins.detail.actions' | 'plugins.detail.badge' | 'plugins.detail.section' > - & InjectFace + & InjectFace & PropsStore> /** The page's slot renderer, narrowed to the configuration slots. */ @@ -65,10 +67,12 @@ function AddPluginMenu({ t, disabled, openInstall, renderSlot }: { const onDismiss = (): void => { setOpen(false) } return ( - + + + ) } - const heading = t(SCREEN_TITLE_KEYS[phase]) + const heading = t(phase === 'done' && install.subject?.bundled ? 'installBundledTitle' : SCREEN_TITLE_KEYS[phase]) const pending = isInstallPending(phase) const cancellable = phase === 'starting' || phase === 'running' || phase === 'unconfirmed' const stoppable = cancellable || phase === 'failed' || phase === 'unknown' @@ -1393,6 +1366,7 @@ function InstallDialog({ : }

{heading}

+ {phase === 'done' && subject?.bundled ?

{t('installBundledDescription')}

: null} {phase === 'failed' ?

{failureText(install.failure, t, install)}

: null} {attemptLine === null ? null :

{attemptLine}

} {uncertaintyText === null ? null :

{uncertaintyText}

} @@ -1437,10 +1411,12 @@ function InstallDialog({ ?

{t('installDoneApproved', { names: install.approvedBuilds.join(', ') })}

: null}
- + {subject?.bundled ? null : ( + + )} {uncertainty === 'result' ? : null} {pending ? ( @@ -1545,6 +1521,7 @@ export function PluginManagerPage(props: PluginManagerPageProps): ReactNode { useEffect(() => { if (tab !== selectedTab) setTab(tab) }, [tab, selectedTab, setTab]) const tabsId = useId() const [activation, setActivation] = useState(null) + const [headerRef] = useState(props.createHeaderRef) useEffect(() => { ensure() }, [ensure]) // Installation highlights visible cards without changing the user's current page. const { highlight, clearHighlight } = { highlight: state.highlight, clearHighlight: props.clearHighlight } @@ -1557,15 +1534,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)) @@ -1579,7 +1556,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 @@ -1639,64 +1616,6 @@ export function PluginManagerPage(props: PluginManagerPageProps): ReactNode { return (
- {showsMainList - ? ( -
-
-

{t('title')}

-
- {t('intro')} - - - -
-
- -
- ) - : null} - {showsMainList && tabs.length > 0 - ? ( -
- { setTab(next) }} - items={[ - { value: INSTALLED_TAB, label: t('installedTab'), id: `${tabsId}-${INSTALLED_TAB}`, panelId: `${tabsId}-${INSTALLED_TAB}-panel` }, - ...tabs.map(item => ({ value: item.id, label: item.label, id: `${tabsId}-${item.id}`, panelId: `${tabsId}-${item.id}-panel` })), - ]} - /> -
- ) - : null} - {!onInstalled - ? ( -
- {renderSlot('plugins.tab', { - install: props.installFrom, - openPackage: (name) => { setTab(INSTALLED_TAB); setView({ kind: 'package', name, returnTab: tab }) }, - }, { only: tab })} -
- ) - : null} {showsMoreList ? (
) : null} - {installedCards && !loaded ? ( -
- {state.status === 'loading' ? : null} - {state.status === 'unavailable' ? ( -

- {t('unavailable')} -

- ) : null} -
- ) : null} - {state.notice === null || noticeLine === null - ? null - : ( - } - holdMs={toastHoldMs(noticeLine)} - onDone={props.dismissNotice} - /> - )} - {!showsCards && state.status === 'error' && !refreshing - ? ( -
-

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

- -
- ) - : null} - {onInstalled && loaded && openPkg !== undefined && openRow !== undefined - ? ( - { setView({ kind: 'package', name: openPkg.name, ...pkgOrigin }) }} - /> - ) - : null} - {onInstalled && loaded && openPkg !== undefined && openRow === undefined - ? ( - row.entryId !== undefined && state.busy.includes(rowKey(row.entryId))} - configured={ledger.bundles.has(openPkg.name)} - configure={configure(openPkg)} - renderSlot={renderSlot} - inMore={backToMore} - detail={( - - )} - onBack={() => { - if (returnTab !== undefined) setTab(returnTab) - setView({ kind: backToMore ? 'more' : 'list' }) - }} - onSetEnabled={(enabled) => { props.setEnabled(openPkg.name, enabled) }} - onUninstall={() => { props.uninstall(openPkg.name) }} - onUpdate={() => { props.update(openPkg.name) }} - onSetRowEnabled={setRowEnabled} - versions={state.versions[openPkg.name]} - onEnsureVersions={props.ensureVersions} - onCheckVersions={() => { props.checkVersions(openPkg.name) }} - onUpdateVersion={(version) => { props.openUpdate(openPkg.name, version) }} - /> - ) - : null} - {onInstalled && loaded && openItem !== undefined - ? { setView({ kind: 'list' }) }} /> - : null} - {loaded && installedCards - ? basicCards.length === 0 && official.length === 0 && mine.length === 0 && state.status !== 'error' - ?

{t('empty')}

- : ( -
- {showsMoreList ? ( -
-
    {more.map(packageCard)}
-
- ) : ( - <> - {renderGroup('basic', t('basicTitle'), basicCards)} - {renderGroup('extensions', t('extensionsTitle'), extensions.map(packageCard))} - {renderGroup('bundles', t('bundlesTitle'), mine.map(packageCard))} - - )} - {/* A failed package read trails the groups it left incomplete: right under the groups on a - first-load failure, and after the kept cards when a refresh fails over stale data. */} - {state.status === 'error' && !refreshing - ? ( -
-

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

- +
+ {showsMainList + ? ( +
0 ? ` ${css.pageBarWithTabs}` : ''}`} + data-plugin-bar + ref={headerRef} + > +
+
+

{t('title')}

+
+ {t('intro')} + + +
- ) - : null} +
+ + {tabs.length > 0 + ? ( +
+ { setTab(next) }} + items={[ + { value: INSTALLED_TAB, label: t('installedTab'), id: `${tabsId}-${INSTALLED_TAB}`, panelId: `${tabsId}-${INSTALLED_TAB}-panel` }, + ...tabs.map(item => ({ value: item.id, label: item.label, id: `${tabsId}-${item.id}`, panelId: `${tabsId}-${item.id}-panel` })), + ]} + /> +
+ ) + : null} +
) - : null} - {/* Activation guidance belongs beside the Installed list that holds the enabled bundle's card. */} - {installedCards && activated !== undefined && !state.install.open - ? renderSlot('plugins.bundle.activation', { - packageName: activated.name, - onDismiss: () => { setActivation(null) }, - onOpenDetails: () => { - setActivation(null) - setView({ kind: 'package', name: activated.name, ...showsMoreList ? { from: 'more' } : {} }) - }, - }, { entryKey: activated.name }) : null} + : null} + + {!onInstalled + ? ( +
+ {renderSlot('plugins.tab', { + install: props.installFrom, + openPackage: (name) => { setTab(INSTALLED_TAB); setView({ kind: 'package', name, returnTab: tab }) }, + }, { only: tab })} +
+ ) + : null} + {installedCards && !loaded ? ( +
+ {state.status === 'loading' ? : null} + {state.status === 'unavailable' ? ( +

+ {t('unavailable')} +

+ ) : null} +
+ ) : null} + {state.notice === null || noticeLine === null + ? null + : ( + } + holdMs={toastHoldMs(noticeLine)} + onDone={props.dismissNotice} + /> + )} + {!showsCards && state.status === 'error' && !refreshing + ? ( +
+

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

+ +
+ ) + : null} + {onInstalled && loaded && openPkg !== undefined && openRow !== undefined + ? ( + { setView({ kind: 'package', name: openPkg.name, ...pkgOrigin }) }} + /> + ) + : null} + {onInstalled && loaded && openPkg !== undefined && openRow === undefined + ? ( + row.entryId !== undefined && state.busy.includes(rowKey(row.entryId))} + configured={ledger.bundles.has(openPkg.name)} + configure={configure(openPkg)} + renderSlot={renderSlot} + inMore={backToMore} + detail={( + + )} + onBack={() => { + if (returnTab !== undefined) setTab(returnTab) + setView({ kind: backToMore ? 'more' : 'list' }) + }} + onSetEnabled={(enabled) => { props.setEnabled(openPkg.name, enabled) }} + onUninstall={() => { props.uninstall(openPkg.name) }} + onUpdate={() => { props.update(openPkg.name) }} + onSetRowEnabled={setRowEnabled} + versions={state.versions[openPkg.name]} + onEnsureVersions={props.ensureVersions} + onCheckVersions={() => { props.checkVersions(openPkg.name) }} + onUpdateVersion={(version) => { props.openUpdate(openPkg.name, version) }} + /> + ) + : null} + {onInstalled && loaded && openItem !== undefined + ? { setView({ kind: 'list' }) }} /> + : null} + {loaded && installedCards + ? basicCards.length === 0 && official.length === 0 && mine.length === 0 && state.status !== 'error' + ?

{t('empty')}

+ : ( +
+ {showsMoreList ? ( +
+
    {more.map(packageCard)}
+
+ ) : ( + <> + {renderGroup('basic', t('basicTitle'), basicCards)} + {renderGroup('extensions', t('extensionsTitle'), extensions.map(packageCard))} + {renderGroup('bundles', t('bundlesTitle'), mine.map(packageCard))} + + )} + {/* A failed package read trails the groups it left incomplete: right under the groups on a + first-load failure, and after the kept cards when a refresh fails over stale data. */} + {state.status === 'error' && !refreshing + ? ( +
+

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

+ +
+ ) + : null} +
+ ) + : null} + {/* Activation guidance belongs beside the Installed list that holds the enabled bundle's card. */} + {installedCards && activated !== undefined && !state.install.open + ? renderSlot('plugins.bundle.activation', { + packageName: activated.name, + onDismiss: () => { setActivation(null) }, + onOpenDetails: () => { + setActivation(null) + setView({ kind: 'package', name: activated.name, ...showsMoreList ? { from: 'more' } : {} }) + }, + }, { entryKey: activated.name }) : null} +
{state.update === null ? null : ( (element: HTMLDivElement | null) => void +} + +interface Box { + x: number + y: number + width: number + height: number +} + +interface Part { + element: HTMLElement + from: Box + to: Box + distance: number + clipStart?: number + clipEnd?: number +} + +const PARTS = ['title', 'intro', 'toolbar', 'tabs'] as const + +/** + * Measure the existing resting and compact CSS layouts in an inert, out-of-flow copy. + * Their independent flex wrapping requires pixel measurements; interpolating only positioned + * contents leaves the real header's resting footprint and the scroll range unchanged. + * @returns A ref callback that disposes its element observers and scroll listener on detachment. + */ +export function createPluginHeaderRef(): (element: HTMLDivElement | null) => void { + let dispose: (() => void) | undefined + return (element) => { + dispose?.() + dispose = element === null ? undefined : attach(element) + } +} + +function attach(bar: HTMLDivElement): () => void { + const body = bar.parentElement as HTMLElement + const parts: Part[] = [] + let distance = 1 + let restHeight = 0 + let compactHeight = 0 + let disposed = false + + const scroll = (): void => { + const offset = Math.max(0, body.scrollTop) + bar.toggleAttribute('data-compact', offset >= distance) + bar.toggleAttribute('data-scrolled', offset > 0) + for (const part of parts) { + const progress = Math.min(1, offset / part.distance) + part.element.style.translate = `${(part.to.x - part.from.x) * progress}px ${(part.to.y - part.from.y) * progress}px` + part.element.style.width = `${part.from.width + (part.to.width - part.from.width) * progress}px` + if (part.clipStart !== undefined && part.clipEnd !== undefined) { + const clip = Math.max(0, Math.min(1, (offset - part.clipStart) / (part.clipEnd - part.clipStart))) + part.element.style.clipPath = `inset(${clip * 100}% 0 0)` + } else { + part.element.style.removeProperty('clip-path') + } + if (part.element.dataset.pluginHeaderPart === 'intro') { + part.element.inert = offset >= (part.clipEnd ?? part.distance) + } + } + bar.style.setProperty('--plugin-header-paint-height', `${Math.max(compactHeight, restHeight - offset)}px`) + } + + const measure = (): void => { + if (disposed) return + body.style.setProperty('--plugin-page-scrollbar-width', `${body.offsetWidth - body.clientWidth}px`) + const clone = bar.cloneNode(true) as HTMLDivElement + clone.removeAttribute('data-natural') + clone.removeAttribute('data-compact') + clone.removeAttribute('data-scrolled') + clone.setAttribute('data-measuring', '') + clone.setAttribute('aria-hidden', 'true') + clone.inert = true + clone.style.width = `${bar.getBoundingClientRect().width}px` + for (const element of clone.querySelectorAll('[id], [data-plugin-header-part]')) { + element.removeAttribute('id') + if (element.hasAttribute('data-plugin-header-part')) element.removeAttribute('style') + } + body.append(clone) + const snapshot = (): { height: number; boxes: Map } => { + const head = (clone.querySelector('header') as HTMLElement).getBoundingClientRect() + const boxes = new Map() + for (const name of PARTS) { + const element = clone.querySelector(`[data-plugin-header-part="${name}"]`) + if (element === null) continue + const box = element.getBoundingClientRect() + boxes.set(name, { x: box.x - head.x, y: box.y - head.y, width: box.width, height: box.height }) + } + return { height: clone.getBoundingClientRect().height, boxes } + } + let resting: ReturnType + let compact: ReturnType + try { + resting = snapshot() + clone.setAttribute('data-compact', '') + compact = snapshot() + } finally { + clone.remove() + } + restHeight = resting.height + compactHeight = compact.height + distance = Math.max(1, restHeight - compactHeight) + parts.length = 0 + const hasTabs = resting.boxes.has('tabs') + const compactTitle = compact.boxes.get('title') as Box + for (const name of PARTS) { + const from = resting.boxes.get(name) + if (from === undefined) continue + const element = bar.querySelector(`[data-plugin-header-part="${name}"]`) as HTMLElement + let to = compact.boxes.get(name) as Box + let clipStart: number | undefined + let clipEnd: number | undefined + if (name === 'intro' || (name === 'title' && hasTabs)) { + to = { ...from, y: -from.height } + if (name === 'intro' && !hasTabs) { + clipStart = Math.max(0, from.y - compactTitle.y - compactTitle.height) + clipEnd = clipStart + from.height + to.y = from.y - clipEnd + } + } + const travel = Math.max(1, Math.abs(to.y - from.y)) + distance = Math.max(distance, travel) + element.style.setProperty('--plugin-part-x', `${from.x}px`) + element.style.setProperty('--plugin-part-y', `${from.y}px`) + element.style.setProperty('--plugin-part-width', `${from.width}px`) + parts.push({ + element, from, to, distance: travel, + ...clipStart !== undefined && clipEnd !== undefined ? { clipStart, clipEnd } : {}, + }) + } + bar.style.setProperty('--plugin-header-rest-height', `${restHeight}px`) + bar.style.setProperty('--plugin-header-compact-height', `${compactHeight}px`) + bar.style.setProperty('--plugin-header-travel', `${distance}px`) + body.style.setProperty('--plugin-header-scroll-padding', `${compactHeight + 8}px`) + bar.setAttribute('data-natural', '') + scroll() + } + + measure() + const resize = new ResizeObserver(measure) + resize.observe(body) + const mutation = new MutationObserver(measure) + mutation.observe(bar.querySelector('header') as HTMLElement, { + childList: true, characterData: true, subtree: true, attributes: true, attributeFilter: ['hidden'], + }) + body.addEventListener('scroll', scroll, { passive: true }) + document.fonts.addEventListener('loadingdone', measure) + return () => { + disposed = true + resize.disconnect() + mutation.disconnect() + body.removeEventListener('scroll', scroll) + document.fonts.removeEventListener('loadingdone', measure) + body.style.removeProperty('--plugin-header-scroll-padding') + body.style.removeProperty('--plugin-page-scrollbar-width') + } +} diff --git a/packages/client/ui-plugin-manager/src/client/index.ts b/packages/client/ui-plugin-manager/src/client/index.ts index d310d9228a..f5908227a4 100644 --- a/packages/client/ui-plugin-manager/src/client/index.ts +++ b/packages/client/ui-plugin-manager/src/client/index.ts @@ -16,18 +16,21 @@ 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 // through the owning package's client-safe types subpath). import type {} from '@deepseek-ai/dsh-plugin-manager/types' import { PluginManagerPage } from './PluginManagerPage.tsx' +import { createPluginHeaderRef } from './header-scroll.ts' import { PluginRefreshToast, type PluginRefreshToastFace } from './PluginRefreshToast.tsx' 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, es, fr, de, ru, pt, zh, type PluginManagerLocaleKey } from './locales.ts' import { createNavigationStore } from './navigation-store.ts' import type {} from './slot-contract.ts' @@ -79,96 +82,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, es, fr, de, ru, pt }), 'ui-plugin-manager: dictionaries') const t = ctx.locale.bind(NS) const controller = new PluginManagerController(ctx) ctx.effect(() => () => { controller.dispose() }, 'ui-plugin-manager: controller') ctx.provide('pluginChanges', { bundle: packageName => controller.observeChanges(packageName) }) - // 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') - // 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, createHeaderRef: createPluginHeaderRef }), + 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 3f8a6b1dbd..a871ab3692 100644 --- a/packages/client/ui-plugin-manager/src/client/locales.ts +++ b/packages/client/ui-plugin-manager/src/client/locales.ts @@ -105,6 +105,8 @@ export const zh = { installProblemInvalid: '无法识别这个包名或地址:{reason}', installProblemInstalled: '该插件已安装。如需升级,请卸载后重新安装', installProblemShipped: '该插件随 DSH 提供,升级 DSH 即可获得新版本', + installBundledTitle: '插件已随 DSH 提供', + installBundledDescription: '无需下载,启用即可使用', installProblemNotFound: '未找到相关插件', installProblemNotPackage: '该路径不存在或不是有效的插件包', installProblemNotBundle: '这个包没有声明组合包,无法作为插件安装:{reason}', @@ -378,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', @@ -397,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}', @@ -585,3 +589,1749 @@ 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 + +/** Spanish dictionary, checked against the English keys. */ +export const es = { + panel: 'Complementos', + title: 'Complementos', + intro: 'Instalar, habilitar y configurar complementos', + infoLabel: 'Acerca de los complementos', + infoDescription: 'Configure complementos oficiales e instale o administre otros complementos aquí. Vea la lista de complementos integrados y el estado del tiempo de ejecución en Configuración → Complementos integrados.', + loading: 'Leyendo complementos…', + error: 'No se pudieron leer todos los complementos, posiblemente debido a un problema de red', + unavailable: 'Esta implementación se ejecuta sin un perfil manejable, por lo que los complementos no se pueden instalar ni cambiar aquí.', + retry: 'Reintentar', + refresh: 'Actualizar', + refreshError: 'Error al actualizar. Por favor inténtalo de nuevo.', + empty: 'Aún no hay complementos instalados.', + addPlugin: 'Agregar complemento', + chooseAddMethod: 'Elija cómo agregar un complemento', + installExisting: 'Instalar un complemento de terceros', + installExistingDescription: 'Admite paquetes npm, repositorios Git y directorios locales', + restartNotice: 'Reinicie DSH para aplicar el cambio. {reason}', + restartReasonNoHmr: 'Este perfil aplica cambios solo al inicio.', + restartReasonPackageMoved: 'Un paquete instalado ahora se resuelve en otro directorio o el cambio reemplaza un paquete que proporciona la instalación de DSH. Desinstale un paquete antes de instalarlo desde otra ubicación.', + restartReasonHostModule: 'El cambio llega a los módulos DSH propiamente cargados.', + restartReasonUnmanagedPlugin: 'El cambio afecta a un complemento que se ejecuta fuera de la lista de complementos.', + restartReasonModuleComparison: 'DSH no pudo comparar los complementos cargados con sus archivos.', + overriddenNotice: '{name} se guardó, pero una configuración de mayor prioridad lo anula, por lo que no está vigente', + bundlesTitle: 'Complementos instalados', + basicTitle: 'Configuración del complemento incorporado', + extensionsTitle: 'Complementos experimentales', + moreTitle: 'Complementos experimentales', + moreAction: 'Más', + tabsLabel: 'Vistas de complementos', + installedTab: 'Instalado', + statusProblem: 'problema', + statusBeta: 'Experimental', + reasonLabel: 'Razón', + metadataError: 'Error de metadatos del paquete: {error}', + versionTag: 'v{version}', + partsLabel: 'Componentes', + sourceTitle: 'Fuente', + sourceSpec: 'fuente del código', + sourceBuiltIn: 'Construido en', + sourceVersion: 'Versión actual', + partsEmpty: 'Este paquete de complementos no contiene componentes.', + partsCountTotal: '{count} total', + partsCountRunning: '{count} corriendo', + partsCountOff: '{count} apagado', + partOff: 'Apagado', + partsCountFailed: '{count} falló', + partsFilter: 'Componentes del filtro', + partsFilterEmpty: 'Ningún componente coincide.', + partToggle: 'Habilitar componente {name}', + rowPhasePending: 'Esperando dependencias', + rowPhaseLoading: 'Cargando', + rowPhaseActive: 'corriendo', + rowPhaseFailed: 'problema', + rowPhaseUnloading: 'Descarga', + enableToggle: 'Habilitar {name}', + applyingChange: 'Aplicar cambios a {name}', + openDetail: 'Ver {name}', + backToList: 'Volver a complementos', + crumbRoot: 'Complementos', + backToPackage: 'Volver a {name}', + configureRow: 'Configurar {name}', + rowStateIdle: 'no corriendo', + uninstall: 'Desinstalar', + uninstallLabel: 'Desinstalar {name}', + installTitle: 'Agregar complemento', + installDescription: 'Ingrese el nombre del paquete del complemento, la dirección del repositorio GitHub o la ruta del directorio local.', + installSpecLabel: 'Nombre o dirección del paquete', + installSpecPlaceholder: 'por ejemplo dsh-plugin-ballena-mascota', + installGuideToggle: 'Guía de instalación y ejemplos.', + installGuideHide: 'Ocultar la guía', + installGuideIdTitle: 'Ingrese el nombre del paquete npm del complemento', + installGuideIdExample: 'dsh-plugin-ballena-mascota', + installGuideIdHint: 'El nombre del paquete del complemento es el nombre del paquete npm (como dsh-xxx o @author/plugin): la parte después de dsh plugin add o pnpm add en el comando de instalación README de un complemento comunitario.', + installGuideExampleLabel: 'Ejemplo: ', + installGitTemplateHint: 'Reemplace esto con la dirección real del repositorio de Git.', + installPathTemplateHint: 'Reemplace esto con la ruta real a su directorio de complementos local.', + installGuideFill: 'ejemplo de uso', + installGuideFillAria: 'Utilice el ejemplo {example}', + installGuideSafety: 'Instale sólo complementos en los que confíe: se ejecutan con sus permisos y pueden dañar DeepSeek Harness o filtrar sus datos.', + installUpgradeNotice: 'Los complementos oficiales bajo demanda ofrecen actualización cuando su versión difiere de DSH. Para actualizar otros complementos, desinstálelos y vuelva a instalarlos.', + registryToggle: 'Registro', + registryLegend: 'El registro npm desde el que se descarga el complemento', + registryDefault: 'Registro predeterminado', + registryOfficial: 'Registro oficial de npm', + registryNpmmirror: 'Espejo de China continental', + registryCustom: 'Dirección personalizada', + registryCustomPlaceholder: 'https://npm.example.com/', + registryCustomHint: 'Ingrese una dirección de registro npm interna o privada que comience con http:// o https://.. Si requiere iniciar sesión, almacene las credenciales en ~/.npmrc en esta máquina.', + registryCustomInvalid: 'Introduzca una dirección que comience con http:// o https://', + registryListSeparator: ', ', + sentenceSeparator: ' ', + installRun: 'Instalar', + installChecking: 'Comprobando…', + installConfirmedTitle: 'Instalar complemento', + installConfirmedRefused: 'Este complemento no se puede instalar', + installProblemInvalid: 'Este no es un nombre o dirección de paquete que se pueda instalar: {reason}', + installProblemInstalled: 'Este complemento ya está instalado. Para actualizarlo, desinstálelo e instálelo nuevamente.', + installProblemShipped: 'Este complemento se envía con DSH; actualizar DSH lo actualiza', + installBundledTitle: 'Este complemento se envía con DSH', + installBundledDescription: 'Habilítelo sin descargar otra copia', + installProblemNotFound: 'No se encontró tal complemento', + installProblemNotPackage: 'La ruta no existe o no es un paquete de complemento válido', + installProblemNotBundle: 'Este paquete no declara ningún paquete, por lo que no se puede instalar como complemento: {reason}', + installProblemNetwork: 'No se pudo acceder al registro del complemento; comprueba la red y vuelve a intentarlo', + installProblemNetworkAll: 'No se pudo acceder a ningún registro (probado: {registries}); verifique la configuración de red o proxy, o cambie el registro', + installProblemUnknown: 'No se pudo buscar el complemento: {reason}', + installingTitle: 'Instalando el complemento…', + installedTitle: 'Instalado', + statusNotInstalled: 'No instalado', + statusMissingFiles: 'Paquete no disponible', + statusUpdateAvailable: 'Actualización disponible', + sourceNotInstalled: 'No instalado', + sourceTargetSpec: 'Fuente de este DSH', + sourceTargetVersion: 'Versión para este DSH', + update: 'Actualizar', + updateLabel: 'Actualización {name}', + versionsTitle: 'Versiones', + versionsRecheck: 'comprobar de nuevo', + versionsChecking: 'Buscando actualizaciones', + versionsAvailable: 'Actualización a {version} disponible', + versionsPublished: 'Publicado {date}', + versionsLatest: 'actualizado', + versionsCheckedAt: 'Última comprobación en {time}', + versionsBlocked: 'Las versiones más nuevas necesitan una actualización DSH', + versionsBlockedDetail: '{version} necesita {peers}; este DSH es {runtime}', + versionsNoneInstallable: 'No hay una versión más nueva para instalar directamente', + versionsNoneInstallableDetail: 'Las versiones más recientes están obsoletas, son versiones preliminares o no declaran ningún paquete. Ampliar para verlos', + versionsFailedNetwork: 'No se pudo acceder al registro', + versionsFailedOther: 'No se pudo buscar actualizaciones', + versionsDetails: 'Detalles', + versionsNewerCount: '{count} más nuevo', + versionsCollapse: 'Contraer', + versionsInstalledTag: 'Instalado', + versionsPrereleaseTag: 'Presentación', + versionsDeprecatedTag: 'En desuso', + versionsNeedsDsh: 'Necesita {peers}', + versionsNotBundle: 'Esta versión no declara ningún paquete y no se puede instalar.', + versionsUpdateLabel: 'Actualización a {version}', + versionsRegistry: 'Registro', + versionsCheckedAtLabel: 'Comprobado en', + versionsFallbackTag: 'Respaldo', + versionsFallbackHint: 'No se pudo alcanzar {registry}; la lista proviene de un registro alternativo', + versionsPrimaryTag: 'primero', + versionsShowPrerelease: 'Mostrar presentaciones preliminares', + versionsOmitted: '{count} no se enumeran más versiones más recientes que la instalada', + versionsProblemNetwork: 'Inalcanzable', + versionsProblemNotFound: 'No encontrado', + versionsProblemUnknown: 'Fallido', + updatesAvailable: 'Actualizaciones disponibles: {count}', + updatesNone: 'Todos los complementos están actualizados.', + updatesFailed: 'Algunos complementos no se pudieron verificar', + updateTitle: 'Actualización {name}', + updateSource: 'Instalar desde', + updateDescription: 'La actualización reemplaza {from} por {to}; el complemento puede detenerse brevemente. Si la instalación falla, {from} se conserva o se vuelve a instalar.', + updateRunning: 'Actualizando', + updateStepChecking: 'Comprobar {version}', + updateStepRemoving: 'Detener y eliminar {version}', + updateStepInstalling: 'Instale {version} desde {registry}', + updateStepRestoring: 'Instale {version} nuevamente', + updateDone: 'Actualizado a {version}', + updateDoneDetail: 'La nueva versión se está ejecutando.', + updateRestart: '{version} instalado; entra en vigor después de un reinicio', + updateRefused: 'No se puede actualizar a {version}', + updateRemoveFailed: 'No se pudo eliminar {version}', + updateRestored: 'La actualización falló; restaurado {version}', + updateRestoredDetail: '{to} no pudo instalarse; {from} se instaló nuevamente.', + updateKept: 'No se pudo actualizar a {version}', + updateKeptDetail: '{from} no se eliminó y permanece en uso. {reason}', + updateBroken: 'La actualización falló; el complemento no está instalado', + updateBrokenDetail: '{to} no se pudo instalar y la instalación de {from} nuevamente también falló. Instale {name} nuevamente desde Agregar complemento.', + updateLost: 'Se desconoce el resultado de la actualización.', + updateLostDetail: 'Se interrumpió la conexión con DSH: {reason}. Actualice la lista de complementos para ver la versión instalada.', + updateOutput: 'Mostrar resultado de instalación', + updateFinish: 'hecho', + installFailedTitle: 'No se pudo instalar el complemento', + installGithubFailedTitle: 'No se puede acceder a GitHub', + installGithubTimeoutTitle: 'Se agotó el tiempo de espera de la conexión GitHub', + installGithubFailedDescription: 'Pruebe con otra fuente de instalación.', + installUseGithubMirror: 'Utilice el espejo de China continental', + installTryAnotherWay: 'Prueba de otra manera', + installPackageLabel: 'Nombre del paquete del complemento', + installEdit: 'Editar', + installEditAria: 'Volver a editar', + installCancelAndEdit: 'Cancelar la instalación y volver a editar', + installApplyingCancellationError: 'La cancelación no fue confirmada. Se está aplicando la instalación; espera su resultado. {reason}', + installReconcile: 'Verificar el estado de la instalación', + installUnknownTitle: 'El resultado de la instalación no está disponible', + installUnknownDescription: 'El Host no tiene ninguna instalación activa con esta identificación de solicitud. Consulte la lista de complementos antes de volver a intentarlo.', + installResultUnconfirmed: 'No se recibió el resultado de la instalación. Verificar el estado de la instalación. {reason}', + installAwaitingAcceptance: 'Esperando que el Host acepte la instalación. La cancelación se volverá a intentar automáticamente después de la confirmación.', + installBackgroundUnknown: 'El resultado de la instalación no está disponible. Consulte la lista de complementos.', + installCancel: 'Cancelar instalación', + installCloseCancels: 'Cancelar instalación y cerrar', + installViewTask: 'Ver instalación', + installUnconfirmedTitle: 'Estado de instalación no confirmado', + installBackgroundDone: 'Instalación finalizada. Ver detalles de instalación.', + installBackgroundFailed: 'La instalación falló. Ver detalles de instalación.', + installBackgroundUnconfirmed: 'El estado de la instalación no está confirmado. Vea la instalación para más detalles.', + installBackgroundApplying: 'La instalación se está aplicando y no se puede cancelar. Ver el progreso de la instalación.', + installStarting: 'Preparando la instalación…', + installCancelling: 'Deteniendo la instalación…', + installApplying: 'Aplicando configuración, espere...', + installCancelledShort: 'Cancelado', + installCancelled: 'Instalación cancelada; el complemento no está habilitado y los archivos descargados pueden permanecer', + installCancelUnconfirmed: 'No se ha confirmado que la instalación se haya detenido. Vuelva a intentar la cancelación o espere el resultado de la instalación. {reason}', + installEnableNow: 'Habilitar ahora', + installDetailsShow: 'Mostrar detalles de instalación', + installDetailsHide: 'Ocultar detalles de instalación', + installVersion: 'Versión {version}', + installSubjectPath: 'directorio local', + installSubjectGit: 'repositorio git', + installSubjectTarball: 'bola de tar', + installLocation: 'Se instala en {dir}', + installRetry: 'Reintentar', + installChangeRegistry: 'Cambiar registro', + installAttempt: '{previous} no pudo entregar el paquete; reintentando a través de {registry} (registro {index} de {total})', + installAttemptBadge: 'Intento {index} · {registry}', + installFailureNetwork: 'La conexión de red falló', + installFailureNetworkAll: 'No se pudo acceder a ningún registro (probado: {registries}). Verifique la configuración de red o proxy, o cambie el registro y vuelva a intentarlo.', + installFailureNetworkHost: 'No se pudo contactar con {host}. Una dirección GitHub o un enlace .tgz no se recupera a través del registro: esta máquina debe llegar directamente o mediante un proxy. Si el complemento también está publicado en npm, ingrese el nombre del paquete.', + installFailureNotFound: 'No se encontró tal complemento', + installFailureNoMatchingVersion: 'Ninguna versión coincide con la solicitud', + installFailureDiskFull: 'El disco está lleno; la instalación se detuvo', + installFailurePermission: 'Sin permiso de escritura; el complemento no se puede instalar', + installFailureBuildBlocked: 'Los scripts de instalación de una dependencia necesitan su permiso antes de que la instalación pueda continuar', + installFailureBuildBlockedManual: 'pnpm bloqueó los scripts de instalación; permítalos en enableBuilds en pnpm-workspace.yaml y vuelva a intentarlo', + installFailureIntegrity: 'El paquete descargado no pasó la verificación de integridad', + installFailureTimeout: 'La instalación expiró', + installFailurePnpmMissing: 'No se encontró pnpm, por lo que no se puede instalar nada.', + installFailureGeneric: 'Algo salió mal durante la instalación; los detalles dicen lo que', + terminalCommandLine: 'Línea de comando {n}', + terminalRunning: 'corriendo', + terminalFailed: 'Fallido', + terminalDone: 'hecho', + terminalCopy: 'Copiar', + terminalCopied: 'Copiado', + terminalNoOutput: 'Sin salida', + terminalCollapseAria: 'Contraer salida', + terminalCollapse: 'Contraer', + terminalExpandAria: 'Expanda las líneas de salida {n} restantes', + terminalExpand: '… {n} más líneas', + terminalExitCode: 'código de salida {code}', + terminalSignal: 'señal {signal}', + terminalNoExitCode: 'sin código de salida', + installDoneNothing: 'Instalación finalizada sin nuevas dependencias.', + installDoneRestart: 'Instalado; reinicie DSH para cargarlo. {reason}', + installDoneApproved: 'Instalar scripts permitidos para {names}', + installDoneOtherVersion: 'Según la política de seguridad de la cadena de suministro de pnpm (minimumReleaseAge), las versiones publicadas más recientemente que la antigüedad requerida no se instalan automáticamente, por lo que esta instalación utilizó {installed} en lugar de la última versión {version}. Una versión recién publicada no ha completado este período de espera y puede contener códigos maliciosos o defectos no detectados. Si necesita {version}, desinstale la versión actual y luego instale {exact} para solicitar esa versión.', + installApprovalTitle: 'Instalar scripts necesita permiso', + installApprovalDescription: 'Estos paquetes tienen scripts de instalación que pnpm no ejecutó.', + installApprovalConsequence: 'Una vez permitidos, los scripts se ejecutan aquí con sus permisos y el permiso se guarda en este perfil.', + installApprovalCaution: 'Permita solo paquetes en los que confíe.', + installApproveAndRetry: 'Permita estos scripts y vuelva a intentarlo', + installClose: 'hecho', + close: 'Cerrar', + cancel: 'Cancelar', + confirmUninstallTitle: '¿Desinstalar "{name}"?', + confirmUninstallDescription: 'Lo que proporciona desaparece una vez que se desinstala.', + confirmUninstall: 'Desinstalar', + failedEnable: 'No se pudo habilitar: {reason}', + failedDisable: 'No se pudo desactivar: {reason}', + failedUninstall: 'No se pudo desinstalar: {reason}', + failedRowEnable: 'No se pudo habilitar el componente: {reason}', + failedRowDisable: 'No se pudo desactivar el componente: {reason}', + reasonManagementRequired: 'La gestión de complementos lo necesita; no se puede apagar ni desinstalar.', + reasonUnaddressable: 'El parche de perfil no puede abordar este de forma única.', + reasonUnknownPlugin: 'No existe tal complemento.', + reasonInvalidSpec: 'Ingrese un nombre o dirección de paquete válido.', + reasonAlreadyInstalled: 'Este paquete ya está instalado; desinstálelo antes de instalar otra versión.', + reasonAmbiguousInstall: 'No se puede saber qué paquete se instaló a partir del cambio de dependencia.', + reasonNotBundle: 'Este paquete no declara ningún paquete, por lo que no se puede administrar como un complemento.', + reasonNotRemovable: 'Este paquete no es propiedad del perfil o la administración del complemento lo necesita.', + reasonStopProfile: 'Este perfil se ejecuta sin HMR; deténgalo y desinstale el paquete con el complemento dsh.', + reasonBundleInUse: 'Otra configuración todavía usa los componentes de este paquete; apáguelos primero.', + reasonStaleApproval: 'Las aprobaciones de guiones pendientes cambiaron; instálelo nuevamente para actualizarlos.', + reasonIncompatibleVersion: '{plugin} es incompatible con DSH {runtime} (requiere {requirements}); ejecutarlo puede causar fallas o pérdida de datos.', + reasonIncompatibleVersionUnnamed: 'Este complemento es incompatible con la versión DSH en ejecución; ejecutarlo puede causar fallas o pérdida de datos.', + reasonIncompatibleInstall: 'Instale una versión del complemento compatible con este DSH.', + reasonIncompatibleInstalled: 'Desinstálalo e instala una versión compatible con este DSH.', + reasonOperationError: 'El anfitrión informó un error.', + detailTitle: 'Detalles', + detailLoading: 'Detalles de lectura', + detailTooLarge: 'Los detalles exceden el límite de tamaño y no se cargaron.', + detailNetwork: 'No se pudo llegar a la fuente de los detalles.', + detailInvalid: 'El complemento declara un archivo de detalles no válido.', + detailFailed: 'No se pudieron leer los detalles.', + detailFootnotes: 'Notas a pie de página', +} satisfies Record + +/** French dictionary, checked against the English keys. */ +export const fr = { + panel: 'Plugins', + title: 'Plugins', + intro: 'Installer, activer et configurer les plugins', + infoLabel: 'À propos des plugins', + infoDescription: "Configurez les plugins officiels et installez ou gérez d'autres plugins ici. Affichez la liste des plugins intégrés et l'état d'exécution dans Paramètres → Plugins intégrés.", + loading: 'Plugins de lecture…', + error: "Impossible de lire tous les plugins, probablement à cause d'un problème de réseau", + unavailable: "Ce déploiement s'exécute sans profil gérable, les plugins ne peuvent donc pas être installés ou commutés ici.", + retry: 'Réessayer', + refresh: 'Actualiser', + refreshError: "L'actualisation a échoué. Veuillez réessayer.", + empty: "Aucun plugin n'est encore installé.", + addPlugin: 'Ajouter un plugin', + chooseAddMethod: 'Choisissez comment ajouter un plugin', + installExisting: 'Installer un plugin tiers', + installExistingDescription: 'Prend en charge les packages npm, les référentiels Git et les répertoires locaux', + restartNotice: 'Redémarrez DSH pour appliquer la modification. {reason}', + restartReasonNoHmr: 'Ce profil applique les modifications uniquement au démarrage.', + restartReasonPackageMoved: "Un package installé est désormais résolu dans un autre répertoire ou la modification remplace un package fourni par l'installation de DSH. Désinstallez un package avant de l’installer à partir d’un autre emplacement.", + restartReasonHostModule: 'Le changement atteint les modules DSH lui-même chargés.', + restartReasonUnmanagedPlugin: 'La modification atteint un plugin exécuté en dehors de la liste des plugins.', + restartReasonModuleComparison: "DSH n'a pas pu comparer les plugins chargés avec leurs fichiers.", + overriddenNotice: "{name} a été enregistré, mais une configuration de priorité plus élevée l'écrase, elle n'est donc pas effective", + bundlesTitle: 'Plugins installés', + basicTitle: 'Paramètres du plugin intégré', + extensionsTitle: 'Plugins expérimentaux', + moreTitle: 'Plugins expérimentaux', + moreAction: 'Plus', + tabsLabel: 'Vues des plugins', + installedTab: 'Installé', + statusProblem: 'Problème', + statusBeta: 'Expérimental', + reasonLabel: 'Raison', + metadataError: 'Erreur de métadonnées du package : {error}', + versionTag: 'v{version}', + partsLabel: 'Composants', + sourceTitle: 'Source', + sourceSpec: 'Source du code', + sourceBuiltIn: 'Intégré', + sourceVersion: 'Version actuelle', + partsEmpty: 'Ce pack de plugins ne contient aucun composant.', + partsCountTotal: 'Total {count}', + partsCountRunning: "{count} en cours d'exécution", + partsCountOff: '{count} désactivé', + partOff: 'Désactivé', + partsCountFailed: 'Échec du {count}', + partsFilter: 'Composants du filtre', + partsFilterEmpty: 'Aucun composant ne correspond.', + partToggle: 'Activer le composant {name}', + rowPhasePending: 'En attente de dépendances', + rowPhaseLoading: 'Chargement', + rowPhaseActive: 'Courir', + rowPhaseFailed: 'Problème', + rowPhaseUnloading: 'Déchargement', + enableToggle: 'Activer {name}', + applyingChange: 'Application des modifications à {name}', + openDetail: 'Voir {name}', + backToList: 'Retour aux plugins', + crumbRoot: 'Plugins', + backToPackage: 'Retour à {name}', + configureRow: 'Configurer {name}', + rowStateIdle: 'Ne fonctionne pas', + uninstall: 'Désinstaller', + uninstallLabel: 'Désinstaller {name}', + installTitle: 'Ajouter un plugin', + installDescription: "Entrez le nom du package du plugin, l'adresse du référentiel GitHub ou le chemin du répertoire local.", + installSpecLabel: 'Nom ou adresse du colis', + installSpecPlaceholder: 'par exemple dsh-plugin-whale-pet', + installGuideToggle: "Guide d'installation et exemples", + installGuideHide: 'Masquer le guide', + installGuideIdTitle: 'Entrez le nom du package npm du plugin', + installGuideIdExample: 'dsh-plugin-baleine-animal de compagnie', + installGuideIdHint: "Le nom du package du plugin est le nom du package npm (comme dsh-xxx ou @author/plugin) : la partie après dsh plugin add ou pnpm add dans la commande d'installation README d'un plugin communautaire.", + installGuideExampleLabel: 'Exemple : ', + installGitTemplateHint: "Remplacez-le par l'adresse réelle du référentiel Git.", + installPathTemplateHint: 'Remplacez-le par le chemin réel vers votre répertoire de plugin local.', + installGuideFill: "Utiliser l'exemple", + installGuideFillAria: "Utilisez l'exemple {example}", + installGuideSafety: "Installez uniquement les plugins en qui vous avez confiance : ils s'exécutent avec vos autorisations et peuvent endommager DeepSeek Harness ou divulguer vos données.", + installUpgradeNotice: "Les plugins officiels à la demande proposent la mise à jour lorsque leur version diffère de DSH. Pour mettre à jour d'autres plugins, désinstallez-les et réinstallez-les.", + registryToggle: 'Registre', + registryLegend: 'Le registre npm à partir duquel le plugin est téléchargé', + registryDefault: 'Registre par défaut', + registryOfficial: 'Registre officiel npm', + registryNpmmirror: 'Miroir de Chine continentale', + registryCustom: 'Adresse personnalisée', + registryCustomPlaceholder: 'https://npm.example.com/', + registryCustomHint: "Entrez une adresse de registre npm interne ou privée commençant par http:// ou https://.. Si cela nécessite une connexion, stockez les informations d'identification dans ~/.npmrc sur cette machine.", + registryCustomInvalid: 'Saisissez une adresse commençant par http:// ou https://', + registryListSeparator: ', ', + sentenceSeparator: ' ', + installRun: 'Installer', + installChecking: 'Vérification…', + installConfirmedTitle: 'Installer le plug-in', + installConfirmedRefused: 'Ce plugin ne peut pas être installé', + installProblemInvalid: "Il ne s'agit pas d'un nom ou d'une adresse de package pouvant être installé : {reason}", + installProblemInstalled: 'Ce plugin est déjà installé. Pour le mettre à niveau, désinstallez-le et réinstallez-le', + installProblemShipped: 'Ce plugin est livré avec DSH ; la mise à niveau de DSH le met à jour', + installBundledTitle: 'Ce plugin est livré avec DSH', + installBundledDescription: 'Activez-le sans télécharger une autre copie', + installProblemNotFound: "Aucun plugin de ce type n'a été trouvé", + installProblemNotPackage: "Le chemin n'existe pas ou n'est pas un package de plugin valide", + installProblemNotBundle: 'Ce package ne déclare aucun bundle, il ne peut donc pas être installé en tant que plugin : {reason}', + installProblemNetwork: "Le registre du plugin n'a pas pu être atteint ; vérifie le réseau et réessaye", + installProblemNetworkAll: "Aucun registre n'a pu être atteint (essayé : {registries}) ; vérifiez les paramètres réseau ou proxy, ou modifiez le registre", + installProblemUnknown: "Le plugin n'a pas pu être recherché : {reason}", + installingTitle: 'Installation du plugin…', + installedTitle: 'Installé', + statusNotInstalled: 'Non installé', + statusMissingFiles: 'Forfait indisponible', + statusUpdateAvailable: 'Mise à jour disponible', + sourceNotInstalled: 'Non installé', + sourceTargetSpec: 'Source pour ce DSH', + sourceTargetVersion: 'Version pour ce DSH', + update: 'Mise à jour', + updateLabel: 'Mettre à jour {name}', + versionsTitle: 'Versions', + versionsRecheck: 'Vérifiez à nouveau', + versionsChecking: 'Vérification des mises à jour', + versionsAvailable: 'Mise à jour vers {version} disponible', + versionsPublished: 'Publié {date}', + versionsLatest: 'À jour', + versionsCheckedAt: 'Dernière vérification à {time}', + versionsBlocked: 'Les versions plus récentes nécessitent une mise à jour DSH', + versionsBlockedDetail: '{version} a besoin de {peers} ; ce DSH est {runtime}', + versionsNoneInstallable: 'Aucune version plus récente à installer directement', + versionsNoneInstallableDetail: 'Les versions les plus récentes sont obsolètes, sont des versions préliminaires ou ne déclarent aucun bundle. Agrandissez pour les voir', + versionsFailedNetwork: "Impossible d'accéder au registre", + versionsFailedOther: 'Impossible de vérifier les mises à jour', + versionsDetails: 'Détails', + versionsNewerCount: '{count} plus récent', + versionsCollapse: 'Réduire', + versionsInstalledTag: 'Installé', + versionsPrereleaseTag: 'Avant-première', + versionsDeprecatedTag: 'Obsolète', + versionsNeedsDsh: 'Besoins {peers}', + versionsNotBundle: 'Cette version ne déclare aucun bundle et ne peut pas être installée', + versionsUpdateLabel: 'Mise à jour vers {version}', + versionsRegistry: 'Registre', + versionsCheckedAtLabel: 'Vérifié à', + versionsFallbackTag: 'Repli', + versionsFallbackHint: "{registry} n'a pas pu être atteint ; la liste provient d'un registre de secours", + versionsPrimaryTag: "D'abord", + versionsShowPrerelease: 'Afficher les avant-premières', + versionsOmitted: '{count} et les versions plus récentes que celle installée ne sont pas répertoriées', + versionsProblemNetwork: 'Injoignable', + versionsProblemNotFound: 'Pas trouvé', + versionsProblemUnknown: 'Échec', + updatesAvailable: 'Mises à jour disponibles : {count}', + updatesNone: 'Tous les plugins sont à jour', + updatesFailed: "Certains plugins n'ont pas pu être vérifiés", + updateTitle: 'Mettre à jour {name}', + updateSource: 'Installer à partir de', + updateDescription: "La mise à jour remplace {from} par {to} ; le plugin peut s'arrêter brièvement. Si l'installation échoue, {from} est conservé ou réinstallé.", + updateRunning: 'Mise à jour', + updateStepChecking: 'Vérifiez {version}', + updateStepRemoving: 'Arrêtez et supprimez {version}', + updateStepInstalling: 'Installez {version} à partir de {registry}', + updateStepRestoring: 'Installez à nouveau {version}', + updateDone: 'Mis à jour vers {version}', + updateDoneDetail: "La nouvelle version est en cours d'exécution.", + updateRestart: '{version} installé ; cela prend effet après un redémarrage', + updateRefused: 'Impossible de mettre à jour vers {version}', + updateRemoveFailed: 'Impossible de supprimer {version}', + updateRestored: 'La mise à jour a échoué ; restauré {version}', + updateRestoredDetail: "Échec de l'installation de {to} ; {from} a été réinstallé.", + updateKept: 'Impossible de mettre à jour vers {version}', + updateKeptDetail: '{from} n’a pas été supprimé et reste utilisé. {reason}', + updateBroken: "La mise à jour a échoué ; le plugin n'est pas installé", + updateBrokenDetail: "L'installation de {to} a échoué et l'installation de {from} a également échoué. Installez à nouveau {name} à partir d'Ajouter un plugin.", + updateLost: 'Le résultat de la mise à jour est inconnu', + updateLostDetail: 'La connexion à DSH a été interrompue : {reason}. Actualisez la liste des plugins pour voir la version installée.', + updateOutput: "Afficher le résultat de l'installation", + updateFinish: 'Terminé', + installFailedTitle: "Le plugin n'a pas pu être installé", + installGithubFailedTitle: "Impossible d'accéder à GitHub", + installGithubTimeoutTitle: 'La connexion GitHub a expiré', + installGithubFailedDescription: "Essayez une autre source d'installation.", + installUseGithubMirror: 'Utiliser un miroir de Chine continentale', + installTryAnotherWay: 'Essayez une autre façon', + installPackageLabel: 'Nom du package du plugin', + installEdit: 'Modifier', + installEditAria: "Retour à l'édition", + installCancelAndEdit: "Annuler l'installation et revenir à l'édition", + installApplyingCancellationError: "L'annulation n'a pas été confirmée. L'installation est en cours d'application ; attendre son résultat. {reason}", + installReconcile: "Vérifier l'état de l'installation", + installUnknownTitle: "Résultat de l'installation indisponible", + installUnknownDescription: "L'hôte n'a aucune installation active avec cet identifiant de demande. Vérifiez la liste des plugins avant de réessayer.", + installResultUnconfirmed: "Le résultat de l'installation n'a pas été reçu. Vérifiez l'état de l'installation. {reason}", + installAwaitingAcceptance: "En attente que l'hôte accepte l'installation. L'annulation sera réessayée automatiquement après confirmation.", + installBackgroundUnknown: "Résultat de l'installation indisponible. Vérifiez la liste des plugins.", + installCancel: "Annuler l'installation", + installCloseCancels: "Annuler l'installation et fermer", + installViewTask: "Voir l'installation", + installUnconfirmedTitle: "État de l'installation non confirmé", + installBackgroundDone: "Installation terminée. Afficher les détails de l'installation.", + installBackgroundFailed: "L'installation a échoué. Afficher les détails de l'installation.", + installBackgroundUnconfirmed: "L'état de l'installation n'est pas confirmé. Consultez l'installation pour plus de détails.", + installBackgroundApplying: "L'installation est en cours d'application et ne peut pas être annulée. Afficher la progression de l'installation.", + installStarting: "Préparation de l'installation…", + installCancelling: "Arrêt de l'installation…", + installApplying: 'Application de la configuration, veuillez patienter…', + installCancelledShort: 'Annulé', + installCancelled: "Installation annulée ; le plugin n'est pas activé et les fichiers téléchargés peuvent rester", + installCancelUnconfirmed: "L'installation n'a pas été confirmée arrêtée. Réessayez l'annulation ou attendez le résultat de l'installation. {reason}", + installEnableNow: 'Activer maintenant', + installDetailsShow: "Afficher les détails de l'installation", + installDetailsHide: "Masquer les détails de l'installation", + installVersion: 'Version {version}', + installSubjectPath: 'Annuaire local', + installSubjectGit: 'Dépôt Git', + installSubjectTarball: 'Tarball', + installLocation: "S'installe dans {dir}", + installRetry: 'Réessayer', + installChangeRegistry: 'Changer de registre', + installAttempt: "{previous} n'a pas pu servir le package ; nouvelle tentative via {registry} (registre {index} de {total})", + installAttemptBadge: 'Tentative {index} · {registry}', + installFailureNetwork: 'La connexion réseau a échoué', + installFailureNetworkAll: "Aucun registre n'a pu être atteint (essayé : {registries}). Vérifiez les paramètres réseau ou proxy, ou modifiez le registre et réessayez.", + installFailureNetworkHost: "{host} n'a pas pu être atteint. Une adresse GitHub ou un lien .tgz ne sont pas récupérés via le registre : cette machine doit y accéder directement ou via un proxy. Si le plugin est également publié sur npm, saisissez plutôt le nom de son package.", + installFailureNotFound: "Aucun plugin de ce type n'a été trouvé", + installFailureNoMatchingVersion: 'Aucune version ne correspond à la demande', + installFailureDiskFull: "Le disque est plein ; l'installation s'est arrêtée", + installFailurePermission: "Aucune autorisation d'écriture ; le plugin ne peut pas être installé", + installFailureBuildBlocked: "Les scripts d'installation d'une dépendance ont besoin de votre autorisation avant que l'installation puisse continuer", + installFailureBuildBlockedManual: "pnpm a bloqué les scripts d'installation ; autorisez-les sous AllowBuilds dans pnpm-workspace.yaml et réessayez", + installFailureIntegrity: "Le package téléchargé a échoué à sa vérification d'intégrité", + installFailureTimeout: "L'installation a expiré", + installFailurePnpmMissing: "pnpm n'a pas été trouvé, donc rien ne peut être installé", + installFailureGeneric: "Quelque chose s'est mal passé lors de l'installation ; les détails disent quoi", + terminalCommandLine: 'Ligne de commande {n}', + terminalRunning: 'Courir', + terminalFailed: 'Échec', + terminalDone: 'Terminé', + terminalCopy: 'Copier', + terminalCopied: 'Copié', + terminalNoOutput: 'Aucune sortie', + terminalCollapseAria: 'Réduire la sortie', + terminalCollapse: 'Réduire', + terminalExpandAria: 'Développez les lignes de sortie {n} restantes', + terminalExpand: '… {n} plus de lignes', + terminalExitCode: 'code de sortie {code}', + terminalSignal: 'signal {signal}', + terminalNoExitCode: 'pas de code de sortie', + installDoneNothing: 'Installation terminée sans nouvelle dépendance.', + installDoneRestart: 'Installé ; redémarrez DSH pour le charger. {reason}', + installDoneApproved: "Scripts d'installation autorisés pour {names}", + installDoneOtherVersion: "Conformément à la politique de sécurité de la chaîne d'approvisionnement de pnpm (minimumReleaseAge), les versions publiées plus récemment que l'âge requis ne sont pas installées automatiquement. Cette installation a donc utilisé {installed} au lieu de la dernière version {version}. Une version nouvellement publiée n'a pas dépassé cette période d'attente et peut contenir du code malveillant ou des défauts non détectés. Si vous avez besoin de {version}, désinstallez la version actuelle, puis installez {exact} pour demander cette version.", + installApprovalTitle: "Les scripts d'installation nécessitent une autorisation", + installApprovalDescription: "Ces packages contiennent des scripts d'installation que pnpm n'a pas exécutés.", + installApprovalConsequence: "Une fois autorisés, les scripts s'exécutent ici avec vos autorisations et l'autorisation est enregistrée dans ce profil.", + installApprovalCaution: 'Autorisez uniquement les packages auxquels vous faites confiance.', + installApproveAndRetry: 'Autoriser ces scripts et réessayer', + installClose: 'Terminé', + close: 'Fermer', + cancel: 'Annuler', + confirmUninstallTitle: 'Désinstaller "{name}" ?', + confirmUninstallDescription: "Ce qu'il fournit disparaît une fois désinstallé.", + confirmUninstall: 'Désinstaller', + failedEnable: "Impossible d'activer : {reason}", + failedDisable: 'Impossible de désactiver : {reason}', + failedUninstall: 'Impossible de désinstaller : {reason}', + failedRowEnable: "Impossible d'activer le composant : {reason}", + failedRowDisable: 'Impossible de désactiver le composant : {reason}', + reasonManagementRequired: 'La gestion des plugins en a besoin ; il ne peut pas être éteint ou désinstallé.', + reasonUnaddressable: 'Le correctif de profil ne peut pas résoudre ce problème de manière unique.', + reasonUnknownPlugin: 'Aucun plugin de ce type.', + reasonInvalidSpec: 'Entrez un nom ou une adresse de package valide.', + reasonAlreadyInstalled: "Ce package est déjà installé ; désinstallez-le avant d'installer une autre version.", + reasonAmbiguousInstall: 'Le changement de dépendance ne permet pas de déterminer quel package a été installé.', + reasonNotBundle: 'Ce package ne déclare aucun bundle, il ne peut donc pas être géré comme un plugin.', + reasonNotRemovable: "Ce package n'appartient pas au profil, ou la gestion des plugins en a besoin.", + reasonStopProfile: "Ce profil s'exécute sans HMR ; arrêtez-le et désinstallez le package avec le plugin dsh.", + reasonBundleInUse: "Une autre configuration utilise toujours les composants de ce bundle ; éteignez-les d'abord.", + reasonStaleApproval: 'Les approbations de script en attente ont changé ; réinstallez-les pour les actualiser.', + reasonIncompatibleVersion: '{plugin} est incompatible avec DSH {runtime} (nécessite {requirements}) ; son exécution peut provoquer des plantages ou une perte de données.', + reasonIncompatibleVersionUnnamed: "Ce plugin est incompatible avec la version DSH en cours d'exécution ; son exécution peut provoquer des plantages ou une perte de données.", + reasonIncompatibleInstall: 'Installez une version du plugin compatible avec ce DSH.', + reasonIncompatibleInstalled: 'Désinstallez-le et installez une version compatible avec ce DSH.', + reasonOperationError: "L'hôte a signalé une erreur.", + detailTitle: 'Détails', + detailLoading: 'Détails de lecture', + detailTooLarge: "Les détails dépassent la limite de taille et n'ont pas été chargés.", + detailNetwork: "Impossible d'atteindre la source des détails.", + detailInvalid: 'Le plugin déclare un fichier de détails invalide.', + detailFailed: 'Impossible de lire les détails.', + detailFootnotes: 'Notes de bas de page', +} satisfies Record + +/** German dictionary, checked against the English keys. */ +export const de = { + panel: 'Plugins', + title: 'Plugins', + intro: 'Plugins installieren, aktivieren und konfigurieren', + infoLabel: 'Über Plugins', + infoDescription: 'Konfigurieren Sie hier offizielle Plugins und installieren oder verwalten Sie andere Plugins. Sehen Sie sich die Liste der integrierten Plugins und den Laufzeitstatus unter Einstellungen → Integrierte Plugins an.', + loading: 'Plugins lesen…', + error: 'Es konnten nicht alle Plugins gelesen werden, möglicherweise aufgrund eines Netzwerkproblems', + unavailable: 'Diese Bereitstellung läuft ohne verwaltbares Profil, daher können hier keine Plugins installiert oder gewechselt werden.', + retry: 'Erneut versuchen', + refresh: 'Aktualisieren', + refreshError: 'Aktualisierung fehlgeschlagen. Bitte versuchen Sie es erneut.', + empty: 'Es sind noch keine Plugins installiert.', + addPlugin: 'Plugin hinzufügen', + chooseAddMethod: 'Wählen Sie aus, wie Sie ein Plugin hinzufügen möchten', + installExisting: 'Installieren Sie ein Drittanbieter-Plugin', + installExistingDescription: 'Unterstützt npm-Pakete, Git-Repositorys und lokale Verzeichnisse', + restartNotice: 'Starten Sie DSH neu, um die Änderung zu übernehmen. {reason}', + restartReasonNoHmr: 'Dieses Profil wendet Änderungen nur beim Start an.', + restartReasonPackageMoved: 'Ein installiertes Paket wird nun in ein anderes Verzeichnis aufgelöst, oder die Änderung ersetzt ein Paket, das die DSH-Installation bereitstellt. Deinstallieren Sie ein Paket, bevor Sie es von einem anderen Speicherort installieren.', + restartReasonHostModule: 'Die Änderung erreicht die geladenen Module DSH selbst.', + restartReasonUnmanagedPlugin: 'Die Änderung betrifft ein Plugin, das außerhalb der Plugin-Liste ausgeführt wird.', + restartReasonModuleComparison: 'DSH konnte die geladenen Plugins nicht mit ihren Dateien vergleichen.', + overriddenNotice: '{name} wurde gespeichert, aber eine Konfiguration mit höherer Priorität überschreibt es, sodass es nicht wirksam ist', + bundlesTitle: 'Installierte Plugins', + basicTitle: 'Integrierte Plugin-Einstellungen', + extensionsTitle: 'Experimentelle Plugins', + moreTitle: 'Experimentelle Plugins', + moreAction: 'Mehr', + tabsLabel: 'Plugin-Ansichten', + installedTab: 'Installiert', + statusProblem: 'Problem', + statusBeta: 'Experimentell', + reasonLabel: 'Grund', + metadataError: 'Paketmetadatenfehler: {error}', + versionTag: 'v{version}', + partsLabel: 'Komponenten', + sourceTitle: 'Quelle', + sourceSpec: 'Codequelle', + sourceBuiltIn: 'Eingebaut', + sourceVersion: 'Aktuelle Version', + partsEmpty: 'Dieses Plugin-Paket enthält keine Komponenten.', + partsCountTotal: '{count} insgesamt', + partsCountRunning: '{count} läuft', + partsCountOff: '{count} aus', + partOff: 'Aus', + partsCountFailed: '{count} ist fehlgeschlagen', + partsFilter: 'Komponenten filtern', + partsFilterEmpty: 'Keine Komponentenübereinstimmungen.', + partToggle: 'Aktivieren Sie die Komponente {name}', + rowPhasePending: 'Warten auf Abhängigkeiten', + rowPhaseLoading: 'Laden', + rowPhaseActive: 'Laufen', + rowPhaseFailed: 'Problem', + rowPhaseUnloading: 'Entladen', + enableToggle: 'Aktivieren Sie {name}', + applyingChange: 'Anwenden von Änderungen auf {name}', + openDetail: 'Siehe {name}', + backToList: 'Zurück zu Plugins', + crumbRoot: 'Plugins', + backToPackage: 'Zurück zu {name}', + configureRow: 'Konfigurieren Sie {name}', + rowStateIdle: 'Läuft nicht', + uninstall: 'Deinstallieren', + uninstallLabel: 'Deinstallieren Sie {name}', + installTitle: 'Plugin hinzufügen', + installDescription: 'Geben Sie den Paketnamen des Plugins, die GitHub-Repository-Adresse oder den lokalen Verzeichnispfad ein.', + installSpecLabel: 'Name oder Adresse des Pakets', + installSpecPlaceholder: 'zum Beispiel dsh-plugin-whale-pet', + installGuideToggle: 'Installationsanleitung und Beispiele', + installGuideHide: 'Verstecke den Leitfaden', + installGuideIdTitle: 'Geben Sie den NPM-Paketnamen des Plugins ein', + installGuideIdExample: 'dsh-plugin-whale-pet', + installGuideIdHint: 'Der Plugin-Paketname ist der npm-Paketname (wie dsh-xxx oder @author/plugin): der Teil nach dshplugin add oder pnpm add im README-Installationsbefehl eines Community-Plugins.', + installGuideExampleLabel: 'Beispiel: ', + installGitTemplateHint: 'Ersetzen Sie dies durch die tatsächliche Git-Repository-Adresse.', + installPathTemplateHint: 'Ersetzen Sie dies durch den tatsächlichen Pfad zu Ihrem lokalen Plugin-Verzeichnis.', + installGuideFill: 'Beispiel verwenden', + installGuideFillAria: 'Verwenden Sie das Beispiel {example}', + installGuideSafety: 'Installieren Sie nur Plugins, denen Sie vertrauen: Sie werden mit Ihren Berechtigungen ausgeführt und können DeepSeek Harness beschädigen oder Ihre Daten preisgeben.', + installUpgradeNotice: 'Offizielle On-Demand-Plugins bieten Updates an, wenn ihre Version von DSH abweicht. Um andere Plugins zu aktualisieren, deinstallieren Sie sie und installieren Sie sie erneut.', + registryToggle: 'Registrierung', + registryLegend: 'Die NPM-Registrierung, aus der das Plugin heruntergeladen wird', + registryDefault: 'Standardregistrierung', + registryOfficial: 'Offizielle npm-Registrierung', + registryNpmmirror: 'Spiegel vom chinesischen Festland', + registryCustom: 'Benutzerdefinierte Adresse', + registryCustomPlaceholder: 'https://npm.example.com/', + registryCustomHint: 'Geben Sie eine interne oder private NPM-Registrierungsadresse ein, beginnend mit http:// oder https://.. Wenn eine Anmeldung erforderlich ist, speichern Sie die Anmeldeinformationen in ~/.npmrc auf diesem Computer.', + registryCustomInvalid: 'Geben Sie eine Adresse ein, die mit http:// oder https:// beginnt.', + registryListSeparator: ', ', + sentenceSeparator: ' ', + installRun: 'Installieren', + installChecking: 'Überprüfen…', + installConfirmedTitle: 'Plugin installieren', + installConfirmedRefused: 'Dieses Plugin kann nicht installiert werden', + installProblemInvalid: 'Dies ist kein Paketname oder keine Adresse, die installiert werden kann: {reason}', + installProblemInstalled: 'Dieses Plugin ist bereits installiert. Um es zu aktualisieren, deinstallieren Sie es und installieren Sie es erneut', + installProblemShipped: 'Dieses Plugin wird mit DSH geliefert; Durch das Aktualisieren von DSH wird es aktualisiert', + installBundledTitle: 'Dieses Plugin wird mit DSH geliefert', + installBundledDescription: 'Aktivieren Sie es, ohne eine weitere Kopie herunterzuladen', + installProblemNotFound: 'Es wurde kein solches Plugin gefunden', + installProblemNotPackage: 'Der Pfad existiert nicht oder ist kein gültiges Plugin-Paket', + installProblemNotBundle: 'Dieses Paket deklariert kein Bundle und kann daher nicht als Plugin installiert werden: {reason}', + installProblemNetwork: 'Die Plugin-Registrierung konnte nicht erreicht werden; Überprüfen Sie das Netzwerk und versuchen Sie es erneut', + installProblemNetworkAll: 'Es konnte keine Registry erreicht werden (versucht: {registries}); Überprüfen Sie die Netzwerk- oder Proxy-Einstellungen oder ändern Sie die Registrierung', + installProblemUnknown: 'Das Plugin konnte nicht gefunden werden: {reason}', + installingTitle: 'Plugin installieren…', + installedTitle: 'Installiert', + statusNotInstalled: 'Nicht installiert', + statusMissingFiles: 'Paket nicht verfügbar', + statusUpdateAvailable: 'Update verfügbar', + sourceNotInstalled: 'Nicht installiert', + sourceTargetSpec: 'Quelle hierfür DSH', + sourceTargetVersion: 'Version für dieses DSH', + update: 'Aktualisieren', + updateLabel: 'Aktualisieren Sie {name}', + versionsTitle: 'Versionen', + versionsRecheck: 'Überprüfen Sie es noch einmal', + versionsChecking: 'Suche nach Updates', + versionsAvailable: 'Update auf {version} verfügbar', + versionsPublished: 'Veröffentlicht {date}', + versionsLatest: 'Auf dem neuesten Stand', + versionsCheckedAt: 'Zuletzt überprüft bei {time}', + versionsBlocked: 'Neuere Versionen benötigen ein DSH-Update', + versionsBlockedDetail: '{version} benötigt {peers}; dieser DSH ist {runtime}', + versionsNoneInstallable: 'Keine neuere Version zur direkten Installation', + versionsNoneInstallableDetail: 'Die neueren Versionen sind veraltet, Vorabversionen oder deklarieren kein Bundle. Erweitern Sie, um sie zu sehen', + versionsFailedNetwork: 'Die Registrierung konnte nicht erreicht werden', + versionsFailedOther: 'Es konnte nicht nach Updates gesucht werden', + versionsDetails: 'Details', + versionsNewerCount: '{count} neuer', + versionsCollapse: 'Einklappen', + versionsInstalledTag: 'Installiert', + versionsPrereleaseTag: 'Vorabversion', + versionsDeprecatedTag: 'Veraltet', + versionsNeedsDsh: 'Benötigt {peers}', + versionsNotBundle: 'Diese Version deklariert kein Bundle und kann nicht installiert werden', + versionsUpdateLabel: 'Update auf {version}', + versionsRegistry: 'Registrierung', + versionsCheckedAtLabel: 'Geprüft bei', + versionsFallbackTag: 'Rückfall', + versionsFallbackHint: '{registry} konnte nicht erreicht werden; Die Liste stammt aus einer Fallback-Registrierung', + versionsPrimaryTag: 'Erstens', + versionsShowPrerelease: 'Vorabversionen anzeigen', + versionsOmitted: '{count} Weitere Versionen, die neuer als die installierte sind, werden nicht aufgeführt', + versionsProblemNetwork: 'Unerreichbar', + versionsProblemNotFound: 'Nicht gefunden', + versionsProblemUnknown: 'Fehlgeschlagen', + updatesAvailable: 'Verfügbare Updates: {count}', + updatesNone: 'Alle Plugins sind auf dem neuesten Stand', + updatesFailed: 'Einige Plugins konnten nicht überprüft werden', + updateTitle: 'Aktualisieren Sie {name}', + updateSource: 'Installieren von', + updateDescription: 'Das Update ersetzt {from} durch {to}; Das Plugin stoppt möglicherweise kurzzeitig. Wenn die Installation fehlschlägt, bleibt {from} erhalten oder wird erneut installiert.', + updateRunning: 'Aktualisierung', + updateStepChecking: 'Überprüfen Sie {version}', + updateStepRemoving: 'Stoppen und entfernen Sie {version}', + updateStepInstalling: 'Installieren Sie {version} von {registry}', + updateStepRestoring: 'Installieren Sie {version} erneut', + updateDone: 'Aktualisiert auf {version}', + updateDoneDetail: 'Die neue Version läuft.', + updateRestart: '{version} installiert; es wird erst nach einem Neustart wirksam', + updateRefused: 'Aktualisierung auf {version} nicht möglich', + updateRemoveFailed: '{version} konnte nicht entfernt werden', + updateRestored: 'Aktualisierung fehlgeschlagen; wiederhergestellt {version}', + updateRestoredDetail: '{to} konnte nicht installiert werden; {from} wurde erneut installiert.', + updateKept: 'Konnte nicht auf {version} aktualisiert werden', + updateKeptDetail: '{from} wurde nicht entfernt und wird weiterhin verwendet. {reason}', + updateBroken: 'Aktualisierung fehlgeschlagen; Das Plugin ist nicht installiert', + updateBrokenDetail: 'Die Installation von {to} ist fehlgeschlagen, und auch die erneute Installation von {from} schlug fehl. Installieren Sie {name} erneut über Plugin hinzufügen.', + updateLost: 'Das Update-Ergebnis ist unbekannt', + updateLostDetail: 'Die Verbindung zu DSH wurde unterbrochen: {reason}. Aktualisieren Sie die Plugin-Liste, um die installierte Version anzuzeigen.', + updateOutput: 'Installationsausgabe anzeigen', + updateFinish: 'Fertig', + installFailedTitle: 'Das Plugin konnte nicht installiert werden', + installGithubFailedTitle: 'Auf GitHub kann nicht zugegriffen werden', + installGithubTimeoutTitle: 'Zeitüberschreitung bei der GitHub-Verbindung', + installGithubFailedDescription: 'Versuchen Sie es mit einer anderen Installationsquelle.', + installUseGithubMirror: 'Verwenden Sie einen Spiegel vom chinesischen Festland', + installTryAnotherWay: 'Versuchen Sie es anders', + installPackageLabel: 'Name des Plugin-Pakets', + installEdit: 'Bearbeiten', + installEditAria: 'Zurück zur Bearbeitung', + installCancelAndEdit: 'Brechen Sie die Installation ab und kehren Sie zur Bearbeitung zurück', + installApplyingCancellationError: 'Die Stornierung wurde nicht bestätigt. Installation wird angewendet; warte auf das Ergebnis. {reason}', + installReconcile: 'Überprüfen Sie den Installationsstatus', + installUnknownTitle: 'Installationsergebnis nicht verfügbar', + installUnknownDescription: 'Der Host verfügt über keine aktive Installation mit dieser Anforderungs-ID. Überprüfen Sie die Plugin-Liste, bevor Sie es erneut versuchen.', + installResultUnconfirmed: 'Das Installationsergebnis wurde nicht empfangen. Überprüfen Sie den Installationsstatus. {reason}', + installAwaitingAcceptance: 'Warten darauf, dass der Host die Installation akzeptiert. Die Stornierung wird nach der Bestätigung automatisch wiederholt.', + installBackgroundUnknown: 'Installationsergebnis nicht verfügbar. Überprüfen Sie die Plugin-Liste.', + installCancel: 'Installation abbrechen', + installCloseCancels: 'Installation abbrechen und schließen', + installViewTask: 'Installation ansehen', + installUnconfirmedTitle: 'Installationsstatus unbestätigt', + installBackgroundDone: 'Installation abgeschlossen. Installationsdetails anzeigen.', + installBackgroundFailed: 'Die Installation ist fehlgeschlagen. Installationsdetails anzeigen.', + installBackgroundUnconfirmed: 'Der Installationsstatus ist unbestätigt. Weitere Informationen finden Sie in der Installation.', + installBackgroundApplying: 'Die Installation wird ausgeführt und kann nicht abgebrochen werden. Installationsfortschritt anzeigen.', + installStarting: 'Installation vorbereiten…', + installCancelling: 'Installation wird gestoppt…', + installApplying: 'Konfiguration wird angewendet, bitte warten…', + installCancelledShort: 'Abgesagt', + installCancelled: 'Installation abgebrochen; Das Plugin ist nicht aktiviert und heruntergeladene Dateien bleiben möglicherweise bestehen', + installCancelUnconfirmed: 'Die Installation wurde nicht bestätigt und gestoppt. Versuchen Sie den Abbruch erneut oder warten Sie auf das Installationsergebnis. {reason}', + installEnableNow: 'Jetzt aktivieren', + installDetailsShow: 'Installationsdetails anzeigen', + installDetailsHide: 'Installationsdetails ausblenden', + installVersion: 'Version {version}', + installSubjectPath: 'Lokales Verzeichnis', + installSubjectGit: 'Git-Repository', + installSubjectTarball: 'Tarball', + installLocation: 'Wird in {dir} installiert', + installRetry: 'Erneut versuchen', + installChangeRegistry: 'Registrierung ändern', + installAttempt: '{previous} konnte das Paket nicht bereitstellen; erneuter Versuch über {registry} (Registrierung {index} von {total})', + installAttemptBadge: 'Versuchen Sie {index} · {registry}', + installFailureNetwork: 'Die Netzwerkverbindung ist fehlgeschlagen', + installFailureNetworkAll: 'Es konnte keine Registry erreicht werden (versucht: {registries}). Überprüfen Sie die Netzwerk- oder Proxy-Einstellungen oder ändern Sie die Registrierung und versuchen Sie es erneut.', + installFailureNetworkHost: '{host} konnte nicht erreicht werden. Eine GitHub-Adresse oder ein .tgz-Link wird nicht über die Registrierung abgerufen: Dieser Computer muss sie direkt oder über einen Proxy erreichen. Wenn das Plugin auch auf npm veröffentlicht wird, geben Sie stattdessen seinen Paketnamen ein.', + installFailureNotFound: 'Es wurde kein solches Plugin gefunden', + installFailureNoMatchingVersion: 'Keine Version entspricht der Anfrage', + installFailureDiskFull: 'Die Festplatte ist voll; Die Installation wurde gestoppt', + installFailurePermission: 'Keine Schreibberechtigung; Das Plugin kann nicht installiert werden', + installFailureBuildBlocked: 'Die Installationsskripte einer Abhängigkeit benötigen Ihre Erlaubnis, bevor die Installation fortgesetzt werden kann', + installFailureBuildBlockedManual: 'pnpm blockierte Installationsskripte; Erlauben Sie sie unter „allowBuilds“ in „pnpm-workspace.yaml“ und versuchen Sie es erneut', + installFailureIntegrity: 'Das heruntergeladene Paket hat die Integritätsprüfung nicht bestanden', + installFailureTimeout: 'Bei der Installation ist eine Zeitüberschreitung aufgetreten', + installFailurePnpmMissing: 'pnpm wurde nicht gefunden, daher kann nichts installiert werden', + installFailureGeneric: 'Bei der Installation ist ein Fehler aufgetreten. Die Details sagen was', + terminalCommandLine: 'Befehlszeile {n}', + terminalRunning: 'Laufen', + terminalFailed: 'Fehlgeschlagen', + terminalDone: 'Fertig', + terminalCopy: 'Kopieren', + terminalCopied: 'Kopiert', + terminalNoOutput: 'Keine Ausgabe', + terminalCollapseAria: 'Ausgabe reduzieren', + terminalCollapse: 'Einklappen', + terminalExpandAria: 'Erweitern Sie die verbleibenden {n}-Ausgabezeilen', + terminalExpand: '… {n} weitere Zeilen', + terminalExitCode: 'Exit-Code {code}', + terminalSignal: 'Signal {signal}', + terminalNoExitCode: 'kein Exit-Code', + installDoneNothing: 'Die Installation ist ohne neue Abhängigkeit abgeschlossen.', + installDoneRestart: 'Installiert; Starten Sie DSH neu, um es zu laden. {reason}', + installDoneApproved: 'Für {names} zulässige Installationsskripte', + installDoneOtherVersion: 'Gemäß der Supply-Chain-Sicherheitsrichtlinie von pnpm (minimumReleaseAge) werden Versionen, die jünger als das erforderliche Alter veröffentlicht wurden, nicht automatisch installiert. Daher wurde bei dieser Installation {installed} anstelle der neuesten Version {version} verwendet. Eine neu veröffentlichte Version hat diese Wartezeit nicht durchlaufen und kann unerkannten Schadcode oder Mängel enthalten. Wenn Sie {version} benötigen, deinstallieren Sie die aktuelle Version und installieren Sie dann {exact}, um diese Version anzufordern.', + installApprovalTitle: 'Installationsskripte benötigen eine Genehmigung', + installApprovalDescription: 'Diese Pakete verfügen über Installationsskripte, die pnpm nicht ausgeführt hat.', + installApprovalConsequence: 'Sobald sie zugelassen sind, werden die Skripte hier mit Ihren Berechtigungen ausgeführt und die Berechtigung wird in diesem Profil gespeichert.', + installApprovalCaution: 'Lassen Sie nur Pakete zu, denen Sie vertrauen.', + installApproveAndRetry: 'Lassen Sie diese Skripte zu und versuchen Sie es erneut', + installClose: 'Fertig', + close: 'Schließen', + cancel: 'Abbrechen', + confirmUninstallTitle: '„{name}“ deinstallieren?', + confirmUninstallDescription: 'Was es bietet, verschwindet, sobald es deinstalliert wird.', + confirmUninstall: 'Deinstallieren', + failedEnable: 'Konnte nicht aktiviert werden: {reason}', + failedDisable: 'Konnte nicht deaktiviert werden: {reason}', + failedUninstall: 'Konnte nicht deinstalliert werden: {reason}', + failedRowEnable: 'Die Komponente {reason} konnte nicht aktiviert werden', + failedRowDisable: 'Die Komponente {reason} konnte nicht deaktiviert werden', + reasonManagementRequired: 'Das Plugin-Management braucht es; Es kann nicht ausgeschaltet oder deinstalliert werden.', + reasonUnaddressable: 'Der Profil-Patch kann dieses Problem nicht eindeutig beheben.', + reasonUnknownPlugin: 'Kein solches Plugin.', + reasonInvalidSpec: 'Geben Sie einen gültigen Paketnamen oder eine gültige Paketadresse ein.', + reasonAlreadyInstalled: 'Dieses Paket ist bereits installiert; Deinstallieren Sie es, bevor Sie eine andere Version installieren.', + reasonAmbiguousInstall: 'Welches Paket installiert wurde, lässt sich anhand der Abhängigkeitsänderung nicht erkennen.', + reasonNotBundle: 'Dieses Paket deklariert kein Bundle und kann daher nicht als Plugin verwaltet werden.', + reasonNotRemovable: 'Dieses Paket gehört nicht dem Profil oder die Plugin-Verwaltung benötigt es.', + reasonStopProfile: 'Dieses Profil läuft ohne HMR; Stoppen Sie es und deinstallieren Sie das Paket mit dem dsh-Plugin.', + reasonBundleInUse: 'Andere Konfigurationen verwenden weiterhin die Komponenten dieses Bundles; Schalten Sie sie zuerst aus.', + reasonStaleApproval: 'Die ausstehenden Drehbuchgenehmigungen haben sich geändert; Installieren Sie es erneut, um sie zu aktualisieren.', + reasonIncompatibleVersion: '{plugin} ist nicht kompatibel mit DSH {runtime} (erfordert {requirements}); Die Ausführung kann zu Abstürzen oder Datenverlust führen.', + reasonIncompatibleVersionUnnamed: 'Dieses Plugin ist nicht mit der laufenden DSH-Version kompatibel. Die Ausführung kann zu Abstürzen oder Datenverlust führen.', + reasonIncompatibleInstall: 'Installieren Sie eine mit diesem DSH kompatible Plugin-Version.', + reasonIncompatibleInstalled: 'Deinstallieren Sie es und installieren Sie eine mit diesem DSH kompatible Version.', + reasonOperationError: 'Der Host hat einen Fehler gemeldet.', + detailTitle: 'Details', + detailLoading: 'Details lesen', + detailTooLarge: 'Die Details überschreiten die Größenbeschränkung und wurden nicht geladen.', + detailNetwork: 'Die Quelle der Details konnte nicht erreicht werden.', + detailInvalid: 'Das Plugin deklariert eine ungültige Detaildatei.', + detailFailed: 'Die Details konnten nicht gelesen werden.', + detailFootnotes: 'Fußnoten', +} satisfies Record + +/** Russian dictionary, checked against the English keys. */ +export const ru = { + 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: 'Разгрузка', + enableToggle: 'Включить {name}', + applyingChange: 'Применение изменений в {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-плагин-кит-питомец', + installGuideIdHint: 'Имя пакета плагина — это имя пакета npm (например, dsh-xxx или @author/plugin): часть после добавления плагина dsh или добавления pnpm в команде установки README плагина сообщества.', + 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: 'Введите внутренний или частный адрес реестра npm, начинающийся с http:// или https://.. Если требуется вход в систему, сохраните учетные данные в ~/.npmrc на этом компьютере.', + registryCustomInvalid: 'Введите адрес, начинающийся с http:// или https://.', + registryListSeparator: ', ', + sentenceSeparator: ' ', + installRun: 'Установить', + installChecking: 'Проверка…', + installConfirmedTitle: 'Установить плагин', + installConfirmedRefused: 'Этот плагин не может быть установлен', + installProblemInvalid: 'Это не имя или адрес пакета, который можно установить: {reason}.', + installProblemInstalled: 'Этот плагин уже установлен. Чтобы обновить его, удалите его и установите снова.', + installProblemShipped: 'Этот плагин поставляется с DSH; обновление DSH обновляет его', + installBundledTitle: 'Этот плагин поставляется с DSH.', + installBundledDescription: 'Включите его, не загружая другую копию.', + 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: 'Установите {version} из {registry}.', + 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: 'На хосте нет активной установки с этим идентификатором запроса. Прежде чем повторить попытку, проверьте список плагинов.', + installResultUnconfirmed: 'Результат установки не получен. Проверьте статус установки. {reason}', + installAwaitingAcceptance: 'Ожидание, пока хост примет установку. Отмена будет повторена автоматически после подтверждения.', + 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: 'Тарбол', + 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 блокировал сценарии установки; разрешите их в разделеallowBuilds в pnpm-workspace.yaml и повторите попытку.', + 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) версии, опубликованные позже требуемого возраста, не устанавливаются автоматически, поэтому в этой установке использовалась {installed} вместо последней версии {version}. Недавно опубликованная версия не завершила этот период ожидания и может содержать необнаруженный вредоносный код или дефекты. Если вам нужен {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.', + reasonBundleInUse: 'Другая конфигурация по-прежнему использует компоненты этого пакета; сначала выключите их.', + reasonStaleApproval: 'Ожидаемые утверждения сценария изменились; установите еще раз, чтобы обновить их.', + reasonIncompatibleVersion: '{plugin} несовместим с DSH {runtime} (требуется {requirements}); его запуск может привести к сбоям или потере данных.', + reasonIncompatibleVersionUnnamed: 'Этот плагин несовместим с работающей версией DSH; его запуск может привести к сбоям или потере данных.', + reasonIncompatibleInstall: 'Установите версию плагина, совместимую с этим DSH.', + reasonIncompatibleInstalled: 'Удалите его и установите версию, совместимую с этим DSH.', + reasonOperationError: 'Хост сообщил об ошибке.', + detailTitle: 'Подробности', + detailLoading: 'Читаем подробности', + detailTooLarge: 'Детали превышают лимит размера и не были загружены.', + detailNetwork: 'До источника подробностей добраться не удалось.', + detailInvalid: 'Плагин объявляет неверный файл сведений.', + detailFailed: 'Подробности прочитать не удалось.', + detailFootnotes: 'Сноски', +} satisfies Record + +/** Portuguese dictionary, checked against the English keys. */ +export const pt = { + panel: 'Plug-ins', + title: 'Plug-ins', + intro: 'Instalar, habilitar e configurar plug-ins', + infoLabel: 'Sobre plug-ins', + infoDescription: 'Configure plugins oficiais e instale ou gerencie outros plugins aqui. Veja a lista de plug-ins integrados e o status do tempo de execução em Configurações → Plug-ins integrados.', + loading: 'Lendo plug-ins…', + error: 'Não foi possível ler todos os plugins, possivelmente devido a um problema de rede', + unavailable: 'Esta implantação é executada sem um perfil gerenciável, portanto, os plug-ins não podem ser instalados ou trocados aqui.', + retry: 'Tentar novamente', + refresh: 'Atualizar', + refreshError: 'Falha na atualização. Por favor, tente novamente.', + empty: 'Nenhum plug-in está instalado ainda.', + addPlugin: 'Adicionar plug-in', + chooseAddMethod: 'Escolha como adicionar um plugin', + installExisting: 'Instale um plugin de terceiros', + installExistingDescription: 'Suporta pacotes npm, repositórios Git e diretórios locais', + restartNotice: 'Reinicie DSH para aplicar a alteração. {reason}', + restartReasonNoHmr: 'Este perfil aplica alterações somente na inicialização.', + restartReasonPackageMoved: 'Um pacote instalado agora é resolvido para outro diretório ou a alteração substitui um pacote fornecido pela instalação DSH. Desinstale um pacote antes de instalá-lo de outro local.', + restartReasonHostModule: 'A mudança atinge os módulos DSH carregados.', + restartReasonUnmanagedPlugin: 'A mudança atinge um plugin rodando fora da lista de plugins.', + restartReasonModuleComparison: 'DSH não conseguiu comparar os plug-ins carregados com seus arquivos.', + overriddenNotice: '{name} foi salvo, mas uma configuração de prioridade mais alta o substitui, portanto não está em vigor', + bundlesTitle: 'Plug-ins instalados', + basicTitle: 'Configurações de plug-in integradas', + extensionsTitle: 'Plug-ins experimentais', + moreTitle: 'Plug-ins experimentais', + moreAction: 'Mais', + tabsLabel: 'Visualizações de plug-in', + installedTab: 'Instalado', + statusProblem: 'Problema', + statusBeta: 'Experimental', + reasonLabel: 'Razão', + metadataError: 'Erro de metadados do pacote: {error}', + versionTag: 'v{version}', + partsLabel: 'Componentes', + sourceTitle: 'Fonte', + sourceSpec: 'Fonte do código', + sourceBuiltIn: 'Construído em', + sourceVersion: 'Versão atual', + partsEmpty: 'Este pacote de plugins não contém componentes.', + partsCountTotal: '{count} total', + partsCountRunning: '{count} em execução', + partsCountOff: '{count} desativado', + partOff: 'Desligado', + partsCountFailed: '{count} falhou', + partsFilter: 'Filtrar componentes', + partsFilterEmpty: 'Nenhum componente corresponde.', + partToggle: 'Ativar componente {name}', + rowPhasePending: 'Esperando por dependências', + rowPhaseLoading: 'Carregando', + rowPhaseActive: 'Correndo', + rowPhaseFailed: 'Problema', + rowPhaseUnloading: 'Descarregando', + enableToggle: 'Ativar {name}', + applyingChange: 'Aplicando alterações em {name}', + openDetail: 'Ver {name}', + backToList: 'Voltar aos plug-ins', + crumbRoot: 'Plug-ins', + backToPackage: 'Voltar para {name}', + configureRow: 'Configurar {name}', + rowStateIdle: 'Não está em execução', + uninstall: 'Desinstalar', + uninstallLabel: 'Desinstalar {name}', + installTitle: 'Adicionar plug-in', + installDescription: 'Insira o nome do pacote do plug-in, o endereço do repositório GitHub ou o caminho do diretório local.', + installSpecLabel: 'Nome ou endereço do pacote', + installSpecPlaceholder: 'por exemplo dsh-plugin-baleia-animal de estimação', + installGuideToggle: 'Guia de instalação e exemplos', + installGuideHide: 'Ocultar o guia', + installGuideIdTitle: 'Digite o nome do pacote npm do plugin', + installGuideIdExample: 'dsh-plugin-baleia-animal de estimação', + installGuideIdHint: 'O nome do pacote do plug-in é o nome do pacote npm (como dsh-xxx ou @author/plugin): a parte após dsh plugin add ou pnpm add no comando de instalação README de um plug-in da comunidade.', + installGuideExampleLabel: 'Exemplo: ', + installGitTemplateHint: 'Substitua pelo endereço real do repositório Git.', + installPathTemplateHint: 'Substitua isso pelo caminho real para o diretório local do plugin.', + installGuideFill: 'Exemplo de uso', + installGuideFillAria: 'Use o exemplo {example}', + installGuideSafety: 'Instale apenas plug-ins em que você confia: eles são executados com suas permissões e podem danificar DeepSeek Harness ou vazar seus dados.', + installUpgradeNotice: 'Plug-ins oficiais sob demanda oferecem atualização quando sua versão é diferente de DSH. Para atualizar outros plug-ins, desinstale-os e reinstale-os.', + registryToggle: 'Registro', + registryLegend: 'O registro npm do qual o plugin é baixado', + registryDefault: 'Registro padrão', + registryOfficial: 'Registro oficial do npm', + registryNpmmirror: 'Espelho da China Continental', + registryCustom: 'Endereço personalizado', + registryCustomPlaceholder: 'https://npm.example.com/', + registryCustomHint: 'Insira um endereço de registro npm interno ou privado começando com http:// ou https://. Se for necessário um login, armazene as credenciais em ~/.npmrc nesta máquina.', + registryCustomInvalid: 'Insira um endereço começando com http:// ou https://', + registryListSeparator: ', ', + sentenceSeparator: ' ', + installRun: 'Instalar', + installChecking: 'Verificando…', + installConfirmedTitle: 'Instalar plug-in', + installConfirmedRefused: 'Este plugin não pode ser instalado', + installProblemInvalid: 'Este não é um nome ou endereço de pacote que pode ser instalado: {reason}', + installProblemInstalled: 'Este plugin já está instalado. Para atualizá-lo, desinstale-o e instale-o novamente', + installProblemShipped: 'Este plug-in é fornecido com DSH; atualizar DSH atualiza-o', + installBundledTitle: 'Este plug-in é fornecido com DSH', + installBundledDescription: 'Habilite-o sem baixar outra cópia', + installProblemNotFound: 'Nenhum plugin desse tipo foi encontrado', + installProblemNotPackage: 'O caminho não existe ou não é um pacote de plugin válido', + installProblemNotBundle: 'Este pacote não declara nenhum pacote, portanto não pode ser instalado como um plug-in: {reason}', + installProblemNetwork: 'O registro do plugin não pôde ser acessado; verifique a rede e tente novamente', + installProblemNetworkAll: 'Nenhum registro pôde ser acessado (tentativa: {registries}); verifique as configurações de rede ou proxy ou altere o registro', + installProblemUnknown: 'Não foi possível pesquisar o plug-in: {reason}', + installingTitle: 'Instalando o plug-in…', + installedTitle: 'Instalado', + statusNotInstalled: 'Não instalado', + statusMissingFiles: 'Pacote indisponível', + statusUpdateAvailable: 'Atualização disponível', + sourceNotInstalled: 'Não instalado', + sourceTargetSpec: 'Fonte para este DSH', + sourceTargetVersion: 'Versão para este DSH', + update: 'Atualizar', + updateLabel: 'Atualizar {name}', + versionsTitle: 'Versões', + versionsRecheck: 'Verifique novamente', + versionsChecking: 'Verificando atualizações', + versionsAvailable: 'Atualização para {version} disponível', + versionsPublished: 'Publicado {date}', + versionsLatest: 'Atualizado', + versionsCheckedAt: 'Última verificação em {time}', + versionsBlocked: 'Versões mais recentes precisam de uma atualização DSH', + versionsBlockedDetail: '{version} precisa de {peers}; este DSH é {runtime}', + versionsNoneInstallable: 'Nenhuma versão mais recente para instalar diretamente', + versionsNoneInstallableDetail: 'As versões mais recentes estão obsoletas, são pré-lançamentos ou não declaram nenhum pacote. Expanda para vê-los', + versionsFailedNetwork: 'Não foi possível acessar o registro', + versionsFailedOther: 'Não foi possível verificar atualizações', + versionsDetails: 'Detalhes', + versionsNewerCount: '{count} mais recente', + versionsCollapse: 'Recolher', + versionsInstalledTag: 'Instalado', + versionsPrereleaseTag: 'Pré-lançamento', + versionsDeprecatedTag: 'Obsoleto', + versionsNeedsDsh: 'Precisa de {peers}', + versionsNotBundle: 'Esta versão não declara nenhum pacote e não pode ser instalada', + versionsUpdateLabel: 'Atualizar para {version}', + versionsRegistry: 'Registro', + versionsCheckedAtLabel: 'Verificado em', + versionsFallbackTag: 'Alternativa', + versionsFallbackHint: '{registry} não pôde ser contatado; a lista vem de um registro substituto', + versionsPrimaryTag: 'Primeiro', + versionsShowPrerelease: 'Mostrar pré-lançamentos', + versionsOmitted: '{count} mais versões mais recentes que a instalada não estão listadas', + versionsProblemNetwork: 'Inacessível', + versionsProblemNotFound: 'Não encontrado', + versionsProblemUnknown: 'Falha', + updatesAvailable: 'Atualizações disponíveis: {count}', + updatesNone: 'Todos os plug-ins estão atualizados', + updatesFailed: 'Alguns plug-ins não puderam ser verificados', + updateTitle: 'Atualizar {name}', + updateSource: 'Instalar de', + updateDescription: 'A atualização substitui {from} por {to}; o plugin pode parar brevemente. Se a instalação falhar, {from} será mantido ou instalado novamente.', + updateRunning: 'Atualizando', + updateStepChecking: 'Verifique {version}', + updateStepRemoving: 'Pare e remova {version}', + updateStepInstalling: 'Instalar {version} de {registry}', + updateStepRestoring: 'Instale {version} novamente', + updateDone: 'Atualizado para {version}', + updateDoneDetail: 'A nova versão está em execução.', + updateRestart: '{version} instalado; entra em vigor após uma reinicialização', + updateRefused: 'Não é possível atualizar para {version}', + updateRemoveFailed: 'Não foi possível remover {version}', + updateRestored: 'Falha na atualização; restaurado {version}', + updateRestoredDetail: '{to} falhou ao instalar; {from} foi instalado novamente.', + updateKept: 'Não foi possível atualizar para {version}', + updateKeptDetail: '{from} não foi removido e permanece em uso. {reason}', + updateBroken: 'Falha na atualização; o plugin não está instalado', + updateBrokenDetail: '{to} falhou ao instalar e instalar {from} novamente também falhou. Instale {name} novamente em Adicionar plug-in.', + updateLost: 'O resultado da atualização é desconhecido', + updateLostDetail: 'A conexão com DSH foi interrompida: {reason}. Atualize a lista de plugins para ver a versão instalada.', + updateOutput: 'Mostrar resultado da instalação', + updateFinish: 'Concluído', + installFailedTitle: 'O plugin não pôde ser instalado', + installGithubFailedTitle: 'Não é possível acessar GitHub', + installGithubTimeoutTitle: 'GitHub conexão expirou', + installGithubFailedDescription: 'Tente outra fonte de instalação.', + installUseGithubMirror: 'Use o espelho da China continental', + installTryAnotherWay: 'Tente outra maneira', + installPackageLabel: 'Nome do pacote de plug-ins', + installEdit: 'Editar', + installEditAria: 'Voltar para edição', + installCancelAndEdit: 'Cancele a instalação e retorne à edição', + installApplyingCancellationError: 'O cancelamento não foi confirmado. A instalação está sendo aplicada; espere pelo seu resultado. {reason}', + installReconcile: 'Verifique o status da instalação', + installUnknownTitle: 'Resultado da instalação indisponível', + installUnknownDescription: 'O Host não possui instalação ativa com este ID de solicitação. Verifique a lista de plugins antes de tentar novamente.', + installResultUnconfirmed: 'O resultado da instalação não foi recebido. Verifique o status da instalação. {reason}', + installAwaitingAcceptance: 'Aguardando o Host aceitar a instalação. O cancelamento será tentado novamente automaticamente após a confirmação.', + installBackgroundUnknown: 'Resultado da instalação indisponível. Verifique a lista de plugins.', + installCancel: 'Cancelar instalação', + installCloseCancels: 'Cancelar instalação e fechar', + installViewTask: 'Ver instalação', + installUnconfirmedTitle: 'Status de instalação não confirmado', + installBackgroundDone: 'Instalação concluída. Veja detalhes da instalação.', + installBackgroundFailed: 'A instalação falhou. Veja detalhes da instalação.', + installBackgroundUnconfirmed: 'O status da instalação não foi confirmado. Veja a instalação para obter detalhes.', + installBackgroundApplying: 'A instalação está sendo aplicada e não pode ser cancelada. Veja o progresso da instalação.', + installStarting: 'Preparando a instalação…', + installCancelling: 'Parando a instalação…', + installApplying: 'Aplicando configuração, aguarde…', + installCancelledShort: 'Cancelado', + installCancelled: 'Instalação cancelada; o plugin não está habilitado e os arquivos baixados podem permanecer', + installCancelUnconfirmed: 'A instalação não foi confirmada interrompida. Tente novamente o cancelamento ou aguarde o resultado da instalação. {reason}', + installEnableNow: 'Ativar agora', + installDetailsShow: 'Mostrar detalhes da instalação', + installDetailsHide: 'Ocultar detalhes de instalação', + installVersion: 'Versão {version}', + installSubjectPath: 'Diretório local', + installSubjectGit: 'Repositório Git', + installSubjectTarball: 'Tarball', + installLocation: 'Instala em {dir}', + installRetry: 'Tentar novamente', + installChangeRegistry: 'Alterar registro', + installAttempt: '{previous} não pôde entregar o pacote; tentando novamente através de {registry} (registro {index} de {total})', + installAttemptBadge: 'Tentativa {index} · {registry}', + installFailureNetwork: 'A conexão de rede falhou', + installFailureNetworkAll: 'Nenhum registro pôde ser acessado (tentativa: {registries}). Verifique as configurações de rede ou proxy ou altere o registro e tente novamente.', + installFailureNetworkHost: '{host} não pôde ser alcançado. Um endereço GitHub ou um link .tgz não é obtido através do registro: esta máquina deve acessá-lo diretamente ou através de um proxy. Se o plugin também for publicado no npm, insira o nome do pacote.', + installFailureNotFound: 'Nenhum plugin desse tipo foi encontrado', + installFailureNoMatchingVersion: 'Nenhuma versão corresponde à solicitação', + installFailureDiskFull: 'O disco está cheio; a instalação parou', + installFailurePermission: 'Sem permissão de gravação; o plugin não pode ser instalado', + installFailureBuildBlocked: 'Os scripts de instalação de uma dependência precisam da sua permissão antes que a instalação possa continuar', + installFailureBuildBlockedManual: 'scripts de instalação bloqueados por pnpm; permita-os em permitBuilds em pnpm-workspace.yaml e tente novamente', + installFailureIntegrity: 'O pacote baixado falhou na verificação de integridade', + installFailureTimeout: 'A instalação expirou', + installFailurePnpmMissing: 'pnpm não foi encontrado, então nada pode ser instalado', + installFailureGeneric: 'Algo deu errado durante a instalação; os detalhes dizem o que', + terminalCommandLine: 'Linha de comando {n}', + terminalRunning: 'Correndo', + terminalFailed: 'Falha', + terminalDone: 'Concluído', + terminalCopy: 'Copiar', + terminalCopied: 'Copiado', + terminalNoOutput: 'Sem saída', + terminalCollapseAria: 'Recolher saída', + terminalCollapse: 'Recolher', + terminalExpandAria: 'Expanda as {n} linhas de saída restantes', + terminalExpand: '… mais {n} linhas', + terminalExitCode: 'código de saída {code}', + terminalSignal: 'sinal {signal}', + terminalNoExitCode: 'sem código de saída', + installDoneNothing: 'A instalação foi concluída sem nenhuma nova dependência.', + installDoneRestart: 'Instalado; reinicie DSH para carregá-lo. {reason}', + installDoneApproved: 'Instalar scripts permitidos para {names}', + installDoneOtherVersion: 'De acordo com a política de segurança da cadeia de fornecimento do pnpm (minimumReleaseAge), as versões publicadas mais recentemente do que a idade exigida não são instaladas automaticamente, portanto, esta instalação usou {installed} em vez da versão mais recente {version}. Uma versão recém-publicada não completou esse período de espera e pode conter códigos maliciosos ou defeitos não detectados. Se precisar de {version}, desinstale a versão atual e instale {exact} para solicitar essa versão.', + installApprovalTitle: 'Os scripts de instalação precisam de permissão', + installApprovalDescription: 'Esses pacotes possuem scripts de instalação que o pnpm não executou.', + installApprovalConsequence: 'Uma vez permitido, os scripts são executados aqui com suas permissões, e a permissão é salva neste perfil.', + installApprovalCaution: 'Permita apenas pacotes em que você confia.', + installApproveAndRetry: 'Permita esses scripts e tente novamente', + installClose: 'Concluído', + close: 'Fechar', + cancel: 'Cancelar', + confirmUninstallTitle: 'Desinstalar "{name}"?', + confirmUninstallDescription: 'O que ele fornece desaparece quando é desinstalado.', + confirmUninstall: 'Desinstalar', + failedEnable: 'Não foi possível ativar: {reason}', + failedDisable: 'Não foi possível desativar: {reason}', + failedUninstall: 'Não foi possível desinstalar: {reason}', + failedRowEnable: 'Não foi possível ativar o componente: {reason}', + failedRowDisable: 'Não foi possível desativar o componente: {reason}', + reasonManagementRequired: 'O gerenciamento de plug-ins precisa disso; ele não pode ser desligado ou desinstalado.', + reasonUnaddressable: 'O patch de perfil não pode resolver este problema exclusivamente.', + reasonUnknownPlugin: 'Não existe tal plugin.', + reasonInvalidSpec: 'Insira um nome ou endereço de pacote válido.', + reasonAlreadyInstalled: 'Este pacote já está instalado; desinstale-o antes de instalar outra versão.', + reasonAmbiguousInstall: 'Qual pacote foi instalado não pode ser informado a partir da mudança de dependência.', + reasonNotBundle: 'Este pacote não declara nenhum pacote, portanto não pode ser gerenciado como um plugin.', + reasonNotRemovable: 'Este pacote não pertence ao perfil ou o gerenciamento do plugin precisa dele.', + reasonStopProfile: 'Este perfil é executado sem HMR; pare e desinstale o pacote com o plugin dsh.', + reasonBundleInUse: 'Outras configurações ainda usam os componentes deste pacote configurável; desligue-os primeiro.', + reasonStaleApproval: 'As aprovações de script pendentes foram alteradas; instale novamente para atualizá-los.', + reasonIncompatibleVersion: '{plugin} é incompatível com DSH {runtime} (requer {requirements}); executá-lo pode causar travamentos ou perda de dados.', + reasonIncompatibleVersionUnnamed: 'Este plug-in é incompatível com a versão DSH em execução; executá-lo pode causar travamentos ou perda de dados.', + reasonIncompatibleInstall: 'Instale uma versão de plug-in compatível com este DSH.', + reasonIncompatibleInstalled: 'Desinstale-o e instale uma versão compatível com este DSH.', + reasonOperationError: 'O Host relatou um erro.', + detailTitle: 'Detalhes', + detailLoading: 'Lendo detalhes', + detailTooLarge: 'Os detalhes excedem o limite de tamanho e não foram carregados.', + detailNetwork: 'Não foi possível acessar a fonte dos detalhes.', + detailInvalid: 'O plugin declara um arquivo de detalhes inválido.', + detailFailed: 'Não foi possível ler os detalhes.', + detailFootnotes: 'Notas de rodapé', +} 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 11c04fb2c1..4117366567 100644 --- a/packages/client/ui-plugin-manager/src/client/manager-store.ts +++ b/packages/client/ui-plugin-manager/src/client/manager-store.ts @@ -113,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. */ @@ -133,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. */ @@ -247,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 @@ -554,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, @@ -748,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 }) @@ -1147,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() @@ -1160,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 } }) @@ -1399,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 @@ -1411,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..23a95913bf 100644 --- a/packages/client/ui-plugin-manager/src/client/presentation.ts +++ b/packages/client/ui-plugin-manager/src/client/presentation.ts @@ -1,5 +1,6 @@ /** Display labels and toast sentences for global plugin management. */ +import { EXPERIMENTAL_BUNDLES } from '@deepseek-ai/dsh-official-bundles' import type { IncompatiblePlugin, ManagementError, Registry, RestartReason } from '@deepseek-ai/dsh-api-remotes/client' import type { PropsLocale } from '@deepseek-ai/dsh-client-ui-slots' import type { PluginManagerLocaleKey } from './locales.ts' @@ -108,23 +109,6 @@ export function shortName(name: string): string { return unscoped.replace(/^dsh-(?:host-|client-)?/, '') } -/** Official bundles that display the Experimental badge. */ -const EXPERIMENTAL_BUNDLES: readonly string[] = [ - '@deepseek-ai/dsh-cua-bundle', - '@deepseek-ai/dsh-experimental-agent-team-profile', - '@deepseek-ai/dsh-experimental-voice-input-bundle', - '@deepseek-ai/dsh-experimental-auto-review', - '@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', - '@deepseek-ai/dsh-experimental-ralph-bundle', - '@deepseek-ai/dsh-experimental-plugin-market-bundle', -] - /** * Resolve installed package metadata without changing its technical identity. * @param pkg - package identity and local metadata. @@ -137,7 +121,7 @@ export function packageText( return { title: pkg.meta?.title === undefined ? pkg.name : resolveText(pkg.meta.title), description: pkg.meta?.description === undefined ? undefined : resolveText(pkg.meta.description) || undefined, - beta: EXPERIMENTAL_BUNDLES.includes(pkg.name), + beta: EXPERIMENTAL_BUNDLES.has(pkg.name), } } 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 83f72145d7..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 { @@ -153,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 () => { 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 bc75e20f5d..b2b196bfe1 100644 --- a/packages/client/ui-plugin-manager/tests/components.client.spec.tsx +++ b/packages/client/ui-plugin-manager/tests/components.client.spec.tsx @@ -162,6 +162,7 @@ function renderTab( } const navigation = createNavigationStore().create() const props: PluginManagerPageProps = { + createHeaderRef: vi.fn(() => vi.fn()), useStore: bindSnapshotSelector(navigation), actions: navigation.actions, ...standard, t, @@ -240,10 +241,13 @@ describe('PluginManagerPage', () => { expect(screen.queryByRole('tablist')).toBeNull() setTabs([{ id: 'discover', label: 'Discover' }]) expect(screen.getByRole('tab', { name: en.installedTab }).getAttribute('aria-selected')).toBe('true') + // The tabs stay in the pinned page head with the title; the tab panel scrolls in the page body. + expect(screen.getByRole('tablist').closest('header')?.closest('[data-plugin-bar]')).not.toBeNull() expect(document.querySelector('[data-plugin-group="bundles"]')).not.toBeNull() fireEvent.click(screen.getByRole('tab', { name: 'Discover' })) expect(navigation.getSnapshot().tab).toBe('discover') expect(screen.getByRole('tabpanel').getAttribute('aria-labelledby')).toBe(screen.getByRole('tab', { name: 'Discover' }).id) + expect(screen.getByRole('tabpanel').closest('[data-plugin-body]')).not.toBeNull() expect(document.querySelector('[data-plugin-group="bundles"]')).toBeNull() expect(screen.queryByRole('button', { name: en.refresh })).toBeNull() fireEvent.click(screen.getByRole('button', { name: 'Discover install' })) @@ -277,6 +281,22 @@ describe('PluginManagerPage', () => { expect(screen.getByRole('tabpanel', { name: 'Discover' })).toBeTruthy() }) + it('keeps the header controls in the scrolling page without remounting them', () => { + const { setTabs, props, hidePage } = renderTab({ packages: [pkg()] }) + const bar = document.querySelector('[data-plugin-bar]') + const body = document.querySelector('[data-plugin-body]')! + const refresh = screen.getByRole('button', { name: en.refresh }) + expect(bar?.parentElement).toBe(body) + expect(props.createHeaderRef).toHaveBeenCalledTimes(1) + setTabs([{ id: 'discover', label: 'Discover' }]) + expect(screen.getByRole('tablist').closest('header')?.closest('[data-plugin-bar]')).toBe(bar) + act(() => { body.scrollTop = 160; fireEvent.scroll(body) }) + expect(screen.getByRole('button', { name: en.refresh })).toBe(refresh) + expect(props.createHeaderRef).toHaveBeenCalledTimes(1) + hidePage() + expect(document.querySelector('[data-plugin-bar]')).toBeNull() + }) + it('keeps the Installed tab panel through loading and the More list out of the tabs, and returns a More plugin opened from Discover to Discover', () => { const worktree = '@deepseek-ai/dsh-experimental-tool-worktree' const { navigation, set } = renderTab({ status: 'loading', packages: [] }, {}, { @@ -740,7 +760,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 +770,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 +783,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 +1120,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 +1129,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 +1178,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 +1238,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 +1281,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 +1824,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 +2245,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 +2268,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 }) @@ -2370,7 +2459,7 @@ it.each([false, true])('shows progress only for the changing bundle and clears i }) 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) => , diff --git a/packages/client/ui-plugin-manager/tests/header-scroll.client.spec.ts b/packages/client/ui-plugin-manager/tests/header-scroll.client.spec.ts new file mode 100644 index 0000000000..432dfdcb44 --- /dev/null +++ b/packages/client/ui-plugin-manager/tests/header-scroll.client.spec.ts @@ -0,0 +1,390 @@ +// @vitest-environment jsdom +/** Deterministic layout endpoints exercise scroll geometry without jsdom's absent layout engine. */ +import { describe, expect, it, onTestFinished, vi } from 'vitest' +import { createPluginHeaderRef } from '../src/client/header-scroll.ts' + +type PartName = 'title' | 'intro' | 'toolbar' | 'tabs' +interface Box { x: number; y: number; width: number; height: number } +interface Layout { + width: number + resting: { height: number; parts: Partial> } + compact: { height: number; parts: Partial> } +} + +function box(x: number, y: number, width: number, height: number): Box { + return { x, y, width, height } +} + +function wide(tabs = true): Layout { + return { + width: 960, + resting: { + height: tabs ? 140 : 90, + parts: { + title: box(0, 28, 120, 28), intro: box(0, 60, 320, 20), toolbar: box(600, 28, 220, 32), + ...tabs ? { tabs: box(0, 100, 240, 30) } : {}, + }, + }, + compact: { + height: 64, + parts: { + title: box(0, 16, 120, 28), intro: box(0, 0, 0, 0), toolbar: box(600, 16, 220, 32), + ...tabs ? { tabs: box(0, 16, 180, 30) } : {}, + }, + }, + } +} + +function narrow(): Layout { + return { + width: 320, + resting: { + height: 210, + parts: { + title: box(0, 28, 120, 28), intro: box(0, 60, 280, 20), + toolbar: box(0, 90, 280, 32), tabs: box(0, 160, 280, 30), + }, + }, + compact: { + height: 70, + parts: { + title: box(0, 16, 120, 28), intro: box(0, 0, 0, 0), + toolbar: box(200, 16, 80, 32), tabs: box(0, 16, 160, 30), + }, + }, + } +} + +function harness() { + const fontsDescriptor = Object.getOwnPropertyDescriptor(document, 'fonts') + const fonts = new EventTarget() + const releases: (() => void)[] = [] + onTestFinished(() => { + for (const release of releases.reverse()) release() + if (fontsDescriptor === undefined) Reflect.deleteProperty(document, 'fonts') + else Object.defineProperty(document, 'fonts', fontsDescriptor) + vi.restoreAllMocks() + vi.unstubAllGlobals() + }) + Object.defineProperty(document, 'fonts', { configurable: true, value: fonts }) + + const resizes: ControlledResize[] = [] + const mutations: ControlledMutation[] = [] + class ControlledResize implements ResizeObserver { + observe = vi.fn<(target: Element, options?: ResizeObserverOptions) => void>() + unobserve = vi.fn<(target: Element) => void>() + disconnect = vi.fn<() => void>() + constructor(private readonly callback: ResizeObserverCallback) { resizes.push(this) } + deliver(): void { this.callback([], this) } + } + class ControlledMutation implements MutationObserver { + observe = vi.fn<(target: Node, options?: MutationObserverInit) => void>() + disconnect = vi.fn<() => void>() + takeRecords(): MutationRecord[] { return [] } + constructor(private readonly callback: MutationCallback) { mutations.push(this) } + deliver(): void { this.callback([], this) } + } + vi.stubGlobal('ResizeObserver', ControlledResize) + vi.stubGlobal('MutationObserver', ControlledMutation) + + function fixture(initial: Layout = wide(), ref = createPluginHeaderRef()) { + const body = document.createElement('div') + const bar = document.createElement('div') + const header = document.createElement('header') + const content = document.createElement('div') + const index = releases.length + const elements = new Map() + for (const name of Object.keys(initial.resting.parts) as PartName[]) { + const part = document.createElement('div') + part.dataset.pluginHeaderPart = name + part.id = `header-${index}-${name}` + if (name === 'toolbar' || name === 'intro') { + const button = document.createElement('button') + button.id = `header-${index}-${name}-button` + button.textContent = name + part.append(button) + } else part.textContent = name + header.append(part) + elements.set(name, part) + } + bar.append(header) + content.textContent = 'Plugin cards' + body.append(bar, content) + document.body.append(body) + releases.push(() => { ref(null); body.remove() }) + const state = { layout: initial } + const clones: HTMLDivElement[] = [] + const measurements: { + compact: boolean + inert: boolean + ariaHidden: string | null + ids: number + styles: number + natural: boolean + scrolled: boolean + connected: boolean + width: string + }[] = [] + vi.spyOn(bar, 'getBoundingClientRect').mockImplementation(() => new DOMRect(50, 100, state.layout.width, state.layout.resting.height)) + const cloneNode = bar.cloneNode.bind(bar) + vi.spyOn(bar, 'cloneNode').mockImplementation((deep) => { + const clone = cloneNode(deep) as HTMLDivElement + clones.push(clone) + const cloneHeader = clone.querySelector('header')! + // A different viewport origin ensures only header-relative coordinates drive the live controls. + vi.spyOn(cloneHeader, 'getBoundingClientRect').mockImplementation(() => { + measurements.push({ + compact: clone.hasAttribute('data-compact'), inert: clone.inert, + ariaHidden: clone.getAttribute('aria-hidden'), ids: clone.querySelectorAll('[id]').length, + styles: clone.querySelectorAll('[data-plugin-header-part][style]').length, + natural: clone.hasAttribute('data-natural'), scrolled: clone.hasAttribute('data-scrolled'), + connected: clone.isConnected, width: clone.style.width, + }) + return new DOMRect(180, 240, state.layout.width, 0) + }) + vi.spyOn(clone, 'getBoundingClientRect').mockImplementation(() => + new DOMRect(180, 240, state.layout.width, endpoint().height)) + function endpoint() { return clone.hasAttribute('data-compact') ? state.layout.compact : state.layout.resting } + for (const name of elements.keys()) { + const part = clone.querySelector(`[data-plugin-header-part="${name}"]`)! + vi.spyOn(part, 'getBoundingClientRect').mockImplementation(() => { + const bounds = endpoint().parts[name]! + return new DOMRect(180 + bounds.x, 240 + bounds.y, bounds.width, bounds.height) + }) + } + return clone + }) + function part(name: PartName): HTMLDivElement { + const element = elements.get(name) + if (element === undefined) throw new Error(`Fixture has no ${name}`) + return element + } + function scroll(top: number): void { + body.scrollTop = top + body.dispatchEvent(new Event('scroll')) + } + return { body, bar, header, content, state, clones, measurements, part, scroll, ref, attach: () => { ref(bar) } } + } + return { fonts, resizes, mutations, fixture } +} + +describe('plugin header scroll geometry', () => { + it.each([0, 12, 600])('clamps each trajectory and reverses without using a %ipx scroll range', (range) => { + const h = harness(), f = h.fixture() + Object.defineProperties(f.body, { clientHeight: { value: 500 }, scrollHeight: { value: 500 + range } }) + f.attach() + expect(f.bar.style.getPropertyValue('--plugin-header-travel')).toBe('84px') + expect(f.bar.style.getPropertyValue('--plugin-header-rest-height')).toBe('140px') + expect(f.body.style.getPropertyValue('--plugin-header-scroll-padding')).toBe('72px') + f.scroll(6) + expect(f.part('toolbar').style.translate).toBe('0px -6px') + expect(f.part('tabs').style.translate).toBe('0px -6px') + expect(f.bar.hasAttribute('data-compact')).toBe(false) + expect(f.bar.hasAttribute('data-scrolled')).toBe(true) + f.scroll(20) + expect(f.part('toolbar').style.translate).toBe('0px -12px') + expect(f.part('tabs').style.translate).toBe('0px -20px') + expect(f.bar.style.getPropertyValue('--plugin-header-paint-height')).toBe('120px') + f.scroll(83) + expect(f.bar.hasAttribute('data-compact')).toBe(false) + f.scroll(84) + expect(f.bar.hasAttribute('data-compact')).toBe(true) + expect(f.part('title').style.translate).toBe('0px -56px') + expect(f.part('intro').style.translate).toBe('0px -80px') + expect(f.part('tabs').style.translate).toBe('0px -84px') + f.scroll(1_000) + expect(f.part('tabs').style.translate).toBe('0px -84px') + expect(f.bar.style.getPropertyValue('--plugin-header-paint-height')).toBe('64px') + f.scroll(6) + expect(f.part('toolbar').style.translate).toBe('0px -6px') + expect(f.part('intro').inert).toBe(false) + expect(f.bar.hasAttribute('data-compact')).toBe(false) + f.scroll(-10) + expect(f.part('tabs').style.translate).toBe('0px 0px') + expect(f.bar.hasAttribute('data-scrolled')).toBe(false) + expect(f.bar.style.getPropertyValue('--plugin-header-paint-height')).toBe('140px') + expect(f.bar.style.getPropertyValue('--plugin-header-rest-height')).toBe('140px') + expect(f.body.scrollHeight - f.body.clientHeight).toBe(range) + expect(f.content.style.cssText).toBe('') + }) + + it('accounts for a layout scrollbar without retaining its sizing property after detachment', () => { + const h = harness(), f = h.fixture() + Object.defineProperties(f.body, { offsetWidth: { value: 960 }, clientWidth: { value: 955 } }) + f.attach() + expect(f.body.style.getPropertyValue('--plugin-page-scrollbar-width')).toBe('5px') + f.ref(null) + expect(f.body.style.getPropertyValue('--plugin-page-scrollbar-width')).toBe('') + }) + + it('interpolates narrow wrapped controls to their measured horizontal and width endpoints', () => { + const h = harness(), f = h.fixture(narrow()) + f.attach() + expect(f.part('toolbar').style.getPropertyValue('--plugin-part-y')).toBe('90px') + expect(f.part('toolbar').style.getPropertyValue('--plugin-part-width')).toBe('280px') + f.scroll(37) + expect(f.part('toolbar').style.translate).toBe('100px -37px') + expect(f.part('toolbar').style.width).toBe('180px') + f.scroll(144) + expect(f.part('toolbar').style.translate).toBe('200px -74px') + expect(f.part('toolbar').style.width).toBe('80px') + expect(f.part('tabs').style.translate).toBe('0px -144px') + expect(f.part('tabs').style.width).toBe('160px') + expect(f.bar.style.getPropertyValue('--plugin-header-paint-height')).toBe('70px') + f.scroll(0) + expect(f.part('toolbar').style.translate).toBe('0px 0px') + expect(f.part('toolbar').style.width).toBe('280px') + expect(f.part('tabs').style.width).toBe('280px') + }) + + it('clips the no-tabs intro beneath the pinned title and restores interaction when scrolling back', () => { + const h = harness(), f = h.fixture(wide(false)) + f.attach() + f.scroll(16) + expect(f.part('title').style.translate).toBe('0px -12px') + expect(f.part('intro').style.clipPath).toBe('inset(0% 0 0)') + expect(f.part('intro').inert).toBe(false) + f.scroll(26) + expect(f.part('intro').style.translate).toBe('0px -26px') + expect(f.part('intro').style.clipPath).toBe('inset(50% 0 0)') + expect(f.part('intro').inert).toBe(false) + f.scroll(36) + expect(f.part('intro').style.clipPath).toBe('inset(100% 0 0)') + expect(f.part('intro').inert).toBe(true) + expect(f.bar.hasAttribute('data-compact')).toBe(true) + f.scroll(0) + expect(f.part('intro').style.clipPath).toBe('inset(0% 0 0)') + expect(f.part('intro').inert).toBe(false) + }) + + it('scrolls the tabs intro fully out without the no-tabs clipping treatment', () => { + const h = harness(), f = h.fixture() + f.part('intro').style.clipPath = 'inset(100% 0 0)' + f.attach() + f.scroll(79) + expect(f.part('intro').style.clipPath).toBe('') + expect(f.part('intro').inert).toBe(false) + f.scroll(80) + expect(f.part('intro').style.translate).toBe('0px -80px') + expect(f.part('intro').inert).toBe(true) + f.scroll(40) + expect(f.part('intro').inert).toBe(false) + expect(f.part('intro').style.translate).toBe('0px -40px') + }) + + it.each(['resize', 'mutation', 'font'] as const)('remeasures changed endpoints on %s without replacing focused controls', (event) => { + const h = harness(), f = h.fixture() + const button = f.part('toolbar').querySelector('button')! + const clicked = vi.fn() + button.addEventListener('click', clicked) + f.attach() + f.scroll(144) + button.focus() + f.state.layout = narrow() + if (event === 'resize') h.resizes[0]!.deliver() + else if (event === 'mutation') h.mutations[0]!.deliver() + else h.fonts.dispatchEvent(new Event('loadingdone')) + expect(f.part('toolbar').style.translate).toBe('200px -74px') + expect(f.part('toolbar').style.width).toBe('80px') + expect(f.bar.style.getPropertyValue('--plugin-header-rest-height')).toBe('210px') + expect(f.body.style.getPropertyValue('--plugin-header-scroll-padding')).toBe('78px') + expect(document.activeElement).toBe(button) + expect(f.part('toolbar').querySelector('button')).toBe(button) + button.click() + expect(clicked).toHaveBeenCalledTimes(1) + expect(f.clones).toHaveLength(2) + expect(f.clones.every(clone => !clone.isConnected)).toBe(true) + expect(f.body.querySelector('[data-measuring]')).toBeNull() + expect(f.measurements.slice(-2)).toEqual([false, true].map(compact => ({ + compact, inert: true, ariaHidden: 'true', ids: 0, styles: 0, + natural: false, scrolled: false, connected: true, width: '320px', + }))) + const ids = [...f.body.querySelectorAll('[id]')].map(element => element.id) + expect(new Set(ids).size).toBe(ids.length) + expect(h.resizes[0]!.observe).toHaveBeenCalledWith(f.body) + expect(h.mutations[0]!.observe).toHaveBeenCalledWith(f.header, { + childList: true, characterData: true, subtree: true, attributes: true, attributeFilter: ['hidden'], + }) + }) + + it('removes the inert measurement copy when a compact layout read fails', () => { + const h = harness(), f = h.fixture() + const failure = new Error('Layout read interrupted') + const resting = f.state.layout.resting + Object.defineProperty(f.state, 'layout', { + get: () => ({ + width: 960, + resting, + get compact(): Layout['compact'] { throw failure }, + }), + }) + expect(() => { f.attach() }).toThrow(failure) + expect(f.clones).toHaveLength(1) + expect(f.clones[0]!.isConnected).toBe(false) + expect(f.body.querySelector('[data-measuring]')).toBeNull() + expect(h.resizes).toHaveLength(0) + expect(h.mutations).toHaveLength(0) + }) + + it('disconnects observers and listeners and ignores callbacks already queued before detachment', () => { + const h = harness(), f = h.fixture() + f.attach() + f.scroll(20) + const before = f.bar.outerHTML + f.ref(null) + expect(h.resizes[0]!.disconnect).toHaveBeenCalledTimes(1) + expect(h.mutations[0]!.disconnect).toHaveBeenCalledTimes(1) + expect(f.body.style.getPropertyValue('--plugin-header-scroll-padding')).toBe('') + f.state.layout = narrow() + f.scroll(144) + h.resizes[0]!.deliver() + h.mutations[0]!.deliver() + h.fonts.dispatchEvent(new Event('loadingdone')) + expect(f.bar.outerHTML).toBe(before) + expect(f.clones).toHaveLength(1) + f.ref(null) + expect(h.resizes[0]!.disconnect).toHaveBeenCalledTimes(1) + }) + + it('rebinds one ref without allowing the old element or its callbacks to change the replacement', () => { + const h = harness(), ref = createPluginHeaderRef(), first = h.fixture(wide(), ref), second = h.fixture(narrow(), ref) + first.attach() + first.scroll(20) + const oldMarkup = first.bar.outerHTML + second.attach() + second.scroll(37) + const newMarkup = second.bar.outerHTML + first.scroll(144) + h.resizes[0]!.deliver() + h.mutations[0]!.deliver() + expect(first.bar.outerHTML).toBe(oldMarkup) + expect(second.bar.outerHTML).toBe(newMarkup) + expect(first.body.style.getPropertyValue('--plugin-header-scroll-padding')).toBe('') + expect(second.body.style.getPropertyValue('--plugin-header-scroll-padding')).toBe('78px') + expect(first.clones).toHaveLength(1) + expect(second.clones).toHaveLength(1) + }) + + it('keeps two mounted refs independent when scrolling, remeasuring, and disposing either instance', () => { + const h = harness(), first = h.fixture(), second = h.fixture(narrow()) + first.attach() + second.attach() + first.scroll(20) + expect(second.part('toolbar').style.translate).toBe('0px 0px') + second.scroll(37) + expect(first.part('toolbar').style.translate).toBe('0px -12px') + expect(second.part('toolbar').style.translate).toBe('100px -37px') + first.ref(null) + const oldMarkup = first.bar.outerHTML + second.state.layout = wide() + h.fonts.dispatchEvent(new Event('loadingdone')) + expect(first.bar.outerHTML).toBe(oldMarkup) + expect(first.clones).toHaveLength(1) + expect(second.clones).toHaveLength(2) + expect(second.part('toolbar').style.translate).toBe('0px -12px') + expect(second.body.style.getPropertyValue('--plugin-header-scroll-padding')).toBe('72px') + expect(h.resizes[1]!.disconnect).not.toHaveBeenCalled() + second.scroll(0) + expect(second.part('toolbar').style.translate).toBe('0px 0px') + }) +}) 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 9de75d04d9..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({ diff --git a/packages/client/ui-plugin-manager/tsconfig.client.json b/packages/client/ui-plugin-manager/tsconfig.client.json index 5f8c1f5e42..eefbe67b00 100644 --- a/packages/client/ui-plugin-manager/tsconfig.client.json +++ b/packages/client/ui-plugin-manager/tsconfig.client.json @@ -9,6 +9,9 @@ { "path": "../../util/package-manifest" }, + { + "path": "../../util/official-bundles" + }, { "path": "../../../vendor/cordis" }, diff --git a/packages/client/ui-plugin-market/README.i18n.yaml b/packages/client/ui-plugin-market/README.i18n.yaml index 4ca8e13747..1fa39181d9 100644 --- a/packages/client/ui-plugin-market/README.i18n.yaml +++ b/packages/client/ui-plugin-market/README.i18n.yaml @@ -3,23 +3,23 @@ # After editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-plugin-market/README.md /: - en: af3fe4b70bddbc1b - zh: ac1c4326165266d9 + en: b77d6d729562a88f + zh: b1634cc1d027424c /deepseek-ai-dsh-client-ui-plugin-market: en: 62972eb0dff8c145 zh: 682cc03b492cd730 /deepseek-ai-dsh-client-ui-plugin-market/summary: - en: 55a3d1b12cd7be61 - zh: a961cf0212b3fda8 + en: 71328d505b984fe5 + zh: 9281e7dbaa01ef9a /deepseek-ai-dsh-client-ui-plugin-market/table-of-contents: en: 76368b6b1da79c80 zh: 52384aee991ac542 /deepseek-ai-dsh-client-ui-plugin-market/use-this-package: - en: f873bf36195df821 - zh: e13c78ba3f3e29f1 + en: 868b100459e30aad + zh: 8a3a01199dc3c14c /deepseek-ai-dsh-client-ui-plugin-market/understand-the-implementation: - en: 4107a6967934f6b6 - zh: 46acec8bedb86329 + en: e1a480caaff8efea + zh: 0b725fa5bc929f22 /deepseek-ai-dsh-client-ui-plugin-market/model-experience: en: 08d25ab9cbdd7567 zh: 26e6e75c123951ee diff --git a/packages/client/ui-plugin-market/README.md b/packages/client/ui-plugin-market/README.md index 7a2bf9ffa8..283992a8c6 100644 --- a/packages/client/ui-plugin-market/README.md +++ b/packages/client/ui-plugin-market/README.md @@ -1,5 +1,5 @@ --- -description: "Discover tab of the Web Plugins page: search configured plugin market indexes, read an entry, and install it." +description: "Discover tab of the Web Plugins page: search configured plugin indexes, read an entry, and install it." kind: "package-reference" --- @@ -9,7 +9,7 @@ English | [中文](README.zh.md) ## Summary -Discover is the second tab of the **Plugins** page, after **Installed**. It lists the bundles that plugin market indexes offer; users manage index sources, switch between markets, search, filter by category and label, sort, open an entry's page, and install an entry after confirming it. Entry pages show each entry's long description as Markdown with images. The Host [`pluginMarket`](../../boot/plugin-market/README.md) service fetches and validates indexes; this package only presents them. +Discover is the second tab of the **Plugins** page, after **Installed**. It lists the bundles that plugin indexes offer; users manage index sources, switch between indexes, search, filter by category and label, sort, open an entry's page, and install an entry after confirming it. Entry pages show each entry's long description as Markdown with images. The Host [`pluginMarket`](../../boot/plugin-market/README.md) service fetches and validates indexes; this package only presents them. ## Table of Contents @@ -24,11 +24,11 @@ Discover is the second tab of the **Plugins** page, after **Installed**. It list ## Use this package -Open **Plugins** and select **Discover**. With no index source, the tab shows an empty state whose **Add index source** button opens the **Index sources** dialog. The dialog lists each source by market title, or by host before the first read, with its URL, the error of its last failed read, and a remove button, and keeps a notice that indexes come from third parties. A refused URL keeps the draft and says why: an unsupported scheme, credentials, or fragment; a URL already listed; or a deployment that cannot save sources. A newly added source becomes the selected market. +Open **Plugins** and select **Discover**. With no index source, the tab shows an empty state whose **Add index source** button opens the **Index sources** dialog. The dialog lists each source by index title, or by host before the first read, with its URL, the error of its last failed read, and a remove button, and keeps a notice that indexes come from third parties. A refused URL keeps the draft and says why: an unsupported scheme, credentials, or fragment; a URL already listed; or a deployment that cannot save sources. A newly added source becomes the selected index. -The toolbar holds the market menu, which switches markets and ends with **Manage index sources…**; the search field; a **Labels** menu with multi-select checkmarks when the market declares labels; the **Sort** menu; and a refresh button. Category pills, with **All** first, follow when the market declares categories. +The toolbar holds the index menu, which switches indexes and ends with **Manage index sources…**; the search field; a **Labels** menu with multi-select checkmarks when the index declares labels; the **Sort** menu; and a refresh button. Category pills, with **All** first, follow when the index declares categories. -Each card shows the icon, localized title, label tags, a one-line description, and the default version, author, and publish date, with one action: **Install** for an installable entry; a disabled **Install** whose tooltip says no version is compatible with the running DSH when the plugin has no default version and its highest version is neither compatible nor exempted; or **Installed**, **Update available**, or **Provided by DSH**, which switches to the Installed tab and opens that bundle's page when that tab lists it. Selecting a card opens the entry's page with its full description, compatibility notice, and, under the source heading, the default version's facts: author; homepage and source code repository, as links that open in a new tab, with the package directory beside the repository; the npm package and registry, or the Git repository, full commit, and directory; publish date; category; labels; and market. The page shows the long description first, without a heading of its own, then **Versions**, then the source facts. The **Versions** list is in descending SemVer order; each row tags the recommended, prerelease, installed, incompatible, and exempted versions and offers **Install** for any other version installation accepts, except for a bundle DSH provides. The page's back link returns to the list with its search, filters, and sort intact. +Each card shows the icon, localized title, label tags, a one-line description, and the default version, author, and publish date, with one action: **Install** for an installable entry; a disabled **Install** whose tooltip says no version is compatible with the running DSH when the plugin has no default version and its highest version is neither compatible nor exempted; or **Installed**, **Update available**, or **Provided by DSH**, which switches to the Installed tab and opens that bundle's page when that tab lists it. Selecting a card opens the entry's page with its full description, compatibility notice, and, under the source heading, the default version's facts: author; homepage and source code repository, as links that open in a new tab, with the package directory beside the repository; the npm package and registry, or the Git repository, full commit, and directory; publish date; category; labels; and index title. The page shows the long description first, without a heading of its own, then **Versions**, then the source facts. The **Versions** list is in descending SemVer order; each row tags the recommended, prerelease, installed, incompatible, and exempted versions and offers **Install** for any other version installation accepts, except for a bundle DSH provides. The page's back link returns to the list with its search, filters, and sort intact. **Install** chooses the default version and opens a confirmation dialog with the entry's icon, title, that version, the author, its source facts, an exemption notice when one applies, and a trust notice. Confirming opens the Plugins install dialog at its check screen with the entry's exact spec and registry; there is no spec form, and a refusal offers **Retry**. The registry an entry names applies to that install only. @@ -36,7 +36,7 @@ The Host [`pluginMarket.search`](../../boot/plugin-market/README.md#use-this-pac The description section shows the default version's long description in the active UI language: the version's locale `meta.detail` file, otherwise its package README, otherwise its repository README, in the [order `pluginMarket` documents](../../boot/plugin-market/README.md#plugin-detail-documents). While the document loads, the section shows three skeleton lines; an entry without a document shows no section; a failed read names its reason, such as a size limit, an unreachable source, or a refused repository, with **Retry**. Raw HTML is omitted, and a document taller than the section's height limit scrolls inside it. Relative image paths load from the Host beside the document, absolute HTTP(S) images load directly without a referrer, and relative links stay text. The section is the shared [`DetailDocument`](../ui-primitives/README.md#component-catalog) primitive. -Reading a market shows card skeletons. A failed read shows a short sentence with **Retry**, the Host's reason appears under the source in **Index sources**, and keeps the market's last listing; a failed read of the source list shows **Retry** in place of the tab. Discover re-reads sources on `plugin-market/changed` and connection reset, and recomputes installation status on `plugin-manager/changed`. +Reading an index shows card skeletons. A failed read shows a short sentence with **Retry**, the Host's reason appears under the source in **Index sources**, and keeps the index's last listing; a failed read of the source list shows **Retry** in place of the tab. Discover re-reads sources on `plugin-market/changed` and connection reset, and recomputes installation status on `plugin-manager/changed`. ----- @@ -45,7 +45,7 @@ Reading a market shows card skeletons. A failed read shows a short sentence with Detail reads are kept in a second snapshot store keyed by index URL, entry, version, and language; a section starts its read when it renders, an npm version's read first asks the Host's registry comparison and passes `npmFileSources: ['npmmirror']` when npmmirror answered fastest, only the latest read of a key settles it, **Retry** asks the Host to read again instead of returning its cached result, and a connection reset forgets every read, because a restarted Host no longer knows the document ids its images use, so each shown section reads its document again. Image URLs resolve `api/plugin-market/detail-file` against `document.baseURI`, so they keep a deployment prefix and use the Desktop `dsh-app://app/` origin, which [`MarkdownText`](../ui-primitives/README.md) accepts for that route. -The plugin registers the `discover` entry, labelled **Discover**, into `plugins.tab`, the tab slot that [`ui-plugin-manager`](../ui-plugin-manager/README.md) declares; the owner props provide `install({ spec, registry?, title, version })` and `openPackage(name)`; Discover passes the entry's localized title and the chosen version. A React-free controller owns source reads, source edits, and the selected index's query and loaded pages in a snapshot store; selecting another market starts from the initial query. Later pages send the first page's `fetchedAt`, and a page from a replaced document reloads the list once; a second replacement fails the read. The listing keeps the query its results answer, so the empty-state wording follows the shown results while a new query loads or after it fails, and **Retry** repeats the read that failed: the first pages of a query or the next page. A change to the profile's bundles reloads every loaded page to refresh installation status, and a filter or sort key the index no longer declares leaves the query. The unsent search text, the open entry, and the confirmation are component state. Pure helpers in `presentation.ts` derive the query order from the text, drop undeclared ids, order declarations with `Intl.Collator` in the active UI language, and list source facts. Icons load lazily with `referrerpolicy="no-referrer"` and fall back to the default artwork. +The plugin registers the `discover` entry, labelled **Discover**, into `plugins.tab`, the tab slot that [`ui-plugin-manager`](../ui-plugin-manager/README.md) declares; the owner props provide `install({ spec, registry?, title, version })` and `openPackage(name)`; Discover passes the entry's localized title and the chosen version. A React-free controller owns source reads, source edits, and the selected index's query and loaded pages in a snapshot store; selecting another index starts from the initial query. Later pages send the first page's `fetchedAt`, and a page from a replaced document reloads the list once; a second replacement fails the read. The listing keeps the query its results answer, so the empty-state wording follows the shown results while a new query loads or after it fails, and **Retry** repeats the read that failed: the first pages of a query or the next page. A change to the profile's bundles reloads every loaded page to refresh installation status, and a filter or sort key the index no longer declares leaves the query. The unsent search text, the open entry, and the confirmation are component state. Pure helpers in `presentation.ts` derive the query order from the text, drop undeclared ids, order declarations with `Intl.Collator` in the active UI language, and list source facts. Icons load lazily with `referrerpolicy="no-referrer"` and fall back to the default artwork. ----- diff --git a/packages/client/ui-plugin-market/README.zh.md b/packages/client/ui-plugin-market/README.zh.md index 40b98ec314..276c1a5556 100644 --- a/packages/client/ui-plugin-market/README.zh.md +++ b/packages/client/ui-plugin-market/README.zh.md @@ -1,5 +1,5 @@ --- -description: "Web 插件页的「发现」标签页:搜索已配置的插件市场索引,查看条目并安装。" +description: "Web 插件页的「发现」标签页:搜索已配置的插件索引,查看条目并安装。" kind: "package-reference" --- @@ -9,7 +9,7 @@ kind: "package-reference" ## 概述 -「发现」是**插件**页的第二个标签页,位于**已安装**之后。它列出插件市场索引提供的组合包(bundle);用户可以管理索引源、在插件市场之间切换、搜索、按分类和标签筛选、排序、打开条目页面,并在确认后安装条目。条目页面以带图片的 Markdown 显示每个条目的详细介绍。Host 的 [`pluginMarket`](../../boot/plugin-market/README.zh.md) 服务负责获取和校验索引;本包只负责展示。 +「发现」是**插件**页的第二个标签页,位于**已安装**之后。它列出插件索引提供的组合包(bundle);用户可以管理索引源、在插件索引之间切换、搜索、按分类和标签筛选、排序、打开条目页面,并在确认后安装条目。条目页面以带图片的 Markdown 显示每个条目的详细介绍。Host 的 [`pluginMarket`](../../boot/plugin-market/README.zh.md) 服务负责获取和校验索引;本包只负责展示。 ## 目录 @@ -24,11 +24,11 @@ kind: "package-reference" ## 使用本包 -打开**插件**并选择**发现**。没有索引源时,该标签页显示空状态,其中的**添加索引源**按钮打开**索引源**对话框。对话框按插件市场标题列出每个索引源(首次读取前按主机名),并显示其 URL、最近一次读取失败的错误和删除按钮,同时保留一条提示,说明索引由第三方提供。URL 被拒绝时保留草稿并说明原因:不支持的协议、包含凭据或片段、URL 已在列表中,或当前部署无法保存索引源。新添加的索引源成为所选插件市场。 +打开**插件**并选择**发现**。没有索引源时,该标签页显示空状态,其中的**添加索引源**按钮打开**索引源**对话框。对话框按插件索引标题列出每个索引源(首次读取前按主机名),并显示其 URL、最近一次读取失败的错误和删除按钮,同时保留一条提示,说明索引由第三方提供。URL 被拒绝时保留草稿并说明原因:不支持的协议、包含凭据或片段、URL 已在列表中,或当前部署无法保存索引源。新添加的索引源成为所选插件索引。 -工具栏包含:插件市场菜单,用于切换插件市场,最后一项为**管理索引源…**;搜索框;插件市场声明了标签时显示的**标签**菜单,以勾选标记支持多选;**排序**菜单;以及刷新按钮。插件市场声明了分类时,其后显示分类按钮,**全部**排在第一位。 +工具栏包含:插件索引菜单,用于切换插件索引,最后一项为**管理索引源…**;搜索框;插件索引声明了标签时显示的**标签**菜单,以勾选标记支持多选;**排序**菜单;以及刷新按钮。插件索引声明了分类时,其后显示分类按钮,**全部**排在第一位。 -每张卡片显示图标、本地化标题、标签、一行描述,以及默认版本、作者和发布日期,并只有一个操作:可安装的条目显示**安装**;没有默认版本、且最高版本既不兼容也未豁免的条目显示禁用的**安装**,其提示框说明没有与当前运行的 DSH 兼容的版本;其余条目显示**已安装**、**有新版本**或**随 DSH 提供**,点击后切换到「已安装」标签页,并在该标签页列出该组合包时打开其页面。选择卡片会打开条目页面,显示其完整描述、兼容性提示,并在来源标题下显示默认版本的信息:作者;主页和源代码仓库(在新标签页中打开的链接,仓库旁显示包所在目录);npm 包和注册表,或 Git 仓库、完整提交和目录;发布日期;分类;标签;以及插件市场。页面先显示详细介绍,不带单独的标题,然后是**版本**,然后是来源信息。**版本**列表按 SemVer 降序排列;每行标出推荐、预发布、已安装、不兼容和已豁免的版本,并为安装检查接受的其他版本提供**安装**,DSH 随附的组合包除外。页面的返回链接回到列表,搜索、筛选和排序保持不变。 +每张卡片显示图标、本地化标题、标签、一行描述,以及默认版本、作者和发布日期,并只有一个操作:可安装的条目显示**安装**;没有默认版本、且最高版本既不兼容也未豁免的条目显示禁用的**安装**,其提示框说明没有与当前运行的 DSH 兼容的版本;其余条目显示**已安装**、**有新版本**或**随 DSH 提供**,点击后切换到「已安装」标签页,并在该标签页列出该组合包时打开其页面。选择卡片会打开条目页面,显示其完整描述、兼容性提示,并在来源标题下显示默认版本的信息:作者;主页和源代码仓库(在新标签页中打开的链接,仓库旁显示包所在目录);npm 包和注册表,或 Git 仓库、完整提交和目录;发布日期;分类;标签;以及插件索引标题。页面先显示详细介绍,不带单独的标题,然后是**版本**,然后是来源信息。**版本**列表按 SemVer 降序排列;每行标出推荐、预发布、已安装、不兼容和已豁免的版本,并为安装检查接受的其他版本提供**安装**,DSH 随附的组合包除外。页面的返回链接回到列表,搜索、筛选和排序保持不变。 **安装**选择默认版本并打开确认对话框,显示条目的图标、标题、该版本、作者、来源信息、适用时的豁免提示,以及信任提示。确认后,插件页的安装对话框以该条目的精确 spec 和注册表在检查界面打开;不显示 spec 表单,被拒绝时提供**重试**。条目指定的注册表只用于这一次安装。 @@ -36,7 +36,7 @@ Host 的 [`pluginMarket.search`](../../boot/plugin-market/README.zh.md#use-this- 详细介绍部分以当前界面语言显示默认版本的详细介绍:按 [`pluginMarket` 说明的顺序](../../boot/plugin-market/README.zh.md#plugin-detail-documents),依次为该版本 locale 中 `meta.detail` 指定的文件、其包 README、其仓库 README。文档加载期间,该部分显示三行骨架屏;没有文档的条目不显示该部分;读取失败时说明原因,例如超过大小限制、来源无法访问或仓库拒绝访问,并提供**重试**。原始 HTML 会被省略;文档高度超过该部分的高度上限时,在该部分内滚动。相对路径的图片从 Host 上该文档旁的位置加载,绝对 HTTP(S) 地址的图片不带 referrer 直接加载,相对链接保持为文本。该部分使用共享的 [`DetailDocument`](../ui-primitives/README.zh.md#component-catalog) 原语。 -读取插件市场时显示卡片骨架屏。读取失败时显示一句简短说明和**重试**,Host 给出的原因显示在**索引源**中对应索引源的下方,并保留该插件市场上一次的列表;读取索引源列表失败时,在该标签页的位置显示**重试**。「发现」在收到 `plugin-market/changed` 和连接重置时重新读取索引源,在收到 `plugin-manager/changed` 时重新计算安装状态。 +读取插件索引时显示卡片骨架屏。读取失败时显示一句简短说明和**重试**,Host 给出的原因显示在**索引源**中对应索引源的下方,并保留该插件索引上一次的列表;读取索引源列表失败时,在该标签页的位置显示**重试**。「发现」在收到 `plugin-market/changed` 和连接重置时重新读取索引源,在收到 `plugin-manager/changed` 时重新计算安装状态。 ----- @@ -45,7 +45,7 @@ Host 的 [`pluginMarket.search`](../../boot/plugin-market/README.zh.md#use-this- 详细介绍的读取结果保存在第二个快照存储中,以索引 URL、条目、版本和语言为键;该部分在渲染时开始读取;npm 版本的读取先询问 Host 的注册表比较,npmmirror 响应最快时传入 `npmFileSources: ['npmmirror']`;只有某个键最新一次的读取才会让该部分得到结果;**重试**要求 Host 重新读取,而不是返回其缓存的结果;连接重置时丢弃所有读取结果,因为重启后的 Host 不再知道其图片使用的文档 id,所以每个已显示的部分会重新读取其文档。图片 URL 基于 `document.baseURI` 解析 `api/plugin-market/detail-file`,因此保留部署前缀,并在 Desktop 中使用 `dsh-app://app/` origin;[`MarkdownText`](../ui-primitives/README.zh.md) 对该路由接受此 origin。 -本插件把标签为**发现**的 `discover` 条目注册到 `plugins.tab`,即 [`ui-plugin-manager`](../ui-plugin-manager/README.zh.md) 声明的标签页 slot;owner props 提供 `install({ spec, registry?, title, version })` 和 `openPackage(name)`;发现页传入条目的本地化标题和所选版本。一个不依赖 React 的控制器在快照存储中拥有索引源读取、索引源编辑,以及所选索引的查询和已加载的页;选择另一个插件市场时从初始查询开始。之后的页发送第一页的 `fetchedAt`,来自已替换文档的页会让列表重新加载一次;再次被替换时读取失败。列表保留其结果所回答的查询,因此新查询加载期间或失败后,空状态文案仍与显示的结果一致;**重试**重新执行失败的读取:某个查询的前几页,或下一页。profile 的组合包变化时,重新加载所有已加载的页以刷新安装状态;索引不再声明的过滤条件或排序键会从查询中移除。尚未发送的搜索文本、打开的条目和确认对话框是组件状态。`presentation.ts` 中的纯函数根据文本得出查询顺序、移除未声明的 id、使用当前界面语言的 `Intl.Collator` 对声明排序,以及列出来源信息。图标以 `referrerpolicy="no-referrer"` 延迟加载,失败时回退到默认插画。 +本插件把标签为**发现**的 `discover` 条目注册到 `plugins.tab`,即 [`ui-plugin-manager`](../ui-plugin-manager/README.zh.md) 声明的标签页 slot;owner props 提供 `install({ spec, registry?, title, version })` 和 `openPackage(name)`;发现页传入条目的本地化标题和所选版本。一个不依赖 React 的控制器在快照存储中拥有索引源读取、索引源编辑,以及所选索引的查询和已加载的页;选择另一个插件索引时从初始查询开始。之后的页发送第一页的 `fetchedAt`,来自已替换文档的页会让列表重新加载一次;再次被替换时读取失败。列表保留其结果所回答的查询,因此新查询加载期间或失败后,空状态文案仍与显示的结果一致;**重试**重新执行失败的读取:某个查询的前几页,或下一页。profile 的组合包变化时,重新加载所有已加载的页以刷新安装状态;索引不再声明的过滤条件或排序键会从查询中移除。尚未发送的搜索文本、打开的条目和确认对话框是组件状态。`presentation.ts` 中的纯函数根据文本得出查询顺序、移除未声明的 id、使用当前界面语言的 `Intl.Collator` 对声明排序,以及列出来源信息。图标以 `referrerpolicy="no-referrer"` 延迟加载,失败时回退到默认插画。 ----- diff --git a/packages/client/ui-plugin-market/locale/de.json b/packages/client/ui-plugin-market/locale/de.json new file mode 100644 index 0000000000..7575ee4faa --- /dev/null +++ b/packages/client/ui-plugin-market/locale/de.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "Plugin-Erkennung", + "description": "Konfigurierte Plugin-Indizes durchsuchen und deren Pakete installieren." + } +} 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..222f7277ed --- /dev/null +++ b/packages/client/ui-plugin-market/locale/en.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "Plugin discovery", + "description": "Browse configured plugin indexes and install their packages." + } +} diff --git a/packages/client/ui-plugin-market/locale/es.json b/packages/client/ui-plugin-market/locale/es.json new file mode 100644 index 0000000000..a9dd1b071d --- /dev/null +++ b/packages/client/ui-plugin-market/locale/es.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "Descubrimiento de complementos", + "description": "Explore los índices de complementos configurados e instale sus paquetes." + } +} diff --git a/packages/client/ui-plugin-market/locale/fr.json b/packages/client/ui-plugin-market/locale/fr.json new file mode 100644 index 0000000000..e21adf5411 --- /dev/null +++ b/packages/client/ui-plugin-market/locale/fr.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "Découverte de plugins", + "description": "Parcourez les index de plugins configurés et installez leurs paquets." + } +} 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..32e9d3c3d7 --- /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/pt.json b/packages/client/ui-plugin-market/locale/pt.json new file mode 100644 index 0000000000..6192b57fe6 --- /dev/null +++ b/packages/client/ui-plugin-market/locale/pt.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "Descoberta de plug-ins", + "description": "Navegue pelos índices de plug-ins configurados e instale seus pacotes." + } +} diff --git a/packages/client/ui-plugin-market/locale/ru.json b/packages/client/ui-plugin-market/locale/ru.json new file mode 100644 index 0000000000..c990647d59 --- /dev/null +++ b/packages/client/ui-plugin-market/locale/ru.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "Открытие плагина", + "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..f01c89d5df --- /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..36aed35a01 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, es, fr, de, ru, pt, 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, es, fr, de, ru, pt }), '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..52753bd63a 100644 --- a/packages/client/ui-plugin-market/src/client/locales.ts +++ b/packages/client/ui-plugin-market/src/client/locales.ts @@ -8,14 +8,14 @@ export const zh = { retry: '重试', close: '关闭', cancel: '取消', - emptyTitle: '还没有插件市场', - emptyDescription: '添加一个插件市场索引,即可在这里浏览和安装其中的插件', + emptyTitle: '还没有插件索引', + emptyDescription: '添加一个插件索引,即可在这里浏览和安装其中的插件', addFirstSource: '添加索引源', sourcesFailed: '无法读取索引源', manageSources: '管理索引源…', - marketMenu: '切换插件市场,当前为 {market}', + marketMenu: '切换插件索引,当前为 {market}', sourcesTitle: '索引源', - sourcesDescription: '插件市场以一个索引文件的 URL 提供,「发现」从这里列出的索引浏览插件', + sourcesDescription: '插件索引以一个索引文件的 URL 提供,「发现」从这里列出的索引浏览插件', sourcesLabel: '已添加的索引源', sourceUrl: '索引 URL', sourcePlaceholder: 'https://example.com/index.json', @@ -70,7 +70,7 @@ export const zh = { factSourceCode: '源代码', factCategory: '分类', factLabels: '标签', - factMarket: '插件市场', + factMarket: '插件索引', versionsTitle: '版本', versionsLabel: '{name} 的版本', versionDefault: '推荐', @@ -102,14 +102,14 @@ export const en = { retry: 'Retry', close: 'Close', cancel: 'Cancel', - emptyTitle: 'No plugin markets yet', - emptyDescription: 'Add a plugin market index to browse and install its plugins here.', + emptyTitle: 'No plugin indexes yet', + emptyDescription: 'Add a plugin index to browse and install its plugins here.', addFirstSource: 'Add index source', sourcesFailed: 'Could not read the index sources.', manageSources: 'Manage index sources…', - marketMenu: 'Switch plugin market, currently {market}', + marketMenu: 'Switch plugin index, currently {market}', sourcesTitle: 'Index sources', - sourcesDescription: 'A plugin market is published as the URL of one index file. Discover browses the indexes listed here.', + sourcesDescription: 'A plugin index is published as the URL of one index file. Discover browses the indexes listed here.', sourcesLabel: 'Added index sources', sourceUrl: 'Index URL', sourcePlaceholder: 'https://example.com/index.json', @@ -164,7 +164,7 @@ export const en = { factSourceCode: 'Source code', factCategory: 'Category', factLabels: 'Labels', - factMarket: 'Plugin market', + factMarket: 'Plugin index', versionsTitle: 'Versions', versionsLabel: 'Versions of {name}', versionDefault: 'Recommended', @@ -184,3 +184,549 @@ 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 + +/** Spanish dictionary, checked against the English keys. */ +export const es = { + title: 'Descubrir', + refresh: 'Actualizar', + loading: 'leyendo el índice', + retry: 'Reintentar', + close: 'Cerrar', + cancel: 'Cancelar', + emptyTitle: 'Aún no hay índices de complementos', + emptyDescription: 'Agregue un índice de complementos para explorar e instalar sus complementos aquí.', + addFirstSource: 'Agregar fuente de índice', + sourcesFailed: 'No se pudieron leer las fuentes del índice.', + manageSources: 'Administrar fuentes de índice...', + marketMenu: 'Cambiar el índice de complementos; actual: {market}', + sourcesTitle: 'Fuentes del índice', + sourcesDescription: 'Un índice de complementos se publica mediante la URL de un archivo de índice. Descubrir explora los índices enumerados aquí.', + sourcesLabel: 'Fuentes de índice agregadas', + sourceUrl: 'URL del índice', + sourcePlaceholder: 'https://example.com/index.json', + addSource: 'Añadir', + removeSource: 'Quitar', + removeSourceNamed: 'Quitar {url}', + sourcesTrust: 'Los índices provienen de terceros. Agregue solo fuentes en las que confíe: sus complementos se ejecutan con sus permisos una vez instalados.', + sourceInvalid: 'Utilice una dirección http o https sin nombre de usuario, contraseña o fragmento #.', + sourceDuplicate: 'Esta fuente de índice ya está agregada.', + sourceUnavailable: 'Las fuentes del índice no se pueden guardar aquí.', + sourceFailed: 'No se pudieron guardar las fuentes del índice.', + readFailed: 'No se pudo leer este índice.', + readInvalid: 'Este índice no tiene un formato válido.', + readChanged: 'Este índice cambió durante la carga.', + search: 'Complementos de búsqueda', + filter: 'Etiquetas', + filterCount: 'Etiquetas · {count}', + sortBy: 'Tipo: {sort}', + sortRelevance: 'mejor partido', + sortDefault: 'Orden predeterminado', + sortPublished: 'Fecha de publicación', + switchToAscending: 'Orden ascendente', + switchToDescending: 'Orden descendente', + categoriesLabel: 'Categorías', + allCategories: 'Todos', + emptyIndex: 'Este índice no incluye complementos.', + noMatches: 'Ningún complemento coincide.', + showMore: 'Mostrar más', + entryMeta: '{version} · {date}', + entryMetaAuthor: '{version} · {author} · {date}', + openEntry: 'Ver {name}', + install: 'Instalar', + installTitle: 'Instalar {name}', + installVersionTitle: 'Instalar {name} {version}', + openInstalled: 'Ver {name} en Instalado', + statusInstalled: 'Instalado', + statusUpdate: 'Actualización disponible', + statusProvided: 'Proporcionado por DSH', + incompatible: 'Esta versión no es compatible con DSH {runtime} en ejecución; requiere DSH {range}.', + noCompatibleVersion: 'Ninguna versión es compatible con DSH {runtime}', + exempted: 'Esta versión no es compatible con DSH en ejecución, pero usted establece una exención de compatibilidad para ella.', + backToList: 'Descubrir', + factSource: 'Fuente', + factPackage: 'Paquete', + factRegistry: 'Registro', + factRepository: 'Repositorio', + factCommit: 'comprometerse', + factDirectory: 'Directorio', + factPublished: 'Publicado', + factAuthor: 'Autor', + factHomepage: 'Página de inicio', + factSourceCode: 'código fuente', + factCategory: 'Categoría', + factLabels: 'Etiquetas', + factMarket: 'Índice de complementos', + versionsTitle: 'Versiones', + versionsLabel: 'Versiones de {name}', + versionDefault: 'Recomendado', + versionInstalled: 'Instalado', + versionIncompatible: 'incompatible', + versionExempted: 'exento', + versionPrerelease: 'Presentación', + confirmTitle: '¿Instalar "{name}"?', + installTrust: 'Los complementos se ejecutan en esta máquina con sus permisos. Asegúrate de confiar en el origen de este.', + detailTitle: 'Detalles', + detailLoading: 'Detalles de lectura', + detailTooLarge: 'Los detalles o su repositorio exceden el límite de tamaño y no se cargaron.', + detailNetwork: 'No se pudo llegar a la fuente de los detalles.', + detailDenied: 'No se pudo acceder al repositorio de detalles. Verifique sus credenciales de Git.', + detailGitUnavailable: 'Los detalles están en un repositorio que debe leerse con Git y Git no se pudo ejecutar. Instale Git y vuelva a intentarlo.', + detailInvalid: 'El complemento declara un archivo de detalles no válido.', + detailFailed: 'No se pudieron leer los detalles.', + footnotes: 'Notas a pie de página', +} satisfies Record + +/** French dictionary, checked against the English keys. */ +export const fr = { + title: 'Découvrez', + refresh: 'Actualiser', + loading: "Lire l'index", + retry: 'Réessayer', + close: 'Fermer', + cancel: 'Annuler', + emptyTitle: 'Aucun index de plugins pour le moment', + emptyDescription: 'Ajoutez un index de plugins pour parcourir et installer ses plugins ici.', + addFirstSource: "Ajouter une source d'index", + sourcesFailed: "Impossible de lire les sources de l'index.", + manageSources: "Gérer les sources d'index…", + marketMenu: 'Changer d’index de plugins, actuellement {market}', + sourcesTitle: "Sources d'index", + sourcesDescription: 'Un index de plugins est publié à l’URL d’un fichier d’index. Découvrir parcourt les index répertoriés ici.', + sourcesLabel: "Sources d'index ajoutées", + sourceUrl: "URL de l'index", + sourcePlaceholder: 'https://example.com/index.json', + addSource: 'Ajouter', + removeSource: 'Supprimer', + removeSourceNamed: 'Supprimer {url}', + sourcesTrust: "Les index proviennent de tiers. Ajoutez uniquement des sources auxquelles vous faites confiance : leurs plugins s'exécutent avec vos autorisations une fois installés.", + sourceInvalid: "Utilisez une adresse http ou https sans nom d'utilisateur, mot de passe ou fragment #.", + sourceDuplicate: "Cette source d'index est déjà ajoutée.", + sourceUnavailable: "Les sources d'index ne peuvent pas être enregistrées ici.", + sourceFailed: "Impossible d'enregistrer les sources de l'index.", + readFailed: 'Impossible de lire cet index.', + readInvalid: "Cet index n'est pas dans un format valide.", + readChanged: 'Cet index a changé lors du chargement.', + search: 'Plugins de recherche', + filter: 'Étiquettes', + filterCount: 'Étiquettes · {count}', + sortBy: 'Trier : {sort}', + sortRelevance: 'Meilleure correspondance', + sortDefault: 'Ordre par défaut', + sortPublished: 'Date de publication', + switchToAscending: 'Trier par ordre croissant', + switchToDescending: 'Trier par ordre décroissant', + categoriesLabel: 'Catégories', + allCategories: 'Tout', + emptyIndex: 'Cet index ne répertorie aucun plugin.', + noMatches: 'Aucun plugin ne correspond.', + showMore: 'Afficher plus', + entryMeta: '{version} · {date}', + entryMetaAuthor: '{version} · {author} · {date}', + openEntry: 'Voir {name}', + install: 'Installer', + installTitle: 'Installer {name}', + installVersionTitle: 'Installer {name} {version}', + openInstalled: 'Afficher {name} dans Installé', + statusInstalled: 'Installé', + statusUpdate: 'Mise à jour disponible', + statusProvided: 'Fourni par DSH', + incompatible: "Cette version n'est pas compatible avec le DSH {runtime} en cours d'exécution ; cela nécessite DSH {range}.", + noCompatibleVersion: "Aucune version n'est compatible avec DSH {runtime}", + exempted: "Cette version n'est pas compatible avec le DSH en cours d'exécution, mais vous avez défini une exemption de compatibilité pour celui-ci.", + backToList: 'Découvrez', + factSource: 'Source', + factPackage: 'Forfait', + factRegistry: 'Registre', + factRepository: 'Référentiel', + factCommit: "S'engager", + factDirectory: 'Annuaire', + factPublished: 'Publié', + factAuthor: 'Auteur', + factHomepage: "Page d'accueil", + factSourceCode: 'Code source', + factCategory: 'Catégorie', + factLabels: 'Étiquettes', + factMarket: 'Index de plugins', + versionsTitle: 'Versions', + versionsLabel: 'Versions de {name}', + versionDefault: 'Recommandé', + versionInstalled: 'Installé', + versionIncompatible: 'Incompatible', + versionExempted: 'Exonéré', + versionPrerelease: 'Avant-première', + confirmTitle: 'Installer « {name} » ?', + installTrust: "Les plugins s'exécutent sur cette machine avec vos autorisations. Assurez-vous de faire confiance à l'origine de celui-ci.", + detailTitle: 'Détails', + detailLoading: 'Détails de lecture', + detailTooLarge: "Les détails ou leur référentiel dépassent la limite de taille et n'ont pas été chargés.", + detailNetwork: "Impossible d'atteindre la source des détails.", + detailDenied: "Impossible d'accéder au référentiel des détails. Vérifiez vos informations d'identification Git.", + detailGitUnavailable: "Les détails se trouvent dans un référentiel qui doit être lu avec Git, et Git n'a pas pu s'exécuter. Installez Git, puis réessayez.", + detailInvalid: 'Le plugin déclare un fichier de détails invalide.', + detailFailed: 'Impossible de lire les détails.', + footnotes: 'Notes de bas de page', +} satisfies Record + +/** German dictionary, checked against the English keys. */ +export const de = { + title: 'Entdecken', + refresh: 'Aktualisieren', + loading: 'Den Index lesen', + retry: 'Erneut versuchen', + close: 'Schließen', + cancel: 'Abbrechen', + emptyTitle: 'Noch keine Plugin-Indizes', + emptyDescription: 'Einen Plugin-Index hinzufügen, um dessen Plugins hier zu durchsuchen und zu installieren.', + addFirstSource: 'Indexquelle hinzufügen', + sourcesFailed: 'Die Indexquellen konnten nicht gelesen werden.', + manageSources: 'Indexquellen verwalten…', + marketMenu: 'Plugin-Index wechseln, aktuell {market}', + sourcesTitle: 'Indexquellen', + sourcesDescription: 'Ein Plugin-Index wird über die URL einer Indexdatei veröffentlicht. Entdecken durchsucht die hier aufgeführten Indizes.', + sourcesLabel: 'Indexquellen hinzugefügt', + sourceUrl: 'Index-URL', + sourcePlaceholder: 'https://example.com/index.json', + addSource: 'Hinzufügen', + removeSource: 'Entfernen', + removeSourceNamed: 'Entfernen Sie {url}', + sourcesTrust: 'Indizes stammen von Dritten. Fügen Sie nur Quellen hinzu, denen Sie vertrauen: Ihre Plugins werden nach der Installation mit Ihren Berechtigungen ausgeführt.', + sourceInvalid: 'Verwenden Sie eine http- oder https-Adresse ohne Benutzernamen, Passwort oder #-Fragment.', + sourceDuplicate: 'Diese Indexquelle wurde bereits hinzugefügt.', + sourceUnavailable: 'Indexquellen können hier nicht gespeichert werden.', + sourceFailed: 'Die Indexquellen konnten nicht gespeichert werden.', + readFailed: 'Dieser Index konnte nicht gelesen werden.', + readInvalid: 'Dieser Index hat kein gültiges Format.', + readChanged: 'Dieser Index hat sich beim Laden geändert.', + search: 'Such-Plugins', + filter: 'Etiketten', + filterCount: 'Etiketten · {count}', + sortBy: 'Sortierung: {sort}', + sortRelevance: 'Beste Übereinstimmung', + sortDefault: 'Standardreihenfolge', + sortPublished: 'Veröffentlichungsdatum', + switchToAscending: 'Aufsteigend sortieren', + switchToDescending: 'Absteigend sortieren', + categoriesLabel: 'Kategorien', + allCategories: 'Alle', + emptyIndex: 'Dieser Index listet keine Plugins auf.', + noMatches: 'Es stimmen keine Plugins überein.', + showMore: 'Mehr anzeigen', + entryMeta: '{version} · {date}', + entryMetaAuthor: '{version} · {author} · {date}', + openEntry: 'Siehe {name}', + install: 'Installieren', + installTitle: 'Installieren Sie {name}', + installVersionTitle: 'Installieren Sie {name} {version}', + openInstalled: '{name} unter „Installiert“ anzeigen', + statusInstalled: 'Installiert', + statusUpdate: 'Update verfügbar', + statusProvided: 'Bereitgestellt von DSH', + incompatible: 'Diese Version ist nicht mit dem laufenden DSH {runtime} kompatibel; es erfordert DSH {range}.', + noCompatibleVersion: 'Keine Version ist mit DSH {runtime} kompatibel', + exempted: 'Diese Version ist nicht mit der laufenden Version DSH kompatibel, Sie haben jedoch eine Kompatibilitätsausnahme dafür festgelegt.', + backToList: 'Entdecken', + factSource: 'Quelle', + factPackage: 'Paket', + factRegistry: 'Registrierung', + factRepository: 'Repository', + factCommit: 'Begehen', + factDirectory: 'Verzeichnis', + factPublished: 'Veröffentlicht', + factAuthor: 'Autor', + factHomepage: 'Homepage', + factSourceCode: 'Quellcode', + factCategory: 'Kategorie', + factLabels: 'Etiketten', + factMarket: 'Plugin-Index', + versionsTitle: 'Versionen', + versionsLabel: 'Versionen von {name}', + versionDefault: 'Empfohlen', + versionInstalled: 'Installiert', + versionIncompatible: 'Inkompatibel', + versionExempted: 'Ausgenommen', + versionPrerelease: 'Vorabversion', + confirmTitle: '„{name}“ installieren?', + installTrust: 'Plugins werden mit Ihren Berechtigungen auf diesem Computer ausgeführt. Stellen Sie sicher, dass Sie darauf vertrauen, woher dieser kommt.', + detailTitle: 'Details', + detailLoading: 'Details lesen', + detailTooLarge: 'Die Details oder ihr Repository überschreiten die Größenbeschränkung und wurden nicht geladen.', + detailNetwork: 'Die Quelle der Details konnte nicht erreicht werden.', + detailDenied: 'Auf das Repository mit den Details konnte nicht zugegriffen werden. Überprüfen Sie Ihre Git-Anmeldeinformationen.', + detailGitUnavailable: 'Die Details befinden sich in einem Repository, das mit Git gelesen werden muss, und Git konnte nicht ausgeführt werden. Installieren Sie Git und versuchen Sie es erneut.', + detailInvalid: 'Das Plugin deklariert eine ungültige Detaildatei.', + detailFailed: 'Die Details konnten nicht gelesen werden.', + footnotes: 'Fußnoten', +} satisfies Record + +/** Russian dictionary, checked against the English keys. */ +export const ru = { + title: 'Откройте для себя', + refresh: 'Обновить', + loading: 'Чтение индекса', + retry: 'Повторить', + close: 'Закрыть', + cancel: 'Отмена', + emptyTitle: 'Индексы плагинов ещё не добавлены', + emptyDescription: 'Добавьте индекс плагинов, чтобы просматривать и устанавливать его плагины здесь.', + addFirstSource: 'Добавить источник индекса', + sourcesFailed: 'Не удалось прочитать исходники индекса.', + manageSources: 'Управление источниками индексов…', + marketMenu: 'Переключить индекс плагинов, текущий: {market}', + sourcesTitle: 'Источники индекса', + sourcesDescription: 'Индекс плагинов публикуется по 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 + +/** Portuguese dictionary, checked against the English keys. */ +export const pt = { + title: 'Descubra', + refresh: 'Atualizar', + loading: 'Lendo o índice', + retry: 'Tentar novamente', + close: 'Fechar', + cancel: 'Cancelar', + emptyTitle: 'Ainda não há índices de plug-ins', + emptyDescription: 'Adicione um índice de plug-ins para navegar e instalar seus plug-ins aqui.', + addFirstSource: 'Adicionar fonte de índice', + sourcesFailed: 'Não foi possível ler as fontes do índice.', + manageSources: 'Gerenciar fontes de índice…', + marketMenu: 'Mudar de índice de plug-ins, atual: {market}', + sourcesTitle: 'Fontes de índice', + sourcesDescription: 'Um índice de plug-ins é publicado pela URL de um arquivo de índice. Descobrir navega pelos índices listados aqui.', + sourcesLabel: 'Fontes de índice adicionadas', + sourceUrl: 'URL do índice', + sourcePlaceholder: 'https://example.com/index.json', + addSource: 'Adicionar', + removeSource: 'Remover', + removeSourceNamed: 'Remover {url}', + sourcesTrust: 'Os índices vêm de terceiros. Adicione apenas fontes em que você confia: seus plug-ins são executados com suas permissões, uma vez instalados.', + sourceInvalid: 'Use um endereço http ou https sem nome de usuário, senha ou fragmento #.', + sourceDuplicate: 'Esta fonte de índice já foi adicionada.', + sourceUnavailable: 'As fontes do índice não podem ser salvas aqui.', + sourceFailed: 'Não foi possível salvar as fontes do índice.', + readFailed: 'Não foi possível ler este índice.', + readInvalid: 'Este índice não está em um formato válido.', + readChanged: 'Este índice mudou durante o carregamento.', + search: 'Plug-ins de pesquisa', + filter: 'Etiquetas', + filterCount: 'Marcadores · {count}', + sortBy: 'Classificar: {sort}', + sortRelevance: 'Melhor correspondência', + sortDefault: 'Ordem padrão', + sortPublished: 'Data de publicação', + switchToAscending: 'Classificar em ordem crescente', + switchToDescending: 'Classificar em ordem decrescente', + categoriesLabel: 'Categorias', + allCategories: 'Todos', + emptyIndex: 'Este índice não lista plug-ins.', + noMatches: 'Nenhum plug-in corresponde.', + showMore: 'Mostrar mais', + entryMeta: '{version} · {date}', + entryMetaAuthor: '{version} · {author} · {date}', + openEntry: 'Ver {name}', + install: 'Instalar', + installTitle: 'Instalar {name}', + installVersionTitle: 'Instalar {name} {version}', + openInstalled: 'Ver {name} em Instalado', + statusInstalled: 'Instalado', + statusUpdate: 'Atualização disponível', + statusProvided: 'Fornecido por DSH', + incompatible: 'Esta versão não é compatível com o DSH {runtime} em execução; requer DSH {range}.', + noCompatibleVersion: 'Nenhuma versão é compatível com DSH {runtime}', + exempted: 'Esta versão não é compatível com o DSH em execução, mas você definiu uma isenção de compatibilidade para ela.', + backToList: 'Descubra', + factSource: 'Fonte', + factPackage: 'Pacote', + factRegistry: 'Registro', + factRepository: 'Repositório', + factCommit: 'Confirmar', + factDirectory: 'Diretório', + factPublished: 'Publicado', + factAuthor: 'Autor', + factHomepage: 'Página inicial', + factSourceCode: 'Código fonte', + factCategory: 'Categoria', + factLabels: 'Etiquetas', + factMarket: 'Índice de plug-ins', + versionsTitle: 'Versões', + versionsLabel: 'Versões de {name}', + versionDefault: 'Recomendado', + versionInstalled: 'Instalado', + versionIncompatible: 'Incompatível', + versionExempted: 'Isento', + versionPrerelease: 'Pré-lançamento', + confirmTitle: 'Instalar "{name}"?', + installTrust: 'Os plug-ins são executados nesta máquina com suas permissões. Certifique-se de confiar de onde este vem.', + detailTitle: 'Detalhes', + detailLoading: 'Lendo detalhes', + detailTooLarge: 'Os detalhes ou seu repositório excedem o limite de tamanho e não foram carregados.', + detailNetwork: 'Não foi possível acessar a fonte dos detalhes.', + detailDenied: 'Não foi possível acessar o repositório dos detalhes. Verifique suas credenciais do Git.', + detailGitUnavailable: 'Os detalhes estão em um repositório que deve ser lido com o Git, e o Git não pôde ser executado. Instale o Git e tente novamente.', + detailInvalid: 'O plugin declara um arquivo de detalhes inválido.', + detailFailed: 'Não foi possível ler os detalhes.', + footnotes: 'Notas de rodapé', +} satisfies Record diff --git a/packages/client/ui-plugin-market/tests/discover.client.spec.tsx b/packages/client/ui-plugin-market/tests/discover.client.spec.tsx index 88ee3e6cd3..965275e9f8 100644 --- a/packages/client/ui-plugin-market/tests/discover.client.spec.tsx +++ b/packages/client/ui-plugin-market/tests/discover.client.spec.tsx @@ -50,7 +50,7 @@ const LISTING: MarketSearchResult = { fetchedAt: 1, total: 6, nextOffset: null, - title: { en: 'Alpha market', zh: '甲市场' }, + title: { en: 'Alpha index', zh: '甲索引' }, metadata: { categories: { tools: { title: 'Tools' }, themes: { title: 'Themes' } }, labels: { official: { title: 'Official' }, beta: { title: 'Beta' } }, @@ -231,7 +231,7 @@ describe('DiscoverSection', () => { expect(screen.getByText(en.emptyIndex)).toBeTruthy() }) - it('sorts by publish date, a market sort key, either direction, and back to the index order', () => { + it('sorts by publish date, an index sort key, either direction, and back to the index order', () => { const { actions, answer } = renderTab() openMenu(/^Sort: Default order/) pick(en.sortPublished) @@ -256,7 +256,7 @@ describe('DiscoverSection', () => { answer(INITIAL_QUERY) openMenu(/^Sort: Default order/) expect(screen.queryByRole('menuitem', { name: en.switchToAscending })).toBeNull() - for (const name of [/^Sort:/, en.filter, /^Switch plugin market/]) { + for (const name of [/^Sort:/, en.filter, /^Switch plugin index/]) { fireEvent.keyDown(document.activeElement ?? document.body, { key: 'Escape' }) expect(screen.queryByRole('menu')).toBeNull() openMenu(name) @@ -264,17 +264,17 @@ describe('DiscoverSection', () => { } fireEvent.keyDown(document.activeElement ?? document.body, { key: 'Escape' }) expect(screen.queryByRole('menu')).toBeNull() - openMenu(/^Switch plugin market/) - openMenu(/^Switch plugin market/) + openMenu(/^Switch plugin index/) + openMenu(/^Switch plugin index/) expect(screen.queryByRole('menu')).toBeNull() }) - it('switches markets and opens source management from the market menu', () => { + it('switches indexes and opens source management from the index menu', () => { const { actions } = renderTab() - openMenu('Switch plugin market, currently Alpha market') + openMenu('Switch plugin index, currently Alpha index') pick('b.example') expect(actions.select).toHaveBeenCalledWith(B) - openMenu(/^Switch plugin market/) + openMenu(/^Switch plugin index/) pick(en.manageSources) expect(actions.toggleSources).toHaveBeenCalledOnce() }) @@ -287,7 +287,7 @@ describe('DiscoverSection', () => { expect(detail.getByRole('status', { name: en.detailLoading })).toBeTruthy() expect(detail.getByRole('heading', { name: 'Alpha' })).toBeTruthy() expect(detail.getByText('dsh-a')).toBeTruthy() - for (const value of ['Jane', 'dsh-a@1.0.0', 'https://r.example/', 'Tools', 'Official', 'Beta', 'Alpha market']) expect(detail.getByText(value)).toBeTruthy() + for (const value of ['Jane', 'dsh-a@1.0.0', 'https://r.example/', 'Tools', 'Official', 'Beta', 'Alpha index']) expect(detail.getByText(value)).toBeTruthy() const homepage = detail.getByRole('link', { name: 'https://example.com/alpha#readme' }) expect([homepage.getAttribute('href'), homepage.getAttribute('target'), homepage.getAttribute('rel')]) .toEqual(['https://example.com/alpha#readme', '_blank', 'noopener noreferrer']) @@ -420,14 +420,14 @@ describe('DiscoverSection', () => { expect(screen.getByRole('status', { name: en.loading })).toBeTruthy() expect(screen.getByRole('button', { name: en.refresh }).hasAttribute('disabled')).toBe(true) set({ listing: { url: B, query: INITIAL_QUERY, status: 'ready', value: { ...LISTING, url: B, entries: [], total: 0 }, answered: INITIAL_QUERY, failure: null } }) - expect(screen.getByRole('button', { name: 'Switch plugin market, currently b.example' })).toBeTruthy() + expect(screen.getByRole('button', { name: 'Switch plugin index, currently b.example' })).toBeTruthy() }) it('manages sources in a dialog with the trust notice and each refusal', () => { const { actions, set } = renderTab({ editing: true, draft: 'https://c.example/index.json', indexes: [{ url: A, title: LISTING.title }, { url: B, error: 'HTTP 404' }] }) const dialog = within(screen.getByRole('dialog', { name: en.sourcesTitle })) expect(dialog.getByText(en.sourcesTrust)).toBeTruthy() - expect(dialog.getByText('Alpha market')).toBeTruthy() + expect(dialog.getByText('Alpha index')).toBeTruthy() expect(dialog.getByText(B)).toBeTruthy() expect(dialog.getByText('HTTP 404')).toBeTruthy() fireEvent.click(dialog.getByRole('button', { name: `Remove ${B}` })) @@ -453,7 +453,7 @@ describe('DiscoverSection', () => { it('renders the Chinese copy and dates', () => { renderTab({}, zh) - expect(screen.getByRole('button', { name: '切换插件市场,当前为 甲市场' })).toBeTruthy() + expect(screen.getByRole('button', { name: '切换插件索引,当前为 甲索引' })).toBeTruthy() expect(card('dsh-a').getByText('甲')).toBeTruthy() expect(card('dsh-a').getByText(/^1\.0\.0 · Jane · 2026年9月/)).toBeTruthy() expect(card('dsh-b').getByText(zh.statusInstalled)).toBeTruthy() diff --git a/packages/client/ui-primitives/README.i18n.yaml b/packages/client/ui-primitives/README.i18n.yaml index c4b3fe6eb0..16cef349a8 100644 --- a/packages/client/ui-primitives/README.i18n.yaml +++ b/packages/client/ui-primitives/README.i18n.yaml @@ -15,26 +15,26 @@ en: d152484eb41ac6b4 zh: 09388d293f9be9cb /deepseek-ai-dsh-client-ui-primitives/use-this-package: - en: d2f5701274be5110 - zh: d60e2617136a6f26 + en: 10be7f75a1576575 + zh: abf2905d89b0da48 /deepseek-ai-dsh-client-ui-primitives/use-this-package/component-catalog: - en: cb874a0ca53a6747 - zh: 31179193dd1d6e12 + en: 3c79351ff2210ccb + zh: 4cbe07ded89bbaa1 /deepseek-ai-dsh-client-ui-primitives/use-this-package/controls-and-icons: - en: 35403eb2a55cc77d - zh: 8f4466f7e15ae473 + en: b3744373b4b2c1fb + zh: 2dbde8ee0bfa5f28 /deepseek-ai-dsh-client-ui-primitives/use-this-package/rendering-agent-output: - en: 3a31ad8de6dd0af8 - zh: 6b22881a929c33df + en: 31ed1a6239427512 + zh: cb489d77de109d3f /deepseek-ai-dsh-client-ui-primitives/use-this-package/localizing-copy: - en: 4aef65dea11de083 - zh: abc22cd7c65cdf38 + en: a943603b74080ef5 + zh: 43f7a0188aff271b /deepseek-ai-dsh-client-ui-primitives/understand-the-implementation: en: 846ac0f563a53a64 zh: bb89d7647bdcc1ab /deepseek-ai-dsh-client-ui-primitives/understand-the-implementation/source-map: - en: 44eb32370cbeafe8 - zh: 728009c66375c6bb + en: fd510d34404eb090 + zh: d05b0890db929ae6 /deepseek-ai-dsh-client-ui-primitives/understand-the-implementation/input-modality: en: cb61e1a37d6c27e5 zh: 1f3fa79dd5ab2e45 @@ -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: 5a1e9d041b556b0e + zh: c66b4e7d5f77162d /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 4bfca87305..5fbf36b3b1 100644 --- a/packages/client/ui-primitives/README.md +++ b/packages/client/ui-primitives/README.md @@ -27,6 +27,8 @@ Use `dsh-client-ui-primitives` to build web-client controls and render agent out `Toast` uses the theme’s toast background and label colors in both light and dark modes. `Menu` data rows and `MenuItemButton` component rows accept owner-provided effective shortcuts and align their keys at the trailing edge as muted text without a background, including submenus. Menus hide these hints when the primary pointer is coarse (`pointer: coarse`) and [keyboard navigation](#input-modality) is inactive. Keyboard navigation reveals them until the next pointer press; keyboard bindings remain active. `ShortcutKeys` defaults to unboxed keys for menu and inline hints. Its `tooltip` variant uses lighter keycaps over the dark bubble and groups plus-separated combinations into one filled block. `Tooltip.shortcutKeys` vertically centers keycaps beside optional localized action text, or shows only keys when the label is empty. The optional `className` supplies owner interaction styles. `Modal` uses the frame’s shared overlay inset, with a minimum 24px viewport margin. Its mask leaves the Windows caption unpainted while the full-viewport layer blocks background clicks. `Modal` and the settings shell share top-layer Escape and Tab handling through `useModalLayer`, restoring the previous focus on close. `closeTopModal(document)` requests the foreground modal’s current close action; newer menus or unregistered dialogs block dismissal of the modal behind them. Components use `isBehindModal` to avoid moving focus behind a nested dialog. Menus consume their local Escape before modal dismissal. `observeComposition` supplies the same composition-end and legacy IME guard to local modal and recording handlers; callers dispose its document listeners with their interaction lifetime. +`isMobileDevice()` identifies iOS and Android phones and tablets for automatic text-input focus and keyboard dismissal. It recognizes `Android`, `iPhone`, `iPad`, or `iPod` in the UA, and iPads reporting `MacIntel` with more than one touch point. Other devices, including Windows touchscreen computers, return false regardless of window width. The function caches its first result for all callers in the page; component remounts reuse that result, and reloading the page reads the browser environment again. Identified mobile devices require explicit text-input focus. Browser modes or custom UAs that conceal the mobile platform can prevent identification; unrecognized UAs return false. A desktop UA alone cannot distinguish Android tablets from Linux computers. + Automatic modal entry and modal/menu return focus, including after Escape and application close shortcuts, use `focusWithoutRing(element, options?)` to suppress the outline until Tab or directional navigation resumes normal focus styling. Dialog containers keep their outline-free styling. Existing borders, shadows, and error states remain intact. Mark a dialog's initial control with `data-modal-autofocus` so the modal captures its invoking control before moving focus. Controls mounted with the dialog must not use React `autoFocus`, which runs before that capture. Tab and Shift+Tab from the dialog container enter its first and last focusable controls. This package is a Web-shell build input. Its static ESM retains third-party imports and styles for Vite; independent consumers supply its development dependencies ([dependency rules](../AGENTS.md#dependency-declaration)). @@ -46,20 +48,24 @@ Check this table before writing a control in a feature package. A plugin cannot | `Checkbox` | Labeled native checkbox with controlled state, keyboard interaction, and disabled styling; the caller supplies localized `label` text. | | `Input` | Single-line text entry for search boxes and inline forms. Its ref targets the native input for focus and is cleared on unmount. | | `InlineEditor` | Autofocused multi-line inline editor for short drafts such as queued messages and goals. It grows with its text and its width up to six lines, then scrolls; Enter saves, Shift+Enter breaks the line, Enter during IME composition does nothing, and Escape cancels. The caller supplies the localized `label` and rebinds the scrollbar on an elevated surface. | -| `Menu`, `MenuItemButton` | Dropdown of `items` data rows, separators, and group labels, with nested submenus; `children` adds component rows, each a `MenuItemButton` (`separatorBefore` starts a new group), in the same list. Every row shares the styling, the keyboard walk, and the focus return; closing stays the owner's state change for both kinds. While open, ↑/↓ (with Home and End) walk the list, Tab settles the focused row, and Escape or Shift+Tab close back to the anchor; selecting a row also returns the keyboard to the anchor unless the owner moved it itself. Only a keyboard on the anchor or inside the list is intercepted, and `autoFocus` decides solely whether opening focuses the first row. | +| `ZoomControls`, `DOCUMENT_ZOOM` | Floating document zoom controls with localized labels, fixed presets, and visibility/activity callbacks. An omitted `fit` hides the fitting row. The toolbar carries `data-document-zoom-controls`, plus `data-document-zoom-visible` while visible. An owner `className` overrides `bottom` and `max-width` regardless of stylesheet order; `inset-inline-start` and `transform` center the toolbar and move it on enter and exit, `opacity` fades it, and all three stay with the primitive. Renderers share the published scale limits. | +| `Menu`, `MenuItemButton` | Dropdown of `items` data rows, separators, and group labels, with nested submenus; `children` adds component rows, each a `MenuItemButton` (`separatorBefore` starts a new group), in the same list. `MenuItemButton.current` exposes the current page or view through `aria-current` without changing selection or appearance. Every row shares the styling, the keyboard walk, and the focus return; closing stays the owner's state change for both kinds. While open, ↑/↓ (with Home and End) walk the list, Tab settles the focused row, and Escape or Shift+Tab close back to the anchor; selecting a row also returns the keyboard to the anchor unless the owner moved it itself. Only a keyboard on the anchor or inside the list is intercepted, and `autoFocus` decides solely whether opening focuses the first row. | | `MenuGroup`, `observeStickyMenuGroups` | Localized, accessible groups for custom menus and listboxes, with shared sticky-heading styling and native intersection/size observation; the caller owns the observer's lifetime. | | `Pill` | Selectable capsule button for view switchers and filters; takes `active` and `onClick`. | -| `SegmentedTabs` | Controlled equal-width tabs with a sliding indicator and Left/Right, Home, and End navigation. The caller supplies labels, tab/panel ids, and panel content. | +| `SegmentedTabs` | Controlled equal-width tabs with a sliding indicator and Left/Right, Home, and End navigation. The caller supplies labels, tab/panel ids, and panel content. Default `size="md"` is 42px tall with a 4px inset; `size="sm"` is 34px tall with a 2px inset. | | `Tag` | Read-only capsule badge; `tone` selects one of eight palettes. | | `PathLabel` | Single-line file path with subdued directories, a primary filename, and the full path on hover. Fitting paths align left; clipped paths preserve their suffix with a left-edge fade that updates on path and size changes. | | `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. | +| `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 +73,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. | @@ -88,21 +96,23 @@ Writing your own component in your own package is fine when the need is genuinel ### Controls and icons -The catalog above lists what each export is for; this section covers the behavior that props alone do not show. Product icon names omit artboard sizes and end in `Regular` for the supplied one-pixel artwork or `Medium` for the same geometry at a 1.3px stroke; the `size` prop controls rendered dimensions. Named components keep the chosen weight visible at import sites; the library owns stroke geometry instead of consumer CSS overrides. Each product, reference, link, and permission glyph intentionally keeps both weight exports even when the current product uses only one, so callers can choose emphasis without adding another API later; fill-only pairs render identically. `IconWarningOutlineRegular`/`Medium` use a circle; `IconWarningTriangleOutlineRegular`/`Medium` use a rounded triangle. `FishLogo` and `BrandWordmark` fill brand slots. `FileTypeIcon` renders the traditional 28px spreadsheet, folder, HTML, image, Markdown, generic, PDF, PPT, video, and Word glyphs, and uses the imported square technology artwork for the established 48 code and configuration categories. The import replaces artwork only: archive-only categories do not extend `CodeFileType`. `classifyFileType` applies exact filename, prefix, suffix, optional project-context, and extension rules in that order; React names win over TypeScript/JavaScript, Angular suffixes win over their base extension, and a Dart file becomes Flutter only when the supplied project files contain a `pubspec.yaml` with `flutter:`. Markdown and SVG remain traditional Markdown and image files. Spreadsheet mappings include CSV, TSV, Excel workbooks and templates, OpenDocument spreadsheets, and Numbers; KEY maps to slides, while RTF/ODT/Pages map to documents. `fileExtension` exposes the same basename and final-dot parsing for adjacent metadata labels. Traditional glyphs use a solid category-colored sheet with a white mark and translucent white corner; the generic code glyph uses angle brackets and a slash at the supplied 1.35px stroke; the generic file uses a grey sheet and darker grey corner. Callers may override the sheet color through `--dsh-file-type-icon-color`. The full-color technology artwork is the deliberate exception and retains its embedded palette. All glyphs are decorative and carry no label. `LinkIconMedium` is the leading glyph for clickable artifact links — globe, folder, code, image, document, or plain paper, and for a `url` link the mark of a well-known site named by its `href` — the developer sites a transcript usually cites (GitHub, GitLab, npm, PyPI, Stack Overflow, MDN, Wikipedia, Hacker News, YouTube, X, Bilibili, Zhihu, Juejin, CSDN) and mainstream search, video, social, shopping, and reference sites (Google, Baidu, DuckDuckGo, TikTok, Netflix, Spotify, Facebook, Instagram, Reddit, Telegram, WhatsApp, WeChat, QQ, Weibo, Taobao, AliExpress, eBay, Quora, V2EX, Apple) — while `classifyLinkPath` folds the shared file types into that existing six-category vocabulary. `ConnectionIndicator` renders a warning-colored disconnected action whose permanent retry glyph marks the retry action beside the owner-supplied outage label, the shared ongoing loader in the same warning color as its label whose one-to-three dots advance every 500ms independently of retry timing, or a success-colored recovered status. Clicking either warning state requests an immediate reconnect; no hover interaction changes the copy. The pill fades in on appearance, fades out for 150ms before unmounting, and sizes to its current label. Its owner supplies visibility, the recovery hold, localized labels, and the immediate-reconnect callback; the primitive uses no native title tooltip. `useAnchoredPosition` and `useAnchoredMaxHeight` keep floating panels and bottom-anchored overlays clamped to the viewport and following their anchor; anchored overlays and portalled `Menu` add 20px to the frame's published top clearance, or keep 20px in native fullscreen, while preserving their own minimum margins in ordinary browsers. `HoverCard` keeps its portaled preview reachable across the anchor gap and can expose a copy button through the `copyText` prop. Touch entry does not open its preview or intercept the anchor action. Mouse entry requires an available hover-capable pointer; keyboard focus remains independent of that capability. Its `preview` variant uses the anchor or `widthAnchorRef` element’s width minus 48px, centers the panel with 24px side insets, and places it above or below the row within the viewport. The panel keeps the frame’s top clearance, caps its height at 420px, and follows content and anchor resizing; Escape or pointer and keyboard activation of the anchor dismisses it. The whole preview fades in and out over 100ms; a closing preview stops receiving pointer input and unmounts after the fade. Re-entering the anchor during the fade restores it. Reduced-motion preferences suppress the transition. Copy labels are required only when copying is enabled. `HoverCard` and nested action tooltips display independently; use `Tooltip.portal` to layer the small bubble above the card. A visible nested `Tooltip` suppresses only its enclosing tooltip, not the hover card. `Toast` uses the owner's `holdMs` for both the fade delay and its hold-and-fade lifetime. Rerenders with unchanged `holdMs` do not restart that lifetime; completion uses the latest callback, and fully faded actions cannot receive input. A new component key restarts the banner. `rankByName` is the `/` menu's shared candidate ranker for the command and skill sources: the query must be a case-insensitive ordered subsequence of the name; prefix hits rank first, then alignment score, then source order. Portalled `Menu` lists follow their anchor during dragging and CSS transforms, and stop tracking when closed. `Menu.autoFocus` focuses its first enabled item, supports Arrow Up/Down and Home/End navigation, and focuses the first button in the anchor on Escape; it is opt-in for action menus. +The catalog above lists what each export is for; this section covers the behavior that props alone do not show. Product icon names omit artboard sizes and end in `Regular` for the supplied one-pixel artwork or `Medium` for the same geometry at a 1.3px stroke; the `size` prop controls rendered dimensions. Named components keep the chosen weight visible at import sites; the library owns stroke geometry instead of consumer CSS overrides. Each product icon renders an `` box with one `` of its `dsw-icon--` symbol, for example `dsw-icon-search-outline-regular`, and names that id in `data-icon`. `ICON_SYMBOLS` holds every symbol's default SVG document; ui-theme renders the document icon sprite from it, and an icon whose symbol is missing renders nothing. Artwork reached through `` inherits `currentColor` and CSS variables from the use site. Each product, reference, link, and permission glyph intentionally keeps both weight exports even when the current product uses only one, so callers can choose emphasis without adding another API later; fill-only pairs render identically. `IconWarningOutlineRegular`/`Medium` use a circle; `IconWarningTriangleOutlineRegular`/`Medium` use a rounded triangle. `FishLogo` and `BrandWordmark` fill brand slots. `FileTypeIcon` renders the traditional 28px spreadsheet, folder, HTML, image, Markdown, generic, PDF, PPT, video, and Word glyphs, and uses the imported square technology artwork for the established 48 code and configuration categories. The import replaces artwork only: archive-only categories do not extend `CodeFileType`. `classifyFileType` applies exact filename, prefix, suffix, optional project-context, and extension rules in that order; React names win over TypeScript/JavaScript, Angular suffixes win over their base extension, and a Dart file becomes Flutter only when the supplied project files contain a `pubspec.yaml` with `flutter:`. Markdown and SVG remain traditional Markdown and image files. Spreadsheet mappings include CSV, TSV, Excel workbooks and templates, OpenDocument spreadsheets, and Numbers; KEY maps to slides, while RTF/ODT/Pages map to documents. `fileExtension` exposes the same basename and final-dot parsing for adjacent metadata labels. Traditional glyphs use a solid category-colored sheet with a white mark and translucent white corner; the generic code glyph uses angle brackets and a slash at the supplied 1.35px stroke; the generic file uses a grey sheet and darker grey corner. Callers may override the sheet color through `--dsh-file-type-icon-color`. The full-color technology artwork is the deliberate exception and retains its embedded palette. All glyphs are decorative and carry no label. `LinkIconMedium` is the leading glyph for clickable artifact links — globe, folder, code, image, document, or plain paper, and for a `url` link the mark of a well-known site named by its `href` — the developer sites a transcript usually cites (GitHub, GitLab, npm, PyPI, Stack Overflow, MDN, Wikipedia, Hacker News, YouTube, X, Bilibili, Zhihu, Juejin, CSDN) and mainstream search, video, social, shopping, and reference sites (Google, Baidu, DuckDuckGo, TikTok, Netflix, Spotify, Facebook, Instagram, Reddit, Telegram, WhatsApp, WeChat, QQ, Weibo, Taobao, AliExpress, eBay, Quora, V2EX, Apple) — while `classifyLinkPath` folds the shared file types into that existing six-category vocabulary. `ConnectionIndicator` renders a warning-colored disconnected action whose permanent retry glyph marks the retry action beside the owner-supplied outage label, the shared ongoing loader in the same warning color as its label whose one-to-three dots advance every 500ms independently of retry timing, or a success-colored recovered status. Clicking either warning state requests an immediate reconnect; no hover interaction changes the copy. The pill fades in on appearance, fades out for 150ms before unmounting, and sizes to its current label. Its owner supplies visibility, the recovery hold, localized labels, and the immediate-reconnect callback; the primitive uses no native title tooltip. `useAnchoredPosition` and `useAnchoredMaxHeight` keep floating panels and bottom-anchored overlays clamped to the viewport and following their anchor; anchored overlays and portalled `Menu` add 20px to the frame's published top clearance, or keep 20px in native fullscreen, while preserving their own minimum margins in ordinary browsers; while an anchor is inside that top strip, its overlay, including a `HoverCard`, is never pushed lower than the anchor's bottom edge plus the overlay's gap. `HoverCard` keeps its portaled preview reachable across the anchor gap and can expose a copy button through the `copyText` prop. Touch entry does not open its preview or intercept the anchor action. Mouse entry requires an available hover-capable pointer; keyboard focus remains independent of that capability. Its `preview` variant uses the anchor or `widthAnchorRef` element’s width minus 48px, centers the panel with 24px side insets, and places it above or below the row within the viewport. The panel keeps the frame’s top clearance except below an anchor inside the top strip, caps its height at 420px, and follows content and anchor resizing; Escape or pointer and keyboard activation of the anchor dismisses it. The whole preview fades in and out over 100ms; a closing preview stops receiving pointer input and unmounts after the fade. Re-entering the anchor during the fade restores it. Reduced-motion preferences suppress the transition. Copy labels are required only when copying is enabled. `HoverCard` and nested action tooltips display independently; use `Tooltip.portal` to layer the small bubble above the card. A visible nested `Tooltip` suppresses only its enclosing tooltip, not the hover card. `Toast` uses the owner's `holdMs` for both the fade delay and its hold-and-fade lifetime. Rerenders with unchanged `holdMs` do not restart that lifetime; completion uses the latest callback, and fully faded actions cannot receive input. A new component key restarts the banner. `rankByName` is the `/` menu's shared candidate ranker for the command and skill sources: the query must be a case-insensitive ordered subsequence of the name; prefix hits rank first, then alignment score, then source order. Portalled `Menu` lists follow their anchor during dragging and CSS transforms, and stop tracking when closed. `Menu.autoFocus` focuses its first enabled item, supports Arrow Up/Down and Home/End navigation, and focuses the first button in the anchor on Escape; it is opt-in for action menus. -`Tooltip` reads the anchor on hover or keyboard focus and fits its bubble from `ResizeObserver` border-box sizes. It stays hidden until its first fit, prefers the requested side, then tries the opposite and perpendicular sides, sliding along the anchor without covering it when a side fits. Horizontal placements keep a 10px gap; vertical placements use `gap` (8px by default). Width and height stay within a 12px viewport margin; text wraps, and content taller than the viewport scrolls when pinned. If no side fits, a stable viewport-bounded fallback can overlap the anchor. Viewport resize refreshes the anchor coordinates; label-size changes reuse them. Fitting does not synchronously measure the bubble or trigger a React render. Touch presses dismiss unpinned hints and do not trigger hover hints, including compatibility mouse events. Mouse entry opens hints only while `(any-hover: hover)` reports an available hover-capable input, excluding iOS mouse entry caused by a tapped icon changing. Keyboard focus and explicit click-to-open explanations remain available on touch-only devices. Opt-in `openOnClick` lets an informational button pin the bubble by click or tap and associates it with the anchor description. Another click or tap, Escape, Tab, or an outside pointerdown closes it. Ordinary action buttons execute their action directly without a touch tooltip. +`Tooltip` reads the anchor on hover or keyboard focus and fits its bubble from `ResizeObserver` border-box sizes. It stays hidden until its first fit, prefers the requested side, then tries the opposite and perpendicular sides, sliding along the anchor without covering it when a side fits. Horizontal placements keep a 10px gap; vertical placements use `gap` (8px by default). Width and height stay within a 12px viewport margin; text wraps, and content taller than the viewport scrolls when pinned. If no side fits, a stable viewport-bounded fallback can overlap the anchor. Viewport resize refreshes the anchor coordinates; label-size changes reuse them. Fitting does not synchronously measure the bubble or trigger a React render. Touch presses dismiss unpinned hints and do not trigger hover hints, including compatibility mouse events. Mouse entry opens hints only while `(any-hover: hover)` reports an available hover-capable input, excluding iOS mouse entry caused by a tapped icon changing. Keyboard focus and explicit click-to-open explanations remain available on touch-only devices. Opt-in `openOnClick` lets an informational button pin the bubble by click or tap and associates it with the anchor description. Another click or tap, Escape, Tab, or an outside pointerdown closes it. Ordinary action buttons execute their action directly without a touch tooltip. Opt-in `onlyWhenClipped` shows the bubble only when the anchor or an HTML element inside it overflows horizontally when the bubble is about to show, so a label that repeats ellipsis-cut text appears only while it adds information. -`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. ### 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` 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. Each mounted image appends a `load` query parameter to a resolved Host `api/file` URL, because browsers reuse a same-URL image within one document despite `no-store`; a newly rendered image of an overwritten file therefore loads its current bytes, and every remount requests the file again; the Host file route redirects that request to a cacheable URL for the current file version. 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. @@ -110,11 +120,13 @@ 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 -The atoms cannot read the application locale, so every piece of user-facing copy arrives through required label props. `HoverCard`, `TerminalBlock`, `JsonTree`, `CodeBlock`, `MarkdownText`, `JsonBlock`, `ConnectionIndicator`, `Modal`, `DiffBlock`, `ReadBlock`, `SearchBlock`, and `WebBlock` accept complete localized labels. The package owns no language fallback; omission fails typechecking, and each feature maps its typed `t` seat into the primitive's label interface. +The atoms cannot read the application locale, so every piece of user-facing copy arrives through required label props. `HoverCard`, `TerminalBlock`, `JsonTree`, `CodeBlock`, `MarkdownText`, `JsonBlock`, `ConnectionIndicator`, `Modal`, `DiffBlock`, `ReadBlock`, `SearchBlock`, `WebBlock`, and `ZoomControls` accept complete localized labels. The package owns no language fallback; omission fails typechecking, and each feature maps its typed `t` seat into the primitive's label interface. ----- @@ -147,7 +159,7 @@ The package enforces one separation: presentational React atoms with zero Cordis | [`src/TerminalBlock.tsx`](src/TerminalBlock.tsx) | ANSI escape parsing (`anser`) and terminal card rendering | | [`src/ReadBlock.tsx`](src/ReadBlock.tsx) / [`src/DiffBlock.tsx`](src/DiffBlock.tsx) | Read and diff cards | | [`src/SearchBlock.tsx`](src/SearchBlock.tsx) / [`src/WebBlock.tsx`](src/WebBlock.tsx) | Search and web-retrieval cards | -| [`src/icons/`](src/icons/) | Size-neutral `Regular` and `Medium` product glyph components | +| [`src/icons/`](src/icons/) | Default product glyph artwork, its `ICON_SYMBOLS` sprite documents, and the size-neutral `Regular` and `Medium` components that reference those symbols | | [`src/code-highlighting.ts`](src/code-highlighting.ts) | Shared filename grammar selection and lazy line highlighting | | [`src/input-modality.ts`](src/input-modality.ts) | Document-wide input modality published on `` | | [`src/plugin-artwork.tsx`](src/plugin-artwork.tsx) | Fixed-palette plugin artwork with per-instance SVG def ids | @@ -184,6 +196,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 @@ -200,6 +214,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. @@ -207,8 +222,11 @@ These limits define how the atoms behave at the edges; they are current package - **Overlong lines stay uncolored** — a source line of 1,000 or more UTF-16 units renders as one plain run in every highlighted surface, because TextMate scanning cost grows superlinearly with line length and a single generated line can otherwise block the page for seconds. The grammar state skips that line, so a comment or template literal that opens or closes on it mis-colors the following lines. Highlighting still runs synchronously during render, so a file with thousands of ordinary lines costs proportionally more. - **The fish logo is a redrawn approximation** — its font glyph has no exportable vector geometry in the local design data, so a hand-authored recreation stands in until an exact export path exists. - **`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. +- **Product icons need the ui-theme sprite** — each icon only references its symbol, so without ui-theme, or inside another document such as an iframe, the icon renders nothing. - **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 e5ebe6f486..41d37d6f18 100644 --- a/packages/client/ui-primitives/README.zh.md +++ b/packages/client/ui-primitives/README.zh.md @@ -27,6 +27,8 @@ kind: "package-library" `Toast` 在浅色和深色模式下均使用主题的系统提示背景与文字颜色。`Menu` 数据条目与 `MenuItemButton` 组件条目接收功能 owner 提供的有效快捷键,并在末端以无背景的浅灰色文字对齐显示,子菜单也采用同一呈现。主指针为粗指针(`pointer: coarse`)且未使用[键盘导航](#input-modality)时,菜单隐藏这些提示。键盘导航会显示提示,直到下一次指针按下;快捷键绑定始终生效。`ShortcutKeys` 默认以无方块的按键文字显示菜单和行内提示。`tooltip` 变体在深色气泡上使用稍浅的键帽,以加号连接的组合则共用一个填充色块。`Tooltip.shortcutKeys` 将键帽与可选的本地化操作文本垂直居中排列,标签为空时只显示按键。可选的 `className` 供调用方设置交互状态样式。`Modal` 使用框架共享的浮层起点,视口边距至少为 24px。其遮罩不绘制 Windows 顶栏区域,覆盖整个视口的模态层仍阻挡背景点击。`Modal` 与设置外壳通过 `useModalLayer` 共用顶层 Esc 和 Tab 处理,关闭时恢复先前焦点。`closeTopModal(document)` 请求前台弹窗当前的关闭操作;上层菜单或未注册的对话框会阻止关闭其后方弹窗。组件通过 `isBehindModal` 避免将焦点移到嵌套弹窗后方。菜单先消费自己的 Esc,再由模态层处理关闭。 `observeComposition` 为局部弹层和录键处理提供相同的 composition-end 与旧版 IME 保护;调用方随交互生命周期释放其 document 监听。 +`isMobileDevice()` 识别 iOS 和 Android 手机及平板,供自动聚焦和收起键盘使用。它识别 UA 中的 `Android`、`iPhone`、`iPad` 或 `iPod`,以及报告 `MacIntel` 且触摸点数大于一的 iPad。其他设备(包括 Windows 触屏电脑)返回 false,不受窗口宽度影响。函数缓存首次判断结果,供页面内的所有调用方共享;组件重新挂载会复用该结果,重新加载页面时会再次读取浏览器环境。识别出的移动设备需要用户主动聚焦文本输入框。隐藏移动平台信息的浏览器模式或自定义 UA 可能导致识别失败;未识别的 UA 返回 false。仅凭桌面 UA 无法区分 Android 平板与 Linux 电脑。 + 弹窗自动进入及弹窗、菜单回焦,包括通过 Esc 和应用关闭快捷键触发的回焦,均使用 `focusWithoutRing(element, options?)`,在 Tab 或方向键导航恢复正常焦点样式前抑制外轮廓线。弹窗容器仍不绘制焦点外框。原有边框、阴影和错误状态保持不变。用 `data-modal-autofocus` 标记弹窗的初始控件,让模态层先保存触发控件,再移动焦点。随弹窗挂载的控件不得使用 React `autoFocus`,因为它会在保存触发控件前执行。弹窗容器获得焦点时,Tab 和 Shift+Tab 分别进入第一个和最后一个可聚焦控件。 本包是 Web 壳的构建输入。静态 ESM 为 Vite 保留第三方导入和样式;独立消费方自行提供开发依赖([依赖规则](../AGENTS.md#dependency-declaration))。 @@ -46,20 +48,24 @@ kind: "package-library" | `Checkbox` | 带标签的原生复选框,支持受控状态、键盘交互和禁用样式;调用方提供本地化的 `label` 文本。 | | `Input` | 单行文本输入,用于搜索框与行内表单。ref 指向原生输入框,供焦点控制使用,并在卸载时清空。 | | `InlineEditor` | 自动聚焦的多行行内编辑器,用于排队消息、goal 等短草稿。它随文本和宽度增高,最多六行,之后滚动;Enter 保存,Shift+Enter 换行,输入法组合中的 Enter 不生效,Escape 取消。调用方提供本地化 `label`,并在 elevated 表面上重绑滚动条。 | -| `Menu`, `MenuItemButton` | 由 `items` 数据行、分隔线与分组标题构成的下拉菜单,支持嵌套子菜单;`children` 在同一列表中加入组件行,每行一个 `MenuItemButton`(`separatorBefore` 开启新分组)。所有行共享样式、键盘走位与焦点归还;两类行的关闭都是 owner 状态的改变。打开期间 `↑`/`↓`(以及 Home、End)在列表中走位,Tab 选定聚焦行,Escape 或 Shift+Tab 关闭并把焦点还给锚点;选定一行同样把键盘还给锚点——除非拥有者自己移动了焦点。只拦截位于锚点或列表内的键盘,`autoFocus` 仅决定打开时是否聚焦首行。 | +| `ZoomControls`, `DOCUMENT_ZOOM` | 悬浮文档缩放控件,接收本地化标签、固定预设,以及可见性和交互状态回调。省略 `fit` 时不显示适配项。工具栏带 `data-document-zoom-controls`,可见时另带 `data-document-zoom-visible`。owner 的 `className` 覆盖 `bottom` 与 `max-width`,不受样式表加载顺序影响;`inset-inline-start` 和 `transform` 负责居中与进出场位移,`opacity` 负责淡入淡出,三者都由原语保留。渲染器共用导出的缩放范围。 | +| `Menu`, `MenuItemButton` | 由 `items` 数据行、分隔线与分组标题构成的下拉菜单,支持嵌套子菜单;`children` 在同一列表中加入组件行,每行一个 `MenuItemButton`(`separatorBefore` 开启新分组)。`MenuItemButton.current` 通过 `aria-current` 标识当前页面或视图,不改变选中状态或外观。所有行共享样式、键盘走位与焦点归还;两类行的关闭都是 owner 状态的改变。打开期间 `↑`/`↓`(以及 Home、End)在列表中走位,Tab 选定聚焦行,Escape 或 Shift+Tab 关闭并把焦点还给锚点;选定一行同样把键盘还给锚点——除非拥有者自己移动了焦点。只拦截位于锚点或列表内的键盘,`autoFocus` 仅决定打开时是否聚焦首行。 | | `MenuGroup`、`observeStickyMenuGroups` | 为自定义菜单与列表框提供本地化、可访问的分组,共用吸顶标题样式与原生交叉/尺寸观察;调用方负责观察器的生命周期。 | | `Pill` | 可选中的胶囊按钮,用于视图切换与筛选器;接受 `active` 与 `onClick`。 | -| `SegmentedTabs` | 受控的等宽分段标签,支持滑动指示条及左/右方向键、Home、End 导航。调用方提供文案、标签与面板 id,以及面板内容。 | +| `SegmentedTabs` | 受控的等宽分段标签,支持滑动指示条及左/右方向键、Home、End 导航。调用方提供文案、标签与面板 id,以及面板内容。默认 `size="md"` 总高 42px、内边距 4px;`size="sm"` 总高 34px、内边距 2px。 | | `Tag` | 只读胶囊徽章;`tone` 选择八种配色之一。 | | `PathLabel` | 单行文件路径:目录使用弱化颜色,文件名使用主色,悬停可查看完整路径。空间足够时靠左显示;溢出时保留尾部并在左侧渐隐,路径或尺寸变化时更新。 | | `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` 由调用方提供。 | +| `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 +73,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 浏览器与文件夹插画,用于侧栏引导入口。 | @@ -88,21 +96,23 @@ kind: "package-library" ### 控件与图标 -上面的目录说明每个导出的用途;本节讲 props 本身看不出来的行为。产品图标名称不含画板尺寸,以 `Regular` 表示原始 1px 图形,以 `Medium` 表示同一几何的 1.3px 描边;`size` prop 控制渲染尺寸。具名组件让所选线重在导入处可见;描边几何由组件库拥有,而不是由消费方 CSS 覆盖。每个产品、引用、链接与权限图形都会有意保留两种线重导出,即使当前产品只使用其中一种,也让调用方无需再次扩展 API 就能选择强调程度;仅填充的成对图形外观相同。`IconWarningOutlineRegular`/`Medium` 使用圆形;`IconWarningTriangleOutlineRegular`/`Medium` 使用圆角三角形。`FishLogo` 与 `BrandWordmark` 填充品牌 slot。`FileTypeIcon` 渲染传统的 28px spreadsheet、folder、HTML、image、Markdown、generic、PDF、PPT、video 与 Word 图形,并为现有 48 个代码和配置类别使用导入的方形技术图形。该导入只替换图形:资源包中额外的类别不会扩展 `CodeFileType`。`classifyFileType` 按完整文件名、前缀、后缀、可选项目上下文、扩展名的顺序匹配;React 文件名优先于 TypeScript/JavaScript,Angular 后缀优先于基础扩展名,只有传入的项目文件包含带 `flutter:` 的 `pubspec.yaml` 时 Dart 文件才使用 Flutter。Markdown 与 SVG 仍分别使用传统 Markdown 与图片图形。表格映射包括 CSV、TSV、Excel 工作簿与模板、OpenDocument 表格和 Numbers;KEY 映射为幻灯片,RTF/ODT/Pages 映射为文档。`fileExtension` 为相邻元数据 label 暴露同一套 basename 与最终点号解析。传统图形使用实色分类底板、白色标记和半透明白色折角;通用代码图形使用尖括号与斜线,保留原图 1.35px 描边;通用文件使用灰色底板与较深灰色折角。调用方可通过 `--dsh-file-type-icon-color` 覆盖底板颜色。全彩技术图形是明确例外,会保留其内嵌调色板。所有图形都是装饰性的,不自带 label。`LinkIconMedium` 是可点击产物链接的前置图形——地球、文件夹、代码、图片、文档或纸张,`url` 链接的 `href` 指向已知站点时则改用该站点自己的标记——转写内容常引用的开发者站点(GitHub、GitLab、npm、PyPI、Stack Overflow、MDN、Wikipedia、Hacker News、YouTube、X、Bilibili、知乎、掘金、CSDN),以及主流搜索、视频、社交、购物与参考资料站点(Google、百度、DuckDuckGo、TikTok、Netflix、Spotify、Facebook、Instagram、Reddit、Telegram、WhatsApp、微信、QQ、微博、淘宝、速卖通、eBay、Quora、V2EX、Apple)——`classifyLinkPath` 把共享文件类型折叠进原有六类词汇。`ConnectionIndicator` 可渲染警告色的断联操作(常驻重试图形指明重试动作,断联文案由持有方提供)、与文案使用相同警告色的共享 ongoing loading 加一至三个点以独立于 retry 时序的 500ms 节奏推进的连接中状态,或成功色的恢复状态。点击任一警告状态都会请求立即重连;没有任何悬停交互会改变文案。药丸出现时淡入、卸载前淡出 150ms,宽度随当前 label 自适应。它的持有方提供可见性、恢复驻留时间、本地化 label 与立即重连回调;该原语不使用原生 title tooltip。`useAnchoredPosition` 与 `useAnchoredMaxHeight` 让浮动面板与底部锚定浮层始终钳制在视口内并跟随锚点;锚定浮层与 portal 模式的 `Menu` 在框架发布的顶部占用量上增加 20px,原生全屏时保留 20px,普通浏览器中沿用自身的最小边距。`HoverCard` 通过指针离开宽限期让采用 portal 的预览在跨过锚点间隙时仍可触及,并可通过 `copyText` prop 提供复制按钮。触摸进入不会打开预览,也不会拦截锚点操作。鼠标进入要求存在可悬停的指针设备;键盘聚焦不依赖该能力。其 `preview` 变体使用 anchor 或 `widthAnchorRef` 元素的宽度减去 48px,左右各内缩 24px,并在视口内放置于行的上方或下方。浮层避开框架顶部保留区,高度最多 420px,会跟随内容及 anchor 尺寸变化;Escape 或通过鼠标和键盘激活锚点可将其关闭。整个浮层的淡入和淡出各持续 100ms;关闭中的浮层停止接收指针输入,淡出后卸载。在淡出期间移回锚点可恢复显示。减少动态效果偏好会禁用过渡动画。只有启用复制时才必须提供复制标签。`HoverCard` 与嵌套的操作提示独立显示;使用 `Tooltip.portal` 将小气泡置于卡片上层。可见的嵌套 `Tooltip` 只隐藏外层 tooltip,不隐藏悬停卡片。`Toast` 使用调用方的 `holdMs` 同时控制淡出延迟与停留加淡出的总时长。`holdMs` 未变时,父组件重渲染不会重启该生命周期;完成时调用最新回调,完全淡出的操作不能接收输入。新的组件 key 会重新开始横幅周期。`rankByName` 是 `/` 菜单命令源与 skill(技能)源共享的候选排序器:查询必须是名字的不区分大小写的有序子序列;前缀命中排最前,其次按对齐分数,再按来源顺序。 portal 模式的 `Menu` 列表会在拖动和 CSS 变换期间跟随锚点,并在关闭时停止跟踪。`Menu.autoFocus` 聚焦首个启用项,支持上下方向键与 Home/End 导航,并在 Escape 时聚焦 anchor 内的第一个按钮;操作菜单可显式启用。 +上面的目录说明每个导出的用途;本节讲 props 本身看不出来的行为。产品图标名称不含画板尺寸,以 `Regular` 表示原始 1px 图形,以 `Medium` 表示同一几何的 1.3px 描边;`size` prop 控制渲染尺寸。具名组件让所选线重在导入处可见;描边几何由组件库拥有,而不是由消费方 CSS 覆盖。每个产品图标渲染一个 `` 框,其中包含一个引用对应 `dsw-icon--` symbol 的 ``,例如 `dsw-icon-search-outline-regular`,并在 `data-icon` 中写出该 id。`ICON_SYMBOLS` 保存每个 symbol 的默认 SVG 文档;ui-theme 据此渲染文档的图标 sprite,缺少对应 symbol 的图标不渲染任何内容。通过 `` 引用的图形会从使用处继承 `currentColor` 与 CSS 变量。每个产品、引用、链接与权限图形都会有意保留两种线重导出,即使当前产品只使用其中一种,也让调用方无需再次扩展 API 就能选择强调程度;仅填充的成对图形外观相同。`IconWarningOutlineRegular`/`Medium` 使用圆形;`IconWarningTriangleOutlineRegular`/`Medium` 使用圆角三角形。`FishLogo` 与 `BrandWordmark` 填充品牌 slot。`FileTypeIcon` 渲染传统的 28px spreadsheet、folder、HTML、image、Markdown、generic、PDF、PPT、video 与 Word 图形,并为现有 48 个代码和配置类别使用导入的方形技术图形。该导入只替换图形:资源包中额外的类别不会扩展 `CodeFileType`。`classifyFileType` 按完整文件名、前缀、后缀、可选项目上下文、扩展名的顺序匹配;React 文件名优先于 TypeScript/JavaScript,Angular 后缀优先于基础扩展名,只有传入的项目文件包含带 `flutter:` 的 `pubspec.yaml` 时 Dart 文件才使用 Flutter。Markdown 与 SVG 仍分别使用传统 Markdown 与图片图形。表格映射包括 CSV、TSV、Excel 工作簿与模板、OpenDocument 表格和 Numbers;KEY 映射为幻灯片,RTF/ODT/Pages 映射为文档。`fileExtension` 为相邻元数据 label 暴露同一套 basename 与最终点号解析。传统图形使用实色分类底板、白色标记和半透明白色折角;通用代码图形使用尖括号与斜线,保留原图 1.35px 描边;通用文件使用灰色底板与较深灰色折角。调用方可通过 `--dsh-file-type-icon-color` 覆盖底板颜色。全彩技术图形是明确例外,会保留其内嵌调色板。所有图形都是装饰性的,不自带 label。`LinkIconMedium` 是可点击产物链接的前置图形——地球、文件夹、代码、图片、文档或纸张,`url` 链接的 `href` 指向已知站点时则改用该站点自己的标记——转写内容常引用的开发者站点(GitHub、GitLab、npm、PyPI、Stack Overflow、MDN、Wikipedia、Hacker News、YouTube、X、Bilibili、知乎、掘金、CSDN),以及主流搜索、视频、社交、购物与参考资料站点(Google、百度、DuckDuckGo、TikTok、Netflix、Spotify、Facebook、Instagram、Reddit、Telegram、WhatsApp、微信、QQ、微博、淘宝、速卖通、eBay、Quora、V2EX、Apple)——`classifyLinkPath` 把共享文件类型折叠进原有六类词汇。`ConnectionIndicator` 可渲染警告色的断联操作(常驻重试图形指明重试动作,断联文案由持有方提供)、与文案使用相同警告色的共享 ongoing loading 加一至三个点以独立于 retry 时序的 500ms 节奏推进的连接中状态,或成功色的恢复状态。点击任一警告状态都会请求立即重连;没有任何悬停交互会改变文案。药丸出现时淡入、卸载前淡出 150ms,宽度随当前 label 自适应。它的持有方提供可见性、恢复驻留时间、本地化 label 与立即重连回调;该原语不使用原生 title tooltip。`useAnchoredPosition` 与 `useAnchoredMaxHeight` 让浮动面板与底部锚定浮层始终钳制在视口内并跟随锚点;锚定浮层与 portal 模式的 `Menu` 在框架发布的顶部占用量上增加 20px,原生全屏时保留 20px,普通浏览器中沿用自身的最小边距;锚点位于该顶部区域内时,其浮层(包括 `HoverCard`)不会被推到低于锚点底边加浮层间距的位置。`HoverCard` 通过指针离开宽限期让采用 portal 的预览在跨过锚点间隙时仍可触及,并可通过 `copyText` prop 提供复制按钮。触摸进入不会打开预览,也不会拦截锚点操作。鼠标进入要求存在可悬停的指针设备;键盘聚焦不依赖该能力。其 `preview` 变体使用 anchor 或 `widthAnchorRef` 元素的宽度减去 48px,左右各内缩 24px,并在视口内放置于行的上方或下方。浮层避开框架顶部保留区(位于顶部区域内的锚点下方时除外),高度最多 420px,会跟随内容及 anchor 尺寸变化;Escape 或通过鼠标和键盘激活锚点可将其关闭。整个浮层的淡入和淡出各持续 100ms;关闭中的浮层停止接收指针输入,淡出后卸载。在淡出期间移回锚点可恢复显示。减少动态效果偏好会禁用过渡动画。只有启用复制时才必须提供复制标签。`HoverCard` 与嵌套的操作提示独立显示;使用 `Tooltip.portal` 将小气泡置于卡片上层。可见的嵌套 `Tooltip` 只隐藏外层 tooltip,不隐藏悬停卡片。`Toast` 使用调用方的 `holdMs` 同时控制淡出延迟与停留加淡出的总时长。`holdMs` 未变时,父组件重渲染不会重启该生命周期;完成时调用最新回调,完全淡出的操作不能接收输入。新的组件 key 会重新开始横幅周期。`rankByName` 是 `/` 菜单命令源与 skill(技能)源共享的候选排序器:查询必须是名字的不区分大小写的有序子序列;前缀命中排最前,其次按对齐分数,再按来源顺序。 portal 模式的 `Menu` 列表会在拖动和 CSS 变换期间跟随锚点,并在关闭时停止跟踪。`Menu.autoFocus` 聚焦首个启用项,支持上下方向键与 Home/End 导航,并在 Escape 时聚焦 anchor 内的第一个按钮;操作菜单可显式启用。 -`Tooltip` 在悬停或键盘聚焦时读取锚点位置,再根据 `ResizeObserver` 提供的边框盒尺寸调整气泡。首次定位前保持隐藏,优先使用指定方向,再尝试相反方向和垂直于该方向的两侧;只要某一侧容得下,就沿锚点平移而不遮挡锚点。左右放置保留 10px 间距,上下放置使用 `gap`(默认 8px)。宽高限制在视口内并保留 12px 边距;文本自动换行,超出视口高度的内容在固定气泡后可滚动。若所有方向都放不下,则使用稳定的视口内回退位置,可能遮挡锚点。视口尺寸变化会重新读取锚点坐标,标签尺寸变化则复用坐标;定位不会同步测量气泡,也不会触发 React 渲染。 触摸按下会关闭未固定的提示,且不会触发悬停提示,包括触摸后的兼容鼠标事件。只有 `(any-hover: hover)` 表明存在可悬停的输入设备时,鼠标进入才会打开提示,从而排除 iOS 因已点击图标变化而生成的鼠标进入事件。仅有触摸输入的设备仍可使用键盘聚焦提示和显式点击打开的说明。信息按钮可显式启用 `openOnClick`,通过鼠标或触摸点击固定气泡供阅读,并将内容关联为锚点的无障碍描述;再次点击、Escape、Tab 或外部 pointerdown 会关闭。普通操作按钮在触摸点击时直接执行操作,不显示 Tooltip。 +`Tooltip` 在悬停或键盘聚焦时读取锚点位置,再根据 `ResizeObserver` 提供的边框盒尺寸调整气泡。首次定位前保持隐藏,优先使用指定方向,再尝试相反方向和垂直于该方向的两侧;只要某一侧容得下,就沿锚点平移而不遮挡锚点。左右放置保留 10px 间距,上下放置使用 `gap`(默认 8px)。宽高限制在视口内并保留 12px 边距;文本自动换行,超出视口高度的内容在固定气泡后可滚动。若所有方向都放不下,则使用稳定的视口内回退位置,可能遮挡锚点。视口尺寸变化会重新读取锚点坐标,标签尺寸变化则复用坐标;定位不会同步测量气泡,也不会触发 React 渲染。 触摸按下会关闭未固定的提示,且不会触发悬停提示,包括触摸后的兼容鼠标事件。只有 `(any-hover: hover)` 表明存在可悬停的输入设备时,鼠标进入才会打开提示,从而排除 iOS 因已点击图标变化而生成的鼠标进入事件。仅有触摸输入的设备仍可使用键盘聚焦提示和显式点击打开的说明。信息按钮可显式启用 `openOnClick`,通过鼠标或触摸点击固定气泡供阅读,并将内容关联为锚点的无障碍描述;再次点击、Escape、Tab 或外部 pointerdown 会关闭。普通操作按钮在触摸点击时直接执行操作,不显示 Tooltip。可选的 `onlyWhenClipped` 只在气泡即将显示时锚点或其内部某个 HTML 元素横向溢出才显示气泡,因此重复被省略号截断文本的标签只在能补充信息时出现。 -`HoverCardGroup` 为一个已挂载的物理列表持有私有状态。设置 `grouped` 的 `HoverCard` 共享首张实际显示卡片的等待:在同一次指针交互中,后续卡片立即替换当前卡片。列表与其当前活动的 portal 卡片构成一次指针交互:指针可进入卡片阅读或复制,再返回其他列表项并立即替换卡片。指针离开两者后,共享等待在活动卡片通过原有指针宽限及关闭生命周期关闭时重置;分组不另设计时器。列表没有活动卡片时,指针离开便立即重置等待。指针位于列表外时,通过键盘聚焦打开行内卡片不会让下一次指针进入跳过等待。互斥显示也包括正在淡出的预览:普通预览关闭保留淡出,但分组内替换会立即移除前一张卡片。未设置 `grouped` 的卡片保持独立。 +`HoverCardGroup` 为一个已挂载的物理列表持有私有状态。设置 `grouped` 的 `HoverCard` 共享首张实际显示卡片的等待:在同一次指针交互中,后续卡片立即替换当前卡片。列表与其当前活动的 portal 卡片构成一次指针交互:指针可进入卡片阅读或复制,再返回其他列表项并立即替换卡片。指针离开两者后,共享等待在活动卡片通过原有指针宽限及关闭生命周期关闭时重置;分组不另设计时器。列表没有活动卡片时,指针离开便立即重置等待。指针位于列表外时,通过键盘聚焦打开行内卡片不会让下一次指针进入跳过等待。互斥显示也包括正在淡出的预览:普通预览关闭保留淡出,但分组内替换会立即移除前一张卡片。未设置 `grouped` 的卡片保持独立。`as="span"` 保留行内文本容器。 ### 渲染 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` 渲染不可信的 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` 还提供本地化的失败提示前缀。图片源变化后可重新加载。每个挂载的图片会为解析得到的 Host `api/file` 地址追加 `load` 查询参数,因为浏览器在同一文档内会无视 `no-store` 复用相同 URL 的图片;因此覆盖后的文件在新渲染的图片中会加载当前内容,每次重新挂载都会重新请求该文件;Host 文件路由会把该请求重定向到当前文件版本的可缓存地址。回复流式输出时,它冻结已完成的块、按已完成行推进顶层未闭合 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 保留源码布局。 @@ -110,11 +120,13 @@ 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`。 ### 本地化文案 -这些原子组件无法读取应用 locale,因此每段面向用户的文案都必须通过 label prop 提供。`HoverCard`、`TerminalBlock`、`JsonTree`、`CodeBlock`、`MarkdownText`、`JsonBlock`、`ConnectionIndicator`、`Modal`、`DiffBlock`、`ReadBlock`、`SearchBlock` 与 `WebBlock` 接收完整的本地化 label。本包不拥有语言回退;遗漏会导致类型检查失败,各功能会把带类型的 `t` 席位映射到 primitive 的 label 接口。 +这些原子组件无法读取应用 locale,因此每段面向用户的文案都必须通过 label prop 提供。`HoverCard`、`TerminalBlock`、`JsonTree`、`CodeBlock`、`MarkdownText`、`JsonBlock`、`ConnectionIndicator`、`Modal`、`DiffBlock`、`ReadBlock`、`SearchBlock`、`WebBlock` 与 `ZoomControls` 接收完整的本地化 label。本包不拥有语言回退;遗漏会导致类型检查失败,各功能会把带类型的 `t` 席位映射到 primitive 的 label 接口。 ----- @@ -147,7 +159,7 @@ kind: "package-library" | [`src/TerminalBlock.tsx`](src/TerminalBlock.tsx) | ANSI 转义解析(`anser`)与终端卡片渲染 | | [`src/ReadBlock.tsx`](src/ReadBlock.tsx) / [`src/DiffBlock.tsx`](src/DiffBlock.tsx) | 读取与差异卡片 | | [`src/SearchBlock.tsx`](src/SearchBlock.tsx) / [`src/WebBlock.tsx`](src/WebBlock.tsx) | 搜索与网页检索卡片 | -| [`src/icons/`](src/icons/) | 与尺寸无关的 `Regular` 和 `Medium` 产品图标组件 | +| [`src/icons/`](src/icons/) | 默认产品图标图形、其 `ICON_SYMBOLS` sprite 文档,以及引用这些 symbol 的与尺寸无关的 `Regular` 和 `Medium` 组件 | | [`src/code-highlighting.ts`](src/code-highlighting.ts) | 共享的文件名 grammar 选择与惰性逐行高亮 | | [`src/input-modality.ts`](src/input-modality.ts) | 全文档输入模态,发布到 `` | | [`src/plugin-artwork.tsx`](src/plugin-artwork.tsx) | 固定配色插件插画,SVG def id 按实例生成 | @@ -184,6 +196,8 @@ kind: "package-library" ----- +`QuotePreview` 在 composer 与已发送消息之间共享紧凑 quote 摘录和仅截断时显示的行内 HoverCard。原文弹层按文本内容确定宽度,最大为 300px,并限制在视口内。两处均展示原文;可编辑草稿可以提供本地化的整条 quote 删除操作。quote 加入最近的 `HoverCardGroup`,使原文弹层互斥显示。该 primitive 不负责识别或导航。 + ## 模型体验 @@ -200,6 +214,7 @@ kind: "package-library" 这些限制说明原子组件在边缘情况下的行为;它们是当前包约束,不是组件路线图。 +- **浏览器图片传递**:外部接收依赖 Chromium 的 `DownloadURL` 与接收应用;原生文件交付由 Desktop 提供。Web 令牌不跨窗口或标签页传递。不可读取的跨源图片保留浏览器原生拖动,链接内的 Markdown 图片及未配置 `fileImages` 的 Markdown 也保留原生行为;这些原生路径可能携带 URL。 - **Diff 搜索有上限,输入处理仍为线性**:编辑距离上限使大量改动的片段采用粗粒度替换,不再精确对齐。规范化、回退行及复制内容仍随输入大小增长;高度限制只约束可见行数,不限制这些分配。 - **已知站点标记是固定列表**:只有列名的主机解析为自己的标记,其余外部主机仍使用地球;要识别任意站点需要通过网络抓取它的图标。 - **流式期间跨边界引用解析被推迟**:定义落在增量冻结边界另一侧的引用式链接或脚注,在回复流式输出期间渲染为字面文本;定稿时的全量解析会将其解析。 @@ -207,8 +222,11 @@ kind: "package-library" - **超长行不着色**:所有高亮界面中,长度达到 1,000 个 UTF-16 单元的源码行渲染为一段纯文本,因为 TextMate 扫描成本随行长超线性增长,单个生成行否则会阻塞页面数秒。语法状态会跳过该行,因此在该行开始或结束的注释、模板字符串会使后续行着色错误。高亮仍在渲染期间同步执行,因此数千个普通行的文件仍按比例增加开销。 - **鱼形标志是重新绘制的近似版本**:它来自字体字形,而本地设计数据无法导出其矢量几何;在获得精确导出路径前,使用手工重建版本代替。 - **`Pill` 与 `Input` 没有设计来源**:两个原子组件均自行定义;与其相似的侧边栏搜索字段和视图标签条由消费方组合,不是这些原子组件。 +- **产品图标依赖 ui-theme 的 sprite**:每个图标只引用自己的 symbol,因此没有 ui-theme 时,或在 iframe 等另一个文档中,图标不渲染任何内容。 - **`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 92% rename from packages/client/ui-workspace/src/client/rows/AnimatedRows.tsx rename to packages/client/ui-primitives/src/AnimatedRows.tsx index 12030ab6f2..fb4a8fac68 100644 --- a/packages/client/ui-workspace/src/client/rows/AnimatedRows.tsx +++ b/packages/client/ui-primitives/src/AnimatedRows.tsx @@ -1,14 +1,16 @@ -/** 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 ReactNode } from 'react' import css from './AnimatedRows.module.css' 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 /** Unique DOM-order keys matching the rendered data-row-key attributes. */ rowKeys: readonly string[] ready: boolean @@ -38,7 +40,7 @@ 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. */ @@ -65,9 +67,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 } @@ -170,7 +170,7 @@ export class AnimatedRows extends Component {
{ this.armed = true }} onKeyDownCapture={() => { this.armed = true }} diff --git a/packages/client/ui-primitives/src/DraggableImage.tsx b/packages/client/ui-primitives/src/DraggableImage.tsx new file mode 100644 index 0000000000..a7ce07efce --- /dev/null +++ b/packages/client/ui-primitives/src/DraggableImage.tsx @@ -0,0 +1,26 @@ +/** Original-image drag gestures owned by a presentation primitive. */ +import { forwardRef, type ImgHTMLAttributes } from 'react' +import { useImageDrag } from './image-drag.ts' +import type { FileDragActions } from './file-drag-actions.ts' + +/** Image presentation plus original content and explicit file preparation callbacks. */ +export interface DraggableImageProps extends Omit, 'src' | 'draggable' | 'onDragStart' | 'onDragEnd' | 'onPointerEnter' | 'onPointerLeave' | 'onPointerDown' | 'onPointerUp' | 'onClickCapture'> { + readonly fileDrag: FileDragActions + readonly src: string + readonly name: string + readonly resourceAddress?: string | undefined + readonly original?: File | undefined +} + +/** + * Render an image with original-file dragging; using explicit application-owned callbacks. + * @param props - image attributes, original filename and file preparation callbacks. + * @param ref - image element for measurement and pointer-mode bindings. + * @returns a draggable image that suppresses only the mouse click following its drag. + */ +export const DraggableImage = forwardRef(function DraggableImage( + { fileDrag, src, name, original, resourceAddress, ...props }, ref, +) { + const drag = useImageDrag(fileDrag, src, name, original, resourceAddress) + return +}) diff --git a/packages/client/ui-primitives/src/HoverCard.module.css b/packages/client/ui-primitives/src/HoverCard.module.css index 50c62fa828..5679f43bd3 100644 --- a/packages/client/ui-primitives/src/HoverCard.module.css +++ b/packages/client/ui-primitives/src/HoverCard.module.css @@ -88,3 +88,16 @@ backdrop-filter: var(--dsw-menu-backdrop-filter); box-shadow: var(--dsw-elevation-panel); } + +.fitContent { + width: max-content; +} + +.portal { + display: contents; +} + +.bridge { + position: fixed; + z-index: 100; +} diff --git a/packages/client/ui-primitives/src/HoverCard.tsx b/packages/client/ui-primitives/src/HoverCard.tsx index e30d671c36..f11c6bad30 100644 --- a/packages/client/ui-primitives/src/HoverCard.tsx +++ b/packages/client/ui-primitives/src/HoverCard.tsx @@ -16,6 +16,15 @@ const INLINE_PREVIEW_WIDTH = 300 const ANCHOR_GAP = 8 const VIEWPORT_MARGIN = 8 +interface CardPosition { + left: number + top: number + width?: number + maxWidth?: number + maxHeight?: number + bridge?: { left: number; top: number; width: number; height: number } +} + /** * Render an anchor with a hover-triggered preview card independently of nested tooltips. * Touch entry does not open a preview or intercept the anchor's action. @@ -24,9 +33,12 @@ const VIEWPORT_MARGIN = 8 * readable and selectable, but it carries no dismissal affordance of its own. * @param props.openDelayMs - hover dwell before the card shows (default 500). * @param props.variant - compact card beside the anchor, or a preview above/below it - * with 24px side insets, a 420px height cap, frame-top clearance, and 100ms opacity transitions. + * with 24px side insets, a 420px height cap, the frame-top clearance (capped below an anchor inside the top strip), + * and 100ms opacity transitions. * @param props.widthAnchorRef - optional element whose width and horizontal position size the preview. * @param props.inline - keep the anchor in prose; show a contained preview on hover or keyboard focus. + * Its hover region connects the anchor and preview across the visual gap. + * @param props.fitContent - size an inline card to its content within the usual width cap. * @param props.disabled - suppress opening; turning true dismisses an open card. * @param props.grouped - share first-display dwell across the surrounding list and active portal. * Group replacement removes the previous card immediately, including a fading preview. @@ -38,11 +50,12 @@ const VIEWPORT_MARGIN = 8 */ export function HoverCard({ anchor, content, openDelayMs = 500, disabled = false, grouped = false, - copyText, copyLabel, copiedLabel, variant = 'compact', widthAnchorRef, inline = false, + copyText, copyLabel, copiedLabel, variant = 'compact', widthAnchorRef, inline = false, fitContent = false, }: { anchor: ReactNode /** Inline media preview using the shared menu material and keyboard focus. */ inline?: boolean + fitContent?: boolean content: ReactNode openDelayMs?: number disabled?: boolean @@ -56,6 +69,7 @@ export function HoverCard({ })) { const rootRef = useRef(null) const cardRef = useRef(null) + const portalRef = useRef(null) const timerRef = useRef | null>(null) const copyTimerRef = useRef | null>(null) const copyHeightRef = useRef(null) @@ -65,7 +79,7 @@ export function HoverCard({ const [phase, setPhase] = useState<'closed' | 'open' | 'closing'>('closed') const open = phase !== 'closed' const closing = phase === 'closing' - const [pos, setPos] = useState<{ left: number; top: number; width?: number; maxHeight?: number } | null>(null) + const [pos, setPos] = useState(null) const positioned = pos !== null const [copied, setCopied] = useState(false) const parentGroup = useContext(HoverCardGroupContext) @@ -107,8 +121,8 @@ export function HoverCard({ const groupVisible = open && positioned useLayoutEffect(() => { if (!groupVisible || group === null) return - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- The visible card's ref is attached before layout effects. - group.shown(groupOwner, dismissImmediately, cardRef.current!) + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- The visible portal's ref is attached before layout effects. + group.shown(groupOwner, dismissImmediately, portalRef.current!) return () => { group.release(groupOwner) } }, [group, groupOwner, groupVisible, dismissImmediately]) @@ -165,7 +179,7 @@ export function HoverCard({ if (variant === 'preview') { const bounds = widthAnchorRef?.current?.getBoundingClientRect() ?? r const width = Math.max(0, Math.min(bounds.width - PREVIEW_INSET * 2, window.innerWidth - VIEWPORT_MARGIN * 2)) - const topMargin = overlayTopMargin(VIEWPORT_MARGIN) + const topMargin = overlayTopMargin(VIEWPORT_MARGIN, r, ANCHOR_GAP) const belowTop = Math.max(topMargin, r.bottom + ANCHOR_GAP) const above = Math.max(0, r.top - ANCHOR_GAP - topMargin) const below = Math.max(0, window.innerHeight - belowTop - VIEWPORT_MARGIN) @@ -180,17 +194,25 @@ export function HoverCard({ } if (inline) { const height = Math.max(h, cardRef.current?.scrollHeight ?? 0) - const width = Math.max(0, Math.min(INLINE_PREVIEW_WIDTH, window.innerWidth - VIEWPORT_MARGIN * 2)) - const topMargin = overlayTopMargin(VIEWPORT_MARGIN) + const maxWidth = Math.max(0, Math.min(INLINE_PREVIEW_WIDTH, window.innerWidth - VIEWPORT_MARGIN * 2)) + const width = fitContent ? Math.min(cardRef.current?.offsetWidth ?? maxWidth, maxWidth) : maxWidth + const topMargin = overlayTopMargin(VIEWPORT_MARGIN, r, ANCHOR_GAP) const belowTop = Math.max(topMargin, r.bottom + ANCHOR_GAP) const above = Math.max(0, r.top - ANCHOR_GAP - topMargin) const below = Math.max(0, window.innerHeight - belowTop - VIEWPORT_MARGIN) const onTop = height > below && above > below const maxHeight = onTop ? above : below + const left = Math.max(VIEWPORT_MARGIN, Math.min(r.left, window.innerWidth - width - VIEWPORT_MARGIN)) + const top = onTop ? r.top - ANCHOR_GAP - Math.min(height, maxHeight) : belowTop + const bridgeTop = onTop ? top + Math.min(height, maxHeight) : r.bottom + const bridgeLeft = Math.min(r.left, left) setPos({ - left: Math.max(VIEWPORT_MARGIN, Math.min(r.left, window.innerWidth - width - VIEWPORT_MARGIN)), - top: onTop ? r.top - ANCHOR_GAP - Math.min(height, maxHeight) : belowTop, - width, maxHeight, + left, top, ...fitContent ? {} : { width }, maxWidth, maxHeight, + bridge: { + left: bridgeLeft, top: bridgeTop, + width: Math.max(r.right, left + width) - bridgeLeft, + height: Math.max(0, (onTop ? r.top : top) - bridgeTop), + }, }) return } @@ -209,7 +231,7 @@ export function HoverCard({ window.removeEventListener('scroll', place, true) window.removeEventListener('resize', place) } - }, [open, variant, widthAnchorRef, positioned, inline]) + }, [open, variant, widthAnchorRef, positioned, inline, fitContent]) // The first placement ran before the card mounted (height read 0): once the // card's real height is measurable, correct the bottom-edge clamp. The @@ -240,7 +262,7 @@ export function HoverCard({ const copyable = copyText !== undefined const dismissFromAnchor = (event: SyntheticEvent): void => { // Portal content is a React child, but its clicks and text selection do not activate the anchor. - if (cardRef.current?.contains(event.target as Node)) return + if (portalRef.current?.contains(event.target as Node)) return clearTimer() cancelClose() close() @@ -248,10 +270,11 @@ export function HoverCard({ const card = open && pos !== null && (
{anchor} {open && copyable && {copied ? copiedLabel : ''}} - {card !== false && createPortal(card, document.body)} + {card !== false && createPortal( + + {pos.bridge !== undefined && } + {card} + , document.body, + )} ) } diff --git a/packages/client/ui-primitives/src/HoverCardGroup.tsx b/packages/client/ui-primitives/src/HoverCardGroup.tsx index 163202d813..a218110e1c 100644 --- a/packages/client/ui-primitives/src/HoverCardGroup.tsx +++ b/packages/client/ui-primitives/src/HoverCardGroup.tsx @@ -16,9 +16,14 @@ export const HoverCardGroupContext = createContext(null) * Share first-display dwell across a list and its active portal, including the card's existing pointer grace. * @param props.children - list contents, including opted-in HoverCards. * @param props.className - classes for the physical list container. + * @param props.as - physical container tag; use span to preserve inline text layout. * @returns the list container and its private hover coordinator. */ -export function HoverCardGroup({ children, className }: { children: ReactNode; className?: string }) { +export function HoverCardGroup({ children, className, as: Container = 'div' }: { + children: ReactNode + className?: string | undefined + as?: 'div' | 'span' +}) { const [group] = useState(() => { let pointer: 'outside' | 'list' | 'card' = 'outside' let warm = false @@ -52,7 +57,7 @@ export function HoverCardGroup({ children, className }: { children: ReactNode; c }) return ( -
{ const overCard = group.containsCard(event.target) @@ -66,7 +71,7 @@ export function HoverCardGroup({ children, className }: { children: ReactNode; c }} > {children} -
+
) } diff --git a/packages/client/ui-primitives/src/Menu.tsx b/packages/client/ui-primitives/src/Menu.tsx index 0f1225fc42..68b3a5766e 100644 --- a/packages/client/ui-primitives/src/Menu.tsx +++ b/packages/client/ui-primitives/src/Menu.tsx @@ -51,6 +51,8 @@ export interface MenuItemButtonProps { shortcut?: MenuItem['shortcut'] /** Leading icon (figma .Menu_cell gap 8). */ icon?: ReactNode + /** Whether this row identifies the currently displayed page or view. */ + current?: boolean /** Whether the row cannot be activated. */ disabled?: boolean /** Destructive row: error-colored text/icon and danger hover fill. */ @@ -75,6 +77,7 @@ export interface MenuItemButtonProps { * @param props.children - visible row label. * @param props.shortcut - effective key labels and accessible combination. * @param props.icon - optional leading icon. + * @param props.current - whether the row identifies the currently displayed page or view. * @param props.disabled - whether the row cannot be activated. * @param props.danger - whether to use the destructive row colors. * @param props.separatorBefore - whether this row starts a new group (hairline above it). @@ -82,7 +85,7 @@ export interface MenuItemButtonProps { * @returns one menu-item row. */ export function MenuItemButton({ - children, shortcut, icon, disabled = false, danger = false, separatorBefore = false, onSelect, + children, shortcut, icon, current, disabled = false, danger = false, separatorBefore = false, onSelect, }: MenuItemButtonProps) { return (
@@ -93,6 +96,7 @@ export function MenuItemButton({ className={clsx(css.item, danger && css.danger)} disabled={disabled} aria-keyshortcuts={shortcut?.aria} + aria-current={current === true ? 'page' : undefined} onClick={onSelect} > {icon !== undefined && {icon}} @@ -255,6 +259,7 @@ export function Menu({ open, anchor, items = [], children, selectedId, selectedI } if (r === null) return const MARGIN = 12 + const GAP = 4 const vw = window.innerWidth const vh = window.innerHeight const listEl = listRef.current @@ -268,18 +273,18 @@ export function Menu({ open, anchor, items = [], children, selectedId, selectedI let x: number let y: number if (side === 'right') { - x = r.right + 4 + x = r.right + GAP y = r.top } else if (align === 'start') { x = r.left - y = side === 'bottom' ? r.bottom + 4 : r.top - lh - 4 + y = side === 'bottom' ? r.bottom + GAP : r.top - lh - GAP } else { x = r.right - lw - y = side === 'bottom' ? r.bottom + 4 : r.top - lh - 4 + y = side === 'bottom' ? r.bottom + GAP : r.top - lh - GAP } if (lw > 0) x = Math.min(Math.max(x, MARGIN), vw - lw - MARGIN) - if (lh > 0) y = Math.min(Math.max(y, overlayTopMargin(MARGIN)), vh - lh - MARGIN) + if (lh > 0) y = Math.min(Math.max(y, overlayTopMargin(MARGIN, r, GAP)), vh - lh - MARGIN) setFixedPos(current => current?.left === x && current.top === y ? current : { left: x, top: y }) } diff --git a/packages/client/ui-primitives/src/QuotePreview.module.css b/packages/client/ui-primitives/src/QuotePreview.module.css new file mode 100644 index 0000000000..1418d8393c --- /dev/null +++ b/packages/client/ui-primitives/src/QuotePreview.module.css @@ -0,0 +1,101 @@ +.quote { + display: inline-flex; + align-items: baseline; + gap: 4px; + box-sizing: border-box; + max-width: min(80%, 300px); + margin: 0; + padding: 0; + vertical-align: baseline; + color: var(--dsw-alias-state-business-primary); + font-size: var(--dsh-content-font-size, 14px); + line-height: calc(20px + var(--dsh-content-font-delta, 0px)); +} + +.popoverText { + display: block; + padding: 8px 10px; + color: var(--dsw-alias-label-primary); + font-size: 14px; + line-height: 20px; + overflow-wrap: anywhere; + white-space: pre-wrap; +} + +.source { + min-width: 0; + padding: 0; + border: none; + outline: none; + background: none; + color: inherit; + font: inherit; + line-height: inherit; + text-align: left; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.marker, +.remove { + display: grid; + place-items: center; + align-self: center; + flex: none; + width: 16px; + height: 16px; + padding: 0; + color: inherit; + user-select: none; +} + +.marker { + pointer-events: none; +} + +.remove { + border: none; + border-radius: 50%; + corner-shape: round; + background: transparent; + cursor: pointer; +} + +.quoteMark, +.closeMark { + grid-area: 1 / 1; + transition: opacity 120ms ease; +} + +.closeMark { + opacity: 0; +} + +.quote[data-removable]:focus-within .quoteMark { + opacity: 0; +} + +.quote[data-removable]:focus-within .closeMark { + opacity: 1; +} + +@media (hover: hover) { + .quote[data-removable]:hover .quoteMark { + opacity: 0; + } + + .quote[data-removable]:hover .closeMark { + opacity: 1; + } +} + +@media (hover: none) { + .quote[data-removable] .quoteMark { + opacity: 0; + } + + .quote[data-removable] .closeMark { + opacity: 1; + } +} diff --git a/packages/client/ui-primitives/src/QuotePreview.tsx b/packages/client/ui-primitives/src/QuotePreview.tsx new file mode 100644 index 0000000000..4cc56d2cec --- /dev/null +++ b/packages/client/ui-primitives/src/QuotePreview.tsx @@ -0,0 +1,103 @@ +/** Shared compact presentation for draft and sent text quotes. */ + +import { useLayoutEffect, useRef, useState } from 'react' +import type { JSX, MouseEvent as ReactMouseEvent } from 'react' +import { IconCloseOutlineRegular } from './icons/index.tsx' +import { HoverCard } from './HoverCard.tsx' +import css from './QuotePreview.module.css' + +function QuoteMarkIcon({ className }: { readonly className?: string | undefined }) { + return ( + + + + + + + ) +} + +/** Optional whole-quote removal control supplied by an editable owner. */ +export interface QuotePreviewRemoval { + /** Localized accessible name for the removal button. */ + readonly label: string + /** Remove the complete owning quote node. */ + readonly onRemove: () => void +} + +/** Presentation inputs for one compact quote excerpt. */ +export interface QuotePreviewProps { + /** Exact selected text rendered inline and exposed in a popover while clipped. */ + readonly text: string + /** Composer-only removal action; omit for inert sent quotes. */ + readonly removal?: QuotePreviewRemoval | undefined +} + +/** + * Render one width-capped quote preview shared by editable drafts and sent messages. + * Clipped excerpts open a content-sized, scrollable popover that fits within the viewport. + * @param props - selected text and optional localized removal action. + * @returns a non-navigating quote preview with a full-text popover only while clipped. + */ +export function QuotePreview({ text, removal }: QuotePreviewProps): JSX.Element { + const sourceRef = useRef(null) + const [truncated, setTruncated] = useState(false) + useLayoutEffect(() => { + const source = sourceRef.current + /* v8 ignore next -- the ref is attached before layout effects run. */ + if (source === null) return + const measure = () => { + const clipped = source.scrollWidth > source.clientWidth + setTruncated(clipped) + } + measure() + if (typeof ResizeObserver === 'undefined') return + const observer = new ResizeObserver(measure) + observer.observe(source) + return () => { observer.disconnect() } + }, [text]) + const remove = (event: ReactMouseEvent): void => { + event.preventDefault() + event.stopPropagation() + removal?.onRemove() + } + const preview = ( + + {removal === undefined + ? ( + + + + ) + : ( + + )} + {text} + + ) + return ( +